diff --git a/pkg/ca/intermediateca/intermediateca_test.go b/pkg/ca/intermediateca/intermediateca_test.go index 27216209b..bc67f8896 100644 --- a/pkg/ca/intermediateca/intermediateca_test.go +++ b/pkg/ca/intermediateca/intermediateca_test.go @@ -27,7 +27,7 @@ import ( "testing" ct "github.com/google/certificate-transparency-go" - "github.com/sigstore/fulcio/pkg/challenges" + "github.com/sigstore/fulcio/pkg/ca/x509ca" "github.com/sigstore/fulcio/pkg/test" "github.com/sigstore/sigstore/pkg/cryptoutils" "github.com/sigstore/sigstore/pkg/signature" @@ -156,6 +156,20 @@ func TestIntermediateCAVerifyCertChain(t *testing.T) { } } +type testPrincipal struct{} + +func (tp testPrincipal) Name(context.Context) string { + return "doesntmatter" +} + +func (tp testPrincipal) Embed(ctx context.Context, cert *x509.Certificate) (err error) { + cert.EmailAddresses = []string{"alice@example.com"} + cert.ExtraExtensions, err = x509ca.Extensions{ + Issuer: "example.com", + }.Render() + return +} + func TestCreatePrecertificateAndIssueFinalCertificate(t *testing.T) { rootCert, rootKey, _ := test.GenerateRootCA() subCert, subKey, _ := test.GenerateSubordinateCA(rootCert, rootKey) @@ -164,11 +178,8 @@ func TestCreatePrecertificateAndIssueFinalCertificate(t *testing.T) { certChain := []*x509.Certificate{subCert, rootCert} ica := IntermediateCA{Certs: certChain, Signer: subKey} - precsc, err := ica.CreatePrecertificate(context.TODO(), &challenges.ChallengeResult{ - Issuer: "iss", - TypeVal: challenges.EmailValue, - Value: "foo@example.com", - }, priv.Public()) + + precsc, err := ica.CreatePrecertificate(context.TODO(), testPrincipal{}, priv.Public()) if err != nil { t.Fatalf("error generating precertificate: %v", err) diff --git a/pkg/challenges/challenges.go b/pkg/challenges/challenges.go index 6e27cb294..d308b0705 100644 --- a/pkg/challenges/challenges.go +++ b/pkg/challenges/challenges.go @@ -28,12 +28,12 @@ import ( "github.com/sigstore/fulcio/pkg/ca/x509ca" "github.com/sigstore/fulcio/pkg/config" "github.com/sigstore/fulcio/pkg/identity" + "github.com/sigstore/fulcio/pkg/identity/email" "github.com/sigstore/fulcio/pkg/identity/github" "github.com/sigstore/fulcio/pkg/identity/kubernetes" "github.com/sigstore/fulcio/pkg/identity/spiffe" "github.com/coreos/go-oidc/v3/oidc" - "github.com/sigstore/fulcio/pkg/oauthflow" "github.com/sigstore/sigstore/pkg/cryptoutils" "github.com/sigstore/sigstore/pkg/signature" ) @@ -41,8 +41,7 @@ import ( type ChallengeType int const ( - EmailValue ChallengeType = iota - URIValue + URIValue ChallengeType = iota UsernameValue ) @@ -65,8 +64,6 @@ func (cr *ChallengeResult) Name(context.Context) string { func (cr *ChallengeResult) Embed(ctx context.Context, cert *x509.Certificate) error { switch cr.TypeVal { - case EmailValue: - cert.EmailAddresses = []string{cr.Value} case URIValue: subjectURI, err := url.Parse(cr.Value) if err != nil { @@ -101,32 +98,6 @@ func CheckSignature(pub crypto.PublicKey, proof []byte, subject string) error { return verifier.VerifySignature(bytes.NewReader(proof), strings.NewReader(subject)) } -func email(ctx context.Context, principal *oidc.IDToken) (identity.Principal, error) { - emailAddress, emailVerified, err := oauthflow.EmailFromIDToken(principal) - if !emailVerified { - return nil, errors.New("email_verified claim was false") - } else if err != nil { - return nil, err - } - - cfg, ok := config.FromContext(ctx).GetIssuer(principal.Issuer) - if !ok { - return nil, errors.New("invalid configuration for OIDC ID Token issuer") - } - - issuer, err := oauthflow.IssuerFromIDToken(principal, cfg.IssuerClaim) - if err != nil { - return nil, err - } - - return &ChallengeResult{ - Issuer: issuer, - TypeVal: EmailValue, - Value: emailAddress, - subject: emailAddress, - }, nil -} - func uri(ctx context.Context, principal *oidc.IDToken) (identity.Principal, error) { uriWithSubject := principal.Subject @@ -190,7 +161,7 @@ func PrincipalFromIDToken(ctx context.Context, tok *oidc.IDToken) (identity.Prin var err error switch iss.Type { case config.IssuerTypeEmail: - principal, err = email(ctx, tok) + principal, err = email.PrincipalFromIDToken(ctx, tok) case config.IssuerTypeSpiffe: principal, err = spiffe.PrincipalFromIDToken(ctx, tok) case config.IssuerTypeGithubWorkflow: diff --git a/pkg/challenges/challenges_test.go b/pkg/challenges/challenges_test.go index 090650a14..e7cbe8a83 100644 --- a/pkg/challenges/challenges_test.go +++ b/pkg/challenges/challenges_test.go @@ -29,9 +29,7 @@ import ( "errors" "fmt" "net/url" - "reflect" "testing" - "unsafe" "github.com/coreos/go-oidc/v3/oidc" "github.com/google/go-cmp/cmp" @@ -45,26 +43,6 @@ func TestEmbedChallengeResult(t *testing.T) { WantErr bool WantFacts map[string]func(x509.Certificate) error }{ - `Email challenges should set issuer extension and email subject`: { - Challenge: ChallengeResult{ - Issuer: `example.com`, - TypeVal: EmailValue, - Value: `alice@example.com`, - }, - WantErr: false, - WantFacts: map[string]func(x509.Certificate) error{ - `Certificate should have alice@example.com email subject`: func(cert x509.Certificate) error { - if len(cert.EmailAddresses) != 1 { - return errors.New("no email SAN set for email challenge") - } - if cert.EmailAddresses[0] != `alice@example.com` { - return errors.New("bad email. expected alice@example.com") - } - return nil - }, - `Certificate should have issuer extension set`: factIssuerIs("example.com"), - }, - }, `Good URI value`: { Challenge: ChallengeResult{ Issuer: `foo.example.com`, @@ -278,62 +256,6 @@ func TestUsernameInvalidChar(t *testing.T) { } } -// reflect hack because "claims" field is unexported by oidc IDToken -// https://github.com/coreos/go-oidc/pull/329 -func updateIDToken(idToken *oidc.IDToken, fieldName string, data []byte) { - val := reflect.Indirect(reflect.ValueOf(idToken)) - member := val.FieldByName(fieldName) - pointer := unsafe.Pointer(member.UnsafeAddr()) - realPointer := (*[]byte)(pointer) - *realPointer = data -} - -func TestEmailWithClaims(t *testing.T) { - tests := map[string]struct { - InputClaims []byte - WantErr bool - }{ - "Good": { - InputClaims: []byte(`{"email":"John.Doe@email.com", "email_verified":true}`), - WantErr: false, - }, - "Email not verified": { - InputClaims: []byte(`{"email":"John.Doe@email.com", "email_verified":false}`), - WantErr: true, - }, - "Email missing": { - InputClaims: []byte(`{"email_verified":true}`), - WantErr: true, - }, - } - - ctx := context.Background() - cfg := &config.FulcioConfig{ - OIDCIssuers: map[string]config.OIDCIssuer{ - "email.com": {IssuerURL: "email.com"}, - }, - } - ctx = config.With(ctx, cfg) - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - idToken := &oidc.IDToken{ - Issuer: `email.com`, - } - updateIDToken(idToken, "claims", test.InputClaims) - _, err := email(ctx, idToken) - if err != nil { - if !test.WantErr { - t.Errorf("%s: %v", name, err) - } - return - } else if test.WantErr { - t.Errorf("%s: expected error", name) - } - }) - } -} - func failErr(t *testing.T, err error) { if err != nil { t.Fatal(err) diff --git a/pkg/identity/email/principal.go b/pkg/identity/email/principal.go new file mode 100644 index 000000000..23fddd731 --- /dev/null +++ b/pkg/identity/email/principal.go @@ -0,0 +1,75 @@ +// Copyright 2022 The Sigstore Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package email + +import ( + "context" + "crypto/x509" + "errors" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/sigstore/fulcio/pkg/ca/x509ca" + "github.com/sigstore/fulcio/pkg/config" + "github.com/sigstore/fulcio/pkg/identity" + "github.com/sigstore/fulcio/pkg/oauthflow" +) + +type principal struct { + address string + issuer string +} + +func PrincipalFromIDToken(ctx context.Context, token *oidc.IDToken) (identity.Principal, error) { + emailAddress, emailVerified, err := oauthflow.EmailFromIDToken(token) + if err != nil { + return nil, err + } + if !emailVerified { + return nil, errors.New("email_verified claim was false") + } + + cfg, ok := config.FromContext(ctx).GetIssuer(token.Issuer) + if !ok { + return nil, errors.New("invalid configuration for OIDC ID Token issuer") + } + + issuer, err := oauthflow.IssuerFromIDToken(token, cfg.IssuerClaim) + if err != nil { + return nil, err + } + + return principal{ + issuer: issuer, + address: emailAddress, + }, nil +} + +func (p principal) Name(context.Context) string { + return p.address +} + +func (p principal) Embed(ctx context.Context, cert *x509.Certificate) error { + cert.EmailAddresses = []string{p.address} + + var err error + cert.ExtraExtensions, err = x509ca.Extensions{ + Issuer: p.issuer, + }.Render() + if err != nil { + return err + } + + return nil +} diff --git a/pkg/identity/email/principal_test.go b/pkg/identity/email/principal_test.go new file mode 100644 index 000000000..829230498 --- /dev/null +++ b/pkg/identity/email/principal_test.go @@ -0,0 +1,334 @@ +// Copyright 2022 The Sigstore Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package email + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/asn1" + "encoding/json" + "errors" + "fmt" + "reflect" + "testing" + "unsafe" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/sigstore/fulcio/pkg/config" +) + +func TestPrincipalFromIDToken(t *testing.T) { + tests := map[string]struct { + Claims map[string]interface{} + Config config.FulcioConfig + ExpectedPrincipal principal + WantErr bool + }{ + `Well formed token has no errors`: { + Claims: map[string]interface{}{ + "aud": "sigstore", + "iss": "https://iss.example.com", + "sub": "doesntmatter", + "email": "alice@example.com", + "email_verified": true, + }, + Config: config.FulcioConfig{ + OIDCIssuers: map[string]config.OIDCIssuer{ + "https://iss.example.com": { + IssuerURL: "https://iss.example.com", + Type: config.IssuerTypeEmail, + ClientID: "sigstore", + }, + }, + }, + ExpectedPrincipal: principal{ + issuer: "https://iss.example.com", + address: "alice@example.com", + }, + WantErr: false, + }, + `Custom issuer claim`: { + Claims: map[string]interface{}{ + "aud": "sigstore", + "iss": "https://dex.other.com", + "sub": "doesntmatter", + "email": "alice@example.com", + "email_verified": true, + "federated": map[string]string{ + "issuer": "https://example.com", + }, + }, + Config: config.FulcioConfig{ + OIDCIssuers: map[string]config.OIDCIssuer{ + "https://dex.other.com": { + IssuerURL: "https://dex.other.com", + IssuerClaim: "$.federated.issuer", + Type: config.IssuerTypeEmail, + ClientID: "sigstore", + }, + }, + }, + ExpectedPrincipal: principal{ + issuer: "https://example.com", + address: "alice@example.com", + }, + WantErr: false, + }, + `Custom issuer claim missing`: { + Claims: map[string]interface{}{ + "aud": "sigstore", + "iss": "https://dex.other.com", + "sub": "doesntmatter", + "email": "alice@example.com", + "email_verified": true, + }, + Config: config.FulcioConfig{ + OIDCIssuers: map[string]config.OIDCIssuer{ + "https://dex.other.com": { + IssuerURL: "https://dex.other.com", + IssuerClaim: "$.federated.issuer", + Type: config.IssuerTypeEmail, + ClientID: "sigstore", + }, + }, + }, + WantErr: true, + }, + `Email not verified should error`: { + Claims: map[string]interface{}{ + "aud": "sigstore", + "iss": "https://iss.example.com", + "sub": "doesntmatter", + "email": "alice@example.com", + "email_verified": false, + }, + Config: config.FulcioConfig{ + OIDCIssuers: map[string]config.OIDCIssuer{ + "https://iss.example.com": { + IssuerURL: "https://iss.example.com", + Type: config.IssuerTypeEmail, + ClientID: "sigstore", + }, + }, + }, + WantErr: true, + }, + `Missing email claim should error`: { + Claims: map[string]interface{}{ + "aud": "sigstore", + "iss": "https://iss.example.com", + "sub": "doesntmatter", + "email_verified": true, + }, + Config: config.FulcioConfig{ + OIDCIssuers: map[string]config.OIDCIssuer{ + "https://iss.example.com": { + IssuerURL: "https://iss.example.com", + Type: config.IssuerTypeEmail, + ClientID: "sigstore", + }, + }, + }, + WantErr: true, + }, + `No issuer configured for token`: { + Claims: map[string]interface{}{ + "aud": "sigstore", + "iss": "https://nope.example.com", + "sub": "doesntmatter", + "email": "alice@example.com", + "email_verified": true, + }, + Config: config.FulcioConfig{ + OIDCIssuers: map[string]config.OIDCIssuer{ + "https://iss.example.com": { + IssuerURL: "https://iss.example.com", + Type: config.IssuerTypeEmail, + ClientID: "sigstore", + }, + }, + }, + WantErr: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + token := &oidc.IDToken{ + Issuer: test.Claims["iss"].(string), + Subject: test.Claims["sub"].(string), + } + claims, err := json.Marshal(test.Claims) + if err != nil { + t.Fatal(err) + } + withClaims(token, claims) + + ctx := config.With(context.Background(), &test.Config) + + untyped, err := PrincipalFromIDToken(ctx, token) + if err != nil { + if !test.WantErr { + t.Fatal("didn't expect error", err) + } + return + } + if err == nil && test.WantErr { + t.Fatal("expected error but got none") + } + + gotPrincipal, ok := untyped.(principal) + if !ok { + t.Errorf("Got wrong principal type %v", untyped) + } + if gotPrincipal != test.ExpectedPrincipal { + t.Errorf("got %v principal and expected %v", gotPrincipal, test.ExpectedPrincipal) + } + }) + } +} + +// reflect hack because "claims" field is unexported by oidc IDToken +// https://github.com/coreos/go-oidc/pull/329 +func withClaims(token *oidc.IDToken, data []byte) { + val := reflect.Indirect(reflect.ValueOf(token)) + member := val.FieldByName("claims") + pointer := unsafe.Pointer(member.UnsafeAddr()) + realPointer := (*[]byte)(pointer) + *realPointer = data +} + +func TestName(t *testing.T) { + tests := map[string]struct { + Claims map[string]interface{} + Config config.FulcioConfig + ExpectedName string + }{ + `name should match email address`: { + Claims: map[string]interface{}{ + "aud": "sigstore", + "iss": "https://iss.example.com", + "sub": "doesntmatter", + "email": "alice@example.com", + "email_verified": true, + }, + Config: config.FulcioConfig{ + OIDCIssuers: map[string]config.OIDCIssuer{ + "https://iss.example.com": { + IssuerURL: "https://iss.example.com", + Type: config.IssuerTypeEmail, + ClientID: "sigstore", + }, + }, + }, + ExpectedName: "alice@example.com", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + token := &oidc.IDToken{ + Issuer: test.Claims["iss"].(string), + Subject: test.Claims["sub"].(string), + } + claims, err := json.Marshal(test.Claims) + if err != nil { + t.Fatal(err) + } + withClaims(token, claims) + + ctx := config.With(context.Background(), &test.Config) + + got, err := PrincipalFromIDToken(ctx, token) + if err != nil { + t.Fatal("didn't expect error", err) + } + + if test.ExpectedName != got.Name(ctx) { + t.Errorf("got %s name but expected %s", got.Name(ctx), test.ExpectedName) + } + }) + } + +} + +func TestEmbed(t *testing.T) { + tests := map[string]struct { + Principal principal + WantErr bool + WantFacts map[string]func(x509.Certificate) error + }{ + `should set issuer extension and email subject`: { + Principal: principal{ + issuer: `https://iss.example.com`, + address: `alice@example.com`, + }, + WantErr: false, + WantFacts: map[string]func(x509.Certificate) error{ + `Certificate should have alice@example.com email subject`: func(cert x509.Certificate) error { + if len(cert.EmailAddresses) != 1 { + return errors.New("no email SAN set for email challenge") + } + if cert.EmailAddresses[0] != `alice@example.com` { + return errors.New("bad email. expected alice@example.com") + } + return nil + }, + `Certificate should have issuer extension set`: factIssuerIs("https://iss.example.com"), + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + var cert x509.Certificate + err := test.Principal.Embed(context.TODO(), &cert) + if err != nil { + if !test.WantErr { + t.Error(err) + } + return + } else if test.WantErr { + t.Error("expected error") + } + for factName, fact := range test.WantFacts { + t.Run(factName, func(t *testing.T) { + if err := fact(cert); err != nil { + t.Error(err) + } + }) + } + }) + } +} + +func factIssuerIs(issuer string) func(x509.Certificate) error { + return factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 1}, issuer) +} + +func factExtensionIs(oid asn1.ObjectIdentifier, value string) func(x509.Certificate) error { + return func(cert x509.Certificate) error { + for _, ext := range cert.ExtraExtensions { + if ext.Id.Equal(oid) { + if !bytes.Equal(ext.Value, []byte(value)) { + return fmt.Errorf("expected oid %v to be %s, but got %s", oid, value, ext.Value) + } + return nil + } + } + return errors.New("extension not set") + } +}