Skip to content

Commit

Permalink
feat(auth): enhance AWS Signature V4 authentication support
Browse files Browse the repository at this point in the history
- Add query-based authentication alongside header-based
- Support "UNSIGNED-PAYLOAD"
- Add fallback options for date header extraction
- Fix ordering of query keys by using req.URL.Query().Encode() instead of req.URL.RawQuery
  • Loading branch information
itsHenry35 authored and ncw committed Jul 10, 2024
1 parent 90e8e82 commit d61b9c9
Show file tree
Hide file tree
Showing 3 changed files with 111 additions and 24 deletions.
2 changes: 1 addition & 1 deletion signature/signature-v4-utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ var (
// extractSignedHeaders extract signed headers from Authorization header
func extractSignedHeaders(signedHeaders []string, r *http.Request) (http.Header, ErrorCode) {
reqHeaders := r.Header
reqQueries := r.Form
reqQueries := r.URL.Query()
// find whether "host" is part of list of signed headers.
// if not return ErrUnsignedHeaders. "host" is mandatory.
if !contains(signedHeaders, "host") {
Expand Down
60 changes: 44 additions & 16 deletions signature/signature-v4.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"fmt"
"net/http"
"sort"
"strings"
Expand All @@ -25,6 +26,11 @@ const (
headerDate = "Date"
amzContentSha256 = "X-Amz-Content-Sha256"
amzDate = "X-Amz-Date"
amzAlgorithm = "X-Amz-Algorithm"
amzCredential = "X-Amz-Credential"
amzSignedHeaders = "X-Amz-SignedHeaders"
amzSignature = "X-Amz-Signature"
amzexpires = "X-Amz-Expires"
)

// getCanonicalHeaders generate a list of request headers with their values
Expand Down Expand Up @@ -133,11 +139,22 @@ func getSigningKey(secretKey string, t time.Time, region string) []byte {
func V4SignVerify(r *http.Request) ErrorCode {
// Copy request.
req := *r
hashedPayload := getContentSha256Cksum(r)
queryf := req.URL.Query()
isUnsignedPayload := req.Header.Get("X-Amz-Content-Sha256") == "UNSIGNED-PAYLOAD"

// Save authorization header.
v4Auth := req.Header.Get(headerAuth)

// If the header is empty but the query string has the signature, then it's QueryString authentication. (https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-query-string-auth.html)
if v4Auth == "" && queryf.Get(amzSignature) != "" {
// QueryString authentications are always "UNSIGNED-PAYLOAD".
isUnsignedPayload = true
v4Auth = fmt.Sprintf("%s Credential=%s, SignedHeaders=%s, Signature=%s", queryf.Get(amzAlgorithm), queryf.Get(amzCredential), queryf.Get(amzSignedHeaders), queryf.Get(amzSignature))
if queryf.Get(amzCredential) == "" {
return errMissingCredTag
}
}

// Parse signature version '4' header.
signV4Values, Err := ParseSignV4(v4Auth)
if Err != ErrNone {
Expand All @@ -155,12 +172,18 @@ func V4SignVerify(r *http.Request) ErrorCode {
return ErrCode
}

// Extract date, if not present throw Error.
var date string
if date = req.Header.Get(amzDate); date == "" {
if date = r.Header.Get(headerDate); date == "" {
return errMissingDateHeader
}
// Extract date from various possible sources
date := req.Header.Get(amzDate)
if date == "" {
date = req.Header.Get(headerDate)
}
if date == "" {
date = queryf.Get(amzDate)
}

// If date is still empty after checking all sources, return an error
if date == "" {
return errMissingDateHeader
}

// Parse date header.
Expand All @@ -170,19 +193,24 @@ func V4SignVerify(r *http.Request) ErrorCode {
}

// Query string.
queryStr := req.URL.RawQuery

// Get canonical request.
canonicalRequest := getCanonicalRequest(extractedSignedHeaders, hashedPayload, queryStr, req.URL.Path, req.Method)

// Get string to sign from canonical request.
stringToSign := getStringToSign(canonicalRequest, t, signV4Values.Credential.getScope())
queryf.Del(amzSignature)
rawquery := queryf.Encode()

// Get hmac signing key.
signingKey := getSigningKey(cred.SecretKey, signV4Values.Credential.scope.date, signV4Values.Credential.scope.region)

// Calculate signature.
newSignature := getSignature(signingKey, stringToSign)
var newSignature string
if isUnsignedPayload {
hashedPayload := "UNSIGNED-PAYLOAD"
canonicalRequest := getCanonicalRequest(extractedSignedHeaders, hashedPayload, rawquery, req.URL.Path, req.Method)
stringToSign := getStringToSign(canonicalRequest, t, signV4Values.Credential.getScope())
newSignature = getSignature(signingKey, stringToSign)
} else {
hashedPayload := getContentSha256Cksum(r)
canonicalRequest := getCanonicalRequest(extractedSignedHeaders, hashedPayload, rawquery, req.URL.Path, req.Method)
stringToSign := getStringToSign(canonicalRequest, t, signV4Values.Credential.getScope())
newSignature = getSignature(signingKey, stringToSign)
}

// Verify if signature match.
if !compareSignatureV4(newSignature, signV4Values.Signature) {
Expand Down
73 changes: 66 additions & 7 deletions signature/signature-v4_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"math/rand"
"net/http"
"net/url"
"testing"
"time"

Expand Down Expand Up @@ -37,28 +38,86 @@ func RandString(n int) string {
}

func TestSignatureMatch(t *testing.T) {
testCases := []struct {
name string
useQueryString bool
}{
{
name: "Header-based Authentication",
useQueryString: false,
},
{
name: "Query-based Authentication",
useQueryString: true,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
Body := bytes.NewReader(nil)
ak := RandString(32)
sk := RandString(64)
region := RandString(16)

creds := credentials.NewStaticCredentials(ak, sk, "")
signature.ReloadKeys(map[string]string{ak: sk})
signer := v4.NewSigner(creds)

req, err := http.NewRequest(http.MethodPost, "https://s3-endpoint.example.com/bin", Body)
if err != nil {
t.Error(err)
}

if tc.useQueryString {
// For query-based authentication
req.URL.RawQuery = url.Values{
"X-Amz-Algorithm": []string{signV4Algorithm},
"X-Amz-Credential": []string{fmt.Sprintf("%s/%s/%s/%s/aws4_request", ak, time.Now().Format(yyyymmdd), region, serviceS3)},
"X-Amz-Date": []string{time.Now().Format(iso8601Format)},
"X-Amz-Expires": []string{"900"},
"X-Amz-SignedHeaders": []string{"host"},
}.Encode()
_, err = signer.Sign(req, Body, serviceS3, region, time.Now())
} else {
// For header-based authentication
_, err = signer.Sign(req, Body, serviceS3, region, time.Now())
}

Body := bytes.NewReader(nil)
if err != nil {
t.Error(err)
}

if result := signature.V4SignVerify(req); result != signature.ErrNone {
t.Errorf("invalid result: expect none but got %+v", signature.GetAPIError(result))
}
})
}
}

func TestUnsignedPayload(t *testing.T) {
Body := bytes.NewReader([]byte("test data"))

ak := RandString(32)
sk := RandString(64)
region := RandString(16)

credentials := credentials.NewStaticCredentials(ak, sk, "")
creds := credentials.NewStaticCredentials(ak, sk, "")
signature.ReloadKeys(map[string]string{ak: sk})
signer := v4.NewSigner(credentials)
signer := v4.NewSigner(creds)

req, err := http.NewRequest(http.MethodPost, "https://s3-endpoint.exmaple.com/bin", Body)
req, err := http.NewRequest(http.MethodPost, "https://s3-endpoint.example.com/bin", Body)
if err != nil {
t.Error(err)
t.Fatal(err)
}

req.Header.Set("X-Amz-Content-Sha256", unsignedPayload)

_, err = signer.Sign(req, Body, serviceS3, region, time.Now())
if err != nil {
t.Error(err)
t.Fatal(err)
}

if result := signature.V4SignVerify(req); result != signature.ErrNone {
t.Error(fmt.Errorf("invalid result: expect none but got %+v", signature.GetAPIError(result)))
t.Errorf("invalid result for unsigned payload: expect none but got %+v", signature.GetAPIError(result))
}
}

0 comments on commit d61b9c9

Please sign in to comment.