From 5c4cda3fe4c0bf110b845baa16aea895b4f4a519 Mon Sep 17 00:00:00 2001 From: Facundo Tuesca Date: Wed, 18 Sep 2024 18:51:26 +0200 Subject: [PATCH] Support verifying digests in addition to artifacts Signed-off-by: Facundo Tuesca --- .github/workflows/conformance.yml | 2 +- docs/cli_protocol.md | 5 +- sigstore-python-conformance | 4 + test/client.py | 40 ++++++++-- test/conftest.py | 33 ++++++++- test/test_bundle.py | 118 ++++++++++++++++++++---------- test/test_simple.py | 2 +- 7 files changed, 155 insertions(+), 49 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 59e1c87..5c1e2e5 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -25,7 +25,7 @@ jobs: cache: "pip" - name: install sigstore-python - run: pip install "sigstore ~= 3.0" + run: pip install "sigstore >= 3.3.0, < 4.0" - name: conformance test sigstore-python uses: ./ diff --git a/docs/cli_protocol.md b/docs/cli_protocol.md index 947ac5e..7922e57 100644 --- a/docs/cli_protocol.md +++ b/docs/cli_protocol.md @@ -77,7 +77,7 @@ ${ENTRYPOINT} verify [--staging] --signature FILE --certificate FILE --certifica #### Bundle flow ```console -${ENTRYPOINT} verify-bundle [--staging] --bundle FILE --certificate-identity IDENTITY --certificate-oidc-issuer URL [--trusted-root FILE] FILE +${ENTRYPOINT} verify-bundle [--staging] --bundle FILE --certificate-identity IDENTITY --certificate-oidc-issuer URL [--trusted-root FILE] [--verify-digest] FILE_OR_DIGEST ``` | Option | Description | @@ -87,4 +87,5 @@ ${ENTRYPOINT} verify-bundle [--staging] --bundle FILE --certificate-identity IDE | `--certificate-identity IDENTITY` | The expected identity in the signing certificate's SAN extension | | `--certificate-oidc-issuer URL` | The expected OIDC issuer for the signing certificate | | `--trusted-root` | The path of the custom trusted root to use to verify the bundle | -| `FILE` | The path to the artifact to verify | +| `--verify-digest` | Presence indicates client should interpret `FILE_OR_DIGEST` as a digest. | +| `FILE_OR_DIGEST` | The path to the artifact to verify, or its digest. The digest should start with the `sha256:` prefix. | diff --git a/sigstore-python-conformance b/sigstore-python-conformance index eab5557..574c51d 100755 --- a/sigstore-python-conformance +++ b/sigstore-python-conformance @@ -15,6 +15,8 @@ SUBCMD_REPLACEMENTS = { ARG_REPLACEMENTS = { "--certificate-identity": "--cert-identity", "--certificate-oidc-issuer": "--cert-oidc-issuer", + # sigstore-python detects if the input is a file path or a digest without needing a flag + "--verify-digest": None, } # Trim the script name. @@ -43,5 +45,7 @@ else: # Replace incompatible flags. command.extend(ARG_REPLACEMENTS[arg] if arg in ARG_REPLACEMENTS else arg for arg in fixed_args) +# Remove unneeded flags +command = [arg for arg in command if arg is not None] os.execvp("sigstore", command) diff --git a/test/client.py b/test/client.py index 78605a3..4accd33 100644 --- a/test/client.py +++ b/test/client.py @@ -225,13 +225,13 @@ def _sign_for_bundle(self, materials: BundleMaterials, artifact: os.PathLike) -> self.run(*args) @singledispatchmethod - def verify(self, materials: VerificationMaterials, artifact: os.PathLike) -> None: + def verify(self, materials: VerificationMaterials, artifact: os.PathLike | str) -> None: """ Verify an artifact with the Sigstore client. Dispatches to `_verify_for_sigcrt` - when given `SignatureCertificateMaterials`, or `_verify_for_bundle` when given - `BundleMaterials`. + when given `SignatureCertificateMaterials`, or + `_verify_{artifact|digest}_for_bundle` when given `BundleMaterials`. - `artifact` is the path to the file to verify. + `artifact` is the path to the file to verify, or its digest. `materials` contains paths to the materials to verify with. """ @@ -272,7 +272,9 @@ def _verify_for_sigcrt( self.run(*args, artifact) @verify.register - def _verify_for_bundle(self, materials: BundleMaterials, artifact: os.PathLike) -> None: + def _verify_artifact_for_bundle( + self, materials: BundleMaterials, artifact: os.PathLike + ) -> None: """ Verify an artifact given a bundle with the Sigstore client. @@ -297,3 +299,31 @@ def _verify_for_bundle(self, materials: BundleMaterials, artifact: os.PathLike) args.extend(["--trusted-root", materials.trusted_root]) self.run(*args, artifact) + + @verify.register + def _verify_digest_for_bundle(self, materials: BundleMaterials, digest: str) -> None: + """ + Verify a digest given a bundle with the Sigstore client. + + This is an overload of `verify` for the bundle flow and should not be called + directly. The digest string is expected to start with the `sha256:` prefix. + """ + args: list[str | os.PathLike] = ["verify-bundle"] + if self.staging: + args.append("--staging") + args.extend( + [ + "--bundle", + materials.bundle, + "--certificate-identity", + CERTIFICATE_IDENTITY, + "--certificate-oidc-issuer", + CERTIFICATE_OIDC_ISSUER, + "--verify-digest", + ] + ) + + if getattr(materials, "trusted_root", None) is not None: + args.extend(["--trusted-root", materials.trusted_root]) + + self.run(*args, digest) diff --git a/test/conftest.py b/test/conftest.py index ca8a544..61def78 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -1,4 +1,6 @@ +import enum import functools +import hashlib import json import os import shutil @@ -24,6 +26,7 @@ _M = TypeVar("_M", bound=VerificationMaterials) _MakeMaterialsByType = Callable[[str, _M], tuple[Path, _M]] _MakeMaterials = Callable[[str], tuple[Path, VerificationMaterials]] +_VerifyBundle = Callable[[VerificationMaterials, Path], None] _OIDC_BEACON_API_URL = ( "https://api.github.com/repos/sigstore-conformance/extremely-dangerous-public-oidc-beacon/" @@ -170,7 +173,7 @@ def client(pytestconfig, identity_token): @pytest.fixture -def make_materials_by_type() -> _MakeMaterialsByType: +def make_materials_by_type(request) -> _MakeMaterialsByType: """ Returns a function that constructs the requested subclass of `VerificationMaterials` alongside an appropriate input path. @@ -182,7 +185,7 @@ def _make_materials_by_type( input_path = Path(input_name) output = cls.from_input(input_path) - return (input_path, output) + return input_path, output return _make_materials_by_type @@ -204,6 +207,32 @@ def _make_materials(input_name: str): return _make_materials +class ArtifactInputType(enum.Enum): + PATH = enum.auto() + DIGEST = enum.auto() + + +@pytest.fixture(params=[ArtifactInputType.PATH, ArtifactInputType.DIGEST]) +def verify_bundle(request, client) -> _VerifyBundle: + """ + Returns a function that verifies an artifact using the given verification materials + + The fixture is parametrized to run twice, one verifying the artifact itself (passing + the file path to the verification function), and another verifying the artifact's + digest. + """ + + def _verify_bundle(materials: VerificationMaterials, input_path: Path) -> None: + if request.param == ArtifactInputType.PATH: + client.verify(materials, input_path) + else: + with open(input_path, "rb") as f: + digest = f"sha256:{hashlib.sha256(f.read()).hexdigest()}" + client.verify(materials, digest) + + return _verify_bundle + + @pytest.fixture(autouse=True) def workspace(): """ diff --git a/test/test_bundle.py b/test/test_bundle.py index 0535cef..d3c7b88 100644 --- a/test/test_bundle.py +++ b/test/test_bundle.py @@ -5,10 +5,14 @@ from sigstore_protobuf_specs.dev.sigstore.bundle.v1 import Bundle from test.client import BundleMaterials, SigstoreClient -from test.conftest import _MakeMaterialsByType +from test.conftest import _MakeMaterialsByType, _VerifyBundle -def test_verify(client: SigstoreClient, make_materials_by_type: _MakeMaterialsByType) -> None: +def test_verify( + client: SigstoreClient, + make_materials_by_type: _MakeMaterialsByType, + verify_bundle: _VerifyBundle, +) -> None: """ Test the happy path of verification """ @@ -17,10 +21,14 @@ def test_verify(client: SigstoreClient, make_materials_by_type: _MakeMaterialsBy input_path, materials = make_materials_by_type("a.txt", BundleMaterials) materials.bundle = Path("a.txt.good.sigstore.json") - client.verify(materials, input_path) + verify_bundle(materials, input_path) -def test_verify_v_0_3(client: SigstoreClient, make_materials_by_type: _MakeMaterialsByType) -> None: +def test_verify_v_0_3( + client: SigstoreClient, + make_materials_by_type: _MakeMaterialsByType, + verify_bundle: _VerifyBundle, +) -> None: """ Test the happy path of verification of a v0.3 bundle """ @@ -29,11 +37,13 @@ def test_verify_v_0_3(client: SigstoreClient, make_materials_by_type: _MakeMater input_path, materials = make_materials_by_type("a.txt", BundleMaterials) materials.bundle = Path("a.txt.good.v0.3.sigstore") - client.verify(materials, input_path) + verify_bundle(materials, input_path) def test_verify_dsse_bundle_with_trust_root( - client: SigstoreClient, make_materials_by_type: _MakeMaterialsByType + client: SigstoreClient, + make_materials_by_type: _MakeMaterialsByType, + verify_bundle: _VerifyBundle, ) -> None: """ Test the happy path of verification for DSSE bundle w/ custom trust root @@ -43,11 +53,13 @@ def test_verify_dsse_bundle_with_trust_root( materials.bundle = Path("d.txt.good.sigstore.json") materials.trusted_root = Path("trusted_root.d.json") - client.verify(materials, input_path) + verify_bundle(materials, input_path) def test_verify_rejects_root( - client: SigstoreClient, make_materials_by_type: _MakeMaterialsByType + client: SigstoreClient, + make_materials_by_type: _MakeMaterialsByType, + verify_bundle: _VerifyBundle, ) -> None: """ Check that the client rejects a bundle that contains a root certificate. @@ -57,12 +69,14 @@ def test_verify_rejects_root( input_path, materials = make_materials_by_type("has_root_in_chain.txt", BundleMaterials) with client.raises(): - client.verify(materials, input_path) + verify_bundle(materials, input_path) @pytest.mark.signing def test_sign_does_not_produce_root( - client: SigstoreClient, make_materials_by_type: _MakeMaterialsByType + client: SigstoreClient, + make_materials_by_type: _MakeMaterialsByType, + verify_bundle: _VerifyBundle, ) -> None: """ Check that the client does not produce a bundle that contains a root @@ -94,7 +108,9 @@ def test_sign_does_not_produce_root( def test_verify_rejects_staging_cert( - client: SigstoreClient, make_materials_by_type: _MakeMaterialsByType + client: SigstoreClient, + make_materials_by_type: _MakeMaterialsByType, + verify_bundle: _VerifyBundle, ) -> None: """ Check that the client rejects a bundle that doesn't match trust root. @@ -105,11 +121,13 @@ def test_verify_rejects_staging_cert( materials.bundle = Path("a.txt.staging.sigstore.json") with client.raises(): - client.verify(materials, input_path) + verify_bundle(materials, input_path) def test_verify_rejects_invalid_set( - client: SigstoreClient, make_materials_by_type: _MakeMaterialsByType + client: SigstoreClient, + make_materials_by_type: _MakeMaterialsByType, + verify_bundle: _VerifyBundle, ) -> None: """ Check that the client rejects a bundle with a signed entry timestamp from @@ -121,11 +139,13 @@ def test_verify_rejects_invalid_set( materials.bundle = Path("a.txt.invalid_set.sigstore.json") with client.raises(): - client.verify(materials, input_path) + verify_bundle(materials, input_path) def test_verify_rejects_invalid_signature( - client: SigstoreClient, make_materials_by_type: _MakeMaterialsByType + client: SigstoreClient, + make_materials_by_type: _MakeMaterialsByType, + verify_bundle: _VerifyBundle, ) -> None: """ Check that the client rejects a bundle with a modified signature. @@ -136,11 +156,13 @@ def test_verify_rejects_invalid_signature( materials.bundle = Path("a.txt.invalid_signature.sigstore.json") with client.raises(): - client.verify(materials, input_path) + verify_bundle(materials, input_path) def test_verify_rejects_invalid_key( - client: SigstoreClient, make_materials_by_type: _MakeMaterialsByType + client: SigstoreClient, + make_materials_by_type: _MakeMaterialsByType, + verify_bundle: _VerifyBundle, ) -> None: """ Check that the client rejects a bundle with a modified public key in the @@ -152,11 +174,13 @@ def test_verify_rejects_invalid_key( materials.bundle = Path("a.txt.invalid_key.sigstore.json") with client.raises(): - client.verify(materials, input_path) + verify_bundle(materials, input_path) def test_verify_rejects_invalid_inclusion_proof( - client: SigstoreClient, make_materials_by_type: _MakeMaterialsByType + client: SigstoreClient, + make_materials_by_type: _MakeMaterialsByType, + verify_bundle: _VerifyBundle, ) -> None: """ Check that the client rejects a bundle with an old inclusion proof @@ -167,11 +191,13 @@ def test_verify_rejects_invalid_inclusion_proof( materials.bundle = Path("a.txt.invalid_inclusion_proof.sigstore.json") with client.raises(): - client.verify(materials, input_path) + verify_bundle(materials, input_path) def test_verify_rejects_different_materials( - client: SigstoreClient, make_materials_by_type: _MakeMaterialsByType + client: SigstoreClient, + make_materials_by_type: _MakeMaterialsByType, + verify_bundle: _VerifyBundle, ) -> None: """ Check that the client rejects a bundle for different materials. @@ -182,11 +208,13 @@ def test_verify_rejects_different_materials( materials.bundle = Path("a.txt.good.sigstore.json") with client.raises(): - client.verify(materials, input_path) + verify_bundle(materials, input_path) def test_verify_rejects_expired_certificate( - client: SigstoreClient, make_materials_by_type: _MakeMaterialsByType + client: SigstoreClient, + make_materials_by_type: _MakeMaterialsByType, + verify_bundle: _VerifyBundle, ) -> None: """ Check that the client rejects a bundle if the certificate was issued @@ -198,11 +226,13 @@ def test_verify_rejects_expired_certificate( materials.trusted_root = Path("trusted_root.d.json") with client.raises(): - client.verify(materials, input_path) + verify_bundle(materials, input_path) def test_verify_rejects_missing_inclusion_proof( - client: SigstoreClient, make_materials_by_type: _MakeMaterialsByType + client: SigstoreClient, + make_materials_by_type: _MakeMaterialsByType, + verify_bundle: _VerifyBundle, ) -> None: """ Check that the client rejects a v0.2 bundle if the TLog entry does NOT @@ -214,11 +244,13 @@ def test_verify_rejects_missing_inclusion_proof( materials.trusted_root = Path("trusted_root.d.json") with client.raises(): - client.verify(materials, input_path) + verify_bundle(materials, input_path) def test_verify_rejects_bad_tlog_timestamp( - client: SigstoreClient, make_materials_by_type: _MakeMaterialsByType + client: SigstoreClient, + make_materials_by_type: _MakeMaterialsByType, + verify_bundle: _VerifyBundle, ) -> None: """ Check that the client rejects a bundle if the TLog entry contains a @@ -231,11 +263,13 @@ def test_verify_rejects_bad_tlog_timestamp( materials.trusted_root = Path("trusted_root.d.json") with client.raises(): - client.verify(materials, input_path) + verify_bundle(materials, input_path) def test_verify_rejects_bad_tlog_entry( - client: SigstoreClient, make_materials_by_type: _MakeMaterialsByType + client: SigstoreClient, + make_materials_by_type: _MakeMaterialsByType, + verify_bundle: _VerifyBundle, ) -> None: """ Check that the client rejects a bundle if the body of the TLog entry does @@ -247,11 +281,13 @@ def test_verify_rejects_bad_tlog_entry( materials.trusted_root = Path("trusted_root.d.json") with client.raises(): - client.verify(materials, input_path) + verify_bundle(materials, input_path) def test_verify_rejects_bad_tsa_timestamp( - client: SigstoreClient, make_materials_by_type: _MakeMaterialsByType + client: SigstoreClient, + make_materials_by_type: _MakeMaterialsByType, + verify_bundle: _VerifyBundle, ) -> None: """ Check that the client rejects a bundle if the TSA timestamp falls outside @@ -263,11 +299,13 @@ def test_verify_rejects_bad_tsa_timestamp( materials.trusted_root = Path("trusted_root.d.json") with client.raises(): - client.verify(materials, input_path) + verify_bundle(materials, input_path) def test_verify_rejects_bad_checkpoint( - client: SigstoreClient, make_materials_by_type: _MakeMaterialsByType + client: SigstoreClient, + make_materials_by_type: _MakeMaterialsByType, + verify_bundle: _VerifyBundle, ) -> None: """ Check that the client rejects a bundle if the checkpoint signature is @@ -278,11 +316,13 @@ def test_verify_rejects_bad_checkpoint( materials.bundle = Path("a.txt.checkpoint_invalid_signature.sigstore.json") with client.raises(): - client.verify(materials, input_path) + verify_bundle(materials, input_path) def test_verify_rejects_valid_but_mismatched_checkpoint( - client: SigstoreClient, make_materials_by_type: _MakeMaterialsByType + client: SigstoreClient, + make_materials_by_type: _MakeMaterialsByType, + verify_bundle: _VerifyBundle, ) -> None: """ Check that the client rejects a bundle if the checkpoint self consistent @@ -293,11 +333,13 @@ def test_verify_rejects_valid_but_mismatched_checkpoint( materials.bundle = Path("a.txt.checkpoint_wrong_roothash.sigstore.json") with client.raises(): - client.verify(materials, input_path) + verify_bundle(materials, input_path) def test_verify_rejects_checkpoint_with_no_matching_key( - client: SigstoreClient, make_materials_by_type: _MakeMaterialsByType + client: SigstoreClient, + make_materials_by_type: _MakeMaterialsByType, + verify_bundle: _VerifyBundle, ) -> None: """ Check that the client rejects a bundle if the checkpoint signature @@ -308,4 +350,4 @@ def test_verify_rejects_checkpoint_with_no_matching_key( materials.bundle = Path("a.txt.checkpoint_bad_keyhint.sigstore.json") with client.raises(): - client.verify(materials, input_path) + verify_bundle(materials, input_path) diff --git a/test/test_simple.py b/test/test_simple.py index 9af694c..d3ddbb6 100644 --- a/test/test_simple.py +++ b/test/test_simple.py @@ -1,6 +1,6 @@ import pytest # type: ignore -from test.conftest import _MakeMaterials, identity_token +from test.conftest import _MakeMaterials from .client import SigstoreClient