diff --git a/config/config.jsn b/config/config.jsn index 6c2dff21e..8e66be79e 100644 --- a/config/config.jsn +++ b/config/config.jsn @@ -20,6 +20,11 @@ "IssuerURL": "https://token.actions.githubusercontent.com", "ClientID": "sigstore", "Type": "github-workflow" + }, + "https://oidc.codefresh.io": { + "IssuerURL": "https://oidc.codefresh.io", + "ClientID": "sigstore", + "Type": "codefresh-workflow" } } } diff --git a/config/fulcio-config.yaml b/config/fulcio-config.yaml index ba14fc7df..44a7107bb 100644 --- a/config/fulcio-config.yaml +++ b/config/fulcio-config.yaml @@ -64,6 +64,11 @@ data: "Type": "email", "IssuerClaim": "$.federated_claims.connector_id" }, + "https://oidc.codefresh.io": { + "IssuerURL": "https://oidc.codefresh.io", + "ClientID": "sigstore", + "Type": "codefresh-workflow" + }, "https://ops.gitlab.net": { "IssuerURL": "https://ops.gitlab.net", "ClientID": "sigstore", diff --git a/docs/oid-info.md b/docs/oid-info.md index ab9c3f30f..f171f0e92 100644 --- a/docs/oid-info.md +++ b/docs/oid-info.md @@ -189,27 +189,27 @@ that Sigstore operates. ## Mapping OIDC token claims to Fulcio OIDs -| GitHub [(docs)][github-oidc-doc] | GitLab [(docs)](https://docs.gitlab.com/ee/ci/secrets/id_token_authentication.html#token-payload) | CircleCI | Buildkite | Fulcio Certificate Extension | Why / Notes / Questions | -|--------------------|--------|----------|-----------|------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| aud | aud | aud | aud | N/A | Only used to validate the JWT. | -| iss | iss | iss | iss | Issuer | This already exists. For example: https://token.actions.githubusercontent.com | -| exp | exp | exp | exp | N/A | Only used to validate the JWT. | -| nbf | nbf | nbf | nbf | N/A | Only used to validate the JWT. Optional, as per the OIDC spec | -| iat | iat | iat | iat | N/A | Only used to validate the JWT. | -| server_url + job_workflow_ref | ci_config_ref_uri ([WIP][gitlab-wip-cliams]) | ?? | ?? | Build Signer URI | Reference to specific build instructions that are responsible for signing. Can be the same as Build Config URI. For example a reusable workflow in GitHub Actions or a Circle CI Orbs. | -| job_workflow_sha | ci_config_sha ([WIP][gitlab-wip-cliams]) | ?? | ?? | Build Signer Digest | An immutable reference to the specific version of the build instructions that is responsible for signing. Should include the digest type followed by the digest, e.g. `sha1:abc123`. | -| runner_environment | runner_environment | ?? | ?? | Runner Environment | For platforms to specify whether the build took place in platform-hosted cloud infrastructure or customer-hosted infrastructure. For example: `platform-hosted` and `self-hosted`. | -| server_url + repository | server_url + project_path | ?? | ?? | Source Repository URI | Should include a fully qualified repository URL. | -| sha | sha | ?? | build_commit | Source Repository Digest | An immutable reference to a specific version of the source code. Should include the digest type followed by the digest, e.g. `sha1:abc123`. | -| ref | ref | ?? | build_branch | Source Repository Ref | The source ref that the build run was based upon. For example: refs/head/main. | -| repository_id | project_id | ?? | ?? | Source Repository Identifier | Stable identifier for the owner of the source repository. | -| server_url + repository_owner | server_url + namespace_path | ?? | ?? | Source Repository Owner URI | Fully qualified URL for the owner of the source repository. | -| repository_owner_id | namespace_id | ?? | ?? | Source Repository Owner Identifier | Stable identifier for the owner of the source repository. | -| server_url + workflow_ref | ci_config_ref_uri ([WIP][gitlab-wip-cliams]) | ?? | ?? | Build Config URI | A reference to the initiating build instructions. | -| workflow_sha | ci_config_sha ([WIP][gitlab-wip-cliams]) | ?? | ?? | Build Config Digest | An immutable reference to the specific version of the top-level build instructions. Should include the digest type followed by the digest, e.g. `sha1:abc123`. | -| event_name | pipeline_source | ?? | ?? | Build Trigger | The event or action that triggered the build. | -| server_url + repository + "/actions/runs/" + run_id + "/attempts/" + run_attempt | server_url + project_path + /-/jobs/ + job_id | ?? | ?? | Run Invocation URI | An immutable identifier that can uniquely identify the build execution | -| repository_visibility | project_visibility | ?? | ?? | Source Repository Visibility At Signing | Source repository visibility at the time of signing the certificate | +| GitHub [(docs)][github-oidc-doc] | GitLab [(docs)](https://docs.gitlab.com/ee/ci/secrets/id_token_authentication.html#token-payload) | CircleCI | Buildkite | Codefresh [(docs)](https://codefresh.io/docs/docs/integrations/oidc-pipelines/) | Fulcio Certificate Extension | Why / Notes / Questions | +|--------------------|--------|----------|-----------|-----------|------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| aud | aud | aud | aud | aud | N/A | Only used to validate the JWT. | +| iss | iss | iss | iss | iss | Issuer | This already exists. For example: https://token.actions.githubusercontent.com | +| exp | exp | exp | exp | exp | N/A | Only used to validate the JWT. | +| nbf | nbf | nbf | nbf | nbf | N/A | Only used to validate the JWT. Optional, as per the OIDC spec | +| iat | iat | iat | iat | iat | N/A | Only used to validate the JWT. | +| server_url + job_workflow_ref | ci_config_ref_uri ([WIP][gitlab-wip-cliams]) | ?? | ?? | platform_url + /build/ + workflow_id | Build Signer URI | Reference to specific build instructions that are responsible for signing. Can be the same as Build Config URI. For example a reusable workflow in GitHub Actions or a Circle CI Orbs. | +| job_workflow_sha | ci_config_sha ([WIP][gitlab-wip-cliams]) | ?? | ?? | N/A | Build Signer Digest | An immutable reference to the specific version of the build instructions that is responsible for signing. Should include the digest type followed by the digest, e.g. `sha1:abc123`. | +| runner_environment | runner_environment | ?? | ?? | runner_environment | Runner Environment | For platforms to specify whether the build took place in platform-hosted cloud infrastructure or customer-hosted infrastructure. For example: `platform-hosted` and `self-hosted`. | +| server_url + repository | server_url + project_path | ?? | ?? | scm_repo_url | Source Repository URI | Should include a fully qualified repository URL. | +| sha | sha | ?? | build_commit | N/A | Source Repository Digest | An immutable reference to a specific version of the source code. Should include the digest type followed by the digest, e.g. `sha1:abc123`. | +| ref | ref | ?? | build_branch | scm_ref | Source Repository Ref | The source ref that the build run was based upon. For example: refs/head/main. | +| repository_id | project_id | ?? | ?? | N/A | Source Repository Identifier | Stable identifier for the owner of the source repository. | +| server_url + repository_owner | server_url + namespace_path | ?? | ?? | N/A | Source Repository Owner URI | Fully qualified URL for the owner of the source repository. | +| repository_owner_id | namespace_id | ?? | ?? | N/A | Source Repository Owner Identifier | Stable identifier for the owner of the source repository. | +| server_url + workflow_ref | ci_config_ref_uri ([WIP][gitlab-wip-cliams]) | ?? | ?? | platform_url + /api/pipelines/ + pipeline_id | Build Config URI | A reference to the initiating build instructions. | +| workflow_sha | ci_config_sha ([WIP][gitlab-wip-cliams]) | ?? | ?? | N/A | Build Config Digest | An immutable reference to the specific version of the top-level build instructions. Should include the digest type followed by the digest, e.g. `sha1:abc123`. | +| event_name | pipeline_source | ?? | ?? | N/A | Build Trigger | The event or action that triggered the build. | +| server_url + repository + "/actions/runs/" + run_id + "/attempts/" + run_attempt | server_url + project_path + /-/jobs/ + job_id | ?? | ?? | platform_url + /build/ + workflow_id | Run Invocation URI | An immutable identifier that can uniquely identify the build execution | +| repository_visibility | project_visibility | ?? | ?? | N/A | Source Repository Visibility At Signing | Source repository visibility at the time of signing the certificate | [github-oidc-doc]: https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#understanding-the-oidc-token [oid-link]: http://oid-info.com/get/1.3.6.1.4.1.57264 diff --git a/federation/oidc.codefresh.io/config.yaml b/federation/oidc.codefresh.io/config.yaml new file mode 100644 index 000000000..8d51a8adb --- /dev/null +++ b/federation/oidc.codefresh.io/config.yaml @@ -0,0 +1,18 @@ +# Copyright 2023 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://oidc.codefresh.io +contact: support@codefresh.io +description: "Codefresh OIDC tokens for job identity" +type: "codefresh-workflow" diff --git a/pkg/config/config.go b/pkg/config/config.go index f5c833ea0..c0eaf2a27 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -273,14 +273,15 @@ func (fc *FulcioConfig) prepare() error { type IssuerType string const ( - IssuerTypeBuildkiteJob = "buildkite-job" - IssuerTypeEmail = "email" - IssuerTypeGithubWorkflow = "github-workflow" - IssuerTypeGitLabPipeline = "gitlab-pipeline" - IssuerTypeKubernetes = "kubernetes" - IssuerTypeSpiffe = "spiffe" - IssuerTypeURI = "uri" - IssuerTypeUsername = "username" + IssuerTypeBuildkiteJob = "buildkite-job" + IssuerTypeEmail = "email" + IssuerTypeGithubWorkflow = "github-workflow" + IssuerTypeCodefreshWorkflow = "codefresh-workflow" + IssuerTypeGitLabPipeline = "gitlab-pipeline" + IssuerTypeKubernetes = "kubernetes" + IssuerTypeSpiffe = "spiffe" + IssuerTypeURI = "uri" + IssuerTypeUsername = "username" ) func parseConfig(b []byte) (cfg *FulcioConfig, err error) { @@ -511,6 +512,8 @@ func issuerToChallengeClaim(issType IssuerType, challengeClaim string) string { return "email" case IssuerTypeGithubWorkflow: return "sub" + case IssuerTypeCodefreshWorkflow: + return "sub" case IssuerTypeKubernetes: return "sub" case IssuerTypeSpiffe: diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 2778479e1..1c926a044 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -495,6 +495,9 @@ func Test_issuerToChallengeClaim(t *testing.T) { if claim := issuerToChallengeClaim(IssuerTypeGitLabPipeline, ""); claim != "sub" { t.Fatalf("expected sub subject claim for GitLab issuer, got %s", claim) } + if claim := issuerToChallengeClaim(IssuerTypeCodefreshWorkflow, ""); claim != "sub" { + t.Fatalf("expected sub subject claim for Codefresh 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/codefresh/issuer.go b/pkg/identity/codefresh/issuer.go new file mode 100644 index 000000000..8b8915202 --- /dev/null +++ b/pkg/identity/codefresh/issuer.go @@ -0,0 +1,40 @@ +// Copyright 2023 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 codefresh + +import ( + "context" + "fmt" + + "github.com/sigstore/fulcio/pkg/config" + "github.com/sigstore/fulcio/pkg/identity" + "github.com/sigstore/fulcio/pkg/identity/base" +) + +type codefreshIssuer struct { + identity.Issuer +} + +func Issuer(issuerURL string) identity.Issuer { + return &codefreshIssuer{base.Issuer(issuerURL)} +} + +func (e *codefreshIssuer) 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 codefresh issuer: %w", err) + } + return WorkflowPrincipalFromIDToken(ctx, idtoken) +} diff --git a/pkg/identity/codefresh/issuer_test.go b/pkg/identity/codefresh/issuer_test.go new file mode 100644 index 000000000..8786c33fa --- /dev/null +++ b/pkg/identity/codefresh/issuer_test.go @@ -0,0 +1,81 @@ +// Copyright 2023 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 codefresh + +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: "account:628a80b693a15c0f9c13ab75:pipeline:65e6d5551e47e5bc243ca93f:scm_repo_url:https://github.com/test-codefresh/fulcio:scm_user_name:test-codefresh:scm_ref:feat/codefresh-issuer:scm_pull_request_target_branch:main", + } + claims, err := json.Marshal(map[string]interface{}{ + "sub": "account:628a80b693a15c0f9c13ab75:pipeline:65e6d5551e47e5bc243ca93f:scm_repo_url:https://github.com/test-codefresh/fulcio:scm_user_name:test-codefresh:scm_ref:feat/codefresh-issuer:scm_pull_request_target_branch:main", + "account_id": "628a80b693a15c0f9c13ab75", + "account_name": "test-codefresh", + "pipeline_id": "65e6d5551e47e5bc243ca93f", + "pipeline_name": "oidc-test/oidc-test-2", + "workflow_id": "65e6ebe0bfbfa1782876165e", + "scm_user_name": "test-codefresh", + "scm_repo_url": "https://github.com/test-codefresh/fulcio", + "scm_ref": "feat/codefresh-issuer", + "scm_pull_request_target_branch": "main", + "runner_environment": "hybrid", + "aud": "sigstore", + "exp": 1709633177, + "iat": 1709632877, + "iss": "https://oidc.codefresh.io", + }) + 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) != "account:628a80b693a15c0f9c13ab75:pipeline:65e6d5551e47e5bc243ca93f:scm_repo_url:https://github.com/test-codefresh/fulcio:scm_user_name:test-codefresh:scm_ref:feat/codefresh-issuer:scm_pull_request_target_branch:main" { + t.Fatalf("got unexpected name %s", principal.Name(ctx)) + } + }) +} diff --git a/pkg/identity/codefresh/principal.go b/pkg/identity/codefresh/principal.go new file mode 100644 index 000000000..52fae2538 --- /dev/null +++ b/pkg/identity/codefresh/principal.go @@ -0,0 +1,168 @@ +// Copyright 2022 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 codefresh + +import ( + "context" + "crypto/x509" + "errors" + "fmt" + "net/url" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/sigstore/fulcio/pkg/certificate" + "github.com/sigstore/fulcio/pkg/identity" +) + +const ( + DefaultPlatformURL string = "https://g.codefresh.io" +) + +type workflowPrincipal struct { + // Subject matches the 'sub' claim from the OIDC ID token this is what is signed as proof of possession for Codefresh workflow identities + subject string + + // OIDC Issuer URL. Matches 'iss' claim from ID token. The real issuer URL is + // https://oidc.codefresh.io/.well-known/openid-configuration + issuer string + + // Codefresh account id + accountID string + + // Codefresh account name + accountName string + + // Codefresh Pipeline id + pipelineID string + + // Codefresh pipline name (project/pipeline) + pipelineName string + + // The ID of the specific workflow authorized in the claim. For example, 64f447c02199f903000gh20. + workflowID string + + // Applies to manual trigger types, and is the username of the user manually triggered the pipeline + initiator string + + // Applies to Git push, PR, and manual Git trigger types. The SCM name of the user who initiated the Git action. + scmUsername string + + // Applies to Git push, PR, and manual Git trigger types. The SCM URL specifying the Git repository’s location. For example, https://github.com/codefresh-user/oidc-test + scmRepoURL string + + // Applies to Git push, PR, and manual Git trigger types. The SCM name of the branch or tag within the Git repository for which the workflow should execute. For example, main or v1.0.0. + scmRef string + + // Applies to Git PR trigger types. The SCM target branch the pull request should merge into. For example, production + scmPullRequestTargetBranch string + + // Whether the build took place in cloud or self-hosted infrastructure + runnerEnvironment string + + // Codefresh platform url + platformURL string +} + +func (w workflowPrincipal) Name(_ context.Context) string { + return w.subject +} + +func WorkflowPrincipalFromIDToken(_ context.Context, token *oidc.IDToken) (identity.Principal, error) { + var claims struct { + AccountID string `json:"account_id"` + AccountName string `json:"account_name"` + PipelineID string `json:"pipeline_id"` + PipelineName string `json:"pipeline_name"` + WorkflowID string `json:"workflow_id"` + Initiator string `json:"initiator"` + SCMRepoURL string `json:"scm_repo_url"` + SCMUsername string `json:"scm_user_name"` + SCMRef string `json:"scm_ref"` + SCMPullRequestRef string `json:"scm_pull_request_target_branch"` + RunnerEnvironment string `json:"runner_environment"` + PlatformURL string `json:"platform_url"` + } + + if err := token.Claims(&claims); err != nil { + return nil, err + } + + if claims.AccountID == "" { + return nil, errors.New("missing account_id in token") + } + + if claims.PipelineID == "" { + return nil, errors.New("missing pipeline_id in token") + } + + if claims.WorkflowID == "" { + return nil, errors.New("missing workflow_id in token") + } + + // Set default platform url in case it is missing in the token + if claims.PlatformURL == "" { + claims.PlatformURL = DefaultPlatformURL + } + + return &workflowPrincipal{ + subject: token.Subject, + issuer: token.Issuer, + accountID: claims.AccountID, + accountName: claims.AccountName, + pipelineID: claims.PipelineID, + pipelineName: claims.PipelineName, + workflowID: claims.WorkflowID, + initiator: claims.Initiator, + scmUsername: claims.SCMUsername, + scmRepoURL: claims.SCMRepoURL, + scmRef: claims.SCMRef, + scmPullRequestTargetBranch: claims.SCMPullRequestRef, + runnerEnvironment: claims.RunnerEnvironment, + platformURL: claims.PlatformURL, + }, nil +} + +func (w workflowPrincipal) Embed(_ context.Context, cert *x509.Certificate) error { + + baseURL, err := url.Parse(w.platformURL) + + if err != nil { + return err + } + + // Set SAN to the //:/ - for example https://g.codefresh.io/codefresh-account/oidc-test/get-token:628a80b693a15c0f9c13ab75/65e5a53e52853dc51a5b0cc1 + // In Codefresh account names and pipeline names may be changed where as IDs do not. + // This pattern will give users the possibility to verify the signature using various forms of `cosign verify --certificate-identity-regexp` i.e https://g.codefresh.io/codefresh-account/oidc-test/get-token:* or https://g.codefresh.io/*:628a80b693a15c0f9c13ab75/65e5a53e52853dc51a5b0cc1 + cert.URIs = []*url.URL{baseURL.JoinPath(w.accountName, fmt.Sprintf("%s:%s/%s", w.pipelineName, w.accountID, w.pipelineID))} + + cert.ExtraExtensions, err = certificate.Extensions{ + Issuer: w.issuer, + // URL of the build in Codefresh. + // The workflow url is used for build signer in Codefresh because for public builds unauthenticated users only have access to the workflow, not the pipeline definition. + // Also, the workflow contains the definition of the pipeline that was used at the time of the build, making it ideal to be used as the signer url. + BuildSignerURI: baseURL.JoinPath("build", w.workflowID).String(), + RunnerEnvironment: w.runnerEnvironment, + SourceRepositoryURI: w.scmRepoURL, + SourceRepositoryRef: w.scmRef, + BuildConfigURI: baseURL.JoinPath("api", "pipelines", w.pipelineID).String(), + RunInvocationURI: baseURL.JoinPath("build", w.workflowID).String(), + }.Render() + + if err != nil { + return err + } + + return nil +} diff --git a/pkg/identity/codefresh/principal_test.go b/pkg/identity/codefresh/principal_test.go new file mode 100644 index 000000000..165483354 --- /dev/null +++ b/pkg/identity/codefresh/principal_test.go @@ -0,0 +1,326 @@ +// Copyright 2023 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 codefresh + +import ( + "context" + "crypto/x509" + "encoding/asn1" + "encoding/json" + "errors" + "fmt" + "net/url" + "reflect" + "strings" + "testing" + "unsafe" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/sigstore/fulcio/pkg/identity" +) + +func TestJobPrincipalFromIDToken(t *testing.T) { + tests := map[string]struct { + Claims map[string]interface{} + ExpectPrincipal workflowPrincipal + WantErr bool + ErrContains string + }{ + `Valid token - Manual trigger authenticates with correct claims`: { + Claims: map[string]interface{}{ + "sub": "account:628a80b693a15c0f9c13ab75:pipeline:65e5a53e52853dc51a5b0cc1:initiator:codefresh-user", + "account_id": "628a80b693a15c0f9c13ab75", + "account_name": "codefresh-account", + "pipeline_id": "65e5a53e52853dc51a5b0cc1", + "pipeline_name": "oidc-test/get-token", + "workflow_id": "65e5c23d706f166c4e8985ed", + "initiator": "codefresh-user", + "runner_environment": "hybrid", + "aud": "sigstore", + "exp": 1709556619, + "iat": 1709556319, + "iss": "https://oidc.codefresh.io", + "platform_url": "https://pre-prod.codefresh.io", + }, + ExpectPrincipal: workflowPrincipal{ + issuer: "https://oidc.codefresh.io", + subject: "account:628a80b693a15c0f9c13ab75:pipeline:65e5a53e52853dc51a5b0cc1:initiator:codefresh-user", + accountID: "628a80b693a15c0f9c13ab75", + accountName: "codefresh-account", + pipelineID: "65e5a53e52853dc51a5b0cc1", + pipelineName: "oidc-test/get-token", + workflowID: "65e5c23d706f166c4e8985ed", + initiator: "codefresh-user", + platformURL: "https://pre-prod.codefresh.io", + runnerEnvironment: "hybrid", + }, + WantErr: false, + }, + `Valid token - Git push trigger authenticates with correct claims`: { + Claims: map[string]interface{}{ + "sub": "account:628a80b693a15c0f9c13ab75:pipeline:65e5a53e52853dc51a5b0cc1:initiator:codefresh-user:scm_repo_url:https://github.com/codefresh-user/fulcio:scm_user_name:git-user-name:scm_ref:main", + "account_id": "628a80b693a15c0f9c13ab75", + "account_name": "codefresh-account", + "pipeline_id": "65e5a53e52853dc51a5b0cc1", + "pipeline_name": "oidc-test/get-token", + "workflow_id": "65e6bcf7c2af1f228fa97f80", + "initiator": "codefresh-user", + "scm_user_name": "git-user-name", + "scm_repo_url": "https://github.com/codefresh-io/fulcio-oidc", + "scm_ref": "main", + "runner_environment": "hybrid", + "aud": "sigstore", + "exp": 1709620814, + "iat": 1709620514, + "iss": "https://oidc.codefresh.io", + }, + ExpectPrincipal: workflowPrincipal{ + issuer: "https://oidc.codefresh.io", + subject: "account:628a80b693a15c0f9c13ab75:pipeline:65e5a53e52853dc51a5b0cc1:initiator:codefresh-user:scm_repo_url:https://github.com/codefresh-user/fulcio:scm_user_name:git-user-name:scm_ref:main", + accountID: "628a80b693a15c0f9c13ab75", + accountName: "codefresh-account", + pipelineID: "65e5a53e52853dc51a5b0cc1", + pipelineName: "oidc-test/get-token", + workflowID: "65e6bcf7c2af1f228fa97f80", + initiator: "codefresh-user", + scmUsername: "git-user-name", + scmRepoURL: "https://github.com/codefresh-io/fulcio-oidc", + scmRef: "main", + platformURL: "https://g.codefresh.io", + runnerEnvironment: "hybrid", + }, + WantErr: false, + }, + `Valid token - Git pull request trigger authenticates with correct claims`: { + Claims: map[string]interface{}{ + "sub": "account:628a80b693a15c0f9c13ab75:pipeline:65e6d5551e47e5bc243ca93f:scm_repo_url:https://github.com/test-codefresh/fulcio:scm_user_name:test-codefresh:scm_ref:feat/codefresh-issuer:scm_pull_request_target_branch:main", + "account_id": "628a80b693a15c0f9c13ab75", + "account_name": "test-codefresh", + "pipeline_id": "65e6d5551e47e5bc243ca93f", + "pipeline_name": "oidc-test/oidc-test-2", + "workflow_id": "65e6ebe0bfbfa1782876165e", + "scm_user_name": "test-codefresh", + "scm_repo_url": "https://github.com/test-codefresh/fulcio", + "scm_ref": "feat/codefresh-issuer", + "scm_pull_request_target_branch": "main", + "runner_environment": "hybrid", + "aud": "sigstore", + "exp": 1709633177, + "iat": 1709632877, + "iss": "https://oidc.codefresh.io", + }, + ExpectPrincipal: workflowPrincipal{ + issuer: "https://oidc.codefresh.io", + subject: "account:628a80b693a15c0f9c13ab75:pipeline:65e6d5551e47e5bc243ca93f:scm_repo_url:https://github.com/test-codefresh/fulcio:scm_user_name:test-codefresh:scm_ref:feat/codefresh-issuer:scm_pull_request_target_branch:main", + accountID: "628a80b693a15c0f9c13ab75", + accountName: "test-codefresh", + pipelineID: "65e6d5551e47e5bc243ca93f", + pipelineName: "oidc-test/oidc-test-2", + workflowID: "65e6ebe0bfbfa1782876165e", + scmUsername: "test-codefresh", + scmRepoURL: "https://github.com/test-codefresh/fulcio", + scmRef: "feat/codefresh-issuer", + scmPullRequestTargetBranch: "main", + platformURL: "https://g.codefresh.io", + runnerEnvironment: "hybrid", + }, + WantErr: false, + }, + `Token missing workflow_id claim should be rejected`: { + Claims: map[string]interface{}{ + "sub": "account:628a80b693a15c0f9c13ab75:pipeline:65e5a53e52853dc51a5b0cc1:initiator:codefresh-user", + "account_id": "628a80b693a15c0f9c13ab75", + "account_name": "codefresh-oidc", + "pipeline_id": "65e5a53e52853dc51a5b0cc1", + "pipeline_name": "oidc-test/get-token", + "initiator": "codefresh-user", + "aud": "sigstore", + "exp": 1709556619, + "iat": 1709556319, + "iss": "https://oidc.codefresh.io", + }, + WantErr: true, + ErrContains: "workflow_id", + }, + `Token missing account_id claim should be rejected`: { + Claims: map[string]interface{}{ + "sub": "account:628a80b693a15c0f9c13ab75:pipeline:65e5a53e52853dc51a5b0cc1:initiator:codefresh-user", + "account_name": "codefresh-oidc", + "pipeline_id": "65e5a53e52853dc51a5b0cc1", + "pipeline_name": "oidc-test/get-token", + "initiator": "codefresh-user", + "aud": "sigstore", + "exp": 1709556619, + "iat": 1709556319, + "iss": "https://oidc.codefresh.io", + }, + WantErr: true, + ErrContains: "account_id", + }, + `Token missing pipeline_id claim should be rejected`: { + Claims: map[string]interface{}{ + "sub": "account:628a80b693a15c0f9c13ab75:pipeline:65e5a53e52853dc51a5b0cc1:initiator:codefresh-user", + "account_name": "codefresh-oidc", + "account_id": "628a80b693a15c0f9c13ab75", + "pipeline_name": "oidc-test/get-token", + "workflow_id": "65e6bcf7c2af1f228fa97f80", + "initiator": "codefresh-user", + "aud": "sigstore", + "exp": 1709556619, + "iat": 1709556319, + "iss": "https://oidc.codefresh.io", + }, + WantErr: true, + ErrContains: "pipeline_id", + }, + } + + 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 := WorkflowPrincipalFromIDToken(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 *principal != test.ExpectPrincipal { + t.Errorf("got %v principal and expected %v", *principal, test.ExpectPrincipal) + } + }) + } +} + +// 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 TestEmbed(t *testing.T) { + tests := map[string]struct { + Principal identity.Principal + WantErr bool + WantFacts map[string]func(x509.Certificate) error + }{ + `Github workflow challenge should have all Github workflow extensions and issuer set`: { + Principal: &workflowPrincipal{ + issuer: "https://oidc.codefresh.io", + subject: "account:628a80b693a15c0f9c13ab75:pipeline:65e5a53e52853dc51a5b0cc1:initiator:codefresh-user:scm_repo_url:https://github.com/codefresh-user/fulcio:scm_user_name:git-user-name:scm_ref:main", + accountID: "628a80b693a15c0f9c13ab75", + accountName: "codefresh-account", + pipelineID: "65e5a53e52853dc51a5b0cc1", + pipelineName: "oidc-test/get-token", + workflowID: "65e6bcf7c2af1f228fa97f80", + initiator: "codefresh-user", + scmUsername: "git-user-name", + scmRepoURL: "https://github.com/codefresh-io/fulcio-oidc", + scmRef: "main", + platformURL: "https://g.codefresh.io", + runnerEnvironment: "hybrid", + }, + WantErr: false, + WantFacts: map[string]func(x509.Certificate) error{ + `Certificate SAN has correct value`: factSanURIIs("https://g.codefresh.io/codefresh-account/oidc-test/get-token:628a80b693a15c0f9c13ab75/65e5a53e52853dc51a5b0cc1"), + `Certificate has correct issuer (v2) extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 8}, "https://oidc.codefresh.io"), + `Certificate has correct builder signer URI extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 9}, "https://g.codefresh.io/build/65e6bcf7c2af1f228fa97f80"), + `Certificate has correct runner environment extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 11}, "hybrid"), + `Certificate has correct source repo URI extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 12}, "https://github.com/codefresh-io/fulcio-oidc"), + `Certificate has correct source repo ref extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 14}, "main"), + `Certificate has correct build config URI extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 18}, "https://g.codefresh.io/api/pipelines/65e5a53e52853dc51a5b0cc1"), + `Certificate has correct run invocation URI extension`: factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 21}, "https://g.codefresh.io/build/65e6bcf7c2af1f228fa97f80"), + }, + }, + } + + 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 4558277e0..35fcb323b 100644 --- a/pkg/server/grpc_server_test.go +++ b/pkg/server/grpc_server_test.go @@ -183,6 +183,8 @@ func TestGetConfiguration(t *testing.T) { _, buildkiteIssuer := newOIDCIssuer(t) _, gitHubIssuer := newOIDCIssuer(t) _, gitLabIssuer := newOIDCIssuer(t) + _, codefreshIssuer := newOIDCIssuer(t) + issuerDomain, err := url.Parse(usernameIssuer) if err != nil { t.Fatal("issuer URL could not be parsed", err) @@ -227,6 +229,11 @@ func TestGetConfiguration(t *testing.T) { "IssuerURL": %q, "ClientID": "sigstore", "Type": "gitlab-pipeline" + }, + %q: { + "IssuerURL": %q, + "ClientID": "sigstore", + "Type": "codefresh-workflow" } }, "MetaIssuers": { @@ -242,6 +249,7 @@ func TestGetConfiguration(t *testing.T) { buildkiteIssuer, buildkiteIssuer, gitHubIssuer, gitHubIssuer, gitLabIssuer, gitLabIssuer, + codefreshIssuer, codefreshIssuer, k8sIssuer))) if err != nil { t.Fatalf("config.Read() = %v", err) @@ -262,14 +270,14 @@ func TestGetConfiguration(t *testing.T) { t.Fatal("GetConfiguration failed", err) } - if len(config.Issuers) != 8 { - t.Fatalf("expected 8 issuers, got %v", len(config.Issuers)) + if len(config.Issuers) != 9 { + t.Fatalf("expected 9 issuers, got %v", len(config.Issuers)) } expectedIssuers := map[string]bool{ emailIssuer: true, spiffeIssuer: true, uriIssuer: true, usernameIssuer: true, k8sIssuer: true, gitHubIssuer: true, - buildkiteIssuer: true, gitLabIssuer: true, + buildkiteIssuer: true, gitLabIssuer: true, codefreshIssuer: true, } for _, iss := range config.Issuers { var issURL string @@ -1104,6 +1112,141 @@ func TestAPIWithGitLab(t *testing.T) { } } +// codefreshClaims holds the additional JWT claims for Codefresh OIDC tokens +type codefreshClaims struct { + AccountID string `json:"account_id"` + AccountName string `json:"account_name"` + PipelineID string `json:"pipeline_id"` + PipelineName string `json:"pipeline_name"` + WorkflowID string `json:"workflow_id"` + Initiator string `json:"initiator"` + SCMRepoURL string `json:"scm_repo_url"` + SCMUsername string `json:"scm_user_name"` + SCMRef string `json:"scm_ref"` + SCMPullRequestRef string `json:"scm_pull_request_target_branch"` + RunnerEnvironment string `json:"runner_environment"` + PlatformURL string `json:"platform_url"` +} + +// Tests API for Codefresh subject types +func TestAPIWithCodefresh(t *testing.T) { + codefreshSigner, codefreshIssuer := newOIDCIssuer(t) + + // Create a FulcioConfig that supports these issuers. + cfg, err := config.Read([]byte(fmt.Sprintf(`{ + "OIDCIssuers": { + %q: { + "IssuerURL": %q, + "ClientID": "sigstore", + "Type": "codefresh-workflow" + } + } + }`, codefreshIssuer, codefreshIssuer))) + if err != nil { + t.Fatalf("config.Read() = %v", err) + } + + claims := codefreshClaims{ + AccountID: "628a80b693a15c0f9c13ab75", + AccountName: "test-codefresh", + PipelineID: "65e6d5551e47e5bc243ca93f", + PipelineName: "oidc-test/oidc-test-2", + WorkflowID: "65e6ebe0bfbfa1782876165e", + SCMUsername: "test-codefresh", + SCMRepoURL: "https://github.com/test-codefresh/fulcio", + SCMRef: "feat/codefresh-issuer", + SCMPullRequestRef: "main", + RunnerEnvironment: "hybrid", + PlatformURL: "https://g.codefresh.io", + } + codefreshSubject := "account:628a80b693a15c0f9c13ab75:pipeline:65e6d5551e47e5bc243ca93f:scm_repo_url:https://github.com/test-codefresh/fulcio:scm_user_name:test-codefresh:scm_ref:feat/codefresh-issuer:scm_pull_request_target_branch:main" + + // Create an OIDC token using this issuer's signer. + tok, err := jwt.Signed(codefreshSigner).Claims(jwt.Claims{ + Issuer: codefreshIssuer, + IssuedAt: jwt.NewNumericDate(time.Now()), + Expiry: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)), + Subject: codefreshSubject, + Audience: jwt.Audience{"sigstore"}, + }).Claims(&claims).CompactSerialize() + if err != nil { + t.Fatalf("CompactSerialize() = %v", err) + } + + ctClient, eca := createCA(cfg, t) + ctx := context.Background() + server, conn := setupGRPCForTest(ctx, t, cfg, ctClient, eca) + defer func() { + server.Stop() + conn.Close() + }() + + client := protobuf.NewCAClient(conn) + + pubBytes, proof := generateKeyAndProof(codefreshSubject, 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, codefreshIssuer, t) + + // Expect URI values + if len(leafCert.URIs) != 1 { + t.Fatalf("unexpected length of leaf certificate URIs, expected 1, got %d", len(leafCert.URIs)) + } + codefreshURL := fmt.Sprintf("%s/%s/%s:%s/%s", claims.PlatformURL, claims.AccountName, claims.PipelineName, claims.AccountID, claims.PipelineID) + codefreshURI, err := url.Parse(codefreshURL) + if err != nil { + t.Fatalf("failed to parse expected url") + } + if *leafCert.URIs[0] != *codefreshURI { + t.Fatalf("URIs do not match: Expected %v, got %v", codefreshURI, leafCert.URIs[0]) + } + + expectedExts := map[int]string{ + 9: claims.PlatformURL + "/build/" + claims.WorkflowID, + 11: claims.RunnerEnvironment, + 12: claims.SCMRepoURL, + 14: claims.SCMRef, + 18: claims.PlatformURL + "/api/pipelines/" + claims.PipelineID, + 21: claims.PlatformURL + "/build/" + claims.WorkflowID, + } + 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 101ed92a9..9905df5cb 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/codefresh" "github.com/sigstore/fulcio/pkg/identity/email" "github.com/sigstore/fulcio/pkg/identity/github" "github.com/sigstore/fulcio/pkg/identity/gitlabcom" @@ -59,6 +60,8 @@ func getIssuer(meta string, i config.OIDCIssuer) identity.Issuer { return gitlabcom.Issuer(issuerURL) case config.IssuerTypeBuildkiteJob: return buildkite.Issuer(issuerURL) + case config.IssuerTypeCodefreshWorkflow: + return codefresh.Issuer(issuerURL) case config.IssuerTypeKubernetes: return kubernetes.Issuer(issuerURL) case config.IssuerTypeSpiffe: