Files
incredigo/internal/rotate/aws_test.go
T
leetcrypt 59af53dcc0 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>
2026-06-18 14:48:24 -07:00

223 lines
7.0 KiB
Go

package rotate
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"incredigo/internal/discover"
"incredigo/internal/vault"
)
// awsEmu is a minimal IAM/STS Query-API emulator that ENFORCES access-key validity:
// every request must be SigV4-signed by a key that currently EXISTS (the emulator
// reads the AccessKeyId from the Authorization Credential= field), and
// DeleteAccessKey makes a key stop working. That lets the test prove a real cutover
// — old key rejected (403) after revoke, new key alive — which moto's default mode
// does not enforce. It does not verify the signature math (that is proven by the
// live moto POC); it proves the DRIVER'S create→verify→revoke ordering and that the
// old key is precisely targeted.
type awsEmu struct {
mu sync.Mutex
valid map[string]string // AccessKeyId -> SecretAccessKey (existence == validity)
user string
srv *httptest.Server
}
func newAWSEmu(t *testing.T, user, seedID, seedSecret string) *awsEmu {
e := &awsEmu{
valid: map[string]string{seedID: seedSecret},
user: user,
}
e.srv = httptest.NewServer(http.HandlerFunc(e.handle))
t.Cleanup(e.srv.Close)
return e
}
func (e *awsEmu) isValid(id string) bool {
e.mu.Lock()
defer e.mu.Unlock()
_, ok := e.valid[id]
return ok
}
// credKeyID pulls the AccessKeyId out of "Authorization: AWS4-HMAC-SHA256
// Credential=<keyID>/<date>/<region>/<service>/aws4_request, ...".
func credKeyID(r *http.Request) string {
h := r.Header.Get("Authorization")
i := strings.Index(h, "Credential=")
if i < 0 {
return ""
}
rest := h[i+len("Credential="):]
if j := strings.IndexByte(rest, '/'); j >= 0 {
return rest[:j]
}
return ""
}
func (e *awsEmu) handle(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
action := r.Form.Get("Action")
// Auth gate: the signing key must currently exist. This is what enforces the
// cutover — a deleted key fails here.
if !e.isValid(credKeyID(r)) {
w.Header().Set("Content-Type", "text/xml")
w.WriteHeader(http.StatusForbidden)
io.WriteString(w, `<ErrorResponse><Error><Code>InvalidClientTokenId</Code><Message>key revoked</Message></Error></ErrorResponse>`)
return
}
w.Header().Set("Content-Type", "text/xml")
switch action {
case "CreateAccessKey":
raw := make([]byte, 16)
rand.Read(raw)
newID := "AKIA" + strings.ToUpper(hex.EncodeToString(raw[:8]))
raw2 := make([]byte, 30)
rand.Read(raw2)
newSecret := hex.EncodeToString(raw2)
e.mu.Lock()
e.valid[newID] = newSecret
e.mu.Unlock()
fmt.Fprintf(w, `<CreateAccessKeyResponse><CreateAccessKeyResult><AccessKey>`+
`<UserName>%s</UserName><AccessKeyId>%s</AccessKeyId><Status>Active</Status>`+
`<SecretAccessKey>%s</SecretAccessKey></AccessKey></CreateAccessKeyResult>`+
`</CreateAccessKeyResponse>`, e.user, newID, newSecret)
case "DeleteAccessKey":
id := r.Form.Get("AccessKeyId")
e.mu.Lock()
delete(e.valid, id)
e.mu.Unlock()
io.WriteString(w, `<DeleteAccessKeyResponse><ResponseMetadata><RequestId>1</RequestId></ResponseMetadata></DeleteAccessKeyResponse>`)
case "GetCallerIdentity":
fmt.Fprintf(w, `<GetCallerIdentityResponse><GetCallerIdentityResult>`+
`<Arn>arn:aws:iam::123456789012:user/%s</Arn><UserId>AIDAEMULATED</UserId>`+
`<Account>123456789012</Account></GetCallerIdentityResult></GetCallerIdentityResponse>`, e.user)
default:
w.WriteHeader(http.StatusBadRequest)
io.WriteString(w, `<ErrorResponse><Error><Code>InvalidAction</Code></Error></ErrorResponse>`)
}
}
func TestAWSRotateRealCutover(t *testing.T) {
v := vault.New()
defer v.Purge()
ctx := context.Background()
const user, oldID, oldSecret = "alice", "AKIAOLDKEYEXAMPLE000", "oldSecretAccessKey0123456789abcdef"
emu := newAWSEmu(t, user, oldID, oldSecret)
oldBlob := awsSecret{keyID: oldID, secret: oldSecret, region: "us-east-1", endpoint: emu.srv.URL}.build()
cred := discover.Credential{
Source: "aws",
Kind: discover.KindAWSKey,
Identity: user + " / AKIA***000",
Location: "imported/aws/default",
Secret: v.Store([]byte(oldBlob)),
}
a := &AWS{}
if !emu.isValid(oldID) {
t.Fatal("seed key should be valid at start")
}
// 1. Rotate — create a new key (old still valid).
newH, err := a.Rotate(ctx, cred, v)
if err != nil {
t.Fatalf("Rotate: %v", err)
}
ns, err := parseAWSSecret(v, newH)
if err != nil {
t.Fatalf("parse new secret: %v", err)
}
if ns.keyID == oldID || ns.secret == oldSecret {
t.Fatal("new key equals old — rotation minted nothing")
}
if !emu.isValid(oldID) {
t.Fatal("old key must remain valid before revoke")
}
if !emu.isValid(ns.keyID) {
t.Fatal("new key must be valid after create")
}
// 2. Verify(new) via the driver (STS GetCallerIdentity with the new key).
if err := a.Verify(ctx, newH, v); err != nil {
t.Fatalf("Verify(new): %v", err)
}
// 3. RevokeOld — DeleteAccessKey targeting the OLD AccessKeyId.
if err := a.RevokeOld(ctx, cred, v); err != nil {
t.Fatalf("RevokeOld: %v", err)
}
// Cutover assertions: old dead, new alive.
if emu.isValid(oldID) {
t.Fatal("old key must be invalid after revoke")
}
if err := a.Verify(ctx, cred.Secret, v); err == nil {
t.Fatal("old key must NOT authenticate after revoke")
}
if err := a.Verify(ctx, newH, v); err != nil {
t.Fatalf("new key must still authenticate after revoke: %v", err)
}
// Leak check: key material must not appear in non-secret credential fields.
for _, f := range []string{cred.Identity, cred.Source, cred.Location, string(cred.Kind)} {
if strings.Contains(f, oldSecret) || strings.Contains(f, ns.secret) {
t.Errorf("secret leaked into non-secret field %q", f)
}
}
}
func TestAWSSecretRoundTrip(t *testing.T) {
v := vault.New()
defer v.Purge()
// SecretAccessKey commonly contains '/' and '+'; ensure the blob round-trips.
in := awsSecret{keyID: "AKIAEXAMPLE", secret: "ab/cd+ef==Ghi", region: "eu-west-2", endpoint: "http://127.0.0.1:5000"}
h := v.Store([]byte(in.build()))
out, err := parseAWSSecret(v, h)
if err != nil {
t.Fatalf("parse: %v", err)
}
if out != in {
t.Fatalf("round-trip mismatch: %+v != %+v", out, in)
}
}
func TestSigV4HeadersStable(t *testing.T) {
// A fixed input must yield the canonical AWS SigV4 example-style header shape:
// Credential/SignedHeaders/Signature present and the signing key chain applied.
hdrs, err := sigV4Headers(http.MethodPost, "https://iam.amazonaws.com/", "us-east-1", "iam",
[]byte("Action=CreateAccessKey&Version=2010-05-08"), "AKIDEXAMPLE", "secretkey",
time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
auth := hdrs["Authorization"]
for _, want := range []string{
"AWS4-HMAC-SHA256 ",
"Credential=AKIDEXAMPLE/",
"/us-east-1/iam/aws4_request",
"SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date",
"Signature=",
} {
if !strings.Contains(auth, want) {
t.Errorf("Authorization missing %q; got %q", want, auth)
}
}
if hdrs["X-Amz-Date"] == "" || hdrs["X-Amz-Content-Sha256"] == "" {
t.Error("missing x-amz-date / x-amz-content-sha256")
}
}