Skip to content

Commit

Permalink
Add the ability to specify certificate identity via a regular express…
Browse files Browse the repository at this point in the history
…ion (#236)

For #234

---------

Signed-off-by: Zach Steindler <steiza@github.com>
  • Loading branch information
steiza authored Jul 24, 2024
1 parent 6034b75 commit a808341
Show file tree
Hide file tree
Showing 7 changed files with 133 additions and 61 deletions.
4 changes: 2 additions & 2 deletions cmd/conformance/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion cmd/sigstore-go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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
}
Expand Down
4 changes: 2 additions & 2 deletions docs/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
6 changes: 4 additions & 2 deletions examples/oci-image-verification/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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
}
Expand Down
99 changes: 74 additions & 25 deletions pkg/verify/certificate_identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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)
}
63 changes: 41 additions & 22 deletions pkg/verify/certificate_identity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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
}
Loading

0 comments on commit a808341

Please sign in to comment.