From f794dbeb936c57db349459e5e7287ec84cda93be Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 17 Apr 2019 17:28:21 -0700 Subject: [PATCH 01/41] Add support for GCP identity tokens. --- authority/provisioner/collection.go | 8 +- authority/provisioner/gcp.go | 233 +++++++++++++++++++++++++++ authority/provisioner/jwk.go | 1 - authority/provisioner/keystore.go | 107 +++++++++++- authority/provisioner/provisioner.go | 6 +- 5 files changed, 345 insertions(+), 10 deletions(-) create mode 100644 authority/provisioner/gcp.go diff --git a/authority/provisioner/collection.go b/authority/provisioner/collection.go index 2c1e5ee0..67ad992d 100644 --- a/authority/provisioner/collection.go +++ b/authority/provisioner/collection.go @@ -70,13 +70,17 @@ func (c *Collection) LoadByToken(token *jose.JSONWebToken, claims *jose.Claims) if err := token.UnsafeClaimsWithoutVerification(&payload); err != nil { return nil, false } - // audience is required + // Audience is required if len(payload.Audience) == 0 { return nil, false } + // Try with azp (OIDC) if len(payload.AuthorizedParty) > 0 { - return c.Load(payload.AuthorizedParty) + if p, ok := c.Load(payload.AuthorizedParty); ok { + return p, ok + } } + // Fallback to aud (GCP) return c.Load(payload.Audience[0]) } diff --git a/authority/provisioner/gcp.go b/authority/provisioner/gcp.go new file mode 100644 index 00000000..5ebb737c --- /dev/null +++ b/authority/provisioner/gcp.go @@ -0,0 +1,233 @@ +package provisioner + +import ( + "bytes" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strings" + "time" + + "github.com/pkg/errors" + "github.com/smallstep/cli/jose" +) + +// gcpPayload extends jwt.Claims with custom GCP attributes. +type gcpPayload struct { + jose.Claims + AuthorizedParty string `json:"azp"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + Google gcpGooglePayload `json:"google"` +} + +type gcpGooglePayload struct { + ComputeEngine gcpComputeEnginePayload `json:"compute_engine"` +} + +type gcpComputeEnginePayload struct { + InstanceID string `json:"instance_id"` + InstanceName string `json:"instance_name"` + InstanceCreationTimestamp *jose.NumericDate `json:"instance_creation_timestamp"` + ProjectID string `json:"project_id"` + ProjectNumber int64 `json:"project_number"` + Zone string `json:"zone"` + LicenseID []string `json:"license_id"` +} + +// GCP is the provisioner that supports identity tokens created by the Google +// Cloud Platform metadata API. +type GCP struct { + Type string `json:"type"` + Name string `json:"name"` + ServiceAccounts []string `json:"serviceAccounts"` + Claims *Claims `json:"claims,omitempty"` + claimer *Claimer + certStore *keyStore +} + +// GetID returns the provisioner unique identifier. The name should uniquely +// identify any GCP provisioner. +func (p *GCP) GetID() string { + return "gcp:" + p.Name +} + +// GetTokenID returns the identifier of the token. For GCP this is the sha256 of +// "instance_id.iat.exp". +func (p *GCP) GetTokenID(token string) (string, error) { + jwt, err := jose.ParseSigned(token) + if err != nil { + return "", errors.Wrap(err, "error parsing token") + } + + // Get claims w/out verification. + var claims gcpPayload + if err = jwt.UnsafeClaimsWithoutVerification(&claims); err != nil { + return "", errors.Wrap(err, "error verifying claims") + } + + unique := fmt.Sprintf("%s.%d.%d", claims.Google.ComputeEngine.InstanceID, claims.IssuedAt, claims.Expiry) + sum := sha256.Sum256([]byte(unique)) + return strings.ToLower(hex.EncodeToString(sum[:])), nil +} + +// GetName returns the name of the provisioner. +func (p *GCP) GetName() string { + return p.Name +} + +// GetType returns the type of provisioner. +func (p *GCP) GetType() Type { + return TypeGCP +} + +// GetEncryptedKey is not available in a GCP provisioner. +func (p *GCP) GetEncryptedKey() (kid string, key string, ok bool) { + return "", "", false +} + +// Init validates and initializes the GCP provider. +func (p *GCP) Init(config Config) error { + var 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 + } + // Initialize certificate store + p.certStore, err = newCertificateStore("https://www.googleapis.com/oauth2/v1/certs") + if 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 *GCP) AuthorizeSign(token string) ([]SignOption, error) { + claims, err := p.authorizeToken(token) + if err != nil { + return nil, err + } + + ce := claims.Google.ComputeEngine + dnsNames := []string{ + fmt.Sprintf("%s.c.%s.internal", ce.InstanceName, ce.ProjectID), + fmt.Sprintf("%s.%s.c.%s.internal", ce.InstanceName, ce.Zone, ce.ProjectID), + } + + return []SignOption{ + commonNameValidator(ce.InstanceName), + dnsNamesValidator(dnsNames), + profileDefaultDuration(p.claimer.DefaultTLSCertDuration()), + newProvisionerExtensionOption(TypeGCP, p.Name, claims.AuthorizedParty), + newValidityValidator(p.claimer.MinTLSCertDuration(), p.claimer.MaxTLSCertDuration()), + }, nil +} + +// AuthorizeRenewal returns an error if the renewal is disabled. +func (p *GCP) 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 if the provisioner does not have rights to +// revoke a certificate. +func (p *GCP) AuthorizeRevoke(token string) error { + _, err := p.authorizeToken(token) + return err +} + +// GetIdentityURL returns the url that generates the GCP token. +func (p *GCP) GetIdentityURL() string { + audience := url.QueryEscape(p.GetID()) + return fmt.Sprintf("http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=%s&format=full&licenses=FALSE", audience) +} + +// GetIdentityToken does an HTTP request to the identity url. +func (p *GCP) GetIdentityToken() (string, error) { + req, err := http.NewRequest("GET", p.GetIdentityURL(), http.NoBody) + if err != nil { + return "", errors.Wrap(err, "error creating identity request") + } + req.Header.Set("Metadata-Flavor", "Google") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", errors.Wrap(err, "error doing identity request, are you in a GCP VM?") + } + defer resp.Body.Close() + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", errors.Wrap(err, "error reading identity request response") + } + return string(bytes.TrimSpace(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 *GCP) authorizeToken(token string) (*gcpPayload, 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") + } + kid := jwt.Headers[0].KeyID + cert := p.certStore.GetCertificate(kid) + if cert == nil { + return nil, errors.Errorf("failed to validate payload: cannot find certificate for kid %s", kid) + } + + var claims gcpPayload + if err = jwt.Claims(cert.PublicKey, &claims); err != nil { + return nil, errors.Wrap(err, "error parsing claims") + } + + // According to "rfc7519 JSON Web Token" acceptable skew should be no + // more than a few minutes. + if err = claims.ValidateWithLeeway(jose.Expected{ + Issuer: "https://accounts.google.com", + Time: time.Now().UTC(), + Audience: []string{p.GetID()}, + }, time.Minute); err != nil { + return nil, errors.Wrapf(err, "invalid token") + } + + // validate authorized party + if len(p.ServiceAccounts) > 0 { + var found bool + for _, sa := range p.ServiceAccounts { + if sa == claims.AuthorizedParty { + found = true + break + } + } + if !found { + return nil, errors.New("invalid token: invalid authorized party claim (azp)") + } + } + + switch { + case claims.Google.ComputeEngine.InstanceID == "": + return nil, errors.New("token google.compute_engine.instance_id cannot be empty") + case claims.Google.ComputeEngine.ProjectID == "": + return nil, errors.New("token google.compute_engine.project_id cannot be empty") + case claims.Google.ComputeEngine.Zone == "": + return nil, errors.New("token google.compute_engine.zone cannot be empty") + } + + return &claims, nil +} diff --git a/authority/provisioner/jwk.go b/authority/provisioner/jwk.go index a7e009df..dca5dce9 100644 --- a/authority/provisioner/jwk.go +++ b/authority/provisioner/jwk.go @@ -33,7 +33,6 @@ func (p *JWK) GetID() string { return p.Name + ":" + p.Key.KeyID } -// // GetTokenID returns the identifier of the token. func (p *JWK) GetTokenID(ott string) (string, error) { // Validate payload diff --git a/authority/provisioner/keystore.go b/authority/provisioner/keystore.go index 2f11114a..89e50df2 100644 --- a/authority/provisioner/keystore.go +++ b/authority/provisioner/keystore.go @@ -1,7 +1,9 @@ package provisioner import ( + "crypto/x509" "encoding/json" + "encoding/pem" "math/rand" "net/http" "regexp" @@ -20,13 +22,32 @@ const ( var maxAgeRegex = regexp.MustCompile("max-age=([0-9]*)") +type oauth2Certificate struct { + ID string + Certificate *x509.Certificate +} + +type oauth2CertificateSet struct { + Certificates []oauth2Certificate +} + +func (s oauth2CertificateSet) Get(id string) *x509.Certificate { + for _, c := range s.Certificates { + if c.ID == id { + return c.Certificate + } + } + return nil +} + type keyStore struct { sync.RWMutex - uri string - keySet jose.JSONWebKeySet - timer *time.Timer - expiry time.Time - jitter time.Duration + uri string + keySet jose.JSONWebKeySet + certSet oauth2CertificateSet + timer *time.Timer + expiry time.Time + jitter time.Duration } func newKeyStore(uri string) (*keyStore, error) { @@ -45,6 +66,22 @@ func newKeyStore(uri string) (*keyStore, error) { return ks, nil } +func newCertificateStore(uri string) (*keyStore, error) { + certs, age, err := getOauth2Certificates(uri) + if err != nil { + return nil, err + } + ks := &keyStore{ + uri: uri, + certSet: certs, + expiry: getExpirationTime(age), + jitter: getCacheJitter(age), + } + next := ks.nextReloadDuration(age) + ks.timer = time.AfterFunc(next, ks.reloadCertificates) + return ks, nil +} + func (ks *keyStore) Close() { ks.timer.Stop() } @@ -62,6 +99,19 @@ func (ks *keyStore) Get(kid string) (keys []jose.JSONWebKey) { return } +func (ks *keyStore) GetCertificate(kid string) (cert *x509.Certificate) { + ks.RLock() + // Force reload if expiration has passed + if time.Now().After(ks.expiry) { + ks.RUnlock() + ks.reloadCertificates() + ks.RLock() + } + cert = ks.certSet.Get(kid) + ks.RUnlock() + return +} + func (ks *keyStore) reload() { var next time.Duration keys, age, err := getKeysFromJWKsURI(ks.uri) @@ -81,6 +131,25 @@ func (ks *keyStore) reload() { ks.Unlock() } +func (ks *keyStore) reloadCertificates() { + var next time.Duration + certs, age, err := getOauth2Certificates(ks.uri) + if err != nil { + next = ks.nextReloadDuration(ks.jitter / 2) + } else { + ks.Lock() + ks.certSet = certs + ks.expiry = getExpirationTime(age) + ks.jitter = getCacheJitter(age) + next = ks.nextReloadDuration(age) + ks.Unlock() + } + + ks.Lock() + ks.timer.Reset(next) + ks.Unlock() +} + func (ks *keyStore) nextReloadDuration(age time.Duration) time.Duration { n := rand.Int63n(int64(ks.jitter)) age -= time.Duration(n) @@ -103,6 +172,34 @@ func getKeysFromJWKsURI(uri string) (jose.JSONWebKeySet, time.Duration, error) { return keys, getCacheAge(resp.Header.Get("cache-control")), nil } +func getOauth2Certificates(uri string) (oauth2CertificateSet, time.Duration, error) { + var certs oauth2CertificateSet + resp, err := http.Get(uri) + if err != nil { + return certs, 0, errors.Wrapf(err, "failed to connect to %s", uri) + } + defer resp.Body.Close() + m := make(map[string]string) + if err := json.NewDecoder(resp.Body).Decode(&m); err != nil { + return certs, 0, errors.Wrapf(err, "error reading %s", uri) + } + for k, v := range m { + block, _ := pem.Decode([]byte(v)) + if block == nil || block.Type != "CERTIFICATE" { + return certs, 0, errors.Wrapf(err, "error parsing certificate %s from %s", k, uri) + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return certs, 0, errors.Wrapf(err, "error parsing certificate %s from %s", k, uri) + } + certs.Certificates = append(certs.Certificates, oauth2Certificate{ + ID: k, + Certificate: cert, + }) + } + return certs, getCacheAge(resp.Header.Get("cache-control")), nil +} + func getCacheAge(cacheControl string) time.Duration { age := defaultCacheAge if len(cacheControl) > 0 { diff --git a/authority/provisioner/provisioner.go b/authority/provisioner/provisioner.go index 68e34884..07fd947e 100644 --- a/authority/provisioner/provisioner.go +++ b/authority/provisioner/provisioner.go @@ -37,12 +37,12 @@ type Type int const ( noopType Type = 0 - // TypeJWK is used to indicate the JWK provisioners. TypeJWK Type = 1 - // TypeOIDC is used to indicate the OIDC provisioners. TypeOIDC Type = 2 + // TypeGCP is used to indicate the GCP provisioners. + TypeGCP Type = 3 // RevokeAudienceKey is the key for the 'revoke' audiences in the audiences map. RevokeAudienceKey = "revoke" @@ -86,6 +86,8 @@ func (l *List) UnmarshalJSON(data []byte) error { p = &JWK{} case "oidc": p = &OIDC{} + case "gcp": + p = &GCP{} default: return errors.Errorf("provisioner type %s not supported", typ.Type) } From b4729cd670d0722bd06722d1409daced29696191 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 17 Apr 2019 17:38:24 -0700 Subject: [PATCH 02/41] Use JWKSet to get the GCP keys. --- authority/provisioner/gcp.go | 24 ++++--- authority/provisioner/keystore.go | 107 ++---------------------------- 2 files changed, 19 insertions(+), 112 deletions(-) diff --git a/authority/provisioner/gcp.go b/authority/provisioner/gcp.go index 5ebb737c..c4390f03 100644 --- a/authority/provisioner/gcp.go +++ b/authority/provisioner/gcp.go @@ -47,7 +47,7 @@ type GCP struct { ServiceAccounts []string `json:"serviceAccounts"` Claims *Claims `json:"claims,omitempty"` claimer *Claimer - certStore *keyStore + keyStore *keyStore } // GetID returns the provisioner unique identifier. The name should uniquely @@ -103,8 +103,8 @@ func (p *GCP) Init(config Config) error { if p.claimer, err = NewClaimer(p.Claims, config.Claims); err != nil { return err } - // Initialize certificate store - p.certStore, err = newCertificateStore("https://www.googleapis.com/oauth2/v1/certs") + // Initialize key store + p.keyStore, err = newKeyStore("https://www.googleapis.com/oauth2/v3/certs") if err != nil { return err } @@ -185,15 +185,19 @@ func (p *GCP) authorizeToken(token string) (*gcpPayload, error) { if len(jwt.Headers) == 0 { return nil, errors.New("error parsing token: header is missing") } - kid := jwt.Headers[0].KeyID - cert := p.certStore.GetCertificate(kid) - if cert == nil { - return nil, errors.Errorf("failed to validate payload: cannot find certificate for kid %s", kid) - } + var found bool var claims gcpPayload - if err = jwt.Claims(cert.PublicKey, &claims); err != nil { - return nil, errors.Wrap(err, "error parsing claims") + kid := jwt.Headers[0].KeyID + keys := p.keyStore.Get(kid) + for _, key := range keys { + if err := jwt.Claims(key, &claims); err == nil { + found = true + break + } + } + if !found { + return nil, errors.Errorf("failed to validate payload: cannot find certificate for kid %s", kid) } // According to "rfc7519 JSON Web Token" acceptable skew should be no diff --git a/authority/provisioner/keystore.go b/authority/provisioner/keystore.go index 89e50df2..2f11114a 100644 --- a/authority/provisioner/keystore.go +++ b/authority/provisioner/keystore.go @@ -1,9 +1,7 @@ package provisioner import ( - "crypto/x509" "encoding/json" - "encoding/pem" "math/rand" "net/http" "regexp" @@ -22,32 +20,13 @@ const ( var maxAgeRegex = regexp.MustCompile("max-age=([0-9]*)") -type oauth2Certificate struct { - ID string - Certificate *x509.Certificate -} - -type oauth2CertificateSet struct { - Certificates []oauth2Certificate -} - -func (s oauth2CertificateSet) Get(id string) *x509.Certificate { - for _, c := range s.Certificates { - if c.ID == id { - return c.Certificate - } - } - return nil -} - type keyStore struct { sync.RWMutex - uri string - keySet jose.JSONWebKeySet - certSet oauth2CertificateSet - timer *time.Timer - expiry time.Time - jitter time.Duration + uri string + keySet jose.JSONWebKeySet + timer *time.Timer + expiry time.Time + jitter time.Duration } func newKeyStore(uri string) (*keyStore, error) { @@ -66,22 +45,6 @@ func newKeyStore(uri string) (*keyStore, error) { return ks, nil } -func newCertificateStore(uri string) (*keyStore, error) { - certs, age, err := getOauth2Certificates(uri) - if err != nil { - return nil, err - } - ks := &keyStore{ - uri: uri, - certSet: certs, - expiry: getExpirationTime(age), - jitter: getCacheJitter(age), - } - next := ks.nextReloadDuration(age) - ks.timer = time.AfterFunc(next, ks.reloadCertificates) - return ks, nil -} - func (ks *keyStore) Close() { ks.timer.Stop() } @@ -99,19 +62,6 @@ func (ks *keyStore) Get(kid string) (keys []jose.JSONWebKey) { return } -func (ks *keyStore) GetCertificate(kid string) (cert *x509.Certificate) { - ks.RLock() - // Force reload if expiration has passed - if time.Now().After(ks.expiry) { - ks.RUnlock() - ks.reloadCertificates() - ks.RLock() - } - cert = ks.certSet.Get(kid) - ks.RUnlock() - return -} - func (ks *keyStore) reload() { var next time.Duration keys, age, err := getKeysFromJWKsURI(ks.uri) @@ -131,25 +81,6 @@ func (ks *keyStore) reload() { ks.Unlock() } -func (ks *keyStore) reloadCertificates() { - var next time.Duration - certs, age, err := getOauth2Certificates(ks.uri) - if err != nil { - next = ks.nextReloadDuration(ks.jitter / 2) - } else { - ks.Lock() - ks.certSet = certs - ks.expiry = getExpirationTime(age) - ks.jitter = getCacheJitter(age) - next = ks.nextReloadDuration(age) - ks.Unlock() - } - - ks.Lock() - ks.timer.Reset(next) - ks.Unlock() -} - func (ks *keyStore) nextReloadDuration(age time.Duration) time.Duration { n := rand.Int63n(int64(ks.jitter)) age -= time.Duration(n) @@ -172,34 +103,6 @@ func getKeysFromJWKsURI(uri string) (jose.JSONWebKeySet, time.Duration, error) { return keys, getCacheAge(resp.Header.Get("cache-control")), nil } -func getOauth2Certificates(uri string) (oauth2CertificateSet, time.Duration, error) { - var certs oauth2CertificateSet - resp, err := http.Get(uri) - if err != nil { - return certs, 0, errors.Wrapf(err, "failed to connect to %s", uri) - } - defer resp.Body.Close() - m := make(map[string]string) - if err := json.NewDecoder(resp.Body).Decode(&m); err != nil { - return certs, 0, errors.Wrapf(err, "error reading %s", uri) - } - for k, v := range m { - block, _ := pem.Decode([]byte(v)) - if block == nil || block.Type != "CERTIFICATE" { - return certs, 0, errors.Wrapf(err, "error parsing certificate %s from %s", k, uri) - } - cert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - return certs, 0, errors.Wrapf(err, "error parsing certificate %s from %s", k, uri) - } - certs.Certificates = append(certs.Certificates, oauth2Certificate{ - ID: k, - Certificate: cert, - }) - } - return certs, getCacheAge(resp.Header.Get("cache-control")), nil -} - func getCacheAge(cacheControl string) time.Duration { age := defaultCacheAge if len(cacheControl) > 0 { From 1ea4b0ad64a98b7be68d12cefd8f8adef0c293ba Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Thu, 18 Apr 2019 16:01:30 -0700 Subject: [PATCH 03/41] Add unit test for GCP provider --- authority/provisioner/gcp.go | 85 ++++--- authority/provisioner/gcp_test.go | 352 ++++++++++++++++++++++++++++ authority/provisioner/oidc_test.go | 42 ---- authority/provisioner/utils_test.go | 65 +++++ 4 files changed, 468 insertions(+), 76 deletions(-) create mode 100644 authority/provisioner/gcp_test.go diff --git a/authority/provisioner/gcp.go b/authority/provisioner/gcp.go index c4390f03..dd0fc185 100644 --- a/authority/provisioner/gcp.go +++ b/authority/provisioner/gcp.go @@ -16,6 +16,12 @@ import ( "github.com/smallstep/cli/jose" ) +// googleOauth2Certs is the url that servers Google OAuth2 public keys. +var googleOauth2Certs = "https://www.googleapis.com/oauth2/v3/certs" + +// gcpIdentityURL is the base url for the identity document in GCP. +var gcpIdentityURL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity" + // gcpPayload extends jwt.Claims with custom GCP attributes. type gcpPayload struct { jose.Claims @@ -69,8 +75,11 @@ func (p *GCP) GetTokenID(token string) (string, error) { if err = jwt.UnsafeClaimsWithoutVerification(&claims); err != nil { return "", errors.Wrap(err, "error verifying claims") } - - unique := fmt.Sprintf("%s.%d.%d", claims.Google.ComputeEngine.InstanceID, claims.IssuedAt, claims.Expiry) + // This string should be mostly unique + unique := fmt.Sprintf("%s.%s.%d.%d", + p.GetID(), claims.Google.ComputeEngine.InstanceID, + *claims.IssuedAt, *claims.Expiry, + ) sum := sha256.Sum256([]byte(unique)) return strings.ToLower(hex.EncodeToString(sum[:])), nil } @@ -90,6 +99,37 @@ func (p *GCP) GetEncryptedKey() (kid string, key string, ok bool) { return "", "", false } +// GetIdentityURL returns the url that generates the GCP token. +func (p *GCP) GetIdentityURL() string { + q := url.Values{} + q.Add("audience", p.GetID()) + q.Add("format", "full") + q.Add("licenses", "FALSE") + return fmt.Sprintf("%s?%s", gcpIdentityURL, q.Encode()) +} + +// GetIdentityToken does an HTTP request to the identity url. +func (p *GCP) GetIdentityToken() (string, error) { + req, err := http.NewRequest("GET", p.GetIdentityURL(), http.NoBody) + if err != nil { + return "", errors.Wrap(err, "error creating identity request") + } + req.Header.Set("Metadata-Flavor", "Google") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", errors.Wrap(err, "error doing identity request, are you in a GCP VM?") + } + defer resp.Body.Close() + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", errors.Wrap(err, "error reading identity request response") + } + if resp.StatusCode >= 400 { + return "", errors.Errorf("error on identity response: status=%d, response=%s", resp.StatusCode, b) + } + return string(bytes.TrimSpace(b)), nil +} + // Init validates and initializes the GCP provider. func (p *GCP) Init(config Config) error { var err error @@ -104,7 +144,7 @@ func (p *GCP) Init(config Config) error { return err } // Initialize key store - p.keyStore, err = newKeyStore("https://www.googleapis.com/oauth2/v3/certs") + p.keyStore, err = newKeyStore(googleOauth2Certs) if err != nil { return err } @@ -149,31 +189,6 @@ func (p *GCP) AuthorizeRevoke(token string) error { return err } -// GetIdentityURL returns the url that generates the GCP token. -func (p *GCP) GetIdentityURL() string { - audience := url.QueryEscape(p.GetID()) - return fmt.Sprintf("http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=%s&format=full&licenses=FALSE", audience) -} - -// GetIdentityToken does an HTTP request to the identity url. -func (p *GCP) GetIdentityToken() (string, error) { - req, err := http.NewRequest("GET", p.GetIdentityURL(), http.NoBody) - if err != nil { - return "", errors.Wrap(err, "error creating identity request") - } - req.Header.Set("Metadata-Flavor", "Google") - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", errors.Wrap(err, "error doing identity request, are you in a GCP VM?") - } - defer resp.Body.Close() - b, err := ioutil.ReadAll(resp.Body) - if err != nil { - return "", errors.Wrap(err, "error reading identity request response") - } - return string(bytes.TrimSpace(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. @@ -191,42 +206,44 @@ func (p *GCP) authorizeToken(token string) (*gcpPayload, error) { kid := jwt.Headers[0].KeyID keys := p.keyStore.Get(kid) for _, key := range keys { - if err := jwt.Claims(key, &claims); err == nil { + if err := jwt.Claims(key.Public(), &claims); err == nil { found = true break } } if !found { - return nil, errors.Errorf("failed to validate payload: cannot find certificate for kid %s", kid) + return nil, errors.Errorf("failed to validate payload: cannot find key for kid %s", kid) } // According to "rfc7519 JSON Web Token" acceptable skew should be no // more than a few minutes. if err = claims.ValidateWithLeeway(jose.Expected{ Issuer: "https://accounts.google.com", - Time: time.Now().UTC(), Audience: []string{p.GetID()}, + Time: time.Now().UTC(), }, time.Minute); err != nil { return nil, errors.Wrapf(err, "invalid token") } - // validate authorized party + // validate subject (service account) if len(p.ServiceAccounts) > 0 { var found bool for _, sa := range p.ServiceAccounts { - if sa == claims.AuthorizedParty { + if sa == claims.Subject { found = true break } } if !found { - return nil, errors.New("invalid token: invalid authorized party claim (azp)") + return nil, errors.New("invalid token: invalid subject claim") } } switch { case claims.Google.ComputeEngine.InstanceID == "": return nil, errors.New("token google.compute_engine.instance_id cannot be empty") + case claims.Google.ComputeEngine.InstanceName == "": + return nil, errors.New("token google.compute_engine.instance_name cannot be empty") case claims.Google.ComputeEngine.ProjectID == "": return nil, errors.New("token google.compute_engine.project_id cannot be empty") case claims.Google.ComputeEngine.Zone == "": diff --git a/authority/provisioner/gcp_test.go b/authority/provisioner/gcp_test.go new file mode 100644 index 00000000..aa4748a1 --- /dev/null +++ b/authority/provisioner/gcp_test.go @@ -0,0 +1,352 @@ +package provisioner + +import ( + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/smallstep/assert" +) + +func resetGoogleVars() { + googleOauth2Certs = "https://www.googleapis.com/oauth2/v3/certs" + gcpIdentityURL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity" +} + +func TestGCP_Getters(t *testing.T) { + p, err := generateGCP() + assert.FatalError(t, err) + aud := "gcp:" + p.Name + if got := p.GetID(); got != aud { + t.Errorf("GCP.GetID() = %v, want %v", got, aud) + } + if got := p.GetName(); got != p.Name { + t.Errorf("GCP.GetName() = %v, want %v", got, p.Name) + } + if got := p.GetType(); got != TypeGCP { + t.Errorf("GCP.GetType() = %v, want %v", got, TypeGCP) + } + kid, key, ok := p.GetEncryptedKey() + if kid != "" || key != "" || ok == true { + t.Errorf("GCP.GetEncryptedKey() = (%v, %v, %v), want (%v, %v, %v)", + kid, key, ok, "", "", false) + } + expected := fmt.Sprintf("http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=%s&format=full&licenses=FALSE", url.QueryEscape(p.GetID())) + if got := p.GetIdentityURL(); got != expected { + t.Errorf("GCP.GetIdentityURL() = %v, want %v", got, expected) + } +} + +func TestGCP_GetTokenID(t *testing.T) { + p1, err := generateGCP() + assert.FatalError(t, err) + p1.Name = "name" + + now := time.Now() + t1, err := generateGCPToken(p1.ServiceAccounts[0], + "https://accounts.google.com", "gcp:name", + "instance-id", "instance-name", "project-id", "zone", + now, &p1.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) + + unique := fmt.Sprintf("gcp:name.instance-id.%d.%d", now.Unix(), now.Add(5*time.Minute).Unix()) + sum := sha256.Sum256([]byte(unique)) + want := strings.ToLower(hex.EncodeToString(sum[:])) + + type args struct { + token string + } + tests := []struct { + name string + gcp *GCP + args args + want string + wantErr bool + }{ + {"ok", p1, args{t1}, want, false}, + {"fail token", p1, args{"token"}, "", true}, + {"fail claims", p1, args{"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ey.fooo"}, "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.gcp.GetTokenID(tt.args.token) + if (err != nil) != tt.wantErr { + t.Errorf("GCP.GetTokenID() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("GCP.GetTokenID() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGCP_GetIdentityToken(t *testing.T) { + p1, err := generateGCP() + assert.FatalError(t, err) + defer resetGoogleVars() + + t1, err := generateGCPToken(p1.ServiceAccounts[0], + "https://accounts.google.com", p1.GetID(), + "instance-id", "instance-name", "project-id", "zone", + time.Now(), &p1.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Println(r.RequestURI) + switch r.URL.Path { + case "/bad-request": + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + default: + w.Write([]byte(t1)) + } + })) + defer srv.Close() + + tests := []struct { + name string + gcp *GCP + identityURL string + want string + wantErr bool + }{ + {"ok", p1, srv.URL, t1, false}, + {"bad request", p1, srv.URL + "/bad-request", "", true}, + {"bad url", p1, "badurl", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gcpIdentityURL = tt.identityURL + got, err := tt.gcp.GetIdentityToken() + if (err != nil) != tt.wantErr { + t.Errorf("GCP.GetIdentityToken() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("GCP.GetIdentityToken() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestGCP_Init(t *testing.T) { + srv := generateJWKServer(2) + defer srv.Close() + defer resetGoogleVars() + + config := Config{ + Claims: globalProvisionerClaims, + } + badClaims := &Claims{ + DefaultTLSDur: &Duration{0}, + } + + type fields struct { + Type string + Name string + ServiceAccounts []string + Claims *Claims + } + type args struct { + config Config + certsURL string + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + {"ok", fields{"GCP", "name", nil, nil}, args{config, srv.URL}, false}, + {"ok", fields{"GCP", "name", []string{"service-account"}, nil}, args{config, srv.URL}, false}, + {"bad type", fields{"", "name", nil, nil}, args{config, srv.URL}, true}, + {"bad name", fields{"GCP", "", nil, nil}, args{config, srv.URL}, true}, + {"bad claims", fields{"GCP", "name", nil, badClaims}, args{config, srv.URL}, true}, + {"bad certs", fields{"GCP", "name", nil, badClaims}, args{config, srv.URL + "/error"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + googleOauth2Certs = tt.args.certsURL + p := &GCP{ + Type: tt.fields.Type, + Name: tt.fields.Name, + ServiceAccounts: tt.fields.ServiceAccounts, + Claims: tt.fields.Claims, + } + if err := p.Init(tt.args.config); (err != nil) != tt.wantErr { + t.Errorf("GCP.Init() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestGCP_AuthorizeSign(t *testing.T) { + p1, err := generateGCP() + assert.FatalError(t, err) + + aKey, err := generateJSONWebKey() + assert.FatalError(t, err) + + t1, err := generateGCPToken(p1.ServiceAccounts[0], + "https://accounts.google.com", p1.GetID(), + "instance-id", "instance-name", "project-id", "zone", + time.Now(), &p1.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) + failKey, err := generateGCPToken(p1.ServiceAccounts[0], + "https://accounts.google.com", p1.GetID(), + "instance-id", "instance-name", "project-id", "zone", + time.Now(), aKey) + assert.FatalError(t, err) + failIss, err := generateGCPToken(p1.ServiceAccounts[0], + "https://foo.bar.zar", p1.GetID(), + "instance-id", "instance-name", "project-id", "zone", + time.Now(), &p1.keyStore.keySet.Keys[0]) + failAud, err := generateGCPToken(p1.ServiceAccounts[0], + "https://accounts.google.com", "gcp:foo", + "instance-id", "instance-name", "project-id", "zone", + time.Now(), &p1.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) + failExp, err := generateGCPToken(p1.ServiceAccounts[0], + "https://accounts.google.com", p1.GetID(), + "instance-id", "instance-name", "project-id", "zone", + time.Now().Add(-360*time.Second), &p1.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) + failNbf, err := generateGCPToken(p1.ServiceAccounts[0], + "https://accounts.google.com", p1.GetID(), + "instance-id", "instance-name", "project-id", "zone", + time.Now().Add(360*time.Second), &p1.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) + failServiceAccount, err := generateGCPToken("foo", + "https://accounts.google.com", p1.GetID(), + "instance-id", "instance-name", "project-id", "zone", + time.Now(), &p1.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) + failInstanceID, err := generateGCPToken(p1.ServiceAccounts[0], + "https://accounts.google.com", p1.GetID(), + "", "instance-name", "project-id", "zone", + time.Now(), &p1.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) + failInstanceName, err := generateGCPToken(p1.ServiceAccounts[0], + "https://accounts.google.com", p1.GetID(), + "instance-id", "", "project-id", "zone", + time.Now(), &p1.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) + failProjectID, err := generateGCPToken(p1.ServiceAccounts[0], + "https://accounts.google.com", p1.GetID(), + "instance-id", "instance-name", "", "zone", + time.Now(), &p1.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) + failZone, err := generateGCPToken(p1.ServiceAccounts[0], + "https://accounts.google.com", p1.GetID(), + "instance-id", "instance-name", "project-id", "", + time.Now(), &p1.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) + + type args struct { + token string + } + tests := []struct { + name string + gcp *GCP + args args + wantErr bool + }{ + {"ok", p1, args{t1}, false}, + {"fail token", p1, args{"token"}, true}, + {"fail key", p1, args{failKey}, true}, + {"fail iss", p1, args{failIss}, true}, + {"fail aud", p1, args{failAud}, true}, + {"fail exp", p1, args{failExp}, true}, + {"fail nbf", p1, args{failNbf}, true}, + {"fail service account", p1, args{failServiceAccount}, true}, + {"fail instance id", p1, args{failInstanceID}, true}, + {"fail instance name", p1, args{failInstanceName}, true}, + {"fail project id", p1, args{failProjectID}, true}, + {"fail zone", p1, args{failZone}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.gcp.AuthorizeSign(tt.args.token) + if (err != nil) != tt.wantErr { + t.Errorf("GCP.AuthorizeSign() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err != nil { + assert.Nil(t, got) + } else { + assert.Len(t, 5, got) + } + }) + } +} + +func TestGCP_AuthorizeRenewal(t *testing.T) { + p1, err := generateGCP() + assert.FatalError(t, err) + p2, err := generateGCP() + assert.FatalError(t, err) + + // disable renewal + disable := true + p2.Claims = &Claims{DisableRenewal: &disable} + p2.claimer, err = NewClaimer(p2.Claims, globalProvisionerClaims) + assert.FatalError(t, err) + + type args struct { + cert *x509.Certificate + } + tests := []struct { + name string + prov *GCP + args args + wantErr bool + }{ + {"ok", p1, args{nil}, false}, + {"fail", p2, args{nil}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.prov.AuthorizeRenewal(tt.args.cert); (err != nil) != tt.wantErr { + t.Errorf("GCP.AuthorizeRenewal() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestGCP_AuthorizeRevoke(t *testing.T) { + p1, err := generateGCP() + assert.FatalError(t, err) + + t1, err := generateGCPToken(p1.ServiceAccounts[0], + "https://accounts.google.com", p1.GetID(), + "instance-id", "instance-name", "project-id", "zone", + time.Now(), &p1.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) + + type args struct { + token string + } + tests := []struct { + name string + gcp *GCP + args args + wantErr bool + }{ + {"ok", p1, args{t1}, false}, + {"fail", p1, args{"token"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.gcp.AuthorizeRevoke(tt.args.token); (err != nil) != tt.wantErr { + t.Errorf("GCP.AuthorizeRevoke() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/authority/provisioner/oidc_test.go b/authority/provisioner/oidc_test.go index 89965cbd..80920f41 100644 --- a/authority/provisioner/oidc_test.go +++ b/authority/provisioner/oidc_test.go @@ -278,7 +278,6 @@ func TestOIDC_AuthorizeSign(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := tt.prov.AuthorizeSign(tt.args.token) if (err != nil) != tt.wantErr { - fmt.Println(tt) t.Errorf("OIDC.Authorize() error = %v, wantErr %v", err, tt.wantErr) return } @@ -386,47 +385,6 @@ func TestOIDC_AuthorizeRenewal(t *testing.T) { } } -/* -func TestOIDC_AuthorizeRevoke(t *testing.T) { - srv := generateJWKServer(2) - defer srv.Close() - - var keys jose.JSONWebKeySet - assert.FatalError(t, getAndDecode(srv.URL+"/private", &keys)) - - // Create test provisioners - p1, err := generateOIDC() - assert.FatalError(t, err) - - // Update configuration endpoints and initialize - config := Config{Claims: globalProvisionerClaims} - p1.ConfigurationEndpoint = srv.URL + "/.well-known/openid-configuration" - assert.FatalError(t, p1.Init(config)) - - t1, err := generateSimpleToken("the-issuer", p1.ClientID, &keys.Keys[0]) - assert.FatalError(t, err) - - type args struct { - token string - } - tests := []struct { - name string - prov *OIDC - args args - wantErr bool - }{ - {"disabled", p1, args{t1}, true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if err := tt.prov.AuthorizeRevoke(tt.args.token); (err != nil) != tt.wantErr { - t.Errorf("OIDC.AuthorizeRevoke() error = %v, wantErr %v", err, tt.wantErr) - } - }) - } -} -*/ - func Test_sanitizeEmail(t *testing.T) { tests := []struct { name string diff --git a/authority/provisioner/utils_test.go b/authority/provisioner/utils_test.go index ab68f163..c7022f3c 100644 --- a/authority/provisioner/utils_test.go +++ b/authority/provisioner/utils_test.go @@ -163,6 +163,36 @@ func generateOIDC() (*OIDC, error) { }, nil } +func generateGCP() (*GCP, error) { + name, err := randutil.Alphanumeric(10) + if err != nil { + return nil, err + } + serviceAccount, err := randutil.Alphanumeric(10) + if err != nil { + return nil, err + } + jwk, err := generateJSONWebKey() + if err != nil { + return nil, err + } + claimer, err := NewClaimer(nil, globalProvisionerClaims) + if err != nil { + return nil, err + } + return &GCP{ + Type: "GCP", + Name: name, + ServiceAccounts: []string{serviceAccount}, + Claims: &globalProvisionerClaims, + claimer: claimer, + keyStore: &keyStore{ + keySet: jose.JSONWebKeySet{Keys: []jose.JSONWebKey{*jwk}}, + expiry: time.Now().Add(24 * time.Hour), + }, + }, nil +} + func generateCollection(nJWK, nOIDC int) (*Collection, error) { col := NewCollection(testAudiences) for i := 0; i < nJWK; i++ { @@ -220,6 +250,41 @@ func generateToken(sub, iss, aud string, email string, sans []string, iat time.T return jose.Signed(sig).Claims(claims).CompactSerialize() } +func generateGCPToken(sub, iss, aud, instanceID, instanceName, projectID, zone string, iat time.Time, jwk *jose.JSONWebKey) (string, error) { + sig, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.ES256, Key: jwk.Key}, + new(jose.SignerOptions).WithType("JWT").WithHeader("kid", jwk.KeyID), + ) + if err != nil { + return "", err + } + + claims := gcpPayload{ + Claims: jose.Claims{ + Subject: sub, + Issuer: iss, + IssuedAt: jose.NewNumericDate(iat), + NotBefore: jose.NewNumericDate(iat), + Expiry: jose.NewNumericDate(iat.Add(5 * time.Minute)), + Audience: []string{aud}, + }, + AuthorizedParty: sub, + Email: "foo@developer.gserviceaccount.com", + EmailVerified: true, + Google: gcpGooglePayload{ + ComputeEngine: gcpComputeEnginePayload{ + InstanceID: instanceID, + InstanceName: instanceName, + InstanceCreationTimestamp: jose.NewNumericDate(iat.Add(-24 * time.Hour)), + ProjectID: projectID, + ProjectNumber: 1234567890, + Zone: zone, + }, + }, + } + return jose.Signed(sig).Claims(claims).CompactSerialize() +} + func parseToken(token string) (*jose.JSONWebToken, *jose.Claims, error) { tok, err := jose.ParseSigned(token) if err != nil { From 7727fa5665eb64b7a4738bd010dd6b5c2490980d Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Fri, 19 Apr 2019 10:44:11 -0700 Subject: [PATCH 04/41] Update GCP tests. --- authority/provisioner/gcp.go | 10 +++++----- authority/provisioner/gcp_test.go | 13 +++++++------ 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/authority/provisioner/gcp.go b/authority/provisioner/gcp.go index dd0fc185..80b3c4c8 100644 --- a/authority/provisioner/gcp.go +++ b/authority/provisioner/gcp.go @@ -16,8 +16,8 @@ import ( "github.com/smallstep/cli/jose" ) -// googleOauth2Certs is the url that servers Google OAuth2 public keys. -var googleOauth2Certs = "https://www.googleapis.com/oauth2/v3/certs" +// gcpCertsURL is the url that servers Google OAuth2 public keys. +var gcpCertsURL = "https://www.googleapis.com/oauth2/v3/certs" // gcpIdentityURL is the base url for the identity document in GCP. var gcpIdentityURL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity" @@ -122,10 +122,10 @@ func (p *GCP) GetIdentityToken() (string, error) { defer resp.Body.Close() b, err := ioutil.ReadAll(resp.Body) if err != nil { - return "", errors.Wrap(err, "error reading identity request response") + return "", errors.Wrap(err, "error on identity request") } if resp.StatusCode >= 400 { - return "", errors.Errorf("error on identity response: status=%d, response=%s", resp.StatusCode, b) + return "", errors.Errorf("error on identity request: status=%d, response=%s", resp.StatusCode, b) } return string(bytes.TrimSpace(b)), nil } @@ -144,7 +144,7 @@ func (p *GCP) Init(config Config) error { return err } // Initialize key store - p.keyStore, err = newKeyStore(googleOauth2Certs) + p.keyStore, err = newKeyStore(gcpCertsURL) if err != nil { return err } diff --git a/authority/provisioner/gcp_test.go b/authority/provisioner/gcp_test.go index aa4748a1..ab6d6d44 100644 --- a/authority/provisioner/gcp_test.go +++ b/authority/provisioner/gcp_test.go @@ -16,7 +16,7 @@ import ( ) func resetGoogleVars() { - googleOauth2Certs = "https://www.googleapis.com/oauth2/v3/certs" + gcpCertsURL = "https://www.googleapis.com/oauth2/v3/certs" gcpIdentityURL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity" } @@ -100,7 +100,6 @@ func TestGCP_GetIdentityToken(t *testing.T) { assert.FatalError(t, err) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - fmt.Println(r.RequestURI) switch r.URL.Path { case "/bad-request": http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) @@ -118,13 +117,15 @@ func TestGCP_GetIdentityToken(t *testing.T) { wantErr bool }{ {"ok", p1, srv.URL, t1, false}, - {"bad request", p1, srv.URL + "/bad-request", "", true}, - {"bad url", p1, "badurl", "", true}, + {"fail request", p1, srv.URL + "/bad-request", "", true}, + {"fail url", p1, "://ca.smallstep.com", "", true}, + {"fail connect", p1, "foobarzar", "", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { gcpIdentityURL = tt.identityURL got, err := tt.gcp.GetIdentityToken() + t.Log(err) if (err != nil) != tt.wantErr { t.Errorf("GCP.GetIdentityToken() error = %v, wantErr %v", err, tt.wantErr) return @@ -169,11 +170,11 @@ func TestGCP_Init(t *testing.T) { {"bad type", fields{"", "name", nil, nil}, args{config, srv.URL}, true}, {"bad name", fields{"GCP", "", nil, nil}, args{config, srv.URL}, true}, {"bad claims", fields{"GCP", "name", nil, badClaims}, args{config, srv.URL}, true}, - {"bad certs", fields{"GCP", "name", nil, badClaims}, args{config, srv.URL + "/error"}, true}, + {"bad certs", fields{"GCP", "name", nil, nil}, args{config, srv.URL + "/error"}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - googleOauth2Certs = tt.args.certsURL + gcpCertsURL = tt.args.certsURL p := &GCP{ Type: tt.fields.Type, Name: tt.fields.Name, From 7e53b28320cb8ff81de7c079044e271dd8591aae Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Tue, 23 Apr 2019 14:20:14 -0700 Subject: [PATCH 05/41] Disable revoke for GCP. --- authority/provisioner/gcp.go | 9 ++++----- authority/provisioner/gcp_test.go | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/authority/provisioner/gcp.go b/authority/provisioner/gcp.go index 80b3c4c8..e19ee35f 100644 --- a/authority/provisioner/gcp.go +++ b/authority/provisioner/gcp.go @@ -63,7 +63,7 @@ func (p *GCP) GetID() string { } // GetTokenID returns the identifier of the token. For GCP this is the sha256 of -// "instance_id.iat.exp". +// "provisioner_id.instance_id.iat.exp". func (p *GCP) GetTokenID(token string) (string, error) { jwt, err := jose.ParseSigned(token) if err != nil { @@ -130,7 +130,7 @@ func (p *GCP) GetIdentityToken() (string, error) { return string(bytes.TrimSpace(b)), nil } -// Init validates and initializes the GCP provider. +// Init validates and initializes the GCP provisioner. func (p *GCP) Init(config Config) error { var err error switch { @@ -169,7 +169,7 @@ func (p *GCP) AuthorizeSign(token string) ([]SignOption, error) { commonNameValidator(ce.InstanceName), dnsNamesValidator(dnsNames), profileDefaultDuration(p.claimer.DefaultTLSCertDuration()), - newProvisionerExtensionOption(TypeGCP, p.Name, claims.AuthorizedParty), + newProvisionerExtensionOption(TypeGCP, p.Name, claims.Subject), newValidityValidator(p.claimer.MinTLSCertDuration(), p.claimer.MaxTLSCertDuration()), }, nil } @@ -185,8 +185,7 @@ func (p *GCP) AuthorizeRenewal(cert *x509.Certificate) error { // AuthorizeRevoke returns an error if the provisioner does not have rights to // revoke a certificate. func (p *GCP) AuthorizeRevoke(token string) error { - _, err := p.authorizeToken(token) - return err + return errors.New("revoke is not supported on a GCP provisioner") } // authorizeToken performs common jwt authorization actions and returns the diff --git a/authority/provisioner/gcp_test.go b/authority/provisioner/gcp_test.go index ab6d6d44..34397b3a 100644 --- a/authority/provisioner/gcp_test.go +++ b/authority/provisioner/gcp_test.go @@ -340,7 +340,7 @@ func TestGCP_AuthorizeRevoke(t *testing.T) { args args wantErr bool }{ - {"ok", p1, args{t1}, false}, + {"ok", p1, args{t1}, true}, // revoke is disabled {"fail", p1, args{"token"}, true}, } for _, tt := range tests { From fb6321fb2cff139087dd3cd4e5e3df858a9d090d Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Tue, 23 Apr 2019 14:33:36 -0700 Subject: [PATCH 06/41] Use gcpConfig type to keep configuration urls. Fixes #67 --- authority/provisioner/gcp.go | 33 +++++++++++++++++++++++++++---- authority/provisioner/gcp_test.go | 14 +++++-------- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/authority/provisioner/gcp.go b/authority/provisioner/gcp.go index e19ee35f..794aff5d 100644 --- a/authority/provisioner/gcp.go +++ b/authority/provisioner/gcp.go @@ -17,10 +17,10 @@ import ( ) // gcpCertsURL is the url that servers Google OAuth2 public keys. -var gcpCertsURL = "https://www.googleapis.com/oauth2/v3/certs" +const gcpCertsURL = "https://www.googleapis.com/oauth2/v3/certs" // gcpIdentityURL is the base url for the identity document in GCP. -var gcpIdentityURL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity" +const gcpIdentityURL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity" // gcpPayload extends jwt.Claims with custom GCP attributes. type gcpPayload struct { @@ -45,6 +45,18 @@ type gcpComputeEnginePayload struct { LicenseID []string `json:"license_id"` } +type gcpConfig struct { + CertsURL string + IdentityURL string +} + +func newGCPConfig() *gcpConfig { + return &gcpConfig{ + CertsURL: gcpCertsURL, + IdentityURL: gcpIdentityURL, + } +} + // GCP is the provisioner that supports identity tokens created by the Google // Cloud Platform metadata API. type GCP struct { @@ -53,6 +65,7 @@ type GCP struct { ServiceAccounts []string `json:"serviceAccounts"` Claims *Claims `json:"claims,omitempty"` claimer *Claimer + config *gcpConfig keyStore *keyStore } @@ -101,11 +114,14 @@ func (p *GCP) GetEncryptedKey() (kid string, key string, ok bool) { // GetIdentityURL returns the url that generates the GCP token. func (p *GCP) GetIdentityURL() string { + // Initialize config if required + p.assertConfig() + q := url.Values{} q.Add("audience", p.GetID()) q.Add("format", "full") q.Add("licenses", "FALSE") - return fmt.Sprintf("%s?%s", gcpIdentityURL, q.Encode()) + return fmt.Sprintf("%s?%s", p.config.IdentityURL, q.Encode()) } // GetIdentityToken does an HTTP request to the identity url. @@ -139,12 +155,14 @@ func (p *GCP) Init(config Config) error { case p.Name == "": return errors.New("provisioner name cannot be empty") } + // Initialize config + p.assertConfig() // Update claims with global ones if p.claimer, err = NewClaimer(p.Claims, config.Claims); err != nil { return err } // Initialize key store - p.keyStore, err = newKeyStore(gcpCertsURL) + p.keyStore, err = newKeyStore(p.config.CertsURL) if err != nil { return err } @@ -188,6 +206,13 @@ func (p *GCP) AuthorizeRevoke(token string) error { return errors.New("revoke is not supported on a GCP provisioner") } +// assertConfig initializes the config if it has not been initialized. +func (p *GCP) assertConfig() { + if p.config == nil { + p.config = newGCPConfig() + } +} + // 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. diff --git a/authority/provisioner/gcp_test.go b/authority/provisioner/gcp_test.go index 34397b3a..b4fed04a 100644 --- a/authority/provisioner/gcp_test.go +++ b/authority/provisioner/gcp_test.go @@ -15,11 +15,6 @@ import ( "github.com/smallstep/assert" ) -func resetGoogleVars() { - gcpCertsURL = "https://www.googleapis.com/oauth2/v3/certs" - gcpIdentityURL = "http://metadata/computeMetadata/v1/instance/service-accounts/default/identity" -} - func TestGCP_Getters(t *testing.T) { p, err := generateGCP() assert.FatalError(t, err) @@ -91,7 +86,6 @@ func TestGCP_GetTokenID(t *testing.T) { func TestGCP_GetIdentityToken(t *testing.T) { p1, err := generateGCP() assert.FatalError(t, err) - defer resetGoogleVars() t1, err := generateGCPToken(p1.ServiceAccounts[0], "https://accounts.google.com", p1.GetID(), @@ -123,7 +117,7 @@ func TestGCP_GetIdentityToken(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gcpIdentityURL = tt.identityURL + tt.gcp.config.IdentityURL = tt.identityURL got, err := tt.gcp.GetIdentityToken() t.Log(err) if (err != nil) != tt.wantErr { @@ -140,7 +134,6 @@ func TestGCP_GetIdentityToken(t *testing.T) { func TestGCP_Init(t *testing.T) { srv := generateJWKServer(2) defer srv.Close() - defer resetGoogleVars() config := Config{ Claims: globalProvisionerClaims, @@ -174,12 +167,15 @@ func TestGCP_Init(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gcpCertsURL = tt.args.certsURL p := &GCP{ Type: tt.fields.Type, Name: tt.fields.Name, ServiceAccounts: tt.fields.ServiceAccounts, Claims: tt.fields.Claims, + config: &gcpConfig{ + CertsURL: tt.args.certsURL, + IdentityURL: gcpIdentityURL, + }, } if err := p.Init(tt.args.config); (err != nil) != tt.wantErr { t.Errorf("GCP.Init() error = %v, wantErr %v", err, tt.wantErr) From 2c68915b7055ed387202a8ebcfc3a3d745b44fd2 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Tue, 23 Apr 2019 14:36:11 -0700 Subject: [PATCH 07/41] Fix comment. --- authority/provisioner/gcp.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/authority/provisioner/gcp.go b/authority/provisioner/gcp.go index 794aff5d..21752748 100644 --- a/authority/provisioner/gcp.go +++ b/authority/provisioner/gcp.go @@ -200,8 +200,8 @@ func (p *GCP) AuthorizeRenewal(cert *x509.Certificate) error { return nil } -// AuthorizeRevoke returns an error if the provisioner does not have rights to -// revoke a certificate. +// AuthorizeRevoke returns an error because revoke is not supported on GCP +// provisioners. func (p *GCP) AuthorizeRevoke(token string) error { return errors.New("revoke is not supported on a GCP provisioner") } From 27c98806c034ff253985309693b4d5f7ac0cc6d8 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 24 Apr 2019 11:29:57 -0700 Subject: [PATCH 08/41] Use GetTokenID. --- authority/authorize.go | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/authority/authorize.go b/authority/authorize.go index 62152d09..a53c2d8d 100644 --- a/authority/authorize.go +++ b/authority/authorize.go @@ -64,14 +64,7 @@ func (a *Authority) authorizeToken(ott string) (provisioner.Interface, error) { } // Store the token to protect against reuse. - var reuseKey string - switch p.GetType() { - case provisioner.TypeJWK: - reuseKey = claims.ID - case provisioner.TypeOIDC: - reuseKey = claims.Nonce - } - if reuseKey != "" { + if reuseKey, err := p.GetTokenID(ott); err == nil { if _, ok := a.ottMap.LoadOrStore(reuseKey, &idUsed{ UsedAt: time.Now().Unix(), Subject: claims.Subject, From 5defd8289d346a8b8890488af5ca8d5556fae002 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 24 Apr 2019 11:30:37 -0700 Subject: [PATCH 09/41] Add missing config in tests. --- authority/provisioner/utils_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/authority/provisioner/utils_test.go b/authority/provisioner/utils_test.go index c7022f3c..5c078479 100644 --- a/authority/provisioner/utils_test.go +++ b/authority/provisioner/utils_test.go @@ -186,6 +186,7 @@ func generateGCP() (*GCP, error) { ServiceAccounts: []string{serviceAccount}, Claims: &globalProvisionerClaims, claimer: claimer, + config: newGCPConfig(), keyStore: &keyStore{ keySet: jose.JSONWebKeySet{Keys: []jose.JSONWebKey{*jwk}}, expiry: time.Now().Add(24 * time.Hour), From 75ef5a22752dd6a375c1ee5508c5475b9230ee9a Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 24 Apr 2019 12:12:36 -0700 Subject: [PATCH 10/41] 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) } From 37e84aa535ef91892fd23af46be0d3897a6fadf3 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 24 Apr 2019 13:05:46 -0700 Subject: [PATCH 11/41] Add DisableCustomSANs and DisableTrustOnFirstUse to GCP provisioner. Fixes #67 --- authority/provisioner/aws.go | 6 +- authority/provisioner/aws_test.go | 181 ++++++++++++++++++++++++++++++ authority/provisioner/gcp.go | 63 +++++++---- authority/provisioner/gcp_test.go | 61 ++++++---- 4 files changed, 266 insertions(+), 45 deletions(-) create mode 100644 authority/provisioner/aws_test.go diff --git a/authority/provisioner/aws.go b/authority/provisioner/aws.go index 1bf0f478..0692a638 100644 --- a/authority/provisioner/aws.go +++ b/authority/provisioner/aws.go @@ -118,10 +118,10 @@ type awsInstanceIdentityDocument struct { 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"` + Claims *Claims `json:"claims,omitempty"` claimer *Claimer config *awsConfig } @@ -192,7 +192,7 @@ func (p *AWS) GetIdentityToken() (string, error) { // 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) + unique := fmt.Sprintf("%s.%s", p.GetID(), idoc.InstanceID) sum := sha256.Sum256([]byte(unique)) // Create a JWT from the identity document @@ -256,7 +256,7 @@ func (p *AWS) AuthorizeSign(token string) ([]SignOption, error) { // 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. + // There's no way to trust them other than TOFU. var so []SignOption if p.DisableCustomSANs { so = append(so, dnsNamesValidator([]string{ diff --git a/authority/provisioner/aws_test.go b/authority/provisioner/aws_test.go new file mode 100644 index 00000000..1b998b7b --- /dev/null +++ b/authority/provisioner/aws_test.go @@ -0,0 +1,181 @@ +// +build ignore + +package provisioner + +import ( + "crypto/x509" + "encoding/base64" + "encoding/pem" + "net/http" + "net/http/httptest" + "testing" + + "github.com/fullsailor/pkcs7" + "github.com/smallstep/assert" +) + +var rsaCert = `-----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-----` + +var rsaSig = `eYko51V+DBTE/pLMwqH9tekcIGdIL6jGkgmh0faKQbHUrWVfaw2ffx032iqbEkvbqIMx0I4ewl+Cq5IejPQ5ax4+Nb9gSoMHS8VCjAUkpj9dUXPG2DEvTHukpvUTy8fGn1a/3LS5GdEPnDVkMj2QDHDBGskH4eA46x9c069xeyE=` + +var dsaCert = `-----BEGIN CERTIFICATE----- +MIIC7TCCAq0CCQCWukjZ5V4aZzAJBgcqhkjOOAQDMFwxCzAJBgNVBAYTAlVTMRkw +FwYDVQQIExBXYXNoaW5ndG9uIFN0YXRlMRAwDgYDVQQHEwdTZWF0dGxlMSAwHgYD +VQQKExdBbWF6b24gV2ViIFNlcnZpY2VzIExMQzAeFw0xMjAxMDUxMjU2MTJaFw0z +ODAxMDUxMjU2MTJaMFwxCzAJBgNVBAYTAlVTMRkwFwYDVQQIExBXYXNoaW5ndG9u +IFN0YXRlMRAwDgYDVQQHEwdTZWF0dGxlMSAwHgYDVQQKExdBbWF6b24gV2ViIFNl +cnZpY2VzIExMQzCCAbcwggEsBgcqhkjOOAQBMIIBHwKBgQCjkvcS2bb1VQ4yt/5e +ih5OO6kK/n1Lzllr7D8ZwtQP8fOEpp5E2ng+D6Ud1Z1gYipr58Kj3nssSNpI6bX3 +VyIQzK7wLclnd/YozqNNmgIyZecN7EglK9ITHJLP+x8FtUpt3QbyYXJdmVMegN6P +hviYt5JH/nYl4hh3Pa1HJdskgQIVALVJ3ER11+Ko4tP6nwvHwh6+ERYRAoGBAI1j +k+tkqMVHuAFcvAGKocTgsjJem6/5qomzJuKDmbJNu9Qxw3rAotXau8Qe+MBcJl/U +hhy1KHVpCGl9fueQ2s6IL0CaO/buycU1CiYQk40KNHCcHfNiZbdlx1E9rpUp7bnF +lRa2v1ntMX3caRVDdbtPEWmdxSCYsYFDk4mZrOLBA4GEAAKBgEbmeve5f8LIE/Gf +MNmP9CM5eovQOGx5ho8WqD+aTebs+k2tn92BBPqeZqpWRa5P/+jrdKml1qx4llHW +MXrs3IgIb6+hUIB+S8dz8/mmO0bpr76RoZVCXYab2CZedFut7qc3WUH9+EUAH5mw +vSeDCOUMYQR7R9LINYwouHIziqQYMAkGByqGSM44BAMDLwAwLAIUWXBlk40xTwSw +7HX32MxXYruse9ACFBNGmdX2ZBrVNGrN9N2f6ROk0k9K +-----END CERTIFICATE-----` + +var dsaSig = `MIAGCSqGSIb3DQEHAqCAMIACAQExCzAJBgUrDgMCGgUAMIAGCSqGSIb3DQEHAaCAJIAEggHTewog +ICJwcml2YXRlSXAiIDogIjE3Mi4zMS4yMy40NyIsCiAgImRldnBheVByb2R1Y3RDb2RlcyIgOiBu +dWxsLAogICJtYXJrZXRwbGFjZVByb2R1Y3RDb2RlcyIgOiBudWxsLAogICJ2ZXJzaW9uIiA6ICIy +MDE3LTA5LTMwIiwKICAiaW5zdGFuY2VJZCIgOiAiaS0wMmUzYmVjMWY2MDBmNWUzMyIsCiAgImJp +bGxpbmdQcm9kdWN0cyIgOiBudWxsLAogICJpbnN0YW5jZVR5cGUiIDogInQyLm1pY3JvIiwKICAi +YXZhaWxhYmlsaXR5Wm9uZSIgOiAidXMtd2VzdC0xYiIsCiAgImtlcm5lbElkIiA6IG51bGwsCiAg +InJhbWRpc2tJZCIgOiBudWxsLAogICJhY2NvdW50SWQiIDogIjgwNzQ5MjQ3MzI2MyIsCiAgImFy +Y2hpdGVjdHVyZSIgOiAieDg2XzY0IiwKICAiaW1hZ2VJZCIgOiAiYW1pLTFjMWQyMTdjIiwKICAi +cGVuZGluZ1RpbWUiIDogIjIwMTctMTEtMjFUMDA6MjU6MjNaIiwKICAicmVnaW9uIiA6ICJ1cy13 +ZXN0LTEiCn0AAAAAAAAxggEYMIIBFAIBATBpMFwxCzAJBgNVBAYTAlVTMRkwFwYDVQQIExBXYXNo +aW5ndG9uIFN0YXRlMRAwDgYDVQQHEwdTZWF0dGxlMSAwHgYDVQQKExdBbWF6b24gV2ViIFNlcnZp +Y2VzIExMQwIJAJa6SNnlXhpnMAkGBSsOAwIaBQCgXTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcB +MBwGCSqGSIb3DQEJBTEPFw0xODA3MzAyMzMxMDRaMCMGCSqGSIb3DQEJBDEWBBQUze548OLd+uOT +aOSTDLlV9mevbTAJBgcqhkjOOAQDBC8wLQIUDGeP44Ge1atMQghe+ENV4IDM0zQCFQCBTOEvfKu+ +uscwutj+7RCNgSVaWgAAAAAAAA==` + +var doc = `{ + "privateIp" : "172.31.23.47", + "devpayProductCodes" : null, + "marketplaceProductCodes" : null, + "version" : "2017-09-30", + "instanceId" : "i-02e3bec1f600f5e33", + "billingProducts" : null, + "instanceType" : "t2.micro", + "availabilityZone" : "us-west-1b", + "kernelId" : null, + "ramdiskId" : null, + "accountId" : "807492473263", + "architecture" : "x86_64", + "imageId" : "ami-1c1d217c", + "pendingTime" : "2017-11-21T00:25:23Z", + "region" : "us-west-1" +}` + +func TestAWSRSA(t *testing.T) { + block, _ := pem.Decode([]byte(rsaCert)) + + cert, err := x509.ParseCertificate(block.Bytes) + assert.FatalError(t, err) + + signature, err := base64.StdEncoding.DecodeString(rsaSig) + assert.FatalError(t, err) + + err = cert.CheckSignature(x509.SHA256WithRSA, []byte(doc), signature) + assert.FatalError(t, err) +} + +func TestAWSDSA(t *testing.T) { + block, _ := pem.Decode([]byte(dsaCert)) + + cert, err := x509.ParseCertificate(block.Bytes) + assert.FatalError(t, err) + + signature, err := base64.StdEncoding.DecodeString(dsaSig) + assert.FatalError(t, err) + + p7, err := pkcs7.Parse(signature) + assert.FatalError(t, err) + + p7.Certificates = append(p7.Certificates, cert) + + assert.FatalError(t, p7.Verify()) +} + +func TestAWS_GetIdentityToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/document": + w.Write([]byte(doc)) + case "/signature": + w.Write([]byte(rsaSig)) + default: + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + } + })) + defer srv.Close() + + config, err := newAWSConfig() + assert.FatalError(t, err) + config.identityURL = srv.URL + "/document" + config.signatureURL = srv.URL + "/signature" + + type fields struct { + Type string + Name string + Claims *Claims + claimer *Claimer + config *awsConfig + } + tests := []struct { + name string + fields fields + want string + wantErr bool + }{ + {"ok", fields{"AWS", "name", nil, nil, config}, "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &AWS{ + Type: tt.fields.Type, + Name: tt.fields.Name, + Claims: tt.fields.Claims, + claimer: tt.fields.claimer, + config: tt.fields.config, + } + got, err := p.GetIdentityToken() + if (err != nil) != tt.wantErr { + t.Errorf("AWS.GetIdentityToken() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("AWS.GetIdentityToken() = %v, want %v", got, tt.want) + } + t.Error(got) + // parts := strings.Split(got, ".") + // signed, err := base64.RawURLEncoding.DecodeString(parts[0]) + // assert.FatalError(t, err) + // signature, err := base64.RawURLEncoding.DecodeString(parts[1]) + // assert.FatalError(t, err) + // assert.FatalError(t, err, config.certificate.CheckSignature(config.signatureAlgorithm, signed, signature)) + }) + } +} diff --git a/authority/provisioner/gcp.go b/authority/provisioner/gcp.go index 21752748..1d67c567 100644 --- a/authority/provisioner/gcp.go +++ b/authority/provisioner/gcp.go @@ -59,14 +59,23 @@ func newGCPConfig() *gcpConfig { // GCP is the provisioner that supports identity tokens created by the Google // Cloud Platform metadata API. +// +// 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 GCP struct { - Type string `json:"type"` - Name string `json:"name"` - ServiceAccounts []string `json:"serviceAccounts"` - Claims *Claims `json:"claims,omitempty"` - claimer *Claimer - config *gcpConfig - keyStore *keyStore + Type string `json:"type"` + Name string `json:"name"` + ServiceAccounts []string `json:"serviceAccounts"` + DisableCustomSANs bool `json:"disableCustomSANs"` + DisableTrustOnFirstUse bool `json:"disableTrustOnFirstUse"` + Claims *Claims `json:"claims,omitempty"` + claimer *Claimer + config *gcpConfig + keyStore *keyStore } // GetID returns the provisioner unique identifier. The name should uniquely @@ -75,24 +84,31 @@ func (p *GCP) GetID() string { return "gcp:" + p.Name } -// GetTokenID returns the identifier of the token. For GCP this is the sha256 of -// "provisioner_id.instance_id.iat.exp". +// GetTokenID returns the identifier of the token. The default value for GCP the +// SHA256 of "provisioner_id.instance_id", but if DisableTrustOnFirstUse is set +// to true, then it will be the SHA256 of the token. func (p *GCP) GetTokenID(token string) (string, error) { jwt, err := jose.ParseSigned(token) if err != nil { return "", errors.Wrap(err, "error parsing token") } + // If TOFU is disabled create an ID for the token, so it cannot be reused. + if p.DisableTrustOnFirstUse { + sum := sha256.Sum256([]byte(token)) + return strings.ToLower(hex.EncodeToString(sum[:])), nil + } + // Get claims w/out verification. var claims gcpPayload if err = jwt.UnsafeClaimsWithoutVerification(&claims); err != nil { return "", errors.Wrap(err, "error verifying claims") } - // This string should be mostly unique - unique := fmt.Sprintf("%s.%s.%d.%d", - p.GetID(), claims.Google.ComputeEngine.InstanceID, - *claims.IssuedAt, *claims.Expiry, - ) + + // 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(), claims.Google.ComputeEngine.InstanceID) sum := sha256.Sum256([]byte(unique)) return strings.ToLower(hex.EncodeToString(sum[:])), nil } @@ -176,20 +192,25 @@ func (p *GCP) AuthorizeSign(token string) ([]SignOption, error) { if err != nil { return nil, err } - ce := claims.Google.ComputeEngine - dnsNames := []string{ - fmt.Sprintf("%s.c.%s.internal", ce.InstanceName, ce.ProjectID), - fmt.Sprintf("%s.%s.c.%s.internal", ce.InstanceName, ce.Zone, ce.ProjectID), + + // 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 { + so = append(so, dnsNamesValidator([]string{ + fmt.Sprintf("%s.c.%s.internal", ce.InstanceName, ce.ProjectID), + fmt.Sprintf("%s.%s.c.%s.internal", ce.InstanceName, ce.Zone, ce.ProjectID), + })) } - return []SignOption{ + return append(so, commonNameValidator(ce.InstanceName), - dnsNamesValidator(dnsNames), profileDefaultDuration(p.claimer.DefaultTLSCertDuration()), newProvisionerExtensionOption(TypeGCP, p.Name, claims.Subject), newValidityValidator(p.claimer.MinTLSCertDuration(), p.claimer.MaxTLSCertDuration()), - }, nil + ), nil } // AuthorizeRenewal returns an error if the renewal is disabled. diff --git a/authority/provisioner/gcp_test.go b/authority/provisioner/gcp_test.go index b4fed04a..98b45907 100644 --- a/authority/provisioner/gcp_test.go +++ b/authority/provisioner/gcp_test.go @@ -44,16 +44,26 @@ func TestGCP_GetTokenID(t *testing.T) { assert.FatalError(t, err) p1.Name = "name" + p2, err := generateGCP() + assert.FatalError(t, err) + p2.DisableTrustOnFirstUse = true + now := time.Now() t1, err := generateGCPToken(p1.ServiceAccounts[0], "https://accounts.google.com", "gcp:name", "instance-id", "instance-name", "project-id", "zone", now, &p1.keyStore.keySet.Keys[0]) assert.FatalError(t, err) + t2, err := generateGCPToken(p2.ServiceAccounts[0], + "https://accounts.google.com", p2.GetID(), + "instance-id", "instance-name", "project-id", "zone", + now, &p2.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) - unique := fmt.Sprintf("gcp:name.instance-id.%d.%d", now.Unix(), now.Add(5*time.Minute).Unix()) - sum := sha256.Sum256([]byte(unique)) - want := strings.ToLower(hex.EncodeToString(sum[:])) + sum := sha256.Sum256([]byte("gcp:name.instance-id")) + want1 := strings.ToLower(hex.EncodeToString(sum[:])) + sum = sha256.Sum256([]byte(t2)) + want2 := strings.ToLower(hex.EncodeToString(sum[:])) type args struct { token string @@ -65,7 +75,8 @@ func TestGCP_GetTokenID(t *testing.T) { want string wantErr bool }{ - {"ok", p1, args{t1}, want, false}, + {"ok", p1, args{t1}, want1, false}, + {"ok", p2, args{t2}, want2, false}, {"fail token", p1, args{"token"}, "", true}, {"fail claims", p1, args{"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ey.fooo"}, "", true}, } @@ -188,6 +199,10 @@ func TestGCP_AuthorizeSign(t *testing.T) { p1, err := generateGCP() assert.FatalError(t, err) + p2, err := generateGCP() + assert.FatalError(t, err) + p2.DisableCustomSANs = true + aKey, err := generateJSONWebKey() assert.FatalError(t, err) @@ -196,6 +211,12 @@ func TestGCP_AuthorizeSign(t *testing.T) { "instance-id", "instance-name", "project-id", "zone", time.Now(), &p1.keyStore.keySet.Keys[0]) assert.FatalError(t, err) + t2, err := generateGCPToken(p2.ServiceAccounts[0], + "https://accounts.google.com", p2.GetID(), + "instance-id", "instance-name", "project-id", "zone", + time.Now(), &p2.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) + failKey, err := generateGCPToken(p1.ServiceAccounts[0], "https://accounts.google.com", p1.GetID(), "instance-id", "instance-name", "project-id", "zone", @@ -253,20 +274,22 @@ func TestGCP_AuthorizeSign(t *testing.T) { name string gcp *GCP args args + wantLen int wantErr bool }{ - {"ok", p1, args{t1}, false}, - {"fail token", p1, args{"token"}, true}, - {"fail key", p1, args{failKey}, true}, - {"fail iss", p1, args{failIss}, true}, - {"fail aud", p1, args{failAud}, true}, - {"fail exp", p1, args{failExp}, true}, - {"fail nbf", p1, args{failNbf}, true}, - {"fail service account", p1, args{failServiceAccount}, true}, - {"fail instance id", p1, args{failInstanceID}, true}, - {"fail instance name", p1, args{failInstanceName}, true}, - {"fail project id", p1, args{failProjectID}, true}, - {"fail zone", p1, args{failZone}, true}, + {"ok", p1, args{t1}, 4, false}, + {"ok", p2, args{t2}, 5, false}, + {"fail token", p1, args{"token"}, 0, true}, + {"fail key", p1, args{failKey}, 0, true}, + {"fail iss", p1, args{failIss}, 0, true}, + {"fail aud", p1, args{failAud}, 0, true}, + {"fail exp", p1, args{failExp}, 0, true}, + {"fail nbf", p1, args{failNbf}, 0, true}, + {"fail service account", p1, args{failServiceAccount}, 0, true}, + {"fail instance id", p1, args{failInstanceID}, 0, true}, + {"fail instance name", p1, args{failInstanceName}, 0, true}, + {"fail project id", p1, args{failProjectID}, 0, true}, + {"fail zone", p1, args{failZone}, 0, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -275,11 +298,7 @@ func TestGCP_AuthorizeSign(t *testing.T) { t.Errorf("GCP.AuthorizeSign() error = %v, wantErr %v", err, tt.wantErr) return } - if err != nil { - assert.Nil(t, got) - } else { - assert.Len(t, 5, got) - } + assert.Len(t, tt.wantLen, got) }) } } From da93e40f907989bdbdcdddb7d9b268a17276fce7 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 24 Apr 2019 14:26:37 -0700 Subject: [PATCH 12/41] Add constant for Azure type. --- authority/provisioner/provisioner.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/authority/provisioner/provisioner.go b/authority/provisioner/provisioner.go index eba15d13..2af87cc8 100644 --- a/authority/provisioner/provisioner.go +++ b/authority/provisioner/provisioner.go @@ -45,6 +45,8 @@ const ( TypeGCP Type = 3 // TypeAWS is used to indicate the AWS provisioners. TypeAWS Type = 4 + // TypeAzure is used to indicate the Azure provisioners. + TypeAzure Type = 4 // RevokeAudienceKey is the key for the 'revoke' audiences in the audiences map. RevokeAudienceKey = "revoke" From a7f06c765d362e4db996273a074f6c5280d4397a Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 24 Apr 2019 14:49:28 -0700 Subject: [PATCH 13/41] Fix load of gcp and aws provisioner by certificate. --- authority/provisioner/collection.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/authority/provisioner/collection.go b/authority/provisioner/collection.go index 67ad992d..d0e16234 100644 --- a/authority/provisioner/collection.go +++ b/authority/provisioner/collection.go @@ -93,8 +93,13 @@ func (c *Collection) LoadByCertificate(cert *x509.Certificate) (Interface, bool) if _, err := asn1.Unmarshal(e.Value, &provisioner); err != nil { return nil, false } - if provisioner.Type == int(TypeJWK) { + switch Type(provisioner.Type) { + case TypeJWK: return c.Load(string(provisioner.Name) + ":" + string(provisioner.CredentialID)) + case TypeAWS: + return c.Load("aws:" + string(provisioner.Name)) + case TypeGCP: + return c.Load("gcp:" + string(provisioner.Name)) } return c.Load(string(provisioner.CredentialID)) } From b6a5ebcfc969fb49e83641db34df94ee0252678a Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 24 Apr 2019 14:50:22 -0700 Subject: [PATCH 14/41] Move code to switch default. --- authority/provisioner/collection.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/authority/provisioner/collection.go b/authority/provisioner/collection.go index d0e16234..e34a2fcf 100644 --- a/authority/provisioner/collection.go +++ b/authority/provisioner/collection.go @@ -100,8 +100,9 @@ func (c *Collection) LoadByCertificate(cert *x509.Certificate) (Interface, bool) return c.Load("aws:" + string(provisioner.Name)) case TypeGCP: return c.Load("gcp:" + string(provisioner.Name)) + default: + return c.Load(string(provisioner.CredentialID)) } - return c.Load(string(provisioner.CredentialID)) } } From f755fddc351980308c295646a2a6988926ce9a14 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 24 Apr 2019 14:59:01 -0700 Subject: [PATCH 15/41] Fix lint errors. --- authority/provisioner/aws.go | 3 +++ authority/provisioner/gcp_test.go | 1 + 2 files changed, 4 insertions(+) diff --git a/authority/provisioner/aws.go b/authority/provisioner/aws.go index 0692a638..612a2498 100644 --- a/authority/provisioner/aws.go +++ b/authority/provisioner/aws.go @@ -200,6 +200,9 @@ func (p *AWS) GetIdentityToken() (string, error) { 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{ diff --git a/authority/provisioner/gcp_test.go b/authority/provisioner/gcp_test.go index 98b45907..4e63c2b1 100644 --- a/authority/provisioner/gcp_test.go +++ b/authority/provisioner/gcp_test.go @@ -226,6 +226,7 @@ func TestGCP_AuthorizeSign(t *testing.T) { "https://foo.bar.zar", p1.GetID(), "instance-id", "instance-name", "project-id", "zone", time.Now(), &p1.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) failAud, err := generateGCPToken(p1.ServiceAccounts[0], "https://accounts.google.com", "gcp:foo", "instance-id", "instance-name", "project-id", "zone", From 81bfd2c1cb0864591fdd5e8716a2c2628b5a8652 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 24 Apr 2019 19:52:58 -0700 Subject: [PATCH 16/41] Add tests for AWS provisioner Fixes #68 --- authority/provisioner/aws.go | 21 +- authority/provisioner/aws_test.go | 477 +++++++++++++++++++--------- authority/provisioner/utils_test.go | 199 ++++++++++++ 3 files changed, 542 insertions(+), 155 deletions(-) diff --git a/authority/provisioner/aws.go b/authority/provisioner/aws.go index 612a2498..4a017069 100644 --- a/authority/provisioner/aws.go +++ b/authority/provisioner/aws.go @@ -347,16 +347,6 @@ func (p *AWS) authorizeToken(token string) (*awsPayload, error) { 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 @@ -378,6 +368,17 @@ func (p *AWS) authorizeToken(token string) (*awsPayload, error) { return nil, errors.New("identity document region cannot be empty") } + // According to "rfc7519 JSON Web Token" acceptable skew should be no + // more than a few minutes. + if err = payload.ValidateWithLeeway(jose.Expected{ + Issuer: awsIssuer, + Subject: doc.InstanceID, + Audience: []string{p.GetID()}, + Time: time.Now().UTC(), + }, time.Minute); err != nil { + return nil, errors.Wrapf(err, "invalid token") + } + // validate accounts if len(p.Accounts) > 0 { var found bool diff --git a/authority/provisioner/aws_test.go b/authority/provisioner/aws_test.go index 1b998b7b..429e583e 100644 --- a/authority/provisioner/aws_test.go +++ b/authority/provisioner/aws_test.go @@ -1,181 +1,368 @@ -// +build ignore - package provisioner import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha256" "crypto/x509" - "encoding/base64" + "encoding/hex" "encoding/pem" - "net/http" - "net/http/httptest" + "fmt" + "strings" "testing" + "time" - "github.com/fullsailor/pkcs7" "github.com/smallstep/assert" + "github.com/smallstep/cli/jose" ) -var rsaCert = `-----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-----` - -var rsaSig = `eYko51V+DBTE/pLMwqH9tekcIGdIL6jGkgmh0faKQbHUrWVfaw2ffx032iqbEkvbqIMx0I4ewl+Cq5IejPQ5ax4+Nb9gSoMHS8VCjAUkpj9dUXPG2DEvTHukpvUTy8fGn1a/3LS5GdEPnDVkMj2QDHDBGskH4eA46x9c069xeyE=` - -var dsaCert = `-----BEGIN CERTIFICATE----- -MIIC7TCCAq0CCQCWukjZ5V4aZzAJBgcqhkjOOAQDMFwxCzAJBgNVBAYTAlVTMRkw -FwYDVQQIExBXYXNoaW5ndG9uIFN0YXRlMRAwDgYDVQQHEwdTZWF0dGxlMSAwHgYD -VQQKExdBbWF6b24gV2ViIFNlcnZpY2VzIExMQzAeFw0xMjAxMDUxMjU2MTJaFw0z -ODAxMDUxMjU2MTJaMFwxCzAJBgNVBAYTAlVTMRkwFwYDVQQIExBXYXNoaW5ndG9u -IFN0YXRlMRAwDgYDVQQHEwdTZWF0dGxlMSAwHgYDVQQKExdBbWF6b24gV2ViIFNl -cnZpY2VzIExMQzCCAbcwggEsBgcqhkjOOAQBMIIBHwKBgQCjkvcS2bb1VQ4yt/5e -ih5OO6kK/n1Lzllr7D8ZwtQP8fOEpp5E2ng+D6Ud1Z1gYipr58Kj3nssSNpI6bX3 -VyIQzK7wLclnd/YozqNNmgIyZecN7EglK9ITHJLP+x8FtUpt3QbyYXJdmVMegN6P -hviYt5JH/nYl4hh3Pa1HJdskgQIVALVJ3ER11+Ko4tP6nwvHwh6+ERYRAoGBAI1j -k+tkqMVHuAFcvAGKocTgsjJem6/5qomzJuKDmbJNu9Qxw3rAotXau8Qe+MBcJl/U -hhy1KHVpCGl9fueQ2s6IL0CaO/buycU1CiYQk40KNHCcHfNiZbdlx1E9rpUp7bnF -lRa2v1ntMX3caRVDdbtPEWmdxSCYsYFDk4mZrOLBA4GEAAKBgEbmeve5f8LIE/Gf -MNmP9CM5eovQOGx5ho8WqD+aTebs+k2tn92BBPqeZqpWRa5P/+jrdKml1qx4llHW -MXrs3IgIb6+hUIB+S8dz8/mmO0bpr76RoZVCXYab2CZedFut7qc3WUH9+EUAH5mw -vSeDCOUMYQR7R9LINYwouHIziqQYMAkGByqGSM44BAMDLwAwLAIUWXBlk40xTwSw -7HX32MxXYruse9ACFBNGmdX2ZBrVNGrN9N2f6ROk0k9K ------END CERTIFICATE-----` - -var dsaSig = `MIAGCSqGSIb3DQEHAqCAMIACAQExCzAJBgUrDgMCGgUAMIAGCSqGSIb3DQEHAaCAJIAEggHTewog -ICJwcml2YXRlSXAiIDogIjE3Mi4zMS4yMy40NyIsCiAgImRldnBheVByb2R1Y3RDb2RlcyIgOiBu -dWxsLAogICJtYXJrZXRwbGFjZVByb2R1Y3RDb2RlcyIgOiBudWxsLAogICJ2ZXJzaW9uIiA6ICIy -MDE3LTA5LTMwIiwKICAiaW5zdGFuY2VJZCIgOiAiaS0wMmUzYmVjMWY2MDBmNWUzMyIsCiAgImJp -bGxpbmdQcm9kdWN0cyIgOiBudWxsLAogICJpbnN0YW5jZVR5cGUiIDogInQyLm1pY3JvIiwKICAi -YXZhaWxhYmlsaXR5Wm9uZSIgOiAidXMtd2VzdC0xYiIsCiAgImtlcm5lbElkIiA6IG51bGwsCiAg -InJhbWRpc2tJZCIgOiBudWxsLAogICJhY2NvdW50SWQiIDogIjgwNzQ5MjQ3MzI2MyIsCiAgImFy -Y2hpdGVjdHVyZSIgOiAieDg2XzY0IiwKICAiaW1hZ2VJZCIgOiAiYW1pLTFjMWQyMTdjIiwKICAi -cGVuZGluZ1RpbWUiIDogIjIwMTctMTEtMjFUMDA6MjU6MjNaIiwKICAicmVnaW9uIiA6ICJ1cy13 -ZXN0LTEiCn0AAAAAAAAxggEYMIIBFAIBATBpMFwxCzAJBgNVBAYTAlVTMRkwFwYDVQQIExBXYXNo -aW5ndG9uIFN0YXRlMRAwDgYDVQQHEwdTZWF0dGxlMSAwHgYDVQQKExdBbWF6b24gV2ViIFNlcnZp -Y2VzIExMQwIJAJa6SNnlXhpnMAkGBSsOAwIaBQCgXTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcB -MBwGCSqGSIb3DQEJBTEPFw0xODA3MzAyMzMxMDRaMCMGCSqGSIb3DQEJBDEWBBQUze548OLd+uOT -aOSTDLlV9mevbTAJBgcqhkjOOAQDBC8wLQIUDGeP44Ge1atMQghe+ENV4IDM0zQCFQCBTOEvfKu+ -uscwutj+7RCNgSVaWgAAAAAAAA==` - -var doc = `{ - "privateIp" : "172.31.23.47", - "devpayProductCodes" : null, - "marketplaceProductCodes" : null, - "version" : "2017-09-30", - "instanceId" : "i-02e3bec1f600f5e33", - "billingProducts" : null, - "instanceType" : "t2.micro", - "availabilityZone" : "us-west-1b", - "kernelId" : null, - "ramdiskId" : null, - "accountId" : "807492473263", - "architecture" : "x86_64", - "imageId" : "ami-1c1d217c", - "pendingTime" : "2017-11-21T00:25:23Z", - "region" : "us-west-1" -}` - -func TestAWSRSA(t *testing.T) { - block, _ := pem.Decode([]byte(rsaCert)) - - cert, err := x509.ParseCertificate(block.Bytes) - assert.FatalError(t, err) - - signature, err := base64.StdEncoding.DecodeString(rsaSig) - assert.FatalError(t, err) - - err = cert.CheckSignature(x509.SHA256WithRSA, []byte(doc), signature) +func TestAWS_Getters(t *testing.T) { + p, err := generateAWS() assert.FatalError(t, err) + aud := "aws:" + p.Name + if got := p.GetID(); got != aud { + t.Errorf("AWS.GetID() = %v, want %v", got, aud) + } + if got := p.GetName(); got != p.Name { + t.Errorf("AWS.GetName() = %v, want %v", got, p.Name) + } + if got := p.GetType(); got != TypeAWS { + t.Errorf("AWS.GetType() = %v, want %v", got, TypeAWS) + } + kid, key, ok := p.GetEncryptedKey() + if kid != "" || key != "" || ok == true { + t.Errorf("AWS.GetEncryptedKey() = (%v, %v, %v), want (%v, %v, %v)", + kid, key, ok, "", "", false) + } } -func TestAWSDSA(t *testing.T) { - block, _ := pem.Decode([]byte(dsaCert)) - - cert, err := x509.ParseCertificate(block.Bytes) +func TestAWS_GetTokenID(t *testing.T) { + p1, srv, err := generateAWSWithServer() assert.FatalError(t, err) - - signature, err := base64.StdEncoding.DecodeString(dsaSig) - assert.FatalError(t, err) - - p7, err := pkcs7.Parse(signature) - assert.FatalError(t, err) - - p7.Certificates = append(p7.Certificates, cert) - - assert.FatalError(t, p7.Verify()) -} - -func TestAWS_GetIdentityToken(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/document": - w.Write([]byte(doc)) - case "/signature": - w.Write([]byte(rsaSig)) - default: - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - } - })) defer srv.Close() - config, err := newAWSConfig() + p2, err := generateAWS() assert.FatalError(t, err) - config.identityURL = srv.URL + "/document" - config.signatureURL = srv.URL + "/signature" + p2.Accounts = p1.Accounts + p2.config = p1.config + p2.DisableTrustOnFirstUse = true - type fields struct { - Type string - Name string - Claims *Claims - claimer *Claimer - config *awsConfig + t1, err := p1.GetIdentityToken() + assert.FatalError(t, err) + _, claims, err := parseAWSToken(t1) + assert.FatalError(t, err) + sum := sha256.Sum256([]byte(fmt.Sprintf("%s.%s", p1.GetID(), claims.document.InstanceID))) + w1 := strings.ToLower(hex.EncodeToString(sum[:])) + + t2, err := p2.GetIdentityToken() + assert.FatalError(t, err) + sum = sha256.Sum256([]byte(t2)) + w2 := strings.ToLower(hex.EncodeToString(sum[:])) + + type args struct { + token string } tests := []struct { name string - fields fields + aws *AWS + args args want string wantErr bool }{ - {"ok", fields{"AWS", "name", nil, nil, config}, "", false}, + {"ok", p1, args{t1}, w1, false}, + {"ok no TOFU", p2, args{t2}, w2, false}, + {"fail", p1, args{"bad-token"}, "", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - p := &AWS{ - Type: tt.fields.Type, - Name: tt.fields.Name, - Claims: tt.fields.Claims, - claimer: tt.fields.claimer, - config: tt.fields.config, + got, err := tt.aws.GetTokenID(tt.args.token) + if (err != nil) != tt.wantErr { + t.Errorf("AWS.GetTokenID() error = %v, wantErr %v", err, tt.wantErr) + return } - got, err := p.GetIdentityToken() + if got != tt.want { + t.Errorf("AWS.GetTokenID() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAWS_GetIdentityToken(t *testing.T) { + p1, srv, err := generateAWSWithServer() + assert.FatalError(t, err) + defer srv.Close() + + p2, err := generateAWS() + assert.FatalError(t, err) + p2.Accounts = p1.Accounts + p2.config.identityURL = srv.URL + "/bad-document" + p2.config.signatureURL = p1.config.signatureURL + + p3, err := generateAWS() + assert.FatalError(t, err) + p3.Accounts = p1.Accounts + p3.config.signatureURL = srv.URL + p3.config.identityURL = p1.config.identityURL + + p4, err := generateAWS() + assert.FatalError(t, err) + p4.Accounts = p1.Accounts + p4.config.signatureURL = srv.URL + "/bad-signature" + p4.config.identityURL = p1.config.identityURL + + tests := []struct { + name string + aws *AWS + wantErr bool + }{ + {"ok", p1, false}, + {"fail identityURL", p2, true}, + {"fail signatureURL", p3, true}, + {"fail signature", p4, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.aws.GetIdentityToken() if (err != nil) != tt.wantErr { t.Errorf("AWS.GetIdentityToken() error = %v, wantErr %v", err, tt.wantErr) return } - if got != tt.want { - t.Errorf("AWS.GetIdentityToken() = %v, want %v", got, tt.want) + if tt.wantErr == false { + _, c, err := parseAWSToken(got) + if assert.NoError(t, err) { + assert.Equals(t, awsIssuer, c.Issuer) + assert.Equals(t, c.document.InstanceID, c.Subject) + assert.Equals(t, jose.Audience{tt.aws.GetID()}, c.Audience) + assert.Equals(t, tt.aws.Accounts[0], c.document.AccountID) + err = tt.aws.config.certificate.CheckSignature( + tt.aws.config.signatureAlgorithm, c.Amazon.Document, c.Amazon.Signature) + assert.NoError(t, err) + } + } + }) + } +} + +func TestAWS_Init(t *testing.T) { + config := Config{ + Claims: globalProvisionerClaims, + } + badClaims := &Claims{ + DefaultTLSDur: &Duration{0}, + } + + type fields struct { + Type string + Name string + Accounts []string + DisableCustomSANs bool + DisableTrustOnFirstUse bool + Claims *Claims + } + type args struct { + config Config + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + {"ok", fields{"AWS", "name", []string{"account"}, false, false, nil}, args{config}, false}, + {"fail type ", fields{"", "name", []string{"account"}, false, false, nil}, args{config}, true}, + {"fail name", fields{"AWS", "", []string{"account"}, false, false, nil}, args{config}, true}, + {"fail claims", fields{"AWS", "name", []string{"account"}, false, false, badClaims}, args{config}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &AWS{ + Type: tt.fields.Type, + Name: tt.fields.Name, + Accounts: tt.fields.Accounts, + DisableCustomSANs: tt.fields.DisableCustomSANs, + DisableTrustOnFirstUse: tt.fields.DisableTrustOnFirstUse, + Claims: tt.fields.Claims, + } + if err := p.Init(tt.args.config); (err != nil) != tt.wantErr { + t.Errorf("AWS.Init() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestAWS_AuthorizeSign(t *testing.T) { + p1, srv, err := generateAWSWithServer() + assert.FatalError(t, err) + defer srv.Close() + + p2, err := generateAWS() + assert.FatalError(t, err) + p2.Accounts = p1.Accounts + p2.config = p1.config + p2.DisableCustomSANs = true + + p3, err := generateAWS() + assert.FatalError(t, err) + p3.config = p1.config + + t1, err := p1.GetIdentityToken() + assert.FatalError(t, err) + t2, err := p2.GetIdentityToken() + assert.FatalError(t, err) + t3, err := p3.GetIdentityToken() + assert.FatalError(t, err) + + block, _ := pem.Decode([]byte(awsTestKey)) + if block == nil || block.Type != "RSA PRIVATE KEY" { + t.Fatal("error decoding AWS key") + } + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + assert.FatalError(t, err) + + badKey, err := rsa.GenerateKey(rand.Reader, 1024) + assert.FatalError(t, err) + + t4, err := generateAWSToken( + "instance-id", awsIssuer, p1.GetID(), p1.Accounts[0], "instance-id", + "127.0.0.1", "us-west-1", time.Now(), key) + assert.FatalError(t, err) + failSubject, err := generateAWSToken( + "bad-subject", awsIssuer, p1.GetID(), p1.Accounts[0], "instance-id", + "127.0.0.1", "us-west-1", time.Now(), key) + assert.FatalError(t, err) + failIssuer, err := generateAWSToken( + "instance-id", "bad-issuer", p1.GetID(), p1.Accounts[0], "instance-id", + "127.0.0.1", "us-west-1", time.Now(), key) + assert.FatalError(t, err) + failAudience, err := generateAWSToken( + "instance-id", awsIssuer, "bad-audience", p1.Accounts[0], "instance-id", + "127.0.0.1", "us-west-1", time.Now(), key) + assert.FatalError(t, err) + failAccount, err := generateAWSToken( + "instance-id", awsIssuer, p1.GetID(), "", "instance-id", + "127.0.0.1", "us-west-1", time.Now(), key) + assert.FatalError(t, err) + failInstanceID, err := generateAWSToken( + "instance-id", awsIssuer, p1.GetID(), p1.Accounts[0], "", + "127.0.0.1", "us-west-1", time.Now(), key) + assert.FatalError(t, err) + failPrivateIP, err := generateAWSToken( + "instance-id", awsIssuer, p1.GetID(), p1.Accounts[0], "instance-id", + "", "us-west-1", time.Now(), key) + assert.FatalError(t, err) + failRegion, err := generateAWSToken( + "instance-id", awsIssuer, p1.GetID(), p1.Accounts[0], "instance-id", + "127.0.0.1", "", time.Now(), key) + assert.FatalError(t, err) + failExp, err := generateAWSToken( + "instance-id", awsIssuer, p1.GetID(), p1.Accounts[0], "instance-id", + "127.0.0.1", "us-west-1", time.Now().Add(-360*time.Second), key) + assert.FatalError(t, err) + failNbf, err := generateAWSToken( + "instance-id", awsIssuer, p1.GetID(), p1.Accounts[0], "instance-id", + "127.0.0.1", "us-west-1", time.Now().Add(360*time.Second), key) + assert.FatalError(t, err) + failKey, err := generateAWSToken( + "instance-id", awsIssuer, p1.GetID(), p1.Accounts[0], "instance-id", + "127.0.0.1", "us-west-1", time.Now(), badKey) + assert.FatalError(t, err) + + type args struct { + token string + } + tests := []struct { + name string + aws *AWS + args args + wantLen int + wantErr bool + }{ + {"ok", p1, args{t1}, 4, false}, + {"ok", p2, args{t2}, 6, false}, + {"ok", p1, args{t4}, 4, false}, + {"fail account", p3, args{t3}, 0, true}, + {"fail token", p1, args{"token"}, 0, true}, + {"fail subject", p1, args{failSubject}, 0, true}, + {"fail issuer", p1, args{failIssuer}, 0, true}, + {"fail audience", p1, args{failAudience}, 0, true}, + {"fail account", p1, args{failAccount}, 0, true}, + {"fail instanceID", p1, args{failInstanceID}, 0, true}, + {"fail privateIP", p1, args{failPrivateIP}, 0, true}, + {"fail region", p1, args{failRegion}, 0, true}, + {"fail exp", p1, args{failExp}, 0, true}, + {"fail nbf", p1, args{failNbf}, 0, true}, + {"fail key", p1, args{failKey}, 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.aws.AuthorizeSign(tt.args.token) + if (err != nil) != tt.wantErr { + t.Errorf("AWS.AuthorizeSign() error = %v, wantErr %v", err, tt.wantErr) + return + } + assert.Len(t, tt.wantLen, got) + }) + } +} + +func TestAWS_AuthorizeRenewal(t *testing.T) { + p1, err := generateAWS() + assert.FatalError(t, err) + p2, err := generateAWS() + assert.FatalError(t, err) + + // disable renewal + disable := true + p2.Claims = &Claims{DisableRenewal: &disable} + p2.claimer, err = NewClaimer(p2.Claims, globalProvisionerClaims) + assert.FatalError(t, err) + + type args struct { + cert *x509.Certificate + } + tests := []struct { + name string + aws *AWS + args args + wantErr bool + }{ + {"ok", p1, args{nil}, false}, + {"fail", p2, args{nil}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.aws.AuthorizeRenewal(tt.args.cert); (err != nil) != tt.wantErr { + t.Errorf("AWS.AuthorizeRenewal() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestAWS_AuthorizeRevoke(t *testing.T) { + p1, srv, err := generateAWSWithServer() + assert.FatalError(t, err) + defer srv.Close() + + t1, err := p1.GetIdentityToken() + assert.FatalError(t, err) + + type args struct { + token string + } + tests := []struct { + name string + aws *AWS + args args + wantErr bool + }{ + {"ok", p1, args{t1}, true}, // revoke is disabled + {"fail", p1, args{"token"}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.aws.AuthorizeRevoke(tt.args.token); (err != nil) != tt.wantErr { + t.Errorf("AWS.AuthorizeRevoke() error = %v, wantErr %v", err, tt.wantErr) } - t.Error(got) - // parts := strings.Split(got, ".") - // signed, err := base64.RawURLEncoding.DecodeString(parts[0]) - // assert.FatalError(t, err) - // signature, err := base64.RawURLEncoding.DecodeString(parts[1]) - // assert.FatalError(t, err) - // assert.FatalError(t, err, config.certificate.CheckSignature(config.signatureAlgorithm, signed, signature)) }) } } diff --git a/authority/provisioner/utils_test.go b/authority/provisioner/utils_test.go index 5c078479..47d42622 100644 --- a/authority/provisioner/utils_test.go +++ b/authority/provisioner/utils_test.go @@ -2,12 +2,18 @@ package provisioner import ( "crypto" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" "encoding/hex" "encoding/json" + "encoding/pem" "net/http" "net/http/httptest" "time" + "github.com/pkg/errors" "github.com/smallstep/cli/crypto/randutil" "github.com/smallstep/cli/jose" ) @@ -17,6 +23,37 @@ var testAudiences = Audiences{ Revoke: []string{"https://ca.smallstep.com/revoke", "https://ca.smallstep.com/1.0/revoke"}, } +const awsTestCertificate = `-----BEGIN CERTIFICATE----- +MIICFTCCAX6gAwIBAgIRAKmbVVYAl/1XEqRfF3eJ97MwDQYJKoZIhvcNAQELBQAw +GDEWMBQGA1UEAxMNQVdTIFRlc3QgQ2VydDAeFw0xOTA0MjQyMjU3MzlaFw0yOTA0 +MjEyMjU3MzlaMBgxFjAUBgNVBAMTDUFXUyBUZXN0IENlcnQwgZ8wDQYJKoZIhvcN +AQEBBQADgY0AMIGJAoGBAOHMmMXwbXN90SoRl/xXAcJs5TacaVYJ5iNAVWM5KYyF ++JwqYuJp/umLztFUi0oX0luu3EzD4KurVeUJSzZjTFTX1d/NX6hA45+bvdSUOcgV +UghO+2uhBZ4SNFxFRZ7SKvoWIN195l5bVX6/60Eo6+kUCKCkyxW4V/ksWzdXjHnf +AgMBAAGjXzBdMA4GA1UdDwEB/wQEAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEBMB0G +A1UdDgQWBBRHfLOjEddK/CWCIHNg8Oc/oJa1IzAYBgNVHREEETAPgg1BV1MgVGVz +dCBDZXJ0MA0GCSqGSIb3DQEBCwUAA4GBAKNCiVM9eGb9dW2xNyHaHAmmy7ERB2OJ +7oXHfLjooOavk9lU/Gs2jfX/JSBa84+DzWg9ShmCNLti8CxU/dhzXW7jE/5CcdTa +DCA6B3Yl5TmfG9+D9dtFqRB2CiMgNcsJJE5Dc6pDwBIiSj/MkE0AaGVQmSwn6Cb6 +vX1TAxqeWJHq +-----END CERTIFICATE-----` + +const awsTestKey = `-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQDhzJjF8G1zfdEqEZf8VwHCbOU2nGlWCeYjQFVjOSmMhficKmLi +af7pi87RVItKF9JbrtxMw+Crq1XlCUs2Y0xU19XfzV+oQOOfm73UlDnIFVIITvtr +oQWeEjRcRUWe0ir6FiDdfeZeW1V+v+tBKOvpFAigpMsVuFf5LFs3V4x53wIDAQAB +AoGADZQFF9oWatyFCHeYYSdGRs/PlNIhD3h262XB/L6CPh4MTi/KVH01RAwROstP +uPvnvXWtb7xTtV8PQj+l0zZzb4W/DLCSBdoRwpuNXyffUCtbI22jPupTsVu+ENWR +3x7HHzoZYjU45ADSTMxEtwD7/zyNgpRKjIA2HYpkt+fI27ECQQD5/AOr9/yQD73x +cquF+FWahWgDL25YeMwdfe1HfpUxUxd9kJJKieB8E2BtBAv9XNguxIBpf7VlAKsF +NFhdfWFHAkEA5zuX8vqDecSzyNNEQd3tugxt1pGOXNesHzuPbdlw3ppN9Rbd93an +uU2TaAvTjr/3EkxulYNRmHs+RSVK54+uqQJAKWurhBQMAibJlzcj2ofiTz8pk9WJ +GBmz4HMcHMuJlumoq8KHqtgbnRNs18Ni5TE8FMu0Z0ak3L52l98rgRokQwJBAJS8 +9KTLF79AFBVeME3eH4jJbe3TeyulX4ZHnZ8fe0b1IqhAqU8A+CpuCB+pW9A7Ewam +O4vZCKd4vzljH6eL+OECQHHxhYoTW7lFpKGnUDG9fPZ3eYzWpgka6w1vvBk10BAu +6fbwppM9pQ7DPMg7V6YGEjjT0gX9B9TttfHxGhvtZNQ= +-----END RSA PRIVATE KEY-----` + func must(args ...interface{}) []interface{} { if l := len(args); l > 0 && args[l-1] != nil { if err, ok := args[l-1].(error); ok { @@ -194,6 +231,103 @@ func generateGCP() (*GCP, error) { }, nil } +func generateAWS() (*AWS, error) { + name, err := randutil.Alphanumeric(10) + if err != nil { + return nil, err + } + accountID, err := randutil.Alphanumeric(10) + if err != nil { + return nil, err + } + claimer, err := NewClaimer(nil, globalProvisionerClaims) + if err != nil { + return nil, err + } + block, _ := pem.Decode([]byte(awsTestCertificate)) + 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 &AWS{ + Type: "AWS", + Name: name, + Accounts: []string{accountID}, + Claims: &globalProvisionerClaims, + claimer: claimer, + config: &awsConfig{ + identityURL: awsIdentityURL, + signatureURL: awsSignatureURL, + certificate: cert, + signatureAlgorithm: awsSignatureAlgorithm, + }, + }, nil +} + +func generateAWSWithServer() (*AWS, *httptest.Server, error) { + aws, err := generateAWS() + if err != nil { + return nil, nil, err + } + block, _ := pem.Decode([]byte(awsTestKey)) + if block == nil || block.Type != "RSA PRIVATE KEY" { + return nil, nil, errors.New("error decoding AWS key") + } + key, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, nil, errors.Wrap(err, "error parsing AWS private key") + } + instanceID, err := randutil.Alphanumeric(10) + if err != nil { + return nil, nil, err + } + imageID, err := randutil.Alphanumeric(10) + if err != nil { + return nil, nil, err + } + doc, err := json.MarshalIndent(awsInstanceIdentityDocument{ + AccountID: aws.Accounts[0], + Architecture: "x86_64", + AvailabilityZone: "us-west-2b", + ImageID: imageID, + InstanceID: instanceID, + InstanceType: "t2.micro", + PendingTime: time.Now(), + PrivateIP: "127.0.0.1", + Region: "us-west-1", + Version: "2017-09-30", + }, "", " ") + if err != nil { + return nil, nil, err + } + + sum := sha256.Sum256(doc) + signature, err := key.Sign(rand.Reader, sum[:], crypto.SHA256) + if err != nil { + return nil, nil, errors.Wrap(err, "error signing document") + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/latest/dynamic/instance-identity/document": + w.Write(doc) + case "/latest/dynamic/instance-identity/signature": + w.Write([]byte(base64.StdEncoding.EncodeToString(signature))) + case "/bad-document": + w.Write([]byte("{}")) + case "/bad-signature": + w.Write([]byte("YmFkLXNpZ25hdHVyZQo=")) + default: + http.NotFound(w, r) + } + })) + aws.config.identityURL = srv.URL + "/latest/dynamic/instance-identity/document" + aws.config.signatureURL = srv.URL + "/latest/dynamic/instance-identity/signature" + return aws, srv, nil +} + func generateCollection(nJWK, nOIDC int) (*Collection, error) { col := NewCollection(testAudiences) for i := 0; i < nJWK; i++ { @@ -286,6 +420,54 @@ func generateGCPToken(sub, iss, aud, instanceID, instanceName, projectID, zone s return jose.Signed(sig).Claims(claims).CompactSerialize() } +func generateAWSToken(sub, iss, aud, accountID, instanceID, privateIP, region string, iat time.Time, key crypto.Signer) (string, error) { + doc, err := json.MarshalIndent(awsInstanceIdentityDocument{ + AccountID: accountID, + Architecture: "x86_64", + AvailabilityZone: "us-west-2b", + ImageID: "ami-123123", + InstanceID: instanceID, + InstanceType: "t2.micro", + PendingTime: time.Now(), + PrivateIP: privateIP, + Region: region, + Version: "2017-09-30", + }, "", " ") + if err != nil { + return "", err + } + + sum := sha256.Sum256(doc) + signature, err := key.Sign(rand.Reader, sum[:], crypto.SHA256) + if err != nil { + return "", errors.Wrap(err, "error signing document") + } + + sig, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.HS256, Key: signature}, + new(jose.SignerOptions).WithType("JWT"), + ) + if err != nil { + return "", err + } + + claims := awsPayload{ + Claims: jose.Claims{ + Subject: sub, + Issuer: iss, + IssuedAt: jose.NewNumericDate(iat), + NotBefore: jose.NewNumericDate(iat), + Expiry: jose.NewNumericDate(iat.Add(5 * time.Minute)), + Audience: []string{aud}, + }, + Amazon: awsAmazonPayload{ + Document: doc, + Signature: signature, + }, + } + return jose.Signed(sig).Claims(claims).CompactSerialize() +} + func parseToken(token string) (*jose.JSONWebToken, *jose.Claims, error) { tok, err := jose.ParseSigned(token) if err != nil { @@ -298,6 +480,23 @@ func parseToken(token string) (*jose.JSONWebToken, *jose.Claims, error) { return tok, claims, nil } +func parseAWSToken(token string) (*jose.JSONWebToken, *awsPayload, error) { + tok, err := jose.ParseSigned(token) + if err != nil { + return nil, nil, err + } + claims := new(awsPayload) + if err := tok.UnsafeClaimsWithoutVerification(claims); err != nil { + return nil, nil, err + } + var doc awsInstanceIdentityDocument + if err := json.Unmarshal(claims.Amazon.Document, &doc); err != nil { + return nil, nil, errors.Wrap(err, "error unmarshaling identity document") + } + claims.document = doc + return tok, claims, nil +} + func generateJWKServer(n int) *httptest.Server { hits := struct { Hits int `json:"hits"` From 70196b233123d480c0f23fd72761c3615c723529 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Fri, 3 May 2019 17:30:54 -0700 Subject: [PATCH 17/41] Add skeleton for the Azure provisioner. Related to #69 --- authority/provisioner/azure.go | 101 +++++++++++++++++++++++++++ authority/provisioner/provisioner.go | 4 +- 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 authority/provisioner/azure.go diff --git a/authority/provisioner/azure.go b/authority/provisioner/azure.go new file mode 100644 index 00000000..4d91bf19 --- /dev/null +++ b/authority/provisioner/azure.go @@ -0,0 +1,101 @@ +package provisioner + +import ( + "crypto/x509" + + "github.com/pkg/errors" +) + +// azureAttestedDocumentURL is the URL for the attested document. +const azureAttestedDocumentURL = "http://169.254.169.254/metadata/attested/document?api-version=2018-10-01" + +type azureConfig struct { + attestedDocumentURL string +} + +func newAzureConfig() *azureConfig { + return &azureConfig{ + attestedDocumentURL: azureAttestedDocumentURL, + } +} + +// Azure is the provisioner that supports identity tokens created from the +// Microsoft Azure Instance Metadata service. +// +// 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 Azure struct { + Type string `json:"type"` + Name string `json:"name"` + DisableCustomSANs bool `json:"disableCustomSANs"` + DisableTrustOnFirstUse bool `json:"disableTrustOnFirstUse"` + Claims *Claims `json:"claims,omitempty"` + claimer *Claimer + config *azureConfig +} + +// GetID returns the provisioner unique identifier. +func (p *Azure) GetID() string { + return "azure:" + p.Name +} + +// GetTokenID returns the identifier of the token. +func (p *Azure) GetTokenID(token string) (string, error) { + return "", errors.New("TODO") +} + +// GetName returns the name of the provisioner. +func (p *Azure) GetName() string { + return p.Name +} + +// GetType returns the type of provisioner. +func (p *Azure) GetType() Type { + return TypeAzure +} + +// GetEncryptedKey is not available in an Azure provisioner. +func (p *Azure) GetEncryptedKey() (kid string, key string, ok bool) { + return "", "", false +} + +// Init validates and initializes the Azure provisioner. +func (p *Azure) 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 + } + // Initialize configuration + p.config = newAzureConfig() + 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") +} + +// AuthorizeRenewal returns an error if the renewal is disabled. +func (p *Azure) 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 Azure +// provisioners. +func (p *Azure) AuthorizeRevoke(token string) error { + return errors.New("revoke is not supported on a Azure provisioner") +} diff --git a/authority/provisioner/provisioner.go b/authority/provisioner/provisioner.go index 2af87cc8..8dc95586 100644 --- a/authority/provisioner/provisioner.go +++ b/authority/provisioner/provisioner.go @@ -46,7 +46,7 @@ const ( // TypeAWS is used to indicate the AWS provisioners. TypeAWS Type = 4 // TypeAzure is used to indicate the Azure provisioners. - TypeAzure Type = 4 + TypeAzure Type = 5 // RevokeAudienceKey is the key for the 'revoke' audiences in the audiences map. RevokeAudienceKey = "revoke" @@ -94,6 +94,8 @@ func (l *List) UnmarshalJSON(data []byte) error { p = &GCP{} case "aws": p = &AWS{} + case "azure": + p = &Azure{} default: return errors.Errorf("provisioner type %s not supported", typ.Type) } From 6412b1a79b559558f93b73221fc26a115dc84f29 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Tue, 7 May 2019 17:07:04 -0700 Subject: [PATCH 18/41] 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 +} From 12937c6b75e61c52f230445e7be1f86b4c9e72b3 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Tue, 7 May 2019 17:12:12 -0700 Subject: [PATCH 19/41] Remove pkcs7 related variables and structs. --- authority/provisioner/azure.go | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/authority/provisioner/azure.go b/authority/provisioner/azure.go index cdd376a6..dffa21f2 100644 --- a/authority/provisioner/azure.go +++ b/authority/provisioner/azure.go @@ -3,7 +3,6 @@ package provisioner import ( "crypto/sha256" "crypto/x509" - "encoding/asn1" "encoding/hex" "encoding/json" "io/ioutil" @@ -41,11 +40,6 @@ func newAzureConfig() *azureConfig { } } -type azureAttestedDocument struct { - Encoding string `json:"encoding"` - Signature []byte `json:"signature"` -} - type azureIdentityToken struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` @@ -69,13 +63,6 @@ type azurePayload struct { 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. // From 4c5fec06bf10c1907cffdd7b307667361431bd12 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Tue, 7 May 2019 19:07:49 -0700 Subject: [PATCH 20/41] Require TenantID in azure, add some tests. --- authority/provisioner/azure.go | 35 ++-- authority/provisioner/azure_test.go | 246 ++++++++++++++++++++++++++++ authority/provisioner/utils_test.go | 123 ++++++++++++++ 3 files changed, 393 insertions(+), 11 deletions(-) create mode 100644 authority/provisioner/azure_test.go diff --git a/authority/provisioner/azure.go b/authority/provisioner/azure.go index dffa21f2..e6ac3359 100644 --- a/authority/provisioner/azure.go +++ b/authority/provisioner/azure.go @@ -15,8 +15,8 @@ import ( "github.com/smallstep/cli/jose" ) -// azureOIDCDiscoveryURL is the default discovery url for Microsoft Azure tokens. -const azureOIDCDiscoveryURL = "https://login.microsoftonline.com/common/.well-known/openid-configuration" +// azureOIDCBaseURL is the base discovery url for Microsoft Azure tokens. +const azureOIDCBaseURL = "https://login.microsoftonline.com" // 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" @@ -33,9 +33,9 @@ type azureConfig struct { identityTokenURL string } -func newAzureConfig() *azureConfig { +func newAzureConfig(tenantID string) *azureConfig { return &azureConfig{ - oidcDiscoveryURL: azureOIDCDiscoveryURL, + oidcDiscoveryURL: azureOIDCBaseURL + "/" + tenantID + "/.well-known/openid-configuration", identityTokenURL: azureIdentityTokenURL, } } @@ -77,6 +77,7 @@ type azurePayload struct { type Azure struct { Type string `json:"type"` Name string `json:"name"` + TenantID string `json:"tenantId"` Subscriptions []string `json:"subscriptions"` Audience string `json:"audience,omitempty"` DisableCustomSANs bool `json:"disableCustomSANs"` @@ -90,7 +91,7 @@ type Azure struct { // GetID returns the provisioner unique identifier. func (p *Azure) GetID() string { - return p.Audience + return p.TenantID } // GetTokenID returns the identifier of the token. The default value for Azure @@ -176,16 +177,20 @@ 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.TenantID == "": + return errors.New("provisioner tenantId cannot be empty") case p.Audience == "": // use default audience p.Audience = azureDefaultAudience } + // Initialize config + if err := p.assertConfig(); err != nil { + return err + } // 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 { @@ -209,12 +214,15 @@ func (p *Azure) AuthorizeSign(token string) ([]SignOption, error) { 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 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 { + if err := jwt.Claims(key.Public(), &claims); err == nil { found = true break } @@ -225,12 +233,17 @@ func (p *Azure) AuthorizeSign(token string) ([]SignOption, error) { if err := claims.ValidateWithLeeway(jose.Expected{ Audience: []string{p.Audience}, - Issuer: strings.Replace(p.oidcConfig.Issuer, "{tenantid}", claims.TenantID, 1), + Issuer: p.oidcConfig.Issuer, Time: time.Now(), }, 1*time.Minute); err != nil { return nil, errors.Wrap(err, "failed to validate payload") } + // Validate TenantID + if claims.TenantID != p.TenantID { + return nil, errors.New("validation failed: invalid tenant id claim (tid)") + } + re := azureXMSMirIDRegExp.FindStringSubmatch(claims.XMSMirID) if len(re) == 0 { return nil, errors.Errorf("error parsing xms_mirid claim: %s", claims.XMSMirID) @@ -247,7 +260,7 @@ func (p *Azure) AuthorizeSign(token string) ([]SignOption, error) { } } if !found { - return nil, errors.Errorf("subscription %s is not valid", subscription) + return nil, errors.New("validation failed: invalid subscription id") } } @@ -287,6 +300,6 @@ func (p *Azure) assertConfig() error { if p.config != nil { return nil } - p.config = newAzureConfig() + p.config = newAzureConfig(p.TenantID) return nil } diff --git a/authority/provisioner/azure_test.go b/authority/provisioner/azure_test.go new file mode 100644 index 00000000..c986b5ce --- /dev/null +++ b/authority/provisioner/azure_test.go @@ -0,0 +1,246 @@ +package provisioner + +import ( + "crypto/x509" + "reflect" + "testing" + + "github.com/smallstep/assert" +) + +func TestAzure_Getters(t *testing.T) { + p, err := generateAzure() + assert.FatalError(t, err) + if got := p.GetID(); got != p.TenantID { + t.Errorf("Azure.GetID() = %v, want %v", got, p.TenantID) + } + if got := p.GetName(); got != p.Name { + t.Errorf("Azure.GetName() = %v, want %v", got, p.Name) + } + if got := p.GetType(); got != TypeAzure { + t.Errorf("Azure.GetType() = %v, want %v", got, TypeAzure) + } + kid, key, ok := p.GetEncryptedKey() + if kid != "" || key != "" || ok == true { + t.Errorf("Azure.GetEncryptedKey() = (%v, %v, %v), want (%v, %v, %v)", + kid, key, ok, "", "", false) + } +} + +func TestAzure_GetTokenID(t *testing.T) { + type fields struct { + Type string + Name string + DisableCustomSANs bool + DisableTrustOnFirstUse bool + Claims *Claims + claimer *Claimer + config *azureConfig + } + type args struct { + token string + } + tests := []struct { + name string + fields fields + args args + want string + wantErr bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Azure{ + Type: tt.fields.Type, + Name: tt.fields.Name, + DisableCustomSANs: tt.fields.DisableCustomSANs, + DisableTrustOnFirstUse: tt.fields.DisableTrustOnFirstUse, + Claims: tt.fields.Claims, + claimer: tt.fields.claimer, + config: tt.fields.config, + } + got, err := p.GetTokenID(tt.args.token) + if (err != nil) != tt.wantErr { + t.Errorf("Azure.GetTokenID() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("Azure.GetTokenID() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAzure_Init(t *testing.T) { + az, srv, err := generateAzureWithServer() + assert.FatalError(t, err) + defer srv.Close() + + config := Config{ + Claims: globalProvisionerClaims, + } + badClaims := &Claims{ + DefaultTLSDur: &Duration{0}, + } + + type fields struct { + Type string + Name string + TenantID string + DisableCustomSANs bool + DisableTrustOnFirstUse bool + Claims *Claims + } + type args struct { + config Config + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + {"ok", fields{az.Type, az.Name, az.TenantID, false, false, nil}, args{config}, false}, + {"ok", fields{az.Type, az.Name, az.TenantID, true, false, nil}, args{config}, false}, + {"ok", fields{az.Type, az.Name, az.TenantID, false, true, nil}, args{config}, false}, + {"ok", fields{az.Type, az.Name, az.TenantID, true, true, nil}, args{config}, false}, + {"fail type", fields{"", az.Name, az.TenantID, false, false, nil}, args{config}, true}, + {"fail name", fields{az.Type, "", az.TenantID, false, false, nil}, args{config}, true}, + {"fail tenant id", fields{az.Type, az.Name, "", false, false, nil}, args{config}, true}, + {"fail claims", fields{az.Type, az.Name, az.TenantID, false, false, badClaims}, args{config}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Azure{ + Type: tt.fields.Type, + Name: tt.fields.Name, + TenantID: tt.fields.TenantID, + DisableCustomSANs: tt.fields.DisableCustomSANs, + DisableTrustOnFirstUse: tt.fields.DisableTrustOnFirstUse, + Claims: tt.fields.Claims, + config: az.config, + } + if err := p.Init(tt.args.config); (err != nil) != tt.wantErr { + t.Errorf("Azure.Init() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestAzure_AuthorizeSign(t *testing.T) { + type fields struct { + Type string + Name string + DisableCustomSANs bool + DisableTrustOnFirstUse bool + Claims *Claims + claimer *Claimer + config *azureConfig + } + type args struct { + token string + } + tests := []struct { + name string + fields fields + args args + want []SignOption + wantErr bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Azure{ + Type: tt.fields.Type, + Name: tt.fields.Name, + DisableCustomSANs: tt.fields.DisableCustomSANs, + DisableTrustOnFirstUse: tt.fields.DisableTrustOnFirstUse, + Claims: tt.fields.Claims, + claimer: tt.fields.claimer, + config: tt.fields.config, + } + got, err := p.AuthorizeSign(tt.args.token) + if (err != nil) != tt.wantErr { + t.Errorf("Azure.AuthorizeSign() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Azure.AuthorizeSign() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAzure_AuthorizeRenewal(t *testing.T) { + p1, err := generateAzure() + assert.FatalError(t, err) + p2, err := generateAzure() + assert.FatalError(t, err) + + // disable renewal + disable := true + p2.Claims = &Claims{DisableRenewal: &disable} + p2.claimer, err = NewClaimer(p2.Claims, globalProvisionerClaims) + assert.FatalError(t, err) + + type args struct { + cert *x509.Certificate + } + tests := []struct { + name string + azure *Azure + args args + wantErr bool + }{ + {"ok", p1, args{nil}, false}, + {"fail", p2, args{nil}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := tt.azure.AuthorizeRenewal(tt.args.cert); (err != nil) != tt.wantErr { + t.Errorf("Azure.AuthorizeRenewal() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestAzure_AuthorizeRevoke(t *testing.T) { + type fields struct { + Type string + Name string + DisableCustomSANs bool + DisableTrustOnFirstUse bool + Claims *Claims + claimer *Claimer + config *azureConfig + } + type args struct { + token string + } + tests := []struct { + name string + fields fields + args args + wantErr bool + }{ + // TODO: Add test cases. + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &Azure{ + Type: tt.fields.Type, + Name: tt.fields.Name, + DisableCustomSANs: tt.fields.DisableCustomSANs, + DisableTrustOnFirstUse: tt.fields.DisableTrustOnFirstUse, + Claims: tt.fields.Claims, + claimer: tt.fields.claimer, + config: tt.fields.config, + } + if err := p.AuthorizeRevoke(tt.args.token); (err != nil) != tt.wantErr { + t.Errorf("Azure.AuthorizeRevoke() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/authority/provisioner/utils_test.go b/authority/provisioner/utils_test.go index 47d42622..94fc7015 100644 --- a/authority/provisioner/utils_test.go +++ b/authority/provisioner/utils_test.go @@ -9,6 +9,7 @@ import ( "encoding/hex" "encoding/json" "encoding/pem" + "fmt" "net/http" "net/http/httptest" "time" @@ -328,6 +329,99 @@ func generateAWSWithServer() (*AWS, *httptest.Server, error) { return aws, srv, nil } +func generateAzure() (*Azure, error) { + name, err := randutil.Alphanumeric(10) + if err != nil { + return nil, err + } + tenantID, err := randutil.Alphanumeric(10) + if err != nil { + return nil, err + } + claimer, err := NewClaimer(nil, globalProvisionerClaims) + if err != nil { + return nil, err + } + jwk, err := generateJSONWebKey() + if err != nil { + return nil, err + } + return &Azure{ + Type: "Azure", + Name: name, + TenantID: tenantID, + Claims: &globalProvisionerClaims, + claimer: claimer, + config: newAzureConfig(tenantID), + oidcConfig: openIDConfiguration{ + Issuer: "https://sts.windows.net/" + tenantID + "/", + JWKSetURI: "https://login.microsoftonline.com/common/discovery/keys", + }, + keyStore: &keyStore{ + keySet: jose.JSONWebKeySet{Keys: []jose.JSONWebKey{*jwk}}, + expiry: time.Now().Add(24 * time.Hour), + }, + }, nil +} + +func generateAzureWithServer() (*Azure, *httptest.Server, error) { + az, err := generateAzure() + if err != nil { + return nil, nil, err + } + writeJSON := func(w http.ResponseWriter, v interface{}) { + b, err := json.Marshal(v) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(b) + } + getPublic := func(ks jose.JSONWebKeySet) jose.JSONWebKeySet { + var ret jose.JSONWebKeySet + for _, k := range ks.Keys { + ret.Keys = append(ret.Keys, k.Public()) + } + return ret + } + issuer := "https://sts.windows.net/" + az.TenantID + "/" + srv := httptest.NewUnstartedServer(nil) + srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/error": + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + case "/" + az.TenantID + "/.well-known/openid-configuration": + writeJSON(w, openIDConfiguration{Issuer: issuer, JWKSetURI: srv.URL + "/jwks_uri"}) + case "/random": + keySet := must(generateJSONWebKeySet(2))[0].(jose.JSONWebKeySet) + w.Header().Add("Cache-Control", "max-age=5") + writeJSON(w, getPublic(keySet)) + case "/private": + writeJSON(w, az.keyStore.keySet) + case "/jwks_uri": + w.Header().Add("Cache-Control", "max-age=5") + writeJSON(w, getPublic(az.keyStore.keySet)) + case "/metadata/identity/oauth2/token": + tok, err := generateAzureToken("subject", issuer, "https://management.azure.com/", az.TenantID, "subscriptionID", "resourceGroup", "virtualMachine", time.Now(), &az.keyStore.keySet.Keys[0]) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } else { + writeJSON(w, azureIdentityToken{ + AccessToken: tok, + }) + } + default: + http.NotFound(w, r) + } + }) + srv.Start() + az.config.oidcDiscoveryURL = srv.URL + "/" + az.TenantID + "/.well-known/openid-configuration" + az.config.identityTokenURL = srv.URL + "/metadata/identity/oauth2/token" + return az, srv, nil +} + func generateCollection(nJWK, nOIDC int) (*Collection, error) { col := NewCollection(testAudiences) for i := 0; i < nJWK; i++ { @@ -468,6 +562,35 @@ func generateAWSToken(sub, iss, aud, accountID, instanceID, privateIP, region st return jose.Signed(sig).Claims(claims).CompactSerialize() } +func generateAzureToken(sub, iss, aud, tenantID, subscriptionID, resourceGroup, virtualMachine string, iat time.Time, jwk *jose.JSONWebKey) (string, error) { + sig, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.ES256, Key: jwk.Key}, + new(jose.SignerOptions).WithType("JWT").WithHeader("kid", jwk.KeyID), + ) + if err != nil { + return "", err + } + + claims := azurePayload{ + Claims: jose.Claims{ + Subject: sub, + Issuer: iss, + IssuedAt: jose.NewNumericDate(iat), + NotBefore: jose.NewNumericDate(iat), + Expiry: jose.NewNumericDate(iat.Add(5 * time.Minute)), + Audience: []string{aud}, + }, + AppID: "the-appid", + AppIDAcr: "the-appidacr", + IdentityProvider: "the-idp", + ObjectID: "the-oid", + TenantID: tenantID, + Version: "the-version", + XMSMirID: fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Compute/virtualMachines/%s", subscriptionID, resourceGroup, virtualMachine), + } + return jose.Signed(sig).Claims(claims).CompactSerialize() +} + func parseToken(token string) (*jose.JSONWebToken, *jose.Claims, error) { tok, err := jose.ParseSigned(token) if err != nil { From 803d81d3322cd73c468492d3b485a6737fa73ceb Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 8 May 2019 12:47:45 -0700 Subject: [PATCH 21/41] Improve azure unit tests. --- authority/provisioner/azure.go | 16 +- authority/provisioner/azure_test.go | 318 ++++++++++++++++++++-------- authority/provisioner/utils_test.go | 6 + 3 files changed, 239 insertions(+), 101 deletions(-) diff --git a/authority/provisioner/azure.go b/authority/provisioner/azure.go index e6ac3359..717aa6e7 100644 --- a/authority/provisioner/azure.go +++ b/authority/provisioner/azure.go @@ -139,9 +139,7 @@ func (p *Azure) GetEncryptedKey() (kid string, key string, ok bool) { // 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 - } + p.assertConfig() req, err := http.NewRequest("GET", p.config.identityTokenURL, http.NoBody) if err != nil { @@ -183,9 +181,7 @@ func (p *Azure) Init(config Config) (err error) { p.Audience = azureDefaultAudience } // Initialize config - if err := p.assertConfig(); err != nil { - return err - } + p.assertConfig() // Update claims with global ones if p.claimer, err = NewClaimer(p.Claims, config.Claims); err != nil { @@ -296,10 +292,8 @@ func (p *Azure) AuthorizeRevoke(token string) error { } // assertConfig initializes the config if it has not been initialized -func (p *Azure) assertConfig() error { - if p.config != nil { - return nil +func (p *Azure) assertConfig() { + if p.config == nil { + p.config = newAzureConfig(p.TenantID) } - p.config = newAzureConfig(p.TenantID) - return nil } diff --git a/authority/provisioner/azure_test.go b/authority/provisioner/azure_test.go index c986b5ce..5b3c817c 100644 --- a/authority/provisioner/azure_test.go +++ b/authority/provisioner/azure_test.go @@ -1,9 +1,15 @@ package provisioner import ( + "crypto/sha256" "crypto/x509" - "reflect" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "strings" "testing" + "time" "github.com/smallstep/assert" ) @@ -28,39 +34,44 @@ func TestAzure_Getters(t *testing.T) { } func TestAzure_GetTokenID(t *testing.T) { - type fields struct { - Type string - Name string - DisableCustomSANs bool - DisableTrustOnFirstUse bool - Claims *Claims - claimer *Claimer - config *azureConfig - } + p1, srv, err := generateAzureWithServer() + assert.FatalError(t, err) + defer srv.Close() + + p2, err := generateAzure() + assert.FatalError(t, err) + p2.TenantID = p1.TenantID + p2.config = p1.config + p2.oidcConfig = p1.oidcConfig + p2.keyStore = p1.keyStore + p2.DisableTrustOnFirstUse = true + + t1, err := p1.GetIdentityToken() + assert.FatalError(t, err) + t2, err := p2.GetIdentityToken() + assert.FatalError(t, err) + + sum := sha256.Sum256([]byte("/subscriptions/subscriptionID/resourceGroups/resourceGroup/providers/Microsoft.Compute/virtualMachines/virtualMachine")) + w1 := strings.ToLower(hex.EncodeToString(sum[:])) + type args struct { token string } tests := []struct { name string - fields fields + azure *Azure args args want string wantErr bool }{ - // TODO: Add test cases. + {"ok", p1, args{t1}, w1, false}, + {"ok no TOFU", p2, args{t2}, "the-jti", false}, + {"fail token", p1, args{"bad-token"}, "", true}, + {"fail claims", p1, args{"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ey.fooo"}, "", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - p := &Azure{ - Type: tt.fields.Type, - Name: tt.fields.Name, - DisableCustomSANs: tt.fields.DisableCustomSANs, - DisableTrustOnFirstUse: tt.fields.DisableTrustOnFirstUse, - Claims: tt.fields.Claims, - claimer: tt.fields.claimer, - config: tt.fields.config, - } - got, err := p.GetTokenID(tt.args.token) + got, err := tt.azure.GetTokenID(tt.args.token) if (err != nil) != tt.wantErr { t.Errorf("Azure.GetTokenID() error = %v, wantErr %v", err, tt.wantErr) return @@ -72,8 +83,58 @@ func TestAzure_GetTokenID(t *testing.T) { } } +func TestAzure_GetIdentityToken(t *testing.T) { + p1, err := generateAzure() + assert.FatalError(t, err) + + t1, err := generateAzureToken("subject", p1.oidcConfig.Issuer, azureDefaultAudience, + p1.TenantID, "subscriptionID", "resourceGroup", "virtualMachine", + time.Now(), &p1.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/bad-request": + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + case "/bad-json": + w.Write([]byte(t1)) + default: + w.Header().Add("Content-Type", "application/json") + w.Write([]byte(fmt.Sprintf(`{"access_token":"%s"}`, t1))) + } + })) + defer srv.Close() + + tests := []struct { + name string + azure *Azure + identityTokenURL string + want string + wantErr bool + }{ + {"ok", p1, srv.URL, t1, false}, + {"fail request", p1, srv.URL + "/bad-request", "", true}, + {"fail unmarshal", p1, srv.URL + "/bad-json", "", true}, + {"fail url", p1, "://ca.smallstep.com", "", true}, + {"fail connect", p1, "foobarzar", "", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.azure.config.identityTokenURL = tt.identityTokenURL + got, err := tt.azure.GetIdentityToken() + if (err != nil) != tt.wantErr { + t.Errorf("Azure.GetIdentityToken() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("Azure.GetIdentityToken() = %v, want %v", got, tt.want) + } + }) + } +} + func TestAzure_Init(t *testing.T) { - az, srv, err := generateAzureWithServer() + p1, srv, err := generateAzureWithServer() assert.FatalError(t, err) defer srv.Close() @@ -84,13 +145,25 @@ func TestAzure_Init(t *testing.T) { DefaultTLSDur: &Duration{0}, } + badDiscoveryURL := &azureConfig{ + oidcDiscoveryURL: srv.URL + "/error", + identityTokenURL: p1.config.identityTokenURL, + } + badJWKURL := &azureConfig{ + oidcDiscoveryURL: srv.URL + "/openid-configuration-fail-jwk", + identityTokenURL: p1.config.identityTokenURL, + } + badAzureConfig := &azureConfig{ + oidcDiscoveryURL: srv.URL + "/openid-configuration-no-issuer", + identityTokenURL: p1.config.identityTokenURL, + } + type fields struct { - Type string - Name string - TenantID string - DisableCustomSANs bool - DisableTrustOnFirstUse bool - Claims *Claims + Type string + Name string + TenantID string + Claims *Claims + config *azureConfig } type args struct { config Config @@ -101,25 +174,24 @@ func TestAzure_Init(t *testing.T) { args args wantErr bool }{ - {"ok", fields{az.Type, az.Name, az.TenantID, false, false, nil}, args{config}, false}, - {"ok", fields{az.Type, az.Name, az.TenantID, true, false, nil}, args{config}, false}, - {"ok", fields{az.Type, az.Name, az.TenantID, false, true, nil}, args{config}, false}, - {"ok", fields{az.Type, az.Name, az.TenantID, true, true, nil}, args{config}, false}, - {"fail type", fields{"", az.Name, az.TenantID, false, false, nil}, args{config}, true}, - {"fail name", fields{az.Type, "", az.TenantID, false, false, nil}, args{config}, true}, - {"fail tenant id", fields{az.Type, az.Name, "", false, false, nil}, args{config}, true}, - {"fail claims", fields{az.Type, az.Name, az.TenantID, false, false, badClaims}, args{config}, true}, + {"ok", fields{p1.Type, p1.Name, p1.TenantID, nil, p1.config}, args{config}, false}, + {"ok with config", fields{p1.Type, p1.Name, p1.TenantID, nil, p1.config}, args{config}, false}, + {"fail type", fields{"", p1.Name, p1.TenantID, nil, p1.config}, args{config}, true}, + {"fail name", fields{p1.Type, "", p1.TenantID, nil, p1.config}, args{config}, true}, + {"fail tenant id", fields{p1.Type, p1.Name, "", nil, p1.config}, args{config}, true}, + {"fail claims", fields{p1.Type, p1.Name, p1.TenantID, badClaims, p1.config}, args{config}, true}, + {"fail discovery URL", fields{p1.Type, p1.Name, p1.TenantID, nil, badDiscoveryURL}, args{config}, true}, + {"fail JWK URL", fields{p1.Type, p1.Name, p1.TenantID, nil, badJWKURL}, args{config}, true}, + {"fail config Validate", fields{p1.Type, p1.Name, p1.TenantID, nil, badAzureConfig}, args{config}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := &Azure{ - Type: tt.fields.Type, - Name: tt.fields.Name, - TenantID: tt.fields.TenantID, - DisableCustomSANs: tt.fields.DisableCustomSANs, - DisableTrustOnFirstUse: tt.fields.DisableTrustOnFirstUse, - Claims: tt.fields.Claims, - config: az.config, + Type: tt.fields.Type, + Name: tt.fields.Name, + TenantID: tt.fields.TenantID, + Claims: tt.fields.Claims, + config: tt.fields.config, } if err := p.Init(tt.args.config); (err != nil) != tt.wantErr { t.Errorf("Azure.Init() error = %v, wantErr %v", err, tt.wantErr) @@ -129,46 +201,101 @@ func TestAzure_Init(t *testing.T) { } func TestAzure_AuthorizeSign(t *testing.T) { - type fields struct { - Type string - Name string - DisableCustomSANs bool - DisableTrustOnFirstUse bool - Claims *Claims - claimer *Claimer - config *azureConfig - } + p1, srv, err := generateAzureWithServer() + assert.FatalError(t, err) + defer srv.Close() + + p2, err := generateAzure() + assert.FatalError(t, err) + p2.TenantID = p1.TenantID + p2.Subscriptions = []string{"subscriptionID"} + p2.config = p1.config + p2.oidcConfig = p1.oidcConfig + p2.keyStore = p1.keyStore + p2.DisableCustomSANs = true + + p3, err := generateAzure() + assert.FatalError(t, err) + p3.config = p1.config + p3.oidcConfig = p1.oidcConfig + p3.keyStore = p1.keyStore + + p4, err := generateAzure() + assert.FatalError(t, err) + p4.TenantID = p1.TenantID + p4.Subscriptions = []string{"subscriptionID1"} + p4.config = p1.config + p4.oidcConfig = p1.oidcConfig + p4.keyStore = p1.keyStore + + badKey, err := generateJSONWebKey() + assert.FatalError(t, err) + + t1, err := p1.GetIdentityToken() + assert.FatalError(t, err) + t2, err := p2.GetIdentityToken() + assert.FatalError(t, err) + t3, err := p3.GetIdentityToken() + assert.FatalError(t, err) + t4, err := p4.GetIdentityToken() + assert.FatalError(t, err) + + t11, err := generateAzureToken("subject", p1.oidcConfig.Issuer, azureDefaultAudience, + p1.TenantID, "subscriptionID", "resourceGroup", "virtualMachine", + time.Now(), &p1.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) + + failIssuer, err := generateAzureToken("subject", "bad-issuer", azureDefaultAudience, + p1.TenantID, "subscriptionID", "resourceGroup", "virtualMachine", + time.Now(), &p1.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) + failAudience, err := generateAzureToken("subject", p1.oidcConfig.Issuer, "bad-audience", + p1.TenantID, "subscriptionID", "resourceGroup", "virtualMachine", + time.Now(), &p1.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) + failExp, err := generateAzureToken("subject", p1.oidcConfig.Issuer, azureDefaultAudience, + p1.TenantID, "subscriptionID", "resourceGroup", "virtualMachine", + time.Now().Add(-360*time.Second), &p1.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) + failNbf, err := generateAzureToken("subject", p1.oidcConfig.Issuer, azureDefaultAudience, + p1.TenantID, "subscriptionID", "resourceGroup", "virtualMachine", + time.Now().Add(360*time.Second), &p1.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) + failKey, err := generateAzureToken("subject", p1.oidcConfig.Issuer, azureDefaultAudience, + p1.TenantID, "subscriptionID", "resourceGroup", "virtualMachine", + time.Now(), badKey) + assert.FatalError(t, err) + type args struct { token string } tests := []struct { name string - fields fields + azure *Azure args args - want []SignOption + wantLen int wantErr bool }{ - // TODO: Add test cases. + {"ok", p1, args{t1}, 4, false}, + {"ok", p2, args{t2}, 5, false}, + {"ok", p1, args{t11}, 4, false}, + {"fail tenant", p3, args{t3}, 0, true}, + {"fail subscription", p4, args{t4}, 0, true}, + {"fail token", p1, args{"token"}, 0, true}, + {"fail issuer", p1, args{failIssuer}, 0, true}, + {"fail audience", p1, args{failAudience}, 0, true}, + {"fail exp", p1, args{failExp}, 0, true}, + {"fail nbf", p1, args{failNbf}, 0, true}, + {"fail key", p1, args{failKey}, 0, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - p := &Azure{ - Type: tt.fields.Type, - Name: tt.fields.Name, - DisableCustomSANs: tt.fields.DisableCustomSANs, - DisableTrustOnFirstUse: tt.fields.DisableTrustOnFirstUse, - Claims: tt.fields.Claims, - claimer: tt.fields.claimer, - config: tt.fields.config, - } - got, err := p.AuthorizeSign(tt.args.token) + got, err := tt.azure.AuthorizeSign(tt.args.token) if (err != nil) != tt.wantErr { t.Errorf("Azure.AuthorizeSign() error = %v, wantErr %v", err, tt.wantErr) return } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("Azure.AuthorizeSign() = %v, want %v", got, tt.want) - } + assert.Len(t, tt.wantLen, got) }) } } @@ -207,40 +334,51 @@ func TestAzure_AuthorizeRenewal(t *testing.T) { } func TestAzure_AuthorizeRevoke(t *testing.T) { - type fields struct { - Type string - Name string - DisableCustomSANs bool - DisableTrustOnFirstUse bool - Claims *Claims - claimer *Claimer - config *azureConfig - } + az, srv, err := generateAzureWithServer() + assert.FatalError(t, err) + defer srv.Close() + + token, err := az.GetIdentityToken() + assert.FatalError(t, err) + type args struct { token string } tests := []struct { name string - fields fields + azure *Azure args args wantErr bool }{ - // TODO: Add test cases. + {"ok token", az, args{token}, true}, // revoke is disabled + {"bad token", az, args{"bad token"}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - p := &Azure{ - Type: tt.fields.Type, - Name: tt.fields.Name, - DisableCustomSANs: tt.fields.DisableCustomSANs, - DisableTrustOnFirstUse: tt.fields.DisableTrustOnFirstUse, - Claims: tt.fields.Claims, - claimer: tt.fields.claimer, - config: tt.fields.config, - } - if err := p.AuthorizeRevoke(tt.args.token); (err != nil) != tt.wantErr { + if err := tt.azure.AuthorizeRevoke(tt.args.token); (err != nil) != tt.wantErr { t.Errorf("Azure.AuthorizeRevoke() error = %v, wantErr %v", err, tt.wantErr) } }) } } + +func TestAzure_assertConfig(t *testing.T) { + p1, err := generateAzure() + assert.FatalError(t, err) + p2, err := generateAzure() + assert.FatalError(t, err) + p2.config = nil + + tests := []struct { + name string + azure *Azure + }{ + {"ok with config", p1}, + {"ok no config", p2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.azure.assertConfig() + }) + } +} diff --git a/authority/provisioner/utils_test.go b/authority/provisioner/utils_test.go index 94fc7015..23175677 100644 --- a/authority/provisioner/utils_test.go +++ b/authority/provisioner/utils_test.go @@ -350,6 +350,7 @@ func generateAzure() (*Azure, error) { Type: "Azure", Name: name, TenantID: tenantID, + Audience: azureDefaultAudience, Claims: &globalProvisionerClaims, claimer: claimer, config: newAzureConfig(tenantID), @@ -394,6 +395,10 @@ func generateAzureWithServer() (*Azure, *httptest.Server, error) { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) case "/" + az.TenantID + "/.well-known/openid-configuration": writeJSON(w, openIDConfiguration{Issuer: issuer, JWKSetURI: srv.URL + "/jwks_uri"}) + case "/openid-configuration-no-issuer": + writeJSON(w, openIDConfiguration{Issuer: "", JWKSetURI: srv.URL + "/jwks_uri"}) + case "/openid-configuration-fail-jwk": + writeJSON(w, openIDConfiguration{Issuer: issuer, JWKSetURI: srv.URL + "/error"}) case "/random": keySet := must(generateJSONWebKeySet(2))[0].(jose.JSONWebKeySet) w.Header().Add("Cache-Control", "max-age=5") @@ -579,6 +584,7 @@ func generateAzureToken(sub, iss, aud, tenantID, subscriptionID, resourceGroup, NotBefore: jose.NewNumericDate(iat), Expiry: jose.NewNumericDate(iat.Add(5 * time.Minute)), Audience: []string{aud}, + ID: "the-jti", }, AppID: "the-appid", AppIDAcr: "the-appidacr", From 89eeada2a227f16735bb43d8de7656f66e9811d1 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 8 May 2019 15:39:50 -0700 Subject: [PATCH 22/41] Add support for loading azure tokens by tenant id. --- authority/provisioner/aws_test.go | 1 + authority/provisioner/collection.go | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/authority/provisioner/aws_test.go b/authority/provisioner/aws_test.go index 429e583e..78329838 100644 --- a/authority/provisioner/aws_test.go +++ b/authority/provisioner/aws_test.go @@ -49,6 +49,7 @@ func TestAWS_GetTokenID(t *testing.T) { t1, err := p1.GetIdentityToken() assert.FatalError(t, err) + t.Error(t1) _, claims, err := parseAWSToken(t1) assert.FatalError(t, err) sum := sha256.Sum256([]byte(fmt.Sprintf("%s.%s", p1.GetID(), claims.document.InstanceID))) diff --git a/authority/provisioner/collection.go b/authority/provisioner/collection.go index e34a2fcf..c3c6518c 100644 --- a/authority/provisioner/collection.go +++ b/authority/provisioner/collection.go @@ -33,6 +33,14 @@ func (p provisionerSlice) Len() int { return len(p) } func (p provisionerSlice) Less(i, j int) bool { return p[i].uid < p[j].uid } func (p provisionerSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] } +// loadByTokenPayload is a payload used to extract the id used to load the +// provisioner. +type loadByTokenPayload struct { + jose.Claims + AuthorizedParty string `json:"azp"` // OIDC client id + TenantID string `json:"tid"` // Microsoft Azure tenant id +} + // Collection is a memory map of provisioners. type Collection struct { byID *sync.Map @@ -65,8 +73,8 @@ func (c *Collection) LoadByToken(token *jose.JSONWebToken, claims *jose.Claims) return c.Load(claims.Issuer + ":" + token.Headers[0].KeyID) } - // The ID will be just the clientID stored in azp or aud. - var payload openIDPayload + // The ID will be just the clientID stored in azp, aud or tid. + var payload loadByTokenPayload if err := token.UnsafeClaimsWithoutVerification(&payload); err != nil { return nil, false } @@ -80,6 +88,12 @@ func (c *Collection) LoadByToken(token *jose.JSONWebToken, claims *jose.Claims) return p, ok } } + // Try with tid (Azure) + if payload.TenantID != "" { + if p, ok := c.Load(payload.TenantID); ok { + return p, ok + } + } // Fallback to aud (GCP) return c.Load(payload.Audience[0]) } From e0aaa1a577db38a5a6a406c4ef2864ae91dfc583 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 8 May 2019 15:58:15 -0700 Subject: [PATCH 23/41] Use tenant id in azures's provisioner x509 extension. --- authority/provisioner/azure.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/authority/provisioner/azure.go b/authority/provisioner/azure.go index 717aa6e7..cd1e8299 100644 --- a/authority/provisioner/azure.go +++ b/authority/provisioner/azure.go @@ -272,7 +272,7 @@ func (p *Azure) AuthorizeSign(token string) ([]SignOption, error) { return append(so, commonNameValidator(name), profileDefaultDuration(p.claimer.DefaultTLSCertDuration()), - newProvisionerExtensionOption(TypeAzure, p.Name, subscription), + newProvisionerExtensionOption(TypeAzure, p.Name, p.TenantID), newValidityValidator(p.claimer.MinTLSCertDuration(), p.claimer.MaxTLSCertDuration()), ), nil } From 32d2d6b75ab514ac60c94095e88864f88f36be9a Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 8 May 2019 17:11:33 -0700 Subject: [PATCH 24/41] Remove debug code. --- authority/provisioner/aws_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/authority/provisioner/aws_test.go b/authority/provisioner/aws_test.go index 78329838..429e583e 100644 --- a/authority/provisioner/aws_test.go +++ b/authority/provisioner/aws_test.go @@ -49,7 +49,6 @@ func TestAWS_GetTokenID(t *testing.T) { t1, err := p1.GetIdentityToken() assert.FatalError(t, err) - t.Error(t1) _, claims, err := parseAWSToken(t1) assert.FatalError(t, err) sum := sha256.Sum256([]byte(fmt.Sprintf("%s.%s", p1.GetID(), claims.document.InstanceID))) From 423d505d04a2cbb21d9f6fe85448e6ac14480b49 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 8 May 2019 17:11:55 -0700 Subject: [PATCH 25/41] Replace subscriptions with resource groups. --- authority/provisioner/azure.go | 14 +++++++------- authority/provisioner/azure_test.go | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/authority/provisioner/azure.go b/authority/provisioner/azure.go index cd1e8299..9080bcc2 100644 --- a/authority/provisioner/azure.go +++ b/authority/provisioner/azure.go @@ -78,7 +78,7 @@ type Azure struct { Type string `json:"type"` Name string `json:"name"` TenantID string `json:"tenantId"` - Subscriptions []string `json:"subscriptions"` + ResourceGroups []string `json:"resourceGroups"` Audience string `json:"audience,omitempty"` DisableCustomSANs bool `json:"disableCustomSANs"` DisableTrustOnFirstUse bool `json:"disableTrustOnFirstUse"` @@ -244,19 +244,19 @@ func (p *Azure) AuthorizeSign(token string) ([]SignOption, error) { if len(re) == 0 { return nil, errors.Errorf("error parsing xms_mirid claim: %s", claims.XMSMirID) } - subscription, name := re[1], re[3] + group, name := re[2], re[3] - // Filter by subscriptions - if len(p.Subscriptions) > 0 { + // Filter by resource group + if len(p.ResourceGroups) > 0 { var found bool - for _, s := range p.Subscriptions { - if s == subscription { + for _, g := range p.ResourceGroups { + if g == group { found = true break } } if !found { - return nil, errors.New("validation failed: invalid subscription id") + return nil, errors.New("validation failed: invalid resource group") } } diff --git a/authority/provisioner/azure_test.go b/authority/provisioner/azure_test.go index 5b3c817c..a247dbfa 100644 --- a/authority/provisioner/azure_test.go +++ b/authority/provisioner/azure_test.go @@ -208,7 +208,7 @@ func TestAzure_AuthorizeSign(t *testing.T) { p2, err := generateAzure() assert.FatalError(t, err) p2.TenantID = p1.TenantID - p2.Subscriptions = []string{"subscriptionID"} + p2.ResourceGroups = []string{"resourceGroup"} p2.config = p1.config p2.oidcConfig = p1.oidcConfig p2.keyStore = p1.keyStore @@ -223,7 +223,7 @@ func TestAzure_AuthorizeSign(t *testing.T) { p4, err := generateAzure() assert.FatalError(t, err) p4.TenantID = p1.TenantID - p4.Subscriptions = []string{"subscriptionID1"} + p4.ResourceGroups = []string{"foobarzar"} p4.config = p1.config p4.oidcConfig = p1.oidcConfig p4.keyStore = p1.keyStore @@ -280,7 +280,7 @@ func TestAzure_AuthorizeSign(t *testing.T) { {"ok", p2, args{t2}, 5, false}, {"ok", p1, args{t11}, 4, false}, {"fail tenant", p3, args{t3}, 0, true}, - {"fail subscription", p4, args{t4}, 0, true}, + {"fail resource group", p4, args{t4}, 0, true}, {"fail token", p1, args{"token"}, 0, true}, {"fail issuer", p1, args{failIssuer}, 0, true}, {"fail audience", p1, args{failAudience}, 0, true}, From fb7140444ac8f1fa1245a80e49d17e206f7435f3 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Thu, 9 May 2019 18:45:57 -0700 Subject: [PATCH 26/41] Add docs for provisioners. --- docs/GETTING_STARTED.md | 3 + docs/provisioners.md | 299 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 302 insertions(+) create mode 100644 docs/provisioners.md diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index a5323832..c7cedb61 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -27,6 +27,9 @@ Provisioners are people or code that are registered with the CA and authorized to issue "provisioning tokens". Provisioning tokens are single use tokens that can be used to authenticate with the CA and get a certificate. +See [provisioners.md](provisioners.md) for more information on the supported +provisioners and its options. + ## Initializing PKI and configuring the Certificate Authority To initialize a PKI and configure the Step Certificate Authority run: diff --git a/docs/provisioners.md b/docs/provisioners.md new file mode 100644 index 00000000..cbff14a9 --- /dev/null +++ b/docs/provisioners.md @@ -0,0 +1,299 @@ +# Provisioners + +Provisioners are people or code that are registered with the CA and authorized +to issue "provisioning tokens". Provisioning tokens are single-use tokens that +can be used to authenticate with the CA and get a certificate. + +## JWK + +JWK is the default provisioner type. It uses public-key cryptography sign and +validate a JSON Web Token (JWT). + +The [step](https://github.com/smallstep/cli) CLI tool will create a JWK +provisioner when `step ca init` is used, and it also contains commands to add +(`step ca provisioner add`) and remove (`step ca provisioner remove`) JWK +provisioners. + +In the ca.json configuration file, a complete JWK provisioner example looks like: + +```json +{ + "type": "JWK", + "name": "you@smallstep.com", + "key": { + "use": "sig", + "kty": "EC", + "kid": "NPM_9Gz_omTqchS6Xx9Yfvs-EuxkYo6VAk4sL7gyyM4", + "crv": "P-256", + "alg": "ES256", + "x": "bBI5AkO9lwvDuWGfOr0F6ttXC-ZRzJo8kKn5wTzRJXI", + "y": "rcfaqE-EEZgs34Q9SSH3f9Ua5a8dKopXNfEzDD8KRlU" + }, + "encryptedKey": "eyJhbGciOiJQQkVTMi1IUzI1NitBMTI4S1ciLCJjdHkiOiJqd2sranNvbiIsImVuYyI6IkEyNTZHQ00iLCJwMmMiOjEwMDAwMCwicDJzIjoiTlV6MjlEb3hKMVdOaFI3dUNjaGdYZyJ9.YN7xhz6RAbz_9bcuXoymBOj8bOg23ETAdmSCRyHpxGekkV0q3STYYg.vo1oBnZsZjgRu5Ln.Xop8AvZ74h_im2jxeaq-hYYWnaK_eF7MGr4xcZGodMUxp-hGPqS85oWkyprkQLYt1-jXTURfpejtmPeB4-sxgj7OFxMYYus84BdkG9BZgSBmMN9SqZItOv4pqg_NwQA0bv9g9A_e-N6QUFanxuYQsEPX_-IwWBDbNKyN9bXbpEQa0FKNVsTvFahGzOxQngXipi265VADkh8MJLjYerplKIbNeOJJbLd9CbS9fceLvQUNr3ACGgAejSaWmeNUVqbho1lY4882iS8QVx1VzjluTXlAMdSUUDHArHEihz008kCyF0YfvNdGebyEDLvTmF6KkhqMpsWn3zASYBidc9k._ch9BtvRRhcLD838itIQlw", + "claims": { + "minTLSCertDuration": "5m", + "maxTLSCertDuration": "24h", + "defaultTLSCertDuration": "24h", + "disableRenewal": false + } +} +``` + +* `type` (mandatory): for a JWK provisioner it must be `JWK`, this field is case + insensitive. + +* `name` (mandatory): identifies the provisioner, a good practice is to + use an email address or a descriptive string that allows the identification of + the owner, but it can be any non-empty string. + +* `key` (mandatory): is the JWK (JSON Web Key) representation of a public key + used to validate a signed token. + +* `encryptedKey` (recommended): is the encrypted private key used to sign a + token. It's a JWE compact string containing the JWK representation of the + private key. + + We can use [step](https://github.com/smallstep/cli) to see the private key + encrypted with the password `asdf`: + + ```sh + $ echo ey...lw | step crypto jwe decrypt | jq + Please enter the password to decrypt the content encryption key: + { + "use": "sig", + "kty": "EC", + "kid": "NPM_9Gz_omTqchS6Xx9Yfvs-EuxkYo6VAk4sL7gyyM4", + "crv": "P-256", + "alg": "ES256", + "x": "bBI5AkO9lwvDuWGfOr0F6ttXC-ZRzJo8kKn5wTzRJXI", + "y": "rcfaqE-EEZgs34Q9SSH3f9Ua5a8dKopXNfEzDD8KRlU", + "d": "rsjCCM_2FQ-uk7nywBEQHl84oaPo4mTpYDgXAu63igE" + } + ``` + + If the ca.json does not contain the encryptedKey, the private key must be + provided using the `--key` flag of the `step ca token` to be able to sign the + token. + +* `claims` (optional): overwrites the default claims set in the authority. + You can set one or more of the following claims: + + * `minTLSCertDuration`: do not allow certificates with a duration less than + this value. + + * `maxTLSCertDuration`: do not allow certificates with a duration greater than + this value. + + * `defaultTLSCertDuration`: if no certificate validity period is specified, + use this value. + + * `disableIssuedAtCheck`: disable a check verifying that provisioning tokens + must be issued after the CA has booted. This claim is one prevention against + token reuse. The default value is `false`. Do not change this unless you + know what you are doing. + +## OIDC + +An OIDC provisioner allows a user to get a certificate after authenticating +himself with an OAuth OpenID Connect identity provider. The ID token provided +will be used on the CA authentication, and by default, the certificate will only +have the user's email as a Subject Alternative Name (SAN) Extension. + +One of the most common providers and the one we'll use in the following example +is G-Suite. + +```json +{ + "type": "OIDC", + "name": "Google", + "clientID": "1087160488420-8qt7bavg3qesdhs6it824mhnfgcfe8il.apps.googleusercontent.com", + "clientSecret": "udTrOT3gzrO7W9fDPgZQLfYJ", + "configurationEndpoint": "https://accounts.google.com/.well-known/openid-configuration", + "admins": ["you@smallstep.com"], + "domains": ["smallstep.com"], + "claims": { + "maxTLSCertDuration": "8h", + "defaultTLSCertDuration": "2h", + "disableRenewal": true + } +} +``` + +* `type` (mandatory): indicates the provisioner type and must be `OIDC`. + +* `name` (mandatory): a string used to identify the provider when the CLI is + used. + +* `clientID` (mandatory): the client id provided by the identity provider used + to initialize the authentication flow. + +* `clientSecret` (mandatory): the client secret provided by the identity + provider used to get the id token. Some identity providers might use an empty + string as a secret. + +* `configurationEndpoing` (mandatory): is the HTTP address used by the CA to get + the OpenID Connect configuration and public keys used to validate the tokens. + +* `admins` (optional): is the list of emails that will be able to get + certificates with custom SANs. If a user is not an admin, it will only be able + to get a certificate with its email in it. + +* `domains` (optional): is the list of domains valid. If provided only the + emails with the provided domains will be able to authenticate. + +* `claims` (optional): overwrites the default claims set in the authority, see + the [JWK](#jwk) section for all the options. + +## Provisioners for Cloud Identities + +[Step certificates](https://github.com/smallstep/certificates) can grant +certificates to code running in a machine without any other authentication than +the one provided by the cloud. Usually, this is implemented with some kind of +signed document, but the information contained on them might not be enough to +generate a certificate. Due to this limitation, the cloud identities use by +default a trust model called Trust On First Use (TOFU). + +The Trust On First Use model allows the use of more permissive CSRs that can +have custom SANs that cannot be validated. But it comes with the limitation that +you can only grant a certificate once. After this first grant, the same machine +will need to renew the certificate using mTLS, and the CA will block any other +attempt to grant a certificate to that instance. + +### AWS + +The AWS provisioner allows granting a certificate to an Amazon EC2 instance +using the [Instance Identity Documents](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-identity-documents.html) + +The [step](https://github.com/smallstep/cli) CLI will generate a custom JWT +token containing the instance identity document and its signature and the CA +will grant a certificate after validating it. + +In the ca.json, an AWS provisioner looks like: + +```json +{ + "type": "AWS", + "name": "Amazon Web Services", + "accounts": ["1234567890"], + "disableCustomSANs": false, + "disableTrustOnFirstUse": false, + "claims": { + "maxTLSCertDuration": "2160h", + "defaultTLSCertDuration": "2160h" + } +} +``` + +* `type` (mandatory): indicates the provisioner type and must be `AWS`. + +* `name` (mandatory): a string used to identify the provider when the CLI is + used. + +* `accounts` (optional): the list of AWS account numbers that are allowed to use + this provisioner. If none is specified, all AWS accounts will be valid. + +* `disableCustomSANs` (optional): by default custom SANs are valid, but if this + option is set to true only the SANs available in the instance identity + document will be valid, these are the private IP and the DNS + `ip-..compute.internal`. + +* `disableTrustOnFirstUse` (optional): by default only one certificate will be + granted per instance, but if the option is set to true this limit is not set + and different tokens can be used to get different certificates. + +* `claims` (optional): overwrites the default claims set in the authority, see + the [JWK](#jwk) section for all the options. + +### GCP + +The GCP provisioner grants certificates to Google Compute Engine instance using +its [identity](https://cloud.google.com/compute/docs/instances/verifying-instance-identity) +token. The CA will validate the JWT and grant a certificate. + +In the ca.json, a GCP provisioner looks like: + +```json +{ + "type": "GCP", + "name": "Google Cloud", + "serviceAccounts": ["1234567890"], + "disableCustomSANs": false, + "disableTrustOnFirstUse": false, + "claims": { + "maxTLSCertDuration": "2160h", + "defaultTLSCertDuration": "2160h" + } +} +``` + +* `type` (mandatory): indicates the provisioner type and must be `GCP`. + +* `name` (mandatory): a string used to identify the provider when the CLI is + used. + +* `serviceAccounts` (optional): the list of service account numbers that are + allowed to use this provisioner. If none is specified, all service accounts + will be valid. + +* `disableCustomSANs` (optional): by default custom SANs are valid, but if this + option is set to true only the SANs available in the instance identity + document will be valid, these are the DNS + `.c..internal` and + `..c..internal` + +* `disableTrustOnFirstUse` (optional): by default only one certificate will be + granted per instance, but if the option is set to true this limit is not set + and different tokens can be used to get different certificates. + +* `claims` (optional): overwrites the default claims set in the authority, see + the [JWK](#jwk) section for all the options. + +### Azure + +The Azure provisioner grants certificates to Microsoft Azure instances using +the [managed identities tokens](https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/how-to-use-vm-token). +The CA will validate the JWT and grant a certificate. + +In the ca.json, an Azure provisioner looks like: + +```json +{ + "type": "Azure", + "name": "Microsoft Azure", + "tenantId": "b17c217c-84db-43f0-babd-e06a71083cda", + "resourceGroups": ["backend", "accounting"], + "audience": "https://management.azure.com/", + "disableCustomSANs": false, + "disableTrustOnFirstUse": false, + "claims": { + "maxTLSCertDuration": "2160h", + "defaultTLSCertDuration": "2160h" + } +} +``` + +* `type` (mandatory): indicates the provisioner type and must be `Azure`. + +* `name` (mandatory): a string used to identify the provider when the CLI is + used. + +* `tenantId` (mandatory): the Azure account tenant id for this provisioner. This + id is the Directory ID available in the Azure Active Directory properties. + +* `audience` (optional): defaults to `https://management.azure.com/` but it can + be changed if necessary. + +* `resourceGroups` (optional): the list of resource group names that are allowed + to use this provisioner. If none is specified, all resource groups will be + valid. + +* `disableCustomSANs` (optional): by default custom SANs are valid, but if this + option is set to true only the SANs available in the token will be valid, in + Azure only the virtual machine name is available. + +* `disableTrustOnFirstUse` (optional): by default only one certificate will be + granted per instance, but if the option is set to true this limit is not set + and different tokens can be used to get different certificates. + +* `claims` (optional): overwrites the default claims set in the authority, see + the [JWK](#jwk) section for all the options. From cf07c8f4c0519dc85a3f3bfa03e0e683f6a9eebc Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Thu, 9 May 2019 18:56:24 -0700 Subject: [PATCH 27/41] Fix typos. --- authority/provisioner/aws.go | 2 +- authority/provisioner/azure.go | 6 +++--- authority/provisioner/gcp.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/authority/provisioner/aws.go b/authority/provisioner/aws.go index 4a017069..a6984864 100644 --- a/authority/provisioner/aws.go +++ b/authority/provisioner/aws.go @@ -258,7 +258,7 @@ func (p *AWS) AuthorizeSign(token string) ([]SignOption, error) { doc := payload.document // Enforce default DNS and IP if configured. - // By default we we'll accept the SANs in the CSR. + // By default we'll accept the SANs in the CSR. // There's no way to trust them other than TOFU. var so []SignOption if p.DisableCustomSANs { diff --git a/authority/provisioner/azure.go b/authority/provisioner/azure.go index 9080bcc2..725d0b19 100644 --- a/authority/provisioner/azure.go +++ b/authority/provisioner/azure.go @@ -135,8 +135,8 @@ 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. +// GetIdentityToken retrieves from the metadata service the identity token and +// returns it. func (p *Azure) GetIdentityToken() (string, error) { // Initialize the config if this method is used from the cli. p.assertConfig() @@ -261,7 +261,7 @@ func (p *Azure) AuthorizeSign(token string) ([]SignOption, error) { } // Enforce default DNS if configured. - // By default we we'll accept the SANs in the CSR. + // By default we'll accept the SANs in the CSR. // There's no way to trust them other than TOFU. var so []SignOption if p.DisableCustomSANs { diff --git a/authority/provisioner/gcp.go b/authority/provisioner/gcp.go index 1d67c567..bc221cbd 100644 --- a/authority/provisioner/gcp.go +++ b/authority/provisioner/gcp.go @@ -16,7 +16,7 @@ import ( "github.com/smallstep/cli/jose" ) -// gcpCertsURL is the url that servers Google OAuth2 public keys. +// gcpCertsURL is the url that serves Google OAuth2 public keys. const gcpCertsURL = "https://www.googleapis.com/oauth2/v3/certs" // gcpIdentityURL is the base url for the identity document in GCP. From 54d0186d1f133aaa13f6af09b9068189a58aa551 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Mon, 13 May 2019 11:50:22 -0700 Subject: [PATCH 28/41] Change condition to fail if the length is not the expected. --- authority/provisioner/azure.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/authority/provisioner/azure.go b/authority/provisioner/azure.go index 725d0b19..d0157bd0 100644 --- a/authority/provisioner/azure.go +++ b/authority/provisioner/azure.go @@ -241,7 +241,7 @@ func (p *Azure) AuthorizeSign(token string) ([]SignOption, error) { } re := azureXMSMirIDRegExp.FindStringSubmatch(claims.XMSMirID) - if len(re) == 0 { + if len(re) != 4 { return nil, errors.Errorf("error parsing xms_mirid claim: %s", claims.XMSMirID) } group, name := re[2], re[3] From a54bf925ebb0f6dd635c59cce73f01ee4debacea Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Mon, 3 Jun 2019 11:56:42 -0700 Subject: [PATCH 29/41] Add filtering by GCP Project ID. Fixes smallstep/step#155 --- authority/provisioner/gcp.go | 15 +++++++++++++++ authority/provisioner/gcp_test.go | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/authority/provisioner/gcp.go b/authority/provisioner/gcp.go index bc221cbd..8ee3d86c 100644 --- a/authority/provisioner/gcp.go +++ b/authority/provisioner/gcp.go @@ -70,6 +70,7 @@ type GCP struct { Type string `json:"type"` Name string `json:"name"` ServiceAccounts []string `json:"serviceAccounts"` + ProjectIDs []string `json:"projectIDs"` DisableCustomSANs bool `json:"disableCustomSANs"` DisableTrustOnFirstUse bool `json:"disableTrustOnFirstUse"` Claims *Claims `json:"claims,omitempty"` @@ -284,6 +285,20 @@ func (p *GCP) authorizeToken(token string) (*gcpPayload, error) { } } + // validate projects + if len(p.ProjectIDs) > 0 { + var found bool + for _, pi := range p.ProjectIDs { + if pi == claims.Google.ComputeEngine.ProjectID { + found = true + break + } + } + if !found { + return nil, errors.New("invalid token: invalid project id") + } + } + switch { case claims.Google.ComputeEngine.InstanceID == "": return nil, errors.New("token google.compute_engine.instance_id cannot be empty") diff --git a/authority/provisioner/gcp_test.go b/authority/provisioner/gcp_test.go index 4e63c2b1..75eac9bf 100644 --- a/authority/provisioner/gcp_test.go +++ b/authority/provisioner/gcp_test.go @@ -203,6 +203,10 @@ func TestGCP_AuthorizeSign(t *testing.T) { assert.FatalError(t, err) p2.DisableCustomSANs = true + p3, err := generateGCP() + assert.FatalError(t, err) + p3.ProjectIDs = []string{"other-project-id"} + aKey, err := generateJSONWebKey() assert.FatalError(t, err) @@ -216,6 +220,11 @@ func TestGCP_AuthorizeSign(t *testing.T) { "instance-id", "instance-name", "project-id", "zone", time.Now(), &p2.keyStore.keySet.Keys[0]) assert.FatalError(t, err) + t3, err := generateGCPToken(p3.ServiceAccounts[0], + "https://accounts.google.com", p3.GetID(), + "instance-id", "instance-name", "other-project-id", "zone", + time.Now(), &p3.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) failKey, err := generateGCPToken(p1.ServiceAccounts[0], "https://accounts.google.com", p1.GetID(), @@ -247,6 +256,11 @@ func TestGCP_AuthorizeSign(t *testing.T) { "instance-id", "instance-name", "project-id", "zone", time.Now(), &p1.keyStore.keySet.Keys[0]) assert.FatalError(t, err) + failInvalidProjectID, err := generateGCPToken(p3.ServiceAccounts[0], + "https://accounts.google.com", p3.GetID(), + "instance-id", "instance-name", "project-id", "zone", + time.Now(), &p3.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) failInstanceID, err := generateGCPToken(p1.ServiceAccounts[0], "https://accounts.google.com", p1.GetID(), "", "instance-name", "project-id", "zone", @@ -280,6 +294,7 @@ func TestGCP_AuthorizeSign(t *testing.T) { }{ {"ok", p1, args{t1}, 4, false}, {"ok", p2, args{t2}, 5, false}, + {"ok", p3, args{t3}, 4, false}, {"fail token", p1, args{"token"}, 0, true}, {"fail key", p1, args{failKey}, 0, true}, {"fail iss", p1, args{failIss}, 0, true}, @@ -287,6 +302,7 @@ func TestGCP_AuthorizeSign(t *testing.T) { {"fail exp", p1, args{failExp}, 0, true}, {"fail nbf", p1, args{failNbf}, 0, true}, {"fail service account", p1, args{failServiceAccount}, 0, true}, + {"fail invalid project id", p3, args{failInvalidProjectID}, 0, true}, {"fail instance id", p1, args{failInstanceID}, 0, true}, {"fail instance name", p1, args{failInstanceName}, 0, true}, {"fail project id", p1, args{failProjectID}, 0, true}, From 0a756ce9d0d9269ab55de7552dd0a4c94b7ff80b Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Mon, 3 Jun 2019 17:19:44 -0700 Subject: [PATCH 30/41] Use on GCP audiences with the format https://#/ Fixes smallstep/step#156 --- authority/provisioner/collection.go | 25 ++++++++++++++++++- authority/provisioner/gcp.go | 28 +++++++++++++++------ authority/provisioner/gcp_test.go | 31 ++++++++++++++--------- authority/provisioner/provisioner.go | 37 +++++++++++++++++++++++++++- authority/provisioner/utils_test.go | 6 ++++- 5 files changed, 104 insertions(+), 23 deletions(-) diff --git a/authority/provisioner/collection.go b/authority/provisioner/collection.go index c3c6518c..d0525ad3 100644 --- a/authority/provisioner/collection.go +++ b/authority/provisioner/collection.go @@ -66,8 +66,21 @@ func (c *Collection) Load(id string) (Interface, bool) { // LoadByToken parses the token claims and loads the provisioner associated. func (c *Collection) LoadByToken(token *jose.JSONWebToken, claims *jose.Claims) (Interface, bool) { + var audiences []string + // Get all audiences with the given fragment + fragment := extractFragment(claims.Audience) + if fragment == "" { + audiences = c.audiences.All() + } else { + audiences = c.audiences.WithFragment(fragment).All() + } + // match with server audiences - if matchesAudience(claims.Audience, c.audiences.All()) { + if matchesAudience(claims.Audience, audiences) { + // Use fragment to get audiences (GCP) + if fragment != "" { + return c.Load(fragment) + } // If matches with stored audiences it will be a JWT token (default), and // the id would be :. return c.Load(claims.Issuer + ":" + token.Headers[0].KeyID) @@ -234,3 +247,13 @@ func stripPort(rawurl string) string { u.Host = u.Hostname() return u.String() } + +// extractFragment extracts the +func extractFragment(audience []string) string { + for _, s := range audience { + if u, err := url.Parse(s); err == nil && u.Fragment != "" { + return u.Fragment + } + } + return "" +} diff --git a/authority/provisioner/gcp.go b/authority/provisioner/gcp.go index 8ee3d86c..953ed4c3 100644 --- a/authority/provisioner/gcp.go +++ b/authority/provisioner/gcp.go @@ -77,12 +77,13 @@ type GCP struct { claimer *Claimer config *gcpConfig keyStore *keyStore + audiences Audiences } // GetID returns the provisioner unique identifier. The name should uniquely // identify any GCP provisioner. func (p *GCP) GetID() string { - return "gcp:" + p.Name + return "gcp/" + p.Name } // GetTokenID returns the identifier of the token. The default value for GCP the @@ -130,20 +131,25 @@ func (p *GCP) GetEncryptedKey() (kid string, key string, ok bool) { } // GetIdentityURL returns the url that generates the GCP token. -func (p *GCP) GetIdentityURL() string { +func (p *GCP) GetIdentityURL(audience string) string { // Initialize config if required p.assertConfig() q := url.Values{} - q.Add("audience", p.GetID()) + q.Add("audience", audience) q.Add("format", "full") q.Add("licenses", "FALSE") return fmt.Sprintf("%s?%s", p.config.IdentityURL, q.Encode()) } // GetIdentityToken does an HTTP request to the identity url. -func (p *GCP) GetIdentityToken() (string, error) { - req, err := http.NewRequest("GET", p.GetIdentityURL(), http.NoBody) +func (p *GCP) GetIdentityToken(caURL string) (string, error) { + audience, err := generateSignAudience(caURL, p.GetID()) + if err != nil { + return "", err + } + + req, err := http.NewRequest("GET", p.GetIdentityURL(audience), http.NoBody) if err != nil { return "", errors.Wrap(err, "error creating identity request") } @@ -183,6 +189,8 @@ func (p *GCP) Init(config Config) error { if err != nil { return err } + + p.audiences = config.Audiences.WithFragment(p.GetID()) return nil } @@ -264,13 +272,17 @@ func (p *GCP) authorizeToken(token string) (*gcpPayload, error) { // According to "rfc7519 JSON Web Token" acceptable skew should be no // more than a few minutes. if err = claims.ValidateWithLeeway(jose.Expected{ - Issuer: "https://accounts.google.com", - Audience: []string{p.GetID()}, - Time: time.Now().UTC(), + Issuer: "https://accounts.google.com", + Time: time.Now().UTC(), }, time.Minute); err != nil { return nil, errors.Wrapf(err, "invalid token") } + // validate audiences with the defaults + if !matchesAudience(claims.Audience, p.audiences.Sign) { + return nil, errors.New("invalid token: invalid audience claim (aud)") + } + // validate subject (service account) if len(p.ServiceAccounts) > 0 { var found bool diff --git a/authority/provisioner/gcp_test.go b/authority/provisioner/gcp_test.go index 75eac9bf..c4a7ac24 100644 --- a/authority/provisioner/gcp_test.go +++ b/authority/provisioner/gcp_test.go @@ -18,9 +18,9 @@ import ( func TestGCP_Getters(t *testing.T) { p, err := generateGCP() assert.FatalError(t, err) - aud := "gcp:" + p.Name - if got := p.GetID(); got != aud { - t.Errorf("GCP.GetID() = %v, want %v", got, aud) + id := "gcp/" + p.Name + if got := p.GetID(); got != id { + t.Errorf("GCP.GetID() = %v, want %v", got, id) } if got := p.GetName(); got != p.Name { t.Errorf("GCP.GetName() = %v, want %v", got, p.Name) @@ -33,8 +33,10 @@ func TestGCP_Getters(t *testing.T) { t.Errorf("GCP.GetEncryptedKey() = (%v, %v, %v), want (%v, %v, %v)", kid, key, ok, "", "", false) } - expected := fmt.Sprintf("http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=%s&format=full&licenses=FALSE", url.QueryEscape(p.GetID())) - if got := p.GetIdentityURL(); got != expected { + + aud := "https://ca.smallstep.com/1.0/sign#" + url.QueryEscape(id) + expected := fmt.Sprintf("http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=%s&format=full&licenses=FALSE", url.QueryEscape(aud)) + if got := p.GetIdentityURL(aud); got != expected { t.Errorf("GCP.GetIdentityURL() = %v, want %v", got, expected) } } @@ -50,7 +52,7 @@ func TestGCP_GetTokenID(t *testing.T) { now := time.Now() t1, err := generateGCPToken(p1.ServiceAccounts[0], - "https://accounts.google.com", "gcp:name", + "https://accounts.google.com", "gcp/name", "instance-id", "instance-name", "project-id", "zone", now, &p1.keyStore.keySet.Keys[0]) assert.FatalError(t, err) @@ -60,7 +62,7 @@ func TestGCP_GetTokenID(t *testing.T) { now, &p2.keyStore.keySet.Keys[0]) assert.FatalError(t, err) - sum := sha256.Sum256([]byte("gcp:name.instance-id")) + sum := sha256.Sum256([]byte("gcp/name.instance-id")) want1 := strings.ToLower(hex.EncodeToString(sum[:])) sum = sha256.Sum256([]byte(t2)) want2 := strings.ToLower(hex.EncodeToString(sum[:])) @@ -114,22 +116,27 @@ func TestGCP_GetIdentityToken(t *testing.T) { })) defer srv.Close() + type args struct { + caURL string + } tests := []struct { name string gcp *GCP + args args identityURL string want string wantErr bool }{ - {"ok", p1, srv.URL, t1, false}, - {"fail request", p1, srv.URL + "/bad-request", "", true}, - {"fail url", p1, "://ca.smallstep.com", "", true}, - {"fail connect", p1, "foobarzar", "", true}, + {"ok", p1, args{"https://ca"}, srv.URL, t1, false}, + {"fail ca url", p1, args{"://ca"}, srv.URL, "", true}, + {"fail request", p1, args{"https://ca"}, srv.URL + "/bad-request", "", true}, + {"fail url", p1, args{"https://ca"}, "://ca.smallstep.com", "", true}, + {"fail connect", p1, args{"https://ca"}, "foobarzar", "", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tt.gcp.config.IdentityURL = tt.identityURL - got, err := tt.gcp.GetIdentityToken() + got, err := tt.gcp.GetIdentityToken(tt.args.caURL) t.Log(err) if (err != nil) != tt.wantErr { t.Errorf("GCP.GetIdentityToken() error = %v, wantErr %v", err, tt.wantErr) diff --git a/authority/provisioner/provisioner.go b/authority/provisioner/provisioner.go index 8dc95586..29ebc902 100644 --- a/authority/provisioner/provisioner.go +++ b/authority/provisioner/provisioner.go @@ -3,6 +3,7 @@ package provisioner import ( "crypto/x509" "encoding/json" + "net/url" "strings" "github.com/pkg/errors" @@ -28,10 +29,44 @@ type Audiences struct { } // All returns all supported audiences across all request types in one list. -func (a *Audiences) All() []string { +func (a Audiences) All() []string { return append(a.Sign, a.Revoke...) } +// WithFragment returns a copy of audiences where the url audiences contains the +// given fragment. +func (a Audiences) WithFragment(fragment string) Audiences { + ret := Audiences{ + Sign: make([]string, len(a.Sign)), + Revoke: make([]string, len(a.Revoke)), + } + for i, s := range a.Sign { + if u, err := url.Parse(s); err == nil { + ret.Sign[i] = u.ResolveReference(&url.URL{Fragment: fragment}).String() + } else { + ret.Sign[i] = s + } + } + for i, s := range a.Revoke { + if u, err := url.Parse(s); err == nil { + ret.Revoke[i] = u.ResolveReference(&url.URL{Fragment: fragment}).String() + } else { + ret.Revoke[i] = s + } + } + return ret +} + +// generateSignAudience generates a sign audience with the format +// https:///1.0/sign#provisionerID +func generateSignAudience(caURL string, provisionerID string) (string, error) { + u, err := url.Parse(caURL) + if err != nil { + return "", errors.Wrapf(err, "error parsing %s", caURL) + } + return u.ResolveReference(&url.URL{Path: "/1.0/sign", Fragment: provisionerID}).String(), nil +} + // Type indicates the provisioner Type. type Type int diff --git a/authority/provisioner/utils_test.go b/authority/provisioner/utils_test.go index 23175677..d89cbc5d 100644 --- a/authority/provisioner/utils_test.go +++ b/authority/provisioner/utils_test.go @@ -229,6 +229,7 @@ func generateGCP() (*GCP, error) { keySet: jose.JSONWebKeySet{Keys: []jose.JSONWebKey{*jwk}}, expiry: time.Now().Add(24 * time.Hour), }, + audiences: testAudiences.WithFragment("gcp/" + name), }, nil } @@ -492,7 +493,10 @@ func generateGCPToken(sub, iss, aud, instanceID, instanceName, projectID, zone s if err != nil { return "", err } - + aud, err = generateSignAudience("https://ca.smallstep.com", aud) + if err != nil { + return "", err + } claims := gcpPayload{ Claims: jose.Claims{ Subject: sub, From 4cef086c00ade69793298abed4acfa9f2e562359 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Mon, 3 Jun 2019 17:28:39 -0700 Subject: [PATCH 31/41] Allow to use emails as service accounts on GCP Fixes smallstep/step#163 --- authority/provisioner/gcp.go | 2 +- authority/provisioner/gcp_test.go | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/authority/provisioner/gcp.go b/authority/provisioner/gcp.go index 953ed4c3..eed4e672 100644 --- a/authority/provisioner/gcp.go +++ b/authority/provisioner/gcp.go @@ -287,7 +287,7 @@ func (p *GCP) authorizeToken(token string) (*gcpPayload, error) { if len(p.ServiceAccounts) > 0 { var found bool for _, sa := range p.ServiceAccounts { - if sa == claims.Subject { + if sa == claims.Subject || sa == claims.Email { found = true break } diff --git a/authority/provisioner/gcp_test.go b/authority/provisioner/gcp_test.go index c4a7ac24..47013bdf 100644 --- a/authority/provisioner/gcp_test.go +++ b/authority/provisioner/gcp_test.go @@ -213,6 +213,7 @@ func TestGCP_AuthorizeSign(t *testing.T) { p3, err := generateGCP() assert.FatalError(t, err) p3.ProjectIDs = []string{"other-project-id"} + p3.ServiceAccounts = []string{"foo@developer.gserviceaccount.com"} aKey, err := generateJSONWebKey() assert.FatalError(t, err) From c431538ff23dd5e47ee3c92b39a8f2aabd2f4d08 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Tue, 4 Jun 2019 15:57:15 -0700 Subject: [PATCH 32/41] Add support for instance age check in GCP. Fixes smallstep/step#164 --- authority/provisioner/duration.go | 8 ++++++++ authority/provisioner/duration_test.go | 21 +++++++++++++++++++++ authority/provisioner/gcp.go | 13 ++++++++++++- authority/provisioner/gcp_test.go | 25 ++++++++++++++++++------- authority/provisioner/utils_test.go | 2 +- 5 files changed, 60 insertions(+), 9 deletions(-) diff --git a/authority/provisioner/duration.go b/authority/provisioner/duration.go index 38d504a3..68ffbb0a 100644 --- a/authority/provisioner/duration.go +++ b/authority/provisioner/duration.go @@ -43,3 +43,11 @@ func (d *Duration) UnmarshalJSON(data []byte) (err error) { d.Duration = _d return } + +// Value returns 0 if the duration is null, the inner duration otherwise. +func (d *Duration) Value() time.Duration { + if d == nil { + return 0 + } + return d.Duration +} diff --git a/authority/provisioner/duration_test.go b/authority/provisioner/duration_test.go index 4f7304a0..faf5e7f4 100644 --- a/authority/provisioner/duration_test.go +++ b/authority/provisioner/duration_test.go @@ -59,3 +59,24 @@ func TestDuration_MarshalJSON(t *testing.T) { }) } } + +func TestDuration_Value(t *testing.T) { + var dur *Duration + tests := []struct { + name string + duration *Duration + want time.Duration + }{ + {"ok", &Duration{Duration: 1 * time.Minute}, 1 * time.Minute}, + {"ok new", new(Duration), 0}, + {"ok nil", nil, 0}, + {"ok nil var", dur, 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.duration.Value(); got != tt.want { + t.Errorf("Duration.Value() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/authority/provisioner/gcp.go b/authority/provisioner/gcp.go index eed4e672..421ec77e 100644 --- a/authority/provisioner/gcp.go +++ b/authority/provisioner/gcp.go @@ -73,6 +73,7 @@ type GCP struct { ProjectIDs []string `json:"projectIDs"` DisableCustomSANs bool `json:"disableCustomSANs"` DisableTrustOnFirstUse bool `json:"disableTrustOnFirstUse"` + InstanceAge Duration `json:"instanceAge,omitempty"` Claims *Claims `json:"claims,omitempty"` claimer *Claimer config *gcpConfig @@ -177,6 +178,8 @@ func (p *GCP) Init(config Config) error { 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") } // Initialize config p.assertConfig() @@ -271,9 +274,10 @@ func (p *GCP) authorizeToken(token string) (*gcpPayload, error) { // According to "rfc7519 JSON Web Token" acceptable skew should be no // more than a few minutes. + now := time.Now().UTC() if err = claims.ValidateWithLeeway(jose.Expected{ Issuer: "https://accounts.google.com", - Time: time.Now().UTC(), + Time: now, }, time.Minute); err != nil { return nil, errors.Wrapf(err, "invalid token") } @@ -311,6 +315,13 @@ func (p *GCP) authorizeToken(token string) (*gcpPayload, error) { } } + // validate instance age + if d := p.InstanceAge.Value(); d > 0 { + if now.Sub(claims.Google.ComputeEngine.InstanceCreationTimestamp.Time()) > d { + return nil, errors.New("token google.compute_engine.instance_creation_timestamp is too old") + } + } + switch { case claims.Google.ComputeEngine.InstanceID == "": return nil, errors.New("token google.compute_engine.instance_id cannot be empty") diff --git a/authority/provisioner/gcp_test.go b/authority/provisioner/gcp_test.go index 47013bdf..ca3a9507 100644 --- a/authority/provisioner/gcp_test.go +++ b/authority/provisioner/gcp_test.go @@ -159,11 +159,12 @@ func TestGCP_Init(t *testing.T) { badClaims := &Claims{ DefaultTLSDur: &Duration{0}, } - + zero := Duration{Duration: 0} type fields struct { Type string Name string ServiceAccounts []string + InstanceAge Duration Claims *Claims } type args struct { @@ -176,12 +177,14 @@ func TestGCP_Init(t *testing.T) { args args wantErr bool }{ - {"ok", fields{"GCP", "name", nil, nil}, args{config, srv.URL}, false}, - {"ok", fields{"GCP", "name", []string{"service-account"}, nil}, args{config, srv.URL}, false}, - {"bad type", fields{"", "name", nil, nil}, args{config, srv.URL}, true}, - {"bad name", fields{"GCP", "", nil, nil}, args{config, srv.URL}, true}, - {"bad claims", fields{"GCP", "name", nil, badClaims}, args{config, srv.URL}, true}, - {"bad certs", fields{"GCP", "name", nil, nil}, args{config, srv.URL + "/error"}, true}, + {"ok", fields{"GCP", "name", nil, zero, nil}, args{config, srv.URL}, false}, + {"ok", fields{"GCP", "name", []string{"service-account"}, zero, nil}, args{config, srv.URL}, false}, + {"ok", fields{"GCP", "name", []string{"service-account"}, Duration{Duration: 1 * time.Minute}, nil}, args{config, srv.URL}, false}, + {"bad type", fields{"", "name", nil, zero, nil}, args{config, srv.URL}, true}, + {"bad name", fields{"GCP", "", nil, zero, nil}, args{config, srv.URL}, true}, + {"bad duration", fields{"GCP", "name", nil, Duration{Duration: -1 * time.Minute}, nil}, args{config, srv.URL}, true}, + {"bad claims", fields{"GCP", "name", nil, zero, badClaims}, args{config, srv.URL}, true}, + {"bad certs", fields{"GCP", "name", nil, zero, nil}, args{config, srv.URL + "/error"}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -189,6 +192,7 @@ func TestGCP_Init(t *testing.T) { Type: tt.fields.Type, Name: tt.fields.Name, ServiceAccounts: tt.fields.ServiceAccounts, + InstanceAge: tt.fields.InstanceAge, Claims: tt.fields.Claims, config: &gcpConfig{ CertsURL: tt.args.certsURL, @@ -214,6 +218,7 @@ func TestGCP_AuthorizeSign(t *testing.T) { assert.FatalError(t, err) p3.ProjectIDs = []string{"other-project-id"} p3.ServiceAccounts = []string{"foo@developer.gserviceaccount.com"} + p3.InstanceAge = Duration{1 * time.Minute} aKey, err := generateJSONWebKey() assert.FatalError(t, err) @@ -269,6 +274,11 @@ func TestGCP_AuthorizeSign(t *testing.T) { "instance-id", "instance-name", "project-id", "zone", time.Now(), &p3.keyStore.keySet.Keys[0]) assert.FatalError(t, err) + failInvalidInstanceAge, err := generateGCPToken(p3.ServiceAccounts[0], + "https://accounts.google.com", p3.GetID(), + "instance-id", "instance-name", "other-project-id", "zone", + time.Now().Add(-1*time.Minute), &p3.keyStore.keySet.Keys[0]) + assert.FatalError(t, err) failInstanceID, err := generateGCPToken(p1.ServiceAccounts[0], "https://accounts.google.com", p1.GetID(), "", "instance-name", "project-id", "zone", @@ -311,6 +321,7 @@ func TestGCP_AuthorizeSign(t *testing.T) { {"fail nbf", p1, args{failNbf}, 0, true}, {"fail service account", p1, args{failServiceAccount}, 0, true}, {"fail invalid project id", p3, args{failInvalidProjectID}, 0, true}, + {"fail invalid instance age", p3, args{failInvalidInstanceAge}, 0, true}, {"fail instance id", p1, args{failInstanceID}, 0, true}, {"fail instance name", p1, args{failInstanceName}, 0, true}, {"fail project id", p1, args{failProjectID}, 0, true}, diff --git a/authority/provisioner/utils_test.go b/authority/provisioner/utils_test.go index d89cbc5d..a2bfeee0 100644 --- a/authority/provisioner/utils_test.go +++ b/authority/provisioner/utils_test.go @@ -513,7 +513,7 @@ func generateGCPToken(sub, iss, aud, instanceID, instanceName, projectID, zone s ComputeEngine: gcpComputeEnginePayload{ InstanceID: instanceID, InstanceName: instanceName, - InstanceCreationTimestamp: jose.NewNumericDate(iat.Add(-24 * time.Hour)), + InstanceCreationTimestamp: jose.NewNumericDate(iat), ProjectID: projectID, ProjectNumber: 1234567890, Zone: zone, From 536ec36b9eb3b8d8ba9b70005c1546cde5e5370b Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Tue, 4 Jun 2019 16:31:33 -0700 Subject: [PATCH 33/41] Add support for instance age check in AWS. Fixes smallstep/step#164 --- authority/provisioner/aws.go | 14 +++++++++++++- authority/provisioner/aws_test.go | 19 +++++++++++++++---- authority/provisioner/utils_test.go | 2 +- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/authority/provisioner/aws.go b/authority/provisioner/aws.go index a6984864..a4fd3e9f 100644 --- a/authority/provisioner/aws.go +++ b/authority/provisioner/aws.go @@ -121,6 +121,7 @@ type AWS struct { Accounts []string `json:"accounts"` DisableCustomSANs bool `json:"disableCustomSANs"` DisableTrustOnFirstUse bool `json:"disableTrustOnFirstUse"` + InstanceAge Duration `json:"instanceAge,omitempty"` Claims *Claims `json:"claims,omitempty"` claimer *Claimer config *awsConfig @@ -236,6 +237,8 @@ func (p *AWS) 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.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 { @@ -370,11 +373,12 @@ func (p *AWS) authorizeToken(token string) (*awsPayload, error) { // 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, Subject: doc.InstanceID, Audience: []string{p.GetID()}, - Time: time.Now().UTC(), + Time: now, }, time.Minute); err != nil { return nil, errors.Wrapf(err, "invalid token") } @@ -392,6 +396,14 @@ func (p *AWS) authorizeToken(token string) (*awsPayload, error) { return nil, errors.New("invalid identity document: accountId is not valid") } } + + // validate instance age + if d := p.InstanceAge.Value(); d > 0 { + if now.Sub(doc.PendingTime) > d { + return nil, errors.New("identity document pendingTime is too old") + } + } + payload.document = doc return &payload, nil } diff --git a/authority/provisioner/aws_test.go b/authority/provisioner/aws_test.go index 429e583e..05181809 100644 --- a/authority/provisioner/aws_test.go +++ b/authority/provisioner/aws_test.go @@ -150,6 +150,7 @@ func TestAWS_Init(t *testing.T) { badClaims := &Claims{ DefaultTLSDur: &Duration{0}, } + zero := Duration{Duration: 0} type fields struct { Type string @@ -157,6 +158,7 @@ func TestAWS_Init(t *testing.T) { Accounts []string DisableCustomSANs bool DisableTrustOnFirstUse bool + InstanceAge Duration Claims *Claims } type args struct { @@ -168,10 +170,12 @@ func TestAWS_Init(t *testing.T) { args args wantErr bool }{ - {"ok", fields{"AWS", "name", []string{"account"}, false, false, nil}, args{config}, false}, - {"fail type ", fields{"", "name", []string{"account"}, false, false, nil}, args{config}, true}, - {"fail name", fields{"AWS", "", []string{"account"}, false, false, nil}, args{config}, true}, - {"fail claims", fields{"AWS", "name", []string{"account"}, false, false, badClaims}, args{config}, true}, + {"ok", fields{"AWS", "name", []string{"account"}, false, false, zero, nil}, args{config}, false}, + {"ok", fields{"AWS", "name", []string{"account"}, true, true, Duration{Duration: 1 * time.Minute}, nil}, args{config}, false}, + {"fail type ", fields{"", "name", []string{"account"}, false, false, zero, nil}, args{config}, true}, + {"fail name", fields{"AWS", "", []string{"account"}, false, false, zero, nil}, args{config}, true}, + {"bad instance age", fields{"AWS", "name", []string{"account"}, false, false, Duration{Duration: -1 * time.Minute}, nil}, args{config}, true}, + {"fail claims", fields{"AWS", "name", []string{"account"}, false, false, zero, badClaims}, args{config}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -181,6 +185,7 @@ func TestAWS_Init(t *testing.T) { Accounts: tt.fields.Accounts, DisableCustomSANs: tt.fields.DisableCustomSANs, DisableTrustOnFirstUse: tt.fields.DisableTrustOnFirstUse, + InstanceAge: tt.fields.InstanceAge, Claims: tt.fields.Claims, } if err := p.Init(tt.args.config); (err != nil) != tt.wantErr { @@ -200,6 +205,7 @@ func TestAWS_AuthorizeSign(t *testing.T) { p2.Accounts = p1.Accounts p2.config = p1.config p2.DisableCustomSANs = true + p2.InstanceAge = Duration{1 * time.Minute} p3, err := generateAWS() assert.FatalError(t, err) @@ -266,6 +272,10 @@ func TestAWS_AuthorizeSign(t *testing.T) { "instance-id", awsIssuer, p1.GetID(), p1.Accounts[0], "instance-id", "127.0.0.1", "us-west-1", time.Now(), badKey) assert.FatalError(t, err) + failInstanceAge, err := generateAWSToken( + "instance-id", awsIssuer, p2.GetID(), p2.Accounts[0], "instance-id", + "127.0.0.1", "us-west-1", time.Now().Add(-1*time.Minute), key) + assert.FatalError(t, err) type args struct { token string @@ -292,6 +302,7 @@ func TestAWS_AuthorizeSign(t *testing.T) { {"fail exp", p1, args{failExp}, 0, true}, {"fail nbf", p1, args{failNbf}, 0, true}, {"fail key", p1, args{failKey}, 0, true}, + {"fail instance age", p2, args{failInstanceAge}, 0, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/authority/provisioner/utils_test.go b/authority/provisioner/utils_test.go index a2bfeee0..6803089d 100644 --- a/authority/provisioner/utils_test.go +++ b/authority/provisioner/utils_test.go @@ -531,7 +531,7 @@ func generateAWSToken(sub, iss, aud, accountID, instanceID, privateIP, region st ImageID: "ami-123123", InstanceID: instanceID, InstanceType: "t2.micro", - PendingTime: time.Now(), + PendingTime: iat, PrivateIP: privateIP, Region: region, Version: "2017-09-30", From a36972d840d7d10dff0ea89797ddafb543144319 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 5 Jun 2019 10:50:08 -0700 Subject: [PATCH 34/41] Add instanceAge and projectIds docs. --- docs/provisioners.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/provisioners.md b/docs/provisioners.md index cbff14a9..0038659a 100644 --- a/docs/provisioners.md +++ b/docs/provisioners.md @@ -177,6 +177,7 @@ In the ca.json, an AWS provisioner looks like: "accounts": ["1234567890"], "disableCustomSANs": false, "disableTrustOnFirstUse": false, + "instanceAge": "1h", "claims": { "maxTLSCertDuration": "2160h", "defaultTLSCertDuration": "2160h" @@ -201,6 +202,9 @@ In the ca.json, an AWS provisioner looks like: granted per instance, but if the option is set to true this limit is not set and different tokens can be used to get different certificates. +* `instanceAge` (optional): the maximum age of an instance to grant a + certificate. The instance age is a string using the duration format. + * `claims` (optional): overwrites the default claims set in the authority, see the [JWK](#jwk) section for all the options. @@ -217,8 +221,10 @@ In the ca.json, a GCP provisioner looks like: "type": "GCP", "name": "Google Cloud", "serviceAccounts": ["1234567890"], + "projectIDs": ["project-id"], "disableCustomSANs": false, "disableTrustOnFirstUse": false, + "instanceAge": "1h", "claims": { "maxTLSCertDuration": "2160h", "defaultTLSCertDuration": "2160h" @@ -235,6 +241,9 @@ In the ca.json, a GCP provisioner looks like: allowed to use this provisioner. If none is specified, all service accounts will be valid. +* `projectIDs` (optional): the list of project identifiers that are allowed to + use this provisioner. If non is specified all project will be valid. + * `disableCustomSANs` (optional): by default custom SANs are valid, but if this option is set to true only the SANs available in the instance identity document will be valid, these are the DNS @@ -245,6 +254,9 @@ In the ca.json, a GCP provisioner looks like: granted per instance, but if the option is set to true this limit is not set and different tokens can be used to get different certificates. +* `instanceAge` (optional): the maximum age of an instance to grant a + certificate. The instance age is a string using the duration format. + * `claims` (optional): overwrites the default claims set in the authority, see the [JWK](#jwk) section for all the options. From 6e4a09651a620c657cf825c04300ef41d9cd6c0e Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 5 Jun 2019 11:04:00 -0700 Subject: [PATCH 35/41] Add comments with links to cloud docs. --- authority/provisioner/aws.go | 6 ++++++ authority/provisioner/azure.go | 4 ++++ authority/provisioner/gcp.go | 6 ++++++ 3 files changed, 16 insertions(+) diff --git a/authority/provisioner/aws.go b/authority/provisioner/aws.go index a4fd3e9f..29d937e9 100644 --- a/authority/provisioner/aws.go +++ b/authority/provisioner/aws.go @@ -115,6 +115,12 @@ type awsInstanceIdentityDocument struct { // 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 an 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 { Type string `json:"type"` Name string `json:"name"` diff --git a/authority/provisioner/azure.go b/authority/provisioner/azure.go index d0157bd0..6ec69095 100644 --- a/authority/provisioner/azure.go +++ b/authority/provisioner/azure.go @@ -74,6 +74,10 @@ type azurePayload struct { // 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. +// +// Microsoft Azure identity docs are available at +// https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/how-to-use-vm-token +// and https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service type Azure struct { Type string `json:"type"` Name string `json:"name"` diff --git a/authority/provisioner/gcp.go b/authority/provisioner/gcp.go index 421ec77e..71f4413a 100644 --- a/authority/provisioner/gcp.go +++ b/authority/provisioner/gcp.go @@ -66,6 +66,12 @@ func newGCPConfig() *gcpConfig { // 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 an instance_creation_timestamp +// within the given period will be accepted. +// +// Google Identity docs are available at +// https://cloud.google.com/compute/docs/instances/verifying-instance-identity type GCP struct { Type string `json:"type"` Name string `json:"name"` From 37f2096dffe95ec50014df6039e898b522e6ef4e Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 5 Jun 2019 17:52:29 -0700 Subject: [PATCH 36/41] Add Stringer interface to provisioner.Type. Add missing file. --- authority/provisioner/provisioner.go | 18 ++++++++++++++++ authority/provisioner/provisioner_test.go | 26 +++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 authority/provisioner/provisioner_test.go diff --git a/authority/provisioner/provisioner.go b/authority/provisioner/provisioner.go index 29ebc902..291a74d5 100644 --- a/authority/provisioner/provisioner.go +++ b/authority/provisioner/provisioner.go @@ -89,6 +89,24 @@ const ( SignAudienceKey = "sign" ) +// String returns the string representation of the type. +func (t Type) String() string { + switch t { + case TypeJWK: + return "JWK" + case TypeOIDC: + return "OIDC" + case TypeGCP: + return "GCP" + case TypeAWS: + return "AWS" + case TypeAzure: + return "Azure" + default: + return "" + } +} + // Config defines the default parameters used in the initialization of // provisioners. type Config struct { diff --git a/authority/provisioner/provisioner_test.go b/authority/provisioner/provisioner_test.go new file mode 100644 index 00000000..11615e1a --- /dev/null +++ b/authority/provisioner/provisioner_test.go @@ -0,0 +1,26 @@ +package provisioner + +import "testing" + +func TestType_String(t *testing.T) { + tests := []struct { + name string + t Type + want string + }{ + {"JWK", TypeJWK, "JWK"}, + {"OIDC", TypeOIDC, "OIDC"}, + {"AWS", TypeAWS, "AWS"}, + {"Azure", TypeAzure, "Azure"}, + {"GCP", TypeGCP, "GCP"}, + {"noop", noopType, ""}, + {"notFound", 1000, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.t.String(); got != tt.want { + t.Errorf("Type.String() = %v, want %v", got, tt.want) + } + }) + } +} From 4fa9e9333d2e0c8f5d1b29ce17bee8fe7f3e31bc Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Wed, 5 Jun 2019 17:53:28 -0700 Subject: [PATCH 37/41] Add NewDuration constructor. --- authority/provisioner/duration.go | 16 +++++++++++--- authority/provisioner/duration_test.go | 29 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/authority/provisioner/duration.go b/authority/provisioner/duration.go index 68ffbb0a..d18a81e9 100644 --- a/authority/provisioner/duration.go +++ b/authority/provisioner/duration.go @@ -12,6 +12,16 @@ type Duration struct { time.Duration } +// NewDuration parses a duration string and returns a Duration type or and error +// if the given string is not a duration. +func NewDuration(s string) (*Duration, error) { + d, err := time.ParseDuration(s) + if err != nil { + return nil, errors.Wrapf(err, "error parsing %s as duration", s) + } + return &Duration{Duration: d}, nil +} + // MarshalJSON parses a duration string and sets it to the duration. // // A duration string is a possibly signed sequence of decimal numbers, each with @@ -29,7 +39,7 @@ func (d *Duration) MarshalJSON() ([]byte, error) { func (d *Duration) UnmarshalJSON(data []byte) (err error) { var ( s string - _d time.Duration + dd time.Duration ) if d == nil { return errors.New("duration cannot be nil") @@ -37,10 +47,10 @@ func (d *Duration) UnmarshalJSON(data []byte) (err error) { if err = json.Unmarshal(data, &s); err != nil { return errors.Wrapf(err, "error unmarshaling %s", data) } - if _d, err = time.ParseDuration(s); err != nil { + if dd, err = time.ParseDuration(s); err != nil { return errors.Wrapf(err, "error parsing %s as duration", s) } - d.Duration = _d + d.Duration = dd return } diff --git a/authority/provisioner/duration_test.go b/authority/provisioner/duration_test.go index faf5e7f4..828064cc 100644 --- a/authority/provisioner/duration_test.go +++ b/authority/provisioner/duration_test.go @@ -6,6 +6,35 @@ import ( "time" ) +func TestNewDuration(t *testing.T) { + type args struct { + s string + } + tests := []struct { + name string + args args + want *Duration + wantErr bool + }{ + {"ok", args{"1h2m3s"}, &Duration{Duration: 3723 * time.Second}, false}, + {"fail empty", args{""}, nil, true}, + {"fail number", args{"123"}, nil, true}, + {"fail string", args{"1hour"}, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewDuration(tt.args.s) + if (err != nil) != tt.wantErr { + t.Errorf("NewDuration() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("NewDuration() = %v, want %v", got, tt.want) + } + }) + } +} + func TestDuration_UnmarshalJSON(t *testing.T) { type args struct { data []byte From 2491593cdd0bb71e2133461b857cddfc99cd0950 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Thu, 6 Jun 2019 12:49:51 -0700 Subject: [PATCH 38/41] Add ca-url based audience for AWS tokens Fixes smallstep/step#156 --- authority/provisioner/aws.go | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/authority/provisioner/aws.go b/authority/provisioner/aws.go index 29d937e9..33c35fe0 100644 --- a/authority/provisioner/aws.go +++ b/authority/provisioner/aws.go @@ -131,11 +131,12 @@ type AWS struct { 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 + return "aws/" + p.Name } // GetTokenID returns the identifier of the token. @@ -170,7 +171,7 @@ func (p *AWS) GetEncryptedKey() (kid string, key string, ok bool) { // GetIdentityToken retrieves the identity document and it's signature and // generates a token with them. -func (p *AWS) GetIdentityToken() (string, error) { +func (p *AWS) GetIdentityToken(caURL string) (string, error) { // Initialize the config if this method is used from the cli. if err := p.assertConfig(); err != nil { return "", err @@ -196,6 +197,11 @@ func (p *AWS) GetIdentityToken() (string, error) { 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. @@ -216,7 +222,7 @@ func (p *AWS) GetIdentityToken() (string, error) { Claims: jose.Claims{ Issuer: awsIssuer, Subject: idoc.InstanceID, - Audience: []string{p.GetID()}, + Audience: []string{audience}, Expiry: jose.NewNumericDate(now.Add(5 * time.Minute)), NotBefore: jose.NewNumericDate(now), IssuedAt: jose.NewNumericDate(now), @@ -254,6 +260,7 @@ func (p *AWS) Init(config Config) (err error) { if p.config, err = newAWSConfig(); err != nil { return err } + p.audiences = config.Audiences.WithFragment(p.GetID()) return nil } @@ -381,14 +388,18 @@ func (p *AWS) authorizeToken(token string) (*awsPayload, error) { // more than a few minutes. now := time.Now().UTC() if err = payload.ValidateWithLeeway(jose.Expected{ - Issuer: awsIssuer, - Subject: doc.InstanceID, - Audience: []string{p.GetID()}, - Time: now, + Issuer: awsIssuer, + Subject: doc.InstanceID, + Time: now, }, time.Minute); err != nil { return nil, errors.Wrapf(err, "invalid token") } + // validate audiences with the defaults + if !matchesAudience(payload.Audience, p.audiences.Sign) { + return nil, errors.New("invalid token: invalid audience claim (aud)") + } + // validate accounts if len(p.Accounts) > 0 { var found bool From 37dff5124b5470465d922b5bd380f33c5870c8d0 Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Thu, 6 Jun 2019 13:09:00 -0700 Subject: [PATCH 39/41] Fix audience tests. Fixes smallstep/step#156 --- authority/provisioner/aws.go | 1 + authority/provisioner/aws_test.go | 36 ++++++++++++++++++----------- authority/provisioner/utils_test.go | 6 +++++ 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/authority/provisioner/aws.go b/authority/provisioner/aws.go index 33c35fe0..b2e15c5c 100644 --- a/authority/provisioner/aws.go +++ b/authority/provisioner/aws.go @@ -397,6 +397,7 @@ func (p *AWS) authorizeToken(token string) (*awsPayload, error) { // validate audiences with the defaults if !matchesAudience(payload.Audience, p.audiences.Sign) { + fmt.Println(payload.Audience, "vs", p.audiences.Sign) return nil, errors.New("invalid token: invalid audience claim (aud)") } diff --git a/authority/provisioner/aws_test.go b/authority/provisioner/aws_test.go index 05181809..231b9713 100644 --- a/authority/provisioner/aws_test.go +++ b/authority/provisioner/aws_test.go @@ -8,6 +8,7 @@ import ( "encoding/hex" "encoding/pem" "fmt" + "net/url" "strings" "testing" "time" @@ -19,7 +20,7 @@ import ( func TestAWS_Getters(t *testing.T) { p, err := generateAWS() assert.FatalError(t, err) - aud := "aws:" + p.Name + aud := "aws/" + p.Name if got := p.GetID(); got != aud { t.Errorf("AWS.GetID() = %v, want %v", got, aud) } @@ -47,14 +48,14 @@ func TestAWS_GetTokenID(t *testing.T) { p2.config = p1.config p2.DisableTrustOnFirstUse = true - t1, err := p1.GetIdentityToken() + t1, err := p1.GetIdentityToken("https://ca.smallstep.com") assert.FatalError(t, err) _, claims, err := parseAWSToken(t1) assert.FatalError(t, err) sum := sha256.Sum256([]byte(fmt.Sprintf("%s.%s", p1.GetID(), claims.document.InstanceID))) w1 := strings.ToLower(hex.EncodeToString(sum[:])) - t2, err := p2.GetIdentityToken() + t2, err := p2.GetIdentityToken("https://ca.smallstep.com") assert.FatalError(t, err) sum = sha256.Sum256([]byte(t2)) w2 := strings.ToLower(hex.EncodeToString(sum[:])) @@ -110,19 +111,28 @@ func TestAWS_GetIdentityToken(t *testing.T) { p4.config.signatureURL = srv.URL + "/bad-signature" p4.config.identityURL = p1.config.identityURL + caURL := "https://ca.smallstep.com" + u, err := url.Parse(caURL) + assert.FatalError(t, err) + + type args struct { + caURL string + } tests := []struct { name string aws *AWS + args args wantErr bool }{ - {"ok", p1, false}, - {"fail identityURL", p2, true}, - {"fail signatureURL", p3, true}, - {"fail signature", p4, true}, + {"ok", p1, args{caURL}, false}, + {"fail ca url", p1, args{"://ca.smallstep.com"}, true}, + {"fail identityURL", p2, args{caURL}, true}, + {"fail signatureURL", p3, args{caURL}, true}, + {"fail signature", p4, args{caURL}, true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := tt.aws.GetIdentityToken() + got, err := tt.aws.GetIdentityToken(tt.args.caURL) if (err != nil) != tt.wantErr { t.Errorf("AWS.GetIdentityToken() error = %v, wantErr %v", err, tt.wantErr) return @@ -132,7 +142,7 @@ func TestAWS_GetIdentityToken(t *testing.T) { if assert.NoError(t, err) { assert.Equals(t, awsIssuer, c.Issuer) assert.Equals(t, c.document.InstanceID, c.Subject) - assert.Equals(t, jose.Audience{tt.aws.GetID()}, c.Audience) + assert.Equals(t, jose.Audience{u.ResolveReference(&url.URL{Path: "/1.0/sign", Fragment: tt.aws.GetID()}).String()}, c.Audience) assert.Equals(t, tt.aws.Accounts[0], c.document.AccountID) err = tt.aws.config.certificate.CheckSignature( tt.aws.config.signatureAlgorithm, c.Amazon.Document, c.Amazon.Signature) @@ -211,11 +221,11 @@ func TestAWS_AuthorizeSign(t *testing.T) { assert.FatalError(t, err) p3.config = p1.config - t1, err := p1.GetIdentityToken() + t1, err := p1.GetIdentityToken("https://ca.smallstep.com") assert.FatalError(t, err) - t2, err := p2.GetIdentityToken() + t2, err := p2.GetIdentityToken("https://ca.smallstep.com") assert.FatalError(t, err) - t3, err := p3.GetIdentityToken() + t3, err := p3.GetIdentityToken("https://ca.smallstep.com") assert.FatalError(t, err) block, _ := pem.Decode([]byte(awsTestKey)) @@ -354,7 +364,7 @@ func TestAWS_AuthorizeRevoke(t *testing.T) { assert.FatalError(t, err) defer srv.Close() - t1, err := p1.GetIdentityToken() + t1, err := p1.GetIdentityToken("https://ca.smallstep.com") assert.FatalError(t, err) type args struct { diff --git a/authority/provisioner/utils_test.go b/authority/provisioner/utils_test.go index 6803089d..40f4ab05 100644 --- a/authority/provisioner/utils_test.go +++ b/authority/provisioner/utils_test.go @@ -266,6 +266,7 @@ func generateAWS() (*AWS, error) { certificate: cert, signatureAlgorithm: awsSignatureAlgorithm, }, + audiences: testAudiences.WithFragment("aws/" + name), }, nil } @@ -554,6 +555,11 @@ func generateAWSToken(sub, iss, aud, accountID, instanceID, privateIP, region st return "", err } + aud, err = generateSignAudience("https://ca.smallstep.com", aud) + if err != nil { + return "", err + } + claims := awsPayload{ Claims: jose.Claims{ Subject: sub, From b88a2f137306c2ab86d13074e1328bdbb08ca44f Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Thu, 6 Jun 2019 15:24:15 -0700 Subject: [PATCH 40/41] Fix provisioner id in LoadByCertificate --- authority/provisioner/collection.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/authority/provisioner/collection.go b/authority/provisioner/collection.go index d0525ad3..bfaeb25c 100644 --- a/authority/provisioner/collection.go +++ b/authority/provisioner/collection.go @@ -77,7 +77,7 @@ func (c *Collection) LoadByToken(token *jose.JSONWebToken, claims *jose.Claims) // match with server audiences if matchesAudience(claims.Audience, audiences) { - // Use fragment to get audiences (GCP) + // Use fragment to get audiences (GCP, AWS) if fragment != "" { return c.Load(fragment) } @@ -107,7 +107,7 @@ func (c *Collection) LoadByToken(token *jose.JSONWebToken, claims *jose.Claims) return p, ok } } - // Fallback to aud (GCP) + // Fallback to aud return c.Load(payload.Audience[0]) } @@ -124,9 +124,9 @@ func (c *Collection) LoadByCertificate(cert *x509.Certificate) (Interface, bool) case TypeJWK: return c.Load(string(provisioner.Name) + ":" + string(provisioner.CredentialID)) case TypeAWS: - return c.Load("aws:" + string(provisioner.Name)) + return c.Load("aws/" + string(provisioner.Name)) case TypeGCP: - return c.Load("gcp:" + string(provisioner.Name)) + return c.Load("gcp/" + string(provisioner.Name)) default: return c.Load(string(provisioner.CredentialID)) } From 8f8c862c04f03c3556a3128d379a5e784c59395b Mon Sep 17 00:00:00 2001 From: Mariano Cano Date: Fri, 7 Jun 2019 11:24:56 -0700 Subject: [PATCH 41/41] Fix spelling errors. --- authority/provisioner/aws.go | 4 ++-- authority/provisioner/collection.go | 4 ++-- authority/provisioner/duration.go | 2 +- authority/provisioner/provisioner.go | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/authority/provisioner/aws.go b/authority/provisioner/aws.go index b2e15c5c..be142ca3 100644 --- a/authority/provisioner/aws.go +++ b/authority/provisioner/aws.go @@ -116,8 +116,8 @@ type awsInstanceIdentityDocument struct { // with the same instance will be accepted. By default only the first request // will be accepted. // -// If InstanceAge is set, only the instances with an pendingTime within the -// given period 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 diff --git a/authority/provisioner/collection.go b/authority/provisioner/collection.go index bfaeb25c..ca7a5391 100644 --- a/authority/provisioner/collection.go +++ b/authority/provisioner/collection.go @@ -77,7 +77,7 @@ func (c *Collection) LoadByToken(token *jose.JSONWebToken, claims *jose.Claims) // match with server audiences if matchesAudience(claims.Audience, audiences) { - // Use fragment to get audiences (GCP, AWS) + // Use fragment to get provisioner name (GCP, AWS) if fragment != "" { return c.Load(fragment) } @@ -248,7 +248,7 @@ func stripPort(rawurl string) string { return u.String() } -// extractFragment extracts the +// extractFragment extracts the first fragment of an audience url. func extractFragment(audience []string) string { for _, s := range audience { if u, err := url.Parse(s); err == nil && u.Fragment != "" { diff --git a/authority/provisioner/duration.go b/authority/provisioner/duration.go index d18a81e9..1d8d8f7b 100644 --- a/authority/provisioner/duration.go +++ b/authority/provisioner/duration.go @@ -12,7 +12,7 @@ type Duration struct { time.Duration } -// NewDuration parses a duration string and returns a Duration type or and error +// NewDuration parses a duration string and returns a Duration type or an error // if the given string is not a duration. func NewDuration(s string) (*Duration, error) { d, err := time.ParseDuration(s) diff --git a/authority/provisioner/provisioner.go b/authority/provisioner/provisioner.go index 291a74d5..711b0439 100644 --- a/authority/provisioner/provisioner.go +++ b/authority/provisioner/provisioner.go @@ -58,7 +58,7 @@ func (a Audiences) WithFragment(fragment string) Audiences { } // generateSignAudience generates a sign audience with the format -// https:///1.0/sign#provisionerID +// https:///1.0/sign#provisionerID func generateSignAudience(caURL string, provisionerID string) (string, error) { u, err := url.Parse(caURL) if err != nil {