diff --git a/pkg/certificate/extensions.go b/pkg/certificate/extensions.go index 38f80d5ae..584aac971 100644 --- a/pkg/certificate/extensions.go +++ b/pkg/certificate/extensions.go @@ -69,69 +69,69 @@ type Extensions struct { // Deprecated // Triggering event of the Github Workflow. Matches the `event_name` claim of ID // tokens from Github Actions - GithubWorkflowTrigger string // OID 1.3.6.1.4.1.57264.1.2 + GithubWorkflowTrigger string `json:"GithubWorkflowTrigger,omitempty" yaml:"github-workflow-trigger,omitempty"` // OID 1.3.6.1.4.1.57264.1.2 // Deprecated // SHA of git commit being built in Github Actions. Matches the `sha` claim of ID // tokens from Github Actions - GithubWorkflowSHA string // OID 1.3.6.1.4.1.57264.1.3 + GithubWorkflowSHA string `json:"GithubWorkflowSHA,omitempty" yaml:"github-workflow-sha,omitempty"` // OID 1.3.6.1.4.1.57264.1.3 // Deprecated // Name of Github Actions Workflow. Matches the `workflow` claim of the ID // tokens from Github Actions - GithubWorkflowName string // OID 1.3.6.1.4.1.57264.1.4 + GithubWorkflowName string `json:"GithubWorkflowName,omitempty" yaml:"github-workflow-name,omitempty"` // OID 1.3.6.1.4.1.57264.1.4 // Deprecated // Repository of the Github Actions Workflow. Matches the `repository` claim of the ID // tokens from Github Actions - GithubWorkflowRepository string // OID 1.3.6.1.4.1.57264.1.5 + GithubWorkflowRepository string `json:"GithubWorkflowRepository,omitempty" yaml:"github-workflow-repository,omitempty"` // OID 1.3.6.1.4.1.57264.1.5 // Deprecated // Git Ref of the Github Actions Workflow. Matches the `ref` claim of the ID tokens // from Github Actions - GithubWorkflowRef string // 1.3.6.1.4.1.57264.1.6 + GithubWorkflowRef string `json:"GithubWorkflowRef,omitempty" yaml:"github-workflow-ref,omitempty"` // 1.3.6.1.4.1.57264.1.6 // Reference to specific build instructions that are responsible for signing. - BuildSignerURI string // 1.3.6.1.4.1.57264.1.9 + BuildSignerURI string `json:"BuildSignerURI,omitempty" yaml:"build-signer-uri,omitempty"` // 1.3.6.1.4.1.57264.1.9 // Immutable reference to the specific version of the build instructions that is responsible for signing. - BuildSignerDigest string // 1.3.6.1.4.1.57264.1.10 + BuildSignerDigest string `json:"BuildSignerDigest,omitempty" yaml:"build-signer-digest,omitempty"` // 1.3.6.1.4.1.57264.1.10 // Specifies whether the build took place in platform-hosted cloud infrastructure or customer/self-hosted infrastructure. - RunnerEnvironment string // 1.3.6.1.4.1.57264.1.11 + RunnerEnvironment string `json:"RunnerEnvironment,omitempty" yaml:"runner-environment,omitempty"` // 1.3.6.1.4.1.57264.1.11 // Source repository URL that the build was based on. - SourceRepositoryURI string // 1.3.6.1.4.1.57264.1.12 + SourceRepositoryURI string `json:"SourceRepositoryURI,omitempty" yaml:"source-repository-uri,omitempty"` // 1.3.6.1.4.1.57264.1.12 // Immutable reference to a specific version of the source code that the build was based upon. - SourceRepositoryDigest string // 1.3.6.1.4.1.57264.1.13 + SourceRepositoryDigest string `json:"SourceRepositoryDigest,omitempty" yaml:"source-repository-digest,omitempty"` // 1.3.6.1.4.1.57264.1.13 // Source Repository Ref that the build run was based upon. - SourceRepositoryRef string // 1.3.6.1.4.1.57264.1.14 + SourceRepositoryRef string `json:"SourceRepositoryRef,omitempty" yaml:"source-repository-ref,omitempty"` // 1.3.6.1.4.1.57264.1.14 // Immutable identifier for the source repository the workflow was based upon. - SourceRepositoryIdentifier string // 1.3.6.1.4.1.57264.1.15 + SourceRepositoryIdentifier string `json:"SourceRepositoryIdentifier,omitempty" yaml:"source-repository-identifier,omitempty"` // 1.3.6.1.4.1.57264.1.15 // Source repository owner URL of the owner of the source repository that the build was based on. - SourceRepositoryOwnerURI string // 1.3.6.1.4.1.57264.1.16 + SourceRepositoryOwnerURI string `json:"SourceRepositoryOwnerURI,omitempty" yaml:"source-repository-owner-uri,omitempty"` // 1.3.6.1.4.1.57264.1.16 // Immutable identifier for the owner of the source repository that the workflow was based upon. - SourceRepositoryOwnerIdentifier string // 1.3.6.1.4.1.57264.1.17 + SourceRepositoryOwnerIdentifier string `json:"SourceRepositoryOwnerIdentifier,omitempty" yaml:"source-repository-owner-identifier,omitempty"` // 1.3.6.1.4.1.57264.1.17 // Build Config URL to the top-level/initiating build instructions. - BuildConfigURI string // 1.3.6.1.4.1.57264.1.18 + BuildConfigURI string `json:"BuildConfigURI,omitempty" yaml:"build-config-uri,omitempty"` // 1.3.6.1.4.1.57264.1.18 // Immutable reference to the specific version of the top-level/initiating build instructions. - BuildConfigDigest string // 1.3.6.1.4.1.57264.1.19 + BuildConfigDigest string `json:"BuildConfigDigest,omitempty" yaml:"build-config-digest,omitempty"` // 1.3.6.1.4.1.57264.1.19 // Event or action that initiated the build. - BuildTrigger string // 1.3.6.1.4.1.57264.1.20 + BuildTrigger string `json:"BuildTrigger,omitempty" yaml:"build-trigger,omitempty"` // 1.3.6.1.4.1.57264.1.20 // Run Invocation URL to uniquely identify the build execution. - RunInvocationURI string // 1.3.6.1.4.1.57264.1.21 + RunInvocationURI string `json:"RunInvocationURI,omitempty" yaml:"run-invocation-uri,omitempty"` // 1.3.6.1.4.1.57264.1.21 // Source repository visibility at the time of signing the certificate. - SourceRepositoryVisibilityAtSigning string // 1.3.6.1.4.1.57264.1.22 + SourceRepositoryVisibilityAtSigning string `json:"SourceRepositoryVisibilityAtSigning,omitempty" yaml:"source-repository-visibility-at-signing,omitempty"` // 1.3.6.1.4.1.57264.1.22 } func (e Extensions) Render() ([]pkix.Extension, error) { diff --git a/pkg/challenges/challenges.go b/pkg/challenges/challenges.go index dda3298ff..143fdaab9 100644 --- a/pkg/challenges/challenges.go +++ b/pkg/challenges/challenges.go @@ -27,6 +27,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/ciprovider" "github.com/sigstore/fulcio/pkg/identity/email" "github.com/sigstore/fulcio/pkg/identity/github" "github.com/sigstore/fulcio/pkg/identity/gitlabcom" @@ -75,6 +76,8 @@ func PrincipalFromIDToken(ctx context.Context, tok *oidc.IDToken) (identity.Prin principal, err = uri.PrincipalFromIDToken(ctx, tok) case config.IssuerTypeUsername: principal, err = username.PrincipalFromIDToken(ctx, tok) + case config.IssuerTypeCIProvider: + principal, err = ciprovider.WorkflowPrincipalFromIDToken(ctx, tok) default: return nil, fmt.Errorf("unsupported issuer: %s", iss.Type) } diff --git a/pkg/config/config.go b/pkg/config/config.go index 6a6aca77e..6b5f01f9a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -21,6 +21,7 @@ import ( "encoding/json" "errors" "fmt" + "html/template" "net/http" "net/url" "os" @@ -31,6 +32,7 @@ import ( "github.com/coreos/go-oidc/v3/oidc" lru "github.com/hashicorp/golang-lru" + "github.com/sigstore/fulcio/pkg/certificate" fulciogrpc "github.com/sigstore/fulcio/pkg/generated/protobuf" "github.com/sigstore/fulcio/pkg/log" "github.com/spiffe/go-spiffe/v2/spiffeid" @@ -60,12 +62,33 @@ type FulcioConfig struct { // * https://container.googleapis.com/v1/projects/mattmoor-credit/locations/us-west1-b/clusters/tenant-cluster MetaIssuers map[string]OIDCIssuer `json:"MetaIssuers,omitempty" yaml:"meta-issuers,omitempty"` + // It defines metadata to be used for the CIProvider identity provider principal. + // The CI provider has a generic logic for ci providers, this metadata is used + // to define the right behavior for each ci provider that is defined + // on the configuration file + CIIssuerMetadata map[string]IssuerMetadata `json:"CIIssuerMetadata,omitempty" yaml:"ci-issuer-metadata,omitempty"` + // verifiers is a fixed mapping from our OIDCIssuers to their OIDC verifiers. verifiers map[string][]*verifierWithConfig // lru is an LRU cache of recently used verifiers for our meta issuers. lru *lru.TwoQueueCache } +type IssuerMetadata struct { + // Defaults contains key-value pairs that can be used for filling the templates from ExtensionTemplates + // If a key cannot be found on the token claims, the template will use the defaults + DefaultTemplateValues map[string]string `json:"DefaultTemplateValues,omitempty" yaml:"default-template-values,omitempty"` + // ExtensionTemplates contains a mapping between certificate extension and token claim + // Provide either strings following https://pkg.go.dev/text/template syntax, + // e.g "{{ .url }}/{{ .repository }}" + // or non-templated strings with token claim keys to be replaced, + // e.g "job_workflow_sha" + ExtensionTemplates certificate.Extensions `json:"ExtensionTemplates,omitempty" yaml:"extension-templates,omitempty"` + // Template for the Subject Alternative Name extension + // It's typically the same value as Build Signer URI + SubjectAlternativeNameTemplate string `json:"SubjectAlternativeNameTemplate,omitempty" yaml:"subject-alternative-name-template,omitempty"` +} + type OIDCIssuer struct { // The expected issuer of an OIDC token IssuerURL string `json:"IssuerURL,omitempty" yaml:"issuer-url,omitempty"` @@ -74,6 +97,8 @@ type OIDCIssuer struct { // Used to determine the subject of the certificate and if additional // certificate values are needed Type IssuerType `json:"Type" yaml:"type,omitempty"` + // CIProvider is an optional configuration to map token claims to extensions for CI workflows + CIProvider string `json:"CIProvider,omitempty" yaml:"ci-provider,omitempty"` // Optional, if the issuer is in a different claim in the OIDC token IssuerClaim string `json:"IssuerClaim,omitempty" yaml:"issuer-claim,omitempty"` // The domain that must be present in the subject for 'uri' issuer types @@ -284,6 +309,7 @@ const ( IssuerTypeSpiffe = "spiffe" IssuerTypeURI = "uri" IssuerTypeUsername = "username" + IssuerTypeCIProvider = "ci-provider" ) func parseConfig(b []byte) (cfg *FulcioConfig, err error) { @@ -391,7 +417,7 @@ func validateConfig(conf *FulcioConfig) error { } } - return nil + return validateCIIssuerMetadata(conf) } var DefaultConfig = &FulcioConfig{ @@ -432,6 +458,34 @@ func FromContext(ctx context.Context) *FulcioConfig { return untyped.(*FulcioConfig) } +// It checks that the templates defined are parseable +// We should check it during the service bootstrap to avoid errors further +func validateCIIssuerMetadata(fulcioConfig *FulcioConfig) error { + + checkParse := func(temp string) error { + t := template.New("").Option("missingkey=error") + _, err := t.Parse(temp) + return err + } + + for _, ciIssuerMetadata := range fulcioConfig.CIIssuerMetadata { + v := reflect.ValueOf(ciIssuerMetadata.ExtensionTemplates) + for i := 0; i < v.NumField(); i++ { + s := v.Field(i).String() + err := checkParse(s) + if err != nil { + return err + } + } + + err := checkParse(ciIssuerMetadata.SubjectAlternativeNameTemplate) + if err != nil { + return err + } + } + return nil +} + // Load a config from disk, or use defaults func Load(configPath string) (*FulcioConfig, error) { if _, err := os.Stat(configPath); os.IsNotExist(err) { @@ -516,6 +570,8 @@ func issuerToChallengeClaim(issType IssuerType, challengeClaim string) string { return "email" case IssuerTypeGithubWorkflow: return "sub" + case IssuerTypeCIProvider: + return "sub" case IssuerTypeCodefreshWorkflow: return "sub" case IssuerTypeChainguard: diff --git a/pkg/config/config_network_test.go b/pkg/config/config_network_test.go index 52808181a..4e00720bf 100644 --- a/pkg/config/config_network_test.go +++ b/pkg/config/config_network_test.go @@ -25,6 +25,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + "github.com/sigstore/fulcio/pkg/certificate" ) func TestLoad(t *testing.T) { @@ -68,6 +69,61 @@ func TestLoad(t *testing.T) { } } +func TestParseTemplate(t *testing.T) { + + validTemplate := "{{.foobar}}" + invalidTemplate := "{{.foobar}" + ciissuerMetadata := make(map[string]IssuerMetadata) + ciissuerMetadata["github"] = IssuerMetadata{ + ExtensionTemplates: certificate.Extensions{ + BuildTrigger: invalidTemplate, + }, + } + fulcioConfig := &FulcioConfig{ + CIIssuerMetadata: ciissuerMetadata, + } + // BuildTrigger as a invalid template should raise an error + err := validateCIIssuerMetadata(fulcioConfig) + if err == nil { + t.Error("invalid template should raise an error") + } + ciissuerMetadata["github"] = IssuerMetadata{ + ExtensionTemplates: certificate.Extensions{ + BuildTrigger: validTemplate, + }, + } + fulcioConfig = &FulcioConfig{ + CIIssuerMetadata: ciissuerMetadata, + } + // BuildTrigger as a valid template shouldn't raise an error + err = validateCIIssuerMetadata(fulcioConfig) + if err != nil { + t.Error("valid template shouldn't raise an error, error: %w", err) + } + ciissuerMetadata["github"] = IssuerMetadata{ + SubjectAlternativeNameTemplate: invalidTemplate, + } + fulcioConfig = &FulcioConfig{ + CIIssuerMetadata: ciissuerMetadata, + } + // A SAN as a invalid template should raise an error + err = validateCIIssuerMetadata(fulcioConfig) + if err == nil { + t.Error("invalid SAN should raise an error") + } + ciissuerMetadata["github"] = IssuerMetadata{ + SubjectAlternativeNameTemplate: invalidTemplate, + } + fulcioConfig = &FulcioConfig{ + CIIssuerMetadata: ciissuerMetadata, + } + // A SAN as a valid template should raise an error + err = validateCIIssuerMetadata(fulcioConfig) + if err == nil { + t.Error("valid SAN shouldn't raise an error") + } +} + func TestLoadDefaults(t *testing.T) { td := t.TempDir() diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 4c0967660..390bd6f6b 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -492,6 +492,9 @@ func Test_issuerToChallengeClaim(t *testing.T) { if claim := issuerToChallengeClaim(IssuerTypeGithubWorkflow, ""); claim != "sub" { t.Fatalf("expected sub subject claim for GitHub issuer, got %s", claim) } + if claim := issuerToChallengeClaim(IssuerTypeCIProvider, ""); claim != "sub" { + t.Fatalf("expected sub subject claim for CI issuer, got %s", claim) + } if claim := issuerToChallengeClaim(IssuerTypeGitLabPipeline, ""); claim != "sub" { t.Fatalf("expected sub subject claim for GitLab issuer, got %s", claim) } diff --git a/pkg/identity/ciprovider/issuer.go b/pkg/identity/ciprovider/issuer.go new file mode 100644 index 000000000..ce82d3558 --- /dev/null +++ b/pkg/identity/ciprovider/issuer.go @@ -0,0 +1,39 @@ +// 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 ciprovider + +import ( + "context" + + "github.com/sigstore/fulcio/pkg/config" + "github.com/sigstore/fulcio/pkg/identity" + "github.com/sigstore/fulcio/pkg/identity/base" +) + +type ciProviderIssuer struct { + identity.Issuer +} + +func Issuer(issuerURL string) identity.Issuer { + return &ciProviderIssuer{base.Issuer(issuerURL)} +} + +func (e *ciProviderIssuer) Authenticate(ctx context.Context, token string, opts ...config.InsecureOIDCConfigOption) (identity.Principal, error) { + idtoken, err := identity.Authorize(ctx, token, opts...) + if err != nil { + return nil, err + } + return WorkflowPrincipalFromIDToken(ctx, idtoken) +} diff --git a/pkg/identity/ciprovider/issuer_test.go b/pkg/identity/ciprovider/issuer_test.go new file mode 100644 index 000000000..1d8e605e3 --- /dev/null +++ b/pkg/identity/ciprovider/issuer_test.go @@ -0,0 +1,100 @@ +// 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 ciprovider + +import ( + "context" + "encoding/json" + "testing" + + "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) { + token := &oidc.IDToken{ + Issuer: "https://iss.example.com", + Subject: "repo:sigstore/fulcio:ref:refs/heads/main", + } + claims, err := json.Marshal(map[string]interface{}{ + "aud": "sigstore", + "event_name": "push", + "exp": 0, + "iss": "https://token.actions.githubusercontent.com", + "job_workflow_ref": "sigstore/fulcio/.github/workflows/foo.yaml@refs/heads/main", + "job_workflow_sha": "example-sha", + "ref": "refs/heads/main", + "repository": "sigstore/fulcio", + "repository_id": "12345", + "repository_owner": "username", + "repository_owner_id": "345", + "repository_visibility": "public", + "run_attempt": "1", + "run_id": "42", + "runner_environment": "cloud-hosted", + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "sub": "repo:sigstore/fulcio:ref:refs/heads/main", + "workflow": "foo", + "workflow_ref": "sigstore/other/.github/workflows/foo.yaml@refs/heads/main", + "workflow_sha": "example-sha-other", + }) + if err != nil { + t.Fatal(err) + } + + withClaims(token, claims) + ctx := context.TODO() + OIDCIssuers := + map[string]config.OIDCIssuer{ + token.Issuer: { + IssuerURL: token.Issuer, + Type: config.IssuerTypeCIProvider, + CIProvider: "github-workflow", + ClientID: "sigstore", + }, + } + cfg := &config.FulcioConfig{ + OIDCIssuers: OIDCIssuers, + } + ctx = config.With(ctx, cfg) + 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) != "repo:sigstore/fulcio:ref:refs/heads/main" { + t.Fatalf("got unexpected name %s", principal.Name(ctx)) + } + }) +} diff --git a/pkg/identity/ciprovider/principal.go b/pkg/identity/ciprovider/principal.go new file mode 100644 index 000000000..fb94df3bd --- /dev/null +++ b/pkg/identity/ciprovider/principal.go @@ -0,0 +1,157 @@ +// 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 ciprovider + +import ( + "bytes" + "context" + "crypto/x509" + "fmt" + "html/template" + "net/url" + "reflect" + "strings" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/sigstore/fulcio/pkg/config" + "github.com/sigstore/fulcio/pkg/identity" +) + +func mapValuesToString(claims map[string]interface{}) map[string]string { + newMap := make(map[string]string) + for k, v := range claims { + newMap[k] = fmt.Sprintf("%s", v) + } + return newMap +} + +func getTokenClaims(token *oidc.IDToken) (map[string]string, error) { + var tokenClaims map[string]interface{} + if err := token.Claims(&tokenClaims); err != nil { + return nil, err + } + return mapValuesToString(tokenClaims), nil +} + +// It makes string interpolation for a given string by using the +// templates syntax https://pkg.go.dev/text/template +func applyTemplateOrReplace(extValueTemplate string, tokenClaims map[string]string, issuerMetadata map[string]string) (string, error) { + + // Here we merge the data from was claimed by the id token with the + // default data provided by the yaml file. + // The order here matter because we want to override the claimed data + // with the default data. + // The default data will have priority over the claimed data. + mergedData := make(map[string]string) + for k, v := range tokenClaims { + mergedData[k] = v + } + for k, v := range issuerMetadata { + mergedData[k] = v + } + + if strings.Contains(extValueTemplate, "{{") { + var doc bytes.Buffer + // This option forces to having the claim that is required + // for the template + t := template.New("").Option("missingkey=error") + // It shouldn't raise error since we already checked all + // templates in validateCIIssuerMetadata functions in config.go + p, err := t.Parse(extValueTemplate) + if err != nil { + return "", err + } + err = p.Execute(&doc, mergedData) + if err != nil { + return "", err + } + return doc.String(), nil + } + claimValue, ok := mergedData[extValueTemplate] + if !ok { + return "", fmt.Errorf("value <%s> not present in either claims or defaults", extValueTemplate) + } + return claimValue, nil +} + +type ciPrincipal struct { + Token *oidc.IDToken + ClaimsMetadata config.IssuerMetadata +} + +func WorkflowPrincipalFromIDToken(ctx context.Context, token *oidc.IDToken) (identity.Principal, error) { + cfg := config.FromContext(ctx) + issuerCfg, ok := cfg.GetIssuer(token.Issuer) + if !ok { + return nil, fmt.Errorf("configuration can not be loaded for issuer %v", token.Issuer) + } + return ciPrincipal{ + token, + cfg.CIIssuerMetadata[issuerCfg.CIProvider], + }, nil +} + +func (principal ciPrincipal) Name(_ context.Context) string { + return principal.Token.Subject +} + +func (principal ciPrincipal) Embed(_ context.Context, cert *x509.Certificate) error { + + claimsTemplates := principal.ClaimsMetadata.ExtensionTemplates + defaults := principal.ClaimsMetadata.DefaultTemplateValues + claims, err := getTokenClaims(principal.Token) + if err != nil { + return err + } + subjectAlternativeName, err := applyTemplateOrReplace(principal.ClaimsMetadata.SubjectAlternativeNameTemplate, claims, defaults) + if err != nil { + return err + } + sanURL, err := url.Parse(subjectAlternativeName) + if err != nil { + return err + } + uris := []*url.URL{sanURL} + cert.URIs = uris + // We should use value.Elem() here as we need a + // addressable reference of the templates for applying the SetString(). + v := reflect.ValueOf(&claimsTemplates).Elem() + // Type of the reflect value is needed as it is necessary + // for getting the field name. + vType := v.Type() + for i := 0; i < v.NumField(); i++ { + s := v.Field(i).String() // value of each field, e.g the template string + // We check the field name to avoid to apply the template for the Issuer + // Issuer field should always come from the token issuer + if s == "" || vType.Field(i).Name == "Issuer" { + continue + } + extValue, err := applyTemplateOrReplace(s, claims, defaults) + if err != nil { + return err + } + v.Field(i).SetString(extValue) + } + + // Guarantees to set the extension issuer as the token issuer + // regardless of whether this field has been set before + claimsTemplates.Issuer = principal.Token.Issuer + // Embed additional information into custom extensions + cert.ExtraExtensions, err = claimsTemplates.Render() + if err != nil { + return err + } + return nil +} diff --git a/pkg/identity/ciprovider/principal_test.go b/pkg/identity/ciprovider/principal_test.go new file mode 100644 index 000000000..aa387f995 --- /dev/null +++ b/pkg/identity/ciprovider/principal_test.go @@ -0,0 +1,422 @@ +// 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 ciprovider + +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/certificate" + "github.com/sigstore/fulcio/pkg/config" +) + +func TestWorkflowPrincipalFromIDToken(t *testing.T) { + tests := map[string]struct { + ExpectedPrincipal ciPrincipal + }{ + `Github workflow challenge should have all Github workflow extensions and issuer set`: { + ExpectedPrincipal: ciPrincipal{ + ClaimsMetadata: config.IssuerMetadata{ + ExtensionTemplates: certificate.Extensions{ + Issuer: "issuer", + GithubWorkflowTrigger: "event_name", + GithubWorkflowSHA: "sha", + GithubWorkflowName: "workflow", + GithubWorkflowRepository: "repository", + GithubWorkflowRef: "ref", + BuildSignerURI: "{{ .url }}/{{ .job_workflow_ref }}", + BuildSignerDigest: "job_workflow_sha", + RunnerEnvironment: "runner_environment", + SourceRepositoryURI: "{{ .url }}/{{ .repository }}", + SourceRepositoryDigest: "sha", + SourceRepositoryRef: "ref", + SourceRepositoryIdentifier: "repository_id", + SourceRepositoryOwnerURI: "{{ .url }}/{{ .repository_owner }}", + SourceRepositoryOwnerIdentifier: "repository_owner_id", + BuildConfigURI: "{{ .url }}/{{ .workflow_ref }}", + BuildConfigDigest: "workflow_sha", + BuildTrigger: "event_name", + RunInvocationURI: "{{ .url }}/{{ .repository }}/actions/runs/{{ .run_id }}/attempts/{{ .run_attempt }}", + SourceRepositoryVisibilityAtSigning: "repository_visibility", + }, + DefaultTemplateValues: map[string]string{ + "url": "https://github.com", + }, + SubjectAlternativeNameTemplate: "{{.url}}/{{.job_workflow_ref}}", + }, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + claims, err := json.Marshal(map[string]interface{}{ + "issuer": "https://token.actions.githubusercontent.com", + "event_name": "trigger", + "sha": "sha", + "workflow": "workflowname", + "repository": "repository", + "ref": "ref", + "job_workflow_sha": "jobWorkflowSha", + "job_workflow_ref": "jobWorkflowRef", + "runner_environment": "runnerEnv", + "repository_id": "repoID", + "repository_owner": "repoOwner", + "repository_owner_id": "repoOwnerID", + "workflow_ref": "workflowRef", + "workflow_sha": "workflowSHA", + "run_id": "runID", + "run_attempt": "runAttempt", + "repository_visibility": "public", + }) + if err != nil { + t.Fatal(err) + } + token := &oidc.IDToken{} + withClaims(token, claims) + + test.ExpectedPrincipal.Token = token + ctx := context.TODO() + OIDCIssuers := + map[string]config.OIDCIssuer{ + token.Issuer: { + IssuerURL: token.Issuer, + Type: config.IssuerTypeCIProvider, + CIProvider: "github-workflow", + ClientID: "sigstore", + }, + } + meta := make(map[string]config.IssuerMetadata) + meta["github-workflow"] = test.ExpectedPrincipal.ClaimsMetadata + cfg := &config.FulcioConfig{ + OIDCIssuers: OIDCIssuers, + CIIssuerMetadata: meta, + } + ctx = config.With(ctx, cfg) + principal, err := WorkflowPrincipalFromIDToken(ctx, token) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(principal, test.ExpectedPrincipal) { + t.Error("Principals should be equals") + } + }) + } + +} + +// 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{} + ExpectName string + }{ + `Valid token authenticates with correct claims`: { + Claims: map[string]interface{}{ + "aud": "sigstore", + "event_name": "push", + "exp": "0", + "iss": "https://token.actions.githubusercontent.com", + "job_workflow_ref": "sigstore/fulcio/.github/workflows/foo.yaml@refs/heads/main", + "job_workflow_sha": "example-sha", + "ref": "refs/heads/main", + "repository": "sigstore/fulcio", + "repository_id": "12345", + "repository_owner": "username", + "repository_owner_id": "345", + "repository_visibility": "public", + "run_attempt": "1", + "run_id": "42", + "runner_environment": "cloud-hosted", + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "sub": "repo:sigstore/fulcio:ref:refs/heads/main", + "workflow": "foo", + "workflow_ref": "sigstore/other/.github/workflows/foo.yaml@refs/heads/main", + "workflow_sha": "example-sha-other", + }, + ExpectName: "repo:sigstore/fulcio:ref:refs/heads/main", + }, + } + + 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 := context.TODO() + OIDCIssuers := + map[string]config.OIDCIssuer{ + token.Issuer: { + IssuerURL: token.Issuer, + Type: config.IssuerTypeCIProvider, + CIProvider: "ci-provider", + ClientID: "sigstore", + }, + } + cfg := &config.FulcioConfig{ + OIDCIssuers: OIDCIssuers, + } + ctx = config.With(ctx, cfg) + principal, err := WorkflowPrincipalFromIDToken(ctx, token) + if err != nil { + t.Fatal(err) + } + + gotName := principal.Name(context.TODO()) + if gotName != test.ExpectName { + t.Error("name should match sub claim") + } + }) + } +} + +func TestApplyTemplateOrReplace(t *testing.T) { + + tokenClaims := map[string]string{ + "aud": "sigstore", + "event_name": "push", + "exp": "0", + "iss": "https://token.actions.githubusercontent.com", + "job_workflow_ref": "sigstore/fulcio/.github/workflows/foo.yaml@refs/heads/main", + "job_workflow_sha": "example-sha", + "ref": "refs/heads/main", + "repository": "sigstore/fulcio", + "repository_id": "12345", + "repository_owner": "username", + "repository_owner_id": "345", + "repository_visibility": "public", + "run_attempt": "1", + "run_id": "42", + "runner_environment": "cloud-hosted", + "sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "sub": "repo:sigstore/fulcio:ref:refs/heads/main", + "workflow": "foo", + "workflow_ref": "sigstore/other/.github/workflows/foo.yaml@refs/heads/main", + "workflow_sha": "example-sha-other", + } + issuerMetadata := map[string]string{ + "url": "https://github.com", + } + + tests := map[string]struct { + Template string + ExpectedResult string + ExpectErr bool + }{ + `Valid template`: { + Template: "{{ .url }}/{{ .repository }}/actions/runs/{{ .run_id }}/attempts/{{ .run_attempt }}", + ExpectedResult: "https://github.com/sigstore/fulcio/actions/runs/42/attempts/1", + ExpectErr: false, + }, + `Empty template`: { + Template: "{{}}", + ExpectedResult: "", + ExpectErr: true, + }, + `Missing key for template`: { + Template: "{{ .foo }}", + ExpectedResult: "", + ExpectErr: true, + }, + `Empty string`: { + Template: "", + ExpectedResult: "", + ExpectErr: true, + }, + `Replaceable string`: { + Template: "job_workflow_ref", + ExpectedResult: "sigstore/fulcio/.github/workflows/foo.yaml@refs/heads/main", + ExpectErr: false, + }, + `Missing string`: { + Template: "bar", + ExpectedResult: "", + ExpectErr: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + res, err := applyTemplateOrReplace(test.Template, tokenClaims, issuerMetadata) + if res != test.ExpectedResult { + t.Errorf("expected result don't matches: Expected %s, received: %s", + test.ExpectedResult, res) + } + if (err != nil) != test.ExpectErr { + t.Errorf("should raise an error don't matches: Expected %v, received: %v", + test.ExpectErr, err != nil) + } + }) + } +} + +func TestEmbed(t *testing.T) { + tests := map[string]struct { + WantFacts map[string]func(x509.Certificate) error + Principal ciPrincipal + }{ + `Github workflow challenge should have all Github workflow extensions and issuer set`: { + WantFacts: map[string]func(x509.Certificate) error{ + `Certifificate should have correct issuer`: factDeprecatedExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 1}, "https://token.actions.githubusercontent.com"), + `Certificate has correct trigger extension`: factDeprecatedExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 2}, "trigger"), + `Certificate has correct SHA extension`: factDeprecatedExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 3}, "sha"), + `Certificate has correct workflow extension`: factDeprecatedExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 4}, "workflowname"), + `Certificate has correct repository extension`: factDeprecatedExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 5}, "repository"), + `Certificate has correct ref extension`: factDeprecatedExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 6}, "ref"), + `Certificate has correct issuer (v2) extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 8}, "https://token.actions.githubusercontent.com"), + `Certificate has correct builder signer URI extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 9}, "https://github.com/jobWorkflowRef"), + `Certificate has correct builder signer digest extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 10}, "jobWorkflowSha"), + `Certificate has correct runner environment extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 11}, "runnerEnv"), + `Certificate has correct source repo URI extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 12}, "https://github.com/repository"), + `Certificate has correct source repo digest extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 13}, "sha"), + `Certificate has correct source repo ref extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 14}, "ref"), + `Certificate has correct source repo ID extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 15}, "repoID"), + `Certificate has correct source repo owner URI extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 16}, "https://github.com/repoOwner"), + `Certificate has correct source repo owner ID extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 17}, "repoOwnerID"), + `Certificate has correct build config URI extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 18}, "https://github.com/workflowRef"), + `Certificate has correct build config digest extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 19}, "workflowSHA"), + `Certificate has correct build trigger extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 20}, "trigger"), + `Certificate has correct run invocation ID extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 21}, "https://github.com/repository/actions/runs/runID/attempts/runAttempt"), + `Certificate has correct source repository visibility extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 22}, "public"), + }, + Principal: ciPrincipal{ + ClaimsMetadata: config.IssuerMetadata{ + ExtensionTemplates: certificate.Extensions{ + GithubWorkflowTrigger: "event_name", + GithubWorkflowSHA: "sha", + GithubWorkflowName: "workflow", + GithubWorkflowRepository: "repository", + GithubWorkflowRef: "ref", + BuildSignerURI: "{{ .url }}/{{ .job_workflow_ref }}", + BuildSignerDigest: "job_workflow_sha", + RunnerEnvironment: "runner_environment", + SourceRepositoryURI: "{{ .url }}/{{ .repository }}", + SourceRepositoryDigest: "sha", + SourceRepositoryRef: "ref", + SourceRepositoryIdentifier: "repository_id", + SourceRepositoryOwnerURI: "{{ .url }}/{{ .repository_owner }}", + SourceRepositoryOwnerIdentifier: "repository_owner_id", + BuildConfigURI: "{{ .url }}/{{ .workflow_ref }}", + BuildConfigDigest: "workflow_sha", + BuildTrigger: "event_name", + RunInvocationURI: "{{ .url }}/{{ .repository }}/actions/runs/{{ .run_id }}/attempts/{{ .run_attempt }}", + SourceRepositoryVisibilityAtSigning: "repository_visibility", + }, + DefaultTemplateValues: map[string]string{ + "url": "https://github.com", + }, + SubjectAlternativeNameTemplate: "{{.url}}/{{.job_workflow_ref}}", + }, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + var cert x509.Certificate + claims, err := json.Marshal(map[string]interface{}{ + "event_name": "trigger", + "sha": "sha", + "workflow": "workflowname", + "repository": "repository", + "ref": "ref", + "job_workflow_sha": "jobWorkflowSha", + "job_workflow_ref": "jobWorkflowRef", + "runner_environment": "runnerEnv", + "repository_id": "repoID", + "repository_owner": "repoOwner", + "repository_owner_id": "repoOwnerID", + "workflow_ref": "workflowRef", + "workflow_sha": "workflowSHA", + "run_id": "runID", + "run_attempt": "runAttempt", + "repository_visibility": "public", + }) + if err != nil { + t.Fatal(err) + } + token := &oidc.IDToken{} + token.Issuer = "https://token.actions.githubusercontent.com" + withClaims(token, claims) + + test.Principal.Token = token + err = test.Principal.Embed(context.TODO(), &cert) + if err != nil { + t.Error(err) + } + 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 factDeprecatedExtensionIs(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") + } +} diff --git a/pkg/server/grpc_server_test.go b/pkg/server/grpc_server_test.go index 999083160..56f05b71a 100644 --- a/pkg/server/grpc_server_test.go +++ b/pkg/server/grpc_server_test.go @@ -53,6 +53,7 @@ import ( "github.com/sigstore/fulcio/pkg/ca" "github.com/sigstore/fulcio/pkg/ca/ephemeralca" + "github.com/sigstore/fulcio/pkg/certificate" "github.com/sigstore/fulcio/pkg/config" "github.com/sigstore/fulcio/pkg/generated/protobuf" "github.com/sigstore/fulcio/pkg/identity" @@ -199,6 +200,7 @@ func TestGetConfiguration(t *testing.T) { _, gitLabIssuer := newOIDCIssuer(t) _, codefreshIssuer := newOIDCIssuer(t) _, chainguardIssuer := newOIDCIssuer(t) + _, ciProviderIssuer := newOIDCIssuer(t) issuerDomain, err := url.Parse(usernameIssuer) if err != nil { @@ -254,6 +256,11 @@ func TestGetConfiguration(t *testing.T) { "IssuerURL": %q, "ClientID": "sigstore", "Type": "chainguard-identity" + }, + %q: { + "IssuerURL": %q, + "ClientID": "sigstore", + "Type": "ci-provider" } }, "MetaIssuers": { @@ -271,6 +278,7 @@ func TestGetConfiguration(t *testing.T) { gitLabIssuer, gitLabIssuer, codefreshIssuer, codefreshIssuer, chainguardIssuer, chainguardIssuer, + ciProviderIssuer, ciProviderIssuer, k8sIssuer))) if err != nil { t.Fatalf("config.Read() = %v", err) @@ -291,7 +299,7 @@ func TestGetConfiguration(t *testing.T) { t.Fatal("GetConfiguration failed", err) } - if got, want := len(config.Issuers), 10; got != want { + if got, want := len(config.Issuers), 11; got != want { t.Fatalf("expected %d issuers, got %d", want, got) } @@ -299,7 +307,7 @@ func TestGetConfiguration(t *testing.T) { emailIssuer: true, spiffeIssuer: true, uriIssuer: true, usernameIssuer: true, k8sIssuer: true, gitHubIssuer: true, buildkiteIssuer: true, gitLabIssuer: true, codefreshIssuer: true, - chainguardIssuer: true, + chainguardIssuer: true, ciProviderIssuer: true, } for _, iss := range config.Issuers { var issURL string @@ -1123,6 +1131,178 @@ func TestAPIWithGitHub(t *testing.T) { } } +// Tests API for CiProvider subject types +func TestAPIWithCiProvider(t *testing.T) { + ciProviderSigner, ciProviderIssuer := newOIDCIssuer(t) + // Create a FulcioConfig that supports these issuers. + cfg, err := config.Read([]byte(fmt.Sprintf(`{ + "OIDCIssuers": { + %q: { + "IssuerURL": %q, + "ClientID": "sigstore", + "Type": "ci-provider", + "CIProvider": "github-workflow" + } + } + }`, ciProviderIssuer, ciProviderIssuer))) + if err != nil { + t.Fatalf("config.Read() = %v", err) + } + claims := githubClaims{ + JobWorkflowRef: "job/workflow/ref", + Sha: "sha", + EventName: "trigger", + Repository: "sigstore/fulcio", + Workflow: "workflow", + Ref: "refs/heads/main", + JobWorkflowSha: "example-sha", + RunnerEnvironment: "cloud-hosted", + RepositoryID: "12345", + RepositoryOwner: "username", + RepositoryOwnerID: "345", + RepositoryVisibility: "public", + WorkflowRef: "sigstore/other/.github/workflows/foo.yaml@refs/heads/main", + WorkflowSha: "example-sha-other", + RunID: "42", + RunAttempt: "1", + } + githubSubject := fmt.Sprintf("repo:%s:ref:%s", claims.Repository, claims.Ref) + // Create an OIDC token using this issuer's signer. + tok, err := jwt.Signed(ciProviderSigner).Claims(jwt.Claims{ + Issuer: ciProviderIssuer, + IssuedAt: jwt.NewNumericDate(time.Now()), + Expiry: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)), + Subject: githubSubject, + Audience: jwt.Audience{"sigstore"}, + }).Claims(&claims).Serialize() + if err != nil { + t.Fatalf("Serialize() = %v", err) + } + + ctClient, eca := createCA(cfg, t) + ctx := context.Background() + cfg.CIIssuerMetadata = make(map[string]config.IssuerMetadata) + cfg.CIIssuerMetadata["github-workflow"] = config.IssuerMetadata{ + ExtensionTemplates: certificate.Extensions{ + Issuer: "issuer", + GithubWorkflowTrigger: "event_name", + GithubWorkflowSHA: "sha", + GithubWorkflowName: "workflow", + GithubWorkflowRepository: "repository", + GithubWorkflowRef: "ref", + BuildSignerURI: "{{ .url }}/{{ .job_workflow_ref }}", + BuildSignerDigest: "job_workflow_sha", + RunnerEnvironment: "runner_environment", + SourceRepositoryURI: "{{ .url }}/{{ .repository }}", + SourceRepositoryDigest: "sha", + SourceRepositoryRef: "ref", + SourceRepositoryIdentifier: "repository_id", + SourceRepositoryOwnerURI: "{{ .url }}/{{ .repository_owner }}", + SourceRepositoryOwnerIdentifier: "repository_owner_id", + BuildConfigURI: "{{ .url }}/{{ .workflow_ref }}", + BuildConfigDigest: "workflow_sha", + BuildTrigger: "event_name", + RunInvocationURI: "{{ .url }}/{{ .repository }}/actions/runs/{{ .run_id }}/attempts/{{ .run_attempt }}", + SourceRepositoryVisibilityAtSigning: "repository_visibility", + }, + DefaultTemplateValues: map[string]string{ + "url": "https://github.com", + }, + SubjectAlternativeNameTemplate: "{{.url}}/{{.job_workflow_ref}}", + } + + server, conn := setupGRPCForTest(t, cfg, ctClient, eca) + defer func() { + server.Stop() + conn.Close() + }() + client := protobuf.NewCAClient(conn) + pubBytes, proof := generateKeyAndProof(githubSubject, 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, ciProviderIssuer, t) + // Expect URI values + if len(leafCert.URIs) != 1 { + t.Fatalf("unexpected length of leaf certificate URIs, expected 1, got %d", len(leafCert.URIs)) + } + githubURL := fmt.Sprintf("https://github.com/%s", claims.JobWorkflowRef) + githubURI, err := url.Parse(githubURL) + if err != nil { + t.Fatalf("failed to parse expected url") + } + if *leafCert.URIs[0] != *githubURI { + t.Fatalf("URIs do not match: Expected %v, got %v", githubURI, leafCert.URIs[0]) + } + // Verify custom OID values + deprecatedExpectedExts := map[int]string{ + 2: claims.EventName, + 3: claims.Sha, + 4: claims.Workflow, + 5: claims.Repository, + 6: claims.Ref, + } + for o, value := range deprecatedExpectedExts { + 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) + } + if string(ext.Value) != value { + t.Fatalf("unexpected extension value, expected %s, got %s", value, ext.Value) + } + } + url := "https://github.com/" + expectedExts := map[int]string{ + 9: url + claims.JobWorkflowRef, + 10: claims.JobWorkflowSha, + 11: claims.RunnerEnvironment, + 12: url + claims.Repository, + 13: claims.Sha, + 14: claims.Ref, + 15: claims.RepositoryID, + 16: url + claims.RepositoryOwner, + 17: claims.RepositoryOwnerID, + 18: url + claims.WorkflowRef, + 19: claims.WorkflowSha, + 20: claims.EventName, + 21: url + claims.Repository + "/actions/runs/" + claims.RunID + "/attempts/" + claims.RunAttempt, + 22: claims.RepositoryVisibility, + } + 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) + } + } +} + // gitlabClaims holds the additional JWT claims for GitLab OIDC tokens type gitlabClaims struct { ProjectPath string `json:"project_path"` diff --git a/pkg/server/issuer_pool.go b/pkg/server/issuer_pool.go index 18349f262..61e05fa34 100644 --- a/pkg/server/issuer_pool.go +++ b/pkg/server/issuer_pool.go @@ -19,6 +19,7 @@ import ( "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/ciprovider" "github.com/sigstore/fulcio/pkg/identity/codefresh" "github.com/sigstore/fulcio/pkg/identity/email" "github.com/sigstore/fulcio/pkg/identity/github" @@ -57,6 +58,8 @@ func getIssuer(meta string, i config.OIDCIssuer) identity.Issuer { return email.Issuer(issuerURL) case config.IssuerTypeGithubWorkflow: return github.Issuer(issuerURL) + case config.IssuerTypeCIProvider: + return ciprovider.Issuer(issuerURL) case config.IssuerTypeGitLabPipeline: return gitlabcom.Issuer(issuerURL) case config.IssuerTypeBuildkiteJob: