From 9029402c939b6f4be96177e1b91826110c6fdc95 Mon Sep 17 00:00:00 2001 From: Hayden Blauzvern Date: Wed, 9 Mar 2022 00:51:01 +0000 Subject: [PATCH] Add Username scoped to domain OIDC type This implements the second part of #398, adding support for OIDC subjects that are simply usernames. A configured domain will be appended to the username and included as a SAN email address. Like #455, token issuers must partially match the configured domain. The top level and second level domain must match, and it's expected that we validate ownership for what's configured in the issuer and domain fields. Signed-off-by: Hayden Blauzvern --- pkg/api/api_test.go | 169 +++++++++++++++++++++++++++++- pkg/api/ca.go | 2 + pkg/ca/x509ca/common.go | 2 + pkg/challenges/challenges.go | 54 +++++++++- pkg/challenges/challenges_test.go | 99 +++++++++++++++++ pkg/config/config.go | 2 + 6 files changed, 322 insertions(+), 6 deletions(-) diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go index 6c2a4afd9..138100e22 100644 --- a/pkg/api/api_test.go +++ b/pkg/api/api_test.go @@ -75,9 +75,172 @@ func TestMissingRootFails(t *testing.T) { // oidcTestContainer holds values needed for each API test invocation type oidcTestContainer struct { - Signer jose.Signer - Issuer string - Subject string + Signer jose.Signer + Issuer string + Subject string + ExpectedSubject string +} + +// customClaims holds additional JWT claims for email-based OIDC tokens +type customClaims struct { + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` +} + +// Tests API for email and username subject types +func TestAPIWithEmail(t *testing.T) { + emailSigner, emailIssuer := newOIDCIssuer(t) + usernameSigner, usernameIssuer := newOIDCIssuer(t) + + issuerDomain, err := url.Parse(usernameIssuer) + if err != nil { + t.Fatal("Issuer URL could not be parsed", err) + } + + // Create a FulcioConfig that supports these issuers. + cfg, err := config.Read([]byte(fmt.Sprintf(`{ + "OIDCIssuers": { + %q: { + "IssuerURL": %q, + "ClientID": "sigstore", + "Type": "email" + }, + %q: { + "IssuerURL": %q, + "ClientID": "sigstore", + "SubjectDomain": %q, + "Type": "username" + } + } + }`, emailIssuer, emailIssuer, usernameIssuer, usernameIssuer, issuerDomain.Hostname()))) + if err != nil { + t.Fatalf("config.Read() = %v", err) + } + + emailSubject := "foo@example.com" + usernameSubject := "foo" + expectedUsernamedSubject := fmt.Sprintf("%s@%s", usernameSubject, issuerDomain.Hostname()) + + for _, c := range []oidcTestContainer{ + { + Signer: emailSigner, Issuer: emailIssuer, Subject: emailSubject, ExpectedSubject: emailSubject, + }, + { + Signer: usernameSigner, Issuer: usernameIssuer, Subject: usernameSubject, ExpectedSubject: expectedUsernamedSubject, + }} { + // Create an OIDC token using this issuer's signer. + tok, err := jwt.Signed(c.Signer).Claims(jwt.Claims{ + Issuer: c.Issuer, + IssuedAt: jwt.NewNumericDate(time.Now()), + Expiry: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)), + Subject: c.Subject, + Audience: jwt.Audience{"sigstore"}, + }).Claims(customClaims{Email: c.Subject, EmailVerified: true}).CompactSerialize() + if err != nil { + t.Fatalf("CompactSerialize() = %v", err) + } + + // Stand up an ephemeral CA we can use for signing certificate requests. + eca, err := ephemeralca.NewEphemeralCA() + if err != nil { + t.Fatalf("ephemeralca.NewEphemeralCA() = %v", err) + } + + ctlogServer := fakeCTLogServer(t) + if ctlogServer == nil { + t.Fatalf("Failed to create the fake ctlog server") + } + + // Create a test HTTP server to host our API. + h := New(ctl.New(ctlogServer.URL), eca) + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + // For each request, infuse context with our snapshot of the FulcioConfig. + ctx = config.With(ctx, cfg) + + h.ServeHTTP(rw, r.WithContext(ctx)) + })) + t.Cleanup(server.Close) + + // Create an API client that speaks to the API endpoint we created above. + u, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("url.Parse() = %v", err) + } + client := NewClient(u) + + // Sign the subject with our keypair, and provide the public key + // for verification. + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("GenerateKey() = %v", err) + } + pubBytes, err := x509.MarshalPKIXPublicKey(&priv.PublicKey) + if err != nil { + t.Fatalf("x509.MarshalPKIXPublicKey() = %v", err) + } + hash := sha256.Sum256([]byte(c.Subject)) + proof, err := ecdsa.SignASN1(rand.Reader, priv, hash[:]) + if err != nil { + t.Fatalf("SignASN1() = %v", err) + } + + // Hit the API to have it sign our certificate. + resp, err := client.SigningCert(CertificateRequest{ + PublicKey: Key{ + Content: pubBytes, + }, + SignedEmailAddress: proof, + }, tok) + if err != nil { + t.Fatalf("SigningCert() = %v", err) + } + + if string(resp.SCT) == "" { + t.Error("Did not get SCT") + } + + // Check that we get the CA root back as well. + root, err := client.RootCert() + if err != nil { + t.Fatal("Failed to get Root", err) + } + if root == nil { + t.Fatal("Got nil root back") + } + if len(root.ChainPEM) == 0 { + t.Fatal("Got back empty chain") + } + block, rest := pem.Decode(root.ChainPEM) + if block == nil { + t.Fatal("Did not find PEM data") + } + if len(rest) != 0 { + t.Fatal("Got more than bargained for, should only have one cert") + } + if block.Type != "CERTIFICATE" { + t.Fatalf("Unexpected root type, expected CERTIFICATE, got %s", block.Type) + } + rootCert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("Failed to parse the received root cert: %v", err) + } + if !rootCert.Equal(eca.RootCA) { + t.Errorf("Root CA does not match, wanted %+v got %+v", eca.RootCA, rootCert) + } + // Compare leaf certificate values + block, _ = pem.Decode(resp.CertPEM) + leafCert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("Failed to parse the received leaf cert: %v", err) + } + if len(leafCert.EmailAddresses) != 1 { + t.Fatalf("Unexpected length of leaf certificate URIs, expected 1, got %d", len(leafCert.URIs)) + } + if leafCert.EmailAddresses[0] != c.ExpectedSubject { + t.Fatalf("Subjects do not match: Expected %v, got %v", c.ExpectedSubject, leafCert.EmailAddresses[0]) + } + } } // Tests API for SPIFFE and URI subject types diff --git a/pkg/api/ca.go b/pkg/api/ca.go index 313d9b644..23f63cd41 100644 --- a/pkg/api/ca.go +++ b/pkg/api/ca.go @@ -271,6 +271,8 @@ func ExtractSubject(ctx context.Context, tok *oidc.IDToken, publicKey crypto.Pub return challenges.Kubernetes(ctx, tok, publicKey, challenge) case config.IssuerTypeURI: return challenges.URI(ctx, tok, publicKey, challenge) + case config.IssuerTypeUsername: + return challenges.Username(ctx, tok, publicKey, challenge) default: return nil, fmt.Errorf("unsupported issuer: %s", iss.Type) } diff --git a/pkg/ca/x509ca/common.go b/pkg/ca/x509ca/common.go index fcb8f4fa8..9b986b2e5 100644 --- a/pkg/ca/x509ca/common.go +++ b/pkg/ca/x509ca/common.go @@ -85,6 +85,8 @@ func MakeX509(subject *challenges.ChallengeResult) (*x509.Certificate, error) { return nil, ca.ValidationError(err) } cert.URIs = []*url.URL{subjectURI} + case challenges.UsernameValue: + cert.EmailAddresses = []string{subject.Value} } cert.ExtraExtensions = append(IssuerExtension(subject.Issuer), AdditionalExtensions(subject)...) return cert, nil diff --git a/pkg/challenges/challenges.go b/pkg/challenges/challenges.go index df2150293..d3a0a4e3b 100644 --- a/pkg/challenges/challenges.go +++ b/pkg/challenges/challenges.go @@ -39,6 +39,7 @@ const ( GithubWorkflowValue KubernetesValue URIValue + UsernameValue ) // All hostnames for subject and issuer OIDC claims must have at least a @@ -273,6 +274,50 @@ func URI(ctx context.Context, principal *oidc.IDToken, pubKey crypto.PublicKey, }, nil } +func Username(ctx context.Context, principal *oidc.IDToken, pubKey crypto.PublicKey, challenge []byte) (*ChallengeResult, error) { + username := principal.Subject + + cfg, ok := config.FromContext(ctx).GetIssuer(principal.Issuer) + if !ok { + return nil, errors.New("invalid configuration for OIDC ID Token issuer") + } + + // The domain in the configuration must match the domain (excluding the subdomain) of the issuer + // In order to declare this configuration, a test must have been done to prove ownership + // over both the issuer and domain configuration values. + // Valid examples: + // * domain = https://example.com/users/user1, issuer = https://accounts.example.com + // * domain = https://accounts.example.com/users/user1, issuer = https://accounts.example.com + // * domain = https://users.example.com/users/user1, issuer = https://accounts.example.com + uIssuer, err := url.Parse(cfg.IssuerURL) + if err != nil { + return nil, err + } + if err := isDomainAllowed(cfg.SubjectDomain, uIssuer.Hostname()); err != nil { + return nil, err + } + + // Check the proof - A signature over the OIDC token subject + if err := CheckSignature(pubKey, challenge, username); err != nil { + return nil, err + } + + issuer, err := oauthflow.IssuerFromIDToken(principal, cfg.IssuerClaim) + if err != nil { + return nil, err + } + + emailSubject := fmt.Sprintf("%s@%s", username, cfg.SubjectDomain) + + // Now issue cert! + return &ChallengeResult{ + Issuer: issuer, + PublicKey: pubKey, + TypeVal: UsernameValue, + Value: emailSubject, + }, nil +} + func kubernetesToken(token *oidc.IDToken) (string, error) { // Extract custom claims var claims struct { @@ -363,13 +408,16 @@ func isSpiffeIDAllowed(host, spiffeID string) bool { // isURISubjectAllowed compares the subject and issuer URIs, // returning an error if the scheme or the hostnames do not match func isURISubjectAllowed(subject, issuer *url.URL) error { - subjectHostname := subject.Hostname() - issuerHostname := issuer.Hostname() - if subject.Scheme != issuer.Scheme { return fmt.Errorf("subject (%s) and issuer (%s) URI schemes do not match", subject.Scheme, issuer.Scheme) } + return isDomainAllowed(subject.Hostname(), issuer.Hostname()) +} + +// isDomainAllowed compares two hostnames, returning an error if the +// top-level and second-level domains do not match +func isDomainAllowed(subjectHostname, issuerHostname string) error { // If the hostnames exactly match, return early if subjectHostname == issuerHostname { return nil diff --git a/pkg/challenges/challenges_test.go b/pkg/challenges/challenges_test.go index 9d3d5c85f..244fcea62 100644 --- a/pkg/challenges/challenges_test.go +++ b/pkg/challenges/challenges_test.go @@ -109,6 +109,44 @@ func TestURI(t *testing.T) { } } +func TestUsername(t *testing.T) { + cfg := &config.FulcioConfig{ + OIDCIssuers: map[string]config.OIDCIssuer{ + "https://accounts.example.com": { + IssuerURL: "https://accounts.example.com", + ClientID: "sigstore", + SubjectDomain: "example.com", + Type: config.IssuerTypeUsername, + }, + }, + } + ctx := config.With(context.Background(), cfg) + username := "foobar" + usernameWithEmail := "foobar@example.com" + issuer := "https://accounts.example.com" + token := &oidc.IDToken{Subject: username, Issuer: issuer} + + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + failErr(t, err) + h := sha256.Sum256([]byte(username)) + signature, err := priv.Sign(rand.Reader, h[:], crypto.SHA256) + failErr(t, err) + + result, err := Username(ctx, token, priv.Public(), signature) + if err != nil { + t.Errorf("Expected test success, got %v", err) + } + if result.Issuer != issuer { + t.Errorf("Expected issuer %s, got %s", issuer, result.Issuer) + } + if result.Value != usernameWithEmail { + t.Errorf("Expected subject %s, got %s", usernameWithEmail, result.Value) + } + if result.TypeVal != UsernameValue { + t.Errorf("Expected type %v, got %v", UsernameValue, result.TypeVal) + } +} + func Test_isURISubjectAllowed(t *testing.T) { tests := []struct { name string @@ -177,6 +215,67 @@ func Test_isURISubjectAllowed(t *testing.T) { } } +func Test_isDomainAllowed(t *testing.T) { + tests := []struct { + name string + subject string // Parsed to url.URL + issuer string // Parsed to url.URL + want error + }{{ + name: "match", + subject: "accounts.example.com", + issuer: "accounts.example.com", + want: nil, + }, { + name: "issuer subdomain", + subject: "example.com", + issuer: "accounts.example.com", + want: nil, + }, { + name: "subject subdomain", + subject: "profiles.example.com", + issuer: "example.com", + want: nil, + }, { + name: "subdomain mismatch", + subject: "profiles.example.com", + issuer: "accounts.example.com", + want: nil, + }, { + name: "subject domain too short", + subject: "example", + issuer: "example.com", + want: fmt.Errorf("subject URI hostname too short: example"), + }, { + name: "issuer domain too short", + subject: "example.com", + issuer: "issuer", + want: fmt.Errorf("issuer URI hostname too short: issuer"), + }, { + name: "domain mismatch", + subject: "example.com", + issuer: "otherexample.com", + want: fmt.Errorf("subject and issuer hostnames do not match: example.com, otherexample.com"), + }, { + name: "top level domain mismatch", + subject: "example.com", + issuer: "example.org", + want: fmt.Errorf("subject and issuer hostnames do not match: example.com, example.org"), + }} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isDomainAllowed(tt.subject, tt.issuer) + if got == nil && tt.want != nil || + got != nil && tt.want == nil { + t.Errorf("isURISubjectAllowed() = %v, want %v", got, tt.want) + } + if got != nil && tt.want != nil && got.Error() != tt.want.Error() { + t.Errorf("isURISubjectAllowed() = %v, want %v", got, tt.want) + } + }) + } +} + func failErr(t *testing.T, err error) { if err != nil { t.Fatal(err) diff --git a/pkg/config/config.go b/pkg/config/config.go index 02526813b..c73c50b15 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -60,6 +60,7 @@ type OIDCIssuer struct { // Optional, if the issuer is in a different claim in the OIDC token IssuerClaim string `json:"IssuerClaim,omitempty"` // The domain that must be present in the subject for 'uri' issuer types + // Also used to create an email for 'username' issuer types SubjectDomain string `json:"SubjectDomain,omitempty"` } @@ -167,6 +168,7 @@ const ( IssuerTypeKubernetes = "kubernetes" IssuerTypeSpiffe = "spiffe" IssuerTypeURI = "uri" + IssuerTypeUsername = "username" ) func parseConfig(b []byte) (cfg *FulcioConfig, err error) {