rotate: safety-spine scaffold + mandatory backup gate (NO rotation)

Design-phase foundation for v2 rotation. Changes NO credential at any service.

- internal/rotate: Rotator interface + registry + PlanAll (zero drivers
  registered, so every credential plans as "(none)")
- internal/rotate.Snapshot: the MANDATORY backup gate — seals the gopass
  prefix into an authenticated bundle, re-opens it, and confirms the entry
  count matches before returning; any failure blocks rotation
- internal/sink.CountBundleRecords: read-only bundle completeness check
- cmd: `incredigo rotate` runs the backup gate + prints the plan; it is
  dry-run only and refuses --execute (design phase)
- tests: backup gate happy path, no-overwrite, tamper detection; race-clean

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-15 10:39:45 -07:00
parent a7bcbed8ce
commit 5c4727d1e6
5 changed files with 377 additions and 1 deletions
+87 -1
View File
@@ -10,6 +10,7 @@ import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"text/tabwriter"
"time"
@@ -20,6 +21,7 @@ import (
"incredigo/internal/audit"
"incredigo/internal/discover"
"incredigo/internal/policy"
"incredigo/internal/rotate"
"incredigo/internal/sink"
"incredigo/internal/vault"
)
@@ -49,7 +51,7 @@ func main() {
root.PersistentFlags().StringVar(&flagConfig, "config", "", "policy.yaml path")
root.PersistentFlags().StringVar(&flagSealer, "sealer", "age", "backup sealer: age (default, authenticated), hmac (authenticated, openssl-only), or openssl (unauthenticated)")
root.AddCommand(scanCmd(), migrateCmd(), statusCmd(), exportCmd(), importCmd())
root.AddCommand(scanCmd(), migrateCmd(), statusCmd(), exportCmd(), importCmd(), rotateCmd())
if err := root.Execute(); err != nil {
fmt.Fprintln(os.Stderr, "incredigo:", err)
@@ -323,6 +325,90 @@ func importCmd() *cobra.Command {
return c
}
// rotateCmd is the rotation entry point. ROTATION IS NOT IMPLEMENTED (design
// phase, see docs/ROTATION.md). The command runs the mandatory backup gate and
// prints the rotation plan, but cannot and does not change any credential: there
// are zero registered drivers, and --execute is refused.
func rotateCmd() *cobra.Command {
var prefix, backupOut string
var execute bool
c := &cobra.Command{
Use: "rotate",
Short: "Plan rotation + take a verified backup (DESIGN PHASE — rotates nothing)",
Long: "Rotation is not implemented yet (see docs/ROTATION.md). This command runs the\n" +
"MANDATORY backup gate (sealed, verified snapshot of the gopass prefix) and prints\n" +
"the rotation plan. It never changes a credential.",
RunE: func(cmd *cobra.Command, args []string) error {
if execute {
return fmt.Errorf("rotate --execute refused: no rotation drivers are implemented (design phase) — see docs/ROTATION.md")
}
applyPaths()
gp := &sink.Gopass{Prefix: prefix}
if !gp.Available() {
return fmt.Errorf("gopass not found on PATH")
}
sealer, err := sealerFor(flagSealer)
if err != nil {
return err
}
if backupOut == "" {
dir := filepath.Join(os.Getenv("HOME"), ".incredigo", "rotation-backups")
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
backupOut = filepath.Join(dir, time.Now().UTC().Format("2006-01-02T15-04-05Z")+".age")
}
return withVault(func(ctx context.Context, v *vault.Vault) error {
h, err := readPassphrase(v, true)
if err != nil {
return err
}
pass, err := v.Open(h)
if err != nil {
return err
}
// MANDATORY backup gate — rotation may never run without this.
n, err := rotate.Snapshot(ctx, gp, prefix, sealer, pass, backupOut)
if err != nil {
return fmt.Errorf("backup gate failed — rotation would be blocked: %w", err)
}
fmt.Fprintf(os.Stderr, "✓ backup gate: %d entr(ies) sealed + verified -> %s\n\n", n, backupOut)
// Non-destructive plan over discoverable credentials.
creds, err := discover.ScanAll(ctx, v, flagSources...)
if err != nil {
return err
}
w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
fmt.Fprintln(w, "DRIVER\tSOURCE\tIDENTITY")
for _, p := range rotate.PlanAll(creds) {
drv := p.Driver
if drv == "" {
drv = "(none)"
}
fmt.Fprintf(w, "%s\t%s\t%s\n", drv, p.Credential.Source, p.Credential.Identity)
}
w.Flush()
log, _ := audit.Open(flagAuditLog, time.Now)
defer log.Close()
log.Write(audit.Entry{Action: "rotate-plan", Outcome: "ok", Location: backupOut})
fmt.Fprintf(os.Stderr, "\nDESIGN PHASE: %d rotation driver(s) registered — nothing rotated. See docs/ROTATION.md\n",
len(rotate.Drivers()))
return nil
})
},
}
c.Flags().StringVar(&prefix, "prefix", "imported/", "gopass prefix to back up and plan")
c.Flags().StringVar(&backupOut, "backup-out", "", "backup bundle path (default ~/.incredigo/rotation-backups/<ts>.age)")
c.Flags().BoolVar(&execute, "execute", false, "(reserved) perform rotation — refused in design phase")
c.Flags().StringArrayVar(&flagPaths, "path", nil, "extra file or directory to include in the plan")
c.Flags().StringSliceVar(&flagSources, "source", nil, "limit discovery to these scanners")
return c
}
// sealerFor selects the backup sealer. age is the authenticated default; openssl
// is an unauthenticated fallback (see internal/sink/openssl.go).
func sealerFor(name string) (sink.Sealer, error) {
+79
View File
@@ -0,0 +1,79 @@
package rotate
import (
"context"
"fmt"
"io"
"os"
"github.com/awnumar/memguard"
"incredigo/internal/sink"
)
// Snapshot is the MANDATORY backup gate (docs/ROTATION.md §3). It MUST succeed
// before any credential is rotated.
//
// It exports the gopass entries under prefix into a sealed, authenticated bundle
// at outPath (refusing to overwrite), then re-opens and walks the bundle to
// confirm it decrypts/authenticates and contains exactly the number of entries
// that were sealed. It returns the verified entry count. Any error — failure to
// seal, to re-open/authenticate, or an entry-count mismatch — means the caller
// MUST NOT proceed with rotation.
func Snapshot(ctx context.Context, gp *sink.Gopass, prefix string, sealer sink.Sealer, pass *memguard.LockedBuffer, outPath string) (int, error) {
f, err := os.OpenFile(outPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return 0, fmt.Errorf("backup: create %s (use a fresh path): %w", outPath, err)
}
// export (framed plaintext) -> Sealer -> file
type exp struct {
n int
err error
}
ch := make(chan exp, 1)
pr, pw := io.Pipe()
go func() {
n, e := gp.ExportTo(ctx, pw, prefix)
pw.CloseWithError(e)
ch <- exp{n, e}
}()
sealErr := sealer.Seal(ctx, pr, f, pass)
closeErr := f.Close()
res := <-ch // synchronizes the exported count
if res.err != nil {
os.Remove(outPath)
return 0, fmt.Errorf("backup: export: %w", res.err)
}
if sealErr != nil {
os.Remove(outPath)
return 0, fmt.Errorf("backup: seal: %w", sealErr)
}
if closeErr != nil {
return 0, closeErr
}
verified, err := verifyBundle(ctx, sealer, pass, outPath)
if err != nil {
return 0, fmt.Errorf("backup: VERIFY FAILED for %s — do NOT rotate: %w", outPath, err)
}
if verified != res.n {
return 0, fmt.Errorf("backup: integrity mismatch — sealed %d entries but verified %d; do NOT rotate", res.n, verified)
}
return verified, nil
}
// verifyBundle re-opens a sealed bundle, authenticates it via the Sealer, and
// counts its records — without writing anything anywhere.
func verifyBundle(ctx context.Context, sealer sink.Sealer, pass *memguard.LockedBuffer, path string) (int, error) {
f, err := os.Open(path)
if err != nil {
return 0, err
}
defer f.Close()
pr, pw := io.Pipe()
go func() { pw.CloseWithError(sealer.Open(ctx, f, pw, pass)) }()
return sink.CountBundleRecords(pr)
}
+92
View File
@@ -0,0 +1,92 @@
package rotate
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/awnumar/memguard"
"incredigo/internal/sink"
)
func fakeGopass(t *testing.T) *sink.Gopass {
t.Helper()
bin := filepath.Join(t.TempDir(), "gopass")
script := `#!/usr/bin/env bash
store="$FAKE_GOPASS_STORE"
case "$1" in
ls) (cd "$store" 2>/dev/null && find . -type f | sed 's#^\./##' | sort) ;;
show) cat "$store/$3" ;;
insert) shift; path=""; for a in "$@"; do case "$a" in --multiline=false|-f) ;; *) path="$a";; esac; done
mkdir -p "$store/$(dirname "$path")"; cat > "$store/$path" ;;
esac
`
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
store := t.TempDir()
t.Setenv("FAKE_GOPASS_STORE", store)
for p, s := range map[string]string{
"imported/aws/default": "AKIA-secret-1",
"imported/env/api": "tok-2-末",
"other/skip": "not-backed-up",
} {
full := filepath.Join(store, p)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(s), 0o600); err != nil {
t.Fatal(err)
}
}
return &sink.Gopass{Bin: bin}
}
func TestSnapshotBackupGate(t *testing.T) {
gp := fakeGopass(t)
pass := memguard.NewBufferFromBytes([]byte("backup-pass"))
defer pass.Destroy()
out := filepath.Join(t.TempDir(), "backup.age")
n, err := Snapshot(context.Background(), gp, "imported/", &sink.Age{}, pass, out)
if err != nil {
t.Fatal(err)
}
if n != 2 {
t.Fatalf("backed up %d entries, want 2 (other/ excluded by prefix)", n)
}
fi, err := os.Stat(out)
if err != nil || fi.Size() == 0 {
t.Fatalf("backup bundle missing or empty: %v", err)
}
// The gate must refuse to overwrite an existing backup.
if _, err := Snapshot(context.Background(), gp, "imported/", &sink.Age{}, pass, out); err == nil {
t.Error("Snapshot should refuse to overwrite an existing backup path")
}
}
func TestVerifyBundleDetectsTamper(t *testing.T) {
gp := fakeGopass(t)
pass := memguard.NewBufferFromBytes([]byte("pw"))
defer pass.Destroy()
out := filepath.Join(t.TempDir(), "b.age")
if _, err := Snapshot(context.Background(), gp, "imported/", &sink.Age{}, pass, out); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(out)
if err != nil {
t.Fatal(err)
}
b[len(b)/2] ^= 0x01 // flip a byte
tampered := filepath.Join(t.TempDir(), "t.age")
if err := os.WriteFile(tampered, b, 0o600); err != nil {
t.Fatal(err)
}
if _, err := verifyBundle(context.Background(), &sink.Age{}, pass, tampered); err == nil {
t.Error("verifyBundle must fail on a tampered bundle")
}
}
+82
View File
@@ -0,0 +1,82 @@
// Package rotate is the credential-rotation scaffold.
//
// IMPORTANT — DESIGN PHASE: no Rotator that actually changes a credential at a
// service is implemented. The interface, registry, planning, and the mandatory
// backup gate (see backup.go and docs/ROTATION.md) exist so rotation can be built
// safely later. Today there are zero registered drivers, so PlanAll reports
// "(none)" for every credential and nothing can be rotated.
package rotate
import (
"context"
"sort"
"sync"
"incredigo/internal/discover"
"incredigo/internal/vault"
)
// Rotator changes one class of credential at its issuing authority.
//
// Contract (docs/ROTATION.md §5): Rotate mints and returns a NEW secret but MUST
// NOT revoke the old one. RevokeOld runs only after the new value has been
// verified against the service AND stored in gopass. A backup (see Snapshot) must
// already exist before Rotate is ever called.
type Rotator interface {
Name() string
// Detect reports whether this driver handles the given credential.
Detect(c discover.Credential) bool
// Rotate mints a new secret at the provider and returns a vault handle to it.
// It must not revoke or invalidate the old credential.
Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error)
// Verify proves the newly minted secret actually authenticates.
Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error
// RevokeOld retires the previous credential — only after verify + store.
RevokeOld(ctx context.Context, c discover.Credential) error
}
var (
regMu sync.Mutex
registry []Rotator
)
// Register adds a rotation driver. (No real driver registers yet.)
func Register(r Rotator) {
regMu.Lock()
defer regMu.Unlock()
registry = append(registry, r)
}
// Drivers returns the registered rotators, sorted by name.
func Drivers() []Rotator {
regMu.Lock()
defer regMu.Unlock()
out := append([]Rotator(nil), registry...)
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
return out
}
// Plan is the non-destructive decision for one credential: which driver would
// handle it, or none.
type Plan struct {
Credential discover.Credential
Driver string // "" when no registered driver claims the credential
}
// PlanAll classifies credentials against the registered drivers WITHOUT changing
// anything. With no drivers registered (the current state) every Plan.Driver is "".
func PlanAll(creds []discover.Credential) []Plan {
drivers := Drivers()
plans := make([]Plan, 0, len(creds))
for _, c := range creds {
p := Plan{Credential: c}
for _, d := range drivers {
if d.Detect(c) {
p.Driver = d.Name()
break
}
}
plans = append(plans, p)
}
return plans
}
+37
View File
@@ -180,6 +180,43 @@ func copySecret(dst io.Writer, r io.Reader) error {
}
}
// CountBundleRecords walks a decrypted bundle stream and returns how many records
// (entries) it contains, writing nothing. It is the completeness half of backup
// verification: a bundle that the Sealer can Open is authentic; counting its
// records confirms every expected entry is present. Secret bytes are read only to
// skip past them (discarded).
func CountBundleRecords(r io.Reader) (int, error) {
var n int
for {
var pathLen uint16
if err := binary.Read(r, binary.BigEndian, &pathLen); err != nil {
if err == io.EOF {
return n, nil
}
return n, err
}
if pathLen == 0 {
return n, nil // end-of-stream sentinel
}
if _, err := io.CopyN(io.Discard, r, int64(pathLen)); err != nil {
return n, err
}
for { // skip the secret's chunks up to the zero-length marker
var chunkLen uint32
if err := binary.Read(r, binary.BigEndian, &chunkLen); err != nil {
return n, err
}
if chunkLen == 0 {
break
}
if _, err := io.CopyN(io.Discard, r, int64(chunkLen)); err != nil {
return n, err
}
}
n++
}
}
func writePath(w io.Writer, path string) error {
if len(path) > 0xffff {
return fmt.Errorf("gopass path too long: %d bytes", len(path))