diff --git a/cmd/conformance/main.go b/cmd/conformance/main.go index c8ff9eed..4d6045da 100644 --- a/cmd/conformance/main.go +++ b/cmd/conformance/main.go @@ -284,7 +284,7 @@ func main() { identityPolicies := []verify.PolicyOption{} if *certOIDC != "" || *certSAN != "" { - certID, err := verify.NewShortCertificateIdentity(*certOIDC, *certSAN, "") + certID, err := verify.NewShortCertificateIdentity(*certOIDC, "", *certSAN, "") if err != nil { fmt.Println(err) os.Exit(1) @@ -333,7 +333,7 @@ func main() { // Configure verification options identityPolicies := []verify.PolicyOption{} if *certOIDC != "" || *certSAN != "" { - certID, err := verify.NewShortCertificateIdentity(*certOIDC, *certSAN, "") + certID, err := verify.NewShortCertificateIdentity(*certOIDC, "", *certSAN, "") if err != nil { fmt.Println(err) os.Exit(1) diff --git a/cmd/sigstore-go/main.go b/cmd/sigstore-go/main.go index 7023bb6b..35d5f734 100644 --- a/cmd/sigstore-go/main.go +++ b/cmd/sigstore-go/main.go @@ -41,6 +41,7 @@ var artifact *string var artifactDigest *string var artifactDigestAlgorithm *string var expectedOIDIssuer *string +var expectedOIDIssuerRegex *string var expectedSAN *string var expectedSANRegex *string var requireTimestamp *bool @@ -58,6 +59,7 @@ func init() { artifactDigest = flag.String("artifact-digest", "", "Hex-encoded digest of artifact to verify") artifactDigestAlgorithm = flag.String("artifact-digest-algorithm", "sha256", "Digest algorithm") expectedOIDIssuer = flag.String("expectedIssuer", "", "The expected OIDC issuer for the signing certificate") + expectedOIDIssuerRegex = flag.String("expectedIssuerRegex", "", "The expected OIDC issuer for the signing certificate") expectedSAN = flag.String("expectedSAN", "", "The expected identity in the signing certificate's SAN extension") expectedSANRegex = flag.String("expectedSANRegex", "", "The expected identity in the signing certificate's SAN extension") requireTimestamp = flag.Bool("requireTimestamp", true, "Require either an RFC3161 signed timestamp or log entry integrated timestamp") @@ -120,7 +122,7 @@ func run() error { verifierConfig = append(verifierConfig, verify.WithOnlineVerification()) } - certID, err := verify.NewShortCertificateIdentity(*expectedOIDIssuer, *expectedSAN, *expectedSANRegex) + certID, err := verify.NewShortCertificateIdentity(*expectedOIDIssuer, *expectedOIDIssuerRegex, *expectedSAN, *expectedSANRegex) if err != nil { return err } diff --git a/docs/verification.md b/docs/verification.md index aa5de0b4..3533c1e6 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -95,7 +95,7 @@ Then, we need to prepare the expected artifact digest. Note that this option has In this case, we also need to prepare the expected certificate identity. Note that this option has an alternative option `WithoutIdentitiesUnsafe`. This is a failsafe to ensure that the caller is aware that simply verifying the bundle is not enough, you must also verify the contents of the bundle against a specific identity. If your bundle was signed with a key, and thus does not have a certificate identity, a better choice is to use the `WithKey` option. ```go - certID, err := verify.NewShortCertificateIdentity("https://token.actions.githubusercontent.com", "", "^https://github.com/sigstore/sigstore-js/") + certID, err := verify.NewShortCertificateIdentity("https://token.actions.githubusercontent.com", "", "", "^https://github.com/sigstore/sigstore-js/") if err != nil { panic(err) } @@ -221,7 +221,7 @@ func main() { panic(err) } - certID, err := verify.NewShortCertificateIdentity("https://token.actions.githubusercontent.com", "", "^https://github.com/sigstore/sigstore-js/") + certID, err := verify.NewShortCertificateIdentity("https://token.actions.githubusercontent.com", "", "", "^https://github.com/sigstore/sigstore-js/") if err != nil { panic(err) } diff --git a/examples/oci-image-verification/main.go b/examples/oci-image-verification/main.go index 07b82d94..8c36b7be 100644 --- a/examples/oci-image-verification/main.go +++ b/examples/oci-image-verification/main.go @@ -48,6 +48,7 @@ var artifact *string var artifactDigest *string var artifactDigestAlgorithm *string var expectedOIDIssuer *string +var expectedOIDIssuerRegex *string var expectedSAN *string var expectedSANRegex *string var requireTimestamp *bool @@ -65,6 +66,7 @@ func init() { artifactDigest = flag.String("artifact-digest", "", "Hex-encoded digest of artifact to verify") artifactDigestAlgorithm = flag.String("artifact-digest-algorithm", "sha256", "Digest algorithm") expectedOIDIssuer = flag.String("expectedIssuer", "", "The expected OIDC issuer for the signing certificate") + expectedOIDIssuerRegex = flag.String("expectedIssuerRegex", "", "The expected OIDC issuer for the signing certificate") expectedSAN = flag.String("expectedSAN", "", "The expected identity in the signing certificate's SAN extension") expectedSANRegex = flag.String("expectedSANRegex", "", "The expected identity in the signing certificate's SAN extension") requireTimestamp = flag.Bool("requireTimestamp", true, "Require either an RFC3161 signed timestamp or log entry integrated timestamp") @@ -133,8 +135,8 @@ func run() error { verifierConfig = append(verifierConfig, verify.WithOnlineVerification()) } - if *expectedOIDIssuer != "" || *expectedSAN != "" || *expectedSANRegex != "" { - certID, err := verify.NewShortCertificateIdentity(*expectedOIDIssuer, *expectedSAN, *expectedSANRegex) + if *expectedOIDIssuer != "" || *expectedOIDIssuerRegex != "" || *expectedSAN != "" || *expectedSANRegex != "" { + certID, err := verify.NewShortCertificateIdentity(*expectedOIDIssuer, *expectedOIDIssuerRegex, *expectedSAN, *expectedSANRegex) if err != nil { return err } diff --git a/pkg/verify/certificate_identity.go b/pkg/verify/certificate_identity.go index 11bd6008..7487c147 100644 --- a/pkg/verify/certificate_identity.go +++ b/pkg/verify/certificate_identity.go @@ -28,38 +28,37 @@ type SubjectAlternativeNameMatcher struct { Regexp regexp.Regexp `json:"regexp,omitempty"` } +type IssuerMatcher struct { + Issuer string `json:"issuer"` + Regexp regexp.Regexp `json:"regexp,omitempty"` +} + type CertificateIdentity struct { SubjectAlternativeName SubjectAlternativeNameMatcher `json:"subjectAlternativeName"` + Issuer IssuerMatcher `json:"issuer"` certificate.Extensions } type CertificateIdentities []CertificateIdentity -type ErrSANTypeMismatch struct { +type ErrValueMismatch struct { + object string expected string actual string } -func (e *ErrSANTypeMismatch) Error() string { - return fmt.Sprintf("expected SAN type %s, got %s", e.expected, e.actual) +func (e *ErrValueMismatch) Error() string { + return fmt.Sprintf("expected %s value \"%s\", got \"%s\"", e.object, e.expected, e.actual) } -type ErrSANValueMismatch struct { - expected string - actual string +type ErrValueRegexMismatch struct { + object string + regex string + value string } -func (e *ErrSANValueMismatch) Error() string { - return fmt.Sprintf("expected SAN value \"%s\", got \"%s\"", e.expected, e.actual) -} - -type ErrSANValueRegexMismatch struct { - regex string - value string -} - -func (e *ErrSANValueRegexMismatch) Error() string { - return fmt.Sprintf("expected SAN value to match regex \"%s\", got \"%s\"", e.regex, e.value) +func (e *ErrValueRegexMismatch) Error() string { + return fmt.Sprintf("expected %s value to match regex \"%s\", got \"%s\"", e.object, e.regex, e.value) } type ErrNoMatchingCertificateIdentity struct { @@ -106,25 +105,65 @@ func (s *SubjectAlternativeNameMatcher) MarshalJSON() ([]byte, error) { func (s SubjectAlternativeNameMatcher) Verify(actualCert certificate.Summary) error { if s.SubjectAlternativeName != "" && actualCert.SubjectAlternativeName != s.SubjectAlternativeName { - return &ErrSANValueMismatch{string(s.SubjectAlternativeName), string(actualCert.SubjectAlternativeName)} + return &ErrValueMismatch{"SAN", string(s.SubjectAlternativeName), string(actualCert.SubjectAlternativeName)} } if s.Regexp.String() != "" && !s.Regexp.MatchString(actualCert.SubjectAlternativeName) { - return &ErrSANValueRegexMismatch{string(s.Regexp.String()), string(actualCert.SubjectAlternativeName)} + return &ErrValueRegexMismatch{"SAN", string(s.Regexp.String()), string(actualCert.SubjectAlternativeName)} + } + return nil +} + +func NewIssuserMatcher(issuerValue, regexpStr string) (IssuerMatcher, error) { + r, err := regexp.Compile(regexpStr) + if err != nil { + return IssuerMatcher{}, err + } + + return IssuerMatcher{Issuer: issuerValue, Regexp: *r}, nil +} + +func (i *IssuerMatcher) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + Issuer string `json:"issuer"` + Regexp string `json:"regexp,omitempty"` + }{ + Issuer: i.Issuer, + Regexp: i.Regexp.String(), + }) +} + +func (i IssuerMatcher) Verify(actualCert certificate.Summary) error { + if i.Issuer != "" && + actualCert.Extensions.Issuer != i.Issuer { + return &ErrValueMismatch{"issuer", string(i.Issuer), string(actualCert.Extensions.Issuer)} + } + + if i.Regexp.String() != "" && + !i.Regexp.MatchString(actualCert.Extensions.Issuer) { + return &ErrValueRegexMismatch{"issuer", string(i.Regexp.String()), string(actualCert.Extensions.Issuer)} } return nil } -func NewCertificateIdentity(sanMatcher SubjectAlternativeNameMatcher, extensions certificate.Extensions) (CertificateIdentity, error) { +func NewCertificateIdentity(sanMatcher SubjectAlternativeNameMatcher, issuerMatcher IssuerMatcher, extensions certificate.Extensions) (CertificateIdentity, error) { if sanMatcher.SubjectAlternativeName == "" && sanMatcher.Regexp.String() == "" { return CertificateIdentity{}, errors.New("when verifying a certificate identity, there must be subject alternative name criteria") } - certID := CertificateIdentity{SubjectAlternativeName: sanMatcher, Extensions: extensions} + if issuerMatcher.Issuer == "" && issuerMatcher.Regexp.String() == "" { + return CertificateIdentity{}, errors.New("when verifying a certificate identity, must specify Issuer criteria") + } + + if extensions.Issuer != "" { + return CertificateIdentity{}, errors.New("please specify issuer in IssuerMatcher, not Extensions") + } - if certID.Issuer == "" { - return CertificateIdentity{}, errors.New("when verifying a certificate identity, the Issuer field can't be empty") + certID := CertificateIdentity{ + SubjectAlternativeName: sanMatcher, + Issuer: issuerMatcher, + Extensions: extensions, } return certID, nil @@ -133,13 +172,18 @@ func NewCertificateIdentity(sanMatcher SubjectAlternativeNameMatcher, extensions // NewShortCertificateIdentity provides a more convenient way of initializing // a CertificiateIdentity with a SAN and the Issuer OID extension. If you need // to check more OID extensions, use NewCertificateIdentity instead. -func NewShortCertificateIdentity(issuer, sanValue, sanRegex string) (CertificateIdentity, error) { +func NewShortCertificateIdentity(issuer, issuerRegex, sanValue, sanRegex string) (CertificateIdentity, error) { sanMatcher, err := NewSANMatcher(sanValue, sanRegex) if err != nil { return CertificateIdentity{}, err } - return NewCertificateIdentity(sanMatcher, certificate.Extensions{Issuer: issuer}) + issuerMatcher, err := NewIssuserMatcher(issuer, issuerRegex) + if err != nil { + return CertificateIdentity{}, err + } + + return NewCertificateIdentity(sanMatcher, issuerMatcher, certificate.Extensions{}) } // Verify verifies the CertificateIdentities, and if ANY of them match the cert, @@ -164,5 +208,10 @@ func (c CertificateIdentity) Verify(actualCert certificate.Summary) error { if err = c.SubjectAlternativeName.Verify(actualCert); err != nil { return err } + + if err = c.Issuer.Verify(actualCert); err != nil { + return err + } + return certificate.CompareExtensions(c.Extensions, actualCert.Extensions) } diff --git a/pkg/verify/certificate_identity_test.go b/pkg/verify/certificate_identity_test.go index f8debf16..158d5eff 100644 --- a/pkg/verify/certificate_identity_test.go +++ b/pkg/verify/certificate_identity_test.go @@ -23,6 +23,7 @@ import ( const ( ActionsIssuerValue = "https://token.actions.githubusercontent.com" + ActionsIssuerRegex = "githubusercontent.com$" SigstoreSanValue = "https://github.com/sigstore/sigstore-js/.github/workflows/release.yml@refs/heads/main" SigstoreSanRegex = "^https://github.com/sigstore/sigstore-js/" ) @@ -57,31 +58,38 @@ func TestCertificateIdentityVerify(t *testing.T) { } // First, let's test happy paths: - issuerOnlyID, _ := certIDForTesting("", "", ActionsIssuerValue, "") + issuerOnlyID, _ := certIDForTesting("", "", ActionsIssuerValue, "", "") assert.NoError(t, issuerOnlyID.Verify(actualCert)) - sanValueOnly, _ := certIDForTesting(SigstoreSanValue, "", "", "") + issuerOnlyRegex, _ := certIDForTesting("", "", "", ActionsIssuerRegex, "") + assert.NoError(t, issuerOnlyRegex.Verify(actualCert)) + + sanValueOnly, _ := certIDForTesting(SigstoreSanValue, "", "", "", "") assert.NoError(t, sanValueOnly.Verify(actualCert)) - sanRegexOnly, _ := certIDForTesting("", SigstoreSanRegex, "", "") + sanRegexOnly, _ := certIDForTesting("", SigstoreSanRegex, "", "", "") assert.NoError(t, sanRegexOnly.Verify(actualCert)) // multiple values can be specified - sanRegexAndIssuer, _ := certIDForTesting("", SigstoreSanRegex, ActionsIssuerValue, "github-hosted") + sanRegexAndIssuer, _ := certIDForTesting("", SigstoreSanRegex, ActionsIssuerValue, "", "github-hosted") assert.NoError(t, sanRegexAndIssuer.Verify(actualCert)) // unhappy paths: // wrong issuer - sanRegexAndWrongIssuer, _ := certIDForTesting("", SigstoreSanRegex, "https://token.actions.example.com", "") - errCompareExtensions := &certificate.ErrCompareExtensions{} - assert.ErrorAs(t, sanRegexAndWrongIssuer.Verify(actualCert), &errCompareExtensions) - assert.Equal(t, "expected Issuer to be \"https://token.actions.example.com\", got \"https://token.actions.githubusercontent.com\"", errCompareExtensions.Error()) + sanRegexAndWrongIssuer, _ := certIDForTesting("", SigstoreSanRegex, "https://token.actions.example.com", "", "") + errValueMismatch := &ErrValueMismatch{} + assert.ErrorAs(t, sanRegexAndWrongIssuer.Verify(actualCert), &errValueMismatch) + assert.Equal(t, "expected issuer value \"https://token.actions.example.com\", got \"https://token.actions.githubusercontent.com\"", errValueMismatch.Error()) // bad san regex - badRegex, _ := certIDForTesting("", "^badregex.*", "", "") - errSANValueRegexMismatch := &ErrSANValueRegexMismatch{} - assert.ErrorAs(t, badRegex.Verify(actualCert), &errSANValueRegexMismatch) - assert.Equal(t, "expected SAN value to match regex \"^badregex.*\", got \"https://github.com/sigstore/sigstore-js/.github/workflows/release.yml@refs/heads/main\"", errSANValueRegexMismatch.Error()) + badRegex, _ := certIDForTesting("", "^badregex.*", "", "", "") + errValueRegexMismatch := &ErrValueRegexMismatch{} + assert.ErrorAs(t, badRegex.Verify(actualCert), &errValueRegexMismatch) + assert.Equal(t, "expected SAN value to match regex \"^badregex.*\", got \"https://github.com/sigstore/sigstore-js/.github/workflows/release.yml@refs/heads/main\"", errValueRegexMismatch.Error()) + + // bad issuer regex + badIssuerRegex, _ := certIDForTesting("", "", "", "^badregex$", "") + assert.Error(t, badIssuerRegex.Verify(actualCert)) // if we have an array of certIDs, only one needs to match ci, err := CertificateIdentities{sanRegexAndWrongIssuer, sanRegexAndIssuer}.Verify(actualCert) @@ -91,12 +99,12 @@ func TestCertificateIdentityVerify(t *testing.T) { // if none match, we fail ci, err = CertificateIdentities{badRegex, sanRegexAndWrongIssuer}.Verify(actualCert) assert.Error(t, err) - assert.Equal(t, "no matching CertificateIdentity found, last error: expected Issuer to be \"https://token.actions.example.com\", got \"https://token.actions.githubusercontent.com\"", err.Error()) + assert.Equal(t, "no matching CertificateIdentity found, last error: expected issuer value \"https://token.actions.example.com\", got \"https://token.actions.githubusercontent.com\"", err.Error()) assert.Nil(t, ci) // test err unwrap for previous error - errCompareExtensions = &certificate.ErrCompareExtensions{} - assert.ErrorAs(t, err, &errCompareExtensions) - assert.Equal(t, "expected Issuer to be \"https://token.actions.example.com\", got \"https://token.actions.githubusercontent.com\"", errCompareExtensions.Error()) + errValueMismatch = &ErrValueMismatch{} + assert.ErrorAs(t, err, &errValueMismatch) + assert.Equal(t, "expected issuer value \"https://token.actions.example.com\", got \"https://token.actions.githubusercontent.com\"", errValueMismatch.Error()) // if no certIDs are specified, we fail _, err = CertificateIdentities{}.Verify(actualCert) @@ -105,24 +113,35 @@ func TestCertificateIdentityVerify(t *testing.T) { } func TestThatCertIDsAreFullySpecified(t *testing.T) { - _, err := NewShortCertificateIdentity("", "", "") + _, err := NewShortCertificateIdentity("", "", "", "") assert.Error(t, err) - _, err = NewShortCertificateIdentity("foobar", "", "") + _, err = NewShortCertificateIdentity("foobar", "", "", "") assert.Error(t, err) - _, err = NewShortCertificateIdentity("", "", SigstoreSanRegex) + _, err = NewShortCertificateIdentity("", ActionsIssuerRegex, "", "") assert.Error(t, err) - _, err = NewShortCertificateIdentity("foobar", "", SigstoreSanRegex) + _, err = NewShortCertificateIdentity("", "", "", SigstoreSanRegex) + assert.Error(t, err) + + _, err = NewShortCertificateIdentity("foobar", "", "", SigstoreSanRegex) + assert.Nil(t, err) + + _, err = NewShortCertificateIdentity("", ActionsIssuerRegex, "", SigstoreSanRegex) assert.Nil(t, err) } -func certIDForTesting(sanValue, sanRegex, issuer, runnerEnv string) (CertificateIdentity, error) { +func certIDForTesting(sanValue, sanRegex, issuer, issuerRegex, runnerEnv string) (CertificateIdentity, error) { san, err := NewSANMatcher(sanValue, sanRegex) if err != nil { return CertificateIdentity{}, err } - return CertificateIdentity{SubjectAlternativeName: san, Extensions: certificate.Extensions{Issuer: issuer, RunnerEnvironment: runnerEnv}}, nil + issuerMatcher, err := NewIssuserMatcher(issuer, issuerRegex) + if err != nil { + return CertificateIdentity{}, err + } + + return CertificateIdentity{SubjectAlternativeName: san, Issuer: issuerMatcher, Extensions: certificate.Extensions{RunnerEnvironment: runnerEnv}}, nil } diff --git a/pkg/verify/signed_entity_test.go b/pkg/verify/signed_entity_test.go index 965c2ff5..ab04c37f 100644 --- a/pkg/verify/signed_entity_test.go +++ b/pkg/verify/signed_entity_test.go @@ -210,17 +210,17 @@ func TestEntityWithOthernameSan(t *testing.T) { digest, err := hex.DecodeString("bc103b4a84971ef6459b294a2b98568a2bfb72cded09d4acd1e16366a401f95b") assert.NoError(t, err) - certID, err := verify.NewShortCertificateIdentity("http://oidc.local:8080", "foo!oidc.local", "") + certID, err := verify.NewShortCertificateIdentity("http://oidc.local:8080", "", "foo!oidc.local", "") assert.NoError(t, err) res, err := v.Verify(entity, verify.NewPolicy(verify.WithArtifactDigest("sha256", digest), verify.WithCertificateIdentity(certID))) assert.NoError(t, err) assert.NotNil(t, res) - assert.Equal(t, res.VerifiedIdentity.Issuer, "http://oidc.local:8080") + assert.Equal(t, res.VerifiedIdentity.Issuer.Issuer, "http://oidc.local:8080") assert.Equal(t, res.VerifiedIdentity.SubjectAlternativeName.SubjectAlternativeName, "foo!oidc.local") // an email address doesn't verify - certID, err = verify.NewShortCertificateIdentity("http://oidc.local:8080", "foo@oidc.local", "") + certID, err = verify.NewShortCertificateIdentity("http://oidc.local:8080", "", "foo@oidc.local", "") assert.NoError(t, err) _, err = v.Verify(entity, verify.NewPolicy(verify.WithArtifactDigest("sha256", digest), verify.WithCertificateIdentity(certID))) assert.Error(t, err) @@ -235,7 +235,7 @@ func TestVerifyPolicyOptionErors(t *testing.T) { verifier, err := verify.NewSignedEntityVerifier(tr, verify.WithTransparencyLog(1), verify.WithObserverTimestamps(1)) assert.Nil(t, err) - goodCertID, err := verify.NewShortCertificateIdentity(verify.ActionsIssuerValue, "", verify.SigstoreSanRegex) + goodCertID, err := verify.NewShortCertificateIdentity(verify.ActionsIssuerValue, "", "", verify.SigstoreSanRegex) assert.Nil(t, err) digest, _ := hex.DecodeString("46d4e2f74c4877316640000a6fdf8a8b59f1e0847667973e9859f774dd31b8f1e0937813b777fb66a2ac67d50540fe34640966eee9fc2ccca387082b4c85cd3c") @@ -326,8 +326,8 @@ func TestEntitySignedByPublicGoodWithCertificateIdentityVerifiesSuccessfully(t * tr := data.PublicGoodTrustedMaterialRoot(t) entity := data.SigstoreJS200ProvenanceBundle(t) - goodCI, _ := verify.NewShortCertificateIdentity(verify.ActionsIssuerValue, "", verify.SigstoreSanRegex) - badCI, _ := verify.NewShortCertificateIdentity(verify.ActionsIssuerValue, "BadSANValue", "") + goodCI, _ := verify.NewShortCertificateIdentity(verify.ActionsIssuerValue, "", "", verify.SigstoreSanRegex) + badCI, _ := verify.NewShortCertificateIdentity(verify.ActionsIssuerValue, "", "BadSANValue", "") verifier, err := verify.NewSignedEntityVerifier(tr, verify.WithTransparencyLog(1), verify.WithObserverTimestamps(1)) @@ -342,7 +342,7 @@ func TestEntitySignedByPublicGoodWithCertificateIdentityVerifiesSuccessfully(t * verify.WithCertificateIdentity(goodCI))) assert.Nil(t, err) - assert.Equal(t, res.VerifiedIdentity.Issuer, verify.ActionsIssuerValue) + assert.Equal(t, res.VerifiedIdentity.Issuer.Issuer, verify.ActionsIssuerValue) // but if only pass in the bad CI, it will fail: res, err = verifier.Verify(entity,