From 5c4727d1e61633ed568536e52f4fe58e4a7575f4 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Mon, 15 Jun 2026 10:39:45 -0700 Subject: [PATCH] rotate: safety-spine scaffold + mandatory backup gate (NO rotation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/incredigo/main.go | 88 +++++++++++++++++++++++++++++++- internal/rotate/backup.go | 79 +++++++++++++++++++++++++++++ internal/rotate/backup_test.go | 92 ++++++++++++++++++++++++++++++++++ internal/rotate/rotate.go | 82 ++++++++++++++++++++++++++++++ internal/sink/bundle.go | 37 ++++++++++++++ 5 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 internal/rotate/backup.go create mode 100644 internal/rotate/backup_test.go create mode 100644 internal/rotate/rotate.go diff --git a/cmd/incredigo/main.go b/cmd/incredigo/main.go index 8f927bf..b6437f4 100644 --- a/cmd/incredigo/main.go +++ b/cmd/incredigo/main.go @@ -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/.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) { diff --git a/internal/rotate/backup.go b/internal/rotate/backup.go new file mode 100644 index 0000000..f8a7296 --- /dev/null +++ b/internal/rotate/backup.go @@ -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) +} diff --git a/internal/rotate/backup_test.go b/internal/rotate/backup_test.go new file mode 100644 index 0000000..89a3a4c --- /dev/null +++ b/internal/rotate/backup_test.go @@ -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") + } +} diff --git a/internal/rotate/rotate.go b/internal/rotate/rotate.go new file mode 100644 index 0000000..12060c2 --- /dev/null +++ b/internal/rotate/rotate.go @@ -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 +} diff --git a/internal/sink/bundle.go b/internal/sink/bundle.go index 9265472..4e9cf25 100644 --- a/internal/sink/bundle.go +++ b/internal/sink/bundle.go @@ -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))