package rotate import ( "context" "fmt" "incredigo/internal/discover" "incredigo/internal/sink" "incredigo/internal/vault" ) // ExecuteResult records, without any secret, what the REAL rotation spine did for // one credential. type ExecuteResult struct { Credential discover.Credential Driver string StorePath string // gopass entry the new secret was written to Rotated bool // a new secret was minted at the provider 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 } // Execute performs the REAL, destructive rotation spine for every credential a // registered driver claims, enforcing Hard Rule #2 (verify-new-before-revoke-old): // // Rotate → Verify(new) → store new in gopass → re-read + Verify(stored) → RevokeOld // // The old credential is revoked ONLY after the new secret authenticates, is // persisted, and is read back and re-verified. Any failure short-circuits before // RevokeOld, leaving the old credential intact (the §3 sealed backup is the // recovery path on top of that). // // 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. // // 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 { var d Rotator for _, cand := range drivers { if cand.Detect(c) { d = cand break } } if d == nil { continue // no driver claims it — handled by the manual worklist, not here } 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) out = append(out, res) continue } res.Rotated = true // Verify the new secret authenticates BEFORE it is stored or the old revoked. if err := d.Verify(ctx, newH, v); err != nil { res.Err = fmt.Errorf("verify(new): %w", err) v.Forget(newH) out = append(out, res) continue } res.Verified = true // Persist the new secret at the SAME gopass path (overwrite). if err := gp.InsertAt(ctx, v, res.StorePath, newH, true); err != nil { res.Err = fmt.Errorf("store: %w", err) v.Forget(newH) out = append(out, res) continue } v.Forget(newH) // Re-read what was stored and prove it still authenticates. This closes the // gap between "Verify passed on the in-vault value" and "the bytes gopass // now holds are the working secret" — only then is revoke safe. rh, err := gp.Show(ctx, v, res.StorePath) if err != nil { res.Err = fmt.Errorf("re-read: %w", err) out = append(out, res) continue } if err := d.Verify(ctx, rh, v); err != nil { res.Err = fmt.Errorf("verify(stored): %w", err) v.Forget(rh) out = append(out, res) continue } v.Forget(rh) res.Stored = true // Only now retire the old credential. if err := d.RevokeOld(ctx, c, v); err != nil { res.Err = fmt.Errorf("revoke-old: %w", err) out = append(out, res) continue } res.Revoked = true out = append(out, res) } return out }