feat: presigned urls (#533)

This commit is contained in:
Bryn Mailer
2026-07-14 13:37:14 +10:00
committed by GitHub
parent 4d8a34b545
commit 6cdb6000ce
6 changed files with 155 additions and 500 deletions
+4 -3
View File
@@ -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()
+47 -64
View File
@@ -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()
+33 -19
View File
@@ -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)
+39 -267
View File
@@ -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
@@ -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")
+7 -12
View File
@@ -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()