From d87bb86a29d719d1c0b3749ef5c57dbc460ad85e Mon Sep 17 00:00:00 2001 From: Matt Moore Date: Thu, 20 Jun 2024 15:29:06 -0700 Subject: [PATCH] Add Chainguard OIDC provider. This adds support for Chainguard issued tokens, so that users can sign with their Chainguard-issued identity, and so that we can explore signing our own content with our internal service principal construct (see issue). Related: https://github.com/sigstore/fulcio/issues/1702 Signed-off-by: Matt Moore --- config/config.jsn | 5 + config/fulcio-config.yaml | 5 + federation/issuer.enforce.dev/config.yaml | 19 ++ go.mod | 3 +- go.sum | 6 +- pkg/config/config.go | 3 + pkg/config/config_test.go | 3 + pkg/identity/chainguard/issuer.go | 40 ++++ pkg/identity/chainguard/issuer_test.go | 97 +++++++++ pkg/identity/chainguard/principal.go | 80 ++++++++ pkg/identity/chainguard/principal_test.go | 232 ++++++++++++++++++++++ pkg/server/grpc_server_test.go | 137 ++++++++++++- pkg/server/issuer_pool.go | 3 + 13 files changed, 628 insertions(+), 5 deletions(-) create mode 100644 federation/issuer.enforce.dev/config.yaml create mode 100644 pkg/identity/chainguard/issuer.go create mode 100644 pkg/identity/chainguard/issuer_test.go create mode 100644 pkg/identity/chainguard/principal.go create mode 100644 pkg/identity/chainguard/principal_test.go diff --git a/config/config.jsn b/config/config.jsn index 8e66be79e..27fae5423 100644 --- a/config/config.jsn +++ b/config/config.jsn @@ -25,6 +25,11 @@ "IssuerURL": "https://oidc.codefresh.io", "ClientID": "sigstore", "Type": "codefresh-workflow" + }, + "https://issuer.enforce.dev": { + "IssuerURL": "https://issuer.enforce.dev", + "ClientID": "sigstore", + "Type": "chainguard-identity" } } } diff --git a/config/fulcio-config.yaml b/config/fulcio-config.yaml index 44a7107bb..0f7a0aded 100644 --- a/config/fulcio-config.yaml +++ b/config/fulcio-config.yaml @@ -58,6 +58,11 @@ data: "ClientID": "sigstore", "Type": "gitlab-pipeline" }, + "https://issuer.enforce.dev": { + "IssuerURL": "https://issuer.enforce.dev", + "ClientID": "sigstore", + "Type": "chainguard-identity" + }, "https://oauth2.sigstore.dev/auth": { "IssuerURL": "https://oauth2.sigstore.dev/auth", "ClientID": "sigstore", diff --git a/federation/issuer.enforce.dev/config.yaml b/federation/issuer.enforce.dev/config.yaml new file mode 100644 index 000000000..45e252a88 --- /dev/null +++ b/federation/issuer.enforce.dev/config.yaml @@ -0,0 +1,19 @@ +# Copyright 2024 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. + +url: https://issuer.enforce.dev +# TODO(mattmoor): Change to a group. +contact: mattmoor@chainguard.dev +description: "Chainguard identity tokens" +type: "chainguard-identity" diff --git a/go.mod b/go.mod index 05cf24829..b33009ceb 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.22.4 require ( chainguard.dev/go-grpc-kit v0.17.5 + chainguard.dev/sdk v0.1.20 cloud.google.com/go/security v1.17.0 github.com/PaesslerAG/jsonpath v0.1.1 github.com/ThalesIgnite/crypto11 v1.2.5 @@ -140,7 +141,7 @@ require ( go.uber.org/multierr v1.11.0 // indirect goa.design/goa v2.2.5+incompatible // indirect golang.org/x/crypto v0.24.0 // indirect - golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb // indirect golang.org/x/net v0.26.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect golang.org/x/sync v0.7.0 // indirect diff --git a/go.sum b/go.sum index 71dc3ca48..16749b98f 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ chainguard.dev/go-grpc-kit v0.17.5 h1:y0MHgqm3v0LKKQfxPJV57wkXxa8uMSpNTjhtHbNh1DY= chainguard.dev/go-grpc-kit v0.17.5/go.mod h1:vQGcwZiX6jXwhyLPCZwVMvjITD+XcrSmQzuCTW/XcVc= +chainguard.dev/sdk v0.1.20 h1:x46/Nd+DbfvaO4F0NEHUYIkGFa/B3wA+0KKByVNd61I= +chainguard.dev/sdk v0.1.20/go.mod h1:UO+3bmvsha1UoXxvgNnMze1kfNLuADe2WWi3AirvvxE= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14= cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU= @@ -398,8 +400,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8= +golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= diff --git a/pkg/config/config.go b/pkg/config/config.go index 900ecef26..6a6aca77e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -279,6 +279,7 @@ const ( IssuerTypeGithubWorkflow = "github-workflow" IssuerTypeCodefreshWorkflow = "codefresh-workflow" IssuerTypeGitLabPipeline = "gitlab-pipeline" + IssuerTypeChainguard = "chainguard-identity" IssuerTypeKubernetes = "kubernetes" IssuerTypeSpiffe = "spiffe" IssuerTypeURI = "uri" @@ -517,6 +518,8 @@ func issuerToChallengeClaim(issType IssuerType, challengeClaim string) string { return "sub" case IssuerTypeCodefreshWorkflow: return "sub" + case IssuerTypeChainguard: + return "sub" case IssuerTypeKubernetes: return "sub" case IssuerTypeSpiffe: diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 1c926a044..4c0967660 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -498,6 +498,9 @@ func Test_issuerToChallengeClaim(t *testing.T) { if claim := issuerToChallengeClaim(IssuerTypeCodefreshWorkflow, ""); claim != "sub" { t.Fatalf("expected sub subject claim for Codefresh issuer, got %s", claim) } + if claim := issuerToChallengeClaim(IssuerTypeChainguard, ""); claim != "sub" { + t.Fatalf("expected sub subject claim for Chainguard issuer, got %s", claim) + } if claim := issuerToChallengeClaim(IssuerTypeKubernetes, ""); claim != "sub" { t.Fatalf("expected sub subject claim for K8S issuer, got %s", claim) } diff --git a/pkg/identity/chainguard/issuer.go b/pkg/identity/chainguard/issuer.go new file mode 100644 index 000000000..41df8c4b0 --- /dev/null +++ b/pkg/identity/chainguard/issuer.go @@ -0,0 +1,40 @@ +// Copyright 2024 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 chainguard + +import ( + "context" + "fmt" + + "github.com/sigstore/fulcio/pkg/config" + "github.com/sigstore/fulcio/pkg/identity" + "github.com/sigstore/fulcio/pkg/identity/base" +) + +type issuer struct { + identity.Issuer +} + +func Issuer(issuerURL string) identity.Issuer { + return &issuer{base.Issuer(issuerURL)} +} + +func (e *issuer) Authenticate(ctx context.Context, token string, opts ...config.InsecureOIDCConfigOption) (identity.Principal, error) { + idtoken, err := identity.Authorize(ctx, token, opts...) + if err != nil { + return nil, fmt.Errorf("authorizing chainguard issuer: %w", err) + } + return PrincipalFromIDToken(ctx, idtoken) +} diff --git a/pkg/identity/chainguard/issuer_test.go b/pkg/identity/chainguard/issuer_test.go new file mode 100644 index 000000000..6e3412a26 --- /dev/null +++ b/pkg/identity/chainguard/issuer_test.go @@ -0,0 +1,97 @@ +// Copyright 2024 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 chainguard + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "testing" + "unsafe" + + "chainguard.dev/sdk/uidp" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/sigstore/fulcio/pkg/config" + "github.com/sigstore/fulcio/pkg/identity" +) + +func TestIssuer(t *testing.T) { + ctx := context.Background() + url := "test-issuer-url" + issuer := Issuer(url) + + // test the Match function + t.Run("match", func(t *testing.T) { + if matches := issuer.Match(ctx, url); !matches { + t.Fatal("expected url to match but it doesn't") + } + if matches := issuer.Match(ctx, "some-other-url"); matches { + t.Fatal("expected match to fail but it didn't") + } + }) + + t.Run("authenticate", func(t *testing.T) { + group := uidp.NewUIDP("") + id := group.NewChild() + + token := &oidc.IDToken{ + Issuer: "https://iss.example.com", + Subject: id.String(), + } + claims, err := json.Marshal(map[string]interface{}{ + "iss": "https://iss.example.com", + "sub": id.String(), + + // Actor claims track the identity that was used to assume the + // Chainguard identity. In this case, it is the Catalog Syncer + // service principal. + "act": map[string]string{ + "iss": "https://iss.example.com/", + "sub": fmt.Sprintf("catalog-syncer:%s", group.String()), + "aud": "chainguard", + }, + "internal": map[string]interface{}{ + "service-principal": "CATALOG_SYNCER", + }, + }) + if err != nil { + t.Fatal(err) + } + withClaims(token, claims) + + identity.Authorize = func(_ context.Context, _ string, _ ...config.InsecureOIDCConfigOption) (*oidc.IDToken, error) { + return token, nil + } + principal, err := issuer.Authenticate(ctx, "token") + if err != nil { + t.Fatal(err) + } + + if principal.Name(ctx) != id.String() { + t.Fatalf("got unexpected name %s", principal.Name(ctx)) + } + }) +} + +// 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 +} diff --git a/pkg/identity/chainguard/principal.go b/pkg/identity/chainguard/principal.go new file mode 100644 index 000000000..d2940c667 --- /dev/null +++ b/pkg/identity/chainguard/principal.go @@ -0,0 +1,80 @@ +// Copyright 2024 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 chainguard + +import ( + "context" + "crypto/x509" + "net/url" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/sigstore/fulcio/pkg/certificate" + "github.com/sigstore/fulcio/pkg/identity" +) + +type workflowPrincipal struct { + issuer string + subject string + + actor map[string]string + servicePrincipal string +} + +var _ identity.Principal = (*workflowPrincipal)(nil) + +func (w workflowPrincipal) Name(_ context.Context) string { + return w.subject +} + +func PrincipalFromIDToken(_ context.Context, token *oidc.IDToken) (identity.Principal, error) { + var claims struct { + Actor map[string]string `json:"act"` + Internal struct { + ServicePrincipal string `json:"service-principal,omitempty"` + } `json:"internal"` + } + + if err := token.Claims(&claims); err != nil { + return nil, err + } + + return &workflowPrincipal{ + issuer: token.Issuer, + subject: token.Subject, + actor: claims.Actor, + servicePrincipal: claims.Internal.ServicePrincipal, + }, nil +} + +func (w workflowPrincipal) Embed(_ context.Context, cert *x509.Certificate) error { + baseURL, err := url.Parse(w.issuer) + if err != nil { + return err + } + + // Set SAN to the / + cert.URIs = []*url.URL{baseURL.JoinPath(w.subject)} + + cert.ExtraExtensions, err = certificate.Extensions{ + Issuer: w.issuer, + + // TODO(mattmoor): Embed more of the Chainguard token structure via OIDs. + }.Render() + if err != nil { + return err + } + + return nil +} diff --git a/pkg/identity/chainguard/principal_test.go b/pkg/identity/chainguard/principal_test.go new file mode 100644 index 000000000..db995db17 --- /dev/null +++ b/pkg/identity/chainguard/principal_test.go @@ -0,0 +1,232 @@ +// Copyright 2024 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 chainguard + +import ( + "context" + "crypto/x509" + "encoding/asn1" + "encoding/json" + "errors" + "fmt" + "net/url" + "reflect" + "strings" + "testing" + + "chainguard.dev/sdk/uidp" + "github.com/coreos/go-oidc/v3/oidc" + "github.com/sigstore/fulcio/pkg/identity" +) + +func TestJobPrincipalFromIDToken(t *testing.T) { + group := uidp.NewUIDP("") + id := group.NewChild() + + tests := map[string]struct { + Claims map[string]interface{} + ExpectPrincipal workflowPrincipal + WantErr bool + ErrContains string + }{ + `Service principal token`: { + Claims: map[string]interface{}{ + "iss": "https://issuer.enforce.dev", + "sub": id.String(), + // Actor claims track the identity that was used to assume the + // Chainguard identity. In this case, it is the Catalog Syncer + // service principal. + "act": map[string]string{ + "iss": "https://iss.example.com/", + "sub": fmt.Sprintf("catalog-syncer:%s", group.String()), + "aud": "chainguard", + }, + "internal": map[string]interface{}{ + "service-principal": "CATALOG_SYNCER", + }, + }, + ExpectPrincipal: workflowPrincipal{ + issuer: "https://issuer.enforce.dev", + subject: id.String(), + actor: map[string]string{ + "iss": "https://iss.example.com/", + "sub": fmt.Sprintf("catalog-syncer:%s", group.String()), + "aud": "chainguard", + }, + servicePrincipal: "CATALOG_SYNCER", + }, + WantErr: false, + }, + `Human SSO token`: { + Claims: map[string]interface{}{ + "iss": "https://issuer.enforce.dev", + "sub": group.String(), + // Actor claims track the identity that was used to assume the + // Chainguard identity. In this case, it is the Catalog Syncer + // service principal. + "act": map[string]string{ + "iss": "https://auth.chainguard.dev/", + "sub": "google-oauth2|1234567890", + "aud": "fdsaldfkjhasldf", + }, + }, + ExpectPrincipal: workflowPrincipal{ + issuer: "https://issuer.enforce.dev", + subject: group.String(), + actor: map[string]string{ + "iss": "https://auth.chainguard.dev/", + "sub": "google-oauth2|1234567890", + "aud": "fdsaldfkjhasldf", + }, + }, + WantErr: false, + }, + } + + 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) + + untyped, err := PrincipalFromIDToken(context.TODO(), token) + if err != nil { + if !test.WantErr { + t.Fatal("didn't expect error", err) + } + if !strings.Contains(err.Error(), test.ErrContains) { + t.Fatalf("expected error %s to contain %s", err, test.ErrContains) + } + return + } + if err == nil && test.WantErr { + t.Fatal("expected error but got none") + } + + principal, ok := untyped.(*workflowPrincipal) + if !ok { + t.Errorf("Got wrong principal type %v", untyped) + } + if !reflect.DeepEqual(*principal, test.ExpectPrincipal) { + t.Errorf("got %v principal and expected %v", *principal, test.ExpectPrincipal) + } + }) + } +} + +func TestEmbed(t *testing.T) { + group := uidp.NewUIDP("") + id := group.NewChild() + + tests := map[string]struct { + Principal identity.Principal + WantErr bool + WantFacts map[string]func(x509.Certificate) error + }{ + `Chainguard Service Principal`: { + Principal: &workflowPrincipal{ + issuer: "https://issuer.enforce.dev", + subject: id.String(), + actor: map[string]string{ + "iss": "https://iss.example.com/", + "sub": fmt.Sprintf("catalog-syncer:%s", group.String()), + "aud": "chainguard", + }, + servicePrincipal: "CATALOG_SYNCER", + }, + WantErr: false, + WantFacts: map[string]func(x509.Certificate) error{ + `Certificate SAN has correct value`: factSanURIIs(fmt.Sprintf("https://issuer.enforce.dev/%s", id.String())), + `Certificate has correct issuer (v2) extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 8}, "https://issuer.enforce.dev"), + }, + }, + `Chainguard Human SSO`: { + Principal: &workflowPrincipal{ + issuer: "https://issuer.enforce.dev", + subject: group.String(), + actor: map[string]string{ + "iss": "https://auth.chainguard.dev/", + "sub": "google-oauth2|1234567890", + "aud": "fdsaldfkjhasldf", + }, + }, + WantErr: false, + WantFacts: map[string]func(x509.Certificate) error{ + `Certificate SAN has correct value`: factSanURIIs(fmt.Sprintf("https://issuer.enforce.dev/%s", group.String())), + `Certificate has correct issuer (v2) extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 8}, "https://issuer.enforce.dev"), + }, + }, + } + + 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 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) { + var strVal string + _, _ = asn1.Unmarshal(ext.Value, &strVal) + if value != strVal { + return fmt.Errorf("expected oid %v to be %s, but got %s", oid, value, strVal) + } + return nil + } + } + return errors.New("extension not set") + } +} + +func factSanURIIs(value string) func(x509.Certificate) error { + return func(cert x509.Certificate) error { + url, err := url.Parse(value) + + if err != nil { + return err + } + + if cert.URIs[0].String() != url.String() { + return fmt.Errorf("expected SAN o be %s, but got %s", value, cert.URIs[0].String()) + } + + return nil + } +} diff --git a/pkg/server/grpc_server_test.go b/pkg/server/grpc_server_test.go index 06d6c9e2e..999083160 100644 --- a/pkg/server/grpc_server_test.go +++ b/pkg/server/grpc_server_test.go @@ -39,6 +39,7 @@ import ( "testing" "time" + "chainguard.dev/sdk/uidp" "github.com/go-jose/go-jose/v4" "github.com/go-jose/go-jose/v4/jwt" ctclient "github.com/google/certificate-transparency-go/client" @@ -197,6 +198,7 @@ func TestGetConfiguration(t *testing.T) { _, gitHubIssuer := newOIDCIssuer(t) _, gitLabIssuer := newOIDCIssuer(t) _, codefreshIssuer := newOIDCIssuer(t) + _, chainguardIssuer := newOIDCIssuer(t) issuerDomain, err := url.Parse(usernameIssuer) if err != nil { @@ -247,6 +249,11 @@ func TestGetConfiguration(t *testing.T) { "IssuerURL": %q, "ClientID": "sigstore", "Type": "codefresh-workflow" + }, + %q: { + "IssuerURL": %q, + "ClientID": "sigstore", + "Type": "chainguard-identity" } }, "MetaIssuers": { @@ -263,6 +270,7 @@ func TestGetConfiguration(t *testing.T) { gitHubIssuer, gitHubIssuer, gitLabIssuer, gitLabIssuer, codefreshIssuer, codefreshIssuer, + chainguardIssuer, chainguardIssuer, k8sIssuer))) if err != nil { t.Fatalf("config.Read() = %v", err) @@ -283,14 +291,15 @@ func TestGetConfiguration(t *testing.T) { t.Fatal("GetConfiguration failed", err) } - if len(config.Issuers) != 9 { - t.Fatalf("expected 9 issuers, got %v", len(config.Issuers)) + if got, want := len(config.Issuers), 10; got != want { + t.Fatalf("expected %d issuers, got %d", want, got) } expectedIssuers := map[string]bool{ emailIssuer: true, spiffeIssuer: true, uriIssuer: true, usernameIssuer: true, k8sIssuer: true, gitHubIssuer: true, buildkiteIssuer: true, gitLabIssuer: true, codefreshIssuer: true, + chainguardIssuer: true, } for _, iss := range config.Issuers { var issURL string @@ -1400,6 +1409,130 @@ func TestAPIWithCodefresh(t *testing.T) { } } +// chainguardClaims holds the additional JWT claims for Chainguard OIDC tokens +type chainguardClaims struct { + Actor map[string]string `json:"act"` + Internal struct { + ServicePrincipal string `json:"service-principal,omitempty"` + } `json:"internal"` +} + +// Tests API for Chainguard subject types +func TestAPIWithChainguard(t *testing.T) { + chainguardSigner, chainguardIssuer := newOIDCIssuer(t) + + // Create a FulcioConfig that supports these issuers. + cfg, err := config.Read([]byte(fmt.Sprintf(`{ + "OIDCIssuers": { + %q: { + "IssuerURL": %q, + "ClientID": "sigstore", + "Type": "chainguard-identity" + } + } + }`, chainguardIssuer, chainguardIssuer))) + if err != nil { + t.Fatalf("config.Read() = %v", err) + } + + group := uidp.NewUIDP("") + chainguardSubject := group.NewChild() + claims := chainguardClaims{ + Actor: map[string]string{ + "iss": chainguardIssuer, + "sub": fmt.Sprintf("catalog-syncer:%s", group.String()), + "aud": "chainguard", + }, + Internal: struct { + ServicePrincipal string `json:"service-principal,omitempty"` + }{ + ServicePrincipal: "CATALOG_SYNCER", + }, + } + + // Create an OIDC token using this issuer's signer. + tok, err := jwt.Signed(chainguardSigner).Claims(jwt.Claims{ + Issuer: chainguardIssuer, + IssuedAt: jwt.NewNumericDate(time.Now()), + Expiry: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)), + Subject: chainguardSubject.String(), + Audience: jwt.Audience{"sigstore"}, + }).Claims(&claims).Serialize() + if err != nil { + t.Fatalf("CompactSerialize() = %v", err) + } + + ctClient, eca := createCA(cfg, t) + ctx := context.Background() + server, conn := setupGRPCForTest(t, cfg, ctClient, eca) + defer func() { + server.Stop() + conn.Close() + }() + + client := protobuf.NewCAClient(conn) + + pubBytes, proof := generateKeyAndProof(chainguardSubject.String(), t) + + // Hit the API to have it sign our certificate. + resp, err := client.CreateSigningCertificate(ctx, &protobuf.CreateSigningCertificateRequest{ + Credentials: &protobuf.Credentials{ + Credentials: &protobuf.Credentials_OidcIdentityToken{ + OidcIdentityToken: tok, + }, + }, + Key: &protobuf.CreateSigningCertificateRequest_PublicKeyRequest{ + PublicKeyRequest: &protobuf.PublicKeyRequest{ + PublicKey: &protobuf.PublicKey{ + Content: pubBytes, + }, + ProofOfPossession: proof, + }, + }, + }) + if err != nil { + t.Fatalf("SigningCert() = %v", err) + } + + leafCert := verifyResponse(resp, eca, chainguardIssuer, t) + + // Expect URI values + if len(leafCert.URIs) != 1 { + t.Fatalf("unexpected length of leaf certificate URIs, expected 1, got %d", len(leafCert.URIs)) + } + chainguardURL := fmt.Sprintf("%s/%s", chainguardIssuer, chainguardSubject) + chainguardURI, err := url.Parse(chainguardURL) + if err != nil { + t.Fatalf("failed to parse expected url") + } + if *leafCert.URIs[0] != *chainguardURI { + t.Fatalf("URIs do not match: Expected %v, got %v", chainguardURI, leafCert.URIs[0]) + } + + expectedExts := map[int]string{ + 8: chainguardIssuer, + + // TODO(mattmoor): Embed more of the Chainguard token structure via OIDs. + } + for o, value := range expectedExts { + ext, found := findCustomExtension(leafCert, asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, o}) + if !found { + t.Fatalf("expected extension in custom OID 1.3.6.1.4.1.57264.1.%d", o) + } + var extValue string + rest, err := asn1.Unmarshal(ext.Value, &extValue) + if err != nil { + t.Fatalf("error unmarshalling extension: :%v", err) + } + if len(rest) != 0 { + t.Fatal("error unmarshalling extension, rest is not 0") + } + if string(extValue) != value { + t.Fatalf("unexpected extension value, expected %s, got %s", value, extValue) + } + } +} + // Tests API with issuer claim in different field in the OIDC token func TestAPIWithIssuerClaimConfig(t *testing.T) { emailSigner, emailIssuer := newOIDCIssuer(t) diff --git a/pkg/server/issuer_pool.go b/pkg/server/issuer_pool.go index 9905df5cb..18349f262 100644 --- a/pkg/server/issuer_pool.go +++ b/pkg/server/issuer_pool.go @@ -18,6 +18,7 @@ import ( "github.com/sigstore/fulcio/pkg/config" "github.com/sigstore/fulcio/pkg/identity" "github.com/sigstore/fulcio/pkg/identity/buildkite" + "github.com/sigstore/fulcio/pkg/identity/chainguard" "github.com/sigstore/fulcio/pkg/identity/codefresh" "github.com/sigstore/fulcio/pkg/identity/email" "github.com/sigstore/fulcio/pkg/identity/github" @@ -62,6 +63,8 @@ func getIssuer(meta string, i config.OIDCIssuer) identity.Issuer { return buildkite.Issuer(issuerURL) case config.IssuerTypeCodefreshWorkflow: return codefresh.Issuer(issuerURL) + case config.IssuerTypeChainguard: + return chainguard.Issuer(issuerURL) case config.IssuerTypeKubernetes: return kubernetes.Issuer(issuerURL) case config.IssuerTypeSpiffe: