certificates/authority/provisioner/aws.go
David Cowden 51f16ee2e0 aws: add tests covering metadata service versions
* Add constructor tests for the aws provisioner.
* Add a test to make sure the "v1" logic continues to work.

By and large, v2 is the way to go. However, there are some instances of
things that specifically request metadata service version 1 and so this
adds minimal coverage to make sure we don't accidentally break the path
should anyone need to depend on the former logic.
2020-07-22 16:52:06 -07:00

602 lines
20 KiB
Go

package provisioner
import (
"context"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"fmt"
"io/ioutil"
"net"
"net/http"
"strings"
"time"
"github.com/pkg/errors"
"github.com/smallstep/certificates/errs"
"github.com/smallstep/cli/jose"
)
// awsIssuer is the string used as issuer in the generated tokens.
const awsIssuer = "ec2.amazonaws.com"
// awsIdentityURL is the url used to retrieve the instance identity document.
const awsIdentityURL = "http://169.254.169.254/latest/dynamic/instance-identity/document"
// awsSignatureURL is the url used to retrieve the instance identity signature.
const awsSignatureURL = "http://169.254.169.254/latest/dynamic/instance-identity/signature"
// awsAPITokenURL is the url used to get the IMDSv2 API token
const awsAPITokenURL = "http://169.254.169.254/latest/api/token"
// awsAPITokenTTL is the default TTL to use when requesting IMDSv2 API tokens
// -- we keep this short-lived since we get a new token with every call to readURL()
const awsAPITokenTTL = "30"
// awsMetadataTokenHeader is the header that must be passed with every IMDSv2 request
const awsMetadataTokenHeader = "X-aws-ec2-metadata-token"
// awsMetadataTokenTTLHeader is the header used to indicate the token TTL requested
const awsMetadataTokenTTLHeader = "X-aws-ec2-metadata-token-ttl-seconds"
// awsCertificate is the certificate used to validate the instance identity
// signature.
const awsCertificate = `-----BEGIN CERTIFICATE-----
MIIDIjCCAougAwIBAgIJAKnL4UEDMN/FMA0GCSqGSIb3DQEBBQUAMGoxCzAJBgNV
BAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdTZWF0dGxlMRgw
FgYDVQQKEw9BbWF6b24uY29tIEluYy4xGjAYBgNVBAMTEWVjMi5hbWF6b25hd3Mu
Y29tMB4XDTE0MDYwNTE0MjgwMloXDTI0MDYwNTE0MjgwMlowajELMAkGA1UEBhMC
VVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1NlYXR0bGUxGDAWBgNV
BAoTD0FtYXpvbi5jb20gSW5jLjEaMBgGA1UEAxMRZWMyLmFtYXpvbmF3cy5jb20w
gZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAIe9GN//SRK2knbjySG0ho3yqQM3
e2TDhWO8D2e8+XZqck754gFSo99AbT2RmXClambI7xsYHZFapbELC4H91ycihvrD
jbST1ZjkLQgga0NE1q43eS68ZeTDccScXQSNivSlzJZS8HJZjgqzBlXjZftjtdJL
XeE4hwvo0sD4f3j9AgMBAAGjgc8wgcwwHQYDVR0OBBYEFCXWzAgVyrbwnFncFFIs
77VBdlE4MIGcBgNVHSMEgZQwgZGAFCXWzAgVyrbwnFncFFIs77VBdlE4oW6kbDBq
MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHU2Vh
dHRsZTEYMBYGA1UEChMPQW1hem9uLmNvbSBJbmMuMRowGAYDVQQDExFlYzIuYW1h
em9uYXdzLmNvbYIJAKnL4UEDMN/FMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEF
BQADgYEAFYcz1OgEhQBXIwIdsgCOS8vEtiJYF+j9uO6jz7VOmJqO+pRlAbRlvY8T
C1haGgSI/A1uZUKs/Zfnph0oEI0/hu1IIJ/SKBDtN5lvmZ/IzbOPIJWirlsllQIQ
7zvWbGd9c9+Rm3p04oTvhup99la7kZqevJK0QRdD/6NpCKsqP/0=
-----END CERTIFICATE-----`
// awsSignatureAlgorithm is the signature algorithm used to verify the identity
// document signature.
const awsSignatureAlgorithm = x509.SHA256WithRSA
type awsConfig struct {
identityURL string
signatureURL string
tokenURL string
tokenTTL string
certificate *x509.Certificate
signatureAlgorithm x509.SignatureAlgorithm
}
func newAWSConfig() (*awsConfig, error) {
block, _ := pem.Decode([]byte(awsCertificate))
if block == nil || block.Type != "CERTIFICATE" {
return nil, errors.New("error decoding AWS certificate")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, errors.Wrap(err, "error parsing AWS certificate")
}
return &awsConfig{
identityURL: awsIdentityURL,
signatureURL: awsSignatureURL,
tokenURL: awsAPITokenURL,
tokenTTL: awsAPITokenTTL,
certificate: cert,
signatureAlgorithm: awsSignatureAlgorithm,
}, nil
}
type awsPayload struct {
jose.Claims
Amazon awsAmazonPayload `json:"amazon"`
SANs []string `json:"sans"`
document awsInstanceIdentityDocument
}
type awsAmazonPayload struct {
Document []byte `json:"document"`
Signature []byte `json:"signature"`
}
type awsInstanceIdentityDocument struct {
AccountID string `json:"accountId"`
Architecture string `json:"architecture"`
AvailabilityZone string `json:"availabilityZone"`
BillingProducts []string `json:"billingProducts"`
DevpayProductCodes []string `json:"devpayProductCodes"`
ImageID string `json:"imageId"`
InstanceID string `json:"instanceId"`
InstanceType string `json:"instanceType"`
KernelID string `json:"kernelId"`
PendingTime time.Time `json:"pendingTime"`
PrivateIP string `json:"privateIp"`
RamdiskID string `json:"ramdiskId"`
Region string `json:"region"`
Version string `json:"version"`
}
// AWS is the provisioner that supports identity tokens created from the Amazon
// Web Services Instance Identity Documents.
//
// If DisableCustomSANs is true, only the internal DNS and IP will be added as a
// SAN. By default it will accept any SAN in the CSR.
//
// If DisableTrustOnFirstUse is true, multiple sign request for this provisioner
// with the same instance will be accepted. By default only the first request
// will be accepted.
//
// If InstanceAge is set, only the instances with a pendingTime within the given
// period will be accepted.
//
// Amazon Identity docs are available at
// https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-identity-documents.html
type AWS struct {
*base
Type string `json:"type"`
Name string `json:"name"`
Accounts []string `json:"accounts"`
DisableCustomSANs bool `json:"disableCustomSANs"`
DisableTrustOnFirstUse bool `json:"disableTrustOnFirstUse"`
IMDSVersions []string `json:"imdsVersions"`
InstanceAge Duration `json:"instanceAge,omitempty"`
Claims *Claims `json:"claims,omitempty"`
claimer *Claimer
config *awsConfig
audiences Audiences
}
// GetID returns the provisioner unique identifier.
func (p *AWS) GetID() string {
return "aws/" + p.Name
}
// GetTokenID returns the identifier of the token.
func (p *AWS) GetTokenID(token string) (string, error) {
payload, err := p.authorizeToken(token)
if err != nil {
return "", err
}
// If TOFU is disabled create an ID for the token, so it cannot be reused.
// The timestamps, document and signatures should be mostly unique.
if p.DisableTrustOnFirstUse {
sum := sha256.Sum256([]byte(token))
return strings.ToLower(hex.EncodeToString(sum[:])), nil
}
return payload.ID, nil
}
// GetName returns the name of the provisioner.
func (p *AWS) GetName() string {
return p.Name
}
// GetType returns the type of provisioner.
func (p *AWS) GetType() Type {
return TypeAWS
}
// GetEncryptedKey is not available in an AWS provisioner.
func (p *AWS) GetEncryptedKey() (kid string, key string, ok bool) {
return "", "", false
}
// GetIdentityToken retrieves the identity document and it's signature and
// generates a token with them.
func (p *AWS) GetIdentityToken(subject, caURL string) (string, error) {
// Initialize the config if this method is used from the cli.
if err := p.assertConfig(); err != nil {
return "", err
}
var idoc awsInstanceIdentityDocument
doc, err := p.readURL(p.config.identityURL)
if err != nil {
return "", errors.Wrap(err, "error retrieving identity document:\n Are you in an AWS VM?\n Is the metadata service enabled?\n Are you using the proper metadata service version?")
}
if err := json.Unmarshal(doc, &idoc); err != nil {
return "", errors.Wrap(err, "error unmarshaling identity document")
}
sig, err := p.readURL(p.config.signatureURL)
if err != nil {
return "", errors.Wrap(err, "error retrieving identity document:\n Are you in an AWS VM?\n Is the metadata service enabled?\n Are you using the proper metadata service version?")
}
signature, err := base64.StdEncoding.DecodeString(string(sig))
if err != nil {
return "", errors.Wrap(err, "error decoding identity document signature")
}
if err := p.checkSignature(doc, signature); err != nil {
return "", err
}
audience, err := generateSignAudience(caURL, p.GetID())
if err != nil {
return "", err
}
// Create unique ID for Trust On First Use (TOFU). Only the first instance
// per provisioner is allowed as we don't have a way to trust the given
// sans.
unique := fmt.Sprintf("%s.%s", p.GetID(), idoc.InstanceID)
sum := sha256.Sum256([]byte(unique))
// Create a JWT from the identity document
signer, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.HS256, Key: signature},
new(jose.SignerOptions).WithType("JWT"),
)
if err != nil {
return "", errors.Wrap(err, "error creating signer")
}
now := time.Now()
payload := awsPayload{
Claims: jose.Claims{
Issuer: awsIssuer,
Subject: subject,
Audience: []string{audience},
Expiry: jose.NewNumericDate(now.Add(5 * time.Minute)),
NotBefore: jose.NewNumericDate(now),
IssuedAt: jose.NewNumericDate(now),
ID: strings.ToLower(hex.EncodeToString(sum[:])),
},
Amazon: awsAmazonPayload{
Document: doc,
Signature: signature,
},
}
tok, err := jose.Signed(signer).Claims(payload).CompactSerialize()
if err != nil {
return "", errors.Wrap(err, "error serialiazing token")
}
return tok, nil
}
// Init validates and initializes the AWS provisioner.
func (p *AWS) Init(config Config) (err error) {
switch {
case p.Type == "":
return errors.New("provisioner type cannot be empty")
case p.Name == "":
return errors.New("provisioner name cannot be empty")
case p.InstanceAge.Value() < 0:
return errors.New("provisioner instanceAge cannot be negative")
}
// Update claims with global ones
if p.claimer, err = NewClaimer(p.Claims, config.Claims); err != nil {
return err
}
// Add default config
if p.config, err = newAWSConfig(); err != nil {
return err
}
p.audiences = config.Audiences.WithFragment(p.GetID())
// validate IMDS versions
if len(p.IMDSVersions) == 0 {
p.IMDSVersions = []string{"v2", "v1"}
}
for _, v := range p.IMDSVersions {
switch v {
case "v1":
// valid
case "v2":
// valid
default:
return errors.Errorf("%s: not a supported AWS Instance Metadata Service version", v)
}
}
return nil
}
// AuthorizeSign validates the given token and returns the sign options that
// will be used on certificate creation.
func (p *AWS) AuthorizeSign(ctx context.Context, token string) ([]SignOption, error) {
payload, err := p.authorizeToken(token)
if err != nil {
return nil, errs.Wrap(http.StatusInternalServerError, err, "aws.AuthorizeSign")
}
doc := payload.document
// Enforce known CN and default DNS and IP if configured.
// By default we'll accept the CN and SANs in the CSR.
// There's no way to trust them other than TOFU.
var so []SignOption
if p.DisableCustomSANs {
so = append(so, dnsNamesValidator([]string{
fmt.Sprintf("ip-%s.%s.compute.internal", strings.Replace(doc.PrivateIP, ".", "-", -1), doc.Region),
}))
so = append(so, ipAddressesValidator([]net.IP{
net.ParseIP(doc.PrivateIP),
}))
so = append(so, emailAddressesValidator(nil))
so = append(so, urisValidator(nil))
}
return append(so,
// modifiers / withOptions
newProvisionerExtensionOption(TypeAWS, p.Name, doc.AccountID, "InstanceID", doc.InstanceID),
profileDefaultDuration(p.claimer.DefaultTLSCertDuration()),
// validators
defaultPublicKeyValidator{},
commonNameValidator(payload.Claims.Subject),
newValidityValidator(p.claimer.MinTLSCertDuration(), p.claimer.MaxTLSCertDuration()),
), nil
}
// AuthorizeRenew returns an error if the renewal is disabled.
// NOTE: This method does not actually validate the certificate or check it's
// revocation status. Just confirms that the provisioner that created the
// certificate was configured to allow renewals.
func (p *AWS) AuthorizeRenew(ctx context.Context, cert *x509.Certificate) error {
if p.claimer.IsDisableRenewal() {
return errs.Unauthorized("aws.AuthorizeRenew; renew is disabled for aws provisioner %s", p.GetID())
}
return nil
}
// assertConfig initializes the config if it has not been initialized
func (p *AWS) assertConfig() (err error) {
if p.config != nil {
return
}
p.config, err = newAWSConfig()
return err
}
// checkSignature returns an error if the signature is not valid.
func (p *AWS) checkSignature(signed, signature []byte) error {
if err := p.config.certificate.CheckSignature(p.config.signatureAlgorithm, signed, signature); err != nil {
return errors.Wrap(err, "error validating identity document signature")
}
return nil
}
// readURL does a GET request to the given url and returns the body. It's not
// using pkg/errors to avoid verbose errors, the caller should use it and write
// the appropriate error.
func (p *AWS) readURL(url string) ([]byte, error) {
var resp *http.Response
var err error
for _, v := range p.IMDSVersions {
switch v {
case "v1":
resp, err = p.readURLv1(url)
if err == nil && resp.StatusCode < 400 {
return p.readResponseBody(resp)
}
case "v2":
resp, err = p.readURLv2(url)
if err == nil && resp.StatusCode < 400 {
return p.readResponseBody(resp)
}
default:
return nil, fmt.Errorf("%s: not a supported AWS Instance Metadata Service version", v)
}
if resp != nil {
resp.Body.Close()
}
}
// all versions have been exhausted and we haven't returned successfully yet so pass
// the error on to the caller
if err != nil {
return nil, err
}
return nil, fmt.Errorf("Request for metadata returned non-successful status code %d",
resp.StatusCode)
}
func (p *AWS) readURLv1(url string) (*http.Response, error) {
client := http.Client{}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
return resp, nil
}
func (p *AWS) readURLv2(url string) (*http.Response, error) {
client := http.Client{}
// first get the token
req, err := http.NewRequest(http.MethodPut, p.config.tokenURL, nil)
if err != nil {
return nil, err
}
req.Header.Set(awsMetadataTokenTTLHeader, p.config.tokenTTL)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("Request for API token returned non-successful status code %d", resp.StatusCode)
}
token, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// now make the request
req, err = http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set(awsMetadataTokenHeader, string(token))
resp, err = client.Do(req)
if err != nil {
return nil, err
}
return resp, nil
}
func (p *AWS) readResponseBody(resp *http.Response) ([]byte, error) {
defer resp.Body.Close()
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return b, nil
}
// authorizeToken performs common jwt authorization actions and returns the
// claims for case specific downstream parsing.
// e.g. a Sign request will auth/validate different fields than a Revoke request.
func (p *AWS) authorizeToken(token string) (*awsPayload, error) {
jwt, err := jose.ParseSigned(token)
if err != nil {
return nil, errs.Wrapf(http.StatusUnauthorized, err, "aws.authorizeToken; error parsing aws token")
}
if len(jwt.Headers) == 0 {
return nil, errs.InternalServer("aws.authorizeToken; error parsing token, header is missing")
}
var unsafeClaims awsPayload
if err := jwt.UnsafeClaimsWithoutVerification(&unsafeClaims); err != nil {
return nil, errs.Wrap(http.StatusUnauthorized, err, "aws.authorizeToken; error unmarshaling claims")
}
var payload awsPayload
if err := jwt.Claims(unsafeClaims.Amazon.Signature, &payload); err != nil {
return nil, errs.Wrap(http.StatusUnauthorized, err, "aws.authorizeToken; error verifying claims")
}
// Validate identity document signature
if err := p.checkSignature(payload.Amazon.Document, payload.Amazon.Signature); err != nil {
return nil, errs.Wrap(http.StatusUnauthorized, err, "aws.authorizeToken; invalid aws token signature")
}
var doc awsInstanceIdentityDocument
if err := json.Unmarshal(payload.Amazon.Document, &doc); err != nil {
return nil, errs.Wrap(http.StatusUnauthorized, err, "aws.authorizeToken; error unmarshaling aws identity document")
}
switch {
case doc.AccountID == "":
return nil, errs.Unauthorized("aws.authorizeToken; aws identity document accountId cannot be empty")
case doc.InstanceID == "":
return nil, errs.Unauthorized("aws.authorizeToken; aws identity document instanceId cannot be empty")
case doc.PrivateIP == "":
return nil, errs.Unauthorized("aws.authorizeToken; aws identity document privateIp cannot be empty")
case doc.Region == "":
return nil, errs.Unauthorized("aws.authorizeToken; aws identity document region cannot be empty")
}
// According to "rfc7519 JSON Web Token" acceptable skew should be no
// more than a few minutes.
now := time.Now().UTC()
if err = payload.ValidateWithLeeway(jose.Expected{
Issuer: awsIssuer,
Time: now,
}, time.Minute); err != nil {
return nil, errs.Wrapf(http.StatusUnauthorized, err, "aws.authorizeToken; invalid aws token")
}
// validate audiences with the defaults
if !matchesAudience(payload.Audience, p.audiences.Sign) {
return nil, errs.Unauthorized("aws.authorizeToken; invalid token - invalid audience claim (aud)")
}
// Validate subject, it has to be known if disableCustomSANs is enabled
if p.DisableCustomSANs {
if payload.Subject != doc.InstanceID &&
payload.Subject != doc.PrivateIP &&
payload.Subject != fmt.Sprintf("ip-%s.%s.compute.internal", strings.Replace(doc.PrivateIP, ".", "-", -1), doc.Region) {
return nil, errs.Unauthorized("aws.authorizeToken; invalid token - invalid subject claim (sub)")
}
}
// validate accounts
if len(p.Accounts) > 0 {
var found bool
for _, sa := range p.Accounts {
if sa == doc.AccountID {
found = true
break
}
}
if !found {
return nil, errs.Unauthorized("aws.authorizeToken; invalid aws identity document - accountId is not valid")
}
}
// validate instance age
if d := p.InstanceAge.Value(); d > 0 {
if now.Sub(doc.PendingTime) > d {
return nil, errs.Unauthorized("aws.authorizeToken; aws identity document pendingTime is too old")
}
}
payload.document = doc
return &payload, nil
}
// AuthorizeSSHSign returns the list of SignOption for a SignSSH request.
func (p *AWS) AuthorizeSSHSign(ctx context.Context, token string) ([]SignOption, error) {
if !p.claimer.IsSSHCAEnabled() {
return nil, errs.Unauthorized("aws.AuthorizeSSHSign; ssh ca is disabled for aws provisioner %s", p.GetID())
}
claims, err := p.authorizeToken(token)
if err != nil {
return nil, errs.Wrap(http.StatusInternalServerError, err, "aws.AuthorizeSSHSign")
}
doc := claims.document
signOptions := []SignOption{
// set the key id to the instance id
sshCertKeyIDModifier(doc.InstanceID),
}
// Only enforce known principals if disable custom sans is true.
var principals []string
if p.DisableCustomSANs {
principals = []string{
doc.PrivateIP,
fmt.Sprintf("ip-%s.%s.compute.internal", strings.Replace(doc.PrivateIP, ".", "-", -1), doc.Region),
}
}
// Default to cert type to host
defaults := SSHOptions{
CertType: SSHHostCert,
Principals: principals,
}
// Validate user options
signOptions = append(signOptions, sshCertOptionsValidator(defaults))
// Set defaults if not given as user options
signOptions = append(signOptions, sshCertDefaultsModifier(defaults))
return append(signOptions,
// Set the default extensions.
&sshDefaultExtensionModifier{},
// Set the validity bounds if not set.
&sshDefaultDuration{p.claimer},
// Validate public key
&sshDefaultPublicKeyValidator{},
// Validate the validity period.
&sshCertValidityValidator{p.claimer},
// Require all the fields in the SSH certificate
&sshCertDefaultValidator{},
), nil
}