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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user