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 {