rotate: nine rotation drivers + verify-before-revoke execute spine
Implements the Rotator interface across all four rotation patterns, each a self-contained one-file driver self-registering via init(): in-place DB password postgres, mysql (3-stmt unprivileged fallback), redis local keypair + propagate sshkey (ed25519), wireguard (clamped curve25519) provider-API token gitea PAT, mullvad device key cloud-key self-identifying aws IAM access key (hand-rolled SigV4, no SDK dep) Spine (execute.go) enforces Hard Rule #2: backup -> rotate -> verify(new) -> store -> re-read+verify -> revoke-old; dryrun.go keeps --execute gated. Each driver ships a table-driven test proving real cutover (old secret stops authenticating) and asserting no secret substring leaks to argv/Meta/errors. Promotes golang.org/x/crypto to a direct dependency. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
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
|
||||
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.
|
||||
func Execute(ctx context.Context, gp *sink.Gopass, creds []discover.Credential, v *vault.Vault) []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}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user