From 6d1ff5691b61c7e1b350d97487e1319da146af96 Mon Sep 17 00:00:00 2001 From: Hayden B Date: Fri, 18 Mar 2022 06:03:25 -0700 Subject: [PATCH] Refactor API tests (#483) * 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 * Refactor API tests This refactor adds tests for all supported OIDC types, and makes it simpler to add new tests for new OIDC types. * Add tests for K8s and GitHub OIDC types. * Add additional verification for issued certificate values * Add dedicated test for RootCert success, don't call RootCert in every test. * Move common expectations to function. This provides a single place to check response values. * Move common set up to dedicated functions. * Lowercase all error messages, because style. Signed-off-by: Hayden Blauzvern --- pkg/api/api_test.go | 566 +++++++++++++++++++++++++----- 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, 638 insertions(+), 87 deletions(-) diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go index 6c2a4afd9..5ed7ceda6 100644 --- a/pkg/api/api_test.go +++ b/pkg/api/api_test.go @@ -23,6 +23,8 @@ import ( "crypto/rsa" "crypto/sha256" "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" "encoding/base64" "encoding/json" "encoding/pem" @@ -32,6 +34,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "reflect" "strings" "testing" "time" @@ -69,15 +72,145 @@ func TestMissingRootFails(t *testing.T) { t.Fatal("RootCert did not fail", err) } if err.Error() != expectedNoRootMessage { - t.Errorf("Got an unexpected error: %q wanted: %q", err, expectedNoRootMessage) + t.Errorf("got an unexpected error: %q wanted: %q", err, expectedNoRootMessage) + } +} + +func TestRootCertSuccess(t *testing.T) { + eca, serverURL := createCA(&config.FulcioConfig{}, t) + + // Create an API client that speaks to the API endpoint we created above. + u, err := url.Parse(serverURL) + if err != nil { + t.Fatalf("url.Parse() = %v", err) + } + client := NewClient(u) + + root, err := client.RootCert() + if err != nil { + t.Fatal("RootCert did not fail", err) + } + 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) } } // 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) + } + + eca, serverURL := createCA(cfg, t) + + // Create an API client that speaks to the API endpoint we created above. + u, err := url.Parse(serverURL) + if err != nil { + t.Fatalf("url.Parse() = %v", err) + } + client := NewClient(u) + + pubBytes, proof := generateKeyAndProof(c.Subject, t) + + // 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) + } + + leafCert := verifyResponse(resp, eca, c.Issuer, t) + + // Expect email subject + 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 @@ -127,50 +260,16 @@ func TestAPIWithUriSubject(t *testing.T) { 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) + eca, serverURL := createCA(cfg, t) // Create an API client that speaks to the API endpoint we created above. - u, err := url.Parse(server.URL) + u, err := url.Parse(serverURL) 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) - } + pubBytes, proof := generateKeyAndProof(c.Subject, t) // Hit the API to have it sign our certificate. resp, err := client.SigningCert(CertificateRequest{ @@ -183,54 +282,225 @@ func TestAPIWithUriSubject(t *testing.T) { t.Fatalf("SigningCert() = %v", err) } - if string(resp.SCT) == "" { - t.Error("Did not get SCT") - } + leafCert := verifyResponse(resp, eca, c.Issuer, t) - // 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) - } + // Expect URI values if len(leafCert.URIs) != 1 { - t.Fatalf("Unexpected length of leaf certificate URIs, expected 1, got %d", len(leafCert.URIs)) + t.Fatalf("unexpected length of leaf certificate URIs, expected 1, got %d", len(leafCert.URIs)) } uSubject, err := url.Parse(c.Subject) if err != nil { t.Fatalf("Failed to parse subject URI") } if *leafCert.URIs[0] != *uSubject { - t.Fatalf("Subjects do not match: Expected %v, got %v", uSubject, leafCert.URIs[0]) + t.Fatalf("subjects do not match: Expected %v, got %v", uSubject, leafCert.URIs[0]) + } + } +} + +// k8sClaims holds the additional Kubernetes claims for the JWT +type k8sClaims struct { + Kubernetes struct { + Namespace string `json:"namespace"` + ServiceAccount struct { + Name string `json:"name"` } + } `json:"kubernetes.io"` +} + +// Tests API for Kubernetes URI subject types +func TestAPIWithKubernetes(t *testing.T) { + k8sSigner, k8sIssuer := newOIDCIssuer(t) + + // Create a FulcioConfig that supports these issuers. + cfg, err := config.Read([]byte(fmt.Sprintf(`{ + "MetaIssuers": { + %q: { + "ClientID": "sigstore", + "Type": "kubernetes" + } + } + }`, k8sIssuer))) + if err != nil { + t.Fatalf("config.Read() = %v", err) + } + + namespace := "namespace" + saName := "sa" + k8sSubject := fmt.Sprintf("https://kubernetes.io/namespaces/%s/serviceaccounts/%s", namespace, saName) + + // Create an OIDC token using this issuer's signer. + claims := k8sClaims{} + claims.Kubernetes.Namespace = namespace + claims.Kubernetes.ServiceAccount.Name = saName + tok, err := jwt.Signed(k8sSigner).Claims(jwt.Claims{ + Issuer: k8sIssuer, + IssuedAt: jwt.NewNumericDate(time.Now()), + Expiry: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)), + Subject: k8sSubject, + Audience: jwt.Audience{"sigstore"}, + }).Claims(&claims).CompactSerialize() + if err != nil { + t.Fatalf("CompactSerialize() = %v", err) + } + + eca, serverURL := createCA(cfg, t) + + // Create an API client that speaks to the API endpoint we created above. + u, err := url.Parse(serverURL) + if err != nil { + t.Fatalf("url.Parse() = %v", err) + } + client := NewClient(u) + + pubBytes, proof := generateKeyAndProof(k8sSubject, t) + + // 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) + } + + leafCert := verifyResponse(resp, eca, k8sIssuer, t) + + // Expect URI values + if len(leafCert.URIs) != 1 { + t.Fatalf("unexpected length of leaf certificate URIs, expected 1, got %d", len(leafCert.URIs)) + } + uSubject, err := url.Parse(k8sSubject) + if err != nil { + t.Fatalf("failed to parse subject URI") + } + if *leafCert.URIs[0] != *uSubject { + t.Fatalf("subjects do not match: Expected %v, got %v", uSubject, leafCert.URIs[0]) + } +} + +// gitClaims holds the additional JWT claims for GitHub OIDC tokens +type gitClaims struct { + JobWorkflowRef string `json:"job_workflow_ref"` + Sha string `json:"sha"` + Trigger string `json:"event_name"` + Repository string `json:"repository"` + Workflow string `json:"workflow"` + Ref string `json:"ref"` +} + +// Tests API for GitHub subject types +func TestAPIWithGitHub(t *testing.T) { + gitSigner, gitIssuer := newOIDCIssuer(t) + + // Create a FulcioConfig that supports these issuers. + cfg, err := config.Read([]byte(fmt.Sprintf(`{ + "OIDCIssuers": { + %q: { + "IssuerURL": %q, + "ClientID": "sigstore", + "Type": "github-workflow" + } + } + }`, gitIssuer, gitIssuer))) + if err != nil { + t.Fatalf("config.Read() = %v", err) + } + + claims := gitClaims{ + JobWorkflowRef: "job/workflow/ref", + Sha: "sha", + Trigger: "trigger", + Repository: "repo", + Workflow: "workflow", + Ref: "ref", + } + gitSubject := fmt.Sprintf("https://github.com/%s", claims.JobWorkflowRef) + + // Create an OIDC token using this issuer's signer. + tok, err := jwt.Signed(gitSigner).Claims(jwt.Claims{ + Issuer: gitIssuer, + IssuedAt: jwt.NewNumericDate(time.Now()), + Expiry: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)), + Subject: gitSubject, + Audience: jwt.Audience{"sigstore"}, + }).Claims(&claims).CompactSerialize() + if err != nil { + t.Fatalf("CompactSerialize() = %v", err) + } + + eca, serverURL := createCA(cfg, t) + + // Create an API client that speaks to the API endpoint we created above. + u, err := url.Parse(serverURL) + if err != nil { + t.Fatalf("url.Parse() = %v", err) + } + client := NewClient(u) + + pubBytes, proof := generateKeyAndProof(gitSubject, t) + + // 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) + } + + leafCert := verifyResponse(resp, eca, gitIssuer, t) + + // Expect URI values + if len(leafCert.URIs) != 1 { + t.Fatalf("unexpected length of leaf certificate URIs, expected 1, got %d", len(leafCert.URIs)) + } + uSubject, err := url.Parse(gitSubject) + if err != nil { + t.Fatalf("failed to parse subject URI") + } + if *leafCert.URIs[0] != *uSubject { + t.Fatalf("subjects do not match: Expected %v, got %v", uSubject, leafCert.URIs[0]) + } + // Verify custom OID values + triggerExt, found := findCustomExtension(leafCert, asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 2}) + if !found { + t.Fatal("expected trigger in custom OID") + } + if string(triggerExt.Value) != claims.Trigger { + t.Fatalf("unexpected trigger, expected %s, got %s", claims.Trigger, string(triggerExt.Value)) + } + shaExt, found := findCustomExtension(leafCert, asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 3}) + if !found { + t.Fatal("expected sha in custom OID") + } + if string(shaExt.Value) != claims.Sha { + t.Fatalf("unexpected sha, expected %s, got %s", claims.Sha, string(shaExt.Value)) + } + workflowExt, found := findCustomExtension(leafCert, asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 4}) + if !found { + t.Fatal("expected workflow name in custom OID") + } + if string(workflowExt.Value) != claims.Workflow { + t.Fatalf("unexpected workflow name, expected %s, got %s", claims.Workflow, string(workflowExt.Value)) + } + repoExt, found := findCustomExtension(leafCert, asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 5}) + if !found { + t.Fatal("expected repo in custom OID") + } + if string(repoExt.Value) != claims.Repository { + t.Fatalf("unexpected repo, expected %s, got %s", claims.Repository, string(repoExt.Value)) + } + refExt, found := findCustomExtension(leafCert, asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 6}) + if !found { + t.Fatal("expected ref in custom OID") + } + if string(refExt.Value) != claims.Ref { + t.Fatalf("unexpected ref, expected %s, got %s", claims.Ref, string(refExt.Value)) } } @@ -240,7 +510,7 @@ func newOIDCIssuer(t *testing.T) (jose.Signer, string) { pk, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { - t.Fatalf("Cannot generate RSA key %v", err) + t.Fatalf("cannot generate RSA key %v", err) } jwk := jose.JSONWebKey{ Algorithm: string(jose.RS256), @@ -306,13 +576,13 @@ func fakeCTLogServer(t *testing.T) *httptest.Server { var chain certChain json.Unmarshal(body, &chain) if len(chain.Chain) != 2 { - t.Fatalf("Did not get expected chain for input, wanted 2 entries, got %d", len(chain.Chain)) + t.Fatalf("did not get expected chain for input, wanted 2 entries, got %d", len(chain.Chain)) } // Just make sure we can decode it. for _, chainEntry := range chain.Chain { _, err := base64.StdEncoding.DecodeString(chainEntry) if err != nil { - t.Fatalf("Failed to decode incoming chain entry: %v", err) + t.Fatalf("failed to decode incoming chain entry: %v", err) } } @@ -324,7 +594,7 @@ func fakeCTLogServer(t *testing.T) *httptest.Server { } responseBytes, err := json.Marshal(&resp) if err != nil { - t.Fatalf("Failed to marshal response: %v", err) + t.Fatalf("failed to marshal response: %v", err) } w.WriteHeader(http.StatusOK) w.Header().Set("SCT", testSCT) @@ -332,6 +602,134 @@ func fakeCTLogServer(t *testing.T) *httptest.Server { })) } +// createCA initializes an ephemeral CA server and CT log server +func createCA(cfg *config.FulcioConfig, t *testing.T) (*ephemeralca.EphemeralCA, string) { + // 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) + + return eca, server.URL +} + +// generateKeyAndProof creates a public key to be certified and creates a +// signature for the OIDC token subject +func generateKeyAndProof(subject string, t *testing.T) ([]byte, []byte) { + 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(subject)) + proof, err := ecdsa.SignASN1(rand.Reader, priv, hash[:]) + if err != nil { + t.Fatalf("SignASN1() = %v", err) + } + return pubBytes, proof +} + +// findCustomExtension searches a certificate's non-critical extensions by OID +func findCustomExtension(cert *x509.Certificate, oid asn1.ObjectIdentifier) (pkix.Extension, bool) { + for _, ext := range cert.Extensions { + if reflect.DeepEqual(ext.Id, oid) { + return ext, true + } + } + return pkix.Extension{}, false +} + +// verifyResponse validates common response expectations for each response field +func verifyResponse(resp *CertificateResponse, eca *ephemeralca.EphemeralCA, issuer string, t *testing.T) *x509.Certificate { + // Expect SCT + if string(resp.SCT) == "" { + t.Fatal("unexpected empty SCT in response") + } + + // Expect root certficate in resp.ChainPEM + if len(resp.ChainPEM) == 0 { + t.Fatal("unexpected empty chain in response") + } + + // Expect root cert matches the server's configured root + block, rest := pem.Decode(resp.ChainPEM) + if block == nil { + t.Fatal("missing PEM data") + } + // Note: This may change in the future if we use intermediate certificates. + if len(rest) != 0 { + t.Fatal("expected only one certificate in PEM block chain") + } + 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) + } + + // Expect leaf certificate values + block, rest = pem.Decode(resp.CertPEM) + if len(rest) != 0 { + t.Fatal("expected only one leaf certificate in PEM block") + } + leafCert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("failed to parse the received leaf cert: %v", err) + } + if leafCert.SerialNumber == nil { + t.Fatalf("expected certificate serial number") + } + if leafCert.NotAfter.Sub(leafCert.NotBefore) != time.Duration(10*time.Minute) { + t.Fatalf("expected 10 minute lifetime, got %v", leafCert.NotAfter.Sub(leafCert.NotBefore)) + } + if len(leafCert.SubjectKeyId) != 20 { + t.Fatalf("expected certificate subject key ID to be of length 20 bytes, got %d", len(leafCert.SubjectKeyId)) + } + if leafCert.KeyUsage != x509.KeyUsageCertSign { + t.Fatalf("unexpected key usage, expected %v, got %v", x509.KeyUsageCertSign, leafCert.KeyUsage) + } + if len(leafCert.ExtKeyUsage) != 1 { + t.Fatalf("unexpected length of extended key usage, expected 1, got %d", len(leafCert.ExtKeyUsage)) + } + if leafCert.ExtKeyUsage[0] != x509.ExtKeyUsageCodeSigning { + t.Fatalf("unexpected key usage, expected %v, got %v", x509.ExtKeyUsageCodeSigning, leafCert.ExtKeyUsage[0]) + } + // Check issuer in custom OID + issuerExt, found := findCustomExtension(leafCert, asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 1}) + if !found { + t.Fatal("expected issuer in custom OID") + } + if string(issuerExt.Value) != issuer { + t.Fatalf("unexpected issuer, expected %s, got %s", issuer, string(issuerExt.Value)) + } + + return leafCert +} + +// Fake CA service that always fails. type FailingCertificateAuthority struct { } 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) {