From 2459cd4fd83835467c5fbf7bfea921243e28ec33 Mon Sep 17 00:00:00 2001 From: leetcrypt Date: Wed, 15 Jul 2026 11:29:07 -0700 Subject: [PATCH] rotate: LIVE-REAL proof level + execute-time proof gate (M2) Add ProofLiveReal (highest level, no driver yet) and enforce it at execute time: rotate --execute only runs drivers with proof >= LIVE-VM (MinExecuteProof). MOCK-ONLY/UNPROVEN drivers are skipped before any provider call unless --allow-mock-proven is passed. ExecuteResult gains Skipped/SkipReason; the CLI reports and audit-logs skips separately from failures. Closes the last structural gap where an emulator-only driver could touch a real credential unattended. Covered by execute_gate_test.go. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 9 +- cmd/incredigo/main.go | 34 ++++--- docs/ROADMAP.md | 13 +-- docs/ROTATION-PROOFS.md | 16 ++- internal/rotate/execute.go | 21 +++- internal/rotate/execute_gate_test.go | 145 +++++++++++++++++++++++++++ internal/rotate/proofs.go | 15 +++ 7 files changed, 229 insertions(+), 24 deletions(-) create mode 100644 internal/rotate/execute_gate_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 3844814..a83356c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,9 +48,12 @@ credentials that legitimately require a human. See `docs/ROTATION.md` Part II. `rotate --execute` runs behind `INCREDIGO_ALLOW_EXECUTE=1` **and** the mandatory verified-backup gate (`Snapshot`). Proof status is DATA (`internal/rotate/proofs.go` mirrored in `docs/ROTATION-PROOFS.md`): **8 LIVE-VM** (postgres, mysql, redis, - mongo, wireguard, gitea, appsecret, k8s), **14 MOCK-ONLY**. **No real (host) - credential has been rotated yet** — every real rotation still requires explicit - per-credential authorization (safe-candidate ladder, ROADMAP M2). + mongo, wireguard, gitea, appsecret, k8s), **14 MOCK-ONLY**, plus a `LIVE-REAL` + level no driver carries yet. `--execute` enforces a proof gate: only drivers + with proof ≥ LIVE-VM (`MinExecuteProof`) run; MOCK-ONLY/UNPROVEN are skipped + unless `--allow-mock-proven` is passed. **No real (host) credential has been + rotated yet** — every real rotation still requires explicit per-credential + authorization (safe-candidate ladder, ROADMAP M2). - **Guided manual layer done:** `internal/links` + `internal/worklist` + `internal/tui` → `incredigo worklist` / `incredigo guide` (read-only). - **Phase B scaffolded, not wired:** 12 pwstore adapters, pwgen, staging; diff --git a/cmd/incredigo/main.go b/cmd/incredigo/main.go index ecc8f76..c1b0ce2 100644 --- a/cmd/incredigo/main.go +++ b/cmd/incredigo/main.go @@ -378,7 +378,7 @@ func importCmd() *cobra.Command { // are zero registered drivers, and --execute is refused. func rotateCmd() *cobra.Command { var prefix, backupOut string - var execute, dryRun, blastScan bool + var execute, dryRun, blastScan, allowMock bool var blastRoots []string c := &cobra.Command{ Use: "rotate", @@ -389,7 +389,9 @@ func rotateCmd() *cobra.Command { "full spine (rotate→verify→revoke) using the noop driver, touching no service.\n\n" + "--execute performs REAL, destructive rotation of the gopass entries under --prefix\n" + "(rotate→verify→store→re-read→revoke). It is refused unless INCREDIGO_ALLOW_EXECUTE=1\n" + - "is set and at least one driver is registered. See docs/ROTATION.md §16.", + "is set and at least one driver is registered. Only drivers proven against real\n" + + "target software (proof ≥ LIVE-VM) run; MOCK-ONLY drivers are skipped unless\n" + + "--allow-mock-proven is passed. See docs/ROTATION.md §16.", RunE: func(cmd *cobra.Command, args []string) error { if execute { // Real, destructive rotation. Gated by an explicit env authorization @@ -448,29 +450,36 @@ func rotateCmd() *cobra.Command { if err != nil { return err } - results := rotate.Execute(ctx, gp, ecreds, v) + results := rotate.Execute(ctx, gp, ecreds, v, allowMock) ew := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) fmt.Fprintln(ew, "DRIVER\tPROOF\tENTRY\tROTATED\tVERIFIED\tSTORED\tREVOKED\tERROR") - var rotated, failed int + var rotated, failed, skipped int for _, r := range results { errStr := "" - if r.Err != nil { + outcome := "ok" + switch { + case r.Skipped: + errStr = "SKIPPED: " + r.SkipReason + outcome = errStr + skipped++ + case r.Err != nil: errStr = r.Err.Error() + outcome = errStr failed++ - } else { + default: rotated++ } fmt.Fprintf(ew, "%s\t%s\t%s\t%t\t%t\t%t\t%t\t%s\n", r.Driver, rotate.ProofLevelOf(r.Driver).String(), r.StorePath, r.Rotated, r.Verified, r.Stored, r.Revoked, errStr) - out := "ok" - if r.Err != nil { - out = r.Err.Error() - } log.Write(audit.Entry{Action: "rotate-execute", Source: r.Credential.Source, - Identity: r.StorePath, Location: backupOut, Outcome: out}) + Identity: r.StorePath, Location: backupOut, Outcome: outcome}) } ew.Flush() - fmt.Fprintf(os.Stderr, "\nROTATED %d credential(s), %d failed. Backup: %s\n", rotated, failed, backupOut) + fmt.Fprintf(os.Stderr, "\nROTATED %d credential(s), %d failed, %d skipped (proof gate). Backup: %s\n", rotated, failed, skipped, backupOut) + if skipped > 0 { + fmt.Fprintf(os.Stderr, "%d credential(s) skipped: driver proof below %s. Re-run with --allow-mock-proven to rotate them anyway.\n", + skipped, rotate.MinExecuteProof) + } if failed > 0 { return fmt.Errorf("rotation completed with %d failure(s) — old credential(s) left intact; restore from backup if needed", failed) } @@ -563,6 +572,7 @@ func rotateCmd() *cobra.Command { 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().BoolVar(&dryRun, "dry-run", false, "walk the full rotation spine with the noop driver (touches no service, persists nothing)") + c.Flags().BoolVar(&allowMock, "allow-mock-proven", false, "also execute drivers proven only against a mock/emulator (MOCK-ONLY); default skips them") c.Flags().BoolVar(&blastScan, "blast", false, "map each credential's blast radius — which files consume it (read-only, non-secret markers)") c.Flags().StringArrayVar(&blastRoots, "blast-root", nil, "directories to search for consumers (default: current directory)") c.Flags().StringArrayVar(&flagPaths, "path", nil, "extra file or directory to include in the plan") diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 96cd933..852a4b9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -66,12 +66,13 @@ paths proven at the CLI level. Rule (unchanged): **one credential at a time, explicit authorization per credential, verified backup + restore-drill first, verify-new-before-revoke-old.** -- [ ] Add `ProofLiveReal` to `proofs.go` (+ docs mirror + PROOF column) so - promotions are recorded as data. -- [ ] Policy gate in `execute.go`: `--execute` only runs drivers with proof ≥ - LIVE-VM; MOCK-ONLY requires an explicit `--allow-mock-proven` opt-in flag. - Today nothing distinguishes them at execute time — closing this is the last - structural safety gap. +- [x] Add `ProofLiveReal` to `proofs.go` (+ docs mirror + PROOF column) so + promotions are recorded as data. (Level added, ordered highest; no driver + carries it yet — filled by the ladder below.) +- [x] Policy gate in `execute.go`: `--execute` only runs drivers with proof ≥ + LIVE-VM (`MinExecuteProof`); MOCK-ONLY/UNPROVEN are SKIPPED (Rotate never + called) unless `--allow-mock-proven` is passed. Result rows carry + `Skipped`/`SkipReason`; covered by `execute_gate_test.go`. - [ ] Climb the ladder, promoting proofs as you go (lowest blast radius, fastest recovery first): 1. **appsecret** — local file, trivially reversible. diff --git a/docs/ROTATION-PROOFS.md b/docs/ROTATION-PROOFS.md index 19bc3aa..948fdcd 100644 --- a/docs/ROTATION-PROOFS.md +++ b/docs/ROTATION-PROOFS.md @@ -19,16 +19,28 @@ stops a mock-only driver from ever being mistaken for a live one. ## Levels +Ordered lowest → highest (a higher level is a strictly stronger cutover proof): + | Level | Meaning | |-----------|---------| -| **LIVE-VM** | Cutover proven against the **real target software** running in the sandbox VM (real PostgreSQL / MariaDB / Redis / `wg` / Gitea / local files). | -| **MOCK-ONLY** | Cutover proven only against an **emulator** — our own `httptest` / in-process SSH server, **or a third-party mock** (e.g. `moto` for AWS). **NOT** proven against the real target service. | | **UNPROVEN** | No cutover proof recorded (e.g. the dry-run noop helper). | +| **MOCK-ONLY** | Cutover proven only against an **emulator** — our own `httptest` / in-process SSH server, **or a third-party mock** (e.g. `moto` for AWS). **NOT** proven against the real target service. | +| **LIVE-VM** | Cutover proven against the **real target software** running in the sandbox VM (real PostgreSQL / MariaDB / Redis / `wg` / Gitea / local files). | +| **LIVE-REAL** | Cutover proven against a **real, host-owned credential at the real provider** (not the VM, not an emulator) — recorded only after an authorized per-credential rotation on the safe-candidate ladder (ROADMAP M2). **No driver carries this yet.** | > **Honesty note:** running in the VM does **not** automatically make a driver LIVE-VM. > AWS ran in the VM but only against **moto** (a mock AWS), so it is **MOCK-ONLY**. LIVE-VM > means the *real* target software validated the cutover. +### Execute-time proof gate + +`rotate --execute` runs a driver **only if its proof is ≥ LIVE-VM** (`MinExecuteProof` +in `proofs.go`). MOCK-ONLY and UNPROVEN drivers are **skipped** — their +`Rotate`/`Verify`/`RevokeOld` are never called, so the old secret is left fully intact — +unless the operator passes the explicit **`--allow-mock-proven`** opt-in. This closes the +last structural gap where a driver proven only against an emulator could touch a real +credential unattended. + ## Current status ### LIVE-VM — proven against real target software in the sandbox VM diff --git a/internal/rotate/execute.go b/internal/rotate/execute.go index b62679a..a0ce738 100644 --- a/internal/rotate/execute.go +++ b/internal/rotate/execute.go @@ -19,6 +19,8 @@ type ExecuteResult struct { Verified bool // the new secret authenticated Stored bool // the new secret was written to gopass and re-read OK Revoked bool // RevokeOld was invoked + Skipped bool // gated off by the proof policy — the driver was NOT run + SkipReason string // why it was gated (only when Skipped) Err error // first error for this credential, if any } @@ -35,7 +37,14 @@ type ExecuteResult struct { // The MANDATORY backup gate (Snapshot) MUST already have succeeded — Execute does // not perform it. Each credential's StorePath is taken from c.Location (the exact // gopass path it was sourced from), so the new secret overwrites the same entry. -func Execute(ctx context.Context, gp *sink.Gopass, creds []discover.Credential, v *vault.Vault) []ExecuteResult { +// +// Proof gate (Hard Rule spirit — no unattended real cutover through an unvalidated +// path): a driver runs only if its recorded proof level is ≥ MinExecuteProof +// (LIVE-VM). Drivers proven only against an emulator/mock (MOCK-ONLY) or with no +// proof at all (UNPROVEN) are SKIPPED — their Rotate/Verify/RevokeOld are never +// called — unless the caller passes allowMock, the explicit --allow-mock-proven +// opt-in. Skipped credentials leave the old secret fully intact. +func Execute(ctx context.Context, gp *sink.Gopass, creds []discover.Credential, v *vault.Vault, allowMock bool) []ExecuteResult { drivers := Drivers() var out []ExecuteResult for _, c := range creds { @@ -51,6 +60,16 @@ func Execute(ctx context.Context, gp *sink.Gopass, creds []discover.Credential, } res := ExecuteResult{Credential: c, Driver: d.Name(), StorePath: c.Location} + // Proof gate: refuse to run a driver that has not been proven against real + // target software, unless the operator explicitly opted in. This is checked + // BEFORE any provider call, so a gated driver touches nothing. + if lvl := ProofLevelOf(d.Name()); lvl < MinExecuteProof && !allowMock { + res.Skipped = true + res.SkipReason = fmt.Sprintf("proof %s < %s; pass --allow-mock-proven to override", lvl, MinExecuteProof) + out = append(out, res) + continue + } + newH, err := d.Rotate(ctx, c, v) if err != nil { res.Err = fmt.Errorf("rotate: %w", err) diff --git a/internal/rotate/execute_gate_test.go b/internal/rotate/execute_gate_test.go new file mode 100644 index 0000000..f809f66 --- /dev/null +++ b/internal/rotate/execute_gate_test.go @@ -0,0 +1,145 @@ +package rotate + +import ( + "context" + "errors" + "testing" + + "incredigo/internal/discover" + "incredigo/internal/vault" +) + +// spyGateDriver is a minimal registered driver used to observe whether Execute's +// proof gate actually reached (or skipped) a driver's Rotate. It contacts no +// service: Rotate records that it ran and returns rotateErr, short-circuiting the +// spine before any gopass write, so allowMock=true can be proven without a store. +type spyGateDriver struct { + name string + source string + rotateErr error + rotated bool +} + +func (s *spyGateDriver) Name() string { return s.name } +func (s *spyGateDriver) Detect(c discover.Credential) bool { return c.Source == s.source } +func (s *spyGateDriver) Verify(context.Context, *vault.Handle, *vault.Vault) error { + return nil +} +func (s *spyGateDriver) RevokeOld(context.Context, discover.Credential, *vault.Vault) error { + return nil +} +func (s *spyGateDriver) Rotate(context.Context, discover.Credential, *vault.Vault) (*vault.Handle, error) { + s.rotated = true + return nil, s.rotateErr +} + +// TestExecuteProofGateSkipsMockOnly proves that by default (allowMock=false) a +// driver whose proof is below MinExecuteProof is SKIPPED: its Rotate is never +// called and the credential is reported as skipped, not rotated. +func TestExecuteProofGateSkipsMockOnly(t *testing.T) { + v := vault.New() + defer v.Purge() + + // Unknown name => ProofUnproven (< MinExecuteProof); unique source so no real + // driver claims it. + spy := &spyGateDriver{name: "spy-unproven", source: "faketest-gate-skip"} + Register(spy) + + creds := []discover.Credential{{Source: "faketest-gate-skip", Identity: "x", Location: "imported/x"}} + res := Execute(context.Background(), nil, creds, v, false) + + if len(res) != 1 { + t.Fatalf("got %d results, want 1", len(res)) + } + r := res[0] + if !r.Skipped { + t.Errorf("mock-only driver was not skipped: %+v", r) + } + if r.SkipReason == "" { + t.Error("skipped result has no SkipReason") + } + if r.Rotated || r.Err != nil { + t.Errorf("skipped credential should not be rotated or errored: %+v", r) + } + if spy.rotated { + t.Error("gate leaked: Rotate was called on a gated driver") + } +} + +// TestExecuteProofGateAllowMockOverride proves --allow-mock-proven (allowMock=true) +// lets a below-threshold driver through: Rotate IS called (we make it fail fast so +// no gopass write is needed) and the result is not marked Skipped. +func TestExecuteProofGateAllowMockOverride(t *testing.T) { + v := vault.New() + defer v.Purge() + + spy := &spyGateDriver{name: "spy-unproven-override", source: "faketest-gate-allow", rotateErr: errors.New("boom")} + Register(spy) + + creds := []discover.Credential{{Source: "faketest-gate-allow", Identity: "x", Location: "imported/x"}} + res := Execute(context.Background(), nil, creds, v, true) + + if len(res) != 1 { + t.Fatalf("got %d results, want 1", len(res)) + } + r := res[0] + if r.Skipped { + t.Errorf("allowMock should not skip the driver: %+v", r) + } + if !spy.rotated { + t.Error("allowMock did not let the driver run: Rotate never called") + } + if r.Err == nil { + t.Error("expected the driver's Rotate error to surface") + } +} + +// TestExecuteProofGateLiveVMRuns proves a driver at or above MinExecuteProof runs +// WITHOUT the override. The spy takes the name "postgres" (LIVE-VM in proofs.go) +// but a unique source so only it claims the credential; Rotate fails fast to avoid +// any real service call. +func TestExecuteProofGateLiveVMRuns(t *testing.T) { + if ProofLevelOf("postgres") < MinExecuteProof { + t.Skip("postgres proof level changed; test assumption invalid") + } + v := vault.New() + defer v.Purge() + + spy := &spyGateDriver{name: "postgres", source: "faketest-gate-livevm", rotateErr: errors.New("boom")} + Register(spy) + + creds := []discover.Credential{{Source: "faketest-gate-livevm", Identity: "x", Location: "imported/x"}} + res := Execute(context.Background(), nil, creds, v, false) + + if len(res) != 1 { + t.Fatalf("got %d results, want 1", len(res)) + } + if res[0].Skipped { + t.Errorf("LIVE-VM driver was gated by default: %+v", res[0]) + } + if !spy.rotated { + t.Error("LIVE-VM driver did not run under the default gate") + } +} + +// TestProofLevelOrderAndString locks the level ordering (Unproven < MockOnly < +// LiveVM < LiveReal) and the LIVE-REAL token the CLI/manifest render. +func TestProofLevelOrderAndString(t *testing.T) { + if !(ProofUnproven < ProofMockOnly && ProofMockOnly < ProofLiveVM && ProofLiveVM < ProofLiveReal) { + t.Fatal("proof levels are not ordered lowest→highest") + } + if MinExecuteProof != ProofLiveVM { + t.Errorf("MinExecuteProof = %v, want ProofLiveVM", MinExecuteProof) + } + want := map[ProofLevel]string{ + ProofUnproven: "UNPROVEN", + ProofMockOnly: "MOCK-ONLY", + ProofLiveVM: "LIVE-VM", + ProofLiveReal: "LIVE-REAL", + } + for lvl, s := range want { + if got := lvl.String(); got != s { + t.Errorf("%d.String() = %q, want %q", lvl, got, s) + } + } +} diff --git a/internal/rotate/proofs.go b/internal/rotate/proofs.go index d5f908e..74b9576 100644 --- a/internal/rotate/proofs.go +++ b/internal/rotate/proofs.go @@ -16,6 +16,8 @@ package rotate // ProofLevel records how a driver's real-cutover behaviour has been validated. type ProofLevel int +// Ordered lowest→highest: a higher level is a strictly stronger cutover proof. +// The execute-time gate (see execute.go) compares against ProofLiveVM. const ( // ProofUnproven: no cutover proof recorded (e.g. the dry-run noop helper). ProofUnproven ProofLevel = iota @@ -26,11 +28,24 @@ const ( // ProofLiveVM: cutover proven against the REAL target software running in the // sandbox VM (real PostgreSQL/MariaDB/Redis/wireguard-tools/Gitea/local files). ProofLiveVM + // ProofLiveReal: cutover proven against a REAL, host-owned credential at the + // real provider (not the VM, not an emulator) — the highest level, recorded + // only after an authorized per-credential rotation on the safe-candidate ladder + // (ROADMAP M2). No driver carries this yet; promotions land here as data. + ProofLiveReal ) +// MinExecuteProof is the minimum proof level a driver must carry to run under +// `rotate --execute` WITHOUT the explicit --allow-mock-proven opt-in. Anything +// below this (MOCK-ONLY, UNPROVEN) is gated off by default so a driver never +// proven against real target software cannot touch a real credential unattended. +const MinExecuteProof = ProofLiveVM + // String renders the level as the token shown in the CLI and the manifest. func (p ProofLevel) String() string { switch p { + case ProofLiveReal: + return "LIVE-REAL" case ProofLiveVM: return "LIVE-VM" case ProofMockOnly: