From 75ef5a22752dd6a375c1ee5508c5475b9230ee9a Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 24 Apr 2019 12:12:36 -0700 Subject: [PATCH] Add AWS provisioner. Fixes #68 --- authority/provisioner/aws.go | 393 +++++++++++++++++++++++++++ authority/provisioner/provisioner.go | 4 + 2 files changed, 397 insertions(+) create mode 100644 authority/provisioner/aws.go diff --git a/authority/provisioner/aws.go b/authority/provisioner/aws.go new file mode 100644 index 00000000..1bf0f478 --- /dev/null +++ b/authority/provisioner/aws.go @@ -0,0 +1,393 @@ +package provisioner + +import ( + "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/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" + +// 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 + 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, + 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. +type AWS struct { + Type string `json:"type"` + Name string `json:"name"` + Claims *Claims `json:"claims,omitempty"` + Accounts []string `json:"accounts"` + DisableCustomSANs bool `json:"disableCustomSANs"` + DisableTrustOnFirstUse bool `json:"disableTrustOnFirstUse"` + claimer *Claimer + config *awsConfig +} + +// 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() (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, are you in an AWS VM?") + } + 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 signature, are you in an AWS VM?") + } + 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 + } + + // 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"), + ) + + now := time.Now() + payload := awsPayload{ + Claims: jose.Claims{ + Issuer: awsIssuer, + Subject: idoc.InstanceID, + Audience: []string{p.GetID()}, + 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") + } + // 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 + } + return nil +} + +// AuthorizeSign validates the given token and returns the sign options that +// will be used on certificate creation. +func (p *AWS) AuthorizeSign(token string) ([]SignOption, error) { + payload, err := p.authorizeToken(token) + if err != nil { + return nil, err + } + doc := payload.document + + // Enforce default DNS and IP if configured. + // By default we we'll accept the SANs in the CSR. + // There's no way to trust them. + 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), + })) + } + + return append(so, + commonNameValidator(doc.InstanceID), + profileDefaultDuration(p.claimer.DefaultTLSCertDuration()), + newProvisionerExtensionOption(TypeAWS, p.Name, doc.AccountID), + newValidityValidator(p.claimer.MinTLSCertDuration(), p.claimer.MaxTLSCertDuration()), + ), nil +} + +// AuthorizeRenewal returns an error if the renewal is disabled. +func (p *AWS) AuthorizeRenewal(cert *x509.Certificate) error { + if p.claimer.IsDisableRenewal() { + return errors.Errorf("renew is disabled for provisioner %s", p.GetID()) + } + return nil +} + +// AuthorizeRevoke returns an error because revoke is not supported on AWS +// provisioners. +func (p *AWS) AuthorizeRevoke(token string) error { + return errors.New("revoke is not supported on a AWS provisioner") +} + +// 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) { + r, err := http.Get(url) + if err != nil { + return nil, err + } + defer r.Body.Close() + b, err := ioutil.ReadAll(r.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, errors.Wrapf(err, "error parsing token") + } + if len(jwt.Headers) == 0 { + return nil, errors.New("error parsing token: header is missing") + } + + var unsafeClaims awsPayload + if err := jwt.UnsafeClaimsWithoutVerification(&unsafeClaims); err != nil { + return nil, errors.Wrap(err, "error unmarshaling claims") + } + + var payload awsPayload + if err := jwt.Claims(unsafeClaims.Amazon.Signature, &payload); err != nil { + return nil, errors.Wrap(err, "error verifying claims") + } + + // According to "rfc7519 JSON Web Token" acceptable skew should be no + // more than a few minutes. + if err = payload.ValidateWithLeeway(jose.Expected{ + Issuer: awsIssuer, + Audience: []string{p.GetID()}, + Time: time.Now().UTC(), + }, time.Minute); err != nil { + return nil, errors.Wrapf(err, "invalid token") + } + + // Validate identity document signature + if err := p.checkSignature(payload.Amazon.Document, payload.Amazon.Signature); err != nil { + return nil, err + } + + var doc awsInstanceIdentityDocument + if err := json.Unmarshal(payload.Amazon.Document, &doc); err != nil { + return nil, errors.Wrap(err, "error unmarshaling identity document") + } + + switch { + case doc.AccountID == "": + return nil, errors.New("identity document accountId cannot be empty") + case doc.InstanceID == "": + return nil, errors.New("identity document instanceId cannot be empty") + case doc.PrivateIP == "": + return nil, errors.New("identity document privateIp cannot be empty") + case doc.Region == "": + return nil, errors.New("identity document region cannot be empty") + } + + // 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, errors.New("invalid identity document: accountId is not valid") + } + } + payload.document = doc + return &payload, nil +} diff --git a/authority/provisioner/provisioner.go b/authority/provisioner/provisioner.go index 07fd947e..eba15d13 100644 --- a/authority/provisioner/provisioner.go +++ b/authority/provisioner/provisioner.go @@ -43,6 +43,8 @@ const ( TypeOIDC Type = 2 // TypeGCP is used to indicate the GCP provisioners. TypeGCP Type = 3 + // TypeAWS is used to indicate the AWS provisioners. + TypeAWS Type = 4 // RevokeAudienceKey is the key for the 'revoke' audiences in the audiences map. RevokeAudienceKey = "revoke" @@ -88,6 +90,8 @@ func (l *List) UnmarshalJSON(data []byte) error { p = &OIDC{} case "gcp": p = &GCP{} + case "aws": + p = &AWS{} default: return errors.Errorf("provisioner type %s not supported", typ.Type) }