From 6cdb6000cef26012ab4fc4686325c29703cd4e46 Mon Sep 17 00:00:00 2001 From: Bryn Mailer Date: Tue, 14 Jul 2026 13:37:14 +1000 Subject: [PATCH] feat: presigned urls (#533) --- internal/gwsign/signer_test.go | 7 +- spinifex/gateway/auth.go | 111 +++---- spinifex/gateway/auth_test.go | 52 +-- spinifex/handlers/sts/presigned_url_verify.go | 306 +++--------------- .../handlers/sts/presigned_url_verify_test.go | 160 ++------- spinifex/lbagent/agent_test.go | 19 +- 6 files changed, 155 insertions(+), 500 deletions(-) diff --git a/internal/gwsign/signer_test.go b/internal/gwsign/signer_test.go index 6cba441ea..6ad479186 100644 --- a/internal/gwsign/signer_test.go +++ b/internal/gwsign/signer_test.go @@ -12,7 +12,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/credentials" - "github.com/mulgadc/predastore/auth" + "github.com/mulgadc/predastore/pkg/sigv4" ) // failingProvider models a cold IMDS datapath: Retrieve always errors, as the @@ -40,13 +40,14 @@ func TestSign_StaticVerifies(t *testing.T) { var verifyErr error srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - req, err := auth.ParseReq(r) + // sigv4.Parse reads and hashes the body itself, so no explicit body hash. + sr, err := sigv4.Parse(r) if err != nil { verifyErr = err http.Error(w, err.Error(), http.StatusBadRequest) return } - verifyErr = req.Verify(testSecretKey, testService, testRegion, auth.WithBodyHash(payloadHash)) + _, verifyErr = sr.Verify(testSecretKey, testRegion, testService) w.WriteHeader(http.StatusOK) })) defer srv.Close() diff --git a/spinifex/gateway/auth.go b/spinifex/gateway/auth.go index a3516791c..ba1737b7a 100644 --- a/spinifex/gateway/auth.go +++ b/spinifex/gateway/auth.go @@ -3,8 +3,6 @@ package gateway import ( "bytes" "context" - "crypto/sha256" - "encoding/hex" "errors" "io" "log/slog" @@ -13,15 +11,12 @@ import ( "time" "github.com/google/uuid" - "github.com/mulgadc/predastore/auth" + "github.com/mulgadc/predastore/pkg/sigv4" "github.com/mulgadc/spinifex/spinifex/awserrors" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" "github.com/mulgadc/spinifex/spinifex/utils" ) -// Maximum request body size for signature validation (10 MB). -const maxBodySize = 10 * 1024 * 1024 - // AKID prefixes. Prefix-first dispatch prevents a misfiled record from being // resolved by the wrong lookup path. const ( @@ -42,15 +37,36 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler { return } - sig, err := auth.ParseReq(r) + // sigv4.Parse reads, hashes, and rewinds the request body to build the + // canonical request; Verify below only recomputes the HMAC. + sig, err := sigv4.Parse(r) if err != nil { - gw.writeSigV4Error(w, r, parseSigV4ErrorCode(err)) + // sigv4 validates the envelope, credential scope, and timestamp at + // parse time. Distinguish a request that never presented credentials + // (or is simply too large) from a failed authentication attempt: the + // latter is rate-limited so a brute-forcer is locked out regardless of + // which validation stage rejects it. + switch { + case errors.Is(err, sigv4.ErrMissingAuthentication): + gw.writeSigV4Error(w, r, awserrors.ErrorMissingAuthenticationToken) + case errors.Is(err, sigv4.ErrPayloadTooLarge): + gw.writeSigV4Error(w, r, awserrors.ErrorRequestEntityTooLarge) + case errors.Is(err, sigv4.ErrRequestTimeTooSkewed): + // Skew/replay: AWS returns this as SignatureDoesNotMatch. + gw.RateLimiter.RecordFailure(clientIP) + gw.writeSigV4Error(w, r, awserrors.ErrorSignatureDoesNotMatch) + default: + // Malformed Authorization, bad credential scope, unsupported + // algorithm, missing content hash: a failed auth attempt. + gw.RateLimiter.RecordFailure(clientIP) + gw.writeSigV4Error(w, r, awserrors.ErrorIncompleteSignature) + } return } // Reject unknown services before crypto; otherwise Verify re-signs with // the client-claimed service name and rubber-stamps the scope. - if !supportedServices[sig.Service] { + if !supportedServices[sig.Credential.Service] { gw.writeSigV4Error(w, r, awserrors.ErrorSignatureDoesNotMatch) return } @@ -58,7 +74,7 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler { // Fail fast when NATS is down; LookupAccessKey would hang 5 s otherwise. // Nil NATSConn (test helpers) skips this check. if gw.NATSConn != nil && !gw.NATSConn.IsConnected() { - gw.writeClusterUnavailable(w, r, sig.Service) + gw.writeClusterUnavailable(w, r, sig.Credential.Service) return } @@ -74,12 +90,12 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler { lookupCode string ) switch { - case strings.HasPrefix(sig.AccessKeyID, longLivedAKIDPrefix): - secret, principal, lookupCode = gw.resolveLongLivedAKID(sig.AccessKeyID, clientIP) - case strings.HasPrefix(sig.AccessKeyID, sessionAKIDPrefix): - secret, principal, lookupCode = gw.resolveSessionAKID(r, sig.AccessKeyID, clientIP) + case strings.HasPrefix(sig.Credential.AccessKeyID, longLivedAKIDPrefix): + secret, principal, lookupCode = gw.resolveLongLivedAKID(sig.Credential.AccessKeyID, clientIP) + case strings.HasPrefix(sig.Credential.AccessKeyID, sessionAKIDPrefix): + secret, principal, lookupCode = gw.resolveSessionAKID(r, sig.Credential.AccessKeyID, clientIP) default: - slog.Warn("Auth failure: unknown AKID prefix", "accessKeyID", sig.AccessKeyID, "sourceIP", clientIP) + slog.Warn("Auth failure: unknown AKID prefix", "accessKeyID", sig.Credential.AccessKeyID, "sourceIP", clientIP) gw.RateLimiter.RecordFailure(clientIP) gw.writeSigV4Error(w, r, awserrors.ErrorInvalidClientTokenId) return @@ -89,47 +105,32 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler { return } - r.Body = http.MaxBytesReader(w, r.Body, maxBodySize) + // Region is pinned to the gateway; service is the client-claimed scope, + // already gated against supportedServices above. + if _, err := sig.Verify(secret, gw.Region, sig.Credential.Service); err != nil { + slog.Warn("Auth failure: verification failed", + "accessKeyID", sig.Credential.AccessKeyID, "sourceIP", clientIP, "err", err) + gw.RateLimiter.RecordFailure(clientIP) + gw.writeSigV4Error(w, r, awserrors.ErrorSignatureDoesNotMatch) + return + } + + // Parse rewound the body; re-read it for query-arg parsing, then rewind + // again for the downstream handler. body, err := io.ReadAll(r.Body) if err != nil { - var maxBytesErr *http.MaxBytesError - if errors.As(err, &maxBytesErr) { - slog.Warn("Request body too large", "limit", maxBodySize) - gw.writeSigV4Error(w, r, awserrors.ErrorRequestEntityTooLarge) - return - } slog.Error("Failed to read request body", "err", err) gw.writeSigV4Error(w, r, awserrors.ErrorInternalError) return } r.Body = io.NopCloser(bytes.NewReader(body)) - sum := sha256.Sum256(body) - bodyHash := hex.EncodeToString(sum[:]) - - if err := sig.Verify(secret, sig.Service, gw.Region, - auth.WithBodyHash(bodyHash)); err != nil { - attrs := []any{ - "accessKeyID", sig.AccessKeyID, - "sourceIP", clientIP, - "err", err, - } - var sme *auth.SigMismatchError - if errors.As(err, &sme) { - attrs = append(attrs, "expectedAuthHdr", sme.ExpectedAuthHdr, "providedAuthHdr", sme.ProvidedAuthHdr) - } - slog.Warn("Auth failure: verification failed", attrs...) - gw.RateLimiter.RecordFailure(clientIP) - gw.writeSigV4Error(w, r, verifySigV4ErrorCode(err)) - return - } - ctx := r.Context() ctx = context.WithValue(ctx, ctxIdentity, principal.identity) ctx = context.WithValue(ctx, ctxAccountID, principal.accountID) - ctx = context.WithValue(ctx, ctxService, sig.Service) - ctx = context.WithValue(ctx, ctxRegion, sig.Region) - ctx = context.WithValue(ctx, ctxAccessKey, sig.AccessKeyID) + ctx = context.WithValue(ctx, ctxService, sig.Credential.Service) + ctx = context.WithValue(ctx, ctxRegion, sig.Credential.Region) + ctx = context.WithValue(ctx, ctxAccessKey, sig.Credential.AccessKeyID) ctx = context.WithValue(ctx, ctxPrincipalType, principal.principalType) if principal.assumedRoleARN != "" { ctx = context.WithValue(ctx, ctxAssumedRoleARN, principal.assumedRoleARN) @@ -151,7 +152,7 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler { } slog.Debug("SigV4 authentication successful", - "accessKey", sig.AccessKeyID, + "accessKey", sig.Credential.AccessKeyID, "identity", principal.identity, "principalType", principal.principalType) gw.RateLimiter.RecordSuccess(clientIP) @@ -269,24 +270,6 @@ func (gw *GatewayConfig) resolveSessionAKID(r *http.Request, accessKeyID, client }, "" } -func parseSigV4ErrorCode(err error) string { - switch { - case errors.Is(err, auth.ErrMissingAuth): - return awserrors.ErrorMissingAuthenticationToken - default: - return awserrors.ErrorIncompleteSignature - } -} - -func verifySigV4ErrorCode(err error) string { - switch { - case errors.Is(err, auth.ErrMissingContentSHA): - return awserrors.ErrorIncompleteSignature - default: - return awserrors.ErrorSignatureDoesNotMatch - } -} - // writeSigV4Error writes an EC2-compatible XML error response for auth failures. func (gw *GatewayConfig) writeSigV4Error(w http.ResponseWriter, r *http.Request, errorCode string) { requestID := uuid.NewString() diff --git a/spinifex/gateway/auth_test.go b/spinifex/gateway/auth_test.go index 87b693ad0..b0682b33d 100644 --- a/spinifex/gateway/auth_test.go +++ b/spinifex/gateway/auth_test.go @@ -2,6 +2,7 @@ package gateway import ( "bytes" + "context" "crypto/sha256" "encoding/hex" "errors" @@ -14,8 +15,10 @@ import ( "testing" "time" + "github.com/aws/aws-sdk-go-v2/aws" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" "github.com/go-chi/chi/v5" - "github.com/mulgadc/predastore/auth" + "github.com/mulgadc/predastore/pkg/sigv4" "github.com/mulgadc/spinifex/spinifex/awserrors" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts" @@ -67,13 +70,24 @@ func init() { } } -// signTestRequest signs req in place using SigV4. body must match what the -// middleware will read from r.Body so the sha256 matches X-Amz-Content-Sha256. -func signTestRequest(t *testing.T, req *http.Request, body []byte, accessKey, secret string, optFns ...func(*auth.Options)) { +// signTestRequest signs req in place with the aws-sdk-go-v2 v4 signer — the same +// path production callers use via gwsign — so the gateway verifies a real SDK +// signature. body must match what the middleware reads from r.Body so the payload +// hash agrees. An optional signingTime pins the clock for skew tests. +func signTestRequest(t *testing.T, req *http.Request, body []byte, accessKey, secret string, signingTime ...time.Time) { t.Helper() sum := sha256.Sum256(body) - require.NoError(t, auth.SignReq(req, accessKey, secret, - hex.EncodeToString(sum[:]), testService, testRegion, optFns...)) + payloadHash := hex.EncodeToString(sum[:]) + when := time.Now().UTC() + if len(signingTime) > 0 { + when = signingTime[0] + } + // gwsign sets this header so the SDK signs it; sigv4 reproduces it when + // reconstructing the canonical request. + req.Header.Set("X-Amz-Content-Sha256", payloadHash) + require.NoError(t, v4.NewSigner().SignHTTP(context.Background(), + aws.Credentials{AccessKeyID: accessKey, SecretAccessKey: secret}, + req, payloadHash, testService, testRegion, when)) } func setupTestApp(accessKey, secretKey string) http.Handler { @@ -410,7 +424,7 @@ func TestSigV4Auth_DecryptFailure(t *testing.T) { func TestSigV4Auth_RequestBodyTooLarge(t *testing.T) { handler := setupTestApp(testAccessKey, testSecretKey) - oversizedBody := []byte(strings.Repeat("x", maxBodySize+1)) + oversizedBody := []byte(strings.Repeat("x", sigv4.MaxPayloadLen+1)) req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(oversizedBody)) req.Host = "localhost:9999" req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -433,10 +447,10 @@ func TestSigV4Auth_RequestBodyTooLarge(t *testing.T) { func TestSigV4Auth_ExpiredTimestamp(t *testing.T) { handler := setupTestApp(testAccessKey, testSecretKey) - past := time.Now().UTC().Add(-6 * time.Minute) // exceeds 5-minute maxClockSkew + past := time.Now().UTC().Add(-16 * time.Minute) // exceeds sigv4 15-minute MaxClockSkew req := httptest.NewRequest(http.MethodGet, "/", nil) req.Host = "localhost:9999" - signTestRequest(t, req, nil, testAccessKey, testSecretKey, auth.WithTime(past)) + signTestRequest(t, req, nil, testAccessKey, testSecretKey, past) resp := doRequest(handler, req) @@ -453,10 +467,10 @@ func TestSigV4Auth_ExpiredTimestamp(t *testing.T) { func TestSigV4Auth_FutureTimestamp(t *testing.T) { handler := setupTestApp(testAccessKey, testSecretKey) - future := time.Now().UTC().Add(6 * time.Minute) // exceeds 5-minute maxClockSkew + future := time.Now().UTC().Add(16 * time.Minute) // exceeds sigv4 15-minute MaxClockSkew req := httptest.NewRequest(http.MethodGet, "/", nil) req.Host = "localhost:9999" - signTestRequest(t, req, nil, testAccessKey, testSecretKey, auth.WithTime(future)) + signTestRequest(t, req, nil, testAccessKey, testSecretKey, future) resp := doRequest(handler, req) @@ -473,10 +487,10 @@ func TestSigV4Auth_FutureTimestamp(t *testing.T) { func TestSigV4Auth_TimestampWithinSkew(t *testing.T) { handler := setupTestApp(testAccessKey, testSecretKey) - recent := time.Now().UTC().Add(-4 * time.Minute) // within the 5-minute window + recent := time.Now().UTC().Add(-14 * time.Minute) // within the 15-minute window req := httptest.NewRequest(http.MethodGet, "/", nil) req.Host = "localhost:9999" - signTestRequest(t, req, nil, testAccessKey, testSecretKey, auth.WithTime(recent)) + signTestRequest(t, req, nil, testAccessKey, testSecretKey, recent) resp := doRequest(handler, req) @@ -496,11 +510,11 @@ func TestSigV4Auth_ClockSkewBoundary(t *testing.T) { offset time.Duration expectPass bool }{ - // 4m59s not 5m to absorb test execution time. - {"just within 5 min past", -(4*time.Minute + 59*time.Second), true}, - {"just beyond 5 min past", -(5*time.Minute + 1*time.Second), false}, - {"just within 5 min future", 4*time.Minute + 59*time.Second, true}, - {"just beyond 5 min future", 5*time.Minute + 1*time.Second, false}, + // 14m59s not 15m to absorb test execution time. + {"just within 15 min past", -(14*time.Minute + 59*time.Second), true}, + {"just beyond 15 min past", -(15*time.Minute + 1*time.Second), false}, + {"just within 15 min future", 14*time.Minute + 59*time.Second, true}, + {"just beyond 15 min future", 15*time.Minute + 1*time.Second, false}, } for _, tc := range testCases { @@ -508,7 +522,7 @@ func TestSigV4Auth_ClockSkewBoundary(t *testing.T) { at := time.Now().UTC().Add(tc.offset) req := httptest.NewRequest(http.MethodGet, "/", nil) req.Host = "localhost:9999" - signTestRequest(t, req, nil, testAccessKey, testSecretKey, auth.WithTime(at)) + signTestRequest(t, req, nil, testAccessKey, testSecretKey, at) resp := doRequest(handler, req) diff --git a/spinifex/handlers/sts/presigned_url_verify.go b/spinifex/handlers/sts/presigned_url_verify.go index d0e71aa40..e5c211bb5 100644 --- a/spinifex/handlers/sts/presigned_url_verify.go +++ b/spinifex/handlers/sts/presigned_url_verify.go @@ -1,42 +1,24 @@ package handlers_sts import ( - "crypto/hmac" - "crypto/sha256" - "crypto/subtle" - "encoding/hex" "errors" "fmt" "log/slog" - "maps" + "net/http" "net/url" - "slices" - "sort" - "strconv" "strings" "time" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/iam" + "github.com/mulgadc/predastore/pkg/sigv4" "github.com/mulgadc/spinifex/spinifex/awserrors" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" ) const ( - presignedAlgorithm = "AWS4-HMAC-SHA256" - presignedTerminator = "aws4_request" presignedService = "sts" - presignedAmzDateFmt = "20060102T150405Z" - presignedDateFmt = "20060102" presignedSignedHeader = "x-k8s-aws-id" - presignedHostHeader = "host" - - // presignedMaxExpiresSeconds is the AWS-imposed ceiling on X-Amz-Expires. - // Rejecting beyond this ceiling catches forged URLs that lie about their TTL. - presignedMaxExpiresSeconds = 7 * 24 * 60 * 60 - - // presignedClockSkew is the SigV4 wire-protocol clock-skew allowance. - presignedClockSkew = 5 * time.Minute ) // PresignedCallerIdentity is the result of validating a SigV4-presigned GetCallerIdentity @@ -54,7 +36,8 @@ var presignedTimeNow = func() time.Time { return time.Now().UTC() } // VerifyPresignedGetCallerIdentity validates a SigV4-presigned GetCallerIdentity URL // and resolves the calling principal. Called by the EKS token webhook over NATS. -// Reconstructs the canonical request with expectedClusterName to prevent cross-cluster replay. +// The X-K8s-Aws-Id header is reconstructed from expectedClusterName rather than the URL, +// so a URL presigned for one cluster produces a signature mismatch against another. func (s *STSServiceImpl) VerifyPresignedGetCallerIdentity(presignedURL, expectedClusterName string) (*PresignedCallerIdentity, error) { if presignedURL == "" || expectedClusterName == "" { return nil, errors.New(awserrors.ErrorMissingParameter) @@ -68,73 +51,42 @@ func (s *STSServiceImpl) VerifyPresignedGetCallerIdentity(presignedURL, expected return nil, errors.New(awserrors.ErrorInvalidIdentityToken) } - q := u.Query() - env, err := parsePresignedEnvelope(q) + // Reconstruct the signed request, binding X-K8s-Aws-Id to the expected cluster. + // sigv4.Parse reads the header value off the request, so an attacker's URL signed + // for another cluster reconstructs under a different value and fails the compare. + r, err := http.NewRequest(http.MethodGet, presignedURL, nil) + if err != nil { + return nil, errors.New(awserrors.ErrorInvalidIdentityToken) + } + // Canonical MIME key on Set; sigv4 lowercases header names for the SignedHeaders set. + r.Header.Set("X-K8s-Aws-Id", expectedClusterName) + + req, err := sigv4.Parse(r, sigv4.WithTime(presignedTimeNow())) if err != nil { slog.Warn("VerifyPresignedGetCallerIdentity: envelope parse failed", "err", err) + return nil, mapSigv4Err(err) + } + + // sigv4 enforces host is signed but not X-K8s-Aws-Id; without it the reconstructed + // canonical request drops the cluster binding, so verify would self-match any URL. + if _, ok := req.Canonical.SignedHeaders[presignedSignedHeader]; !ok { + slog.Warn("VerifyPresignedGetCallerIdentity: x-k8s-aws-id not in SignedHeaders") return nil, errors.New(awserrors.ErrorInvalidIdentityToken) } - if !env.signedHeaders[presignedSignedHeader] { - slog.Warn("VerifyPresignedGetCallerIdentity: x-k8s-aws-id not in SignedHeaders", - "signed_headers", env.rawSignedHeaders) - return nil, errors.New(awserrors.ErrorInvalidIdentityToken) - } - if !env.signedHeaders[presignedHostHeader] { - return nil, errors.New(awserrors.ErrorInvalidIdentityToken) - } - - if env.credential.service != presignedService { - return nil, errors.New(awserrors.ErrorInvalidIdentityToken) - } - if env.credential.terminator != presignedTerminator { - return nil, errors.New(awserrors.ErrorInvalidIdentityToken) - } - - now := presignedTimeNow() - if now.Sub(env.signedTime).Abs() > presignedClockSkew+time.Duration(env.expiresSeconds)*time.Second { - // Guards a nonsense X-Amz-Date (months out); exact expiry is checked below. - return nil, errors.New(awserrors.ErrorExpiredToken) - } - if now.After(env.signedTime.Add(time.Duration(env.expiresSeconds) * time.Second)) { - return nil, errors.New(awserrors.ErrorExpiredToken) - } - if env.signedTime.UTC().Format(presignedDateFmt) != env.credential.date { - // Credential-scope date must match the X-Amz-Date date portion. - return nil, errors.New(awserrors.ErrorInvalidIdentityToken) - } - - principal, secret, err := s.resolvePrincipalForVerify(env.credential.accessKeyID) + principal, secret, err := s.resolvePrincipalForVerify(req.Credential.AccessKeyID) if err != nil { return nil, err } - canonicalQuery := canonicalQueryStringExcludingSignature(q) - canonicalHeaders, signedHeadersList := buildCanonicalHeaders(u.Host, expectedClusterName) - canonicalRequest := strings.Join([]string{ - "GET", - canonicalURI(u), - canonicalQuery, - canonicalHeaders, - signedHeadersList, - // Non-S3 presigned requests use SHA256("") as the payload hash; "UNSIGNED-PAYLOAD" is S3-only. - emptyStringSHA256, - }, "\n") - - stringToSign := strings.Join([]string{ - presignedAlgorithm, - env.amzDate, - env.credential.scope(), - hexSHA256(canonicalRequest), - }, "\n") - - signingKey := deriveSigningKey(secret, env.credential.date, env.credential.region, env.credential.service) - expectedSig := hex.EncodeToString(hmacSHA256(signingKey, stringToSign)) - - if subtle.ConstantTimeCompare([]byte(expectedSig), []byte(env.signature)) != 1 { - slog.Warn("VerifyPresignedGetCallerIdentity: signature mismatch (cluster name replay or tampered URL)", - "akid", env.credential.accessKeyID, - "expected_cluster", expectedClusterName) + // Pass the credential's own region back so Verify's region check is a no-op: + // `aws eks get-token` signs against the caller's regional STS endpoint, which we + // do not constrain. The service is pinned to sts. + if _, err := req.Verify(secret, req.Credential.Region, presignedService); err != nil { + slog.Warn("VerifyPresignedGetCallerIdentity: signature verify failed (cluster name replay or tampered URL)", + "akid", req.Credential.AccessKeyID, + "expected_cluster", expectedClusterName, + "err", err) return nil, errors.New(awserrors.ErrorInvalidIdentityToken) } @@ -142,195 +94,15 @@ func (s *STSServiceImpl) VerifyPresignedGetCallerIdentity(presignedURL, expected return principal, nil } -// presignedEnvelope holds the parsed envelope facts from a SigV4-presigned URL query string. -type presignedEnvelope struct { - credential presignedCredential - amzDate string // raw X-Amz-Date value (used in StringToSign) - signedTime time.Time - expiresSeconds int64 - rawSignedHeaders string - signedHeaders map[string]bool - signature string -} - -type presignedCredential struct { - accessKeyID string - date string // YYYYMMDD - region string - service string - terminator string -} - -func (c presignedCredential) scope() string { - return strings.Join([]string{c.date, c.region, c.service, c.terminator}, "/") -} - -func parsePresignedEnvelope(q url.Values) (presignedEnvelope, error) { - algo := q.Get("X-Amz-Algorithm") - if algo != presignedAlgorithm { - return presignedEnvelope{}, fmt.Errorf("unsupported algorithm %q", algo) +// mapSigv4Err maps sigv4 sentinel errors onto AWS error codes. Time-window failures +// surface as ExpiredToken; every other structural failure is an invalid token. +func mapSigv4Err(err error) error { + switch { + case errors.Is(err, sigv4.ErrPresignedURLExpired), errors.Is(err, sigv4.ErrRequestTimeTooSkewed): + return errors.New(awserrors.ErrorExpiredToken) + default: + return errors.New(awserrors.ErrorInvalidIdentityToken) } - credRaw := q.Get("X-Amz-Credential") - if credRaw == "" { - return presignedEnvelope{}, errors.New("missing X-Amz-Credential") - } - parts := strings.Split(credRaw, "/") - if len(parts) != 5 { - return presignedEnvelope{}, fmt.Errorf("X-Amz-Credential has %d parts, expected 5", len(parts)) - } - cred := presignedCredential{ - accessKeyID: parts[0], - date: parts[1], - region: parts[2], - service: parts[3], - terminator: parts[4], - } - if cred.accessKeyID == "" { - return presignedEnvelope{}, errors.New("X-Amz-Credential has empty access key id") - } - - amzDate := q.Get("X-Amz-Date") - if amzDate == "" { - return presignedEnvelope{}, errors.New("missing X-Amz-Date") - } - signedTime, err := time.Parse(presignedAmzDateFmt, amzDate) - if err != nil { - return presignedEnvelope{}, fmt.Errorf("invalid X-Amz-Date: %w", err) - } - - expiresRaw := q.Get("X-Amz-Expires") - if expiresRaw == "" { - return presignedEnvelope{}, errors.New("missing X-Amz-Expires") - } - expires, err := strconv.ParseInt(expiresRaw, 10, 64) - if err != nil || expires <= 0 || expires > presignedMaxExpiresSeconds { - return presignedEnvelope{}, fmt.Errorf("invalid X-Amz-Expires: %q", expiresRaw) - } - - signedHeadersRaw := q.Get("X-Amz-SignedHeaders") - if signedHeadersRaw == "" { - return presignedEnvelope{}, errors.New("missing X-Amz-SignedHeaders") - } - signedSet := make(map[string]bool) - for h := range strings.SplitSeq(signedHeadersRaw, ";") { - signedSet[strings.ToLower(h)] = true - } - - sig := q.Get("X-Amz-Signature") - if sig == "" { - return presignedEnvelope{}, errors.New("missing X-Amz-Signature") - } - - return presignedEnvelope{ - credential: cred, - amzDate: amzDate, - signedTime: signedTime.UTC(), - expiresSeconds: expires, - rawSignedHeaders: signedHeadersRaw, - signedHeaders: signedSet, - signature: sig, - }, nil -} - -// canonicalQueryStringExcludingSignature returns the SigV4 canonical query string -// with X-Amz-Signature dropped, per-RFC-3986 encoded, sorted by key then value. -func canonicalQueryStringExcludingSignature(q url.Values) string { - type kv struct { - k string - v string - } - pairs := make([]kv, 0, len(q)) - for k, vs := range q { - if k == "X-Amz-Signature" { - continue - } - ek := awsQueryEscape(k) - for _, v := range vs { - pairs = append(pairs, kv{ek, awsQueryEscape(v)}) - } - } - sort.Slice(pairs, func(i, j int) bool { - if pairs[i].k == pairs[j].k { - return pairs[i].v < pairs[j].v - } - return pairs[i].k < pairs[j].k - }) - parts := make([]string, len(pairs)) - for i, p := range pairs { - parts[i] = p.k + "=" + p.v - } - return strings.Join(parts, "&") -} - -// awsQueryEscape encodes per SigV4 (unreserved set, uppercase hex). -// net/url.QueryEscape encodes spaces as '+' and lower-cases hex, both diverging from AWS. -func awsQueryEscape(s string) string { - var b strings.Builder - b.Grow(len(s)) - for i := 0; i < len(s); i++ { - c := s[i] - switch { - case (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'), - c == '-' || c == '_' || c == '.' || c == '~': - b.WriteByte(c) - default: - b.WriteByte('%') - b.WriteString(strings.ToUpper(hexByte(c))) - } - } - return b.String() -} - -func hexByte(b byte) string { - const hexDigits = "0123456789ABCDEF" - return string([]byte{hexDigits[b>>4], hexDigits[b&0x0f]}) -} - -// canonicalURI returns the SigV4 canonical URI; empty path normalises to "/". -func canonicalURI(u *url.URL) string { - if u.Path == "" { - return "/" - } - return u.Path -} - -// buildCanonicalHeaders reconstructs the canonical-headers/signed-headers pair for -// the presigned URL, using the URL host and expectedClusterName for X-K8s-Aws-Id. -func buildCanonicalHeaders(host, xK8sAwsID string) (canonicalHeaders, signedHeadersList string) { - headers := map[string]string{ - presignedHostHeader: host, - presignedSignedHeader: xK8sAwsID, - } - names := slices.Sorted(maps.Keys(headers)) - var b strings.Builder - for _, n := range names { - b.WriteString(n) - b.WriteByte(':') - b.WriteString(strings.TrimSpace(headers[n])) - b.WriteByte('\n') - } - return b.String(), strings.Join(names, ";") -} - -func hexSHA256(s string) string { - sum := sha256.Sum256([]byte(s)) - return hex.EncodeToString(sum[:]) -} - -// emptyStringSHA256 is the SigV4 payload hash for an empty body. -var emptyStringSHA256 = hexSHA256("") - -func hmacSHA256(key []byte, data string) []byte { - mac := hmac.New(sha256.New, key) - mac.Write([]byte(data)) - return mac.Sum(nil) -} - -func deriveSigningKey(secret, date, region, service string) []byte { - kDate := hmacSHA256([]byte("AWS4"+secret), date) - kRegion := hmacSHA256(kDate, region) - kService := hmacSHA256(kRegion, service) - return hmacSHA256(kService, presignedTerminator) } // resolvePrincipalForVerify resolves an access key to its plaintext secret and a diff --git a/spinifex/handlers/sts/presigned_url_verify_test.go b/spinifex/handlers/sts/presigned_url_verify_test.go index e7c05b7dd..e13b94947 100644 --- a/spinifex/handlers/sts/presigned_url_verify_test.go +++ b/spinifex/handlers/sts/presigned_url_verify_test.go @@ -1,11 +1,8 @@ package handlers_sts import ( - "encoding/hex" "net/http" "net/url" - "sort" - "strconv" "strings" "testing" "time" @@ -24,76 +21,22 @@ const ( testPresignedRegion = "au-mel-1" ) -// presignTestURL builds an `aws eks get-token`-shape SigV4-presigned GET URL -// for sts.GetCallerIdentity, signing the X-K8s-Aws-Id header with the given -// clusterName so a Verify call against the same clusterName succeeds. Hand- -// rolled rather than via aws-sdk-go-v2 so the test does not pull an extra -// dependency just to assert the production reconstruction. +// presignTestURL builds an `aws eks get-token`-shape SigV4-presigned GET URL for +// sts.GetCallerIdentity using the real aws-sdk-go v4 presigner — the same +// canonicalisation `aws eks get-token` uses — signing the X-K8s-Aws-Id header with +// the given clusterName so a Verify call against the same clusterName succeeds. func presignTestURL(t *testing.T, accessKeyID, secret, clusterName string, signedTime time.Time, expires int64) string { t.Helper() - host := testPresignedHost - region := testPresignedRegion - service := "sts" - terminator := "aws4_request" - date := signedTime.UTC().Format("20060102") - amzDate := signedTime.UTC().Format("20060102T150405Z") - credentialScope := strings.Join([]string{date, region, service, terminator}, "/") + req, err := http.NewRequest(http.MethodGet, + "https://"+testPresignedHost+"/?Action=GetCallerIdentity&Version=2011-06-15", nil) + require.NoError(t, err) + req.Header.Set("X-K8s-Aws-Id", clusterName) - q := url.Values{} - q.Set("Action", "GetCallerIdentity") - q.Set("Version", "2011-06-15") - q.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256") - q.Set("X-Amz-Credential", accessKeyID+"/"+credentialScope) - q.Set("X-Amz-Date", amzDate) - q.Set("X-Amz-Expires", strconv.FormatInt(expires, 10)) - q.Set("X-Amz-SignedHeaders", "host;x-k8s-aws-id") + signer := v4.NewSigner(credentials.NewStaticCredentials(accessKeyID, secret, "")) + _, err = signer.Presign(req, nil, presignedService, testPresignedRegion, time.Duration(expires)*time.Second, signedTime) + require.NoError(t, err) - // Canonical query string (no signature yet) — must match the - // production reconstruction, byte-for-byte. - canonQ := canonicalQueryStringForTest(q) - - canonHeaders := "host:" + host + "\n" + "x-k8s-aws-id:" + clusterName + "\n" - signedHeadersList := "host;x-k8s-aws-id" - canonRequest := strings.Join([]string{ - "GET", "/", canonQ, canonHeaders, signedHeadersList, emptyStringSHA256, - }, "\n") - - stringToSign := strings.Join([]string{ - "AWS4-HMAC-SHA256", amzDate, credentialScope, hexSHA256(canonRequest), - }, "\n") - - key := deriveSigningKey(secret, date, region, service) - sig := hex.EncodeToString(hmacSHA256(key, stringToSign)) - q.Set("X-Amz-Signature", sig) - - return "https://" + host + "/?" + canonicalQueryStringForTest(q) -} - -// canonicalQueryStringForTest mirrors the production canonical-query -// implementation so the test signer agrees with the verifier on -// canonicalisation. Sorting + encoding rules must stay aligned; if either -// implementation drifts, signature verification breaks immediately — -// caught by TestVerifyPresignedGetCallerIdentity_HappyPath. -func canonicalQueryStringForTest(q url.Values) string { - type kv struct{ k, v string } - pairs := make([]kv, 0, len(q)) - for k, vs := range q { - ek := awsQueryEscape(k) - for _, v := range vs { - pairs = append(pairs, kv{ek, awsQueryEscape(v)}) - } - } - sort.Slice(pairs, func(i, j int) bool { - if pairs[i].k == pairs[j].k { - return pairs[i].v < pairs[j].v - } - return pairs[i].k < pairs[j].k - }) - parts := make([]string, len(pairs)) - for i, p := range pairs { - parts[i] = p.k + "=" + p.v - } - return strings.Join(parts, "&") + return req.URL.String() } // seedAccessKey writes an active IAM access key for the user and returns its @@ -184,40 +127,23 @@ func TestVerifyPresignedGetCallerIdentity_CrossClusterReplay(t *testing.T) { func TestVerifyPresignedGetCallerIdentity_MissingXK8sAwsIdInSignedHeaders(t *testing.T) { // An attacker presigning without x-k8s-aws-id in SignedHeaders strips the - // cross-cluster check from the canonical request. Verify must refuse - // before any signature work — the URL is structurally untrustworthy. + // cross-cluster check from the canonical request. Verify must refuse even + // though the signature itself is valid — the URL is structurally untrustworthy. svc, _ := newTestSetup(t) akid, secret := seedAccessKey(t, svc, testCallerAccountID, "eve") signedAt := time.Now().UTC().Truncate(time.Second) withFrozenTime(t, signedAt) - date := signedAt.UTC().Format("20060102") - amzDate := signedAt.UTC().Format("20060102T150405Z") - credentialScope := date + "/au-mel-1/sts/aws4_request" - q := url.Values{} - q.Set("Action", "GetCallerIdentity") - q.Set("Version", "2011-06-15") - q.Set("X-Amz-Algorithm", "AWS4-HMAC-SHA256") - q.Set("X-Amz-Credential", akid+"/"+credentialScope) - q.Set("X-Amz-Date", amzDate) - q.Set("X-Amz-Expires", "900") - q.Set("X-Amz-SignedHeaders", "host") // no x-k8s-aws-id + // Presign without setting X-K8s-Aws-Id, so SignedHeaders is host only. + req, err := http.NewRequest(http.MethodGet, + "https://"+testPresignedHost+"/?Action=GetCallerIdentity&Version=2011-06-15", nil) + require.NoError(t, err) + signer := v4.NewSigner(credentials.NewStaticCredentials(akid, secret, "")) + _, err = signer.Presign(req, nil, presignedService, testPresignedRegion, 900*time.Second, signedAt) + require.NoError(t, err) - canonQ := canonicalQueryStringForTest(q) - canonHeaders := "host:" + testPresignedHost + "\n" - canonReq := strings.Join([]string{ - "GET", "/", canonQ, canonHeaders, "host", emptyStringSHA256, - }, "\n") - stringToSign := strings.Join([]string{ - "AWS4-HMAC-SHA256", amzDate, credentialScope, hexSHA256(canonReq), - }, "\n") - key := deriveSigningKey(secret, date, "au-mel-1", "sts") - sig := hex.EncodeToString(hmacSHA256(key, stringToSign)) - q.Set("X-Amz-Signature", sig) - u := "https://" + testPresignedHost + "/?" + canonicalQueryStringForTest(q) - - _, err := svc.VerifyPresignedGetCallerIdentity(u, "any-cluster") + _, err = svc.VerifyPresignedGetCallerIdentity(req.URL.String(), "any-cluster") require.Error(t, err) assert.Equal(t, awserrors.ErrorInvalidIdentityToken, err.Error()) } @@ -353,47 +279,11 @@ func TestVerifyPresignedGetCallerIdentity_CredentialDateMismatch(t *testing.T) { assert.Equal(t, awserrors.ErrorInvalidIdentityToken, err.Error()) } -// ----- Component unit tests ----------------------------------------------- - -func TestAWSQueryEscape(t *testing.T) { - cases := []struct { - in, want string - }{ - {"abc", "abc"}, - {"a b", "a%20b"}, - {"a+b", "a%2Bb"}, - {"a/b", "a%2Fb"}, - {"a=b", "a%3Db"}, - {"~_.-", "~_.-"}, // unreserved set passes through - } - for _, tc := range cases { - t.Run(tc.in, func(t *testing.T) { - assert.Equal(t, tc.want, awsQueryEscape(tc.in)) - }) - } -} - -func TestDeriveSigningKey_KnownVector(t *testing.T) { - // From AWS SigV4 docs (Example signing key derivation). - // Inputs: - // secret = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY" - // date = "20120215", region = "us-east-1", service = "iam" - // The doc's expected signing-key hex digest stays stable across SDK versions. - key := deriveSigningKey( - "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", - "20120215", "us-east-1", "iam", - ) - const wantHex = "f4780e2d9f65fa895f9c67b32ce1baf0b0d8a43505a000a1a9e090d414db404d" - assert.Equal(t, wantHex, hex.EncodeToString(key)) -} - // TestVerifyPresignedGetCallerIdentity_RealSDKPresign signs the URL with the // actual aws-sdk-go v4 presigner — the same canonicalisation `aws eks -// get-token` uses — rather than the hand-rolled presignTestURL. This is the -// faithful end-to-end guard: it caught the payload-hash bug (verify used the -// S3-only "UNSIGNED-PAYLOAD" instead of the empty-string SHA256 that botocore -// signs non-S3 requests with), which every hand-rolled test masked by using -// the same wrong constant. +// get-token` uses. This is the faithful end-to-end guard: it caught the +// payload-hash bug (verify used the S3-only "UNSIGNED-PAYLOAD" instead of the +// empty-string SHA256 that botocore signs non-S3 requests with). func TestVerifyPresignedGetCallerIdentity_RealSDKPresign(t *testing.T) { svc, _ := newTestSetup(t) akid, secret := seedAccessKey(t, svc, testCallerAccountID, "carol") diff --git a/spinifex/lbagent/agent_test.go b/spinifex/lbagent/agent_test.go index f1c215630..214a78aef 100644 --- a/spinifex/lbagent/agent_test.go +++ b/spinifex/lbagent/agent_test.go @@ -1,10 +1,7 @@ package lbagent import ( - "crypto/sha256" - "encoding/hex" "fmt" - "io" "net/http" "net/http/httptest" "net/url" @@ -14,7 +11,7 @@ import ( "testing" "time" - "github.com/mulgadc/predastore/auth" + "github.com/mulgadc/predastore/pkg/sigv4" ) func TestNew(t *testing.T) { @@ -75,9 +72,9 @@ func TestNew_SocketPath(t *testing.T) { } } -// TestSignedPost_ProducesVerifiableSignature confirms the migrated signing path -// (predastore/auth.SignReq over aws-sdk-go-v2) produces a request the gateway's -// SigV4 verifier accepts, including a body-hash that matches X-Amz-Content-Sha256. +// TestSignedPost_ProducesVerifiableSignature confirms the agent's signing path +// (aws-sdk-go-v2 via gwsign) produces a request the gateway's sigv4 verifier +// accepts, including a body-hash that matches X-Amz-Content-Sha256. func TestSignedPost_ProducesVerifiableSignature(t *testing.T) { const ( accessKey = "AKIAIOSFODNN7EXAMPLE" @@ -88,17 +85,15 @@ func TestSignedPost_ProducesVerifiableSignature(t *testing.T) { var parsed bool var verifyErr error srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - sum := sha256.Sum256(body) - req, err := auth.ParseReq(r) + // sigv4.Parse reads and hashes the body itself; don't drain r.Body first. + sr, err := sigv4.Parse(r) if err != nil { verifyErr = err http.Error(w, err.Error(), http.StatusBadRequest) return } parsed = true - verifyErr = req.Verify(secretKey, "elasticloadbalancing", region, - auth.WithBodyHash(hex.EncodeToString(sum[:]))) + _, verifyErr = sr.Verify(secretKey, region, "elasticloadbalancing") fmt.Fprint(w, "ok") })) defer srv.Close()