diff --git a/README.md b/README.md index 9164f18b7..d2ada8bdc 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,10 @@ - [Verification for Google Cloud Build](#verification-for-google-cloud-build) - [Artifacts](#artifacts-1) - [Containers](#containers-1) +- [Verification Summary Attestations (VSA)](#verification-summary-attestations-vsa) + - [Caveats](#caveats) + - [Sigstore](#sigstore) + - [Subject Resource Descriptors](#subject-resource-descriptors) - [Known Issues](#known-issues) - [tuf: invalid key](#tuf-invalid-key) - [panic: assignment to entry in nil map](#panic-assignment-to-entry-in-nil-map) @@ -481,6 +485,68 @@ The verified in-toto statement may be written to stdout with the Note that `--source-uri` supports GitHub repository URIs like `github.com/$OWNER/$REPO` when the build was enabled with a Cloud Build [GitHub trigger](https://cloud.google.com/build/docs/automating-builds/github/build-repos-from-github). Otherwise, the build provenance will contain the name of the Cloud Storage bucket used to host the source files, usually of the form `gs://[PROJECT_ID]_cloudbuild/source` (see [Running build](https://cloud.google.com/build/docs/running-builds/submit-build-via-cli-api#running_builds)). We recommend using GitHub triggers in order to preserve the source provenance and valiate that the source came from an expected, version-controlled repository. You _may_ match on the fully-qualified tar like `gs://[PROJECT_ID]_cloudbuild/source/1665165360.279777-955d1904741e4bbeb3461080299e929a.tgz`. +## Verification Summary Attestations (VSA) + +We have support for [verifying](https://slsa.dev/spec/v1.1/verification_summary#how-to-verify) VSAs. +Rather than passing in filepaths as arguments, we allow passing in mulitple `--subject-digest` cli options, to +accomodate subjects that are not simple-files. + + +The verify-vsa command + +```shell +$ slsa-verifier verify-vsa --help +Verifies SLSA VSAs for the given subject-digests + +Usage: + slsa-verifier verify-vsa [flags] subject-digest [subject-digest...] + +Flags: + --attestation-path string path to a file containing the attestation + -h, --help help for verify-vsa + --print-attestation [optional] print the contents of attestation to stdout + --public-key-id string [optional] the ID of the public key, defaults to the SHA256 digest of the base64-encoded public key + --public-key-path string path to a public key file + --resource-uri string the resource URI to be verified + --subject-digest stringArray the digests to be verified. Pass multiple digests by repeating the flag. e.g. --subject-digest : --subject-digest : + --verified-level stringArray [optional] the levels of verification to be performed. Pass multiple digests by repeating the flag, e.g., --verified-level SLSA_BUILD_LEVEL_2 --verified-level FEDRAMP_LOW' + --verifier-id string the unique verifier ID who created the attestation +``` + +To verify VSAs, invoke like this + +```shell +$ slsa-verifier verify-vsa \ +--subject-digest gce_image_id:8970095005306000053 \ +--attestation-path ./cli/slsa-verifier/testdata/vsa/gce/v1/gke-gce-pre.bcid-vsa.jsonl \ +--verifier-id https://bcid.corp.google.com/verifier/bcid_package_enforcer/v0.1 \ +--resource-uri gce_image://gke-node-images:gke-12615-gke1418000-cos-101-17162-463-29-c-cgpv1-pre \ +--verified-level BCID_L1 \ +--verified-level SLSA_BUILD_LEVEL_2 \ +--public-key-path ./cli/slsa-verifier/testdata/vsa/gce/v1/vsa_signing_public_key.pem \ +--public-key-id keystore://76574:prod:vsa_signing_public_key \ +--print-attestation +``` + +For multiple subjects, use: + +``` +--subject-digest sha256:abc123 +--subject-digest sha256:xyz456 +``` + +### Caveats + +#### Sigstore + +This support does not work yet with VSAs wrapped in Sigstore bundles, only with simple DSSE envelopes. +With that, we allow the user to pass in the public key. +Note that if the DSSE Envelope `signatures` specifies a `keyid` that is not a unpadded base64 encoded sha256 hash the key, like `sha256:abc123...` (not a well-known identifier, e.g, `my-kms:prod-vsa-key`), then you must supply the `--public-key-id` cli option. + +#### Subject Resource Descriptors + +According to slsa.dev's [VSA schema](https://slsa.dev/spec/v1.1/verification_summary#schema), we only support the Subject's `Name` and `Digest`, not the full in_toto [Statement](https://pkg.go.dev/github.com/in-toto/attestation/go/v1#Statement)'s [ResourceDescriptor](https://github.com/in-toto/attestation/blob/main/spec/v1/resource_descriptor.md). + ## Known Issues ### tuf: invalid key diff --git a/cli/slsa-verifier/main.go b/cli/slsa-verifier/main.go index fefd9f372..297699033 100644 --- a/cli/slsa-verifier/main.go +++ b/cli/slsa-verifier/main.go @@ -37,6 +37,7 @@ For more information on SLSA, visit https://slsa.dev`, c.AddCommand(verifyArtifactCmd()) c.AddCommand(verifyImageCmd()) c.AddCommand(verifyNpmPackageCmd()) + c.AddCommand(verifyVSACmd()) // We print our own errors and usage in the check function. c.SilenceErrors = true return c diff --git a/cli/slsa-verifier/main_regression_test.go b/cli/slsa-verifier/main_regression_test.go index a7ade249f..67ee7ac43 100644 --- a/cli/slsa-verifier/main_regression_test.go +++ b/cli/slsa-verifier/main_regression_test.go @@ -1515,88 +1515,88 @@ func Test_runVerifyNpmPackage(t *testing.T) { name: "valid npm CLI builder", artifact: "supreme-googles-cli-v02-tag.tgz", source: "github.com/trishankatdatadog/supreme-goggles", - pkgVersion: PointerTo("1.0.5"), - pkgName: PointerTo("@trishankatdatadog/supreme-goggles"), - builderID: PointerTo("https://github.com/actions/runner/github-hosted"), + pkgVersion: pointerTo("1.0.5"), + pkgName: pointerTo("@trishankatdatadog/supreme-goggles"), + builderID: pointerTo("https://github.com/actions/runner/github-hosted"), }, { name: "valid npm CLI builder short runner name", artifact: "supreme-googles-cli-v02-tag.tgz", source: "github.com/trishankatdatadog/supreme-goggles", - pkgVersion: PointerTo("1.0.5"), - pkgName: PointerTo("@trishankatdatadog/supreme-goggles"), - builderID: PointerTo("https://github.com/actions/runner"), + pkgVersion: pointerTo("1.0.5"), + pkgName: pointerTo("@trishankatdatadog/supreme-goggles"), + builderID: pointerTo("https://github.com/actions/runner"), }, { name: "valid npm CLI builder no builder", artifact: "supreme-googles-cli-v02-tag.tgz", source: "github.com/trishankatdatadog/supreme-goggles", - pkgVersion: PointerTo("1.0.5"), - pkgName: PointerTo("@trishankatdatadog/supreme-goggles"), + pkgVersion: pointerTo("1.0.5"), + pkgName: pointerTo("@trishankatdatadog/supreme-goggles"), err: serrors.ErrorInvalidBuilderID, }, { name: "valid npm CLI builder mismatch builder", artifact: "supreme-googles-cli-v02-tag.tgz", source: "github.com/trishankatdatadog/supreme-goggles", - pkgVersion: PointerTo("1.0.5"), - pkgName: PointerTo("@trishankatdatadog/supreme-goggles"), - builderID: PointerTo("https://github.com/actions/runner2"), + pkgVersion: pointerTo("1.0.5"), + pkgName: pointerTo("@trishankatdatadog/supreme-goggles"), + builderID: pointerTo("https://github.com/actions/runner2"), err: serrors.ErrorNotSupported, }, { name: "valid npm CLI builder no package name", artifact: "supreme-googles-cli-v02-tag.tgz", source: "github.com/trishankatdatadog/supreme-goggles", - pkgVersion: PointerTo("1.0.5"), - builderID: PointerTo("https://github.com/actions/runner/github-hosted"), + pkgVersion: pointerTo("1.0.5"), + builderID: pointerTo("https://github.com/actions/runner/github-hosted"), }, { name: "valid npm CLI builder no package version", artifact: "supreme-googles-cli-v02-tag.tgz", source: "github.com/trishankatdatadog/supreme-goggles", - pkgName: PointerTo("@trishankatdatadog/supreme-goggles"), - builderID: PointerTo("https://github.com/actions/runner/github-hosted"), + pkgName: pointerTo("@trishankatdatadog/supreme-goggles"), + builderID: pointerTo("https://github.com/actions/runner/github-hosted"), }, { name: "valid npm CLI builder mismatch source", artifact: "supreme-googles-cli-v02-tag.tgz", source: "github.com/trishankatdatadog/supreme-goggleS", - pkgVersion: PointerTo("1.0.5"), - pkgName: PointerTo("@trishankatdatadog/supreme-goggles"), - builderID: PointerTo("https://github.com/actions/runner/github-hosted"), + pkgVersion: pointerTo("1.0.5"), + pkgName: pointerTo("@trishankatdatadog/supreme-goggles"), + builderID: pointerTo("https://github.com/actions/runner/github-hosted"), err: serrors.ErrorMismatchSource, }, { name: "valid npm CLI builder mismatch package version", artifact: "supreme-googles-cli-v02-tag.tgz", source: "github.com/trishankatdatadog/supreme-goggles", - pkgVersion: PointerTo("1.0.4"), - builderID: PointerTo("https://github.com/actions/runner/github-hosted"), + pkgVersion: pointerTo("1.0.4"), + builderID: pointerTo("https://github.com/actions/runner/github-hosted"), err: serrors.ErrorMismatchPackageVersion, }, { name: "valid npm CLI builder mismatch package name", artifact: "supreme-googles-cli-v02-tag.tgz", source: "github.com/trishankatdatadog/supreme-goggles", - pkgName: PointerTo("@trishankatdatadog/supreme-goggleS"), - builderID: PointerTo("https://github.com/actions/runner/github-hosted"), + pkgName: pointerTo("@trishankatdatadog/supreme-goggleS"), + builderID: pointerTo("https://github.com/actions/runner/github-hosted"), err: serrors.ErrorMismatchPackageName, }, { name: "invalid signature provenance npm CLI", artifact: "supreme-googles-cli-v02-tag-invalidsigprov.tgz", source: "github.com/trishankatdatadog/supreme-goggles", - pkgName: PointerTo("@trishankatdatadog/supreme-goggles"), - builderID: PointerTo("https://github.com/actions/runner/github-hosted"), + pkgName: pointerTo("@trishankatdatadog/supreme-goggles"), + builderID: pointerTo("https://github.com/actions/runner/github-hosted"), err: serrors.ErrorInvalidSignature, }, { name: "invalid signature provenance npm CLI", artifact: "supreme-googles-cli-v02-tag-invalidsigpub.tgz", source: "github.com/trishankatdatadog/supreme-goggles", - pkgName: PointerTo("@trishankatdatadog/supreme-goggles"), - builderID: PointerTo("https://github.com/actions/runner/github-hosted"), + pkgName: pointerTo("@trishankatdatadog/supreme-goggles"), + builderID: pointerTo("https://github.com/actions/runner/github-hosted"), err: serrors.ErrorInvalidSignature, }, // npm CLI with main branch. @@ -1604,86 +1604,86 @@ func Test_runVerifyNpmPackage(t *testing.T) { name: "valid npm CLI builder", artifact: "provenance-npm-test-cli-v02-prega.tgz", source: "github.com/laurentsimon/provenance-npm-test", - pkgVersion: PointerTo("1.0.3"), - pkgName: PointerTo("@laurentsimon/provenance-npm-test"), - builderID: PointerTo("https://github.com/actions/runner/github-hosted"), + pkgVersion: pointerTo("1.0.3"), + pkgName: pointerTo("@laurentsimon/provenance-npm-test"), + builderID: pointerTo("https://github.com/actions/runner/github-hosted"), }, { name: "valid npm CLI builder short runner name", artifact: "provenance-npm-test-cli-v02-prega.tgz", source: "github.com/laurentsimon/provenance-npm-test", - pkgVersion: PointerTo("1.0.3"), - pkgName: PointerTo("@laurentsimon/provenance-npm-test"), - builderID: PointerTo("https://github.com/actions/runner"), + pkgVersion: pointerTo("1.0.3"), + pkgName: pointerTo("@laurentsimon/provenance-npm-test"), + builderID: pointerTo("https://github.com/actions/runner"), }, { name: "valid npm CLI builder no builder", artifact: "provenance-npm-test-cli-v02-prega.tgz", source: "github.com/laurentsimon/provenance-npm-test", - pkgVersion: PointerTo("1.0.3"), - pkgName: PointerTo("@laurentsimon/provenance-npm-test"), + pkgVersion: pointerTo("1.0.3"), + pkgName: pointerTo("@laurentsimon/provenance-npm-test"), err: serrors.ErrorInvalidBuilderID, }, { name: "valid npm CLI builder mismatch builder", artifact: "provenance-npm-test-cli-v02-prega.tgz", source: "github.com/laurentsimon/provenance-npm-test", - pkgVersion: PointerTo("1.0.3"), - pkgName: PointerTo("@laurentsimon/provenance-npm-test"), - builderID: PointerTo("https://github.com/actions/runner2"), + pkgVersion: pointerTo("1.0.3"), + pkgName: pointerTo("@laurentsimon/provenance-npm-test"), + builderID: pointerTo("https://github.com/actions/runner2"), err: serrors.ErrorNotSupported, }, { name: "valid npm CLI builder no package name", artifact: "provenance-npm-test-cli-v02-prega.tgz", - pkgVersion: PointerTo("1.0.3"), + pkgVersion: pointerTo("1.0.3"), source: "github.com/laurentsimon/provenance-npm-test", - builderID: PointerTo("https://github.com/actions/runner/github-hosted"), + builderID: pointerTo("https://github.com/actions/runner/github-hosted"), }, { name: "valid npm CLI builder no package version", artifact: "provenance-npm-test-cli-v02-prega.tgz", source: "github.com/laurentsimon/provenance-npm-test", - pkgName: PointerTo("@laurentsimon/provenance-npm-test"), - builderID: PointerTo("https://github.com/actions/runner/github-hosted"), + pkgName: pointerTo("@laurentsimon/provenance-npm-test"), + builderID: pointerTo("https://github.com/actions/runner/github-hosted"), }, { name: "valid npm CLI builder mismatch source", artifact: "provenance-npm-test-cli-v02-prega.tgz", source: "github.com/laurentsimon/provenance-npm-test2", - builderID: PointerTo("https://github.com/actions/runner/github-hosted"), + builderID: pointerTo("https://github.com/actions/runner/github-hosted"), err: serrors.ErrorMismatchSource, }, { name: "valid npm CLI builder mismatch package version", artifact: "provenance-npm-test-cli-v02-prega.tgz", source: "github.com/laurentsimon/provenance-npm-test", - pkgVersion: PointerTo("1.0.4"), - builderID: PointerTo("https://github.com/actions/runner/github-hosted"), + pkgVersion: pointerTo("1.0.4"), + builderID: pointerTo("https://github.com/actions/runner/github-hosted"), err: serrors.ErrorMismatchPackageVersion, }, { name: "valid npm CLI builder mismatch package name", artifact: "provenance-npm-test-cli-v02-prega.tgz", source: "github.com/laurentsimon/provenance-npm-test", - pkgName: PointerTo("@laurentsimon/provenance-npm-test2"), - builderID: PointerTo("https://github.com/actions/runner/github-hosted"), + pkgName: pointerTo("@laurentsimon/provenance-npm-test2"), + builderID: pointerTo("https://github.com/actions/runner/github-hosted"), err: serrors.ErrorMismatchPackageName, }, { name: "invalid signature provenance npm CLI", artifact: "provenance-npm-test-cli-v02-prega-invalidsigprov.tgz", source: "github.com/laurentsimon/provenance-npm-test", - pkgName: PointerTo("@laurentsimon/provenance-npm-test"), - builderID: PointerTo("https://github.com/actions/runner/github-hosted"), + pkgName: pointerTo("@laurentsimon/provenance-npm-test"), + builderID: pointerTo("https://github.com/actions/runner/github-hosted"), err: serrors.ErrorInvalidSignature, }, { name: "invalid signature publish npm CLI", artifact: "provenance-npm-test-cli-v02-prega-invalidsigpub.tgz", source: "github.com/laurentsimon/provenance-npm-test", - pkgName: PointerTo("@laurentsimon/provenance-npm-test"), - builderID: PointerTo("https://github.com/actions/runner/github-hosted"), + pkgName: pointerTo("@laurentsimon/provenance-npm-test"), + builderID: pointerTo("https://github.com/actions/runner/github-hosted"), err: serrors.ErrorInvalidSignature, }, // OSSF builder. @@ -1691,84 +1691,84 @@ func Test_runVerifyNpmPackage(t *testing.T) { name: "valid npm OSSF builder", artifact: "provenance-npm-test-ossf.tgz", source: "github.com/laurentsimon/provenance-npm-test", - pkgVersion: PointerTo("1.0.5"), - pkgName: PointerTo("@laurentsimon/provenance-npm-test"), - builderID: PointerTo("https://github.com/slsa-framework/slsa-github-generator/.github/workflows/builder_nodejs_slsa3.yml"), + pkgVersion: pointerTo("1.0.5"), + pkgName: pointerTo("@laurentsimon/provenance-npm-test"), + builderID: pointerTo("https://github.com/slsa-framework/slsa-github-generator/.github/workflows/builder_nodejs_slsa3.yml"), }, { name: "valid npm OSSF builder no builder", artifact: "provenance-npm-test-ossf.tgz", source: "github.com/laurentsimon/provenance-npm-test", - pkgVersion: PointerTo("1.0.5"), - pkgName: PointerTo("@laurentsimon/provenance-npm-test"), + pkgVersion: pointerTo("1.0.5"), + pkgName: pointerTo("@laurentsimon/provenance-npm-test"), err: serrors.ErrorInvalidBuilderID, }, { name: "valid npm OSSF builder mismatch builder", artifact: "provenance-npm-test-ossf.tgz", source: "github.com/laurentsimon/provenance-npm-test", - pkgVersion: PointerTo("1.0.5"), - pkgName: PointerTo("@laurentsimon/provenance-npm-test"), - builderID: PointerTo("https://github.com/slsa-framework/slsa-github-generator/.github/workflows/builder_nodejs_slsa.yml"), + pkgVersion: pointerTo("1.0.5"), + pkgName: pointerTo("@laurentsimon/provenance-npm-test"), + builderID: pointerTo("https://github.com/slsa-framework/slsa-github-generator/.github/workflows/builder_nodejs_slsa.yml"), err: serrors.ErrorMismatchBuilderID, }, { name: "valid npm OSSF builder no package name", artifact: "provenance-npm-test-ossf.tgz", source: "github.com/laurentsimon/provenance-npm-test", - pkgVersion: PointerTo("1.0.5"), - builderID: PointerTo("https://github.com/slsa-framework/slsa-github-generator/.github/workflows/builder_nodejs_slsa3.yml"), + pkgVersion: pointerTo("1.0.5"), + builderID: pointerTo("https://github.com/slsa-framework/slsa-github-generator/.github/workflows/builder_nodejs_slsa3.yml"), }, { name: "valid npm OSSF builder no package version", artifact: "provenance-npm-test-ossf.tgz", source: "github.com/laurentsimon/provenance-npm-test", - pkgName: PointerTo("@laurentsimon/provenance-npm-test"), - builderID: PointerTo("https://github.com/slsa-framework/slsa-github-generator/.github/workflows/builder_nodejs_slsa3.yml"), + pkgName: pointerTo("@laurentsimon/provenance-npm-test"), + builderID: pointerTo("https://github.com/slsa-framework/slsa-github-generator/.github/workflows/builder_nodejs_slsa3.yml"), }, { name: "valid npm OSSF builder mismatch package name", artifact: "provenance-npm-test-ossf.tgz", source: "github.com/laurentsimon/provenance-npm-test", - pkgVersion: PointerTo("1.0.5"), - pkgName: PointerTo("@laurentsimon/provenance-npm-test2"), - builderID: PointerTo("https://github.com/slsa-framework/slsa-github-generator/.github/workflows/builder_nodejs_slsa3.yml"), + pkgVersion: pointerTo("1.0.5"), + pkgName: pointerTo("@laurentsimon/provenance-npm-test2"), + builderID: pointerTo("https://github.com/slsa-framework/slsa-github-generator/.github/workflows/builder_nodejs_slsa3.yml"), err: serrors.ErrorMismatchPackageName, }, { name: "valid npm OSSF builder mismatch package version", artifact: "provenance-npm-test-ossf.tgz", source: "github.com/laurentsimon/provenance-npm-test", - pkgVersion: PointerTo("1.0.6"), - pkgName: PointerTo("@laurentsimon/provenance-npm-test"), - builderID: PointerTo("https://github.com/slsa-framework/slsa-github-generator/.github/workflows/builder_nodejs_slsa3.yml"), + pkgVersion: pointerTo("1.0.6"), + pkgName: pointerTo("@laurentsimon/provenance-npm-test"), + builderID: pointerTo("https://github.com/slsa-framework/slsa-github-generator/.github/workflows/builder_nodejs_slsa3.yml"), err: serrors.ErrorMismatchPackageVersion, }, { name: "valid npm OSSF builder mismatch mismatch source", artifact: "provenance-npm-test-ossf.tgz", source: "github.com/laurentsimon/provenance-npm-test2", - pkgVersion: PointerTo("1.0.5"), - pkgName: PointerTo("@laurentsimon/provenance-npm-test"), - builderID: PointerTo("https://github.com/slsa-framework/slsa-github-generator/.github/workflows/builder_nodejs_slsa3.yml"), + pkgVersion: pointerTo("1.0.5"), + pkgName: pointerTo("@laurentsimon/provenance-npm-test"), + builderID: pointerTo("https://github.com/slsa-framework/slsa-github-generator/.github/workflows/builder_nodejs_slsa3.yml"), err: serrors.ErrorMismatchSource, }, { name: "invalid signature provenance npm OSSF builder", artifact: "provenance-npm-test-ossf-invalidsigprov.tgz", source: "github.com/laurentsimon/provenance-npm-test", - pkgVersion: PointerTo("1.0.5"), - pkgName: PointerTo("@laurentsimon/provenance-npm-test"), - builderID: PointerTo("https://github.com/slsa-framework/slsa-github-generator/.github/workflows/builder_nodejs_slsa3.yml"), + pkgVersion: pointerTo("1.0.5"), + pkgName: pointerTo("@laurentsimon/provenance-npm-test"), + builderID: pointerTo("https://github.com/slsa-framework/slsa-github-generator/.github/workflows/builder_nodejs_slsa3.yml"), err: serrors.ErrorInvalidSignature, }, { name: "invalid signature publish npm OSSF builder", artifact: "provenance-npm-test-ossf-invalidsigpub.tgz", source: "github.com/laurentsimon/provenance-npm-test", - pkgVersion: PointerTo("1.0.5"), - pkgName: PointerTo("@laurentsimon/provenance-npm-test"), - builderID: PointerTo("https://github.com/slsa-framework/slsa-github-generator/.github/workflows/builder_nodejs_slsa3.yml"), + pkgVersion: pointerTo("1.0.5"), + pkgName: pointerTo("@laurentsimon/provenance-npm-test"), + builderID: pointerTo("https://github.com/slsa-framework/slsa-github-generator/.github/workflows/builder_nodejs_slsa3.yml"), err: serrors.ErrorInvalidSignature, }, } @@ -1795,6 +1795,74 @@ func Test_runVerifyNpmPackage(t *testing.T) { } } -func PointerTo[K any](object K) *K { +// Test_runVerifyVSA tests the CLI inputes of verify-vsa. More extensive tests are in +// slsa-verifier/verifiers/internal/vsa/verifier_test.go +func Test_runVerifyVSA(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + attestationPath *string + subjectDigests *[]string + verifierID *string + resourceURI *string + verifiedLevels *[]string + publicKeyPath *string + publicKeyID *string + err error + }{ + { + name: "success: gke", + attestationPath: pointerTo("gce/v1/gke-gce-pre.bcid-vsa.jsonl"), + subjectDigests: pointerTo([]string{"gce_image_id:8970095005306000053"}), + verifierID: pointerTo("https://bcid.corp.google.com/verifier/bcid_package_enforcer/v0.1"), + resourceURI: pointerTo("gce_image://gke-node-images:gke-12615-gke1418000-cos-101-17162-463-29-c-cgpv1-pre"), + verifiedLevels: pointerTo([]string{"BCID_L1", "SLSA_BUILD_LEVEL_2"}), + publicKeyPath: pointerTo("gce/v1/vsa_signing_public_key.pem"), + publicKeyID: pointerTo("keystore://76574:prod:vsa_signing_public_key"), + }, + { + name: "fail: gke, empty public key id", + attestationPath: pointerTo("gce/v1/gke-gce-pre.bcid-vsa.jsonl"), + publicKeyPath: pointerTo("gce/v1/vsa_signing_public_key.pem"), + publicKeyID: pointerTo(""), + err: serrors.ErrorNoValidSignature, + }, + { + name: "fail: gke, wrong key id", + attestationPath: pointerTo("gce/v1/gke-gce-pre.bcid-vsa.jsonl"), + publicKeyPath: pointerTo("gce/v1/vsa_signing_public_key.pem"), + publicKeyID: pointerTo("my_key_id"), + err: serrors.ErrorNoValidSignature, + }, + } + + 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() + + attestationPath := filepath.Clean(filepath.Join(TEST_DIR, "vsa", *tt.attestationPath)) + publicKeyPath := filepath.Clean(filepath.Join(TEST_DIR, "vsa", *tt.publicKeyPath)) + + cmd := verify.VerifyVSACommand{ + AttestationPath: &attestationPath, + SubjectDigests: tt.subjectDigests, + VerifierID: tt.verifierID, + ResourceURI: tt.resourceURI, + VerifiedLevels: tt.verifiedLevels, + PublicKeyPath: &publicKeyPath, + PublicKeyID: tt.publicKeyID, + } + + err := cmd.Exec(context.Background()) + if diff := cmp.Diff(tt.err, err, cmpopts.EquateErrors()); diff != "" { + t.Fatalf("unexpected error (-want +got): \n%s", diff) + } + }) + } +} + +func pointerTo[K any](object K) *K { return &object } diff --git a/cli/slsa-verifier/testdata/vsa/gce/v1/gke-gce-pre.bcid-vsa.jsonl b/cli/slsa-verifier/testdata/vsa/gce/v1/gke-gce-pre.bcid-vsa.jsonl new file mode 100644 index 000000000..ba750c6ca --- /dev/null +++ b/cli/slsa-verifier/testdata/vsa/gce/v1/gke-gce-pre.bcid-vsa.jsonl @@ -0,0 +1 @@ +{"payload":"eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEiLCJwcmVkaWNhdGVUeXBlIjoiaHR0cHM6Ly9zbHNhLmRldi92ZXJpZmljYXRpb25fc3VtbWFyeS92MSIsInByZWRpY2F0ZSI6eyJ0aW1lVmVyaWZpZWQiOiIyMDI0LTA2LTEyVDA3OjI0OjM0LjM1MTYwOFoiLCJ2ZXJpZmllciI6eyJpZCI6Imh0dHBzOi8vYmNpZC5jb3JwLmdvb2dsZS5jb20vdmVyaWZpZXIvYmNpZF9wYWNrYWdlX2VuZm9yY2VyL3YwLjEifSwidmVyaWZpY2F0aW9uUmVzdWx0IjoiUEFTU0VEIiwidmVyaWZpZWRMZXZlbHMiOlsiQkNJRF9MMSIsIlNMU0FfQlVJTERfTEVWRUxfMiJdLCJyZXNvdXJjZVVyaSI6ImdjZV9pbWFnZTovL2drZS1ub2RlLWltYWdlczpna2UtMTI2MTUtZ2tlMTQxODAwMC1jb3MtMTAxLTE3MTYyLTQ2My0yOS1jLWNncHYxLXByZSIsInBvbGljeSI6eyJ1cmkiOiJnb29nbGVmaWxlOi9nb29nbGVfc3JjL2ZpbGVzLzY0MjUxMzE5Mi9kZXBvdC9nb29nbGUzL3Byb2R1Y3Rpb24vc2VjdXJpdHkvYmNpZC9zb2Z0d2FyZS9nY2VfaW1hZ2UvZ2tlL3ZtX2ltYWdlcy5zd19wb2xpY3kudGV4dHByb3RvIn19LCJzdWJqZWN0IjpbeyJuYW1lIjoiXyIsImRpZ2VzdCI6eyJnY2VfaW1hZ2VfaWQiOiI4OTcwMDk1MDA1MzA2MDAwMDUzIn19XX0=","payloadType":"application/vnd.in-toto+json","signatures":[{"sig":"bmIy2gfnQt6oYpd0WbpQMtZcMRtmntDmyki+Be+2Z9qkboMVbi2RQAD1b5AWbBs7iAP8NZVJOI4R/4jOVYB/FA==","keyid":"keystore://76574:prod:vsa_signing_public_key"}]} \ No newline at end of file diff --git a/cli/slsa-verifier/testdata/vsa/gce/v1/vsa_signing_public_key.pem b/cli/slsa-verifier/testdata/vsa/gce/v1/vsa_signing_public_key.pem new file mode 100644 index 000000000..27bda3346 --- /dev/null +++ b/cli/slsa-verifier/testdata/vsa/gce/v1/vsa_signing_public_key.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEeGa6ZCZn0q6WpaUwJrSk+PPYEsca +3Xkk3UrxvbQtoZzTmq0zIYq+4QQl0YBedSyy+XcwAMaUWTouTrB05WhYtg== +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/cli/slsa-verifier/verify.go b/cli/slsa-verifier/verify.go index fcaaaf870..84f6a4ee0 100644 --- a/cli/slsa-verifier/verify.go +++ b/cli/slsa-verifier/verify.go @@ -24,7 +24,7 @@ import ( ) const ( - SUCCESS = "PASSED: Verified SLSA provenance" + SUCCESS = "PASSED: SLSA verification passed" FAILURE = "FAILED: SLSA verification failed" ) @@ -184,3 +184,34 @@ func verifyNpmPackageCmd() *cobra.Command { o.AddFlags(cmd) return cmd } + +func verifyVSACmd() *cobra.Command { + o := &verify.VerifyVSAOptions{} + + cmd := &cobra.Command{ + Use: "verify-vsa [flags] subject-digest [subject-digest...]", + Args: cobra.NoArgs, + Short: "Verifies SLSA VSAs for the given subject-digests", + Run: func(cmd *cobra.Command, args []string) { + v := verify.VerifyVSACommand{ + SubjectDigests: &o.SubjectDigests, + AttestationPath: &o.AttestationPath, + VerifierID: &o.VerifierID, + ResourceURI: &o.ResourceURI, + VerifiedLevels: &o.VerifiedLevels, + PrintAttestation: o.PrintAttestation, + PublicKeyPath: &o.PublicKeyPath, + PublicKeyID: &o.PublicKeyID, + } + if err := v.Exec(cmd.Context()); err != nil { + fmt.Fprintf(os.Stderr, "%s: %v\n", FAILURE, err) + os.Exit(1) + } else { + fmt.Fprintf(os.Stderr, "%s\n", SUCCESS) + } + }, + } + + o.AddFlags(cmd) + return cmd +} diff --git a/cli/slsa-verifier/verify/options.go b/cli/slsa-verifier/verify/options.go index e9079ea44..888338ee8 100644 --- a/cli/slsa-verifier/verify/options.go +++ b/cli/slsa-verifier/verify/options.go @@ -127,6 +127,54 @@ func (o *VerifyNpmOptions) AddFlags(cmd *cobra.Command) { cmd.MarkFlagsMutuallyExclusive("source-versioned-tag", "source-tag") } +// VerifyVSAOptions is the top-level options for the `verifyVSA` command. +type VerifyVSAOptions struct { + SubjectDigests []string + AttestationPath string + VerifierID string + ResourceURI string + VerifiedLevels []string + PublicKeyPath string + PublicKeyID string + PrintAttestation bool +} + +var _ Interface = (*VerifyVSAOptions)(nil) + +// AddFlags implements Interface. +func (o *VerifyVSAOptions) AddFlags(cmd *cobra.Command) { + cmd.Flags().StringArrayVar(&o.SubjectDigests, "subject-digest", []string{}, + "the digests to be verified. Pass multiple digests by repeating the flag. e.g. --subject-digest : --subject-digest :") + + cmd.Flags().StringVar(&o.AttestationPath, "attestation-path", "", + "path to a file containing the attestation") + + cmd.Flags().StringVar(&o.VerifierID, "verifier-id", "", + "the unique verifier ID who created the attestation") + + cmd.Flags().StringVar(&o.ResourceURI, "resource-uri", "", + "the resource URI to be verified") + + cmd.Flags().StringArrayVar(&o.VerifiedLevels, "verified-level", []string{}, + "[optional] the levels of verification to be performed. Pass multiple digests by repeating the flag, e.g., --verified-level SLSA_BUILD_LEVEL_2 --verified-level FEDRAMP_LOW'") + + cmd.Flags().BoolVar(&o.PrintAttestation, "print-attestation", false, + "[optional] print the contents of attestation to stdout") + + cmd.Flags().StringVar(&o.PublicKeyPath, "public-key-path", "", + "path to a public key file") + + cmd.Flags().StringVar(&o.PublicKeyID, "public-key-id", "", + "[optional] the ID of the public key, defaults to the SHA256 digest of the base64-encoded public key") + + cmd.MarkFlagRequired("subject-digests") + cmd.MarkFlagRequired("attestation-path") + cmd.MarkFlagRequired("verifier-id") + cmd.MarkFlagRequired("resource-uri") + cmd.MarkFlagRequired("public-key-path") + // public-key-id" and "public-key-signing-hash-algo" are optional since they have useful defaults +} + type workflowInputs struct { kv map[string]string } diff --git a/cli/slsa-verifier/verify/verify_vsa.go b/cli/slsa-verifier/verify/verify_vsa.go new file mode 100644 index 000000000..04c50b06e --- /dev/null +++ b/cli/slsa-verifier/verify/verify_vsa.go @@ -0,0 +1,117 @@ +// Copyright 2022 SLSA Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package verify + +import ( + "context" + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rsa" + "fmt" + "os" + + "github.com/sigstore/sigstore/pkg/cryptoutils" + serrors "github.com/slsa-framework/slsa-verifier/v2/errors" + "github.com/slsa-framework/slsa-verifier/v2/options" + "github.com/slsa-framework/slsa-verifier/v2/verifiers" +) + +// VerifyVSACommand contains the parameters for the verify-vsa command. +type VerifyVSACommand struct { + SubjectDigests *[]string + AttestationPath *string + VerifierID *string + ResourceURI *string + VerifiedLevels *[]string + PrintAttestation bool + PublicKeyPath *string + PublicKeyID *string +} + +// Exec executes the verifiers.VerifyVSA. +func (c *VerifyVSACommand) Exec(ctx context.Context) error { + vsaOpts := &options.VSAOpts{ + ExpectedDigests: c.SubjectDigests, + ExpectedVerifierID: c.VerifierID, + ExpectedResourceURI: c.ResourceURI, + ExpectedVerifiedLevels: c.VerifiedLevels, + } + pubKeyBytes, err := os.ReadFile(*c.PublicKeyPath) + if err != nil { + printFailed(err) + return err + } + pubKey, err := cryptoutils.UnmarshalPEMToPublicKey(pubKeyBytes) + if err != nil { + err = fmt.Errorf("%w: %w", serrors.ErrorInvalidPublicKey, err) + printFailed(err) + return err + } + hashAlgo := determineSignatureHashAlgo(pubKey) + VerificationOpts := &options.VerificationOpts{ + PublicKey: pubKey, + PublicKeyID: c.PublicKeyID, + PublicKeyHashAlgo: hashAlgo, + } + attestation, err := os.ReadFile(*c.AttestationPath) + if err != nil { + printFailed(err) + return err + } + vsaBytes, err := verifiers.VerifyVSA(ctx, attestation, vsaOpts, VerificationOpts) + if err != nil { + printFailed(err) + return err + } + if c.PrintAttestation { + fmt.Fprintf(os.Stdout, "%s\n", string(vsaBytes)) + } + fmt.Fprintf(os.Stderr, "Verifying VSA: PASSED\n\n") + // verfiers.VerifyVSA already checks if the producerID matches + return nil +} + +// printFailed prints the error message to stderr. +func printFailed(err error) { + fmt.Fprintf(os.Stderr, "Verifying VSA: FAILED: %v\n\n", err) +} + +// determineSignatureHashAlgo determines the hash algorithm used to compute the digest to be signed, based on the public key. +// some well-known defaults can be determined, otherwise the it returns crypto.SHA256. +func determineSignatureHashAlgo(pubKey crypto.PublicKey) crypto.Hash { + var h crypto.Hash + switch pk := pubKey.(type) { + case *rsa.PublicKey: + h = crypto.SHA256 + case *ecdsa.PublicKey: + switch pk.Curve { + case elliptic.P256(): + h = crypto.SHA256 + case elliptic.P384(): + h = crypto.SHA384 + case elliptic.P521(): + h = crypto.SHA512 + default: + h = crypto.SHA256 + } + case ed25519.PublicKey: + h = crypto.SHA512 + default: + h = crypto.SHA256 + } + return h +} diff --git a/errors/errors.go b/errors/errors.go index 9701262d7..91ff45711 100644 --- a/errors/errors.go +++ b/errors/errors.go @@ -44,4 +44,11 @@ var ( ErrorInvalidHash = errors.New("invalid hash") ErrorNotPresent = errors.New("not present") ErrorInvalidPublicKey = errors.New("invalid public key") + ErrorInvalidVerificationResult = errors.New("verificationResult is not PASSED") + ErrorMismatchVerifiedLevels = errors.New("verified levels do not match") + ErrorMissingSubjectDigest = errors.New("missing subject digest") + ErrorEmptyRequiredField = errors.New("empty value in required field") + ErrorMismatchResourceURI = errors.New("resource URI does not match") + ErrorMismatchVerifierID = errors.New("verifier ID does not match") + ErrorInvalidSLSALevel = errors.New("invalid SLSA level") ) diff --git a/go.mod b/go.mod index 3fb36cf59..160b6d874 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( require ( github.com/google/go-containerregistry v0.19.1 github.com/gorilla/mux v1.8.1 + github.com/in-toto/attestation v1.1.0 github.com/sigstore/cosign/v2 v2.2.4 github.com/sigstore/sigstore-go v0.2.0 github.com/slsa-framework/slsa-github-generator v1.9.0 @@ -115,7 +116,7 @@ require ( golang.org/x/term v0.19.0 // indirect golang.org/x/text v0.14.0 // indirect google.golang.org/grpc v1.62.1 // indirect - google.golang.org/protobuf v1.33.0 + google.golang.org/protobuf v1.34.1 gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.120.1 // indirect diff --git a/go.sum b/go.sum index f77df98d4..033e2a6db 100644 --- a/go.sum +++ b/go.sum @@ -322,6 +322,8 @@ github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpO github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/in-toto/attestation v1.1.0 h1:oRWzfmZPDSctChD0VaQV7MJrywKOzyNrtpENQFq//2Q= +github.com/in-toto/attestation v1.1.0/go.mod h1:DB59ytd3z7cIHgXxwpSX2SABrU6WJUKg/grpdgHVgVs= github.com/in-toto/in-toto-golang v0.9.0 h1:tHny7ac4KgtsfrG6ybU8gVOZux2H8jN05AXJ9EBM1XU= github.com/in-toto/in-toto-golang v0.9.0/go.mod h1:xsBVrVsHNsB61++S6Dy2vWosKhuA3lUTQd+eF9HdeMo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -640,8 +642,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/options/options.go b/options/options.go index c3b68442f..aadcb123b 100644 --- a/options/options.go +++ b/options/options.go @@ -1,5 +1,7 @@ package options +import "crypto" + // ProvenanceOpts are the options for checking provenance information. type ProvenanceOpts struct { // ExpectedBranch is the expected branch (github_ref or github_base_ref) in @@ -28,12 +30,38 @@ type ProvenanceOpts struct { ExpectedPackageVersion *string - // ExpectedProvenanceRepository is the provenance repository that is passed from user and not verified + // ExpectedProvenanceRepository is the provenance repository that is passed from user. ExpectedProvenanceRepository *string } // BuildOpts are the options for checking the builder. type BuilderOpts struct { - // ExpectedBuilderID is the builderID passed in from the user to be verified + // ExpectedBuilderID is the builderID passed in from the user. ExpectedID *string } + +// VSAOpts are the options for checking the VSA. +type VSAOpts struct { + // ExpectedDigests are the digests expected to be in the VSA. + ExpectedDigests *[]string + + // ExpectedVerifierID is the verifier ID that is passed from user. + ExpectedVerifierID *string + + // ExpectedResourceURI is the resource URI that is passed from user. + ExpectedResourceURI *string + + // ExpectedVerifiedLevels is the levels of verification that are passed from user. + ExpectedVerifiedLevels *[]string +} + +type VerificationOpts struct { + // PublicKey is the public key used to verify the signature on the Envelope. + PublicKey crypto.PublicKey + + // PublicKeyID is the ID of the public key. + PublicKeyID *string + + // PublicKeyHashAlgo is the hash algorithm used to compute digest that was signed. + PublicKeyHashAlgo crypto.Hash +} diff --git a/verifiers/internal/vsa/v1.0/vsa.go b/verifiers/internal/vsa/v1.0/vsa.go new file mode 100644 index 000000000..bbc4bd28d --- /dev/null +++ b/verifiers/internal/vsa/v1.0/vsa.go @@ -0,0 +1,58 @@ +package vsa10 + +import ( + "encoding/json" + "fmt" + "time" + + intotoGolang "github.com/in-toto/in-toto-golang/in_toto" + intotoCommon "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/common" + serrors "github.com/slsa-framework/slsa-verifier/v2/errors" +) + +const PredicateType = "https://slsa.dev/verification_summary/v1" + +// VSA is a struct that represents a VSA statement. +// spec: https://slsa.dev/spec/v1.0/verification_summary. +// Idealy, we use "github.com/in-toto/attestation/go/predicates/vsa/v1"'s VerfificationSummary, +// but it currently does not correctly implement some fields according to spec, such as VerifiedLevels. +type VSA struct { + intotoGolang.StatementHeader + // Predicate is the VSA predicate. + Predicate Predicate `json:"predicate"` +} + +// Predicate is the VSA predicate. +type Predicate struct { + Verifier Verifier `json:"verifier"` + TimeVerified time.Time `json:"timeVerified"` + ResourceURI string `json:"resourceUri"` + Policy intotoCommon.ProvenanceMaterial `json:"policy"` + InputAttestations []intotoCommon.ProvenanceMaterial `json:"inputAttestations"` + VerificationResult string `json:"verificationResult"` + VerifiedLevels []string `json:"verifiedLevels"` + DependecyLevels map[string]int `json:"dependencyLevels"` + SlsaVersion string `json:"slsaVersion"` +} + +// Verifier is the VSA verifier. +type Verifier struct { + ID string `json:"id"` + Version map[string]string `json:"version"` +} + +// VSAFromStatement creates a VSA from a statement. +func VSAFromStatement(statement *intotoGolang.Statement) (*VSA, error) { + if statement.PredicateType != PredicateType { + return nil, fmt.Errorf("%w: expected predicate type %q, got %q", serrors.ErrorInvalidDssePayload, PredicateType, statement.PredicateType) + } + vsaBytes, err := json.Marshal(statement) + if err != nil { + return nil, fmt.Errorf("%w: %s", serrors.ErrorInvalidDssePayload, err) + } + var vsa VSA + if err := json.Unmarshal(vsaBytes, &vsa); err != nil { + return nil, fmt.Errorf("%w: %s", serrors.ErrorInvalidDssePayload, err) + } + return &vsa, nil +} diff --git a/verifiers/internal/vsa/verifier.go b/verifiers/internal/vsa/verifier.go new file mode 100644 index 000000000..a20cfccde --- /dev/null +++ b/verifiers/internal/vsa/verifier.go @@ -0,0 +1,276 @@ +package vsa + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/secure-systems-lab/go-securesystemslib/dsse" + sigstoreSignature "github.com/sigstore/sigstore/pkg/signature" + sigstoreDSSE "github.com/sigstore/sigstore/pkg/signature/dsse" + serrors "github.com/slsa-framework/slsa-verifier/v2/errors" + "github.com/slsa-framework/slsa-verifier/v2/options" + vsa10 "github.com/slsa-framework/slsa-verifier/v2/verifiers/internal/vsa/v1.0" + "github.com/slsa-framework/slsa-verifier/v2/verifiers/utils" +) + +// VerifyVSA verifies the VSA attestation. It returns the attestation base64-decoded from the envelope, and the trusted attester ID. +// We don't return a TrustedBuilderID. Instead, the user can user can parse the builderID separately, perhaps with +// https://pkg.go.dev/golang.org/x/mod/semver. +func VerifyVSA(ctx context.Context, + attestation []byte, + vsaOpts *options.VSAOpts, + verificationOpts *options.VerificationOpts, +) ([]byte, error) { + // following steps in https://slsa.dev/spec/v1.1/verification_summary#how-to-verify + envelope, err := utils.EnvelopeFromBytes(attestation) + if err != nil { + return nil, err + } + + // 1. verify the envelope signature, + // 4. match the verfier with the public key: implicit because we accept a user-provided public key. + // 3. parse the VSA, verifying the predicateType. + vsa, err := extractSignedVSA(ctx, envelope, verificationOpts) + if err != nil { + return nil, err + } + + // 2. match the subject digests, + // 4. match the verifier ID, + // 5. match the expected resourceURI, + // 6. confirm the slsaResult is PASSED, + // 7. match the verifiedLevels, + // no other fields are checked. + err = matchExpectedValues(vsa, vsaOpts) + if err != nil { + return nil, err + } + vsaBytes, err := envelope.DecodeB64Payload() + if err != nil { + return nil, fmt.Errorf("%w: %w", serrors.ErrorInvalidDssePayload, err) + } + return vsaBytes, nil +} + +// extractSignedVSA verifies the envelope signature and type and extracts the VSA from the envelope. +func extractSignedVSA(ctx context.Context, envelope *dsse.Envelope, verificationOpts *options.VerificationOpts) (*vsa10.VSA, error) { + // 1. verify the envelope signature, + // 4. match the verfier with the public key: implicit because we accept a user-provided public key. + err := verifyEnvelopeSignature(ctx, envelope, verificationOpts) + if err != nil { + return nil, err + } + statement, err := utils.StatementFromEnvelope(envelope) + if err != nil { + return nil, err + } + // 3. parse the VSA, verifying the predicateType. + vsa, err := vsa10.VSAFromStatement(statement) + if err != nil { + return nil, err + } + return vsa, nil +} + +// verifyEnvelopeSignature verifies the signature of the envelope. +func verifyEnvelopeSignature(ctx context.Context, envelope *dsse.Envelope, verificationOpts *options.VerificationOpts) error { + signatureVerifier, err := sigstoreSignature.LoadVerifier(verificationOpts.PublicKey, verificationOpts.PublicKeyHashAlgo) + if err != nil { + return fmt.Errorf("%w: loading sigstore DSSE envolope verifier: %w", serrors.ErrorInvalidPublicKey, err) + } + envelopeVerifier, err := dsse.NewEnvelopeVerifier(&sigstoreDSSE.VerifierAdapter{ + SignatureVerifier: signatureVerifier, + Pub: verificationOpts.PublicKey, + PubKeyID: *verificationOpts.PublicKeyID, + }) + if err != nil { + return fmt.Errorf("%w: creating sigstore DSSE envelope verifier: %w", serrors.ErrorInvalidPublicKey, err) + } + _, err = envelopeVerifier.Verify(ctx, envelope) + if err != nil { + return fmt.Errorf("%w: verifying envelope: %w", serrors.ErrorNoValidSignature, err) + } + return nil +} + +// matchExpectedValues checks if the expected values are present in the VSA. +func matchExpectedValues(vsa *vsa10.VSA, vsaOpts *options.VSAOpts) error { + // 2. match the expected subject digests + if err := matchExepectedSubjectDigests(vsa, vsaOpts); err != nil { + return err + } + // 4. match the verifier ID + if err := matchVerifierID(vsa, vsaOpts); err != nil { + return err + } + // 5. match the expected resourceURI + if err := matchResourceURI(vsa, vsaOpts); err != nil { + return err + } + // 6. confirm the verificationResult is Passed + if err := confirmVerificationResult(vsa); err != nil { + return err + } + // 7. match the verifiedLevels + if err := matchVerifiedLevels(vsa, vsaOpts); err != nil { + return err + } + return nil +} + +// matchExepectedSubjectDigests checks if the expected subject digests are present in the VSA. +func matchExepectedSubjectDigests(vsa *vsa10.VSA, vsaOpts *options.VSAOpts) error { + if len(*vsaOpts.ExpectedDigests) == 0 { + return fmt.Errorf("%w: no subject digests provided", serrors.ErrorEmptyRequiredField) + } + // collect all digests from the VSA, so we can efficiently search, e.g.: + // { + // "sha256": { + // "abc": true, + // "def": true, + // }, + // "gce_image_id": { + // "123": true, + // "456": true, + // } + // } + allVSASubjectDigests := make(map[string]map[string]bool) + for _, subject := range vsa.Subject { + for digestType, digestValue := range subject.Digest { + if _, ok := allVSASubjectDigests[digestType]; !ok { + allVSASubjectDigests[digestType] = make(map[string]bool) + } + allVSASubjectDigests[digestType][digestValue] = true + } + } + if len(allVSASubjectDigests) == 0 { + return fmt.Errorf("%w: no subject digests found in the VSA", serrors.ErrorInvalidDssePayload) + } + // search for the expected digests in the VSA + for _, expectedDigest := range *vsaOpts.ExpectedDigests { + parts := strings.SplitN(expectedDigest, ":", 2) + if len(parts) != 2 { + return fmt.Errorf("%w: expected digest %s is not in the format :", serrors.ErrorInvalidDssePayload, expectedDigest) + } + digestType := parts[0] + digestValue := parts[1] + if _, ok := allVSASubjectDigests[digestType]; !ok { + return fmt.Errorf("%w: expected digest not found: %s", serrors.ErrorMissingSubjectDigest, expectedDigest) + } + if _, ok := allVSASubjectDigests[digestType][digestValue]; !ok { + return fmt.Errorf("%w: expected digest not found: %s", serrors.ErrorMissingSubjectDigest, expectedDigest) + } + } + return nil +} + +// matchVerifierID checks if the verifier ID in the VSA matches the expected value. +func matchVerifierID(vsa *vsa10.VSA, vsaOpts *options.VSAOpts) error { + if vsa.Predicate.Verifier.ID == "" { + return fmt.Errorf("%w: no verifierID found in the VSA", serrors.ErrorEmptyRequiredField) + } + if *vsaOpts.ExpectedVerifierID != vsa.Predicate.Verifier.ID { + return fmt.Errorf("%w: verifier ID mismatch: wanted %s, got %s", serrors.ErrorMismatchVerifierID, *vsaOpts.ExpectedVerifierID, vsa.Predicate.Verifier.ID) + } + return nil +} + +// matchResourceURI checks if the resource URI in the VSA matches the expected value. +func matchResourceURI(vsa *vsa10.VSA, vsaOpts *options.VSAOpts) error { + if vsa.Predicate.ResourceURI == "" { + return fmt.Errorf("%w: no resourceURI provided", serrors.ErrorEmptyRequiredField) + } + if *vsaOpts.ExpectedResourceURI != vsa.Predicate.ResourceURI { + return fmt.Errorf("%w: resource URI mismatch: wanted %s, got %s", serrors.ErrorMismatchResourceURI, *vsaOpts.ExpectedResourceURI, vsa.Predicate.ResourceURI) + } + return nil +} + +// confirmVerificationResult checks that the policy verification result is "PASSED". +func confirmVerificationResult(vsa *vsa10.VSA) error { + if vsa.Predicate.VerificationResult != "PASSED" { + return fmt.Errorf("%w: verification result is not Passed: %s", serrors.ErrorInvalidVerificationResult, vsa.Predicate.VerificationResult) + } + return nil +} + +// matchVerifiedLevels checks if the verified levels in the VSA match the expected values. +func matchVerifiedLevels(vsa *vsa10.VSA, vsaOpts *options.VSAOpts) error { + // check for SLSA track levels + wantedSLSALevels, err := extractSLSALevels(vsaOpts.ExpectedVerifiedLevels) + if err != nil { + return err + } + gotSLSALevels, err := extractSLSALevels(&vsa.Predicate.VerifiedLevels) + if err != nil { + return err + } + for track, expectedMinLSLSALevel := range wantedSLSALevels { + if vsaLevel, exists := gotSLSALevels[track]; !exists { + return fmt.Errorf("%w: expected SLSA level not found: %s", serrors.ErrorMismatchVerifiedLevels, track) + } else if vsaLevel < expectedMinLSLSALevel { + return fmt.Errorf("%w: expected SLSA level %s to be at least %d, got %d", serrors.ErrorMismatchVerifiedLevels, track, expectedMinLSLSALevel, vsaLevel) + } + } + + // check for non-SLSA track levels + nonSLSAVSALevels := make(map[string]bool) + for _, level := range vsa.Predicate.VerifiedLevels { + if isSLSATRACKLevel(level) { + continue + } + nonSLSAVSALevels[level] = true + } + for _, expectedLevel := range *vsaOpts.ExpectedVerifiedLevels { + if isSLSATRACKLevel(expectedLevel) { + continue + } + if _, ok := nonSLSAVSALevels[expectedLevel]; !ok { + return fmt.Errorf("%w: expected verified level not found: %s", serrors.ErrorMismatchVerifiedLevels, expectedLevel) + } + } + return nil +} + +// isSLSATRACKLevel checks if the level is an SLSA track level. +// SLSA track levels are of the form SLSA__LEVEL_, e.g., SLSA_BUILD_LEVEL_2. +func isSLSATRACKLevel(level string) bool { + return strings.HasPrefix(level, "SLSA_") +} + +// extractSLSALevels extracts the SLSA levels from the verified levels. +// It returns a map of track to the highest level found, e.g., +// SLSA_BUILD_LEVEL_2, SLSA_SOURCE_LEVEL_3 -> +// +// { +// "BUILD": 2, +// "SOURCE": 3, +// } +func extractSLSALevels(trackLevels *[]string) (map[string]int, error) { + vsaSLSATrackLadder := make(map[string]int) + for _, trackLevel := range *trackLevels { + if !strings.HasPrefix(trackLevel, "SLSA_") { + continue + } + parts := strings.SplitN(trackLevel, "_", 4) + if len(parts) != 4 { + return nil, fmt.Errorf("%w: invalid SLSA level: %s", serrors.ErrorInvalidSLSALevel, trackLevel) + } + if parts[2] != "LEVEL" { + return nil, fmt.Errorf("%w: invalid SLSA level: %s", serrors.ErrorInvalidSLSALevel, trackLevel) + } + track := parts[1] + level, err := strconv.Atoi(parts[3]) + if err != nil { + return nil, fmt.Errorf("%w: invalid SLSA level: %s", serrors.ErrorInvalidSLSALevel, trackLevel) + } + if currentLevel, exists := vsaSLSATrackLadder[track]; exists { + vsaSLSATrackLadder[track] = max(currentLevel, level) + } else { + vsaSLSATrackLadder[track] = level + } + } + return vsaSLSATrackLadder, nil +} diff --git a/verifiers/internal/vsa/verifier_test.go b/verifiers/internal/vsa/verifier_test.go new file mode 100644 index 000000000..d280ffc10 --- /dev/null +++ b/verifiers/internal/vsa/verifier_test.go @@ -0,0 +1,682 @@ +package vsa + +import ( + "bytes" + "context" + "crypto" + "encoding/base64" + "encoding/json" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + intotoAttestations "github.com/in-toto/attestation/go/v1" + intotoGolang "github.com/in-toto/in-toto-golang/in_toto" + intotoCommon "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/common" + "github.com/secure-systems-lab/go-securesystemslib/dsse" + "github.com/sigstore/sigstore/pkg/cryptoutils" + + serrors "github.com/slsa-framework/slsa-verifier/v2/errors" + "github.com/slsa-framework/slsa-verifier/v2/options" + vsa10 "github.com/slsa-framework/slsa-verifier/v2/verifiers/internal/vsa/v1.0" +) + +func Test_extractSignedVSA(t *testing.T) { + ctx := context.Background() + + t.Parallel() + + goodAttestationString := ` + { + "_type": "https://in-toto.io/Statement/v1", + "predicateType": "https://slsa.dev/verification_summary/v1", + "predicate": { + "timeVerified": "2024-06-12T07:24:34.351608Z", + "verifier": { + "id": "https://bcid.corp.google.com/verifier/bcid_package_enforcer/v0.1" + }, + "verificationResult": "PASSED", + "verifiedLevels": [ + "BCID_L1", + "SLSA_BUILD_LEVEL_2" + ], + "resourceUri": "gce_image://gke-node-images:gke-12615-gke1418000-cos-101-17162-463-29-c-cgpv1-pre", + "policy": { + "uri": "googlefile:/google_src/files/642513192/depot/google3/production/security/bcid/software/gce_image/gke/vm_images.sw_policy.textproto" + } + }, + "subject": [ + { + "name": "_", + "digest": { + "gce_image_id": "8970095005306000053" + } + } + ] + } + ` + goodEnvelope := &dsse.Envelope{ + PayloadType: intotoGolang.PayloadType, + Payload: mustEncodeAttestationString(goodAttestationString), + Signatures: []dsse.Signature{ + { + KeyID: "keystore://76574:prod:vsa_signing_public_key", + Sig: "bmIy2gfnQt6oYpd0WbpQMtZcMRtmntDmyki+Be+2Z9qkboMVbi2RQAD1b5AWbBs7iAP8NZVJOI4R/4jOVYB/FA==", + }, + }, + } + goodVSAOpts := &options.VerificationOpts{ + PublicKey: mustPublicKeyFromBytes([]byte(`-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEeGa6ZCZn0q6WpaUwJrSk+PPYEsca +3Xkk3UrxvbQtoZzTmq0zIYq+4QQl0YBedSyy+XcwAMaUWTouTrB05WhYtg== +-----END PUBLIC KEY-----`)), + PublicKeyID: pointerTo("keystore://76574:prod:vsa_signing_public_key"), + PublicKeyHashAlgo: crypto.SHA256, + } + goodVSA := &vsa10.VSA{ + StatementHeader: intotoGolang.StatementHeader{ + Type: intotoAttestations.StatementTypeUri, + PredicateType: vsa10.PredicateType, + Subject: []intotoGolang.Subject{ + { + Name: "_", + Digest: map[string]string{ + "gce_image_id": "8970095005306000053", + }, + }, + }, + }, + Predicate: vsa10.Predicate{ + TimeVerified: time.Date(2024, 6, 12, 7, 24, 34, 351608000, time.UTC), + Verifier: vsa10.Verifier{ + ID: "https://bcid.corp.google.com/verifier/bcid_package_enforcer/v0.1", + }, + ResourceURI: "gce_image://gke-node-images:gke-12615-gke1418000-cos-101-17162-463-29-c-cgpv1-pre", + Policy: intotoCommon.ProvenanceMaterial{ + URI: "googlefile:/google_src/files/642513192/depot/google3/production/security/bcid/software/gce_image/gke/vm_images.sw_policy.textproto", + }, + VerificationResult: "PASSED", + VerifiedLevels: []string{"BCID_L1", "SLSA_BUILD_LEVEL_2"}, + }, + } + + tests := []struct { + name string + envelope *dsse.Envelope + opts *options.VerificationOpts + expectedVSA *vsa10.VSA + err error + }{ + { + name: "success", + envelope: goodEnvelope, + opts: goodVSAOpts, + expectedVSA: goodVSA, + }, + { + name: "success: sha256 key id in envelope", + envelope: &dsse.Envelope{ + PayloadType: goodEnvelope.PayloadType, + Payload: goodEnvelope.Payload, + Signatures: []dsse.Signature{ + { + KeyID: "SHA256:Zphi7kubaI7RnOrkqPgkRdVhF5a2JOFB4gor/Zajiiw", + Sig: goodEnvelope.Signatures[0].Sig, + }, + }, + }, + opts: &options.VerificationOpts{ + PublicKey: goodVSAOpts.PublicKey, + PublicKeyID: pointerTo(""), + PublicKeyHashAlgo: crypto.SHA256, + }, + expectedVSA: goodVSA, + }, + { + name: "success: no key ids", + envelope: &dsse.Envelope{ + PayloadType: goodEnvelope.PayloadType, + Payload: goodEnvelope.Payload, + Signatures: []dsse.Signature{ + { + KeyID: "", + Sig: goodEnvelope.Signatures[0].Sig, + }, + }, + }, + opts: &options.VerificationOpts{ + PublicKey: goodVSAOpts.PublicKey, + PublicKeyID: pointerTo(""), + PublicKeyHashAlgo: crypto.SHA256, + }, + expectedVSA: goodVSA, + }, + { + name: "success: keyid only in opts", + envelope: &dsse.Envelope{ + PayloadType: goodEnvelope.PayloadType, + Payload: goodEnvelope.Payload, + Signatures: []dsse.Signature{ + { + KeyID: "", + Sig: goodEnvelope.Signatures[0].Sig, + }, + }, + }, + opts: &options.VerificationOpts{ + PublicKey: goodVSAOpts.PublicKey, + PublicKeyID: pointerTo("SHA256:Zphi7kubaI7RnOrkqPgkRdVhF5a2JOFB4gor/Zajiiw"), + PublicKeyHashAlgo: crypto.SHA256, + }, + expectedVSA: goodVSA, + }, + { + name: "failure: empty signatures", + envelope: &dsse.Envelope{ + PayloadType: goodEnvelope.PayloadType, + Payload: goodEnvelope.Payload, + Signatures: []dsse.Signature{}, + }, + opts: goodVSAOpts, + expectedVSA: nil, + err: dsse.ErrNoSignature, + }, + { + name: "failure: mismatch signature", + envelope: &dsse.Envelope{ + PayloadType: goodEnvelope.PayloadType, + Payload: mustEncodeAttestationString("{}"), + Signatures: goodEnvelope.Signatures, + }, + opts: goodVSAOpts, + expectedVSA: nil, + err: serrors.ErrorNoValidSignature, + }, + { + name: "failure: misatch keyID", + envelope: goodEnvelope, + opts: &options.VerificationOpts{ + PublicKey: goodVSAOpts.PublicKey, + PublicKeyID: pointerTo("keystore://76574:prod:another_key_id"), + PublicKeyHashAlgo: crypto.SHA256, + }, + expectedVSA: nil, + err: serrors.ErrorNoValidSignature, + }, + { + name: "failure: missing needed keyID", + envelope: goodEnvelope, + opts: &options.VerificationOpts{ + PublicKey: goodVSAOpts.PublicKey, + PublicKeyID: pointerTo(""), + PublicKeyHashAlgo: crypto.SHA256, + }, + expectedVSA: nil, + err: serrors.ErrorNoValidSignature, + }, + { + name: "failure: incorrect algorithm", + envelope: goodEnvelope, + opts: &options.VerificationOpts{ + PublicKey: goodVSAOpts.PublicKey, + PublicKeyID: pointerTo(""), + PublicKeyHashAlgo: crypto.SHA512, + }, + expectedVSA: nil, + err: serrors.ErrorNoValidSignature, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + vsa, err := extractSignedVSA(ctx, tc.envelope, tc.opts) + + if diff := cmp.Diff(tc.expectedVSA, vsa, cmpopts.EquateComparable()); diff != "" { + t.Errorf("unexpected VSA (-want +got): \n%s", diff) + } + + if diff := cmp.Diff(tc.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("unexpected error (-want +got): \n%s", diff) + } + }) + } +} + +func Test_matchExpectedValues(t *testing.T) { + t.Parallel() + + goodVSA := &vsa10.VSA{ + StatementHeader: intotoGolang.StatementHeader{ + PredicateType: vsa10.PredicateType, + Subject: []intotoGolang.Subject{ + { + Digest: map[string]string{ + "gce_image_id": "8970095005306000053", + "sha256": "abc", + }, + }, + }, + }, + Predicate: vsa10.Predicate{ + Verifier: vsa10.Verifier{ + ID: "https://bcid.corp.google.com/verifier/bcid_package_enforcer/v0.1", + }, + ResourceURI: "gce_image://gke-node-images:gke-12615-gke1418000-cos-101-17162-463-29-c-cgpv1-pre", + VerificationResult: "PASSED", + VerifiedLevels: []string{"BCID_L1", "SLSA_BUILD_LEVEL_2"}, + }, + } + goodVSAOpts := &options.VSAOpts{ + ExpectedDigests: &[]string{"gce_image_id:8970095005306000053", "sha256:abc"}, + ExpectedVerifierID: pointerTo("https://bcid.corp.google.com/verifier/bcid_package_enforcer/v0.1"), + ExpectedResourceURI: pointerTo("gce_image://gke-node-images:gke-12615-gke1418000-cos-101-17162-463-29-c-cgpv1-pre"), + ExpectedVerifiedLevels: &[]string{"BCID_L1", "SLSA_BUILD_LEVEL_2"}, + } + + tests := []struct { + name string + vsa *vsa10.VSA + opts *options.VSAOpts + err error + }{ + // success cases + { + name: "success", + vsa: goodVSA, + opts: goodVSAOpts, + }, + { + name: "success: empty verifiedLevels", + vsa: &vsa10.VSA{ + StatementHeader: goodVSA.StatementHeader, + Predicate: vsa10.Predicate{ + Verifier: goodVSA.Predicate.Verifier, + ResourceURI: goodVSA.Predicate.ResourceURI, + VerificationResult: goodVSA.Predicate.VerificationResult, + VerifiedLevels: []string{}, + }, + }, + opts: &options.VSAOpts{ + ExpectedDigests: goodVSAOpts.ExpectedDigests, + ExpectedResourceURI: goodVSAOpts.ExpectedResourceURI, + ExpectedVerifiedLevels: &[]string{}, + ExpectedVerifierID: goodVSAOpts.ExpectedVerifierID, + }, + }, + { + name: "success: unspecified verifiedLevels", + vsa: goodVSA, + opts: &options.VSAOpts{ + ExpectedDigests: goodVSAOpts.ExpectedDigests, + ExpectedResourceURI: goodVSAOpts.ExpectedResourceURI, + ExpectedVerifiedLevels: &[]string{}, + ExpectedVerifierID: goodVSAOpts.ExpectedVerifierID, + }, + }, + { + name: "success: expected lower SLSA level", + vsa: goodVSA, + opts: &options.VSAOpts{ + ExpectedDigests: goodVSAOpts.ExpectedDigests, + ExpectedResourceURI: goodVSAOpts.ExpectedResourceURI, + ExpectedVerifiedLevels: &[]string{"SLSA_BUILD_LEVEL_1"}, + ExpectedVerifierID: goodVSAOpts.ExpectedVerifierID, + }, + }, + // failure cases + { + name: "expected higher SLSA level", + vsa: goodVSA, + opts: &options.VSAOpts{ + ExpectedDigests: goodVSAOpts.ExpectedDigests, + ExpectedResourceURI: goodVSAOpts.ExpectedResourceURI, + ExpectedVerifiedLevels: &[]string{"SLSA_BUILD_LEVEL_3"}, + ExpectedVerifierID: goodVSAOpts.ExpectedVerifierID, + }, + err: serrors.ErrorMismatchVerifiedLevels, + }, + { + name: "failure empty digests", + vsa: &vsa10.VSA{ + StatementHeader: intotoGolang.StatementHeader{ + PredicateType: vsa10.PredicateType, + Subject: []intotoGolang.Subject{ + { + Digest: map[string]string{}, + }, + }, + }, + Predicate: goodVSA.Predicate, + }, + opts: goodVSAOpts, + err: serrors.ErrorInvalidDssePayload, + }, + { + name: "failure: no supplied digests", + vsa: goodVSA, + opts: &options.VSAOpts{ + ExpectedDigests: &[]string{}, + ExpectedResourceURI: goodVSAOpts.ExpectedResourceURI, + ExpectedVerifiedLevels: goodVSAOpts.ExpectedVerifiedLevels, + ExpectedVerifierID: goodVSAOpts.ExpectedVerifierID, + }, + err: serrors.ErrorEmptyRequiredField, + }, + { + name: "failure: missing digest", + vsa: goodVSA, + opts: &options.VSAOpts{ + ExpectedDigests: &[]string{"zeit:geist"}, + ExpectedResourceURI: goodVSAOpts.ExpectedResourceURI, + ExpectedVerifiedLevels: goodVSAOpts.ExpectedVerifiedLevels, + ExpectedVerifierID: goodVSAOpts.ExpectedVerifierID, + }, + err: serrors.ErrorMissingSubjectDigest, + }, + { + name: "failure: empty verifierID", + vsa: &vsa10.VSA{ + StatementHeader: goodVSA.StatementHeader, + Predicate: vsa10.Predicate{ + Verifier: vsa10.Verifier{}, + ResourceURI: goodVSA.Predicate.ResourceURI, + VerificationResult: goodVSA.Predicate.VerificationResult, + VerifiedLevels: goodVSA.Predicate.VerifiedLevels, + }, + }, + opts: goodVSAOpts, + err: serrors.ErrorEmptyRequiredField, + }, + { + name: "failure: mismatch verifierID", + vsa: goodVSA, + opts: &options.VSAOpts{ + ExpectedDigests: goodVSAOpts.ExpectedDigests, + ExpectedResourceURI: goodVSAOpts.ExpectedResourceURI, + ExpectedVerifiedLevels: goodVSAOpts.ExpectedVerifiedLevels, + ExpectedVerifierID: pointerTo("https://bcid.corp.google.com/verifier/bcid_package_enforcer/v0.2"), + }, + err: serrors.ErrorMismatchVerifierID, + }, + { + name: "failure: empty resourceURI", + vsa: &vsa10.VSA{ + StatementHeader: goodVSA.StatementHeader, + Predicate: vsa10.Predicate{ + Verifier: goodVSA.Predicate.Verifier, + ResourceURI: "", + VerificationResult: goodVSA.Predicate.VerificationResult, + VerifiedLevels: goodVSA.Predicate.VerifiedLevels, + }, + }, + opts: goodVSAOpts, + err: serrors.ErrorEmptyRequiredField, + }, + { + name: "failure: mismatch resourceURI", + vsa: goodVSA, + opts: &options.VSAOpts{ + ExpectedDigests: goodVSAOpts.ExpectedDigests, + ExpectedResourceURI: pointerTo("gce_image://gke-node-images:gke-126GGG"), + ExpectedVerifiedLevels: goodVSAOpts.ExpectedVerifiedLevels, + ExpectedVerifierID: goodVSAOpts.ExpectedVerifierID, + }, + err: serrors.ErrorMismatchResourceURI, + }, + { + name: "failure: empty verificationResult", + vsa: &vsa10.VSA{ + StatementHeader: goodVSA.StatementHeader, + Predicate: vsa10.Predicate{ + Verifier: goodVSA.Predicate.Verifier, + ResourceURI: goodVSA.Predicate.ResourceURI, + VerificationResult: "", + VerifiedLevels: goodVSA.Predicate.VerifiedLevels, + }, + }, + opts: goodVSAOpts, + err: serrors.ErrorInvalidVerificationResult, + }, + { + name: "failure: wrong verificationResult", + vsa: &vsa10.VSA{ + StatementHeader: goodVSA.StatementHeader, + Predicate: vsa10.Predicate{ + Verifier: goodVSA.Predicate.Verifier, + ResourceURI: goodVSA.Predicate.ResourceURI, + VerificationResult: "FAILED", + VerifiedLevels: goodVSA.Predicate.VerifiedLevels, + }, + }, + opts: goodVSAOpts, + err: serrors.ErrorInvalidVerificationResult, + }, + { + name: "failure: missing verifiedLevels", + vsa: goodVSA, + opts: &options.VSAOpts{ + ExpectedDigests: goodVSAOpts.ExpectedDigests, + ExpectedResourceURI: goodVSAOpts.ExpectedResourceURI, + ExpectedVerifiedLevels: &[]string{"SLSA_BUILD_LEVEL_3"}, + ExpectedVerifierID: goodVSAOpts.ExpectedVerifierID, + }, + err: serrors.ErrorMismatchVerifiedLevels, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := matchExpectedValues(tc.vsa, tc.opts) + if diff := cmp.Diff(tc.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("unexpected error (-want +got): \n%s", diff) + } + }) + } +} + +func Test_matchVerifiedLevels(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + vsa *vsa10.VSA + vsaOpts *options.VSAOpts + err error + }{ + // success cases + { + name: "success: equal levels", + vsa: &vsa10.VSA{ + Predicate: vsa10.Predicate{ + VerifiedLevels: []string{"SLSA_BUILD_LEVEL_1", "SLSA_SOURCE_LEVEL_2", "BCID_L1"}, + }, + }, + vsaOpts: &options.VSAOpts{ + ExpectedVerifiedLevels: &[]string{"SLSA_BUILD_LEVEL_1", "SLSA_SOURCE_LEVEL_2", "BCID_L1"}, + }, + }, + { + name: "success: expected lower SLSA level", + vsa: &vsa10.VSA{ + Predicate: vsa10.Predicate{ + VerifiedLevels: []string{"SLSA_BUILD_LEVEL_1", "SLSA_SOURCE_LEVEL_2", "BCID_L1"}, + }, + }, + vsaOpts: &options.VSAOpts{ + ExpectedVerifiedLevels: &[]string{"SLSA_BUILD_LEVEL_0", "SLSA_SOURCE_LEVEL_2", "BCID_L1"}, + }, + }, + { + name: "success: unspecified verifiedLevels", + vsa: &vsa10.VSA{ + Predicate: vsa10.Predicate{ + VerifiedLevels: []string{"SLSA_BUILD_LEVEL_1", "SLSA_SOURCE_LEVEL_2", "BCID_L1"}, + }, + }, + vsaOpts: &options.VSAOpts{ + ExpectedVerifiedLevels: &[]string{}, + }, + }, + { + name: "success: no SLSA levels", + vsa: &vsa10.VSA{ + Predicate: vsa10.Predicate{ + VerifiedLevels: []string{"BCID_L1"}, + }, + }, + vsaOpts: &options.VSAOpts{ + ExpectedVerifiedLevels: &[]string{}, + }, + }, + // failure cases + { + name: "failure: expected higher SLSA level", + vsa: &vsa10.VSA{ + Predicate: vsa10.Predicate{ + VerifiedLevels: []string{"SLSA_BUILD_LEVEL_1", "SLSA_SOURCE_LEVEL_2", "BCID_L1"}, + }, + }, + vsaOpts: &options.VSAOpts{ + ExpectedVerifiedLevels: &[]string{"SLSA_BUILD_LEVEL_2", "SLSA_SOURCE_LEVEL_2", "BCID_L1"}, + }, + err: serrors.ErrorMismatchVerifiedLevels, + }, + { + name: "failure: missing a expected SLSA track", + vsa: &vsa10.VSA{ + Predicate: vsa10.Predicate{ + VerifiedLevels: []string{"SLSA_BUILD_LEVEL_2", "BCID_L1"}, + }, + }, + vsaOpts: &options.VSAOpts{ + ExpectedVerifiedLevels: &[]string{"SLSA_BUILD_LEVEL_2", "SLSA_SOURCE_LEVEL_2", "BCID_L1"}, + }, + err: serrors.ErrorMismatchVerifiedLevels, + }, + { + name: "failure: missing a expected non-SLSA track", + vsa: &vsa10.VSA{ + Predicate: vsa10.Predicate{ + VerifiedLevels: []string{"SLSA_BUILD_LEVEL_2"}, + }, + }, + vsaOpts: &options.VSAOpts{ + ExpectedVerifiedLevels: &[]string{"SLSA_BUILD_LEVEL_2", "BCID_L1"}, + }, + err: serrors.ErrorMismatchVerifiedLevels, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := matchVerifiedLevels(tc.vsa, tc.vsaOpts) + + if diff := cmp.Diff(tc.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("unexpected error (-want +got): \n%s", diff) + } + }) + } +} + +func Test_extractSLSALevels(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + levels *[]string + want map[string]int + err error + }{ + { + name: "success", + levels: &[]string{ + "SLSA_BUILD_LEVEL_1", + "SLSA_SOURCE_LEVEL_2", + }, + want: map[string]int{ + "BUILD": 1, + "SOURCE": 2, + }, + }, + { + name: "success: empty", + levels: &[]string{}, + want: map[string]int{}, + }, + { + name: "failure: invalid level number", + levels: &[]string{ + "SLSA_BUILD_LEVEL_X", + }, + err: serrors.ErrorInvalidSLSALevel, + }, + { + name: "failure: invalid level text", + levels: &[]string{ + "SLSA_BUILD_L_1", + }, + err: serrors.ErrorInvalidSLSALevel, + }, + { + name: "failure: no level number", + levels: &[]string{ + "SLSA_BUILD_LEVEL_", + }, + err: serrors.ErrorInvalidSLSALevel, + }, + { + name: "failure: no last underscore", + levels: &[]string{ + "SLSA_BUILD_LEVEL", + }, + err: serrors.ErrorInvalidSLSALevel, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got, err := extractSLSALevels(tc.levels) + + if diff := cmp.Diff(tc.want, got, cmpopts.EquateComparable()); diff != "" { + t.Errorf("unexpected VSA (-want +got): \n%s", diff) + } + + if diff := cmp.Diff(tc.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("unexpected error (-want +got): \n%s", diff) + } + }) + } +} + +func mustEncodeAttestationString(attestationString string) string { + dst := &bytes.Buffer{} + if err := json.Compact(dst, []byte(attestationString)); err != nil { + panic(err) + } + return base64.StdEncoding.EncodeToString(dst.Bytes()) +} + +func mustPublicKeyFromBytes(pubKeyBytes []byte) crypto.PublicKey { + pubKey, err := cryptoutils.UnmarshalPEMToPublicKey(pubKeyBytes) + if err != nil { + panic(err) + } + return pubKey +} + +func pointerTo[K any](object K) *K { + return &object +} diff --git a/verifiers/utils/dsse.go b/verifiers/utils/dsse.go index 91512c55c..8ff226a6b 100644 --- a/verifiers/utils/dsse.go +++ b/verifiers/utils/dsse.go @@ -12,6 +12,7 @@ import ( "fmt" "math/big" + intotoAttestations "github.com/in-toto/attestation/go/v1" intoto "github.com/in-toto/in-toto-golang/in_toto" dsselib "github.com/secure-systems-lab/go-securesystemslib/dsse" serrors "github.com/slsa-framework/slsa-verifier/v2/errors" @@ -42,13 +43,17 @@ func PayloadFromEnvelope(env *dsselib.Envelope) ([]byte, error) { return payload, nil } +// StatementFromBytes parses the provided byte slice as a JSON payload and returns an intoto.Statement. +// Ideally, we use the "V1" Statement in https://pkg.go.dev/github.com/in-toto/attestation/go/v1#pkg-constants, +// but it parses json fields in snake case, while the official spec uses camel case +// https://github.com/in-toto/attestation/blob/v1.0/spec/v1.0/statement.md. func StatementFromBytes(payload []byte) (*intoto.Statement, error) { var statement intoto.Statement if err := json.Unmarshal(payload, &statement); err != nil { return nil, fmt.Errorf("%w: %w", serrors.ErrorInvalidDssePayload, err) } - if statement.Type != intoto.StatementInTotoV01 { + if statement.Type != intoto.StatementInTotoV01 && statement.Type != intotoAttestations.StatementTypeUri { return nil, fmt.Errorf("%w: invalid statement type: %q", serrors.ErrorInvalidDssePayload, statement.Type) } return &statement, nil diff --git a/verifiers/verifier.go b/verifiers/verifier.go index 745523d09..c978d20a8 100644 --- a/verifiers/verifier.go +++ b/verifiers/verifier.go @@ -9,6 +9,7 @@ import ( "github.com/slsa-framework/slsa-verifier/v2/register" _ "github.com/slsa-framework/slsa-verifier/v2/verifiers/internal/gcb" "github.com/slsa-framework/slsa-verifier/v2/verifiers/internal/gha" + "github.com/slsa-framework/slsa-verifier/v2/verifiers/internal/vsa" "github.com/slsa-framework/slsa-verifier/v2/verifiers/utils" ) @@ -74,3 +75,14 @@ func VerifyNpmPackage(ctx context.Context, return verifier.VerifyNpmPackage(ctx, attestations, tarballHash, provenanceOpts, builderOpts) } + +// VerifyVSA verifies the VSA attestation. It returns the attestation base64-decoded from the envelope. +// We don't return a TrustedBuilderID. Instead, the user can user can parse the builderID separately, perhaps with +// https://pkg.go.dev/golang.org/x/mod/semver +func VerifyVSA(ctx context.Context, + attestation []byte, + vsaOpts *options.VSAOpts, + verificationOpts *options.VerificationOpts, +) ([]byte, error) { + return vsa.VerifyVSA(ctx, attestation, vsaOpts, verificationOpts) +}