581 lines
17 KiB
Go
581 lines
17 KiB
Go
package v4a
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"hash"
|
|
"math/big"
|
|
"net/http"
|
|
"net/textproto"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
signerCrypto "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/auth/signer/v4asdk2/internal/crypto"
|
|
v4Internal "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/auth/signer/v4asdk2/internal/v4"
|
|
"github.com/aws/smithy-go/encoding/httpbinding"
|
|
"github.com/aws/smithy-go/logging"
|
|
)
|
|
|
|
const (
|
|
// AmzRegionSetKey represents the region set header used for sigv4a
|
|
AmzRegionSetKey = "X-Amz-Region-Set"
|
|
amzAlgorithmKey = v4Internal.AmzAlgorithmKey
|
|
amzSecurityTokenKey = v4Internal.AmzSecurityTokenKey
|
|
amzDateKey = v4Internal.AmzDateKey
|
|
amzCredentialKey = v4Internal.AmzCredentialKey
|
|
amzSignedHeadersKey = v4Internal.AmzSignedHeadersKey
|
|
authorizationHeader = "Authorization"
|
|
|
|
signingAlgorithm = "AWS4-ECDSA-P256-SHA256"
|
|
|
|
timeFormat = "20060102T150405Z"
|
|
shortTimeFormat = "20060102"
|
|
|
|
// EmptyStringSHA256 is a hex encoded SHA-256 hash of an empty string
|
|
EmptyStringSHA256 = v4Internal.EmptyStringSHA256
|
|
|
|
// Version of signing v4a
|
|
Version = "SigV4A"
|
|
)
|
|
|
|
var (
|
|
p256 elliptic.Curve
|
|
nMinusTwoP256 *big.Int
|
|
|
|
one = new(big.Int).SetInt64(1)
|
|
)
|
|
|
|
func init() {
|
|
// Ensure the elliptic curve parameters are initialized on package import rather then on first usage
|
|
p256 = elliptic.P256()
|
|
|
|
nMinusTwoP256 = new(big.Int).SetBytes(p256.Params().N.Bytes())
|
|
nMinusTwoP256 = nMinusTwoP256.Sub(nMinusTwoP256, new(big.Int).SetInt64(2))
|
|
}
|
|
|
|
// SignerOptions is the SigV4a signing options for constructing a Signer.
|
|
type SignerOptions struct {
|
|
Logger logging.Logger
|
|
LogSigning bool
|
|
|
|
// Disables the Signer's moving HTTP header key/value pairs from the HTTP
|
|
// request header to the request's query string. This is most commonly used
|
|
// with pre-signed requests preventing headers from being added to the
|
|
// request's query string.
|
|
DisableHeaderHoisting bool
|
|
|
|
// Disables the automatic escaping of the URI path of the request for the
|
|
// siganture's canonical string's path. For services that do not need additional
|
|
// escaping then use this to disable the signer escaping the path.
|
|
//
|
|
// S3 is an example of a service that does not need additional escaping.
|
|
//
|
|
// http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
|
|
DisableURIPathEscaping bool
|
|
}
|
|
|
|
// Signer is a SigV4a HTTP signing implementation
|
|
type Signer struct {
|
|
options SignerOptions
|
|
}
|
|
|
|
// NewSigner constructs a SigV4a Signer.
|
|
func NewSigner(optFns ...func(*SignerOptions)) *Signer {
|
|
options := SignerOptions{}
|
|
|
|
for _, fn := range optFns {
|
|
fn(&options)
|
|
}
|
|
|
|
return &Signer{options: options}
|
|
}
|
|
|
|
// deriveKeyFromAccessKeyPair derives a NIST P-256 PrivateKey from the given
|
|
// IAM AccessKey and SecretKey pair.
|
|
//
|
|
// Based on FIPS.186-4 Appendix B.4.2
|
|
func deriveKeyFromAccessKeyPair(accessKey, secretKey string) (*ecdsa.PrivateKey, error) {
|
|
params := p256.Params()
|
|
bitLen := params.BitSize // Testing random candidates does not require an additional 64 bits
|
|
counter := 0x01
|
|
|
|
buffer := make([]byte, 1+len(accessKey)) // 1 byte counter + len(accessKey)
|
|
kdfContext := bytes.NewBuffer(buffer)
|
|
|
|
inputKey := append([]byte("AWS4A"), []byte(secretKey)...)
|
|
|
|
d := new(big.Int)
|
|
for {
|
|
kdfContext.Reset()
|
|
kdfContext.WriteString(accessKey)
|
|
kdfContext.WriteByte(byte(counter))
|
|
|
|
key, err := signerCrypto.HMACKeyDerivation(sha256.New, bitLen, inputKey, []byte(signingAlgorithm), kdfContext.Bytes())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check key first before calling SetBytes if key key is in fact a valid candidate.
|
|
// This ensures the byte slice is the correct length (32-bytes) to compare in constant-time
|
|
cmp, err := signerCrypto.ConstantTimeByteCompare(key, nMinusTwoP256.Bytes())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if cmp == -1 {
|
|
d.SetBytes(key)
|
|
break
|
|
}
|
|
|
|
counter++
|
|
if counter > 0xFF {
|
|
return nil, fmt.Errorf("exhausted single byte external counter")
|
|
}
|
|
}
|
|
d = d.Add(d, one)
|
|
|
|
priv := new(ecdsa.PrivateKey)
|
|
priv.PublicKey.Curve = p256
|
|
priv.D = d
|
|
priv.PublicKey.X, priv.PublicKey.Y = p256.ScalarBaseMult(d.Bytes())
|
|
|
|
return priv, nil
|
|
}
|
|
|
|
type httpSigner struct {
|
|
Request *http.Request
|
|
ServiceName string
|
|
RegionSet []string
|
|
Time time.Time
|
|
Credentials Credentials
|
|
IsPreSign bool
|
|
|
|
Logger logging.Logger
|
|
Debug bool
|
|
|
|
// PayloadHash is the hex encoded SHA-256 hash of the request payload
|
|
// If len(PayloadHash) == 0 the signer will attempt to send the request
|
|
// as an unsigned payload. Note: Unsigned payloads only work for a subset of services.
|
|
PayloadHash string
|
|
|
|
DisableHeaderHoisting bool
|
|
DisableURIPathEscaping bool
|
|
}
|
|
|
|
// SignHTTP takes the provided http.Request, payload hash, service, regionSet, and time and signs using SigV4a.
|
|
// The passed in request will be modified in place.
|
|
func (s *Signer) SignHTTP(ctx context.Context, credentials Credentials, r *http.Request, payloadHash string, service string, regionSet []string, signingTime time.Time, optFns ...func(*SignerOptions)) error {
|
|
options := s.options
|
|
for _, fn := range optFns {
|
|
fn(&options)
|
|
}
|
|
|
|
signer := &httpSigner{
|
|
Request: r,
|
|
PayloadHash: payloadHash,
|
|
ServiceName: service,
|
|
RegionSet: regionSet,
|
|
Credentials: credentials,
|
|
Time: signingTime.UTC(),
|
|
DisableHeaderHoisting: options.DisableHeaderHoisting,
|
|
DisableURIPathEscaping: options.DisableURIPathEscaping,
|
|
}
|
|
|
|
signedRequest, err := signer.Build()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
logHTTPSigningInfo(ctx, options, signedRequest)
|
|
|
|
return nil
|
|
}
|
|
|
|
// VerifySignature checks sigv4a.
|
|
func (s *Signer) VerifySignature(credentials Credentials, r *http.Request, payloadHash string, service string, regionSet []string, signingTime time.Time, signature string, optFns ...func(*SignerOptions)) error {
|
|
return s.verifySignature(credentials, r, payloadHash, service, regionSet, signingTime, signature, false, optFns...)
|
|
}
|
|
|
|
// VerifyPresigned checks sigv4a.
|
|
func (s *Signer) VerifyPresigned(credentials Credentials, r *http.Request, payloadHash string, service string, regionSet []string, signingTime time.Time, signature string, optFns ...func(*SignerOptions)) error {
|
|
return s.verifySignature(credentials, r, payloadHash, service, regionSet, signingTime, signature, true, optFns...)
|
|
}
|
|
|
|
func (s *Signer) verifySignature(credentials Credentials, r *http.Request, payloadHash string, service string, regionSet []string, signingTime time.Time, signature string, isPresigned bool, optFns ...func(*SignerOptions)) error {
|
|
options := s.options
|
|
for _, fn := range optFns {
|
|
fn(&options)
|
|
}
|
|
|
|
signer := &httpSigner{
|
|
Request: r,
|
|
PayloadHash: payloadHash,
|
|
ServiceName: service,
|
|
RegionSet: regionSet,
|
|
Credentials: credentials,
|
|
Time: signingTime.UTC(),
|
|
IsPreSign: isPresigned,
|
|
DisableHeaderHoisting: options.DisableHeaderHoisting,
|
|
DisableURIPathEscaping: options.DisableURIPathEscaping,
|
|
}
|
|
|
|
signedReq, err := signer.Build()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
logHTTPSigningInfo(context.TODO(), options, signedReq)
|
|
|
|
signatureRaw, err := hex.DecodeString(signature)
|
|
if err != nil {
|
|
return fmt.Errorf("decode hex signature: %w", err)
|
|
}
|
|
|
|
ok, err := signerCrypto.VerifySignature(&credentials.PrivateKey.PublicKey, makeHash(sha256.New(), []byte(signedReq.StringToSign)), signatureRaw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !ok {
|
|
return fmt.Errorf("v4a: invalid signature")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// PresignHTTP takes the provided http.Request, payload hash, service, regionSet, and time and presigns using SigV4a
|
|
// Returns the presigned URL along with the headers that were signed with the request.
|
|
//
|
|
// PresignHTTP will not set the expires time of the presigned request
|
|
// automatically. To specify the expire duration for a request add the
|
|
// "X-Amz-Expires" query parameter on the request with the value as the
|
|
// duration in seconds the presigned URL should be considered valid for. This
|
|
// parameter is not used by all AWS services, and is most notable used by
|
|
// Amazon S3 APIs.
|
|
func (s *Signer) PresignHTTP(ctx context.Context, credentials Credentials, r *http.Request, payloadHash string, service string, regionSet []string, signingTime time.Time, optFns ...func(*SignerOptions)) (signedURI string, signedHeaders http.Header, err error) {
|
|
options := s.options
|
|
for _, fn := range optFns {
|
|
fn(&options)
|
|
}
|
|
|
|
signer := &httpSigner{
|
|
Request: r,
|
|
PayloadHash: payloadHash,
|
|
ServiceName: service,
|
|
RegionSet: regionSet,
|
|
Credentials: credentials,
|
|
Time: signingTime.UTC(),
|
|
IsPreSign: true,
|
|
DisableHeaderHoisting: options.DisableHeaderHoisting,
|
|
DisableURIPathEscaping: options.DisableURIPathEscaping,
|
|
}
|
|
|
|
signedRequest, err := signer.Build()
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
|
|
logHTTPSigningInfo(ctx, options, signedRequest)
|
|
|
|
signedHeaders = make(http.Header)
|
|
|
|
// For the signed headers we canonicalize the header keys in the returned map.
|
|
// This avoids situations where can standard library double headers like host header. For example the standard
|
|
// library will set the Host header, even if it is present in lower-case form.
|
|
for k, v := range signedRequest.SignedHeaders {
|
|
key := textproto.CanonicalMIMEHeaderKey(k)
|
|
signedHeaders[key] = append(signedHeaders[key], v...)
|
|
}
|
|
|
|
return signedRequest.Request.URL.String(), signedHeaders, nil
|
|
}
|
|
|
|
func (s *httpSigner) setRequiredSigningFields(headers http.Header, query url.Values) {
|
|
amzDate := s.Time.Format(timeFormat)
|
|
|
|
if s.IsPreSign {
|
|
query.Set(AmzRegionSetKey, strings.Join(s.RegionSet, ","))
|
|
query.Set(amzDateKey, amzDate)
|
|
query.Set(amzAlgorithmKey, signingAlgorithm)
|
|
if len(s.Credentials.SessionToken) > 0 {
|
|
query.Set(amzSecurityTokenKey, s.Credentials.SessionToken)
|
|
}
|
|
return
|
|
}
|
|
|
|
headers.Set(AmzRegionSetKey, strings.Join(s.RegionSet, ","))
|
|
headers.Set(amzDateKey, amzDate)
|
|
if len(s.Credentials.SessionToken) > 0 {
|
|
headers.Set(amzSecurityTokenKey, s.Credentials.SessionToken)
|
|
}
|
|
}
|
|
|
|
func (s *httpSigner) Build() (signedRequest, error) {
|
|
req := s.Request
|
|
|
|
query := req.URL.Query()
|
|
headers := req.Header
|
|
|
|
s.setRequiredSigningFields(headers, query)
|
|
|
|
// Sort Each Query Key's Values
|
|
for key := range query {
|
|
sort.Strings(query[key])
|
|
}
|
|
|
|
v4Internal.SanitizeHostForHeader(req)
|
|
|
|
credentialScope := s.buildCredentialScope()
|
|
credentialStr := s.Credentials.Context + "/" + credentialScope
|
|
if s.IsPreSign {
|
|
query.Set(amzCredentialKey, credentialStr)
|
|
}
|
|
|
|
unsignedHeaders := headers
|
|
if s.IsPreSign && !s.DisableHeaderHoisting {
|
|
urlValues := url.Values{}
|
|
urlValues, unsignedHeaders = buildQuery(v4Internal.AllowedQueryHoisting, unsignedHeaders)
|
|
for k := range urlValues {
|
|
query[k] = urlValues[k]
|
|
}
|
|
}
|
|
|
|
host := req.URL.Host
|
|
if len(req.Host) > 0 {
|
|
host = req.Host
|
|
}
|
|
|
|
var (
|
|
signedHeaders http.Header
|
|
signedHeadersStr string
|
|
canonicalHeaderStr string
|
|
)
|
|
|
|
if s.IsPreSign {
|
|
signedHeaders, signedHeadersStr, canonicalHeaderStr = s.buildCanonicalHeaders(host, v4Internal.IgnoredPresignedHeaders, unsignedHeaders, s.Request.ContentLength)
|
|
} else {
|
|
signedHeaders, signedHeadersStr, canonicalHeaderStr = s.buildCanonicalHeaders(host, v4Internal.IgnoredHeaders, unsignedHeaders, s.Request.ContentLength)
|
|
}
|
|
|
|
if s.IsPreSign {
|
|
query.Set(amzSignedHeadersKey, signedHeadersStr)
|
|
}
|
|
|
|
rawQuery := strings.Replace(query.Encode(), "+", "%20", -1)
|
|
|
|
canonicalURI := v4Internal.GetURIPath(req.URL)
|
|
if !s.DisableURIPathEscaping {
|
|
canonicalURI = httpbinding.EscapePath(canonicalURI, false)
|
|
}
|
|
|
|
canonicalString := s.buildCanonicalString(
|
|
req.Method,
|
|
canonicalURI,
|
|
rawQuery,
|
|
signedHeadersStr,
|
|
canonicalHeaderStr,
|
|
)
|
|
|
|
strToSign := s.buildStringToSign(credentialScope, canonicalString)
|
|
signingSignature, err := s.buildSignature(strToSign)
|
|
if err != nil {
|
|
return signedRequest{}, err
|
|
}
|
|
|
|
if s.IsPreSign {
|
|
rawQuery += "&X-Amz-Signature=" + signingSignature
|
|
} else {
|
|
headers[authorizationHeader] = append(headers[authorizationHeader][:0], buildAuthorizationHeader(credentialStr, signedHeadersStr, signingSignature))
|
|
}
|
|
|
|
req.URL.RawQuery = rawQuery
|
|
|
|
return signedRequest{
|
|
Request: req,
|
|
SignedHeaders: signedHeaders,
|
|
CanonicalString: canonicalString,
|
|
StringToSign: strToSign,
|
|
PreSigned: s.IsPreSign,
|
|
}, nil
|
|
}
|
|
|
|
func buildAuthorizationHeader(credentialStr, signedHeadersStr, signingSignature string) string {
|
|
const credential = "Credential="
|
|
const signedHeaders = "SignedHeaders="
|
|
const signature = "Signature="
|
|
const commaSpace = ", "
|
|
|
|
var parts strings.Builder
|
|
parts.Grow(len(signingAlgorithm) + 1 +
|
|
len(credential) + len(credentialStr) + len(commaSpace) +
|
|
len(signedHeaders) + len(signedHeadersStr) + len(commaSpace) +
|
|
len(signature) + len(signingSignature),
|
|
)
|
|
parts.WriteString(signingAlgorithm)
|
|
parts.WriteRune(' ')
|
|
parts.WriteString(credential)
|
|
parts.WriteString(credentialStr)
|
|
parts.WriteString(commaSpace)
|
|
parts.WriteString(signedHeaders)
|
|
parts.WriteString(signedHeadersStr)
|
|
parts.WriteString(commaSpace)
|
|
parts.WriteString(signature)
|
|
parts.WriteString(signingSignature)
|
|
return parts.String()
|
|
}
|
|
|
|
func (s *httpSigner) buildCredentialScope() string {
|
|
return strings.Join([]string{
|
|
s.Time.Format(shortTimeFormat),
|
|
s.ServiceName,
|
|
"aws4_request",
|
|
}, "/")
|
|
|
|
}
|
|
|
|
func buildQuery(r v4Internal.Rule, header http.Header) (url.Values, http.Header) {
|
|
query := url.Values{}
|
|
unsignedHeaders := http.Header{}
|
|
for k, h := range header {
|
|
if r.IsValid(k) {
|
|
query[k] = h
|
|
} else {
|
|
unsignedHeaders[k] = h
|
|
}
|
|
}
|
|
|
|
return query, unsignedHeaders
|
|
}
|
|
|
|
func (s *httpSigner) buildCanonicalHeaders(host string, rule v4Internal.Rule, header http.Header, length int64) (signed http.Header, signedHeaders, canonicalHeadersStr string) {
|
|
signed = make(http.Header)
|
|
|
|
var headers []string
|
|
const hostHeader = "host"
|
|
headers = append(headers, hostHeader)
|
|
signed[hostHeader] = append(signed[hostHeader], host)
|
|
|
|
//const contentLengthHeader = "content-length"
|
|
//if length > 0 {
|
|
// headers = append(headers, contentLengthHeader)
|
|
// signed[contentLengthHeader] = append(signed[contentLengthHeader], strconv.FormatInt(length, 10))
|
|
//}
|
|
|
|
for k, v := range header {
|
|
if !rule.IsValid(k) {
|
|
continue // ignored header
|
|
}
|
|
|
|
lowerCaseKey := strings.ToLower(k)
|
|
if _, ok := signed[lowerCaseKey]; ok {
|
|
// include additional values
|
|
signed[lowerCaseKey] = append(signed[lowerCaseKey], v...)
|
|
continue
|
|
}
|
|
|
|
headers = append(headers, lowerCaseKey)
|
|
signed[lowerCaseKey] = v
|
|
}
|
|
sort.Strings(headers)
|
|
|
|
signedHeaders = strings.Join(headers, ";")
|
|
|
|
var canonicalHeaders strings.Builder
|
|
n := len(headers)
|
|
const colon = ':'
|
|
for i := 0; i < n; i++ {
|
|
if headers[i] == hostHeader {
|
|
canonicalHeaders.WriteString(hostHeader)
|
|
canonicalHeaders.WriteRune(colon)
|
|
canonicalHeaders.WriteString(v4Internal.StripExcessSpaces(host))
|
|
} else {
|
|
canonicalHeaders.WriteString(headers[i])
|
|
canonicalHeaders.WriteRune(colon)
|
|
// Trim out leading, trailing, and dedup inner spaces from signed header values.
|
|
values := signed[headers[i]]
|
|
for j, v := range values {
|
|
cleanedValue := strings.TrimSpace(v4Internal.StripExcessSpaces(v))
|
|
canonicalHeaders.WriteString(cleanedValue)
|
|
if j < len(values)-1 {
|
|
canonicalHeaders.WriteRune(',')
|
|
}
|
|
}
|
|
}
|
|
canonicalHeaders.WriteRune('\n')
|
|
}
|
|
canonicalHeadersStr = canonicalHeaders.String()
|
|
|
|
return signed, signedHeaders, canonicalHeadersStr
|
|
}
|
|
|
|
func (s *httpSigner) buildCanonicalString(method, uri, query, signedHeaders, canonicalHeaders string) string {
|
|
return strings.Join([]string{
|
|
method,
|
|
uri,
|
|
query,
|
|
canonicalHeaders,
|
|
signedHeaders,
|
|
s.PayloadHash,
|
|
}, "\n")
|
|
}
|
|
|
|
func (s *httpSigner) buildStringToSign(credentialScope, canonicalRequestString string) string {
|
|
return strings.Join([]string{
|
|
signingAlgorithm,
|
|
s.Time.Format(timeFormat),
|
|
credentialScope,
|
|
hex.EncodeToString(makeHash(sha256.New(), []byte(canonicalRequestString))),
|
|
}, "\n")
|
|
}
|
|
|
|
func makeHash(hash hash.Hash, b []byte) []byte {
|
|
hash.Reset()
|
|
hash.Write(b)
|
|
return hash.Sum(nil)
|
|
}
|
|
|
|
func (s *httpSigner) buildSignature(strToSign string) (string, error) {
|
|
sig, err := s.Credentials.PrivateKey.Sign(rand.Reader, makeHash(sha256.New(), []byte(strToSign)), crypto.SHA256)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(sig), nil
|
|
}
|
|
|
|
const logSignInfoMsg = `Request Signature:
|
|
---[ CANONICAL STRING ]-----------------------------
|
|
%s
|
|
---[ STRING TO SIGN ]--------------------------------
|
|
%s%s
|
|
-----------------------------------------------------`
|
|
const logSignedURLMsg = `
|
|
---[ SIGNED URL ]------------------------------------
|
|
%s`
|
|
|
|
func logHTTPSigningInfo(ctx context.Context, options SignerOptions, r signedRequest) {
|
|
if !options.LogSigning {
|
|
return
|
|
}
|
|
signedURLMsg := ""
|
|
if r.PreSigned {
|
|
signedURLMsg = fmt.Sprintf(logSignedURLMsg, r.Request.URL.String())
|
|
}
|
|
logger := logging.WithContext(ctx, options.Logger)
|
|
logger.Logf(logging.Debug, logSignInfoMsg, r.CanonicalString, r.StringToSign, signedURLMsg)
|
|
}
|
|
|
|
type signedRequest struct {
|
|
Request *http.Request
|
|
SignedHeaders http.Header
|
|
CanonicalString string
|
|
StringToSign string
|
|
PreSigned bool
|
|
}
|