From 6412b1a79b559558f93b73221fc26a115dc84f29 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Tue, 7 May 2019 17:07:04 -0700 Subject: [PATCH] Add first version of Asure support. Fixes #69 --- authority/provisioner/azure.go | 230 +++++++++++++++++++++++++++++++-- 1 file changed, 217 insertions(+), 13 deletions(-) diff --git a/authority/provisioner/azure.go b/authority/provisioner/azure.go index 4d91bf19..cdd376a6 100644 --- a/authority/provisioner/azure.go +++ b/authority/provisioner/azure.go @@ -1,27 +1,86 @@ package provisioner import ( + "crypto/sha256" "crypto/x509" + "encoding/asn1" + "encoding/hex" + "encoding/json" + "io/ioutil" + "net/http" + "regexp" + "strings" + "time" "github.com/pkg/errors" + "github.com/smallstep/cli/jose" ) -// azureAttestedDocumentURL is the URL for the attested document. -const azureAttestedDocumentURL = "http://169.254.169.254/metadata/attested/document?api-version=2018-10-01" +// azureOIDCDiscoveryURL is the default discovery url for Microsoft Azure tokens. +const azureOIDCDiscoveryURL = "https://login.microsoftonline.com/common/.well-known/openid-configuration" + +// azureIdentityTokenURL is the URL to get the identity token for an instance. +const azureIdentityTokenURL = "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fmanagement.azure.com%2F" + +// azureDefaultAudience is the default audience used. +const azureDefaultAudience = "https://management.azure.com/" + +// azureXMSMirIDRegExp is the regular expression used to parse the xms_mirid claim. +// Using case insensitive as resourceGroups appears as resourcegroups. +var azureXMSMirIDRegExp = regexp.MustCompile(`(?i)^/subscriptions/([^/]+)/resourceGroups/([^/]+)/providers/Microsoft.Compute/virtualMachines/([^/]+)$`) type azureConfig struct { - attestedDocumentURL string + oidcDiscoveryURL string + identityTokenURL string } func newAzureConfig() *azureConfig { return &azureConfig{ - attestedDocumentURL: azureAttestedDocumentURL, + oidcDiscoveryURL: azureOIDCDiscoveryURL, + identityTokenURL: azureIdentityTokenURL, } } +type azureAttestedDocument struct { + Encoding string `json:"encoding"` + Signature []byte `json:"signature"` +} + +type azureIdentityToken struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ClientID string `json:"client_id"` + ExpiresIn int64 `json:"expires_in,string"` + ExpiresOn int64 `json:"expires_on,string"` + ExtExpiresIn int64 `json:"ext_expires_in,string"` + NotBefore int64 `json:"not_before,string"` + Resource string `json:"resource"` + TokenType string `json:"token_type"` +} + +type azurePayload struct { + jose.Claims + AppID string `json:"appid"` + AppIDAcr string `json:"appidacr"` + IdentityProvider string `json:"idp"` + ObjectID string `json:"oid"` + TenantID string `json:"tid"` + Version string `json:"ver"` + XMSMirID string `json:"xms_mirid"` +} + +var ( + oidSHA256 = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1} + oidSignatureSHA256WithRSA = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 11} + oidSignatureECDSAWithSHA256 = asn1.ObjectIdentifier{1, 2, 840, 10045, 4, 3, 2} + oidExtensionAuthorityInfoAccess = []int{1, 3, 6, 1, 5, 5, 7, 1, 1} +) + // Azure is the provisioner that supports identity tokens created from the // Microsoft Azure Instance Metadata service. // +// The default audience is "https://management.azure.com/". +// // 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. // @@ -29,23 +88,48 @@ func newAzureConfig() *azureConfig { // with the same instance will be accepted. By default only the first request // will be accepted. type Azure struct { - Type string `json:"type"` - Name string `json:"name"` - DisableCustomSANs bool `json:"disableCustomSANs"` - DisableTrustOnFirstUse bool `json:"disableTrustOnFirstUse"` - Claims *Claims `json:"claims,omitempty"` + Type string `json:"type"` + Name string `json:"name"` + Subscriptions []string `json:"subscriptions"` + Audience string `json:"audience,omitempty"` + DisableCustomSANs bool `json:"disableCustomSANs"` + DisableTrustOnFirstUse bool `json:"disableTrustOnFirstUse"` + Claims *Claims `json:"claims,omitempty"` claimer *Claimer config *azureConfig + oidcConfig openIDConfiguration + keyStore *keyStore } // GetID returns the provisioner unique identifier. func (p *Azure) GetID() string { - return "azure:" + p.Name + return p.Audience } -// GetTokenID returns the identifier of the token. +// GetTokenID returns the identifier of the token. The default value for Azure +// the SHA256 of "xms_mirid", but if DisableTrustOnFirstUse is set to true, then +// it will be the token kid. func (p *Azure) GetTokenID(token string) (string, error) { - return "", errors.New("TODO") + jwt, err := jose.ParseSigned(token) + if err != nil { + return "", errors.Wrap(err, "error parsing token") + } + + // Get claims w/out verification. We need to look up the provisioner + // key in order to verify the claims and we need the issuer from the claims + // before we can look up the provisioner. + var claims azurePayload + if err = jwt.UnsafeClaimsWithoutVerification(&claims); err != nil { + return "", errors.Wrap(err, "error verifying claims") + } + + // If TOFU is disabled create return the token kid + if p.DisableTrustOnFirstUse { + return claims.ID, nil + } + + sum := sha256.Sum256([]byte(claims.XMSMirID)) + return strings.ToLower(hex.EncodeToString(sum[:])), nil } // GetName returns the name of the provisioner. @@ -63,6 +147,41 @@ func (p *Azure) 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 *Azure) GetIdentityToken() (string, error) { + // Initialize the config if this method is used from the cli. + if err := p.assertConfig(); err != nil { + return "", err + } + + req, err := http.NewRequest("GET", p.config.identityTokenURL, http.NoBody) + if err != nil { + return "", errors.Wrap(err, "error creating request") + } + req.Header.Set("Metadata", "true") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", errors.Wrap(err, "error getting identity token, are you in a Azure VM?") + } + defer resp.Body.Close() + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", errors.Wrap(err, "error reading identity token response") + } + if resp.StatusCode >= 400 { + return "", errors.Errorf("error getting identity token: status=%d, response=%s", resp.StatusCode, b) + } + + var identityToken azureIdentityToken + if err := json.Unmarshal(b, &identityToken); err != nil { + return "", errors.Wrap(err, "error unmarshaling identity token response") + } + + return identityToken.AccessToken, nil +} + // Init validates and initializes the Azure provisioner. func (p *Azure) Init(config Config) (err error) { switch { @@ -70,20 +189,96 @@ func (p *Azure) Init(config Config) (err error) { return errors.New("provisioner type cannot be empty") case p.Name == "": return errors.New("provisioner name cannot be empty") + case p.Audience == "": // use default audience + p.Audience = azureDefaultAudience } + // Update claims with global ones if p.claimer, err = NewClaimer(p.Claims, config.Claims); err != nil { return err } // Initialize configuration p.config = newAzureConfig() + + // Decode and validate openid-configuration endpoint + if err := getAndDecode(p.config.oidcDiscoveryURL, &p.oidcConfig); err != nil { + return err + } + if err := p.oidcConfig.Validate(); err != nil { + return errors.Wrapf(err, "error parsing %s", p.config.oidcDiscoveryURL) + } + // Get JWK key set + if p.keyStore, err = newKeyStore(p.oidcConfig.JWKSetURI); 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 *Azure) AuthorizeSign(token string) ([]SignOption, error) { - return nil, errors.New("TODO") + jwt, err := jose.ParseSigned(token) + if err != nil { + return nil, errors.Wrapf(err, "error parsing token") + } + + var found bool + var claims azurePayload + keys := p.keyStore.Get(jwt.Headers[0].KeyID) + for _, key := range keys { + if err := jwt.Claims(key, &claims); err == nil { + found = true + break + } + } + if !found { + return nil, errors.New("cannot validate token") + } + + if err := claims.ValidateWithLeeway(jose.Expected{ + Audience: []string{p.Audience}, + Issuer: strings.Replace(p.oidcConfig.Issuer, "{tenantid}", claims.TenantID, 1), + Time: time.Now(), + }, 1*time.Minute); err != nil { + return nil, errors.Wrap(err, "failed to validate payload") + } + + re := azureXMSMirIDRegExp.FindStringSubmatch(claims.XMSMirID) + if len(re) == 0 { + return nil, errors.Errorf("error parsing xms_mirid claim: %s", claims.XMSMirID) + } + subscription, name := re[1], re[3] + + // Filter by subscriptions + if len(p.Subscriptions) > 0 { + var found bool + for _, s := range p.Subscriptions { + if s == subscription { + found = true + break + } + } + if !found { + return nil, errors.Errorf("subscription %s is not valid", subscription) + } + } + + // Enforce default DNS if configured. + // By default we we'll accept the SANs in the CSR. + // There's no way to trust them other than TOFU. + var so []SignOption + if p.DisableCustomSANs { + // name will work only inside the virtual network + so = append(so, dnsNamesValidator([]string{name})) + } + + return append(so, + commonNameValidator(name), + profileDefaultDuration(p.claimer.DefaultTLSCertDuration()), + newProvisionerExtensionOption(TypeAzure, p.Name, subscription), + newValidityValidator(p.claimer.MinTLSCertDuration(), p.claimer.MaxTLSCertDuration()), + ), nil } // AuthorizeRenewal returns an error if the renewal is disabled. @@ -99,3 +294,12 @@ func (p *Azure) AuthorizeRenewal(cert *x509.Certificate) error { func (p *Azure) AuthorizeRevoke(token string) error { return errors.New("revoke is not supported on a Azure provisioner") } + +// assertConfig initializes the config if it has not been initialized +func (p *Azure) assertConfig() error { + if p.config != nil { + return nil + } + p.config = newAzureConfig() + return nil +}