From 981f92ab420c931d9cc1343a426988c631d0bc6a Mon Sep 17 00:00:00 2001 From: "Artur M. Wolff" Date: Thu, 23 Dec 2021 12:48:39 +0100 Subject: [PATCH] pkg/linksharing/sharing: add support for Signature Version 4-signed URLs This change adds support for Signature Version 4-signed linksharing URLs (only for signed and not for pre-signed). The code that implements the new feature introduced a new package: pkg/linksharing/sharing/internal/signed, as it's orthogonal to the rest of the existing packages. Signed URLs will allow users to use non-public access grants after signing the URL using Secret Access Key corresponding to non-public access grant. Furthermore, the key reason to implement this feature is to allow satellite UI to use linksharing for object and nodes storing the object-map previews (the reason we can't just use Gateway-MT), but not make the credentials public. Signature Version 4 verification/re-signing is implemented as in https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html, but a few assumptions were made for linksharing only. Specifically: we always assume that the URL is non-pre-signed, we don't escape paths Amazon-style (AWS also doesn't do this for S3), and we always assume an empty request body (we only allow HEAD and GET requests). Hosting requests are currently not supported due to shared cache per custom hostname that successfully verified signed request would update and make non-public access grant public from the linksharing perspective. Closes #113 Change-Id: I18cb4896ae36c48cf62fbabf6813f9aeff56622c --- pkg/linksharing/sharing/access.go | 34 +- .../sharing/internal/signed/signed.go | 418 +++++++++++++++++ .../sharing/internal/signed/signed_test.go | 425 ++++++++++++++++++ pkg/linksharing/sharing/standard.go | 6 +- pkg/linksharing/sharing/txtrecords.go | 7 +- 5 files changed, 876 insertions(+), 14 deletions(-) create mode 100644 pkg/linksharing/sharing/internal/signed/signed.go create mode 100644 pkg/linksharing/sharing/internal/signed/signed_test.go diff --git a/pkg/linksharing/sharing/access.go b/pkg/linksharing/sharing/access.go index dda394fe..61166218 100644 --- a/pkg/linksharing/sharing/access.go +++ b/pkg/linksharing/sharing/access.go @@ -6,24 +6,33 @@ package sharing import ( "context" "net/http" + "time" "github.com/btcsuite/btcutil/base58" "github.com/zeebo/errs" "storj.io/gateway-mt/pkg/authclient" "storj.io/gateway-mt/pkg/errdata" + "storj.io/gateway-mt/pkg/linksharing/sharing/internal/signed" "storj.io/uplink" ) -// parseAccess parses access to identify if it's a valid access grant otherwise -// identifies as being an access key and request the Auth services to resolve -// it. clientIP is the IP of the client that originated the request and it's -// required to be sent to the Auth Service. -// -// It returns an error if the access grant is correctly encoded but it doesn't -// parse or if the Auth Service responds with an error. -func parseAccess(ctx context.Context, access string, cfg *authclient.AuthClient, clientIP string) (_ *uplink.Access, err error) { +// parseAccess guesses whether access is an access grant or Access Key ID. If +// latter, it contacts authservice to resolve it. If the resolved access grant +// isn't public, it will assume r is AWS Signature Version 4-signed if it +// contains a valid signature. signedAccessValidityTolerance is how much r's +// signature time can be skewed. clientIP is the IP of the client that +// originated the request and cannot be empty. +func parseAccess( + ctx context.Context, + r *http.Request, + access string, + signedAccessValidityTolerance time.Duration, + cfg *authclient.AuthClient, + clientIP string, +) (_ *uplink.Access, err error) { defer mon.Task()(&ctx)(&err) + wrappedParse := func(access string) (*uplink.Access, error) { parsed, err := uplink.ParseAccess(access) if err != nil { @@ -42,8 +51,13 @@ func parseAccess(ctx context.Context, access string, cfg *authclient.AuthClient, if err != nil { return nil, err } - if !authResp.Public { - return nil, errdata.WithStatus(errs.New("non-public access key id"), http.StatusForbidden) + if !authResp.Public { // If credentials aren't public, assume signed request. + if err = signed.VerifySigningInfo(r, authResp.SecretKey, time.Now(), signedAccessValidityTolerance); err != nil { + if errs.Is(err, signed.ErrMissingAuthorizationHeader) { + return nil, errdata.WithStatus(errs.New("non-public Access Key ID"), http.StatusForbidden) + } + return nil, errdata.WithStatus(err, http.StatusForbidden) + } } return wrappedParse(authResp.AccessGrant) diff --git a/pkg/linksharing/sharing/internal/signed/signed.go b/pkg/linksharing/sharing/internal/signed/signed.go new file mode 100644 index 00000000..3adc7392 --- /dev/null +++ b/pkg/linksharing/sharing/internal/signed/signed.go @@ -0,0 +1,418 @@ +// Copyright (C) 2021 Storj Labs, Inc. +// See LICENSE for copying information. + +// Package signed provides verification of requests signed with AWS Signature +// Version 4 machinery. Signed requests enable the usage of non-public access +// grants while requesting an object. +// +// The parsing part of the package (parseSigningInfo and child types/functions) +// used MinIO's parsing code [0] as an edge-case reference. +// +// The verification/re-signing part of the package (VerifySigningInfo and child +// types/functions) strictly follows Signature Version 4 signing process [1]. +// +// Some parts of the signing process are tuned specifically for linksharing. For +// example, we always assume an empty request body as we only allow HEAD and GET +// requests. +// +// [0]: +// - https://github.com/minio/minio/blob/e0d3a8c1f4e52bb4a7d82f7f369b6796103740b3/cmd/signature-v4-parser.go +// [1]: +// - https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html +// - https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html +package signed + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "github.com/zeebo/errs" +) + +const ( + iso8601TimeLayout = "20060102T150405Z" + yyyymmddTimeLayout = "20060102" + v4SigningAlgorithm = "AWS4-HMAC-SHA256" + emptyBodySHA256Hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" +) + +// ErrMissingAuthorizationHeader indicates that the Authorization header for a +// particular request was not found. It's used to differentiate between signed +// requests that have invalid signing info and unsigned requests trying to use +// non-public access grant. +var ErrMissingAuthorizationHeader = errs.New("missing Authorization header") + +// VerifySigningInfo reports whether r's signature is valid and constructed with +// secretAccessKey. The function additionally performs signature time validity +// check using currentTime as the current time. Signature time skewed backward +// or onwards up to validityTolerance will be tolerated. +// +// TODO(artur): add fuzz test for VerifySigningInfo vide +// https://pkg.go.dev/testing@master#hdr-Fuzzing. +func VerifySigningInfo(r *http.Request, secretAccessKey string, currentTime time.Time, validityTolerance time.Duration) error { + errVerification := errs.Class("signing info verification") + + if r == nil { + return errVerification.Wrap(ErrMissingAuthorizationHeader) + } + + authorizationValue := r.Header.Get("Authorization") + if authorizationValue == "" { + return errVerification.Wrap(ErrMissingAuthorizationHeader) + } + + info, err := parseSigningInfo(authorizationValue) + if err != nil { + return errVerification.Wrap(err) + } + + headers, err := extractHeaders(info.signedHeaders, r) + if err != nil { + return errVerification.Wrap(err) + } + + date := r.Header.Get("X-Amz-Date") + if date == "" { + date = r.Header.Get("Date") + } + + t, err := time.Parse(iso8601TimeLayout, date) + if err != nil { + return errVerification.Wrap(err) + } + + // Step 1: https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html + canonicalRequest := canonicalizeRequest(r, headers) + + // Step 2: https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html + stringToSign := createStringToSign(canonicalRequest, info.credential.buildScope(), t) + + // Step 3: https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html + reg, svc, req := info.credential.scope.region, info.credential.scope.service, info.credential.scope.request + + signature := calculateSignature(secretAccessKey, reg, svc, req, stringToSign, t) + + // Step 4: comparison! + if subtle.ConstantTimeCompare([]byte(info.signature), []byte(signature)) != 1 { + return errVerification.New("signature mismatch") + } + + if currentTime.Add(-validityTolerance).After(t) || currentTime.Add(validityTolerance).Before(t) { + return errVerification.New("signature time too skewed") + } + + return nil +} + +// extractHeaders extracts signedHeaders from r. +// +// TODO(artur): better doc. +func extractHeaders(signedHeaders []string, r *http.Request) (http.Header, error) { + extracted := make(http.Header) + + for _, header := range signedHeaders { + if v, ok := r.Header[http.CanonicalHeaderKey(header)]; ok { + extracted[http.CanonicalHeaderKey(header)] = v + continue + } + // If we don't find the signed header in headers, we fall back to + // request attributes. + // + // Go HTTP server strips off Expect, Host and Transfer-Encoding. + // + // Content-Length is normally excluded from the signature calculation, + // but some clients still use it, so we try to be most compatible here. + switch header { + case "content-length": + extracted.Set(header, strconv.FormatInt(r.ContentLength, 10)) + case "expect": + extracted.Set(header, "100-continue") + case "host": + extracted.Set(header, r.Host) + case "transfer-encoding": + extracted[http.CanonicalHeaderKey(header)] = r.TransferEncoding + default: + return nil, errs.New("signed header %q not found", header) + } + } + + return extracted, nil +} + +// canonicalizeRequest creates a canonical string from r and headers (signed). +// +// V4's spec instructs us to double-escape the request's URI for all AWS +// services except S3. For S3, we only need to perform escaping once. +// Linksharing is an S3-like service as its URL contains bucket, prefix, and +// object path. We escape using (*URL).EscapedPath method, but it's not fully +// Amazon-style compatible (for example, it does not change +'s to %2B's). We +// don't do the Amazon-style escaping because AWS SDK for Go +// (https://github.com/aws/aws-sdk-go) also does not follow the spec in this +// regard, and we test against the signatures it produces. +// +// TODO(artur): send a patch to AWS SDK for Go rewriting their escaping logic to +// follow the V4's spec thoroughly. AWS SDK for Go Version 2 +// (https://github.com/aws/aws-sdk-go-v2) suffers from the same problem (the +// signing code seems to be a copy-paste of Version 1's). When fixed, it will be +// great to have it fixed here, but it's not a show-stopper given that both SDKs +// never received many complaints about this behavior (I only found one issue +// that might be related: https://github.com/aws/aws-sdk-go/issues/3592). +// +// It assumes an empty request body as we only allow HEAD and GET requests given +// the service's nature. +func canonicalizeRequest(r *http.Request, headers http.Header) string { + return strings.Join([]string{ + r.Method, + r.URL.EscapedPath(), + strings.ReplaceAll(r.URL.Query().Encode(), "+", "%20"), + canonicalizeHeaders(headers), + createSignedHeaders(headers), + emptyBodySHA256Hash, + }, "\n") +} + +func canonicalizeHeaders(headers http.Header) string { + var keys []string + keysValues := make(http.Header) + + for k, v := range headers { + l := strings.ToLower(k) + keys = append(keys, l) + keysValues[l] = v + } + + sort.Strings(keys) + + var b strings.Builder + + for _, k := range keys { + b.WriteString(k) + b.WriteRune(':') + for i, v := range keysValues[k] { + if i > 0 { + b.WriteRune(',') + } + b.WriteString(trimAll(v)) + } + b.WriteRune('\n') + } + + return b.String() +} + +func trimAll(s string) string { + return strings.Join(strings.Fields(s), " ") +} + +func createSignedHeaders(headers http.Header) string { + var ret []string + + for k := range headers { + ret = append(ret, strings.ToLower(k)) + } + + sort.Strings(ret) + + return strings.Join(ret, ";") +} + +func createStringToSign(canonicalRequest, scope string, t time.Time) string { + canonicalRequestSHA256Sum := sha256.Sum256([]byte(canonicalRequest)) + + return strings.Join([]string{ + v4SigningAlgorithm, + t.Format(iso8601TimeLayout), + scope, + hex.EncodeToString(canonicalRequestSHA256Sum[:]), + }, "\n") +} + +func calculateSignature(secretAccessKey, region, service, request, stringToSign string, t time.Time) string { + keySigning := deriveSigningKey(secretAccessKey, region, service, request, t) + return hex.EncodeToString(hmacSHA256(keySigning, []byte(stringToSign))) +} + +func deriveSigningKey(keySecret, region, service, request string, t time.Time) []byte { + keyDate := hmacSHA256([]byte("AWS4"+keySecret), []byte(t.Format(yyyymmddTimeLayout))) + keyRegion := hmacSHA256(keyDate, []byte(region)) + keyService := hmacSHA256(keyRegion, []byte(service)) + return hmacSHA256(keyService, []byte(request)) +} + +func hmacSHA256(key []byte, data []byte) []byte { + hash := hmac.New(sha256.New, key) + hash.Write(data) + return hash.Sum(nil) +} + +// signingInfo represents a structured form of AWS Signature Version 4's +// Authorization header value. +type signingInfo struct { + credential parsedCredential + signedHeaders []string + signature string +} + +// parseSigningInfo parses the Authorization header value of the form below into +// signingInfo. +// +// AWS4-HMAC-SHA256 Credential=accessKeyID/scope, SignedHeaders=..., Signature=... +func parseSigningInfo(authorizationValue string) (signingInfo, error) { + errSigningInfo := errs.Class("signing info") + + // We don't care about Access Key ID in the Authorization header value as we + // get it from the URL. With this context in mind, we can remove all spaces. + // + // TODO(artur): should we verify Access Key ID? It seems a little excessive. + authorizationValue = strings.ReplaceAll(authorizationValue, " ", "") + + if !strings.HasPrefix(authorizationValue, v4SigningAlgorithm) { + return signingInfo{}, errSigningInfo.New("invalid algorithm") + } + + // Strip off the algorithm prefix after we made sure we support it. + authorizationValue = authorizationValue[len(v4SigningAlgorithm):] + + parts := strings.SplitN(authorizationValue, ",", 3) + if len(parts) < 3 { + return signingInfo{}, errSigningInfo.New("invalid number of parts") + } + + credential, err := parseCredential(parts[0]) + if err != nil { + return signingInfo{}, errSigningInfo.Wrap(err) + } + + signedHeaders, err := parseSignedHeaders(parts[1]) + if err != nil { + return signingInfo{}, errSigningInfo.Wrap(err) + } + + signature, err := parseSignature(parts[2]) + if err != nil { + return signingInfo{}, errSigningInfo.Wrap(err) + } + + return signingInfo{ + credential: credential, + signedHeaders: signedHeaders, + signature: signature, + }, nil +} + +// parsedCredential represents a structured form of the Credential part from the +// Authorization header value. +type parsedCredential struct { + accessKeyID string + scope struct { + date time.Time + region string + service string + request string + } +} + +func (p parsedCredential) buildScope() string { + return strings.Join([]string{ + p.scope.date.Format(yyyymmddTimeLayout), + p.scope.region, + p.scope.service, + p.scope.request, + }, "/") +} + +// parseCredential parses Credential part into its structured form. +func parseCredential(credential string) (ret parsedCredential, err error) { + errCredential := errs.Class("Credential") + + fields := strings.SplitN(credential, "=", 2) + if len(fields) < 2 { + return parsedCredential{}, errCredential.New("invalid number of fields") + } + + if fields[0] != "Credential" { + return parsedCredential{}, errCredential.New("not a Credential") + } + + // Our Access Key IDs never contain forward slashes, so we can treat forward + // slashes as final separators. + elements := strings.SplitN(fields[1], "/", 5) + if len(elements) < 5 { + return parsedCredential{}, errCredential.New("invalid number of elements") + } + + ret.accessKeyID = elements[0] + + ret.scope.date, err = time.Parse(yyyymmddTimeLayout, elements[1]) + if err != nil { + return parsedCredential{}, errCredential.New("invalid date: %w", err) + } + + ret.scope.region = elements[2] + + if elements[3] != "linksharing" { + return parsedCredential{}, errCredential.New("invalid service") + } + if elements[4] != "aws4_request" { + return parsedCredential{}, errCredential.New("invalid request") + } + + ret.scope.service = elements[3] + ret.scope.request = elements[4] + + return ret, nil +} + +// parseSignedHeaders parses SignedHeaders part into a slice of headers. +func parseSignedHeaders(signedHeaders string) ([]string, error) { + errSignedHeaders := errs.Class("SignedHeaders") + + fields := strings.SplitN(signedHeaders, "=", 2) + if len(fields) < 2 { + return nil, errSignedHeaders.New("invalid number of fields") + } + + if fields[0] != "SignedHeaders" { + return nil, errSignedHeaders.New("not a SignedHeaders") + } + if fields[1] == "" { + return nil, errSignedHeaders.New("empty") + } + + headers := strings.Split(fields[1], ";") + + // The list of signed headers must contain the "host" header. + for _, h := range headers { + if h == "host" { + return headers, nil + } + } + + return nil, errSignedHeaders.New("host header is mandatory") +} + +// parseSignature parses Signature part. +func parseSignature(signature string) (string, error) { + errSignature := errs.Class("Signature") + + fields := strings.SplitN(signature, "=", 2) + if len(fields) < 2 { + return "", errSignature.New("invalid number of fields") + } + + if fields[0] != "Signature" { + return "", errSignature.New("not a Signature") + } + if fields[1] == "" { + return "", errSignature.New("empty") + } + + return fields[1], nil +} diff --git a/pkg/linksharing/sharing/internal/signed/signed_test.go b/pkg/linksharing/sharing/internal/signed/signed_test.go new file mode 100644 index 00000000..2be1abde --- /dev/null +++ b/pkg/linksharing/sharing/internal/signed/signed_test.go @@ -0,0 +1,425 @@ +// Copyright (C) 2021 Storj Labs, Inc. +// See LICENSE for copying information. + +package signed + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestErrMissingAuthorizationHeader(t *testing.T) { + err := VerifySigningInfo(nil, "", time.Time{}, 0) + + assert.ErrorIs(t, err, ErrMissingAuthorizationHeader) + + r, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, "https://link.eu1.storjshare.io/raw/AKIAIOSFODNN7EXAMPLE/b/o", nil) + require.NoError(t, err) + + err = VerifySigningInfo(r, "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", time.Now(), 15*time.Minute) + + assert.ErrorIs(t, err, ErrMissingAuthorizationHeader) +} + +func TestVerifySigningInfo(t *testing.T) { + // The following code was used to generate valid signatures for test cases + // below (SDK version: v1.42.25): + // + // import ( + // "net/http" + // "time" + // + // "github.com/aws/aws-sdk-go/aws/credentials" + // v4 "github.com/aws/aws-sdk-go/aws/signer/v4" + // ) + // + // ... + // + // c := credentials.NewCredentials(&credentials.StaticProvider{ + // Value: credentials.Value{ + // AccessKeyID: "AKIAIOSFODNN7EXAMPLE", + // SecretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + // }}) + // + // signer := v4.NewSigner(c, func(s *v4.Signer) { + // s.DisableURIPathEscaping = true + // }) + // + // r, err := http.NewRequest(http.MethodGet, "https://link.eu1.storjshare.io/raw/AKIAIOSFODNN7EXAMPLE/b/o", nil) + // if err != nil { + // panic(err) + // } + // + // if _, err = signer.Sign(r, nil, "linksharing", "eu1", time.Unix(1640867116, 0)); err != nil { + // panic(err) + // } + for i, tt := range [...]struct { + headers map[string]string + wantErr bool + }{ + { + headers: nil, + wantErr: true, + }, + { + headers: map[string]string{ + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20211230/eu1/linksharing/aws4_request, SignedHeaders=host;x-amz-date, Signature=22aa9f54124d4f1bb89c9ba733513c83710a6826908adcc8a1b97577a4c543ec", + }, + wantErr: true, + }, + { + headers: map[string]string{ + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/19700101/eu1/linksharing/aws4_request, SignedHeaders=host;x-amz-date, Signature=0000000000000000000000000000000000000000000000000000000000000000", + "X-Amz-Date": "20211230T122516Z", + }, + wantErr: true, + }, + { + headers: map[string]string{ + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20211230/eu1/linksharing/aws4_request, SignedHeaders=host;x-amz-date, Signature=47045f7ebb8fcfc2003957eb072eec28e8e433f6926c7ce1c1535dec9c2f09d9", + "X-Amz-Date": "20211230T121015Z", // 20211230T122516Z - 15m1s + }, + wantErr: true, + }, + { + headers: map[string]string{ + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20211230/eu1/linksharing/aws4_request, SignedHeaders=host;x-amz-date, Signature=fb8e27a8cd8693cea0ece617d0fc6aec44b08fcf45e89014a78edb259c12f895", + "X-Amz-Date": "20211230T124018Z", // 20211230T122516Z + 15m2s + }, + wantErr: true, + }, + { + headers: map[string]string{ + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20211230/eu1/linksharing/aws4_request, SignedHeaders=host;x-amz-date, Signature=22aa9f54124d4f1bb89c9ba733513c83710a6826908adcc8a1b97577a4c543ec", + "X-Amz-Date": "20211230T122516Z", + }, + wantErr: false, + }, + { + headers: map[string]string{ + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20211230/eu1/linksharing/aws4_request, SignedHeaders=host;x-amz-date, Signature=d6c784d6ff6978f456abec109bdb3150f674ac3db2441551e01135036ccd3883", + "X-Amz-Date": "20211230T121017Z", // 20211230T122516Z - 14m59s + }, + wantErr: false, + }, + { + headers: map[string]string{ + "Authorization": "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20211230/eu1/linksharing/aws4_request, SignedHeaders=host;x-amz-date, Signature=ccdb4bd3fe0861c81ab4d31800c67d08456525dd72f4c10744ba5dbaeadd8507", + "X-Amz-Date": "20211230T124016Z", // 20211230T122516Z + 15m + }, + wantErr: false, + }, + } { + r, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, "https://link.eu1.storjshare.io/raw/AKIAIOSFODNN7EXAMPLE/b/o", nil) + require.NoError(t, err, i) + + for k, v := range tt.headers { + r.Header.Set(k, v) + } + + err = VerifySigningInfo(r, "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", time.Unix(1640867116, 0), 15*time.Minute) + + assert.Equal(t, tt.wantErr, err != nil, i) + } +} + +func TestParseSigningInfo(t *testing.T) { + for i, tt := range [...]struct { + authorizationValue string + want signingInfo + wantErr bool + }{ + { + authorizationValue: "", + want: signingInfo{}, + wantErr: true, + }, + { + authorizationValue: "AWS4-HMAC-SHA1", + want: signingInfo{}, + wantErr: true, + }, + { + authorizationValue: "AWS4-HMAC-SHA256", + want: signingInfo{}, + wantErr: true, + }, + { + authorizationValue: "AWS4-HMAC-SHA256 Credential=AKID/20211229/eu1/linksharing/aws4_request", + want: signingInfo{}, + wantErr: true, + }, + { + authorizationValue: "AWS4-HMAC-SHA256 Credential=AKID/20211229/eu1/linksharing/aws4_request, SignedHeaders=host", + want: signingInfo{}, + wantErr: true, + }, + { + authorizationValue: " AWS4-HMAC-SHA256 Credential = AKID/20211229/eu1/linksharing/aws4_request , SignedHeaders = host,Signature=... ", + want: signingInfo{ + credential: parsedCredential{ + accessKeyID: "AKID", + scope: struct { + date time.Time + region, service, request string + }{ + date: mustParseTime("20060102", "20211229"), + region: "eu1", + service: "linksharing", + request: "aws4_request", + }, + }, + signedHeaders: []string{"host"}, + signature: "...", + }, + wantErr: false, + }, + } { + got, err := parseSigningInfo(tt.authorizationValue) + + if tt.wantErr { + assert.Error(t, err, i) + } else { + require.NoError(t, err, i) + } + + assert.Equal(t, tt.want, got, i) + } +} + +func TestParseCredential(t *testing.T) { + for i, tt := range [...]struct { + credential string + want parsedCredential + wantScope string + wantErr bool + }{ + { + credential: "", + want: parsedCredential{}, + wantErr: true, + }, + { + credential: "Credential", + want: parsedCredential{}, + wantErr: true, + }, + { + credential: "Credential=", + want: parsedCredential{}, + wantErr: true, + }, + { + credential: "Credential=AccessKeyID", + want: parsedCredential{}, + wantErr: true, + }, + { + credential: "Credential=AccessKeyID/20211229", + want: parsedCredential{}, + wantErr: true, + }, + { + credential: "Credential=AccessKeyID/20211229/eu1", + want: parsedCredential{}, + wantErr: true, + }, + { + credential: "Credential=AccessKeyID/20211229/eu1/linksharing", + want: parsedCredential{}, + wantErr: true, + }, + { + credential: "Credential=AccessKeyID/Wed Dec 29 01:07:45 CET 2021/eu1/linksharing/aws4_request", + want: parsedCredential{}, + wantErr: true, + }, + { + credential: "Credential=AccessKeyID/20211229/eu1/s3/aws4_request", + want: parsedCredential{}, + wantErr: true, + }, + { + credential: "Credential=AccessKeyID/20211229/eu1/linksharing/???", + want: parsedCredential{}, + wantErr: true, + }, + { + credential: "Credential=////", + want: parsedCredential{}, + wantErr: true, + }, + { + credential: "Test=AccessKeyID/20211229/eu1/linksharing/aws4_request", + want: parsedCredential{}, + wantErr: true, + }, + { + credential: "Credential=AccessKeyID/20211229/eu1/linksharing/aws4_request", + want: parsedCredential{ + accessKeyID: "AccessKeyID", + scope: struct { + date time.Time + region, service, request string + }{ + date: mustParseTime("20060102", "20211229"), + region: "eu1", + service: "linksharing", + request: "aws4_request", + }, + }, + wantScope: "20211229/eu1/linksharing/aws4_request", + wantErr: false, + }, + } { + got, err := parseCredential(tt.credential) + + if tt.wantErr { + assert.Error(t, err, i) + } else { + require.NoError(t, err, i) + } + + if !tt.wantErr { + assert.Equal(t, tt.wantScope, got.buildScope(), i) + } + + assert.Equal(t, tt.want, got, i) + } +} + +func mustParseTime(layout, value string) time.Time { + t, err := time.Parse(layout, value) + if err != nil { + panic(err) + } + return t +} + +func TestParseSignedHeaders(t *testing.T) { + for i, tt := range [...]struct { + signedHeaders string + want []string + wantErr bool + }{ + { + signedHeaders: "", + want: nil, + wantErr: true, + }, + { + signedHeaders: "SignedHeaders", + want: nil, + wantErr: true, + }, + { + signedHeaders: "SignedHeaders=", + want: nil, + wantErr: true, + }, + { + signedHeaders: "SignedHeaders=;", + want: nil, + wantErr: true, + }, + { + signedHeaders: "SignedHeaders=test", + want: nil, + wantErr: true, + }, + { + signedHeaders: "SignedHeaders=test;", + want: nil, + wantErr: true, + }, + { + signedHeaders: "Test=host", + want: nil, + wantErr: true, + }, + { + signedHeaders: "Test=host;", + want: nil, + wantErr: true, + }, + { + signedHeaders: "SignedHeaders=host", + want: []string{"host"}, + wantErr: false, + }, + { + signedHeaders: "SignedHeaders=host;", + want: []string{"host", ""}, + wantErr: false, + }, + { + signedHeaders: "SignedHeaders=test;host", + want: []string{"test", "host"}, + wantErr: false, + }, + { + signedHeaders: "SignedHeaders=test;host;", + want: []string{"test", "host", ""}, + wantErr: false, + }, + } { + got, err := parseSignedHeaders(tt.signedHeaders) + + if tt.wantErr { + assert.Error(t, err, i) + } else { + require.NoError(t, err, i) + } + + assert.Equal(t, tt.want, got, i) + } +} + +func TestParseSignature(t *testing.T) { + for i, tt := range [...]struct { + signature string + want string + wantErr bool + }{ + { + signature: "", + want: "", + wantErr: true, + }, + { + signature: "Signature", + want: "", + wantErr: true, + }, + { + signature: "Signature=", + want: "", + wantErr: true, + }, + { + signature: "Test=test", + want: "", + wantErr: true, + }, + { + signature: "Signature=test", + want: "test", + wantErr: false, + }, + } { + got, err := parseSignature(tt.signature) + + if tt.wantErr { + assert.Error(t, err, i) + } else { + require.NoError(t, err, i) + } + + assert.Equal(t, tt.want, got, i) + } +} diff --git a/pkg/linksharing/sharing/standard.go b/pkg/linksharing/sharing/standard.go index 0c474651..4d744d93 100644 --- a/pkg/linksharing/sharing/standard.go +++ b/pkg/linksharing/sharing/standard.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "strings" + "time" "github.com/zeebo/errs" @@ -53,9 +54,8 @@ func (handler *Handler) handleStandard(ctx context.Context, w http.ResponseWrite pr.realKey = parts[2] } - access, err := parseAccess(ctx, serializedAccess, handler.authClient, - trustedip.GetClientIP(handler.trustedClientIPsList, r), - ) + // TODO(artur): make signedAccessValidityTolerance a configuration attribute. + access, err := parseAccess(ctx, r, serializedAccess, 15*time.Minute, handler.authClient, trustedip.GetClientIP(handler.trustedClientIPsList, r)) if err != nil { return err } diff --git a/pkg/linksharing/sharing/txtrecords.go b/pkg/linksharing/sharing/txtrecords.go index bd71f455..7d700e0f 100644 --- a/pkg/linksharing/sharing/txtrecords.go +++ b/pkg/linksharing/sharing/txtrecords.go @@ -135,7 +135,12 @@ func (records *txtRecords) queryAccessFromDNS(ctx context.Context, hostname stri root = set.Lookup("storj-path") } - access, err := parseAccess(ctx, serializedAccess, records.auth, clientIP) + // NOTE(artur): due to cache shared among all clients per hostname for + // hosting requests, signed requests cannot be served. One client with a + // valid signed request could update the cache for all other clients. One + // way to circumvent this would be to guess the signed request before and + // disable cache in that path. However, this requires major refactoring. + access, err := parseAccess(ctx, nil, serializedAccess, 0, records.auth, clientIP) if err != nil { return nil, errs.New("failure with hostname %q: %w", hostname, err) }