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,102 @@
|
||||
package rotate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"incredigo/internal/discover"
|
||||
"incredigo/internal/vault"
|
||||
)
|
||||
|
||||
// writeFakeRedisCli writes a stub `redis-cli` that emulates AUTH against a state
|
||||
// file holding the instance's CURRENT requirepass. CONFIG SET requirepass updates
|
||||
// the state (only if REDISCLI_AUTH matched the current password); PING succeeds only
|
||||
// when REDISCLI_AUTH matches. This lets a test prove a real cutover: after Rotate
|
||||
// the OLD password must stop authenticating. It models redis-cli's behaviour of
|
||||
// printing "(error) ..." replies on stdout while still exiting 0.
|
||||
func writeFakeRedisCli(t *testing.T, statePath string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "redis-cli")
|
||||
script := `#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
cmd="$(cat)"
|
||||
cur="$(cat "` + statePath + `" 2>/dev/null || true)"
|
||||
# If a password is set on the instance, an authless or wrong-password client fails.
|
||||
if [ -n "$cur" ] && [ "${REDISCLI_AUTH:-}" != "$cur" ]; then
|
||||
echo "(error) WRONGPASS invalid username-password pair"; exit 0
|
||||
fi
|
||||
case "$cmd" in
|
||||
"CONFIG SET requirepass "*)
|
||||
np="${cmd#CONFIG SET requirepass }"
|
||||
printf '%s' "$np" > "` + statePath + `"
|
||||
echo "OK"; exit 0 ;;
|
||||
"PING"*)
|
||||
echo "PONG"; exit 0 ;;
|
||||
*) echo "(error) ERR unknown command"; exit 0 ;;
|
||||
esac
|
||||
`
|
||||
if err := os.WriteFile(p, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestRedisDetect(t *testing.T) {
|
||||
r := &Redis{}
|
||||
if !r.Detect(discover.Credential{Source: "redis"}) {
|
||||
t.Error("should detect Source=redis")
|
||||
}
|
||||
if r.Detect(discover.Credential{Source: "mysql"}) {
|
||||
t.Error("should not detect Source=mysql")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisRotateRealCutover(t *testing.T) {
|
||||
state := filepath.Join(t.TempDir(), "pw")
|
||||
if err := os.WriteFile(state, []byte("oldpw"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("REDISCLI_AUTH", "") // ensure the driver, not the env, supplies it
|
||||
r := &Redis{Bin: writeFakeRedisCli(t, state)}
|
||||
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
oldURL := "redis://:oldpw@127.0.0.1:6379"
|
||||
cred := discover.Credential{Source: "redis", Identity: "redis@127.0.0.1", Secret: v.Store([]byte(oldURL))}
|
||||
ctx := context.Background()
|
||||
|
||||
newH, err := r.Rotate(ctx, cred, v)
|
||||
if err != nil {
|
||||
t.Fatalf("Rotate: %v", err)
|
||||
}
|
||||
if cur, _ := os.ReadFile(state); string(cur) == "oldpw" {
|
||||
t.Fatal("password not changed by CONFIG SET requirepass")
|
||||
}
|
||||
|
||||
if err := r.Verify(ctx, newH, v); err != nil {
|
||||
t.Errorf("Verify(new) should pass: %v", err)
|
||||
}
|
||||
if err := r.Verify(ctx, cred.Secret, v); err == nil {
|
||||
t.Error("Verify(old) must fail after rotation — old password should be revoked")
|
||||
} else if strings.Contains(err.Error(), "oldpw") {
|
||||
t.Error("error leaked the old password")
|
||||
}
|
||||
|
||||
if err := r.RevokeOld(ctx, cred, v); err != nil {
|
||||
t.Errorf("RevokeOld (no-op) returned: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedisRejectsNonRedisURL(t *testing.T) {
|
||||
r := &Redis{Bin: "/bin/false"}
|
||||
v := vault.New()
|
||||
defer v.Purge()
|
||||
cred := discover.Credential{Source: "redis", Secret: v.Store([]byte("postgres://u:p@h/db"))}
|
||||
if _, err := r.Rotate(context.Background(), cred, v); err == nil {
|
||||
t.Error("Rotate should reject a non-redis scheme")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user