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) {