From fd951400888049e57627af4e870cc53bc9f93675 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Fri, 7 Oct 2022 18:06:08 -0400 Subject: [PATCH 01/33] _cli: flag scaffolding for offline rekor verification Signed-off-by: William Woodruff --- sigstore/_cli.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/sigstore/_cli.py b/sigstore/_cli.py index ae9f0b330..5ac8bc259 100644 --- a/sigstore/_cli.py +++ b/sigstore/_cli.py @@ -209,6 +209,12 @@ def _parser() -> argparse.ArgumentParser: type=Path, help="The signature to verify against; not used with multiple inputs", ) + input_options.add_argument( + "--bundle", + metavar="FILE", + type=Path, + 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( @@ -223,6 +229,11 @@ def _parser() -> argparse.ArgumentParser: type=str, help="The OIDC issuer URL to check for in the certificate's OIDC issuer extension", ) + verification_options.add_argument( + "--offline", + action="store_true", + help="Perform offline Rekor verification using a bundle; implied by --bundle", + ) instance_options = verify.add_argument_group("Sigstore instance options") instance_options.add_argument( From 51d611d20f645954c95c08ca84785e7936a03b15 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Fri, 7 Oct 2022 18:10:00 -0400 Subject: [PATCH 02/33] _cli: more scaffolding Signed-off-by: William Woodruff --- sigstore/_cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sigstore/_cli.py b/sigstore/_cli.py index 5ac8bc259..2b85c4919 100644 --- a/sigstore/_cli.py +++ b/sigstore/_cli.py @@ -387,10 +387,10 @@ def _sign(args: argparse.Namespace) -> None: 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: + # Fail if --certificate, --signature, or --bundle is specified and we have more than one input. + if (args.certificate or args.signature or args.bundle) and len(args.files) > 1: args._parser.error( - "--certificate and --signature can only be used with a single input file" + "--certificate, --signature, and --bundle can only be used with a single input file" ) # The converse of `sign`: we build up an expected input map and check From 5aaeaf3d1b989b0c8d5a7f58ee95b1752c87f437 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Tue, 11 Oct 2022 16:21:03 -0400 Subject: [PATCH 03/33] sigstore: refactor RekorEntry/SET verification for offline bundles Signed-off-by: William Woodruff --- sigstore/_cli.py | 22 ++++++-- sigstore/_internal/merkle.py | 9 ++-- sigstore/_internal/rekor/client.py | 83 ++++++++++++++++++++++++++++-- sigstore/_internal/set.py | 19 ++----- sigstore/_verify.py | 52 ++++++++++++------- 5 files changed, 137 insertions(+), 48 deletions(-) diff --git a/sigstore/_cli.py b/sigstore/_cli.py index 202fe5789..8408bdbc7 100644 --- a/sigstore/_cli.py +++ b/sigstore/_cli.py @@ -13,6 +13,7 @@ # limitations under the License. import argparse +import json import logging import os import sys @@ -33,7 +34,11 @@ 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, + RekorClient, + RekorEntry, +) from sigstore._sign import Signer from sigstore._verify import ( CertificateVerificationFailure, @@ -427,12 +432,16 @@ 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.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}.bundle" + # NOTE: We don't produce errors on missing bundle files, + # since the absence of a bundle file implies online verification. missing = [] if not sig.is_file(): missing.append(str(sig)) @@ -444,7 +453,7 @@ def _verify(args: argparse.Namespace) -> None: 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") @@ -467,6 +476,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 = json.loads(inputs["bundle"].read_text()) + entry = RekorEntry.from_bundle(bundle) + logger.debug(f"Verifying contents from: {file}") result = verifier.verify( @@ -475,6 +490,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 f9e75bb2f..07a6d6035 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, rsa from pydantic import BaseModel, Field, StrictInt, StrictStr, validator +from securesystemslib.formats import encode_canonical logger = logging.getLogger(__name__) @@ -48,13 +49,47 @@ @dataclass(frozen=True) class RekorEntry: - uuid: str + 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: @@ -71,10 +106,48 @@ 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"], ) + @classmethod + def from_bundle(cls, dict_: Dict[str, Any]) -> RekorEntry: + """ + Creates a `RekorEntry` from an offline Rekor bundle. + + See: + """ + + payload = dict_["payload"] + return cls( + uuid=None, + body=payload["body"], + integrated_time=payload["body"], + log_id=payload["body"], + log_index=payload["body"], + inclusion_proof=None, + signed_entry_timestamp=dict_["SignedEntryTimestamp"], + ) + + def encode_canonical(self) -> bytes: + """ + Returns a base64-encoded, 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: 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..5f8ebc022 100644 --- a/sigstore/_verify.py +++ b/sigstore/_verify.py @@ -50,7 +50,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 +123,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 +137,10 @@ 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. + Returns a `VerificationResult` which will be truthy or falsey depending on success. """ @@ -236,27 +241,34 @@ 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: + 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 - 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}" - ) + # 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 used; skipping Merkle inclusion proof") # 5) Verify the Signed Entry Timestamp (SET) supplied by Rekor for this artifact try: From 8c96fcd3422a2ac7e301d8afa8bf4507e3bf2296 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Tue, 11 Oct 2022 16:33:16 -0400 Subject: [PATCH 04/33] _cli: add envvar defaults for new flags Signed-off-by: William Woodruff --- sigstore/_cli.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sigstore/_cli.py b/sigstore/_cli.py index 8408bdbc7..d4892e98e 100644 --- a/sigstore/_cli.py +++ b/sigstore/_cli.py @@ -254,6 +254,7 @@ def _parser() -> argparse.ArgumentParser: "--bundle", metavar="FILE", type=Path, + default=os.getenv("SIGSTORE_BUNDLE"), help="The offline Rekor bundle to verify with; not used with multiple inputs", ) @@ -275,6 +276,7 @@ def _parser() -> argparse.ArgumentParser: verification_options.add_argument( "--offline", action="store_true", + default=_boolify_env("SIGSTORE_OFFLINE"), help="Perform offline Rekor verification using a bundle; implied by --bundle", ) From 96f3af9eb04374c212844cf89fbd2fd22c5feb79 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Tue, 11 Oct 2022 16:33:25 -0400 Subject: [PATCH 05/33] README: update `sigstore verify --help` Signed-off-by: William Woodruff --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index aeb6563e3..9190a6091 100644 --- a/README.md +++ b/README.md @@ -146,8 +146,9 @@ Verifying: ``` usage: sigstore verify [-h] [--certificate FILE] [--signature FILE] - [--cert-email EMAIL] [--cert-oidc-issuer URL] - [--staging] [--rekor-url URL] + [--bundle FILE] [--cert-email EMAIL] + [--cert-oidc-issuer URL] [--offline] [--staging] + [--rekor-url URL] FILE [FILE ...] positional arguments: @@ -162,6 +163,8 @@ Verification inputs: used with multiple inputs (default: None) --signature FILE The signature to verify against; not used with multiple inputs (default: None) + --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 @@ -169,6 +172,8 @@ Extended verification options: --cert-oidc-issuer URL The OIDC issuer URL to check for in the certificate's OIDC issuer extension (default: None) + --offline Perform offline Rekor verification using a bundle; + implied by --bundle (default: False) Sigstore instance options: --staging Use sigstore's staging instances, instead of the From 98529de4549ee7ecb9ea0d0492ea7653aa246527 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Tue, 11 Oct 2022 16:37:30 -0400 Subject: [PATCH 06/33] _cli: handle `verify --offline` correctly Signed-off-by: William Woodruff --- sigstore/_cli.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sigstore/_cli.py b/sigstore/_cli.py index d4892e98e..0584240ea 100644 --- a/sigstore/_cli.py +++ b/sigstore/_cli.py @@ -442,13 +442,16 @@ def _verify(args: argparse.Namespace) -> None: if bundle is None: bundle = file.parent / f"{file.name}.bundle" - # NOTE: We don't produce errors on missing bundle files, - # since the absence of a bundle file implies online verification. 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.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( From 5d2e6ae103baaefb3d7de052e751e88be09ed149 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Tue, 11 Oct 2022 16:46:44 -0400 Subject: [PATCH 07/33] rekor/client: fix docstring The returned value here is not base64-encoded. Signed-off-by: William Woodruff --- sigstore/_internal/rekor/client.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sigstore/_internal/rekor/client.py b/sigstore/_internal/rekor/client.py index 07a6d6035..4fb536dd7 100644 --- a/sigstore/_internal/rekor/client.py +++ b/sigstore/_internal/rekor/client.py @@ -133,8 +133,7 @@ def from_bundle(cls, dict_: Dict[str, Any]) -> RekorEntry: def encode_canonical(self) -> bytes: """ - Returns a base64-encoded, canonicalized JSON (RFC 8785) representation - of the Rekor log entry. + Returns a canonicalized JSON (RFC 8785) representation of the Rekor log entry. This encoded representation is suitable for verification against the Signed Entry Timestamp. From 988e75a82fcb5b69474b5a58c17de01d4a734afe Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Tue, 11 Oct 2022 16:59:02 -0400 Subject: [PATCH 08/33] _cli: Add `rekor` suffix to offline bundle flags/options Signed-off-by: William Woodruff --- sigstore/_cli.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/sigstore/_cli.py b/sigstore/_cli.py index 0584240ea..9bafed761 100644 --- a/sigstore/_cli.py +++ b/sigstore/_cli.py @@ -251,10 +251,10 @@ def _parser() -> argparse.ArgumentParser: help="The signature to verify against; not used with multiple inputs", ) input_options.add_argument( - "--bundle", + "--rekor-bundle", metavar="FILE", type=Path, - default=os.getenv("SIGSTORE_BUNDLE"), + default=os.getenv("SIGSTORE_REKOR_BUNDLE"), help="The offline Rekor bundle to verify with; not used with multiple inputs", ) @@ -274,9 +274,9 @@ def _parser() -> argparse.ArgumentParser: help="The OIDC issuer URL to check for in the certificate's OIDC issuer extension", ) verification_options.add_argument( - "--offline", + "--rekor-offline", action="store_true", - default=_boolify_env("SIGSTORE_OFFLINE"), + default=_boolify_env("SIGSTORE_REKOR_OFFLINE"), help="Perform offline Rekor verification using a bundle; implied by --bundle", ) @@ -422,7 +422,9 @@ def _sign(args: argparse.Namespace) -> None: def _verify(args: argparse.Namespace) -> None: # Fail if --certificate, --signature, or --bundle is specified and we have more than one input. - if (args.certificate or args.signature or args.bundle) and len(args.files) > 1: + if (args.certificate or args.signature or args.rekor_bundle) and len( + args.files + ) > 1: args._parser.error( "--certificate, --signature, and --bundle can only be used with a single input file" ) @@ -434,7 +436,7 @@ def _verify(args: argparse.Namespace) -> None: if not file.is_file(): args._parser.error(f"Input must be a file: {file}") - sig, cert, bundle = args.signature, args.certificate, args.bundle + 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: @@ -447,7 +449,7 @@ def _verify(args: argparse.Namespace) -> None: missing.append(str(sig)) if not cert.is_file(): missing.append(str(cert)) - if not bundle.is_file() and args.offline: + if not bundle.is_file() and args.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. From 446cf319022c1cff1148dfa32cf266e7a4669088 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Tue, 11 Oct 2022 17:00:01 -0400 Subject: [PATCH 09/33] README: update `sigstore verify` Signed-off-by: William Woodruff --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9190a6091..1a0dd47e9 100644 --- a/README.md +++ b/README.md @@ -146,8 +146,8 @@ Verifying: ``` usage: sigstore verify [-h] [--certificate FILE] [--signature FILE] - [--bundle FILE] [--cert-email EMAIL] - [--cert-oidc-issuer URL] [--offline] [--staging] + [--rekor-bundle FILE] [--cert-email EMAIL] + [--cert-oidc-issuer URL] [--rekor-offline] [--staging] [--rekor-url URL] FILE [FILE ...] @@ -163,7 +163,7 @@ Verification inputs: used with multiple inputs (default: None) --signature FILE The signature to verify against; not used with multiple inputs (default: None) - --bundle FILE The offline Rekor bundle to verify with; not used with + --rekor-bundle FILE The offline Rekor bundle to verify with; not used with multiple inputs (default: None) Extended verification options: @@ -172,7 +172,7 @@ Extended verification options: --cert-oidc-issuer URL The OIDC issuer URL to check for in the certificate's OIDC issuer extension (default: None) - --offline Perform offline Rekor verification using a bundle; + --rekor-offline Perform offline Rekor verification using a bundle; implied by --bundle (default: False) Sigstore instance options: From 94db41034a87637e96072baf41a1ffe8c37495f2 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Tue, 11 Oct 2022 17:05:29 -0400 Subject: [PATCH 10/33] _verify: elaborate on the properties of a non-inclusion-proof verification Signed-off-by: William Woodruff --- sigstore/_verify.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/sigstore/_verify.py b/sigstore/_verify.py index 5f8ebc022..75d8cdcd5 100644 --- a/sigstore/_verify.py +++ b/sigstore/_verify.py @@ -139,7 +139,10 @@ def verify( `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. + 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 guarantee + the log's consistency. Returns a `VerificationResult` which will be truthy or falsey depending on success. From 57d93e2022dd4f042839bd9d96992260c5ad4727 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Tue, 11 Oct 2022 17:12:44 -0400 Subject: [PATCH 11/33] _verify: fix comment typos, reflow comments Signed-off-by: William Woodruff --- sigstore/_verify.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/sigstore/_verify.py b/sigstore/_verify.py index 75d8cdcd5..7bfddd25f 100644 --- a/sigstore/_verify.py +++ b/sigstore/_verify.py @@ -162,17 +162,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 root + # certificate 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 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. From 5986ade5b33081e31a2fb95c5fdf50b123446fbd Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Wed, 12 Oct 2022 17:36:50 -0400 Subject: [PATCH 12/33] Apply suggestions from code review Co-authored-by: Dustin Ingram Signed-off-by: William Woodruff --- README.md | 2 +- sigstore/_cli.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1a0dd47e9..b1ebc192e 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,7 @@ Extended verification options: The OIDC issuer URL to check for in the certificate's OIDC issuer extension (default: None) --rekor-offline Perform offline Rekor verification using a bundle; - implied by --bundle (default: False) + 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 9bafed761..014a4b3c8 100644 --- a/sigstore/_cli.py +++ b/sigstore/_cli.py @@ -277,7 +277,7 @@ def _parser() -> argparse.ArgumentParser: "--rekor-offline", action="store_true", default=_boolify_env("SIGSTORE_REKOR_OFFLINE"), - help="Perform offline Rekor verification using a bundle; implied by --bundle", + help="Perform offline Rekor verification using a bundle; implied by --rekor-bundle", ) instance_options = verify.add_argument_group("Sigstore instance options") @@ -421,12 +421,12 @@ def _sign(args: argparse.Namespace) -> None: def _verify(args: argparse.Namespace) -> None: - # Fail if --certificate, --signature, or --bundle is specified and we have more than one input. + # 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, --signature, and --bundle 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 From 286a5a4fc37bc09f9c6292c1575bda92d16e66c7 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Wed, 12 Oct 2022 17:38:08 -0400 Subject: [PATCH 13/33] _cli: lint Signed-off-by: William Woodruff --- sigstore/_cli.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sigstore/_cli.py b/sigstore/_cli.py index 014a4b3c8..382b06097 100644 --- a/sigstore/_cli.py +++ b/sigstore/_cli.py @@ -421,12 +421,14 @@ def _sign(args: argparse.Namespace) -> None: def _verify(args: argparse.Namespace) -> None: - # Fail if --certificate, --signature, or --rekor-bundle is specified and we have more than one input. + # 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, --signature, and --rekor-bundle 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 From b851afe7d5ce0b564a988047b69fe5b9c0c321b3 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 13 Oct 2022 10:11:36 -0400 Subject: [PATCH 14/33] rekor/client: fix capitalization on Payload key Signed-off-by: William Woodruff --- sigstore/_internal/rekor/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sigstore/_internal/rekor/client.py b/sigstore/_internal/rekor/client.py index 4fb536dd7..ba838168b 100644 --- a/sigstore/_internal/rekor/client.py +++ b/sigstore/_internal/rekor/client.py @@ -120,7 +120,7 @@ def from_bundle(cls, dict_: Dict[str, Any]) -> RekorEntry: See: """ - payload = dict_["payload"] + payload = dict_["Payload"] return cls( uuid=None, body=payload["body"], From 081caadf78e0d00a479738d51d0d02958b035ac7 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 13 Oct 2022 10:27:17 -0400 Subject: [PATCH 15/33] rekor/client: fix keys Signed-off-by: William Woodruff --- sigstore/_internal/rekor/client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sigstore/_internal/rekor/client.py b/sigstore/_internal/rekor/client.py index ba838168b..f3f8faabf 100644 --- a/sigstore/_internal/rekor/client.py +++ b/sigstore/_internal/rekor/client.py @@ -124,9 +124,9 @@ def from_bundle(cls, dict_: Dict[str, Any]) -> RekorEntry: return cls( uuid=None, body=payload["body"], - integrated_time=payload["body"], - log_id=payload["body"], - log_index=payload["body"], + integrated_time=payload["integratedTime"], + log_id=payload["logID"], + log_index=payload["logIndex"], inclusion_proof=None, signed_entry_timestamp=dict_["SignedEntryTimestamp"], ) From 34a1e4a4291c952c4bc6640aaa5dd1f754386cf2 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 13 Oct 2022 10:31:08 -0400 Subject: [PATCH 16/33] _cli: --rekor-bundle implies --rekor-offline In other words: if a user explicitly passes a bundle filename, we never fall back on online verification. Signed-off-by: William Woodruff --- sigstore/_cli.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/sigstore/_cli.py b/sigstore/_cli.py index 382b06097..10a54dd2f 100644 --- a/sigstore/_cli.py +++ b/sigstore/_cli.py @@ -421,6 +421,9 @@ def _sign(args: argparse.Namespace) -> None: def _verify(args: argparse.Namespace) -> None: + # The presence of --rekor-bundle implies --rekor-offline. + args.rekor_offline = args.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( From caafd8de29c4f1de005705774797777f998c7465 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 13 Oct 2022 15:20:51 -0400 Subject: [PATCH 17/33] sigstore, test: create and use a separate RekorBundle model This makes validation a little simpler. Signed-off-by: William Woodruff --- sigstore/_cli.py | 6 +-- sigstore/_internal/rekor/client.py | 61 ++++++++++++++++++++---------- test/assets/example.bundle | 9 +++++ test/conftest.py | 8 ++++ test/internal/rekor/test_client.py | 29 ++++++++++++++ 5 files changed, 91 insertions(+), 22 deletions(-) create mode 100644 test/assets/example.bundle diff --git a/sigstore/_cli.py b/sigstore/_cli.py index 10a54dd2f..24663762d 100644 --- a/sigstore/_cli.py +++ b/sigstore/_cli.py @@ -13,7 +13,6 @@ # limitations under the License. import argparse -import json import logging import os import sys @@ -36,6 +35,7 @@ ) from sigstore._internal.rekor.client import ( DEFAULT_REKOR_URL, + RekorBundle, RekorClient, RekorEntry, ) @@ -491,8 +491,8 @@ def _verify(args: argparse.Namespace) -> None: entry: Optional[RekorEntry] = None if inputs["bundle"].is_file(): logger.debug(f"Using offline Rekor bundle from: {inputs['bundle']}") - bundle = json.loads(inputs["bundle"].read_text()) - entry = RekorEntry.from_bundle(bundle) + bundle = RekorBundle.parse_file(inputs["bundle"]) + entry = bundle.to_entry() logger.debug(f"Verifying contents from: {file}") diff --git a/sigstore/_internal/rekor/client.py b/sigstore/_internal/rekor/client.py index f3f8faabf..b974febc2 100644 --- a/sigstore/_internal/rekor/client.py +++ b/sigstore/_internal/rekor/client.py @@ -47,8 +47,50 @@ ) +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 _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") + + 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: + """ + 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. @@ -112,25 +154,6 @@ def from_response(cls, dict_: Dict[str, Any]) -> RekorEntry: signed_entry_timestamp=entry["verification"]["signedEntryTimestamp"], ) - @classmethod - def from_bundle(cls, dict_: Dict[str, Any]) -> RekorEntry: - """ - Creates a `RekorEntry` from an offline Rekor bundle. - - See: - """ - - payload = dict_["Payload"] - return cls( - uuid=None, - body=payload["body"], - integrated_time=payload["integratedTime"], - log_id=payload["logID"], - log_index=payload["logIndex"], - inclusion_proof=None, - signed_entry_timestamp=dict_["SignedEntryTimestamp"], - ) - def encode_canonical(self) -> bytes: """ Returns a canonicalized JSON (RFC 8785) representation of the Rekor log entry. 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/conftest.py b/test/conftest.py index 57bd9cf40..05e90c719 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -42,6 +42,14 @@ 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]: diff --git a/test/internal/rekor/test_client.py b/test/internal/rekor/test_client.py index 95b13e7e1..6a628360d 100644 --- a/test/internal/rekor/test_client.py +++ b/test/internal/rekor/test_client.py @@ -12,12 +12,41 @@ # 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 + + class TestRekorInclusionProof: def test_valid(self): proof = client.RekorInclusionProof( From 0b0d03617801686d0846f77013640e806931f75d Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 13 Oct 2022 15:43:25 -0400 Subject: [PATCH 18/33] sigstore, test: add offline bundle generation Signed-off-by: William Woodruff --- .gitignore | 2 ++ sigstore/_cli.py | 52 +++++++++++++++++++++--------- sigstore/_internal/rekor/client.py | 21 ++++++++++++ test/internal/rekor/test_client.py | 3 ++ 4 files changed, 62 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index d6b45fa8d..c3e552b38 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ build *.pem *.sh *.pub +*.bundle # 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/*.bundle diff --git a/sigstore/_cli.py b/sigstore/_cli.py index 24663762d..34e3dc6ad 100644 --- a/sigstore/_cli.py +++ b/sigstore/_cli.py @@ -167,7 +167,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}.bundle)", ) output_options.add_argument( "--signature", @@ -189,6 +189,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", @@ -324,20 +335,20 @@ def main() -> None: def _sign(args: argparse.Namespace) -> None: - # `--no-default-files` has no effect on `--{signature,certificate}`, but we + # `--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, @@ -347,10 +358,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}.bundle" if not args.overwrite: extants = [] @@ -358,6 +370,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( @@ -365,7 +379,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: @@ -411,13 +425,19 @@ 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: diff --git a/sigstore/_internal/rekor/client.py b/sigstore/_internal/rekor/client.py index b974febc2..217003e5f 100644 --- a/sigstore/_internal/rekor/client.py +++ b/sigstore/_internal/rekor/client.py @@ -57,12 +57,18 @@ class RekorBundle(BaseModel): 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") @@ -154,6 +160,21 @@ def from_response(cls, dict_: Dict[str, Any]) -> RekorEntry: 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. diff --git a/test/internal/rekor/test_client.py b/test/internal/rekor/test_client.py index 6a628360d..7ee99dcf5 100644 --- a/test/internal/rekor/test_client.py +++ b/test/internal/rekor/test_client.py @@ -46,6 +46,9 @@ def test_parses_and_converts_to_log_entry(self, asset): 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): From 3abef33715875d7d3e33f86cb159b1fe32a511d9 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 13 Oct 2022 15:54:52 -0400 Subject: [PATCH 19/33] sigstore: blacken Signed-off-by: William Woodruff --- sigstore/_cli.py | 8 ++++++-- sigstore/_internal/rekor/client.py | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/sigstore/_cli.py b/sigstore/_cli.py index 34e3dc6ad..a829f5a4f 100644 --- a/sigstore/_cli.py +++ b/sigstore/_cli.py @@ -337,7 +337,9 @@ def main() -> None: def _sign(args: argparse.Namespace) -> None: # `--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 or args.rekor_bundle): + 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, " "--certificate, or --rekor-bundle", @@ -345,7 +347,9 @@ def _sign(args: argparse.Namespace) -> None: # Fail if `--signature` or `--certificate` is specified *and* we have more # than one input. - if (args.signature or args.certificate or args.rekor_bundle) 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, --certificate, and --rekor-bundle can't be used " "with explicit outputs for multiple inputs", diff --git a/sigstore/_internal/rekor/client.py b/sigstore/_internal/rekor/client.py index 217003e5f..bd3282bac 100644 --- a/sigstore/_internal/rekor/client.py +++ b/sigstore/_internal/rekor/client.py @@ -172,7 +172,7 @@ def to_bundle(self) -> RekorBundle: integrated_time=self.integrated_time, log_index=self.log_index, log_id=self.log_id, - ) + ), ) def encode_canonical(self) -> bytes: From 8873b751d2725ef06404ed08d965dbc0736bea19 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 13 Oct 2022 15:54:58 -0400 Subject: [PATCH 20/33] test: add an offline rekor test Signed-off-by: William Woodruff --- test/assets/offline-rekor.txt | 5 +++++ test/assets/offline-rekor.txt.bundle | 1 + test/assets/offline-rekor.txt.crt | 28 ++++++++++++++++++++++++++++ test/assets/offline-rekor.txt.sig | 1 + test/conftest.py | 7 ++++++- test/test_verify.py | 10 ++++++++++ 6 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 test/assets/offline-rekor.txt create mode 100644 test/assets/offline-rekor.txt.bundle create mode 100644 test/assets/offline-rekor.txt.crt create mode 100644 test/assets/offline-rekor.txt.sig 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.bundle b/test/assets/offline-rekor.txt.bundle new file mode 100644 index 000000000..e9a97361d --- /dev/null +++ b/test/assets/offline-rekor.txt.bundle @@ -0,0 +1 @@ +{"SignedEntryTimestamp": "MEUCIQCjDatq0XZeZDd0KO8rqgAEdoHAyzXREh7vzeSBLcQGdwIgFzZKEXISn/G0BlF7DsLnaH4iYCrOWhM1U4OitB16LYs=", "Payload": {"body": "eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiJkYTk0MGE4MGMxZDdlNTY4YWI0MDk2ODI4YmE0NThmMDYyOTQ5ZTI2ZTY3OWJhOWFlNDU1YjdkZWI3MjM2YWViIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1HVUNNUUNrSEMraXV2VG85SDFFNHlncUN2U3ErZEF4YnFPOUdyZzEyR0pEbFJlMGhNTytUZEUvY24yS1JCN1ZHb25OMEVNQ01CdnRJa2pjSWNiQlNWMEg4cFBtcHNaaUgvT3hXYzVKN2p5RUpMRVJxL003MUdhbVpPb3I5eHg1eDgzTDhEZzJIQT09IiwicHVibGljS2V5Ijp7ImNvbnRlbnQiOiJMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VWbWVrTkRRa0ZYWjBGM1NVSkJaMGxWVEZZemNXUnpPVm94UVhJeGFFOXdWeXN2T1ZWTWVXd3hUR2QzZDBObldVbExiMXBKZW1vd1JVRjNUWGNLVG5wRlZrMUNUVWRCTVZWRlEyaE5UV015Ykc1ak0xSjJZMjFWZFZwSFZqSk5ValIzU0VGWlJGWlJVVVJGZUZaNllWZGtlbVJIT1hsYVV6RndZbTVTYkFwamJURnNXa2RzYUdSSFZYZElhR05PVFdwSmVFMUVSWHBOVkdzd1QwUk5lVmRvWTA1TmFrbDRUVVJGZWsxVWF6RlBSRTE1VjJwQlFVMUlXWGRGUVZsSUNrdHZXa2w2YWpCRFFWRlpSa3MwUlVWQlEwbEVXV2RCUldabU4waGxZbGhCYTBOSFMyVTRMMUZOYlVvelQwTnFVMDlvYzFJck0wNUhXVzR4Umt0dE4xSUtOamN5UW5aSVpXczFXbnBoTWtRMVlrWkVSWGRDUlhSTk0wVTVhRTB5THk5UGQwNHlSVlU0WkVzMlFrRmhWa2QwYkVWSVduWkJla05qVjBOVmQxZEdhZ280VVZSd09XVlJSSFF6U0hKdGVXZDVjRGx4UWpadFQzSnZORWxFUW5wRFEwRjNUWGRFWjFsRVZsSXdVRUZSU0M5Q1FWRkVRV2RsUVUxQ1RVZEJNVlZrQ2twUlVVMU5RVzlIUTBOelIwRlJWVVpDZDAxRVRVSXdSMEV4VldSRVoxRlhRa0pVZUdNMFJqa3JNWG93YURSclJ6UXhNRU12WmpCT2VHVnlRV2g2UVdZS1FtZE9Wa2hUVFVWSFJFRlhaMEpTZUdocVEyMUdTSGhwWWk5dU16RjJVVVpIYmpsbUx5dDBkbkpFUVdwQ1owNVdTRkpGUWtGbU9FVkhWRUZZWjFKV013cGhWM2h6WVZkR2RGRkliSFpqTTA1b1kyMXNhR0pwTlhWYVdGRjNURUZaUzB0M1dVSkNRVWRFZG5wQlFrRlJVV1ZoU0ZJd1kwaE5Oa3g1T1c1aFdGSnZDbVJYU1hWWk1qbDBUREo0ZGxveWJIVk1NamxvWkZoU2IwMUpTVU5TZDFsTFMzZFpRa0pCU0ZkbFVVbEZRV2RUUTBGcVkwVm5aMGw2UVdwRlEweDNRV0lLWmtKUmNWUndhM0p3T1RobFNEaFdNRXBHVVZSaVFsWTJWRXhOWTFGUmFXeEpZbWw0Y1N0bEwxUkJRVUZCV1ZCVE5VTXhWa0ZCUVVWQlVVbEJUemwzTUFvMVUzUnpSRnB6U3pJM2RtWnFTREZ1Ylhwb1FqaGtRV05wWm5kelEyUjFURGRZVXpBM09VcDZPV2hWWm1OcWNVdE5XazlSWWt3MVpHeDFiR3QwWlhGdENtOVJVRTh5TnpKMUwwRjRUR05oTjJkTFJFUTBOMmRDZURBdlR6bDVhelpVWVhCSFVYVnhjMDV5YmpKS1VIQm1UV1IyZW5kS2RsaFJTaTgzY2t3Mk1Xd0taRFo2Y3k4emNUQlZVVkYxTkZCeFZrbGtSRkJvVGtZNVkyaFZUVWRwWVhVMVZVdEJRM05OWVc1WlMzUnRWR2s0Tml0M1kwTlVPRGxGZEdJNVUzRlRhZ29yVVdsVWJGUjZVWEZKYVRsalMxaGlWV2hQVkhwd2FVdEJUR3AzVG5aemRrSTFjRkUyVlRsWFRpczRUMVp2VVZCeU9URTVhbk1yVHpCQlpWWm1PRkkyQ2xsTGFGWjFiVTFDY1hWMlZqYzFOa1p2WTBNdmJIaFVhRmxKVkdKdFZVZzVNV0paTDI1UlVGbDVOSFJCYUhWMWJYTTJRMk1yT1haNldXRmxVWGMyZVRBS1pGVm1kVzB4V0UwNFlXZEtjMmxvV1hwMVlVd3ZWVEJUTW00NFNISm1jMHhxVEZVMllUQTJTVkJOUlhnM1YxWkhVMFZhZUZSSU56aFFkWEpZUkV0Q09BcHpURXRITWxneWQwbFJjR2w1WjJ4ck5rTlZNSHBuZHpSWFdHSXJjVTlPTjFaR1NVdzBkMDlsTlhSa2NsTklkMUprVmpaNGNVZFBaR1ZUWml0VWVVZzBDamRIVWxCaE1ISmhWREp3VmxkQldtWTJiR2xLVUVRMGRuRklNbXBLVjBVelYySm9UMWRyWmxsTk9YVnhiMFV4WmxGVFVYSTNSMDQwSzA1S2VtMXpaRTRLYzJONGMwUXlkR2xGZUd4WVRrbE5TWFp3V0hGVWNtSlhVM2hFUXk5eVpVMVFhbTVpY0U1VlNFSkRkM0ZUZVdGTU4waDVWekJ2UWpObE5rcEtUM1ZYYkFwNVJrUktTV2x0V0RKMGNFeFhlVTFXTkhSTVEwMWtMM0F6UlZwelJUVnZRM014WTBkUGFVUlJhRUZXVlZSM1NrOTBlRWcyYW1zcmRtaEdSRXBUU0RaRENtZHJlVWw1ZFRoMlVVRjNWa2RoZEVOa1JXeFpTMHMyVWpCcmREZ3ZlVUU1YzNweWMwWk5kM2REWjFsSlMyOWFTWHBxTUVWQmQwMUVZVUZCZDFwUlNYZ0tRVXBFVjBwVE5ERkZkMU5yT0V4TVdubHhRbXBMTW5KSE56Y3JZMlZDYWtReVZuZzJhREZ2UjBoV1IxWkNkM05wY1RSRFoxQnpSWGxRU25SV1Z5c3hVUW80ZDBsM1dpOW5UWFZZUVhwSmJHeFVTRW8wU0VKR1ZHdFBSRVZRVldOV1dXTjBVa1JyUmpjMVZqSnNkblJUTkdWUE1FcEdZeXRoWjJKdUwwRm9PVGxXQ21Gd2NtZ0tMUzB0TFMxRlRrUWdRMFZTVkVsR1NVTkJWRVV0TFMwdExRbz0ifX19fQ==", "integratedTime": 1665690514, "logIndex": 827575, "logID": "d32f30a3c32d639c2b762205a21c7bb07788e68283a4ae6f42118723a1bea496"}} 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.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 05e90c719..d01103280 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -56,7 +56,12 @@ 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}.bundle" - return (file.read_bytes(), cert.read_bytes(), sig.read_bytes()) + bundle_bytes = None + if bundle.is_file(): + bundle_bytes = bundle.read_bytes() + + return (file.read_bytes(), cert.read_bytes(), sig.read_bytes(), bundle_bytes) return _signed_asset diff --git a/test/test_verify.py b/test/test_verify.py index 718ee0565..1bbdf02d3 100644 --- a/test/test_verify.py +++ b/test/test_verify.py @@ -14,6 +14,7 @@ import pytest +from sigstore._internal.rekor.client import RekorBundle, RekorEntry 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")) From d689c039606d66159dac4607886c0ca44cbc20e0 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 13 Oct 2022 15:58:24 -0400 Subject: [PATCH 21/33] _cli: tweak `--rekor-offline` language slightly To emphasize that the absence of `--rekor-offline` does not always imply fully online verification. Signed-off-by: William Woodruff --- sigstore/_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sigstore/_cli.py b/sigstore/_cli.py index a829f5a4f..8d525a2c6 100644 --- a/sigstore/_cli.py +++ b/sigstore/_cli.py @@ -288,7 +288,7 @@ def _parser() -> argparse.ArgumentParser: "--rekor-offline", action="store_true", default=_boolify_env("SIGSTORE_REKOR_OFFLINE"), - help="Perform offline Rekor verification using a bundle; implied by --rekor-bundle", + help="Require offline Rekor verification with a bundle; implied by --rekor-bundle", ) instance_options = verify.add_argument_group("Sigstore instance options") From 64f435456995a535f3805c88d93ada2921689037 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 13 Oct 2022 15:59:48 -0400 Subject: [PATCH 22/33] README: update `--help` blocks Signed-off-by: William Woodruff --- README.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index b1ebc192e..b75006a5a 100644 --- a/README.md +++ b/README.md @@ -88,9 +88,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: @@ -114,14 +114,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}.bundle) (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) @@ -172,7 +176,7 @@ Extended verification options: --cert-oidc-issuer URL The OIDC issuer URL to check for in the certificate's OIDC issuer extension (default: None) - --rekor-offline Perform offline Rekor verification using a bundle; + --rekor-offline Require offline Rekor verification with a bundle; implied by --rekor-bundle (default: False) Sigstore instance options: From 76b7f4c067cdd8c20a5cc75d0a886db87c5a3a39 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 13 Oct 2022 16:05:03 -0400 Subject: [PATCH 23/33] test: unused import Signed-off-by: William Woodruff --- test/test_verify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/test_verify.py b/test/test_verify.py index 1bbdf02d3..31c179ebc 100644 --- a/test/test_verify.py +++ b/test/test_verify.py @@ -14,7 +14,7 @@ import pytest -from sigstore._internal.rekor.client import RekorBundle, RekorEntry +from sigstore._internal.rekor.client import RekorBundle from sigstore._verify import ( CertificateVerificationFailure, VerificationFailure, From 64370f5720ff547234ead7a22ad972d5e4cc12c0 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 13 Oct 2022 16:39:25 -0400 Subject: [PATCH 24/33] sigstore: test Rekor entry's consistency against signing artifacts Signed-off-by: William Woodruff --- sigstore/_internal/rekor/client.py | 2 +- sigstore/_verify.py | 59 +++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/sigstore/_internal/rekor/client.py b/sigstore/_internal/rekor/client.py index bd3282bac..50f101408 100644 --- a/sigstore/_internal/rekor/client.py +++ b/sigstore/_internal/rekor/client.py @@ -294,7 +294,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/_verify.py b/sigstore/_verify.py index 7bfddd25f..a2eb34319 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 @@ -247,6 +248,60 @@ def verify( entry: Optional[RekorEntry] if offline_rekor_entry is not None: + # TODO(ww): This should all go in a separate API, probably under the + # RekorEntry class. + logger.debug("offline Rekor entry: checking consistency") + + try: + entry_body = json.loads(base64.b64decode(offline_rekor_entry.body)) + except Exception as e: + 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 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}')" + ) + ) + + if expected_cert != cert: + return VerificationFailure( + reason=( + f"Rekor entry's certificate ('{expected_cert}') does not " + f"match supplied certificate ('{certificate}')" + ) + ) + + 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 is consistent with signing artifacts!") entry = offline_rekor_entry else: # Retrieve the relevant Rekor entry to verify the inclusion proof and SET. @@ -272,7 +327,7 @@ def verify( reason=f"invalid Rekor inclusion proof: {inval_inclusion_proof}" ) else: - logger.debug("offline Rekor entry used; skipping Merkle inclusion proof") + logger.debug("offline Rekor entry: skipping Merkle inclusion proof") # 5) Verify the Signed Entry Timestamp (SET) supplied by Rekor for this artifact try: @@ -290,7 +345,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() From 96bec0f1b19a934d8846812fba87e7b9a8a8ff34 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 13 Oct 2022 16:43:07 -0400 Subject: [PATCH 25/33] conftest: strip trailing whitespace from cert and sig Trailing whitespace from the signature was breaking the Rekor consistency check. Signed-off-by: William Woodruff --- test/conftest.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/conftest.py b/test/conftest.py index d01103280..7056d85d2 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -62,6 +62,11 @@ def _signed_asset(name: str) -> Tuple[bytes, bytes, bytes]: if bundle.is_file(): bundle_bytes = bundle.read_bytes() - return (file.read_bytes(), cert.read_bytes(), sig.read_bytes(), bundle_bytes) + return ( + file.read_bytes(), + cert.read_bytes().rstrip(), + sig.read_bytes().rstrip(), + bundle_bytes, + ) return _signed_asset From 76e2700a3ab74bbc30d6767346e8a930ef846140 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 13 Oct 2022 16:47:28 -0400 Subject: [PATCH 26/33] treewide: use .rekor for offline rekor bundle files Signed-off-by: William Woodruff --- .gitignore | 4 ++-- README.md | 2 +- sigstore/_cli.py | 6 +++--- .../{offline-rekor.txt.bundle => offline-rekor.txt.rekor} | 0 test/conftest.py | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) rename test/assets/{offline-rekor.txt.bundle => offline-rekor.txt.rekor} (100%) diff --git a/.gitignore b/.gitignore index c3e552b38..5f33313e1 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ build *.pem *.sh *.pub -*.bundle +*.rekor # Don't ignore these files when we intend to include them !sigstore/_store/*.crt @@ -24,4 +24,4 @@ build !test/assets/*.txt !test/assets/*.crt !test/assets/*.sig -!test/assets/*.bundle +!test/assets/*.rekor diff --git a/README.md b/README.md index b75006a5a..9ef5150b0 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ OpenID Connect options: Output options: --no-default-files Don't emit the default output files ({input}.sig, - {input}.crt, {input}.bundle) (default: False) + {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) diff --git a/sigstore/_cli.py b/sigstore/_cli.py index 8d525a2c6..d25dc44e5 100644 --- a/sigstore/_cli.py +++ b/sigstore/_cli.py @@ -167,7 +167,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, {input}.crt, {input}.bundle)", + help="Don't emit the default output files ({input}.sig, {input}.crt, {input}.rekor)", ) output_options.add_argument( "--signature", @@ -366,7 +366,7 @@ def _sign(args: argparse.Namespace) -> None: 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}.bundle" + bundle = file.parent / f"{file.name}.rekor" if not args.overwrite: extants = [] @@ -471,7 +471,7 @@ def _verify(args: argparse.Namespace) -> None: if cert is None: cert = file.parent / f"{file.name}.crt" if bundle is None: - bundle = file.parent / f"{file.name}.bundle" + bundle = file.parent / f"{file.name}.rekor" missing = [] if not sig.is_file(): diff --git a/test/assets/offline-rekor.txt.bundle b/test/assets/offline-rekor.txt.rekor similarity index 100% rename from test/assets/offline-rekor.txt.bundle rename to test/assets/offline-rekor.txt.rekor diff --git a/test/conftest.py b/test/conftest.py index 7056d85d2..f359062bf 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -56,7 +56,7 @@ 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}.bundle" + bundle = _ASSETS / f"{name}.rekor" bundle_bytes = None if bundle.is_file(): From df005fe47de7c600fe20a625f619177270178f79 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 13 Oct 2022 16:50:17 -0400 Subject: [PATCH 27/33] _verify: lint fixes Signed-off-by: William Woodruff --- sigstore/_verify.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/sigstore/_verify.py b/sigstore/_verify.py index a2eb34319..622eb3791 100644 --- a/sigstore/_verify.py +++ b/sigstore/_verify.py @@ -254,7 +254,7 @@ def verify( try: entry_body = json.loads(base64.b64decode(offline_rekor_entry.body)) - except Exception as e: + except Exception: return VerificationFailure( reason="couldn't parse offline Rekor entry's body" ) @@ -265,7 +265,10 @@ def verify( 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 version ('{version}')" + reason=( + f"Rekor entry is of unsupported kind ('{kind}') or API " + f"version ('{version}')" + ) ) spec = entry_body["spec"] From b5eb560f4317f5bb8b246aab7f47383caa3742fe Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 13 Oct 2022 16:55:38 -0400 Subject: [PATCH 28/33] _verify: more lint fixes Signed-off-by: William Woodruff --- sigstore/_verify.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sigstore/_verify.py b/sigstore/_verify.py index 622eb3791..51c758ba5 100644 --- a/sigstore/_verify.py +++ b/sigstore/_verify.py @@ -284,7 +284,7 @@ def verify( return VerificationFailure( reason=( f"Rekor entry's signature ('{expected_sig}') does not " - f"match supplied signature ('{signature}')" + f"match supplied signature ('{signature.decode()}')" ) ) @@ -292,7 +292,7 @@ def verify( return VerificationFailure( reason=( f"Rekor entry's certificate ('{expected_cert}') does not " - f"match supplied certificate ('{certificate}')" + f"match supplied certificate ('{cert}')" ) ) From 1c3788cda044ebac5fa347bad992c48aa5cdcfc0 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 13 Oct 2022 17:28:55 -0400 Subject: [PATCH 29/33] README, _cli: `--rekor-offline` -> `--require-rekor-offline` Signed-off-by: William Woodruff --- README.md | 7 ++++--- sigstore/_cli.py | 10 +++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 9ef5150b0..8220fe0a2 100644 --- a/README.md +++ b/README.md @@ -151,8 +151,8 @@ Verifying: ``` usage: sigstore verify [-h] [--certificate FILE] [--signature FILE] [--rekor-bundle FILE] [--cert-email EMAIL] - [--cert-oidc-issuer URL] [--rekor-offline] [--staging] - [--rekor-url URL] + [--cert-oidc-issuer URL] [--require-rekor-offline] + [--staging] [--rekor-url URL] FILE [FILE ...] positional arguments: @@ -176,7 +176,8 @@ Extended verification options: --cert-oidc-issuer URL The OIDC issuer URL to check for in the certificate's OIDC issuer extension (default: None) - --rekor-offline Require offline Rekor verification with a bundle; + --require-rekor-offline + Require offline Rekor verification with a bundle; implied by --rekor-bundle (default: False) Sigstore instance options: diff --git a/sigstore/_cli.py b/sigstore/_cli.py index d25dc44e5..88d5126af 100644 --- a/sigstore/_cli.py +++ b/sigstore/_cli.py @@ -285,9 +285,9 @@ def _parser() -> argparse.ArgumentParser: help="The OIDC issuer URL to check for in the certificate's OIDC issuer extension", ) verification_options.add_argument( - "--rekor-offline", + "--require-rekor-offline", action="store_true", - default=_boolify_env("SIGSTORE_REKOR_OFFLINE"), + default=_boolify_env("SIGSTORE_REQUIRE_REKOR_OFFLINE"), help="Require offline Rekor verification with a bundle; implied by --rekor-bundle", ) @@ -445,8 +445,8 @@ def _sign(args: argparse.Namespace) -> None: def _verify(args: argparse.Namespace) -> None: - # The presence of --rekor-bundle implies --rekor-offline. - args.rekor_offline = args.rekor_offline or args.rekor_bundle + # 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. @@ -478,7 +478,7 @@ def _verify(args: argparse.Namespace) -> None: missing.append(str(sig)) if not cert.is_file(): missing.append(str(cert)) - if not bundle.is_file() and args.rekor_offline: + 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. From 44f6546569c0bf3b5f6749bbefa3819c9f819b11 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Fri, 14 Oct 2022 10:02:53 -0400 Subject: [PATCH 30/33] Apply suggestions from code review Co-authored-by: Hayden B Signed-off-by: William Woodruff --- sigstore/_verify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sigstore/_verify.py b/sigstore/_verify.py index 51c758ba5..dc1d18ba8 100644 --- a/sigstore/_verify.py +++ b/sigstore/_verify.py @@ -167,7 +167,7 @@ def verify( # certificate 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 signature was signed by the public key in the + # 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. From d1a8157b27ec9cc3ba45c0b709aa891363e70035 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Fri, 14 Oct 2022 10:10:21 -0400 Subject: [PATCH 31/33] _verify: clarify comments, add a long comment explaining process Signed-off-by: William Woodruff --- sigstore/_verify.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/sigstore/_verify.py b/sigstore/_verify.py index dc1d18ba8..ed3fef979 100644 --- a/sigstore/_verify.py +++ b/sigstore/_verify.py @@ -142,8 +142,8 @@ def verify( 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 guarantee - the log's consistency. + 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. @@ -163,8 +163,8 @@ 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 + # 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 @@ -248,9 +248,19 @@ def verify( 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: checking consistency") + logger.debug("offline Rekor entry: ensuring contents match signing materials") try: entry_body = json.loads(base64.b64decode(offline_rekor_entry.body)) @@ -304,7 +314,7 @@ def verify( ) ) - logger.debug("offline Rekor entry is consistent with signing artifacts!") + 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. From e30dd3acd6426f4e0f2cf65c1c40d48a5f556642 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Fri, 14 Oct 2022 12:05:52 -0400 Subject: [PATCH 32/33] _verify: blacken Signed-off-by: William Woodruff --- sigstore/_verify.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sigstore/_verify.py b/sigstore/_verify.py index ed3fef979..efde13635 100644 --- a/sigstore/_verify.py +++ b/sigstore/_verify.py @@ -260,7 +260,9 @@ def verify( # 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") + logger.debug( + "offline Rekor entry: ensuring contents match signing materials" + ) try: entry_body = json.loads(base64.b64decode(offline_rekor_entry.body)) From ea45d3e64ae1f936faeac1f724378cddb477389e Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Tue, 1 Nov 2022 12:31:14 -0400 Subject: [PATCH 33/33] _cli: add warnings when `--rekor-bundle` is used Signed-off-by: William Woodruff --- sigstore/_cli.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/sigstore/_cli.py b/sigstore/_cli.py index 3e8dd8473..35a784547 100644 --- a/sigstore/_cli.py +++ b/sigstore/_cli.py @@ -337,6 +337,14 @@ def main() -> None: def _sign(args: argparse.Namespace) -> None: + # `--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 ( @@ -448,6 +456,14 @@ def _sign(args: argparse.Namespace) -> None: def _verify(args: argparse.Namespace) -> None: + # `--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