diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go index ae3f42bfe..6c2a4afd9 100644 --- a/pkg/api/api_test.go +++ b/pkg/api/api_test.go @@ -73,127 +73,165 @@ func TestMissingRootFails(t *testing.T) { } } -func TestAPI(t *testing.T) { - signer, issuer := newOIDCIssuer(t) - - subject := strings.ReplaceAll(issuer+"/foo/bar", "http", "spiffe") - - // Create an OIDC token using this issuer's signer. - tok, err := jwt.Signed(signer).Claims(jwt.Claims{ - Issuer: issuer, - IssuedAt: jwt.NewNumericDate(time.Now()), - Expiry: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)), - Subject: subject, - Audience: jwt.Audience{"sigstore"}, - }).CompactSerialize() - if err != nil { - t.Fatalf("CompactSerialize() = %v", err) - } +// oidcTestContainer holds values needed for each API test invocation +type oidcTestContainer struct { + Signer jose.Signer + Issuer string + Subject string +} - // Create a FulcioConfig that supports this issuer. +// Tests API for SPIFFE and URI subject types +func TestAPIWithUriSubject(t *testing.T) { + spiffeSigner, spiffeIssuer := newOIDCIssuer(t) + uriSigner, uriIssuer := newOIDCIssuer(t) + + // Create a FulcioConfig that supports these issuers. cfg, err := config.Read([]byte(fmt.Sprintf(`{ "OIDCIssuers": { %q: { "IssuerURL": %q, "ClientID": "sigstore", "Type": "spiffe" + }, + %q: { + "IssuerURL": %q, + "ClientID": "sigstore", + "SubjectDomain": %q, + "Type": "uri" } } - }`, issuer, issuer))) + }`, spiffeIssuer, spiffeIssuer, uriIssuer, uriIssuer, uriIssuer))) if err != nil { t.Fatalf("config.Read() = %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) - } + spiffeSubject := strings.ReplaceAll(spiffeIssuer+"/foo/bar", "http", "spiffe") + uriSubject := uriIssuer + "/users/1" - ctlogServer := fakeCTLogServer(t) - if ctlogServer == nil { - t.Fatalf("Failed to create the fake ctlog server") - } + for _, c := range []oidcTestContainer{ + { + Signer: spiffeSigner, Issuer: spiffeIssuer, Subject: spiffeSubject, + }, + { + Signer: uriSigner, Issuer: uriIssuer, Subject: uriSubject, + }} { + // 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"}, + }).CompactSerialize() + if err != nil { + t.Fatalf("CompactSerialize() = %v", err) + } - // 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) + // 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) + } - h.ServeHTTP(rw, r.WithContext(ctx)) - })) - t.Cleanup(server.Close) + ctlogServer := fakeCTLogServer(t) + if ctlogServer == nil { + t.Fatalf("Failed to create the fake ctlog server") + } - // 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) + // 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) - // 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(subject)) - proof, err := ecdsa.SignASN1(rand.Reader, priv, hash[:]) - if err != nil { - t.Fatalf("SignASN1() = %v", err) - } + h.ServeHTTP(rw, r.WithContext(ctx)) + })) + t.Cleanup(server.Close) - // 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) - } + // 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) - if string(resp.SCT) == "" { - t.Error("Did not get SCT") - } + // 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) + } - // 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) + // 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.URIs) != 1 { + 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]) + } } - // TODO(mattmoor): What interesting checks can we perform on - // the other return values? } // Stand up a very simple OIDC endpoint. diff --git a/pkg/api/ca.go b/pkg/api/ca.go index 38b8a0750..313d9b644 100644 --- a/pkg/api/ca.go +++ b/pkg/api/ca.go @@ -269,6 +269,8 @@ func ExtractSubject(ctx context.Context, tok *oidc.IDToken, publicKey crypto.Pub return challenges.GithubWorkflow(ctx, tok, publicKey, challenge) case config.IssuerTypeKubernetes: return challenges.Kubernetes(ctx, tok, publicKey, challenge) + case config.IssuerTypeURI: + return challenges.URI(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 0d9f63c90..fcb8f4fa8 100644 --- a/pkg/ca/x509ca/common.go +++ b/pkg/ca/x509ca/common.go @@ -79,6 +79,12 @@ func MakeX509(subject *challenges.ChallengeResult) (*x509.Certificate, error) { return nil, ca.ValidationError(err) } cert.URIs = []*url.URL{k8sURI} + case challenges.URIValue: + subjectURI, err := url.Parse(subject.Value) + if err != nil { + return nil, ca.ValidationError(err) + } + cert.URIs = []*url.URL{subjectURI} } cert.ExtraExtensions = append(IssuerExtension(subject.Issuer), AdditionalExtensions(subject)...) return cert, nil diff --git a/pkg/challenges/challenges.go b/pkg/challenges/challenges.go index db71f04c1..df2150293 100644 --- a/pkg/challenges/challenges.go +++ b/pkg/challenges/challenges.go @@ -38,8 +38,13 @@ const ( SpiffeValue GithubWorkflowValue KubernetesValue + URIValue ) +// All hostnames for subject and issuer OIDC claims must have at least a +// top-level and second-level domain +const minimumHostnameLength = 2 + type AdditionalInfo int // Additional information that can be added as a cert extension. @@ -205,6 +210,69 @@ func GithubWorkflow(ctx context.Context, principal *oidc.IDToken, pubKey crypto. }, nil } +func URI(ctx context.Context, principal *oidc.IDToken, pubKey crypto.PublicKey, challenge []byte) (*ChallengeResult, error) { + uriWithSubject := principal.Subject + + cfg, ok := config.FromContext(ctx).GetIssuer(principal.Issuer) + if !ok { + return nil, errors.New("invalid configuration for OIDC ID Token issuer") + } + + uSubject, err := url.Parse(uriWithSubject) + if err != nil { + return nil, err + } + + // The subject prefix URI 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: + // * uriWithSubject = https://example.com/users/user1, issuer = https://accounts.example.com + // * uriWithSubject = https://accounts.example.com/users/user1, issuer = https://accounts.example.com + // * uriWithSubject = https://users.example.com/users/user1, issuer = https://accounts.example.com + uIssuer, err := url.Parse(cfg.IssuerURL) + if err != nil { + return nil, err + } + + // Check that: + // * The URI schemes match + // * Either the hostnames exactly match or the top level and second level domains match + if err := isURISubjectAllowed(uSubject, uIssuer); err != nil { + return nil, err + } + + // The subject hostname must exactly match the subject domain from the configuration + uDomain, err := url.Parse(cfg.SubjectDomain) + if err != nil { + return nil, err + } + if uSubject.Scheme != uDomain.Scheme { + return nil, fmt.Errorf("subject URI scheme (%s) must match expected domain URI scheme (%s)", uSubject.Scheme, uDomain.Scheme) + } + if uSubject.Hostname() != uDomain.Hostname() { + return nil, fmt.Errorf("subject hostname (%s) must match expected domain (%s)", uSubject.Hostname(), uDomain.Hostname()) + } + + // Check the proof - A signature over the OIDC token subject + if err := CheckSignature(pubKey, challenge, uriWithSubject); err != nil { + return nil, err + } + + issuer, err := oauthflow.IssuerFromIDToken(principal, cfg.IssuerClaim) + if err != nil { + return nil, err + } + + // Now issue cert! + return &ChallengeResult{ + Issuer: issuer, + PublicKey: pubKey, + TypeVal: URIValue, + Value: uriWithSubject, + }, nil +} + func kubernetesToken(token *oidc.IDToken) (string, error) { // Extract custom claims var claims struct { @@ -291,3 +359,34 @@ func isSpiffeIDAllowed(host, spiffeID string) bool { } return strings.Contains(u.Hostname(), "."+host) } + +// 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) + } + + // If the hostnames exactly match, return early + if subjectHostname == issuerHostname { + return nil + } + + // Compare the top level and second level domains + sHostname := strings.Split(subjectHostname, ".") + iHostname := strings.Split(issuerHostname, ".") + if len(sHostname) < minimumHostnameLength { + return fmt.Errorf("subject URI hostname too short: %s", subjectHostname) + } + if len(iHostname) < minimumHostnameLength { + return fmt.Errorf("issuer URI hostname too short: %s", issuerHostname) + } + if sHostname[len(sHostname)-1] == iHostname[len(iHostname)-1] && + sHostname[len(sHostname)-2] == iHostname[len(iHostname)-2] { + return nil + } + return fmt.Errorf("subject and issuer hostnames do not match: %s, %s", subjectHostname, issuerHostname) +} diff --git a/pkg/challenges/challenges_test.go b/pkg/challenges/challenges_test.go index 203a1b4ef..9d3d5c85f 100644 --- a/pkg/challenges/challenges_test.go +++ b/pkg/challenges/challenges_test.go @@ -16,13 +16,19 @@ package challenges import ( + "context" "crypto" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/rsa" "crypto/sha256" + "fmt" + "net/url" "testing" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/sigstore/fulcio/pkg/config" ) func Test_isSpiffeIDAllowed(t *testing.T) { @@ -66,6 +72,111 @@ func Test_isSpiffeIDAllowed(t *testing.T) { } } +func TestURI(t *testing.T) { + cfg := &config.FulcioConfig{ + OIDCIssuers: map[string]config.OIDCIssuer{ + "https://accounts.example.com": { + IssuerURL: "https://accounts.example.com", + ClientID: "sigstore", + SubjectDomain: "https://example.com", + Type: config.IssuerTypeURI, + }, + }, + } + ctx := config.With(context.Background(), cfg) + subject := "https://example.com/users/1" + issuer := "https://accounts.example.com" + token := &oidc.IDToken{Subject: subject, Issuer: issuer} + + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + failErr(t, err) + h := sha256.Sum256([]byte(subject)) + signature, err := priv.Sign(rand.Reader, h[:], crypto.SHA256) + failErr(t, err) + + result, err := URI(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 != subject { + t.Errorf("Expected subject %s, got %s", subject, result.Value) + } + if result.TypeVal != URIValue { + t.Errorf("Expected type %v, got %v", URIValue, result.TypeVal) + } +} + +func Test_isURISubjectAllowed(t *testing.T) { + tests := []struct { + name string + subject string // Parsed to url.URL + issuer string // Parsed to url.URL + want error + }{{ + name: "match", + subject: "https://accounts.example.com", + issuer: "https://accounts.example.com", + want: nil, + }, { + name: "issuer subdomain", + subject: "https://example.com", + issuer: "https://accounts.example.com", + want: nil, + }, { + name: "subject subdomain", + subject: "https://profiles.example.com", + issuer: "https://example.com", + want: nil, + }, { + name: "subdomain mismatch", + subject: "https://profiles.example.com", + issuer: "https://accounts.example.com", + want: nil, + }, { + name: "scheme mismatch", + subject: "http://example.com", + issuer: "https://example.com", + want: fmt.Errorf("subject (http) and issuer (https) URI schemes do not match"), + }, { + name: "subject domain too short", + subject: "https://example", + issuer: "https://example.com", + want: fmt.Errorf("subject URI hostname too short: example"), + }, { + name: "issuer domain too short", + subject: "https://example.com", + issuer: "https://issuer", + want: fmt.Errorf("issuer URI hostname too short: issuer"), + }, { + name: "domain mismatch", + subject: "https://example.com", + issuer: "https://otherexample.com", + want: fmt.Errorf("subject and issuer hostnames do not match: example.com, otherexample.com"), + }, { + name: "top level domain mismatch", + subject: "https://example.com", + issuer: "https://example.org", + want: fmt.Errorf("subject and issuer hostnames do not match: example.com, example.org"), + }} + for _, tt := range tests { + subject, _ := url.Parse(tt.subject) + issuer, _ := url.Parse(tt.issuer) + t.Run(tt.name, func(t *testing.T) { + got := isURISubjectAllowed(subject, 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 eeeae2ff3..02526813b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -50,10 +50,17 @@ type FulcioConfig struct { } type OIDCIssuer struct { - IssuerURL string `json:"IssuerURL,omitempty"` - ClientID string `json:"ClientID"` - Type IssuerType `json:"Type"` - IssuerClaim string `json:"IssuerClaim,omitempty"` + // The expected issuer of an OIDC token + IssuerURL string `json:"IssuerURL,omitempty"` + // The expected client ID of the OIDC token + ClientID string `json:"ClientID"` + // Used to determine the subject of the certificate and if additional + // certificate values are needed + Type IssuerType `json:"Type"` + // 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 + SubjectDomain string `json:"SubjectDomain,omitempty"` } func metaRegex(issuer string) (*regexp.Regexp, error) { @@ -89,10 +96,11 @@ func (fc *FulcioConfig) GetIssuer(issuerURL string) (OIDCIssuer, bool) { // If it matches, then return a concrete OIDCIssuer // configuration for this issuer URL. return OIDCIssuer{ - IssuerURL: issuerURL, - ClientID: iss.ClientID, - Type: iss.Type, - IssuerClaim: iss.IssuerClaim, + IssuerURL: issuerURL, + ClientID: iss.ClientID, + Type: iss.Type, + IssuerClaim: iss.IssuerClaim, + SubjectDomain: iss.SubjectDomain, }, true } } @@ -158,6 +166,7 @@ const ( IssuerTypeGithubWorkflow = "github-workflow" IssuerTypeKubernetes = "kubernetes" IssuerTypeSpiffe = "spiffe" + IssuerTypeURI = "uri" ) func parseConfig(b []byte) (cfg *FulcioConfig, err error) {