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.