59af53dcc0
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>
118 lines
3.3 KiB
Go
118 lines
3.3 KiB
Go
package rotate
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/awnumar/memguard"
|
|
|
|
"incredigo/internal/discover"
|
|
"incredigo/internal/sink"
|
|
"incredigo/internal/vault"
|
|
)
|
|
|
|
// TestNoopFullFlow walks the entire safety spine end-to-end with the dry-run
|
|
// driver: mandatory backup → Detect → Rotate(mint new) → Verify → store →
|
|
// RevokeOld. It asserts the spine's invariants without touching any service:
|
|
// the old secret is never disturbed, a distinct new secret is minted, and
|
|
// revoke runs only after verify succeeds.
|
|
func TestNoopFullFlow(t *testing.T) {
|
|
v := vault.New()
|
|
defer v.Purge()
|
|
|
|
old := []byte("AKIA-OLD-SECRET-1")
|
|
oldHandle := v.Store(append([]byte(nil), old...))
|
|
creds := []discover.Credential{
|
|
{Source: "aws", Kind: discover.KindAWSKey, Identity: "default / AKIA…REDACTED", Secret: oldHandle},
|
|
{Source: "env", Kind: discover.KindToken, Identity: "API_TOKEN"},
|
|
}
|
|
|
|
// 1. MANDATORY backup gate must pass before anything rotates.
|
|
gp := fakeGopass(t)
|
|
pass := memguard.NewBufferFromBytes([]byte("flow-pass"))
|
|
defer pass.Destroy()
|
|
out := filepath.Join(t.TempDir(), "flow.age")
|
|
if _, err := Snapshot(context.Background(), gp, "imported/", &sink.Age{}, pass, out); err != nil {
|
|
t.Fatalf("backup gate must succeed before rotation: %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
d := &NoopRotator{}
|
|
|
|
for _, c := range creds {
|
|
if !d.Detect(c) {
|
|
t.Fatalf("noop must Detect %s", c.Identity)
|
|
}
|
|
|
|
// 2. Rotate mints a NEW secret and must not revoke anything yet.
|
|
revokedBefore := len(d.Revoked())
|
|
newHandle, err := d.Rotate(ctx, c, v)
|
|
if err != nil {
|
|
t.Fatalf("Rotate %s: %v", c.Identity, err)
|
|
}
|
|
if len(d.Revoked()) != revokedBefore {
|
|
t.Fatalf("Rotate must not revoke; revoked=%v", d.Revoked())
|
|
}
|
|
|
|
// New secret is real and distinct from the old value.
|
|
nb, err := v.Open(newHandle)
|
|
if err != nil {
|
|
t.Fatalf("open new secret: %v", err)
|
|
}
|
|
if nb.Size() == 0 {
|
|
t.Fatalf("new secret for %s is empty", c.Identity)
|
|
}
|
|
if bytes.Equal(nb.Bytes(), old) {
|
|
t.Fatalf("new secret equals old for %s — rotation minted nothing", c.Identity)
|
|
}
|
|
|
|
// 3. Verify before revoke.
|
|
if err := d.Verify(ctx, newHandle, v); err != nil {
|
|
t.Fatalf("Verify %s: %v", c.Identity, err)
|
|
}
|
|
|
|
// 4. Only now retire the old credential.
|
|
if err := d.RevokeOld(ctx, c, v); err != nil {
|
|
t.Fatalf("RevokeOld %s: %v", c.Identity, err)
|
|
}
|
|
}
|
|
|
|
// The old secret must remain readable — the noop never touches a real value.
|
|
ob, err := v.Open(oldHandle)
|
|
if err != nil {
|
|
t.Fatalf("old secret was disturbed: %v", err)
|
|
}
|
|
if !bytes.Equal(ob.Bytes(), old) {
|
|
t.Fatal("old secret value changed — dry run must leave originals intact")
|
|
}
|
|
|
|
if got, want := d.Revoked(), []string{"default / AKIA…REDACTED", "API_TOKEN"}; !equal(got, want) {
|
|
t.Fatalf("revoked = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
// TestNoopNotRegistered guards the hard rule: the dry-run driver must not leak
|
|
// into the global registry, so the production `rotate` command still sees zero
|
|
// drivers and PlanAll reports "(none)".
|
|
func TestNoopNotRegistered(t *testing.T) {
|
|
for _, d := range Drivers() {
|
|
if d.Name() == "noop" {
|
|
t.Fatal("NoopRotator must NOT auto-register — production rotate must report zero drivers")
|
|
}
|
|
}
|
|
}
|
|
|
|
func equal(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|