package auth import ( "bytes" "context" "fmt" "mime/multipart" "net/http" "net/http/httptest" "net/url" "os" "strings" "testing" "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" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/cache" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/errors" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/creds/accessbox" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/creds/tokens" frosterr "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/frostfs/errors" "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/bearer" 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" oidtest "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id/test" 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/nspcc-dev/neo-go/pkg/crypto/keys" "github.com/stretchr/testify/require" "go.uber.org/zap/zaptest" ) type centerSettingsMock struct { accessBoxContainer *cid.ID } func (c *centerSettingsMock) AccessBoxContainer() (cid.ID, bool) { if c.accessBoxContainer == nil { return cid.ID{}, false } return *c.accessBoxContainer, true } func TestAuthHeaderParse(t *testing.T) { defaultHeader := "AWS4-HMAC-SHA256 Credential=oid0cid/20210809/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=2811ccb9e242f41426738fb1f" center := &Center{ reg: NewRegexpMatcher(AuthorizationFieldRegexp), settings: ¢erSettingsMock{}, } for _, tc := range []struct { header string err error expected *AuthHeader }{ { header: defaultHeader, err: nil, expected: &AuthHeader{ AccessKeyID: "oid0cid", Service: "s3", Region: "us-east-1", Signature: "2811ccb9e242f41426738fb1f", SignedFields: []string{"host", "x-amz-content-sha256", "x-amz-date"}, Date: "20210809", Preamble: signaturePreambleSigV4, }, }, { header: strings.ReplaceAll(defaultHeader, "Signature=2811ccb9e242f41426738fb1f", ""), err: errors.GetAPIError(errors.ErrAuthorizationHeaderMalformed), expected: nil, }, } { authHeader, err := center.parseAuthHeader(tc.header, nil) require.ErrorIs(t, err, tc.err, tc.header) require.Equal(t, tc.expected, authHeader, tc.header) } } func TestSignature(t *testing.T) { secret := "66be461c3cd429941c55daf42fad2b8153e5a2016ba89c9494d97677cc9d3872" strToSign := "eyAiZXhwaXJhdGlvbiI6ICIyMDE1LTEyLTMwVDEyOjAwOjAwLjAwMFoiLAogICJjb25kaXRpb25zIjogWwogICAgeyJidWNrZXQiOiAiYWNsIn0sCiAgICBbInN0YXJ0cy13aXRoIiwgIiRrZXkiLCAidXNlci91c2VyMS8iXSwKICAgIHsic3VjY2Vzc19hY3Rpb25fcmVkaXJlY3QiOiAiaHR0cDovL2xvY2FsaG9zdDo4MDg0L2FjbCJ9LAogICAgWyJzdGFydHMtd2l0aCIsICIkQ29udGVudC1UeXBlIiwgImltYWdlLyJdLAogICAgeyJ4LWFtei1tZXRhLXV1aWQiOiAiMTQzNjUxMjM2NTEyNzQifSwKICAgIFsic3RhcnRzLXdpdGgiLCAiJHgtYW16LW1ldGEtdGFnIiwgIiJdLAoKICAgIHsiWC1BbXotQ3JlZGVudGlhbCI6ICI4Vmk0MVBIbjVGMXNzY2J4OUhqMXdmMUU2aERUYURpNndxOGhxTU05NllKdTA1QzVDeUVkVlFoV1E2aVZGekFpTkxXaTlFc3BiUTE5ZDRuR3pTYnZVZm10TS8yMDE1MTIyOS91cy1lYXN0LTEvczMvYXdzNF9yZXF1ZXN0In0sCiAgICB7IngtYW16LWFsZ29yaXRobSI6ICJBV1M0LUhNQUMtU0hBMjU2In0sCiAgICB7IlgtQW16LURhdGUiOiAiMjAxNTEyMjlUMDAwMDAwWiIgfSwKICAgIHsieC1pZ25vcmUtdG1wIjogInNvbWV0aGluZyIgfQogIF0KfQ==" signTime, err := time.Parse("20060102T150405Z", "20151229T000000Z") if err != nil { panic(err) } signature := SignStr(secret, "s3", "us-east-1", signTime, strToSign) require.Equal(t, "dfbe886241d9e369cf4b329ca0f15eb27306c97aa1022cc0bb5a914c4ef87634", signature) } func TestSignatureV4A(t *testing.T) { accessKeyID := "2XEbqH4M3ym7a3E3esxfZ2gRLnMwDXrCN4y1SkQg5fHa09sThVmVL3EE6xeKsyMzaqu5jPi41YCaVbnwbwCTF3bx1" secretKey := "00637f53f842573aaa06c2164c598973cd986880987111416cf71f1619def537" signer := v4a.NewSigner(func(options *v4a.SignerOptions) { options.DisableURIPathEscaping = true options.Logger = logging.NewStandardLogger(os.Stdout) options.LogSigning = true }) credAdapter := v4a.SymmetricCredentialAdaptor{ SymmetricProvider: credentialsv2.NewStaticCredentialsProvider(accessKeyID, secretKey, ""), } bodyStr := ` 1b;chunk-signature=3045022100b63692a1b20759bdabd342011823427a8952df75c93174d98ad043abca8052e002201695228a91ba986171b8d0ad20856d3d94ca3614d0a90a50a531ba8e52447b9b** Testing with the {sdk-java} 0;chunk-signature=30440220455885a2d4e9f705256ca6b0a5a22f7f784780ccbd1c0a371e5db3059c91745b022073259dd44746cbd63261d628a04d25be5a32a974c077c5c2d83c8157fb323b9f**** ` body := bytes.NewBufferString(bodyStr) req, err := http.NewRequest("PUT", "http://localhost:8084/test/tmp", body) require.NoError(t, err) req.Header.Set("Amz-Sdk-Invocation-Id", "ca3a3cde-7d26-fce6-ed9c-82f7a0573824") req.Header.Set("Amz-Sdk-Request", "attempt=2; max=2") req.Header.Set("Authorization", "AWS4-ECDSA-P256-SHA256 Credential=2XEbqH4M3ym7a3E3esxfZ2gRLnMwDXrCN4y1SkQg5fHa09sThVmVL3EE6xeKsyMzaqu5jPi41YCaVbnwbwCTF3bx1/20240904/s3/aws4_request, SignedHeaders=amz-sdk-invocation-id;amz-sdk-request;content-length;content-type;host;x-amz-content-sha256;x-amz-date;x-amz-decoded-content-length;x-amz-region-set, Signature=30440220574244c5ff5deba388c4e3b0541a42113179b6839b3e6b4212d255a118fa9089022056f7b9b72c93f67dbcd25fe9ca67950b5913fc00bb7a62bc276c21e828c0b6c7") req.Header.Set("Content-Length", "360") req.Header.Set("Content-Type", "text/plain; charset=UTF-8") req.Header.Set("X-Amz-Content-Sha256", "STREAMING-AWS4-ECDSA-P256-SHA256-PAYLOAD") req.Header.Set("X-Amz-Date", "20240904T133253Z") req.Header.Set("X-Amz-Decoded-Content-Length", "27") req.Header.Set("X-Amz-Region-Set", "us-east-1") service := "s3" regionSet := []string{"us-east-1"} signature := "30440220574244c5ff5deba388c4e3b0541a42113179b6839b3e6b4212d255a118fa9089022056f7b9b72c93f67dbcd25fe9ca67950b5913fc00bb7a62bc276c21e828c0b6c7" signingTime, err := time.Parse("20060102T150405Z", "20240904T133253Z") require.NoError(t, err) creds, err := credAdapter.RetrievePrivateKey(req.Context()) require.NoError(t, err) err = signer.VerifySignature(creds, req, "STREAMING-AWS4-ECDSA-P256-SHA256-PAYLOAD", service, regionSet, signingTime, signature) require.NoError(t, err) } func TestCheckFormatContentSHA256(t *testing.T) { defaultErr := errors.GetAPIError(errors.ErrContentSHA256Mismatch) for _, tc := range []struct { name string hash string error error }{ { name: "invalid hash format: length and character", hash: "invalid-hash", error: defaultErr, }, { name: "invalid hash format: length (63 characters)", hash: "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f7", error: defaultErr, }, { name: "invalid hash format: character", hash: "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f7s", error: defaultErr, }, { name: "invalid hash format: hash size", hash: "5aadb45520dcd8726b2822a7a78bb53d794f557199d5d4abdedd2c55a4bd6ca73607605c558de3db80c8e86c3196484566163ed1327e82e8b6757d1932113cb8", error: defaultErr, }, { name: "unsigned payload", hash: "UNSIGNED-PAYLOAD", error: nil, }, { name: "no hash", hash: "", error: nil, }, { name: "correct hash format", hash: "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", error: nil, }, } { t.Run(tc.name, func(t *testing.T) { err := checkFormatHashContentSHA256(tc.hash) require.ErrorIs(t, err, tc.error) }) } } type frostFSMock struct { objects map[string]*object.Object } func newFrostFSMock() *frostFSMock { return &frostFSMock{ objects: map[string]*object.Object{}, } } func (f *frostFSMock) GetCredsObject(_ context.Context, prm tokens.PrmGetCredsObject) (*object.Object, error) { obj, ok := f.objects[prm.AccessKeyID] if !ok { return nil, fmt.Errorf("not found") } return obj, nil } func (f *frostFSMock) CreateObject(context.Context, tokens.PrmObjectCreate) (oid.ID, error) { return oid.ID{}, fmt.Errorf("the mock method is not implemented") } func TestAuthenticate(t *testing.T) { key, err := keys.NewPrivateKey() require.NoError(t, err) cfg := &cache.Config{ Size: 10, Lifetime: 24 * time.Hour, Logger: zaptest.NewLogger(t), } gateData := []*accessbox.GateData{{ BearerToken: &bearer.Token{}, GateKey: key.PublicKey(), }} accessBox, secret, err := accessbox.PackTokens(gateData, []byte("secret"), false) require.NoError(t, err) data, err := accessBox.Marshal() require.NoError(t, err) var obj object.Object obj.SetPayload(data) addr := oidtest.Address() obj.SetContainerID(addr.Container()) obj.SetID(addr.Object()) accessKeyID := getAccessKeyID(addr) frostfs := newFrostFSMock() frostfs.objects[accessKeyID] = &obj awsCreds := credentials.NewStaticCredentials(accessKeyID, secret.SecretKey, "") defaultSigner := v4.NewSigner(awsCreds) service, region := "s3", "default" invalidValue := "invalid-value" bigConfig := tokens.Config{ FrostFS: frostfs, Key: key, CacheConfig: cfg, } for _, tc := range []struct { name string prefixes []string request *http.Request err bool errCode errors.ErrorCode }{ { name: "valid sign", prefixes: []string{addr.Container().String()}, request: func() *http.Request { r := httptest.NewRequest(http.MethodPost, "/", nil) _, err = defaultSigner.Sign(r, nil, service, region, time.Now()) require.NoError(t, err) return r }(), }, { name: "no authorization header", request: func() *http.Request { return httptest.NewRequest(http.MethodPost, "/", nil) }(), err: true, }, { name: "invalid authorization header", request: func() *http.Request { r := httptest.NewRequest(http.MethodPost, "/", nil) r.Header.Set(AuthorizationHdr, invalidValue) return r }(), err: true, errCode: errors.ErrAuthorizationHeaderMalformed, }, { name: "invalid access key id format", request: func() *http.Request { r := httptest.NewRequest(http.MethodPost, "/", nil) signer := v4.NewSigner(credentials.NewStaticCredentials(addr.Object().String(), secret.SecretKey, "")) _, err = signer.Sign(r, nil, service, region, time.Now()) require.NoError(t, err) return r }(), err: true, errCode: errors.ErrInvalidAccessKeyID, }, { name: "not allowed access key id", prefixes: []string{addr.Object().String()}, request: func() *http.Request { r := httptest.NewRequest(http.MethodPost, "/", nil) _, err = defaultSigner.Sign(r, nil, service, region, time.Now()) require.NoError(t, err) return r }(), err: true, errCode: errors.ErrAccessDenied, }, { name: "invalid access key id value", request: func() *http.Request { r := httptest.NewRequest(http.MethodPost, "/", nil) signer := v4.NewSigner(credentials.NewStaticCredentials(accessKeyID[:len(accessKeyID)-4], secret.SecretKey, "")) _, err = signer.Sign(r, nil, service, region, time.Now()) require.NoError(t, err) return r }(), err: true, errCode: errors.ErrInvalidAccessKeyID, }, { name: "unknown access key id", request: func() *http.Request { r := httptest.NewRequest(http.MethodPost, "/", nil) signer := v4.NewSigner(credentials.NewStaticCredentials(addr.Object().String()+"0"+addr.Container().String(), secret.SecretKey, "")) _, err = signer.Sign(r, nil, service, region, time.Now()) require.NoError(t, err) return r }(), err: true, }, { name: "invalid signature", request: func() *http.Request { r := httptest.NewRequest(http.MethodPost, "/", nil) signer := v4.NewSigner(credentials.NewStaticCredentials(accessKeyID, "secret", "")) _, err = signer.Sign(r, nil, service, region, time.Now()) require.NoError(t, err) return r }(), err: true, errCode: errors.ErrSignatureDoesNotMatch, }, { name: "invalid signature - AmzDate", prefixes: []string{addr.Container().String()}, request: func() *http.Request { r := httptest.NewRequest(http.MethodPost, "/", nil) _, err = defaultSigner.Sign(r, nil, service, region, time.Now()) r.Header.Set(AmzDate, invalidValue) require.NoError(t, err) return r }(), err: true, }, { name: "invalid AmzContentSHA256", prefixes: []string{addr.Container().String()}, request: func() *http.Request { r := httptest.NewRequest(http.MethodPost, "/", nil) _, err = defaultSigner.Sign(r, nil, service, region, time.Now()) r.Header.Set(AmzContentSHA256, invalidValue) require.NoError(t, err) return r }(), err: true, }, { name: "valid presign", request: func() *http.Request { r := httptest.NewRequest(http.MethodPost, "/", nil) _, err = defaultSigner.Presign(r, nil, service, region, time.Minute, time.Now()) require.NoError(t, err) return r }(), }, { name: "presign, bad X-Amz-Credential", request: func() *http.Request { r := httptest.NewRequest(http.MethodPost, "/", nil) query := url.Values{ AmzAlgorithm: []string{"AWS4-HMAC-SHA256"}, AmzCredential: []string{invalidValue}, } r.URL.RawQuery = query.Encode() return r }(), err: true, }, { name: "presign, bad X-Amz-Expires", request: func() *http.Request { r := httptest.NewRequest(http.MethodPost, "/", nil) _, err = defaultSigner.Presign(r, nil, service, region, time.Minute, time.Now()) queryParams := r.URL.Query() queryParams.Set("X-Amz-Expires", invalidValue) r.URL.RawQuery = queryParams.Encode() require.NoError(t, err) return r }(), err: true, }, { name: "presign, expired", request: func() *http.Request { r := httptest.NewRequest(http.MethodPost, "/", nil) _, err = defaultSigner.Presign(r, nil, service, region, time.Minute, time.Now().Add(-time.Minute)) require.NoError(t, err) return r }(), err: true, errCode: errors.ErrExpiredPresignRequest, }, { name: "presign, signature from future", request: func() *http.Request { r := httptest.NewRequest(http.MethodPost, "/", nil) _, err = defaultSigner.Presign(r, nil, service, region, time.Minute, time.Now().Add(time.Minute)) require.NoError(t, err) return r }(), err: true, errCode: errors.ErrBadRequest, }, } { t.Run(tc.name, func(t *testing.T) { creds := tokens.New(bigConfig) cntr := New(creds, tc.prefixes, ¢erSettingsMock{}) box, err := cntr.Authenticate(tc.request) if tc.err { require.Error(t, err) if tc.errCode > 0 { err = frosterr.UnwrapErr(err) require.Equal(t, errors.GetAPIError(tc.errCode), err) } } else { require.NoError(t, err) require.Equal(t, accessKeyID, box.AuthHeaders.AccessKeyID) require.Equal(t, region, box.AuthHeaders.Region) require.Equal(t, secret.SecretKey, box.AccessBox.Gate.SecretKey) } }) } } func TestHTTPPostAuthenticate(t *testing.T) { const ( policyBase64 = "eyJleHBpcmF0aW9uIjogIjIwMjUtMTItMDFUMTI6MDA6MDAuMDAwWiIsImNvbmRpdGlvbnMiOiBbCiBbInN0YXJ0cy13aXRoIiwgIiR4LWFtei1jcmVkZW50aWFsIiwgIiJdLAogWyJzdGFydHMtd2l0aCIsICIkeC1hbXotZGF0ZSIsICIiXQpdfQ==" invalidValue = "invalid-value" defaultFieldName = "file" service = "s3" region = "default" ) key, err := keys.NewPrivateKey() require.NoError(t, err) cfg := &cache.Config{ Size: 10, Lifetime: 24 * time.Hour, Logger: zaptest.NewLogger(t), } gateData := []*accessbox.GateData{{ BearerToken: &bearer.Token{}, GateKey: key.PublicKey(), }} accessBox, secret, err := accessbox.PackTokens(gateData, []byte("secret"), false) require.NoError(t, err) data, err := accessBox.Marshal() require.NoError(t, err) var obj object.Object obj.SetPayload(data) addr := oidtest.Address() obj.SetContainerID(addr.Container()) obj.SetID(addr.Object()) accessKeyID := getAccessKeyID(addr) frostfs := newFrostFSMock() frostfs.objects[accessKeyID] = &obj invalidAccessKeyID := oidtest.Address().String() + "0" + oidtest.Address().Object().String() timeToSign := time.Now() timeToSignStr := timeToSign.Format("20060102T150405Z") bigConfig := tokens.Config{ FrostFS: frostfs, Key: key, CacheConfig: cfg, } for _, tc := range []struct { name string prefixes []string request *http.Request err bool errCode errors.ErrorCode }{ { name: "HTTP POST valid", request: func() *http.Request { creds := getCredsStr(accessKeyID, timeToSignStr, region, service) sign := SignStr(secret.SecretKey, service, region, timeToSign, policyBase64) return getRequestWithMultipartForm(t, policyBase64, creds, timeToSignStr, sign, defaultFieldName) }(), }, { name: "HTTP POST valid with custom field name", request: func() *http.Request { creds := getCredsStr(accessKeyID, timeToSignStr, region, service) sign := SignStr(secret.SecretKey, service, region, timeToSign, policyBase64) return getRequestWithMultipartForm(t, policyBase64, creds, timeToSignStr, sign, "files") }(), }, { name: "HTTP POST valid with field name with a capital letter", request: func() *http.Request { creds := getCredsStr(accessKeyID, timeToSignStr, region, service) sign := SignStr(secret.SecretKey, service, region, timeToSign, policyBase64) return getRequestWithMultipartForm(t, policyBase64, creds, timeToSignStr, sign, "File") }(), }, { name: "HTTP POST invalid multipart form", request: func() *http.Request { req := httptest.NewRequest(http.MethodPost, "/", nil) req.Header.Set(ContentTypeHdr, "multipart/form-data") return req }(), err: true, errCode: errors.ErrInvalidArgument, }, { name: "HTTP POST invalid signature date time", request: func() *http.Request { creds := getCredsStr(accessKeyID, timeToSignStr, region, service) sign := SignStr(secret.SecretKey, service, region, timeToSign, policyBase64) return getRequestWithMultipartForm(t, policyBase64, creds, invalidValue, sign, defaultFieldName) }(), err: true, }, { name: "HTTP POST invalid creds", request: func() *http.Request { sign := SignStr(secret.SecretKey, service, region, timeToSign, policyBase64) return getRequestWithMultipartForm(t, policyBase64, invalidValue, timeToSignStr, sign, defaultFieldName) }(), err: true, errCode: errors.ErrAuthorizationHeaderMalformed, }, { name: "HTTP POST missing policy", request: func() *http.Request { creds := getCredsStr(accessKeyID, timeToSignStr, region, service) sign := SignStr(secret.SecretKey, service, region, timeToSign, policyBase64) return getRequestWithMultipartForm(t, "", creds, timeToSignStr, sign, defaultFieldName) }(), err: true, }, { name: "HTTP POST invalid accessKeyId", request: func() *http.Request { creds := getCredsStr(invalidValue, timeToSignStr, region, service) sign := SignStr(secret.SecretKey, service, region, timeToSign, policyBase64) return getRequestWithMultipartForm(t, policyBase64, creds, timeToSignStr, sign, defaultFieldName) }(), err: true, }, { name: "HTTP POST invalid accessKeyId - a non-existent box", request: func() *http.Request { creds := getCredsStr(invalidAccessKeyID, timeToSignStr, region, service) sign := SignStr(secret.SecretKey, service, region, timeToSign, policyBase64) return getRequestWithMultipartForm(t, policyBase64, creds, timeToSignStr, sign, defaultFieldName) }(), err: true, }, { name: "HTTP POST invalid signature", request: func() *http.Request { creds := getCredsStr(accessKeyID, timeToSignStr, region, service) sign := SignStr(secret.SecretKey, service, region, timeToSign, invalidValue) return getRequestWithMultipartForm(t, policyBase64, creds, timeToSignStr, sign, defaultFieldName) }(), err: true, errCode: errors.ErrSignatureDoesNotMatch, }, } { t.Run(tc.name, func(t *testing.T) { creds := tokens.New(bigConfig) cntr := New(creds, tc.prefixes, ¢erSettingsMock{}) box, err := cntr.Authenticate(tc.request) if tc.err { require.Error(t, err) if tc.errCode > 0 { err = frosterr.UnwrapErr(err) require.Equal(t, errors.GetAPIError(tc.errCode), err) } } else { require.NoError(t, err) require.Equal(t, secret.SecretKey, box.AccessBox.Gate.SecretKey) require.Equal(t, accessKeyID, box.AuthHeaders.AccessKeyID) } }) } } func getCredsStr(accessKeyID, timeToSign, region, service string) string { return accessKeyID + "/" + timeToSign + "/" + region + "/" + service + "/aws4_request" } func getRequestWithMultipartForm(t *testing.T, policy, creds, date, sign, fieldName string) *http.Request { body := &bytes.Buffer{} writer := multipart.NewWriter(body) defer writer.Close() err := writer.WriteField("policy", policy) require.NoError(t, err) err = writer.WriteField(AmzCredential, creds) require.NoError(t, err) err = writer.WriteField(AmzDate, date) require.NoError(t, err) err = writer.WriteField(AmzSignature, sign) require.NoError(t, err) _, err = writer.CreateFormFile(fieldName, "test.txt") require.NoError(t, err) req := httptest.NewRequest(http.MethodPost, "/", body) req.Header.Set(ContentTypeHdr, writer.FormDataContentType()) return req } func getAccessKeyID(addr oid.Address) string { return strings.ReplaceAll(addr.EncodeToString(), "/", "0") }