-
Notifications
You must be signed in to change notification settings - Fork 51
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
Changes from 5 commits
498d4b3
5955327
34fd2b8
29f4eea
1481751
8859372
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
} |
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 |
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) | ||
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Aren't we missing this case on query parameters?
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will check and make sure a test covers this There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Docs say we need to do sorting after encoding
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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-") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The docs say
EDIT but then it says in another place
So, I guess do what you feel it's best 🤷 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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). |
||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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"