diff --git a/config/fulcio-config.yaml b/config/fulcio-config.yaml index 3a146f135..f8c0fecac 100644 --- a/config/fulcio-config.yaml +++ b/config/fulcio-config.yaml @@ -22,6 +22,11 @@ data: "ClientID": "sigstore", "Type": "email" }, + "https://agent.buildkite.com": { + "IssuerURL": "https://agent.buildkite.com", + "ClientID": "sigstore", + "Type": "buildkite-job" + }, "https://allow.pub": { "IssuerURL": "https://allow.pub", "ClientID": "sigstore", diff --git a/federation/agent.buildkite.com/config.yaml b/federation/agent.buildkite.com/config.yaml new file mode 100644 index 000000000..bc1d46425 --- /dev/null +++ b/federation/agent.buildkite.com/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://agent.buildkite.com +contact: support@buildkite.com +description: "Buildkite Agent OIDC tokens for job identity" +type: "buildkite-job" diff --git a/pkg/challenges/challenges.go b/pkg/challenges/challenges.go index b545d88fc..b13107bbe 100644 --- a/pkg/challenges/challenges.go +++ b/pkg/challenges/challenges.go @@ -26,6 +26,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/email" "github.com/sigstore/fulcio/pkg/identity/github" "github.com/sigstore/fulcio/pkg/identity/kubernetes" @@ -57,6 +58,8 @@ func PrincipalFromIDToken(ctx context.Context, tok *oidc.IDToken) (identity.Prin var principal identity.Principal var err error switch iss.Type { + case config.IssuerTypeBuildkiteJob: + principal, err = buildkite.JobPrincipalFromIDToken(ctx, tok) case config.IssuerTypeEmail: principal, err = email.PrincipalFromIDToken(ctx, tok) case config.IssuerTypeSpiffe: diff --git a/pkg/config/config.go b/pkg/config/config.go index f0314c3fa..0dd26b097 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -208,6 +208,7 @@ func (fc *FulcioConfig) prepare() error { type IssuerType string const ( + IssuerTypeBuildkiteJob = "buildkite-job" IssuerTypeEmail = "email" IssuerTypeGithubWorkflow = "github-workflow" IssuerTypeKubernetes = "kubernetes" @@ -459,6 +460,8 @@ func validateAllowedDomain(subjectHostname, issuerHostname string) error { func issuerToChallengeClaim(issType IssuerType) string { switch issType { + case IssuerTypeBuildkiteJob: + return "sub" case IssuerTypeEmail: return "email" case IssuerTypeGithubWorkflow: diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 8e7ce8af2..abe21a0a3 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -482,6 +482,9 @@ func Test_issuerToChallengeClaim(t *testing.T) { if claim := issuerToChallengeClaim(IssuerTypeURI); claim != "sub" { t.Fatalf("expected sub subject claim for URI issuer, got %s", claim) } + if claim := issuerToChallengeClaim(IssuerTypeBuildkiteJob); claim != "sub" { + t.Fatalf("expected sub subject claim for Buildkite issuer, got %s", claim) + } if claim := issuerToChallengeClaim(IssuerTypeGithubWorkflow); claim != "sub" { t.Fatalf("expected sub subject claim for GitHub issuer, got %s", claim) } diff --git a/pkg/identity/buildkite/principal.go b/pkg/identity/buildkite/principal.go new file mode 100644 index 000000000..bd6709978 --- /dev/null +++ b/pkg/identity/buildkite/principal.go @@ -0,0 +1,88 @@ +// 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 buildkite + +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" +) + +type jobPrincipal struct { + // Subject matches the 'sub' claim from the OIDC ID token this is what is + // signed as proof of possession for Buildkite job identities + subject string + + // OIDC Issuer URL. Matches 'iss' claim from ID token. The real issuer URL is + // https://agent.buildkite.com/.well-known/openid-configuration + issuer string + + // The URL of the pipeline, the container of many builds. This will be + // set as a human-friendly SubjectAlternativeName URI in the certificate. + url string +} + +func JobPrincipalFromIDToken(ctx context.Context, token *oidc.IDToken) (identity.Principal, error) { + var claims struct { + OrganizationSlug string `json:"organization_slug"` + PipelineSlug string `json:"pipeline_slug"` + } + if err := token.Claims(&claims); err != nil { + return nil, err + } + + if claims.OrganizationSlug == "" { + return nil, errors.New("missing organization_slug claim in ID token") + } + + if claims.PipelineSlug == "" { + return nil, errors.New("missing pipeline_slug claim in ID token") + } + + return &jobPrincipal{ + subject: token.Subject, + issuer: token.Issuer, + url: fmt.Sprintf("https://buildkite.com/%s/%s", claims.OrganizationSlug, claims.PipelineSlug), + }, nil +} + +func (p jobPrincipal) Name(ctx context.Context) string { + return p.subject +} + +func (p jobPrincipal) Embed(ctx context.Context, cert *x509.Certificate) error { + // Set SubjectAlternativeName to the pipeline URL on the certificate + parsed, err := url.Parse(p.url) + if err != nil { + return err + } + cert.URIs = []*url.URL{parsed} + + // Embed additional information into custom extensions + cert.ExtraExtensions, err = certificate.Extensions{ + Issuer: p.issuer, + }.Render() + if err != nil { + return err + } + + return nil +} diff --git a/pkg/identity/buildkite/principal_test.go b/pkg/identity/buildkite/principal_test.go new file mode 100644 index 000000000..37218d273 --- /dev/null +++ b/pkg/identity/buildkite/principal_test.go @@ -0,0 +1,237 @@ +// 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 buildkite + +import ( + "bytes" + "context" + "crypto/x509" + "encoding/asn1" + "encoding/json" + "errors" + "fmt" + "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 jobPrincipal + WantErr bool + ErrContains string + }{ + `Valid token authenticates with correct claims`: { + Claims: map[string]interface{}{ + "aud": "sigstore", + "exp": 0, + "iss": "https://agent.buildkite.com", + "organization_slug": "acme-inc", + "pipeline_slug": "bash-example", + "sub": "organization:acme-inc:pipeline:bash-example:ref:refs/heads/main:commit:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:step:build", + }, + ExpectPrincipal: jobPrincipal{ + issuer: "https://agent.buildkite.com", + subject: "organization:acme-inc:pipeline:bash-example:ref:refs/heads/main:commit:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:step:build", + url: "https://buildkite.com/acme-inc/bash-example", + }, + WantErr: false, + }, + `Token missing organization_slug claim should be rejected`: { + Claims: map[string]interface{}{ + "aud": "sigstore", + "exp": 0, + "iss": "https://agent.buildkite.com", + "pipeline_slug": "bash-example", + "sub": "organization:acme-inc:pipeline:bash-example:ref:refs/heads/main:commit:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:step:build", + }, + WantErr: true, + ErrContains: "organization_slug", + }, + `Token missing pipeline_slug claim should be rejected`: { + Claims: map[string]interface{}{ + "aud": "sigstore", + "exp": 0, + "iss": "https://agent.buildkite.com", + "organization_slug": "acme-inc", + "sub": "organization:acme-inc:pipeline:bash-example:ref:refs/heads/main:commit:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:step:build", + }, + WantErr: true, + ErrContains: "pipeline_slug", + }, + } + + 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 := JobPrincipalFromIDToken(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.(*jobPrincipal) + 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 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", + "exp": 0, + "iss": "https://agent.buildkite.com", + "organization_slug": "acme-inc", + "pipeline_slug": "bash-example", + "sub": "organization:acme-inc:pipeline:bash-example:ref:refs/heads/main:commit:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:step:build", + }, + ExpectName: "organization:acme-inc:pipeline:bash-example:ref:refs/heads/main:commit:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:step:build", + }, + } + + 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) + + principal, err := JobPrincipalFromIDToken(context.TODO(), token) + if err != nil { + t.Fatal(err) + } + + gotName := principal.Name(context.TODO()) + if gotName != test.ExpectName { + t.Error("name should match sub claim") + } + }) + } + +} + +func TestEmbed(t *testing.T) { + tests := map[string]struct { + Principal identity.Principal + WantErr bool + WantFacts map[string]func(x509.Certificate) error + }{ + `Buildkite job challenge should have issue, subject and url embedded`: { + Principal: &jobPrincipal{ + issuer: "https://agent.buildkite.com", + subject: "doesntmatter", + url: `https://buildkite.com/foo/bar`, + }, + WantErr: false, + WantFacts: map[string]func(x509.Certificate) error{ + `Certifificate should have correct issuer`: factIssuerIs(`https://agent.buildkite.com`), + }, + }, + `Buildkite job principal with bad URL fails`: { + Principal: &jobPrincipal{ + subject: "doesntmatter", + url: "\nbadurl", + }, + WantErr: true, + }, + } + + 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 factIssuerIs(issuer string) func(x509.Certificate) error { + return factExtensionIs(asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 1}, issuer) +} + +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) { + 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 94be1ec3b..7aae1b508 100644 --- a/pkg/server/grpc_server_test.go +++ b/pkg/server/grpc_server_test.go @@ -178,6 +178,7 @@ func TestGetConfiguration(t *testing.T) { _, uriIssuer := newOIDCIssuer(t) _, usernameIssuer := newOIDCIssuer(t) _, k8sIssuer := newOIDCIssuer(t) + _, buildkiteIssuer := newOIDCIssuer(t) _, gitHubIssuer := newOIDCIssuer(t) issuerDomain, err := url.Parse(usernameIssuer) @@ -210,22 +211,28 @@ func TestGetConfiguration(t *testing.T) { "SubjectDomain": %q, "Type": "username" }, + %q: { + "IssuerURL": %q, + "ClientID": "sigstore", + "Type": "buildkite-job" + }, %q: { "IssuerURL": %q, "ClientID": "sigstore", "Type": "github-workflow" } }, - "MetaIssuers": { - %q: { - "ClientID": "sigstore", - "Type": "kubernetes" - } - } + "MetaIssuers": { + %q: { + "ClientID": "sigstore", + "Type": "kubernetes" + } + } }`, spiffeIssuer, spiffeIssuer, uriIssuer, uriIssuer, uriIssuer, emailIssuer, emailIssuer, usernameIssuer, usernameIssuer, issuerDomain.Hostname(), + buildkiteIssuer, buildkiteIssuer, gitHubIssuer, gitHubIssuer, k8sIssuer))) if err != nil { @@ -247,13 +254,14 @@ func TestGetConfiguration(t *testing.T) { t.Fatal("GetConfiguration failed", err) } - if len(config.Issuers) != 6 { - t.Fatalf("expected 6 issuers, got %v", len(config.Issuers)) + if len(config.Issuers) != 7 { + t.Fatalf("expected 7 issuers, got %v", len(config.Issuers)) } expectedIssuers := map[string]bool{ emailIssuer: true, spiffeIssuer: true, uriIssuer: true, usernameIssuer: true, k8sIssuer: true, gitHubIssuer: true, + buildkiteIssuer: true, } for _, iss := range config.Issuers { var issURL string @@ -679,8 +687,98 @@ func TestAPIWithKubernetes(t *testing.T) { } } -// gitClaims holds the additional JWT claims for GitHub OIDC tokens -type gitClaims struct { +// buildkiteClaims holds the additional JWT claims for Buildkite OIDC tokens +type buildkiteClaims struct { + OrganizationSlug string `json:"organization_slug"` + PipelineSlug string `json:"pipeline_slug"` +} + +// Tests API for Buildkite subject types +func TestAPIWithBuildkite(t *testing.T) { + buildkiteSigner, buildkiteIssuer := newOIDCIssuer(t) + + // Create a FulcioConfig that supports these issuers. + cfg, err := config.Read([]byte(fmt.Sprintf(`{ + "OIDCIssuers": { + %q: { + "IssuerURL": %q, + "ClientID": "sigstore", + "Type": "buildkite-job" + } + } + }`, buildkiteIssuer, buildkiteIssuer))) + if err != nil { + t.Fatalf("config.Read() = %v", err) + } + + claims := buildkiteClaims{ + OrganizationSlug: "acme-inc", + PipelineSlug: "bash-example", + } + buildkiteSubject := fmt.Sprintf("organization:%s:pipeline:%s:ref:refs/heads/main:commit:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:step:build", claims.OrganizationSlug, claims.PipelineSlug) + + // Create an OIDC token using this issuer's signer. + tok, err := jwt.Signed(buildkiteSigner).Claims(jwt.Claims{ + Issuer: buildkiteIssuer, + IssuedAt: jwt.NewNumericDate(time.Now()), + Expiry: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)), + Subject: buildkiteSubject, + 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(buildkiteSubject, 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, buildkiteIssuer, t) + + // Expect URI values + if len(leafCert.URIs) != 1 { + t.Fatalf("unexpected length of leaf certificate URIs, expected 1, got %d", len(leafCert.URIs)) + } + buildkiteURL := fmt.Sprintf("https://buildkite.com/%s/%s", claims.OrganizationSlug, claims.PipelineSlug) + buildkiteURI, err := url.Parse(buildkiteURL) + if err != nil { + t.Fatalf("failed to parse subject URI") + } + if *leafCert.URIs[0] != *buildkiteURI { + t.Fatalf("URIs do not match: Expected %v, got %v", buildkiteURI, leafCert.URIs[0]) + } +} + +// githubClaims holds the additional JWT claims for GitHub OIDC tokens +type githubClaims struct { JobWorkflowRef string `json:"job_workflow_ref"` Sha string `json:"sha"` Trigger string `json:"event_name"` @@ -691,7 +789,7 @@ type gitClaims struct { // Tests API for GitHub subject types func TestAPIWithGitHub(t *testing.T) { - gitSigner, gitIssuer := newOIDCIssuer(t) + githubSigner, githubIssuer := newOIDCIssuer(t) // Create a FulcioConfig that supports these issuers. cfg, err := config.Read([]byte(fmt.Sprintf(`{ @@ -702,27 +800,27 @@ func TestAPIWithGitHub(t *testing.T) { "Type": "github-workflow" } } - }`, gitIssuer, gitIssuer))) + }`, githubIssuer, githubIssuer))) if err != nil { t.Fatalf("config.Read() = %v", err) } - claims := gitClaims{ + claims := githubClaims{ JobWorkflowRef: "job/workflow/ref", Sha: "sha", Trigger: "trigger", - Repository: "repo", + Repository: "sigstore/fulcio", Workflow: "workflow", - Ref: "ref", + Ref: "refs/heads/main", } - gitSubject := fmt.Sprintf("https://github.com/%s", claims.JobWorkflowRef) + githubSubject := fmt.Sprintf("repo:%s:ref:%s", claims.Repository, claims.Ref) // Create an OIDC token using this issuer's signer. - tok, err := jwt.Signed(gitSigner).Claims(jwt.Claims{ - Issuer: gitIssuer, + tok, err := jwt.Signed(githubSigner).Claims(jwt.Claims{ + Issuer: githubIssuer, IssuedAt: jwt.NewNumericDate(time.Now()), Expiry: jwt.NewNumericDate(time.Now().Add(30 * time.Minute)), - Subject: gitSubject, + Subject: githubSubject, Audience: jwt.Audience{"sigstore"}, }).Claims(&claims).CompactSerialize() if err != nil { @@ -739,7 +837,7 @@ func TestAPIWithGitHub(t *testing.T) { client := protobuf.NewCAClient(conn) - pubBytes, proof := generateKeyAndProof(gitSubject, t) + pubBytes, proof := generateKeyAndProof(githubSubject, t) // Hit the API to have it sign our certificate. resp, err := client.CreateSigningCertificate(ctx, &protobuf.CreateSigningCertificateRequest{ @@ -761,18 +859,19 @@ func TestAPIWithGitHub(t *testing.T) { t.Fatalf("SigningCert() = %v", err) } - leafCert := verifyResponse(resp, eca, gitIssuer, t) + leafCert := verifyResponse(resp, eca, githubIssuer, t) // Expect URI values if len(leafCert.URIs) != 1 { t.Fatalf("unexpected length of leaf certificate URIs, expected 1, got %d", len(leafCert.URIs)) } - uSubject, err := url.Parse(gitSubject) + githubURL := fmt.Sprintf("https://github.com/%s", claims.JobWorkflowRef) + githubURI, err := url.Parse(githubURL) if err != nil { - t.Fatalf("failed to parse subject URI") + t.Fatalf("failed to parse expected url") } - if *leafCert.URIs[0] != *uSubject { - t.Fatalf("subjects do not match: Expected %v, got %v", uSubject, leafCert.URIs[0]) + if *leafCert.URIs[0] != *githubURI { + t.Fatalf("URIs do not match: Expected %v, got %v", githubURI, leafCert.URIs[0]) } // Verify custom OID values triggerExt, found := findCustomExtension(leafCert, asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 57264, 1, 2})