diff --git a/internal/sigv4auth/auth.go b/internal/sigv4auth/auth.go new file mode 100644 index 0000000..e57a16d --- /dev/null +++ b/internal/sigv4auth/auth.go @@ -0,0 +1,247 @@ +// Copyright 2026 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sigv4auth + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "time" + "unicode" +) + +const ( + AlgorithmHMACSHA256 = "AWS4-HMAC-SHA256" + Terminal = "aws4_request" + ServiceS3 = "s3" + ServiceIAM = "iam" + ServiceSTS = "sts" + + ISO8601Format = "20060102T150405Z" + YYYYMMDD = "20060102" + + // HeaderSecurityToken is the header a temporary credential's + // SessionToken is presented in, matching AWS's X-Amz-Security-Token. + HeaderSecurityToken = "X-Amz-Security-Token" + + // TempAccessKeyIDPrefix marks temporary credentials minted by + // AssumeRoleWithWebIdentity, matching AWS's ASIA… convention that + // distinguishes them from long-term AKIA… access keys. + TempAccessKeyIDPrefix = "ASIA" +) + +// IsTempAccessKeyID reports whether accessKeyID is a temporary (session) +// credential rather than a long-term AKIA… access key. +func IsTempAccessKeyID(accessKeyID string) bool { + return strings.HasPrefix(accessKeyID, TempAccessKeyIDPrefix) +} + +type ParseErrorKind string + +const ( + ErrInvalidAuthorizationHeader ParseErrorKind = "invalid_authorization_header" + ErrUnsupportedAuthorizationVersion ParseErrorKind = "unsupported_authorization_version" + ErrInvalidAuthorizationType ParseErrorKind = "invalid_authorization_type" + ErrMissingComponents ParseErrorKind = "missing_components" + ErrMissingCredential ParseErrorKind = "missing_credential" + ErrMissingSignedHeaders ParseErrorKind = "missing_signed_headers" + ErrMissingSignature ParseErrorKind = "missing_signature" + ErrMalformedComponent ParseErrorKind = "malformed_component" + ErrMalformedCredential ParseErrorKind = "malformed_credential" + ErrIncorrectService ParseErrorKind = "incorrect_service" + ErrIncorrectTerminal ParseErrorKind = "incorrect_terminal" + ErrInvalidDateFormat ParseErrorKind = "invalid_date_format" +) + +type ParseError struct { + Kind ParseErrorKind + Input string + Value string + Expected string + Actual string +} + +func (e *ParseError) Error() string { + if e == nil { + return "" + } + switch e.Kind { + case ErrIncorrectService, ErrIncorrectTerminal: + return fmt.Sprintf("sigv4 %s: expected %q, got %q", e.Kind, e.Expected, e.Actual) + case ErrInvalidAuthorizationType, ErrMalformedComponent, ErrInvalidDateFormat: + return fmt.Sprintf("sigv4 %s: %q", e.Kind, e.Value) + default: + return string(e.Kind) + } +} + +// AuthData is the parsed authorization data from an AWS SigV4 Authorization header. +type AuthData struct { + Algorithm string + Access string + Region string + Service string + SignedHeaders string + Signature string + Date string +} + +type CredentialsScope struct { + Access string + Date string + Region string + Service string +} + +// HexBytes returns the hex byte representation used by AWS-style diagnostic +// signature mismatch errors. +func HexBytes(s string) string { + b := []byte(s) + + parts := make([]string, len(b)) + for i, v := range b { + parts[i] = fmt.Sprintf("%02x", v) + } + + return strings.Join(parts, " ") +} + +func PayloadSHA256Hex(payload []byte) string { + hashedPayload := sha256.Sum256(payload) + return hex.EncodeToString(hashedPayload[:]) +} + +// ParseAuthorization parses and validates an AWS SigV4 Authorization header. +// The credential scope service must match expectedService. +func ParseAuthorization(authorization, expectedService string) (AuthData, error) { + a := AuthData{} + + authParts := strings.SplitN(authorization, " ", 2) + for i, el := range authParts { + if strings.Contains(el, " ") { + authParts[i] = removeSpace(el) + } + } + + if len(authParts) < 2 { + return a, &ParseError{Kind: ErrInvalidAuthorizationHeader, Input: authorization} + } + + algo := authParts[0] + if algo == "AWS" { + return a, &ParseError{Kind: ErrUnsupportedAuthorizationVersion, Value: algo} + } + if algo != AlgorithmHMACSHA256 { + return a, &ParseError{Kind: ErrInvalidAuthorizationType, Value: algo} + } + + kvPairs := strings.Split(authParts[1], ",") + if len(kvPairs) != 3 { + return a, &ParseError{Kind: ErrMissingComponents, Input: authorization} + } + + var access, region, service, signedHeaders, signature, date string + for i, kv := range kvPairs { + keyValue := strings.Split(kv, "=") + if len(keyValue) != 2 { + return a, &ParseError{Kind: ErrMalformedComponent, Value: kv} + } + key, value := keyValue[0], keyValue[1] + switch i { + case 0: + if key != "Credential" { + return a, &ParseError{Kind: ErrMissingCredential} + } + case 1: + if key != "SignedHeaders" { + return a, &ParseError{Kind: ErrMissingSignedHeaders} + } + case 2: + if key != "Signature" { + return a, &ParseError{Kind: ErrMissingSignature} + } + } + + switch key { + case "Credential": + creds, err := ParseCredentials(value, expectedService) + if err != nil { + return a, err + } + access = creds.Access + date = creds.Date + region = creds.Region + service = creds.Service + case "SignedHeaders": + signedHeaders = value + case "Signature": + signature = value + } + } + + return AuthData{ + Algorithm: algo, + Access: access, + Region: region, + Service: service, + SignedHeaders: signedHeaders, + Signature: signature, + Date: date, + }, nil +} + +func ParseCredentials(input, expectedService string) (*CredentialsScope, error) { + creds := strings.Split(input, "/") + if len(creds) != 5 { + return nil, &ParseError{Kind: ErrMalformedCredential, Input: input} + } + if creds[3] != expectedService { + return nil, &ParseError{ + Kind: ErrIncorrectService, + Input: input, + Expected: expectedService, + Actual: creds[3], + } + } + if creds[4] != Terminal { + return nil, &ParseError{ + Kind: ErrIncorrectTerminal, + Input: input, + Expected: Terminal, + Actual: creds[4], + } + } + if _, err := time.Parse(YYYYMMDD, creds[1]); err != nil { + return nil, &ParseError{Kind: ErrInvalidDateFormat, Input: input, Value: creds[1]} + } + + return &CredentialsScope{ + Access: creds[0], + Date: creds[1], + Region: creds[2], + Service: creds[3], + }, nil +} + +func removeSpace(str string) string { + var b strings.Builder + b.Grow(len(str)) + for _, ch := range str { + if !unicode.IsSpace(ch) { + b.WriteRune(ch) + } + } + return b.String() +} diff --git a/s3api/utils/auth-reader.go b/s3api/utils/auth-reader.go new file mode 100644 index 0000000..8161b5e --- /dev/null +++ b/s3api/utils/auth-reader.go @@ -0,0 +1,171 @@ +// Copyright 2023 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package utils + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/versity/versitygw/internal/sigv4auth" + "github.com/versity/versitygw/s3err" +) + +const ( + iso8601Format = sigv4auth.ISO8601Format + yyyymmdd = sigv4auth.YYYYMMDD +) + +func HexBytes(s string) string { + return sigv4auth.HexBytes(s) +} + +const ( + service = sigv4auth.ServiceS3 +) + +// CheckValidSignature validates the ctx v4 auth signature against +// derivedKey — the request's kSigning value, either derived locally from a +// known secret or obtained from a standalone IAM service that never reveals +// the secret itself. +func CheckValidSignature(ctx fiber.Ctx, auth AuthData, derivedKey []byte, checksum string, tdate time.Time, contentLen int64) (string, error) { + result, err := sigv4auth.CheckSignature(ctx, auth, derivedKey, checksum, tdate, contentLen, sigv4auth.CheckOptions{ + Service: service, + }) + if err != nil { + return "", mapSigV4Error(err) + } + + return result.CanonicalString, nil +} + +// AuthData is the parsed authorization data from the header. +type AuthData = sigv4auth.AuthData + +// ParseAuthorization returns the parsed fields for the aws v4 auth header +// example authorization string from aws docs: +// Authorization: AWS4-HMAC-SHA256 +// Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, +// SignedHeaders=host;range;x-amz-date, +// Signature=fe5f80f77d5fa3beca038a248ff027d0445342fe2855ddc963176630326f1024 +func ParseAuthorization(authorization string) (AuthData, error) { + authData, err := sigv4auth.ParseAuthorization(authorization, service) + if err != nil { + return AuthData{}, mapSigV4Error(err) + } + return authData, nil +} + +type CredentialsScope = sigv4auth.CredentialsScope + +type CredsError interface { + MalformedCredential(string) s3err.S3Error + IncorrectService(string, string) s3err.S3Error + IncorrectTerminal(string, string) s3err.S3Error + InvalidDateFormat(string, string) s3err.S3Error +} + +func ParseCredentials(input string, errHandler CredsError) (*CredentialsScope, error) { + creds, err := sigv4auth.ParseCredentials(input, service) + if err != nil { + return nil, mapCredentialsError(input, err, errHandler) + } + return creds, nil +} + +// SignPostPolicy signs a POST-policy document with derivedKey +func SignPostPolicy(base64Policy string, derivedKey []byte) (string, error) { + h := hmac.New(sha256.New, derivedKey) + h.Write([]byte(base64Policy)) + return hex.EncodeToString(h.Sum(nil)), nil +} + +func mapSigV4Error(err error) error { + var parseErr *sigv4auth.ParseError + if errors.As(err, &parseErr) { + return mapAuthParseError(parseErr) + } + + var headersErr *sigv4auth.HeadersNotSignedError + if errors.As(err, &headersErr) { + return s3err.GetHeadersNotSignedErr(headersErr.Headers) + } + + var sigErr *sigv4auth.SignatureMismatchError + if errors.As(err, &sigErr) { + return s3err.GetSignatureDoesNotMatchErr( + sigErr.AccessKeyID, + sigErr.StringToSign, + sigErr.SignatureProvided, + sigErr.StringToSignBytes, + sigErr.CanonicalRequest, + sigErr.CanonicalRequestBytes, + ) + } + + return err +} + +func mapAuthParseError(err *sigv4auth.ParseError) error { + switch err.Kind { + case sigv4auth.ErrInvalidAuthorizationHeader: + return s3err.GetInvalidArgumentErr(s3err.InvalidArgAuthHeader, err.Input) + case sigv4auth.ErrUnsupportedAuthorizationVersion: + return s3err.GetAPIError(s3err.ErrUnsupportedAuthorizationMechanism) + case sigv4auth.ErrInvalidAuthorizationType: + return s3err.GetInvalidArgumentErr(s3err.InvalidArgAuthorizationType, err.Value) + case sigv4auth.ErrMissingComponents: + return s3err.MalformedAuth.MissingComponents() + case sigv4auth.ErrMissingCredential: + return s3err.MalformedAuth.MissingCredential() + case sigv4auth.ErrMissingSignedHeaders: + return s3err.MalformedAuth.MissingSignedHeaders() + case sigv4auth.ErrMissingSignature: + return s3err.MalformedAuth.MissingSignature() + case sigv4auth.ErrMalformedComponent: + return s3err.MalformedAuth.MalformedComponent(err.Value) + default: + return mapCredentialsParseError(err, s3err.MalformedAuth) + } +} + +func mapCredentialsError(input string, err error, errHandler CredsError) error { + var parseErr *sigv4auth.ParseError + if !errors.As(err, &parseErr) { + return err + } + if parseErr.Input == "" { + parseErr.Input = input + } + return mapCredentialsParseError(parseErr, errHandler) +} + +func mapCredentialsParseError(err *sigv4auth.ParseError, errHandler CredsError) error { + switch err.Kind { + case sigv4auth.ErrMalformedCredential: + return errHandler.MalformedCredential(err.Input) + case sigv4auth.ErrIncorrectService: + return errHandler.IncorrectService(err.Input, err.Actual) + case sigv4auth.ErrIncorrectTerminal: + return errHandler.IncorrectTerminal(err.Input, err.Actual) + case sigv4auth.ErrInvalidDateFormat: + return errHandler.InvalidDateFormat(err.Input, err.Value) + default: + return err + } +} diff --git a/s3api/utils/auth_test.go b/s3api/utils/auth_test.go new file mode 100644 index 0000000..af1f97a --- /dev/null +++ b/s3api/utils/auth_test.go @@ -0,0 +1,146 @@ +// Copyright 2023 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package utils + +import ( + "net" + "strings" + "testing" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/valyala/fasthttp" + "github.com/valyala/fasthttp/fasthttputil" + "github.com/versity/versitygw/internal/sigv4auth" +) + +func TestAuthParse(t *testing.T) { + vectors := []struct { + name string // name of test string + authstr string // Authorization string + algo string + sig string + }{ + { + name: "restic", + authstr: "AWS4-HMAC-SHA256 Credential=user/20240116/us-east-1/s3/aws4_request,SignedHeaders=content-md5;host;x-amz-content-sha256;x-amz-date;x-amz-decoded-content-length,Signature=d5199fc7f3aa35dd3d400427be2ae4c98bfad390785280cbb9eea015b51e12ac", + algo: "AWS4-HMAC-SHA256", + sig: "d5199fc7f3aa35dd3d400427be2ae4c98bfad390785280cbb9eea015b51e12ac", + }, + { + name: "aws eaxample", + authstr: "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/us-east-1/s3/aws4_request, SignedHeaders=host;range;x-amz-date, Signature=fe5f80f77d5fa3beca038a248ff027d0445342fe2855ddc963176630326f1024", + algo: "AWS4-HMAC-SHA256", + sig: "fe5f80f77d5fa3beca038a248ff027d0445342fe2855ddc963176630326f1024", + }, + { + name: "s3browser", + authstr: "AWS4-HMAC-SHA256 Credential=access_key/20240206/us-east-1/s3/aws4_request,SignedHeaders=host;user-agent;x-amz-content-sha256;x-amz-date, Signature=37a35d96998d786113ad420c57c22c5433f6aca74f88f26566caa047fc3601c6", + algo: "AWS4-HMAC-SHA256", + sig: "37a35d96998d786113ad420c57c22c5433f6aca74f88f26566caa047fc3601c6", + }, + } + + for _, v := range vectors { + t.Run(v.name, func(t *testing.T) { + data, err := ParseAuthorization(v.authstr) + if err != nil { + t.Fatal(err) + } + if data.Algorithm != v.algo { + t.Errorf("algo got %v, expected %v", data.Algorithm, v.algo) + } + if data.Signature != v.sig { + t.Errorf("signature got %v, expected %v", data.Signature, v.sig) + } + }) + } +} + +// 2024/02/06 21:03:28 Request headers: +// 2024/02/06 21:03:28 Host: 172.21.0.160:11000 +// 2024/02/06 21:03:28 User-Agent: S3 Browser/11.5.7 (https://s3browser.com) +// 2024/02/06 21:03:28 Authorization: AWS4-HMAC-SHA256 Credential=access_key/20240206/us-east-1/s3/aws4_request,SignedHeaders=host;user-agent;x-amz-content-sha256;x-amz-date, Signature=37a35d96998d786113ad420c57c22c5433f6aca74f88f26566caa047fc3601c6 +// 2024/02/06 21:03:28 X-Amz-Content-Sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 +// 2024/02/06 21:03:28 X-Amz-Date: 20240206T210328Z +func Test_Client_UserAgent(t *testing.T) { + signedHdrs := []string{"host", "user-agent", "x-amz-content-sha256", "x-amz-date"} + access := "access_key" + secret := "secret_key" + region := "us-east-1" + host := "172.21.0.160:11000" + agent := "S3 Browser/11.5.7 (https://s3browser.com)" + expectedSig := "37a35d96998d786113ad420c57c22c5433f6aca74f88f26566caa047fc3601c6" + dateStr := "20240206T210328Z" + + app := fiber.New(fiber.Config{}) + + tdate, err := time.Parse(iso8601Format, dateStr) + if err != nil { + t.Fatal(err) + } + + app.Get("/", func(c fiber.Ctx) error { + auth := sigv4auth.AuthData{ + Access: access, + Region: region, + Service: service, + SignedHeaders: strings.Join(signedHdrs, ";"), + Signature: expectedSig, + } + derivedKey := sigv4auth.DeriveKey(secret, dateStr[:8], region, service) + opts := sigv4auth.CheckOptions{DisableURIPathEscaping: true} + contentLen := int64(c.Request().Header.ContentLength()) + + if _, err := sigv4auth.CheckSignature(c, auth, derivedKey, zeroLenSig, tdate, contentLen, opts); err != nil { + t.Errorf("CheckSignature: %v", err) + } + + return c.Send(c.Request().Header.UserAgent()) + }) + + ln := fasthttputil.NewInmemoryListener() + go func() { + err := app.Listener(ln) + if err != nil { + panic(err) + } + }() + + client := fasthttp.Client{ + Dial: func(_ string) (net.Conn, error) { return ln.Dial() }, + } + + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseRequest(req) + defer fasthttp.ReleaseResponse(resp) + + // Host/User-Agent/X-Amz-Content-Sha256/X-Amz-Date are sent as real + // headers, reproducing the captured request verbatim, so CheckSignature + // extracts them straight off the live fiber.Ctx like it does for any + // real request. + req.SetRequestURI("http://" + host + "/") + req.Header.SetUserAgent(agent) + req.Header.Set("X-Amz-Content-Sha256", zeroLenSig) + req.Header.Set("X-Amz-Date", dateStr) + if err := client.Do(req, resp); err != nil { + t.Fatal(err) + } + + if got := string(resp.Body()); got != agent { + t.Errorf("user-agent got %q, expected %q", got, agent) + } +} diff --git a/tests/integration/sigv4_auth.go b/tests/integration/sigv4_auth.go new file mode 100644 index 0000000..551b43f --- /dev/null +++ b/tests/integration/sigv4_auth.go @@ -0,0 +1,666 @@ +// Copyright 2023 Versity Software +// This file is licensed under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package integration + +import ( + "encoding/xml" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/versity/versitygw/s3err" +) + +func Authentication_invalid_auth_header(s *S3Conf) error { + testName := "Authentication_invalid_auth_header" + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodGet, + body: nil, + service: "s3", + date: time.Now(), + }, func(req *http.Request) error { + req.Header.Set("Authorization", "invalid_header") + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + return checkHTTPResponseApiErr(resp, s3err.GetInvalidArgumentErr(s3err.InvalidArgAuthHeader, "invalid_header")) + }) +} + +func Authentication_unsupported_signature_version(s *S3Conf) error { + testName := "Authentication_unsupported_signature_version" + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodGet, + body: nil, + service: "s3", + date: time.Now(), + }, func(req *http.Request) error { + authHdr := req.Header.Get("Authorization") + authHdr = strings.Replace(authHdr, "AWS4-HMAC-SHA256", "AWS2-HMAC-SHA1", 1) + req.Header.Set("Authorization", authHdr) + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + return checkHTTPResponseApiErr(resp, s3err.GetInvalidArgumentErr(s3err.InvalidArgAuthorizationType, "AWS2-HMAC-SHA1")) + }) +} + +func Authentication_missing_components(s *S3Conf) error { + testName := "Authentication_missing_components" + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodGet, + body: nil, + service: "s3", + date: time.Now(), + }, func(req *http.Request) error { + // missing SignedHeaders component + req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=access/20250912/us-east-1/s3/aws4_request,Signature=5fb279ae552098ea7c5c807df54cdb159e74939e19449b29831552639ec34b29") + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + return checkHTTPResponseApiErr(resp, s3err.MalformedAuth.MissingComponents()) + }) +} + +func Authentication_malformed_component(s *S3Conf) error { + testName := "Authentication_malformed_component" + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodGet, + body: nil, + service: "s3", + date: time.Now(), + }, func(req *http.Request) error { + // malformed SignedHeaders + req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=access/20250912/us-east-1/s3/aws4_request,SignedHeaders-Content-Length,Signature=signature") + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + return checkHTTPResponseApiErr(resp, s3err.MalformedAuth.MalformedComponent("SignedHeaders-Content-Length")) + }) +} + +func Authentication_missing_credentials(s *S3Conf) error { + testName := "Authentication_missing_credentials" + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodGet, + body: nil, + service: "s3", + date: time.Now(), + }, func(req *http.Request) error { + // missing Credentials + req.Header.Set("Authorization", "AWS4-HMAC-SHA256 missing_creds=access/20250912/us-east-1/s3/aws4_request,SignedHeaders=content-length;x-amz-date,Signature=5fb279ae552098ea7c5c807df54cdb159e74939e19449b29831552639ec34b29") + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + return checkHTTPResponseApiErr(resp, s3err.MalformedAuth.MissingCredential()) + }) +} + +func Authentication_missing_signedheaders(s *S3Conf) error { + testName := "Authentication_missing_signedheaders" + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodGet, + body: nil, + service: "s3", + date: time.Now(), + }, func(req *http.Request) error { + // missing SignedHeaders + req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=access/20250912/us-east-1/s3/aws4_request,missing=content-length;x-amz-date,Signature=5fb279ae552098ea7c5c807df54cdb159e74939e19449b29831552639ec34b29") + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + return checkHTTPResponseApiErr(resp, s3err.MalformedAuth.MissingSignedHeaders()) + }) +} + +func Authentication_missing_signature(s *S3Conf) error { + testName := "Authentication_missing_signature" + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodGet, + body: nil, + service: "s3", + date: time.Now(), + }, func(req *http.Request) error { + // missing Signature + req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=access/20250912/us-east-1/s3/aws4_request,SignedHeaders=content-length;x-amz-date,missing=5fb279ae552098ea7c5c807df54cdb159e74939e19449b29831552639ec34b29") + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + return checkHTTPResponseApiErr(resp, s3err.MalformedAuth.MissingSignature()) + }) +} + +func Authentication_malformed_credential(s *S3Conf) error { + testName := "Authentication_malformed_credential" + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodGet, + body: nil, + service: "s3", + date: time.Now(), + }, func(req *http.Request) error { + authHdr := req.Header.Get("Authorization") + regExp := regexp.MustCompile("Credential=[^,]+,") + hdr := regExp.ReplaceAllString(authHdr, "Credential=access/32234/us-east-1/s3/extra/things,") + req.Header.Set("Authorization", hdr) + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + return checkHTTPResponseApiErr(resp, s3err.MalformedAuth.MalformedCredential("")) + }) +} + +func Authentication_credentials_invalid_terminal(s *S3Conf) error { + testName := "Authentication_credentials_invalid_terminal" + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodGet, + body: nil, + service: "s3", + date: time.Now(), + }, func(req *http.Request) error { + authHdr := req.Header.Get("Authorization") + regExp := regexp.MustCompile("Credential=[^,]+,") + hdr := regExp.ReplaceAllString(authHdr, "Credential=access/32234/us-east-1/s3/aws_request,") + req.Header.Set("Authorization", hdr) + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + return checkHTTPResponseApiErr(resp, s3err.MalformedAuth.IncorrectTerminal("", "aws_request")) + }) +} + +func Authentication_credentials_incorrect_service(s *S3Conf) error { + testName := "Authentication_credentials_incorrect_service" + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodGet, + body: nil, + service: "s3", + date: time.Now(), + }, func(req *http.Request) error { + authHdr := req.Header.Get("Authorization") + regExp := regexp.MustCompile("Credential=[^,]+,") + hdr := regExp.ReplaceAllString(authHdr, "Credential=access/32234/us-east-1/ec2/aws4_request,") + req.Header.Set("Authorization", hdr) + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + return checkHTTPResponseApiErr(resp, s3err.MalformedAuth.IncorrectService("", "ec2")) + }) +} + +func Authentication_credentials_incorrect_region(s *S3Conf) error { + testName := "Authentication_credentials_incorrect_region" + cfg := *s + if cfg.awsRegion == "us-east-1" { + cfg.awsRegion = "us-west-1" + } else { + cfg.awsRegion = "us-east-1" + } + return authHandler(&cfg, &authConfig{ + testName: testName, + method: http.MethodGet, + body: nil, + service: "s3", + date: time.Now(), + }, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + return checkHTTPResponseApiErr(resp, s3err.MalformedAuth.IncorrectRegion(s.awsRegion, cfg.awsRegion)) + }) +} + +// Authentication_credentials_incorrect_region_head verifies that a HEAD request +// signed for the wrong region reports the gateway region in the +// x-amz-bucket-region response header. HEAD responses carry no body, so the +// header is the only channel through which a client can discover the region and +// retry against it, which is how the aws sdks recover from the mismatch. +func Authentication_credentials_incorrect_region_head(s *S3Conf) error { + testName := "Authentication_credentials_incorrect_region_head" + cfg := *s + if cfg.awsRegion == "us-east-1" { + cfg.awsRegion = "us-west-1" + } else { + cfg.awsRegion = "us-east-1" + } + return authHandler(&cfg, &authConfig{ + testName: testName, + path: getBucketName(), + method: http.MethodHead, + body: nil, + service: "s3", + date: time.Now(), + }, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + expected := s3err.MalformedAuth.IncorrectRegion(s.awsRegion, cfg.awsRegion) + if resp.StatusCode != expected.BaseError().HTTPStatusCode { + return fmt.Errorf("expected response status code to be %v, instead got %v", + expected.BaseError().HTTPStatusCode, resp.StatusCode) + } + + return checkRegionMismatchHeader(resp, expected) + }) +} + +func Authentication_credentials_invalid_date(s *S3Conf) error { + testName := "Authentication_credentials_invalid_date" + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodGet, + body: nil, + service: "s3", + date: time.Now(), + }, func(req *http.Request) error { + authHdr := req.Header.Get("Authorization") + regExp := regexp.MustCompile("Credential=[^,]+,") + hdr := regExp.ReplaceAllString(authHdr, "Credential=access/3223423234/us-east-1/s3/aws4_request,") + req.Header.Set("Authorization", hdr) + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + return checkHTTPResponseApiErr(resp, s3err.MalformedAuth.InvalidDateFormat("", "3223423234")) + }) +} + +func Authentication_credentials_future_date(s *S3Conf) error { + testName := "Authentication_credentials_future_date" + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodGet, + body: nil, + service: "s3", + date: time.Now().Add(time.Duration(5) * 24 * time.Hour), + }, func(req *http.Request) error { + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + var errResp APIErrorResponse + err = xml.Unmarshal(body, &errResp) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusForbidden { + return fmt.Errorf("expected response status code to be %v, instead got %v", http.StatusForbidden, resp.StatusCode) + } + if errResp.Code != "RequestTimeTooSkewed" { + return fmt.Errorf("expected error code to be %v, instead got %v", "RequestTimeTooSkewed", errResp.Code) + } + + return nil + }) +} + +func Authentication_credentials_past_date(s *S3Conf) error { + testName := "Authentication_credentials_past_date" + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodGet, + body: nil, + service: "s3", + date: time.Now().Add(time.Duration(-5) * 24 * time.Hour), + }, func(req *http.Request) error { + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + var errResp APIErrorResponse + err = xml.Unmarshal(body, &errResp) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusForbidden { + return fmt.Errorf("expected response status code to be %v, instead got %v", http.StatusForbidden, resp.StatusCode) + } + if errResp.Code != "RequestTimeTooSkewed" { + return fmt.Errorf("expected error code to be %v, instead got %v", "RequestTimeTooSkewed", errResp.Code) + } + + return nil + }) +} + +func Authentication_credentials_non_existing_access_key(s *S3Conf) error { + testName := "Authentication_credentials_non_existing_access_key" + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodGet, + body: nil, + service: "s3", + date: time.Now(), + }, func(req *http.Request) error { + accessKeyID := "a_rarely_existing_access_key_id_a7s86df78as6df89790a8sd7f" + authHdr := req.Header.Get("Authorization") + regExp := regexp.MustCompile("Credential=([^/]+)") + hdr := regExp.ReplaceAllString(authHdr, "Credential="+accessKeyID) + req.Header.Set("Authorization", hdr) + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + return checkHTTPResponseApiErr(resp, s3err.GetInvalidAccessKeyIdErr(accessKeyID)) + }) +} + +func Authentication_missing_date_header(s *S3Conf) error { + testName := "Authentication_missing_date_header" + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodGet, + body: nil, + service: "s3", + date: time.Now(), + }, func(req *http.Request) error { + req.Header.Set("X-Amz-Date", "") + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + return checkHTTPResponseApiErr(resp, s3err.GetAPIError(s3err.ErrMissingDateHeader)) + }) +} + +func Authentication_invalid_date_header(s *S3Conf) error { + testName := "Authentication_invalid_date_header" + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodGet, + body: nil, + service: "s3", + date: time.Now(), + }, func(req *http.Request) error { + req.Header.Set("X-Amz-Date", "03032006") + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + return checkHTTPResponseApiErr(resp, s3err.GetAPIError(s3err.ErrMissingDateHeader)) + }) +} + +func Authentication_date_mismatch(s *S3Conf) error { + testName := "Authentication_date_mismatch" + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodGet, + body: nil, + service: "s3", + date: time.Now(), + }, func(req *http.Request) error { + testuser := getUser("user") + err := createUsers(s, []user{testuser}) + if err != nil { + return err + } + + authHdr := req.Header.Get("Authorization") + regExp := regexp.MustCompile("Credential=[^,]+,") + hdr := regExp.ReplaceAllString(authHdr, fmt.Sprintf("Credential=%s/20250912/us-east-1/s3/aws4_request,", testuser.access)) + req.Header.Set("Authorization", hdr) + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + return checkHTTPResponseApiErr(resp, s3err.MalformedAuth.DateMismatch()) + }) +} + +func Authentication_invalid_sha256_payload_hash(s *S3Conf) error { + testName := "Authentication_invalid_sha256_payload_hash" + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPut, + body: nil, + service: "s3", + date: time.Now(), + path: "bucket/object", + }, func(req *http.Request) error { + req.Header.Set("X-Amz-Content-Sha256", "invalid_sha256") + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + return checkHTTPResponseApiErr(resp, s3err.GetInvalidArgumentErr(s3err.InvalidArgSHA256Payload, "invalid_sha256")) + }) +} + +func Authentication_incorrect_payload_hash(s *S3Conf) error { + testName := "Authentication_incorrect_payload_hash" + const incorrectPayloadHash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b854" + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPut, + body: nil, + service: "s3", + date: time.Now(), + path: "bucket/object?tagging", + overrideSha256: incorrectPayloadHash, + }, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + return checkHTTPResponseApiErr(resp, s3err.GetContentSHA256MismatchErr(incorrectPayloadHash, emptySHA256Hash)) + }) +} + +func Authentication_unsigned_required_header(s *S3Conf) error { + testName := "Authentication_unsigned_required_header" + return actionHandler(s, testName, func(_ *s3.Client, bucket string) error { + req, err := createSignedReq(http.MethodPut, s.endpoint, fmt.Sprintf("%s/obj", bucket), s.awsID, s.awsSecret, "s3", s.awsRegion, "", nil, time.Now(), nil) + if err != nil { + return err + } + + req.Header.Set("X-Amz-Copy-Source", "source-bucket/source-key") + req.Header.Set("X-Amz-Tagging", "a=b") + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + return checkHTTPResponseApiErr(resp, s3err.GetHeadersNotSignedErr([]string{"x-amz-copy-source", "x-amz-tagging"})) + }) +} + +func Authentication_unsigned_non_required_header(s *S3Conf) error { + testName := "Authentication_unsigned_non_required_header" + return actionHandler(s, testName, func(_ *s3.Client, bucket string) error { + req, err := createSignedReq(http.MethodPut, s.endpoint, fmt.Sprintf("%s/obj", bucket), s.awsID, s.awsSecret, "s3", s.awsRegion, "", nil, time.Now(), nil) + if err != nil { + return err + } + + req.Header.Set("Content-Type", "text/plain") + req.Header.Set("X-Custom-Header", "value") + req.Header.Set("X-Another-Custom-Header", "value") + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("expected response status code to be %v, instead got %v", http.StatusOK, resp.StatusCode) + } + + return nil + }) +} + +func Authentication_signature_error_incorrect_secret_key(s *S3Conf) error { + testName := "Authentication_signature_error_incorrect_secret_key" + cfg := *s + cfg.awsSecret = s.awsSecret + "a" + return authHandler(&cfg, &authConfig{ + testName: testName, + method: http.MethodGet, + body: nil, + service: "s3", + date: time.Now(), + }, func(req *http.Request) error { + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + return checkHTTPResponseApiErr(resp, s3err.GetAPIError(s3err.ErrSignatureDoesNotMatch)) + }) +} + +func Authentication_sigv2_not_supported(s *S3Conf) error { + testName := "Authentication_sigv2_not_supported" + bucket := getBucketName() + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPut, + service: "s3", + date: time.Now(), + path: fmt.Sprintf("%s/object", bucket), + }, func(req *http.Request) error { + err := setup(s, bucket) + if err != nil { + return err + } + + req.Header.Del("Authorization") + req.Header.Set("Authorization", "AWS seed_signature") + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + if err := checkHTTPResponseApiErr(resp, s3err.GetAPIError(s3err.ErrUnsupportedAuthorizationMechanism)); err != nil { + return err + } + + return teardown(s, bucket) + }) +} + +func Authentication_with_expect_header(s *S3Conf) error { + testName := "Authentication_with_expect_header" + bucket, object := getBucketName(), "object" + return authHandler(s, &authConfig{ + testName: testName, + method: http.MethodPut, + body: []byte("dummy data"), + service: "s3", + date: time.Now(), + path: fmt.Sprintf("%s/%s", bucket, object), + headers: map[string]string{ + "Expect": "100-continue", + }, + }, func(req *http.Request) error { + err := setup(s, bucket) + if err != nil { + return err + } + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + + if resp.StatusCode != 200 { + return fmt.Errorf("expected the response status to be 200, instead got %v", resp.StatusCode) + } + + err = teardown(s, bucket) + return err + }) +}