Add first version of Asure support.

Fixes #69
This commit is contained in:
Mariano Cano 2019-05-07 17:07:04 -07:00
parent 70196b2331
commit 6412b1a79b

View file

@ -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
}