From 4d8db3b38a76326d97ee28675083b443ebdedfeb Mon Sep 17 00:00:00 2001 From: Denis Kirillov Date: Wed, 27 Mar 2024 09:14:58 +0300 Subject: [PATCH] [#339] tmp Signed-off-by: Denis Kirillov --- api/auth/center.go | 190 ++++-- api/auth/center_test.go | 43 +- api/auth/presign.go | 41 ++ api/auth/presign_test.go | 96 +++ api/auth/signer/v4asdk2/credentials.go | 140 +++++ api/auth/signer/v4asdk2/credentials_test.go | 77 +++ api/auth/signer/v4asdk2/error.go | 17 + .../signer/v4asdk2/internal/crypto/compare.go | 30 + .../v4asdk2/internal/crypto/compare_test.go | 60 ++ .../signer/v4asdk2/internal/crypto/ecc.go | 113 ++++ .../v4asdk2/internal/crypto/ecc_test.go | 277 +++++++++ api/auth/signer/v4asdk2/internal/v4/const.go | 36 ++ .../v4asdk2/internal/v4/header_rules.go | 88 +++ .../signer/v4asdk2/internal/v4/headers.go | 79 +++ api/auth/signer/v4asdk2/internal/v4/hmac.go | 13 + api/auth/signer/v4asdk2/internal/v4/host.go | 75 +++ api/auth/signer/v4asdk2/internal/v4/time.go | 36 ++ api/auth/signer/v4asdk2/internal/v4/util.go | 64 ++ .../signer/v4asdk2/internal/v4/util_test.go | 75 +++ api/auth/signer/v4asdk2/middleware.go | 118 ++++ api/auth/signer/v4asdk2/middleware_test.go | 149 +++++ api/auth/signer/v4asdk2/presign_middleware.go | 116 ++++ .../signer/v4asdk2/presign_middleware_test.go | 222 +++++++ api/auth/signer/v4asdk2/shared_test.go | 18 + api/auth/signer/v4asdk2/smithy.go | 85 +++ api/auth/signer/v4asdk2/stream.go | 96 +++ api/auth/signer/v4asdk2/v4a.go | 577 ++++++++++++++++++ api/auth/signer/v4asdk2/v4a_test.go | 429 +++++++++++++ .../signer/v4sdk2/signer/internal/v4/cache.go | 115 ++++ .../signer/v4sdk2/signer/internal/v4/const.go | 40 ++ .../v4sdk2/signer/internal/v4/header_rules.go | 88 +++ .../v4sdk2/signer/internal/v4/headers.go | 84 +++ .../v4sdk2/signer/internal/v4/headers_test.go | 63 ++ .../signer/v4sdk2/signer/internal/v4/hmac.go | 13 + .../signer/v4sdk2/signer/internal/v4/host.go | 75 +++ .../signer/v4sdk2/signer/internal/v4/scope.go | 13 + .../signer/v4sdk2/signer/internal/v4/time.go | 36 ++ .../signer/v4sdk2/signer/internal/v4/util.go | 80 +++ .../v4sdk2/signer/internal/v4/util_test.go | 158 +++++ .../signer/v4sdk2/signer/v4/middleware.go | 442 ++++++++++++++ .../v4sdk2/signer/v4/presign_middleware.go | 126 ++++ api/auth/signer/v4sdk2/signer/v4/stream.go | 87 +++ api/auth/signer/v4sdk2/signer/v4/v4.go | 570 +++++++++++++++++ api/auth/signer/v4sdk2/signer/v4/v4_test.go | 363 +++++++++++ api/handler/put.go | 14 +- api/handler/s3v4aReader.go | 209 +++++++ api/headers.go | 11 +- go.mod | 3 + go.sum | 6 + 49 files changed, 5904 insertions(+), 52 deletions(-) create mode 100644 api/auth/signer/v4asdk2/credentials.go create mode 100644 api/auth/signer/v4asdk2/credentials_test.go create mode 100644 api/auth/signer/v4asdk2/error.go create mode 100644 api/auth/signer/v4asdk2/internal/crypto/compare.go create mode 100644 api/auth/signer/v4asdk2/internal/crypto/compare_test.go create mode 100644 api/auth/signer/v4asdk2/internal/crypto/ecc.go create mode 100644 api/auth/signer/v4asdk2/internal/crypto/ecc_test.go create mode 100644 api/auth/signer/v4asdk2/internal/v4/const.go create mode 100644 api/auth/signer/v4asdk2/internal/v4/header_rules.go create mode 100644 api/auth/signer/v4asdk2/internal/v4/headers.go create mode 100644 api/auth/signer/v4asdk2/internal/v4/hmac.go create mode 100644 api/auth/signer/v4asdk2/internal/v4/host.go create mode 100644 api/auth/signer/v4asdk2/internal/v4/time.go create mode 100644 api/auth/signer/v4asdk2/internal/v4/util.go create mode 100644 api/auth/signer/v4asdk2/internal/v4/util_test.go create mode 100644 api/auth/signer/v4asdk2/middleware.go create mode 100644 api/auth/signer/v4asdk2/middleware_test.go create mode 100644 api/auth/signer/v4asdk2/presign_middleware.go create mode 100644 api/auth/signer/v4asdk2/presign_middleware_test.go create mode 100644 api/auth/signer/v4asdk2/shared_test.go create mode 100644 api/auth/signer/v4asdk2/smithy.go create mode 100644 api/auth/signer/v4asdk2/stream.go create mode 100644 api/auth/signer/v4asdk2/v4a.go create mode 100644 api/auth/signer/v4asdk2/v4a_test.go create mode 100644 api/auth/signer/v4sdk2/signer/internal/v4/cache.go create mode 100644 api/auth/signer/v4sdk2/signer/internal/v4/const.go create mode 100644 api/auth/signer/v4sdk2/signer/internal/v4/header_rules.go create mode 100644 api/auth/signer/v4sdk2/signer/internal/v4/headers.go create mode 100644 api/auth/signer/v4sdk2/signer/internal/v4/headers_test.go create mode 100644 api/auth/signer/v4sdk2/signer/internal/v4/hmac.go create mode 100644 api/auth/signer/v4sdk2/signer/internal/v4/host.go create mode 100644 api/auth/signer/v4sdk2/signer/internal/v4/scope.go create mode 100644 api/auth/signer/v4sdk2/signer/internal/v4/time.go create mode 100644 api/auth/signer/v4sdk2/signer/internal/v4/util.go create mode 100644 api/auth/signer/v4sdk2/signer/internal/v4/util_test.go create mode 100644 api/auth/signer/v4sdk2/signer/v4/middleware.go create mode 100644 api/auth/signer/v4sdk2/signer/v4/presign_middleware.go create mode 100644 api/auth/signer/v4sdk2/signer/v4/stream.go create mode 100644 api/auth/signer/v4sdk2/signer/v4/v4.go create mode 100644 api/auth/signer/v4sdk2/signer/v4/v4_test.go create mode 100644 api/handler/s3v4aReader.go diff --git a/api/auth/center.go b/api/auth/center.go index 72fb7fe..e67b9de 100644 --- a/api/auth/center.go +++ b/api/auth/center.go @@ -9,28 +9,38 @@ import ( "io" "mime/multipart" "net/http" + "os" "regexp" "strings" "time" v4 "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/auth/signer/v4" + v4a "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/auth/signer/v4asdk2" apiErrors "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/errors" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/middleware" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/creds/accessbox" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/creds/tokens" oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id" + credentialsv2 "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/smithy-go/logging" ) -// authorizationFieldRegexp -- is regexp for credentials with Base58 encoded cid and oid and '0' (zero) as delimiter. -var authorizationFieldRegexp = regexp.MustCompile(`AWS4-HMAC-SHA256 Credential=(?P[^/]+)/(?P[^/]+)/(?P[^/]*)/(?P[^/]+)/aws4_request,\s*SignedHeaders=(?P.+),\s*Signature=(?P.+)`) +var ( + // authorizationFieldRegexp -- is regexp for credentials with Base58 encoded cid and oid and '0' (zero) as delimiter. + authorizationFieldRegexp = regexp.MustCompile(`AWS4-HMAC-SHA256 Credential=(?P[^/]+)/(?P[^/]+)/(?P[^/]*)/(?P[^/]+)/aws4_request,\s*SignedHeaders=(?P.+),\s*Signature=(?P.+)`) -// postPolicyCredentialRegexp -- is regexp for credentials when uploading file using POST with policy. -var postPolicyCredentialRegexp = regexp.MustCompile(`(?P[^/]+)/(?P[^/]+)/(?P[^/]*)/(?P[^/]+)/aws4_request`) + // authorizationFieldV4aRegexp -- is regexp for credentials with Base58 encoded cid and oid and '0' (zero) as delimiter. + authorizationFieldV4aRegexp = regexp.MustCompile(`AWS4-ECDSA-P256-SHA256 Credential=(?P[^/]+)/(?P[^/]+)/(?P[^/]+)/aws4_request,\s*SignedHeaders=(?P.+),\s*Signature=(?P.+)`) + + // postPolicyCredentialRegexp -- is regexp for credentials when uploading file using POST with policy. + postPolicyCredentialRegexp = regexp.MustCompile(`(?P[^/]+)/(?P[^/]+)/(?P[^/]*)/(?P[^/]+)/aws4_request`) +) type ( Center struct { reg *RegexpSubmatcher + regV4a *RegexpSubmatcher postReg *RegexpSubmatcher cli tokens.Credentials allowedAccessKeyIDPrefixes []string // empty slice means all access key ids are allowed @@ -41,23 +51,27 @@ type ( AccessKeyID string Service string Region string - SignatureV4 string + Signature string SignedFields []string Date string IsPresigned bool Expiration time.Duration + Preamble string + PayloadHash string } ) const ( - accessKeyPartsNum = 2 - authHeaderPartsNum = 6 - maxFormSizeMemory = 50 * 1048576 // 50 MB + accessKeyPartsNum = 2 + authHeaderPartsNum = 6 + authHeaderV4aPartsNum = 5 + maxFormSizeMemory = 50 * 1048576 // 50 MB AmzAlgorithm = "X-Amz-Algorithm" AmzCredential = "X-Amz-Credential" AmzSignature = "X-Amz-Signature" AmzSignedHeaders = "X-Amz-SignedHeaders" + AmzRegionSet = "X-Amz-Region-Set" AmzExpires = "X-Amz-Expires" AmzDate = "X-Amz-Date" AmzContentSHA256 = "X-Amz-Content-Sha256" @@ -86,31 +100,60 @@ func New(creds tokens.Credentials, prefixes []string) *Center { return &Center{ cli: creds, reg: NewRegexpMatcher(authorizationFieldRegexp), + regV4a: NewRegexpMatcher(authorizationFieldV4aRegexp), postReg: NewRegexpMatcher(postPolicyCredentialRegexp), allowedAccessKeyIDPrefixes: prefixes, } } -func (c *Center) parseAuthHeader(header string) (*AuthHeader, error) { - submatches := c.reg.GetSubmatches(header) - if len(submatches) != authHeaderPartsNum { - return nil, fmt.Errorf("%w: %s", apiErrors.GetAPIError(apiErrors.ErrAuthorizationHeaderMalformed), header) +const ( + signaturePreambleSigV4 = "AWS4-HMAC-SHA256" + signaturePreambleSigV4A = "AWS4-ECDSA-P256-SHA256" +) + +func (c *Center) parseAuthHeader(authHeader string, headers http.Header) (*AuthHeader, error) { + preamble, _, _ := strings.Cut(authHeader, " ") + + var ( + submatches map[string]string + region string + ) + + switch preamble { + case signaturePreambleSigV4: + submatches = c.reg.GetSubmatches(authHeader) + if len(submatches) != authHeaderPartsNum { + return nil, fmt.Errorf("%w: %s", apiErrors.GetAPIError(apiErrors.ErrAuthorizationHeaderMalformed), authHeader) + } + region = submatches["region"] + case signaturePreambleSigV4A: + submatches = c.regV4a.GetSubmatches(authHeader) + if len(submatches) != authHeaderV4aPartsNum { + return nil, fmt.Errorf("%w: %s", apiErrors.GetAPIError(apiErrors.ErrAuthorizationHeaderMalformed), authHeader) + } + region = headers.Get(AmzRegionSet) + default: + return nil, fmt.Errorf("%w: %s", apiErrors.GetAPIError(apiErrors.ErrAuthorizationHeaderMalformed), authHeader) } + // AWS4-ECDSA-P256-SHA256 + // Credential=2XGRML5EW3LMHdf64W2DkBy1Nkuu4y4wGhUj44QjbXBi05ZNvs8WVwy1XTmSEkcVkydPKzCgtmR7U3zyLYTj3Snxf/20240326/s3/aws4_request, + // SignedHeaders=amz-sdk-invocation-id;amz-sdk-request;host;x-amz-content-sha256;x-amz-date;x-amz-region-set, + // Signature=3044022006a2bc760140834101d0a79667d6aa75768c1a28e9cafc8963484d0752a6c6050220629dc06d7d6505e1b1e2a5d1f974b25ba32fdffc6f3f70dc4dda31b8a6f7ea2b accessKey := strings.Split(submatches["access_key_id"], "0") if len(accessKey) != accessKeyPartsNum { return nil, fmt.Errorf("%w: %s", apiErrors.GetAPIError(apiErrors.ErrInvalidAccessKeyID), accessKey) } - signedFields := strings.Split(submatches["signed_header_fields"], ";") - return &AuthHeader{ AccessKeyID: submatches["access_key_id"], Service: submatches["service"], - Region: submatches["region"], - SignatureV4: submatches["v4_signature"], - SignedFields: signedFields, + Region: region, + Signature: submatches["v4_signature"], + SignedFields: strings.Split(submatches["signed_header_fields"], ";"), Date: submatches["date"], + Preamble: preamble, + PayloadHash: headers.Get(AmzContentSHA256), }, nil } @@ -137,7 +180,7 @@ func (c *Center) Authenticate(r *http.Request) (*middleware.Box, error) { ) queryValues := r.URL.Query() - if queryValues.Get(AmzAlgorithm) == "AWS4-HMAC-SHA256" { + if queryValues.Get(AmzAlgorithm) == signaturePreambleSigV4 { creds := strings.Split(queryValues.Get(AmzCredential), "/") if len(creds) != 5 || creds[4] != "aws4_request" { return nil, fmt.Errorf("bad X-Amz-Credential") @@ -146,10 +189,31 @@ func (c *Center) Authenticate(r *http.Request) (*middleware.Box, error) { AccessKeyID: creds[0], Service: creds[3], Region: creds[2], - SignatureV4: queryValues.Get(AmzSignature), + Signature: queryValues.Get(AmzSignature), SignedFields: strings.Split(queryValues.Get(AmzSignedHeaders), ";"), Date: creds[1], IsPresigned: true, + Preamble: signaturePreambleSigV4, + } + authHdr.Expiration, err = time.ParseDuration(queryValues.Get(AmzExpires) + "s") + if err != nil { + return nil, fmt.Errorf("couldn't parse X-Amz-Expires: %w", err) + } + signatureDateTimeStr = queryValues.Get(AmzDate) + } else if queryValues.Get(AmzAlgorithm) == signaturePreambleSigV4A { + creds := strings.Split(queryValues.Get(AmzCredential), "/") + if len(creds) != 4 || creds[3] != "aws4_request" { + return nil, fmt.Errorf("bad X-Amz-Credential") + } + authHdr = &AuthHeader{ + AccessKeyID: creds[0], + Service: creds[2], + Region: queryValues.Get(AmzRegionSet), + Signature: queryValues.Get(AmzSignature), + SignedFields: strings.Split(queryValues.Get(AmzSignedHeaders), ";"), + Date: creds[1], + IsPresigned: true, + Preamble: signaturePreambleSigV4A, } authHdr.Expiration, err = time.ParseDuration(queryValues.Get(AmzExpires) + "s") if err != nil { @@ -164,7 +228,7 @@ func (c *Center) Authenticate(r *http.Request) (*middleware.Box, error) { } return nil, fmt.Errorf("%w: %v", middleware.ErrNoAuthorizationHeader, authHeaderField) } - authHdr, err = c.parseAuthHeader(authHeaderField[0]) + authHdr, err = c.parseAuthHeader(authHeaderField[0], r.Header) if err != nil { return nil, err } @@ -205,7 +269,7 @@ func (c *Center) Authenticate(r *http.Request) (*middleware.Box, error) { AuthHeaders: &middleware.AuthHeader{ AccessKeyID: authHdr.AccessKeyID, Region: authHdr.Region, - SignatureV4: authHdr.SignatureV4, + SignatureV4: authHdr.Signature, }, Attributes: attrs, } @@ -315,37 +379,75 @@ func cloneRequest(r *http.Request, authHeader *AuthHeader) *http.Request { } func (c *Center) checkSign(authHeader *AuthHeader, box *accessbox.Box, request *http.Request, signatureDateTime time.Time) error { - awsCreds := credentials.NewStaticCredentials(authHeader.AccessKeyID, box.Gate.SecretKey, "") - signer := v4.NewSigner(awsCreds) - signer.DisableURIPathEscaping = true - var signature string - if authHeader.IsPresigned { - now := time.Now() - if signatureDateTime.Add(authHeader.Expiration).Before(now) { - return fmt.Errorf("%w: expired: now %s, signature %s", apiErrors.GetAPIError(apiErrors.ErrExpiredPresignRequest), - now.Format(time.RFC3339), signatureDateTime.Format(time.RFC3339)) + + switch authHeader.Preamble { + case signaturePreambleSigV4: + awsCreds := credentials.NewStaticCredentials(authHeader.AccessKeyID, box.Gate.SecretKey, "") + signer := v4.NewSigner(awsCreds) + signer.DisableURIPathEscaping = true + + if authHeader.IsPresigned { + if err := checkPresignedDate(authHeader, signatureDateTime); err != nil { + return err + } + + if _, err := signer.Presign(request, nil, authHeader.Service, authHeader.Region, authHeader.Expiration, signatureDateTime); err != nil { + return fmt.Errorf("failed to pre-sign temporary HTTP request: %w", err) + } + signature = request.URL.Query().Get(AmzSignature) + } else { + if _, err := signer.Sign(request, nil, authHeader.Service, authHeader.Region, signatureDateTime); err != nil { + return fmt.Errorf("failed to sign temporary HTTP request: %w", err) + } + signature = c.reg.GetSubmatches(request.Header.Get(AuthorizationHdr))["v4_signature"] } - if now.Before(signatureDateTime) { - return fmt.Errorf("%w: signature time from the future: now %s, signature %s", apiErrors.GetAPIError(apiErrors.ErrBadRequest), - now.Format(time.RFC3339), signatureDateTime.Format(time.RFC3339)) + if authHeader.Signature != signature { + return fmt.Errorf("%w: %s != %s: headers %v", apiErrors.GetAPIError(apiErrors.ErrSignatureDoesNotMatch), + authHeader.Signature, signature, authHeader.SignedFields) } - if _, err := signer.Presign(request, nil, authHeader.Service, authHeader.Region, authHeader.Expiration, signatureDateTime); err != nil { - return fmt.Errorf("failed to pre-sign temporary HTTP request: %w", err) + + case signaturePreambleSigV4A: + signer := v4a.NewSigner(func(options *v4a.SignerOptions) { + options.DisableURIPathEscaping = true + options.LogSigning = true + options.Logger = logging.NewStandardLogger(os.Stdout) + }) + + credAdapter := v4a.SymmetricCredentialAdaptor{ + SymmetricProvider: credentialsv2.NewStaticCredentialsProvider(authHeader.AccessKeyID, box.Gate.SecretKey, ""), } - signature = request.URL.Query().Get(AmzSignature) - } else { - if _, err := signer.Sign(request, nil, authHeader.Service, authHeader.Region, signatureDateTime); err != nil { - return fmt.Errorf("failed to sign temporary HTTP request: %w", err) + + creds, err := credAdapter.RetrievePrivateKey(request.Context()) + if err != nil { + return fmt.Errorf("failed to derive assymetric key from credentials: %w", err) } - signature = c.reg.GetSubmatches(request.Header.Get(AuthorizationHdr))["v4_signature"] + + if authHeader.IsPresigned { + if err = checkPresignedDate(authHeader, signatureDateTime); err != nil { + return err + } + } + + return signer.VerifySignature(creds, request, authHeader.PayloadHash, authHeader.Service, + strings.Split(authHeader.Region, ","), signatureDateTime, authHeader.Signature) + default: + return fmt.Errorf("invalid preamble: %s", authHeader.Preamble) } - if authHeader.SignatureV4 != signature { - return fmt.Errorf("%w: %s != %s: headers %v", apiErrors.GetAPIError(apiErrors.ErrSignatureDoesNotMatch), - authHeader.SignatureV4, signature, authHeader.SignedFields) - } + return nil +} +func checkPresignedDate(authHeader *AuthHeader, signatureDateTime time.Time) error { + now := time.Now() + if signatureDateTime.Add(authHeader.Expiration).Before(now) { + return fmt.Errorf("%w: expired: now %s, signature %s", apiErrors.GetAPIError(apiErrors.ErrExpiredPresignRequest), + now.Format(time.RFC3339), signatureDateTime.Format(time.RFC3339)) + } + if now.Before(signatureDateTime) { + return fmt.Errorf("%w: signature time from the future: now %s, signature %s", apiErrors.GetAPIError(apiErrors.ErrBadRequest), + now.Format(time.RFC3339), signatureDateTime.Format(time.RFC3339)) + } return nil } diff --git a/api/auth/center_test.go b/api/auth/center_test.go index acca1ae..eb65dc5 100644 --- a/api/auth/center_test.go +++ b/api/auth/center_test.go @@ -1,11 +1,15 @@ package auth import ( + "net/http" "strings" "testing" "time" + v4a "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/auth/signer/v4asdk2" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/errors" + oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id" + credentialsv2 "github.com/aws/aws-sdk-go-v2/credentials" "github.com/stretchr/testify/require" ) @@ -28,7 +32,7 @@ func TestAuthHeaderParse(t *testing.T) { AccessKeyID: "oid0cid", Service: "s3", Region: "us-east-1", - SignatureV4: "2811ccb9e242f41426738fb1f", + Signature: "2811ccb9e242f41426738fb1f", SignedFields: []string{"host", "x-amz-content-sha256", "x-amz-date"}, Date: "20210809", }, @@ -44,7 +48,7 @@ func TestAuthHeaderParse(t *testing.T) { expected: nil, }, } { - authHeader, err := center.parseAuthHeader(tc.header) + authHeader, err := center.parseAuthHeader(tc.header, nil) require.ErrorIs(t, err, tc.err, tc.header) require.Equal(t, tc.expected, authHeader, tc.header) } @@ -100,6 +104,41 @@ func TestSignature(t *testing.T) { require.Equal(t, "dfbe886241d9e369cf4b329ca0f15eb27306c97aa1022cc0bb5a914c4ef87634", signature) } +func TestSignatureV4A(t *testing.T) { + var accessKeyAddr oid.Address + err := accessKeyAddr.DecodeString("8N7CYBY74kxZXoyvA5UNdmovaXqFpwNfvEPsqaN81es2/3tDwq5tR8fByrJcyJwyiuYX7Dae8tyDT7pd8oaL1MBto") + require.NoError(t, err) + + accessKeyID := strings.ReplaceAll(accessKeyAddr.String(), "/", "0") + secretKey := "713d0a0b9efc7d22923e17b0402a6a89b4273bc711c8bacb2da1b643d0006aeb" + + signer := v4a.NewSigner(func(options *v4a.SignerOptions) { + options.DisableURIPathEscaping = true + }) + + credAdapter := v4a.SymmetricCredentialAdaptor{ + SymmetricProvider: credentialsv2.NewStaticCredentialsProvider(accessKeyID, secretKey, ""), + } + + req, err := http.NewRequest("GET", "http://localhost:8084/bucket/object", nil) + require.NoError(t, err) + + creds, err := credAdapter.RetrievePrivateKey(req.Context()) + require.NoError(t, err) + + signingTime := time.Now() + service := "s3" + regionSet := []string{"spb"} + + err = signer.SignHTTP(req.Context(), creds, req, "", service, regionSet, signingTime) + require.NoError(t, err) + + signature := NewRegexpMatcher(authorizationFieldV4aRegexp).GetSubmatches(req.Header.Get(AuthorizationHdr))["v4_signature"] + + err = signer.VerifySignature(creds, req, "", service, regionSet, signingTime, signature) + require.NoError(t, err) +} + func TestCheckFormatContentSHA256(t *testing.T) { defaultErr := errors.GetAPIError(errors.ErrContentSHA256Mismatch) diff --git a/api/auth/presign.go b/api/auth/presign.go index ea34ba1..bad3796 100644 --- a/api/auth/presign.go +++ b/api/auth/presign.go @@ -3,12 +3,17 @@ package auth import ( "fmt" "net/http" + "os" + "strconv" "strings" "time" v4 "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/auth/signer/v4" + v4a "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/auth/signer/v4asdk2" + credentialsv2 "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/private/protocol/rest" + "github.com/aws/smithy-go/logging" ) type RequestData struct { @@ -45,3 +50,39 @@ func PresignRequest(creds *credentials.Credentials, reqData RequestData, presign return req, nil } + +// PresignRequestV4a forms pre-signed request to access objects without aws credentials. +func PresignRequestV4a(credProvider credentialsv2.StaticCredentialsProvider, reqData RequestData, presignData PresignData) (*http.Request, error) { + urlStr := fmt.Sprintf("%s/%s/%s", reqData.Endpoint, rest.EscapePath(reqData.Bucket, false), rest.EscapePath(reqData.Object, false)) + req, err := http.NewRequest(strings.ToUpper(reqData.Method), urlStr, nil) + if err != nil { + return nil, fmt.Errorf("failed to create new request: %w", err) + } + + req.Header.Set(AmzDate, presignData.SignTime.Format("20060102T150405Z")) + req.Header.Set(ContentTypeHdr, "text/plain") + req.Header.Set(AmzExpires, strconv.Itoa(int(presignData.Lifetime.Seconds()))) + + signer := v4a.NewSigner(func(options *v4a.SignerOptions) { + options.DisableURIPathEscaping = true + options.LogSigning = true + options.Logger = logging.NewStandardLogger(os.Stdout) + }) + + credAdapter := v4a.SymmetricCredentialAdaptor{ + SymmetricProvider: credProvider, + } + + creds, err := credAdapter.RetrievePrivateKey(req.Context()) + if err != nil { + return nil, fmt.Errorf("failed to derive assymetric key from credentials: %w", err) + } + presignedURL, _, err := signer.PresignHTTP(req.Context(), creds, req, "", presignData.Service, []string{presignData.Region}, presignData.SignTime) + if err != nil { + return nil, fmt.Errorf("presign: %w", err) + } + + fmt.Println(presignedURL) + + return http.NewRequest(reqData.Method, presignedURL, nil) +} diff --git a/api/auth/presign_test.go b/api/auth/presign_test.go index 9e5d330..e5e18f6 100644 --- a/api/auth/presign_test.go +++ b/api/auth/presign_test.go @@ -2,17 +2,23 @@ package auth import ( "context" + "fmt" + "net/http" + "os" "strings" "testing" "time" + v4a "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/auth/signer/v4asdk2" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/creds/accessbox" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/creds/tokens" apistatus "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/client/status" cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object" oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id" + credentialsv2 "github.com/aws/aws-sdk-go-v2/credentials" "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/smithy-go/logging" "github.com/stretchr/testify/require" ) @@ -92,3 +98,93 @@ func TestCheckSign(t *testing.T) { require.NoError(t, err) require.EqualValues(t, expBox, box.AccessBox) } + +func TestCheckSignV4a(t *testing.T) { + var accessKeyAddr oid.Address + err := accessKeyAddr.DecodeString("8N7CYBY74kxZXoyvA5UNdmovaXqFpwNfvEPsqaN81es2/3tDwq5tR8fByrJcyJwyiuYX7Dae8tyDT7pd8oaL1MBto") + require.NoError(t, err) + + accessKeyID := strings.ReplaceAll(accessKeyAddr.String(), "/", "0") + secretKey := "713d0a0b9efc7d22923e17b0402a6a89b4273bc711c8bacb2da1b643d0006aeb" + awsCreds := credentialsv2.NewStaticCredentialsProvider(accessKeyID, secretKey, "") + + reqData := RequestData{ + Method: "GET", + Endpoint: "http://localhost:8084", + Bucket: "my-bucket", + Object: "@obj/name", + } + presignData := PresignData{ + Service: "s3", + Region: "spb", + Lifetime: 10 * time.Minute, + SignTime: time.Now().UTC(), + } + + req, err := PresignRequestV4a(awsCreds, reqData, presignData) + require.NoError(t, err) + + expBox := &accessbox.Box{ + Gate: &accessbox.GateData{ + SecretKey: secretKey, + }, + } + + mock := newTokensFrostfsMock() + mock.addBox(accessKeyAddr, expBox) + + c := &Center{ + cli: mock, + regV4a: NewRegexpMatcher(authorizationFieldV4aRegexp), + postReg: NewRegexpMatcher(postPolicyCredentialRegexp), + } + box, err := c.Authenticate(req) + require.NoError(t, err) + require.EqualValues(t, expBox, box.AccessBox) +} + +func TestPresignRequestV4a(t *testing.T) { + var accessKeyAddr oid.Address + err := accessKeyAddr.DecodeString("8N7CYBY74kxZXoyvA5UNdmovaXqFpwNfvEPsqaN81es2/3tDwq5tR8fByrJcyJwyiuYX7Dae8tyDT7pd8oaL1MBto") + require.NoError(t, err) + + accessKeyID := strings.ReplaceAll(accessKeyAddr.String(), "/", "0") + secretKey := "713d0a0b9efc7d22923e17b0402a6a89b4273bc711c8bacb2da1b643d0006aeb" + + signer := v4a.NewSigner(func(options *v4a.SignerOptions) { + options.DisableURIPathEscaping = true + options.LogSigning = true + options.Logger = logging.NewStandardLogger(os.Stdout) + }) + + credAdapter := v4a.SymmetricCredentialAdaptor{ + SymmetricProvider: credentialsv2.NewStaticCredentialsProvider(accessKeyID, secretKey, ""), + } + + creds, err := credAdapter.RetrievePrivateKey(context.TODO()) + require.NoError(t, err) + + signingTime := time.Now() + service := "s3" + regionSet := []string{"spb"} + + req, err := http.NewRequest("GET", "http://localhost:8084/bucket/object", nil) + require.NoError(t, err) + //req.Header.Set(AmzRegionSet, strings.Join(regionSet, ",")) + //req.Header.Set(AmzDate, signingTime.Format("20060102T150405Z")) + //req.Header.Set(AmzAlgorithm, signaturePreambleSigV4A) + + presignedURL, hdr, err := signer.PresignHTTP(req.Context(), creds, req, "", service, regionSet, signingTime) + require.NoError(t, err) + + fmt.Println(presignedURL) + fmt.Println(hdr) + + signature := req.URL.Query().Get(AmzSignature) + + r, err := http.NewRequest("GET", presignedURL, nil) + require.NoError(t, err) + + err = signer.VerifySignature(creds, r, "", service, regionSet, signingTime, signature) + require.NoError(t, err) +} diff --git a/api/auth/signer/v4asdk2/credentials.go b/api/auth/signer/v4asdk2/credentials.go new file mode 100644 index 0000000..152482d --- /dev/null +++ b/api/auth/signer/v4asdk2/credentials.go @@ -0,0 +1,140 @@ +package v4a + +import ( + "context" + "crypto/ecdsa" + "fmt" + "sync" + "sync/atomic" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" +) + +// Credentials is Context, ECDSA, and Optional Session Token that can be used +// to sign requests using SigV4a +type Credentials struct { + Context string + PrivateKey *ecdsa.PrivateKey + SessionToken string + + // Time the credentials will expire. + CanExpire bool + Expires time.Time +} + +// Expired returns if the credentials have expired. +func (v Credentials) Expired() bool { + if v.CanExpire { + return !v.Expires.After(time.Now()) + } + + return false +} + +// HasKeys returns if the credentials keys are set. +func (v Credentials) HasKeys() bool { + return len(v.Context) > 0 && v.PrivateKey != nil +} + +// SymmetricCredentialAdaptor wraps a SigV4 AccessKey/SecretKey provider and adapts the credentials +// to a ECDSA PrivateKey for signing with SiV4a +type SymmetricCredentialAdaptor struct { + SymmetricProvider aws.CredentialsProvider + + asymmetric atomic.Value + m sync.Mutex +} + +// Retrieve retrieves symmetric credentials from the underlying provider. +func (s *SymmetricCredentialAdaptor) Retrieve(ctx context.Context) (aws.Credentials, error) { + symCreds, err := s.retrieveFromSymmetricProvider(ctx) + if err != nil { + return aws.Credentials{}, err + } + + if asymCreds := s.getCreds(); asymCreds == nil { + return symCreds, nil + } + + s.m.Lock() + defer s.m.Unlock() + + asymCreds := s.getCreds() + if asymCreds == nil { + return symCreds, nil + } + + // if the context does not match the access key id clear it + if asymCreds.Context != symCreds.AccessKeyID { + s.asymmetric.Store((*Credentials)(nil)) + } + + return symCreds, nil +} + +// RetrievePrivateKey returns credentials suitable for SigV4a signing +func (s *SymmetricCredentialAdaptor) RetrievePrivateKey(ctx context.Context) (Credentials, error) { + if asymCreds := s.getCreds(); asymCreds != nil { + return *asymCreds, nil + } + + s.m.Lock() + defer s.m.Unlock() + + if asymCreds := s.getCreds(); asymCreds != nil { + return *asymCreds, nil + } + + symmetricCreds, err := s.retrieveFromSymmetricProvider(ctx) + if err != nil { + return Credentials{}, fmt.Errorf("failed to retrieve symmetric credentials: %v", err) + } + + privateKey, err := deriveKeyFromAccessKeyPair(symmetricCreds.AccessKeyID, symmetricCreds.SecretAccessKey) + if err != nil { + return Credentials{}, fmt.Errorf("failed to derive assymetric key from credentials") + } + + creds := Credentials{ + Context: symmetricCreds.AccessKeyID, + PrivateKey: privateKey, + SessionToken: symmetricCreds.SessionToken, + CanExpire: symmetricCreds.CanExpire, + Expires: symmetricCreds.Expires, + } + + s.asymmetric.Store(&creds) + + return creds, nil +} + +func (s *SymmetricCredentialAdaptor) getCreds() *Credentials { + v := s.asymmetric.Load() + + if v == nil { + return nil + } + + c := v.(*Credentials) + if c != nil && c.HasKeys() && !c.Expired() { + return c + } + + return nil +} + +func (s *SymmetricCredentialAdaptor) retrieveFromSymmetricProvider(ctx context.Context) (aws.Credentials, error) { + credentials, err := s.SymmetricProvider.Retrieve(ctx) + if err != nil { + return aws.Credentials{}, err + } + + return credentials, nil +} + +// CredentialsProvider is the interface for a provider to retrieve credentials +// to sign requests with. +type CredentialsProvider interface { + RetrievePrivateKey(context.Context) (Credentials, error) +} diff --git a/api/auth/signer/v4asdk2/credentials_test.go b/api/auth/signer/v4asdk2/credentials_test.go new file mode 100644 index 0000000..bc89ec3 --- /dev/null +++ b/api/auth/signer/v4asdk2/credentials_test.go @@ -0,0 +1,77 @@ +package v4a + +import ( + "context" + "fmt" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" +) + +type rotatingCredsProvider struct { + count int + fail chan struct{} +} + +func (r *rotatingCredsProvider) Retrieve(ctx context.Context) (aws.Credentials, error) { + select { + case <-r.fail: + return aws.Credentials{}, fmt.Errorf("rotatingCredsProvider error") + default: + } + credentials := aws.Credentials{ + AccessKeyID: fmt.Sprintf("ACCESS_KEY_ID_%d", r.count), + SecretAccessKey: fmt.Sprintf("SECRET_ACCESS_KEY_%d", r.count), + SessionToken: fmt.Sprintf("SESSION_TOKEN_%d", r.count), + } + return credentials, nil +} + +func TestSymmetricCredentialAdaptor(t *testing.T) { + provider := &rotatingCredsProvider{ + count: 0, + fail: make(chan struct{}), + } + + adaptor := &SymmetricCredentialAdaptor{SymmetricProvider: provider} + + if symCreds, err := adaptor.Retrieve(context.Background()); err != nil { + t.Fatalf("expect no error, got %v", err) + } else if !symCreds.HasKeys() { + t.Fatalf("expect symmetric credentials to have keys") + } + + if load := adaptor.asymmetric.Load(); load != nil { + t.Errorf("expect asymmetric credentials to be nil") + } + + if asymCreds, err := adaptor.RetrievePrivateKey(context.Background()); err != nil { + t.Fatalf("expect no error, got %v", err) + } else if !asymCreds.HasKeys() { + t.Fatalf("expect asymmetric credentials to have keys") + } + + if _, err := adaptor.Retrieve(context.Background()); err != nil { + t.Fatalf("expect no error, got %v", err) + } + + if load := adaptor.asymmetric.Load(); load.(*Credentials) == nil { + t.Errorf("expect asymmetric credentials to be not nil") + } + + provider.count++ + + if _, err := adaptor.Retrieve(context.Background()); err != nil { + t.Fatalf("expect no error, got %v", err) + } + + if load := adaptor.asymmetric.Load(); load.(*Credentials) != nil { + t.Errorf("expect asymmetric credentials to be nil") + } + + close(provider.fail) // All requests to the original provider will now fail from this point-on. + _, err := adaptor.Retrieve(context.Background()) + if err == nil { + t.Error("expect error, got nil") + } +} diff --git a/api/auth/signer/v4asdk2/error.go b/api/auth/signer/v4asdk2/error.go new file mode 100644 index 0000000..380d174 --- /dev/null +++ b/api/auth/signer/v4asdk2/error.go @@ -0,0 +1,17 @@ +package v4a + +import "fmt" + +// SigningError indicates an error condition occurred while performing SigV4a signing +type SigningError struct { + Err error +} + +func (e *SigningError) Error() string { + return fmt.Sprintf("failed to sign request: %v", e.Err) +} + +// Unwrap returns the underlying error cause +func (e *SigningError) Unwrap() error { + return e.Err +} diff --git a/api/auth/signer/v4asdk2/internal/crypto/compare.go b/api/auth/signer/v4asdk2/internal/crypto/compare.go new file mode 100644 index 0000000..1d0f25f --- /dev/null +++ b/api/auth/signer/v4asdk2/internal/crypto/compare.go @@ -0,0 +1,30 @@ +package crypto + +import "fmt" + +// ConstantTimeByteCompare is a constant-time byte comparison of x and y. This function performs an absolute comparison +// if the two byte slices assuming they represent a big-endian number. +// +// error if len(x) != len(y) +// -1 if x < y +// 0 if x == y +// +1 if x > y +func ConstantTimeByteCompare(x, y []byte) (int, error) { + if len(x) != len(y) { + return 0, fmt.Errorf("slice lengths do not match") + } + + xLarger, yLarger := 0, 0 + + for i := 0; i < len(x); i++ { + xByte, yByte := int(x[i]), int(y[i]) + + x := ((yByte - xByte) >> 8) & 1 + y := ((xByte - yByte) >> 8) & 1 + + xLarger |= x &^ yLarger + yLarger |= y &^ xLarger + } + + return xLarger - yLarger, nil +} diff --git a/api/auth/signer/v4asdk2/internal/crypto/compare_test.go b/api/auth/signer/v4asdk2/internal/crypto/compare_test.go new file mode 100644 index 0000000..2bbdfdb --- /dev/null +++ b/api/auth/signer/v4asdk2/internal/crypto/compare_test.go @@ -0,0 +1,60 @@ +package crypto + +import ( + "bytes" + "math/big" + "testing" +) + +func TestConstantTimeByteCompare(t *testing.T) { + cases := []struct { + x, y []byte + r int + expectErr bool + }{ + {x: []byte{}, y: []byte{}, r: 0}, + {x: []byte{40}, y: []byte{30}, r: 1}, + {x: []byte{30}, y: []byte{40}, r: -1}, + {x: []byte{60, 40, 30, 10, 20}, y: []byte{50, 30, 20, 0, 10}, r: 1}, + {x: []byte{50, 30, 20, 0, 10}, y: []byte{60, 40, 30, 10, 20}, r: -1}, + {x: nil, y: []byte{}, r: 0}, + {x: []byte{}, y: nil, r: 0}, + {x: []byte{}, y: []byte{10}, expectErr: true}, + {x: []byte{10}, y: []byte{}, expectErr: true}, + {x: []byte{10, 20}, y: []byte{10}, expectErr: true}, + } + + for _, tt := range cases { + compare, err := ConstantTimeByteCompare(tt.x, tt.y) + if (err != nil) != tt.expectErr { + t.Fatalf("expectErr=%v, got %v", tt.expectErr, err) + } + if e, a := tt.r, compare; e != a { + t.Errorf("expect %v, got %v", e, a) + } + } +} + +func BenchmarkConstantTimeCompare(b *testing.B) { + x, y := big.NewInt(1023), big.NewInt(1024) + b.ResetTimer() + for i := 0; i < b.N; i++ { + ConstantTimeByteCompare(x.Bytes(), y.Bytes()) + } +} + +func BenchmarkCompare(b *testing.B) { + x, y := big.NewInt(1023).Bytes(), big.NewInt(1024).Bytes() + b.ResetTimer() + for i := 0; i < b.N; i++ { + bytes.Compare(x, y) + } +} + +func mustBigInt(s string) *big.Int { + b, ok := (&big.Int{}).SetString(s, 16) + if !ok { + panic("can't parse as big.Int") + } + return b +} diff --git a/api/auth/signer/v4asdk2/internal/crypto/ecc.go b/api/auth/signer/v4asdk2/internal/crypto/ecc.go new file mode 100644 index 0000000..758c73f --- /dev/null +++ b/api/auth/signer/v4asdk2/internal/crypto/ecc.go @@ -0,0 +1,113 @@ +package crypto + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/hmac" + "encoding/asn1" + "encoding/binary" + "fmt" + "hash" + "math" + "math/big" +) + +type ecdsaSignature struct { + R, S *big.Int +} + +// ECDSAKey takes the given elliptic curve, and private key (d) byte slice +// and returns the private ECDSA key. +func ECDSAKey(curve elliptic.Curve, d []byte) *ecdsa.PrivateKey { + return ECDSAKeyFromPoint(curve, (&big.Int{}).SetBytes(d)) +} + +// ECDSAKeyFromPoint takes the given elliptic curve and point and returns the +// private and public keypair +func ECDSAKeyFromPoint(curve elliptic.Curve, d *big.Int) *ecdsa.PrivateKey { + pX, pY := curve.ScalarBaseMult(d.Bytes()) + + privKey := &ecdsa.PrivateKey{ + PublicKey: ecdsa.PublicKey{ + Curve: curve, + X: pX, + Y: pY, + }, + D: d, + } + + return privKey +} + +// ECDSAPublicKey takes the provide curve and (x, y) coordinates and returns +// *ecdsa.PublicKey. Returns an error if the given points are not on the curve. +func ECDSAPublicKey(curve elliptic.Curve, x, y []byte) (*ecdsa.PublicKey, error) { + xPoint := (&big.Int{}).SetBytes(x) + yPoint := (&big.Int{}).SetBytes(y) + + if !curve.IsOnCurve(xPoint, yPoint) { + return nil, fmt.Errorf("point(%v, %v) is not on the given curve", xPoint.String(), yPoint.String()) + } + + return &ecdsa.PublicKey{ + Curve: curve, + X: xPoint, + Y: yPoint, + }, nil +} + +// VerifySignature takes the provided public key, hash, and asn1 encoded signature and returns +// whether the given signature is valid. +func VerifySignature(key *ecdsa.PublicKey, hash []byte, signature []byte) (bool, error) { + var ecdsaSignature ecdsaSignature + + _, err := asn1.Unmarshal(signature, &ecdsaSignature) + if err != nil { + return false, err + } + + return ecdsa.Verify(key, hash, ecdsaSignature.R, ecdsaSignature.S), nil +} + +// HMACKeyDerivation provides an implementation of a NIST-800-108 of a KDF (Key Derivation Function) in Counter Mode. +// For the purposes of this implantation HMAC is used as the PRF (Pseudorandom function), where the value of +// `r` is defined as a 4 byte counter. +func HMACKeyDerivation(hash func() hash.Hash, bitLen int, key []byte, label, context []byte) ([]byte, error) { + // verify that we won't overflow the counter + n := int64(math.Ceil((float64(bitLen) / 8) / float64(hash().Size()))) + if n > 0x7FFFFFFF { + return nil, fmt.Errorf("unable to derive key of size %d using 32-bit counter", bitLen) + } + + // verify the requested bit length is not larger then the length encoding size + if int64(bitLen) > 0x7FFFFFFF { + return nil, fmt.Errorf("bitLen is greater than 32-bits") + } + + fixedInput := bytes.NewBuffer(nil) + fixedInput.Write(label) + fixedInput.WriteByte(0x00) + fixedInput.Write(context) + if err := binary.Write(fixedInput, binary.BigEndian, int32(bitLen)); err != nil { + return nil, fmt.Errorf("failed to write bit length to fixed input string: %v", err) + } + + var output []byte + + h := hmac.New(hash, key) + + for i := int64(1); i <= n; i++ { + h.Reset() + if err := binary.Write(h, binary.BigEndian, int32(i)); err != nil { + return nil, err + } + _, err := h.Write(fixedInput.Bytes()) + if err != nil { + return nil, err + } + output = append(output, h.Sum(nil)...) + } + + return output[:bitLen/8], nil +} diff --git a/api/auth/signer/v4asdk2/internal/crypto/ecc_test.go b/api/auth/signer/v4asdk2/internal/crypto/ecc_test.go new file mode 100644 index 0000000..72a5e8d --- /dev/null +++ b/api/auth/signer/v4asdk2/internal/crypto/ecc_test.go @@ -0,0 +1,277 @@ +package crypto + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "io" + "testing" +) + +func TestECDSAPublicKeyDerivation_P256(t *testing.T) { + d := []byte{ + 0xc9, 0x80, 0x68, 0x98, 0xa0, 0x33, 0x49, 0x16, 0xc8, 0x60, 0x74, 0x88, 0x80, 0xa5, 0x41, 0xf0, + 0x93, 0xb5, 0x79, 0xa9, 0xb1, 0xf3, 0x29, 0x34, 0xd8, 0x6c, 0x36, 0x3c, 0x39, 0x80, 0x03, 0x57, + } + + x := []byte{ + 0xd0, 0x72, 0x0d, 0xc6, 0x91, 0xaa, 0x80, 0x09, 0x6b, 0xa3, 0x2f, 0xed, 0x1c, 0xb9, 0x7c, 0x2b, + 0x62, 0x06, 0x90, 0xd0, 0x6d, 0xe0, 0x31, 0x7b, 0x86, 0x18, 0xd5, 0xce, 0x65, 0xeb, 0x72, 0x8f, + } + + y := []byte{ + 0x96, 0x81, 0xb5, 0x17, 0xb1, 0xcd, 0xa1, 0x7d, 0x0d, 0x83, 0xd3, 0x35, 0xd9, 0xc4, 0xa8, 0xa9, + 0xa9, 0xb0, 0xb1, 0xb3, 0xc7, 0x10, 0x6d, 0x8f, 0x3c, 0x72, 0xbc, 0x50, 0x93, 0xdc, 0x27, 0x5f, + } + + testKeyDerivation(t, elliptic.P256(), d, x, y) +} + +func TestECDSAPublicKeyDerivation_P384(t *testing.T) { + d := []byte{ + 0x53, 0x94, 0xf7, 0x97, 0x3e, 0xa8, 0x68, 0xc5, 0x2b, 0xf3, 0xff, 0x8d, 0x8c, 0xee, 0xb4, 0xdb, + 0x90, 0xa6, 0x83, 0x65, 0x3b, 0x12, 0x48, 0x5d, 0x5f, 0x62, 0x7c, 0x3c, 0xe5, 0xab, 0xd8, 0x97, + 0x8f, 0xc9, 0x67, 0x3d, 0x14, 0xa7, 0x1d, 0x92, 0x57, 0x47, 0x93, 0x16, 0x62, 0x49, 0x3c, 0x37, + } + + x := []byte{ + 0xfd, 0x3c, 0x84, 0xe5, 0x68, 0x9b, 0xed, 0x27, 0x0e, 0x60, 0x1b, 0x3d, 0x80, 0xf9, 0x0d, 0x67, + 0xa9, 0xae, 0x45, 0x1c, 0xce, 0x89, 0x0f, 0x53, 0xe5, 0x83, 0x22, 0x9a, 0xd0, 0xe2, 0xee, 0x64, + 0x56, 0x11, 0xfa, 0x99, 0x36, 0xdf, 0xa4, 0x53, 0x06, 0xec, 0x18, 0x06, 0x67, 0x74, 0xaa, 0x24, + } + + y := []byte{ + 0xb8, 0x3c, 0xa4, 0x12, 0x6c, 0xfc, 0x4c, 0x4d, 0x1d, 0x18, 0xa4, 0xb6, 0xc2, 0x1c, 0x7f, 0x69, + 0x9d, 0x51, 0x23, 0xdd, 0x9c, 0x24, 0xf6, 0x6f, 0x83, 0x38, 0x46, 0xee, 0xb5, 0x82, 0x96, 0x19, + 0x6b, 0x42, 0xec, 0x06, 0x42, 0x5d, 0xb5, 0xb7, 0x0a, 0x4b, 0x81, 0xb7, 0xfc, 0xf7, 0x05, 0xa0, + } + + testKeyDerivation(t, elliptic.P384(), d, x, y) +} + +func TestECDSAKnownSigningValue_P256(t *testing.T) { + d := []byte{ + 0x51, 0x9b, 0x42, 0x3d, 0x71, 0x5f, 0x8b, 0x58, 0x1f, 0x4f, 0xa8, 0xee, 0x59, 0xf4, 0x77, 0x1a, + 0x5b, 0x44, 0xc8, 0x13, 0x0b, 0x4e, 0x3e, 0xac, 0xca, 0x54, 0xa5, 0x6d, 0xda, 0x72, 0xb4, 0x64, + } + + testKnownSigningValue(t, elliptic.P256(), d) +} + +func TestECDSAKnownSigningValue_P384(t *testing.T) { + d := []byte{ + 0x53, 0x94, 0xf7, 0x97, 0x3e, 0xa8, 0x68, 0xc5, 0x2b, 0xf3, 0xff, 0x8d, 0x8c, 0xee, 0xb4, 0xdb, + 0x90, 0xa6, 0x83, 0x65, 0x3b, 0x12, 0x48, 0x5d, 0x5f, 0x62, 0x7c, 0x3c, 0xe5, 0xab, 0xd8, 0x97, + 0x8f, 0xc9, 0x67, 0x3d, 0x14, 0xa7, 0x1d, 0x92, 0x57, 0x47, 0x93, 0x16, 0x62, 0x49, 0x3c, 0x37, + } + + testKnownSigningValue(t, elliptic.P384(), d) +} + +func testKeyDerivation(t *testing.T, curve elliptic.Curve, d, expectedX, expectedY []byte) { + privKey := ECDSAKey(curve, d) + + if e, a := d, privKey.D.Bytes(); bytes.Compare(e, a) != 0 { + t.Errorf("expected % x, got % x", e, a) + } + + if e, a := expectedX, privKey.X.Bytes(); bytes.Compare(e, a) != 0 { + t.Errorf("expected % x, got % x", e, a) + } + + if e, a := expectedY, privKey.Y.Bytes(); bytes.Compare(e, a) != 0 { + t.Errorf("expected % x, got % x", e, a) + } +} + +func testKnownSigningValue(t *testing.T, curve elliptic.Curve, d []byte) { + signingKey := ECDSAKey(curve, d) + + message := []byte{ + 0x59, 0x05, 0x23, 0x88, 0x77, 0xc7, 0x74, 0x21, 0xf7, 0x3e, 0x43, 0xee, 0x3d, 0xa6, 0xf2, 0xd9, + 0xe2, 0xcc, 0xad, 0x5f, 0xc9, 0x42, 0xdc, 0xec, 0x0c, 0xbd, 0x25, 0x48, 0x29, 0x35, 0xfa, 0xaf, + 0x41, 0x69, 0x83, 0xfe, 0x16, 0x5b, 0x1a, 0x04, 0x5e, 0xe2, 0xbc, 0xd2, 0xe6, 0xdc, 0xa3, 0xbd, + 0xf4, 0x6c, 0x43, 0x10, 0xa7, 0x46, 0x1f, 0x9a, 0x37, 0x96, 0x0c, 0xa6, 0x72, 0xd3, 0xfe, 0xb5, + 0x47, 0x3e, 0x25, 0x36, 0x05, 0xfb, 0x1d, 0xdf, 0xd2, 0x80, 0x65, 0xb5, 0x3c, 0xb5, 0x85, 0x8a, + 0x8a, 0xd2, 0x81, 0x75, 0xbf, 0x9b, 0xd3, 0x86, 0xa5, 0xe4, 0x71, 0xea, 0x7a, 0x65, 0xc1, 0x7c, + 0xc9, 0x34, 0xa9, 0xd7, 0x91, 0xe9, 0x14, 0x91, 0xeb, 0x37, 0x54, 0xd0, 0x37, 0x99, 0x79, 0x0f, + 0xe2, 0xd3, 0x08, 0xd1, 0x61, 0x46, 0xd5, 0xc9, 0xb0, 0xd0, 0xde, 0xbd, 0x97, 0xd7, 0x9c, 0xe8, + } + + sha256Hash := sha256.New() + _, err := io.Copy(sha256Hash, bytes.NewReader(message)) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + msgHash := sha256Hash.Sum(nil) + msgSignature, err := signingKey.Sign(rand.Reader, msgHash, crypto.SHA256) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + verified, err := VerifySignature(&signingKey.PublicKey, msgHash, msgSignature) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if !verified { + t.Fatalf("failed to verify message msgSignature") + } +} + +func TestECDSAInvalidSignature_P256(t *testing.T) { + testInvalidSignature(t, elliptic.P256()) +} + +func TestECDSAInvalidSignature_P384(t *testing.T) { + testInvalidSignature(t, elliptic.P384()) +} + +func TestECDSAGenKeySignature_P256(t *testing.T) { + testGenKeySignature(t, elliptic.P256()) +} + +func TestECDSAGenKeySignature_P384(t *testing.T) { + testGenKeySignature(t, elliptic.P384()) +} + +func testInvalidSignature(t *testing.T, curve elliptic.Curve) { + privateKey, err := ecdsa.GenerateKey(curve, rand.Reader) + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + + message := []byte{ + 0x59, 0x05, 0x23, 0x88, 0x77, 0xc7, 0x74, 0x21, 0xf7, 0x3e, 0x43, 0xee, 0x3d, 0xa6, 0xf2, 0xd9, + 0xe2, 0xcc, 0xad, 0x5f, 0xc9, 0x42, 0xdc, 0xec, 0x0c, 0xbd, 0x25, 0x48, 0x29, 0x35, 0xfa, 0xaf, + 0x41, 0x69, 0x83, 0xfe, 0x16, 0x5b, 0x1a, 0x04, 0x5e, 0xe2, 0xbc, 0xd2, 0xe6, 0xdc, 0xa3, 0xbd, + 0xf4, 0x6c, 0x43, 0x10, 0xa7, 0x46, 0x1f, 0x9a, 0x37, 0x96, 0x0c, 0xa6, 0x72, 0xd3, 0xfe, 0xb5, + 0x47, 0x3e, 0x25, 0x36, 0x05, 0xfb, 0x1d, 0xdf, 0xd2, 0x80, 0x65, 0xb5, 0x3c, 0xb5, 0x85, 0x8a, + 0x8a, 0xd2, 0x81, 0x75, 0xbf, 0x9b, 0xd3, 0x86, 0xa5, 0xe4, 0x71, 0xea, 0x7a, 0x65, 0xc1, 0x7c, + 0xc9, 0x34, 0xa9, 0xd7, 0x91, 0xe9, 0x14, 0x91, 0xeb, 0x37, 0x54, 0xd0, 0x37, 0x99, 0x79, 0x0f, + 0xe2, 0xd3, 0x08, 0xd1, 0x61, 0x46, 0xd5, 0xc9, 0xb0, 0xd0, 0xde, 0xbd, 0x97, 0xd7, 0x9c, 0xe8, + } + + sha256Hash := sha256.New() + _, err = io.Copy(sha256Hash, bytes.NewReader(message)) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + msgHash := sha256Hash.Sum(nil) + msgSignature, err := privateKey.Sign(rand.Reader, msgHash, crypto.SHA256) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + byteToFlip := 15 + switch msgSignature[byteToFlip] { + case 0: + msgSignature[byteToFlip] = 0x0a + default: + msgSignature[byteToFlip] &^= msgSignature[byteToFlip] + } + + verified, err := VerifySignature(&privateKey.PublicKey, msgHash, msgSignature) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if verified { + t.Fatalf("expected message verification to fail") + } +} + +func testGenKeySignature(t *testing.T, curve elliptic.Curve) { + privateKey, err := ecdsa.GenerateKey(curve, rand.Reader) + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + + message := []byte{ + 0x59, 0x05, 0x23, 0x88, 0x77, 0xc7, 0x74, 0x21, 0xf7, 0x3e, 0x43, 0xee, 0x3d, 0xa6, 0xf2, 0xd9, + 0xe2, 0xcc, 0xad, 0x5f, 0xc9, 0x42, 0xdc, 0xec, 0x0c, 0xbd, 0x25, 0x48, 0x29, 0x35, 0xfa, 0xaf, + 0x41, 0x69, 0x83, 0xfe, 0x16, 0x5b, 0x1a, 0x04, 0x5e, 0xe2, 0xbc, 0xd2, 0xe6, 0xdc, 0xa3, 0xbd, + 0xf4, 0x6c, 0x43, 0x10, 0xa7, 0x46, 0x1f, 0x9a, 0x37, 0x96, 0x0c, 0xa6, 0x72, 0xd3, 0xfe, 0xb5, + 0x47, 0x3e, 0x25, 0x36, 0x05, 0xfb, 0x1d, 0xdf, 0xd2, 0x80, 0x65, 0xb5, 0x3c, 0xb5, 0x85, 0x8a, + 0x8a, 0xd2, 0x81, 0x75, 0xbf, 0x9b, 0xd3, 0x86, 0xa5, 0xe4, 0x71, 0xea, 0x7a, 0x65, 0xc1, 0x7c, + 0xc9, 0x34, 0xa9, 0xd7, 0x91, 0xe9, 0x14, 0x91, 0xeb, 0x37, 0x54, 0xd0, 0x37, 0x99, 0x79, 0x0f, + 0xe2, 0xd3, 0x08, 0xd1, 0x61, 0x46, 0xd5, 0xc9, 0xb0, 0xd0, 0xde, 0xbd, 0x97, 0xd7, 0x9c, 0xe8, + } + + sha256Hash := sha256.New() + _, err = io.Copy(sha256Hash, bytes.NewReader(message)) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + msgHash := sha256Hash.Sum(nil) + msgSignature, err := privateKey.Sign(rand.Reader, msgHash, crypto.SHA256) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + verified, err := VerifySignature(&privateKey.PublicKey, msgHash, msgSignature) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if !verified { + t.Fatalf("expected message verification to fail") + } +} + +func TestECDSASignatureFormat(t *testing.T) { + asn1Signature := []byte{ + 0x30, 0x45, 0x02, 0x21, 0x00, 0xd7, 0xc5, 0xb9, 0x9e, 0x0b, 0xb1, 0x1a, 0x1f, 0x32, 0xda, 0x66, 0xe0, 0xff, + 0x59, 0xb7, 0x8a, 0x5e, 0xb3, 0x94, 0x9c, 0x23, 0xb3, 0xfc, 0x1f, 0x18, 0xcc, 0xf6, 0x61, 0x67, 0x8b, 0xf1, + 0xc1, 0x02, 0x20, 0x26, 0x4d, 0x8b, 0x7c, 0xaa, 0x52, 0x4c, 0xc0, 0x2e, 0x5f, 0xf6, 0x7e, 0x24, 0x82, 0xe5, + 0xfb, 0xcb, 0xc7, 0x9b, 0x83, 0x0d, 0x19, 0x7e, 0x7a, 0x40, 0x37, 0x87, 0xdd, 0x1c, 0x93, 0x13, 0xc4, + } + + x := []byte{ + 0x1c, 0xcb, 0xe9, 0x1c, 0x07, 0x5f, 0xc7, 0xf4, 0xf0, 0x33, 0xbf, 0xa2, 0x48, 0xdb, 0x8f, 0xcc, + 0xd3, 0x56, 0x5d, 0xe9, 0x4b, 0xbf, 0xb1, 0x2f, 0x3c, 0x59, 0xff, 0x46, 0xc2, 0x71, 0xbf, 0x83, + } + + y := []byte{ + 0xce, 0x40, 0x14, 0xc6, 0x88, 0x11, 0xf9, 0xa2, 0x1a, 0x1f, 0xdb, 0x2c, 0x0e, 0x61, 0x13, 0xe0, + 0x6d, 0xb7, 0xca, 0x93, 0xb7, 0x40, 0x4e, 0x78, 0xdc, 0x7c, 0xcd, 0x5c, 0xa8, 0x9a, 0x4c, 0xa9, + } + + publicKey, err := ECDSAPublicKey(elliptic.P256(), x, y) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + message := []byte{ + 0x59, 0x05, 0x23, 0x88, 0x77, 0xc7, 0x74, 0x21, 0xf7, 0x3e, 0x43, 0xee, 0x3d, 0xa6, 0xf2, 0xd9, + 0xe2, 0xcc, 0xad, 0x5f, 0xc9, 0x42, 0xdc, 0xec, 0x0c, 0xbd, 0x25, 0x48, 0x29, 0x35, 0xfa, 0xaf, + 0x41, 0x69, 0x83, 0xfe, 0x16, 0x5b, 0x1a, 0x04, 0x5e, 0xe2, 0xbc, 0xd2, 0xe6, 0xdc, 0xa3, 0xbd, + 0xf4, 0x6c, 0x43, 0x10, 0xa7, 0x46, 0x1f, 0x9a, 0x37, 0x96, 0x0c, 0xa6, 0x72, 0xd3, 0xfe, 0xb5, + 0x47, 0x3e, 0x25, 0x36, 0x05, 0xfb, 0x1d, 0xdf, 0xd2, 0x80, 0x65, 0xb5, 0x3c, 0xb5, 0x85, 0x8a, + 0x8a, 0xd2, 0x81, 0x75, 0xbf, 0x9b, 0xd3, 0x86, 0xa5, 0xe4, 0x71, 0xea, 0x7a, 0x65, 0xc1, 0x7c, + 0xc9, 0x34, 0xa9, 0xd7, 0x91, 0xe9, 0x14, 0x91, 0xeb, 0x37, 0x54, 0xd0, 0x37, 0x99, 0x79, 0x0f, + 0xe2, 0xd3, 0x08, 0xd1, 0x61, 0x46, 0xd5, 0xc9, 0xb0, 0xd0, 0xde, 0xbd, 0x97, 0xd7, 0x9c, 0xe8, + } + + hash := sha256.New() + _, err = io.Copy(hash, bytes.NewReader(message)) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + msgHash := hash.Sum(nil) + + verifySignature, err := VerifySignature(publicKey, msgHash, asn1Signature) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if !verifySignature { + t.Fatalf("failed to verify signature") + } +} diff --git a/api/auth/signer/v4asdk2/internal/v4/const.go b/api/auth/signer/v4asdk2/internal/v4/const.go new file mode 100644 index 0000000..89a76e2 --- /dev/null +++ b/api/auth/signer/v4asdk2/internal/v4/const.go @@ -0,0 +1,36 @@ +package v4 + +const ( + // EmptyStringSHA256 is the hex encoded sha256 value of an empty string + EmptyStringSHA256 = `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` + + // UnsignedPayload indicates that the request payload body is unsigned + UnsignedPayload = "UNSIGNED-PAYLOAD" + + // AmzAlgorithmKey indicates the signing algorithm + AmzAlgorithmKey = "X-Amz-Algorithm" + + // AmzSecurityTokenKey indicates the security token to be used with temporary credentials + AmzSecurityTokenKey = "X-Amz-Security-Token" + + // AmzDateKey is the UTC timestamp for the request in the format YYYYMMDD'T'HHMMSS'Z' + AmzDateKey = "X-Amz-Date" + + // AmzCredentialKey is the access key ID and credential scope + AmzCredentialKey = "X-Amz-Credential" + + // AmzSignedHeadersKey is the set of headers signed for the request + AmzSignedHeadersKey = "X-Amz-SignedHeaders" + + // AmzSignatureKey is the query parameter to store the SigV4 signature + AmzSignatureKey = "X-Amz-Signature" + + // TimeFormat is the time format to be used in the X-Amz-Date header or query parameter + TimeFormat = "20060102T150405Z" + + // ShortTimeFormat is the shorten time format used in the credential scope + ShortTimeFormat = "20060102" + + // ContentSHAKey is the SHA256 of request body + ContentSHAKey = "X-Amz-Content-Sha256" +) diff --git a/api/auth/signer/v4asdk2/internal/v4/header_rules.go b/api/auth/signer/v4asdk2/internal/v4/header_rules.go new file mode 100644 index 0000000..7a9d415 --- /dev/null +++ b/api/auth/signer/v4asdk2/internal/v4/header_rules.go @@ -0,0 +1,88 @@ +package v4 + +import ( + "strings" +) + +// Rules houses a set of Rule needed for validation of a +// string value +type Rules []Rule + +// Rule interface allows for more flexible rules and just simply +// checks whether or not a value adheres to that Rule +type Rule interface { + IsValid(value string) bool +} + +// IsValid will iterate through all rules and see if any rules +// apply to the value and supports nested rules +func (r Rules) IsValid(value string) bool { + for _, rule := range r { + if rule.IsValid(value) { + return true + } + } + return false +} + +// MapRule generic Rule for maps +type MapRule map[string]struct{} + +// IsValid for the map Rule satisfies whether it exists in the map +func (m MapRule) IsValid(value string) bool { + _, ok := m[value] + return ok +} + +// AllowList is a generic Rule for whitelisting +type AllowList struct { + Rule +} + +// IsValid for AllowList checks if the value is within the AllowList +func (w AllowList) IsValid(value string) bool { + return w.Rule.IsValid(value) +} + +// DenyList is a generic Rule for blacklisting +type DenyList struct { + Rule +} + +// IsValid for AllowList checks if the value is within the AllowList +func (b DenyList) IsValid(value string) bool { + return !b.Rule.IsValid(value) +} + +// Patterns is a list of strings to match against +type Patterns []string + +// IsValid for Patterns checks each pattern and returns if a match has +// been found +func (p Patterns) IsValid(value string) bool { + for _, pattern := range p { + if HasPrefixFold(value, pattern) { + return true + } + } + return false +} + +// InclusiveRules rules allow for rules to depend on one another +type InclusiveRules []Rule + +// IsValid will return true if all rules are true +func (r InclusiveRules) IsValid(value string) bool { + for _, rule := range r { + if !rule.IsValid(value) { + return false + } + } + return true +} + +// HasPrefixFold tests whether the string s begins with prefix, interpreted as UTF-8 strings, +// under Unicode case-folding. +func HasPrefixFold(s, prefix string) bool { + return len(s) >= len(prefix) && strings.EqualFold(s[0:len(prefix)], prefix) +} diff --git a/api/auth/signer/v4asdk2/internal/v4/headers.go b/api/auth/signer/v4asdk2/internal/v4/headers.go new file mode 100644 index 0000000..f6fc7f6 --- /dev/null +++ b/api/auth/signer/v4asdk2/internal/v4/headers.go @@ -0,0 +1,79 @@ +package v4 + +// IgnoredPresignedHeaders is a list of headers that are ignored during signing +var IgnoredPresignedHeaders = Rules{ + DenyList{ + MapRule{ + "Authorization": struct{}{}, + "User-Agent": struct{}{}, + "X-Amzn-Trace-Id": struct{}{}, + }, + }, +} + +// IgnoredHeaders is a list of headers that are ignored during signing +// drop User-Agent header to be compatible with aws sdk java v1. +var IgnoredHeaders = Rules{ + DenyList{ + MapRule{ + "Authorization": struct{}{}, + //"User-Agent": struct{}{}, + "X-Amzn-Trace-Id": struct{}{}, + }, + }, +} + +// RequiredSignedHeaders is a whitelist for Build canonical headers. +var RequiredSignedHeaders = Rules{ + AllowList{ + MapRule{ + "Cache-Control": struct{}{}, + "Content-Disposition": struct{}{}, + "Content-Encoding": struct{}{}, + "Content-Language": struct{}{}, + "Content-Md5": struct{}{}, + "Content-Type": struct{}{}, + "Expires": struct{}{}, + "If-Match": struct{}{}, + "If-Modified-Since": struct{}{}, + "If-None-Match": struct{}{}, + "If-Unmodified-Since": struct{}{}, + "Range": struct{}{}, + "X-Amz-Acl": struct{}{}, + "X-Amz-Copy-Source": struct{}{}, + "X-Amz-Copy-Source-If-Match": struct{}{}, + "X-Amz-Copy-Source-If-Modified-Since": struct{}{}, + "X-Amz-Copy-Source-If-None-Match": struct{}{}, + "X-Amz-Copy-Source-If-Unmodified-Since": struct{}{}, + "X-Amz-Copy-Source-Range": struct{}{}, + "X-Amz-Copy-Source-Server-Side-Encryption-Customer-Algorithm": struct{}{}, + "X-Amz-Copy-Source-Server-Side-Encryption-Customer-Key": struct{}{}, + "X-Amz-Copy-Source-Server-Side-Encryption-Customer-Key-Md5": struct{}{}, + "X-Amz-Grant-Full-control": struct{}{}, + "X-Amz-Grant-Read": struct{}{}, + "X-Amz-Grant-Read-Acp": struct{}{}, + "X-Amz-Grant-Write": struct{}{}, + "X-Amz-Grant-Write-Acp": struct{}{}, + "X-Amz-Metadata-Directive": struct{}{}, + "X-Amz-Mfa": struct{}{}, + "X-Amz-Request-Payer": struct{}{}, + "X-Amz-Server-Side-Encryption": struct{}{}, + "X-Amz-Server-Side-Encryption-Aws-Kms-Key-Id": struct{}{}, + "X-Amz-Server-Side-Encryption-Customer-Algorithm": struct{}{}, + "X-Amz-Server-Side-Encryption-Customer-Key": struct{}{}, + "X-Amz-Server-Side-Encryption-Customer-Key-Md5": struct{}{}, + "X-Amz-Storage-Class": struct{}{}, + "X-Amz-Website-Redirect-Location": struct{}{}, + "X-Amz-Content-Sha256": struct{}{}, + "X-Amz-Tagging": struct{}{}, + }, + }, + Patterns{"X-Amz-Meta-"}, +} + +// AllowedQueryHoisting is a whitelist for Build query headers. The boolean value +// represents whether or not it is a pattern. +var AllowedQueryHoisting = InclusiveRules{ + DenyList{RequiredSignedHeaders}, + Patterns{"X-Amz-"}, +} diff --git a/api/auth/signer/v4asdk2/internal/v4/hmac.go b/api/auth/signer/v4asdk2/internal/v4/hmac.go new file mode 100644 index 0000000..e7fa7a1 --- /dev/null +++ b/api/auth/signer/v4asdk2/internal/v4/hmac.go @@ -0,0 +1,13 @@ +package v4 + +import ( + "crypto/hmac" + "crypto/sha256" +) + +// HMACSHA256 computes a HMAC-SHA256 of data given the provided key. +func HMACSHA256(key []byte, data []byte) []byte { + hash := hmac.New(sha256.New, key) + hash.Write(data) + return hash.Sum(nil) +} diff --git a/api/auth/signer/v4asdk2/internal/v4/host.go b/api/auth/signer/v4asdk2/internal/v4/host.go new file mode 100644 index 0000000..bf93659 --- /dev/null +++ b/api/auth/signer/v4asdk2/internal/v4/host.go @@ -0,0 +1,75 @@ +package v4 + +import ( + "net/http" + "strings" +) + +// SanitizeHostForHeader removes default port from host and updates request.Host +func SanitizeHostForHeader(r *http.Request) { + host := getHost(r) + port := portOnly(host) + if port != "" && isDefaultPort(r.URL.Scheme, port) { + r.Host = stripPort(host) + } +} + +// Returns host from request +func getHost(r *http.Request) string { + if r.Host != "" { + return r.Host + } + + return r.URL.Host +} + +// Hostname returns u.Host, without any port number. +// +// If Host is an IPv6 literal with a port number, Hostname returns the +// IPv6 literal without the square brackets. IPv6 literals may include +// a zone identifier. +// +// Copied from the Go 1.8 standard library (net/url) +func stripPort(hostport string) string { + colon := strings.IndexByte(hostport, ':') + if colon == -1 { + return hostport + } + if i := strings.IndexByte(hostport, ']'); i != -1 { + return strings.TrimPrefix(hostport[:i], "[") + } + return hostport[:colon] +} + +// Port returns the port part of u.Host, without the leading colon. +// If u.Host doesn't contain a port, Port returns an empty string. +// +// Copied from the Go 1.8 standard library (net/url) +func portOnly(hostport string) string { + colon := strings.IndexByte(hostport, ':') + if colon == -1 { + return "" + } + if i := strings.Index(hostport, "]:"); i != -1 { + return hostport[i+len("]:"):] + } + if strings.Contains(hostport, "]") { + return "" + } + return hostport[colon+len(":"):] +} + +// Returns true if the specified URI is using the standard port +// (i.e. port 80 for HTTP URIs or 443 for HTTPS URIs) +func isDefaultPort(scheme, port string) bool { + if port == "" { + return true + } + + lowerCaseScheme := strings.ToLower(scheme) + if (lowerCaseScheme == "http" && port == "80") || (lowerCaseScheme == "https" && port == "443") { + return true + } + + return false +} diff --git a/api/auth/signer/v4asdk2/internal/v4/time.go b/api/auth/signer/v4asdk2/internal/v4/time.go new file mode 100644 index 0000000..1de06a7 --- /dev/null +++ b/api/auth/signer/v4asdk2/internal/v4/time.go @@ -0,0 +1,36 @@ +package v4 + +import "time" + +// SigningTime provides a wrapper around a time.Time which provides cached values for SigV4 signing. +type SigningTime struct { + time.Time + timeFormat string + shortTimeFormat string +} + +// NewSigningTime creates a new SigningTime given a time.Time +func NewSigningTime(t time.Time) SigningTime { + return SigningTime{ + Time: t, + } +} + +// TimeFormat provides a time formatted in the X-Amz-Date format. +func (m *SigningTime) TimeFormat() string { + return m.format(&m.timeFormat, TimeFormat) +} + +// ShortTimeFormat provides a time formatted of 20060102. +func (m *SigningTime) ShortTimeFormat() string { + return m.format(&m.shortTimeFormat, ShortTimeFormat) +} + +func (m *SigningTime) format(target *string, format string) string { + if len(*target) > 0 { + return *target + } + v := m.Time.Format(format) + *target = v + return v +} diff --git a/api/auth/signer/v4asdk2/internal/v4/util.go b/api/auth/signer/v4asdk2/internal/v4/util.go new file mode 100644 index 0000000..741019b --- /dev/null +++ b/api/auth/signer/v4asdk2/internal/v4/util.go @@ -0,0 +1,64 @@ +package v4 + +import ( + "net/url" + "strings" +) + +const doubleSpace = " " + +// StripExcessSpaces will rewrite the passed in slice's string values to not +// contain muliple side-by-side spaces. +func StripExcessSpaces(str string) string { + var j, k, l, m, spaces int + // Trim trailing spaces + for j = len(str) - 1; j >= 0 && str[j] == ' '; j-- { + } + + // Trim leading spaces + for k = 0; k < j && str[k] == ' '; k++ { + } + str = str[k : j+1] + + // Strip multiple spaces. + j = strings.Index(str, doubleSpace) + if j < 0 { + return str + } + + buf := []byte(str) + for k, m, l = j, j, len(buf); k < l; k++ { + if buf[k] == ' ' { + if spaces == 0 { + // First space. + buf[m] = buf[k] + m++ + } + spaces++ + } else { + // End of multiple spaces. + spaces = 0 + buf[m] = buf[k] + m++ + } + } + + return string(buf[:m]) +} + +// GetURIPath returns the escaped URI component from the provided URL +func GetURIPath(u *url.URL) string { + var uri string + + if len(u.Opaque) > 0 { + uri = "/" + strings.Join(strings.Split(u.Opaque, "/")[3:], "/") + } else { + uri = u.EscapedPath() + } + + if len(uri) == 0 { + uri = "/" + } + + return uri +} diff --git a/api/auth/signer/v4asdk2/internal/v4/util_test.go b/api/auth/signer/v4asdk2/internal/v4/util_test.go new file mode 100644 index 0000000..c29c1fa --- /dev/null +++ b/api/auth/signer/v4asdk2/internal/v4/util_test.go @@ -0,0 +1,75 @@ +package v4 + +import ( + "testing" +) + +func TestStripExcessHeaders(t *testing.T) { + vals := []string{ + "", + "123", + "1 2 3", + "1 2 3 ", + " 1 2 3", + "1 2 3", + "1 23", + "1 2 3", + "1 2 ", + " 1 2 ", + "12 3", + "12 3 1", + "12 3 1", + "12 3 1abc123", + } + + expected := []string{ + "", + "123", + "1 2 3", + "1 2 3", + "1 2 3", + "1 2 3", + "1 23", + "1 2 3", + "1 2", + "1 2", + "12 3", + "12 3 1", + "12 3 1", + "12 3 1abc123", + } + + for i := 0; i < len(vals); i++ { + r := StripExcessSpaces(vals[i]) + if e, a := expected[i], r; e != a { + t.Errorf("%d, expect %v, got %v", i, e, a) + } + } +} + +var stripExcessSpaceCases = []string{ + `AWS4-HMAC-SHA256 Credential=AKIDFAKEIDFAKEID/20160628/us-west-2/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=1234567890abcdef1234567890abcdef1234567890abcdef`, + `123 321 123 321`, + ` 123 321 123 321 `, + ` 123 321 123 321 `, + "123", + "1 2 3", + " 1 2 3", + "1 2 3", + "1 23", + "1 2 3", + "1 2 ", + " 1 2 ", + "12 3", + "12 3 1", + "12 3 1", + "12 3 1abc123", +} + +func BenchmarkStripExcessSpaces(b *testing.B) { + for i := 0; i < b.N; i++ { + for _, v := range stripExcessSpaceCases { + StripExcessSpaces(v) + } + } +} diff --git a/api/auth/signer/v4asdk2/middleware.go b/api/auth/signer/v4asdk2/middleware.go new file mode 100644 index 0000000..e1b6612 --- /dev/null +++ b/api/auth/signer/v4asdk2/middleware.go @@ -0,0 +1,118 @@ +package v4a + +import ( + "context" + "fmt" + "net/http" + "time" + + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + "github.com/aws/smithy-go/middleware" + smithyhttp "github.com/aws/smithy-go/transport/http" +) + +// HTTPSigner is SigV4a HTTP signer implementation +type HTTPSigner interface { + SignHTTP(ctx context.Context, credentials Credentials, r *http.Request, payloadHash string, service string, regionSet []string, signingTime time.Time, optfns ...func(*SignerOptions)) error +} + +// SignHTTPRequestMiddlewareOptions is the middleware options for constructing a SignHTTPRequestMiddleware. +type SignHTTPRequestMiddlewareOptions struct { + Credentials CredentialsProvider + Signer HTTPSigner + LogSigning bool +} + +// SignHTTPRequestMiddleware is a middleware for signing an HTTP request using SigV4a. +type SignHTTPRequestMiddleware struct { + credentials CredentialsProvider + signer HTTPSigner + logSigning bool +} + +// NewSignHTTPRequestMiddleware constructs a SignHTTPRequestMiddleware using the given SignHTTPRequestMiddlewareOptions. +func NewSignHTTPRequestMiddleware(options SignHTTPRequestMiddlewareOptions) *SignHTTPRequestMiddleware { + return &SignHTTPRequestMiddleware{ + credentials: options.Credentials, + signer: options.Signer, + logSigning: options.LogSigning, + } +} + +// ID the middleware identifier. +func (s *SignHTTPRequestMiddleware) ID() string { + return "Signing" +} + +// HandleFinalize signs an HTTP request using SigV4a. +func (s *SignHTTPRequestMiddleware) HandleFinalize( + ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler, +) ( + out middleware.FinalizeOutput, metadata middleware.Metadata, err error, +) { + if !hasCredentialProvider(s.credentials) { + return next.HandleFinalize(ctx, in) + } + + req, ok := in.Request.(*smithyhttp.Request) + if !ok { + return out, metadata, fmt.Errorf("unexpected request middleware type %T", in.Request) + } + + signingName, signingRegion := awsmiddleware.GetSigningName(ctx), awsmiddleware.GetSigningRegion(ctx) + payloadHash := v4.GetPayloadHash(ctx) + if len(payloadHash) == 0 { + return out, metadata, &SigningError{Err: fmt.Errorf("computed payload hash missing from context")} + } + + credentials, err := s.credentials.RetrievePrivateKey(ctx) + if err != nil { + return out, metadata, &SigningError{Err: fmt.Errorf("failed to retrieve credentials: %w", err)} + } + + signerOptions := []func(o *SignerOptions){ + func(o *SignerOptions) { + o.Logger = middleware.GetLogger(ctx) + o.LogSigning = s.logSigning + }, + } + + // existing DisableURIPathEscaping is equivalent in purpose + // to authentication scheme property DisableDoubleEncoding + //disableDoubleEncoding, overridden := internalauth.GetDisableDoubleEncoding(ctx) // internalauth "github.com/aws/aws-sdk-go-v2/internal/auth" + //if overridden { + // signerOptions = append(signerOptions, func(o *SignerOptions) { + // o.DisableURIPathEscaping = disableDoubleEncoding + // }) + //} + + err = s.signer.SignHTTP(ctx, credentials, req.Request, payloadHash, signingName, []string{signingRegion}, time.Now().UTC(), signerOptions...) + if err != nil { + return out, metadata, &SigningError{Err: fmt.Errorf("failed to sign http request, %w", err)} + } + + return next.HandleFinalize(ctx, in) +} + +func hasCredentialProvider(p CredentialsProvider) bool { + if p == nil { + return false + } + + return true +} + +// RegisterSigningMiddleware registers the SigV4a signing middleware to the stack. If a signing middleware is already +// present, this provided middleware will be swapped. Otherwise the middleware will be added at the tail of the +// finalize step. +func RegisterSigningMiddleware(stack *middleware.Stack, signingMiddleware *SignHTTPRequestMiddleware) (err error) { + const signedID = "Signing" + _, present := stack.Finalize.Get(signedID) + if present { + _, err = stack.Finalize.Swap(signedID, signingMiddleware) + } else { + err = stack.Finalize.Add(signingMiddleware, middleware.After) + } + return err +} diff --git a/api/auth/signer/v4asdk2/middleware_test.go b/api/auth/signer/v4asdk2/middleware_test.go new file mode 100644 index 0000000..135a5c5 --- /dev/null +++ b/api/auth/signer/v4asdk2/middleware_test.go @@ -0,0 +1,149 @@ +package v4a + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "strings" + "testing" + "time" + + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + "github.com/aws/smithy-go/logging" + "github.com/aws/smithy-go/middleware" + smithyhttp "github.com/aws/smithy-go/transport/http" +) + +type stubCredentialsProviderFunc func(context.Context) (Credentials, error) + +func (f stubCredentialsProviderFunc) RetrievePrivateKey(ctx context.Context) (Credentials, error) { + return f(ctx) +} + +type httpSignerFunc func(ctx context.Context, credentials Credentials, r *http.Request, payloadHash string, service string, regionSet []string, signingTime time.Time, optFns ...func(*SignerOptions)) error + +func (f httpSignerFunc) SignHTTP(ctx context.Context, credentials Credentials, r *http.Request, payloadHash string, service string, regionSet []string, signingTime time.Time, optFns ...func(*SignerOptions)) error { + return f(ctx, credentials, r, payloadHash, service, regionSet, signingTime, optFns...) +} + +func TestSignHTTPRequestMiddleware(t *testing.T) { + cases := map[string]struct { + creds CredentialsProvider + hash string + logSigning bool + expectedErr interface{} + }{ + "success": { + creds: stubCredentials, + hash: "0123456789abcdef", + }, + "error": { + creds: stubCredentialsProviderFunc(func(ctx context.Context) (Credentials, error) { + return Credentials{}, fmt.Errorf("credential error") + }), + hash: "", + expectedErr: &SigningError{}, + }, + "nil creds": { + creds: nil, + }, + "with log signing": { + creds: stubCredentials, + hash: "0123456789abcdef", + logSigning: true, + }, + } + + const ( + signingName = "serviceId" + signingRegion = "regionName" + ) + + for name, tt := range cases { + t.Run(name, func(t *testing.T) { + c := &SignHTTPRequestMiddleware{ + credentials: tt.creds, + signer: httpSignerFunc( + func(ctx context.Context, + credentials Credentials, r *http.Request, payloadHash string, + service string, regionSet []string, signingTime time.Time, + optFns ...func(*SignerOptions), + ) error { + var options SignerOptions + for _, fn := range optFns { + fn(&options) + } + if options.Logger == nil { + t.Errorf("expect logger, got none") + } + if options.LogSigning { + options.Logger.Logf(logging.Debug, t.Name()) + } + + expectCreds, _ := tt.creds.RetrievePrivateKey(ctx) + if diff := cmpDiff(expectCreds, credentials); len(diff) > 0 { + t.Error(diff) + } + if e, a := tt.hash, payloadHash; e != a { + t.Errorf("expected %v, got %v", e, a) + } + if e, a := signingName, service; e != a { + t.Errorf("expected %v, got %v", e, a) + } + if diff := cmpDiff([]string{signingRegion}, regionSet); len(diff) > 0 { + t.Error(diff) + } + return nil + }), + logSigning: tt.logSigning, + } + + next := middleware.FinalizeHandlerFunc(func(ctx context.Context, in middleware.FinalizeInput) (out middleware.FinalizeOutput, metadata middleware.Metadata, err error) { + return out, metadata, err + }) + + ctx := awsmiddleware.SetSigningRegion( + awsmiddleware.SetSigningName(context.Background(), signingName), + signingRegion) + + var loggerBuf bytes.Buffer + logger := logging.NewStandardLogger(&loggerBuf) + ctx = middleware.SetLogger(ctx, logger) + + if len(tt.hash) != 0 { + ctx = v4.SetPayloadHash(ctx, tt.hash) + } + + _, _, err := c.HandleFinalize(ctx, middleware.FinalizeInput{ + Request: &smithyhttp.Request{Request: &http.Request{}}, + }, next) + if err != nil && tt.expectedErr == nil { + t.Errorf("expected no error, got %v", err) + } else if err != nil && tt.expectedErr != nil { + e, a := tt.expectedErr, err + if !errors.As(a, &e) { + t.Errorf("expected error type %T, got %T", e, a) + } + } else if err == nil && tt.expectedErr != nil { + t.Errorf("expected error, got nil") + } + + if tt.logSigning { + if e, a := t.Name(), loggerBuf.String(); !strings.Contains(a, e) { + t.Errorf("expect %v logged in %v", e, a) + } + } else { + if loggerBuf.Len() != 0 { + t.Errorf("expect no log, got %v", loggerBuf.String()) + } + } + }) + } +} + +var ( + _ middleware.FinalizeMiddleware = &SignHTTPRequestMiddleware{} +) diff --git a/api/auth/signer/v4asdk2/presign_middleware.go b/api/auth/signer/v4asdk2/presign_middleware.go new file mode 100644 index 0000000..ecb0f9e --- /dev/null +++ b/api/auth/signer/v4asdk2/presign_middleware.go @@ -0,0 +1,116 @@ +package v4a + +import ( + "context" + "fmt" + "net/http" + "time" + + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + "github.com/aws/smithy-go/middleware" + smithyHTTP "github.com/aws/smithy-go/transport/http" +) + +// HTTPPresigner is an interface to a SigV4a signer that can sign create a +// presigned URL for a HTTP requests. +type HTTPPresigner interface { + PresignHTTP( + ctx context.Context, credentials Credentials, r *http.Request, + payloadHash string, service string, regionSet []string, signingTime time.Time, + optFns ...func(*SignerOptions), + ) (url string, signedHeader http.Header, err error) +} + +// PresignHTTPRequestMiddlewareOptions is the options for the PresignHTTPRequestMiddleware middleware. +type PresignHTTPRequestMiddlewareOptions struct { + CredentialsProvider CredentialsProvider + Presigner HTTPPresigner + LogSigning bool +} + +// PresignHTTPRequestMiddleware provides the Finalize middleware for creating a +// presigned URL for an HTTP request. +// +// Will short circuit the middleware stack and not forward onto the next +// Finalize handler. +type PresignHTTPRequestMiddleware struct { + credentialsProvider CredentialsProvider + presigner HTTPPresigner + logSigning bool +} + +// NewPresignHTTPRequestMiddleware returns a new PresignHTTPRequestMiddleware +// initialized with the presigner. +func NewPresignHTTPRequestMiddleware(options PresignHTTPRequestMiddlewareOptions) *PresignHTTPRequestMiddleware { + return &PresignHTTPRequestMiddleware{ + credentialsProvider: options.CredentialsProvider, + presigner: options.Presigner, + logSigning: options.LogSigning, + } +} + +// ID provides the middleware ID. +func (*PresignHTTPRequestMiddleware) ID() string { return "PresignHTTPRequest" } + +// HandleFinalize will take the provided input and create a presigned url for +// the http request using the SigV4 presign authentication scheme. +func (s *PresignHTTPRequestMiddleware) HandleFinalize( + ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler, +) ( + out middleware.FinalizeOutput, metadata middleware.Metadata, err error, +) { + req, ok := in.Request.(*smithyHTTP.Request) + if !ok { + return out, metadata, &SigningError{ + Err: fmt.Errorf("unexpected request middleware type %T", in.Request), + } + } + + httpReq := req.Build(ctx) + if !hasCredentialProvider(s.credentialsProvider) { + out.Result = &v4.PresignedHTTPRequest{ + URL: httpReq.URL.String(), + Method: httpReq.Method, + SignedHeader: http.Header{}, + } + + return out, metadata, nil + } + + signingName := awsmiddleware.GetSigningName(ctx) + signingRegion := awsmiddleware.GetSigningRegion(ctx) + payloadHash := v4.GetPayloadHash(ctx) + if len(payloadHash) == 0 { + return out, metadata, &SigningError{ + Err: fmt.Errorf("computed payload hash missing from context"), + } + } + + credentials, err := s.credentialsProvider.RetrievePrivateKey(ctx) + if err != nil { + return out, metadata, &SigningError{ + Err: fmt.Errorf("failed to retrieve credentials: %w", err), + } + } + + u, h, err := s.presigner.PresignHTTP(ctx, credentials, + httpReq, payloadHash, signingName, []string{signingRegion}, time.Now(), + func(o *SignerOptions) { + o.Logger = middleware.GetLogger(ctx) + o.LogSigning = s.logSigning + }) + if err != nil { + return out, metadata, &SigningError{ + Err: fmt.Errorf("failed to sign http request, %w", err), + } + } + + out.Result = &v4.PresignedHTTPRequest{ + URL: u, + Method: httpReq.Method, + SignedHeader: h, + } + + return out, metadata, nil +} diff --git a/api/auth/signer/v4asdk2/presign_middleware_test.go b/api/auth/signer/v4asdk2/presign_middleware_test.go new file mode 100644 index 0000000..ec83a81 --- /dev/null +++ b/api/auth/signer/v4asdk2/presign_middleware_test.go @@ -0,0 +1,222 @@ +package v4a + +import ( + "bytes" + "context" + "net/http" + "net/url" + "strings" + "testing" + "time" + + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + "github.com/aws/smithy-go/logging" + "github.com/aws/smithy-go/middleware" + smithyhttp "github.com/aws/smithy-go/transport/http" +) + +type httpPresignerFunc func( + ctx context.Context, credentials Credentials, r *http.Request, + payloadHash string, service string, regionSet []string, signingTime time.Time, + optFns ...func(*SignerOptions), +) (url string, signedHeader http.Header, err error) + +func (f httpPresignerFunc) PresignHTTP( + ctx context.Context, credentials Credentials, r *http.Request, + payloadHash string, service string, regionSet []string, signingTime time.Time, + optFns ...func(*SignerOptions), +) ( + url string, signedHeader http.Header, err error, +) { + return f(ctx, credentials, r, payloadHash, service, regionSet, signingTime, optFns...) +} + +func TestPresignHTTPRequestMiddleware(t *testing.T) { + cases := map[string]struct { + Request *http.Request + Creds CredentialsProvider + PayloadHash string + LogSigning bool + ExpectResult *v4.PresignedHTTPRequest + ExpectErr string + }{ + "success": { + Request: &http.Request{ + URL: func() *url.URL { + u, _ := url.Parse("https://example.aws/path?query=foo") + return u + }(), + Header: http.Header{}, + }, + Creds: stubCredentials, + PayloadHash: "0123456789abcdef", + ExpectResult: &v4.PresignedHTTPRequest{ + URL: "https://example.aws/path?query=foo", + SignedHeader: http.Header{}, + }, + }, + "error": { + Request: func() *http.Request { + return &http.Request{} + }(), + Creds: stubCredentials, + PayloadHash: "", + ExpectErr: "failed to sign request", + }, + "anonymous creds": { + Request: &http.Request{ + URL: func() *url.URL { + u, _ := url.Parse("https://example.aws/path?query=foo") + return u + }(), + Header: http.Header{}, + }, + Creds: stubCredentials, + PayloadHash: "", + ExpectErr: "failed to sign request", + ExpectResult: &v4.PresignedHTTPRequest{ + URL: "https://example.aws/path?query=foo", + SignedHeader: http.Header{}, + }, + }, + "nil creds": { + Request: &http.Request{ + URL: func() *url.URL { + u, _ := url.Parse("https://example.aws/path?query=foo") + return u + }(), + Header: http.Header{}, + }, + Creds: nil, + ExpectResult: &v4.PresignedHTTPRequest{ + URL: "https://example.aws/path?query=foo", + SignedHeader: http.Header{}, + }, + }, + "with log signing": { + Request: &http.Request{ + URL: func() *url.URL { + u, _ := url.Parse("https://example.aws/path?query=foo") + return u + }(), + Header: http.Header{}, + }, + Creds: stubCredentials, + PayloadHash: "0123456789abcdef", + ExpectResult: &v4.PresignedHTTPRequest{ + URL: "https://example.aws/path?query=foo", + SignedHeader: http.Header{}, + }, + + LogSigning: true, + }, + } + + const ( + signingName = "serviceId" + signingRegion = "regionName" + ) + + for name, tt := range cases { + t.Run(name, func(t *testing.T) { + m := &PresignHTTPRequestMiddleware{ + credentialsProvider: tt.Creds, + + presigner: httpPresignerFunc(func( + ctx context.Context, credentials Credentials, r *http.Request, + payloadHash string, service string, regionSet []string, signingTime time.Time, + optFns ...func(*SignerOptions), + ) (url string, signedHeader http.Header, err error) { + var options SignerOptions + for _, fn := range optFns { + fn(&options) + } + if options.Logger == nil { + t.Errorf("expect logger, got none") + } + if options.LogSigning { + options.Logger.Logf(logging.Debug, t.Name()) + } + + if !hasCredentialProvider(tt.Creds) { + t.Errorf("expect presigner not to be called for not credentials provider") + } + + expectCreds, _ := tt.Creds.RetrievePrivateKey(context.Background()) + if diff := cmpDiff(expectCreds, credentials); len(diff) > 0 { + t.Error(diff) + } + if e, a := tt.PayloadHash, payloadHash; e != a { + t.Errorf("expected %v, got %v", e, a) + } + if e, a := signingName, service; e != a { + t.Errorf("expected %v, got %v", e, a) + } + if diff := cmpDiff([]string{signingRegion}, regionSet); len(diff) > 0 { + t.Error(diff) + } + + return tt.ExpectResult.URL, tt.ExpectResult.SignedHeader, nil + }), + logSigning: tt.LogSigning, + } + + next := middleware.FinalizeHandlerFunc( + func(ctx context.Context, in middleware.FinalizeInput) ( + out middleware.FinalizeOutput, metadata middleware.Metadata, err error, + ) { + t.Errorf("expect next handler not to be called") + return out, metadata, err + }) + + ctx := awsmiddleware.SetSigningRegion( + awsmiddleware.SetSigningName(context.Background(), signingName), + signingRegion) + + var loggerBuf bytes.Buffer + logger := logging.NewStandardLogger(&loggerBuf) + ctx = middleware.SetLogger(ctx, logger) + + if len(tt.PayloadHash) != 0 { + ctx = v4.SetPayloadHash(ctx, tt.PayloadHash) + } + + result, _, err := m.HandleFinalize(ctx, middleware.FinalizeInput{ + Request: &smithyhttp.Request{ + Request: tt.Request, + }, + }, next) + if len(tt.ExpectErr) != 0 { + if err == nil { + t.Fatalf("expect error, got none") + } + if e, a := tt.ExpectErr, err.Error(); !strings.Contains(a, e) { + t.Fatalf("expect error to contain %v, got %v", e, a) + } + return + } + if err != nil { + t.Fatalf("expect no error, got %v", err) + } + + if diff := cmpDiff(tt.ExpectResult, result.Result); len(diff) != 0 { + t.Errorf("expect result match\n%v", diff) + } + + if tt.LogSigning { + if e, a := t.Name(), loggerBuf.String(); !strings.Contains(a, e) { + t.Errorf("expect %v logged in %v", e, a) + } + } else { + if loggerBuf.Len() != 0 { + t.Errorf("expect no log, got %v", loggerBuf.String()) + } + } + }) + } +} + +var ( + _ middleware.FinalizeMiddleware = &PresignHTTPRequestMiddleware{} +) diff --git a/api/auth/signer/v4asdk2/shared_test.go b/api/auth/signer/v4asdk2/shared_test.go new file mode 100644 index 0000000..6c94400 --- /dev/null +++ b/api/auth/signer/v4asdk2/shared_test.go @@ -0,0 +1,18 @@ +package v4a + +import ( + "bytes" + "context" + "crypto/ecdsa" +) + +var stubCredentials = stubCredentialsProviderFunc(func(ctx context.Context) (Credentials, error) { + stubKey, err := ecdsa.GenerateKey(p256, bytes.NewReader(bytes.Repeat([]byte{1}, 40))) + if err != nil { + return Credentials{}, err + } + return Credentials{ + Context: "STUB", + PrivateKey: stubKey, + }, nil +}) diff --git a/api/auth/signer/v4asdk2/smithy.go b/api/auth/signer/v4asdk2/smithy.go new file mode 100644 index 0000000..4ee4ca4 --- /dev/null +++ b/api/auth/signer/v4asdk2/smithy.go @@ -0,0 +1,85 @@ +package v4a + +import ( + "context" + "fmt" + "time" + + v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + "github.com/aws/smithy-go" + "github.com/aws/smithy-go/auth" + "github.com/aws/smithy-go/logging" + smithyhttp "github.com/aws/smithy-go/transport/http" +) + +// CredentialsAdapter adapts v4a.Credentials to smithy auth.Identity. +type CredentialsAdapter struct { + Credentials Credentials +} + +var _ auth.Identity = (*CredentialsAdapter)(nil) + +// Expiration returns the time of expiration for the credentials. +func (v *CredentialsAdapter) Expiration() time.Time { + return v.Credentials.Expires +} + +// CredentialsProviderAdapter adapts v4a.CredentialsProvider to +// auth.IdentityResolver. +type CredentialsProviderAdapter struct { + Provider CredentialsProvider +} + +var _ (auth.IdentityResolver) = (*CredentialsProviderAdapter)(nil) + +// GetIdentity retrieves v4a credentials using the underlying provider. +func (v *CredentialsProviderAdapter) GetIdentity(ctx context.Context, _ smithy.Properties) ( + auth.Identity, error, +) { + creds, err := v.Provider.RetrievePrivateKey(ctx) + if err != nil { + return nil, fmt.Errorf("get credentials: %w", err) + } + + return &CredentialsAdapter{Credentials: creds}, nil +} + +// SignerAdapter adapts v4a.HTTPSigner to smithy http.Signer. +type SignerAdapter struct { + Signer HTTPSigner + Logger logging.Logger + LogSigning bool +} + +var _ (smithyhttp.Signer) = (*SignerAdapter)(nil) + +// SignRequest signs the request with the provided identity. +func (v *SignerAdapter) SignRequest(ctx context.Context, r *smithyhttp.Request, identity auth.Identity, props smithy.Properties) error { + ca, ok := identity.(*CredentialsAdapter) + if !ok { + return fmt.Errorf("unexpected identity type: %T", identity) + } + + name, ok := smithyhttp.GetSigV4SigningName(&props) + if !ok { + return fmt.Errorf("sigv4a signing name is required") + } + + regions, ok := smithyhttp.GetSigV4ASigningRegions(&props) + if !ok { + return fmt.Errorf("sigv4a signing region is required") + } + + hash := v4.GetPayloadHash(ctx) + err := v.Signer.SignHTTP(ctx, ca.Credentials, r.Request, hash, name, regions, time.Now(), func(o *SignerOptions) { + o.DisableURIPathEscaping, _ = smithyhttp.GetDisableDoubleEncoding(&props) + + o.Logger = v.Logger + o.LogSigning = v.LogSigning + }) + if err != nil { + return fmt.Errorf("sign http: %w", err) + } + + return nil +} diff --git a/api/auth/signer/v4asdk2/stream.go b/api/auth/signer/v4asdk2/stream.go new file mode 100644 index 0000000..b168c6c --- /dev/null +++ b/api/auth/signer/v4asdk2/stream.go @@ -0,0 +1,96 @@ +package v4a + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "time" + + signerCrypto "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/auth/signer/v4asdk2/internal/crypto" + v4Internal "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/auth/signer/v4asdk2/internal/v4" +) + +// EventStreamSigner is an AWS EventStream protocol signer. +type EventStreamSigner interface { + GetSignature(ctx context.Context, headers, payload []byte, signingTime time.Time, optFns ...func(*StreamSignerOptions)) ([]byte, error) +} + +// StreamSignerOptions is the configuration options for StreamSigner. +type StreamSignerOptions struct{} + +// StreamSigner implements Signature Version 4 (SigV4) signing of event stream encoded payloads. +type StreamSigner struct { + options StreamSignerOptions + + credentials Credentials + service string + + prevSignature []byte +} + +// NewStreamSigner returns a new AWS EventStream protocol signer. +func NewStreamSigner(credentials Credentials, service string, seedSignature []byte, optFns ...func(*StreamSignerOptions)) *StreamSigner { + o := StreamSignerOptions{} + + for _, fn := range optFns { + fn(&o) + } + + return &StreamSigner{ + options: o, + credentials: credentials, + service: service, + prevSignature: seedSignature, + } +} + +func (s *StreamSigner) VerifySignature(headers, payload []byte, signingTime time.Time, signature []byte, optFns ...func(*StreamSignerOptions)) error { + options := s.options + + for _, fn := range optFns { + fn(&options) + } + + prevSignature := s.prevSignature + + st := v4Internal.NewSigningTime(signingTime) + + scope := buildCredentialScope(st, s.service) + + stringToSign := s.buildEventStreamStringToSign(headers, payload, prevSignature, scope, &st) + + ok, err := signerCrypto.VerifySignature(&s.credentials.PrivateKey.PublicKey, makeHash(sha256.New(), []byte(stringToSign)), signature) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("v4a: invalid signature") + } + + s.prevSignature = signature + + return nil +} + +func (s *StreamSigner) buildEventStreamStringToSign(headers, payload, previousSignature []byte, credentialScope string, signingTime *v4Internal.SigningTime) string { + hash := sha256.New() + return strings.Join([]string{ + "AWS4-ECDSA-P256-SHA256-PAYLOAD", + signingTime.TimeFormat(), + credentialScope, + hex.EncodeToString(previousSignature), + hex.EncodeToString(makeHash(hash, headers)), + hex.EncodeToString(makeHash(hash, payload)), + }, "\n") +} + +func buildCredentialScope(st v4Internal.SigningTime, service string) string { + return strings.Join([]string{ + st.Format(shortTimeFormat), + service, + "aws4_request", + }, "/") + +} diff --git a/api/auth/signer/v4asdk2/v4a.go b/api/auth/signer/v4asdk2/v4a.go new file mode 100644 index 0000000..305fbd1 --- /dev/null +++ b/api/auth/signer/v4asdk2/v4a.go @@ -0,0 +1,577 @@ +package v4a + +import ( + "bytes" + "context" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "hash" + "math/big" + "net/http" + "net/textproto" + "net/url" + "sort" + "strconv" + "strings" + "time" + + signerCrypto "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/auth/signer/v4asdk2/internal/crypto" + v4Internal "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/auth/signer/v4asdk2/internal/v4" + "github.com/aws/smithy-go/encoding/httpbinding" + "github.com/aws/smithy-go/logging" +) + +const ( + // AmzRegionSetKey represents the region set header used for sigv4a + AmzRegionSetKey = "X-Amz-Region-Set" + amzAlgorithmKey = v4Internal.AmzAlgorithmKey + amzSecurityTokenKey = v4Internal.AmzSecurityTokenKey + amzDateKey = v4Internal.AmzDateKey + amzCredentialKey = v4Internal.AmzCredentialKey + amzSignedHeadersKey = v4Internal.AmzSignedHeadersKey + authorizationHeader = "Authorization" + + signingAlgorithm = "AWS4-ECDSA-P256-SHA256" + + timeFormat = "20060102T150405Z" + shortTimeFormat = "20060102" + + // EmptyStringSHA256 is a hex encoded SHA-256 hash of an empty string + EmptyStringSHA256 = v4Internal.EmptyStringSHA256 + + // Version of signing v4a + Version = "SigV4A" +) + +var ( + p256 elliptic.Curve + nMinusTwoP256 *big.Int + + one = new(big.Int).SetInt64(1) +) + +func init() { + // Ensure the elliptic curve parameters are initialized on package import rather then on first usage + p256 = elliptic.P256() + + nMinusTwoP256 = new(big.Int).SetBytes(p256.Params().N.Bytes()) + nMinusTwoP256 = nMinusTwoP256.Sub(nMinusTwoP256, new(big.Int).SetInt64(2)) +} + +// SignerOptions is the SigV4a signing options for constructing a Signer. +type SignerOptions struct { + Logger logging.Logger + LogSigning bool + + // Disables the Signer's moving HTTP header key/value pairs from the HTTP + // request header to the request's query string. This is most commonly used + // with pre-signed requests preventing headers from being added to the + // request's query string. + DisableHeaderHoisting bool + + // Disables the automatic escaping of the URI path of the request for the + // siganture's canonical string's path. For services that do not need additional + // escaping then use this to disable the signer escaping the path. + // + // S3 is an example of a service that does not need additional escaping. + // + // http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html + DisableURIPathEscaping bool +} + +// Signer is a SigV4a HTTP signing implementation +type Signer struct { + options SignerOptions +} + +// NewSigner constructs a SigV4a Signer. +func NewSigner(optFns ...func(*SignerOptions)) *Signer { + options := SignerOptions{} + + for _, fn := range optFns { + fn(&options) + } + + return &Signer{options: options} +} + +// deriveKeyFromAccessKeyPair derives a NIST P-256 PrivateKey from the given +// IAM AccessKey and SecretKey pair. +// +// Based on FIPS.186-4 Appendix B.4.2 +func deriveKeyFromAccessKeyPair(accessKey, secretKey string) (*ecdsa.PrivateKey, error) { + params := p256.Params() + bitLen := params.BitSize // Testing random candidates does not require an additional 64 bits + counter := 0x01 + + buffer := make([]byte, 1+len(accessKey)) // 1 byte counter + len(accessKey) + kdfContext := bytes.NewBuffer(buffer) + + inputKey := append([]byte("AWS4A"), []byte(secretKey)...) + + d := new(big.Int) + for { + kdfContext.Reset() + kdfContext.WriteString(accessKey) + kdfContext.WriteByte(byte(counter)) + + key, err := signerCrypto.HMACKeyDerivation(sha256.New, bitLen, inputKey, []byte(signingAlgorithm), kdfContext.Bytes()) + if err != nil { + return nil, err + } + + // Check key first before calling SetBytes if key key is in fact a valid candidate. + // This ensures the byte slice is the correct length (32-bytes) to compare in constant-time + cmp, err := signerCrypto.ConstantTimeByteCompare(key, nMinusTwoP256.Bytes()) + if err != nil { + return nil, err + } + if cmp == -1 { + d.SetBytes(key) + break + } + + counter++ + if counter > 0xFF { + return nil, fmt.Errorf("exhausted single byte external counter") + } + } + d = d.Add(d, one) + + priv := new(ecdsa.PrivateKey) + priv.PublicKey.Curve = p256 + priv.D = d + priv.PublicKey.X, priv.PublicKey.Y = p256.ScalarBaseMult(d.Bytes()) + + return priv, nil +} + +type httpSigner struct { + Request *http.Request + ServiceName string + RegionSet []string + Time time.Time + Credentials Credentials + IsPreSign bool + + Logger logging.Logger + Debug bool + + // PayloadHash is the hex encoded SHA-256 hash of the request payload + // If len(PayloadHash) == 0 the signer will attempt to send the request + // as an unsigned payload. Note: Unsigned payloads only work for a subset of services. + PayloadHash string + + DisableHeaderHoisting bool + DisableURIPathEscaping bool +} + +// SignHTTP takes the provided http.Request, payload hash, service, regionSet, and time and signs using SigV4a. +// The passed in request will be modified in place. +func (s *Signer) SignHTTP(ctx context.Context, credentials Credentials, r *http.Request, payloadHash string, service string, regionSet []string, signingTime time.Time, optFns ...func(*SignerOptions)) error { + options := s.options + for _, fn := range optFns { + fn(&options) + } + + signer := &httpSigner{ + Request: r, + PayloadHash: payloadHash, + ServiceName: service, + RegionSet: regionSet, + Credentials: credentials, + Time: signingTime.UTC(), + DisableHeaderHoisting: options.DisableHeaderHoisting, + DisableURIPathEscaping: options.DisableURIPathEscaping, + } + + signedRequest, err := signer.Build() + if err != nil { + return err + } + + logHTTPSigningInfo(ctx, options, signedRequest) + + return nil +} + +// VerifySignature checks sigv4a. +func (s *Signer) VerifySignature(credentials Credentials, r *http.Request, payloadHash string, service string, regionSet []string, signingTime time.Time, signature string, optFns ...func(*SignerOptions)) error { + options := s.options + for _, fn := range optFns { + fn(&options) + } + + signer := &httpSigner{ + Request: r, + PayloadHash: payloadHash, + ServiceName: service, + RegionSet: regionSet, + Credentials: credentials, + Time: signingTime.UTC(), + DisableHeaderHoisting: options.DisableHeaderHoisting, + DisableURIPathEscaping: options.DisableURIPathEscaping, + } + + signedReq, err := signer.Build() + if err != nil { + return err + } + + logHTTPSigningInfo(context.TODO(), options, signedReq) + + signatureRaw, err := hex.DecodeString(signature) + if err != nil { + return fmt.Errorf("decode hex signature: %w", err) + } + + ok, err := signerCrypto.VerifySignature(&credentials.PrivateKey.PublicKey, makeHash(sha256.New(), []byte(signedReq.StringToSign)), signatureRaw) + if err != nil { + return err + } + + if !ok { + return fmt.Errorf("v4a: invalid signature") + } + + return nil +} + +// PresignHTTP takes the provided http.Request, payload hash, service, regionSet, and time and presigns using SigV4a +// Returns the presigned URL along with the headers that were signed with the request. +// +// PresignHTTP will not set the expires time of the presigned request +// automatically. To specify the expire duration for a request add the +// "X-Amz-Expires" query parameter on the request with the value as the +// duration in seconds the presigned URL should be considered valid for. This +// parameter is not used by all AWS services, and is most notable used by +// Amazon S3 APIs. +func (s *Signer) PresignHTTP(ctx context.Context, credentials Credentials, r *http.Request, payloadHash string, service string, regionSet []string, signingTime time.Time, optFns ...func(*SignerOptions)) (signedURI string, signedHeaders http.Header, err error) { + options := s.options + for _, fn := range optFns { + fn(&options) + } + + signer := &httpSigner{ + Request: r, + PayloadHash: payloadHash, + ServiceName: service, + RegionSet: regionSet, + Credentials: credentials, + Time: signingTime.UTC(), + IsPreSign: true, + DisableHeaderHoisting: options.DisableHeaderHoisting, + DisableURIPathEscaping: options.DisableURIPathEscaping, + } + + signedRequest, err := signer.Build() + if err != nil { + return "", nil, err + } + + logHTTPSigningInfo(ctx, options, signedRequest) + + signedHeaders = make(http.Header) + + // For the signed headers we canonicalize the header keys in the returned map. + // This avoids situations where can standard library double headers like host header. For example the standard + // library will set the Host header, even if it is present in lower-case form. + for k, v := range signedRequest.SignedHeaders { + key := textproto.CanonicalMIMEHeaderKey(k) + signedHeaders[key] = append(signedHeaders[key], v...) + } + + return signedRequest.Request.URL.String(), signedHeaders, nil +} + +func (s *httpSigner) setRequiredSigningFields(headers http.Header, query url.Values) { + amzDate := s.Time.Format(timeFormat) + + if s.IsPreSign { + query.Set(AmzRegionSetKey, strings.Join(s.RegionSet, ",")) + query.Set(amzDateKey, amzDate) + query.Set(amzAlgorithmKey, signingAlgorithm) + if len(s.Credentials.SessionToken) > 0 { + query.Set(amzSecurityTokenKey, s.Credentials.SessionToken) + } + return + } + + headers.Set(AmzRegionSetKey, strings.Join(s.RegionSet, ",")) + headers.Set(amzDateKey, amzDate) + if len(s.Credentials.SessionToken) > 0 { + headers.Set(amzSecurityTokenKey, s.Credentials.SessionToken) + } +} + +func (s *httpSigner) Build() (signedRequest, error) { + req := s.Request + + query := req.URL.Query() + headers := req.Header + + s.setRequiredSigningFields(headers, query) + + // Sort Each Query Key's Values + for key := range query { + sort.Strings(query[key]) + } + + v4Internal.SanitizeHostForHeader(req) + + credentialScope := s.buildCredentialScope() + credentialStr := s.Credentials.Context + "/" + credentialScope + if s.IsPreSign { + query.Set(amzCredentialKey, credentialStr) + } + + unsignedHeaders := headers + if s.IsPreSign && !s.DisableHeaderHoisting { + urlValues := url.Values{} + urlValues, unsignedHeaders = buildQuery(v4Internal.AllowedQueryHoisting, unsignedHeaders) + for k := range urlValues { + query[k] = urlValues[k] + } + } + + host := req.URL.Host + if len(req.Host) > 0 { + host = req.Host + } + + var ( + signedHeaders http.Header + signedHeadersStr string + canonicalHeaderStr string + ) + + if s.IsPreSign { + signedHeaders, signedHeadersStr, canonicalHeaderStr = s.buildCanonicalHeaders(host, v4Internal.IgnoredPresignedHeaders, unsignedHeaders, s.Request.ContentLength) + } else { + signedHeaders, signedHeadersStr, canonicalHeaderStr = s.buildCanonicalHeaders(host, v4Internal.IgnoredHeaders, unsignedHeaders, s.Request.ContentLength) + } + + if s.IsPreSign { + query.Set(amzSignedHeadersKey, signedHeadersStr) + } + + rawQuery := strings.Replace(query.Encode(), "+", "%20", -1) + + canonicalURI := v4Internal.GetURIPath(req.URL) + if !s.DisableURIPathEscaping { + canonicalURI = httpbinding.EscapePath(canonicalURI, false) + } + + canonicalString := s.buildCanonicalString( + req.Method, + canonicalURI, + rawQuery, + signedHeadersStr, + canonicalHeaderStr, + ) + + strToSign := s.buildStringToSign(credentialScope, canonicalString) + signingSignature, err := s.buildSignature(strToSign) + if err != nil { + return signedRequest{}, err + } + + if s.IsPreSign { + rawQuery += "&X-Amz-Signature=" + signingSignature + } else { + headers[authorizationHeader] = append(headers[authorizationHeader][:0], buildAuthorizationHeader(credentialStr, signedHeadersStr, signingSignature)) + } + + req.URL.RawQuery = rawQuery + + return signedRequest{ + Request: req, + SignedHeaders: signedHeaders, + CanonicalString: canonicalString, + StringToSign: strToSign, + PreSigned: s.IsPreSign, + }, nil +} + +func buildAuthorizationHeader(credentialStr, signedHeadersStr, signingSignature string) string { + const credential = "Credential=" + const signedHeaders = "SignedHeaders=" + const signature = "Signature=" + const commaSpace = ", " + + var parts strings.Builder + parts.Grow(len(signingAlgorithm) + 1 + + len(credential) + len(credentialStr) + len(commaSpace) + + len(signedHeaders) + len(signedHeadersStr) + len(commaSpace) + + len(signature) + len(signingSignature), + ) + parts.WriteString(signingAlgorithm) + parts.WriteRune(' ') + parts.WriteString(credential) + parts.WriteString(credentialStr) + parts.WriteString(commaSpace) + parts.WriteString(signedHeaders) + parts.WriteString(signedHeadersStr) + parts.WriteString(commaSpace) + parts.WriteString(signature) + parts.WriteString(signingSignature) + return parts.String() +} + +func (s *httpSigner) buildCredentialScope() string { + return strings.Join([]string{ + s.Time.Format(shortTimeFormat), + s.ServiceName, + "aws4_request", + }, "/") + +} + +func buildQuery(r v4Internal.Rule, header http.Header) (url.Values, http.Header) { + query := url.Values{} + unsignedHeaders := http.Header{} + for k, h := range header { + if r.IsValid(k) { + query[k] = h + } else { + unsignedHeaders[k] = h + } + } + + return query, unsignedHeaders +} + +func (s *httpSigner) buildCanonicalHeaders(host string, rule v4Internal.Rule, header http.Header, length int64) (signed http.Header, signedHeaders, canonicalHeadersStr string) { + signed = make(http.Header) + + var headers []string + const hostHeader = "host" + headers = append(headers, hostHeader) + signed[hostHeader] = append(signed[hostHeader], host) + + const contentLengthHeader = "content-length" + if length > 0 { + headers = append(headers, contentLengthHeader) + signed[contentLengthHeader] = append(signed[contentLengthHeader], strconv.FormatInt(length, 10)) + } + + for k, v := range header { + if !rule.IsValid(k) { + continue // ignored header + } + + if strings.EqualFold(k, contentLengthHeader) { + // prevent signing already handled content-length header. + continue + } + + lowerCaseKey := strings.ToLower(k) + if _, ok := signed[lowerCaseKey]; ok { + // include additional values + signed[lowerCaseKey] = append(signed[lowerCaseKey], v...) + continue + } + + headers = append(headers, lowerCaseKey) + signed[lowerCaseKey] = v + } + sort.Strings(headers) + + signedHeaders = strings.Join(headers, ";") + + var canonicalHeaders strings.Builder + n := len(headers) + const colon = ':' + for i := 0; i < n; i++ { + if headers[i] == hostHeader { + canonicalHeaders.WriteString(hostHeader) + canonicalHeaders.WriteRune(colon) + canonicalHeaders.WriteString(v4Internal.StripExcessSpaces(host)) + } else { + canonicalHeaders.WriteString(headers[i]) + canonicalHeaders.WriteRune(colon) + // Trim out leading, trailing, and dedup inner spaces from signed header values. + values := signed[headers[i]] + for j, v := range values { + cleanedValue := strings.TrimSpace(v4Internal.StripExcessSpaces(v)) + canonicalHeaders.WriteString(cleanedValue) + if j < len(values)-1 { + canonicalHeaders.WriteRune(',') + } + } + } + canonicalHeaders.WriteRune('\n') + } + canonicalHeadersStr = canonicalHeaders.String() + + return signed, signedHeaders, canonicalHeadersStr +} + +func (s *httpSigner) buildCanonicalString(method, uri, query, signedHeaders, canonicalHeaders string) string { + return strings.Join([]string{ + method, + uri, + query, + canonicalHeaders, + signedHeaders, + s.PayloadHash, + }, "\n") +} + +func (s *httpSigner) buildStringToSign(credentialScope, canonicalRequestString string) string { + return strings.Join([]string{ + signingAlgorithm, + s.Time.Format(timeFormat), + credentialScope, + hex.EncodeToString(makeHash(sha256.New(), []byte(canonicalRequestString))), + }, "\n") +} + +func makeHash(hash hash.Hash, b []byte) []byte { + hash.Reset() + hash.Write(b) + return hash.Sum(nil) +} + +func (s *httpSigner) buildSignature(strToSign string) (string, error) { + sig, err := s.Credentials.PrivateKey.Sign(rand.Reader, makeHash(sha256.New(), []byte(strToSign)), crypto.SHA256) + if err != nil { + return "", err + } + return hex.EncodeToString(sig), nil +} + +const logSignInfoMsg = `Request Signature: +---[ CANONICAL STRING ]----------------------------- +%s +---[ STRING TO SIGN ]-------------------------------- +%s%s +-----------------------------------------------------` +const logSignedURLMsg = ` +---[ SIGNED URL ]------------------------------------ +%s` + +func logHTTPSigningInfo(ctx context.Context, options SignerOptions, r signedRequest) { + if !options.LogSigning { + return + } + signedURLMsg := "" + if r.PreSigned { + signedURLMsg = fmt.Sprintf(logSignedURLMsg, r.Request.URL.String()) + } + logger := logging.WithContext(ctx, options.Logger) + logger.Logf(logging.Debug, logSignInfoMsg, r.CanonicalString, r.StringToSign, signedURLMsg) +} + +type signedRequest struct { + Request *http.Request + SignedHeaders http.Header + CanonicalString string + StringToSign string + PreSigned bool +} diff --git a/api/auth/signer/v4asdk2/v4a_test.go b/api/auth/signer/v4asdk2/v4a_test.go new file mode 100644 index 0000000..a67ca20 --- /dev/null +++ b/api/auth/signer/v4asdk2/v4a_test.go @@ -0,0 +1,429 @@ +package v4a + +import ( + "context" + "encoding/hex" + "fmt" + "math/big" + "net/http" + "net/url" + "reflect" + "strconv" + "strings" + "testing" + "time" + + "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/auth/signer/v4asdk2/internal/crypto" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/smithy-go/logging" +) + +const ( + accessKey = "AKISORANDOMAASORANDOM" + secretKey = "q+jcrXGc+0zWN6uzclKVhvMmUsIfRPa4rlRandom" +) + +func TestDeriveECDSAKeyPairFromSecret(t *testing.T) { + privateKey, err := deriveKeyFromAccessKeyPair(accessKey, secretKey) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + expectedX := func() *big.Int { + t.Helper() + b, ok := new(big.Int).SetString("15D242CEEBF8D8169FD6A8B5A746C41140414C3B07579038DA06AF89190FFFCB", 16) + if !ok { + t.Fatalf("failed to parse big integer") + } + return b + }() + expectedY := func() *big.Int { + t.Helper() + b, ok := new(big.Int).SetString("515242CEDD82E94799482E4C0514B505AFCCF2C0C98D6A553BF539F424C5EC0", 16) + if !ok { + t.Fatalf("failed to parse big integer") + } + return b + }() + + if privateKey.X.Cmp(expectedX) != 0 { + t.Errorf("expected % X, got % X", expectedX, privateKey.X) + } + if privateKey.Y.Cmp(expectedY) != 0 { + t.Errorf("expected % X, got % X", expectedY, privateKey.Y) + } +} + +func TestSignHTTP(t *testing.T) { + req := buildRequest("dynamodb", "us-east-1") + + signer, credProvider := buildSigner(t, true) + + key, err := credProvider.RetrievePrivateKey(context.Background()) + if err != nil { + t.Fatalf("expect no error, got %v", err) + } + + err = signer.SignHTTP(context.Background(), key, req, EmptyStringSHA256, "dynamodb", []string{"us-east-1"}, time.Unix(0, 0)) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + expectedDate := "19700101T000000Z" + expectedAlg := "AWS4-ECDSA-P256-SHA256" + expectedCredential := "AKISORANDOMAASORANDOM/19700101/dynamodb/aws4_request" + expectedSignedHeaders := "content-length;content-type;host;x-amz-date;x-amz-meta-other-header;x-amz-meta-other-header_with_underscore;x-amz-region-set;x-amz-security-token;x-amz-target" + expectedStrToSignHash := "4ba7d0482cf4d5450cefdc067a00de1a4a715e444856fa3e1d85c35fb34d9730" + + q := req.Header + + validateAuthorization(t, q.Get("Authorization"), expectedAlg, expectedCredential, expectedSignedHeaders, expectedStrToSignHash) + + if e, a := expectedDate, q.Get("X-Amz-Date"); e != a { + t.Errorf("expect %v, got %v", e, a) + } +} + +func TestSignHTTP_NoSessionToken(t *testing.T) { + req := buildRequest("dynamodb", "us-east-1") + + signer, credProvider := buildSigner(t, false) + + key, err := credProvider.RetrievePrivateKey(context.Background()) + if err != nil { + t.Fatalf("expect no error, got %v", err) + } + + err = signer.SignHTTP(context.Background(), key, req, EmptyStringSHA256, "dynamodb", []string{"us-east-1"}, time.Unix(0, 0)) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + expectedAlg := "AWS4-ECDSA-P256-SHA256" + expectedCredential := "AKISORANDOMAASORANDOM/19700101/dynamodb/aws4_request" + expectedSignedHeaders := "content-length;content-type;host;x-amz-date;x-amz-meta-other-header;x-amz-meta-other-header_with_underscore;x-amz-region-set;x-amz-target" + expectedStrToSignHash := "1aeefb422ae6aa0de7aec829da813e55cff35553cac212dffd5f9474c71e47ee" + + q := req.Header + + validateAuthorization(t, q.Get("Authorization"), expectedAlg, expectedCredential, expectedSignedHeaders, expectedStrToSignHash) +} + +func TestPresignHTTP(t *testing.T) { + req := buildRequest("dynamodb", "us-east-1") + + signer, credProvider := buildSigner(t, false) + + key, err := credProvider.RetrievePrivateKey(context.Background()) + if err != nil { + t.Fatalf("expect no error, got %v", err) + } + + query := req.URL.Query() + query.Set("X-Amz-Expires", "18000") + req.URL.RawQuery = query.Encode() + + signedURL, _, err := signer.PresignHTTP(context.Background(), key, req, EmptyStringSHA256, "dynamodb", []string{"us-east-1"}, time.Unix(0, 0)) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + expectedDate := "19700101T000000Z" + expectedAlg := "AWS4-ECDSA-P256-SHA256" + expectedHeaders := "content-length;content-type;host;x-amz-meta-other-header;x-amz-meta-other-header_with_underscore" + expectedCredential := "AKISORANDOMAASORANDOM/19700101/dynamodb/aws4_request" + expectedStrToSignHash := "d7ffbd2fab644384c056957e6ac38de4ae68246764b5f5df171b3824153b6397" + expectedTarget := "prefix.Operation" + + signedReq, err := url.Parse(signedURL) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + q := signedReq.Query() + + validateSignature(t, expectedStrToSignHash, q.Get("X-Amz-Signature")) + + if e, a := expectedAlg, q.Get("X-Amz-Algorithm"); e != a { + t.Errorf("expect %v, got %v", e, a) + } + if e, a := expectedCredential, q.Get("X-Amz-Credential"); e != a { + t.Errorf("expect %v, got %v", e, a) + } + if e, a := expectedHeaders, q.Get("X-Amz-SignedHeaders"); e != a { + t.Errorf("expect %v, got %v", e, a) + } + if e, a := expectedDate, q.Get("X-Amz-Date"); e != a { + t.Errorf("expect %v, got %v", e, a) + } + if a := q.Get("X-Amz-Meta-Other-Header"); len(a) != 0 { + t.Errorf("expect %v to be empty", a) + } + if e, a := expectedTarget, q.Get("X-Amz-Target"); e != a { + t.Errorf("expect %v, got %v", e, a) + } + if e, a := "us-east-1", q.Get("X-Amz-Region-Set"); e != a { + t.Errorf("expect %v, got %v", e, a) + } +} + +func TestPresignHTTP_BodyWithArrayRequest(t *testing.T) { + req := buildRequest("dynamodb", "us-east-1") + req.URL.RawQuery = "Foo=z&Foo=o&Foo=m&Foo=a" + + signer, credProvider := buildSigner(t, true) + + key, err := credProvider.RetrievePrivateKey(context.Background()) + if err != nil { + t.Fatalf("expect no error, got %v", err) + } + + query := req.URL.Query() + query.Set("X-Amz-Expires", "300") + req.URL.RawQuery = query.Encode() + + signedURI, _, err := signer.PresignHTTP(context.Background(), key, req, EmptyStringSHA256, "dynamodb", []string{"us-east-1"}, time.Unix(0, 0)) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + signedReq, err := url.Parse(signedURI) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + expectedAlg := "AWS4-ECDSA-P256-SHA256" + expectedDate := "19700101T000000Z" + expectedHeaders := "content-length;content-type;host;x-amz-meta-other-header;x-amz-meta-other-header_with_underscore" + expectedStrToSignHash := "acff64fd3689be96259d4112c3742ff79f4da0d813bc58a285dc1c4449760bec" + expectedCred := "AKISORANDOMAASORANDOM/19700101/dynamodb/aws4_request" + expectedTarget := "prefix.Operation" + + q := signedReq.Query() + + validateSignature(t, expectedStrToSignHash, q.Get("X-Amz-Signature")) + + if e, a := expectedAlg, q.Get("X-Amz-Algorithm"); e != a { + t.Errorf("expect %v, got %v", e, a) + } + if e, a := expectedCred, q.Get("X-Amz-Credential"); e != a { + t.Errorf("expect %v, got %v", e, a) + } + if e, a := expectedHeaders, q.Get("X-Amz-SignedHeaders"); e != a { + t.Errorf("expect %v, got %v", e, a) + } + if e, a := expectedDate, q.Get("X-Amz-Date"); e != a { + t.Errorf("expect %v, got %v", e, a) + } + if a := q.Get("X-Amz-Meta-Other-Header"); len(a) != 0 { + t.Errorf("expect %v to be empty, was not", a) + } + if e, a := expectedTarget, q.Get("X-Amz-Target"); e != a { + t.Errorf("expect %v, got %v", e, a) + } + if e, a := "us-east-1", q.Get("X-Amz-Region-Set"); e != a { + t.Errorf("expect %v, got %v", e, a) + } +} +func TestSign_buildCanonicalHeaders(t *testing.T) { + serviceName := "mockAPI" + region := "mock-region" + endpoint := "https://" + serviceName + "." + region + ".amazonaws.com" + + req, err := http.NewRequest("POST", endpoint, nil) + if err != nil { + t.Fatalf("failed to create request, %v", err) + } + + req.Header.Set("FooInnerSpace", " inner space ") + req.Header.Set("FooLeadingSpace", " leading-space") + req.Header.Add("FooMultipleSpace", "no-space") + req.Header.Add("FooMultipleSpace", "\ttab-space") + req.Header.Add("FooMultipleSpace", "trailing-space ") + req.Header.Set("FooNoSpace", "no-space") + req.Header.Set("FooTabSpace", "\ttab-space\t") + req.Header.Set("FooTrailingSpace", "trailing-space ") + req.Header.Set("FooWrappedSpace", " wrapped-space ") + + credProvider := &SymmetricCredentialAdaptor{ + SymmetricProvider: staticCredentialsProvider{ + Value: aws.Credentials{ + AccessKeyID: accessKey, + SecretAccessKey: secretKey, + }, + }, + } + key, err := credProvider.RetrievePrivateKey(context.Background()) + if err != nil { + t.Fatalf("expect no error, got %v", err) + } + + ctx := &httpSigner{ + Request: req, + ServiceName: serviceName, + RegionSet: []string{region}, + Credentials: key, + Time: time.Date(2021, 10, 20, 12, 42, 0, 0, time.UTC), + } + + build, err := ctx.Build() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + expectCanonicalString := strings.Join([]string{ + `POST`, + `/`, + ``, + `fooinnerspace:inner space`, + `fooleadingspace:leading-space`, + `foomultiplespace:no-space,tab-space,trailing-space`, + `foonospace:no-space`, + `footabspace:tab-space`, + `footrailingspace:trailing-space`, + `foowrappedspace:wrapped-space`, + `host:mockAPI.mock-region.amazonaws.com`, + `x-amz-date:20211020T124200Z`, + `x-amz-region-set:mock-region`, + ``, + `fooinnerspace;fooleadingspace;foomultiplespace;foonospace;footabspace;footrailingspace;foowrappedspace;host;x-amz-date;x-amz-region-set`, + ``, + }, "\n") + if diff := cmpDiff(expectCanonicalString, build.CanonicalString); diff != "" { + t.Errorf("expect match, got\n%s", diff) + } +} + +func validateAuthorization(t *testing.T, authorization, expectedAlg, expectedCredential, expectedSignedHeaders, expectedStrToSignHash string) { + t.Helper() + split := strings.SplitN(authorization, " ", 2) + + if len(split) != 2 { + t.Fatal("unexpected authorization header format") + } + + if e, a := split[0], expectedAlg; e != a { + t.Errorf("expected %v, got %v", e, a) + } + + keyValues := strings.Split(split[1], ", ") + seen := make(map[string]string) + + for _, kv := range keyValues { + idx := strings.Index(kv, "=") + if idx == -1 { + continue + } + key, value := kv[:idx], kv[idx+1:] + seen[key] = value + } + + if a, ok := seen["Credential"]; ok { + if expectedCredential != a { + t.Errorf("expected credential %v, got %v", expectedCredential, a) + } + } else { + t.Errorf("Credential not found in authorization string") + } + + if a, ok := seen["SignedHeaders"]; ok { + if expectedSignedHeaders != a { + t.Errorf("expected signed headers %v, got %v", expectedSignedHeaders, a) + } + } else { + t.Errorf("SignedHeaders not found in authorization string") + } + + if a, ok := seen["Signature"]; ok { + validateSignature(t, expectedStrToSignHash, a) + } else { + t.Errorf("signature not found in authorization string") + } +} + +func validateSignature(t *testing.T, expectedHash, signature string) { + t.Helper() + pair, err := deriveKeyFromAccessKeyPair(accessKey, secretKey) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + hash, _ := hex.DecodeString(expectedHash) + sig, _ := hex.DecodeString(signature) + + ok, err := crypto.VerifySignature(&pair.PublicKey, hash, sig) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !ok { + t.Errorf("failed to verify signing singature") + } +} + +func buildRequest(serviceName, region string) *http.Request { + endpoint := "https://" + serviceName + "." + region + ".amazonaws.com" + req, _ := http.NewRequest("POST", endpoint, nil) + req.URL.Opaque = "//example.org/bucket/key-._~,!@%23$%25^&*()" + req.Header.Set("X-Amz-Target", "prefix.Operation") + req.Header.Set("Content-Type", "application/x-amz-json-1.0") + + req.Header.Set("Content-Length", strconv.Itoa(1024)) + + req.Header.Set("X-Amz-Meta-Other-Header", "some-value=!@#$%^&* (+)") + req.Header.Add("X-Amz-Meta-Other-Header_With_Underscore", "some-value=!@#$%^&* (+)") + req.Header.Add("X-amz-Meta-Other-Header_With_Underscore", "some-value=!@#$%^&* (+)") + return req +} + +func buildSigner(t *testing.T, withToken bool) (*Signer, CredentialsProvider) { + creds := aws.Credentials{ + AccessKeyID: accessKey, + SecretAccessKey: secretKey, + } + + if withToken { + creds.SessionToken = "TOKEN" + } + + return NewSigner(func(options *SignerOptions) { + options.Logger = loggerFunc(func(format string, v ...interface{}) { + t.Logf(format, v...) + }) + }), &SymmetricCredentialAdaptor{ + SymmetricProvider: staticCredentialsProvider{ + Value: creds, + }, + } +} + +type loggerFunc func(format string, v ...interface{}) + +func (l loggerFunc) Logf(_ logging.Classification, format string, v ...interface{}) { + l(format, v...) +} + +type staticCredentialsProvider struct { + Value aws.Credentials +} + +func (s staticCredentialsProvider) Retrieve(_ context.Context) (aws.Credentials, error) { + v := s.Value + if v.AccessKeyID == "" || v.SecretAccessKey == "" { + return aws.Credentials{ + Source: "Source Name", + }, fmt.Errorf("static credentials are empty") + } + + if len(v.Source) == 0 { + v.Source = "Source Name" + } + + return v, nil +} + +func cmpDiff(e, a interface{}) string { + if !reflect.DeepEqual(e, a) { + return fmt.Sprintf("%v != %v", e, a) + } + return "" +} diff --git a/api/auth/signer/v4sdk2/signer/internal/v4/cache.go b/api/auth/signer/v4sdk2/signer/internal/v4/cache.go new file mode 100644 index 0000000..cbf22f1 --- /dev/null +++ b/api/auth/signer/v4sdk2/signer/internal/v4/cache.go @@ -0,0 +1,115 @@ +package v4 + +import ( + "strings" + "sync" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" +) + +func lookupKey(service, region string) string { + var s strings.Builder + s.Grow(len(region) + len(service) + 3) + s.WriteString(region) + s.WriteRune('/') + s.WriteString(service) + return s.String() +} + +type derivedKey struct { + AccessKey string + Date time.Time + Credential []byte +} + +type derivedKeyCache struct { + values map[string]derivedKey + mutex sync.RWMutex +} + +func newDerivedKeyCache() derivedKeyCache { + return derivedKeyCache{ + values: make(map[string]derivedKey), + } +} + +func (s *derivedKeyCache) Get(credentials aws.Credentials, service, region string, signingTime SigningTime) []byte { + key := lookupKey(service, region) + s.mutex.RLock() + if cred, ok := s.get(key, credentials, signingTime.Time); ok { + s.mutex.RUnlock() + return cred + } + s.mutex.RUnlock() + + s.mutex.Lock() + if cred, ok := s.get(key, credentials, signingTime.Time); ok { + s.mutex.Unlock() + return cred + } + cred := deriveKey(credentials.SecretAccessKey, service, region, signingTime) + entry := derivedKey{ + AccessKey: credentials.AccessKeyID, + Date: signingTime.Time, + Credential: cred, + } + s.values[key] = entry + s.mutex.Unlock() + + return cred +} + +func (s *derivedKeyCache) get(key string, credentials aws.Credentials, signingTime time.Time) ([]byte, bool) { + cacheEntry, ok := s.retrieveFromCache(key) + if ok && cacheEntry.AccessKey == credentials.AccessKeyID && isSameDay(signingTime, cacheEntry.Date) { + return cacheEntry.Credential, true + } + return nil, false +} + +func (s *derivedKeyCache) retrieveFromCache(key string) (derivedKey, bool) { + if v, ok := s.values[key]; ok { + return v, true + } + return derivedKey{}, false +} + +// SigningKeyDeriver derives a signing key from a set of credentials +type SigningKeyDeriver struct { + cache derivedKeyCache +} + +// NewSigningKeyDeriver returns a new SigningKeyDeriver +func NewSigningKeyDeriver() *SigningKeyDeriver { + return &SigningKeyDeriver{ + cache: newDerivedKeyCache(), + } +} + +// DeriveKey returns a derived signing key from the given credentials to be used with SigV4 signing. +func (k *SigningKeyDeriver) DeriveKey(credential aws.Credentials, service, region string, signingTime SigningTime) []byte { + return k.cache.Get(credential, service, region, signingTime) +} + +func deriveKey(secret, service, region string, t SigningTime) []byte { + hmacDate := HMACSHA256([]byte("AWS4"+secret), []byte(t.ShortTimeFormat())) + hmacRegion := HMACSHA256(hmacDate, []byte(region)) + hmacService := HMACSHA256(hmacRegion, []byte(service)) + return HMACSHA256(hmacService, []byte("aws4_request")) +} + +func isSameDay(x, y time.Time) bool { + xYear, xMonth, xDay := x.Date() + yYear, yMonth, yDay := y.Date() + + if xYear != yYear { + return false + } + + if xMonth != yMonth { + return false + } + + return xDay == yDay +} diff --git a/api/auth/signer/v4sdk2/signer/internal/v4/const.go b/api/auth/signer/v4sdk2/signer/internal/v4/const.go new file mode 100644 index 0000000..a23cb00 --- /dev/null +++ b/api/auth/signer/v4sdk2/signer/internal/v4/const.go @@ -0,0 +1,40 @@ +package v4 + +// Signature Version 4 (SigV4) Constants +const ( + // EmptyStringSHA256 is the hex encoded sha256 value of an empty string + EmptyStringSHA256 = `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` + + // UnsignedPayload indicates that the request payload body is unsigned + UnsignedPayload = "UNSIGNED-PAYLOAD" + + // AmzAlgorithmKey indicates the signing algorithm + AmzAlgorithmKey = "X-Amz-Algorithm" + + // AmzSecurityTokenKey indicates the security token to be used with temporary credentials + AmzSecurityTokenKey = "X-Amz-Security-Token" + + // AmzDateKey is the UTC timestamp for the request in the format YYYYMMDD'T'HHMMSS'Z' + AmzDateKey = "X-Amz-Date" + + // AmzCredentialKey is the access key ID and credential scope + AmzCredentialKey = "X-Amz-Credential" + + // AmzSignedHeadersKey is the set of headers signed for the request + AmzSignedHeadersKey = "X-Amz-SignedHeaders" + + // AmzSignatureKey is the query parameter to store the SigV4 signature + AmzSignatureKey = "X-Amz-Signature" + + // TimeFormat is the time format to be used in the X-Amz-Date header or query parameter + TimeFormat = "20060102T150405Z" + + // ShortTimeFormat is the shorten time format used in the credential scope + ShortTimeFormat = "20060102" + + // ContentSHAKey is the SHA256 of request body + ContentSHAKey = "X-Amz-Content-Sha256" + + // StreamingEventsPayload indicates that the request payload body is a signed event stream. + StreamingEventsPayload = "STREAMING-AWS4-HMAC-SHA256-EVENTS" +) diff --git a/api/auth/signer/v4sdk2/signer/internal/v4/header_rules.go b/api/auth/signer/v4sdk2/signer/internal/v4/header_rules.go new file mode 100644 index 0000000..76fa34b --- /dev/null +++ b/api/auth/signer/v4sdk2/signer/internal/v4/header_rules.go @@ -0,0 +1,88 @@ +package v4 + +import ( + "strings" +) + +// Rules houses a set of Rule needed for validation of a +// string value +type Rules []Rule + +// Rule interface allows for more flexible rules and just simply +// checks whether or not a value adheres to that Rule +type Rule interface { + IsValid(value string) bool +} + +// IsValid will iterate through all rules and see if any rules +// apply to the value and supports nested rules +func (r Rules) IsValid(value string) bool { + for _, rule := range r { + if rule.IsValid(value) { + return true + } + } + return false +} + +// MapRule generic Rule for maps +type MapRule map[string]struct{} + +// IsValid for the map Rule satisfies whether it exists in the map +func (m MapRule) IsValid(value string) bool { + _, ok := m[value] + return ok +} + +// AllowList is a generic Rule for include listing +type AllowList struct { + Rule +} + +// IsValid for AllowList checks if the value is within the AllowList +func (w AllowList) IsValid(value string) bool { + return w.Rule.IsValid(value) +} + +// ExcludeList is a generic Rule for exclude listing +type ExcludeList struct { + Rule +} + +// IsValid for AllowList checks if the value is within the AllowList +func (b ExcludeList) IsValid(value string) bool { + return !b.Rule.IsValid(value) +} + +// Patterns is a list of strings to match against +type Patterns []string + +// IsValid for Patterns checks each pattern and returns if a match has +// been found +func (p Patterns) IsValid(value string) bool { + for _, pattern := range p { + if HasPrefixFold(value, pattern) { + return true + } + } + return false +} + +// InclusiveRules rules allow for rules to depend on one another +type InclusiveRules []Rule + +// IsValid will return true if all rules are true +func (r InclusiveRules) IsValid(value string) bool { + for _, rule := range r { + if !rule.IsValid(value) { + return false + } + } + return true +} + +// HasPrefixFold tests whether the string s begins with prefix, interpreted as UTF-8 strings, +// under Unicode case-folding. +func HasPrefixFold(s, prefix string) bool { + return len(s) >= len(prefix) && strings.EqualFold(s[0:len(prefix)], prefix) +} diff --git a/api/auth/signer/v4sdk2/signer/internal/v4/headers.go b/api/auth/signer/v4sdk2/signer/internal/v4/headers.go new file mode 100644 index 0000000..f2a7766 --- /dev/null +++ b/api/auth/signer/v4sdk2/signer/internal/v4/headers.go @@ -0,0 +1,84 @@ +package v4 + +// IgnoredPresignedHeaders is a list of headers that are ignored during signing +var IgnoredPresignedHeaders = Rules{ + ExcludeList{ + MapRule{ + "Authorization": struct{}{}, + "User-Agent": struct{}{}, + "X-Amzn-Trace-Id": struct{}{}, + "Expect": struct{}{}, + }, + }, +} + +// IgnoredHeaders is a list of headers that are ignored during signing +// drop User-Agent header to be compatible with aws sdk java v1. +var IgnoredHeaders = Rules{ + ExcludeList{ + MapRule{ + "Authorization": struct{}{}, + //"User-Agent": struct{}{}, + "X-Amzn-Trace-Id": struct{}{}, + "Expect": struct{}{}, + }, + }, +} + +// RequiredSignedHeaders is a allow list for Build canonical headers. +var RequiredSignedHeaders = Rules{ + AllowList{ + MapRule{ + "Cache-Control": struct{}{}, + "Content-Disposition": struct{}{}, + "Content-Encoding": struct{}{}, + "Content-Language": struct{}{}, + "Content-Md5": struct{}{}, + "Content-Type": struct{}{}, + "Expires": struct{}{}, + "If-Match": struct{}{}, + "If-Modified-Since": struct{}{}, + "If-None-Match": struct{}{}, + "If-Unmodified-Since": struct{}{}, + "Range": struct{}{}, + "X-Amz-Acl": struct{}{}, + "X-Amz-Copy-Source": struct{}{}, + "X-Amz-Copy-Source-If-Match": struct{}{}, + "X-Amz-Copy-Source-If-Modified-Since": struct{}{}, + "X-Amz-Copy-Source-If-None-Match": struct{}{}, + "X-Amz-Copy-Source-If-Unmodified-Since": struct{}{}, + "X-Amz-Copy-Source-Range": struct{}{}, + "X-Amz-Copy-Source-Server-Side-Encryption-Customer-Algorithm": struct{}{}, + "X-Amz-Copy-Source-Server-Side-Encryption-Customer-Key": struct{}{}, + "X-Amz-Copy-Source-Server-Side-Encryption-Customer-Key-Md5": struct{}{}, + "X-Amz-Expected-Bucket-Owner": struct{}{}, + "X-Amz-Grant-Full-control": struct{}{}, + "X-Amz-Grant-Read": struct{}{}, + "X-Amz-Grant-Read-Acp": struct{}{}, + "X-Amz-Grant-Write": struct{}{}, + "X-Amz-Grant-Write-Acp": struct{}{}, + "X-Amz-Metadata-Directive": struct{}{}, + "X-Amz-Mfa": struct{}{}, + "X-Amz-Request-Payer": struct{}{}, + "X-Amz-Server-Side-Encryption": struct{}{}, + "X-Amz-Server-Side-Encryption-Aws-Kms-Key-Id": struct{}{}, + "X-Amz-Server-Side-Encryption-Context": struct{}{}, + "X-Amz-Server-Side-Encryption-Customer-Algorithm": struct{}{}, + "X-Amz-Server-Side-Encryption-Customer-Key": struct{}{}, + "X-Amz-Server-Side-Encryption-Customer-Key-Md5": struct{}{}, + "X-Amz-Storage-Class": struct{}{}, + "X-Amz-Website-Redirect-Location": struct{}{}, + "X-Amz-Content-Sha256": struct{}{}, + "X-Amz-Tagging": struct{}{}, + }, + }, + Patterns{"X-Amz-Object-Lock-"}, + Patterns{"X-Amz-Meta-"}, +} + +// AllowedQueryHoisting is a allowed list for Build query headers. The boolean value +// represents whether or not it is a pattern. +var AllowedQueryHoisting = InclusiveRules{ + ExcludeList{RequiredSignedHeaders}, + Patterns{"X-Amz-"}, +} diff --git a/api/auth/signer/v4sdk2/signer/internal/v4/headers_test.go b/api/auth/signer/v4sdk2/signer/internal/v4/headers_test.go new file mode 100644 index 0000000..6405ea9 --- /dev/null +++ b/api/auth/signer/v4sdk2/signer/internal/v4/headers_test.go @@ -0,0 +1,63 @@ +package v4 + +import "testing" + +func TestAllowedQueryHoisting(t *testing.T) { + cases := map[string]struct { + Header string + ExpectHoist bool + }{ + "object-lock": { + Header: "X-Amz-Object-Lock-Mode", + ExpectHoist: false, + }, + "s3 metadata": { + Header: "X-Amz-Meta-SomeName", + ExpectHoist: false, + }, + "another header": { + Header: "X-Amz-SomeOtherHeader", + ExpectHoist: true, + }, + "non X-AMZ header": { + Header: "X-SomeOtherHeader", + ExpectHoist: false, + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + if e, a := c.ExpectHoist, AllowedQueryHoisting.IsValid(c.Header); e != a { + t.Errorf("expect hoist %v, was %v", e, a) + } + }) + } +} + +func TestIgnoredHeaders(t *testing.T) { + cases := map[string]struct { + Header string + ExpectIgnored bool + }{ + "expect": { + Header: "Expect", + ExpectIgnored: true, + }, + "authorization": { + Header: "Authorization", + ExpectIgnored: true, + }, + "X-AMZ header": { + Header: "X-Amz-Content-Sha256", + ExpectIgnored: false, + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + if e, a := c.ExpectIgnored, IgnoredHeaders.IsValid(c.Header); e == a { + t.Errorf("expect ignored %v, was %v", e, a) + } + }) + } +} diff --git a/api/auth/signer/v4sdk2/signer/internal/v4/hmac.go b/api/auth/signer/v4sdk2/signer/internal/v4/hmac.go new file mode 100644 index 0000000..e7fa7a1 --- /dev/null +++ b/api/auth/signer/v4sdk2/signer/internal/v4/hmac.go @@ -0,0 +1,13 @@ +package v4 + +import ( + "crypto/hmac" + "crypto/sha256" +) + +// HMACSHA256 computes a HMAC-SHA256 of data given the provided key. +func HMACSHA256(key []byte, data []byte) []byte { + hash := hmac.New(sha256.New, key) + hash.Write(data) + return hash.Sum(nil) +} diff --git a/api/auth/signer/v4sdk2/signer/internal/v4/host.go b/api/auth/signer/v4sdk2/signer/internal/v4/host.go new file mode 100644 index 0000000..bf93659 --- /dev/null +++ b/api/auth/signer/v4sdk2/signer/internal/v4/host.go @@ -0,0 +1,75 @@ +package v4 + +import ( + "net/http" + "strings" +) + +// SanitizeHostForHeader removes default port from host and updates request.Host +func SanitizeHostForHeader(r *http.Request) { + host := getHost(r) + port := portOnly(host) + if port != "" && isDefaultPort(r.URL.Scheme, port) { + r.Host = stripPort(host) + } +} + +// Returns host from request +func getHost(r *http.Request) string { + if r.Host != "" { + return r.Host + } + + return r.URL.Host +} + +// Hostname returns u.Host, without any port number. +// +// If Host is an IPv6 literal with a port number, Hostname returns the +// IPv6 literal without the square brackets. IPv6 literals may include +// a zone identifier. +// +// Copied from the Go 1.8 standard library (net/url) +func stripPort(hostport string) string { + colon := strings.IndexByte(hostport, ':') + if colon == -1 { + return hostport + } + if i := strings.IndexByte(hostport, ']'); i != -1 { + return strings.TrimPrefix(hostport[:i], "[") + } + return hostport[:colon] +} + +// Port returns the port part of u.Host, without the leading colon. +// If u.Host doesn't contain a port, Port returns an empty string. +// +// Copied from the Go 1.8 standard library (net/url) +func portOnly(hostport string) string { + colon := strings.IndexByte(hostport, ':') + if colon == -1 { + return "" + } + if i := strings.Index(hostport, "]:"); i != -1 { + return hostport[i+len("]:"):] + } + if strings.Contains(hostport, "]") { + return "" + } + return hostport[colon+len(":"):] +} + +// Returns true if the specified URI is using the standard port +// (i.e. port 80 for HTTP URIs or 443 for HTTPS URIs) +func isDefaultPort(scheme, port string) bool { + if port == "" { + return true + } + + lowerCaseScheme := strings.ToLower(scheme) + if (lowerCaseScheme == "http" && port == "80") || (lowerCaseScheme == "https" && port == "443") { + return true + } + + return false +} diff --git a/api/auth/signer/v4sdk2/signer/internal/v4/scope.go b/api/auth/signer/v4sdk2/signer/internal/v4/scope.go new file mode 100644 index 0000000..fc78879 --- /dev/null +++ b/api/auth/signer/v4sdk2/signer/internal/v4/scope.go @@ -0,0 +1,13 @@ +package v4 + +import "strings" + +// BuildCredentialScope builds the Signature Version 4 (SigV4) signing scope +func BuildCredentialScope(signingTime SigningTime, region, service string) string { + return strings.Join([]string{ + signingTime.ShortTimeFormat(), + region, + service, + "aws4_request", + }, "/") +} diff --git a/api/auth/signer/v4sdk2/signer/internal/v4/time.go b/api/auth/signer/v4sdk2/signer/internal/v4/time.go new file mode 100644 index 0000000..1de06a7 --- /dev/null +++ b/api/auth/signer/v4sdk2/signer/internal/v4/time.go @@ -0,0 +1,36 @@ +package v4 + +import "time" + +// SigningTime provides a wrapper around a time.Time which provides cached values for SigV4 signing. +type SigningTime struct { + time.Time + timeFormat string + shortTimeFormat string +} + +// NewSigningTime creates a new SigningTime given a time.Time +func NewSigningTime(t time.Time) SigningTime { + return SigningTime{ + Time: t, + } +} + +// TimeFormat provides a time formatted in the X-Amz-Date format. +func (m *SigningTime) TimeFormat() string { + return m.format(&m.timeFormat, TimeFormat) +} + +// ShortTimeFormat provides a time formatted of 20060102. +func (m *SigningTime) ShortTimeFormat() string { + return m.format(&m.shortTimeFormat, ShortTimeFormat) +} + +func (m *SigningTime) format(target *string, format string) string { + if len(*target) > 0 { + return *target + } + v := m.Time.Format(format) + *target = v + return v +} diff --git a/api/auth/signer/v4sdk2/signer/internal/v4/util.go b/api/auth/signer/v4sdk2/signer/internal/v4/util.go new file mode 100644 index 0000000..d025dba --- /dev/null +++ b/api/auth/signer/v4sdk2/signer/internal/v4/util.go @@ -0,0 +1,80 @@ +package v4 + +import ( + "net/url" + "strings" +) + +const doubleSpace = " " + +// StripExcessSpaces will rewrite the passed in slice's string values to not +// contain multiple side-by-side spaces. +func StripExcessSpaces(str string) string { + var j, k, l, m, spaces int + // Trim trailing spaces + for j = len(str) - 1; j >= 0 && str[j] == ' '; j-- { + } + + // Trim leading spaces + for k = 0; k < j && str[k] == ' '; k++ { + } + str = str[k : j+1] + + // Strip multiple spaces. + j = strings.Index(str, doubleSpace) + if j < 0 { + return str + } + + buf := []byte(str) + for k, m, l = j, j, len(buf); k < l; k++ { + if buf[k] == ' ' { + if spaces == 0 { + // First space. + buf[m] = buf[k] + m++ + } + spaces++ + } else { + // End of multiple spaces. + spaces = 0 + buf[m] = buf[k] + m++ + } + } + + return string(buf[:m]) +} + +// GetURIPath returns the escaped URI component from the provided URL. +func GetURIPath(u *url.URL) string { + var uriPath string + + if len(u.Opaque) > 0 { + const schemeSep, pathSep, queryStart = "//", "/", "?" + + opaque := u.Opaque + // Cut off the query string if present. + if idx := strings.Index(opaque, queryStart); idx >= 0 { + opaque = opaque[:idx] + } + + // Cutout the scheme separator if present. + if strings.Index(opaque, schemeSep) == 0 { + opaque = opaque[len(schemeSep):] + } + + // capture URI path starting with first path separator. + if idx := strings.Index(opaque, pathSep); idx >= 0 { + uriPath = opaque[idx:] + } + } else { + uriPath = u.EscapedPath() + } + + if len(uriPath) == 0 { + uriPath = "/" + } + + return uriPath +} diff --git a/api/auth/signer/v4sdk2/signer/internal/v4/util_test.go b/api/auth/signer/v4sdk2/signer/internal/v4/util_test.go new file mode 100644 index 0000000..a38ef2d --- /dev/null +++ b/api/auth/signer/v4sdk2/signer/internal/v4/util_test.go @@ -0,0 +1,158 @@ +package v4 + +import ( + "net/http" + "net/url" + "testing" +) + +func lazyURLParse(v string) func() (*url.URL, error) { + return func() (*url.URL, error) { + return url.Parse(v) + } +} + +func TestGetURIPath(t *testing.T) { + cases := map[string]struct { + getURL func() (*url.URL, error) + expect string + }{ + // Cases + "with scheme": { + getURL: lazyURLParse("https://localhost:9000"), + expect: "/", + }, + "no port, with scheme": { + getURL: lazyURLParse("https://localhost"), + expect: "/", + }, + "without scheme": { + getURL: lazyURLParse("localhost:9000"), + expect: "/", + }, + "without scheme, with path": { + getURL: lazyURLParse("localhost:9000/abc123"), + expect: "/abc123", + }, + "without scheme, with separator": { + getURL: lazyURLParse("//localhost:9000"), + expect: "/", + }, + "no port, without scheme, with separator": { + getURL: lazyURLParse("//localhost"), + expect: "/", + }, + "without scheme, with separator, with path": { + getURL: lazyURLParse("//localhost:9000/abc123"), + expect: "/abc123", + }, + "no port, without scheme, with separator, with path": { + getURL: lazyURLParse("//localhost/abc123"), + expect: "/abc123", + }, + "opaque with query string": { + getURL: lazyURLParse("localhost:9000/abc123?efg=456"), + expect: "/abc123", + }, + "failing test": { + getURL: func() (*url.URL, error) { + endpoint := "https://service.region.amazonaws.com" + req, _ := http.NewRequest("POST", endpoint, nil) + u := req.URL + + u.Opaque = "//example.org/bucket/key-._~,!@#$%^&*()" + + query := u.Query() + query.Set("some-query-key", "value") + u.RawQuery = query.Encode() + + return u, nil + }, + expect: "/bucket/key-._~,!@#$%^&*()", + }, + } + + for name, c := range cases { + t.Run(name, func(t *testing.T) { + u, err := c.getURL() + if err != nil { + t.Fatalf("failed to get URL, %v", err) + } + + actual := GetURIPath(u) + if e, a := c.expect, actual; e != a { + t.Errorf("expect %v path, got %v", e, a) + } + }) + } +} + +func TestStripExcessHeaders(t *testing.T) { + vals := []string{ + "", + "123", + "1 2 3", + "1 2 3 ", + " 1 2 3", + "1 2 3", + "1 23", + "1 2 3", + "1 2 ", + " 1 2 ", + "12 3", + "12 3 1", + "12 3 1", + "12 3 1abc123", + } + + expected := []string{ + "", + "123", + "1 2 3", + "1 2 3", + "1 2 3", + "1 2 3", + "1 23", + "1 2 3", + "1 2", + "1 2", + "12 3", + "12 3 1", + "12 3 1", + "12 3 1abc123", + } + + for i := 0; i < len(vals); i++ { + r := StripExcessSpaces(vals[i]) + if e, a := expected[i], r; e != a { + t.Errorf("%d, expect %v, got %v", i, e, a) + } + } +} + +var stripExcessSpaceCases = []string{ + `AWS4-HMAC-SHA256 Credential=AKIDFAKEIDFAKEID/20160628/us-west-2/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=1234567890abcdef1234567890abcdef1234567890abcdef`, + `123 321 123 321`, + ` 123 321 123 321 `, + ` 123 321 123 321 `, + "123", + "1 2 3", + " 1 2 3", + "1 2 3", + "1 23", + "1 2 3", + "1 2 ", + " 1 2 ", + "12 3", + "12 3 1", + "12 3 1", + "12 3 1abc123", +} + +func BenchmarkStripExcessSpaces(b *testing.B) { + for i := 0; i < b.N; i++ { + for _, v := range stripExcessSpaceCases { + StripExcessSpaces(v) + } + } +} diff --git a/api/auth/signer/v4sdk2/signer/v4/middleware.go b/api/auth/signer/v4sdk2/signer/v4/middleware.go new file mode 100644 index 0000000..82bf735 --- /dev/null +++ b/api/auth/signer/v4sdk2/signer/v4/middleware.go @@ -0,0 +1,442 @@ +package v4 + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "strings" + "time" + + v4Internal "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/auth/signer/v4sdk2/signer/internal/v4" + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/aws-sdk-go-v2/aws/middleware/private/metrics" + "github.com/aws/smithy-go/middleware" + smithyhttp "github.com/aws/smithy-go/transport/http" +) + +const computePayloadHashMiddlewareID = "ComputePayloadHash" + +// HashComputationError indicates an error occurred while computing the signing hash +type HashComputationError struct { + Err error +} + +// Error is the error message +func (e *HashComputationError) Error() string { + return fmt.Sprintf("failed to compute payload hash: %v", e.Err) +} + +// Unwrap returns the underlying error if one is set +func (e *HashComputationError) Unwrap() error { + return e.Err +} + +// SigningError indicates an error condition occurred while performing SigV4 signing +type SigningError struct { + Err error +} + +func (e *SigningError) Error() string { + return fmt.Sprintf("failed to sign request: %v", e.Err) +} + +// Unwrap returns the underlying error cause +func (e *SigningError) Unwrap() error { + return e.Err +} + +// UseDynamicPayloadSigningMiddleware swaps the compute payload sha256 middleware with a resolver middleware that +// switches between unsigned and signed payload based on TLS state for request. +// This middleware should not be used for AWS APIs that do not support unsigned payload signing auth. +// By default, SDK uses this middleware for known AWS APIs that support such TLS based auth selection . +// +// Usage example - +// S3 PutObject API allows unsigned payload signing auth usage when TLS is enabled, and uses this middleware to +// dynamically switch between unsigned and signed payload based on TLS state for request. +func UseDynamicPayloadSigningMiddleware(stack *middleware.Stack) error { + _, err := stack.Finalize.Swap(computePayloadHashMiddlewareID, &dynamicPayloadSigningMiddleware{}) + return err +} + +// dynamicPayloadSigningMiddleware dynamically resolves the middleware that computes and set payload sha256 middleware. +type dynamicPayloadSigningMiddleware struct { +} + +// ID returns the resolver identifier +func (m *dynamicPayloadSigningMiddleware) ID() string { + return computePayloadHashMiddlewareID +} + +// HandleFinalize delegates SHA256 computation according to whether the request +// is TLS-enabled. +func (m *dynamicPayloadSigningMiddleware) HandleFinalize( + ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler, +) ( + out middleware.FinalizeOutput, metadata middleware.Metadata, err error, +) { + req, ok := in.Request.(*smithyhttp.Request) + if !ok { + return out, metadata, fmt.Errorf("unknown transport type %T", in.Request) + } + + if req.IsHTTPS() { + return (&UnsignedPayload{}).HandleFinalize(ctx, in, next) + } + return (&ComputePayloadSHA256{}).HandleFinalize(ctx, in, next) +} + +// UnsignedPayload sets the SigV4 request payload hash to unsigned. +// +// Will not set the Unsigned Payload magic SHA value, if a SHA has already been +// stored in the context. (e.g. application pre-computed SHA256 before making +// API call). +// +// This middleware does not check the X-Amz-Content-Sha256 header, if that +// header is serialized a middleware must translate it into the context. +type UnsignedPayload struct{} + +// AddUnsignedPayloadMiddleware adds unsignedPayload to the operation +// middleware stack +func AddUnsignedPayloadMiddleware(stack *middleware.Stack) error { + return stack.Finalize.Insert(&UnsignedPayload{}, "ResolveEndpointV2", middleware.After) +} + +// ID returns the unsignedPayload identifier +func (m *UnsignedPayload) ID() string { + return computePayloadHashMiddlewareID +} + +// HandleFinalize sets the payload hash magic value to the unsigned sentinel. +func (m *UnsignedPayload) HandleFinalize( + ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler, +) ( + out middleware.FinalizeOutput, metadata middleware.Metadata, err error, +) { + if GetPayloadHash(ctx) == "" { + ctx = SetPayloadHash(ctx, v4Internal.UnsignedPayload) + } + return next.HandleFinalize(ctx, in) +} + +// ComputePayloadSHA256 computes SHA256 payload hash to sign. +// +// Will not set the Unsigned Payload magic SHA value, if a SHA has already been +// stored in the context. (e.g. application pre-computed SHA256 before making +// API call). +// +// This middleware does not check the X-Amz-Content-Sha256 header, if that +// header is serialized a middleware must translate it into the context. +type ComputePayloadSHA256 struct{} + +// AddComputePayloadSHA256Middleware adds computePayloadSHA256 to the +// operation middleware stack +func AddComputePayloadSHA256Middleware(stack *middleware.Stack) error { + return stack.Finalize.Insert(&ComputePayloadSHA256{}, "ResolveEndpointV2", middleware.After) +} + +// RemoveComputePayloadSHA256Middleware removes computePayloadSHA256 from the +// operation middleware stack +func RemoveComputePayloadSHA256Middleware(stack *middleware.Stack) error { + _, err := stack.Finalize.Remove(computePayloadHashMiddlewareID) + return err +} + +// ID is the middleware name +func (m *ComputePayloadSHA256) ID() string { + return computePayloadHashMiddlewareID +} + +// HandleFinalize computes the payload hash for the request, storing it to the +// context. This is a no-op if a caller has previously set that value. +func (m *ComputePayloadSHA256) HandleFinalize( + ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler, +) ( + out middleware.FinalizeOutput, metadata middleware.Metadata, err error, +) { + if GetPayloadHash(ctx) != "" { + return next.HandleFinalize(ctx, in) + } + + req, ok := in.Request.(*smithyhttp.Request) + if !ok { + return out, metadata, &HashComputationError{ + Err: fmt.Errorf("unexpected request middleware type %T", in.Request), + } + } + + hash := sha256.New() + if stream := req.GetStream(); stream != nil { + _, err = io.Copy(hash, stream) + if err != nil { + return out, metadata, &HashComputationError{ + Err: fmt.Errorf("failed to compute payload hash, %w", err), + } + } + + if err := req.RewindStream(); err != nil { + return out, metadata, &HashComputationError{ + Err: fmt.Errorf("failed to seek body to start, %w", err), + } + } + } + + ctx = SetPayloadHash(ctx, hex.EncodeToString(hash.Sum(nil))) + + return next.HandleFinalize(ctx, in) +} + +// SwapComputePayloadSHA256ForUnsignedPayloadMiddleware replaces the +// ComputePayloadSHA256 middleware with the UnsignedPayload middleware. +// +// Use this to disable computing the Payload SHA256 checksum and instead use +// UNSIGNED-PAYLOAD for the SHA256 value. +func SwapComputePayloadSHA256ForUnsignedPayloadMiddleware(stack *middleware.Stack) error { + _, err := stack.Finalize.Swap(computePayloadHashMiddlewareID, &UnsignedPayload{}) + return err +} + +// ContentSHA256Header sets the X-Amz-Content-Sha256 header value to +// the Payload hash stored in the context. +type ContentSHA256Header struct{} + +// AddContentSHA256HeaderMiddleware adds ContentSHA256Header to the +// operation middleware stack +func AddContentSHA256HeaderMiddleware(stack *middleware.Stack) error { + return stack.Finalize.Insert(&ContentSHA256Header{}, computePayloadHashMiddlewareID, middleware.After) +} + +// RemoveContentSHA256HeaderMiddleware removes contentSHA256Header middleware +// from the operation middleware stack +func RemoveContentSHA256HeaderMiddleware(stack *middleware.Stack) error { + _, err := stack.Finalize.Remove((*ContentSHA256Header)(nil).ID()) + return err +} + +// ID returns the ContentSHA256HeaderMiddleware identifier +func (m *ContentSHA256Header) ID() string { + return "SigV4ContentSHA256Header" +} + +// HandleFinalize sets the X-Amz-Content-Sha256 header value to the Payload hash +// stored in the context. +func (m *ContentSHA256Header) HandleFinalize( + ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler, +) ( + out middleware.FinalizeOutput, metadata middleware.Metadata, err error, +) { + req, ok := in.Request.(*smithyhttp.Request) + if !ok { + return out, metadata, &HashComputationError{Err: fmt.Errorf("unexpected request middleware type %T", in.Request)} + } + + req.Header.Set(v4Internal.ContentSHAKey, GetPayloadHash(ctx)) + return next.HandleFinalize(ctx, in) +} + +// SignHTTPRequestMiddlewareOptions is the configuration options for +// [SignHTTPRequestMiddleware]. +// +// Deprecated: [SignHTTPRequestMiddleware] is deprecated. +type SignHTTPRequestMiddlewareOptions struct { + CredentialsProvider aws.CredentialsProvider + Signer HTTPSigner + LogSigning bool +} + +// SignHTTPRequestMiddleware is a `FinalizeMiddleware` implementation for SigV4 +// HTTP Signing. +// +// Deprecated: AWS service clients no longer use this middleware. Signing as an +// SDK operation is now performed through an internal per-service middleware +// which opaquely selects and uses the signer from the resolved auth scheme. +type SignHTTPRequestMiddleware struct { + credentialsProvider aws.CredentialsProvider + signer HTTPSigner + logSigning bool +} + +// NewSignHTTPRequestMiddleware constructs a [SignHTTPRequestMiddleware] using +// the given [Signer] for signing requests. +// +// Deprecated: SignHTTPRequestMiddleware is deprecated. +func NewSignHTTPRequestMiddleware(options SignHTTPRequestMiddlewareOptions) *SignHTTPRequestMiddleware { + return &SignHTTPRequestMiddleware{ + credentialsProvider: options.CredentialsProvider, + signer: options.Signer, + logSigning: options.LogSigning, + } +} + +// ID is the SignHTTPRequestMiddleware identifier. +// +// Deprecated: SignHTTPRequestMiddleware is deprecated. +func (s *SignHTTPRequestMiddleware) ID() string { + return "Signing" +} + +// HandleFinalize will take the provided input and sign the request using the +// SigV4 authentication scheme. +// +// Deprecated: SignHTTPRequestMiddleware is deprecated. +func (s *SignHTTPRequestMiddleware) HandleFinalize(ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler) ( + out middleware.FinalizeOutput, metadata middleware.Metadata, err error, +) { + if !haveCredentialProvider(s.credentialsProvider) { + return next.HandleFinalize(ctx, in) + } + + req, ok := in.Request.(*smithyhttp.Request) + if !ok { + return out, metadata, &SigningError{Err: fmt.Errorf("unexpected request middleware type %T", in.Request)} + } + + signingName, signingRegion := awsmiddleware.GetSigningName(ctx), awsmiddleware.GetSigningRegion(ctx) + payloadHash := GetPayloadHash(ctx) + if len(payloadHash) == 0 { + return out, metadata, &SigningError{Err: fmt.Errorf("computed payload hash missing from context")} + } + + mctx := metrics.Context(ctx) + + if mctx != nil { + if attempt, err := mctx.Data().LatestAttempt(); err == nil { + attempt.CredentialFetchStartTime = time.Now() + } + } + + credentials, err := s.credentialsProvider.Retrieve(ctx) + + if mctx != nil { + if attempt, err := mctx.Data().LatestAttempt(); err == nil { + attempt.CredentialFetchEndTime = time.Now() + } + } + + if err != nil { + return out, metadata, &SigningError{Err: fmt.Errorf("failed to retrieve credentials: %w", err)} + } + + signerOptions := []func(o *SignerOptions){ + func(o *SignerOptions) { + o.Logger = middleware.GetLogger(ctx) + o.LogSigning = s.logSigning + }, + } + + // existing DisableURIPathEscaping is equivalent in purpose + // to authentication scheme property DisableDoubleEncoding + //disableDoubleEncoding, overridden := internalauth.GetDisableDoubleEncoding(ctx) // internalauth "github.com/aws/aws-sdk-go-v2/internal/auth" + //if overridden { + // signerOptions = append(signerOptions, func(o *SignerOptions) { + // o.DisableURIPathEscaping = disableDoubleEncoding + // }) + //} + + if mctx != nil { + if attempt, err := mctx.Data().LatestAttempt(); err == nil { + attempt.SignStartTime = time.Now() + } + } + + err = s.signer.SignHTTP(ctx, credentials, req.Request, payloadHash, signingName, signingRegion, time.Now(), signerOptions...) + + if mctx != nil { + if attempt, err := mctx.Data().LatestAttempt(); err == nil { + attempt.SignEndTime = time.Now() + } + } + + if err != nil { + return out, metadata, &SigningError{Err: fmt.Errorf("failed to sign http request, %w", err)} + } + + ctx = awsmiddleware.SetSigningCredentials(ctx, credentials) + + return next.HandleFinalize(ctx, in) +} + +// StreamingEventsPayload signs input event stream messages. +type StreamingEventsPayload struct{} + +// AddStreamingEventsPayload adds the streamingEventsPayload middleware to the stack. +func AddStreamingEventsPayload(stack *middleware.Stack) error { + return stack.Finalize.Add(&StreamingEventsPayload{}, middleware.Before) +} + +// ID identifies the middleware. +func (s *StreamingEventsPayload) ID() string { + return computePayloadHashMiddlewareID +} + +// HandleFinalize marks the input stream to be signed with SigV4. +func (s *StreamingEventsPayload) HandleFinalize( + ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler, +) ( + out middleware.FinalizeOutput, metadata middleware.Metadata, err error, +) { + contentSHA := GetPayloadHash(ctx) + if len(contentSHA) == 0 { + contentSHA = v4Internal.StreamingEventsPayload + } + + ctx = SetPayloadHash(ctx, contentSHA) + + return next.HandleFinalize(ctx, in) +} + +// GetSignedRequestSignature attempts to extract the signature of the request. +// Returning an error if the request is unsigned, or unable to extract the +// signature. +func GetSignedRequestSignature(r *http.Request) ([]byte, error) { + const authHeaderSignatureElem = "Signature=" + + if auth := r.Header.Get(authorizationHeader); len(auth) != 0 { + ps := strings.Split(auth, ", ") + for _, p := range ps { + if idx := strings.Index(p, authHeaderSignatureElem); idx >= 0 { + sig := p[len(authHeaderSignatureElem):] + if len(sig) == 0 { + return nil, fmt.Errorf("invalid request signature authorization header") + } + return hex.DecodeString(sig) + } + } + } + + if sig := r.URL.Query().Get("X-Amz-Signature"); len(sig) != 0 { + return hex.DecodeString(sig) + } + + return nil, fmt.Errorf("request not signed") +} + +func haveCredentialProvider(p aws.CredentialsProvider) bool { + if p == nil { + return false + } + + return !aws.IsCredentialsProvider(p, (*aws.AnonymousCredentials)(nil)) +} + +type payloadHashKey struct{} + +// GetPayloadHash retrieves the payload hash to use for signing +// +// Scoped to stack values. Use github.com/aws/smithy-go/middleware#ClearStackValues +// to clear all stack values. +func GetPayloadHash(ctx context.Context) (v string) { + v, _ = middleware.GetStackValue(ctx, payloadHashKey{}).(string) + return v +} + +// SetPayloadHash sets the payload hash to be used for signing the request +// +// Scoped to stack values. Use github.com/aws/smithy-go/middleware#ClearStackValues +// to clear all stack values. +func SetPayloadHash(ctx context.Context, hash string) context.Context { + return middleware.WithStackValue(ctx, payloadHashKey{}, hash) +} diff --git a/api/auth/signer/v4sdk2/signer/v4/presign_middleware.go b/api/auth/signer/v4sdk2/signer/v4/presign_middleware.go new file mode 100644 index 0000000..8522eb3 --- /dev/null +++ b/api/auth/signer/v4sdk2/signer/v4/presign_middleware.go @@ -0,0 +1,126 @@ +package v4 + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsmiddleware "github.com/aws/aws-sdk-go-v2/aws/middleware" + "github.com/aws/smithy-go/middleware" + smithyHTTP "github.com/aws/smithy-go/transport/http" +) + +// HTTPPresigner is an interface to a SigV4 signer that can sign create a +// presigned URL for a HTTP requests. +type HTTPPresigner interface { + PresignHTTP( + ctx context.Context, credentials aws.Credentials, r *http.Request, + payloadHash string, service string, region string, signingTime time.Time, + optFns ...func(*SignerOptions), + ) (url string, signedHeader http.Header, err error) +} + +// PresignedHTTPRequest provides the URL and signed headers that are included +// in the presigned URL. +type PresignedHTTPRequest struct { + URL string + Method string + SignedHeader http.Header +} + +// PresignHTTPRequestMiddlewareOptions is the options for the PresignHTTPRequestMiddleware middleware. +type PresignHTTPRequestMiddlewareOptions struct { + CredentialsProvider aws.CredentialsProvider + Presigner HTTPPresigner + LogSigning bool +} + +// PresignHTTPRequestMiddleware provides the Finalize middleware for creating a +// presigned URL for an HTTP request. +// +// Will short circuit the middleware stack and not forward onto the next +// Finalize handler. +type PresignHTTPRequestMiddleware struct { + credentialsProvider aws.CredentialsProvider + presigner HTTPPresigner + logSigning bool +} + +// NewPresignHTTPRequestMiddleware returns a new PresignHTTPRequestMiddleware +// initialized with the presigner. +func NewPresignHTTPRequestMiddleware(options PresignHTTPRequestMiddlewareOptions) *PresignHTTPRequestMiddleware { + return &PresignHTTPRequestMiddleware{ + credentialsProvider: options.CredentialsProvider, + presigner: options.Presigner, + logSigning: options.LogSigning, + } +} + +// ID provides the middleware ID. +func (*PresignHTTPRequestMiddleware) ID() string { return "PresignHTTPRequest" } + +// HandleFinalize will take the provided input and create a presigned url for +// the http request using the SigV4 presign authentication scheme. +// +// Since the signed request is not a valid HTTP request +func (s *PresignHTTPRequestMiddleware) HandleFinalize( + ctx context.Context, in middleware.FinalizeInput, next middleware.FinalizeHandler, +) ( + out middleware.FinalizeOutput, metadata middleware.Metadata, err error, +) { + req, ok := in.Request.(*smithyHTTP.Request) + if !ok { + return out, metadata, &SigningError{ + Err: fmt.Errorf("unexpected request middleware type %T", in.Request), + } + } + + httpReq := req.Build(ctx) + if !haveCredentialProvider(s.credentialsProvider) { + out.Result = &PresignedHTTPRequest{ + URL: httpReq.URL.String(), + Method: httpReq.Method, + SignedHeader: http.Header{}, + } + + return out, metadata, nil + } + + signingName := awsmiddleware.GetSigningName(ctx) + signingRegion := awsmiddleware.GetSigningRegion(ctx) + payloadHash := GetPayloadHash(ctx) + if len(payloadHash) == 0 { + return out, metadata, &SigningError{ + Err: fmt.Errorf("computed payload hash missing from context"), + } + } + + credentials, err := s.credentialsProvider.Retrieve(ctx) + if err != nil { + return out, metadata, &SigningError{ + Err: fmt.Errorf("failed to retrieve credentials: %w", err), + } + } + + u, h, err := s.presigner.PresignHTTP(ctx, credentials, + httpReq, payloadHash, signingName, signingRegion, time.Now(), + func(o *SignerOptions) { + o.Logger = middleware.GetLogger(ctx) + o.LogSigning = s.logSigning + }) + if err != nil { + return out, metadata, &SigningError{ + Err: fmt.Errorf("failed to sign http request, %w", err), + } + } + + out.Result = &PresignedHTTPRequest{ + URL: u, + Method: httpReq.Method, + SignedHeader: h, + } + + return out, metadata, nil +} diff --git a/api/auth/signer/v4sdk2/signer/v4/stream.go b/api/auth/signer/v4sdk2/signer/v4/stream.go new file mode 100644 index 0000000..aa23387 --- /dev/null +++ b/api/auth/signer/v4sdk2/signer/v4/stream.go @@ -0,0 +1,87 @@ +package v4 + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "strings" + "time" + + v4Internal "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/auth/signer/v4sdk2/signer/internal/v4" + "github.com/aws/aws-sdk-go-v2/aws" +) + +// EventStreamSigner is an AWS EventStream protocol signer. +type EventStreamSigner interface { + GetSignature(ctx context.Context, headers, payload []byte, signingTime time.Time, optFns ...func(*StreamSignerOptions)) ([]byte, error) +} + +// StreamSignerOptions is the configuration options for StreamSigner. +type StreamSignerOptions struct{} + +// StreamSigner implements Signature Version 4 (SigV4) signing of event stream encoded payloads. +type StreamSigner struct { + options StreamSignerOptions + + credentials aws.Credentials + service string + region string + + prevSignature []byte + + signingKeyDeriver *v4Internal.SigningKeyDeriver +} + +// NewStreamSigner returns a new AWS EventStream protocol signer. +func NewStreamSigner(credentials aws.Credentials, service, region string, seedSignature []byte, optFns ...func(*StreamSignerOptions)) *StreamSigner { + o := StreamSignerOptions{} + + for _, fn := range optFns { + fn(&o) + } + + return &StreamSigner{ + options: o, + credentials: credentials, + service: service, + region: region, + signingKeyDeriver: v4Internal.NewSigningKeyDeriver(), + prevSignature: seedSignature, + } +} + +// GetSignature signs the provided header and payload bytes. +func (s *StreamSigner) GetSignature(ctx context.Context, headers, payload []byte, signingTime time.Time, optFns ...func(*StreamSignerOptions)) ([]byte, error) { + options := s.options + + for _, fn := range optFns { + fn(&options) + } + + prevSignature := s.prevSignature + + st := v4Internal.NewSigningTime(signingTime) + + sigKey := s.signingKeyDeriver.DeriveKey(s.credentials, s.service, s.region, st) + + scope := v4Internal.BuildCredentialScope(st, s.region, s.service) + + stringToSign := s.buildEventStreamStringToSign(headers, payload, prevSignature, scope, &st) + + signature := v4Internal.HMACSHA256(sigKey, []byte(stringToSign)) + s.prevSignature = signature + + return signature, nil +} + +func (s *StreamSigner) buildEventStreamStringToSign(headers, payload, previousSignature []byte, credentialScope string, signingTime *v4Internal.SigningTime) string { + hash := sha256.New() + return strings.Join([]string{ + "AWS4-HMAC-SHA256-PAYLOAD", + signingTime.TimeFormat(), + credentialScope, + hex.EncodeToString(previousSignature), + hex.EncodeToString(makeHash(hash, headers)), + hex.EncodeToString(makeHash(hash, payload)), + }, "\n") +} diff --git a/api/auth/signer/v4sdk2/signer/v4/v4.go b/api/auth/signer/v4sdk2/signer/v4/v4.go new file mode 100644 index 0000000..f79ed5c --- /dev/null +++ b/api/auth/signer/v4sdk2/signer/v4/v4.go @@ -0,0 +1,570 @@ +// Package v4 implements signing for AWS V4 signer +// +// Provides request signing for request that need to be signed with +// AWS V4 Signatures. +// +// # Standalone Signer +// +// Generally using the signer outside of the SDK should not require any additional +// +// The signer does this by taking advantage of the URL.EscapedPath method. If your request URI requires +// +// additional escaping you many need to use the URL.Opaque to define what the raw URI should be sent +// to the service as. +// +// The signer will first check the URL.Opaque field, and use its value if set. +// The signer does require the URL.Opaque field to be set in the form of: +// +// "///" +// +// // e.g. +// "//example.com/some/path" +// +// The leading "//" and hostname are required or the URL.Opaque escaping will +// not work correctly. +// +// If URL.Opaque is not set the signer will fallback to the URL.EscapedPath() +// method and using the returned value. +// +// AWS v4 signature validation requires that the canonical string's URI path +// element must be the URI escaped form of the HTTP request's path. +// http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html +// +// The Go HTTP client will perform escaping automatically on the request. Some +// of these escaping may cause signature validation errors because the HTTP +// request differs from the URI path or query that the signature was generated. +// https://golang.org/pkg/net/url/#URL.EscapedPath +// +// Because of this, it is recommended that when using the signer outside of the +// SDK that explicitly escaping the request prior to being signed is preferable, +// and will help prevent signature validation errors. This can be done by setting +// the URL.Opaque or URL.RawPath. The SDK will use URL.Opaque first and then +// call URL.EscapedPath() if Opaque is not set. +// +// Test `TestStandaloneSign` provides a complete example of using the signer +// outside of the SDK and pre-escaping the URI path. +package v4 + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "hash" + "net/http" + "net/textproto" + "net/url" + "sort" + "strconv" + "strings" + "time" + + v4Internal "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/auth/signer/v4sdk2/signer/internal/v4" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/smithy-go/encoding/httpbinding" + "github.com/aws/smithy-go/logging" +) + +const ( + signingAlgorithm = "AWS4-HMAC-SHA256" + authorizationHeader = "Authorization" + + // Version of signing v4 + Version = "SigV4" +) + +// HTTPSigner is an interface to a SigV4 signer that can sign HTTP requests +type HTTPSigner interface { + SignHTTP(ctx context.Context, credentials aws.Credentials, r *http.Request, payloadHash string, service string, region string, signingTime time.Time, optFns ...func(*SignerOptions)) error +} + +type keyDerivator interface { + DeriveKey(credential aws.Credentials, service, region string, signingTime v4Internal.SigningTime) []byte +} + +// SignerOptions is the SigV4 Signer options. +type SignerOptions struct { + // Disables the Signer's moving HTTP header key/value pairs from the HTTP + // request header to the request's query string. This is most commonly used + // with pre-signed requests preventing headers from being added to the + // request's query string. + DisableHeaderHoisting bool + + // Disables the automatic escaping of the URI path of the request for the + // siganture's canonical string's path. For services that do not need additional + // escaping then use this to disable the signer escaping the path. + // + // S3 is an example of a service that does not need additional escaping. + // + // http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html + DisableURIPathEscaping bool + + // The logger to send log messages to. + Logger logging.Logger + + // Enable logging of signed requests. + // This will enable logging of the canonical request, the string to sign, and for presigning the subsequent + // presigned URL. + LogSigning bool + + // Disables setting the session token on the request as part of signing + // through X-Amz-Security-Token. This is needed for variations of v4 that + // present the token elsewhere. + DisableSessionToken bool +} + +// Signer applies AWS v4 signing to given request. Use this to sign requests +// that need to be signed with AWS V4 Signatures. +type Signer struct { + options SignerOptions + keyDerivator keyDerivator +} + +// NewSigner returns a new SigV4 Signer +func NewSigner(optFns ...func(signer *SignerOptions)) *Signer { + options := SignerOptions{} + + for _, fn := range optFns { + fn(&options) + } + + return &Signer{options: options, keyDerivator: v4Internal.NewSigningKeyDeriver()} +} + +type httpSigner struct { + Request *http.Request + ServiceName string + Region string + Time v4Internal.SigningTime + Credentials aws.Credentials + KeyDerivator keyDerivator + IsPreSign bool + + PayloadHash string + + DisableHeaderHoisting bool + DisableURIPathEscaping bool + DisableSessionToken bool +} + +func (s *httpSigner) Build() (signedRequest, error) { + req := s.Request + + query := req.URL.Query() + headers := req.Header + + s.setRequiredSigningFields(headers, query) + + // Sort Each Query Key's Values + for key := range query { + sort.Strings(query[key]) + } + + v4Internal.SanitizeHostForHeader(req) + + credentialScope := s.buildCredentialScope() + credentialStr := s.Credentials.AccessKeyID + "/" + credentialScope + if s.IsPreSign { + query.Set(v4Internal.AmzCredentialKey, credentialStr) + } + + unsignedHeaders := headers + if s.IsPreSign && !s.DisableHeaderHoisting { + var urlValues url.Values + urlValues, unsignedHeaders = buildQuery(v4Internal.AllowedQueryHoisting, headers) + for k := range urlValues { + query[k] = urlValues[k] + } + } + + host := req.URL.Host + if len(req.Host) > 0 { + host = req.Host + } + + var ( + signedHeaders http.Header + signedHeadersStr string + canonicalHeaderStr string + ) + + if s.IsPreSign { + signedHeaders, signedHeadersStr, canonicalHeaderStr = s.buildCanonicalHeaders(host, v4Internal.IgnoredPresignedHeaders, unsignedHeaders, s.Request.ContentLength) + } else { + signedHeaders, signedHeadersStr, canonicalHeaderStr = s.buildCanonicalHeaders(host, v4Internal.IgnoredHeaders, unsignedHeaders, s.Request.ContentLength) + } + + if s.IsPreSign { + query.Set(v4Internal.AmzSignedHeadersKey, signedHeadersStr) + } + + var rawQuery strings.Builder + rawQuery.WriteString(strings.Replace(query.Encode(), "+", "%20", -1)) + + canonicalURI := v4Internal.GetURIPath(req.URL) + if !s.DisableURIPathEscaping { + canonicalURI = httpbinding.EscapePath(canonicalURI, false) + } + + canonicalString := s.buildCanonicalString( + req.Method, + canonicalURI, + rawQuery.String(), + signedHeadersStr, + canonicalHeaderStr, + ) + + strToSign := s.buildStringToSign(credentialScope, canonicalString) + signingSignature, err := s.buildSignature(strToSign) + if err != nil { + return signedRequest{}, err + } + + if s.IsPreSign { + rawQuery.WriteString("&X-Amz-Signature=") + rawQuery.WriteString(signingSignature) + } else { + headers[authorizationHeader] = append(headers[authorizationHeader][:0], buildAuthorizationHeader(credentialStr, signedHeadersStr, signingSignature)) + } + + req.URL.RawQuery = rawQuery.String() + + return signedRequest{ + Request: req, + SignedHeaders: signedHeaders, + CanonicalString: canonicalString, + StringToSign: strToSign, + PreSigned: s.IsPreSign, + }, nil +} + +func buildAuthorizationHeader(credentialStr, signedHeadersStr, signingSignature string) string { + const credential = "Credential=" + const signedHeaders = "SignedHeaders=" + const signature = "Signature=" + const commaSpace = ", " + + var parts strings.Builder + parts.Grow(len(signingAlgorithm) + 1 + + len(credential) + len(credentialStr) + 2 + + len(signedHeaders) + len(signedHeadersStr) + 2 + + len(signature) + len(signingSignature), + ) + parts.WriteString(signingAlgorithm) + parts.WriteRune(' ') + parts.WriteString(credential) + parts.WriteString(credentialStr) + parts.WriteString(commaSpace) + parts.WriteString(signedHeaders) + parts.WriteString(signedHeadersStr) + parts.WriteString(commaSpace) + parts.WriteString(signature) + parts.WriteString(signingSignature) + return parts.String() +} + +// SignHTTP signs AWS v4 requests with the provided payload hash, service name, region the +// request is made to, and time the request is signed at. The signTime allows +// you to specify that a request is signed for the future, and cannot be +// used until then. +// +// The payloadHash is the hex encoded SHA-256 hash of the request payload, and +// must be provided. Even if the request has no payload (aka body). If the +// request has no payload you should use the hex encoded SHA-256 of an empty +// string as the payloadHash value. +// +// "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" +// +// Some services such as Amazon S3 accept alternative values for the payload +// hash, such as "UNSIGNED-PAYLOAD" for requests where the body will not be +// included in the request signature. +// +// https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html +// +// Sign differs from Presign in that it will sign the request using HTTP +// header values. This type of signing is intended for http.Request values that +// will not be shared, or are shared in a way the header values on the request +// will not be lost. +// +// The passed in request will be modified in place. +func (s Signer) SignHTTP(ctx context.Context, credentials aws.Credentials, r *http.Request, payloadHash string, service string, region string, signingTime time.Time, optFns ...func(options *SignerOptions)) error { + options := s.options + + for _, fn := range optFns { + fn(&options) + } + + signer := &httpSigner{ + Request: r, + PayloadHash: payloadHash, + ServiceName: service, + Region: region, + Credentials: credentials, + Time: v4Internal.NewSigningTime(signingTime.UTC()), + DisableHeaderHoisting: options.DisableHeaderHoisting, + DisableURIPathEscaping: options.DisableURIPathEscaping, + DisableSessionToken: options.DisableSessionToken, + KeyDerivator: s.keyDerivator, + } + + signedRequest, err := signer.Build() + if err != nil { + return err + } + + logSigningInfo(ctx, options, &signedRequest, false) + + return nil +} + +// PresignHTTP signs AWS v4 requests with the payload hash, service name, region +// the request is made to, and time the request is signed at. The signTime +// allows you to specify that a request is signed for the future, and cannot +// be used until then. +// +// Returns the signed URL and the map of HTTP headers that were included in the +// signature or an error if signing the request failed. For presigned requests +// these headers and their values must be included on the HTTP request when it +// is made. This is helpful to know what header values need to be shared with +// the party the presigned request will be distributed to. +// +// The payloadHash is the hex encoded SHA-256 hash of the request payload, and +// must be provided. Even if the request has no payload (aka body). If the +// request has no payload you should use the hex encoded SHA-256 of an empty +// string as the payloadHash value. +// +// "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" +// +// Some services such as Amazon S3 accept alternative values for the payload +// hash, such as "UNSIGNED-PAYLOAD" for requests where the body will not be +// included in the request signature. +// +// https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html +// +// PresignHTTP differs from SignHTTP in that it will sign the request using +// query string instead of header values. This allows you to share the +// Presigned Request's URL with third parties, or distribute it throughout your +// system with minimal dependencies. +// +// PresignHTTP will not set the expires time of the presigned request +// automatically. To specify the expire duration for a request add the +// "X-Amz-Expires" query parameter on the request with the value as the +// duration in seconds the presigned URL should be considered valid for. This +// parameter is not used by all AWS services, and is most notable used by +// Amazon S3 APIs. +// +// expires := 20 * time.Minute +// query := req.URL.Query() +// query.Set("X-Amz-Expires", strconv.FormatInt(int64(expires/time.Second), 10)) +// req.URL.RawQuery = query.Encode() +// +// This method does not modify the provided request. +func (s *Signer) PresignHTTP( + ctx context.Context, credentials aws.Credentials, r *http.Request, + payloadHash string, service string, region string, signingTime time.Time, + optFns ...func(*SignerOptions), +) (signedURI string, signedHeaders http.Header, err error) { + options := s.options + + for _, fn := range optFns { + fn(&options) + } + + signer := &httpSigner{ + Request: r.Clone(r.Context()), + PayloadHash: payloadHash, + ServiceName: service, + Region: region, + Credentials: credentials, + Time: v4Internal.NewSigningTime(signingTime.UTC()), + IsPreSign: true, + DisableHeaderHoisting: options.DisableHeaderHoisting, + DisableURIPathEscaping: options.DisableURIPathEscaping, + DisableSessionToken: options.DisableSessionToken, + KeyDerivator: s.keyDerivator, + } + + signedRequest, err := signer.Build() + if err != nil { + return "", nil, err + } + + logSigningInfo(ctx, options, &signedRequest, true) + + signedHeaders = make(http.Header) + + // For the signed headers we canonicalize the header keys in the returned map. + // This avoids situations where can standard library double headers like host header. For example the standard + // library will set the Host header, even if it is present in lower-case form. + for k, v := range signedRequest.SignedHeaders { + key := textproto.CanonicalMIMEHeaderKey(k) + signedHeaders[key] = append(signedHeaders[key], v...) + } + + return signedRequest.Request.URL.String(), signedHeaders, nil +} + +func (s *httpSigner) buildCredentialScope() string { + return v4Internal.BuildCredentialScope(s.Time, s.Region, s.ServiceName) +} + +func buildQuery(r v4Internal.Rule, header http.Header) (url.Values, http.Header) { + query := url.Values{} + unsignedHeaders := http.Header{} + for k, h := range header { + if r.IsValid(k) { + query[k] = h + } else { + unsignedHeaders[k] = h + } + } + + return query, unsignedHeaders +} + +func (s *httpSigner) buildCanonicalHeaders(host string, rule v4Internal.Rule, header http.Header, length int64) (signed http.Header, signedHeaders, canonicalHeadersStr string) { + signed = make(http.Header) + + var headers []string + const hostHeader = "host" + headers = append(headers, hostHeader) + signed[hostHeader] = append(signed[hostHeader], host) + + const contentLengthHeader = "content-length" + if length > 0 { + headers = append(headers, contentLengthHeader) + signed[contentLengthHeader] = append(signed[contentLengthHeader], strconv.FormatInt(length, 10)) + } + + for k, v := range header { + if !rule.IsValid(k) { + continue // ignored header + } + if strings.EqualFold(k, contentLengthHeader) { + // prevent signing already handled content-length header. + continue + } + + lowerCaseKey := strings.ToLower(k) + if _, ok := signed[lowerCaseKey]; ok { + // include additional values + signed[lowerCaseKey] = append(signed[lowerCaseKey], v...) + continue + } + + headers = append(headers, lowerCaseKey) + signed[lowerCaseKey] = v + } + sort.Strings(headers) + + signedHeaders = strings.Join(headers, ";") + + var canonicalHeaders strings.Builder + n := len(headers) + const colon = ':' + for i := 0; i < n; i++ { + if headers[i] == hostHeader { + canonicalHeaders.WriteString(hostHeader) + canonicalHeaders.WriteRune(colon) + canonicalHeaders.WriteString(v4Internal.StripExcessSpaces(host)) + } else { + canonicalHeaders.WriteString(headers[i]) + canonicalHeaders.WriteRune(colon) + // Trim out leading, trailing, and dedup inner spaces from signed header values. + values := signed[headers[i]] + for j, v := range values { + cleanedValue := strings.TrimSpace(v4Internal.StripExcessSpaces(v)) + canonicalHeaders.WriteString(cleanedValue) + if j < len(values)-1 { + canonicalHeaders.WriteRune(',') + } + } + } + canonicalHeaders.WriteRune('\n') + } + canonicalHeadersStr = canonicalHeaders.String() + + return signed, signedHeaders, canonicalHeadersStr +} + +func (s *httpSigner) buildCanonicalString(method, uri, query, signedHeaders, canonicalHeaders string) string { + return strings.Join([]string{ + method, + uri, + query, + canonicalHeaders, + signedHeaders, + s.PayloadHash, + }, "\n") +} + +func (s *httpSigner) buildStringToSign(credentialScope, canonicalRequestString string) string { + return strings.Join([]string{ + signingAlgorithm, + s.Time.TimeFormat(), + credentialScope, + hex.EncodeToString(makeHash(sha256.New(), []byte(canonicalRequestString))), + }, "\n") +} + +func makeHash(hash hash.Hash, b []byte) []byte { + hash.Reset() + hash.Write(b) + return hash.Sum(nil) +} + +func (s *httpSigner) buildSignature(strToSign string) (string, error) { + key := s.KeyDerivator.DeriveKey(s.Credentials, s.ServiceName, s.Region, s.Time) + return hex.EncodeToString(v4Internal.HMACSHA256(key, []byte(strToSign))), nil +} + +func (s *httpSigner) setRequiredSigningFields(headers http.Header, query url.Values) { + amzDate := s.Time.TimeFormat() + + if s.IsPreSign { + query.Set(v4Internal.AmzAlgorithmKey, signingAlgorithm) + sessionToken := s.Credentials.SessionToken + if !s.DisableSessionToken && len(sessionToken) > 0 { + query.Set("X-Amz-Security-Token", sessionToken) + } + + query.Set(v4Internal.AmzDateKey, amzDate) + return + } + + headers[v4Internal.AmzDateKey] = append(headers[v4Internal.AmzDateKey][:0], amzDate) + + if !s.DisableSessionToken && len(s.Credentials.SessionToken) > 0 { + headers[v4Internal.AmzSecurityTokenKey] = append(headers[v4Internal.AmzSecurityTokenKey][:0], s.Credentials.SessionToken) + } +} + +func logSigningInfo(ctx context.Context, options SignerOptions, request *signedRequest, isPresign bool) { + if !options.LogSigning { + return + } + signedURLMsg := "" + if isPresign { + signedURLMsg = fmt.Sprintf(logSignedURLMsg, request.Request.URL.String()) + } + logger := logging.WithContext(ctx, options.Logger) + logger.Logf(logging.Debug, logSignInfoMsg, request.CanonicalString, request.StringToSign, signedURLMsg) +} + +type signedRequest struct { + Request *http.Request + SignedHeaders http.Header + CanonicalString string + StringToSign string + PreSigned bool +} + +const logSignInfoMsg = `Request Signature: +---[ CANONICAL STRING ]----------------------------- +%s +---[ STRING TO SIGN ]-------------------------------- +%s%s +-----------------------------------------------------` +const logSignedURLMsg = ` +---[ SIGNED URL ]------------------------------------ +%s` diff --git a/api/auth/signer/v4sdk2/signer/v4/v4_test.go b/api/auth/signer/v4sdk2/signer/v4/v4_test.go new file mode 100644 index 0000000..420afd9 --- /dev/null +++ b/api/auth/signer/v4sdk2/signer/v4/v4_test.go @@ -0,0 +1,363 @@ +package v4 + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "reflect" + "strings" + "testing" + "time" + + v4Internal "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/auth/signer/v4sdk2/signer/internal/v4" + "github.com/aws/aws-sdk-go-v2/aws" +) + +var testCredentials = aws.Credentials{AccessKeyID: "AKID", SecretAccessKey: "SECRET", SessionToken: "SESSION"} + +func buildRequest(serviceName, region, body string) (*http.Request, string) { + reader := strings.NewReader(body) + return buildRequestWithBodyReader(serviceName, region, reader) +} + +func buildRequestWithBodyReader(serviceName, region string, body io.Reader) (*http.Request, string) { + var bodyLen int + + type lenner interface { + Len() int + } + if lr, ok := body.(lenner); ok { + bodyLen = lr.Len() + } + + endpoint := "https://" + serviceName + "." + region + ".amazonaws.com" + req, _ := http.NewRequest("POST", endpoint, body) + req.URL.Opaque = "//example.org/bucket/key-._~,!@#$%^&*()" + req.Header.Set("X-Amz-Target", "prefix.Operation") + req.Header.Set("Content-Type", "application/x-amz-json-1.0") + + if bodyLen > 0 { + req.ContentLength = int64(bodyLen) + } + + req.Header.Set("X-Amz-Meta-Other-Header", "some-value=!@#$%^&* (+)") + req.Header.Add("X-Amz-Meta-Other-Header_With_Underscore", "some-value=!@#$%^&* (+)") + req.Header.Add("X-amz-Meta-Other-Header_With_Underscore", "some-value=!@#$%^&* (+)") + + h := sha256.New() + _, _ = io.Copy(h, body) + payloadHash := hex.EncodeToString(h.Sum(nil)) + + return req, payloadHash +} + +func TestPresignRequest(t *testing.T) { + req, body := buildRequest("dynamodb", "us-east-1", "{}") + + query := req.URL.Query() + query.Set("X-Amz-Expires", "300") + req.URL.RawQuery = query.Encode() + + signer := NewSigner() + signed, headers, err := signer.PresignHTTP(context.Background(), testCredentials, req, body, "dynamodb", "us-east-1", time.Unix(0, 0)) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + expectedDate := "19700101T000000Z" + expectedHeaders := "content-length;content-type;host;x-amz-meta-other-header;x-amz-meta-other-header_with_underscore" + expectedSig := "122f0b9e091e4ba84286097e2b3404a1f1f4c4aad479adda95b7dff0ccbe5581" + expectedCred := "AKID/19700101/us-east-1/dynamodb/aws4_request" + expectedTarget := "prefix.Operation" + + q, err := url.ParseQuery(signed[strings.Index(signed, "?"):]) + if err != nil { + t.Errorf("expect no error, got %v", err) + } + + if e, a := expectedSig, q.Get("X-Amz-Signature"); e != a { + t.Errorf("expect %v, got %v", e, a) + } + if e, a := expectedCred, q.Get("X-Amz-Credential"); e != a { + t.Errorf("expect %v, got %v", e, a) + } + if e, a := expectedHeaders, q.Get("X-Amz-SignedHeaders"); e != a { + t.Errorf("expect %v, got %v", e, a) + } + if e, a := expectedDate, q.Get("X-Amz-Date"); e != a { + t.Errorf("expect %v, got %v", e, a) + } + if a := q.Get("X-Amz-Meta-Other-Header"); len(a) != 0 { + t.Errorf("expect %v to be empty", a) + } + if e, a := expectedTarget, q.Get("X-Amz-Target"); e != a { + t.Errorf("expect %v, got %v", e, a) + } + + for _, h := range strings.Split(expectedHeaders, ";") { + v := headers.Get(h) + if len(v) == 0 { + t.Errorf("expect %v, to be present in header map", h) + } + } +} + +func TestPresignBodyWithArrayRequest(t *testing.T) { + req, body := buildRequest("dynamodb", "us-east-1", "{}") + req.URL.RawQuery = "Foo=z&Foo=o&Foo=m&Foo=a" + + query := req.URL.Query() + query.Set("X-Amz-Expires", "300") + req.URL.RawQuery = query.Encode() + + signer := NewSigner() + signed, headers, err := signer.PresignHTTP(context.Background(), testCredentials, req, body, "dynamodb", "us-east-1", time.Unix(0, 0)) + if err != nil { + t.Fatalf("expect no error, got %v", err) + } + + q, err := url.ParseQuery(signed[strings.Index(signed, "?"):]) + if err != nil { + t.Errorf("expect no error, got %v", err) + } + + expectedDate := "19700101T000000Z" + expectedHeaders := "content-length;content-type;host;x-amz-meta-other-header;x-amz-meta-other-header_with_underscore" + expectedSig := "e3ac55addee8711b76c6d608d762cff285fe8b627a057f8b5ec9268cf82c08b1" + expectedCred := "AKID/19700101/us-east-1/dynamodb/aws4_request" + expectedTarget := "prefix.Operation" + + if e, a := expectedSig, q.Get("X-Amz-Signature"); e != a { + t.Errorf("expect %v, got %v", e, a) + } + if e, a := expectedCred, q.Get("X-Amz-Credential"); e != a { + t.Errorf("expect %v, got %v", e, a) + } + if e, a := expectedHeaders, q.Get("X-Amz-SignedHeaders"); e != a { + t.Errorf("expect %v, got %v", e, a) + } + if e, a := expectedDate, q.Get("X-Amz-Date"); e != a { + t.Errorf("expect %v, got %v", e, a) + } + if a := q.Get("X-Amz-Meta-Other-Header"); len(a) != 0 { + t.Errorf("expect %v to be empty, was not", a) + } + if e, a := expectedTarget, q.Get("X-Amz-Target"); e != a { + t.Errorf("expect %v, got %v", e, a) + } + + for _, h := range strings.Split(expectedHeaders, ";") { + v := headers.Get(h) + if len(v) == 0 { + t.Errorf("expect %v, to be present in header map", h) + } + } +} + +func TestSignRequest(t *testing.T) { + req, body := buildRequest("dynamodb", "us-east-1", "{}") + signer := NewSigner() + err := signer.SignHTTP(context.Background(), testCredentials, req, body, "dynamodb", "us-east-1", time.Unix(0, 0)) + if err != nil { + t.Fatalf("expect no error, got %v", err) + } + + expectedDate := "19700101T000000Z" + expectedSig := "AWS4-HMAC-SHA256 Credential=AKID/19700101/us-east-1/dynamodb/aws4_request, SignedHeaders=content-length;content-type;host;x-amz-date;x-amz-meta-other-header;x-amz-meta-other-header_with_underscore;x-amz-security-token;x-amz-target, Signature=a518299330494908a70222cec6899f6f32f297f8595f6df1776d998936652ad9" + + q := req.Header + if e, a := expectedSig, q.Get("Authorization"); e != a { + t.Errorf("expect %v, got %v", e, a) + } + if e, a := expectedDate, q.Get("X-Amz-Date"); e != a { + t.Errorf("expect %v, got %v", e, a) + } +} + +func TestBuildCanonicalRequest(t *testing.T) { + req, _ := buildRequest("dynamodb", "us-east-1", "{}") + req.URL.RawQuery = "Foo=z&Foo=o&Foo=m&Foo=a" + + ctx := &httpSigner{ + ServiceName: "dynamodb", + Region: "us-east-1", + Request: req, + Time: v4Internal.NewSigningTime(time.Now()), + KeyDerivator: v4Internal.NewSigningKeyDeriver(), + } + + build, err := ctx.Build() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + expected := "https://example.org/bucket/key-._~,!@#$%^&*()?Foo=a&Foo=m&Foo=o&Foo=z" + if e, a := expected, build.Request.URL.String(); e != a { + t.Errorf("expect %v, got %v", e, a) + } +} + +func TestSigner_SignHTTP_NoReplaceRequestBody(t *testing.T) { + req, bodyHash := buildRequest("dynamodb", "us-east-1", "{}") + req.Body = ioutil.NopCloser(bytes.NewReader([]byte{})) + + s := NewSigner() + + origBody := req.Body + + err := s.SignHTTP(context.Background(), testCredentials, req, bodyHash, "dynamodb", "us-east-1", time.Now()) + if err != nil { + t.Fatalf("expect no error, got %v", err) + } + + if req.Body != origBody { + t.Errorf("expect request body to not be chagned") + } +} + +func TestRequestHost(t *testing.T) { + req, _ := buildRequest("dynamodb", "us-east-1", "{}") + req.URL.RawQuery = "Foo=z&Foo=o&Foo=m&Foo=a" + req.Host = "myhost" + + query := req.URL.Query() + query.Set("X-Amz-Expires", "5") + req.URL.RawQuery = query.Encode() + + ctx := &httpSigner{ + ServiceName: "dynamodb", + Region: "us-east-1", + Request: req, + Time: v4Internal.NewSigningTime(time.Now()), + KeyDerivator: v4Internal.NewSigningKeyDeriver(), + } + + build, err := ctx.Build() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if !strings.Contains(build.CanonicalString, "host:"+req.Host) { + t.Errorf("canonical host header invalid") + } +} + +func TestSign_buildCanonicalHeadersContentLengthPresent(t *testing.T) { + body := `{"description": "this is a test"}` + req, _ := buildRequest("dynamodb", "us-east-1", body) + req.URL.RawQuery = "Foo=z&Foo=o&Foo=m&Foo=a" + req.Host = "myhost" + + contentLength := fmt.Sprintf("%d", len([]byte(body))) + req.Header.Add("Content-Length", contentLength) + + query := req.URL.Query() + query.Set("X-Amz-Expires", "5") + req.URL.RawQuery = query.Encode() + + ctx := &httpSigner{ + ServiceName: "dynamodb", + Region: "us-east-1", + Request: req, + Time: v4Internal.NewSigningTime(time.Now()), + KeyDerivator: v4Internal.NewSigningKeyDeriver(), + } + + build, err := ctx.Build() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if !strings.Contains(build.CanonicalString, "content-length:"+contentLength+"\n") { + t.Errorf("canonical header content-length invalid") + } +} + +func TestSign_buildCanonicalHeaders(t *testing.T) { + serviceName := "mockAPI" + region := "mock-region" + endpoint := "https://" + serviceName + "." + region + ".amazonaws.com" + + req, err := http.NewRequest("POST", endpoint, nil) + if err != nil { + t.Fatalf("failed to create request, %v", err) + } + + req.Header.Set("FooInnerSpace", " inner space ") + req.Header.Set("FooLeadingSpace", " leading-space") + req.Header.Add("FooMultipleSpace", "no-space") + req.Header.Add("FooMultipleSpace", "\ttab-space") + req.Header.Add("FooMultipleSpace", "trailing-space ") + req.Header.Set("FooNoSpace", "no-space") + req.Header.Set("FooTabSpace", "\ttab-space\t") + req.Header.Set("FooTrailingSpace", "trailing-space ") + req.Header.Set("FooWrappedSpace", " wrapped-space ") + + ctx := &httpSigner{ + ServiceName: serviceName, + Region: region, + Request: req, + Time: v4Internal.NewSigningTime(time.Date(2021, 10, 20, 12, 42, 0, 0, time.UTC)), + KeyDerivator: v4Internal.NewSigningKeyDeriver(), + } + + build, err := ctx.Build() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + expectCanonicalString := strings.Join([]string{ + `POST`, + `/`, + ``, + `fooinnerspace:inner space`, + `fooleadingspace:leading-space`, + `foomultiplespace:no-space,tab-space,trailing-space`, + `foonospace:no-space`, + `footabspace:tab-space`, + `footrailingspace:trailing-space`, + `foowrappedspace:wrapped-space`, + `host:mockAPI.mock-region.amazonaws.com`, + `x-amz-date:20211020T124200Z`, + ``, + `fooinnerspace;fooleadingspace;foomultiplespace;foonospace;footabspace;footrailingspace;foowrappedspace;host;x-amz-date`, + ``, + }, "\n") + if diff := cmpDiff(expectCanonicalString, build.CanonicalString); diff != "" { + t.Errorf("expect match, got\n%s", diff) + } +} + +func BenchmarkPresignRequest(b *testing.B) { + signer := NewSigner() + req, bodyHash := buildRequest("dynamodb", "us-east-1", "{}") + + query := req.URL.Query() + query.Set("X-Amz-Expires", "5") + req.URL.RawQuery = query.Encode() + + for i := 0; i < b.N; i++ { + signer.PresignHTTP(context.Background(), testCredentials, req, bodyHash, "dynamodb", "us-east-1", time.Now()) + } +} + +func BenchmarkSignRequest(b *testing.B) { + signer := NewSigner() + req, bodyHash := buildRequest("dynamodb", "us-east-1", "{}") + for i := 0; i < b.N; i++ { + signer.SignHTTP(context.Background(), testCredentials, req, bodyHash, "dynamodb", "us-east-1", time.Now()) + } +} + +func cmpDiff(e, a interface{}) string { + if !reflect.DeepEqual(e, a) { + return fmt.Sprintf("%v != %v", e, a) + } + return "" +} diff --git a/api/handler/put.go b/api/handler/put.go index 28d6635..434fec8 100644 --- a/api/handler/put.go +++ b/api/handler/put.go @@ -351,7 +351,8 @@ func (h *handler) PutObjectHandler(w http.ResponseWriter, r *http.Request) { } func (h *handler) getBodyReader(r *http.Request) (io.ReadCloser, error) { - if !api.IsSignedStreamingV4(r) { + shaType, streaming := api.IsSignedStreamingV4(r) + if !streaming { return r.Body, nil } @@ -384,7 +385,16 @@ func (h *handler) getBodyReader(r *http.Request) (io.ReadCloser, error) { return nil, fmt.Errorf("%w: parse decoded content length: %s", errors.GetAPIError(errors.ErrMissingContentLength), err.Error()) } - chunkReader, err := newSignV4ChunkedReader(r) + var ( + err error + chunkReader io.ReadCloser + ) + if shaType == api.StreamingContentV4aSHA256 { + chunkReader, err = newSignV4aChunkedReader(r) + } else { + chunkReader, err = newSignV4ChunkedReader(r) + } + if err != nil { return nil, fmt.Errorf("initialize chunk reader: %w", err) } diff --git a/api/handler/s3v4aReader.go b/api/handler/s3v4aReader.go new file mode 100644 index 0000000..75d3491 --- /dev/null +++ b/api/handler/s3v4aReader.go @@ -0,0 +1,209 @@ +package handler + +import ( + "bufio" + "bytes" + "encoding/hex" + "fmt" + "io" + "net/http" + "time" + + v4a "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/auth/signer/v4asdk2" + errs "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/errors" + "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/middleware" + credentialsv2 "github.com/aws/aws-sdk-go-v2/credentials" +) + +type ( + s3v4aChunkReader struct { + reader *bufio.Reader + streamSigner *v4a.StreamSigner + + requestTime time.Time + buffer []byte + offset int + err error + } +) + +func (c *s3v4aChunkReader) Close() (err error) { + return nil +} + +func (c *s3v4aChunkReader) Read(buf []byte) (num int, err error) { + if c.offset > 0 { + num = copy(buf, c.buffer[c.offset:]) + if num == len(buf) { + c.offset += num + return num, nil + } + c.offset = 0 + buf = buf[num:] + } + + var size int + for { + b, err := c.reader.ReadByte() + if err != nil { + return c.handleErr(num, err) + } + if b == ';' { // separating character + break + } + + // Manually deserialize the size since AWS specified + // the chunk size to be of variable width. In particular, + // a size of 16 is encoded as `10` while a size of 64 KB + // is `10000`. + switch { + case b >= '0' && b <= '9': + size = size<<4 | int(b-'0') + case b >= 'a' && b <= 'f': + size = size<<4 | int(b-('a'-10)) + case b >= 'A' && b <= 'F': + size = size<<4 | int(b-('A'-10)) + default: + c.err = errMalformedChunkedEncoding + return num, c.err + } + if size > maxChunkSize { + c.err = errGiantChunk + return num, c.err + } + } + + // Now, we read the signature of the following payload and expect: + // chunk-signature=" + + "**\r\n" + // + // The signature is 64 bytes long (hex-encoded SHA256 hash) and + // starts with a 16 byte header: len("chunk-signature=") + 144 == 160. + var signature [160]byte + _, err = io.ReadFull(c.reader, signature[:]) + if err != nil { + return c.handleErr(num, err) + } + if !bytes.HasPrefix(signature[:], []byte(chunkSignatureHeader)) { + c.err = errMalformedChunkedEncoding + return num, c.err + } + b, err := c.reader.ReadByte() + if err != nil { + return c.handleErr(num, err) + } + if b != '\r' { + c.err = errMalformedChunkedEncoding + return num, c.err + } + b, err = c.reader.ReadByte() + if err != nil { + return c.handleErr(num, err) + } + if b != '\n' { + c.err = errMalformedChunkedEncoding + return num, c.err + } + + if cap(c.buffer) < size { + c.buffer = make([]byte, size) + } else { + c.buffer = c.buffer[:size] + } + + // Now, we read the payload and compute its SHA-256 hash. + _, err = io.ReadFull(c.reader, c.buffer) + if err == io.EOF && size != 0 { + err = io.ErrUnexpectedEOF + } + if err != nil && err != io.EOF { + c.err = err + return num, c.err + } + b, err = c.reader.ReadByte() + if b != '\r' || err != nil { + c.err = errMalformedChunkedEncoding + return num, c.err + } + b, err = c.reader.ReadByte() + if err != nil { + return c.handleErr(num, err) + } + if b != '\n' { + c.err = errMalformedChunkedEncoding + return num, c.err + } + + // Once we have read the entire chunk successfully, we verify + // that the received signature is valid. + + n, err := hex.Decode(signature[:], bytes.TrimSuffix(signature[:], []byte("**"))[16:]) + if err != nil { + c.err = errMalformedChunkedEncoding + return num, c.err + } + + if err = c.streamSigner.VerifySignature(nil, c.buffer, c.requestTime, signature[:n]); err != nil { + c.err = err + return num, c.err + } + + // If the chunk size is zero we return io.EOF. As specified by AWS, + // only the last chunk is zero-sized. + if size == 0 { + c.err = io.EOF + return num, c.err + } + + c.offset = copy(buf, c.buffer) + num += c.offset + return num, err +} + +func (c *s3v4aChunkReader) handleErr(num int, err error) (int, error) { + if err == io.EOF { + err = io.ErrUnexpectedEOF + } + + c.err = err + return num, c.err +} + +func newSignV4aChunkedReader(req *http.Request) (io.ReadCloser, error) { + box, err := middleware.GetBoxData(req.Context()) + if err != nil { + return nil, errs.GetAPIError(errs.ErrAuthorizationHeaderMalformed) + } + + authHeaders, err := middleware.GetAuthHeaders(req.Context()) + if err != nil { + return nil, errs.GetAPIError(errs.ErrAuthorizationHeaderMalformed) + } + + seed, err := hex.DecodeString(authHeaders.SignatureV4) + if err != nil { + return nil, errs.GetAPIError(errs.ErrSignatureDoesNotMatch) + } + + reqTime, err := middleware.GetClientTime(req.Context()) + if err != nil { + return nil, errs.GetAPIError(errs.ErrMalformedDate) + } + + credAdapter := v4a.SymmetricCredentialAdaptor{ + SymmetricProvider: credentialsv2.NewStaticCredentialsProvider(authHeaders.AccessKeyID, box.Gate.SecretKey, ""), + } + + creds, err := credAdapter.RetrievePrivateKey(req.Context()) + if err != nil { + return nil, fmt.Errorf("failed to derive assymetric key from credentials: %w", err) + } + + newStreamSigner := v4a.NewStreamSigner(creds, "s3", seed) + + return &s3v4aChunkReader{ + reader: bufio.NewReader(req.Body), + streamSigner: newStreamSigner, + requestTime: reqTime, + buffer: make([]byte, 64*1024), + }, nil +} diff --git a/api/headers.go b/api/headers.go index a91149c..761661a 100644 --- a/api/headers.go +++ b/api/headers.go @@ -93,7 +93,8 @@ const ( DefaultLocationConstraint = "default" - StreamingContentSHA256 = "STREAMING-AWS4-HMAC-SHA256-PAYLOAD" + StreamingContentSHA256 = "STREAMING-AWS4-HMAC-SHA256-PAYLOAD" + StreamingContentV4aSHA256 = "STREAMING-AWS4-ECDSA-P256-SHA256-PAYLOAD" DefaultStorageClass = "STANDARD" ) @@ -124,7 +125,9 @@ var SystemMetadata = map[string]struct{}{ ContentLanguage: {}, } -func IsSignedStreamingV4(r *http.Request) bool { - return r.Header.Get(AmzContentSha256) == StreamingContentSHA256 && - r.Method == http.MethodPut +func IsSignedStreamingV4(r *http.Request) (string, bool) { + shaHeader := r.Header.Get(AmzContentSha256) + return shaHeader, + (shaHeader == StreamingContentSHA256 || shaHeader == StreamingContentV4aSHA256) && + r.Method == http.MethodPut } diff --git a/go.mod b/go.mod index b17e58c..4cf48ff 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,9 @@ require ( git.frostfs.info/TrueCloudLab/policy-engine v0.0.0-20240416071728-04a79f57ef1f git.frostfs.info/TrueCloudLab/zapjournald v0.0.0-20240124114243-cb2e66427d02 github.com/aws/aws-sdk-go v1.44.6 + github.com/aws/aws-sdk-go-v2 v1.26.0 + github.com/aws/aws-sdk-go-v2/credentials v1.17.9 + github.com/aws/smithy-go v1.20.1 github.com/bluele/gcache v0.0.2 github.com/go-chi/chi/v5 v5.0.8 github.com/google/uuid v1.3.1 diff --git a/go.sum b/go.sum index 042a80e..09496c4 100644 --- a/go.sum +++ b/go.sum @@ -64,6 +64,12 @@ github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8 github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/aws/aws-sdk-go v1.44.6 h1:Y+uHxmZfhRTLX2X3khkdxCoTZAyGEX21aOUHe1U6geg= github.com/aws/aws-sdk-go v1.44.6/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= +github.com/aws/aws-sdk-go-v2 v1.26.0 h1:/Ce4OCiM3EkpW7Y+xUnfAFpchU78K7/Ug01sZni9PgA= +github.com/aws/aws-sdk-go-v2 v1.26.0/go.mod h1:35hUlJVYd+M++iLI3ALmVwMOyRYMmRqUXpTtRGW+K9I= +github.com/aws/aws-sdk-go-v2/credentials v1.17.9 h1:N8s0/7yW+h8qR8WaRlPQeJ6czVMNQVNtNdUqf6cItao= +github.com/aws/aws-sdk-go-v2/credentials v1.17.9/go.mod h1:446YhIdmSV0Jf/SLafGZalQo+xr2iw7/fzXGDPTU1yQ= +github.com/aws/smithy-go v1.20.1 h1:4SZlSlMr36UEqC7XOyRVb27XMeZubNcBNN+9IgEPIQw= +github.com/aws/smithy-go v1.20.1/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bits-and-blooms/bitset v1.8.0 h1:FD+XqgOZDUxxZ8hzoBFuV9+cGWY9CslN6d5MS5JVb4c=