v4.go

  1// Package v4 implements the AWS signature version 4 algorithm (commonly known
  2// as SigV4).
  3//
  4// For more information about SigV4, see [Signing AWS API requests] in the IAM
  5// user guide.
  6//
  7// While this implementation CAN work in an external context, it is developed
  8// primarily for SDK use and you may encounter fringe behaviors around header
  9// canonicalization.
 10//
 11// # Pre-escaping a request URI
 12//
 13// AWS v4 signature validation requires that the canonical string's URI path
 14// component must be the escaped form of the HTTP request's path.
 15//
 16// The Go HTTP client will perform escaping automatically on the HTTP request.
 17// This may cause signature validation errors because the request differs from
 18// the URI path or query from which the signature was generated.
 19//
 20// Because of this, we recommend that you explicitly escape the request when
 21// using this signer outside of the SDK to prevent possible signature mismatch.
 22// This can be done by setting URL.Opaque on the request. The signer will
 23// prefer that value, falling back to the return of URL.EscapedPath if unset.
 24//
 25// When setting URL.Opaque you must do so in the form of:
 26//
 27//	"//<hostname>/<path>"
 28//
 29//	// e.g.
 30//	"//example.com/some/path"
 31//
 32// The leading "//" and hostname are required or the escaping will not work
 33// correctly.
 34//
 35// The TestStandaloneSign unit test provides a complete example of using the
 36// signer outside of the SDK and pre-escaping the URI path.
 37//
 38// [Signing AWS API requests]: https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_aws-signing.html
 39package v4
 40
 41import (
 42	"context"
 43	"crypto/sha256"
 44	"encoding/hex"
 45	"fmt"
 46	"hash"
 47	"net/http"
 48	"net/textproto"
 49	"net/url"
 50	"sort"
 51	"strconv"
 52	"strings"
 53	"time"
 54
 55	"github.com/aws/aws-sdk-go-v2/aws"
 56	v4Internal "github.com/aws/aws-sdk-go-v2/aws/signer/internal/v4"
 57	"github.com/aws/smithy-go/encoding/httpbinding"
 58	"github.com/aws/smithy-go/logging"
 59)
 60
 61const (
 62	signingAlgorithm    = "AWS4-HMAC-SHA256"
 63	authorizationHeader = "Authorization"
 64
 65	// Version of signing v4
 66	Version = "SigV4"
 67)
 68
 69// HTTPSigner is an interface to a SigV4 signer that can sign HTTP requests
 70type HTTPSigner interface {
 71	SignHTTP(ctx context.Context, credentials aws.Credentials, r *http.Request, payloadHash string, service string, region string, signingTime time.Time, optFns ...func(*SignerOptions)) error
 72}
 73
 74type keyDerivator interface {
 75	DeriveKey(credential aws.Credentials, service, region string, signingTime v4Internal.SigningTime) []byte
 76}
 77
 78// SignerOptions is the SigV4 Signer options.
 79type SignerOptions struct {
 80	// Disables the Signer's moving HTTP header key/value pairs from the HTTP
 81	// request header to the request's query string. This is most commonly used
 82	// with pre-signed requests preventing headers from being added to the
 83	// request's query string.
 84	DisableHeaderHoisting bool
 85
 86	// Disables the automatic escaping of the URI path of the request for the
 87	// siganture's canonical string's path. For services that do not need additional
 88	// escaping then use this to disable the signer escaping the path.
 89	//
 90	// S3 is an example of a service that does not need additional escaping.
 91	//
 92	// http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
 93	DisableURIPathEscaping bool
 94
 95	// The logger to send log messages to.
 96	Logger logging.Logger
 97
 98	// Enable logging of signed requests.
 99	// This will enable logging of the canonical request, the string to sign, and for presigning the subsequent
100	// presigned URL.
101	LogSigning bool
102
103	// Disables setting the session token on the request as part of signing
104	// through X-Amz-Security-Token. This is needed for variations of v4 that
105	// present the token elsewhere.
106	DisableSessionToken bool
107}
108
109// Signer applies AWS v4 signing to given request. Use this to sign requests
110// that need to be signed with AWS V4 Signatures.
111type Signer struct {
112	options      SignerOptions
113	keyDerivator keyDerivator
114}
115
116// NewSigner returns a new SigV4 Signer
117func NewSigner(optFns ...func(signer *SignerOptions)) *Signer {
118	options := SignerOptions{}
119
120	for _, fn := range optFns {
121		fn(&options)
122	}
123
124	return &Signer{options: options, keyDerivator: v4Internal.NewSigningKeyDeriver()}
125}
126
127type httpSigner struct {
128	Request      *http.Request
129	ServiceName  string
130	Region       string
131	Time         v4Internal.SigningTime
132	Credentials  aws.Credentials
133	KeyDerivator keyDerivator
134	IsPreSign    bool
135
136	PayloadHash string
137
138	DisableHeaderHoisting  bool
139	DisableURIPathEscaping bool
140	DisableSessionToken    bool
141}
142
143func (s *httpSigner) Build() (signedRequest, error) {
144	req := s.Request
145
146	query := req.URL.Query()
147	headers := req.Header
148
149	s.setRequiredSigningFields(headers, query)
150
151	// Sort Each Query Key's Values
152	for key := range query {
153		sort.Strings(query[key])
154	}
155
156	v4Internal.SanitizeHostForHeader(req)
157
158	credentialScope := s.buildCredentialScope()
159	credentialStr := s.Credentials.AccessKeyID + "/" + credentialScope
160	if s.IsPreSign {
161		query.Set(v4Internal.AmzCredentialKey, credentialStr)
162	}
163
164	unsignedHeaders := headers
165	if s.IsPreSign && !s.DisableHeaderHoisting {
166		var urlValues url.Values
167		urlValues, unsignedHeaders = buildQuery(v4Internal.AllowedQueryHoisting, headers)
168		for k := range urlValues {
169			query[k] = urlValues[k]
170		}
171	}
172
173	host := req.URL.Host
174	if len(req.Host) > 0 {
175		host = req.Host
176	}
177
178	signedHeaders, signedHeadersStr, canonicalHeaderStr := s.buildCanonicalHeaders(host, v4Internal.IgnoredHeaders, unsignedHeaders, s.Request.ContentLength)
179
180	if s.IsPreSign {
181		query.Set(v4Internal.AmzSignedHeadersKey, signedHeadersStr)
182	}
183
184	var rawQuery strings.Builder
185	rawQuery.WriteString(strings.Replace(query.Encode(), "+", "%20", -1))
186
187	canonicalURI := v4Internal.GetURIPath(req.URL)
188	if !s.DisableURIPathEscaping {
189		canonicalURI = httpbinding.EscapePath(canonicalURI, false)
190	}
191
192	canonicalString := s.buildCanonicalString(
193		req.Method,
194		canonicalURI,
195		rawQuery.String(),
196		signedHeadersStr,
197		canonicalHeaderStr,
198	)
199
200	strToSign := s.buildStringToSign(credentialScope, canonicalString)
201	signingSignature, err := s.buildSignature(strToSign)
202	if err != nil {
203		return signedRequest{}, err
204	}
205
206	if s.IsPreSign {
207		rawQuery.WriteString("&X-Amz-Signature=")
208		rawQuery.WriteString(signingSignature)
209	} else {
210		headers[authorizationHeader] = append(headers[authorizationHeader][:0], buildAuthorizationHeader(credentialStr, signedHeadersStr, signingSignature))
211	}
212
213	req.URL.RawQuery = rawQuery.String()
214
215	return signedRequest{
216		Request:         req,
217		SignedHeaders:   signedHeaders,
218		CanonicalString: canonicalString,
219		StringToSign:    strToSign,
220		PreSigned:       s.IsPreSign,
221	}, nil
222}
223
224func buildAuthorizationHeader(credentialStr, signedHeadersStr, signingSignature string) string {
225	const credential = "Credential="
226	const signedHeaders = "SignedHeaders="
227	const signature = "Signature="
228	const commaSpace = ", "
229
230	var parts strings.Builder
231	parts.Grow(len(signingAlgorithm) + 1 +
232		len(credential) + len(credentialStr) + 2 +
233		len(signedHeaders) + len(signedHeadersStr) + 2 +
234		len(signature) + len(signingSignature),
235	)
236	parts.WriteString(signingAlgorithm)
237	parts.WriteRune(' ')
238	parts.WriteString(credential)
239	parts.WriteString(credentialStr)
240	parts.WriteString(commaSpace)
241	parts.WriteString(signedHeaders)
242	parts.WriteString(signedHeadersStr)
243	parts.WriteString(commaSpace)
244	parts.WriteString(signature)
245	parts.WriteString(signingSignature)
246	return parts.String()
247}
248
249// SignHTTP signs AWS v4 requests with the provided payload hash, service name, region the
250// request is made to, and time the request is signed at. The signTime allows
251// you to specify that a request is signed for the future, and cannot be
252// used until then.
253//
254// The payloadHash is the hex encoded SHA-256 hash of the request payload, and
255// must be provided. Even if the request has no payload (aka body). If the
256// request has no payload you should use the hex encoded SHA-256 of an empty
257// string as the payloadHash value.
258//
259//	"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
260//
261// Some services such as Amazon S3 accept alternative values for the payload
262// hash, such as "UNSIGNED-PAYLOAD" for requests where the body will not be
263// included in the request signature.
264//
265// https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html
266//
267// Sign differs from Presign in that it will sign the request using HTTP
268// header values. This type of signing is intended for http.Request values that
269// will not be shared, or are shared in a way the header values on the request
270// will not be lost.
271//
272// The passed in request will be modified in place.
273func (s Signer) SignHTTP(ctx context.Context, credentials aws.Credentials, r *http.Request, payloadHash string, service string, region string, signingTime time.Time, optFns ...func(options *SignerOptions)) error {
274	options := s.options
275
276	for _, fn := range optFns {
277		fn(&options)
278	}
279
280	signer := &httpSigner{
281		Request:                r,
282		PayloadHash:            payloadHash,
283		ServiceName:            service,
284		Region:                 region,
285		Credentials:            credentials,
286		Time:                   v4Internal.NewSigningTime(signingTime.UTC()),
287		DisableHeaderHoisting:  options.DisableHeaderHoisting,
288		DisableURIPathEscaping: options.DisableURIPathEscaping,
289		DisableSessionToken:    options.DisableSessionToken,
290		KeyDerivator:           s.keyDerivator,
291	}
292
293	signedRequest, err := signer.Build()
294	if err != nil {
295		return err
296	}
297
298	logSigningInfo(ctx, options, &signedRequest, false)
299
300	return nil
301}
302
303// PresignHTTP signs AWS v4 requests with the payload hash, service name, region
304// the request is made to, and time the request is signed at. The signTime
305// allows you to specify that a request is signed for the future, and cannot
306// be used until then.
307//
308// Returns the signed URL and the map of HTTP headers that were included in the
309// signature or an error if signing the request failed. For presigned requests
310// these headers and their values must be included on the HTTP request when it
311// is made. This is helpful to know what header values need to be shared with
312// the party the presigned request will be distributed to.
313//
314// The payloadHash is the hex encoded SHA-256 hash of the request payload, and
315// must be provided. Even if the request has no payload (aka body). If the
316// request has no payload you should use the hex encoded SHA-256 of an empty
317// string as the payloadHash value.
318//
319//	"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
320//
321// Some services such as Amazon S3 accept alternative values for the payload
322// hash, such as "UNSIGNED-PAYLOAD" for requests where the body will not be
323// included in the request signature.
324//
325// https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html
326//
327// PresignHTTP differs from SignHTTP in that it will sign the request using
328// query string instead of header values. This allows you to share the
329// Presigned Request's URL with third parties, or distribute it throughout your
330// system with minimal dependencies.
331//
332// PresignHTTP will not set the expires time of the presigned request
333// automatically. To specify the expire duration for a request add the
334// "X-Amz-Expires" query parameter on the request with the value as the
335// duration in seconds the presigned URL should be considered valid for. This
336// parameter is not used by all AWS services, and is most notable used by
337// Amazon S3 APIs.
338//
339//	expires := 20 * time.Minute
340//	query := req.URL.Query()
341//	query.Set("X-Amz-Expires", strconv.FormatInt(int64(expires/time.Second), 10))
342//	req.URL.RawQuery = query.Encode()
343//
344// This method does not modify the provided request.
345func (s *Signer) PresignHTTP(
346	ctx context.Context, credentials aws.Credentials, r *http.Request,
347	payloadHash string, service string, region string, signingTime time.Time,
348	optFns ...func(*SignerOptions),
349) (signedURI string, signedHeaders http.Header, err error) {
350	options := s.options
351
352	for _, fn := range optFns {
353		fn(&options)
354	}
355
356	signer := &httpSigner{
357		Request:                r.Clone(r.Context()),
358		PayloadHash:            payloadHash,
359		ServiceName:            service,
360		Region:                 region,
361		Credentials:            credentials,
362		Time:                   v4Internal.NewSigningTime(signingTime.UTC()),
363		IsPreSign:              true,
364		DisableHeaderHoisting:  options.DisableHeaderHoisting,
365		DisableURIPathEscaping: options.DisableURIPathEscaping,
366		DisableSessionToken:    options.DisableSessionToken,
367		KeyDerivator:           s.keyDerivator,
368	}
369
370	signedRequest, err := signer.Build()
371	if err != nil {
372		return "", nil, err
373	}
374
375	logSigningInfo(ctx, options, &signedRequest, true)
376
377	signedHeaders = make(http.Header)
378
379	// For the signed headers we canonicalize the header keys in the returned map.
380	// This avoids situations where can standard library double headers like host header. For example the standard
381	// library will set the Host header, even if it is present in lower-case form.
382	for k, v := range signedRequest.SignedHeaders {
383		key := textproto.CanonicalMIMEHeaderKey(k)
384		signedHeaders[key] = append(signedHeaders[key], v...)
385	}
386
387	return signedRequest.Request.URL.String(), signedHeaders, nil
388}
389
390func (s *httpSigner) buildCredentialScope() string {
391	return v4Internal.BuildCredentialScope(s.Time, s.Region, s.ServiceName)
392}
393
394func buildQuery(r v4Internal.Rule, header http.Header) (url.Values, http.Header) {
395	query := url.Values{}
396	unsignedHeaders := http.Header{}
397	for k, h := range header {
398		// literally just this header has this constraint for some stupid reason,
399		// see #2508
400		if k == "X-Amz-Expected-Bucket-Owner" {
401			k = "x-amz-expected-bucket-owner"
402		}
403
404		if r.IsValid(k) {
405			query[k] = h
406		} else {
407			unsignedHeaders[k] = h
408		}
409	}
410
411	return query, unsignedHeaders
412}
413
414func (s *httpSigner) buildCanonicalHeaders(host string, rule v4Internal.Rule, header http.Header, length int64) (signed http.Header, signedHeaders, canonicalHeadersStr string) {
415	signed = make(http.Header)
416
417	var headers []string
418	const hostHeader = "host"
419	headers = append(headers, hostHeader)
420	signed[hostHeader] = append(signed[hostHeader], host)
421
422	const contentLengthHeader = "content-length"
423	if length > 0 {
424		headers = append(headers, contentLengthHeader)
425		signed[contentLengthHeader] = append(signed[contentLengthHeader], strconv.FormatInt(length, 10))
426	}
427
428	for k, v := range header {
429		if !rule.IsValid(k) {
430			continue // ignored header
431		}
432		if strings.EqualFold(k, contentLengthHeader) {
433			// prevent signing already handled content-length header.
434			continue
435		}
436
437		lowerCaseKey := strings.ToLower(k)
438		if _, ok := signed[lowerCaseKey]; ok {
439			// include additional values
440			signed[lowerCaseKey] = append(signed[lowerCaseKey], v...)
441			continue
442		}
443
444		headers = append(headers, lowerCaseKey)
445		signed[lowerCaseKey] = v
446	}
447	sort.Strings(headers)
448
449	signedHeaders = strings.Join(headers, ";")
450
451	var canonicalHeaders strings.Builder
452	n := len(headers)
453	const colon = ':'
454	for i := 0; i < n; i++ {
455		if headers[i] == hostHeader {
456			canonicalHeaders.WriteString(hostHeader)
457			canonicalHeaders.WriteRune(colon)
458			canonicalHeaders.WriteString(v4Internal.StripExcessSpaces(host))
459		} else {
460			canonicalHeaders.WriteString(headers[i])
461			canonicalHeaders.WriteRune(colon)
462			// Trim out leading, trailing, and dedup inner spaces from signed header values.
463			values := signed[headers[i]]
464			for j, v := range values {
465				cleanedValue := strings.TrimSpace(v4Internal.StripExcessSpaces(v))
466				canonicalHeaders.WriteString(cleanedValue)
467				if j < len(values)-1 {
468					canonicalHeaders.WriteRune(',')
469				}
470			}
471		}
472		canonicalHeaders.WriteRune('\n')
473	}
474	canonicalHeadersStr = canonicalHeaders.String()
475
476	return signed, signedHeaders, canonicalHeadersStr
477}
478
479func (s *httpSigner) buildCanonicalString(method, uri, query, signedHeaders, canonicalHeaders string) string {
480	return strings.Join([]string{
481		method,
482		uri,
483		query,
484		canonicalHeaders,
485		signedHeaders,
486		s.PayloadHash,
487	}, "\n")
488}
489
490func (s *httpSigner) buildStringToSign(credentialScope, canonicalRequestString string) string {
491	return strings.Join([]string{
492		signingAlgorithm,
493		s.Time.TimeFormat(),
494		credentialScope,
495		hex.EncodeToString(makeHash(sha256.New(), []byte(canonicalRequestString))),
496	}, "\n")
497}
498
499func makeHash(hash hash.Hash, b []byte) []byte {
500	hash.Reset()
501	hash.Write(b)
502	return hash.Sum(nil)
503}
504
505func (s *httpSigner) buildSignature(strToSign string) (string, error) {
506	key := s.KeyDerivator.DeriveKey(s.Credentials, s.ServiceName, s.Region, s.Time)
507	return hex.EncodeToString(v4Internal.HMACSHA256(key, []byte(strToSign))), nil
508}
509
510func (s *httpSigner) setRequiredSigningFields(headers http.Header, query url.Values) {
511	amzDate := s.Time.TimeFormat()
512
513	if s.IsPreSign {
514		query.Set(v4Internal.AmzAlgorithmKey, signingAlgorithm)
515		sessionToken := s.Credentials.SessionToken
516		if !s.DisableSessionToken && len(sessionToken) > 0 {
517			query.Set("X-Amz-Security-Token", sessionToken)
518		}
519
520		query.Set(v4Internal.AmzDateKey, amzDate)
521		return
522	}
523
524	headers[v4Internal.AmzDateKey] = append(headers[v4Internal.AmzDateKey][:0], amzDate)
525
526	if !s.DisableSessionToken && len(s.Credentials.SessionToken) > 0 {
527		headers[v4Internal.AmzSecurityTokenKey] = append(headers[v4Internal.AmzSecurityTokenKey][:0], s.Credentials.SessionToken)
528	}
529}
530
531func logSigningInfo(ctx context.Context, options SignerOptions, request *signedRequest, isPresign bool) {
532	if !options.LogSigning {
533		return
534	}
535	signedURLMsg := ""
536	if isPresign {
537		signedURLMsg = fmt.Sprintf(logSignedURLMsg, request.Request.URL.String())
538	}
539	logger := logging.WithContext(ctx, options.Logger)
540	logger.Logf(logging.Debug, logSignInfoMsg, request.CanonicalString, request.StringToSign, signedURLMsg)
541}
542
543type signedRequest struct {
544	Request         *http.Request
545	SignedHeaders   http.Header
546	CanonicalString string
547	StringToSign    string
548	PreSigned       bool
549}
550
551const logSignInfoMsg = `Request Signature:
552---[ CANONICAL STRING  ]-----------------------------
553%s
554---[ STRING TO SIGN ]--------------------------------
555%s%s
556-----------------------------------------------------`
557const logSignedURLMsg = `
558---[ SIGNED URL ]------------------------------------
559%s`