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,339 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user