Blob Blame History Raw
package ec2tokens

import (
	"crypto/hmac"
	"crypto/sha1"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"math/rand"
	"net/url"
	"sort"
	"strings"
	"time"

	"github.com/gophercloud/gophercloud"
	"github.com/gophercloud/gophercloud/openstack/identity/v3/tokens"
)

const (
	// EC2CredentialsAwsRequestV4 is a constant, used to generate AWS
	// Credential V4.
	EC2CredentialsAwsRequestV4 = "aws4_request"
	// EC2CredentialsHmacSha1V2 is a HMAC SHA1 signature method. Used to
	// generate AWS Credential V2.
	EC2CredentialsHmacSha1V2 = "HmacSHA1"
	// EC2CredentialsHmacSha256V2 is a HMAC SHA256 signature method. Used
	// to generate AWS Credential V2.
	EC2CredentialsHmacSha256V2 = "HmacSHA256"
	// EC2CredentialsAwsHmacV4 is an AWS signature V4 signing method.
	// More details:
	// https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html
	EC2CredentialsAwsHmacV4 = "AWS4-HMAC-SHA256"
	// EC2CredentialsTimestampFormatV4 is an AWS signature V4 timestamp
	// format.
	EC2CredentialsTimestampFormatV4 = "20060102T150405Z"
	// EC2CredentialsDateFormatV4 is an AWS signature V4 date format.
	EC2CredentialsDateFormatV4 = "20060102"
)

// AuthOptions represents options for authenticating a user using EC2 credentials.
type AuthOptions struct {
	// Access is the EC2 Credential Access ID.
	Access string `json:"access" required:"true"`
	// Secret is the EC2 Credential Secret, used to calculate signature.
	// Not used, when a Signature is is.
	Secret string `json:"-"`
	// Host is a HTTP request Host header. Used to calculate an AWS
	// signature V2. For signature V4 set the Host inside Headers map.
	// Optional.
	Host string `json:"host"`
	// Path is a HTTP request path. Optional.
	Path string `json:"path"`
	// Verb is a HTTP request method. Optional.
	Verb string `json:"verb"`
	// Headers is a map of HTTP request headers. Optional.
	Headers map[string]string `json:"headers"`
	// Region is a region name to calculate an AWS signature V4. Optional.
	Region string `json:"-"`
	// Service is a service name to calculate an AWS signature V4. Optional.
	Service string `json:"-"`
	// Params is a map of GET method parameters. Optional.
	Params map[string]string `json:"params"`
	// AllowReauth allows Gophercloud to re-authenticate automatically
	// if/when your token expires.
	AllowReauth bool `json:"-"`
	// Signature can be either a []byte (encoded to base64 automatically) or
	// a string. You can set the singature explicitly, when you already know
	// it. In this case default Params won't be automatically set. Optional.
	Signature interface{} `json:"signature"`
	// BodyHash is a HTTP request body sha256 hash. When nil and Signature
	// is not set, a random hash is generated. Optional.
	BodyHash *string `json:"body_hash"`
	// Timestamp is a timestamp to calculate a V4 signature. Optional.
	Timestamp *time.Time `json:"-"`
	// Token is a []byte string (encoded to base64 automatically) which was
	// signed by an EC2 secret key. Used by S3 tokens for validation only.
	// Token must be set with a Signature. If a Signature is not provided,
	// a Token will be generated automatically along with a Signature.
	Token []byte `json:"token,omitempty"`
}

// EC2CredentialsBuildCanonicalQueryStringV2 builds a canonical query string
// for an AWS signature V2.
// https://github.com/openstack/python-keystoneclient/blob/stable/train/keystoneclient/contrib/ec2/utils.py#L133
func EC2CredentialsBuildCanonicalQueryStringV2(params map[string]string) string {
	var keys []string
	for k := range params {
		keys = append(keys, k)
	}
	sort.Strings(keys)

	var pairs []string
	for _, k := range keys {
		pairs = append(pairs, fmt.Sprintf("%s=%s", k, url.QueryEscape(params[k])))
	}

	return strings.Join(pairs, "&")
}

// EC2CredentialsBuildStringToSignV2 builds a string to sign an AWS signature
// V2.
// https://github.com/openstack/python-keystoneclient/blob/stable/train/keystoneclient/contrib/ec2/utils.py#L148
func EC2CredentialsBuildStringToSignV2(opts AuthOptions) []byte {
	stringToSign := strings.Join([]string{
		opts.Verb,
		opts.Host,
		opts.Path,
	}, "\n")

	return []byte(strings.Join([]string{
		stringToSign,
		EC2CredentialsBuildCanonicalQueryStringV2(opts.Params),
	}, "\n"))
}

// EC2CredentialsBuildCanonicalQueryStringV2 builds a canonical query string
// for an AWS signature V4.
// https://github.com/openstack/python-keystoneclient/blob/stable/train/keystoneclient/contrib/ec2/utils.py#L244
func EC2CredentialsBuildCanonicalQueryStringV4(verb string, params map[string]string) string {
	if verb == "POST" {
		return ""
	}
	return EC2CredentialsBuildCanonicalQueryStringV2(params)
}

// EC2CredentialsBuildCanonicalHeadersV4 builds a canonical string based on
// "headers" map and "signedHeaders" string parameters.
// https://github.com/openstack/python-keystoneclient/blob/stable/train/keystoneclient/contrib/ec2/utils.py#L216
func EC2CredentialsBuildCanonicalHeadersV4(headers map[string]string, signedHeaders string) string {
	headersLower := make(map[string]string, len(headers))
	for k, v := range headers {
		headersLower[strings.ToLower(k)] = v
	}

	var headersList []string
	for _, h := range strings.Split(signedHeaders, ";") {
		if v, ok := headersLower[h]; ok {
			headersList = append(headersList, h+":"+v)
		}
	}

	return strings.Join(headersList, "\n") + "\n"
}

// EC2CredentialsBuildSignatureKeyV4 builds a HMAC 256 signature key based on
// input parameters.
// https://github.com/openstack/python-keystoneclient/blob/stable/train/keystoneclient/contrib/ec2/utils.py#L169
func EC2CredentialsBuildSignatureKeyV4(secret, region, service string, date time.Time) []byte {
	kDate := sumHMAC256([]byte("AWS4"+secret), []byte(date.Format(EC2CredentialsDateFormatV4)))
	kRegion := sumHMAC256(kDate, []byte(region))
	kService := sumHMAC256(kRegion, []byte(service))
	return sumHMAC256(kService, []byte(EC2CredentialsAwsRequestV4))
}

// EC2CredentialsBuildStringToSignV4 builds an AWS v4 signature string to sign
// based on input parameters.
// https://github.com/openstack/python-keystoneclient/blob/stable/train/keystoneclient/contrib/ec2/utils.py#L251
func EC2CredentialsBuildStringToSignV4(opts AuthOptions, signedHeaders string, bodyHash string, date time.Time) []byte {
	scope := strings.Join([]string{
		date.Format(EC2CredentialsDateFormatV4),
		opts.Region,
		opts.Service,
		EC2CredentialsAwsRequestV4,
	}, "/")

	canonicalRequest := strings.Join([]string{
		opts.Verb,
		opts.Path,
		EC2CredentialsBuildCanonicalQueryStringV4(opts.Verb, opts.Params),
		EC2CredentialsBuildCanonicalHeadersV4(opts.Headers, signedHeaders),
		signedHeaders,
		bodyHash,
	}, "\n")
	hash := sha256.Sum256([]byte(canonicalRequest))

	return []byte(strings.Join([]string{
		EC2CredentialsAwsHmacV4,
		date.Format(EC2CredentialsTimestampFormatV4),
		scope,
		hex.EncodeToString(hash[:]),
	}, "\n"))
}

// EC2CredentialsBuildSignatureV4 builds an AWS v4 signature based on input
// parameters.
// https://github.com/openstack/python-keystoneclient/blob/stable/train/keystoneclient/contrib/ec2/utils.py#L285..L286
func EC2CredentialsBuildSignatureV4(key []byte, stringToSign []byte) string {
	return hex.EncodeToString(sumHMAC256(key, stringToSign))
}

// EC2CredentialsBuildAuthorizationHeaderV4 builds an AWS v4 Authorization
// header based on auth parameters, date and signature
func EC2CredentialsBuildAuthorizationHeaderV4(opts AuthOptions, signedHeaders string, signature string, date time.Time) string {
	return fmt.Sprintf("%s Credential=%s/%s/%s/%s/%s, SignedHeaders=%s, Signature=%s",
		EC2CredentialsAwsHmacV4,
		opts.Access,
		date.Format(EC2CredentialsDateFormatV4),
		opts.Region,
		opts.Service,
		EC2CredentialsAwsRequestV4,
		signedHeaders,
		signature)
}

// ToTokenV3ScopeMap is a dummy method to satisfy tokens.AuthOptionsBuilder
// interface.
func (opts *AuthOptions) ToTokenV3ScopeMap() (map[string]interface{}, error) {
	return nil, nil
}

// ToTokenV3HeadersMap allows AuthOptions to satisfy the AuthOptionsBuilder
// interface in the v3 tokens package.
func (opts *AuthOptions) ToTokenV3HeadersMap(map[string]interface{}) (map[string]string, error) {
	return nil, nil
}

// CanReauth is a method method to satisfy tokens.AuthOptionsBuilder interface
func (opts *AuthOptions) CanReauth() bool {
	return opts.AllowReauth
}

// ToTokenV3CreateMap formats an AuthOptions into a create request.
func (opts *AuthOptions) ToTokenV3CreateMap(map[string]interface{}) (map[string]interface{}, error) {
	b, err := gophercloud.BuildRequestBody(opts, "credentials")
	if err != nil {
		return nil, err
	}

	if opts.Signature != nil {
		return b, nil
	}

	// calculate signature, when it is not set
	c, _ := b["credentials"].(map[string]interface{})
	h := interfaceToMap(c, "headers")
	p := interfaceToMap(c, "params")

	// detect and process a signature v2
	if v, ok := p["SignatureVersion"]; ok && v == "2" {
		if _, ok := c["body_hash"]; ok {
			delete(c, "body_hash")
		}
		if _, ok := c["headers"]; ok {
			delete(c, "headers")
		}
		if v, ok := p["SignatureMethod"]; ok {
			// params is a map of strings
			strToSign := EC2CredentialsBuildStringToSignV2(*opts)
			switch v {
			case EC2CredentialsHmacSha1V2:
				// keystone uses this method only when HmacSHA256 is not available on the server side
				// https://github.com/openstack/python-keystoneclient/blob/stable/train/keystoneclient/contrib/ec2/utils.py#L151..L156
				c["signature"] = sumHMAC1([]byte(opts.Secret), strToSign)
				return b, nil
			case EC2CredentialsHmacSha256V2:
				c["signature"] = sumHMAC256([]byte(opts.Secret), strToSign)
				return b, nil
			}
			return nil, fmt.Errorf("unsupported signature method: %s", v)
		}
		return nil, fmt.Errorf("signature method must be provided")
	} else if ok {
		return nil, fmt.Errorf("unsupported signature version: %s", v)
	}

	// it is not a signature v2, but a signature v4
	date := time.Now().UTC()
	if opts.Timestamp != nil {
		date = *opts.Timestamp
	}
	if v, _ := c["body_hash"]; v == nil {
		// when body_hash is not set, generate a random one
		c["body_hash"] = randomBodyHash()
	}

	signedHeaders, _ := h["X-Amz-SignedHeaders"]

	stringToSign := EC2CredentialsBuildStringToSignV4(*opts, signedHeaders, c["body_hash"].(string), date)
	key := EC2CredentialsBuildSignatureKeyV4(opts.Secret, opts.Region, opts.Service, date)
	c["signature"] = EC2CredentialsBuildSignatureV4(key, stringToSign)
	h["X-Amz-Date"] = date.Format(EC2CredentialsTimestampFormatV4)
	h["Authorization"] = EC2CredentialsBuildAuthorizationHeaderV4(*opts, signedHeaders, c["signature"].(string), date)

	// token is only used for S3 tokens validation and will be removed when using EC2 validation
	c["token"] = stringToSign

	return b, nil
}

// Create authenticates and either generates a new token from EC2 credentials
func Create(c *gophercloud.ServiceClient, opts tokens.AuthOptionsBuilder) (r tokens.CreateResult) {
	b, err := opts.ToTokenV3CreateMap(nil)
	if err != nil {
		r.Err = err
		return
	}

	// delete "token" element, since it is used in s3tokens
	deleteBodyElements(b, "token")

	resp, err := c.Post(ec2tokensURL(c), b, &r.Body, &gophercloud.RequestOpts{
		MoreHeaders: map[string]string{"X-Auth-Token": ""},
		OkCodes:     []int{200},
	})
	_, r.Header, r.Err = gophercloud.ParseResponse(resp, err)
	return
}

// ValidateS3Token authenticates an S3 request using EC2 credentials. Doesn't
// generate a new token ID, but returns a tokens.CreateResult.
func ValidateS3Token(c *gophercloud.ServiceClient, opts tokens.AuthOptionsBuilder) (r tokens.CreateResult) {
	b, err := opts.ToTokenV3CreateMap(nil)
	if err != nil {
		r.Err = err
		return
	}

	// delete unused element, since it is used in ec2tokens only
	deleteBodyElements(b, "body_hash", "headers", "host", "params", "path", "verb")

	resp, err := c.Post(s3tokensURL(c), b, &r.Body, &gophercloud.RequestOpts{
		MoreHeaders: map[string]string{"X-Auth-Token": ""},
		OkCodes:     []int{200},
	})
	_, r.Header, r.Err = gophercloud.ParseResponse(resp, err)
	return
}

// The following are small helper functions used to help build the signature.

// sumHMAC1 is a func to implement the HMAC SHA1 signature method.
func sumHMAC1(key []byte, data []byte) []byte {
	hash := hmac.New(sha1.New, key)
	hash.Write(data)
	return hash.Sum(nil)
}

// sumHMAC256 is a func to implement the HMAC SHA256 signature method.
func sumHMAC256(key []byte, data []byte) []byte {
	hash := hmac.New(sha256.New, key)
	hash.Write(data)
	return hash.Sum(nil)
}

// randomBodyHash is a func to generate a random sha256 hexdigest.
func randomBodyHash() string {
	h := make([]byte, 64)
	rand.Read(h)
	return hex.EncodeToString(h)
}

// interfaceToMap is a func used to represent a "credentials" map element as a
// "map[string]string"
func interfaceToMap(c map[string]interface{}, key string) map[string]string {
	// convert map[string]interface{} to map[string]string
	m := make(map[string]string)
	if v, _ := c[key].(map[string]interface{}); v != nil {
		for k, v := range v {
			m[k] = v.(string)
		}
	}

	c[key] = m

	return m
}

// deleteBodyElements deletes map body elements
func deleteBodyElements(b map[string]interface{}, elements ...string) {
	if c, ok := b["credentials"].(map[string]interface{}); ok {
		for _, k := range elements {
			if _, ok := c[k]; ok {
				delete(c, k)
			}
		}
	}
}