Files
incredigo/internal/rotate/aws.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

340 lines
11 KiB
Go

package rotate
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"incredigo/internal/discover"
"incredigo/internal/vault"
)
// AWS rotates an AWS IAM access key via the IAM/STS Query APIs by CREATING a new
// access key, verifying it with STS GetCallerIdentity, then DELETING the old one —
// the provider-API rotation pattern (create-new → verify → revoke-old).
//
// This is the cleanest provider-API driver because the OLD credential is
// self-identifying: an access key's AccessKeyId is *non-secret* and travels in the
// secret blob, so RevokeOld deletes exactly the right key (DeleteAccessKey takes the
// AccessKeyId directly — no name-capture dance like Gitea needed).
//
// The credential's secret is a single self-contained line (so it round-trips
// through the existing single-line gopass sink unchanged):
//
// aws://<AccessKeyId>:<SecretAccessKey>@aws/?region=<region>[&endpoint=<url>]
//
// - AccessKeyId / SecretAccessKey are the userinfo user/password.
// - region= signing region (default us-east-1). IAM is global and always
// signs us-east-1; region matters only for STS regional endpoints.
// - endpoint= OPTIONAL override of the API base (for a self-hosted moto/LocalStack
// in testing). Absent → real AWS (iam/sts .amazonaws.com).
//
// SECURITY: requests are signed with AWS SigV4 by HMAC chaining over the secret key
// (crypto/hmac); the secret never appears in argv, on disk, or in any header except
// as the derived signature. The new SecretAccessKey is returned by AWS only once, in
// the CreateAccessKey XML body — it is decoded straight into the vault and never
// logged. Error paths read only non-success bodies (AWS error XML, which carries no
// secret) and surface just the error Code, never a raw body.
type AWS struct {
// HTTPClient is injectable for tests / custom TLS. Defaults to a 15s-timeout
// client.
HTTPClient *http.Client
// now is injectable so tests can pin the SigV4 timestamp; defaults to time.Now.
now func() time.Time
}
// init self-registers the driver. Availability alone changes nothing; the
// `rotate --execute` + INCREDIGO_ALLOW_EXECUTE=1 gate still applies.
func init() { Register(&AWS{}) }
// Name identifies the driver in plans and audit records.
func (a *AWS) Name() string { return "aws" }
// Detect claims credentials tagged Source == "aws".
func (a *AWS) Detect(c discover.Credential) bool { return c.Source == "aws" }
func (a *AWS) client() *http.Client {
if a.HTTPClient != nil {
return a.HTTPClient
}
return &http.Client{Timeout: 15 * time.Second}
}
func (a *AWS) clock() time.Time {
if a.now != nil {
return a.now()
}
return time.Now()
}
// awsSecret is the parsed credential blob. None of its fields are ever logged.
type awsSecret struct {
keyID string
secret string
region string
endpoint string // "" = real AWS
}
// parseAWSSecret reads the single-line blob from the vault and decomposes it.
func parseAWSSecret(v *vault.Vault, h *vault.Handle) (awsSecret, error) {
buf, err := v.Open(h)
if err != nil {
return awsSecret{}, err
}
u, err := url.Parse(strings.TrimSpace(string(buf.Bytes())))
if err != nil {
return awsSecret{}, fmt.Errorf("aws: parse secret url: %w", err)
}
if u.Scheme != "aws" {
return awsSecret{}, fmt.Errorf("aws: unexpected scheme %q", u.Scheme)
}
if u.User == nil {
return awsSecret{}, fmt.Errorf("aws: secret has no AccessKeyId:SecretAccessKey")
}
sec, _ := u.User.Password()
if u.User.Username() == "" || sec == "" {
return awsSecret{}, fmt.Errorf("aws: secret missing key id or secret")
}
q := u.Query()
region := q.Get("region")
if region == "" {
region = "us-east-1"
}
return awsSecret{
keyID: u.User.Username(),
secret: sec,
region: region,
endpoint: q.Get("endpoint"),
}, nil
}
// build re-encodes an awsSecret into the single-line blob form.
func (s awsSecret) build() string {
u := &url.URL{
Scheme: "aws",
Host: "aws",
User: url.UserPassword(s.keyID, s.secret),
Path: "/",
}
q := url.Values{}
q.Set("region", s.region)
if s.endpoint != "" {
q.Set("endpoint", s.endpoint)
}
u.RawQuery = q.Encode()
return u.String()
}
// endpointFor resolves the API base for a service ("iam" or "sts"), honoring an
// explicit endpoint override (moto/LocalStack) when set.
func (a *AWS) endpointFor(s awsSecret, service string) string {
if s.endpoint != "" {
return strings.TrimRight(s.endpoint, "/")
}
return "https://" + service + ".amazonaws.com"
}
// Rotate creates a NEW access key for the calling identity (authenticated by the
// OLD key) and returns a vault handle to a rebuilt blob carrying the new key pair.
// It does not delete the old key.
func (a *AWS) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) {
s, err := parseAWSSecret(v, c.Secret)
if err != nil {
return nil, err
}
body, err := a.call(ctx, s, "iam", "CreateAccessKey", "2010-05-08", nil)
if err != nil {
return nil, fmt.Errorf("aws: create access key: %w", err)
}
var r struct {
KeyID string `xml:"CreateAccessKeyResult>AccessKey>AccessKeyId"`
Secret string `xml:"CreateAccessKeyResult>AccessKey>SecretAccessKey"`
}
if err := xml.Unmarshal(body, &r); err != nil {
return nil, fmt.Errorf("aws: decode created key: %w", err)
}
if r.KeyID == "" || r.Secret == "" {
return nil, fmt.Errorf("aws: create access key: empty key in response")
}
ns := s // carry region/endpoint forward
ns.keyID = r.KeyID
ns.secret = r.Secret
return v.Store([]byte(ns.build())), nil
}
// Verify proves the freshly minted key authenticates by calling STS
// GetCallerIdentity with it and checking a caller ARN comes back.
func (a *AWS) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error {
s, err := parseAWSSecret(v, newSecret)
if err != nil {
return err
}
body, err := a.call(ctx, s, "sts", "GetCallerIdentity", "2011-06-15", nil)
if err != nil {
return fmt.Errorf("aws verify: %w", err)
}
var r struct {
Arn string `xml:"GetCallerIdentityResult>Arn"`
}
if err := xml.Unmarshal(body, &r); err != nil {
return fmt.Errorf("aws verify: decode caller identity: %w", err)
}
if r.Arn == "" {
return fmt.Errorf("aws verify: GetCallerIdentity returned no ARN")
}
return nil
}
// RevokeOld deletes the OLD access key by its (non-secret) AccessKeyId. The old
// key is still valid at this point (revoke runs only after the new key is
// stored+verified), so it authenticates its own deletion.
func (a *AWS) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error {
s, err := parseAWSSecret(v, c.Secret)
if err != nil {
return err
}
extra := url.Values{}
extra.Set("AccessKeyId", s.keyID)
if _, err := a.call(ctx, s, "iam", "DeleteAccessKey", "2010-05-08", extra); err != nil {
return fmt.Errorf("aws revoke: %w", err)
}
return nil
}
// call POSTs an AWS Query-protocol request (form-encoded Action/Version + extras),
// SigV4-signed with the blob's own key pair, and returns the response body. A
// non-2xx response yields an error carrying only the AWS error Code (never the
// raw body).
func (a *AWS) call(ctx context.Context, s awsSecret, service, action, version string, extra url.Values) ([]byte, error) {
form := url.Values{}
form.Set("Action", action)
form.Set("Version", version)
for k, vs := range extra {
for _, val := range vs {
form.Add(k, val)
}
}
body := []byte(form.Encode())
region := s.region
if service == "iam" {
region = "us-east-1" // IAM is a global service; SigV4 region is always us-east-1.
}
endpoint := a.endpointFor(s, service)
hdrs, err := sigV4Headers(http.MethodPost, endpoint+"/", region, service, body, s.keyID, s.secret, a.clock())
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint+"/", bytes.NewReader(body))
if err != nil {
return nil, err
}
for k, val := range hdrs {
req.Header.Set(k, val)
}
resp, err := a.client().Do(req)
if err != nil {
return nil, fmt.Errorf("%s: %w", action, err)
}
defer resp.Body.Close()
rb, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("%s: status %s: %s", action, resp.Status, awsErrCode(rb))
}
return rb, nil
}
// awsErrCode extracts the <Error><Code> from an AWS error response so failures are
// legible without dumping the body. AWS error bodies never contain a secret.
func awsErrCode(b []byte) string {
var e struct {
Code string `xml:"Error>Code"`
}
if xml.Unmarshal(b, &e) == nil && e.Code != "" {
return e.Code
}
return "unknown error"
}
// sigV4Headers computes the AWS Signature Version 4 headers for a POST request
// whose parameters are in the (already-encoded) body. Returns the headers the
// caller must set: Authorization, X-Amz-Date, X-Amz-Content-Sha256, Content-Type.
func sigV4Headers(method, rawURL, region, service string, body []byte, keyID, secret string, t time.Time) (map[string]string, error) {
u, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
t = t.UTC()
amzDate := t.Format("20060102T150405Z")
dateStamp := t.Format("20060102")
contentType := "application/x-www-form-urlencoded; charset=utf-8"
payloadHash := sha256hex(body)
canonicalURI := u.EscapedPath()
if canonicalURI == "" {
canonicalURI = "/"
}
canonicalHeaders := "content-type:" + contentType + "\n" +
"host:" + u.Host + "\n" +
"x-amz-content-sha256:" + payloadHash + "\n" +
"x-amz-date:" + amzDate + "\n"
signedHeaders := "content-type;host;x-amz-content-sha256;x-amz-date"
canonicalRequest := strings.Join([]string{
method,
canonicalURI,
u.RawQuery, // empty: params are in the body
canonicalHeaders,
signedHeaders,
payloadHash,
}, "\n")
scope := dateStamp + "/" + region + "/" + service + "/aws4_request"
stringToSign := strings.Join([]string{
"AWS4-HMAC-SHA256",
amzDate,
scope,
sha256hex([]byte(canonicalRequest)),
}, "\n")
kDate := hmacSHA256([]byte("AWS4"+secret), dateStamp)
kRegion := hmacSHA256(kDate, region)
kService := hmacSHA256(kRegion, service)
kSigning := hmacSHA256(kService, "aws4_request")
signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
auth := "AWS4-HMAC-SHA256 " +
"Credential=" + keyID + "/" + scope + ", " +
"SignedHeaders=" + signedHeaders + ", " +
"Signature=" + signature
return map[string]string{
"Authorization": auth,
"X-Amz-Date": amzDate,
"X-Amz-Content-Sha256": payloadHash,
"Content-Type": contentType,
}, nil
}
func sha256hex(b []byte) string {
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func hmacSHA256(key []byte, data string) []byte {
m := hmac.New(sha256.New, key)
m.Write([]byte(data))
return m.Sum(nil)
}