diff --git a/main.go b/main.go index f96b42a..0457b36 100644 --- a/main.go +++ b/main.go @@ -29,9 +29,7 @@ var ( var defaultRekorAddr = "https://rekor.sigstore.dev" func verify(ctx context.Context, - provenance []byte, artifactHash, source, branch string, - tag, versiontag *string, -) ([]byte, error) { + provenance []byte, artifactHash, source string, provenanceOpts *pkg.ProvenanceOpts) ([]byte, error) { rClient, err := rekor.NewClient(defaultRekorAddr) if err != nil { return nil, err @@ -56,36 +54,17 @@ func verify(ctx context.Context, } fmt.Fprintf(os.Stderr, "Signing certificate information:\n %s\n", b) - /* Verify properties of the SLSA provenance. */ // Verify the workflow identity. if err := pkg.VerifyWorkflowIdentity(workflowInfo, source); err != nil { return nil, err } + /* Verify properties of the SLSA provenance. */ // Unpack and verify info in the provenance, including the Subject Digest. - if err := pkg.VerifyProvenance(env, artifactHash); err != nil { + if err := pkg.VerifyProvenance(env, provenanceOpts); err != nil { return nil, err } - // Verify the branch. - if err := pkg.VerifyBranch(env, branch); err != nil { - return nil, err - } - - // Verify the tag. - if tag != nil { - if err := pkg.VerifyTag(env, *tag); err != nil { - return nil, err - } - } - - // Verify the versioned tag. - if versiontag != nil { - if err := pkg.VerifyVersionedTag(env, *versiontag); err != nil { - return nil, err - } - } - // Return verified provenance. return base64.StdEncoding.DecodeString(env.Payload) } @@ -117,13 +96,12 @@ func main() { pversiontag = &versiontag } - if pversiontag != nil && ptag != nil { + if ptag != nil && pversiontag != nil { fmt.Fprintf(os.Stderr, "'version' and 'tag' options cannot be used together\n") os.Exit(1) } - verifiedProvenance, err := runVerify(artifactPath, provenancePath, source, branch, - ptag, pversiontag) + verifiedProvenance, err := runVerify(artifactPath, provenancePath, source, branch, ptag, pversiontag) if err != nil { fmt.Fprintf(os.Stderr, "FAILED: SLSA verification failed: %v\n", err) os.Exit(2) @@ -146,9 +124,7 @@ func isFlagPassed(name string) bool { return found } -func runVerify(artifactPath, provenancePath, source, branch string, - ptag, pversiontag *string, -) ([]byte, error) { +func runVerify(artifactPath, provenancePath, source, branch string, ptag, pversiontag *string) ([]byte, error) { f, err := os.Open(artifactPath) if err != nil { log.Fatal(err) @@ -164,10 +140,17 @@ func runVerify(artifactPath, provenancePath, source, branch string, if _, err := io.Copy(h, f); err != nil { log.Panic(err) } + artifactHash := hex.EncodeToString(h.Sum(nil)) + + provenanceOpts := &pkg.ProvenanceOpts{ + ExpectedBranch: branch, + ExpectedDigest: artifactHash, + ExpectedVersionedTag: pversiontag, + ExpectedTag: ptag, + } ctx := context.Background() return verify(ctx, provenance, - hex.EncodeToString(h.Sum(nil)), - source, branch, - ptag, pversiontag) + artifactHash, + source, provenanceOpts) } diff --git a/pkg/builder.go b/pkg/builder.go new file mode 100644 index 0000000..e635e20 --- /dev/null +++ b/pkg/builder.go @@ -0,0 +1,118 @@ +package pkg + +import ( + "crypto/x509" + "errors" + "fmt" + "strings" + + "golang.org/x/mod/semver" +) + +var ( + trustedBuilderRepository = "slsa-framework/slsa-github-generator" + e2eTestRepository = "slsa-framework/example-package" + certOidcIssuer = "https://token.actions.githubusercontent.com" +) + +var trustedReusableWorkflows = map[string]bool{ + trustedBuilderRepository + "/.github/workflows/generator_generic_slsa3.yml": true, + trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml": true, +} + +// VerifyWorkflowIdentity verifies the signing certificate information +func VerifyWorkflowIdentity(id *WorkflowIdentity, source string) error { + // cert URI path is /org/repo/path/to/workflow@ref + workflowPath := strings.SplitN(id.JobWobWorkflowRef, "@", 2) + if len(workflowPath) < 2 { + return fmt.Errorf("%w: %s", errorMalformedWorkflowURI, id.JobWobWorkflowRef) + } + + // Trusted workflow verification by name. + reusableWorkflowName := strings.Trim(workflowPath[0], "/") + if _, ok := trustedReusableWorkflows[reusableWorkflowName]; !ok { + return fmt.Errorf("%w: %s", ErrorUntrustedReusableWorkflow, reusableWorkflowName) + } + + // Verify the ref. + if err := verifyTrustedBuilderRef(id, strings.Trim(workflowPath[1], "/")); err != nil { + return err + } + + // Issuer verification. + if !strings.EqualFold(id.Issuer, certOidcIssuer) { + return fmt.Errorf("untrusted token issuer: %s", id.Issuer) + } + + // The caller repository in the x509 extension is not fully qualified. It only contains + // {org}/{repository}. + expectedSource := strings.TrimPrefix(source, "github.com/") + if !strings.EqualFold(id.CallerRepository, expectedSource) { + return fmt.Errorf("%w: expected source '%s', got '%s'", ErrorMismatchRepository, + expectedSource, id.CallerRepository) + } + + return nil +} + +// Only allow `@refs/heads/main` for the builder and the e2e tests that need to work at HEAD. +// This lets us use the pre-build builder binary generated during release (release happen at main). +// For other projects, we only allow semantic versions that map to a release. +func verifyTrustedBuilderRef(id *WorkflowIdentity, ref string) error { + if (id.CallerRepository == trustedBuilderRepository || + id.CallerRepository == e2eTestRepository) && + strings.EqualFold("refs/heads/main", ref) { + return nil + } + + if !strings.HasPrefix(ref, "refs/tags/") { + return fmt.Errorf("%w: %s: not of the form 'refs/tags/name'", errorInvalidRef, ref) + } + + // Valid semver of the form vX.Y.Z with no metadata. + pin := strings.TrimPrefix(ref, "refs/tags/") + if !(semver.IsValid(pin) && + len(strings.Split(pin, ".")) == 3 && + semver.Prerelease(pin) == "" && + semver.Build(pin) == "") { + return fmt.Errorf("%w: %s: not of the form vX.Y.Z", errorInvalidRef, pin) + } + return nil +} + +func getExtension(cert *x509.Certificate, oid string) string { + for _, ext := range cert.Extensions { + if strings.Contains(ext.Id.String(), oid) { + return string(ext.Value) + } + } + return "" +} + +type WorkflowIdentity struct { + // The caller repository + CallerRepository string `json:"caller"` + // The commit SHA where the workflow was triggered + CallerHash string `json:"commit"` + // Current workflow (reuseable workflow) ref + JobWobWorkflowRef string `json:"job_workflow_ref"` + // Trigger + Trigger string `json:"trigger"` + // Issuer + Issuer string `json:"issuer"` +} + +// GetWorkflowFromCertificate gets the workflow identity from the Fulcio authenticated content. +func GetWorkflowInfoFromCertificate(cert *x509.Certificate) (*WorkflowIdentity, error) { + if len(cert.URIs) == 0 { + return nil, errors.New("missing URI information from certificate") + } + + return &WorkflowIdentity{ + CallerRepository: getExtension(cert, "1.3.6.1.4.1.57264.1.5"), + Issuer: getExtension(cert, "1.3.6.1.4.1.57264.1.1"), + Trigger: getExtension(cert, "1.3.6.1.4.1.57264.1.2"), + CallerHash: getExtension(cert, "1.3.6.1.4.1.57264.1.3"), + JobWobWorkflowRef: cert.URIs[0].Path, + }, nil +} diff --git a/pkg/builder_test.go b/pkg/builder_test.go new file mode 100644 index 0000000..058895e --- /dev/null +++ b/pkg/builder_test.go @@ -0,0 +1,330 @@ +package pkg + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +func Test_VerifyWorkflowIdentity(t *testing.T) { + t.Parallel() + tests := []struct { + name string + workflow *WorkflowIdentity + source string + err error + }{ + { + name: "invalid job workflow ref", + workflow: &WorkflowIdentity{ + CallerRepository: "asraa/slsa-on-github-test", + CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", + JobWobWorkflowRef: "random/workflow/ref", + Trigger: "workflow_dispatch", + Issuer: "https://token.actions.githubusercontent.com", + }, + source: "asraa/slsa-on-github-test", + err: errorMalformedWorkflowURI, + }, + { + name: "untrusted job workflow ref", + workflow: &WorkflowIdentity{ + CallerRepository: "asraa/slsa-on-github-test", + CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", + JobWobWorkflowRef: "/malicious/slsa-go/.github/workflows/builder.yml@refs/heads/main", + Trigger: "workflow_dispatch", + Issuer: "https://token.actions.githubusercontent.com", + }, + source: "asraa/slsa-on-github-test", + err: ErrorUntrustedReusableWorkflow, + }, + { + name: "untrusted job workflow ref for general repos", + workflow: &WorkflowIdentity{ + CallerRepository: "asraa/slsa-on-github-test", + CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", + JobWobWorkflowRef: trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml@refs/heads/main", + Trigger: "workflow_dispatch", + Issuer: "https://bad.issuer.com", + }, + source: "asraa/slsa-on-github-test", + err: errorInvalidRef, + }, + { + name: "valid main ref for trusted builder", + workflow: &WorkflowIdentity{ + CallerRepository: trustedBuilderRepository, + CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", + JobWobWorkflowRef: trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml@refs/heads/main", + Trigger: "workflow_dispatch", + Issuer: "https://token.actions.githubusercontent.com", + }, + source: trustedBuilderRepository, + }, + { + name: "valid main ref for e2e test", + workflow: &WorkflowIdentity{ + CallerRepository: e2eTestRepository, + CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", + JobWobWorkflowRef: trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml@refs/heads/main", + Trigger: "workflow_dispatch", + Issuer: certOidcIssuer, + }, + source: e2eTestRepository, + }, + { + name: "unexpected source for e2e test", + workflow: &WorkflowIdentity{ + CallerRepository: e2eTestRepository, + CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", + JobWobWorkflowRef: trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml@refs/heads/main", + Trigger: "workflow_dispatch", + Issuer: certOidcIssuer, + }, + source: "malicious/source", + err: ErrorMismatchRepository, + }, + { + name: "valid main ref for builder", + workflow: &WorkflowIdentity{ + CallerRepository: trustedBuilderRepository, + JobWobWorkflowRef: trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml@refs/heads/main", + Trigger: "workflow_dispatch", + Issuer: certOidcIssuer, + }, + source: "malicious/source", + err: ErrorMismatchRepository, + }, + { + name: "unexpected source", + workflow: &WorkflowIdentity{ + CallerRepository: "malicious/slsa-on-github-test", + CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", + JobWobWorkflowRef: trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml@refs/tags/v1.2.3", + Trigger: "workflow_dispatch", + Issuer: certOidcIssuer, + }, + source: "asraa/slsa-on-github-test", + err: ErrorMismatchRepository, + }, + { + name: "valid workflow identity", + workflow: &WorkflowIdentity{ + CallerRepository: "asraa/slsa-on-github-test", + CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", + JobWobWorkflowRef: trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml@refs/tags/v1.2.3", + Trigger: "workflow_dispatch", + Issuer: certOidcIssuer, + }, + source: "asraa/slsa-on-github-test", + }, + { + name: "invalid workflow identity with prerelease", + workflow: &WorkflowIdentity{ + CallerRepository: "asraa/slsa-on-github-test", + CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", + JobWobWorkflowRef: trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml@refs/tags/v1.2.3-alpha", + Trigger: "workflow_dispatch", + Issuer: certOidcIssuer, + }, + source: "asraa/slsa-on-github-test", + err: errorInvalidRef, + }, + { + name: "invalid workflow identity with build", + workflow: &WorkflowIdentity{ + CallerRepository: "asraa/slsa-on-github-test", + CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", + JobWobWorkflowRef: trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml@refs/tags/v1.2.3+123", + Trigger: "workflow_dispatch", + Issuer: certOidcIssuer, + }, + source: "asraa/slsa-on-github-test", + err: errorInvalidRef, + }, + { + name: "invalid workflow identity with metadata", + workflow: &WorkflowIdentity{ + CallerRepository: "asraa/slsa-on-github-test", + CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", + JobWobWorkflowRef: trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml@refs/tags/v1.2.3-alpha+123", + Trigger: "workflow_dispatch", + Issuer: certOidcIssuer, + }, + source: "asraa/slsa-on-github-test", + err: errorInvalidRef, + }, + { + name: "valid workflow identity with fully qualified source", + workflow: &WorkflowIdentity{ + CallerRepository: "asraa/slsa-on-github-test", + CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", + JobWobWorkflowRef: trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml@refs/tags/v1.2.3", + Trigger: "workflow_dispatch", + Issuer: certOidcIssuer, + }, + source: "github.com/asraa/slsa-on-github-test", + }, + } + for _, tt := range tests { + tt := tt // Re-initializing variable so it is not changed while executing the closure below + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := VerifyWorkflowIdentity(tt.workflow, tt.source) + if !errCmp(err, tt.err) { + t.Errorf(cmp.Diff(err, tt.err, cmpopts.EquateErrors())) + } + }) + } +} + +func Test_verifyTrustedBuilderRef(t *testing.T) { + t.Parallel() + tests := []struct { + name string + callerRepo string + builderRef string + expected error + }{ + // Trusted repo. + { + name: "main allowed for builder", + callerRepo: trustedBuilderRepository, + builderRef: "refs/heads/main", + }, + { + name: "full semver for builder", + callerRepo: trustedBuilderRepository, + builderRef: "refs/tags/v1.2.3", + }, + { + name: "no patch semver for other builder", + callerRepo: trustedBuilderRepository, + builderRef: "refs/tags/v1.2", + expected: errorInvalidRef, + }, + { + name: "no min semver for builder", + callerRepo: trustedBuilderRepository, + builderRef: "refs/tags/v1", + expected: errorInvalidRef, + }, + { + name: "full semver with prerelease for builder", + callerRepo: trustedBuilderRepository, + builderRef: "refs/tags/v1.2.3-alpha", + expected: errorInvalidRef, + }, + { + name: "full semver with build for builder", + callerRepo: trustedBuilderRepository, + builderRef: "refs/tags/v1.2.3+123", + expected: errorInvalidRef, + }, + { + name: "full semver with build/prerelease for builder", + callerRepo: trustedBuilderRepository, + builderRef: "refs/tags/v1.2.3-alpha+123", + expected: errorInvalidRef, + }, + // E2e tests repo. + { + name: "main allowed for test repo", + callerRepo: e2eTestRepository, + builderRef: "refs/heads/main", + }, + { + name: "full semver for test repo", + callerRepo: e2eTestRepository, + builderRef: "refs/tags/v1.2.3", + }, + { + name: "no patch semver for test repo", + callerRepo: e2eTestRepository, + builderRef: "refs/tags/v1.2", + expected: errorInvalidRef, + }, + { + name: "no min semver for test repo", + callerRepo: e2eTestRepository, + builderRef: "refs/tags/v1", + expected: errorInvalidRef, + }, + { + name: "full semver with prerelease for test repo", + callerRepo: e2eTestRepository, + builderRef: "refs/tags/v1.2.3-alpha", + expected: errorInvalidRef, + }, + { + name: "full semver with build for test repo", + callerRepo: e2eTestRepository, + builderRef: "refs/tags/v1.2.3+123", + expected: errorInvalidRef, + }, + { + name: "full semver with build/prerelease for test repo", + callerRepo: e2eTestRepository, + builderRef: "refs/tags/v1.2.3-alpha+123", + expected: errorInvalidRef, + }, + // Other repos. + { + name: "main not allowed for other repos", + callerRepo: "some/repo", + builderRef: "refs/heads/main", + expected: errorInvalidRef, + }, + { + name: "full semver for other repos", + callerRepo: "some/repo", + builderRef: "refs/tags/v1.2.3", + }, + { + name: "no patch semver for other repos", + callerRepo: "some/repo", + builderRef: "refs/tags/v1.2", + expected: errorInvalidRef, + }, + { + name: "no min semver for other repos", + callerRepo: "some/repo", + builderRef: "refs/tags/v1", + expected: errorInvalidRef, + }, + { + name: "full semver with prerelease for other repos", + callerRepo: "some/repo", + builderRef: "refs/tags/v1.2.3-alpha", + expected: errorInvalidRef, + }, + { + name: "full semver with build for other repos", + callerRepo: "some/repo", + builderRef: "refs/tags/v1.2.3+123", + expected: errorInvalidRef, + }, + { + name: "full semver with build/prerelease for other repos", + callerRepo: "some/repo", + builderRef: "refs/tags/v1.2.3-alpha+123", + expected: errorInvalidRef, + }, + } + for _, tt := range tests { + tt := tt // Re-initializing variable so it is not changed while executing the closure below + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + wf := WorkflowIdentity{ + CallerRepository: tt.callerRepo, + } + + err := verifyTrustedBuilderRef(&wf, tt.builderRef) + if !errCmp(err, tt.expected) { + t.Errorf(cmp.Diff(err, tt.expected, cmpopts.EquateErrors())) + } + }) + } +} diff --git a/pkg/errors.go b/pkg/errors.go new file mode 100644 index 0000000..a75d3c0 --- /dev/null +++ b/pkg/errors.go @@ -0,0 +1,18 @@ +package pkg + +import "errors" + +var ( + ErrorInvalidDssePayload = errors.New("invalid DSSE envelope payload") + ErrorMismatchBranch = errors.New("branch used to generate the binary does not match provenance") + ErrorMismatchRepository = errors.New("repository used to generate the binary does not match provenance") + ErrorMismatchTag = errors.New("tag used to generate the binary does not match provenance") + ErrorMismatchVersionedTag = errors.New("tag used to generate the binary does not match provenance") + ErrorInvalidSemver = errors.New("invalid semantic version") + ErrorRekorSearch = errors.New("error searching rekor entries") + errorMismatchHash = errors.New("binary artifact hash does not match provenance subject") + errorInvalidRef = errors.New("invalid ref") + errorMalformedWorkflowURI = errors.New("malformed URI for workflow") + ErrorUntrustedReusableWorkflow = errors.New("untrusted reusable workflow") + ErrorNoValidRekorEntries = errors.New("could not find a matching valid signature entry") +) diff --git a/pkg/options.go b/pkg/options.go new file mode 100644 index 0000000..1c4bb5b --- /dev/null +++ b/pkg/options.go @@ -0,0 +1,17 @@ +package pkg + +// ProvenanceOpts are the options for checking provenance information. +type ProvenanceOpts struct { + // ExpectedDigest is the expected artifact sha included in the provenance + ExpectedDigest string + + // ExpectedBranch is the expected branch (github_ref or github_base_ref) in + // the invocation parameters. + ExpectedBranch string + + // ExpectedTag is the expected tag, github_ref, in the invocation parameters. + ExpectedTag *string + + // ExpectedVersionedTag is the expected versioned tag + ExpectedVersionedTag *string +} diff --git a/pkg/provenance.go b/pkg/provenance.go index 56a4041..fe1d027 100644 --- a/pkg/provenance.go +++ b/pkg/provenance.go @@ -1,78 +1,19 @@ package pkg import ( - "bytes" "context" - "crypto" - "crypto/ecdsa" "crypto/x509" "encoding/base64" - "encoding/hex" "encoding/json" - "errors" "fmt" - "os" "strings" - "time" - - merkleproof "github.com/transparency-dev/merkle/proof" "golang.org/x/mod/semver" - cjson "github.com/docker/go/canonical/json" - "github.com/go-openapi/runtime" - "github.com/go-openapi/strfmt" - "github.com/go-openapi/swag" intoto "github.com/in-toto/in-toto-golang/in_toto" dsselib "github.com/secure-systems-lab/go-securesystemslib/dsse" - "github.com/sigstore/sigstore/pkg/signature" - "github.com/sigstore/sigstore/pkg/signature/dsse" - "github.com/transparency-dev/merkle/rfc6962" - "github.com/sigstore/cosign/cmd/cosign/cli/fulcio" - "github.com/sigstore/cosign/pkg/cosign" - "github.com/sigstore/cosign/pkg/cosign/bundle" "github.com/sigstore/rekor/pkg/generated/client" - "github.com/sigstore/rekor/pkg/generated/client/entries" - "github.com/sigstore/rekor/pkg/generated/client/index" - "github.com/sigstore/rekor/pkg/generated/client/tlog" - "github.com/sigstore/rekor/pkg/generated/models" - "github.com/sigstore/rekor/pkg/sharding" - "github.com/sigstore/rekor/pkg/types" - intotod "github.com/sigstore/rekor/pkg/types/intoto/v0.0.1" - "github.com/sigstore/rekor/pkg/util" - "github.com/sigstore/sigstore/pkg/cryptoutils" - "github.com/slsa-framework/slsa-github-generator/signing/envelope" -) - -const ( - defaultRekorAddr = "https://rekor.sigstore.dev" - certOidcIssuer = "https://token.actions.githubusercontent.com" -) - -var ( - trustedBuilderRepository = "slsa-framework/slsa-github-generator" - e2eTestRepository = "slsa-framework/example-package" -) - -var trustedReusableWorkflows = map[string]bool{ - trustedBuilderRepository + "/.github/workflows/generator_generic_slsa3.yml": true, - trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml": true, -} - -var ( - ErrorInvalidDssePayload = errors.New("invalid DSSE envelope payload") - ErrorMismatchBranch = errors.New("branch used to generate the binary does not match provenance") - ErrorMismatchRepository = errors.New("repository used to generate the binary does not match provenance") - ErrorMismatchTag = errors.New("tag used to generate the binary does not match provenance") - ErrorMismatchVersionedTag = errors.New("tag used to generate the binary does not match provenance") - ErrorInvalidSemver = errors.New("invalid semantic version") - ErrorRekorSearch = errors.New("error searching rekor entries") - errorMismatchHash = errors.New("binary artifact hash does not match provenance subject") - errorInvalidRef = errors.New("invalid ref") - errorMalformedWorkflowURI = errors.New("malformed URI for workflow") - ErrorUntrustedReusableWorkflow = errors.New("untrusted reusable workflow") - ErrorNoValidRekorEntries = errors.New("could not find a matching valid signature entry") ) func EnvelopeFromBytes(payload []byte) (env *dsselib.Envelope, err error) { @@ -81,29 +22,20 @@ func EnvelopeFromBytes(payload []byte) (env *dsselib.Envelope, err error) { return } -func intotoEntry(certPem []byte, provenance []byte) (*intotod.V001Entry, error) { - cert := strfmt.Base64(certPem) - return &intotod.V001Entry{ - IntotoObj: models.IntotoV001Schema{ - Content: &models.IntotoV001SchemaContent{ - Envelope: string(provenance), - }, - PublicKey: &cert, - }, - }, nil +func provenanceFromEnv(env *dsselib.Envelope) (prov *intoto.ProvenanceStatement, err error) { + pyld, err := base64.StdEncoding.DecodeString(env.Payload) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrorInvalidDssePayload, "decoding payload") + } + prov = &intoto.ProvenanceStatement{} + if err := json.Unmarshal(pyld, prov); err != nil { + return nil, fmt.Errorf("%w: %s", ErrorInvalidDssePayload, "unmarshalling json") + } + return } // Verify SHA256 Subject Digest from the provenance statement. -func verifySha256Digest(env *dsselib.Envelope, expectedHash string) error { - pyld, err := base64.StdEncoding.DecodeString(env.Payload) - if err != nil { - return fmt.Errorf("%w: %s", ErrorInvalidDssePayload, "decoding payload") - } - prov := &intoto.ProvenanceStatement{} - if err := json.Unmarshal(pyld, prov); err != nil { - return fmt.Errorf("%w: %s", ErrorInvalidDssePayload, "unmarshalling json") - } - +func verifySha256Digest(prov *intoto.ProvenanceStatement, expectedHash string) error { if len(prov.Subject) == 0 { return fmt.Errorf("%w: %s", ErrorInvalidDssePayload, "no subjects") } @@ -123,280 +55,6 @@ func verifySha256Digest(env *dsselib.Envelope, expectedHash string) error { return fmt.Errorf("expected hash '%s' not found: %w", expectedHash, errorMismatchHash) } -// GetRekorEntries finds all entry UUIDs by the digest of the artifact binary. -func GetRekorEntries(rClient *client.Rekor, artifactHash string) ([]string, error) { - // Use search index to find rekor entry UUIDs that match Subject Digest. - params := index.NewSearchIndexParams() - params.Query = &models.SearchIndex{Hash: fmt.Sprintf("sha256:%v", artifactHash)} - resp, err := rClient.Index.SearchIndex(params) - if err != nil { - return nil, fmt.Errorf("%w: %s", ErrorRekorSearch, err.Error()) - } - - if len(resp.Payload) == 0 { - return nil, fmt.Errorf("%w: no matching entries found", ErrorRekorSearch) - } - - return resp.GetPayload(), nil -} - -// GetRekorEntriesWithCert finds all entry UUIDs with the full intoto attestation. -// The attestation generated by the slsa-github-generator libraries contain a signing certificate. -func GetRekorEntriesWithCert(rClient *client.Rekor, provenance []byte) (*dsselib.Envelope, *x509.Certificate, error) { - // Use intoto attestation to find rekor entry UUIDs. - params := entries.NewSearchLogQueryParams() - searchLogQuery := models.SearchLogQuery{} - certPem, err := envelope.GetCertFromEnvelope(provenance) - if err != nil { - return nil, nil, fmt.Errorf("error getting certificate from provenance: %w", err) - } - - e, err := intotoEntry(certPem, provenance) - if err != nil { - return nil, nil, fmt.Errorf("error creating intoto entry: %w", err) - } - entry := models.Intoto{ - APIVersion: swag.String(e.APIVersion()), - Spec: e.IntotoObj, - } - entries := []models.ProposedEntry{&entry} - searchLogQuery.SetEntries(entries) - - params.SetEntry(&searchLogQuery) - resp, err := rClient.Entries.SearchLogQuery(params) - if err != nil { - return nil, nil, fmt.Errorf("%w: %s", ErrorRekorSearch, err.Error()) - } - - if len(resp.GetPayload()) != 1 { - return nil, nil, fmt.Errorf("%w: %s", ErrorRekorSearch, "no matching rekor entries") - } - - logEntry := resp.Payload[0] - for uuid, e := range logEntry { - if _, err := verifyTlogEntry(context.Background(), rClient, uuid, e); err != nil { - return nil, nil, fmt.Errorf("error verifying tlog entry: %w", err) - } - url := fmt.Sprintf("%v/%v/%v", defaultRekorAddr, "api/v1/log/entries", uuid) - fmt.Fprintf(os.Stderr, "Verified signature against tlog entry index %d at URL: %s\n", *e.LogIndex, url) - } - - env, err := EnvelopeFromBytes(provenance) - if err != nil { - return nil, nil, err - } - - certs, err := cryptoutils.UnmarshalCertificatesFromPEM(certPem) - if err != nil { - return nil, nil, err - } - if len(certs) != 1 { - return nil, nil, fmt.Errorf("error unmarshaling certificate from pem") - } - - return env, certs[0], nil -} - -func verifyRootHash(ctx context.Context, rekorClient *client.Rekor, proof *models.InclusionProof, pub *ecdsa.PublicKey) error { - infoParams := tlog.NewGetLogInfoParamsWithContext(ctx) - result, err := rekorClient.Tlog.GetLogInfo(infoParams) - if err != nil { - return err - } - - logInfo := result.GetPayload() - - sth := util.SignedCheckpoint{} - if err := sth.UnmarshalText([]byte(*logInfo.SignedTreeHead)); err != nil { - return err - } - - verifier, err := signature.LoadVerifier(pub, crypto.SHA256) - if err != nil { - return err - } - - if !sth.Verify(verifier) { - return errors.New("signature on tree head did not verify") - } - - rootHash, err := hex.DecodeString(*proof.RootHash) - if err != nil { - return errors.New("error decoding root hash in inclusion proof") - } - - if *proof.TreeSize == int64(sth.Size) { - if !bytes.Equal(rootHash, sth.Hash) { - return errors.New("root hash returned from server does not match inclusion proof hash") - } - } else if *proof.TreeSize < int64(sth.Size) { - consistencyParams := tlog.NewGetLogProofParamsWithContext(ctx) - consistencyParams.FirstSize = proof.TreeSize // Root hash at the time the proof was returned - consistencyParams.LastSize = int64(sth.Size) // Root hash verified with rekor pubkey - - consistencyProof, err := rekorClient.Tlog.GetLogProof(consistencyParams) - if err != nil { - return err - } - var hashes [][]byte - for _, h := range consistencyProof.Payload.Hashes { - b, err := hex.DecodeString(h) - if err != nil { - return errors.New("error decoding consistency proof hashes") - } - hashes = append(hashes, b) - } - if err := merkleproof.VerifyConsistency(rfc6962.DefaultHasher, - uint64(*proof.TreeSize), sth.Size, hashes, rootHash, sth.Hash); err != nil { - return err - } - } else if *proof.TreeSize > int64(sth.Size) { - return errors.New("inclusion proof returned a tree size larger than the verified tree size") - } - return nil -} - -func verifyTlogEntryByUUID(ctx context.Context, rekorClient *client.Rekor, entryUUID string) (*models.LogEntryAnon, error) { - params := entries.NewGetLogEntryByUUIDParamsWithContext(ctx) - params.EntryUUID = entryUUID - - lep, err := rekorClient.Entries.GetLogEntryByUUID(params) - if err != nil { - return nil, err - } - - if len(lep.Payload) != 1 { - return nil, errors.New("UUID value can not be extracted") - } - - uuid, err := sharding.GetUUIDFromIDString(params.EntryUUID) - if err != nil { - return nil, err - } - - var e models.LogEntryAnon - for k, entry := range lep.Payload { - if k != uuid { - return nil, errors.New("expected matching UUID") - } - e = entry - } - - return verifyTlogEntry(ctx, rekorClient, uuid, e) -} - -func verifyTlogEntry(ctx context.Context, rekorClient *client.Rekor, uuid string, e models.LogEntryAnon) (*models.LogEntryAnon, error) { - if e.Verification == nil || e.Verification.InclusionProof == nil { - return nil, errors.New("inclusion proof not provided") - } - - var hashes [][]byte - for _, h := range e.Verification.InclusionProof.Hashes { - hb, err := hex.DecodeString(h) - if err != nil { - return nil, errors.New("error decoding inclusion proof hashes") - } - hashes = append(hashes, hb) - } - - rootHash, err := hex.DecodeString(*e.Verification.InclusionProof.RootHash) - if err != nil { - return nil, errors.New("error decoding hex encoded root hash") - } - leafHash, err := hex.DecodeString(uuid) - if err != nil { - return nil, errors.New("error decoding hex encoded leaf hash") - } - - // Verify the root hash against the current Signed Entry Tree Head - pubs, err := cosign.GetRekorPubs(ctx, nil) - if err != nil { - return nil, fmt.Errorf("%w: %s", err, "unable to fetch Rekor public keys from TUF repository") - } - - var entryVerError error - for _, pubKey := range pubs { - // Verify inclusion against the signed tree head - entryVerError = verifyRootHash(ctx, rekorClient, e.Verification.InclusionProof, pubKey.PubKey) - if entryVerError == nil { - break - } - } - if entryVerError != nil { - return nil, fmt.Errorf("%w: %s", err, "error verifying root hash") - } - - // Verify the entry's inclusion - if err := merkleproof.VerifyInclusion(rfc6962.DefaultHasher, - uint64(*e.Verification.InclusionProof.LogIndex), - uint64(*e.Verification.InclusionProof.TreeSize), leafHash, hashes, rootHash); err != nil { - return nil, fmt.Errorf("%w: %s", err, "verifying inclusion proof") - } - - // Verify rekor's signature over the SET. - payload := bundle.RekorPayload{ - Body: e.Body, - IntegratedTime: *e.IntegratedTime, - LogIndex: *e.LogIndex, - LogID: *e.LogID, - } - - var setVerError error - for _, pubKey := range pubs { - setVerError = cosign.VerifySET(payload, e.Verification.SignedEntryTimestamp, pubKey.PubKey) - // Return once the SET is verified successfully. - if setVerError == nil { - break - } - } - - return &e, setVerError -} - -func extractCert(e *models.LogEntryAnon) (*x509.Certificate, error) { - b, err := base64.StdEncoding.DecodeString(e.Body.(string)) - if err != nil { - return nil, err - } - - pe, err := models.UnmarshalProposedEntry(bytes.NewReader(b), runtime.JSONConsumer()) - if err != nil { - return nil, err - } - - eimpl, err := types.NewEntry(pe) - if err != nil { - return nil, err - } - - var publicKeyB64 []byte - switch e := eimpl.(type) { - case *intotod.V001Entry: - publicKeyB64, err = e.IntotoObj.PublicKey.MarshalText() - if err != nil { - return nil, err - } - default: - return nil, errors.New("unexpected tlog entry type") - } - - publicKey, err := base64.StdEncoding.DecodeString(string(publicKeyB64)) - if err != nil { - return nil, err - } - - certs, err := cryptoutils.UnmarshalCertificatesFromPEM(publicKey) - if err != nil { - return nil, err - } - - if len(certs) != 1 { - return nil, errors.New("unexpected number of cert pem tlog entry") - } - - return certs[0], err -} - // VerifyProvenanceSignature returns the verified DSSE envelope containing the provenance // and the signing certificate given the provenance and artifact hash. func VerifyProvenanceSignature(ctx context.Context, rClient *client.Rekor, provenance []byte, artifactHash string) (*dsselib.Envelope, *x509.Certificate, error) { @@ -425,167 +83,41 @@ func VerifyProvenanceSignature(ctx context.Context, rClient *client.Rekor, prove return env, cert, nil } -// FindSigningCertificate finds and verifies a matching signing certificate from a list of Rekor entry UUIDs. -func FindSigningCertificate(ctx context.Context, uuids []string, dssePayload dsselib.Envelope, rClient *client.Rekor) (*x509.Certificate, error) { - attBytes, err := cjson.MarshalCanonical(dssePayload) +func VerifyProvenance(env *dsselib.Envelope, opts *ProvenanceOpts) error { + prov, err := provenanceFromEnv(env) if err != nil { - return nil, err - } - - // Iterate through each matching UUID and perform: - // * Verify TLOG entry (inclusion and signed entry timestamp against Rekor pubkey). - // * Verify the signing certificate against the Fulcio root CA. - // * Verify dsse envelope signature against signing certificate. - // * Check signature expiration against IntegratedTime in entry. - // * If all succeed, return the signing certificate. - for _, uuid := range uuids { - entry, err := verifyTlogEntryByUUID(ctx, rClient, uuid) - if err != nil { - continue - } - cert, err := extractCert(entry) - if err != nil { - continue - } - - roots, err := fulcio.GetRoots() - if err != nil { - continue - } - co := &cosign.CheckOpts{ - RootCerts: roots, - CertOidcIssuer: certOidcIssuer, - } - verifier, err := cosign.ValidateAndUnpackCert(cert, co) - if err != nil { - continue - } - verifier = dsse.WrapVerifier(verifier) - if err := verifier.VerifySignature(bytes.NewReader(attBytes), bytes.NewReader(attBytes)); err != nil { - continue - } - it := time.Unix(*entry.IntegratedTime, 0) - if err := cosign.CheckExpiry(cert, it); err != nil { - continue - } - uuid, err := cosign.ComputeLeafHash(entry) - if err != nil { - fmt.Fprintf(os.Stderr, "Error computing leaf hash for tlog entry at index: %d\n", *entry.LogIndex) - continue - } - - // success! - url := fmt.Sprintf("%v/%v/%v", defaultRekorAddr, "api/v1/log/entries", hex.EncodeToString(uuid)) - fmt.Fprintf(os.Stderr, "Verified signature against tlog entry index %d at URL: %s\n", *entry.LogIndex, url) - return cert, nil - } - - return nil, ErrorNoValidRekorEntries -} - -func getExtension(cert *x509.Certificate, oid string) string { - for _, ext := range cert.Extensions { - if strings.Contains(ext.Id.String(), oid) { - return string(ext.Value) - } - } - return "" -} - -type WorkflowIdentity struct { - // The caller repository - CallerRepository string `json:"caller"` - // The commit SHA where the workflow was triggered - CallerHash string `json:"commit"` - // Current workflow (reuseable workflow) ref - JobWobWorkflowRef string `json:"job_workflow_ref"` - // Trigger - Trigger string `json:"trigger"` - // Issuer - Issuer string `json:"issuer"` -} - -// GetWorkflowInfoFromCertificate gets the workflow identity from the Fulcio authenticated content. -func GetWorkflowInfoFromCertificate(cert *x509.Certificate) (*WorkflowIdentity, error) { - if len(cert.URIs) == 0 { - return nil, errors.New("missing URI information from certificate") - } - - return &WorkflowIdentity{ - CallerRepository: getExtension(cert, "1.3.6.1.4.1.57264.1.5"), - Issuer: getExtension(cert, "1.3.6.1.4.1.57264.1.1"), - Trigger: getExtension(cert, "1.3.6.1.4.1.57264.1.2"), - CallerHash: getExtension(cert, "1.3.6.1.4.1.57264.1.3"), - JobWobWorkflowRef: cert.URIs[0].Path, - }, nil -} - -// VerifyWorkflowIdentity verifies the signing certificate information -func VerifyWorkflowIdentity(id *WorkflowIdentity, source string) error { - // cert URI path is /org/repo/path/to/workflow@ref - workflowPath := strings.SplitN(id.JobWobWorkflowRef, "@", 2) - if len(workflowPath) < 2 { - return fmt.Errorf("%w: %s", errorMalformedWorkflowURI, id.JobWobWorkflowRef) - } - - // Trusted workflow verification by name. - reusableWorkflowName := strings.Trim(workflowPath[0], "/") - if _, ok := trustedReusableWorkflows[reusableWorkflowName]; !ok { - return fmt.Errorf("%w: %s", ErrorUntrustedReusableWorkflow, reusableWorkflowName) - } - - // Verify the ref. - if err := verifyTrustedBuilderRef(id, strings.Trim(workflowPath[1], "/")); err != nil { return err } - // Issue verification. - if !strings.EqualFold(id.Issuer, certOidcIssuer) { - return fmt.Errorf("untrusted token issuer: %s", id.Issuer) + // Verify subject digest. + if err := verifySha256Digest(prov, opts.ExpectedDigest); err != nil { + return err } - // The caller repository in the x509 extension is not fully qualified. It only contains - // {org}/{repository}. - expectedSource := strings.TrimPrefix(source, "github.com/") - if !strings.EqualFold(id.CallerRepository, expectedSource) { - return fmt.Errorf("%w: expected source '%s', got '%s'", ErrorMismatchRepository, - expectedSource, id.CallerRepository) + // Verify the branch. + if err := VerifyBranch(prov, opts.ExpectedBranch); err != nil { + return err + } + + // Verify the tag. + if opts.ExpectedTag != nil { + if err := VerifyTag(prov, *opts.ExpectedTag); err != nil { + return err + } + } + + // Verify the versioned tag. + if opts.ExpectedVersionedTag != nil { + if err := VerifyVersionedTag(prov, *opts.ExpectedVersionedTag); err != nil { + return err + } } return nil } -// Only allow `@refs/heads/main` for the builder and the e2e tests that need to work at HEAD. -// This lets us use the pre-build builder binary generated during release (release happen at main). -// For other projects, we only allow semantic versions that map to a release. -func verifyTrustedBuilderRef(id *WorkflowIdentity, ref string) error { - if (id.CallerRepository == trustedBuilderRepository || - id.CallerRepository == e2eTestRepository) && - strings.EqualFold("refs/heads/main", ref) { - return nil - } - - if !strings.HasPrefix(ref, "refs/tags/") { - return fmt.Errorf("%w: %s: not of the form 'refs/tags/name'", errorInvalidRef, ref) - } - - // Valid semver of the form vX.Y.Z with no metadata. - pin := strings.TrimPrefix(ref, "refs/tags/") - if !(semver.IsValid(pin) && - len(strings.Split(pin, ".")) == 3 && - semver.Prerelease(pin) == "" && - semver.Build(pin) == "") { - return fmt.Errorf("%w: %s: not of the form vX.Y.Z", errorInvalidRef, pin) - } - return nil -} - -func VerifyProvenance(env *dsselib.Envelope, expectedHash string) error { - return verifySha256Digest(env, expectedHash) -} - -func VerifyBranch(env *dsselib.Envelope, expectedBranch string) error { - branch, err := getBranch(env) +func VerifyBranch(prov *intoto.ProvenanceStatement, expectedBranch string) error { + branch, err := getBranch(prov) if err != nil { return err } @@ -598,8 +130,8 @@ func VerifyBranch(env *dsselib.Envelope, expectedBranch string) error { return nil } -func VerifyTag(env *dsselib.Envelope, expectedTag string) error { - tag, err := getTag(env) +func VerifyTag(prov *intoto.ProvenanceStatement, expectedTag string) error { + tag, err := getTag(prov) if err != nil { return err } @@ -612,7 +144,7 @@ func VerifyTag(env *dsselib.Envelope, expectedTag string) error { return nil } -func VerifyVersionedTag(env *dsselib.Envelope, expectedTag string) error { +func VerifyVersionedTag(prov *intoto.ProvenanceStatement, expectedTag string) error { // Validate and canonicalize the provenance tag. if !semver.IsValid(expectedTag) { return fmt.Errorf("%s: %w", expectedTag, ErrorInvalidSemver) @@ -622,7 +154,7 @@ func VerifyVersionedTag(env *dsselib.Envelope, expectedTag string) error { // Note: prerelease is validated as part of patch validation // and must be equal. Build is discarded as per https://semver.org/: // "Build metadata MUST be ignored when determining version precedence", - tag, err := getTag(env) + tag, err := getTag(prov) if err != nil { return err } @@ -796,17 +328,7 @@ func getBranchForTag(environment map[string]interface{}) (string, error) { } // Get tag from the provenance invocation parameters. -func getTag(env *dsselib.Envelope) (string, error) { - pyld, err := base64.StdEncoding.DecodeString(env.Payload) - if err != nil { - return "", fmt.Errorf("%w: %s", ErrorInvalidDssePayload, "decoding payload") - } - - var prov intoto.ProvenanceStatement - if err := json.Unmarshal(pyld, &prov); err != nil { - return "", fmt.Errorf("%w: %s", ErrorInvalidDssePayload, "unmarshalling json") - } - +func getTag(prov *intoto.ProvenanceStatement) (string, error) { environment, ok := prov.Predicate.Invocation.Environment.(map[string]interface{}) if !ok { return "", fmt.Errorf("%w: %s", ErrorInvalidDssePayload, "parameters type") @@ -829,17 +351,7 @@ func getTag(env *dsselib.Envelope) (string, error) { } // Get branch from the provenance invocation parameters. -func getBranch(env *dsselib.Envelope) (string, error) { - pyld, err := base64.StdEncoding.DecodeString(env.Payload) - if err != nil { - return "", fmt.Errorf("%w: %s", ErrorInvalidDssePayload, "decoding payload") - } - - var prov intoto.ProvenanceStatement - if err := json.Unmarshal(pyld, &prov); err != nil { - return "", fmt.Errorf("%w: %s", ErrorInvalidDssePayload, "unmarshalling json") - } - +func getBranch(prov *intoto.ProvenanceStatement) (string, error) { environment, ok := prov.Predicate.Invocation.Environment.(map[string]interface{}) if !ok { return "", fmt.Errorf("%w: %s", ErrorInvalidDssePayload, "parameters type") diff --git a/pkg/provenance_test.go b/pkg/provenance_test.go index 880d9bb..4890132 100644 --- a/pkg/provenance_test.go +++ b/pkg/provenance_test.go @@ -1,103 +1,23 @@ package pkg import ( - "encoding/json" - "errors" "fmt" "os" "testing" - "github.com/go-openapi/runtime" "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - dsselib "github.com/secure-systems-lab/go-securesystemslib/dsse" - "github.com/sigstore/rekor/pkg/generated/client" - "github.com/sigstore/rekor/pkg/generated/client/index" + intoto "github.com/in-toto/in-toto-golang/in_toto" ) -type searchResult struct { - resp *index.SearchIndexOK - err error -} - -func envelopeFromBytes(payload []byte) (env *dsselib.Envelope, err error) { - env = &dsselib.Envelope{} - err = json.Unmarshal(payload, env) - return -} - -type MockIndexClient struct { - result searchResult -} - -func (m *MockIndexClient) SearchIndex(params *index.SearchIndexParams, - opts ...index.ClientOption) (*index.SearchIndexOK, error) { - return m.result.resp, m.result.err -} - -func (m *MockIndexClient) SetTransport(transport runtime.ClientTransport) { -} - -func errCmp(e1, e2 error) bool { - return errors.Is(e1, e2) || errors.Is(e2, e1) -} - -func Test_GetRekorEntries(t *testing.T) { - t.Parallel() - tests := []struct { - name string - artifactHash string - res searchResult - expected error - }{ - { - name: "rekor search result error", - artifactHash: "0ae7e4fa71686538440012ee36a2634dbaa19df2dd16a466f52411fb348bbc4e", - res: searchResult{ - err: index.NewSearchIndexDefault(500), - }, - expected: ErrorRekorSearch, - }, - { - name: "no rekor entries found", - artifactHash: "0ae7e4fa71686538440012ee36a2634dbaa19df2dd16a466f52411fb348bbc4e", - res: searchResult{ - err: nil, - resp: &index.SearchIndexOK{ - Payload: []string{}, - }, - }, - expected: ErrorRekorSearch, - }, - { - name: "valid rekor entries found", - artifactHash: "0ae7e4fa71686538440012ee36a2634dbaa19df2dd16a466f52411fb348bbc4e", - res: searchResult{ - err: nil, - resp: &index.SearchIndexOK{ - Payload: []string{"39d5109436c43dad92897d50f3b271aa456382875a922b28fedef9038b8f683a"}, - }, - }, - expected: nil, - }, - } - for _, tt := range tests { - tt := tt // Re-initializing variable so it is not changed while executing the closure below - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - var mClient client.Rekor - mClient.Index = &MockIndexClient{result: tt.res} - - _, err := GetRekorEntries(&mClient, tt.artifactHash) - if !errCmp(err, tt.expected) { - t.Errorf(cmp.Diff(err, tt.expected)) - } - }) +func provenanceFromBytes(payload []byte) (*intoto.ProvenanceStatement, error) { + env, err := EnvelopeFromBytes(payload) + if err != nil { + return nil, err } + return provenanceFromEnv(env) } -func Test_VerifyProvenance(t *testing.T) { +func Test_VerifySha256Subject(t *testing.T) { t.Parallel() tests := []struct { name string @@ -163,12 +83,12 @@ func Test_VerifyProvenance(t *testing.T) { if err != nil { panic(fmt.Errorf("os.ReadFile: %w", err)) } - env, err := envelopeFromBytes(content) + prov, err := provenanceFromBytes(content) if err != nil { - panic(fmt.Errorf("envelopeFromBytes: %w", err)) + panic(fmt.Errorf("provenanceFromBytes: %w", err)) } - err = VerifyProvenance(env, tt.artifactHash) + err = verifySha256Digest(prov, tt.artifactHash) if !errCmp(err, tt.expected) { t.Errorf(cmp.Diff(err, tt.expected)) } @@ -176,178 +96,6 @@ func Test_VerifyProvenance(t *testing.T) { } } -func Test_VerifyWorkflowIdentity(t *testing.T) { - t.Parallel() - tests := []struct { - name string - workflow *WorkflowIdentity - source string - err error - }{ - { - name: "invalid job workflow ref", - workflow: &WorkflowIdentity{ - CallerRepository: "asraa/slsa-on-github-test", - CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", - JobWobWorkflowRef: "random/workflow/ref", - Trigger: "workflow_dispatch", - Issuer: "https://token.actions.githubusercontent.com", - }, - source: "asraa/slsa-on-github-test", - err: errorMalformedWorkflowURI, - }, - { - name: "untrusted job workflow ref", - workflow: &WorkflowIdentity{ - CallerRepository: "asraa/slsa-on-github-test", - CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", - JobWobWorkflowRef: "/malicious/slsa-go/.github/workflows/builder.yml@refs/heads/main", - Trigger: "workflow_dispatch", - Issuer: "https://token.actions.githubusercontent.com", - }, - source: "asraa/slsa-on-github-test", - err: ErrorUntrustedReusableWorkflow, - }, - { - name: "untrusted job workflow ref for general repos", - workflow: &WorkflowIdentity{ - CallerRepository: "asraa/slsa-on-github-test", - CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", - JobWobWorkflowRef: trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml@refs/heads/main", - Trigger: "workflow_dispatch", - Issuer: "https://bad.issuer.com", - }, - source: "asraa/slsa-on-github-test", - err: errorInvalidRef, - }, - { - name: "valid main ref for trusted builder", - workflow: &WorkflowIdentity{ - CallerRepository: trustedBuilderRepository, - CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", - JobWobWorkflowRef: trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml@refs/heads/main", - Trigger: "workflow_dispatch", - Issuer: "https://token.actions.githubusercontent.com", - }, - source: trustedBuilderRepository, - }, - { - name: "valid main ref for e2e test", - workflow: &WorkflowIdentity{ - CallerRepository: e2eTestRepository, - CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", - JobWobWorkflowRef: trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml@refs/heads/main", - Trigger: "workflow_dispatch", - Issuer: certOidcIssuer, - }, - source: e2eTestRepository, - }, - { - name: "unexpected source for e2e test", - workflow: &WorkflowIdentity{ - CallerRepository: e2eTestRepository, - CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", - JobWobWorkflowRef: trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml@refs/heads/main", - Trigger: "workflow_dispatch", - Issuer: certOidcIssuer, - }, - source: "malicious/source", - err: ErrorMismatchRepository, - }, - { - name: "valid main ref for builder", - workflow: &WorkflowIdentity{ - CallerRepository: trustedBuilderRepository, - JobWobWorkflowRef: trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml@refs/heads/main", - Trigger: "workflow_dispatch", - Issuer: certOidcIssuer, - }, - source: "malicious/source", - err: ErrorMismatchRepository, - }, - { - name: "unexpected source", - workflow: &WorkflowIdentity{ - CallerRepository: "malicious/slsa-on-github-test", - CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", - JobWobWorkflowRef: trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml@refs/tags/v1.2.3", - Trigger: "workflow_dispatch", - Issuer: certOidcIssuer, - }, - source: "asraa/slsa-on-github-test", - err: ErrorMismatchRepository, - }, - { - name: "valid workflow identity", - workflow: &WorkflowIdentity{ - CallerRepository: "asraa/slsa-on-github-test", - CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", - JobWobWorkflowRef: trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml@refs/tags/v1.2.3", - Trigger: "workflow_dispatch", - Issuer: certOidcIssuer, - }, - source: "asraa/slsa-on-github-test", - }, - { - name: "invalid workflow identity with prerelease", - workflow: &WorkflowIdentity{ - CallerRepository: "asraa/slsa-on-github-test", - CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", - JobWobWorkflowRef: trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml@refs/tags/v1.2.3-alpha", - Trigger: "workflow_dispatch", - Issuer: certOidcIssuer, - }, - source: "asraa/slsa-on-github-test", - err: errorInvalidRef, - }, - { - name: "invalid workflow identity with build", - workflow: &WorkflowIdentity{ - CallerRepository: "asraa/slsa-on-github-test", - CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", - JobWobWorkflowRef: trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml@refs/tags/v1.2.3+123", - Trigger: "workflow_dispatch", - Issuer: certOidcIssuer, - }, - source: "asraa/slsa-on-github-test", - err: errorInvalidRef, - }, - { - name: "invalid workflow identity with metadata", - workflow: &WorkflowIdentity{ - CallerRepository: "asraa/slsa-on-github-test", - CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", - JobWobWorkflowRef: trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml@refs/tags/v1.2.3-alpha+123", - Trigger: "workflow_dispatch", - Issuer: certOidcIssuer, - }, - source: "asraa/slsa-on-github-test", - err: errorInvalidRef, - }, - { - name: "valid workflow identity with fully qualified source", - workflow: &WorkflowIdentity{ - CallerRepository: "asraa/slsa-on-github-test", - CallerHash: "0dfcd24824432c4ce587f79c918eef8fc2c44d7b", - JobWobWorkflowRef: trustedBuilderRepository + "/.github/workflows/builder_go_slsa3.yml@refs/tags/v1.2.3", - Trigger: "workflow_dispatch", - Issuer: certOidcIssuer, - }, - source: "github.com/asraa/slsa-on-github-test", - }, - } - for _, tt := range tests { - tt := tt // Re-initializing variable so it is not changed while executing the closure below - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - err := VerifyWorkflowIdentity(tt.workflow, tt.source) - if !errCmp(err, tt.err) { - t.Errorf(cmp.Diff(err, tt.err, cmpopts.EquateErrors())) - } - }) - } -} - func Test_VerifyBranch(t *testing.T) { t.Parallel() tests := []struct { @@ -391,12 +139,12 @@ func Test_VerifyBranch(t *testing.T) { if err != nil { panic(fmt.Errorf("os.ReadFile: %w", err)) } - env, err := envelopeFromBytes(content) + prov, err := provenanceFromBytes(content) if err != nil { - panic(fmt.Errorf("envelopeFromBytes: %w", err)) + panic(fmt.Errorf("provenanceFromBytes: %w", err)) } - err = VerifyBranch(env, tt.branch) + err = VerifyBranch(prov, tt.branch) if !errCmp(err, tt.expected) { t.Errorf(cmp.Diff(err, tt.expected)) } @@ -442,12 +190,12 @@ func Test_VerifyTag(t *testing.T) { if err != nil { panic(fmt.Errorf("os.ReadFile: %w", err)) } - env, err := envelopeFromBytes(content) + prov, err := provenanceFromBytes(content) if err != nil { - panic(fmt.Errorf("envelopeFromBytes: %w", err)) + panic(fmt.Errorf("provenanceFromBytes: %w", err)) } - err = VerifyTag(env, tt.tag) + err = VerifyTag(prov, tt.tag) if !errCmp(err, tt.expected) { t.Errorf(cmp.Diff(err, tt.expected)) } @@ -455,156 +203,6 @@ func Test_VerifyTag(t *testing.T) { } } -func Test_verifyTrustedBuilderRef(t *testing.T) { - t.Parallel() - tests := []struct { - name string - callerRepo string - builderRef string - expected error - }{ - // Trusted repo. - { - name: "main allowed for builder", - callerRepo: trustedBuilderRepository, - builderRef: "refs/heads/main", - }, - { - name: "full semver for builder", - callerRepo: trustedBuilderRepository, - builderRef: "refs/tags/v1.2.3", - }, - { - name: "no patch semver for other builder", - callerRepo: trustedBuilderRepository, - builderRef: "refs/tags/v1.2", - expected: errorInvalidRef, - }, - { - name: "no min semver for builder", - callerRepo: trustedBuilderRepository, - builderRef: "refs/tags/v1", - expected: errorInvalidRef, - }, - { - name: "full semver with prerelease for builder", - callerRepo: trustedBuilderRepository, - builderRef: "refs/tags/v1.2.3-alpha", - expected: errorInvalidRef, - }, - { - name: "full semver with build for builder", - callerRepo: trustedBuilderRepository, - builderRef: "refs/tags/v1.2.3+123", - expected: errorInvalidRef, - }, - { - name: "full semver with build/prerelease for builder", - callerRepo: trustedBuilderRepository, - builderRef: "refs/tags/v1.2.3-alpha+123", - expected: errorInvalidRef, - }, - // E2e tests repo. - { - name: "main allowed for test repo", - callerRepo: e2eTestRepository, - builderRef: "refs/heads/main", - }, - { - name: "full semver for test repo", - callerRepo: e2eTestRepository, - builderRef: "refs/tags/v1.2.3", - }, - { - name: "no patch semver for test repo", - callerRepo: e2eTestRepository, - builderRef: "refs/tags/v1.2", - expected: errorInvalidRef, - }, - { - name: "no min semver for test repo", - callerRepo: e2eTestRepository, - builderRef: "refs/tags/v1", - expected: errorInvalidRef, - }, - { - name: "full semver with prerelease for test repo", - callerRepo: e2eTestRepository, - builderRef: "refs/tags/v1.2.3-alpha", - expected: errorInvalidRef, - }, - { - name: "full semver with build for test repo", - callerRepo: e2eTestRepository, - builderRef: "refs/tags/v1.2.3+123", - expected: errorInvalidRef, - }, - { - name: "full semver with build/prerelease for test repo", - callerRepo: e2eTestRepository, - builderRef: "refs/tags/v1.2.3-alpha+123", - expected: errorInvalidRef, - }, - // Other repos. - { - name: "main not allowed for other repos", - callerRepo: "some/repo", - builderRef: "refs/heads/main", - expected: errorInvalidRef, - }, - { - name: "full semver for other repos", - callerRepo: "some/repo", - builderRef: "refs/tags/v1.2.3", - }, - { - name: "no patch semver for other repos", - callerRepo: "some/repo", - builderRef: "refs/tags/v1.2", - expected: errorInvalidRef, - }, - { - name: "no min semver for other repos", - callerRepo: "some/repo", - builderRef: "refs/tags/v1", - expected: errorInvalidRef, - }, - { - name: "full semver with prerelease for other repos", - callerRepo: "some/repo", - builderRef: "refs/tags/v1.2.3-alpha", - expected: errorInvalidRef, - }, - { - name: "full semver with build for other repos", - callerRepo: "some/repo", - builderRef: "refs/tags/v1.2.3+123", - expected: errorInvalidRef, - }, - { - name: "full semver with build/prerelease for other repos", - callerRepo: "some/repo", - builderRef: "refs/tags/v1.2.3-alpha+123", - expected: errorInvalidRef, - }, - } - for _, tt := range tests { - tt := tt // Re-initializing variable so it is not changed while executing the closure below - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - wf := WorkflowIdentity{ - CallerRepository: tt.callerRepo, - } - - err := verifyTrustedBuilderRef(&wf, tt.builderRef) - if !errCmp(err, tt.expected) { - t.Errorf(cmp.Diff(err, tt.expected, cmpopts.EquateErrors())) - } - }) - } -} - func Test_VerifyVersionedTag(t *testing.T) { t.Parallel() tests := []struct { @@ -922,12 +520,12 @@ func Test_VerifyVersionedTag(t *testing.T) { if err != nil { panic(fmt.Errorf("os.ReadFile: %w", err)) } - env, err := envelopeFromBytes(content) + prov, err := provenanceFromBytes(content) if err != nil { - panic(fmt.Errorf("envelopeFromBytes: %w", err)) + panic(fmt.Errorf("provenanceFromBytes: %w", err)) } - err = VerifyVersionedTag(env, tt.tag) + err = VerifyVersionedTag(prov, tt.tag) if !errCmp(err, tt.expected) { t.Errorf(cmp.Diff(err, tt.expected)) } diff --git a/pkg/rekor.go b/pkg/rekor.go new file mode 100644 index 0000000..b2e4827 --- /dev/null +++ b/pkg/rekor.go @@ -0,0 +1,387 @@ +package pkg + +import ( + "bytes" + "context" + "crypto" + "crypto/ecdsa" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "os" + "time" + + cjson "github.com/docker/go/canonical/json" + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/google/trillian/merkle/rfc6962" + dsselib "github.com/secure-systems-lab/go-securesystemslib/dsse" + "github.com/sigstore/cosign/cmd/cosign/cli/fulcio" + "github.com/sigstore/cosign/pkg/cosign" + "github.com/sigstore/cosign/pkg/cosign/bundle" + "github.com/sigstore/rekor/pkg/generated/client" + "github.com/sigstore/rekor/pkg/generated/client/entries" + "github.com/sigstore/rekor/pkg/generated/client/index" + "github.com/sigstore/rekor/pkg/generated/client/tlog" + "github.com/sigstore/rekor/pkg/generated/models" + "github.com/sigstore/rekor/pkg/sharding" + "github.com/sigstore/rekor/pkg/types" + intotod "github.com/sigstore/rekor/pkg/types/intoto/v0.0.1" + "github.com/sigstore/rekor/pkg/util" + "github.com/sigstore/sigstore/pkg/cryptoutils" + "github.com/sigstore/sigstore/pkg/signature" + "github.com/sigstore/sigstore/pkg/signature/dsse" + "github.com/slsa-framework/slsa-github-generator/signing/envelope" + "github.com/transparency-dev/merkle/proof" +) + +const ( + defaultRekorAddr = "https://rekor.sigstore.dev" +) + +func verifyRootHash(ctx context.Context, rekorClient *client.Rekor, eproof *models.InclusionProof, pub *ecdsa.PublicKey) error { + infoParams := tlog.NewGetLogInfoParamsWithContext(ctx) + result, err := rekorClient.Tlog.GetLogInfo(infoParams) + if err != nil { + return err + } + + logInfo := result.GetPayload() + + sth := util.SignedCheckpoint{} + if err := sth.UnmarshalText([]byte(*logInfo.SignedTreeHead)); err != nil { + return err + } + + verifier, err := signature.LoadVerifier(pub, crypto.SHA256) + if err != nil { + return err + } + + if !sth.Verify(verifier) { + return errors.New("signature on tree head did not verify") + } + + rootHash, err := hex.DecodeString(*eproof.RootHash) + if err != nil { + return errors.New("error decoding root hash in inclusion proof") + } + + if *eproof.TreeSize == int64(sth.Size) { + if !bytes.Equal(rootHash, sth.Hash) { + return errors.New("root hash returned from server does not match inclusion proof hash") + } + } else if *eproof.TreeSize < int64(sth.Size) { + consistencyParams := tlog.NewGetLogProofParamsWithContext(ctx) + consistencyParams.FirstSize = eproof.TreeSize // Root hash at the time the proof was returned + consistencyParams.LastSize = int64(sth.Size) // Root hash verified with rekor pubkey + + consistencyProof, err := rekorClient.Tlog.GetLogProof(consistencyParams) + if err != nil { + return err + } + var hashes [][]byte + for _, h := range consistencyProof.Payload.Hashes { + b, err := hex.DecodeString(h) + if err != nil { + return errors.New("error decoding consistency proof hashes") + } + hashes = append(hashes, b) + } + if err := proof.VerifyConsistency(rfc6962.DefaultHasher, + uint64(*eproof.TreeSize), sth.Size, hashes, rootHash, sth.Hash); err != nil { + return err + } + } else if *eproof.TreeSize > int64(sth.Size) { + return errors.New("inclusion proof returned a tree size larger than the verified tree size") + } + return nil +} + +func verifyTlogEntryByUUID(ctx context.Context, rekorClient *client.Rekor, entryUUID string) (*models.LogEntryAnon, error) { + params := entries.NewGetLogEntryByUUIDParamsWithContext(ctx) + params.EntryUUID = entryUUID + + lep, err := rekorClient.Entries.GetLogEntryByUUID(params) + if err != nil { + return nil, err + } + + if len(lep.Payload) != 1 { + return nil, errors.New("UUID value can not be extracted") + } + + uuid, err := sharding.GetUUIDFromIDString(params.EntryUUID) + if err != nil { + return nil, err + } + + var e models.LogEntryAnon + for k, entry := range lep.Payload { + if k != uuid { + return nil, errors.New("expected matching UUID") + } + e = entry + } + + return verifyTlogEntry(ctx, rekorClient, uuid, e) +} + +func verifyTlogEntry(ctx context.Context, rekorClient *client.Rekor, uuid string, e models.LogEntryAnon) (*models.LogEntryAnon, error) { + if e.Verification == nil || e.Verification.InclusionProof == nil { + return nil, errors.New("inclusion proof not provided") + } + + var hashes [][]byte + for _, h := range e.Verification.InclusionProof.Hashes { + hb, err := hex.DecodeString(h) + if err != nil { + return nil, errors.New("error decoding inclusion proof hashes") + } + hashes = append(hashes, hb) + } + + rootHash, err := hex.DecodeString(*e.Verification.InclusionProof.RootHash) + if err != nil { + return nil, errors.New("error decoding hex encoded root hash") + } + leafHash, err := hex.DecodeString(uuid) + if err != nil { + return nil, errors.New("error decoding hex encoded leaf hash") + } + + // Verify the root hash against the current Signed Entry Tree Head + pubs, err := cosign.GetRekorPubs(ctx, nil) + if err != nil { + return nil, fmt.Errorf("%w: %s", err, "unable to fetch Rekor public keys from TUF repository") + } + + var entryVerError error + for _, pubKey := range pubs { + // Verify inclusion against the signed tree head + entryVerError = verifyRootHash(ctx, rekorClient, e.Verification.InclusionProof, pubKey.PubKey) + if entryVerError == nil { + break + } + } + if entryVerError != nil { + return nil, fmt.Errorf("%w: %s", err, "error verifying root hash") + } + + // Verify the entry's inclusion + if err := proof.VerifyInclusion(rfc6962.DefaultHasher, + uint64(*e.Verification.InclusionProof.LogIndex), + uint64(*e.Verification.InclusionProof.TreeSize), leafHash, hashes, rootHash); err != nil { + return nil, fmt.Errorf("%w: %s", err, "verifying inclusion proof") + } + + // Verify rekor's signature over the SET. + payload := bundle.RekorPayload{ + Body: e.Body, + IntegratedTime: *e.IntegratedTime, + LogIndex: *e.LogIndex, + LogID: *e.LogID, + } + + var setVerError error + for _, pubKey := range pubs { + setVerError = cosign.VerifySET(payload, e.Verification.SignedEntryTimestamp, pubKey.PubKey) + // Return once the SET is verified successfully. + if setVerError == nil { + break + } + } + + return &e, setVerError +} + +func extractCert(e *models.LogEntryAnon) (*x509.Certificate, error) { + b, err := base64.StdEncoding.DecodeString(e.Body.(string)) + if err != nil { + return nil, err + } + + pe, err := models.UnmarshalProposedEntry(bytes.NewReader(b), runtime.JSONConsumer()) + if err != nil { + return nil, err + } + + eimpl, err := types.NewEntry(pe) + if err != nil { + return nil, err + } + + var publicKeyB64 []byte + switch e := eimpl.(type) { + case *intotod.V001Entry: + publicKeyB64, err = e.IntotoObj.PublicKey.MarshalText() + if err != nil { + return nil, err + } + default: + return nil, errors.New("unexpected tlog entry type") + } + + publicKey, err := base64.StdEncoding.DecodeString(string(publicKeyB64)) + if err != nil { + return nil, err + } + + certs, err := cryptoutils.UnmarshalCertificatesFromPEM(publicKey) + if err != nil { + return nil, err + } + + if len(certs) != 1 { + return nil, errors.New("unexpected number of cert pem tlog entry") + } + + return certs[0], err +} + +func intotoEntry(certPem []byte, provenance []byte) (*intotod.V001Entry, error) { + cert := strfmt.Base64(certPem) + return &intotod.V001Entry{ + IntotoObj: models.IntotoV001Schema{ + Content: &models.IntotoV001SchemaContent{ + Envelope: string(provenance), + }, + PublicKey: &cert, + }, + }, nil +} + +// GetRekorEntries finds all entry UUIDs by the digest of the artifact binary. +func GetRekorEntries(rClient *client.Rekor, artifactHash string) ([]string, error) { + // Use search index to find rekor entry UUIDs that match Subject Digest. + params := index.NewSearchIndexParams() + params.Query = &models.SearchIndex{Hash: fmt.Sprintf("sha256:%v", artifactHash)} + resp, err := rClient.Index.SearchIndex(params) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrorRekorSearch, err.Error()) + } + + if len(resp.Payload) == 0 { + return nil, fmt.Errorf("%w: no matching entries found", ErrorRekorSearch) + } + + return resp.GetPayload(), nil +} + +// GetRekorEntriesWithCert finds all entry UUIDs with the full intoto attestation. +// The attestation generated by the slsa-github-generator libraries contain a signing certificate. +func GetRekorEntriesWithCert(rClient *client.Rekor, provenance []byte) (*dsselib.Envelope, *x509.Certificate, error) { + // Use intoto attestation to find rekor entry UUIDs. + params := entries.NewSearchLogQueryParams() + searchLogQuery := models.SearchLogQuery{} + certPem, err := envelope.GetCertFromEnvelope(provenance) + if err != nil { + return nil, nil, fmt.Errorf("error getting certificate from provenance: %w", err) + } + + e, err := intotoEntry(certPem, provenance) + if err != nil { + return nil, nil, fmt.Errorf("error creating intoto entry: %w", err) + } + entry := models.Intoto{ + APIVersion: swag.String(e.APIVersion()), + Spec: e.IntotoObj, + } + entries := []models.ProposedEntry{&entry} + searchLogQuery.SetEntries(entries) + + params.SetEntry(&searchLogQuery) + resp, err := rClient.Entries.SearchLogQuery(params) + if err != nil { + return nil, nil, fmt.Errorf("%w: %s", ErrorRekorSearch, err.Error()) + } + + if len(resp.GetPayload()) != 1 { + return nil, nil, fmt.Errorf("%w: %s", ErrorRekorSearch, "no matching rekor entries") + } + + logEntry := resp.Payload[0] + for uuid, e := range logEntry { + if _, err := verifyTlogEntry(context.Background(), rClient, uuid, e); err != nil { + return nil, nil, fmt.Errorf("error verifying tlog entry: %w", err) + } + url := fmt.Sprintf("%v/%v/%v", defaultRekorAddr, "api/v1/log/entries", uuid) + fmt.Fprintf(os.Stderr, "Verified signature against tlog entry index %d at URL: %s\n", *e.LogIndex, url) + } + + env, err := EnvelopeFromBytes(provenance) + if err != nil { + return nil, nil, err + } + + certs, err := cryptoutils.UnmarshalCertificatesFromPEM(certPem) + if err != nil { + return nil, nil, err + } + if len(certs) != 1 { + return nil, nil, fmt.Errorf("error unmarshaling certificate from pem") + } + + return env, certs[0], nil +} + +// FindSigningCertificate finds and verifies a matching signing certificate from a list of Rekor entry UUIDs. +func FindSigningCertificate(ctx context.Context, uuids []string, dssePayload dsselib.Envelope, rClient *client.Rekor) (*x509.Certificate, error) { + attBytes, err := cjson.MarshalCanonical(dssePayload) + if err != nil { + return nil, err + } + + // Iterate through each matching UUID and perform: + // * Verify TLOG entry (inclusion and signed entry timestamp against Rekor pubkey). + // * Verify the signing certificate against the Fulcio root CA. + // * Verify dsse envelope signature against signing certificate. + // * Check signature expiration against IntegratedTime in entry. + // * If all succeed, return the signing certificate. + for _, uuid := range uuids { + entry, err := verifyTlogEntryByUUID(ctx, rClient, uuid) + if err != nil { + continue + } + cert, err := extractCert(entry) + if err != nil { + continue + } + + roots, err := fulcio.GetRoots() + if err != nil { + continue + } + co := &cosign.CheckOpts{ + RootCerts: roots, + CertOidcIssuer: certOidcIssuer, + } + verifier, err := cosign.ValidateAndUnpackCert(cert, co) + if err != nil { + continue + } + verifier = dsse.WrapVerifier(verifier) + if err := verifier.VerifySignature(bytes.NewReader(attBytes), bytes.NewReader(attBytes)); err != nil { + continue + } + it := time.Unix(*entry.IntegratedTime, 0) + if err := cosign.CheckExpiry(cert, it); err != nil { + continue + } + uuid, err := cosign.ComputeLeafHash(entry) + if err != nil { + fmt.Fprintf(os.Stderr, "Error computing leaf hash for tlog entry at index: %d\n", *entry.LogIndex) + continue + } + + // success! + url := fmt.Sprintf("%v/%v/%v", defaultRekorAddr, "api/v1/log/entries", hex.EncodeToString(uuid)) + fmt.Fprintf(os.Stderr, "Verified signature against tlog entry index %d at URL: %s\n", *entry.LogIndex, url) + return cert, nil + } + + return nil, ErrorNoValidRekorEntries +} diff --git a/pkg/rekor_test.go b/pkg/rekor_test.go new file mode 100644 index 0000000..2b74472 --- /dev/null +++ b/pkg/rekor_test.go @@ -0,0 +1,87 @@ +package pkg + +import ( + "errors" + "testing" + + "github.com/go-openapi/runtime" + "github.com/google/go-cmp/cmp" + "github.com/sigstore/rekor/pkg/generated/client" + "github.com/sigstore/rekor/pkg/generated/client/index" +) + +type searchResult struct { + resp *index.SearchIndexOK + err error +} + +type MockIndexClient struct { + result searchResult +} + +func (m *MockIndexClient) SearchIndex(params *index.SearchIndexParams, + opts ...index.ClientOption) (*index.SearchIndexOK, error) { + return m.result.resp, m.result.err +} + +func (m *MockIndexClient) SetTransport(transport runtime.ClientTransport) { +} + +func errCmp(e1, e2 error) bool { + return errors.Is(e1, e2) || errors.Is(e2, e1) +} + +func Test_GetRekorEntries(t *testing.T) { + t.Parallel() + tests := []struct { + name string + artifactHash string + res searchResult + expected error + }{ + { + name: "rekor search result error", + artifactHash: "0ae7e4fa71686538440012ee36a2634dbaa19df2dd16a466f52411fb348bbc4e", + res: searchResult{ + err: index.NewSearchIndexDefault(500), + }, + expected: ErrorRekorSearch, + }, + { + name: "no rekor entries found", + artifactHash: "0ae7e4fa71686538440012ee36a2634dbaa19df2dd16a466f52411fb348bbc4e", + res: searchResult{ + err: nil, + resp: &index.SearchIndexOK{ + Payload: []string{}, + }, + }, + expected: ErrorRekorSearch, + }, + { + name: "valid rekor entries found", + artifactHash: "0ae7e4fa71686538440012ee36a2634dbaa19df2dd16a466f52411fb348bbc4e", + res: searchResult{ + err: nil, + resp: &index.SearchIndexOK{ + Payload: []string{"39d5109436c43dad92897d50f3b271aa456382875a922b28fedef9038b8f683a"}, + }, + }, + expected: nil, + }, + } + for _, tt := range tests { + tt := tt // Re-initializing variable so it is not changed while executing the closure below + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var mClient client.Rekor + mClient.Index = &MockIndexClient{result: tt.res} + + _, err := GetRekorEntries(&mClient, tt.artifactHash) + if !errCmp(err, tt.expected) { + t.Errorf(cmp.Diff(err, tt.expected)) + } + }) + } +}