Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

introduce new aws-http-auth module which implements sigv4 and sigv4a #541

Merged
merged 6 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions aws-http-auth/credentials/credentials.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Package credentials exposes container types for AWS credentials.
package credentials

import (
"time"
)

// Credentials describes a shared-secret AWS credential identity.
type Credentials struct {
AccessKeyID string
SecretAccessKey string
SessionToken string
Expires time.Time
}
3 changes: 3 additions & 0 deletions aws-http-auth/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/aws/smithy-go/aws-http-auth

go 1.21
Empty file added aws-http-auth/go.sum
Empty file.
225 changes: 225 additions & 0 deletions aws-http-auth/internal/v4/signer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
package v4

import (
"encoding/hex"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"

"github.com/aws/smithy-go/aws-http-auth/credentials"
v4 "github.com/aws/smithy-go/aws-http-auth/v4"
)

const (
// TimeFormat is the full-width form to be used in the X-Amz-Date header.
TimeFormat = "20060102T150405Z"

// ShortTimeFormat is the shortened form used in credential scope.
ShortTimeFormat = "20060102"
)

// Signer is the implementation structure for all variants of v4 signing.
type Signer struct {
Request *http.Request
PayloadHash []byte
Time time.Time
Credentials credentials.Credentials
Options v4.SignerOptions

// variant-specific inputs
Algorithm string
CredentialScope string
Finalizer Finalizer
}

// Finalizer performs the final step in v4 signing, deriving a signature for
// the string-to-sign with algorithm-specific key material.
type Finalizer interface {
SignString(string) (string, error)
}

// Do performs v4 signing, modifying the request in-place with the
// signature.
//
// Do should be called exactly once for a configured Signer. The behavior of
// doing otherwise is undefined.
func (s *Signer) Do() error {
if err := s.init(); err != nil {
return err
}

s.setRequiredHeaders()

canonicalRequest, signedHeaders := s.buildCanonicalRequest()
stringToSign := s.buildStringToSign(canonicalRequest)
signature, err := s.Finalizer.SignString(stringToSign)
if err != nil {
return err
}

s.Request.Header.Set("Authorization",
s.buildAuthorizationHeader(signature, signedHeaders))

return nil
}

func (s *Signer) init() error {
// it might seem like time should also get defaulted/normalized here, but
// in practice sigv4 and sigv4a both need to do that beforehand to
// calculate scope, so there's no point

if s.Options.HeaderRules == nil {
s.Options.HeaderRules = defaultHeaderRules{}
}

if err := s.resolvePayloadHash(); err != nil {
return err
}

return nil
}

// ensure we have a value for payload hash, whether that be explicit, implicit,
// or the unsigned sentinel
func (s *Signer) resolvePayloadHash() error {
if len(s.PayloadHash) > 0 {
return nil
}

rs, ok := s.Request.Body.(io.ReadSeeker)
if !ok || s.Options.DisableImplicitPayloadHashing {
s.PayloadHash = []byte(v4.UnsignedPayload)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't a request with no payload needs to hash the empty string instead of the magic string?

If there is no payload in the request, you compute a hash of the empty string, such as when you retrieve an object by using a GET request, there is nothing in the payload.
Hex(SHA256Hash(""))

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes but that's not what this case is checking. This is just saying "if we can't compute the payload ourselves (or they shut it off) do explicit unsigned"

return nil
}

p, err := rtosha(rs)
if err != nil {
return err
}

s.PayloadHash = p
return nil
}

func (s *Signer) setRequiredHeaders() {
headers := s.Request.Header

s.Request.Header.Set("Host", s.Request.Host)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsure of the support in Go, but the V4 spec does talk about using :authority header instead of Host when the request is using HTTP2. I'm not sure if we want to tackle this now, since I'm also unsure of the support for HTTP2 for AWS services, but it may be something that we want to think about if it will come in the future

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm inclined to leave this until we get engagement about it for the reason you've cited.

s.Request.Header.Set("X-Amz-Date", s.Time.Format(TimeFormat))

if len(s.Credentials.SessionToken) > 0 {
s.Request.Header.Set("X-Amz-Security-Token", s.Credentials.SessionToken)
}
if len(s.PayloadHash) > 0 && s.Options.AddPayloadHashHeader {
headers.Set("X-Amz-Content-Sha256", payloadHashString(s.PayloadHash))
}
}

func (s *Signer) buildCanonicalRequest() (string, string) {
canonPath := s.Request.URL.EscapedPath()
// https://docs.aws.amazon.com/IAM/latest/UserGuide/create-signed-request.html:
// if input has no path, "/" is used
if len(canonPath) == 0 {
canonPath = "/"
}
if !s.Options.DisableDoublePathEscape {
canonPath = uriEncode(canonPath)
}

query := s.Request.URL.Query()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aren't we missing this case on query parameters?

When a request targets a subresource, the corresponding query parameter value will be an empty string (""). For example, the following URI identifies the ACL subresource on the amzn-s3-demo-bucket bucket:
http://s3.amazonaws.com/amzn-s3-demo-bucket?acl

In this case, the CanonicalQueryString would be:
UriEncode("acl") + "=" + ""

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will check and make sure a test covers this

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, Query() handles this. Added test.

for key := range query {
sort.Strings(query[key])
}
canonQuery := strings.Replace(query.Encode(), "+", "%20", -1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs say we need to do sorting after encoding

You must also sort the parameters in the canonical query string alphabetically by key name. The sorting occurs after encoding

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's already happening on 134 - Query() turns URL.RawQuery into a map, RawQuery is already in encoded form.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a test that demonstrates this.


canonHeaders, signedHeaders := s.buildCanonicalHeaders()

req := strings.Join([]string{
s.Request.Method,
canonPath,
canonQuery,
canonHeaders,
signedHeaders,
payloadHashString(s.PayloadHash),
}, "\n")

return req, signedHeaders
}

func (s *Signer) buildCanonicalHeaders() (canon, signed string) {
var canonHeaders []string
signedHeaders := map[string][]string{}

// step 1: find what we're signing
for header, values := range s.Request.Header {
lowercase := strings.ToLower(header)
if !s.Options.HeaderRules.IsSigned(lowercase) {
continue
}

canonHeaders = append(canonHeaders, lowercase)
signedHeaders[lowercase] = values
}
sort.Strings(canonHeaders)

// step 2: indexing off of the list we built previously (which guarantees
// alphabetical order), build the canonical list
var ch strings.Builder
for i := range canonHeaders {
ch.WriteString(canonHeaders[i])
ch.WriteRune(':')

// headers can have multiple values
values := signedHeaders[canonHeaders[i]]
for j, value := range values {
ch.WriteString(strings.TrimSpace(value))
if j < len(values)-1 {
ch.WriteRune(',')
}
}
ch.WriteRune('\n')
}

return ch.String(), strings.Join(canonHeaders, ";")
}

func (s *Signer) buildStringToSign(canonicalRequest string) string {
return strings.Join([]string{
s.Algorithm,
s.Time.Format(TimeFormat),
s.CredentialScope,
hex.EncodeToString(Stosha(canonicalRequest)),
}, "\n")
}

func (s *Signer) buildAuthorizationHeader(signature, headers string) string {
return fmt.Sprintf("%s Credential=%s, SignedHeaders=%s, Signature=%s",
s.Algorithm,
s.Credentials.AccessKeyID+"/"+s.CredentialScope,
headers,
signature)
}

func payloadHashString(p []byte) string {
if string(p) == "UNSIGNED-PAYLOAD" {
return string(p) // sentinel, do not hex-encode
}
return hex.EncodeToString(p)
}

// ResolveTime initializes a time value for signing.
func ResolveTime(t time.Time) time.Time {
if t.IsZero() {
return time.Now().UTC()
}
return t.UTC()
}

type defaultHeaderRules struct{}

func (defaultHeaderRules) IsSigned(h string) bool {
return h == "host" || strings.HasPrefix(h, "x-amz-")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs say

CanonicalHeaders list must include the following:

  • If the Content-Type header is present in the request, you must add it to the CanonicalHeaders list.

EDIT but then it says in another place

You can optionally include other standard headers in the signature, such as content-type.

So, I guess do what you feel it's best 🤷

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I noticed this and ended up leaving it out because testing shows it's not actually required (at least by us).

}
Loading
Loading