From c1722185b93932fa47fcae94956117b670a2da90 Mon Sep 17 00:00:00 2001 From: Anthony Byrne Date: Tue, 3 Feb 2026 17:41:01 -0500 Subject: [PATCH] image: Add buildSignerURI for Fulcio workflow identity verification Add support for verifying Sigstore signatures from CI workflows (e.g., GitHub Actions) by matching on the Fulcio BuildSignerURI certificate extension (OID 1.3.6.1.4.1.57264.1.9). This addresses a gap where keyless Sigstore signatures created by automated CI workflows could not be verified via policy.json, since the existing subjectEmail field only matches email-based identities from interactive signing. The new buildSignerURI field: - Extracts from the specific OID, not the SAN URI (avoiding ambiguity) - Uses exact string matching (no substring or regex) - Follows existing code patterns for OID extraction Example policy.json configuration: "fulcio": { "caPath": "/etc/pki/containers/fulcio.sigstore.dev.pub", "oidcIssuer": "https://token.actions.githubusercontent.com", "buildSignerURI": "https://github.com/org/repo/.github/workflows/release.yml@refs/heads/main" } Closes: https://github.com/containers/container-libs/issues/34 Co-authored-by: Claude Signed-off-by: Anthony Byrne --- image/docs/containers-policy.json.5.md | 35 ++- image/signature/fulcio_cert.go | 59 +++- image/signature/fulcio_cert_test.go | 276 +++++++++++++++++- image/signature/policy_config_sigstore.go | 24 +- .../signature/policy_config_sigstore_test.go | 56 +++- image/signature/policy_eval_sigstore.go | 1 + image/signature/policy_types.go | 6 + 7 files changed, 421 insertions(+), 36 deletions(-) diff --git a/image/docs/containers-policy.json.5.md b/image/docs/containers-policy.json.5.md index 1408e6510a..a5802adc83 100644 --- a/image/docs/containers-policy.json.5.md +++ b/image/docs/containers-policy.json.5.md @@ -317,7 +317,8 @@ This requirement requires an image to be signed using a sigstore signature with "caPath": "/path/to/local/CA/file", "caData": "base64-encoded-CA-data", "oidcIssuer": "https://expected.OIDC.issuer/", - "subjectEmail", "expected-signing-user@example.com", + "subjectEmail": "expected-signing-user@example.com", + "buildSignerURI": "https://github.com/OWNER/REPO/.github/workflows/WORKFLOW.yml@refs/heads/BRANCH" }, "pki": { "caRootsPath": "/path/to/local/CARoots/file", @@ -344,9 +345,12 @@ Only signatures made by any key in the list are accepted. If `fulcio` is present, the signature must be based on a Fulcio-issued certificate. One of `caPath` and `caData` must be specified, containing the public key of the Fulcio instance. -Both `oidcIssuer` and `subjectEmail` are mandatory, -exactly specifying the expected identity provider, -and the identity of the user obtaining the Fulcio certificate. +`oidcIssuer` is mandatory, specifying the expected identity provider. +At least one of `subjectEmail` or `buildSignerURI` must be specified to identify the signer: +`subjectEmail` matches the email address of a user who signed interactively, +while `buildSignerURI` matches the Build Signer URI certificate extension (OID 1.3.6.1.4.1.57264.1.9), +typically used for signatures from CI workflows like GitHub Actions. +If both are specified, both must match. If `pki` is present, the signature must be based on a non-Fulcio X.509 certificate. One of `caRootsPath` and `caRootsData` must be specified, containing certificates of the CAs. @@ -395,9 +399,10 @@ selectively allow individual transports and scopes as desired. "keyPath": "/path/to/sigstore-pubkey.pub" } ], - /* A sigstore-signed repository using the community Fulcio+Rekor servers. + /* A sigstore-signed repository using the community Fulcio+Rekor servers, + signed by a user interactively. - The community servers’ public keys can be obtained from + The community servers' public keys can be obtained from https://github.com/sigstore/sigstore/tree/main/pkg/tuf/repository/targets . */ "hostname:5000/myns/sigstore-signed-fulcio-rekor": [ { @@ -407,7 +412,23 @@ selectively allow individual transports and scopes as desired. "oidcIssuer": "https://github.com/login/oauth", "subjectEmail": "test-user@example.com" }, - "rekorPublicKeyPath": "/path/to/rekor.pub", + "rekorPublicKeyPath": "/path/to/rekor.pub" + } + ], + /* A sigstore-signed repository using keyless signing from a GitHub Actions workflow. + This verifies that the image was signed by a specific workflow in a specific repository. + + The community servers' public keys can be obtained from + https://github.com/sigstore/sigstore/tree/main/pkg/tuf/repository/targets . */ + "hostname:5000/myns/sigstore-signed-github-actions": [ + { + "type": "sigstoreSigned", + "fulcio": { + "caPath": "/path/to/fulcio_v1.crt.pem", + "oidcIssuer": "https://token.actions.githubusercontent.com", + "buildSignerURI": "https://github.com/myorg/myrepo/.github/workflows/release.yml@refs/heads/main" + }, + "rekorPublicKeyPath": "/path/to/rekor.pub" } ], /* A Sigstore-signed repository using a certificate generated by a custom public-key infrastructure.*/ diff --git a/image/signature/fulcio_cert.go b/image/signature/fulcio_cert.go index 203ed2ca5b..3ac774bf47 100644 --- a/image/signature/fulcio_cert.go +++ b/image/signature/fulcio_cert.go @@ -21,14 +21,15 @@ type fulcioTrustRoot struct { caCertificates *x509.CertPool oidcIssuer string subjectEmail string + buildSignerURI string } func (f *fulcioTrustRoot) validate() error { if f.oidcIssuer == "" { return errors.New("Internal inconsistency: Fulcio use set up without OIDC issuer") } - if f.subjectEmail == "" { - return errors.New("Internal inconsistency: Fulcio use set up without subject email") + if f.subjectEmail == "" && f.buildSignerURI == "" { + return errors.New("Internal inconsistency: Fulcio use set up without subject identity (subjectEmail or buildSignerURI)") } return nil } @@ -85,6 +86,31 @@ func fulcioIssuerInCertificate(untrustedCertificate *x509.Certificate) (string, } } +// fulcioBuildSignerURIInCertificate extracts the BuildSignerURI (OID 1.3.6.1.4.1.57264.1.9) from the certificate. +// Returns an empty string and no error if the extension is not present. +func fulcioBuildSignerURIInCertificate(untrustedCertificate *x509.Certificate) (string, error) { + var buildSignerURI string + found := false + for _, untrustedExt := range untrustedCertificate.Extensions { + if untrustedExt.Id.Equal(certificate.OIDBuildSignerURI) { + if found { + // Coverage: This is unreachable in Go ≥1.19, which rejects certificates with duplicate extensions + // already in ParseCertificate. + return "", internal.NewInvalidSignatureError("Fulcio certificate has a duplicate BuildSignerURI extension") + } + rest, err := asn1.Unmarshal(untrustedExt.Value, &buildSignerURI) + if err != nil { + return "", internal.NewInvalidSignatureError(fmt.Sprintf("invalid ASN.1 in BuildSignerURI extension: %v", err)) + } + if len(rest) != 0 { + return "", internal.NewInvalidSignatureError("invalid ASN.1 in BuildSignerURI extension, trailing data") + } + found = true + } + } + return buildSignerURI, nil +} + func (f *fulcioTrustRoot) verifyFulcioCertificateAtTime(relevantTime time.Time, untrustedCertificateBytes []byte, untrustedIntermediateChainBytes []byte) (crypto.PublicKey, error) { // == Verify the certificate is correctly signed var untrustedIntermediatePool *x509.CertPool // = nil @@ -164,15 +190,30 @@ func (f *fulcioTrustRoot) verifyFulcioCertificateAtTime(relevantTime time.Time, return nil, internal.NewInvalidSignatureError(fmt.Sprintf("Unexpected Fulcio OIDC issuer %q", oidcIssuer)) } - // == Validate the OIDC subject - if !slices.Contains(untrustedCertificate.EmailAddresses, f.subjectEmail) { - return nil, internal.NewInvalidSignatureError(fmt.Sprintf("Required email %q not found (got %q)", - f.subjectEmail, - untrustedCertificate.EmailAddresses)) + // == Validate the OIDC subject identity + // At least one of subjectEmail or buildSignerURI must be specified (enforced by validate()). + // If both are specified, both must match (AND semantics). + if f.subjectEmail != "" { + if !slices.Contains(untrustedCertificate.EmailAddresses, f.subjectEmail) { + return nil, internal.NewInvalidSignatureError(fmt.Sprintf("Required email %q not found (got %q)", + f.subjectEmail, + untrustedCertificate.EmailAddresses)) + } + } + if f.buildSignerURI != "" { + certBuildSignerURI, err := fulcioBuildSignerURIInCertificate(untrustedCertificate) + if err != nil { + return nil, err + } + if certBuildSignerURI != f.buildSignerURI { + return nil, internal.NewInvalidSignatureError(fmt.Sprintf("Required BuildSignerURI %q not found (got %q)", + f.buildSignerURI, + certBuildSignerURI)) + } } // FIXME: Match more subject types? Cosign does: - // - .DNSNames (can’t be issued by Fulcio) - // - .IPAddresses (can’t be issued by Fulcio) + // - .DNSNames (can't be issued by Fulcio) + // - .IPAddresses (can't be issued by Fulcio) // - .URIs (CAN be issued by Fulcio) // - OtherName values in SAN (CAN be issued by Fulcio) // - Various values about GitHub workflows (CAN be issued by Fulcio) diff --git a/image/signature/fulcio_cert_test.go b/image/signature/fulcio_cert_test.go index c678b18f6d..d1ec672ea8 100644 --- a/image/signature/fulcio_cert_test.go +++ b/image/signature/fulcio_cert_test.go @@ -36,29 +36,75 @@ func assertPublicKeyMatchesCert(t *testing.T, certPEM []byte, pk crypto.PublicKe func TestFulcioTrustRootValidate(t *testing.T) { certs := x509.NewCertPool() // Empty is valid enough for our purposes. - for _, tr := range []fulcioTrustRoot{ + // Invalid configurations + for _, c := range []struct { + name string + tr fulcioTrustRoot + }{ { - caCertificates: certs, - oidcIssuer: "", - subjectEmail: "email", + name: "Missing OIDC issuer with subjectEmail", + tr: fulcioTrustRoot{ + caCertificates: certs, + oidcIssuer: "", + subjectEmail: "email", + }, }, { - caCertificates: certs, - oidcIssuer: "issuer", - subjectEmail: "", + name: "Missing OIDC issuer with buildSignerURI", + tr: fulcioTrustRoot{ + caCertificates: certs, + oidcIssuer: "", + buildSignerURI: "https://github.com/org/repo/.github/workflows/build.yml@refs/heads/main", + }, + }, + { + name: "Missing both subjectEmail and buildSignerURI", + tr: fulcioTrustRoot{ + caCertificates: certs, + oidcIssuer: "issuer", + subjectEmail: "", + buildSignerURI: "", + }, }, } { - err := tr.validate() - assert.Error(t, err) + err := c.tr.validate() + assert.Error(t, err, c.name) } - tr := fulcioTrustRoot{ - caCertificates: certs, - oidcIssuer: "issuer", - subjectEmail: "email", + // Valid configurations + for _, c := range []struct { + name string + tr fulcioTrustRoot + }{ + { + name: "With subjectEmail only", + tr: fulcioTrustRoot{ + caCertificates: certs, + oidcIssuer: "issuer", + subjectEmail: "email", + }, + }, + { + name: "With buildSignerURI only", + tr: fulcioTrustRoot{ + caCertificates: certs, + oidcIssuer: "issuer", + buildSignerURI: "https://github.com/org/repo/.github/workflows/build.yml@refs/heads/main", + }, + }, + { + name: "With both subjectEmail and buildSignerURI", + tr: fulcioTrustRoot{ + caCertificates: certs, + oidcIssuer: "issuer", + subjectEmail: "email", + buildSignerURI: "https://github.com/org/repo/.github/workflows/build.yml@refs/heads/main", + }, + }, + } { + err := c.tr.validate() + assert.NoError(t, err, c.name) } - err := tr.validate() - assert.NoError(t, err) } // oidIssuerV1Ext creates an certificate.OIDIssuer extension @@ -84,6 +130,100 @@ func oidIssuerV2Ext(t *testing.T, value string) pkix.Extension { } } +// oidBuildSignerURIExt creates an certificate.OIDBuildSignerURI extension +func oidBuildSignerURIExt(t *testing.T, value string) pkix.Extension { + return pkix.Extension{ + Id: certificate.OIDBuildSignerURI, + Value: asn1MarshalTest(t, value, "utf8"), + } +} + +func TestFulcioBuildSignerURIInCertificate(t *testing.T) { + referenceTime := time.Now() + for _, c := range []struct { + name string + extensions []pkix.Extension + errorFragment string + expected string + }{ + { + name: "Missing BuildSignerURI extension", + extensions: nil, + expected: "", // Not an error, just returns empty string + }, + { + name: "Valid BuildSignerURI", + extensions: []pkix.Extension{ + oidBuildSignerURIExt(t, "https://github.com/org/repo/.github/workflows/build.yml@refs/heads/main"), + }, + expected: "https://github.com/org/repo/.github/workflows/build.yml@refs/heads/main", + }, + { + name: "Duplicate BuildSignerURI extension", + extensions: []pkix.Extension{ + oidBuildSignerURIExt(t, "https://github.com/org/repo/.github/workflows/build.yml@refs/heads/main"), + oidBuildSignerURIExt(t, "https://github.com/org/repo/.github/workflows/other.yml@refs/heads/main"), + }, + // Match both our message and the Go 1.19 message: "certificate contains duplicate extensions" + errorFragment: "duplicate", + }, + { + name: "Invalid ASN.1 in BuildSignerURI extension", + extensions: []pkix.Extension{ + { + Id: certificate.OIDBuildSignerURI, + Value: asn1MarshalTest(t, 1, ""), // not a string type + }, + }, + errorFragment: "invalid ASN.1 in BuildSignerURI extension: asn1: structure error", + }, + { + name: "Trailing data in BuildSignerURI extension", + extensions: []pkix.Extension{ + { + Id: certificate.OIDBuildSignerURI, + Value: append(bytes.Clone(asn1MarshalTest(t, "https://", "utf8")), asn1MarshalTest(t, "example.com", "utf8")...), + }, + }, + errorFragment: "invalid ASN.1 in BuildSignerURI extension, trailing data", + }, + } { + testLeafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err, c.name) + testLeafContents := x509.Certificate{ + Subject: pkix.Name{CommonName: "leaf"}, + NotBefore: referenceTime.Add(-1 * time.Minute), + NotAfter: referenceTime.Add(1 * time.Hour), + ExtraExtensions: c.extensions, + EmailAddresses: []string{"test-user@example.com"}, + } + testLeafCert, err := x509.CreateCertificate(rand.Reader, &testLeafContents, &testLeafContents, testLeafKey.Public(), testLeafKey) + require.NoError(t, err, c.name) + testLeafPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: testLeafCert, + }) + + parsedLeafCerts, err := cryptoutils.UnmarshalCertificatesFromPEM(testLeafPEM) + if err != nil { + require.NotEqual(t, "", c.errorFragment, c.name) + assert.ErrorContains(t, err, c.errorFragment, c.name) + } else { + require.Len(t, parsedLeafCerts, 1) + parsedLeafCert := parsedLeafCerts[0] + + res, err := fulcioBuildSignerURIInCertificate(parsedLeafCert) + if c.errorFragment == "" { + require.NoError(t, err, c.name) + assert.Equal(t, c.expected, res, c.name) + } else { + assert.ErrorContains(t, err, c.errorFragment, c.name) + assert.Equal(t, "", res, c.name) + } + } + } +} + func TestFulcioIssuerInCertificate(t *testing.T) { referenceTime := time.Now() fulcioExtensions, err := certificate.Extensions{Issuer: "https://github.com/login/oauth"}.Render() @@ -427,6 +567,112 @@ func TestFulcioTrustRootVerifyFulcioCertificateAtTime(t *testing.T) { } } +func TestFulcioTrustRootVerifyBuildSignerURI(t *testing.T) { + referenceTime := time.Now() + testCAKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + testCAContents := x509.Certificate{ + Subject: pkix.Name{CommonName: "root CA"}, + NotBefore: referenceTime.Add(-1 * time.Minute), + NotAfter: referenceTime.Add(1 * time.Hour), + BasicConstraintsValid: true, + IsCA: true, + } + testCACertBytes, err := x509.CreateCertificate(rand.Reader, &testCAContents, &testCAContents, + testCAKey.Public(), testCAKey) + require.NoError(t, err) + testCACert, err := x509.ParseCertificate(testCACertBytes) + require.NoError(t, err) + testCACertPool := x509.NewCertPool() + testCACertPool.AddCert(testCACert) + + const testBuildSignerURI = "https://github.com/org/repo/.github/workflows/build.yml@refs/heads/main" + + for _, c := range []struct { + name string + extensions []pkix.Extension + trustRoot fulcioTrustRoot + errorFragment string + }{ + { + name: "Matching BuildSignerURI", + extensions: []pkix.Extension{ + oidIssuerV2Ext(t, "https://token.actions.githubusercontent.com"), + oidBuildSignerURIExt(t, testBuildSignerURI), + }, + trustRoot: fulcioTrustRoot{ + caCertificates: testCACertPool, + oidcIssuer: "https://token.actions.githubusercontent.com", + buildSignerURI: testBuildSignerURI, + }, + errorFragment: "", + }, + { + name: "BuildSignerURI mismatch", + extensions: []pkix.Extension{ + oidIssuerV2Ext(t, "https://token.actions.githubusercontent.com"), + oidBuildSignerURIExt(t, "https://github.com/other/repo/.github/workflows/build.yml@refs/heads/main"), + }, + trustRoot: fulcioTrustRoot{ + caCertificates: testCACertPool, + oidcIssuer: "https://token.actions.githubusercontent.com", + buildSignerURI: testBuildSignerURI, + }, + errorFragment: `Required BuildSignerURI`, + }, + { + name: "Missing BuildSignerURI in certificate", + extensions: []pkix.Extension{ + oidIssuerV2Ext(t, "https://token.actions.githubusercontent.com"), + // No BuildSignerURI extension + }, + trustRoot: fulcioTrustRoot{ + caCertificates: testCACertPool, + oidcIssuer: "https://token.actions.githubusercontent.com", + buildSignerURI: testBuildSignerURI, + }, + errorFragment: `Required BuildSignerURI`, + }, + { + name: "Both subjectEmail and buildSignerURI required, both match", + extensions: []pkix.Extension{ + oidIssuerV2Ext(t, "https://token.actions.githubusercontent.com"), + oidBuildSignerURIExt(t, testBuildSignerURI), + }, + trustRoot: fulcioTrustRoot{ + caCertificates: testCACertPool, + oidcIssuer: "https://token.actions.githubusercontent.com", + subjectEmail: "test@example.com", + buildSignerURI: testBuildSignerURI, + }, + errorFragment: `Required email "test@example.com" not found`, // Email not in cert + }, + } { + testLeafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err, c.name) + testLeafContents := x509.Certificate{ + Subject: pkix.Name{CommonName: "leaf"}, + NotBefore: referenceTime.Add(-1 * time.Minute), + NotAfter: referenceTime.Add(1 * time.Hour), + ExtraExtensions: c.extensions, + } + testLeafCert, err := x509.CreateCertificate(rand.Reader, &testLeafContents, testCACert, testLeafKey.Public(), testCAKey) + require.NoError(t, err, c.name) + testLeafPEM := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: testLeafCert, + }) + pk, err := c.trustRoot.verifyFulcioCertificateAtTime(referenceTime, testLeafPEM, []byte{}) + if c.errorFragment == "" { + require.NoError(t, err, c.name) + assertPublicKeyMatchesCert(t, testLeafPEM, pk) + } else { + assert.ErrorContains(t, err, c.errorFragment, c.name) + assert.Nil(t, pk, c.name) + } + } +} + func TestVerifyRekorFulcio(t *testing.T) { caCertificates := x509.NewCertPool() fulcioCABundlePEM, err := os.ReadFile("fixtures/fulcio_v1.crt.pem") diff --git a/image/signature/policy_config_sigstore.go b/image/signature/policy_config_sigstore.go index 87fb455581..ded67c7249 100644 --- a/image/signature/policy_config_sigstore.go +++ b/image/signature/policy_config_sigstore.go @@ -384,6 +384,18 @@ func PRSigstoreSignedFulcioWithSubjectEmail(subjectEmail string) PRSigstoreSigne } } +// PRSigstoreSignedFulcioWithBuildSignerURI specifies a value for the "buildSignerURI" field when calling NewPRSigstoreSignedFulcio. +// This is used for verifying workflow-based identity in CI-generated signatures (e.g., GitHub Actions). +func PRSigstoreSignedFulcioWithBuildSignerURI(buildSignerURI string) PRSigstoreSignedFulcioOption { + return func(f *prSigstoreSignedFulcio) error { + if f.BuildSignerURI != "" { + return InvalidPolicyFormatError(`"buildSignerURI" already specified`) + } + f.BuildSignerURI = buildSignerURI + return nil + } +} + // newPRSigstoreSignedFulcio is NewPRSigstoreSignedFulcio, except it returns the private type func newPRSigstoreSignedFulcio(options ...PRSigstoreSignedFulcioOption) (*prSigstoreSignedFulcio, error) { res := prSigstoreSignedFulcio{} @@ -402,8 +414,8 @@ func newPRSigstoreSignedFulcio(options ...PRSigstoreSignedFulcioOption) (*prSigs if res.OIDCIssuer == "" { return nil, InvalidPolicyFormatError("oidcIssuer not specified") } - if res.SubjectEmail == "" { - return nil, InvalidPolicyFormatError("subjectEmail not specified") + if res.SubjectEmail == "" && res.BuildSignerURI == "" { + return nil, InvalidPolicyFormatError("at least one of subjectEmail or buildSignerURI must be specified") } return &res, nil @@ -420,7 +432,7 @@ var _ json.Unmarshaler = (*prSigstoreSignedFulcio)(nil) func (f *prSigstoreSignedFulcio) UnmarshalJSON(data []byte) error { *f = prSigstoreSignedFulcio{} var tmp prSigstoreSignedFulcio - var gotCAPath, gotCAData, gotOIDCIssuer, gotSubjectEmail bool // = false... + var gotCAPath, gotCAData, gotOIDCIssuer, gotSubjectEmail, gotBuildSignerURI bool // = false... if err := internal.ParanoidUnmarshalJSONObject(data, func(key string) any { switch key { case "caPath": @@ -435,6 +447,9 @@ func (f *prSigstoreSignedFulcio) UnmarshalJSON(data []byte) error { case "subjectEmail": gotSubjectEmail = true return &tmp.SubjectEmail + case "buildSignerURI": + gotBuildSignerURI = true + return &tmp.BuildSignerURI default: return nil } @@ -455,6 +470,9 @@ func (f *prSigstoreSignedFulcio) UnmarshalJSON(data []byte) error { if gotSubjectEmail { opts = append(opts, PRSigstoreSignedFulcioWithSubjectEmail(tmp.SubjectEmail)) } + if gotBuildSignerURI { + opts = append(opts, PRSigstoreSignedFulcioWithBuildSignerURI(tmp.BuildSignerURI)) + } res, err := newPRSigstoreSignedFulcio(opts...) if err != nil { diff --git a/image/signature/policy_config_sigstore_test.go b/image/signature/policy_config_sigstore_test.go index 3d432e2aa2..6f46f67690 100644 --- a/image/signature/policy_config_sigstore_test.go +++ b/image/signature/policy_config_sigstore_test.go @@ -658,6 +658,7 @@ func TestNewPRSigstoreSignedFulcio(t *testing.T) { testCAData := []byte("abc") const testOIDCIssuer = "https://example.com" const testSubjectEmail = "test@example.com" + const testBuildSignerURI = "https://github.com/org/repo/.github/workflows/build.yml@refs/heads/main" // Success: for _, c := range []struct { @@ -688,6 +689,32 @@ func TestNewPRSigstoreSignedFulcio(t *testing.T) { SubjectEmail: testSubjectEmail, }, }, + { // With buildSignerURI instead of subjectEmail + options: []PRSigstoreSignedFulcioOption{ + PRSigstoreSignedFulcioWithCAPath(testCAPath), + PRSigstoreSignedFulcioWithOIDCIssuer(testOIDCIssuer), + PRSigstoreSignedFulcioWithBuildSignerURI(testBuildSignerURI), + }, + expected: prSigstoreSignedFulcio{ + CAPath: testCAPath, + OIDCIssuer: testOIDCIssuer, + BuildSignerURI: testBuildSignerURI, + }, + }, + { // With both subjectEmail and buildSignerURI + options: []PRSigstoreSignedFulcioOption{ + PRSigstoreSignedFulcioWithCAPath(testCAPath), + PRSigstoreSignedFulcioWithOIDCIssuer(testOIDCIssuer), + PRSigstoreSignedFulcioWithSubjectEmail(testSubjectEmail), + PRSigstoreSignedFulcioWithBuildSignerURI(testBuildSignerURI), + }, + expected: prSigstoreSignedFulcio{ + CAPath: testCAPath, + OIDCIssuer: testOIDCIssuer, + SubjectEmail: testSubjectEmail, + BuildSignerURI: testBuildSignerURI, + }, + }, } { pr, err := newPRSigstoreSignedFulcio(c.options...) require.NoError(t, err) @@ -727,7 +754,7 @@ func TestNewPRSigstoreSignedFulcio(t *testing.T) { PRSigstoreSignedFulcioWithOIDCIssuer(testOIDCIssuer + "1"), PRSigstoreSignedFulcioWithSubjectEmail(testSubjectEmail), }, - { // Missing subjectEmail + { // Missing both subjectEmail and buildSignerURI PRSigstoreSignedFulcioWithCAPath(testCAPath), PRSigstoreSignedFulcioWithOIDCIssuer(testOIDCIssuer), }, @@ -737,6 +764,12 @@ func TestNewPRSigstoreSignedFulcio(t *testing.T) { PRSigstoreSignedFulcioWithSubjectEmail(testSubjectEmail), PRSigstoreSignedFulcioWithSubjectEmail("1" + testSubjectEmail), }, + { // Duplicate buildSignerURI + PRSigstoreSignedFulcioWithCAPath(testCAPath), + PRSigstoreSignedFulcioWithOIDCIssuer(testOIDCIssuer), + PRSigstoreSignedFulcioWithBuildSignerURI(testBuildSignerURI), + PRSigstoreSignedFulcioWithBuildSignerURI(testBuildSignerURI + "1"), + }, } { _, err := newPRSigstoreSignedFulcio(c...) logrus.Errorf("%#v", err) @@ -770,7 +803,7 @@ func TestPRSigstoreSignedFulcioUnmarshalJSON(t *testing.T) { func(v mSA) { delete(v, "oidcIssuer") }, // Invalid "subjectEmail" field func(v mSA) { v["subjectEmail"] = 1 }, - // "subjectEmail" is missing + // Both "subjectEmail" and "buildSignerURI" are missing func(v mSA) { delete(v, "subjectEmail") }, }, duplicateFields: []string{"caPath", "oidcIssuer", "subjectEmail"}, @@ -793,6 +826,25 @@ func TestPRSigstoreSignedFulcioUnmarshalJSON(t *testing.T) { }, duplicateFields: []string{"caData", "oidcIssuer", "subjectEmail"}, }.run(t) + // Test buildSignerURI specifics + policyJSONUmarshallerTests[PRSigstoreSignedFulcio]{ + newDest: func() json.Unmarshaler { return &prSigstoreSignedFulcio{} }, + newValidObject: func() (PRSigstoreSignedFulcio, error) { + return NewPRSigstoreSignedFulcio( + PRSigstoreSignedFulcioWithCAPath("fixtures/fulcio_v1.crt.pem"), + PRSigstoreSignedFulcioWithOIDCIssuer("https://token.actions.githubusercontent.com"), + PRSigstoreSignedFulcioWithBuildSignerURI("https://github.com/org/repo/.github/workflows/build.yml@refs/heads/main"), + ) + }, + otherJSONParser: nil, + breakFns: []func(mSA){ + // Invalid "buildSignerURI" field + func(v mSA) { v["buildSignerURI"] = 1 }, + // Both "subjectEmail" and "buildSignerURI" missing + func(v mSA) { delete(v, "buildSignerURI") }, + }, + duplicateFields: []string{"caPath", "oidcIssuer", "buildSignerURI"}, + }.run(t) } func TestNewPRSigstoreSignedPKI(t *testing.T) { diff --git a/image/signature/policy_eval_sigstore.go b/image/signature/policy_eval_sigstore.go index 7069506b81..b52e8acac4 100644 --- a/image/signature/policy_eval_sigstore.go +++ b/image/signature/policy_eval_sigstore.go @@ -90,6 +90,7 @@ func (f *prSigstoreSignedFulcio) prepareTrustRoot() (*fulcioTrustRoot, error) { caCertificates: certs, oidcIssuer: f.OIDCIssuer, subjectEmail: f.SubjectEmail, + buildSignerURI: f.BuildSignerURI, } if err := fulcio.validate(); err != nil { return nil, err diff --git a/image/signature/policy_types.go b/image/signature/policy_types.go index 2d107ed450..c47d659ce5 100644 --- a/image/signature/policy_types.go +++ b/image/signature/policy_types.go @@ -167,7 +167,13 @@ type prSigstoreSignedFulcio struct { // OIDCIssuer specifies the expected OIDC issuer, recorded by Fulcio into the generated certificates. OIDCIssuer string `json:"oidcIssuer,omitempty"` // SubjectEmail specifies the expected email address of the authenticated OIDC identity, recorded by Fulcio into the generated certificates. + // At least one of SubjectEmail and BuildSignerURI must be specified. SubjectEmail string `json:"subjectEmail,omitempty"` + // BuildSignerURI specifies the expected BuildSignerURI value (OID 1.3.6.1.4.1.57264.1.9) for workflow-based signatures. + // This is typically a URI identifying the CI workflow that signed the image, e.g. + // "https://github.com/org/repo/.github/workflows/build.yml@refs/heads/main". + // At least one of SubjectEmail and BuildSignerURI must be specified. + BuildSignerURI string `json:"buildSignerURI,omitempty"` } // PRSigstoreSignedPKI contains PKI configuration options for a "sigstoreSigned" PolicyRequirement.