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:
+87
-1
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user