diff --git a/.gitignore b/.gitignore index d6b45fa8d..5f33313e1 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ build *.pem *.sh *.pub +*.rekor # Don't ignore these files when we intend to include them !sigstore/_store/*.crt @@ -23,3 +24,4 @@ build !test/assets/*.txt !test/assets/*.crt !test/assets/*.sig +!test/assets/*.rekor diff --git a/README.md b/README.md index 7d0ca8706..80f9f6752 100644 --- a/README.md +++ b/README.md @@ -89,9 +89,9 @@ usage: sigstore sign [-h] [--identity-token TOKEN] [--oidc-client-id ID] [--oidc-client-secret SECRET] [--oidc-disable-ambient-providers] [--oidc-issuer URL] [--no-default-files] [--signature FILE] - [--certificate FILE] [--overwrite] [--staging] - [--rekor-url URL] [--fulcio-url URL] [--ctfe FILE] - [--rekor-root-pubkey FILE] + [--certificate FILE] [--rekor-bundle FILE] [--overwrite] + [--staging] [--rekor-url URL] [--fulcio-url URL] + [--ctfe FILE] [--rekor-root-pubkey FILE] FILE [FILE ...] positional arguments: @@ -115,14 +115,18 @@ OpenID Connect options: --staging) (default: https://oauth2.sigstore.dev/auth) Output options: - --no-default-files Don't emit the default output files ({input}.sig and - {input}.crt) (default: False) + --no-default-files Don't emit the default output files ({input}.sig, + {input}.crt, {input}.rekor) (default: False) --signature FILE, --output-signature FILE Write a single signature to the given file; does not work with multiple input files (default: None) --certificate FILE, --output-certificate FILE Write a single certificate to the given file; does not work with multiple input files (default: None) + --rekor-bundle FILE, --output-rekor-bundle FILE + Write a single offline Rekor bundle to the given file; + does not work with multiple input files (default: + None) --overwrite Overwrite preexisting signature and certificate outputs, if present (default: False) @@ -147,7 +151,8 @@ Verifying: ``` usage: sigstore verify [-h] [--certificate FILE] [--signature FILE] - [--cert-email EMAIL] [--cert-oidc-issuer URL] + [--rekor-bundle FILE] [--cert-email EMAIL] + [--cert-oidc-issuer URL] [--require-rekor-offline] [--staging] [--rekor-url URL] FILE [FILE ...] @@ -163,6 +168,8 @@ Verification inputs: used with multiple inputs (default: None) --signature FILE The signature to verify against; not used with multiple inputs (default: None) + --rekor-bundle FILE The offline Rekor bundle to verify with; not used with + multiple inputs (default: None) Extended verification options: --cert-email EMAIL The email address to check for in the certificate's @@ -170,6 +177,9 @@ Extended verification options: --cert-oidc-issuer URL The OIDC issuer URL to check for in the certificate's OIDC issuer extension (default: None) + --require-rekor-offline + Require offline Rekor verification with a bundle; + implied by --rekor-bundle (default: False) Sigstore instance options: --staging Use sigstore's staging instances, instead of the diff --git a/sigstore/_cli.py b/sigstore/_cli.py index 4f4f1398e..35a784547 100644 --- a/sigstore/_cli.py +++ b/sigstore/_cli.py @@ -34,7 +34,12 @@ STAGING_OAUTH_ISSUER, get_identity_token, ) -from sigstore._internal.rekor.client import DEFAULT_REKOR_URL, RekorClient +from sigstore._internal.rekor.client import ( + DEFAULT_REKOR_URL, + RekorBundle, + RekorClient, + RekorEntry, +) from sigstore._sign import Signer from sigstore._utils import load_pem_public_key from sigstore._verify import ( @@ -164,7 +169,7 @@ def _parser() -> argparse.ArgumentParser: "--no-default-files", action="store_true", default=_boolify_env("SIGSTORE_NO_DEFAULT_FILES"), - help="Don't emit the default output files ({input}.sig and {input}.crt)", + help="Don't emit the default output files ({input}.sig, {input}.crt, {input}.rekor)", ) output_options.add_argument( "--signature", @@ -186,6 +191,17 @@ def _parser() -> argparse.ArgumentParser: "Write a single certificate to the given file; does not work with multiple input files" ), ) + output_options.add_argument( + "--rekor-bundle", + "--output-rekor-bundle", + metavar="FILE", + type=Path, + default=os.getenv("SIGSTORE_OUTPUT_BUNDLE"), + help=( + "Write a single offline Rekor bundle to the given file; does not work with " + "multiple input files" + ), + ) output_options.add_argument( "--overwrite", action="store_true", @@ -247,6 +263,13 @@ def _parser() -> argparse.ArgumentParser: default=os.getenv("SIGSTORE_SIGNATURE"), help="The signature to verify against; not used with multiple inputs", ) + input_options.add_argument( + "--rekor-bundle", + metavar="FILE", + type=Path, + default=os.getenv("SIGSTORE_REKOR_BUNDLE"), + help="The offline Rekor bundle to verify with; not used with multiple inputs", + ) verification_options = verify.add_argument_group("Extended verification options") verification_options.add_argument( @@ -263,6 +286,12 @@ def _parser() -> argparse.ArgumentParser: default=os.getenv("SIGSTORE_CERT_OIDC_ISSUER"), help="The OIDC issuer URL to check for in the certificate's OIDC issuer extension", ) + verification_options.add_argument( + "--require-rekor-offline", + action="store_true", + default=_boolify_env("SIGSTORE_REQUIRE_REKOR_OFFLINE"), + help="Require offline Rekor verification with a bundle; implied by --rekor-bundle", + ) instance_options = verify.add_argument_group("Sigstore instance options") _add_shared_instance_options(instance_options) @@ -308,20 +337,32 @@ def main() -> None: def _sign(args: argparse.Namespace) -> None: - # `--no-default-files` has no effect on `--{signature,certificate}`, but we + # `--rekor-bundle` is a temporary option, pending stabilization of the + # Sigstore bundle format. + if args.rekor_bundle: + logger.warning( + "--rekor-bundle is a temporary format, and will be removed in an " + "upcoming release of sigstore-python in favor of Sigstore-style bundles" + ) + + # `--no-default-files` has no effect on `--{signature,certificate,rekor-bundle}`, but we # forbid it because it indicates user confusion. - if args.no_default_files and (args.signature or args.certificate): + if args.no_default_files and ( + args.signature or args.certificate or args.rekor_bundle + ): args._parser.error( - "--no-default-files may not be combined with --signature or " - "--certificate", + "--no-default-files may not be combined with --signature, " + "--certificate, or --rekor-bundle", ) # Fail if `--signature` or `--certificate` is specified *and* we have more # than one input. - if (args.signature or args.certificate) and len(args.files) > 1: + if (args.signature or args.certificate or args.rekor_bundle) and len( + args.files + ) > 1: args._parser.error( - "Error: --signature and --certificate can't be used with explicit " - "outputs for multiple inputs", + "Error: --signature, --certificate, and --rekor-bundle can't be used " + "with explicit outputs for multiple inputs", ) # Build up the map of inputs -> outputs ahead of any signing operations, @@ -331,10 +372,11 @@ def _sign(args: argparse.Namespace) -> None: if not file.is_file(): args._parser.error(f"Input must be a file: {file}") - sig, cert = args.signature, args.certificate - if not sig and not cert and not args.no_default_files: + sig, cert, bundle = args.signature, args.certificate, args.rekor_bundle + if not sig and not cert and not bundle and not args.no_default_files: sig = file.parent / f"{file.name}.sig" cert = file.parent / f"{file.name}.crt" + bundle = file.parent / f"{file.name}.rekor" if not args.overwrite: extants = [] @@ -342,6 +384,8 @@ def _sign(args: argparse.Namespace) -> None: extants.append(str(sig)) if cert and cert.exists(): extants.append(str(cert)) + if bundle and bundle.exists(): + extants.append(str(bundle)) if extants: args._parser.error( @@ -349,7 +393,7 @@ def _sign(args: argparse.Namespace) -> None: f"{', '.join(extants)}" ) - output_map[file] = {"cert": cert, "sig": sig} + output_map[file] = {"cert": cert, "sig": sig, "bundle": bundle} # Select the signer to use. if args.staging: @@ -396,20 +440,41 @@ def _sign(args: argparse.Namespace) -> None: sig_output = sys.stdout print(result.b64_signature, file=sig_output) - if outputs["sig"]: - print(f"Signature written to file {outputs['sig']}") + if outputs["sig"] is not None: + print(f"Signature written to {outputs['sig']}") if outputs["cert"] is not None: - cert_output = open(outputs["cert"], "w") - print(result.cert_pem, file=cert_output) - print(f"Certificate written to file {outputs['cert']}") + with outputs["cert"].open(mode="w") as io: + print(result.cert_pem, file=io) + print(f"Certificate written to {outputs['cert']}") + + if outputs["bundle"] is not None: + with outputs["bundle"].open(mode="w") as io: + bundle = result.log_entry.to_bundle() + print(bundle.json(by_alias=True), file=io) + print(f"Rekor bundle written to {outputs['bundle']}") def _verify(args: argparse.Namespace) -> None: - # Fail if `--certificate` or `--signature` is specified and we have more than one input. - if (args.certificate or args.signature) and len(args.files) > 1: + # `--rekor-bundle` is a temporary option, pending stabilization of the + # Sigstore bundle format. + if args.rekor_bundle: + logger.warning( + "--rekor-bundle is a temporary format, and will be removed in an " + "upcoming release of sigstore-python in favor of Sigstore-style bundles" + ) + + # The presence of --rekor-bundle implies --require-rekor-offline. + args.require_rekor_offline = args.require_rekor_offline or args.rekor_bundle + + # Fail if --certificate, --signature, or --rekor-bundle is specified and we + # have more than one input. + if (args.certificate or args.signature or args.rekor_bundle) and len( + args.files + ) > 1: args._parser.error( - "--certificate and --signature can only be used with a single input file" + "--certificate, --signature, and --rekor-bundle can only be used " + "with a single input file" ) # The converse of `sign`: we build up an expected input map and check @@ -419,24 +484,31 @@ def _verify(args: argparse.Namespace) -> None: if not file.is_file(): args._parser.error(f"Input must be a file: {file}") - sig, cert = args.signature, args.certificate + sig, cert, bundle = args.signature, args.certificate, args.rekor_bundle if sig is None: sig = file.parent / f"{file.name}.sig" if cert is None: cert = file.parent / f"{file.name}.crt" + if bundle is None: + bundle = file.parent / f"{file.name}.rekor" missing = [] if not sig.is_file(): missing.append(str(sig)) if not cert.is_file(): missing.append(str(cert)) + if not bundle.is_file() and args.require_rekor_offline: + # NOTE: We only produce errors on missing bundle files + # if the user has explicitly requested offline-only verification. + # Otherwise, we fall back on online verification. + missing.append(str(bundle)) if missing: args._parser.error( f"Missing verification materials for {(file)}: {', '.join(missing)}" ) - input_map[file] = {"cert": cert, "sig": sig} + input_map[file] = {"cert": cert, "sig": sig, "bundle": bundle} if args.staging: logger.debug("verify: staging instances requested") @@ -459,6 +531,12 @@ def _verify(args: argparse.Namespace) -> None: logger.debug(f"Using signature from: {inputs['sig']}") signature = inputs["sig"].read_bytes().rstrip() + entry: Optional[RekorEntry] = None + if inputs["bundle"].is_file(): + logger.debug(f"Using offline Rekor bundle from: {inputs['bundle']}") + bundle = RekorBundle.parse_file(inputs["bundle"]) + entry = bundle.to_entry() + logger.debug(f"Verifying contents from: {file}") result = verifier.verify( @@ -467,6 +545,7 @@ def _verify(args: argparse.Namespace) -> None: signature=signature, expected_cert_email=args.cert_email, expected_cert_oidc_issuer=args.cert_oidc_issuer, + offline_rekor_entry=entry, ) if result: diff --git a/sigstore/_internal/merkle.py b/sigstore/_internal/merkle.py index 46fe5add5..8661bffe2 100644 --- a/sigstore/_internal/merkle.py +++ b/sigstore/_internal/merkle.py @@ -26,7 +26,7 @@ import struct from typing import List, Tuple -from sigstore._internal.rekor import RekorEntry, RekorInclusionProof +from sigstore._internal.rekor import RekorEntry class InvalidInclusionProofError(Exception): @@ -91,10 +91,11 @@ def _hash_leaf(leaf: bytes) -> bytes: return hashlib.sha256(data).digest() -def verify_merkle_inclusion( - inclusion_proof: RekorInclusionProof, entry: RekorEntry -) -> None: +def verify_merkle_inclusion(entry: RekorEntry) -> None: """Verify the Merkle Inclusion Proof for a given Rekor entry""" + inclusion_proof = entry.inclusion_proof + if inclusion_proof is None: + raise InvalidInclusionProofError("Rekor entry has no inclusion proof") # Figure out which subset of hashes corresponds to the inner and border nodes. inner, border = _decomp_inclusion_proof( diff --git a/sigstore/_internal/rekor/client.py b/sigstore/_internal/rekor/client.py index 5669a93cc..df6228626 100644 --- a/sigstore/_internal/rekor/client.py +++ b/sigstore/_internal/rekor/client.py @@ -29,6 +29,7 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec from pydantic import BaseModel, Field, StrictInt, StrictStr, validator +from securesystemslib.formats import encode_canonical from sigstore._internal.ctfe import CTKeyring @@ -48,15 +49,97 @@ ) +class RekorBundle(BaseModel): + """ + Represents an offline Rekor bundle. + + This model contains most of the same information as `RekorEntry`, but + with a slightly different layout. + + See: + """ + + class Config: + allow_population_by_field_name = True + + class _Payload(BaseModel): + body: StrictStr = Field(alias="body") + integrated_time: StrictInt = Field(alias="integratedTime") + log_index: StrictInt = Field(alias="logIndex") + log_id: StrictStr = Field(alias="logID") + + class Config: + allow_population_by_field_name = True + + signed_entry_timestamp: StrictStr = Field(alias="SignedEntryTimestamp") + payload: RekorBundle._Payload = Field(alias="Payload") + + def to_entry(self) -> RekorEntry: + """ + Creates a `RekorEntry` from this offline Rekor bundle. + """ + + return RekorEntry( + uuid=None, + body=self.payload.body, + integrated_time=self.payload.integrated_time, + log_id=self.payload.log_id, + log_index=self.payload.log_index, + inclusion_proof=None, + signed_entry_timestamp=self.signed_entry_timestamp, + ) + + @dataclass(frozen=True) class RekorEntry: - uuid: str + """ + Represents a Rekor log entry. + + Log entries are retrieved from Rekor after signing or verification events, + or generated from "offline" Rekor bundles supplied by the user. + """ + + uuid: Optional[str] + """ + This entry's unique ID in the Rekor instance it was retrieved from. + + For sharded Rekor deployments, IDs are unique per-shard. + + Not present for `RekorEntry` instances loaded from offline bundles. + """ + body: str + """ + The base64-encoded body of the Rekor entry. + """ + integrated_time: int + """ + The UNIX time at which this entry was integrated into the Rekor log. + """ + log_id: str + """ + The log's ID (as the SHA256 hash of the DER-encoded public key for the log + at the time of entry inclusion). + """ + log_index: int - verification: dict - raw_data: dict + """ + The index of this entry within the log. + """ + + inclusion_proof: Optional[RekorInclusionProof] + """ + An optional inclusion proof for this log entry. + + Only present for entries retrieved from online logs. + """ + + signed_entry_timestamp: str + """ + The base64-encoded Signed Entry Timestamp (SET) for this log entry. + """ @classmethod def from_response(cls, dict_: Dict[str, Any]) -> RekorEntry: @@ -73,10 +156,43 @@ def from_response(cls, dict_: Dict[str, Any]) -> RekorEntry: integrated_time=entry["integratedTime"], log_id=entry["logID"], log_index=entry["logIndex"], - verification=entry["verification"], - raw_data=entry, + inclusion_proof=RekorInclusionProof.parse_obj( + entry["verification"]["inclusionProof"] + ), + signed_entry_timestamp=entry["verification"]["signedEntryTimestamp"], ) + def to_bundle(self) -> RekorBundle: + """ + Returns a `RekorBundle` for this `RekorEntry`. + """ + + return RekorBundle( + signed_entry_timestamp=self.signed_entry_timestamp, + payload=RekorBundle._Payload( + body=self.body, + integrated_time=self.integrated_time, + log_index=self.log_index, + log_id=self.log_id, + ), + ) + + def encode_canonical(self) -> bytes: + """ + Returns a canonicalized JSON (RFC 8785) representation of the Rekor log entry. + + This encoded representation is suitable for verification against + the Signed Entry Timestamp. + """ + payload = { + "body": self.body, + "integratedTime": self.integrated_time, + "logID": self.log_id, + "logIndex": self.log_index, + } + + return encode_canonical(payload).encode() # type: ignore + @dataclass(frozen=True) class RekorLogInfo: @@ -180,7 +296,7 @@ def post( sha256_artifact_hash: str, b64_cert: str, ) -> RekorEntry: - # TODO(ww): Dedupe this payload construction with the retrive endpoint below. + # TODO(ww): Dedupe this payload construction with the retrieve endpoint below. data = { "kind": "hashedrekord", "apiVersion": "0.0.1", diff --git a/sigstore/_internal/set.py b/sigstore/_internal/set.py index 52a6932fa..ceec5e638 100644 --- a/sigstore/_internal/set.py +++ b/sigstore/_internal/set.py @@ -21,7 +21,6 @@ import cryptography.hazmat.primitives.asymmetric.ec as ec from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import hashes -from securesystemslib.formats import encode_canonical from sigstore._internal.rekor import RekorClient, RekorEntry @@ -34,27 +33,15 @@ def verify_set(client: RekorClient, entry: RekorEntry) -> None: """ Verify the Signed Entry Timestamp for a given Rekor `entry` using the given `client`. """ - - # Put together the payload - # - # This involves removing any non-required fields (verification and attestation) and then - # canonicalizing the remaining JSON in accordance with IETF's RFC 8785. - raw_data = entry.raw_data.copy() - raw_data.pop("verification", None) - raw_data.pop("attestation", None) - canon_data: bytes = encode_canonical(raw_data).encode() - # Decode the SET field - signed_entry_ts: bytes = base64.b64decode( - entry.verification["signedEntryTimestamp"].encode() - ) + signed_entry_ts: bytes = base64.b64decode(entry.signed_entry_timestamp) # Validate the SET try: client._pubkey.verify( signature=signed_entry_ts, - data=canon_data, + data=entry.encode_canonical(), signature_algorithm=ec.ECDSA(hashes.SHA256()), ) except InvalidSignature as inval_sig: - raise InvalidSetError from inval_sig + raise InvalidSetError("invalid signature") from inval_sig diff --git a/sigstore/_verify.py b/sigstore/_verify.py index 5622892e0..efde13635 100644 --- a/sigstore/_verify.py +++ b/sigstore/_verify.py @@ -21,6 +21,7 @@ import base64 import datetime import hashlib +import json import logging from importlib import resources from typing import List, Optional, cast @@ -50,7 +51,7 @@ InvalidInclusionProofError, verify_merkle_inclusion, ) -from sigstore._internal.rekor import RekorClient, RekorInclusionProof +from sigstore._internal.rekor import RekorClient, RekorEntry from sigstore._internal.set import InvalidSetError, verify_set logger = logging.getLogger(__name__) @@ -123,6 +124,7 @@ def verify( signature: bytes, expected_cert_email: Optional[str] = None, expected_cert_oidc_issuer: Optional[str] = None, + offline_rekor_entry: Optional[RekorEntry] = None, ) -> VerificationResult: """Public API for verifying. @@ -136,6 +138,13 @@ def verify( `expected_cert_oidc_issuer` is the expected OIDC Issuer Extension within `certificate`. + `offline_rekor_entry` is an optional offline `RekorEntry` to verify against. If supplied, + verification will be done against this entry rather than the against the online + transparency log. Offline Rekor entries do not carry their Merkle inclusion + proofs, and as such are verified only against their Signed Entry Timestamps. + This is a slightly weaker verification verification mode, as it does not + demonstrate inclusion in the log. + Returns a `VerificationResult` which will be truthy or falsey depending on success. """ @@ -154,17 +163,18 @@ def verify( # In order to verify an artifact, we need to achieve the following: # - # 1) Verify that the signing certificate is signed by the root certificate and that the - # signing certificate was valid at the time of signing. - # 2) Verify that the signing certiticate belongs to the signer - # 3) Verify that the signature was signed by the public key in the signing certificate - # - # And optionally, if we're performing verification online: - # - # 4) Verify the inclusion proof supplied by Rekor for this artifact - # 5) Verify the Signed Entry Timestamp (SET) supplied by Rekor for this artifact - # 6) Verify that the signing certificate was valid at the time of signing by comparing the - # expiry against the integrated timestamp + # 1) Verify that the signing certificate is signed by the certificate + # chain and that the signing certificate was valid at the time + # of signing. + # 2) Verify that the signing certificate belongs to the signer. + # 3) Verify that the artifact signature was signed by the public key in the + # signing certificate. + # 4) Verify the inclusion proof supplied by Rekor for this artifact, + # if we're doing online verification. + # 5) Verify the Signed Entry Timestamp (SET) supplied by Rekor for this + # artifact. + # 6) Verify that the signing certificate was valid at the time of + # signing by comparing the expiry against the integrated timestamp. # 1) Verify that the signing certificate is signed by the root certificate and that the # signing certificate was valid at the time of signing. @@ -236,28 +246,104 @@ def verify( logger.debug("Successfully verified signature...") - # Retrieve the relevant Rekor entry to verify the inclusion proof and SET - entry = self._rekor.log.entries.retrieve.post( - signature.decode(), - sha256_artifact_hash, - base64.b64encode(certificate).decode(), - ) - if entry is None: - return RekorEntryMissing( - signature=signature.decode(), sha256_artifact_hash=sha256_artifact_hash + entry: Optional[RekorEntry] + if offline_rekor_entry is not None: + # NOTE: CVE-2022-36056 in cosign happened because the offline Rekor + # entry was not matched against the other signing materials: an + # adversary could present a *valid but unrelated* Rekor entry + # and cosign would perform verification "as if" the entry was a + # legitimate entry for the certificate and signature. + # The steps below avoid this by decomposing the Rekor entry's + # body and confirming that it contains the same signature, + # certificate, and artifact hash as the rest of the verification + # process. + + # TODO(ww): This should all go in a separate API, probably under the + # RekorEntry class. + logger.debug( + "offline Rekor entry: ensuring contents match signing materials" ) - # 4) Verify the inclusion proof supplied by Rekor for this artifact - inclusion_proof = RekorInclusionProof.parse_obj( - entry.verification.get("inclusionProof") - ) - try: - verify_merkle_inclusion(inclusion_proof, entry) - except InvalidInclusionProofError as inval_inclusion_proof: - return VerificationFailure( - reason=f"invalid Rekor inclusion proof: {inval_inclusion_proof}" + try: + entry_body = json.loads(base64.b64decode(offline_rekor_entry.body)) + except Exception: + return VerificationFailure( + reason="couldn't parse offline Rekor entry's body" + ) + + # The Rekor entry's body should be a hashedrekord object. + # TODO: This should use a real data model, ideally generated from + # Rekor's official JSON schema. + kind, version = entry_body.get("kind"), entry_body.get("apiVersion") + if kind != "hashedrekord" or version != "0.0.1": + return VerificationFailure( + reason=( + f"Rekor entry is of unsupported kind ('{kind}') or API " + f"version ('{version}')" + ) + ) + + spec = entry_body["spec"] + expected_sig, expected_cert, expected_hash = ( + spec["signature"]["content"], + load_pem_x509_certificate( + base64.b64decode(spec["signature"]["publicKey"]["content"]) + ), + spec["data"]["hash"]["value"], ) + if expected_sig != signature.decode(): + return VerificationFailure( + reason=( + f"Rekor entry's signature ('{expected_sig}') does not " + f"match supplied signature ('{signature.decode()}')" + ) + ) + + if expected_cert != cert: + return VerificationFailure( + reason=( + f"Rekor entry's certificate ('{expected_cert}') does not " + f"match supplied certificate ('{cert}')" + ) + ) + + if expected_hash != sha256_artifact_hash: + return VerificationFailure( + reason=( + f"Rekor entry's hash ('{expected_hash}') does not " + f"match supplied hash ('{sha256_artifact_hash}')" + ) + ) + + logger.debug("offline Rekor entry matches signing artifacts!") + entry = offline_rekor_entry + else: + # Retrieve the relevant Rekor entry to verify the inclusion proof and SET. + entry = self._rekor.log.entries.retrieve.post( + signature.decode(), + sha256_artifact_hash, + base64.b64encode(certificate).decode(), + ) + if entry is None: + return RekorEntryMissing( + signature=signature.decode(), + sha256_artifact_hash=sha256_artifact_hash, + ) + + # 4) Verify the inclusion proof supplied by Rekor for this artifact. + # + # We skip the inclusion proof for offline Rekor bundles. + if offline_rekor_entry is None: + try: + verify_merkle_inclusion(entry) + except InvalidInclusionProofError as inval_inclusion_proof: + return VerificationFailure( + reason=f"invalid Rekor inclusion proof: {inval_inclusion_proof}" + ) + else: + logger.debug("offline Rekor entry: skipping Merkle inclusion proof") + # 5) Verify the Signed Entry Timestamp (SET) supplied by Rekor for this artifact try: verify_set(self._rekor, entry) @@ -274,7 +360,7 @@ def verify( reason="invalid signing cert: expired at time of Rekor entry" ) - logger.debug(f"Successfully verified Rekor entry at index {entry.log_index}..") + logger.debug(f"Successfully verified Rekor entry at index {entry.log_index}") return VerificationSuccess() diff --git a/test/assets/example.bundle b/test/assets/example.bundle new file mode 100644 index 000000000..0c60b3330 --- /dev/null +++ b/test/assets/example.bundle @@ -0,0 +1,9 @@ +{ + "SignedEntryTimestamp": "MEUCIQDHiGUesxPpn+qRONLmKlNIVPhl9gBMnwNeIQmRkRmZVQIgRxPpuYQDZR/8lYKcEfiQn5b+7VDoJIC72ZWHO9ZCp1A=", + "Payload": { + "body": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJzcGVjIjp7ImRhdGEiOnsiaGFzaCI6eyJhbGdvcml0aG0iOiJzaGEyNTYiLCJ2YWx1ZSI6ImE0NDkyYjBlYWJkZDIzMTJmMDYzMjkwYWJkNzk3ZDlkNzFhM2FiMjhiZDY1YTJjMTg5YjBkZjBkMzliOGMzYjkifX0sInNpZ25hdHVyZSI6eyJjb250ZW50IjoiTUVRQ0lDTmRYeTNiWHAxRE1PTDZOUGZYMzVnSjI3YnpsZHdTdkNBTnd5ZE9RVWlqQWlCQWg5WlJwQ3AzYlg5eE9UbEhTR2w0cFVGd0ZtUFJJWGZpY09pRTBHM1Vzdz09IiwiZm9ybWF0IjoieDUwOSIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVTmxla05EUVdkRFowRjNTVUpCWjBsVVZISk9aa013YkZSSmRWSXZWR0UyWm14MWFtdFFOWHBaTDFSQlMwSm5aM0ZvYTJwUFVGRlJSRUY2UVhFS1RWSlZkMFYzV1VSV1VWRkxSWGQ0ZW1GWFpIcGtSemw1V2xNMWExcFlXWGhGVkVGUVFtZE9Wa0pCVFZSRFNFNXdXak5PTUdJelNteE5RalJZUkZSSmVBcE5SRmw1VFdwSmVFMUVaM2RPUm05WVJGUkplRTFFV1hsTmFrbDRUV3BuZDAweGIzZEJSRUphVFVKTlIwSjVjVWRUVFRRNVFXZEZSME5EY1VkVFRUUTVDa0YzUlVoQk1FbEJRazFGV1M4ck4yRktjRmRLVFhjNWVrTmljMDFrT0hOQlRUTmxSbk5OTjBSbFpFZGlXRzlNUjJ4YUwyZHBNR2h5WTBaU1NWVTRiM2NLUzBKeU1ISkVTRE5QVkZaSWJVdFVZMkV2SzIweGQxQjNTVzlZTTFGUVYycG5aMFYwVFVsSlFrdFVRVTlDWjA1V1NGRTRRa0ZtT0VWQ1FVMURRalJCZHdwRmQxbEVWbEl3YkVKQmQzZERaMWxKUzNkWlFrSlJWVWhCZDAxM1JFRlpSRlpTTUZSQlVVZ3ZRa0ZKZDBGRVFXUkNaMDVXU0ZFMFJVWm5VVlZ5WVRoTENuSnJaMjAzVGtsNFRrNXBVMkpZVG00eFdFVkxhRzFyZDBoM1dVUldVakJxUWtKbmQwWnZRVlY1VFZWa1FVVkhZVXBEYTNsVlUxUnlSR0UxU3pkVmIwY0tNQ3QzZDJkWk1FZERRM05IUVZGVlJrSjNSVUpDU1VkQlRVZzBkMlpCV1VsTGQxbENRbEZWU0UxQlMwZGpSMmd3WkVoQk5reDVPWGRqYld3eVdWaFNiQXBaTWtWMFdUSTVkV1JIVm5Wa1F6QXlUVVJPYlZwVVpHeE9lVEIzVFVSQmQweFVTWGxOYW1OMFdXMVpNMDVUTVcxT1Ixa3hXbFJuZDFwRVNUVk9WRkYxQ21NelVuWmpiVVp1V2xNMWJtSXlPVzVpUjFab1kwZHNla3h0VG5aaVV6bHFXVlJOTWxsVVJteFBWRmw1VGtSS2FVOVhXbXBaYWtVd1RtazVhbGxUTldvS1kyNVJkMHBCV1VSV1VqQlNRVkZJTDBKQ2IzZEhTVVZYWTBoS2NHVlhSak5aVjFKdlpESkdRVm95T1haYU1uaHNURzFPZG1KVVFVdENaMmR4YUd0cVR3cFFVVkZFUVhkT2NFRkVRbTFCYWtWQk1UQlVSR015Wm1oUFZrRlVNWFJzZFM4MmMzWnhSbEZ1YkRaWU9YZGhNbXRUU2t0RGJqUkZZbFJFYTNwYVJYb3lDblppUWtwb2FFZ3ZjbWRXUjFKMU5tWkJha1ZCYkhsb05uUmhZelJZVFRaS2IzVlZlRWtyTjFnelFtUTFXVXR5WlRGS1dFOWhia0ZaYW1adldHNTVUSFFLZDNCSVFWb3paVzFhY0VWa00yeHFTVEF3Vm04S0xTMHRMUzFGVGtRZ1EwVlNWRWxHU1VOQlZFVXRMUzB0TFFvPSJ9fX0sImtpbmQiOiJyZWtvcmQifQ==", + "integratedTime": 1624396085, + "logIndex": 5179, + "logID": "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d" + } +} diff --git a/test/assets/offline-rekor.txt b/test/assets/offline-rekor.txt new file mode 100644 index 000000000..a7d3cdb9b --- /dev/null +++ b/test/assets/offline-rekor.txt @@ -0,0 +1,5 @@ +DO NOT MODIFY ME! + +this is "offline-rekor.txt", a sample input for sigstore-python's unit tests. + +DO NOT MODIFY ME! diff --git a/test/assets/offline-rekor.txt.crt b/test/assets/offline-rekor.txt.crt new file mode 100644 index 000000000..3ddba1455 --- /dev/null +++ b/test/assets/offline-rekor.txt.crt @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIEfzCCBAWgAwIBAgIULV3qds9Z1Ar1hOpW+/9ULyl1LgwwCgYIKoZIzj0EAwMw +NzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRl +cm1lZGlhdGUwHhcNMjIxMDEzMTk0ODMyWhcNMjIxMDEzMTk1ODMyWjAAMHYwEAYH +KoZIzj0CAQYFK4EEACIDYgAEff7HebXAkCGKe8/QMmJ3OCjSOhsR+3NGYn1FKm7R +672BvHek5Zza2D5bFDEwBEtM3E9hM2//OwN2EU8dK6BAaVGtlEHZvAzCcWCUwWFj +8QTp9eQDt3Hrmygyp9qB6mOro4IDBzCCAwMwDgYDVR0PAQH/BAQDAgeAMBMGA1Ud +JQQMMAoGCCsGAQUFBwMDMB0GA1UdDgQWBBTxc4F9+1z0h4kG410C/f0NxerAhzAf +BgNVHSMEGDAWgBRxhjCmFHxib/n31vQFGn9f/+tvrDAjBgNVHREBAf8EGTAXgRV3 +aWxsaWFtQHlvc3Nhcmlhbi5uZXQwLAYKKwYBBAGDvzABAQQeaHR0cHM6Ly9naXRo +dWIuY29tL2xvZ2luL29hdXRoMIICRwYKKwYBBAHWeQIEAgSCAjcEggIzAjECLwAb +fBQqTpkrp98eH8V0JFQTbBV6TLMcQQilIbixq+e/TAAAAYPS5C1VAAAEAQIAO9w0 +5StsDZsK27vfjH1nmzhB8dAcifwsCduL7XS079Jz9hUfcjqKMZOQbL5dlulkteqm +oQPO272u/AxLca7gKDD47gBx0/O9yk6TapGQuqsNrn2JPpfMdvzwJvXQJ/7rL61l +d6zs/3q0UQQu4PqVIdDPhNF9chUMGiau5UKACsManYKtmTi86+wcCT89Etb9SqSj ++QiTlTzQqIi9cKXbUhOTzpiKALjwNvsvB5pQ6U9WN+8OVoQPr919js+O0AeVf8R6 +YKhVumMBquvV756FocC/lxThYITbmUH91bY/nQPYy4tAhuums6Cc+9vzYaeQw6y0 +dUfum1XM8agJsihYzuaL/U0S2n8HrfsLjLU6a06IPMEx7WVGSEZxTH78PurXDKB8 +sLKG2X2wIQpiyglk6CU0zgw4WXb+qON7VFIL4wOe5tdrSHwRdV6xqGOdeSf+TyH4 +7GRPa0raT2pVWAZf6liJPD4vqH2jJWE3WbhOWkfYM9uqoE1fQSQr7GN4+NJzmsdN +scxsD2tiExlXNIMIvpXqTrbWSxDC/reMPjnbpNUHBCwqSyaL7HyW0oB3e6JJOuWl +yFDJIimX2tpLWyMV4tLCMd/p3EZsE5oCs1cGOiDQhAVUTwJOtxH6jk+vhFDJSH6C +gkyIyu8vQAwVGatCdElYKK6R0kt8/yA9szrsFMwwCgYIKoZIzj0EAwMDaAAwZQIx +AJDWJS41EwSk8LLZyqBjK2rG77+ceBjD2Vx6h1oGHVGVBwsiq4CgPsEyPJtVW+1Q +8wIwZ/gMuXAzIllTHJ4HBFTkODEPUcVYctRDkF75V2lvtS4eO0JFc+agbn/Ah99V +aprh +-----END CERTIFICATE----- + diff --git a/test/assets/offline-rekor.txt.rekor b/test/assets/offline-rekor.txt.rekor new file mode 100644 index 000000000..e9a97361d --- /dev/null +++ b/test/assets/offline-rekor.txt.rekor @@ -0,0 +1 @@ +{"SignedEntryTimestamp": "MEUCIQCjDatq0XZeZDd0KO8rqgAEdoHAyzXREh7vzeSBLcQGdwIgFzZKEXISn/G0BlF7DsLnaH4iYCrOWhM1U4OitB16LYs=", "Payload": {"body": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiJkYTk0MGE4MGMxZDdlNTY4YWI0MDk2ODI4YmE0NThmMDYyOTQ5ZTI2ZTY3OWJhOWFlNDU1YjdkZWI3MjM2YWViIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1HVUNNUUNrSEMraXV2VG85SDFFNHlncUN2U3ErZEF4YnFPOUdyZzEyR0pEbFJlMGhNTytUZEUvY24yS1JCN1ZHb25OMEVNQ01CdnRJa2pjSWNiQlNWMEg4cFBtcHNaaUgvT3hXYzVKN2p5RUpMRVJxL003MUdhbVpPb3I5eHg1eDgzTDhEZzJIQT09IiwicHVibGljS2V5Ijp7ImNvbnRlbnQiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VWbWVrTkRRa0ZYWjBGM1NVSkJaMGxWVEZZemNXUnpPVm94UVhJeGFFOXdWeXN2T1ZWTWVXd3hUR2QzZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwSmVFMUVSWHBOVkdzd1QwUk5lVmRvWTA1TmFrbDRUVVJGZWsxVWF6RlBSRTE1VjJwQlFVMUlXWGRGUVZsSUNrdHZXa2w2YWpCRFFWRlpSa3MwUlVWQlEwbEVXV2RCUldabU4waGxZbGhCYTBOSFMyVTRMMUZOYlVvelQwTnFVMDlvYzFJck0wNUhXVzR4Umt0dE4xSUtOamN5UW5aSVpXczFXbnBoTWtRMVlrWkVSWGRDUlhSTk0wVTVhRTB5THk5UGQwNHlSVlU0WkVzMlFrRmhWa2QwYkVWSVduWkJla05qVjBOVmQxZEdhZ280VVZSd09XVlJSSFF6U0hKdGVXZDVjRGx4UWpadFQzSnZORWxFUW5wRFEwRjNUWGRFWjFsRVZsSXdVRUZSU0M5Q1FWRkVRV2RsUVUxQ1RVZEJNVlZrQ2twUlVVMU5RVzlIUTBOelIwRlJWVVpDZDAxRVRVSXdSMEV4VldSRVoxRlhRa0pVZUdNMFJqa3JNWG93YURSclJ6UXhNRU12WmpCT2VHVnlRV2g2UVdZS1FtZE9Wa2hUVFVWSFJFRlhaMEpTZUdocVEyMUdTSGhwWWk5dU16RjJVVVpIYmpsbUx5dDBkbkpFUVdwQ1owNVdTRkpGUWtGbU9FVkhWRUZZWjFKV013cGhWM2h6WVZkR2RGRkliSFpqTTA1b1kyMXNhR0pwTlhWYVdGRjNURUZaUzB0M1dVSkNRVWRFZG5wQlFrRlJVV1ZoU0ZJd1kwaE5Oa3g1T1c1aFdGSnZDbVJYU1hWWk1qbDBUREo0ZGxveWJIVk1NamxvWkZoU2IwMUpTVU5TZDFsTFMzZFpRa0pCU0ZkbFVVbEZRV2RUUTBGcVkwVm5aMGw2UVdwRlEweDNRV0lLWmtKUmNWUndhM0p3T1RobFNEaFdNRXBHVVZSaVFsWTJWRXhOWTFGUmFXeEpZbWw0Y1N0bEwxUkJRVUZCV1ZCVE5VTXhWa0ZCUVVWQlVVbEJUemwzTUFvMVUzUnpSRnB6U3pJM2RtWnFTREZ1Ylhwb1FqaGtRV05wWm5kelEyUjFURGRZVXpBM09VcDZPV2hWWm1OcWNVdE5XazlSWWt3MVpHeDFiR3QwWlhGdENtOVJVRTh5TnpKMUwwRjRUR05oTjJkTFJFUTBOMmRDZURBdlR6bDVhelpVWVhCSFVYVnhjMDV5YmpKS1VIQm1UV1IyZW5kS2RsaFJTaTgzY2t3Mk1Xd0taRFo2Y3k4emNUQlZVVkYxTkZCeFZrbGtSRkJvVGtZNVkyaFZUVWRwWVhVMVZVdEJRM05OWVc1WlMzUnRWR2s0Tml0M1kwTlVPRGxGZEdJNVUzRlRhZ29yVVdsVWJGUjZVWEZKYVRsalMxaGlWV2hQVkhwd2FVdEJUR3AzVG5aemRrSTFjRkUyVlRsWFRpczRUMVp2VVZCeU9URTVhbk1yVHpCQlpWWm1PRkkyQ2xsTGFGWjFiVTFDY1hWMlZqYzFOa1p2WTBNdmJIaFVhRmxKVkdKdFZVZzVNV0paTDI1UlVGbDVOSFJCYUhWMWJYTTJRMk1yT1haNldXRmxVWGMyZVRBS1pGVm1kVzB4V0UwNFlXZEtjMmxvV1hwMVlVd3ZWVEJUTW00NFNISm1jMHhxVEZVMllUQTJTVkJOUlhnM1YxWkhVMFZhZUZSSU56aFFkWEpZUkV0Q09BcHpURXRITWxneWQwbFJjR2w1WjJ4ck5rTlZNSHBuZHpSWFdHSXJjVTlPTjFaR1NVdzBkMDlsTlhSa2NsTklkMUprVmpaNGNVZFBaR1ZUWml0VWVVZzBDamRIVWxCaE1ISmhWREp3VmxkQldtWTJiR2xLVUVRMGRuRklNbXBLVjBVelYySm9UMWRyWmxsTk9YVnhiMFV4WmxGVFVYSTNSMDQwSzA1S2VtMXpaRTRLYzJONGMwUXlkR2xGZUd4WVRrbE5TWFp3V0hGVWNtSlhVM2hFUXk5eVpVMVFhbTVpY0U1VlNFSkRkM0ZUZVdGTU4waDVWekJ2UWpObE5rcEtUM1ZYYkFwNVJrUktTV2x0V0RKMGNFeFhlVTFXTkhSTVEwMWtMM0F6UlZwelJUVnZRM014WTBkUGFVUlJhRUZXVlZSM1NrOTBlRWcyYW1zcmRtaEdSRXBUU0RaRENtZHJlVWw1ZFRoMlVVRjNWa2RoZEVOa1JXeFpTMHMyVWpCcmREZ3ZlVUU1YzNweWMwWk5kM2REWjFsSlMyOWFTWHBxTUVWQmQwMUVZVUZCZDFwUlNYZ0tRVXBFVjBwVE5ERkZkMU5yT0V4TVdubHhRbXBMTW5KSE56Y3JZMlZDYWtReVZuZzJhREZ2UjBoV1IxWkNkM05wY1RSRFoxQnpSWGxRU25SV1Z5c3hVUW80ZDBsM1dpOW5UWFZZUVhwSmJHeFVTRW8wU0VKR1ZHdFBSRVZRVldOV1dXTjBVa1JyUmpjMVZqSnNkblJUTkdWUE1FcEdZeXRoWjJKdUwwRm9PVGxXQ21Gd2NtZ0tMUzB0TFMxRlRrUWdRMFZTVkVsR1NVTkJWRVV0TFMwdExRbz0ifX19fQ==", "integratedTime": 1665690514, "logIndex": 827575, "logID": "d32f30a3c32d639c2b762205a21c7bb07788e68283a4ae6f42118723a1bea496"}} diff --git a/test/assets/offline-rekor.txt.sig b/test/assets/offline-rekor.txt.sig new file mode 100644 index 000000000..628fce2c6 --- /dev/null +++ b/test/assets/offline-rekor.txt.sig @@ -0,0 +1 @@ +MGUCMQCkHC+iuvTo9H1E4ygqCvSq+dAxbqO9Grg12GJDlRe0hMO+TdE/cn2KRB7VGonN0EMCMBvtIkjcIcbBSV0H8pPmpsZiH/OxWc5J7jyEJLERq/M71GamZOor9xx5x83L8Dg2HA== diff --git a/test/conftest.py b/test/conftest.py index 64c967207..76ea0937a 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -76,13 +76,31 @@ def pytest_configure(config): ) +@pytest.fixture +def asset(): + def _asset(name: str) -> Path: + return _ASSETS / name + + return _asset + + @pytest.fixture def signed_asset(): def _signed_asset(name: str) -> Tuple[bytes, bytes, bytes]: file = _ASSETS / name cert = _ASSETS / f"{name}.crt" sig = _ASSETS / f"{name}.sig" + bundle = _ASSETS / f"{name}.rekor" + + bundle_bytes = None + if bundle.is_file(): + bundle_bytes = bundle.read_bytes() - return (file.read_bytes(), cert.read_bytes(), sig.read_bytes()) + return ( + file.read_bytes(), + cert.read_bytes().rstrip(), + sig.read_bytes().rstrip(), + bundle_bytes, + ) return _signed_asset diff --git a/test/internal/rekor/test_client.py b/test/internal/rekor/test_client.py index 95b13e7e1..7ee99dcf5 100644 --- a/test/internal/rekor/test_client.py +++ b/test/internal/rekor/test_client.py @@ -12,12 +12,44 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json + import pytest from pydantic import ValidationError from sigstore._internal.rekor import client +class TestRekorBundle: + def test_parses_and_converts_to_log_entry(self, asset): + path = asset("example.bundle") + bundle = client.RekorBundle.parse_file(path) + + assert bundle.payload.integrated_time == 1624396085 + assert bundle.payload.log_index == 5179 + assert ( + bundle.payload.log_id + == "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d" + ) + + raw = json.loads(path.read_text()) + assert raw["SignedEntryTimestamp"] == bundle.signed_entry_timestamp + assert raw["Payload"]["body"] == bundle.payload.body + + entry = bundle.to_entry() + assert isinstance(entry, client.RekorEntry) + assert entry.uuid is None + assert entry.body == bundle.payload.body + assert entry.integrated_time == bundle.payload.integrated_time + assert entry.log_id == bundle.payload.log_id + assert entry.log_index == bundle.payload.log_index + assert entry.inclusion_proof is None + assert entry.signed_entry_timestamp == bundle.signed_entry_timestamp + + # Round-tripping from RekorBundle -> RekorEntry -> RekorBundle is lossless. + assert entry.to_bundle() == bundle + + class TestRekorInclusionProof: def test_valid(self): proof = client.RekorInclusionProof( diff --git a/test/test_verify.py b/test/test_verify.py index 718ee0565..31c179ebc 100644 --- a/test/test_verify.py +++ b/test/test_verify.py @@ -14,6 +14,7 @@ import pytest +from sigstore._internal.rekor.client import RekorBundle from sigstore._verify import ( CertificateVerificationFailure, VerificationFailure, @@ -50,6 +51,15 @@ def test_verifier_multiple_verifications(signed_asset): assert verifier.verify(assets[0], assets[1], assets[2]) +@pytest.mark.online +def test_verifier_offline_rekor_bundle(signed_asset): + assets = signed_asset("offline-rekor.txt") + entry = RekorBundle.parse_raw(assets[3]).to_entry() + + verifier = Verifier.staging() + assert verifier.verify(assets[0], assets[1], assets[2], offline_rekor_entry=entry) + + def test_verify_result_boolish(): assert not VerificationFailure(reason="foo") assert not CertificateVerificationFailure(reason="foo", exception=ValueError("bar"))