From 61a2f86df315d95ea6bb72c9a48cef468a17b443 Mon Sep 17 00:00:00 2001 From: achamayou Date: Wed, 22 Jul 2026 23:07:27 +0100 Subject: [PATCH 01/27] Add recovery snapshot endorsement sidecars Derive and validate COSE previous-service-identity chains from the public ledger suffix before recovery snapshot installation, persist them as revalidated adjacent sidecars, and fall back to full replay on any invalid material. Extend Python verification tooling and cover direct scanning, lifecycle, tamper, reuse, and multi-DR recovery. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 Copilot-Session: 4d631113-a6ff-453d-a658-3b46aaaec95a Copilot-Session: 972cd758-c6d2-4d1b-b043-b482e129cb54 --- CMakeLists.txt | 6 + python/src/ccf/cose.py | 123 +++++ python/src/ccf/ledger.py | 53 ++- python/src/ccf/read_ledger.py | 30 ++ python/tests/test_cose_sign.py | 73 +++ src/host/files_cleanup_timer.h | 19 + src/host/test/files_cleanup_test.cpp | 26 + src/node/node_state.h | 422 ++++++++++++++-- src/node/recovery_snapshot_ledger.h | 450 ++++++++++++++++++ src/node/rpc/network_identity_chain_helpers.h | 82 ++++ src/node/rpc/network_identity_subsystem.h | 65 +-- src/node/snapshot_serdes.h | 385 ++++++++++++++- src/node/test/network_identity_subsystem.cpp | 66 +++ src/node/test/snapshotter.cpp | 119 +++++ src/snapshots/filenames.h | 15 + tests/ci-buckets.txt | 1 + tests/recovery_snapshot_endorsements.py | 315 ++++++++++++ 17 files changed, 2151 insertions(+), 99 deletions(-) create mode 100644 src/node/recovery_snapshot_ledger.h create mode 100644 tests/recovery_snapshot_endorsements.py diff --git a/CMakeLists.txt b/CMakeLists.txt index fc17ba071c09..eac641cd7306 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1103,6 +1103,12 @@ if(BUILD_TESTS) ADDITIONAL_ARGS --regex ^recovery_intermediate_snapshot_join$ ) + add_e2e_test( + NAME recovery_snapshot_endorsements_test + PYTHON_SCRIPT ${CMAKE_SOURCE_DIR}/tests/recovery_snapshot_endorsements.py + BUCKET bucket_b + ) + add_e2e_test( NAME recovery_test_suite PYTHON_SCRIPT ${CMAKE_SOURCE_DIR}/tests/e2e_suite.py diff --git a/python/src/ccf/cose.py b/python/src/ccf/cose.py index 37be544a177a..cdceadb7d5b3 100644 --- a/python/src/ccf/cose.py +++ b/python/src/ccf/cose.py @@ -2,6 +2,7 @@ # Licensed under the Apache 2.0 License. import argparse +import io import sys from typing import Any @@ -9,6 +10,7 @@ import base64 import cwt import cwt.const +import cwt.exceptions import cwt.utils import cwt.enums import cbor2 @@ -25,9 +27,11 @@ from cryptography.hazmat.primitives.serialization import ( load_pem_private_key, load_pem_public_key, + load_der_public_key, ) from cryptography.x509.base import CertificatePublicKeyTypes from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from ccf.tx_id import TxID Pem = str @@ -53,6 +57,11 @@ CCF_PROOF_LEAF_LABEL = 1 CCF_PROOF_PATH_LABEL = 2 +CCF_V1_LABEL = "ccf.v1" +CCF_ENDORSEMENT_RANGE_BEGIN = "epoch.start.txid" +CCF_ENDORSEMENT_RANGE_END = "epoch.end.txid" +RECOVERY_VIEW_CHANGE = 2 + def default_algorithm_for_key(key) -> int: """ @@ -189,6 +198,120 @@ def verify_cose_sign1_with_key(key, cose_sign1, payload=None): return cose_ctx.decode_with_headers(cose_sign1, cose_key, detached_payload=payload) +def serialize_cose_endorsements(endorsements: list[bytes]) -> bytes: + if not all(isinstance(endorsement, bytes) for endorsement in endorsements): + raise TypeError("COSE endorsements must be bytes") + return cbor2.dumps(endorsements) + + +def deserialize_cose_endorsements(serialized: bytes) -> list[bytes]: + stream = io.BytesIO(serialized) + decoder = cbor2.CBORDecoder(stream) + try: + endorsements = decoder.decode() + except (cbor2.CBORDecodeError, EOFError) as e: + raise ValueError("Invalid snapshot endorsements CBOR") from e + try: + decoder.decode() + except cbor2.CBORDecodeEOF: + pass + else: + raise ValueError("Trailing data after snapshot endorsements CBOR array") + if not isinstance(endorsements, list) or not all( + isinstance(endorsement, bytes) for endorsement in endorsements + ): + raise ValueError("Snapshot endorsements must be a CBOR array of byte strings") + return endorsements + + +def verify_cose_endorsements( + endorsements: list[bytes], + target_key: CertificatePublicKeyTypes, + snapshot_seqno: int, +) -> CertificatePublicKeyTypes: + current_key = target_key + ranges: list[tuple[TxID, TxID]] = [] + + for index, endorsement in enumerate(endorsements): + current_key_pem = current_key.public_bytes( + Encoding.PEM, PublicFormat.SubjectPublicKeyInfo + ) + try: + protected, _, endorsed_key_der = verify_cose_sign1_with_key( + current_key_pem, endorsement + ) + except cwt.exceptions.CWTError as e: + raise ValueError( + f"COSE endorsement at index {index} failed signature verification" + ) from e + + ccf_headers = protected.get(CCF_V1_LABEL) + if not isinstance(ccf_headers, dict): + raise ValueError( + f"COSE endorsement at index {index} has no {CCF_V1_LABEL} headers" + ) + begin_header = ccf_headers.get(CCF_ENDORSEMENT_RANGE_BEGIN) + end_header = ccf_headers.get(CCF_ENDORSEMENT_RANGE_END) + if not isinstance(begin_header, str) or not isinstance(end_header, str): + raise ValueError( + f"COSE endorsement at index {index} has invalid epoch headers" + ) + begin = TxID.from_str(begin_header) + end = TxID.from_str(end_header) + if not begin.valid() or not end.valid() or end.seqno < begin.seqno: + raise ValueError( + f"COSE endorsement at index {index} has an invalid epoch range" + ) + if not isinstance(endorsed_key_der, bytes) or not endorsed_key_der: + raise ValueError( + f"COSE endorsement at index {index} contains no endorsed key" + ) + + ranges.append((begin, end)) + try: + endorsed_key = load_der_public_key(endorsed_key_der) + except ValueError as e: + raise ValueError( + f"COSE endorsement at index {index} contains an invalid public key" + ) from e + if not isinstance(endorsed_key, EllipticCurvePublicKey): + raise ValueError( + f"COSE endorsement at index {index} contains a non-EC public key" + ) + current_key = endorsed_key + + for (newer_begin, _), (_, older_end) in zip(ranges, ranges[1:]): + if ( + newer_begin.view - RECOVERY_VIEW_CHANGE != older_end.view + or newer_begin.seqno - 1 != older_end.seqno + ): + raise ValueError( + "COSE endorsement epoch ranges are not contiguous newest-to-oldest" + ) + + if ranges: + oldest_begin, oldest_end = ranges[-1] + if not oldest_begin.seqno <= snapshot_seqno <= oldest_end.seqno: + raise ValueError( + f"Oldest COSE endorsement range does not cover snapshot seqno {snapshot_seqno}" + ) + + return current_key + + +def verify_receipt_with_endorsements( + receipt_bytes: bytes, + target_key: CertificatePublicKeyTypes, + claim_digest: bytes, + endorsements: list[bytes], + snapshot_seqno: int, +): + snapshot_signer_key = verify_cose_endorsements( + endorsements, target_key, snapshot_seqno + ) + return verify_receipt(receipt_bytes, snapshot_signer_key, claim_digest) + + def verify_receipt( receipt_bytes: bytes, key: CertificatePublicKeyTypes, claim_digest: bytes ): diff --git a/python/src/ccf/ledger.py b/python/src/ccf/ledger.py index 21e488a10fdd..25eaea8d1de7 100644 --- a/python/src/ccf/ledger.py +++ b/python/src/ccf/ledger.py @@ -8,7 +8,7 @@ import json from dataclasses import dataclass -from cryptography.x509 import load_pem_x509_certificate +from cryptography.x509 import Certificate, load_pem_x509_certificate from cryptography.hazmat.backends import default_backend from ccf.merkletree import MerkleTree @@ -64,6 +64,7 @@ def __getattr__(name: str): COMMITTED_FILE_SUFFIX = ".committed" RECOVERY_FILE_SUFFIX = ".recovery" IGNORED_FILE_SUFFIX = ".ignored" +SNAPSHOT_ENDORSEMENTS_SUFFIX = ".endorsements" SHA256_DIGEST_SIZE = sha256().digest_size ENCODED_COSE_SIGN1_TAG = 0xD2 @@ -137,6 +138,17 @@ def is_snapshot_file_committed(file_name): return file_name.endswith(COMMITTED_FILE_SUFFIX) +def snapshot_endorsements_path(snapshot_path: str) -> str: + if not is_snapshot_file_committed(snapshot_path): + raise ValueError(f"Snapshot file {snapshot_path} is not committed") + return snapshot_path + SNAPSHOT_ENDORSEMENTS_SUFFIX + + +def read_snapshot_endorsements(path: str) -> list[bytes]: + with open(path, "rb") as endorsements_file: + return ccf.cose.deserialize_cose_endorsements(endorsements_file.read()) + + def digest(data): return sha256(data).digest() @@ -996,6 +1008,8 @@ def __init__(self, filename: str): super().__init__(SimpleBuffer.from_file(filename)) self._filename = filename self._file_size = os.path.getsize(filename) + self._receipt_bytes: bytes | None = None + self._snapshot_digest: bytes | None = None if self._file_size == 0: raise InvalidSnapshotException(f"{filename} is currently empty") @@ -1007,6 +1021,8 @@ def __init__(self, filename: str): receipt_pos = entry_start_pos + self._header.size receipt_bytes = _peek_all(self._file, pos=receipt_pos) snapshot_digest = sha256(_peek(self._file, receipt_pos, pos=0)).digest() + self._receipt_bytes = receipt_bytes + self._snapshot_digest = snapshot_digest self._verify_snapshot_receipt(receipt_bytes, receipt_pos, snapshot_digest) def is_committed(self): @@ -1067,6 +1083,41 @@ def _verify_cose_snapshot_receipt( receipt_bytes, self._service_public_key(), snapshot_digest ) + def verify_cose_receipt( + self, + target_service_certificate: Certificate | str | bytes, + endorsements: list[bytes] | None = None, + ): + if self._receipt_bytes is None or self._snapshot_digest is None: + raise InvalidSnapshotException( + "Snapshot does not contain a current-format receipt" + ) + if self._receipt_bytes[0] != ENCODED_COSE_SIGN1_TAG: + raise InvalidSnapshotException( + "Only COSE snapshot receipts support endorsement sidecars" + ) + + if isinstance(target_service_certificate, Certificate): + certificate = target_service_certificate + else: + certificate_bytes = ( + target_service_certificate.encode("ascii") + if isinstance(target_service_certificate, str) + else target_service_certificate + ) + certificate = load_pem_x509_certificate( + certificate_bytes, default_backend() + ) + + snapshot_seqno, _ = snapshot_index_from_filename(self._filename) + return ccf.cose.verify_receipt_with_endorsements( + self._receipt_bytes, + certificate.public_key(), + self._snapshot_digest, + endorsements or [], + snapshot_seqno, + ) + def _verify_json_snapshot_receipt( self, receipt_bytes: bytes, receipt_pos: int, snapshot_digest: bytes ): diff --git a/python/src/ccf/read_ledger.py b/python/src/ccf/read_ledger.py index 4c09d004abb3..b847980a276a 100644 --- a/python/src/ccf/read_ledger.py +++ b/python/src/ccf/read_ledger.py @@ -154,6 +154,8 @@ def run( uncommitted=False, read_recovery_files=False, tables_format_rules=None, + snapshot_service_cert=None, + snapshot_endorsements=None, # Deprecated parameter, kept for backward compatibility insecure_skip_verification=None, ): @@ -180,6 +182,19 @@ def run( if is_snapshot: snapshot_file = paths[0] with ccf.ledger.Snapshot(snapshot_file) as snapshot: + if snapshot_service_cert is not None: + with open(snapshot_service_cert, "rb") as cert_file: + service_cert = cert_file.read() + endorsements = ( + ccf.ledger.read_snapshot_endorsements(snapshot_endorsements) + if snapshot_endorsements is not None + else [] + ) + snapshot.verify_cose_receipt(service_cert, endorsements) + print( + "Verified snapshot receipt against target service identity " + f"with {len(endorsements)} endorsement(s)" + ) print( f"Reading snapshot from {snapshot_file} ({'' if snapshot.is_committed() else 'un'}committed)" ) @@ -263,6 +278,14 @@ def main(): help="Indicates that the path to read is a snapshot", action="store_true", ) + parser.add_argument( + "--snapshot-service-cert", + help="PEM service certificate to trust when verifying a COSE snapshot", + ) + parser.add_argument( + "--snapshot-endorsements", + help="CBOR endorsements sidecar used with --snapshot-service-cert", + ) parser.add_argument( "--uncommitted", help="Also parse uncommitted ledger files. Note that if these are in a live node directory, they may be being modified.", @@ -319,6 +342,11 @@ def main(): args = parser.parse_args() + if args.snapshot_endorsements and not args.snapshot_service_cert: + parser.error("--snapshot-endorsements requires --snapshot-service-cert") + if (args.snapshot_service_cert or args.snapshot_endorsements) and not args.snapshot: + parser.error("snapshot verification options require --snapshot") + # Parse verification level verification_level = None if args.verification_level: @@ -344,6 +372,8 @@ def main(): insecure_skip_verification=insecure_skip_verification, uncommitted=args.uncommitted, read_recovery_files=args.recovery, + snapshot_service_cert=args.snapshot_service_cert, + snapshot_endorsements=args.snapshot_endorsements, ): sys.exit(1) diff --git a/python/tests/test_cose_sign.py b/python/tests/test_cose_sign.py index 5e733d344d81..8dd856954f40 100644 --- a/python/tests/test_cose_sign.py +++ b/python/tests/test_cose_sign.py @@ -16,6 +16,9 @@ from cryptography.hazmat.primitives.asymmetric import utils import ccf.cose import cbor2 +import cwt +import cwt.enums +import cwt.utils import pytest @@ -123,3 +126,73 @@ def test_create_cose_sign1_prepare_and_finish(curve): ccf.cose.verify_cose_sign1_with_cert( cert.encode(), finished_cose_sign1, use_key=False ) + + +def make_cose_endorsement(signer, payload_key, begin: str, end: str) -> bytes: + signer_priv_pem, signer_pub_pem = make_pem_pair(signer) + kid = ccf.cose.key_fingerprint_from_key(signer_pub_pem) + cose_key = cwt.COSEKey.from_pem(signer_priv_pem, kid=kid) + protected = cwt.utils.ResolvedHeader( + { + int(cwt.COSEHeaders.KID): kid.encode("utf-8"), + ccf.cose.CCF_V1_LABEL: { + ccf.cose.CCF_ENDORSEMENT_RANGE_BEGIN: begin, + ccf.cose.CCF_ENDORSEMENT_RANGE_END: end, + }, + } + ) + payload = payload_key.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) + return cwt.COSE.new( + alg_auto_inclusion=True, deterministic_header=True + ).encode_and_sign(payload, cose_key, protected=protected) + + +def test_cose_endorsement_sidecar_chain(): + identities = [make_private_key(ec.SECP384R1()) for _ in range(3)] + oldest = make_cose_endorsement( + identities[1], identities[0].public_key(), "2.1", "4.100" + ) + newest = make_cose_endorsement( + identities[2], identities[1].public_key(), "6.101", "8.200" + ) + endorsements = [newest, oldest] + + serialized = ccf.cose.serialize_cose_endorsements(endorsements) + assert ccf.cose.deserialize_cose_endorsements(serialized) == endorsements + + snapshot_key = ccf.cose.verify_cose_endorsements( + endorsements, identities[2].public_key(), snapshot_seqno=50 + ) + assert snapshot_key.public_bytes( + Encoding.DER, PublicFormat.SubjectPublicKeyInfo + ) == identities[0].public_key().public_bytes( + Encoding.DER, PublicFormat.SubjectPublicKeyInfo + ) + + with pytest.raises(ValueError): + ccf.cose.verify_cose_endorsements( + list(reversed(endorsements)), + identities[2].public_key(), + snapshot_seqno=50, + ) + + tampered = [bytearray(newest), oldest] + tampered[0][-1] ^= 0xFF + with pytest.raises(ValueError): + ccf.cose.verify_cose_endorsements( + [bytes(tampered[0]), tampered[1]], + identities[2].public_key(), + snapshot_seqno=50, + ) + + with pytest.raises(ValueError, match="does not cover"): + ccf.cose.verify_cose_endorsements( + endorsements, identities[2].public_key(), snapshot_seqno=1000 + ) + + +def test_cose_endorsement_sidecar_encoding_is_strict(): + with pytest.raises(ValueError, match="CBOR array"): + ccf.cose.deserialize_cose_endorsements(cbor2.dumps({"not": "an array"})) + with pytest.raises(ValueError, match="Trailing data"): + ccf.cose.deserialize_cose_endorsements(cbor2.dumps([]) + b"\x00") diff --git a/src/host/files_cleanup_timer.h b/src/host/files_cleanup_timer.h index 28115c7ec561..aa373b7758ed 100644 --- a/src/host/files_cleanup_timer.h +++ b/src/host/files_cleanup_timer.h @@ -299,6 +299,25 @@ namespace asynchost path.filename(), ec.message()); } + else + { + const auto sidecar_path = + snapshots::get_snapshot_endorsements_path(path); + std::error_code sidecar_ec; + { + TimeBoundLogger log_remove_sidecar_if_slow(fmt::format( + "Deleting old snapshot endorsements sidecar - remove({})", + sidecar_path.filename())); + std::filesystem::remove(sidecar_path, sidecar_ec); + } + if (sidecar_ec) + { + LOG_FAIL_FMT( + "Failed to delete old snapshot endorsements sidecar {}: {}", + sidecar_path.filename(), + sidecar_ec.message()); + } + } } } } diff --git a/src/host/test/files_cleanup_test.cpp b/src/host/test/files_cleanup_test.cpp index c1951b28630b..8882e3d60880 100644 --- a/src/host/test/files_cleanup_test.cpp +++ b/src/host/test/files_cleanup_test.cpp @@ -518,6 +518,32 @@ TEST_CASE("highest_committed_snapshot_seqno: ignores uncommitted snapshots") fs::remove_all(tmp); } +TEST_CASE("cleanup_old_snapshots: removes adjacent endorsement sidecars") +{ + auto tmp = make_unique_test_dir("test_snapshot_sidecar_cleanup"); + const auto oldest = create_committed_snapshot(tmp, 100, 105); + const auto middle = create_committed_snapshot(tmp, 200, 205); + const auto newest = create_committed_snapshot(tmp, 300, 305); + for (const auto& snapshot : {oldest, middle, newest}) + { + write_file( + snapshots::get_snapshot_endorsements_path(snapshot), "endorsements"); + } + + const auto committed = find_committed_snapshots(tmp); + REQUIRE(committed.has_value()); + cleanup_old_snapshots(*committed, 1); + + CHECK_FALSE(fs::exists(oldest)); + CHECK_FALSE(fs::exists(snapshots::get_snapshot_endorsements_path(oldest))); + CHECK_FALSE(fs::exists(middle)); + CHECK_FALSE(fs::exists(snapshots::get_snapshot_endorsements_path(middle))); + CHECK(fs::exists(newest)); + CHECK(fs::exists(snapshots::get_snapshot_endorsements_path(newest))); + + fs::remove_all(tmp); +} + // ---- snapshot watermark in cleanup_old_ledger_chunks tests ---- TEST_CASE( diff --git a/src/node/node_state.h b/src/node/node_state.h index caf4357872a8..aff066f4de1f 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -45,6 +45,7 @@ #include "node/node_inbound_message.h" #include "node/node_to_node_channel_manager.h" #include "node/recovery_decision_protocol.h" +#include "node/recovery_snapshot_ledger.h" #include "node/signature_cache_subsystem.h" #include "node/snapshotter.h" #include "node_to_node.h" @@ -471,6 +472,7 @@ namespace ccf std::shared_ptr jwt_key_auto_refresh; std::unique_ptr startup_snapshot_info = nullptr; + std::optional startup_snapshot_path = std::nullopt; // Set to the snapshot seqno when a node starts from one and remembered for // the lifetime of the node ccf::kv::Version startup_seqno = 0; @@ -520,24 +522,42 @@ namespace ccf snapshots::find_committed_snapshots_in_directories(directories); for (const auto& [snapshot_seqno, snapshot_path] : committed_snapshots) { - auto snapshot_data = files::slurp(snapshot_path); - - LOG_INFO_FMT( - "Found latest local snapshot file: {} (size: {})", - snapshot_path, - snapshot_data.size()); - - const auto segments = separate_segments(snapshot_data); - + std::vector snapshot_data; try { - verify_snapshot(segments, config.recover.previous_service_identity); + snapshot_data = files::slurp(snapshot_path); + + LOG_INFO_FMT( + "Found latest local snapshot file: {} (size: {})", + snapshot_path, + snapshot_data.size()); + + const auto segments = separate_segments(snapshot_data); + if (start_type == StartType::Recover) + { + // Validate the receipt structure and snapshot digest, but defer the + // identity check until the recovery sidecar gate has run. + verify_snapshot(segments); + } + else + { + verify_snapshot(segments, config.recover.previous_service_identity); + } } catch (const std::exception& e) { LOG_FAIL_FMT( "Error while verifying {}: {}", snapshot_path.string(), e.what()); + if (start_type == StartType::Recover) + { + LOG_INFO_FMT( + "Leaving unusable recovery snapshot {} untouched and looking " + "for the next candidate", + snapshot_path.string()); + continue; + } + const auto dir = snapshot_path.parent_path(); const auto file_name = snapshot_path.filename(); @@ -569,6 +589,18 @@ namespace ccf continue; } + if (start_type == StartType::Recover) + { + startup_snapshot_info = std::make_unique( + snapshot_seqno, std::move(snapshot_data)); + startup_snapshot_path = snapshot_path; + LOG_INFO_FMT( + "Selected recovery snapshot {} for previous-service-identity " + "verification", + snapshot_path.string()); + return; + } + set_startup_snapshot(snapshot_seqno, std::move(snapshot_data)); return; } @@ -587,8 +619,20 @@ namespace ccf startup_snapshot_info = std::make_unique( snapshot_seqno, std::move(snapshot_data)); + startup_snapshot_path.reset(); + + install_startup_snapshot(); + } - LOG_INFO_FMT("Setting startup snapshot seqno to {}", snapshot_seqno); + void install_startup_snapshot() + { + if (!startup_snapshot_info) + { + throw std::logic_error("No startup snapshot selected for installation"); + } + + LOG_INFO_FMT( + "Setting startup snapshot seqno to {}", startup_snapshot_info->seqno); startup_seqno = startup_snapshot_info->seqno; last_recovered_idx = startup_seqno; @@ -635,6 +679,298 @@ namespace ccf } } + bool drop_recovery_snapshot_sidecar_unsafe( + const std::filesystem::path& sidecar_path) + { + std::error_code ec; + const auto exists = std::filesystem::exists(sidecar_path, ec); + if (ec) + { + LOG_FAIL_FMT( + "Unable to inspect snapshot endorsements sidecar {}: {}", + sidecar_path.string(), + ec.message()); + return false; + } + if (!exists) + { + return true; + } + + const auto removed = std::filesystem::remove(sidecar_path, ec); + if (ec || !removed) + { + LOG_FAIL_FMT( + "Unable to remove stale snapshot endorsements sidecar {}: {}", + sidecar_path.string(), + ec ? ec.message() : "file was not removed"); + return false; + } + + LOG_INFO_FMT( + "Removed stale snapshot endorsements sidecar {}", + sidecar_path.string()); + return true; + } + + void start_public_ledger_recovery_unsafe() + { + sm.advance(NodeStartupState::readingPublicLedger); + start_ledger_recovery_unsafe(); + } + + void fallback_from_recovery_snapshot_unsafe(const std::string& reason) + { + LOG_FAIL_FMT( + "Recovery snapshot cannot be verified under the configured previous " + "service identity: {}. Falling back to full-ledger recovery.", + reason); + + if (startup_snapshot_path.has_value()) + { + const auto sidecar_path = + snapshots::get_snapshot_endorsements_path(*startup_snapshot_path); + std::ignore = drop_recovery_snapshot_sidecar_unsafe(sidecar_path); + } + + startup_snapshot_info.reset(); + startup_snapshot_path.reset(); + startup_seqno = 0; + last_recovered_idx = 0; + last_recovered_signed_idx = 0; + view_history.clear(); + + start_public_ledger_recovery_unsafe(); + } + + bool recovery_snapshot_directory_is_writable_unsafe() const + { + if (!startup_snapshot_path.has_value()) + { + return false; + } + + std::error_code ec; + const auto snapshot_dir = std::filesystem::weakly_canonical( + startup_snapshot_path->parent_path(), ec); + if (ec) + { + return false; + } + const auto writable_dir = + std::filesystem::weakly_canonical(config.snapshots.directory, ec); + return !ec && snapshot_dir == writable_dir; + } + + void install_recovery_snapshot_and_start_unsafe() + { + install_startup_snapshot(); + start_public_ledger_recovery_unsafe(); + } + + void start_recovery_snapshot_verification_unsafe() + { + if ( + !startup_snapshot_info || !startup_snapshot_path.has_value() || + !config.recover.previous_service_identity.has_value()) + { + start_public_ledger_recovery_unsafe(); + return; + } + + const auto segments = separate_segments(startup_snapshot_info->raw); + const ccf::crypto::Pem target_identity( + *config.recover.previous_service_identity); + const auto sidecar_path = + snapshots::get_snapshot_endorsements_path(*startup_snapshot_path); + + std::error_code ec; + const auto sidecar_exists = std::filesystem::exists(sidecar_path, ec); + if (ec) + { + fallback_from_recovery_snapshot_unsafe(fmt::format( + "unable to inspect sidecar {}: {}", + sidecar_path.string(), + ec.message())); + return; + } + + if (sidecar_exists) + { + try + { + const auto endorsements = + read_cose_endorsements_sidecar(sidecar_path); + verify_recovery_snapshot( + segments, + target_identity, + endorsements, + startup_snapshot_info->seqno); + LOG_INFO_FMT( + "Reusing verified snapshot endorsements sidecar {}", + sidecar_path.string()); + install_recovery_snapshot_and_start_unsafe(); + return; + } + catch (const std::exception& e) + { + LOG_FAIL_FMT( + "Snapshot endorsements sidecar {} is invalid: {}", + sidecar_path.string(), + e.what()); + if (!drop_recovery_snapshot_sidecar_unsafe(sidecar_path)) + { + fallback_from_recovery_snapshot_unsafe( + "invalid sidecar could not be removed"); + return; + } + } + } + + try + { + verify_recovery_snapshot( + segments, target_identity, {}, startup_snapshot_info->seqno); + LOG_INFO_FMT( + "Recovery snapshot {} is directly signed by the configured previous " + "service identity", + startup_snapshot_path->string()); + install_recovery_snapshot_and_start_unsafe(); + return; + } + catch (const std::exception& e) + { + if (segments.receipt.empty() || segments.receipt[0] != 0xD2) + { + fallback_from_recovery_snapshot_unsafe(fmt::format( + "old-style snapshot receipt cannot be augmented: {}", e.what())); + return; + } + + LOG_INFO_FMT( + "Recovery snapshot {} is not directly signed by the configured " + "previous service identity; scanning the public ledger suffix for " + "COSE endorsements", + startup_snapshot_path->string()); + } + + if (!recovery_snapshot_directory_is_writable_unsafe()) + { + fallback_from_recovery_snapshot_unsafe( + "the selected snapshot is in a read-only directory and has no valid " + "endorsements sidecar"); + return; + } + + if ( + startup_snapshot_info->seqno == + std::numeric_limits::max()) + { + fallback_from_recovery_snapshot_unsafe( + "snapshot seqno cannot be incremented for ledger scanning"); + return; + } + + std::optional scan = std::nullopt; + try + { + scan = scan_recovery_snapshot_ledger_files( + config.ledger, + network.tables->get_encryptor(), + startup_snapshot_info->seqno); + } + catch (const std::exception& e) + { + fallback_from_recovery_snapshot_unsafe(e.what()); + return; + } + finish_recovery_snapshot_endorsements_scan_unsafe(std::move(*scan)); + } + + void finish_recovery_snapshot_endorsements_scan_unsafe( + RecoverySnapshotLedgerScan&& scan) + { + if ( + !startup_snapshot_info || !startup_snapshot_path.has_value() || + !config.recover.previous_service_identity.has_value()) + { + fallback_from_recovery_snapshot_unsafe( + "snapshot scan completed without a selected snapshot or target " + "identity"); + return; + } + + const auto snapshot_seqno = startup_snapshot_info->seqno; + ccf::SerialisedCoseEndorsements serialised_endorsements; + auto sidecar_path = + snapshots::get_snapshot_endorsements_path(*startup_snapshot_path); + try + { + if ( + scan.last_signed_idx <= + static_cast<::consensus::Index>(snapshot_seqno)) + { + throw std::logic_error( + "no committed ledger suffix was found after the snapshot"); + } + if (scan.service_infos.empty()) + { + throw std::logic_error( + "no service identity was found in the committed ledger suffix"); + } + + const ccf::crypto::Pem target_identity( + *config.recover.previous_service_identity); + const auto& latest_service = scan.service_infos.back().second; + if (latest_service.cert != target_identity) + { + throw std::logic_error( + "latest ledger service identity does not match the configured " + "previous service identity"); + } + if (!latest_service.current_service_create_txid.has_value()) + { + throw std::logic_error( + "latest ledger service identity has no creation TxID"); + } + + const auto target_key = ccf::crypto::public_key_der_from_cert( + ccf::crypto::cert_pem_to_der(target_identity)); + serialised_endorsements = validate_and_serialise_collected_endorsements( + scan.endorsements, + target_key, + snapshot_seqno, + *latest_service.current_service_create_txid); + verify_recovery_snapshot( + separate_segments(startup_snapshot_info->raw), + target_identity, + serialised_endorsements, + snapshot_seqno, + latest_service.current_service_create_txid); + write_cose_endorsements_sidecar(sidecar_path, serialised_endorsements); + } + catch (const std::exception& e) + { + fallback_from_recovery_snapshot_unsafe(e.what()); + return; + } + + LOG_INFO_FMT( + "Persisted {} verified recovery snapshot endorsement(s) to {}", + serialised_endorsements.size(), + sidecar_path.string()); + + try + { + install_recovery_snapshot_and_start_unsafe(); + } + catch (const std::exception&) + { + std::ignore = drop_recovery_snapshot_sidecar_unsafe(sidecar_path); + throw; + } + } + RecoveryDecisionProtocolSubsystem recovery_decision_protocol; public: @@ -836,8 +1172,14 @@ namespace ccf find_local_startup_snapshot(); - sm.advance(NodeStartupState::readingPublicLedger); - start_ledger_recovery_unsafe(); + if (startup_snapshot_info) + { + start_recovery_snapshot_verification_unsafe(); + } + else + { + start_public_ledger_recovery_unsafe(); + } return; } default: @@ -1277,14 +1619,15 @@ namespace ccf { // The legacy httpclient path silently dropped a failed // connection and relied on the periodic join timer to retry - // when the target could not yet be reached, while treating TLS - // handshake failures (e.g. an untrusted service certificate) as - // fatal. Preserve both behaviours: transient transport errors - // are retried, everything else is fatal. + // when the target could not yet be reached, while treating + // TLS handshake failures (e.g. an untrusted service + // certificate) as fatal. Preserve both behaviours: transient + // transport errors are retried, everything else is fatal. if (ccf::curl::is_transient_transport_error(curl_response)) { LOG_INFO_FMT( - "Transient error contacting {} to join: {} ({}). The join " + "Transient error contacting {} to join: {} ({}). The " + "join " "timer will retry.", target_address, curl_easy_strerror(curl_response), @@ -1292,14 +1635,16 @@ namespace ccf return; } - // CURLE_WRITE_ERROR here means our own write callback rejected - // the response body, which for the join can only be the body - // exceeding max_join_response_size. Surface a clear, actionable - // message rather than curl's generic "write error". + // CURLE_WRITE_ERROR here means our own write callback + // rejected the response body, which for the join can only be + // the body exceeding max_join_response_size. Surface a clear, + // actionable message rather than curl's generic "write + // error". if (curl_response == CURLE_WRITE_ERROR) { auto error_msg = fmt::format( - "Join response from {} exceeded the maximum permitted size " + "Join response from {} exceeded the maximum permitted " + "size " "of {} bytes. Shutting down node gracefully...", target_address, max_join_response_size); @@ -1321,7 +1666,8 @@ namespace ccf curl_response == CURLE_PEER_FAILED_VERIFICATION || curl_response == CURLE_SSL_CACERT_BADFILE; auto error_msg = fmt::format( - "Early error when joining existing network at {}: {}{} ({}). " + "Early error when joining existing network at {}: {}{} " + "({}). " "Shutting down node gracefully...", target_address, tls_certificate_trust_check_failed ? @@ -1362,7 +1708,8 @@ namespace ccf { // Leave error_response == nullopt LOG_FAIL_FMT( - "Join request returned {}, body is not ODataErrorResponse: " + "Join request returned {}, body is not " + "ODataErrorResponse: " "{}", status, std::string(data.begin(), data.end())); @@ -1375,15 +1722,16 @@ namespace ccf config.join.fetch_recent_snapshot) { LOG_INFO_FMT( - "Join request to {} returned {} error. Attempting to fetch " + "Join request to {} returned {} error. Attempting to " + "fetch " "fresher snapshot", target_address, ccf::errors::StartupSeqnoIsOld); - // If we've followed a redirect, it will have been updated in - // config.join. Note that this is fire-and-forget, it is - // assumed that it proceeds in the background, updating state - // when it completes, and the join timer separately + // If we've followed a redirect, it will have been updated + // in config.join. Note that this is fire-and-forget, it is + // assumed that it proceeds in the background, updating + // state when it completes, and the join timer separately // re-attempts join after this succeeds if ( snapshot_fetch_task != nullptr && @@ -1451,7 +1799,8 @@ namespace ccf catch (const std::exception& e) { LOG_FAIL_FMT( - "An error occurred while parsing the join network response"); + "An error occurred while parsing the join network " + "response"); LOG_DEBUG_FMT("Join network response error: {}", e.what()); LOG_DEBUG_FMT( @@ -1506,8 +1855,9 @@ namespace ccf std::vector view_history_ = {}; if (startup_snapshot_info) { - // It is only possible to deserialise the entire snapshot now, - // once the ledger secrets have been passed in by the network + // It is only possible to deserialise the entire snapshot + // now, once the ledger secrets have been passed in by the + // network ccf::kv::ConsensusHookPtrs hooks; network.tables->set_readiness( ccf::kv::StoreReadiness::InstallingSnapshot); @@ -1530,9 +1880,9 @@ namespace ccf if (!resp.network_info->public_only) { - // Only clear snapshot if not recovering. When joining the - // public network the snapshot is used later to initialise - // the recovery store + // Only clear snapshot if not recovering. When joining + // the public network the snapshot is used later to + // initialise the recovery store startup_snapshot_info.reset(); } diff --git a/src/node/recovery_snapshot_ledger.h b/src/node/recovery_snapshot_ledger.h new file mode 100644 index 000000000000..dcac68124a42 --- /dev/null +++ b/src/node/recovery_snapshot_ledger.h @@ -0,0 +1,450 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +#include "ccf/node/startup_config.h" +#include "ccf/service/tables/service.h" +#include "ds/internal_logger.h" +#include "host/ledger_filenames.h" +#include "kv/kv_serialiser.h" +#include "kv/serialised_entry_format.h" +#include "node/rpc/network_identity_chain_helpers.h" +#include "service/tables/previous_service_identity.h" +#include "service/tables/signatures.h" + +#include +#include +#include +#include +#include + +namespace ccf +{ + struct RecoverySnapshotLedgerEntry + { + ccf::kv::Version version = 0; + bool is_signature = false; + std::optional endorsement = std::nullopt; + std::optional service_info = std::nullopt; + }; + + struct RecoverySnapshotLedgerScan + { + ccf::kv::Version last_signed_idx = 0; + std::vector endorsements; + std::vector> service_infos; + }; + + static RecoverySnapshotLedgerEntry parse_recovery_snapshot_ledger_entry( + const std::vector& entry, + const std::shared_ptr& encryptor) + { + auto deserialiser = ccf::kv::RawKvStoreDeserialiser( + encryptor, ccf::kv::SecurityDomain::PUBLIC); + ccf::kv::Term term = 0; + ccf::kv::EntryFlags flags = {}; + const auto version = + deserialiser.init(entry.data(), entry.size(), term, flags, false); + if (!version.has_value()) + { + throw std::logic_error( + "Failed to initialise public ledger entry deserialiser"); + } + + RecoverySnapshotLedgerEntry result; + result.version = *version; + size_t map_count = 0; + bool has_signature = false; + bool has_cose_signature = false; + bool has_serialised_tree = false; + + for (auto map_name = deserialiser.start_map(); map_name.has_value(); + map_name = deserialiser.start_map()) + { + ++map_count; + std::ignore = deserialiser.deserialise_entry_version(); + + const auto read_count = deserialiser.deserialise_read_header(); + for (size_t i = 0; i < read_count; ++i) + { + std::ignore = deserialiser.deserialise_read(); + } + + const auto write_count = deserialiser.deserialise_write_header(); + if (write_count > 0) + { + has_signature = has_signature || *map_name == ccf::Tables::SIGNATURES; + has_cose_signature = + has_cose_signature || *map_name == ccf::Tables::COSE_SIGNATURES; + has_serialised_tree = has_serialised_tree || + *map_name == ccf::Tables::SERIALISED_MERKLE_TREE; + } + + for (size_t i = 0; i < write_count; ++i) + { + auto [key, value] = deserialiser.deserialise_write(); + if (*map_name == ccf::Tables::PREVIOUS_SERVICE_IDENTITY_ENDORSEMENT) + { + if ( + result.endorsement.has_value() || + key != ccf::PreviousServiceIdentityEndorsement::create_unit()) + { + throw std::logic_error( + "Invalid previous service identity endorsement table write"); + } + result.endorsement = ccf::PreviousServiceIdentityEndorsement:: + ValueSerialiser::from_serialised(value); + } + else if (*map_name == ccf::Tables::SERVICE) + { + if ( + result.service_info.has_value() || + key != ccf::Service::create_unit()) + { + throw std::logic_error("Invalid service info table write"); + } + result.service_info = + ccf::Service::ValueSerialiser::from_serialised(value); + } + } + + const auto remove_count = deserialiser.deserialise_remove_header(); + for (size_t i = 0; i < remove_count; ++i) + { + std::ignore = deserialiser.deserialise_remove(); + if ( + *map_name == ccf::Tables::PREVIOUS_SERVICE_IDENTITY_ENDORSEMENT || + *map_name == ccf::Tables::SERVICE) + { + throw std::logic_error(fmt::format( + "Unexpected removal from recovery snapshot scan table {}", + *map_name)); + } + } + } + + if (!deserialiser.end()) + { + throw std::logic_error( + "Public ledger entry contains trailing serialised data"); + } + + result.is_signature = has_serialised_tree && + ((map_count == 2 && has_signature != has_cose_signature) || + (map_count == 3 && has_signature && has_cose_signature)); + return result; + } + + namespace + { + struct RecoverySnapshotLedgerFile + { + std::filesystem::path path; + size_t start_idx; + std::optional end_idx; + bool committed; + }; + + static std::vector + find_recovery_snapshot_ledger_files(const ccf::CCFConfig::Ledger& config) + { + std::vector files; + + auto add_directory = + [&](const std::filesystem::path& directory, bool read_only) { + std::error_code ec; + const auto exists = std::filesystem::exists(directory, ec); + if (ec) + { + throw std::logic_error(fmt::format( + "Unable to inspect ledger directory {}: {}", + directory.string(), + ec.message())); + } + if (!exists) + { + return; + } + + for (std::filesystem::directory_iterator it(directory, ec), end; + it != end; + it.increment(ec)) + { + if (ec) + { + throw std::logic_error(fmt::format( + "Unable to iterate ledger directory {}: {}", + directory.string(), + ec.message())); + } + if (!it->is_regular_file()) + { + continue; + } + + const auto name = it->path().filename().string(); + if ( + !name.starts_with("ledger_") || + asynchost::is_ledger_file_ignored(name)) + { + continue; + } + + const auto committed = + asynchost::is_ledger_file_name_committed(name); + if (read_only && !committed) + { + continue; + } + + try + { + files.push_back( + {it->path(), + asynchost::get_start_idx_from_file_name(name), + asynchost::get_last_idx_from_file_name(name), + committed}); + } + catch (const std::exception& e) + { + LOG_INFO_FMT( + "Ignoring invalid ledger file name {} while scanning recovery " + "snapshot endorsements: {}", + it->path().string(), + e.what()); + } + } + if (ec) + { + throw std::logic_error(fmt::format( + "Unable to iterate ledger directory {}: {}", + directory.string(), + ec.message())); + } + }; + + add_directory(config.directory, false); + for (const auto& directory : config.read_only_directories) + { + add_directory(directory, true); + } + + std::sort( + files.begin(), files.end(), [](const auto& lhs, const auto& rhs) { + if (lhs.start_idx != rhs.start_idx) + { + return lhs.start_idx < rhs.start_idx; + } + return lhs.end_idx.has_value() && !rhs.end_idx.has_value(); + }); + return files; + } + } + + static RecoverySnapshotLedgerScan scan_recovery_snapshot_ledger_files( + const ccf::CCFConfig::Ledger& ledger_config, + const std::shared_ptr& encryptor, + ccf::kv::Version snapshot_seqno) + { + if (snapshot_seqno == std::numeric_limits::max()) + { + throw std::logic_error( + "Snapshot seqno cannot be incremented for ledger scanning"); + } + + RecoverySnapshotLedgerScan scan; + scan.last_signed_idx = snapshot_seqno; + auto expected_seqno = snapshot_seqno + 1; + + for (const auto& ledger_file : + find_recovery_snapshot_ledger_files(ledger_config)) + { + if ( + ledger_file.end_idx.has_value() && + ledger_file.end_idx.value() < expected_seqno) + { + continue; + } + + std::ifstream file(ledger_file.path, std::ios::binary | std::ios::ate); + if (!file) + { + throw std::logic_error(fmt::format( + "Unable to open ledger file {}", ledger_file.path.string())); + } + + const auto file_size = static_cast(file.tellg()); + if (file_size < static_cast(sizeof(size_t))) + { + throw std::logic_error(fmt::format( + "Ledger file {} is too small", ledger_file.path.string())); + } + file.seekg(0); + + size_t positions_offset = 0; + file.read( + reinterpret_cast(&positions_offset), sizeof(positions_offset)); + if (!file) + { + throw std::logic_error(fmt::format( + "Unable to read ledger file header {}", ledger_file.path.string())); + } + if (ledger_file.committed && positions_offset == 0) + { + throw std::logic_error(fmt::format( + "Committed ledger file {} has no positions table", + ledger_file.path.string())); + } + + const auto entries_end = positions_offset == 0 ? + file_size : + static_cast(positions_offset); + if ( + entries_end < static_cast(sizeof(size_t)) || + entries_end > file_size) + { + throw std::logic_error(fmt::format( + "Ledger file {} has invalid positions table offset {}", + ledger_file.path.string(), + positions_offset)); + } + + std::optional first_file_version = std::nullopt; + std::optional previous_file_version = std::nullopt; + while (file.tellg() < entries_end) + { + const auto remaining = entries_end - file.tellg(); + if ( + remaining < + static_cast(sizeof(ccf::kv::SerialisedEntryHeader))) + { + if (positions_offset == 0) + { + break; + } + throw std::logic_error(fmt::format( + "Committed ledger file {} ends with a partial entry header", + ledger_file.path.string())); + } + + ccf::kv::SerialisedEntryHeader header{}; + file.read(reinterpret_cast(&header), sizeof(header)); + if (!file) + { + throw std::logic_error(fmt::format( + "Unable to read entry header from ledger file {}", + ledger_file.path.string())); + } + + const auto body_remaining = entries_end - file.tellg(); + if ( + header.size == 0 || header.size > static_cast(body_remaining)) + { + if (positions_offset == 0) + { + break; + } + throw std::logic_error(fmt::format( + "Committed ledger file {} contains a truncated entry", + ledger_file.path.string())); + } + + std::vector entry(sizeof(header) + header.size); + std::memcpy(entry.data(), &header, sizeof(header)); + file.read( + reinterpret_cast(entry.data() + sizeof(header)), + static_cast(header.size)); + if (!file) + { + throw std::logic_error(fmt::format( + "Unable to read complete entry from ledger file {}", + ledger_file.path.string())); + } + + auto parsed = parse_recovery_snapshot_ledger_entry(entry, encryptor); + if (!first_file_version.has_value()) + { + first_file_version = parsed.version; + } + if ( + previous_file_version.has_value() && + (previous_file_version.value() == + std::numeric_limits::max() || + parsed.version != previous_file_version.value() + 1)) + { + throw std::logic_error(fmt::format( + "Ledger file {} contains non-contiguous versions {} and {}", + ledger_file.path.string(), + previous_file_version.value(), + parsed.version)); + } + previous_file_version = parsed.version; + + if (parsed.version < expected_seqno) + { + continue; + } + if (parsed.version > expected_seqno) + { + throw std::logic_error(fmt::format( + "Ledger suffix after snapshot is missing seqno {} (next entry is " + "{})", + expected_seqno, + parsed.version)); + } + + if (parsed.endorsement.has_value()) + { + scan.endorsements.push_back( + {parsed.version, std::move(*parsed.endorsement)}); + } + if (parsed.service_info.has_value()) + { + scan.service_infos.emplace_back( + parsed.version, std::move(*parsed.service_info)); + } + if (parsed.is_signature) + { + scan.last_signed_idx = parsed.version; + } + + if (expected_seqno == std::numeric_limits::max()) + { + throw std::logic_error( + "Ledger seqno overflow while scanning snapshot endorsements"); + } + ++expected_seqno; + } + + if ( + first_file_version.has_value() && + first_file_version.value() != + static_cast(ledger_file.start_idx)) + { + throw std::logic_error(fmt::format( + "Ledger file {} does not start at its declared seqno {}", + ledger_file.path.string(), + ledger_file.start_idx)); + } + if ( + ledger_file.end_idx.has_value() && + (!previous_file_version.has_value() || + previous_file_version.value() != + static_cast(ledger_file.end_idx.value()))) + { + throw std::logic_error(fmt::format( + "Ledger file {} does not end at its declared seqno {}", + ledger_file.path.string(), + ledger_file.end_idx.value())); + } + } + + std::erase_if(scan.endorsements, [&](const auto& item) { + return item.write_version > scan.last_signed_idx; + }); + std::erase_if(scan.service_infos, [&](const auto& item) { + return item.first > scan.last_signed_idx; + }); + return scan; + } +} diff --git a/src/node/rpc/network_identity_chain_helpers.h b/src/node/rpc/network_identity_chain_helpers.h index 8f1f154d8425..a340f257b8c1 100644 --- a/src/node/rpc/network_identity_chain_helpers.h +++ b/src/node/rpc/network_identity_chain_helpers.h @@ -2,8 +2,11 @@ // Licensed under the Apache 2.0 License. #pragma once +#include "ccf/crypto/cose_verifier.h" #include "ccf/tx_id.h" #include "consensus/aft/raft_types.h" +#include "crypto/cose.h" +#include "ds/internal_logger.h" #include "service/tables/previous_service_identity.h" #include @@ -11,6 +14,17 @@ namespace ccf { + struct CollectedCoseEndorsement + { + ccf::kv::Version write_version; + ccf::CoseEndorsement endorsement; + }; + + inline std::string format_epoch(const std::optional& epoch_end) + { + return epoch_end.has_value() ? epoch_end->to_str() : "null"; + } + inline bool is_self_endorsement(const ccf::CoseEndorsement& endorsement) { return !endorsement.previous_version.has_value(); @@ -24,6 +38,74 @@ namespace ccf endorsement.endorsement_epoch_begin.seqno; } + inline void validate_fetched_endorsement( + const ccf::CoseEndorsement& endorsement) + { + LOG_INFO_FMT( + "Validating fetched endorsement from {} to {}", + endorsement.endorsement_epoch_begin.to_str(), + format_epoch(endorsement.endorsement_epoch_end)); + + if (!is_self_endorsement(endorsement)) + { + const auto [from, to] = + ccf::crypto::extract_cose_endorsement_validity(endorsement.endorsement); + + const auto from_txid = ccf::TxID::from_str(from); + if (!from_txid.has_value()) + { + throw std::logic_error(fmt::format( + "Cannot parse COSE endorsement header: {}", + ccf::cose::header::custom::TX_RANGE_BEGIN)); + } + + const auto to_txid = ccf::TxID::from_str(to); + if (!to_txid.has_value()) + { + throw std::logic_error(fmt::format( + "Cannot parse COSE endorsement header: {}", + ccf::cose::header::custom::TX_RANGE_END)); + } + + if (!endorsement.endorsement_epoch_end.has_value()) + { + throw std::logic_error( + "COSE endorsement does not contain epoch end in the table entry"); + } + if ( + endorsement.endorsement_epoch_begin != *from_txid || + *endorsement.endorsement_epoch_end != *to_txid) + { + throw std::logic_error(fmt::format( + "COSE endorsement fetched but range is invalid, epoch begin {}, " + "epoch end {}, header epoch begin: {}, header epoch end: {}", + endorsement.endorsement_epoch_begin.to_str(), + endorsement.endorsement_epoch_end->to_str(), + from, + to)); + } + } + } + + inline std::vector verify_cose_endorsement_signature( + std::span endorsement, + std::span endorsing_key) + { + auto verifier = ccf::crypto::make_cose_verifier_from_key(endorsing_key); + std::span endorsed_key; + if (!verifier->verify(endorsement, endorsed_key)) + { + throw std::logic_error("COSE endorsement failed signature verification"); + } + + if (endorsed_key.empty()) + { + throw std::logic_error("COSE endorsement contains an empty public key"); + } + + return {endorsed_key.begin(), endorsed_key.end()}; + } + inline void verify_endorsements_connected( const ccf::CoseEndorsement& newer, const ccf::CoseEndorsement& older) { diff --git a/src/node/rpc/network_identity_subsystem.h b/src/node/rpc/network_identity_subsystem.h index 60fe40d8e4f2..bfd08a7409dd 100644 --- a/src/node/rpc/network_identity_subsystem.h +++ b/src/node/rpc/network_identity_subsystem.h @@ -16,60 +16,6 @@ namespace ccf { - inline std::string format_epoch(const std::optional& epoch_end) - { - return epoch_end.has_value() ? epoch_end->to_str() : "null"; - } - - inline void validate_fetched_endorsement( - const ccf::CoseEndorsement& endorsement) - { - LOG_INFO_FMT( - "Validating fetched endorsement from {} to {}", - endorsement.endorsement_epoch_begin.to_str(), - format_epoch(endorsement.endorsement_epoch_end)); - - if (!is_self_endorsement(endorsement)) - { - const auto [from, to] = - ccf::crypto::extract_cose_endorsement_validity(endorsement.endorsement); - - const auto from_txid = ccf::TxID::from_str(from); - if (!from_txid.has_value()) - { - throw std::logic_error(fmt::format( - "Cannot parse COSE endorsement header: {}", - ccf::cose::header::custom::TX_RANGE_BEGIN)); - } - - const auto to_txid = ccf::TxID::from_str(to); - if (!to_txid.has_value()) - { - throw std::logic_error(fmt::format( - "Cannot parse COSE endorsement header: {}", - ccf::cose::header::custom::TX_RANGE_END)); - } - - if (!endorsement.endorsement_epoch_end.has_value()) - { - throw std::logic_error( - "COSE endorsement does not contain epoch end in the table entry"); - } - if ( - endorsement.endorsement_epoch_begin != *from_txid || - *endorsement.endorsement_epoch_end != *to_txid) - { - throw std::logic_error(fmt::format( - "COSE endorsement fetched but range is invalid, epoch begin {}, " - "epoch end {}, header epoch begin: {}, header epoch end: {}", - endorsement.endorsement_epoch_begin.to_str(), - endorsement.endorsement_epoch_end->to_str(), - from, - to)); - } - } - } - class NetworkIdentitySubsystem : public NetworkIdentitySubsystemInterface { protected: @@ -495,10 +441,13 @@ namespace ccf std::span previous_key_der{}; for (const auto& [seqno, endorsement] : endorsements) { - auto verifier = - ccf::crypto::make_cose_verifier_from_key(endorsement.endorsing_key); - std::span endorsed_key; - if (!verifier->verify(endorsement.endorsement, endorsed_key)) + std::vector endorsed_key; + try + { + endorsed_key = verify_cose_endorsement_signature( + endorsement.endorsement, endorsement.endorsing_key); + } + catch (const std::logic_error&) { throw std::logic_error(fmt::format( "COSE endorsement chain integrity is violated, endorsement from " diff --git a/src/node/snapshot_serdes.h b/src/node/snapshot_serdes.h index 0ac29955eab4..c776a3ac663a 100644 --- a/src/node/snapshot_serdes.h +++ b/src/node/snapshot_serdes.h @@ -5,18 +5,25 @@ #include "ccf/crypto/cose.h" #include "ccf/crypto/cose_verifier.h" #include "ccf/crypto/pem.h" +#include "ccf/crypto/verifier.h" #include "ccf/ds/json.h" #include "ccf/historical_queries_adapter.h" +#include "ccf/receipt.h" #include "ccf/service/tables/nodes.h" +#include "crypto/cbor.h" #include "crypto/cose.h" +#include "ds/files.h" #include "ds/internal_logger.h" #include "ds/serialized.h" #include "kv/kv_types.h" #include "kv/serialised_entry_format.h" #include "node/cose_common.h" #include "node/history.h" +#include "node/rpc/network_identity_chain_helpers.h" #include "node/tx_receipt_impl.h" +#include "snapshots/filenames.h" +#include #include namespace ccf @@ -38,6 +45,8 @@ namespace ccf std::span receipt; }; + static constexpr size_t MAX_SNAPSHOT_ENDORSEMENTS_SIZE = 16 * 1024 * 1024; + static SnapshotSegments separate_segments( const std::vector& snapshot) { @@ -76,9 +85,334 @@ namespace ccf return SnapshotSegments{header_and_body, receipt}; } - static void verify_cose_snapshot_receipt( - const SnapshotSegments& segments, - const std::optional>& prev_service_identity) + static std::vector serialise_cose_endorsements( + const ccf::SerialisedCoseEndorsements& endorsements) + { + std::vector items; + items.reserve(endorsements.size()); + for (const auto& endorsement : endorsements) + { + items.emplace_back(ccf::cbor::make_bytes(endorsement)); + } + + return ccf::cbor::serialize(ccf::cbor::make_array(std::move(items))); + } + + static ccf::SerialisedCoseEndorsements deserialise_cose_endorsements( + std::span serialised) + { + const auto parsed = ccf::cbor::rethrow_with_msg( + [&]() { return ccf::cbor::parse(serialised); }, + "Parse snapshot endorsements sidecar"); + if (!std::holds_alternative(parsed->value)) + { + throw std::logic_error( + "Snapshot endorsements sidecar must contain a CBOR array"); + } + + ccf::SerialisedCoseEndorsements endorsements; + endorsements.reserve(parsed->size()); + for (size_t i = 0; i < parsed->size(); ++i) + { + const auto bytes = ccf::cbor::rethrow_with_msg( + [&]() { return parsed->array_at(i)->as_bytes(); }, + fmt::format("Parse snapshot endorsement at index {}", i)); + endorsements.emplace_back(bytes.begin(), bytes.end()); + } + return endorsements; + } + + static ccf::SerialisedCoseEndorsements read_cose_endorsements_sidecar( + const std::filesystem::path& path) + { + std::error_code ec; + const auto file_size = std::filesystem::file_size(path, ec); + if (ec) + { + throw std::logic_error(fmt::format( + "Unable to read snapshot endorsements sidecar size {}: {}", + path.string(), + ec.message())); + } + if (file_size > MAX_SNAPSHOT_ENDORSEMENTS_SIZE) + { + throw std::logic_error(fmt::format( + "Snapshot endorsements sidecar {} is too large ({} bytes, maximum " + "{} bytes)", + path.string(), + file_size, + MAX_SNAPSHOT_ENDORSEMENTS_SIZE)); + } + + std::ifstream file(path, std::ios::binary); + if (!file) + { + throw std::logic_error(fmt::format( + "Unable to open snapshot endorsements sidecar {}", path.string())); + } + + std::vector serialised(static_cast(file_size)); + if (!serialised.empty()) + { + file.read( + reinterpret_cast(serialised.data()), + static_cast(serialised.size())); + if ( + !file || + file.gcount() != static_cast(serialised.size())) + { + throw std::logic_error(fmt::format( + "Unable to read complete snapshot endorsements sidecar {}", + path.string())); + } + } + + return deserialise_cose_endorsements(serialised); + } + + static void write_cose_endorsements_sidecar( + const std::filesystem::path& path, + const ccf::SerialisedCoseEndorsements& endorsements) + { + if (std::filesystem::exists(path)) + { + throw std::logic_error(fmt::format( + "Refusing to overwrite existing snapshot endorsements sidecar {}", + path.string())); + } + + const auto serialised = serialise_cose_endorsements(endorsements); + if (serialised.size() > MAX_SNAPSHOT_ENDORSEMENTS_SIZE) + { + throw std::logic_error(fmt::format( + "Snapshot endorsements sidecar is too large ({} bytes, maximum {} " + "bytes)", + serialised.size(), + MAX_SNAPSHOT_ENDORSEMENTS_SIZE)); + } + auto temporary_path = std::filesystem::path(path.string() + ".tmp"); + std::error_code ec; + std::filesystem::remove(temporary_path, ec); + + try + { + files::dump(serialised, temporary_path); + files::rename(temporary_path, path); + } + catch (const std::exception&) + { + std::filesystem::remove(temporary_path, ec); + throw; + } + } + + static ccf::CoseEndorsement parse_serialised_cose_endorsement( + const ccf::SerialisedCoseEndorsement& serialised) + { + const auto [from, to] = + ccf::crypto::extract_cose_endorsement_validity(serialised); + const auto from_txid = ccf::TxID::from_str(from); + const auto to_txid = ccf::TxID::from_str(to); + if (!from_txid.has_value() || !to_txid.has_value()) + { + throw std::logic_error( + fmt::format("Invalid COSE endorsement epoch range {} - {}", from, to)); + } + + return ccf::CoseEndorsement{ + .endorsement = serialised, + .endorsing_key = {}, + .endorsement_epoch_begin = *from_txid, + .endorsement_epoch_end = *to_txid, + .previous_version = ccf::kv::Version{0}}; + } + + static std::vector verify_serialised_cose_endorsements( + const ccf::SerialisedCoseEndorsements& endorsements, + std::span target_key, + ccf::kv::Version snapshot_seqno, + std::optional target_service_from = std::nullopt) + { + if (target_key.empty()) + { + throw std::logic_error( + "Cannot verify snapshot endorsements with an empty target key"); + } + if (endorsements.empty()) + { + return {target_key.begin(), target_key.end()}; + } + + std::vector current_key(target_key.begin(), target_key.end()); + std::vector parsed; + parsed.reserve(endorsements.size()); + + for (const auto& endorsement : endorsements) + { + auto parsed_endorsement = parse_serialised_cose_endorsement(endorsement); + if (has_ill_formed_epoch_range(parsed_endorsement)) + { + throw std::logic_error(fmt::format( + "COSE endorsement has an ill-formed epoch range {} - {}", + parsed_endorsement.endorsement_epoch_begin.to_str(), + format_epoch(parsed_endorsement.endorsement_epoch_end))); + } + + current_key = verify_cose_endorsement_signature(endorsement, current_key); + parsed.emplace_back(std::move(parsed_endorsement)); + } + + for (size_t i = 0; i + 1 < parsed.size(); ++i) + { + verify_endorsements_connected(parsed[i], parsed[i + 1]); + } + + if (target_service_from.has_value()) + { + validate_chain_front_connection(parsed.front(), *target_service_from); + } + + const auto& oldest = parsed.back(); + if ( + !oldest.endorsement_epoch_end.has_value() || + oldest.endorsement_epoch_begin.seqno > snapshot_seqno || + oldest.endorsement_epoch_end->seqno < snapshot_seqno) + { + throw std::logic_error(fmt::format( + "Oldest COSE endorsement range {} - {} does not cover snapshot seqno " + "{}", + oldest.endorsement_epoch_begin.to_str(), + format_epoch(oldest.endorsement_epoch_end), + snapshot_seqno)); + } + + return current_key; + } + + static ccf::SerialisedCoseEndorsements + validate_and_serialise_collected_endorsements( + const std::vector& collected, + std::span target_key, + ccf::kv::Version snapshot_seqno, + const ccf::TxID& target_service_from) + { + if (collected.empty()) + { + throw std::logic_error( + "No previous service identity endorsements were found after the " + "snapshot"); + } + + std::vector previous_endorsing_key; + for (size_t i = 0; i < collected.size(); ++i) + { + const auto& [write_version, endorsement] = collected[i]; + if (write_version <= snapshot_seqno) + { + throw std::logic_error(fmt::format( + "Collected endorsement write at {} is not after snapshot seqno {}", + write_version, + snapshot_seqno)); + } + if (is_self_endorsement(endorsement)) + { + throw std::logic_error(fmt::format( + "Unexpected self-endorsement after snapshot at {}", + endorsement.endorsement_epoch_begin.to_str())); + } + if (has_ill_formed_epoch_range(endorsement)) + { + throw std::logic_error(fmt::format( + "Collected endorsement has an ill-formed epoch range {} - {}", + endorsement.endorsement_epoch_begin.to_str(), + format_epoch(endorsement.endorsement_epoch_end))); + } + + validate_fetched_endorsement(endorsement); + + if (i == 0) + { + if ( + !endorsement.previous_version.has_value() || + endorsement.previous_version.value() > snapshot_seqno) + { + throw std::logic_error(fmt::format( + "Oldest collected endorsement at {} does not point to an " + "endorsement at or before snapshot seqno {}", + write_version, + snapshot_seqno)); + } + } + else + { + const auto& previous = collected[i - 1]; + if ( + !endorsement.previous_version.has_value() || + endorsement.previous_version.value() != previous.write_version) + { + throw std::logic_error(fmt::format( + "Collected endorsement at {} does not point to the previous " + "collected endorsement at {}", + write_version, + previous.write_version)); + } + verify_endorsements_connected(endorsement, previous.endorsement); + } + + const auto endorsed_key = verify_cose_endorsement_signature( + endorsement.endorsement, endorsement.endorsing_key); + if ( + !previous_endorsing_key.empty() && + endorsed_key != previous_endorsing_key) + { + throw std::logic_error(fmt::format( + "Collected endorsement at {} does not endorse the preceding " + "service identity", + write_version)); + } + previous_endorsing_key = endorsement.endorsing_key; + } + + if ( + previous_endorsing_key.size() != target_key.size() || + !std::equal( + previous_endorsing_key.begin(), + previous_endorsing_key.end(), + target_key.begin())) + { + throw std::logic_error( + "Newest collected endorsement is not signed by the configured " + "previous service identity"); + } + + validate_chain_front_connection( + collected.back().endorsement, target_service_from); + + const auto& oldest = collected.front().endorsement; + if ( + !oldest.endorsement_epoch_end.has_value() || + oldest.endorsement_epoch_begin.seqno > snapshot_seqno || + oldest.endorsement_epoch_end->seqno < snapshot_seqno) + { + throw std::logic_error(fmt::format( + "Oldest collected endorsement range {} - {} does not cover snapshot " + "seqno {}", + oldest.endorsement_epoch_begin.to_str(), + format_epoch(oldest.endorsement_epoch_end), + snapshot_seqno)); + } + + ccf::SerialisedCoseEndorsements serialised; + serialised.reserve(collected.size()); + for (auto it = collected.rbegin(); it != collected.rend(); ++it) + { + serialised.emplace_back(it->endorsement.endorsement); + } + return serialised; + } + + static auto decode_and_verify_cose_snapshot_receipt( + const SnapshotSegments& segments) { auto receipt = ccf::cose::decode_ccf_receipt( {segments.receipt.begin(), segments.receipt.end()}, @@ -99,6 +433,15 @@ namespace ccf ds::to_hex(receipt.claims_digest))); } + return receipt; + } + + static void verify_cose_snapshot_receipt( + const SnapshotSegments& segments, + const std::optional>& prev_service_identity) + { + const auto receipt = decode_and_verify_cose_snapshot_receipt(segments); + if (prev_service_identity) { auto verifier = ccf::crypto::make_cose_verifier_from_pem_cert( @@ -210,11 +553,45 @@ namespace ccf else { throw std::logic_error(fmt::format( - "Invalid snapshot receipt: unrecognised format (first byte: 0x{:02X})", + "Invalid snapshot receipt: unrecognised format (first byte: " + "0x{:02X})", first_byte)); } } + static void verify_recovery_snapshot( + const SnapshotSegments& segments, + const ccf::crypto::Pem& target_identity, + const ccf::SerialisedCoseEndorsements& endorsements, + ccf::kv::Version snapshot_seqno, + std::optional target_service_from = std::nullopt) + { + if (endorsements.empty()) + { + verify_snapshot(segments, target_identity.raw()); + return; + } + if (segments.receipt.empty() || segments.receipt[0] != 0xD2) + { + throw std::logic_error( + "Only snapshots with COSE receipts can use endorsement sidecars"); + } + + const auto target_key = ccf::crypto::public_key_der_from_cert( + ccf::crypto::cert_pem_to_der(target_identity)); + const auto snapshot_signer_key = verify_serialised_cose_endorsements( + endorsements, target_key, snapshot_seqno, target_service_from); + const auto receipt = decode_and_verify_cose_snapshot_receipt(segments); + const auto verifier = + ccf::crypto::make_cose_verifier_from_key(snapshot_signer_key); + if (!verifier->verify_detached(segments.receipt, receipt.merkle_root)) + { + throw std::logic_error( + "Snapshot receipt signature verification failed under the endorsed " + "snapshot service identity"); + } + } + static void deserialise_snapshot( const std::shared_ptr& store, const SnapshotSegments& segments, diff --git a/src/node/test/network_identity_subsystem.cpp b/src/node/test/network_identity_subsystem.cpp index 0d6f0dd8ea18..3c7458b31c08 100644 --- a/src/node/test/network_identity_subsystem.cpp +++ b/src/node/test/network_identity_subsystem.cpp @@ -7,6 +7,7 @@ #include "crypto/openssl/ec_key_pair.h" #include "node/rpc/network_identity_accessors.h" #include "node/rpc/network_identity_chain_helpers.h" +#include "node/snapshot_serdes.h" #define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN #include @@ -475,6 +476,71 @@ TEST_CASE( ccf::validate_chain_front_connection(bad, after), std::logic_error); } +TEST_CASE("Recovery snapshot endorsements are validated newest-to-oldest") +{ + ChainBuilder cb; + cb.add_self({2, 1}).add_next({2, 1}, {4, 100}).add_next({6, 101}, {8, 200}); + + std::vector collected{ + {cb.write_versions[1], cb.entries[1]}, + {cb.write_versions[2], cb.entries[2]}}; + const auto target_key = cb.current_pkey_der(); + const auto target_from = cb.synthesised_current_service_from(); + + const auto serialised = ccf::validate_and_serialise_collected_endorsements( + collected, target_key, 1, target_from); + REQUIRE(serialised.size() == 2); + REQUIRE(serialised[0] == cb.entries[2].endorsement); + REQUIRE(serialised[1] == cb.entries[1].endorsement); + + const auto snapshot_signer = ccf::verify_serialised_cose_endorsements( + serialised, target_key, 1, target_from); + REQUIRE(snapshot_signer == cb.service_keys.front()->public_key_der()); + + const auto sidecar = ccf::serialise_cose_endorsements(serialised); + REQUIRE(ccf::deserialise_cose_endorsements(sidecar) == serialised); +} + +TEST_CASE("Recovery snapshot endorsements fail closed") +{ + ChainBuilder cb; + cb.add_self({2, 1}).add_next({2, 1}, {4, 100}).add_next({6, 101}, {8, 200}); + + std::vector collected{ + {cb.write_versions[1], cb.entries[1]}, + {cb.write_versions[2], cb.entries[2]}}; + const auto target_key = cb.current_pkey_der(); + const auto target_from = cb.synthesised_current_service_from(); + const auto serialised = ccf::validate_and_serialise_collected_endorsements( + collected, target_key, 1, target_from); + + auto out_of_order = serialised; + std::reverse(out_of_order.begin(), out_of_order.end()); + REQUIRE_THROWS_AS( + ccf::verify_serialised_cose_endorsements( + out_of_order, target_key, 1, target_from), + std::logic_error); + + auto tampered = serialised; + tampered.front().back() ^= 0xff; + REQUIRE_THROWS_AS( + ccf::verify_serialised_cose_endorsements( + tampered, target_key, 1, target_from), + std::logic_error); + + REQUIRE_THROWS_AS( + ccf::verify_serialised_cose_endorsements( + serialised, target_key, 1000, target_from), + std::logic_error); + + auto broken_back_pointer = collected; + broken_back_pointer.back().endorsement.previous_version = 999; + REQUIRE_THROWS_AS( + ccf::validate_and_serialise_collected_endorsements( + broken_back_pointer, target_key, 1, target_from), + std::logic_error); +} + // ------------------------------------------------------------------------ // State-machine tests using Mock{NodeState,HistoricalState}Accessor + // FakeTaskScheduler + ChainBuilder. Each test wires a synthetic ledger, diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index 4386522a8eba..16a9619d2c88 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -10,12 +10,16 @@ #include "kv/test/stub_consensus.h" #include "node/encryptor.h" #include "node/history.h" +#include "node/identity.h" +#include "node/recovery_snapshot_ledger.h" +#include "node/snapshot_serdes.h" #include "snapshots/filenames.h" #define DOCTEST_CONFIG_IMPLEMENT #include #include #include +#include #include #include @@ -54,6 +58,121 @@ struct ScopedSnapshotDir } }; +TEST_CASE("Snapshot endorsement sidecar file lifecycle") +{ + ScopedSnapshotDir snapshot_dir; + const auto snapshot_path = snapshot_dir.path / "snapshot_42_43.committed"; + const std::vector snapshot_data{1, 2, 3, 4, 5}; + files::dump(snapshot_data, snapshot_path); + + const auto sidecar_path = + snapshots::get_snapshot_endorsements_path(snapshot_path); + REQUIRE(sidecar_path.filename() == "snapshot_42_43.committed.endorsements"); + REQUIRE_FALSE(snapshots::is_snapshot_file_committed(sidecar_path.filename())); + + const ccf::SerialisedCoseEndorsements endorsements{ + {0xd2, 0x01, 0x02}, {0xd2, 0x03, 0x04}}; + ccf::write_cose_endorsements_sidecar(sidecar_path, endorsements); + REQUIRE(ccf::read_cose_endorsements_sidecar(sidecar_path) == endorsements); + REQUIRE(files::slurp(snapshot_path) == snapshot_data); + + const auto discovered = + snapshots::find_committed_snapshots_in_directories({snapshot_dir.path}); + REQUIRE(discovered.size() == 1); + REQUIRE(discovered.front().second == snapshot_path); + + REQUIRE_THROWS_AS( + ccf::write_cose_endorsements_sidecar(sidecar_path, endorsements), + std::logic_error); + + const std::vector tampered{0xff}; + files::dump(tampered, sidecar_path); + REQUIRE_THROWS(ccf::read_cose_endorsements_sidecar(sidecar_path)); + REQUIRE(files::slurp(snapshot_path) == snapshot_data); +} + +TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") +{ + ScopedSnapshotDir ledger_dir; + ccf::kv::Store source_store; + auto encryptor = std::make_shared(); + auto consensus = std::make_shared(); + source_store.set_encryptor(encryptor); + source_store.set_consensus(consensus); + source_store.initialise_term(2); + + std::vector> entries; + { + auto tx = source_store.create_tx(); + tx.rw("public:unrelated")->put("key", "value"); + REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); + entries.push_back(consensus->get_latest_data().value()); + } + + ccf::NetworkIdentity target_identity( + "CN=Recovery snapshot ledger scan", + ccf::crypto::CurveID::SECP384R1, + "20240101000000Z", + 365); + { + ccf::ServiceInfo service; + service.cert = target_identity.cert; + service.status = ccf::ServiceStatus::OPEN; + service.current_service_create_txid = ccf::TxID{6, 2}; + + ccf::CoseEndorsement endorsement; + endorsement.endorsement = {0xd2, 0x01}; + endorsement.endorsing_key = {0x02, 0x03}; + endorsement.endorsement_epoch_begin = {2, 1}; + endorsement.endorsement_epoch_end = ccf::TxID{4, 1}; + endorsement.previous_version = 1; + + auto tx = source_store.create_tx(); + tx.rw(ccf::Tables::SERVICE)->put(service); + tx.rw( + ccf::Tables::PREVIOUS_SERVICE_IDENTITY_ENDORSEMENT) + ->put(endorsement); + REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); + entries.push_back(consensus->get_latest_data().value()); + } + { + auto tx = source_store.create_tx(); + tx.rw(ccf::Tables::COSE_SIGNATURES)->put({0xd2, 0x02}); + tx.rw(ccf::Tables::SERIALISED_MERKLE_TREE) + ->put({0x01}); + REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); + entries.push_back(consensus->get_latest_data().value()); + } + + const auto ledger_path = ledger_dir.path / "ledger_1"; + { + std::ofstream ledger_file(ledger_path, std::ios::binary); + REQUIRE(ledger_file); + const size_t positions_offset = 0; + ledger_file.write( + reinterpret_cast(&positions_offset), + sizeof(positions_offset)); + for (const auto& entry : entries) + { + ledger_file.write( + reinterpret_cast(entry.data()), + static_cast(entry.size())); + } + REQUIRE(ledger_file); + } + + ccf::CCFConfig::Ledger ledger_config; + ledger_config.directory = ledger_dir.path.string(); + const auto scan = + ccf::scan_recovery_snapshot_ledger_files(ledger_config, encryptor, 1); + REQUIRE(scan.last_signed_idx == 3); + REQUIRE(scan.endorsements.size() == 1); + REQUIRE(scan.endorsements.front().write_version == 2); + REQUIRE(scan.service_infos.size() == 1); + REQUIRE(scan.service_infos.front().first == 2); + REQUIRE(scan.service_infos.front().second.cert == target_identity.cert); +} + std::optional latest_committed_snapshot_path(const fs::path& dir) { return snapshots::find_latest_committed_snapshot_in_directory(dir); diff --git a/src/snapshots/filenames.h b/src/snapshots/filenames.h index aa7ac55ac7f8..a908987d96c2 100644 --- a/src/snapshots/filenames.h +++ b/src/snapshots/filenames.h @@ -19,6 +19,7 @@ namespace snapshots static constexpr auto snapshot_idx_delimiter = "_"; static constexpr auto snapshot_committed_suffix = ".committed"; static constexpr auto snapshot_ignored_file_suffix = "ignored"; + static constexpr auto snapshot_endorsements_suffix = ".endorsements"; static bool is_snapshot_file(const std::string& file_name) { @@ -35,6 +36,20 @@ namespace snapshots return file_name.ends_with(snapshot_ignored_file_suffix); } + static fs::path get_snapshot_endorsements_path(const fs::path& snapshot_path) + { + const auto file_name = snapshot_path.filename().string(); + if (!is_snapshot_file(file_name) || !is_snapshot_file_committed(file_name)) + { + throw std::logic_error(fmt::format( + "Cannot derive endorsements sidecar name from non-committed snapshot " + "{}", + snapshot_path.string())); + } + + return fs::path(snapshot_path.string() + snapshot_endorsements_suffix); + } + static void ignore_snapshot_file( const fs::path& dir, const std::string& file_name) { diff --git a/tests/ci-buckets.txt b/tests/ci-buckets.txt index 4ad9a4f53775..7ca731569c23 100644 --- a/tests/ci-buckets.txt +++ b/tests/ci-buckets.txt @@ -5,6 +5,7 @@ bucket_b: recovery_test recovery_stale_snapshot_join_test recovery_intermediate_snapshot_join_test + recovery_snapshot_endorsements_test schema_test nodes_test diff --git a/tests/recovery_snapshot_endorsements.py b/tests/recovery_snapshot_endorsements.py new file mode 100644 index 000000000000..f1a577665726 --- /dev/null +++ b/tests/recovery_snapshot_endorsements.py @@ -0,0 +1,315 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the Apache 2.0 License. + +import copy +import hashlib +import os +import shutil + +import ccf.ledger +import infra.e2e_args +import infra.logging_app as app +import infra.network +import infra.node +from infra.runner import ConcurrentRunner +from loguru import logger as LOG + + +def _logs(node): + out_path, _ = node.get_logs() + assert out_path is not None + with open(out_path, encoding="utf-8") as output: + return output.read() + + +def _stop_incomplete_recovery(network): + network.stop_all_nodes( + skip_verification=True, + skip_verify_chunking=True, + check_file_invariants=False, + ) + + +def _recover_and_open(network, args, label): + recovery_args = copy.deepcopy(args) + recovery_args.label = label + network.save_service_identity(recovery_args) + primary, _ = network.find_primary() + network.stop_all_nodes() + current_ledger_dir, committed_ledger_dirs = primary.get_ledger() + + recovered = infra.network.Network( + recovery_args.nodes, + recovery_args.binary_dir, + recovery_args.debug_nodes, + existing_network=network, + ) + recovered.start_in_recovery( + recovery_args, + ledger_dir=current_ledger_dir, + committed_ledger_dirs=committed_ledger_dirs, + ) + recovered.recover(recovery_args) + + app.LoggingTxs("user0").issue( + recovered, + number_txs=2, + send_private=False, + send_public=True, + wait_for_sync=True, + ) + recovered.get_latest_ledger_public_state() + return recovered, recovery_args + + +def _start_recovery_attempt( + base_network, + args, + label, + ledger_dir, + committed_ledger_dirs, + snapshots_dir, + previous_service_identity_file, + next_node_id, +): + attempt_args = copy.deepcopy(args) + attempt_args.label = label + attempt_args.previous_service_identity_file = previous_service_identity_file + attempt = infra.network.Network( + attempt_args.nodes, + attempt_args.binary_dir, + attempt_args.debug_nodes, + existing_network=base_network, + next_node_id=next_node_id, + ) + attempt.ignore_errors_on_shutdown() + attempt.start_in_recovery( + attempt_args, + ledger_dir=ledger_dir, + committed_ledger_dirs=committed_ledger_dirs, + snapshots_dir=snapshots_dir, + common_dir=base_network.common_dir, + ) + return attempt + + +def _copy_recovery_snapshot_cache(network, destination): + primary, _ = network.find_primary() + copied = network.get_committed_snapshots(primary, force_txs=False) + shutil.rmtree(destination, ignore_errors=True) + shutil.copytree(copied, destination) + return destination + + +def _verify_snapshot_cache( + snapshots_dir, snapshot_name, snapshot_digest, target_identity +): + snapshot_path = os.path.join(snapshots_dir, snapshot_name) + sidecar_path = ccf.ledger.snapshot_endorsements_path(snapshot_path) + assert os.path.isfile(sidecar_path), sidecar_path + with open(snapshot_path, "rb") as snapshot_file: + assert hashlib.sha256(snapshot_file.read()).digest() == snapshot_digest + + endorsements = ccf.ledger.read_snapshot_endorsements(sidecar_path) + assert len(endorsements) == 2, len(endorsements) + with ccf.ledger.Snapshot(snapshot_path) as snapshot: + snapshot.verify_cose_receipt(target_identity, endorsements) + return sidecar_path + + +def run_recovery_snapshot_endorsements(args): + with infra.network.network( + args.nodes, + args.binary_dir, + args.debug_nodes, + pdb=args.pdb, + ) as initial_network: + initial_network.start_and_open(args) + primary, _ = initial_network.find_primary() + + app.LoggingTxs("user0").issue( + initial_network, + number_txs=2, + send_private=False, + send_public=True, + wait_for_sync=True, + ) + snapshot_trigger = primary.trigger_snapshot() + committed_snapshots_dir = initial_network.get_committed_snapshots( + primary, + target_seqno=snapshot_trigger.seqno, + wait_for_target_seqno=True, + ) + snapshots = sorted( + ( + name + for name in os.listdir(committed_snapshots_dir) + if name.startswith("snapshot_") + and ccf.ledger.is_snapshot_file_committed(name) + ), + key=lambda name: infra.node.get_snapshot_seqnos(name)[0], + ) + assert snapshots + snapshot_name = snapshots[-1] + original_snapshot_path = os.path.join(committed_snapshots_dir, snapshot_name) + with open(original_snapshot_path, "rb") as snapshot_file: + snapshot_digest = hashlib.sha256(snapshot_file.read()).digest() + + source_snapshots_dir = os.path.join( + initial_network.common_dir, "recovery_snapshot_endorsements_source" + ) + shutil.rmtree(source_snapshots_dir, ignore_errors=True) + os.makedirs(source_snapshots_dir) + shutil.copy(original_snapshot_path, source_snapshots_dir) + + first_recovery, first_args = _recover_and_open( + initial_network, args, f"{args.label}_identity_1" + ) + second_recovery, second_args = _recover_and_open( + first_recovery, first_args, f"{args.label}_identity_2" + ) + + second_recovery.save_service_identity(second_args) + target_identity_file = second_args.previous_service_identity_file + with open(target_identity_file, "rb") as target_file: + target_identity = target_file.read() + primary, _ = second_recovery.find_primary() + second_recovery.stop_all_nodes() + current_ledger_dir, committed_ledger_dirs = primary.get_ledger() + + cache_dir = os.path.join( + second_recovery.common_dir, "recovery_snapshot_endorsements_cache" + ) + first_attempt = _start_recovery_attempt( + second_recovery, + second_args, + f"{args.label}_derive_sidecar", + current_ledger_dir, + committed_ledger_dirs, + source_snapshots_dir, + target_identity_file, + 100, + ) + try: + first_primary, _ = first_attempt.find_primary() + logs = _logs(first_primary) + scan_log = "scanning the public ledger suffix for COSE endorsements" + persisted_log = "Persisted 2 verified recovery snapshot endorsement(s)" + snapshot_body_log = "Deserialising snapshot (size:" + public_recovery_log = "Starting to read public ledger" + assert ( + logs.index(scan_log) + < logs.index(persisted_log) + < logs.index(snapshot_body_log) + < logs.index(public_recovery_log) + ) + _copy_recovery_snapshot_cache(first_attempt, cache_dir) + sidecar_path = _verify_snapshot_cache( + cache_dir, snapshot_name, snapshot_digest, target_identity + ) + finally: + _stop_incomplete_recovery(first_attempt) + + reuse_attempt = _start_recovery_attempt( + second_recovery, + second_args, + f"{args.label}_reuse_sidecar", + current_ledger_dir, + committed_ledger_dirs, + cache_dir, + target_identity_file, + 101, + ) + try: + reuse_primary, _ = reuse_attempt.find_primary() + assert "Reusing verified snapshot endorsements sidecar" in _logs( + reuse_primary + ) + finally: + _stop_incomplete_recovery(reuse_attempt) + + with open(sidecar_path, "rb") as sidecar_file: + tampered = bytearray(sidecar_file.read()) + tampered[-1] ^= 0xFF + with open(sidecar_path, "wb") as sidecar_file: + sidecar_file.write(tampered) + + tamper_attempt = _start_recovery_attempt( + second_recovery, + second_args, + f"{args.label}_replace_tampered_sidecar", + current_ledger_dir, + committed_ledger_dirs, + cache_dir, + target_identity_file, + 102, + ) + try: + tamper_primary, _ = tamper_attempt.find_primary() + logs = _logs(tamper_primary) + assert "Snapshot endorsements sidecar" in logs + assert "is invalid" in logs + assert "Persisted 2 verified recovery snapshot endorsement(s)" in logs + _copy_recovery_snapshot_cache(tamper_attempt, cache_dir) + _verify_snapshot_cache( + cache_dir, snapshot_name, snapshot_digest, target_identity + ) + finally: + _stop_incomplete_recovery(tamper_attempt) + + fallback_dir = os.path.join( + second_recovery.common_dir, "recovery_snapshot_endorsements_fallback" + ) + shutil.rmtree(fallback_dir, ignore_errors=True) + os.makedirs(fallback_dir) + shutil.copy(os.path.join(cache_dir, snapshot_name), fallback_dir) + wrong_identity_file = second_recovery.consortium.user_cert_path("user0") + + fallback_attempt = _start_recovery_attempt( + second_recovery, + second_args, + f"{args.label}_fallback", + current_ledger_dir, + committed_ledger_dirs, + fallback_dir, + wrong_identity_file, + 103, + ) + try: + fallback_primary, _ = fallback_attempt.find_primary() + logs = _logs(fallback_primary) + assert "Falling back to full-ledger recovery" in logs + assert "Setting startup snapshot seqno" not in logs + assert not os.path.exists( + ccf.ledger.snapshot_endorsements_path( + os.path.join(fallback_dir, snapshot_name) + ) + ) + finally: + _stop_incomplete_recovery(fallback_attempt) + + LOG.success( + "Recovery snapshot sidecar derivation, verification, reuse, " + "tamper replacement, and fallback all succeeded" + ) + + +if __name__ == "__main__": + + def add(parser): + parser.description = ( + "Verify recovery snapshot COSE endorsement sidecars across multiple " + "disaster recoveries." + ) + + cr = ConcurrentRunner(add) + cr.add( + "recovery_snapshot_endorsements", + run_recovery_snapshot_endorsements, + package="samples/apps/logging/logging", + nodes=infra.e2e_args.min_nodes(cr.args, f=0), + ledger_chunk_bytes="50KB", + snapshot_tx_interval=10, + sig_tx_interval=1, + ) + cr.run() From 793cd693762cf1f4efd319e5923eaec0f51bb23b Mon Sep 17 00:00:00 2001 From: achamayou Date: Wed, 22 Jul 2026 23:09:18 +0100 Subject: [PATCH 02/27] Document recovery snapshot sidecars Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 Copilot-Session: 4d631113-a6ff-453d-a658-3b46aaaec95a Copilot-Session: 972cd758-c6d2-4d1b-b043-b482e129cb54 --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e23c7beadc22..84c07b589235 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. [7.0.10]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.10 +### Added + +- Recovery can now use a COSE snapshot signed by an earlier service identity after one or more disaster recoveries. Before deserialising the snapshot, the node derives and validates the previous-service-identity endorsement chain from the public ledger suffix and caches it in an adjacent `.endorsements` sidecar. Invalid or incomplete chains fall back to full-ledger replay, and Python tooling can verify a snapshot plus sidecar against the latest trusted service certificate (#8092). + ### Changed - `ccf::http::ParsedQuery` (in `include/ccf/http_query.h`), returned by `ccf::http::parse_query()`, is now a `std::multimap>` that owns its decoded keys and values, rather than a `std::multimap` pointing into the source query string. Owned storage is required because each key and value is now URL-decoded individually after splitting, which produces bytes not present in the original query. Application code that consumed the previous `std::string_view` keys/values may need to be updated (#8024). From 9b059616dc7b97f2b0bf3769606420945db9cc28 Mon Sep 17 00:00:00 2001 From: achamayou Date: Thu, 23 Jul 2026 06:53:50 +0100 Subject: [PATCH 03/27] Harden recovery snapshot endorsement sidecars Fix two security issues in the recovery snapshot endorsement sidecar handling: - Snapshot installation exceptions were caught in the same try/catch as verification, so a failure while deserialising the snapshot (which may have partially mutated the KV store and set readiness to Failed) was misreported as a verification failure and triggered fallback to full-ledger recovery. Installation now runs via try_prepare_and_install_recovery_snapshot outside the verification catch, so install exceptions propagate instead of causing fallback. - A compact CBOR endorsements sidecar could declare a huge number of tiny array or map elements and amplify memory before rejection. ccf::cbor::parse now accepts a max_array_size budget enforced per array and per map during parsing, and deserialise_cose_endorsements bounds the endorsement count, per-endorsement size and total payload size. Add targeted unit regressions for both fixes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 772ce010-6105-4e1a-9bbb-7aad044d3e8e --- src/crypto/cbor.cpp | 67 +++++++++++++++++++------ src/crypto/cbor.h | 6 ++- src/crypto/test/cbor.cpp | 15 ++++++ src/node/node_state.h | 93 ++++++++++++++++++----------------- src/node/snapshot_serdes.h | 81 ++++++++++++++++++++++++++++-- src/node/test/snapshotter.cpp | 54 ++++++++++++++++++++ 6 files changed, 253 insertions(+), 63 deletions(-) diff --git a/src/crypto/cbor.cpp b/src/crypto/cbor.cpp index 02d642bae970..47c31f583cf8 100644 --- a/src/crypto/cbor.cpp +++ b/src/crypto/cbor.cpp @@ -81,7 +81,11 @@ namespace std::list> arrays; std::list> maps; }; - Value consume(cbor_nondet_t cbor, size_t depth, size_t max_depth); + Value consume( + cbor_nondet_t cbor, + size_t depth, + size_t max_depth, + size_t max_array_size); void print_indent(std::ostringstream& os, size_t indent) { @@ -129,7 +133,11 @@ namespace return std::make_shared(value); } - Value consume_array(cbor_nondet_t cbor, size_t depth, size_t max_depth) + Value consume_array( + cbor_nondet_t cbor, + size_t depth, + size_t max_depth, + size_t max_array_size) { cbor_nondet_array_iterator_t iter; if (!cbor_nondet_array_iterator_start(cbor, &iter)) @@ -141,18 +149,31 @@ namespace Array array; while (!cbor_nondet_array_iterator_is_empty(iter)) { + if (array.items.size() >= max_array_size) + { + throw CBORDecodeError( + Error::DECODE_FAILED, + fmt::format( + "Maximum CBOR array size ({}) exceeded", max_array_size)); + } + cbor_nondet_t item; if (!cbor_nondet_array_iterator_next(&iter, &item)) { throw CBORDecodeError( Error::DECODE_FAILED, "Failed to get next array item"); } - array.items.push_back(consume(item, depth + 1, max_depth)); + array.items.push_back( + consume(item, depth + 1, max_depth, max_array_size)); } return std::make_shared(std::move(array)); } - Value consume_map(cbor_nondet_t cbor, size_t depth, size_t max_depth) + Value consume_map( + cbor_nondet_t cbor, + size_t depth, + size_t max_depth, + size_t max_array_size) { cbor_map_iterator iter; if (!cbor_nondet_map_iterator_start(cbor, &iter)) @@ -164,6 +185,13 @@ namespace Map map; while (!cbor_nondet_map_iterator_is_empty(iter)) { + if (map.items.size() >= max_array_size) + { + throw CBORDecodeError( + Error::DECODE_FAILED, + fmt::format("Maximum CBOR map size ({}) exceeded", max_array_size)); + } + cbor_raw key_raw; cbor_raw value_raw; if (!cbor_nondet_map_iterator_next(&iter, &key_raw, &value_raw)) @@ -172,13 +200,17 @@ namespace Error::DECODE_FAILED, "Failed to get next map entry"); } map.items.emplace_back( - consume(key_raw, depth + 1, max_depth), - consume(value_raw, depth + 1, max_depth)); + consume(key_raw, depth + 1, max_depth, max_array_size), + consume(value_raw, depth + 1, max_depth, max_array_size)); } return std::make_shared(std::move(map)); } - Value consume_tagged(cbor_nondet_t cbor, size_t depth, size_t max_depth) + Value consume_tagged( + cbor_nondet_t cbor, + size_t depth, + size_t max_depth, + size_t max_array_size) { uint64_t tag = 0; cbor_nondet_t payload; @@ -190,7 +222,7 @@ namespace Tagged tagged; tagged.tag = tag; - tagged.item = consume(payload, depth + 1, max_depth); + tagged.item = consume(payload, depth + 1, max_depth, max_array_size); return std::make_shared(std::move(tagged)); } @@ -208,7 +240,11 @@ namespace return std::make_shared(value); } - Value consume(cbor_nondet_t cbor, size_t depth, size_t max_depth) + Value consume( + cbor_nondet_t cbor, + size_t depth, + size_t max_depth, + size_t max_array_size) { if (depth > max_depth) { @@ -228,11 +264,11 @@ namespace case CBOR_MAJOR_TYPE_TEXT_STRING: return consume_text_string(cbor); case CBOR_MAJOR_TYPE_ARRAY: - return consume_array(cbor, depth, max_depth); + return consume_array(cbor, depth, max_depth, max_array_size); case CBOR_MAJOR_TYPE_MAP: - return consume_map(cbor, depth, max_depth); + return consume_map(cbor, depth, max_depth, max_array_size); case CBOR_MAJOR_TYPE_TAGGED: - return consume_tagged(cbor, depth, max_depth); + return consume_tagged(cbor, depth, max_depth, max_array_size); case CBOR_MAJOR_TYPE_SIMPLE_VALUE: return consume_simple(cbor); default: @@ -557,7 +593,10 @@ namespace ccf::cbor return std::make_shared(Map{.items = std::move(data)}); } - Value parse(std::span raw, size_t max_depth) + Value parse( + std::span raw, + size_t max_depth, + size_t max_array_size) { cbor_nondet_t cbor; const bool check_map_key_bound = false; @@ -582,7 +621,7 @@ namespace ccf::cbor fmt::format("Trailing {} byte(s) after CBOR item", cbor_parse_size)); } - return consume(cbor, 0, max_depth); + return consume(cbor, 0, max_depth, max_array_size); } std::vector serialize(const Value& value, size_t max_depth) diff --git a/src/crypto/cbor.h b/src/crypto/cbor.h index a321095818b2..33e5366df34b 100644 --- a/src/crypto/cbor.h +++ b/src/crypto/cbor.h @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -116,7 +117,10 @@ namespace ccf::cbor Value make_array(std::vector&& data); Value make_map(std::vector&& data); - Value parse(std::span raw, size_t max_depth = 16); + Value parse( + std::span raw, + size_t max_depth = 16, + size_t max_array_size = std::numeric_limits::max()); std::vector serialize(const Value& value, size_t max_depth = 16); std::string to_string(const Value& value); diff --git a/src/crypto/test/cbor.cpp b/src/crypto/test/cbor.cpp index 733eca766745..c930f1d763b9 100644 --- a/src/crypto/test/cbor.cpp +++ b/src/crypto/test/cbor.cpp @@ -1763,6 +1763,21 @@ TEST_CASE("CBOR: parse max depth") REQUIRE_NOTHROW(parse(map_depth2, 2)); } +TEST_CASE("CBOR: parse max array size") +{ + auto array = ccf::ds::from_hex("83010203"); + REQUIRE_NOTHROW(parse(array, 16, 3)); + REQUIRE_THROWS_AS(parse(array, 16, 2), CBORDecodeError); + + auto nested_array = ccf::ds::from_hex("81820102"); + REQUIRE_THROWS_AS(parse(nested_array, 16, 1), CBORDecodeError); + + // {1: 2, 3: 4} -- a map counts entries against the same budget + auto map = ccf::ds::from_hex("a201020304"); + REQUIRE_NOTHROW(parse(map, 16, 2)); + REQUIRE_THROWS_AS(parse(map, 16, 1), CBORDecodeError); +} + TEST_CASE("CBOR: serialize max depth") { // depth 1: [42] -- should pass at max_depth=1,2 diff --git a/src/node/node_state.h b/src/node/node_state.h index aff066f4de1f..bb2f76065554 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -797,63 +797,68 @@ namespace ccf if (sidecar_exists) { - try + const auto preparation_error = + try_prepare_and_install_recovery_snapshot( + [&]() { + const auto endorsements = + read_cose_endorsements_sidecar(sidecar_path); + verify_recovery_snapshot( + segments, + target_identity, + endorsements, + startup_snapshot_info->seqno); + LOG_INFO_FMT( + "Reusing verified snapshot endorsements sidecar {}", + sidecar_path.string()); + }, + [&]() { install_recovery_snapshot_and_start_unsafe(); }); + if (!preparation_error.has_value()) { - const auto endorsements = - read_cose_endorsements_sidecar(sidecar_path); - verify_recovery_snapshot( - segments, - target_identity, - endorsements, - startup_snapshot_info->seqno); - LOG_INFO_FMT( - "Reusing verified snapshot endorsements sidecar {}", - sidecar_path.string()); - install_recovery_snapshot_and_start_unsafe(); return; } - catch (const std::exception& e) + + LOG_FAIL_FMT( + "Snapshot endorsements sidecar {} is invalid: {}", + sidecar_path.string(), + *preparation_error); + if (!drop_recovery_snapshot_sidecar_unsafe(sidecar_path)) { - LOG_FAIL_FMT( - "Snapshot endorsements sidecar {} is invalid: {}", - sidecar_path.string(), - e.what()); - if (!drop_recovery_snapshot_sidecar_unsafe(sidecar_path)) - { - fallback_from_recovery_snapshot_unsafe( - "invalid sidecar could not be removed"); - return; - } + fallback_from_recovery_snapshot_unsafe( + "invalid sidecar could not be removed"); + return; } } - try + const auto preparation_error = + try_prepare_and_install_recovery_snapshot( + [&]() { + verify_recovery_snapshot( + segments, target_identity, {}, startup_snapshot_info->seqno); + LOG_INFO_FMT( + "Recovery snapshot {} is directly signed by the configured " + "previous service identity", + startup_snapshot_path->string()); + }, + [&]() { install_recovery_snapshot_and_start_unsafe(); }); + if (!preparation_error.has_value()) { - verify_recovery_snapshot( - segments, target_identity, {}, startup_snapshot_info->seqno); - LOG_INFO_FMT( - "Recovery snapshot {} is directly signed by the configured previous " - "service identity", - startup_snapshot_path->string()); - install_recovery_snapshot_and_start_unsafe(); return; } - catch (const std::exception& e) - { - if (segments.receipt.empty() || segments.receipt[0] != 0xD2) - { - fallback_from_recovery_snapshot_unsafe(fmt::format( - "old-style snapshot receipt cannot be augmented: {}", e.what())); - return; - } - LOG_INFO_FMT( - "Recovery snapshot {} is not directly signed by the configured " - "previous service identity; scanning the public ledger suffix for " - "COSE endorsements", - startup_snapshot_path->string()); + if (segments.receipt.empty() || segments.receipt[0] != 0xD2) + { + fallback_from_recovery_snapshot_unsafe(fmt::format( + "old-style snapshot receipt cannot be augmented: {}", + *preparation_error)); + return; } + LOG_INFO_FMT( + "Recovery snapshot {} is not directly signed by the configured " + "previous service identity; scanning the public ledger suffix for " + "COSE endorsements", + startup_snapshot_path->string()); + if (!recovery_snapshot_directory_is_writable_unsafe()) { fallback_from_recovery_snapshot_unsafe( diff --git a/src/node/snapshot_serdes.h b/src/node/snapshot_serdes.h index c776a3ac663a..75dab028edbd 100644 --- a/src/node/snapshot_serdes.h +++ b/src/node/snapshot_serdes.h @@ -46,6 +46,48 @@ namespace ccf }; static constexpr size_t MAX_SNAPSHOT_ENDORSEMENTS_SIZE = 16 * 1024 * 1024; + static constexpr size_t MAX_SNAPSHOT_ENDORSEMENTS_COUNT = 64; + static constexpr size_t MAX_SNAPSHOT_ENDORSEMENT_SIZE = 1024 * 1024; + static constexpr size_t MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE = + 4 * 1024 * 1024; + + template + static void validate_cose_endorsement_resource_limits( + const Endorsements& endorsements) + { + if (endorsements.size() > MAX_SNAPSHOT_ENDORSEMENTS_COUNT) + { + throw std::logic_error(fmt::format( + "Snapshot endorsements sidecar contains too many endorsements ({}; " + "maximum {})", + endorsements.size(), + MAX_SNAPSHOT_ENDORSEMENTS_COUNT)); + } + + size_t payload_size = 0; + for (size_t i = 0; i < endorsements.size(); ++i) + { + const auto endorsement_size = endorsements[i].size(); + if (endorsement_size > MAX_SNAPSHOT_ENDORSEMENT_SIZE) + { + throw std::logic_error(fmt::format( + "Snapshot endorsement at index {} is too large ({} bytes; maximum " + "{} bytes)", + i, + endorsement_size, + MAX_SNAPSHOT_ENDORSEMENT_SIZE)); + } + if ( + endorsement_size > + MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE - payload_size) + { + throw std::logic_error(fmt::format( + "Snapshot endorsements payload is too large (maximum {} bytes)", + MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE)); + } + payload_size += endorsement_size; + } + } static SnapshotSegments separate_segments( const std::vector& snapshot) @@ -88,6 +130,8 @@ namespace ccf static std::vector serialise_cose_endorsements( const ccf::SerialisedCoseEndorsements& endorsements) { + validate_cose_endorsement_resource_limits(endorsements); + std::vector items; items.reserve(endorsements.size()); for (const auto& endorsement : endorsements) @@ -102,7 +146,10 @@ namespace ccf std::span serialised) { const auto parsed = ccf::cbor::rethrow_with_msg( - [&]() { return ccf::cbor::parse(serialised); }, + [&]() { + return ccf::cbor::parse( + serialised, 16, MAX_SNAPSHOT_ENDORSEMENTS_COUNT); + }, "Parse snapshot endorsements sidecar"); if (!std::holds_alternative(parsed->value)) { @@ -110,18 +157,44 @@ namespace ccf "Snapshot endorsements sidecar must contain a CBOR array"); } - ccf::SerialisedCoseEndorsements endorsements; - endorsements.reserve(parsed->size()); + std::vector> endorsement_spans; + endorsement_spans.reserve(parsed->size()); for (size_t i = 0; i < parsed->size(); ++i) { const auto bytes = ccf::cbor::rethrow_with_msg( [&]() { return parsed->array_at(i)->as_bytes(); }, fmt::format("Parse snapshot endorsement at index {}", i)); - endorsements.emplace_back(bytes.begin(), bytes.end()); + endorsement_spans.emplace_back(bytes); + } + validate_cose_endorsement_resource_limits(endorsement_spans); + + ccf::SerialisedCoseEndorsements endorsements; + endorsements.reserve(endorsement_spans.size()); + for (const auto endorsement : endorsement_spans) + { + endorsements.emplace_back(endorsement.begin(), endorsement.end()); } return endorsements; } + template + static std::optional + try_prepare_and_install_recovery_snapshot( + Prepare&& prepare, Install&& install) + { + try + { + prepare(); + } + catch (const std::exception& e) + { + return e.what(); + } + + install(); + return std::nullopt; + } + static ccf::SerialisedCoseEndorsements read_cose_endorsements_sidecar( const std::filesystem::path& path) { diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index 16a9619d2c88..4a6fc496515d 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -58,6 +58,35 @@ struct ScopedSnapshotDir } }; +TEST_CASE("Recovery snapshot installation failures do not request fallback") +{ + bool install_started = false; + bool fallback_requested = false; + + REQUIRE_THROWS_AS( + [&]() { + const auto preparation_error = + ccf::try_prepare_and_install_recovery_snapshot( + []() {}, + [&]() { + install_started = true; + throw std::logic_error("snapshot installation failed"); + }); + fallback_requested = preparation_error.has_value(); + }(), + std::logic_error); + REQUIRE(install_started); + REQUIRE_FALSE(fallback_requested); + + bool install_called = false; + const auto preparation_error = + ccf::try_prepare_and_install_recovery_snapshot( + []() { throw std::logic_error("snapshot verification failed"); }, + [&]() { install_called = true; }); + REQUIRE(preparation_error.has_value()); + REQUIRE_FALSE(install_called); +} + TEST_CASE("Snapshot endorsement sidecar file lifecycle") { ScopedSnapshotDir snapshot_dir; @@ -91,6 +120,31 @@ TEST_CASE("Snapshot endorsement sidecar file lifecycle") REQUIRE(files::slurp(snapshot_path) == snapshot_data); } +TEST_CASE("Snapshot endorsement sidecar resource limits") +{ + std::vector too_many{0x98, 0x41}; + too_many.insert( + too_many.end(), ccf::MAX_SNAPSHOT_ENDORSEMENTS_COUNT + 1, 0x40); + REQUIRE_THROWS(ccf::deserialise_cose_endorsements(too_many)); + + std::vector oversized_endorsement{ + 0x81, 0x5a, 0x00, 0x10, 0x00, 0x01}; + oversized_endorsement.resize( + oversized_endorsement.size() + + ccf::MAX_SNAPSHOT_ENDORSEMENT_SIZE + 1); + REQUIRE_THROWS(ccf::deserialise_cose_endorsements(oversized_endorsement)); + + std::vector oversized_payload{0x85}; + for (size_t i = 0; i < 5; ++i) + { + oversized_payload.insert( + oversized_payload.end(), {0x5a, 0x00, 0x10, 0x00, 0x00}); + oversized_payload.resize( + oversized_payload.size() + ccf::MAX_SNAPSHOT_ENDORSEMENT_SIZE); + } + REQUIRE_THROWS(ccf::deserialise_cose_endorsements(oversized_payload)); +} + TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") { ScopedSnapshotDir ledger_dir; From 3a261e909f5fafc5a3b832b4e61d24c483e3007e Mon Sep 17 00:00:00 2001 From: achamayou Date: Thu, 23 Jul 2026 07:31:31 +0100 Subject: [PATCH 04/27] Fix recovery snapshot CI failures: join structural check, recovery log, formatting - node_state.h: move separate_segments() outside the verification try/catch so a structurally invalid local snapshot (e.g. nulled/truncated) remains a fatal startup error on join instead of being silently ignored, restoring the behaviour asserted by test_nulled_snapshot. - node_state.h: surface the deferred previous-service-identity verification error in the 'not directly signed... scanning' log line so recovery_test still observes the expected 'Previous service identity does not match...' message after identity checking moved to the sidecar gate. - Rename max_array_size to max_container_size across the CBOR parser (arrays and maps share the budget) and apply clang-format. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 --- src/crypto/cbor.cpp | 39 +++++++++++++-------------- src/crypto/cbor.h | 2 +- src/crypto/test/cbor.cpp | 2 +- src/node/node_state.h | 50 +++++++++++++++++++---------------- src/node/snapshot_serdes.h | 3 +-- src/node/test/snapshotter.cpp | 16 +++++------ 6 files changed, 56 insertions(+), 56 deletions(-) diff --git a/src/crypto/cbor.cpp b/src/crypto/cbor.cpp index 47c31f583cf8..6bbcae25c595 100644 --- a/src/crypto/cbor.cpp +++ b/src/crypto/cbor.cpp @@ -85,7 +85,7 @@ namespace cbor_nondet_t cbor, size_t depth, size_t max_depth, - size_t max_array_size); + size_t max_container_size); void print_indent(std::ostringstream& os, size_t indent) { @@ -137,7 +137,7 @@ namespace cbor_nondet_t cbor, size_t depth, size_t max_depth, - size_t max_array_size) + size_t max_container_size) { cbor_nondet_array_iterator_t iter; if (!cbor_nondet_array_iterator_start(cbor, &iter)) @@ -149,12 +149,12 @@ namespace Array array; while (!cbor_nondet_array_iterator_is_empty(iter)) { - if (array.items.size() >= max_array_size) + if (array.items.size() >= max_container_size) { throw CBORDecodeError( Error::DECODE_FAILED, fmt::format( - "Maximum CBOR array size ({}) exceeded", max_array_size)); + "Maximum CBOR array size ({}) exceeded", max_container_size)); } cbor_nondet_t item; @@ -164,7 +164,7 @@ namespace Error::DECODE_FAILED, "Failed to get next array item"); } array.items.push_back( - consume(item, depth + 1, max_depth, max_array_size)); + consume(item, depth + 1, max_depth, max_container_size)); } return std::make_shared(std::move(array)); } @@ -173,7 +173,7 @@ namespace cbor_nondet_t cbor, size_t depth, size_t max_depth, - size_t max_array_size) + size_t max_container_size) { cbor_map_iterator iter; if (!cbor_nondet_map_iterator_start(cbor, &iter)) @@ -185,11 +185,12 @@ namespace Map map; while (!cbor_nondet_map_iterator_is_empty(iter)) { - if (map.items.size() >= max_array_size) + if (map.items.size() >= max_container_size) { throw CBORDecodeError( Error::DECODE_FAILED, - fmt::format("Maximum CBOR map size ({}) exceeded", max_array_size)); + fmt::format( + "Maximum CBOR map size ({}) exceeded", max_container_size)); } cbor_raw key_raw; @@ -200,8 +201,8 @@ namespace Error::DECODE_FAILED, "Failed to get next map entry"); } map.items.emplace_back( - consume(key_raw, depth + 1, max_depth, max_array_size), - consume(value_raw, depth + 1, max_depth, max_array_size)); + consume(key_raw, depth + 1, max_depth, max_container_size), + consume(value_raw, depth + 1, max_depth, max_container_size)); } return std::make_shared(std::move(map)); } @@ -210,7 +211,7 @@ namespace cbor_nondet_t cbor, size_t depth, size_t max_depth, - size_t max_array_size) + size_t max_container_size) { uint64_t tag = 0; cbor_nondet_t payload; @@ -222,7 +223,7 @@ namespace Tagged tagged; tagged.tag = tag; - tagged.item = consume(payload, depth + 1, max_depth, max_array_size); + tagged.item = consume(payload, depth + 1, max_depth, max_container_size); return std::make_shared(std::move(tagged)); } @@ -244,7 +245,7 @@ namespace cbor_nondet_t cbor, size_t depth, size_t max_depth, - size_t max_array_size) + size_t max_container_size) { if (depth > max_depth) { @@ -264,11 +265,11 @@ namespace case CBOR_MAJOR_TYPE_TEXT_STRING: return consume_text_string(cbor); case CBOR_MAJOR_TYPE_ARRAY: - return consume_array(cbor, depth, max_depth, max_array_size); + return consume_array(cbor, depth, max_depth, max_container_size); case CBOR_MAJOR_TYPE_MAP: - return consume_map(cbor, depth, max_depth, max_array_size); + return consume_map(cbor, depth, max_depth, max_container_size); case CBOR_MAJOR_TYPE_TAGGED: - return consume_tagged(cbor, depth, max_depth, max_array_size); + return consume_tagged(cbor, depth, max_depth, max_container_size); case CBOR_MAJOR_TYPE_SIMPLE_VALUE: return consume_simple(cbor); default: @@ -594,9 +595,7 @@ namespace ccf::cbor } Value parse( - std::span raw, - size_t max_depth, - size_t max_array_size) + std::span raw, size_t max_depth, size_t max_container_size) { cbor_nondet_t cbor; const bool check_map_key_bound = false; @@ -621,7 +620,7 @@ namespace ccf::cbor fmt::format("Trailing {} byte(s) after CBOR item", cbor_parse_size)); } - return consume(cbor, 0, max_depth, max_array_size); + return consume(cbor, 0, max_depth, max_container_size); } std::vector serialize(const Value& value, size_t max_depth) diff --git a/src/crypto/cbor.h b/src/crypto/cbor.h index 33e5366df34b..48cd5f04fb5d 100644 --- a/src/crypto/cbor.h +++ b/src/crypto/cbor.h @@ -120,7 +120,7 @@ namespace ccf::cbor Value parse( std::span raw, size_t max_depth = 16, - size_t max_array_size = std::numeric_limits::max()); + size_t max_container_size = std::numeric_limits::max()); std::vector serialize(const Value& value, size_t max_depth = 16); std::string to_string(const Value& value); diff --git a/src/crypto/test/cbor.cpp b/src/crypto/test/cbor.cpp index c930f1d763b9..bf9a8159897d 100644 --- a/src/crypto/test/cbor.cpp +++ b/src/crypto/test/cbor.cpp @@ -1763,7 +1763,7 @@ TEST_CASE("CBOR: parse max depth") REQUIRE_NOTHROW(parse(map_depth2, 2)); } -TEST_CASE("CBOR: parse max array size") +TEST_CASE("CBOR: parse max container size") { auto array = ccf::ds::from_hex("83010203"); REQUIRE_NOTHROW(parse(array, 16, 3)); diff --git a/src/node/node_state.h b/src/node/node_state.h index bb2f76065554..a1f2537510ba 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -522,17 +522,21 @@ namespace ccf snapshots::find_committed_snapshots_in_directories(directories); for (const auto& [snapshot_seqno, snapshot_path] : committed_snapshots) { - std::vector snapshot_data; - try - { - snapshot_data = files::slurp(snapshot_path); + auto snapshot_data = files::slurp(snapshot_path); - LOG_INFO_FMT( - "Found latest local snapshot file: {} (size: {})", - snapshot_path, - snapshot_data.size()); + LOG_INFO_FMT( + "Found latest local snapshot file: {} (size: {})", + snapshot_path, + snapshot_data.size()); + + // A structurally invalid snapshot (for example a truncated or nulled + // file) is a fatal startup error rather than something we silently + // skip and continue past, so separate the segments outside the + // verification try/catch below. + const auto segments = separate_segments(snapshot_data); - const auto segments = separate_segments(snapshot_data); + try + { if (start_type == StartType::Recover) { // Validate the receipt structure and snapshot digest, but defer the @@ -829,17 +833,16 @@ namespace ccf } } - const auto preparation_error = - try_prepare_and_install_recovery_snapshot( - [&]() { - verify_recovery_snapshot( - segments, target_identity, {}, startup_snapshot_info->seqno); - LOG_INFO_FMT( - "Recovery snapshot {} is directly signed by the configured " - "previous service identity", - startup_snapshot_path->string()); - }, - [&]() { install_recovery_snapshot_and_start_unsafe(); }); + const auto preparation_error = try_prepare_and_install_recovery_snapshot( + [&]() { + verify_recovery_snapshot( + segments, target_identity, {}, startup_snapshot_info->seqno); + LOG_INFO_FMT( + "Recovery snapshot {} is directly signed by the configured " + "previous service identity", + startup_snapshot_path->string()); + }, + [&]() { install_recovery_snapshot_and_start_unsafe(); }); if (!preparation_error.has_value()) { return; @@ -855,9 +858,10 @@ namespace ccf LOG_INFO_FMT( "Recovery snapshot {} is not directly signed by the configured " - "previous service identity; scanning the public ledger suffix for " - "COSE endorsements", - startup_snapshot_path->string()); + "previous service identity ({}); scanning the public ledger suffix " + "for COSE endorsements", + startup_snapshot_path->string(), + *preparation_error); if (!recovery_snapshot_directory_is_writable_unsafe()) { diff --git a/src/node/snapshot_serdes.h b/src/node/snapshot_serdes.h index 75dab028edbd..fa4599404b89 100644 --- a/src/node/snapshot_serdes.h +++ b/src/node/snapshot_serdes.h @@ -178,8 +178,7 @@ namespace ccf } template - static std::optional - try_prepare_and_install_recovery_snapshot( + static std::optional try_prepare_and_install_recovery_snapshot( Prepare&& prepare, Install&& install) { try diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index 4a6fc496515d..d54c816cbef6 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -79,10 +79,9 @@ TEST_CASE("Recovery snapshot installation failures do not request fallback") REQUIRE_FALSE(fallback_requested); bool install_called = false; - const auto preparation_error = - ccf::try_prepare_and_install_recovery_snapshot( - []() { throw std::logic_error("snapshot verification failed"); }, - [&]() { install_called = true; }); + const auto preparation_error = ccf::try_prepare_and_install_recovery_snapshot( + []() { throw std::logic_error("snapshot verification failed"); }, + [&]() { install_called = true; }); REQUIRE(preparation_error.has_value()); REQUIRE_FALSE(install_called); } @@ -122,16 +121,15 @@ TEST_CASE("Snapshot endorsement sidecar file lifecycle") TEST_CASE("Snapshot endorsement sidecar resource limits") { - std::vector too_many{0x98, 0x41}; - too_many.insert( - too_many.end(), ccf::MAX_SNAPSHOT_ENDORSEMENTS_COUNT + 1, 0x40); + constexpr size_t pathological_endorsement_count = 1'000'000; + std::vector too_many{0x9a, 0x00, 0x0f, 0x42, 0x40}; + too_many.insert(too_many.end(), pathological_endorsement_count, 0x40); REQUIRE_THROWS(ccf::deserialise_cose_endorsements(too_many)); std::vector oversized_endorsement{ 0x81, 0x5a, 0x00, 0x10, 0x00, 0x01}; oversized_endorsement.resize( - oversized_endorsement.size() + - ccf::MAX_SNAPSHOT_ENDORSEMENT_SIZE + 1); + oversized_endorsement.size() + ccf::MAX_SNAPSHOT_ENDORSEMENT_SIZE + 1); REQUIRE_THROWS(ccf::deserialise_cose_endorsements(oversized_endorsement)); std::vector oversized_payload{0x85}; From cac39e997b73b5589bf47c99dece85eee4b0c3ad Mon Sep 17 00:00:00 2001 From: achamayou Date: Thu, 23 Jul 2026 08:26:04 +0100 Subject: [PATCH 05/27] Fix clang-tidy diagnostics in recovery snapshot sidecar helpers - filenames.h: return a braced-init-list from get_snapshot_endorsements_path (modernize-return-braced-init-list). - network_identity_chain_helpers.h: default-initialise CollectedCoseEndorsement::write_version to ccf::kv::NoVersion so default-constructed instances are type-safely initialised (cppcoreguidelines-pro-type-member-init); explicit aggregate initialisation at all call sites is unaffected. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 --- src/node/rpc/network_identity_chain_helpers.h | 2 +- src/snapshots/filenames.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/node/rpc/network_identity_chain_helpers.h b/src/node/rpc/network_identity_chain_helpers.h index a340f257b8c1..0cac14d58442 100644 --- a/src/node/rpc/network_identity_chain_helpers.h +++ b/src/node/rpc/network_identity_chain_helpers.h @@ -16,7 +16,7 @@ namespace ccf { struct CollectedCoseEndorsement { - ccf::kv::Version write_version; + ccf::kv::Version write_version = ccf::kv::NoVersion; ccf::CoseEndorsement endorsement; }; diff --git a/src/snapshots/filenames.h b/src/snapshots/filenames.h index a908987d96c2..910d696e1ffa 100644 --- a/src/snapshots/filenames.h +++ b/src/snapshots/filenames.h @@ -47,7 +47,7 @@ namespace snapshots snapshot_path.string())); } - return fs::path(snapshot_path.string() + snapshot_endorsements_suffix); + return {snapshot_path.string() + snapshot_endorsements_suffix}; } static void ignore_snapshot_file( From 3db620b2a958c508fc6517e5d8b639613ba464b3 Mon Sep 17 00:00:00 2001 From: achamayou Date: Thu, 23 Jul 2026 09:09:55 +0100 Subject: [PATCH 06/27] Bound recovery snapshot endorsement resources in ledger scan and Python sidecar - recovery_snapshot_ledger.h: stage ledger-suffix endorsements as pending and only commit them at signature boundaries, enforcing per-endorsement size, count, and payload limits (overflow-safe) for both pending and committed sets; collapse the service_infos vector into a single latest_service_info optional snapshotted at each signature, removing the post-loop erase_if. - cbor.cpp/.h: add a max_total_items budget to ccf::cbor::parse, threaded through consume() so deeply/broadly nested structures are rejected by total item count, not just per-container size. snapshot_serdes.h passes MAX_SNAPSHOT_ENDORSEMENTS_COUNT+1 and adds MAX_SNAPSHOT_ENDORSEMENT_RECORD_SIZE. - python/ccf/cose.py, ledger.py: mirror the C++ limits with a streaming CBOR length decoder that rejects oversized/over-count sidecars before materialising them; cap the file read in read_snapshot_endorsements. - Tests: add CBOR max-total-items, pending-endorsement bounding, nested-sidecar, and Python resource-limit coverage; update scan tests for latest_service_info. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 --- python/src/ccf/cose.py | 124 ++++++++++++++++++++++++---- python/src/ccf/ledger.py | 9 +- python/tests/test_cose_sign.py | 34 ++++++++ src/crypto/cbor.cpp | 56 +++++++++---- src/crypto/cbor.h | 3 +- src/crypto/test/cbor.cpp | 8 ++ src/node/node_state.h | 4 +- src/node/recovery_snapshot_ledger.h | 84 +++++++++++++++++-- src/node/snapshot_serdes.h | 7 +- src/node/test/snapshotter.cpp | 66 ++++++++++++++- 10 files changed, 348 insertions(+), 47 deletions(-) diff --git a/python/src/ccf/cose.py b/python/src/ccf/cose.py index cdceadb7d5b3..3040ef0f6b46 100644 --- a/python/src/ccf/cose.py +++ b/python/src/ccf/cose.py @@ -2,7 +2,6 @@ # Licensed under the Apache 2.0 License. import argparse -import io import sys from typing import Any @@ -61,6 +60,10 @@ CCF_ENDORSEMENT_RANGE_BEGIN = "epoch.start.txid" CCF_ENDORSEMENT_RANGE_END = "epoch.end.txid" RECOVERY_VIEW_CHANGE = 2 +MAX_SNAPSHOT_ENDORSEMENTS_SIZE = 16 * 1024 * 1024 +MAX_SNAPSHOT_ENDORSEMENTS_COUNT = 64 +MAX_SNAPSHOT_ENDORSEMENT_SIZE = 1024 * 1024 +MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE = 4 * 1024 * 1024 def default_algorithm_for_key(key) -> int: @@ -198,29 +201,120 @@ def verify_cose_sign1_with_key(key, cose_sign1, payload=None): return cose_ctx.decode_with_headers(cose_sign1, cose_key, detached_payload=payload) +def _validate_cose_endorsement_resource_limits( + endorsements: list[bytes], +) -> None: + if len(endorsements) > MAX_SNAPSHOT_ENDORSEMENTS_COUNT: + raise ValueError( + "Snapshot endorsements sidecar contains too many endorsements " + f"({len(endorsements)}; maximum {MAX_SNAPSHOT_ENDORSEMENTS_COUNT})" + ) + + payload_size = 0 + for index, endorsement in enumerate(endorsements): + endorsement_size = len(endorsement) + if endorsement_size > MAX_SNAPSHOT_ENDORSEMENT_SIZE: + raise ValueError( + f"Snapshot endorsement at index {index} is too large " + f"({endorsement_size} bytes; maximum " + f"{MAX_SNAPSHOT_ENDORSEMENT_SIZE} bytes)" + ) + payload_size += endorsement_size + if payload_size > MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE: + raise ValueError( + "Snapshot endorsements payload is too large " + f"(maximum {MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE} bytes)" + ) + + +def _decode_cbor_length( + serialized: memoryview, offset: int, expected_major_type: int +) -> tuple[int, int]: + if offset >= len(serialized): + raise ValueError("Truncated snapshot endorsements CBOR") + + initial_byte = serialized[offset] + offset += 1 + if initial_byte >> 5 != expected_major_type: + raise ValueError("Unexpected snapshot endorsements CBOR type") + + additional_info = initial_byte & 0x1F + if additional_info < 24: + return additional_info, offset + + length_byte_count = {24: 1, 25: 2, 26: 4, 27: 8}.get(additional_info) + if length_byte_count is None: + raise ValueError("Indefinite or invalid snapshot endorsements CBOR length") + end = offset + length_byte_count + if end > len(serialized): + raise ValueError("Truncated snapshot endorsements CBOR length") + return int.from_bytes(serialized[offset:end]), end + + def serialize_cose_endorsements(endorsements: list[bytes]) -> bytes: if not all(isinstance(endorsement, bytes) for endorsement in endorsements): raise TypeError("COSE endorsements must be bytes") - return cbor2.dumps(endorsements) + _validate_cose_endorsement_resource_limits(endorsements) + serialized = cbor2.dumps(endorsements) + if len(serialized) > MAX_SNAPSHOT_ENDORSEMENTS_SIZE: + raise ValueError( + "Snapshot endorsements sidecar is too large " + f"({len(serialized)} bytes; maximum " + f"{MAX_SNAPSHOT_ENDORSEMENTS_SIZE} bytes)" + ) + return serialized def deserialize_cose_endorsements(serialized: bytes) -> list[bytes]: - stream = io.BytesIO(serialized) - decoder = cbor2.CBORDecoder(stream) try: - endorsements = decoder.decode() - except (cbor2.CBORDecodeError, EOFError) as e: - raise ValueError("Invalid snapshot endorsements CBOR") from e + data = memoryview(serialized) + except TypeError as e: + raise TypeError("Snapshot endorsements CBOR must be bytes-like") from e + if len(data) > MAX_SNAPSHOT_ENDORSEMENTS_SIZE: + raise ValueError( + "Snapshot endorsements sidecar is too large " + f"({len(data)} bytes; maximum {MAX_SNAPSHOT_ENDORSEMENTS_SIZE} bytes)" + ) + try: - decoder.decode() - except cbor2.CBORDecodeEOF: - pass - else: + endorsement_count, offset = _decode_cbor_length(data, 0, 4) + except ValueError as e: + raise ValueError("Snapshot endorsements must be a CBOR array") from e + if endorsement_count > MAX_SNAPSHOT_ENDORSEMENTS_COUNT: + raise ValueError( + "Snapshot endorsements sidecar contains too many endorsements " + f"({endorsement_count}; maximum {MAX_SNAPSHOT_ENDORSEMENTS_COUNT})" + ) + + endorsements: list[bytes] = [] + payload_size = 0 + for index in range(endorsement_count): + try: + endorsement_size, offset = _decode_cbor_length(data, offset, 2) + except ValueError as e: + raise ValueError( + "Snapshot endorsements must be a CBOR array of byte strings" + ) from e + if endorsement_size > MAX_SNAPSHOT_ENDORSEMENT_SIZE: + raise ValueError( + f"Snapshot endorsement at index {index} is too large " + f"({endorsement_size} bytes; maximum " + f"{MAX_SNAPSHOT_ENDORSEMENT_SIZE} bytes)" + ) + payload_size += endorsement_size + if payload_size > MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE: + raise ValueError( + "Snapshot endorsements payload is too large " + f"(maximum {MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE} bytes)" + ) + end = offset + endorsement_size + if end > len(data): + raise ValueError(f"Snapshot endorsement at index {index} is truncated") + endorsements.append(bytes(data[offset:end])) + offset = end + + if offset != len(data): raise ValueError("Trailing data after snapshot endorsements CBOR array") - if not isinstance(endorsements, list) or not all( - isinstance(endorsement, bytes) for endorsement in endorsements - ): - raise ValueError("Snapshot endorsements must be a CBOR array of byte strings") return endorsements diff --git a/python/src/ccf/ledger.py b/python/src/ccf/ledger.py index 25eaea8d1de7..2aad5c410080 100644 --- a/python/src/ccf/ledger.py +++ b/python/src/ccf/ledger.py @@ -146,7 +146,14 @@ def snapshot_endorsements_path(snapshot_path: str) -> str: def read_snapshot_endorsements(path: str) -> list[bytes]: with open(path, "rb") as endorsements_file: - return ccf.cose.deserialize_cose_endorsements(endorsements_file.read()) + serialized = endorsements_file.read(ccf.cose.MAX_SNAPSHOT_ENDORSEMENTS_SIZE + 1) + if len(serialized) > ccf.cose.MAX_SNAPSHOT_ENDORSEMENTS_SIZE: + raise ValueError( + f"Snapshot endorsements sidecar {path} is too large " + f"({len(serialized)} bytes read; maximum " + f"{ccf.cose.MAX_SNAPSHOT_ENDORSEMENTS_SIZE} bytes)" + ) + return ccf.cose.deserialize_cose_endorsements(serialized) def digest(data): diff --git a/python/tests/test_cose_sign.py b/python/tests/test_cose_sign.py index 8dd856954f40..88f2a6bca794 100644 --- a/python/tests/test_cose_sign.py +++ b/python/tests/test_cose_sign.py @@ -15,6 +15,7 @@ ) from cryptography.hazmat.primitives.asymmetric import utils import ccf.cose +import ccf.ledger import cbor2 import cwt import cwt.enums @@ -196,3 +197,36 @@ def test_cose_endorsement_sidecar_encoding_is_strict(): ccf.cose.deserialize_cose_endorsements(cbor2.dumps({"not": "an array"})) with pytest.raises(ValueError, match="Trailing data"): ccf.cose.deserialize_cose_endorsements(cbor2.dumps([]) + b"\x00") + + +def test_cose_endorsement_sidecar_resource_limits(tmp_path): + pathological_count = 1_000_000 + too_many = b"\x9a\x00\x0f\x42\x40" + b"\x40" * pathological_count + with pytest.raises(ValueError, match="too many endorsements"): + ccf.cose.deserialize_cose_endorsements(too_many) + + nested = cbor2.dumps( + [ + [b""] * ccf.cose.MAX_SNAPSHOT_ENDORSEMENTS_COUNT + for _ in range(ccf.cose.MAX_SNAPSHOT_ENDORSEMENTS_COUNT) + ] + ) + with pytest.raises(ValueError, match="byte strings"): + ccf.cose.deserialize_cose_endorsements(nested) + + oversized_endorsement = b"\x00" * (ccf.cose.MAX_SNAPSHOT_ENDORSEMENT_SIZE + 1) + with pytest.raises(ValueError, match="too large"): + ccf.cose.serialize_cose_endorsements([oversized_endorsement]) + with pytest.raises(ValueError, match="too large"): + ccf.cose.deserialize_cose_endorsements(cbor2.dumps([oversized_endorsement])) + + maximum_endorsement = b"\x00" * ccf.cose.MAX_SNAPSHOT_ENDORSEMENT_SIZE + with pytest.raises(ValueError, match="payload is too large"): + ccf.cose.serialize_cose_endorsements([maximum_endorsement] * 5) + with pytest.raises(ValueError, match="payload is too large"): + ccf.cose.deserialize_cose_endorsements(cbor2.dumps([maximum_endorsement] * 5)) + + oversized_path = tmp_path / "oversized.endorsements" + oversized_path.write_bytes(b"\x00" * (ccf.cose.MAX_SNAPSHOT_ENDORSEMENTS_SIZE + 1)) + with pytest.raises(ValueError, match="sidecar .* is too large"): + ccf.ledger.read_snapshot_endorsements(str(oversized_path)) diff --git a/src/crypto/cbor.cpp b/src/crypto/cbor.cpp index 6bbcae25c595..e3dfb1087303 100644 --- a/src/crypto/cbor.cpp +++ b/src/crypto/cbor.cpp @@ -85,7 +85,8 @@ namespace cbor_nondet_t cbor, size_t depth, size_t max_depth, - size_t max_container_size); + size_t max_container_size, + size_t& remaining_items); void print_indent(std::ostringstream& os, size_t indent) { @@ -137,7 +138,8 @@ namespace cbor_nondet_t cbor, size_t depth, size_t max_depth, - size_t max_container_size) + size_t max_container_size, + size_t& remaining_items) { cbor_nondet_array_iterator_t iter; if (!cbor_nondet_array_iterator_start(cbor, &iter)) @@ -163,8 +165,8 @@ namespace throw CBORDecodeError( Error::DECODE_FAILED, "Failed to get next array item"); } - array.items.push_back( - consume(item, depth + 1, max_depth, max_container_size)); + array.items.push_back(consume( + item, depth + 1, max_depth, max_container_size, remaining_items)); } return std::make_shared(std::move(array)); } @@ -173,7 +175,8 @@ namespace cbor_nondet_t cbor, size_t depth, size_t max_depth, - size_t max_container_size) + size_t max_container_size, + size_t& remaining_items) { cbor_map_iterator iter; if (!cbor_nondet_map_iterator_start(cbor, &iter)) @@ -201,8 +204,14 @@ namespace Error::DECODE_FAILED, "Failed to get next map entry"); } map.items.emplace_back( - consume(key_raw, depth + 1, max_depth, max_container_size), - consume(value_raw, depth + 1, max_depth, max_container_size)); + consume( + key_raw, depth + 1, max_depth, max_container_size, remaining_items), + consume( + value_raw, + depth + 1, + max_depth, + max_container_size, + remaining_items)); } return std::make_shared(std::move(map)); } @@ -211,7 +220,8 @@ namespace cbor_nondet_t cbor, size_t depth, size_t max_depth, - size_t max_container_size) + size_t max_container_size, + size_t& remaining_items) { uint64_t tag = 0; cbor_nondet_t payload; @@ -223,7 +233,8 @@ namespace Tagged tagged; tagged.tag = tag; - tagged.item = consume(payload, depth + 1, max_depth, max_container_size); + tagged.item = consume( + payload, depth + 1, max_depth, max_container_size, remaining_items); return std::make_shared(std::move(tagged)); } @@ -245,8 +256,16 @@ namespace cbor_nondet_t cbor, size_t depth, size_t max_depth, - size_t max_container_size) + size_t max_container_size, + size_t& remaining_items) { + if (remaining_items == 0) + { + throw CBORDecodeError( + Error::DECODE_FAILED, "Maximum total CBOR item count exceeded"); + } + --remaining_items; + if (depth > max_depth) { throw CBORDecodeError( @@ -265,11 +284,14 @@ namespace case CBOR_MAJOR_TYPE_TEXT_STRING: return consume_text_string(cbor); case CBOR_MAJOR_TYPE_ARRAY: - return consume_array(cbor, depth, max_depth, max_container_size); + return consume_array( + cbor, depth, max_depth, max_container_size, remaining_items); case CBOR_MAJOR_TYPE_MAP: - return consume_map(cbor, depth, max_depth, max_container_size); + return consume_map( + cbor, depth, max_depth, max_container_size, remaining_items); case CBOR_MAJOR_TYPE_TAGGED: - return consume_tagged(cbor, depth, max_depth, max_container_size); + return consume_tagged( + cbor, depth, max_depth, max_container_size, remaining_items); case CBOR_MAJOR_TYPE_SIMPLE_VALUE: return consume_simple(cbor); default: @@ -595,7 +617,10 @@ namespace ccf::cbor } Value parse( - std::span raw, size_t max_depth, size_t max_container_size) + std::span raw, + size_t max_depth, + size_t max_container_size, + size_t max_total_items) { cbor_nondet_t cbor; const bool check_map_key_bound = false; @@ -620,7 +645,8 @@ namespace ccf::cbor fmt::format("Trailing {} byte(s) after CBOR item", cbor_parse_size)); } - return consume(cbor, 0, max_depth, max_container_size); + auto remaining_items = max_total_items; + return consume(cbor, 0, max_depth, max_container_size, remaining_items); } std::vector serialize(const Value& value, size_t max_depth) diff --git a/src/crypto/cbor.h b/src/crypto/cbor.h index 48cd5f04fb5d..05482adbe060 100644 --- a/src/crypto/cbor.h +++ b/src/crypto/cbor.h @@ -120,7 +120,8 @@ namespace ccf::cbor Value parse( std::span raw, size_t max_depth = 16, - size_t max_container_size = std::numeric_limits::max()); + size_t max_container_size = std::numeric_limits::max(), + size_t max_total_items = std::numeric_limits::max()); std::vector serialize(const Value& value, size_t max_depth = 16); std::string to_string(const Value& value); diff --git a/src/crypto/test/cbor.cpp b/src/crypto/test/cbor.cpp index bf9a8159897d..71b1b44cdc9a 100644 --- a/src/crypto/test/cbor.cpp +++ b/src/crypto/test/cbor.cpp @@ -1778,6 +1778,14 @@ TEST_CASE("CBOR: parse max container size") REQUIRE_THROWS_AS(parse(map, 16, 1), CBORDecodeError); } +TEST_CASE("CBOR: parse max total items") +{ + // [[1, 2], [3, 4]] contains 7 total items, including the 3 arrays. + auto nested_array = ccf::ds::from_hex("82820102820304"); + REQUIRE_NOTHROW(parse(nested_array, 16, 2, 7)); + REQUIRE_THROWS_AS(parse(nested_array, 16, 2, 6), CBORDecodeError); +} + TEST_CASE("CBOR: serialize max depth") { // depth 1: [42] -- should pass at max_depth=1,2 diff --git a/src/node/node_state.h b/src/node/node_state.h index a1f2537510ba..413a4e3c0658 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -922,7 +922,7 @@ namespace ccf throw std::logic_error( "no committed ledger suffix was found after the snapshot"); } - if (scan.service_infos.empty()) + if (!scan.latest_service_info.has_value()) { throw std::logic_error( "no service identity was found in the committed ledger suffix"); @@ -930,7 +930,7 @@ namespace ccf const ccf::crypto::Pem target_identity( *config.recover.previous_service_identity); - const auto& latest_service = scan.service_infos.back().second; + const auto& latest_service = scan.latest_service_info->second; if (latest_service.cert != target_identity) { throw std::logic_error( diff --git a/src/node/recovery_snapshot_ledger.h b/src/node/recovery_snapshot_ledger.h index dcac68124a42..4fb6a9975631 100644 --- a/src/node/recovery_snapshot_ledger.h +++ b/src/node/recovery_snapshot_ledger.h @@ -9,6 +9,7 @@ #include "kv/kv_serialiser.h" #include "kv/serialised_entry_format.h" #include "node/rpc/network_identity_chain_helpers.h" +#include "node/snapshot_serdes.h" #include "service/tables/previous_service_identity.h" #include "service/tables/signatures.h" @@ -16,6 +17,7 @@ #include #include #include +#include #include namespace ccf @@ -32,7 +34,8 @@ namespace ccf { ccf::kv::Version last_signed_idx = 0; std::vector endorsements; - std::vector> service_infos; + std::optional> + latest_service_info = std::nullopt; }; static RecoverySnapshotLedgerEntry parse_recovery_snapshot_ledger_entry( @@ -92,6 +95,14 @@ namespace ccf throw std::logic_error( "Invalid previous service identity endorsement table write"); } + if (value.size() > MAX_SNAPSHOT_ENDORSEMENT_RECORD_SIZE) + { + throw std::logic_error(fmt::format( + "Serialised previous service identity endorsement is too large " + "({} bytes; maximum {} bytes)", + value.size(), + MAX_SNAPSHOT_ENDORSEMENT_RECORD_SIZE)); + } result.endorsement = ccf::PreviousServiceIdentityEndorsement:: ValueSerialiser::from_serialised(value); } @@ -255,6 +266,11 @@ namespace ccf RecoverySnapshotLedgerScan scan; scan.last_signed_idx = snapshot_seqno; auto expected_seqno = snapshot_seqno + 1; + std::vector pending_endorsements; + size_t committed_endorsements_payload_size = 0; + size_t pending_endorsements_payload_size = 0; + std::optional> + latest_observed_service_info = std::nullopt; for (const auto& ledger_file : find_recovery_snapshot_ledger_files(ledger_config)) @@ -395,16 +411,72 @@ namespace ccf if (parsed.endorsement.has_value()) { - scan.endorsements.push_back( + const auto endorsement_size = parsed.endorsement->endorsement.size(); + if (endorsement_size > MAX_SNAPSHOT_ENDORSEMENT_SIZE) + { + throw std::logic_error(fmt::format( + "Ledger endorsement at {} is too large ({} bytes; maximum {} " + "bytes)", + parsed.version, + endorsement_size, + MAX_SNAPSHOT_ENDORSEMENT_SIZE)); + } + if (pending_endorsements.size() >= MAX_SNAPSHOT_ENDORSEMENTS_COUNT) + { + throw std::logic_error(fmt::format( + "Ledger suffix contains too many pending endorsements (maximum " + "{})", + MAX_SNAPSHOT_ENDORSEMENTS_COUNT)); + } + if ( + endorsement_size > MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE - + pending_endorsements_payload_size) + { + throw std::logic_error(fmt::format( + "Pending ledger endorsements payload is too large (maximum {} " + "bytes)", + MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE)); + } + pending_endorsements_payload_size += endorsement_size; + pending_endorsements.push_back( {parsed.version, std::move(*parsed.endorsement)}); } if (parsed.service_info.has_value()) { - scan.service_infos.emplace_back( + latest_observed_service_info.emplace( parsed.version, std::move(*parsed.service_info)); } if (parsed.is_signature) { + if ( + pending_endorsements.size() > + MAX_SNAPSHOT_ENDORSEMENTS_COUNT - scan.endorsements.size()) + { + throw std::logic_error(fmt::format( + "Committed ledger suffix contains too many endorsements " + "(maximum {})", + MAX_SNAPSHOT_ENDORSEMENTS_COUNT)); + } + if ( + pending_endorsements_payload_size > + MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE - + committed_endorsements_payload_size) + { + throw std::logic_error(fmt::format( + "Committed ledger endorsements payload is too large (maximum {} " + "bytes)", + MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE)); + } + + scan.endorsements.insert( + scan.endorsements.end(), + std::make_move_iterator(pending_endorsements.begin()), + std::make_move_iterator(pending_endorsements.end())); + pending_endorsements.clear(); + committed_endorsements_payload_size += + pending_endorsements_payload_size; + pending_endorsements_payload_size = 0; + scan.latest_service_info = latest_observed_service_info; scan.last_signed_idx = parsed.version; } @@ -439,12 +511,6 @@ namespace ccf } } - std::erase_if(scan.endorsements, [&](const auto& item) { - return item.write_version > scan.last_signed_idx; - }); - std::erase_if(scan.service_infos, [&](const auto& item) { - return item.first > scan.last_signed_idx; - }); return scan; } } diff --git a/src/node/snapshot_serdes.h b/src/node/snapshot_serdes.h index fa4599404b89..fae8dd257a89 100644 --- a/src/node/snapshot_serdes.h +++ b/src/node/snapshot_serdes.h @@ -48,6 +48,8 @@ namespace ccf static constexpr size_t MAX_SNAPSHOT_ENDORSEMENTS_SIZE = 16 * 1024 * 1024; static constexpr size_t MAX_SNAPSHOT_ENDORSEMENTS_COUNT = 64; static constexpr size_t MAX_SNAPSHOT_ENDORSEMENT_SIZE = 1024 * 1024; + static constexpr size_t MAX_SNAPSHOT_ENDORSEMENT_RECORD_SIZE = + 2 * MAX_SNAPSHOT_ENDORSEMENT_SIZE; static constexpr size_t MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE = 4 * 1024 * 1024; @@ -148,7 +150,10 @@ namespace ccf const auto parsed = ccf::cbor::rethrow_with_msg( [&]() { return ccf::cbor::parse( - serialised, 16, MAX_SNAPSHOT_ENDORSEMENTS_COUNT); + serialised, + 16, + MAX_SNAPSHOT_ENDORSEMENTS_COUNT, + MAX_SNAPSHOT_ENDORSEMENTS_COUNT + 1); }, "Parse snapshot endorsements sidecar"); if (!std::holds_alternative(parsed->value)) diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index d54c816cbef6..6f365168e545 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -126,6 +126,15 @@ TEST_CASE("Snapshot endorsement sidecar resource limits") too_many.insert(too_many.end(), pathological_endorsement_count, 0x40); REQUIRE_THROWS(ccf::deserialise_cose_endorsements(too_many)); + std::vector nested_items{0x98, 0x40}; + for (size_t i = 0; i < ccf::MAX_SNAPSHOT_ENDORSEMENTS_COUNT; ++i) + { + nested_items.insert(nested_items.end(), {0x98, 0x40}); + nested_items.insert( + nested_items.end(), ccf::MAX_SNAPSHOT_ENDORSEMENTS_COUNT, 0x40); + } + REQUIRE_THROWS(ccf::deserialise_cose_endorsements(nested_items)); + std::vector oversized_endorsement{ 0x81, 0x5a, 0x00, 0x10, 0x00, 0x01}; oversized_endorsement.resize( @@ -220,9 +229,60 @@ TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") REQUIRE(scan.last_signed_idx == 3); REQUIRE(scan.endorsements.size() == 1); REQUIRE(scan.endorsements.front().write_version == 2); - REQUIRE(scan.service_infos.size() == 1); - REQUIRE(scan.service_infos.front().first == 2); - REQUIRE(scan.service_infos.front().second.cert == target_identity.cert); + REQUIRE(scan.latest_service_info.has_value()); + REQUIRE(scan.latest_service_info->first == 2); + REQUIRE(scan.latest_service_info->second.cert == target_identity.cert); +} + +TEST_CASE("Recovery snapshot endorsement scan bounds pending endorsements") +{ + ScopedSnapshotDir ledger_dir; + ccf::kv::Store source_store; + auto encryptor = std::make_shared(); + auto consensus = std::make_shared(); + source_store.set_encryptor(encryptor); + source_store.set_consensus(consensus); + source_store.initialise_term(2); + + std::vector> entries; + for (size_t i = 0; i < ccf::MAX_SNAPSHOT_ENDORSEMENTS_COUNT + 1; ++i) + { + ccf::CoseEndorsement endorsement; + endorsement.endorsement = {0xd2, 0x01}; + endorsement.endorsing_key = {0x02, 0x03}; + endorsement.endorsement_epoch_begin = {2, i + 1}; + endorsement.endorsement_epoch_end = ccf::TxID{4, i + 1}; + endorsement.previous_version = 1; + + auto tx = source_store.create_tx(); + tx.rw( + ccf::Tables::PREVIOUS_SERVICE_IDENTITY_ENDORSEMENT) + ->put(endorsement); + REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); + entries.push_back(consensus->get_latest_data().value()); + } + + const auto ledger_path = ledger_dir.path / "ledger_1"; + { + std::ofstream ledger_file(ledger_path, std::ios::binary); + REQUIRE(ledger_file); + const size_t positions_offset = 0; + ledger_file.write( + reinterpret_cast(&positions_offset), + sizeof(positions_offset)); + for (const auto& entry : entries) + { + ledger_file.write( + reinterpret_cast(entry.data()), + static_cast(entry.size())); + } + REQUIRE(ledger_file); + } + + ccf::CCFConfig::Ledger ledger_config; + ledger_config.directory = ledger_dir.path.string(); + REQUIRE_THROWS( + ccf::scan_recovery_snapshot_ledger_files(ledger_config, encryptor, 0)); } std::optional latest_committed_snapshot_path(const fs::path& dir) From 8a186afaa3989f0c35af72dd3e2f99f8ed7db1e9 Mon Sep 17 00:00:00 2001 From: achamayou Date: Thu, 23 Jul 2026 09:22:02 +0100 Subject: [PATCH 07/27] Fix clang-tidy diagnostics in recovery snapshot ledger scan Address 18 clang-tidy errors surfaced after the resource-bounding refactor: nodiscard on writable-directory check, remove unnamed namespace in header, suppress cognitive-complexity on the ledger scan, guard unchecked optional accesses, widen size constants, forward prepare/install, drop redundant optional dereference, and use a reverse range-based for loop. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 --- src/node/node_state.h | 2 +- src/node/recovery_snapshot_ledger.h | 227 ++++++++++++++-------------- src/node/snapshot_serdes.h | 20 ++- 3 files changed, 128 insertions(+), 121 deletions(-) diff --git a/src/node/node_state.h b/src/node/node_state.h index 413a4e3c0658..12be9929e034 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -747,7 +747,7 @@ namespace ccf start_public_ledger_recovery_unsafe(); } - bool recovery_snapshot_directory_is_writable_unsafe() const + [[nodiscard]] bool recovery_snapshot_directory_is_writable_unsafe() const { if (!startup_snapshot_path.has_value()) { diff --git a/src/node/recovery_snapshot_ledger.h b/src/node/recovery_snapshot_ledger.h index 4fb6a9975631..98eaf1d765e9 100644 --- a/src/node/recovery_snapshot_ledger.h +++ b/src/node/recovery_snapshot_ledger.h @@ -146,112 +146,108 @@ namespace ccf return result; } - namespace + struct RecoverySnapshotLedgerFile { - struct RecoverySnapshotLedgerFile - { - std::filesystem::path path; - size_t start_idx; - std::optional end_idx; - bool committed; - }; - - static std::vector - find_recovery_snapshot_ledger_files(const ccf::CCFConfig::Ledger& config) - { - std::vector files; + std::filesystem::path path; + size_t start_idx; + std::optional end_idx; + bool committed; + }; + + static std::vector + find_recovery_snapshot_ledger_files(const ccf::CCFConfig::Ledger& config) + { + std::vector files; - auto add_directory = - [&](const std::filesystem::path& directory, bool read_only) { - std::error_code ec; - const auto exists = std::filesystem::exists(directory, ec); + auto add_directory = + [&](const std::filesystem::path& directory, bool read_only) { + std::error_code ec; + const auto exists = std::filesystem::exists(directory, ec); + if (ec) + { + throw std::logic_error(fmt::format( + "Unable to inspect ledger directory {}: {}", + directory.string(), + ec.message())); + } + if (!exists) + { + return; + } + + for (std::filesystem::directory_iterator it(directory, ec), end; + it != end; + it.increment(ec)) + { if (ec) { throw std::logic_error(fmt::format( - "Unable to inspect ledger directory {}: {}", + "Unable to iterate ledger directory {}: {}", directory.string(), ec.message())); } - if (!exists) + if (!it->is_regular_file()) { - return; + continue; } - for (std::filesystem::directory_iterator it(directory, ec), end; - it != end; - it.increment(ec)) + const auto name = it->path().filename().string(); + if ( + !name.starts_with("ledger_") || + asynchost::is_ledger_file_ignored(name)) { - if (ec) - { - throw std::logic_error(fmt::format( - "Unable to iterate ledger directory {}: {}", - directory.string(), - ec.message())); - } - if (!it->is_regular_file()) - { - continue; - } - - const auto name = it->path().filename().string(); - if ( - !name.starts_with("ledger_") || - asynchost::is_ledger_file_ignored(name)) - { - continue; - } - - const auto committed = - asynchost::is_ledger_file_name_committed(name); - if (read_only && !committed) - { - continue; - } - - try - { - files.push_back( - {it->path(), - asynchost::get_start_idx_from_file_name(name), - asynchost::get_last_idx_from_file_name(name), - committed}); - } - catch (const std::exception& e) - { - LOG_INFO_FMT( - "Ignoring invalid ledger file name {} while scanning recovery " - "snapshot endorsements: {}", - it->path().string(), - e.what()); - } + continue; } - if (ec) + + const auto committed = asynchost::is_ledger_file_name_committed(name); + if (read_only && !committed) { - throw std::logic_error(fmt::format( - "Unable to iterate ledger directory {}: {}", - directory.string(), - ec.message())); + continue; } - }; - - add_directory(config.directory, false); - for (const auto& directory : config.read_only_directories) - { - add_directory(directory, true); - } - std::sort( - files.begin(), files.end(), [](const auto& lhs, const auto& rhs) { - if (lhs.start_idx != rhs.start_idx) + try { - return lhs.start_idx < rhs.start_idx; + files.push_back( + {it->path(), + asynchost::get_start_idx_from_file_name(name), + asynchost::get_last_idx_from_file_name(name), + committed}); } - return lhs.end_idx.has_value() && !rhs.end_idx.has_value(); - }); - return files; + catch (const std::exception& e) + { + LOG_INFO_FMT( + "Ignoring invalid ledger file name {} while scanning recovery " + "snapshot endorsements: {}", + it->path().string(), + e.what()); + } + } + if (ec) + { + throw std::logic_error(fmt::format( + "Unable to iterate ledger directory {}: {}", + directory.string(), + ec.message())); + } + }; + + add_directory(config.directory, false); + for (const auto& directory : config.read_only_directories) + { + add_directory(directory, true); } + + std::sort(files.begin(), files.end(), [](const auto& lhs, const auto& rhs) { + if (lhs.start_idx != rhs.start_idx) + { + return lhs.start_idx < rhs.start_idx; + } + return lhs.end_idx.has_value() && !rhs.end_idx.has_value(); + }); + return files; } + // NOLINTNEXTLINE(readability-function-cognitive-complexity) static RecoverySnapshotLedgerScan scan_recovery_snapshot_ledger_files( const ccf::CCFConfig::Ledger& ledger_config, const std::shared_ptr& encryptor, @@ -382,17 +378,19 @@ namespace ccf { first_file_version = parsed.version; } - if ( - previous_file_version.has_value() && - (previous_file_version.value() == - std::numeric_limits::max() || - parsed.version != previous_file_version.value() + 1)) + if (previous_file_version.has_value()) { - throw std::logic_error(fmt::format( - "Ledger file {} contains non-contiguous versions {} and {}", - ledger_file.path.string(), - previous_file_version.value(), - parsed.version)); + const auto previous_version = previous_file_version.value(); + if ( + previous_version == std::numeric_limits::max() || + parsed.version != previous_version + 1) + { + throw std::logic_error(fmt::format( + "Ledger file {} contains non-contiguous versions {} and {}", + ledger_file.path.string(), + previous_version, + parsed.version)); + } } previous_file_version = parsed.version; @@ -488,26 +486,31 @@ namespace ccf ++expected_seqno; } - if ( - first_file_version.has_value() && - first_file_version.value() != - static_cast(ledger_file.start_idx)) + if (first_file_version.has_value()) { - throw std::logic_error(fmt::format( - "Ledger file {} does not start at its declared seqno {}", - ledger_file.path.string(), - ledger_file.start_idx)); + const auto first_version = first_file_version.value(); + if ( + first_version != static_cast(ledger_file.start_idx)) + { + throw std::logic_error(fmt::format( + "Ledger file {} does not start at its declared seqno {}", + ledger_file.path.string(), + ledger_file.start_idx)); + } } - if ( - ledger_file.end_idx.has_value() && - (!previous_file_version.has_value() || - previous_file_version.value() != - static_cast(ledger_file.end_idx.value()))) + if (ledger_file.end_idx.has_value()) { - throw std::logic_error(fmt::format( - "Ledger file {} does not end at its declared seqno {}", - ledger_file.path.string(), - ledger_file.end_idx.value())); + const auto declared_end_idx = + static_cast(ledger_file.end_idx.value()); + if ( + !previous_file_version.has_value() || + previous_file_version.value() != declared_end_idx) + { + throw std::logic_error(fmt::format( + "Ledger file {} does not end at its declared seqno {}", + ledger_file.path.string(), + declared_end_idx)); + } } } diff --git a/src/node/snapshot_serdes.h b/src/node/snapshot_serdes.h index fae8dd257a89..992b6baae125 100644 --- a/src/node/snapshot_serdes.h +++ b/src/node/snapshot_serdes.h @@ -25,6 +25,8 @@ #include #include +#include +#include namespace ccf { @@ -45,13 +47,14 @@ namespace ccf std::span receipt; }; - static constexpr size_t MAX_SNAPSHOT_ENDORSEMENTS_SIZE = 16 * 1024 * 1024; + static constexpr size_t MAX_SNAPSHOT_ENDORSEMENTS_SIZE = + size_t{16} * 1024 * 1024; static constexpr size_t MAX_SNAPSHOT_ENDORSEMENTS_COUNT = 64; - static constexpr size_t MAX_SNAPSHOT_ENDORSEMENT_SIZE = 1024 * 1024; + static constexpr size_t MAX_SNAPSHOT_ENDORSEMENT_SIZE = size_t{1024} * 1024; static constexpr size_t MAX_SNAPSHOT_ENDORSEMENT_RECORD_SIZE = 2 * MAX_SNAPSHOT_ENDORSEMENT_SIZE; static constexpr size_t MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE = - 4 * 1024 * 1024; + size_t{4} * 1024 * 1024; template static void validate_cose_endorsement_resource_limits( @@ -188,14 +191,14 @@ namespace ccf { try { - prepare(); + std::forward(prepare)(); } catch (const std::exception& e) { return e.what(); } - install(); + std::forward(install)(); return std::nullopt; } @@ -300,7 +303,7 @@ namespace ccf .endorsement = serialised, .endorsing_key = {}, .endorsement_epoch_begin = *from_txid, - .endorsement_epoch_end = *to_txid, + .endorsement_epoch_end = to_txid, .previous_version = ccf::kv::Version{0}}; } @@ -481,9 +484,10 @@ namespace ccf ccf::SerialisedCoseEndorsements serialised; serialised.reserve(collected.size()); - for (auto it = collected.rbegin(); it != collected.rend(); ++it) + for (const auto& collected_endorsement : + std::ranges::reverse_view(collected)) { - serialised.emplace_back(it->endorsement.endorsement); + serialised.emplace_back(collected_endorsement.endorsement.endorsement); } return serialised; } From 7319a9003bc95eb70e4f47ff1411fe2f602f6a20 Mon Sep 17 00:00:00 2001 From: achamayou Date: Thu, 23 Jul 2026 10:55:27 +0100 Subject: [PATCH 08/27] Satisfy clang-tidy optional-access on recovery ledger scan guards The four remaining bugprone-unchecked-optional-access errors in the ledger scan (VMSS Virtual A) were guarded with has_value()/value() but the checker did not track the guard across the compound/loop-carried blocks. Bind the optional in the if-condition and dereference the binding (operator bool / operator*), the form the optional model recognises. Behaviour is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 --- src/node/recovery_snapshot_ledger.h | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/node/recovery_snapshot_ledger.h b/src/node/recovery_snapshot_ledger.h index 98eaf1d765e9..05704bad5f49 100644 --- a/src/node/recovery_snapshot_ledger.h +++ b/src/node/recovery_snapshot_ledger.h @@ -378,9 +378,9 @@ namespace ccf { first_file_version = parsed.version; } - if (previous_file_version.has_value()) + if (const auto previous_version_opt = previous_file_version) { - const auto previous_version = previous_file_version.value(); + const auto previous_version = *previous_version_opt; if ( previous_version == std::numeric_limits::max() || parsed.version != previous_version + 1) @@ -486,9 +486,9 @@ namespace ccf ++expected_seqno; } - if (first_file_version.has_value()) + if (const auto first_version_opt = first_file_version) { - const auto first_version = first_file_version.value(); + const auto first_version = *first_version_opt; if ( first_version != static_cast(ledger_file.start_idx)) { @@ -498,13 +498,12 @@ namespace ccf ledger_file.start_idx)); } } - if (ledger_file.end_idx.has_value()) + if (const auto end_idx_opt = ledger_file.end_idx) { const auto declared_end_idx = - static_cast(ledger_file.end_idx.value()); - if ( - !previous_file_version.has_value() || - previous_file_version.value() != declared_end_idx) + static_cast(*end_idx_opt); + const auto previous_version_opt = previous_file_version; + if (!previous_version_opt || *previous_version_opt != declared_end_idx) { throw std::logic_error(fmt::format( "Ledger file {} does not end at its declared seqno {}", From da17588ee3507297b2854ac2e6e15de65d931013 Mon Sep 17 00:00:00 2001 From: achamayou Date: Thu, 23 Jul 2026 09:29:59 +0100 Subject: [PATCH 09/27] Bound recovery sidecar decoding Keep snapshot installation outside verification error handling, decode sidecars with strict count and element bounds before materialization, align Python verification, and harden direct ledger scanner checks. Add failed-store, malformed sidecar, cumulative payload, and malicious-count rederivation regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 Copilot-Session: 4d631113-a6ff-453d-a658-3b46aaaec95a Copilot-Session: 972cd758-c6d2-4d1b-b043-b482e129cb54 --- python/src/ccf/cose.py | 27 ++++++++--- python/tests/test_cose_sign.py | 20 ++++++-- src/node/snapshot_serdes.h | 22 +++++++-- src/node/test/snapshotter.cpp | 63 +++++++++++++++++++++---- tests/recovery_snapshot_endorsements.py | 8 ++-- 5 files changed, 111 insertions(+), 29 deletions(-) diff --git a/python/src/ccf/cose.py b/python/src/ccf/cose.py index 3040ef0f6b46..300acb78cf4d 100644 --- a/python/src/ccf/cose.py +++ b/python/src/ccf/cose.py @@ -62,6 +62,7 @@ RECOVERY_VIEW_CHANGE = 2 MAX_SNAPSHOT_ENDORSEMENTS_SIZE = 16 * 1024 * 1024 MAX_SNAPSHOT_ENDORSEMENTS_COUNT = 64 +MIN_SNAPSHOT_ENDORSEMENT_SIZE = 64 MAX_SNAPSHOT_ENDORSEMENT_SIZE = 1024 * 1024 MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE = 4 * 1024 * 1024 @@ -204,15 +205,22 @@ def verify_cose_sign1_with_key(key, cose_sign1, payload=None): def _validate_cose_endorsement_resource_limits( endorsements: list[bytes], ) -> None: - if len(endorsements) > MAX_SNAPSHOT_ENDORSEMENTS_COUNT: + if not 1 <= len(endorsements) <= MAX_SNAPSHOT_ENDORSEMENTS_COUNT: raise ValueError( - "Snapshot endorsements sidecar contains too many endorsements " - f"({len(endorsements)}; maximum {MAX_SNAPSHOT_ENDORSEMENTS_COUNT})" + "Snapshot endorsements sidecar must contain between 1 and " + f"{MAX_SNAPSHOT_ENDORSEMENTS_COUNT} endorsements, got " + f"{len(endorsements)}" ) payload_size = 0 for index, endorsement in enumerate(endorsements): endorsement_size = len(endorsement) + if endorsement_size < MIN_SNAPSHOT_ENDORSEMENT_SIZE: + raise ValueError( + f"Snapshot endorsement at index {index} is too small " + f"({endorsement_size} bytes; minimum " + f"{MIN_SNAPSHOT_ENDORSEMENT_SIZE} bytes)" + ) if endorsement_size > MAX_SNAPSHOT_ENDORSEMENT_SIZE: raise ValueError( f"Snapshot endorsement at index {index} is too large " @@ -280,10 +288,11 @@ def deserialize_cose_endorsements(serialized: bytes) -> list[bytes]: endorsement_count, offset = _decode_cbor_length(data, 0, 4) except ValueError as e: raise ValueError("Snapshot endorsements must be a CBOR array") from e - if endorsement_count > MAX_SNAPSHOT_ENDORSEMENTS_COUNT: + if not 1 <= endorsement_count <= MAX_SNAPSHOT_ENDORSEMENTS_COUNT: raise ValueError( - "Snapshot endorsements sidecar contains too many endorsements " - f"({endorsement_count}; maximum {MAX_SNAPSHOT_ENDORSEMENTS_COUNT})" + "Snapshot endorsements sidecar must contain between 1 and " + f"{MAX_SNAPSHOT_ENDORSEMENTS_COUNT} endorsements, got " + f"{endorsement_count}" ) endorsements: list[bytes] = [] @@ -295,6 +304,12 @@ def deserialize_cose_endorsements(serialized: bytes) -> list[bytes]: raise ValueError( "Snapshot endorsements must be a CBOR array of byte strings" ) from e + if endorsement_size < MIN_SNAPSHOT_ENDORSEMENT_SIZE: + raise ValueError( + f"Snapshot endorsement at index {index} is too small " + f"({endorsement_size} bytes; minimum " + f"{MIN_SNAPSHOT_ENDORSEMENT_SIZE} bytes)" + ) if endorsement_size > MAX_SNAPSHOT_ENDORSEMENT_SIZE: raise ValueError( f"Snapshot endorsement at index {index} is too large " diff --git a/python/tests/test_cose_sign.py b/python/tests/test_cose_sign.py index 88f2a6bca794..d5c165756236 100644 --- a/python/tests/test_cose_sign.py +++ b/python/tests/test_cose_sign.py @@ -196,15 +196,27 @@ def test_cose_endorsement_sidecar_encoding_is_strict(): with pytest.raises(ValueError, match="CBOR array"): ccf.cose.deserialize_cose_endorsements(cbor2.dumps({"not": "an array"})) with pytest.raises(ValueError, match="Trailing data"): - ccf.cose.deserialize_cose_endorsements(cbor2.dumps([]) + b"\x00") + ccf.cose.deserialize_cose_endorsements( + ccf.cose.serialize_cose_endorsements([b"x" * 64]) + b"\x00" + ) def test_cose_endorsement_sidecar_resource_limits(tmp_path): - pathological_count = 1_000_000 - too_many = b"\x9a\x00\x0f\x42\x40" + b"\x40" * pathological_count - with pytest.raises(ValueError, match="too many endorsements"): + too_many = b"\x9a\x00\x0f\x42\x40" + with pytest.raises(ValueError, match="between 1 and"): ccf.cose.deserialize_cose_endorsements(too_many) + with pytest.raises(ValueError, match="between 1 and"): + ccf.cose.deserialize_cose_endorsements(b"\x80") + with pytest.raises(ValueError, match="between 1 and"): + ccf.cose.serialize_cose_endorsements([]) + with pytest.raises(ValueError, match="too small"): + ccf.cose.deserialize_cose_endorsements(b"\x81\x40") + with pytest.raises(ValueError, match="too small"): + ccf.cose.serialize_cose_endorsements([b"x"]) + with pytest.raises(ValueError, match="CBOR array"): + ccf.cose.deserialize_cose_endorsements(b"\x9f\xff") + nested = cbor2.dumps( [ [b""] * ccf.cose.MAX_SNAPSHOT_ENDORSEMENTS_COUNT diff --git a/src/node/snapshot_serdes.h b/src/node/snapshot_serdes.h index 992b6baae125..6c24cdc18104 100644 --- a/src/node/snapshot_serdes.h +++ b/src/node/snapshot_serdes.h @@ -50,6 +50,7 @@ namespace ccf static constexpr size_t MAX_SNAPSHOT_ENDORSEMENTS_SIZE = size_t{16} * 1024 * 1024; static constexpr size_t MAX_SNAPSHOT_ENDORSEMENTS_COUNT = 64; + static constexpr size_t MIN_SNAPSHOT_ENDORSEMENT_SIZE = 64; static constexpr size_t MAX_SNAPSHOT_ENDORSEMENT_SIZE = size_t{1024} * 1024; static constexpr size_t MAX_SNAPSHOT_ENDORSEMENT_RECORD_SIZE = 2 * MAX_SNAPSHOT_ENDORSEMENT_SIZE; @@ -60,19 +61,30 @@ namespace ccf static void validate_cose_endorsement_resource_limits( const Endorsements& endorsements) { - if (endorsements.size() > MAX_SNAPSHOT_ENDORSEMENTS_COUNT) + if ( + endorsements.empty() || + endorsements.size() > MAX_SNAPSHOT_ENDORSEMENTS_COUNT) { throw std::logic_error(fmt::format( - "Snapshot endorsements sidecar contains too many endorsements ({}; " - "maximum {})", - endorsements.size(), - MAX_SNAPSHOT_ENDORSEMENTS_COUNT)); + "Snapshot endorsements sidecar must contain between 1 and {} " + "endorsements, got {}", + MAX_SNAPSHOT_ENDORSEMENTS_COUNT, + endorsements.size())); } size_t payload_size = 0; for (size_t i = 0; i < endorsements.size(); ++i) { const auto endorsement_size = endorsements[i].size(); + if (endorsement_size < MIN_SNAPSHOT_ENDORSEMENT_SIZE) + { + throw std::logic_error(fmt::format( + "Snapshot endorsement at index {} is too small ({} bytes; minimum " + "{} bytes)", + i, + endorsement_size, + MIN_SNAPSHOT_ENDORSEMENT_SIZE)); + } if (endorsement_size > MAX_SNAPSHOT_ENDORSEMENT_SIZE) { throw std::logic_error(fmt::format( diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index 6f365168e545..809b13db5381 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -58,8 +58,17 @@ struct ScopedSnapshotDir } }; +std::vector make_test_endorsement(uint8_t marker) +{ + std::vector endorsement(ccf::MIN_SNAPSHOT_ENDORSEMENT_SIZE, marker); + endorsement.front() = 0xd2; + return endorsement; +} + TEST_CASE("Recovery snapshot installation failures do not request fallback") { + ccf::kv::Store store; + store.set_readiness(ccf::kv::StoreReadiness::InstallingSnapshot); bool install_started = false; bool fallback_requested = false; @@ -70,12 +79,14 @@ TEST_CASE("Recovery snapshot installation failures do not request fallback") []() {}, [&]() { install_started = true; + store.set_readiness(ccf::kv::StoreReadiness::Failed); throw std::logic_error("snapshot installation failed"); }); fallback_requested = preparation_error.has_value(); }(), std::logic_error); REQUIRE(install_started); + REQUIRE(store.get_readiness() == ccf::kv::StoreReadiness::Failed); REQUIRE_FALSE(fallback_requested); bool install_called = false; @@ -99,7 +110,7 @@ TEST_CASE("Snapshot endorsement sidecar file lifecycle") REQUIRE_FALSE(snapshots::is_snapshot_file_committed(sidecar_path.filename())); const ccf::SerialisedCoseEndorsements endorsements{ - {0xd2, 0x01, 0x02}, {0xd2, 0x03, 0x04}}; + make_test_endorsement(0x01), make_test_endorsement(0x02)}; ccf::write_cose_endorsements_sidecar(sidecar_path, endorsements); REQUIRE(ccf::read_cose_endorsements_sidecar(sidecar_path) == endorsements); REQUIRE(files::slurp(snapshot_path) == snapshot_data); @@ -122,10 +133,23 @@ TEST_CASE("Snapshot endorsement sidecar file lifecycle") TEST_CASE("Snapshot endorsement sidecar resource limits") { constexpr size_t pathological_endorsement_count = 1'000'000; - std::vector too_many{0x9a, 0x00, 0x0f, 0x42, 0x40}; - too_many.insert(too_many.end(), pathological_endorsement_count, 0x40); + const std::vector too_many{ + 0x9a, + static_cast(pathological_endorsement_count >> 24), + static_cast(pathological_endorsement_count >> 16), + static_cast(pathological_endorsement_count >> 8), + static_cast(pathological_endorsement_count)}; REQUIRE_THROWS(ccf::deserialise_cose_endorsements(too_many)); + const std::vector empty_array{0x80}; + const std::vector undersized{0x81, 0x40}; + const std::vector map_instead_of_array{0xa0}; + const std::vector indefinite_array{0x9f, 0xff}; + REQUIRE_THROWS(ccf::deserialise_cose_endorsements(empty_array)); + REQUIRE_THROWS(ccf::deserialise_cose_endorsements(undersized)); + REQUIRE_THROWS(ccf::deserialise_cose_endorsements(map_instead_of_array)); + REQUIRE_THROWS(ccf::deserialise_cose_endorsements(indefinite_array)); + std::vector nested_items{0x98, 0x40}; for (size_t i = 0; i < ccf::MAX_SNAPSHOT_ENDORSEMENTS_COUNT; ++i) { @@ -135,10 +159,8 @@ TEST_CASE("Snapshot endorsement sidecar resource limits") } REQUIRE_THROWS(ccf::deserialise_cose_endorsements(nested_items)); - std::vector oversized_endorsement{ + const std::vector oversized_endorsement{ 0x81, 0x5a, 0x00, 0x10, 0x00, 0x01}; - oversized_endorsement.resize( - oversized_endorsement.size() + ccf::MAX_SNAPSHOT_ENDORSEMENT_SIZE + 1); REQUIRE_THROWS(ccf::deserialise_cose_endorsements(oversized_endorsement)); std::vector oversized_payload{0x85}; @@ -150,6 +172,15 @@ TEST_CASE("Snapshot endorsement sidecar resource limits") oversized_payload.size() + ccf::MAX_SNAPSHOT_ENDORSEMENT_SIZE); } REQUIRE_THROWS(ccf::deserialise_cose_endorsements(oversized_payload)); + + ccf::SerialisedCoseEndorsements excessive_count( + ccf::MAX_SNAPSHOT_ENDORSEMENTS_COUNT + 1, make_test_endorsement(0x01)); + REQUIRE_THROWS(ccf::serialise_cose_endorsements(excessive_count)); + REQUIRE_THROWS(ccf::serialise_cose_endorsements({})); + REQUIRE_THROWS( + ccf::serialise_cose_endorsements({std::vector{0xd2}})); + REQUIRE_THROWS(ccf::serialise_cose_endorsements( + {std::vector(ccf::MAX_SNAPSHOT_ENDORSEMENT_SIZE + 1, 0xd2)})); } TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") @@ -167,7 +198,10 @@ TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") auto tx = source_store.create_tx(); tx.rw("public:unrelated")->put("key", "value"); REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); - entries.push_back(consensus->get_latest_data().value()); + auto latest_entry = + consensus->get_latest_data().value_or(std::vector{}); + REQUIRE_FALSE(latest_entry.empty()); + entries.push_back(std::move(latest_entry)); } ccf::NetworkIdentity target_identity( @@ -194,7 +228,10 @@ TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") ccf::Tables::PREVIOUS_SERVICE_IDENTITY_ENDORSEMENT) ->put(endorsement); REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); - entries.push_back(consensus->get_latest_data().value()); + auto latest_entry = + consensus->get_latest_data().value_or(std::vector{}); + REQUIRE_FALSE(latest_entry.empty()); + entries.push_back(std::move(latest_entry)); } { auto tx = source_store.create_tx(); @@ -202,7 +239,10 @@ TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") tx.rw(ccf::Tables::SERIALISED_MERKLE_TREE) ->put({0x01}); REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); - entries.push_back(consensus->get_latest_data().value()); + auto latest_entry = + consensus->get_latest_data().value_or(std::vector{}); + REQUIRE_FALSE(latest_entry.empty()); + entries.push_back(std::move(latest_entry)); } const auto ledger_path = ledger_dir.path / "ledger_1"; @@ -259,7 +299,10 @@ TEST_CASE("Recovery snapshot endorsement scan bounds pending endorsements") ccf::Tables::PREVIOUS_SERVICE_IDENTITY_ENDORSEMENT) ->put(endorsement); REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); - entries.push_back(consensus->get_latest_data().value()); + auto latest_entry = + consensus->get_latest_data().value_or(std::vector{}); + REQUIRE_FALSE(latest_entry.empty()); + entries.push_back(std::move(latest_entry)); } const auto ledger_path = ledger_dir.path / "ledger_1"; diff --git a/tests/recovery_snapshot_endorsements.py b/tests/recovery_snapshot_endorsements.py index f1a577665726..10f1a8b85d23 100644 --- a/tests/recovery_snapshot_endorsements.py +++ b/tests/recovery_snapshot_endorsements.py @@ -6,6 +6,7 @@ import os import shutil +import ccf.cose import ccf.ledger import infra.e2e_args import infra.logging_app as app @@ -228,11 +229,10 @@ def run_recovery_snapshot_endorsements(args): finally: _stop_incomplete_recovery(reuse_attempt) - with open(sidecar_path, "rb") as sidecar_file: - tampered = bytearray(sidecar_file.read()) - tampered[-1] ^= 0xFF with open(sidecar_path, "wb") as sidecar_file: - sidecar_file.write(tampered) + sidecar_file.write( + bytes([0x98, ccf.cose.MAX_SNAPSHOT_ENDORSEMENTS_COUNT + 1]) + ) tamper_attempt = _start_recovery_attempt( second_recovery, From d57b38fc8e768cf77db682d38de95df9c446e29c Mon Sep 17 00:00:00 2001 From: achamayou Date: Thu, 23 Jul 2026 11:39:32 +0100 Subject: [PATCH 10/27] Harden Python COSE receipt verification Replace optimized-away receipt assertions with explicit structure, digest, proof and signature validation. Preserve valid empty Merkle paths and add normal, invalid-signature and python -O regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 Copilot-Session: 4d631113-a6ff-453d-a658-3b46aaaec95a Copilot-Session: 972cd758-c6d2-4d1b-b043-b482e129cb54 --- python/src/ccf/cose.py | 165 ++++++++++++++++++++++++++------- python/tests/test_cose_sign.py | 160 ++++++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+), 34 deletions(-) diff --git a/python/src/ccf/cose.py b/python/src/ccf/cose.py index 300acb78cf4d..22647edfbd7f 100644 --- a/python/src/ccf/cose.py +++ b/python/src/ccf/cose.py @@ -2,6 +2,7 @@ # Licensed under the Apache 2.0 License. import argparse +import io import sys from typing import Any @@ -421,6 +422,24 @@ def verify_receipt_with_endorsements( return verify_receipt(receipt_bytes, snapshot_signer_key, claim_digest) +def _decode_single_cbor(data: bytes, description: str): + if not isinstance(data, bytes): + raise TypeError(f"{description} CBOR must be bytes") + + stream = io.BytesIO(data) + decoder = cbor2.CBORDecoder(stream) + try: + value = decoder.decode() + except (cbor2.CBORDecodeError, EOFError) as e: + raise ValueError(f"Invalid {description} CBOR") from e + + try: + decoder.decode() + except cbor2.CBORDecodeEOF: + return value + raise ValueError(f"Trailing data after {description} CBOR") + + def verify_receipt( receipt_bytes: bytes, key: CertificatePublicKeyTypes, claim_digest: bytes ): @@ -429,6 +448,11 @@ def verify_receipt( using the CCF tree algorithm defined in https://datatracker.ietf.org/doc/draft-birkholz-cose-receipts-ccf-profile/ """ + if not isinstance(claim_digest, bytes): + raise TypeError("Claim digest must be bytes") + if len(claim_digest) != hashlib.sha256().digest_size: + raise ValueError(f"Claim digest must be {hashlib.sha256().digest_size} bytes") + key_pem = key.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo).decode( "ascii" ) @@ -436,46 +460,119 @@ def verify_receipt( cose_key = cwt.COSEKey.from_pem(key_pem, kid=expected_kid) cose_ctx = cwt.COSE.new() - receipt = cbor2.loads(receipt_bytes) - assert receipt.tag == cwt.const.COSE_TYPE_TO_TAG[cwt.const.COSETypes.SIGN1] - phdr, uhdr, payload, sig = receipt.value - phdr = cbor2.loads(phdr) - - assert phdr[4] == expected_kid.encode("utf-8") - - assert COSE_PHDR_VDS_LABEL in phdr, "Verifiable data structure type is required" - assert ( - phdr[COSE_PHDR_VDS_LABEL] == COSE_PHDR_VDS_CCF_LEDGER_SHA256 - ), "vds(395) protected header must be CCF_LEDGER_SHA256(2)" - - assert COSE_PHDR_VDP_LABEL in uhdr, "Verifiable data proof is required" - proof = uhdr[COSE_PHDR_VDP_LABEL] - assert COSE_RECEIPT_INCLUSION_PROOF_LABEL in proof, "Inclusion proof is required" - inclusion_proofs = proof[COSE_RECEIPT_INCLUSION_PROOF_LABEL] - assert inclusion_proofs, "At least one inclusion proof is required" - - ic_phdr = None - for inclusion_proof in inclusion_proofs: - assert isinstance(inclusion_proof, bytes), "Inclusion proof must be bstr" - proof = cbor2.loads(inclusion_proof) - assert CCF_PROOF_LEAF_LABEL in proof, "Leaf must be present" - leaf = proof[CCF_PROOF_LEAF_LABEL] - if claim_digest != leaf[2]: - raise ValueError(f"Claim digest mismatch: {leaf[2]!r} != {claim_digest!r}") + receipt = _decode_single_cbor(receipt_bytes, "COSE receipt") + expected_tag = cwt.const.COSE_TYPE_TO_TAG[cwt.const.COSETypes.SIGN1] + if not isinstance(receipt, cbor2.CBORTag) or receipt.tag != expected_tag: + raise ValueError(f"COSE receipt must use tag {expected_tag}") + if not isinstance(receipt.value, (list, tuple)) or len(receipt.value) != 4: + raise ValueError("COSE receipt must contain a four-element Sign1 array") + + protected_raw, unprotected, payload, signature = receipt.value + if not isinstance(protected_raw, bytes): + raise ValueError("COSE receipt protected header must be a byte string") + if not isinstance(unprotected, dict): + raise ValueError("COSE receipt unprotected header must be a map") + if payload is not None: + raise ValueError("COSE receipt payload must be detached") + if not isinstance(signature, bytes) or not signature: + raise ValueError("COSE receipt signature must be a non-empty byte string") + + protected = _decode_single_cbor(protected_raw, "COSE protected header") + if not isinstance(protected, dict): + raise ValueError("COSE receipt protected header must encode a map") + + algorithm_label = int(cwt.COSEHeaders.ALG) + if algorithm_label not in protected or not isinstance( + protected[algorithm_label], int + ): + raise ValueError("COSE receipt signing algorithm is required") + kid_label = int(cwt.COSEHeaders.KID) + if protected.get(kid_label) != expected_kid.encode("utf-8"): + raise ValueError("COSE receipt key ID does not match the verification key") + if COSE_PHDR_VDS_LABEL not in protected: + raise ValueError("Verifiable data structure type is required") + if protected[COSE_PHDR_VDS_LABEL] != COSE_PHDR_VDS_CCF_LEDGER_SHA256: + raise ValueError("vds(395) protected header must be CCF_LEDGER_SHA256(2)") + + verifiable_data_proof = unprotected.get(COSE_PHDR_VDP_LABEL) + if not isinstance(verifiable_data_proof, dict): + raise ValueError("Verifiable data proof must be a map") + inclusion_proofs = verifiable_data_proof.get(COSE_RECEIPT_INCLUSION_PROOF_LABEL) + if not isinstance(inclusion_proofs, (list, tuple)): + raise ValueError("Inclusion proofs must be an array") + if not inclusion_proofs: + raise ValueError("At least one inclusion proof is required") + + verified_protected_header = None + for proof_index, inclusion_proof in enumerate(inclusion_proofs): + if not isinstance(inclusion_proof, bytes) or not inclusion_proof: + raise ValueError( + f"Inclusion proof at index {proof_index} must be a non-empty " + "byte string" + ) + proof = _decode_single_cbor(inclusion_proof, "inclusion proof") + if not isinstance(proof, dict): + raise ValueError("Inclusion proof must encode a map") + + leaf = proof.get(CCF_PROOF_LEAF_LABEL) + if not isinstance(leaf, (list, tuple)) or len(leaf) != 3: + raise ValueError("Inclusion proof leaf must contain three elements") + write_set_digest, commit_evidence, proof_claim_digest = leaf + if ( + not isinstance(write_set_digest, bytes) + or len(write_set_digest) != hashlib.sha256().digest_size + ): + raise ValueError("Write set digest must be a 32-byte byte string") + if not isinstance(commit_evidence, str) or not commit_evidence: + raise ValueError("Commit evidence must be a non-empty string") + if ( + not isinstance(proof_claim_digest, bytes) + or len(proof_claim_digest) != hashlib.sha256().digest_size + ): + raise ValueError("Proof claim digest must be a 32-byte byte string") + if claim_digest != proof_claim_digest: + raise ValueError( + f"Claim digest mismatch: {proof_claim_digest!r} != {claim_digest!r}" + ) accumulator = hashlib.sha256( - leaf[0] + hashlib.sha256(leaf[1].encode()).digest() + leaf[2] + write_set_digest + + hashlib.sha256(commit_evidence.encode()).digest() + + proof_claim_digest ).digest() - assert CCF_PROOF_PATH_LABEL in proof, "Path must be present" - path = proof[CCF_PROOF_PATH_LABEL] - for left, digest in path: + + path = proof.get(CCF_PROOF_PATH_LABEL) + if not isinstance(path, (list, tuple)): + raise ValueError("Inclusion proof path must be an array") + for path_index, path_entry in enumerate(path): + if not isinstance(path_entry, (list, tuple)) or len(path_entry) != 2: + raise ValueError( + f"Inclusion proof path entry {path_index} must contain " + "direction and digest" + ) + left, digest = path_entry + if not isinstance(left, bool): + raise ValueError( + f"Inclusion proof path direction {path_index} must be boolean" + ) + if ( + not isinstance(digest, bytes) + or len(digest) != hashlib.sha256().digest_size + ): + raise ValueError( + f"Inclusion proof path digest {path_index} must be 32 bytes" + ) if left: accumulator = hashlib.sha256(digest + accumulator).digest() else: accumulator = hashlib.sha256(accumulator + digest).digest() - ic_phdr, _, _ = cose_ctx.decode_with_headers( - receipt_bytes, cose_key, detached_payload=accumulator - ) - return ic_phdr + try: + verified_protected_header, _, _ = cose_ctx.decode_with_headers( + receipt_bytes, cose_key, detached_payload=accumulator + ) + except cwt.exceptions.CWTError as e: + raise ValueError("COSE receipt signature verification failed") from e + + return verified_protected_header _SIGN_DESCRIPTION = """Create and sign a COSE Sign1 message for CCF governance diff --git a/python/tests/test_cose_sign.py b/python/tests/test_cose_sign.py index d5c165756236..473f5d5b1406 100644 --- a/python/tests/test_cose_sign.py +++ b/python/tests/test_cose_sign.py @@ -3,6 +3,11 @@ import base64 import datetime +import hashlib +import os +from pathlib import Path +import subprocess +import sys from typing import Tuple from cryptography.hazmat.primitives.asymmetric import ec from cryptography import x509 @@ -148,6 +153,161 @@ def make_cose_endorsement(signer, payload_key, begin: str, end: str) -> bytes: ).encode_and_sign(payload, cose_key, protected=protected) +def make_cose_receipt( + signer: ec.EllipticCurvePrivateKey, + claim_digest: bytes, + path: list[list[bool | bytes]], +) -> bytes: + signer_priv_pem, signer_pub_pem = make_pem_pair(signer) + kid = ccf.cose.key_fingerprint_from_key(signer_pub_pem) + cose_key = cwt.COSEKey.from_pem(signer_priv_pem, kid=kid) + protected = cwt.utils.ResolvedHeader( + { + int(cwt.COSEHeaders.ALG): ccf.cose.default_algorithm_for_key( + signer.public_key() + ), + int(cwt.COSEHeaders.KID): kid.encode("utf-8"), + ccf.cose.COSE_PHDR_VDS_LABEL: (ccf.cose.COSE_PHDR_VDS_CCF_LEDGER_SHA256), + } + ) + + write_set_digest = hashlib.sha256(b"write set").digest() + commit_evidence = "ce:2.1" + proof = cbor2.dumps( + { + ccf.cose.CCF_PROOF_LEAF_LABEL: [ + write_set_digest, + commit_evidence, + claim_digest, + ], + ccf.cose.CCF_PROOF_PATH_LABEL: path, + } + ) + unprotected = { + ccf.cose.COSE_PHDR_VDP_LABEL: { + ccf.cose.COSE_RECEIPT_INCLUSION_PROOF_LABEL: [proof] + } + } + root = hashlib.sha256( + write_set_digest + + hashlib.sha256(commit_evidence.encode()).digest() + + claim_digest + ).digest() + + embedded = cwt.COSE.new( + alg_auto_inclusion=True, deterministic_header=True + ).encode_and_sign( + root, + cose_key, + protected=protected, + unprotected=unprotected, + ) + detached = cbor2.loads(embedded) + detached.value[2] = None + return cbor2.dumps(detached) + + +def test_verify_cose_receipt_with_empty_merkle_path(): + signer = make_private_key(ec.SECP384R1()) + claim_digest = hashlib.sha256(b"snapshot").digest() + receipt = make_cose_receipt(signer, claim_digest, path=[]) + + protected = ccf.cose.verify_receipt(receipt, signer.public_key(), claim_digest) + assert ( + protected[ccf.cose.COSE_PHDR_VDS_LABEL] + == ccf.cose.COSE_PHDR_VDS_CCF_LEDGER_SHA256 + ) + + endorsed_protected = ccf.cose.verify_receipt_with_endorsements( + receipt, + signer.public_key(), + claim_digest, + endorsements=[], + snapshot_seqno=1, + ) + assert endorsed_protected == protected + + +def test_cose_receipt_rejects_invalid_signature(): + signer = make_private_key(ec.SECP384R1()) + claim_digest = hashlib.sha256(b"snapshot").digest() + receipt = bytearray(make_cose_receipt(signer, claim_digest, path=[])) + receipt[-1] ^= 0xFF + + with pytest.raises(ValueError, match="signature verification failed"): + ccf.cose.verify_receipt(bytes(receipt), signer.public_key(), claim_digest) + + +def test_receipt_verification_rejects_empty_proofs_under_optimization(): + python_src = str(Path(__file__).parents[1] / "src") + env = os.environ.copy() + env["PYTHONPATH"] = os.pathsep.join( + path for path in (python_src, env.get("PYTHONPATH")) if path + ) + script = """ +import cbor2 +import cwt +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +import ccf.cose + +key = ec.generate_private_key(ec.SECP384R1()) +public_pem = key.public_key().public_bytes( + Encoding.PEM, PublicFormat.SubjectPublicKeyInfo +).decode("ascii") +kid = ccf.cose.key_fingerprint_from_key(public_pem).encode("utf-8") +protected = cbor2.dumps( + { + int(cwt.COSEHeaders.ALG): ccf.cose.default_algorithm_for_key( + key.public_key() + ), + int(cwt.COSEHeaders.KID): kid, + ccf.cose.COSE_PHDR_VDS_LABEL: + ccf.cose.COSE_PHDR_VDS_CCF_LEDGER_SHA256, + } +) +fabricated = cbor2.dumps( + cbor2.CBORTag( + cwt.const.COSE_TYPE_TO_TAG[cwt.const.COSETypes.SIGN1], + [ + protected, + { + ccf.cose.COSE_PHDR_VDP_LABEL: { + ccf.cose.COSE_RECEIPT_INCLUSION_PROOF_LABEL: [] + } + }, + None, + b"\\x00" * 96, + ], + ) +) + +try: + ccf.cose.verify_receipt_with_endorsements( + fabricated, + key.public_key(), + b"\\x00" * 32, + endorsements=[], + snapshot_seqno=1, + ) +except ValueError as error: + if str(error) != "At least one inclusion proof is required": + raise + print("rejected") +else: + raise SystemExit("fabricated unsigned receipt was accepted") +""" + result = subprocess.run( + [sys.executable, "-O", "-c", script], + check=False, + capture_output=True, + text=True, + env=env, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "rejected" + + def test_cose_endorsement_sidecar_chain(): identities = [make_private_key(ec.SECP384R1()) for _ in range(3)] oldest = make_cose_endorsement( From 20ca0de20be2d38596a72c5fd681b2a79c09fa13 Mon Sep 17 00:00:00 2001 From: achamayou Date: Thu, 23 Jul 2026 15:53:30 +0100 Subject: [PATCH 11/27] Enforce COSE verification algorithms Require protected algorithms to match the actual verification key for receipts and identity endorsements, reject unprotected duplicates and boolean numeric headers, and add manually signed normal/optimized regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 Copilot-Session: 4d631113-a6ff-453d-a658-3b46aaaec95a Copilot-Session: 972cd758-c6d2-4d1b-b043-b482e129cb54 --- python/src/ccf/cose.py | 113 +++++++++++++++--- python/tests/test_cose_sign.py | 210 +++++++++++++++++++++++++++++---- 2 files changed, 286 insertions(+), 37 deletions(-) diff --git a/python/src/ccf/cose.py b/python/src/ccf/cose.py index 22647edfbd7f..86ffbd3652cd 100644 --- a/python/src/ccf/cose.py +++ b/python/src/ccf/cose.py @@ -334,6 +334,60 @@ def deserialize_cose_endorsements(serialized: bytes) -> list[bytes]: return endorsements +def _get_required_integer_labeled_header(headers: dict, label: int, description: str): + for actual_label, value in headers.items(): + if actual_label == label: + if type(actual_label) is not int: + raise ValueError(f"{description} label must be an integer") + return value + raise ValueError(f"{description} is required") + + +def _reject_unprotected_header(headers: dict, label: int, description: str): + if any(actual_label == label for actual_label in headers): + raise ValueError(f"{description} must not appear in unprotected headers") + + +def _validate_algorithm_and_kid_headers( + protected: dict, + unprotected: dict, + key: CertificatePublicKeyTypes, + description: str, + expected_kid: bytes | None = None, +): + if not isinstance(protected, dict): + raise ValueError(f"{description} protected header must be a map") + if not isinstance(unprotected, dict): + raise ValueError(f"{description} unprotected header must be a map") + + algorithm_label = int(cwt.COSEHeaders.ALG) + algorithm = _get_required_integer_labeled_header( + protected, algorithm_label, f"{description} signing algorithm" + ) + if type(algorithm) is not int: + raise ValueError(f"{description} signing algorithm must be an integer") + expected_algorithm = int(default_algorithm_for_key(key)) + if algorithm != expected_algorithm: + raise ValueError( + f"{description} signing algorithm {algorithm} does not match " + f"verification key algorithm {expected_algorithm}" + ) + _reject_unprotected_header( + unprotected, algorithm_label, f"{description} signing algorithm" + ) + + if expected_kid is None: + return + + kid_label = int(cwt.COSEHeaders.KID) + kid = _get_required_integer_labeled_header( + protected, kid_label, f"{description} key ID" + ) + if not isinstance(kid, bytes) or kid != expected_kid: + raise ValueError(f"{description} key ID does not match the verification key") + _reject_unprotected_header(unprotected, kid_label, f"{description} key ID") + + def verify_cose_endorsements( endorsements: list[bytes], target_key: CertificatePublicKeyTypes, @@ -347,7 +401,7 @@ def verify_cose_endorsements( Encoding.PEM, PublicFormat.SubjectPublicKeyInfo ) try: - protected, _, endorsed_key_der = verify_cose_sign1_with_key( + protected, unprotected, endorsed_key_der = verify_cose_sign1_with_key( current_key_pem, endorsement ) except cwt.exceptions.CWTError as e: @@ -355,6 +409,13 @@ def verify_cose_endorsements( f"COSE endorsement at index {index} failed signature verification" ) from e + _validate_algorithm_and_kid_headers( + protected, + unprotected, + current_key, + f"COSE endorsement at index {index}", + ) + ccf_headers = protected.get(CCF_V1_LABEL) if not isinstance(ccf_headers, dict): raise ValueError( @@ -481,23 +542,39 @@ def verify_receipt( if not isinstance(protected, dict): raise ValueError("COSE receipt protected header must encode a map") - algorithm_label = int(cwt.COSEHeaders.ALG) - if algorithm_label not in protected or not isinstance( - protected[algorithm_label], int - ): - raise ValueError("COSE receipt signing algorithm is required") - kid_label = int(cwt.COSEHeaders.KID) - if protected.get(kid_label) != expected_kid.encode("utf-8"): - raise ValueError("COSE receipt key ID does not match the verification key") - if COSE_PHDR_VDS_LABEL not in protected: - raise ValueError("Verifiable data structure type is required") - if protected[COSE_PHDR_VDS_LABEL] != COSE_PHDR_VDS_CCF_LEDGER_SHA256: + _validate_algorithm_and_kid_headers( + protected, + unprotected, + key, + "COSE receipt", + expected_kid.encode("utf-8"), + ) + + verifiable_data_structure = _get_required_integer_labeled_header( + protected, + COSE_PHDR_VDS_LABEL, + "Verifiable data structure type", + ) + if type(verifiable_data_structure) is not int: + raise ValueError("Verifiable data structure type must be an integer") + if verifiable_data_structure != COSE_PHDR_VDS_CCF_LEDGER_SHA256: raise ValueError("vds(395) protected header must be CCF_LEDGER_SHA256(2)") + _reject_unprotected_header( + unprotected, + COSE_PHDR_VDS_LABEL, + "Verifiable data structure type", + ) - verifiable_data_proof = unprotected.get(COSE_PHDR_VDP_LABEL) + verifiable_data_proof = _get_required_integer_labeled_header( + unprotected, COSE_PHDR_VDP_LABEL, "Verifiable data proof" + ) if not isinstance(verifiable_data_proof, dict): raise ValueError("Verifiable data proof must be a map") - inclusion_proofs = verifiable_data_proof.get(COSE_RECEIPT_INCLUSION_PROOF_LABEL) + inclusion_proofs = _get_required_integer_labeled_header( + verifiable_data_proof, + COSE_RECEIPT_INCLUSION_PROOF_LABEL, + "Inclusion proofs", + ) if not isinstance(inclusion_proofs, (list, tuple)): raise ValueError("Inclusion proofs must be an array") if not inclusion_proofs: @@ -514,7 +591,9 @@ def verify_receipt( if not isinstance(proof, dict): raise ValueError("Inclusion proof must encode a map") - leaf = proof.get(CCF_PROOF_LEAF_LABEL) + leaf = _get_required_integer_labeled_header( + proof, CCF_PROOF_LEAF_LABEL, "Inclusion proof leaf" + ) if not isinstance(leaf, (list, tuple)) or len(leaf) != 3: raise ValueError("Inclusion proof leaf must contain three elements") write_set_digest, commit_evidence, proof_claim_digest = leaf @@ -540,7 +619,9 @@ def verify_receipt( + proof_claim_digest ).digest() - path = proof.get(CCF_PROOF_PATH_LABEL) + path = _get_required_integer_labeled_header( + proof, CCF_PROOF_PATH_LABEL, "Inclusion proof path" + ) if not isinstance(path, (list, tuple)): raise ValueError("Inclusion proof path must be an array") for path_index, path_entry in enumerate(path): diff --git a/python/tests/test_cose_sign.py b/python/tests/test_cose_sign.py index 473f5d5b1406..318d43f6be81 100644 --- a/python/tests/test_cose_sign.py +++ b/python/tests/test_cose_sign.py @@ -153,23 +153,53 @@ def make_cose_endorsement(signer, payload_key, begin: str, end: str) -> bytes: ).encode_and_sign(payload, cose_key, protected=protected) +def make_manually_signed_cose_sign1( + signer: ec.EllipticCurvePrivateKey, + payload: bytes, + protected: dict, + unprotected: dict, + detached: bool, +) -> bytes: + protected_encoded = cbor2.dumps(protected) + to_be_signed = cbor2.dumps(["Signature1", protected_encoded, b"", payload]) + der_signature = signer.sign(to_be_signed, ec.ECDSA(hash_algo(signer))) + r, s = utils.decode_dss_signature(der_signature) + num_bytes = (signer.key_size + 7) // 8 + raw_signature = i2osp(r, num_bytes) + i2osp(s, num_bytes) + return cbor2.dumps( + cbor2.CBORTag( + cwt.const.COSE_TYPE_TO_TAG[cwt.const.COSETypes.SIGN1], + [ + protected_encoded, + unprotected, + None if detached else payload, + raw_signature, + ], + ) + ) + + def make_cose_receipt( signer: ec.EllipticCurvePrivateKey, claim_digest: bytes, path: list[list[bool | bytes]], + include_protected_algorithm: bool = True, + protected_algorithm: int | bool | None = None, + unprotected_algorithm: int | bool | None = None, + verifiable_data_structure: int | bool = (ccf.cose.COSE_PHDR_VDS_CCF_LEDGER_SHA256), ) -> bytes: - signer_priv_pem, signer_pub_pem = make_pem_pair(signer) + _, signer_pub_pem = make_pem_pair(signer) kid = ccf.cose.key_fingerprint_from_key(signer_pub_pem) - cose_key = cwt.COSEKey.from_pem(signer_priv_pem, kid=kid) - protected = cwt.utils.ResolvedHeader( - { - int(cwt.COSEHeaders.ALG): ccf.cose.default_algorithm_for_key( - signer.public_key() - ), - int(cwt.COSEHeaders.KID): kid.encode("utf-8"), - ccf.cose.COSE_PHDR_VDS_LABEL: (ccf.cose.COSE_PHDR_VDS_CCF_LEDGER_SHA256), - } - ) + protected = { + int(cwt.COSEHeaders.KID): kid.encode("utf-8"), + ccf.cose.COSE_PHDR_VDS_LABEL: verifiable_data_structure, + } + if include_protected_algorithm: + protected[int(cwt.COSEHeaders.ALG)] = ( + ccf.cose.default_algorithm_for_key(signer.public_key()) + if protected_algorithm is None + else protected_algorithm + ) write_set_digest = hashlib.sha256(b"write set").digest() commit_evidence = "ce:2.1" @@ -183,28 +213,57 @@ def make_cose_receipt( ccf.cose.CCF_PROOF_PATH_LABEL: path, } ) - unprotected = { + unprotected: dict[int, object] = { ccf.cose.COSE_PHDR_VDP_LABEL: { ccf.cose.COSE_RECEIPT_INCLUSION_PROOF_LABEL: [proof] } } + if unprotected_algorithm is not None: + unprotected[int(cwt.COSEHeaders.ALG)] = unprotected_algorithm root = hashlib.sha256( write_set_digest + hashlib.sha256(commit_evidence.encode()).digest() + claim_digest ).digest() - embedded = cwt.COSE.new( - alg_auto_inclusion=True, deterministic_header=True - ).encode_and_sign( + return make_manually_signed_cose_sign1( + signer, root, - cose_key, - protected=protected, - unprotected=unprotected, + protected, + unprotected, + detached=True, + ) + + +def make_manual_cose_endorsement( + signer: ec.EllipticCurvePrivateKey, + payload_key: ec.EllipticCurvePublicKey, + include_protected_algorithm: bool = True, + protected_algorithm: int | bool | None = None, + unprotected_algorithm: int | bool | None = None, +) -> bytes: + _, signer_pub_pem = make_pem_pair(signer) + kid = ccf.cose.key_fingerprint_from_key(signer_pub_pem) + protected = { + int(cwt.COSEHeaders.KID): kid.encode("utf-8"), + ccf.cose.CCF_V1_LABEL: { + ccf.cose.CCF_ENDORSEMENT_RANGE_BEGIN: "2.1", + ccf.cose.CCF_ENDORSEMENT_RANGE_END: "4.100", + }, + } + if include_protected_algorithm: + protected[int(cwt.COSEHeaders.ALG)] = ( + ccf.cose.default_algorithm_for_key(signer.public_key()) + if protected_algorithm is None + else protected_algorithm + ) + unprotected = {} + if unprotected_algorithm is not None: + unprotected[int(cwt.COSEHeaders.ALG)] = unprotected_algorithm + payload = payload_key.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) + return make_manually_signed_cose_sign1( + signer, payload, protected, unprotected, detached=False ) - detached = cbor2.loads(embedded) - detached.value[2] = None - return cbor2.dumps(detached) def test_verify_cose_receipt_with_empty_merkle_path(): @@ -238,6 +297,115 @@ def test_cose_receipt_rejects_invalid_signature(): ccf.cose.verify_receipt(bytes(receipt), signer.public_key(), claim_digest) +@pytest.mark.parametrize("algorithm", [True, 0, 5, -7, 999999]) +def test_cose_receipt_rejects_wrong_or_boolean_algorithm(algorithm): + signer = make_private_key(ec.SECP384R1()) + claim_digest = hashlib.sha256(b"snapshot").digest() + receipt = make_cose_receipt( + signer, claim_digest, path=[], protected_algorithm=algorithm + ) + + with pytest.raises(ValueError, match="signing algorithm"): + ccf.cose.verify_receipt(receipt, signer.public_key(), claim_digest) + + +def test_cose_receipt_rejects_missing_or_unprotected_algorithm(): + signer = make_private_key(ec.SECP384R1()) + claim_digest = hashlib.sha256(b"snapshot").digest() + + missing = make_cose_receipt( + signer, + claim_digest, + path=[], + include_protected_algorithm=False, + ) + with pytest.raises(ValueError, match="signing algorithm"): + ccf.cose.verify_receipt(missing, signer.public_key(), claim_digest) + + expected_algorithm = ccf.cose.default_algorithm_for_key(signer.public_key()) + for unprotected_algorithm in (0, expected_algorithm): + duplicate = make_cose_receipt( + signer, + claim_digest, + path=[], + unprotected_algorithm=unprotected_algorithm, + ) + with pytest.raises(ValueError, match="unprotected headers"): + ccf.cose.verify_receipt(duplicate, signer.public_key(), claim_digest) + + +def test_cose_receipt_rejects_boolean_vds(): + signer = make_private_key(ec.SECP384R1()) + claim_digest = hashlib.sha256(b"snapshot").digest() + receipt = make_cose_receipt( + signer, + claim_digest, + path=[], + verifiable_data_structure=True, + ) + + with pytest.raises(ValueError, match="structure type must be an integer"): + ccf.cose.verify_receipt(receipt, signer.public_key(), claim_digest) + + +def test_manual_cose_endorsement_accepts_expected_algorithm(): + signer = make_private_key(ec.SECP384R1()) + endorsed = make_private_key(ec.SECP384R1()) + endorsement = make_manual_cose_endorsement(signer, endorsed.public_key()) + + verified_key = ccf.cose.verify_cose_endorsements( + [endorsement], signer.public_key(), snapshot_seqno=50 + ) + assert verified_key.public_bytes( + Encoding.DER, PublicFormat.SubjectPublicKeyInfo + ) == endorsed.public_key().public_bytes( + Encoding.DER, PublicFormat.SubjectPublicKeyInfo + ) + + +@pytest.mark.parametrize("algorithm", [True, 0, 5, -7, 999999]) +def test_cose_endorsement_rejects_wrong_or_boolean_algorithm(algorithm): + signer = make_private_key(ec.SECP384R1()) + endorsed = make_private_key(ec.SECP384R1()) + endorsement = make_manual_cose_endorsement( + signer, + endorsed.public_key(), + protected_algorithm=algorithm, + ) + + with pytest.raises(ValueError, match="signing algorithm"): + ccf.cose.verify_cose_endorsements( + [endorsement], signer.public_key(), snapshot_seqno=50 + ) + + +def test_cose_endorsement_rejects_missing_or_unprotected_algorithm(): + signer = make_private_key(ec.SECP384R1()) + endorsed = make_private_key(ec.SECP384R1()) + + missing = make_manual_cose_endorsement( + signer, + endorsed.public_key(), + include_protected_algorithm=False, + ) + with pytest.raises(ValueError, match="signing algorithm"): + ccf.cose.verify_cose_endorsements( + [missing], signer.public_key(), snapshot_seqno=50 + ) + + expected_algorithm = ccf.cose.default_algorithm_for_key(signer.public_key()) + for unprotected_algorithm in (0, expected_algorithm): + duplicate = make_manual_cose_endorsement( + signer, + endorsed.public_key(), + unprotected_algorithm=unprotected_algorithm, + ) + with pytest.raises(ValueError, match="unprotected headers"): + ccf.cose.verify_cose_endorsements( + [duplicate], signer.public_key(), snapshot_seqno=50 + ) + + def test_receipt_verification_rejects_empty_proofs_under_optimization(): python_src = str(Path(__file__).parents[1] / "src") env = os.environ.copy() From a81128ec5afbc18087bcf8903c00a3ed37b3e18a Mon Sep 17 00:00:00 2001 From: achamayou Date: Thu, 23 Jul 2026 17:30:38 +0100 Subject: [PATCH 12/27] Simplify recovery endorsements to memory Remove persistent sidecars and derive the validated recovery snapshot endorsement chain directly from the committed public ledger for the current recovery attempt. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 Copilot-Session: 4d631113-a6ff-453d-a658-3b46aaaec95a Copilot-Session: 972cd758-c6d2-4d1b-b043-b482e129cb54 --- CHANGELOG.md | 2 +- python/src/ccf/cose.py | 478 ++----------------- python/src/ccf/ledger.py | 60 +-- python/src/ccf/read_ledger.py | 30 -- python/tests/test_cose_sign.py | 447 ----------------- src/crypto/cbor.cpp | 92 +--- src/crypto/cbor.h | 7 +- src/crypto/test/cbor.cpp | 23 - src/host/files_cleanup_timer.h | 19 - src/host/test/files_cleanup_test.cpp | 26 - src/node/node_state.h | 373 ++++----------- src/node/recovery_snapshot_ledger.h | 35 +- src/node/snapshot_serdes.h | 233 +-------- src/node/test/network_identity_subsystem.cpp | 52 +- src/node/test/snapshotter.cpp | 150 +----- src/snapshots/filenames.h | 15 - tests/recovery_snapshot_endorsements.py | 160 +++---- 17 files changed, 295 insertions(+), 1907 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84c07b589235..e8f408fe7397 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added -- Recovery can now use a COSE snapshot signed by an earlier service identity after one or more disaster recoveries. Before deserialising the snapshot, the node derives and validates the previous-service-identity endorsement chain from the public ledger suffix and caches it in an adjacent `.endorsements` sidecar. Invalid or incomplete chains fall back to full-ledger replay, and Python tooling can verify a snapshot plus sidecar against the latest trusted service certificate (#8092). +- Recovery can now use a COSE snapshot signed by an earlier service identity after one or more disaster recoveries. Before deserialising the snapshot, the node derives and validates the previous-service-identity endorsement chain directly from the public ledger suffix and retains it only for the current recovery attempt. Invalid or incomplete chains fall back to full-ledger replay (#8092). ### Changed diff --git a/python/src/ccf/cose.py b/python/src/ccf/cose.py index 86ffbd3652cd..37be544a177a 100644 --- a/python/src/ccf/cose.py +++ b/python/src/ccf/cose.py @@ -2,7 +2,6 @@ # Licensed under the Apache 2.0 License. import argparse -import io import sys from typing import Any @@ -10,7 +9,6 @@ import base64 import cwt import cwt.const -import cwt.exceptions import cwt.utils import cwt.enums import cbor2 @@ -27,11 +25,9 @@ from cryptography.hazmat.primitives.serialization import ( load_pem_private_key, load_pem_public_key, - load_der_public_key, ) from cryptography.x509.base import CertificatePublicKeyTypes from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat -from ccf.tx_id import TxID Pem = str @@ -57,16 +53,6 @@ CCF_PROOF_LEAF_LABEL = 1 CCF_PROOF_PATH_LABEL = 2 -CCF_V1_LABEL = "ccf.v1" -CCF_ENDORSEMENT_RANGE_BEGIN = "epoch.start.txid" -CCF_ENDORSEMENT_RANGE_END = "epoch.end.txid" -RECOVERY_VIEW_CHANGE = 2 -MAX_SNAPSHOT_ENDORSEMENTS_SIZE = 16 * 1024 * 1024 -MAX_SNAPSHOT_ENDORSEMENTS_COUNT = 64 -MIN_SNAPSHOT_ENDORSEMENT_SIZE = 64 -MAX_SNAPSHOT_ENDORSEMENT_SIZE = 1024 * 1024 -MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE = 4 * 1024 * 1024 - def default_algorithm_for_key(key) -> int: """ @@ -203,304 +189,6 @@ def verify_cose_sign1_with_key(key, cose_sign1, payload=None): return cose_ctx.decode_with_headers(cose_sign1, cose_key, detached_payload=payload) -def _validate_cose_endorsement_resource_limits( - endorsements: list[bytes], -) -> None: - if not 1 <= len(endorsements) <= MAX_SNAPSHOT_ENDORSEMENTS_COUNT: - raise ValueError( - "Snapshot endorsements sidecar must contain between 1 and " - f"{MAX_SNAPSHOT_ENDORSEMENTS_COUNT} endorsements, got " - f"{len(endorsements)}" - ) - - payload_size = 0 - for index, endorsement in enumerate(endorsements): - endorsement_size = len(endorsement) - if endorsement_size < MIN_SNAPSHOT_ENDORSEMENT_SIZE: - raise ValueError( - f"Snapshot endorsement at index {index} is too small " - f"({endorsement_size} bytes; minimum " - f"{MIN_SNAPSHOT_ENDORSEMENT_SIZE} bytes)" - ) - if endorsement_size > MAX_SNAPSHOT_ENDORSEMENT_SIZE: - raise ValueError( - f"Snapshot endorsement at index {index} is too large " - f"({endorsement_size} bytes; maximum " - f"{MAX_SNAPSHOT_ENDORSEMENT_SIZE} bytes)" - ) - payload_size += endorsement_size - if payload_size > MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE: - raise ValueError( - "Snapshot endorsements payload is too large " - f"(maximum {MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE} bytes)" - ) - - -def _decode_cbor_length( - serialized: memoryview, offset: int, expected_major_type: int -) -> tuple[int, int]: - if offset >= len(serialized): - raise ValueError("Truncated snapshot endorsements CBOR") - - initial_byte = serialized[offset] - offset += 1 - if initial_byte >> 5 != expected_major_type: - raise ValueError("Unexpected snapshot endorsements CBOR type") - - additional_info = initial_byte & 0x1F - if additional_info < 24: - return additional_info, offset - - length_byte_count = {24: 1, 25: 2, 26: 4, 27: 8}.get(additional_info) - if length_byte_count is None: - raise ValueError("Indefinite or invalid snapshot endorsements CBOR length") - end = offset + length_byte_count - if end > len(serialized): - raise ValueError("Truncated snapshot endorsements CBOR length") - return int.from_bytes(serialized[offset:end]), end - - -def serialize_cose_endorsements(endorsements: list[bytes]) -> bytes: - if not all(isinstance(endorsement, bytes) for endorsement in endorsements): - raise TypeError("COSE endorsements must be bytes") - _validate_cose_endorsement_resource_limits(endorsements) - serialized = cbor2.dumps(endorsements) - if len(serialized) > MAX_SNAPSHOT_ENDORSEMENTS_SIZE: - raise ValueError( - "Snapshot endorsements sidecar is too large " - f"({len(serialized)} bytes; maximum " - f"{MAX_SNAPSHOT_ENDORSEMENTS_SIZE} bytes)" - ) - return serialized - - -def deserialize_cose_endorsements(serialized: bytes) -> list[bytes]: - try: - data = memoryview(serialized) - except TypeError as e: - raise TypeError("Snapshot endorsements CBOR must be bytes-like") from e - if len(data) > MAX_SNAPSHOT_ENDORSEMENTS_SIZE: - raise ValueError( - "Snapshot endorsements sidecar is too large " - f"({len(data)} bytes; maximum {MAX_SNAPSHOT_ENDORSEMENTS_SIZE} bytes)" - ) - - try: - endorsement_count, offset = _decode_cbor_length(data, 0, 4) - except ValueError as e: - raise ValueError("Snapshot endorsements must be a CBOR array") from e - if not 1 <= endorsement_count <= MAX_SNAPSHOT_ENDORSEMENTS_COUNT: - raise ValueError( - "Snapshot endorsements sidecar must contain between 1 and " - f"{MAX_SNAPSHOT_ENDORSEMENTS_COUNT} endorsements, got " - f"{endorsement_count}" - ) - - endorsements: list[bytes] = [] - payload_size = 0 - for index in range(endorsement_count): - try: - endorsement_size, offset = _decode_cbor_length(data, offset, 2) - except ValueError as e: - raise ValueError( - "Snapshot endorsements must be a CBOR array of byte strings" - ) from e - if endorsement_size < MIN_SNAPSHOT_ENDORSEMENT_SIZE: - raise ValueError( - f"Snapshot endorsement at index {index} is too small " - f"({endorsement_size} bytes; minimum " - f"{MIN_SNAPSHOT_ENDORSEMENT_SIZE} bytes)" - ) - if endorsement_size > MAX_SNAPSHOT_ENDORSEMENT_SIZE: - raise ValueError( - f"Snapshot endorsement at index {index} is too large " - f"({endorsement_size} bytes; maximum " - f"{MAX_SNAPSHOT_ENDORSEMENT_SIZE} bytes)" - ) - payload_size += endorsement_size - if payload_size > MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE: - raise ValueError( - "Snapshot endorsements payload is too large " - f"(maximum {MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE} bytes)" - ) - end = offset + endorsement_size - if end > len(data): - raise ValueError(f"Snapshot endorsement at index {index} is truncated") - endorsements.append(bytes(data[offset:end])) - offset = end - - if offset != len(data): - raise ValueError("Trailing data after snapshot endorsements CBOR array") - return endorsements - - -def _get_required_integer_labeled_header(headers: dict, label: int, description: str): - for actual_label, value in headers.items(): - if actual_label == label: - if type(actual_label) is not int: - raise ValueError(f"{description} label must be an integer") - return value - raise ValueError(f"{description} is required") - - -def _reject_unprotected_header(headers: dict, label: int, description: str): - if any(actual_label == label for actual_label in headers): - raise ValueError(f"{description} must not appear in unprotected headers") - - -def _validate_algorithm_and_kid_headers( - protected: dict, - unprotected: dict, - key: CertificatePublicKeyTypes, - description: str, - expected_kid: bytes | None = None, -): - if not isinstance(protected, dict): - raise ValueError(f"{description} protected header must be a map") - if not isinstance(unprotected, dict): - raise ValueError(f"{description} unprotected header must be a map") - - algorithm_label = int(cwt.COSEHeaders.ALG) - algorithm = _get_required_integer_labeled_header( - protected, algorithm_label, f"{description} signing algorithm" - ) - if type(algorithm) is not int: - raise ValueError(f"{description} signing algorithm must be an integer") - expected_algorithm = int(default_algorithm_for_key(key)) - if algorithm != expected_algorithm: - raise ValueError( - f"{description} signing algorithm {algorithm} does not match " - f"verification key algorithm {expected_algorithm}" - ) - _reject_unprotected_header( - unprotected, algorithm_label, f"{description} signing algorithm" - ) - - if expected_kid is None: - return - - kid_label = int(cwt.COSEHeaders.KID) - kid = _get_required_integer_labeled_header( - protected, kid_label, f"{description} key ID" - ) - if not isinstance(kid, bytes) or kid != expected_kid: - raise ValueError(f"{description} key ID does not match the verification key") - _reject_unprotected_header(unprotected, kid_label, f"{description} key ID") - - -def verify_cose_endorsements( - endorsements: list[bytes], - target_key: CertificatePublicKeyTypes, - snapshot_seqno: int, -) -> CertificatePublicKeyTypes: - current_key = target_key - ranges: list[tuple[TxID, TxID]] = [] - - for index, endorsement in enumerate(endorsements): - current_key_pem = current_key.public_bytes( - Encoding.PEM, PublicFormat.SubjectPublicKeyInfo - ) - try: - protected, unprotected, endorsed_key_der = verify_cose_sign1_with_key( - current_key_pem, endorsement - ) - except cwt.exceptions.CWTError as e: - raise ValueError( - f"COSE endorsement at index {index} failed signature verification" - ) from e - - _validate_algorithm_and_kid_headers( - protected, - unprotected, - current_key, - f"COSE endorsement at index {index}", - ) - - ccf_headers = protected.get(CCF_V1_LABEL) - if not isinstance(ccf_headers, dict): - raise ValueError( - f"COSE endorsement at index {index} has no {CCF_V1_LABEL} headers" - ) - begin_header = ccf_headers.get(CCF_ENDORSEMENT_RANGE_BEGIN) - end_header = ccf_headers.get(CCF_ENDORSEMENT_RANGE_END) - if not isinstance(begin_header, str) or not isinstance(end_header, str): - raise ValueError( - f"COSE endorsement at index {index} has invalid epoch headers" - ) - begin = TxID.from_str(begin_header) - end = TxID.from_str(end_header) - if not begin.valid() or not end.valid() or end.seqno < begin.seqno: - raise ValueError( - f"COSE endorsement at index {index} has an invalid epoch range" - ) - if not isinstance(endorsed_key_der, bytes) or not endorsed_key_der: - raise ValueError( - f"COSE endorsement at index {index} contains no endorsed key" - ) - - ranges.append((begin, end)) - try: - endorsed_key = load_der_public_key(endorsed_key_der) - except ValueError as e: - raise ValueError( - f"COSE endorsement at index {index} contains an invalid public key" - ) from e - if not isinstance(endorsed_key, EllipticCurvePublicKey): - raise ValueError( - f"COSE endorsement at index {index} contains a non-EC public key" - ) - current_key = endorsed_key - - for (newer_begin, _), (_, older_end) in zip(ranges, ranges[1:]): - if ( - newer_begin.view - RECOVERY_VIEW_CHANGE != older_end.view - or newer_begin.seqno - 1 != older_end.seqno - ): - raise ValueError( - "COSE endorsement epoch ranges are not contiguous newest-to-oldest" - ) - - if ranges: - oldest_begin, oldest_end = ranges[-1] - if not oldest_begin.seqno <= snapshot_seqno <= oldest_end.seqno: - raise ValueError( - f"Oldest COSE endorsement range does not cover snapshot seqno {snapshot_seqno}" - ) - - return current_key - - -def verify_receipt_with_endorsements( - receipt_bytes: bytes, - target_key: CertificatePublicKeyTypes, - claim_digest: bytes, - endorsements: list[bytes], - snapshot_seqno: int, -): - snapshot_signer_key = verify_cose_endorsements( - endorsements, target_key, snapshot_seqno - ) - return verify_receipt(receipt_bytes, snapshot_signer_key, claim_digest) - - -def _decode_single_cbor(data: bytes, description: str): - if not isinstance(data, bytes): - raise TypeError(f"{description} CBOR must be bytes") - - stream = io.BytesIO(data) - decoder = cbor2.CBORDecoder(stream) - try: - value = decoder.decode() - except (cbor2.CBORDecodeError, EOFError) as e: - raise ValueError(f"Invalid {description} CBOR") from e - - try: - decoder.decode() - except cbor2.CBORDecodeEOF: - return value - raise ValueError(f"Trailing data after {description} CBOR") - - def verify_receipt( receipt_bytes: bytes, key: CertificatePublicKeyTypes, claim_digest: bytes ): @@ -509,11 +197,6 @@ def verify_receipt( using the CCF tree algorithm defined in https://datatracker.ietf.org/doc/draft-birkholz-cose-receipts-ccf-profile/ """ - if not isinstance(claim_digest, bytes): - raise TypeError("Claim digest must be bytes") - if len(claim_digest) != hashlib.sha256().digest_size: - raise ValueError(f"Claim digest must be {hashlib.sha256().digest_size} bytes") - key_pem = key.public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo).decode( "ascii" ) @@ -521,139 +204,46 @@ def verify_receipt( cose_key = cwt.COSEKey.from_pem(key_pem, kid=expected_kid) cose_ctx = cwt.COSE.new() - receipt = _decode_single_cbor(receipt_bytes, "COSE receipt") - expected_tag = cwt.const.COSE_TYPE_TO_TAG[cwt.const.COSETypes.SIGN1] - if not isinstance(receipt, cbor2.CBORTag) or receipt.tag != expected_tag: - raise ValueError(f"COSE receipt must use tag {expected_tag}") - if not isinstance(receipt.value, (list, tuple)) or len(receipt.value) != 4: - raise ValueError("COSE receipt must contain a four-element Sign1 array") - - protected_raw, unprotected, payload, signature = receipt.value - if not isinstance(protected_raw, bytes): - raise ValueError("COSE receipt protected header must be a byte string") - if not isinstance(unprotected, dict): - raise ValueError("COSE receipt unprotected header must be a map") - if payload is not None: - raise ValueError("COSE receipt payload must be detached") - if not isinstance(signature, bytes) or not signature: - raise ValueError("COSE receipt signature must be a non-empty byte string") - - protected = _decode_single_cbor(protected_raw, "COSE protected header") - if not isinstance(protected, dict): - raise ValueError("COSE receipt protected header must encode a map") - - _validate_algorithm_and_kid_headers( - protected, - unprotected, - key, - "COSE receipt", - expected_kid.encode("utf-8"), - ) - - verifiable_data_structure = _get_required_integer_labeled_header( - protected, - COSE_PHDR_VDS_LABEL, - "Verifiable data structure type", - ) - if type(verifiable_data_structure) is not int: - raise ValueError("Verifiable data structure type must be an integer") - if verifiable_data_structure != COSE_PHDR_VDS_CCF_LEDGER_SHA256: - raise ValueError("vds(395) protected header must be CCF_LEDGER_SHA256(2)") - _reject_unprotected_header( - unprotected, - COSE_PHDR_VDS_LABEL, - "Verifiable data structure type", - ) - - verifiable_data_proof = _get_required_integer_labeled_header( - unprotected, COSE_PHDR_VDP_LABEL, "Verifiable data proof" - ) - if not isinstance(verifiable_data_proof, dict): - raise ValueError("Verifiable data proof must be a map") - inclusion_proofs = _get_required_integer_labeled_header( - verifiable_data_proof, - COSE_RECEIPT_INCLUSION_PROOF_LABEL, - "Inclusion proofs", - ) - if not isinstance(inclusion_proofs, (list, tuple)): - raise ValueError("Inclusion proofs must be an array") - if not inclusion_proofs: - raise ValueError("At least one inclusion proof is required") - - verified_protected_header = None - for proof_index, inclusion_proof in enumerate(inclusion_proofs): - if not isinstance(inclusion_proof, bytes) or not inclusion_proof: - raise ValueError( - f"Inclusion proof at index {proof_index} must be a non-empty " - "byte string" - ) - proof = _decode_single_cbor(inclusion_proof, "inclusion proof") - if not isinstance(proof, dict): - raise ValueError("Inclusion proof must encode a map") - - leaf = _get_required_integer_labeled_header( - proof, CCF_PROOF_LEAF_LABEL, "Inclusion proof leaf" - ) - if not isinstance(leaf, (list, tuple)) or len(leaf) != 3: - raise ValueError("Inclusion proof leaf must contain three elements") - write_set_digest, commit_evidence, proof_claim_digest = leaf - if ( - not isinstance(write_set_digest, bytes) - or len(write_set_digest) != hashlib.sha256().digest_size - ): - raise ValueError("Write set digest must be a 32-byte byte string") - if not isinstance(commit_evidence, str) or not commit_evidence: - raise ValueError("Commit evidence must be a non-empty string") - if ( - not isinstance(proof_claim_digest, bytes) - or len(proof_claim_digest) != hashlib.sha256().digest_size - ): - raise ValueError("Proof claim digest must be a 32-byte byte string") - if claim_digest != proof_claim_digest: - raise ValueError( - f"Claim digest mismatch: {proof_claim_digest!r} != {claim_digest!r}" - ) + receipt = cbor2.loads(receipt_bytes) + assert receipt.tag == cwt.const.COSE_TYPE_TO_TAG[cwt.const.COSETypes.SIGN1] + phdr, uhdr, payload, sig = receipt.value + phdr = cbor2.loads(phdr) + + assert phdr[4] == expected_kid.encode("utf-8") + + assert COSE_PHDR_VDS_LABEL in phdr, "Verifiable data structure type is required" + assert ( + phdr[COSE_PHDR_VDS_LABEL] == COSE_PHDR_VDS_CCF_LEDGER_SHA256 + ), "vds(395) protected header must be CCF_LEDGER_SHA256(2)" + + assert COSE_PHDR_VDP_LABEL in uhdr, "Verifiable data proof is required" + proof = uhdr[COSE_PHDR_VDP_LABEL] + assert COSE_RECEIPT_INCLUSION_PROOF_LABEL in proof, "Inclusion proof is required" + inclusion_proofs = proof[COSE_RECEIPT_INCLUSION_PROOF_LABEL] + assert inclusion_proofs, "At least one inclusion proof is required" + + ic_phdr = None + for inclusion_proof in inclusion_proofs: + assert isinstance(inclusion_proof, bytes), "Inclusion proof must be bstr" + proof = cbor2.loads(inclusion_proof) + assert CCF_PROOF_LEAF_LABEL in proof, "Leaf must be present" + leaf = proof[CCF_PROOF_LEAF_LABEL] + if claim_digest != leaf[2]: + raise ValueError(f"Claim digest mismatch: {leaf[2]!r} != {claim_digest!r}") accumulator = hashlib.sha256( - write_set_digest - + hashlib.sha256(commit_evidence.encode()).digest() - + proof_claim_digest + leaf[0] + hashlib.sha256(leaf[1].encode()).digest() + leaf[2] ).digest() - - path = _get_required_integer_labeled_header( - proof, CCF_PROOF_PATH_LABEL, "Inclusion proof path" - ) - if not isinstance(path, (list, tuple)): - raise ValueError("Inclusion proof path must be an array") - for path_index, path_entry in enumerate(path): - if not isinstance(path_entry, (list, tuple)) or len(path_entry) != 2: - raise ValueError( - f"Inclusion proof path entry {path_index} must contain " - "direction and digest" - ) - left, digest = path_entry - if not isinstance(left, bool): - raise ValueError( - f"Inclusion proof path direction {path_index} must be boolean" - ) - if ( - not isinstance(digest, bytes) - or len(digest) != hashlib.sha256().digest_size - ): - raise ValueError( - f"Inclusion proof path digest {path_index} must be 32 bytes" - ) + assert CCF_PROOF_PATH_LABEL in proof, "Path must be present" + path = proof[CCF_PROOF_PATH_LABEL] + for left, digest in path: if left: accumulator = hashlib.sha256(digest + accumulator).digest() else: accumulator = hashlib.sha256(accumulator + digest).digest() - try: - verified_protected_header, _, _ = cose_ctx.decode_with_headers( - receipt_bytes, cose_key, detached_payload=accumulator - ) - except cwt.exceptions.CWTError as e: - raise ValueError("COSE receipt signature verification failed") from e - - return verified_protected_header + ic_phdr, _, _ = cose_ctx.decode_with_headers( + receipt_bytes, cose_key, detached_payload=accumulator + ) + return ic_phdr _SIGN_DESCRIPTION = """Create and sign a COSE Sign1 message for CCF governance diff --git a/python/src/ccf/ledger.py b/python/src/ccf/ledger.py index 2aad5c410080..21e488a10fdd 100644 --- a/python/src/ccf/ledger.py +++ b/python/src/ccf/ledger.py @@ -8,7 +8,7 @@ import json from dataclasses import dataclass -from cryptography.x509 import Certificate, load_pem_x509_certificate +from cryptography.x509 import load_pem_x509_certificate from cryptography.hazmat.backends import default_backend from ccf.merkletree import MerkleTree @@ -64,7 +64,6 @@ def __getattr__(name: str): COMMITTED_FILE_SUFFIX = ".committed" RECOVERY_FILE_SUFFIX = ".recovery" IGNORED_FILE_SUFFIX = ".ignored" -SNAPSHOT_ENDORSEMENTS_SUFFIX = ".endorsements" SHA256_DIGEST_SIZE = sha256().digest_size ENCODED_COSE_SIGN1_TAG = 0xD2 @@ -138,24 +137,6 @@ def is_snapshot_file_committed(file_name): return file_name.endswith(COMMITTED_FILE_SUFFIX) -def snapshot_endorsements_path(snapshot_path: str) -> str: - if not is_snapshot_file_committed(snapshot_path): - raise ValueError(f"Snapshot file {snapshot_path} is not committed") - return snapshot_path + SNAPSHOT_ENDORSEMENTS_SUFFIX - - -def read_snapshot_endorsements(path: str) -> list[bytes]: - with open(path, "rb") as endorsements_file: - serialized = endorsements_file.read(ccf.cose.MAX_SNAPSHOT_ENDORSEMENTS_SIZE + 1) - if len(serialized) > ccf.cose.MAX_SNAPSHOT_ENDORSEMENTS_SIZE: - raise ValueError( - f"Snapshot endorsements sidecar {path} is too large " - f"({len(serialized)} bytes read; maximum " - f"{ccf.cose.MAX_SNAPSHOT_ENDORSEMENTS_SIZE} bytes)" - ) - return ccf.cose.deserialize_cose_endorsements(serialized) - - def digest(data): return sha256(data).digest() @@ -1015,8 +996,6 @@ def __init__(self, filename: str): super().__init__(SimpleBuffer.from_file(filename)) self._filename = filename self._file_size = os.path.getsize(filename) - self._receipt_bytes: bytes | None = None - self._snapshot_digest: bytes | None = None if self._file_size == 0: raise InvalidSnapshotException(f"{filename} is currently empty") @@ -1028,8 +1007,6 @@ def __init__(self, filename: str): receipt_pos = entry_start_pos + self._header.size receipt_bytes = _peek_all(self._file, pos=receipt_pos) snapshot_digest = sha256(_peek(self._file, receipt_pos, pos=0)).digest() - self._receipt_bytes = receipt_bytes - self._snapshot_digest = snapshot_digest self._verify_snapshot_receipt(receipt_bytes, receipt_pos, snapshot_digest) def is_committed(self): @@ -1090,41 +1067,6 @@ def _verify_cose_snapshot_receipt( receipt_bytes, self._service_public_key(), snapshot_digest ) - def verify_cose_receipt( - self, - target_service_certificate: Certificate | str | bytes, - endorsements: list[bytes] | None = None, - ): - if self._receipt_bytes is None or self._snapshot_digest is None: - raise InvalidSnapshotException( - "Snapshot does not contain a current-format receipt" - ) - if self._receipt_bytes[0] != ENCODED_COSE_SIGN1_TAG: - raise InvalidSnapshotException( - "Only COSE snapshot receipts support endorsement sidecars" - ) - - if isinstance(target_service_certificate, Certificate): - certificate = target_service_certificate - else: - certificate_bytes = ( - target_service_certificate.encode("ascii") - if isinstance(target_service_certificate, str) - else target_service_certificate - ) - certificate = load_pem_x509_certificate( - certificate_bytes, default_backend() - ) - - snapshot_seqno, _ = snapshot_index_from_filename(self._filename) - return ccf.cose.verify_receipt_with_endorsements( - self._receipt_bytes, - certificate.public_key(), - self._snapshot_digest, - endorsements or [], - snapshot_seqno, - ) - def _verify_json_snapshot_receipt( self, receipt_bytes: bytes, receipt_pos: int, snapshot_digest: bytes ): diff --git a/python/src/ccf/read_ledger.py b/python/src/ccf/read_ledger.py index b847980a276a..4c09d004abb3 100644 --- a/python/src/ccf/read_ledger.py +++ b/python/src/ccf/read_ledger.py @@ -154,8 +154,6 @@ def run( uncommitted=False, read_recovery_files=False, tables_format_rules=None, - snapshot_service_cert=None, - snapshot_endorsements=None, # Deprecated parameter, kept for backward compatibility insecure_skip_verification=None, ): @@ -182,19 +180,6 @@ def run( if is_snapshot: snapshot_file = paths[0] with ccf.ledger.Snapshot(snapshot_file) as snapshot: - if snapshot_service_cert is not None: - with open(snapshot_service_cert, "rb") as cert_file: - service_cert = cert_file.read() - endorsements = ( - ccf.ledger.read_snapshot_endorsements(snapshot_endorsements) - if snapshot_endorsements is not None - else [] - ) - snapshot.verify_cose_receipt(service_cert, endorsements) - print( - "Verified snapshot receipt against target service identity " - f"with {len(endorsements)} endorsement(s)" - ) print( f"Reading snapshot from {snapshot_file} ({'' if snapshot.is_committed() else 'un'}committed)" ) @@ -278,14 +263,6 @@ def main(): help="Indicates that the path to read is a snapshot", action="store_true", ) - parser.add_argument( - "--snapshot-service-cert", - help="PEM service certificate to trust when verifying a COSE snapshot", - ) - parser.add_argument( - "--snapshot-endorsements", - help="CBOR endorsements sidecar used with --snapshot-service-cert", - ) parser.add_argument( "--uncommitted", help="Also parse uncommitted ledger files. Note that if these are in a live node directory, they may be being modified.", @@ -342,11 +319,6 @@ def main(): args = parser.parse_args() - if args.snapshot_endorsements and not args.snapshot_service_cert: - parser.error("--snapshot-endorsements requires --snapshot-service-cert") - if (args.snapshot_service_cert or args.snapshot_endorsements) and not args.snapshot: - parser.error("snapshot verification options require --snapshot") - # Parse verification level verification_level = None if args.verification_level: @@ -372,8 +344,6 @@ def main(): insecure_skip_verification=insecure_skip_verification, uncommitted=args.uncommitted, read_recovery_files=args.recovery, - snapshot_service_cert=args.snapshot_service_cert, - snapshot_endorsements=args.snapshot_endorsements, ): sys.exit(1) diff --git a/python/tests/test_cose_sign.py b/python/tests/test_cose_sign.py index 318d43f6be81..5e733d344d81 100644 --- a/python/tests/test_cose_sign.py +++ b/python/tests/test_cose_sign.py @@ -3,11 +3,6 @@ import base64 import datetime -import hashlib -import os -from pathlib import Path -import subprocess -import sys from typing import Tuple from cryptography.hazmat.primitives.asymmetric import ec from cryptography import x509 @@ -20,11 +15,7 @@ ) from cryptography.hazmat.primitives.asymmetric import utils import ccf.cose -import ccf.ledger import cbor2 -import cwt -import cwt.enums -import cwt.utils import pytest @@ -132,441 +123,3 @@ def test_create_cose_sign1_prepare_and_finish(curve): ccf.cose.verify_cose_sign1_with_cert( cert.encode(), finished_cose_sign1, use_key=False ) - - -def make_cose_endorsement(signer, payload_key, begin: str, end: str) -> bytes: - signer_priv_pem, signer_pub_pem = make_pem_pair(signer) - kid = ccf.cose.key_fingerprint_from_key(signer_pub_pem) - cose_key = cwt.COSEKey.from_pem(signer_priv_pem, kid=kid) - protected = cwt.utils.ResolvedHeader( - { - int(cwt.COSEHeaders.KID): kid.encode("utf-8"), - ccf.cose.CCF_V1_LABEL: { - ccf.cose.CCF_ENDORSEMENT_RANGE_BEGIN: begin, - ccf.cose.CCF_ENDORSEMENT_RANGE_END: end, - }, - } - ) - payload = payload_key.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) - return cwt.COSE.new( - alg_auto_inclusion=True, deterministic_header=True - ).encode_and_sign(payload, cose_key, protected=protected) - - -def make_manually_signed_cose_sign1( - signer: ec.EllipticCurvePrivateKey, - payload: bytes, - protected: dict, - unprotected: dict, - detached: bool, -) -> bytes: - protected_encoded = cbor2.dumps(protected) - to_be_signed = cbor2.dumps(["Signature1", protected_encoded, b"", payload]) - der_signature = signer.sign(to_be_signed, ec.ECDSA(hash_algo(signer))) - r, s = utils.decode_dss_signature(der_signature) - num_bytes = (signer.key_size + 7) // 8 - raw_signature = i2osp(r, num_bytes) + i2osp(s, num_bytes) - return cbor2.dumps( - cbor2.CBORTag( - cwt.const.COSE_TYPE_TO_TAG[cwt.const.COSETypes.SIGN1], - [ - protected_encoded, - unprotected, - None if detached else payload, - raw_signature, - ], - ) - ) - - -def make_cose_receipt( - signer: ec.EllipticCurvePrivateKey, - claim_digest: bytes, - path: list[list[bool | bytes]], - include_protected_algorithm: bool = True, - protected_algorithm: int | bool | None = None, - unprotected_algorithm: int | bool | None = None, - verifiable_data_structure: int | bool = (ccf.cose.COSE_PHDR_VDS_CCF_LEDGER_SHA256), -) -> bytes: - _, signer_pub_pem = make_pem_pair(signer) - kid = ccf.cose.key_fingerprint_from_key(signer_pub_pem) - protected = { - int(cwt.COSEHeaders.KID): kid.encode("utf-8"), - ccf.cose.COSE_PHDR_VDS_LABEL: verifiable_data_structure, - } - if include_protected_algorithm: - protected[int(cwt.COSEHeaders.ALG)] = ( - ccf.cose.default_algorithm_for_key(signer.public_key()) - if protected_algorithm is None - else protected_algorithm - ) - - write_set_digest = hashlib.sha256(b"write set").digest() - commit_evidence = "ce:2.1" - proof = cbor2.dumps( - { - ccf.cose.CCF_PROOF_LEAF_LABEL: [ - write_set_digest, - commit_evidence, - claim_digest, - ], - ccf.cose.CCF_PROOF_PATH_LABEL: path, - } - ) - unprotected: dict[int, object] = { - ccf.cose.COSE_PHDR_VDP_LABEL: { - ccf.cose.COSE_RECEIPT_INCLUSION_PROOF_LABEL: [proof] - } - } - if unprotected_algorithm is not None: - unprotected[int(cwt.COSEHeaders.ALG)] = unprotected_algorithm - root = hashlib.sha256( - write_set_digest - + hashlib.sha256(commit_evidence.encode()).digest() - + claim_digest - ).digest() - - return make_manually_signed_cose_sign1( - signer, - root, - protected, - unprotected, - detached=True, - ) - - -def make_manual_cose_endorsement( - signer: ec.EllipticCurvePrivateKey, - payload_key: ec.EllipticCurvePublicKey, - include_protected_algorithm: bool = True, - protected_algorithm: int | bool | None = None, - unprotected_algorithm: int | bool | None = None, -) -> bytes: - _, signer_pub_pem = make_pem_pair(signer) - kid = ccf.cose.key_fingerprint_from_key(signer_pub_pem) - protected = { - int(cwt.COSEHeaders.KID): kid.encode("utf-8"), - ccf.cose.CCF_V1_LABEL: { - ccf.cose.CCF_ENDORSEMENT_RANGE_BEGIN: "2.1", - ccf.cose.CCF_ENDORSEMENT_RANGE_END: "4.100", - }, - } - if include_protected_algorithm: - protected[int(cwt.COSEHeaders.ALG)] = ( - ccf.cose.default_algorithm_for_key(signer.public_key()) - if protected_algorithm is None - else protected_algorithm - ) - unprotected = {} - if unprotected_algorithm is not None: - unprotected[int(cwt.COSEHeaders.ALG)] = unprotected_algorithm - payload = payload_key.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) - return make_manually_signed_cose_sign1( - signer, payload, protected, unprotected, detached=False - ) - - -def test_verify_cose_receipt_with_empty_merkle_path(): - signer = make_private_key(ec.SECP384R1()) - claim_digest = hashlib.sha256(b"snapshot").digest() - receipt = make_cose_receipt(signer, claim_digest, path=[]) - - protected = ccf.cose.verify_receipt(receipt, signer.public_key(), claim_digest) - assert ( - protected[ccf.cose.COSE_PHDR_VDS_LABEL] - == ccf.cose.COSE_PHDR_VDS_CCF_LEDGER_SHA256 - ) - - endorsed_protected = ccf.cose.verify_receipt_with_endorsements( - receipt, - signer.public_key(), - claim_digest, - endorsements=[], - snapshot_seqno=1, - ) - assert endorsed_protected == protected - - -def test_cose_receipt_rejects_invalid_signature(): - signer = make_private_key(ec.SECP384R1()) - claim_digest = hashlib.sha256(b"snapshot").digest() - receipt = bytearray(make_cose_receipt(signer, claim_digest, path=[])) - receipt[-1] ^= 0xFF - - with pytest.raises(ValueError, match="signature verification failed"): - ccf.cose.verify_receipt(bytes(receipt), signer.public_key(), claim_digest) - - -@pytest.mark.parametrize("algorithm", [True, 0, 5, -7, 999999]) -def test_cose_receipt_rejects_wrong_or_boolean_algorithm(algorithm): - signer = make_private_key(ec.SECP384R1()) - claim_digest = hashlib.sha256(b"snapshot").digest() - receipt = make_cose_receipt( - signer, claim_digest, path=[], protected_algorithm=algorithm - ) - - with pytest.raises(ValueError, match="signing algorithm"): - ccf.cose.verify_receipt(receipt, signer.public_key(), claim_digest) - - -def test_cose_receipt_rejects_missing_or_unprotected_algorithm(): - signer = make_private_key(ec.SECP384R1()) - claim_digest = hashlib.sha256(b"snapshot").digest() - - missing = make_cose_receipt( - signer, - claim_digest, - path=[], - include_protected_algorithm=False, - ) - with pytest.raises(ValueError, match="signing algorithm"): - ccf.cose.verify_receipt(missing, signer.public_key(), claim_digest) - - expected_algorithm = ccf.cose.default_algorithm_for_key(signer.public_key()) - for unprotected_algorithm in (0, expected_algorithm): - duplicate = make_cose_receipt( - signer, - claim_digest, - path=[], - unprotected_algorithm=unprotected_algorithm, - ) - with pytest.raises(ValueError, match="unprotected headers"): - ccf.cose.verify_receipt(duplicate, signer.public_key(), claim_digest) - - -def test_cose_receipt_rejects_boolean_vds(): - signer = make_private_key(ec.SECP384R1()) - claim_digest = hashlib.sha256(b"snapshot").digest() - receipt = make_cose_receipt( - signer, - claim_digest, - path=[], - verifiable_data_structure=True, - ) - - with pytest.raises(ValueError, match="structure type must be an integer"): - ccf.cose.verify_receipt(receipt, signer.public_key(), claim_digest) - - -def test_manual_cose_endorsement_accepts_expected_algorithm(): - signer = make_private_key(ec.SECP384R1()) - endorsed = make_private_key(ec.SECP384R1()) - endorsement = make_manual_cose_endorsement(signer, endorsed.public_key()) - - verified_key = ccf.cose.verify_cose_endorsements( - [endorsement], signer.public_key(), snapshot_seqno=50 - ) - assert verified_key.public_bytes( - Encoding.DER, PublicFormat.SubjectPublicKeyInfo - ) == endorsed.public_key().public_bytes( - Encoding.DER, PublicFormat.SubjectPublicKeyInfo - ) - - -@pytest.mark.parametrize("algorithm", [True, 0, 5, -7, 999999]) -def test_cose_endorsement_rejects_wrong_or_boolean_algorithm(algorithm): - signer = make_private_key(ec.SECP384R1()) - endorsed = make_private_key(ec.SECP384R1()) - endorsement = make_manual_cose_endorsement( - signer, - endorsed.public_key(), - protected_algorithm=algorithm, - ) - - with pytest.raises(ValueError, match="signing algorithm"): - ccf.cose.verify_cose_endorsements( - [endorsement], signer.public_key(), snapshot_seqno=50 - ) - - -def test_cose_endorsement_rejects_missing_or_unprotected_algorithm(): - signer = make_private_key(ec.SECP384R1()) - endorsed = make_private_key(ec.SECP384R1()) - - missing = make_manual_cose_endorsement( - signer, - endorsed.public_key(), - include_protected_algorithm=False, - ) - with pytest.raises(ValueError, match="signing algorithm"): - ccf.cose.verify_cose_endorsements( - [missing], signer.public_key(), snapshot_seqno=50 - ) - - expected_algorithm = ccf.cose.default_algorithm_for_key(signer.public_key()) - for unprotected_algorithm in (0, expected_algorithm): - duplicate = make_manual_cose_endorsement( - signer, - endorsed.public_key(), - unprotected_algorithm=unprotected_algorithm, - ) - with pytest.raises(ValueError, match="unprotected headers"): - ccf.cose.verify_cose_endorsements( - [duplicate], signer.public_key(), snapshot_seqno=50 - ) - - -def test_receipt_verification_rejects_empty_proofs_under_optimization(): - python_src = str(Path(__file__).parents[1] / "src") - env = os.environ.copy() - env["PYTHONPATH"] = os.pathsep.join( - path for path in (python_src, env.get("PYTHONPATH")) if path - ) - script = """ -import cbor2 -import cwt -from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat -import ccf.cose - -key = ec.generate_private_key(ec.SECP384R1()) -public_pem = key.public_key().public_bytes( - Encoding.PEM, PublicFormat.SubjectPublicKeyInfo -).decode("ascii") -kid = ccf.cose.key_fingerprint_from_key(public_pem).encode("utf-8") -protected = cbor2.dumps( - { - int(cwt.COSEHeaders.ALG): ccf.cose.default_algorithm_for_key( - key.public_key() - ), - int(cwt.COSEHeaders.KID): kid, - ccf.cose.COSE_PHDR_VDS_LABEL: - ccf.cose.COSE_PHDR_VDS_CCF_LEDGER_SHA256, - } -) -fabricated = cbor2.dumps( - cbor2.CBORTag( - cwt.const.COSE_TYPE_TO_TAG[cwt.const.COSETypes.SIGN1], - [ - protected, - { - ccf.cose.COSE_PHDR_VDP_LABEL: { - ccf.cose.COSE_RECEIPT_INCLUSION_PROOF_LABEL: [] - } - }, - None, - b"\\x00" * 96, - ], - ) -) - -try: - ccf.cose.verify_receipt_with_endorsements( - fabricated, - key.public_key(), - b"\\x00" * 32, - endorsements=[], - snapshot_seqno=1, - ) -except ValueError as error: - if str(error) != "At least one inclusion proof is required": - raise - print("rejected") -else: - raise SystemExit("fabricated unsigned receipt was accepted") -""" - result = subprocess.run( - [sys.executable, "-O", "-c", script], - check=False, - capture_output=True, - text=True, - env=env, - ) - assert result.returncode == 0, result.stderr - assert result.stdout.strip() == "rejected" - - -def test_cose_endorsement_sidecar_chain(): - identities = [make_private_key(ec.SECP384R1()) for _ in range(3)] - oldest = make_cose_endorsement( - identities[1], identities[0].public_key(), "2.1", "4.100" - ) - newest = make_cose_endorsement( - identities[2], identities[1].public_key(), "6.101", "8.200" - ) - endorsements = [newest, oldest] - - serialized = ccf.cose.serialize_cose_endorsements(endorsements) - assert ccf.cose.deserialize_cose_endorsements(serialized) == endorsements - - snapshot_key = ccf.cose.verify_cose_endorsements( - endorsements, identities[2].public_key(), snapshot_seqno=50 - ) - assert snapshot_key.public_bytes( - Encoding.DER, PublicFormat.SubjectPublicKeyInfo - ) == identities[0].public_key().public_bytes( - Encoding.DER, PublicFormat.SubjectPublicKeyInfo - ) - - with pytest.raises(ValueError): - ccf.cose.verify_cose_endorsements( - list(reversed(endorsements)), - identities[2].public_key(), - snapshot_seqno=50, - ) - - tampered = [bytearray(newest), oldest] - tampered[0][-1] ^= 0xFF - with pytest.raises(ValueError): - ccf.cose.verify_cose_endorsements( - [bytes(tampered[0]), tampered[1]], - identities[2].public_key(), - snapshot_seqno=50, - ) - - with pytest.raises(ValueError, match="does not cover"): - ccf.cose.verify_cose_endorsements( - endorsements, identities[2].public_key(), snapshot_seqno=1000 - ) - - -def test_cose_endorsement_sidecar_encoding_is_strict(): - with pytest.raises(ValueError, match="CBOR array"): - ccf.cose.deserialize_cose_endorsements(cbor2.dumps({"not": "an array"})) - with pytest.raises(ValueError, match="Trailing data"): - ccf.cose.deserialize_cose_endorsements( - ccf.cose.serialize_cose_endorsements([b"x" * 64]) + b"\x00" - ) - - -def test_cose_endorsement_sidecar_resource_limits(tmp_path): - too_many = b"\x9a\x00\x0f\x42\x40" - with pytest.raises(ValueError, match="between 1 and"): - ccf.cose.deserialize_cose_endorsements(too_many) - - with pytest.raises(ValueError, match="between 1 and"): - ccf.cose.deserialize_cose_endorsements(b"\x80") - with pytest.raises(ValueError, match="between 1 and"): - ccf.cose.serialize_cose_endorsements([]) - with pytest.raises(ValueError, match="too small"): - ccf.cose.deserialize_cose_endorsements(b"\x81\x40") - with pytest.raises(ValueError, match="too small"): - ccf.cose.serialize_cose_endorsements([b"x"]) - with pytest.raises(ValueError, match="CBOR array"): - ccf.cose.deserialize_cose_endorsements(b"\x9f\xff") - - nested = cbor2.dumps( - [ - [b""] * ccf.cose.MAX_SNAPSHOT_ENDORSEMENTS_COUNT - for _ in range(ccf.cose.MAX_SNAPSHOT_ENDORSEMENTS_COUNT) - ] - ) - with pytest.raises(ValueError, match="byte strings"): - ccf.cose.deserialize_cose_endorsements(nested) - - oversized_endorsement = b"\x00" * (ccf.cose.MAX_SNAPSHOT_ENDORSEMENT_SIZE + 1) - with pytest.raises(ValueError, match="too large"): - ccf.cose.serialize_cose_endorsements([oversized_endorsement]) - with pytest.raises(ValueError, match="too large"): - ccf.cose.deserialize_cose_endorsements(cbor2.dumps([oversized_endorsement])) - - maximum_endorsement = b"\x00" * ccf.cose.MAX_SNAPSHOT_ENDORSEMENT_SIZE - with pytest.raises(ValueError, match="payload is too large"): - ccf.cose.serialize_cose_endorsements([maximum_endorsement] * 5) - with pytest.raises(ValueError, match="payload is too large"): - ccf.cose.deserialize_cose_endorsements(cbor2.dumps([maximum_endorsement] * 5)) - - oversized_path = tmp_path / "oversized.endorsements" - oversized_path.write_bytes(b"\x00" * (ccf.cose.MAX_SNAPSHOT_ENDORSEMENTS_SIZE + 1)) - with pytest.raises(ValueError, match="sidecar .* is too large"): - ccf.ledger.read_snapshot_endorsements(str(oversized_path)) diff --git a/src/crypto/cbor.cpp b/src/crypto/cbor.cpp index e3dfb1087303..02d642bae970 100644 --- a/src/crypto/cbor.cpp +++ b/src/crypto/cbor.cpp @@ -81,12 +81,7 @@ namespace std::list> arrays; std::list> maps; }; - Value consume( - cbor_nondet_t cbor, - size_t depth, - size_t max_depth, - size_t max_container_size, - size_t& remaining_items); + Value consume(cbor_nondet_t cbor, size_t depth, size_t max_depth); void print_indent(std::ostringstream& os, size_t indent) { @@ -134,12 +129,7 @@ namespace return std::make_shared(value); } - Value consume_array( - cbor_nondet_t cbor, - size_t depth, - size_t max_depth, - size_t max_container_size, - size_t& remaining_items) + Value consume_array(cbor_nondet_t cbor, size_t depth, size_t max_depth) { cbor_nondet_array_iterator_t iter; if (!cbor_nondet_array_iterator_start(cbor, &iter)) @@ -151,32 +141,18 @@ namespace Array array; while (!cbor_nondet_array_iterator_is_empty(iter)) { - if (array.items.size() >= max_container_size) - { - throw CBORDecodeError( - Error::DECODE_FAILED, - fmt::format( - "Maximum CBOR array size ({}) exceeded", max_container_size)); - } - cbor_nondet_t item; if (!cbor_nondet_array_iterator_next(&iter, &item)) { throw CBORDecodeError( Error::DECODE_FAILED, "Failed to get next array item"); } - array.items.push_back(consume( - item, depth + 1, max_depth, max_container_size, remaining_items)); + array.items.push_back(consume(item, depth + 1, max_depth)); } return std::make_shared(std::move(array)); } - Value consume_map( - cbor_nondet_t cbor, - size_t depth, - size_t max_depth, - size_t max_container_size, - size_t& remaining_items) + Value consume_map(cbor_nondet_t cbor, size_t depth, size_t max_depth) { cbor_map_iterator iter; if (!cbor_nondet_map_iterator_start(cbor, &iter)) @@ -188,14 +164,6 @@ namespace Map map; while (!cbor_nondet_map_iterator_is_empty(iter)) { - if (map.items.size() >= max_container_size) - { - throw CBORDecodeError( - Error::DECODE_FAILED, - fmt::format( - "Maximum CBOR map size ({}) exceeded", max_container_size)); - } - cbor_raw key_raw; cbor_raw value_raw; if (!cbor_nondet_map_iterator_next(&iter, &key_raw, &value_raw)) @@ -204,24 +172,13 @@ namespace Error::DECODE_FAILED, "Failed to get next map entry"); } map.items.emplace_back( - consume( - key_raw, depth + 1, max_depth, max_container_size, remaining_items), - consume( - value_raw, - depth + 1, - max_depth, - max_container_size, - remaining_items)); + consume(key_raw, depth + 1, max_depth), + consume(value_raw, depth + 1, max_depth)); } return std::make_shared(std::move(map)); } - Value consume_tagged( - cbor_nondet_t cbor, - size_t depth, - size_t max_depth, - size_t max_container_size, - size_t& remaining_items) + Value consume_tagged(cbor_nondet_t cbor, size_t depth, size_t max_depth) { uint64_t tag = 0; cbor_nondet_t payload; @@ -233,8 +190,7 @@ namespace Tagged tagged; tagged.tag = tag; - tagged.item = consume( - payload, depth + 1, max_depth, max_container_size, remaining_items); + tagged.item = consume(payload, depth + 1, max_depth); return std::make_shared(std::move(tagged)); } @@ -252,20 +208,8 @@ namespace return std::make_shared(value); } - Value consume( - cbor_nondet_t cbor, - size_t depth, - size_t max_depth, - size_t max_container_size, - size_t& remaining_items) + Value consume(cbor_nondet_t cbor, size_t depth, size_t max_depth) { - if (remaining_items == 0) - { - throw CBORDecodeError( - Error::DECODE_FAILED, "Maximum total CBOR item count exceeded"); - } - --remaining_items; - if (depth > max_depth) { throw CBORDecodeError( @@ -284,14 +228,11 @@ namespace case CBOR_MAJOR_TYPE_TEXT_STRING: return consume_text_string(cbor); case CBOR_MAJOR_TYPE_ARRAY: - return consume_array( - cbor, depth, max_depth, max_container_size, remaining_items); + return consume_array(cbor, depth, max_depth); case CBOR_MAJOR_TYPE_MAP: - return consume_map( - cbor, depth, max_depth, max_container_size, remaining_items); + return consume_map(cbor, depth, max_depth); case CBOR_MAJOR_TYPE_TAGGED: - return consume_tagged( - cbor, depth, max_depth, max_container_size, remaining_items); + return consume_tagged(cbor, depth, max_depth); case CBOR_MAJOR_TYPE_SIMPLE_VALUE: return consume_simple(cbor); default: @@ -616,11 +557,7 @@ namespace ccf::cbor return std::make_shared(Map{.items = std::move(data)}); } - Value parse( - std::span raw, - size_t max_depth, - size_t max_container_size, - size_t max_total_items) + Value parse(std::span raw, size_t max_depth) { cbor_nondet_t cbor; const bool check_map_key_bound = false; @@ -645,8 +582,7 @@ namespace ccf::cbor fmt::format("Trailing {} byte(s) after CBOR item", cbor_parse_size)); } - auto remaining_items = max_total_items; - return consume(cbor, 0, max_depth, max_container_size, remaining_items); + return consume(cbor, 0, max_depth); } std::vector serialize(const Value& value, size_t max_depth) diff --git a/src/crypto/cbor.h b/src/crypto/cbor.h index 05482adbe060..a321095818b2 100644 --- a/src/crypto/cbor.h +++ b/src/crypto/cbor.h @@ -5,7 +5,6 @@ #include #include -#include #include #include #include @@ -117,11 +116,7 @@ namespace ccf::cbor Value make_array(std::vector&& data); Value make_map(std::vector&& data); - Value parse( - std::span raw, - size_t max_depth = 16, - size_t max_container_size = std::numeric_limits::max(), - size_t max_total_items = std::numeric_limits::max()); + Value parse(std::span raw, size_t max_depth = 16); std::vector serialize(const Value& value, size_t max_depth = 16); std::string to_string(const Value& value); diff --git a/src/crypto/test/cbor.cpp b/src/crypto/test/cbor.cpp index 71b1b44cdc9a..733eca766745 100644 --- a/src/crypto/test/cbor.cpp +++ b/src/crypto/test/cbor.cpp @@ -1763,29 +1763,6 @@ TEST_CASE("CBOR: parse max depth") REQUIRE_NOTHROW(parse(map_depth2, 2)); } -TEST_CASE("CBOR: parse max container size") -{ - auto array = ccf::ds::from_hex("83010203"); - REQUIRE_NOTHROW(parse(array, 16, 3)); - REQUIRE_THROWS_AS(parse(array, 16, 2), CBORDecodeError); - - auto nested_array = ccf::ds::from_hex("81820102"); - REQUIRE_THROWS_AS(parse(nested_array, 16, 1), CBORDecodeError); - - // {1: 2, 3: 4} -- a map counts entries against the same budget - auto map = ccf::ds::from_hex("a201020304"); - REQUIRE_NOTHROW(parse(map, 16, 2)); - REQUIRE_THROWS_AS(parse(map, 16, 1), CBORDecodeError); -} - -TEST_CASE("CBOR: parse max total items") -{ - // [[1, 2], [3, 4]] contains 7 total items, including the 3 arrays. - auto nested_array = ccf::ds::from_hex("82820102820304"); - REQUIRE_NOTHROW(parse(nested_array, 16, 2, 7)); - REQUIRE_THROWS_AS(parse(nested_array, 16, 2, 6), CBORDecodeError); -} - TEST_CASE("CBOR: serialize max depth") { // depth 1: [42] -- should pass at max_depth=1,2 diff --git a/src/host/files_cleanup_timer.h b/src/host/files_cleanup_timer.h index aa373b7758ed..28115c7ec561 100644 --- a/src/host/files_cleanup_timer.h +++ b/src/host/files_cleanup_timer.h @@ -299,25 +299,6 @@ namespace asynchost path.filename(), ec.message()); } - else - { - const auto sidecar_path = - snapshots::get_snapshot_endorsements_path(path); - std::error_code sidecar_ec; - { - TimeBoundLogger log_remove_sidecar_if_slow(fmt::format( - "Deleting old snapshot endorsements sidecar - remove({})", - sidecar_path.filename())); - std::filesystem::remove(sidecar_path, sidecar_ec); - } - if (sidecar_ec) - { - LOG_FAIL_FMT( - "Failed to delete old snapshot endorsements sidecar {}: {}", - sidecar_path.filename(), - sidecar_ec.message()); - } - } } } } diff --git a/src/host/test/files_cleanup_test.cpp b/src/host/test/files_cleanup_test.cpp index 8882e3d60880..c1951b28630b 100644 --- a/src/host/test/files_cleanup_test.cpp +++ b/src/host/test/files_cleanup_test.cpp @@ -518,32 +518,6 @@ TEST_CASE("highest_committed_snapshot_seqno: ignores uncommitted snapshots") fs::remove_all(tmp); } -TEST_CASE("cleanup_old_snapshots: removes adjacent endorsement sidecars") -{ - auto tmp = make_unique_test_dir("test_snapshot_sidecar_cleanup"); - const auto oldest = create_committed_snapshot(tmp, 100, 105); - const auto middle = create_committed_snapshot(tmp, 200, 205); - const auto newest = create_committed_snapshot(tmp, 300, 305); - for (const auto& snapshot : {oldest, middle, newest}) - { - write_file( - snapshots::get_snapshot_endorsements_path(snapshot), "endorsements"); - } - - const auto committed = find_committed_snapshots(tmp); - REQUIRE(committed.has_value()); - cleanup_old_snapshots(*committed, 1); - - CHECK_FALSE(fs::exists(oldest)); - CHECK_FALSE(fs::exists(snapshots::get_snapshot_endorsements_path(oldest))); - CHECK_FALSE(fs::exists(middle)); - CHECK_FALSE(fs::exists(snapshots::get_snapshot_endorsements_path(middle))); - CHECK(fs::exists(newest)); - CHECK(fs::exists(snapshots::get_snapshot_endorsements_path(newest))); - - fs::remove_all(tmp); -} - // ---- snapshot watermark in cleanup_old_ledger_chunks tests ---- TEST_CASE( diff --git a/src/node/node_state.h b/src/node/node_state.h index 12be9929e034..81487c2dce35 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -472,7 +472,6 @@ namespace ccf std::shared_ptr jwt_key_auto_refresh; std::unique_ptr startup_snapshot_info = nullptr; - std::optional startup_snapshot_path = std::nullopt; // Set to the snapshot seqno when a node starts from one and remembered for // the lifetime of the node ccf::kv::Version startup_seqno = 0; @@ -529,18 +528,16 @@ namespace ccf snapshot_path, snapshot_data.size()); - // A structurally invalid snapshot (for example a truncated or nulled - // file) is a fatal startup error rather than something we silently - // skip and continue past, so separate the segments outside the - // verification try/catch below. + // A structurally invalid snapshot is a fatal startup error, rather than + // something to skip after potentially consuming an ambiguous prefix. const auto segments = separate_segments(snapshot_data); try { if (start_type == StartType::Recover) { - // Validate the receipt structure and snapshot digest, but defer the - // identity check until the recovery sidecar gate has run. + // Validate the receipt structure and claims now, but defer the + // identity check until the public-ledger endorsement scan. verify_snapshot(segments); } else @@ -597,7 +594,6 @@ namespace ccf { startup_snapshot_info = std::make_unique( snapshot_seqno, std::move(snapshot_data)); - startup_snapshot_path = snapshot_path; LOG_INFO_FMT( "Selected recovery snapshot {} for previous-service-identity " "verification", @@ -623,7 +619,6 @@ namespace ccf startup_snapshot_info = std::make_unique( snapshot_seqno, std::move(snapshot_data)); - startup_snapshot_path.reset(); install_startup_snapshot(); } @@ -683,40 +678,6 @@ namespace ccf } } - bool drop_recovery_snapshot_sidecar_unsafe( - const std::filesystem::path& sidecar_path) - { - std::error_code ec; - const auto exists = std::filesystem::exists(sidecar_path, ec); - if (ec) - { - LOG_FAIL_FMT( - "Unable to inspect snapshot endorsements sidecar {}: {}", - sidecar_path.string(), - ec.message()); - return false; - } - if (!exists) - { - return true; - } - - const auto removed = std::filesystem::remove(sidecar_path, ec); - if (ec || !removed) - { - LOG_FAIL_FMT( - "Unable to remove stale snapshot endorsements sidecar {}: {}", - sidecar_path.string(), - ec ? ec.message() : "file was not removed"); - return false; - } - - LOG_INFO_FMT( - "Removed stale snapshot endorsements sidecar {}", - sidecar_path.string()); - return true; - } - void start_public_ledger_recovery_unsafe() { sm.advance(NodeStartupState::readingPublicLedger); @@ -730,15 +691,7 @@ namespace ccf "service identity: {}. Falling back to full-ledger recovery.", reason); - if (startup_snapshot_path.has_value()) - { - const auto sidecar_path = - snapshots::get_snapshot_endorsements_path(*startup_snapshot_path); - std::ignore = drop_recovery_snapshot_sidecar_unsafe(sidecar_path); - } - startup_snapshot_info.reset(); - startup_snapshot_path.reset(); startup_seqno = 0; last_recovered_idx = 0; last_recovered_signed_idx = 0; @@ -747,25 +700,6 @@ namespace ccf start_public_ledger_recovery_unsafe(); } - [[nodiscard]] bool recovery_snapshot_directory_is_writable_unsafe() const - { - if (!startup_snapshot_path.has_value()) - { - return false; - } - - std::error_code ec; - const auto snapshot_dir = std::filesystem::weakly_canonical( - startup_snapshot_path->parent_path(), ec); - if (ec) - { - return false; - } - const auto writable_dir = - std::filesystem::weakly_canonical(config.snapshots.directory, ec); - return !ec && snapshot_dir == writable_dir; - } - void install_recovery_snapshot_and_start_unsafe() { install_startup_snapshot(); @@ -775,7 +709,7 @@ namespace ccf void start_recovery_snapshot_verification_unsafe() { if ( - !startup_snapshot_info || !startup_snapshot_path.has_value() || + !startup_snapshot_info || !config.recover.previous_service_identity.has_value()) { start_public_ledger_recovery_unsafe(); @@ -785,65 +719,19 @@ namespace ccf const auto segments = separate_segments(startup_snapshot_info->raw); const ccf::crypto::Pem target_identity( *config.recover.previous_service_identity); - const auto sidecar_path = - snapshots::get_snapshot_endorsements_path(*startup_snapshot_path); - - std::error_code ec; - const auto sidecar_exists = std::filesystem::exists(sidecar_path, ec); - if (ec) - { - fallback_from_recovery_snapshot_unsafe(fmt::format( - "unable to inspect sidecar {}: {}", - sidecar_path.string(), - ec.message())); - return; - } - - if (sidecar_exists) - { - const auto preparation_error = - try_prepare_and_install_recovery_snapshot( - [&]() { - const auto endorsements = - read_cose_endorsements_sidecar(sidecar_path); - verify_recovery_snapshot( - segments, - target_identity, - endorsements, - startup_snapshot_info->seqno); - LOG_INFO_FMT( - "Reusing verified snapshot endorsements sidecar {}", - sidecar_path.string()); - }, - [&]() { install_recovery_snapshot_and_start_unsafe(); }); - if (!preparation_error.has_value()) - { - return; - } - LOG_FAIL_FMT( - "Snapshot endorsements sidecar {} is invalid: {}", - sidecar_path.string(), - *preparation_error); - if (!drop_recovery_snapshot_sidecar_unsafe(sidecar_path)) - { - fallback_from_recovery_snapshot_unsafe( - "invalid sidecar could not be removed"); - return; - } - } - - const auto preparation_error = try_prepare_and_install_recovery_snapshot( - [&]() { - verify_recovery_snapshot( - segments, target_identity, {}, startup_snapshot_info->seqno); - LOG_INFO_FMT( - "Recovery snapshot {} is directly signed by the configured " - "previous service identity", - startup_snapshot_path->string()); - }, - [&]() { install_recovery_snapshot_and_start_unsafe(); }); - if (!preparation_error.has_value()) + const auto direct_verification_error = + try_verify_and_install_recovery_snapshot( + [&]() { + verify_recovery_snapshot( + segments, target_identity, {}, startup_snapshot_info->seqno); + LOG_INFO_FMT( + "Recovery snapshot at {} is directly signed by the configured " + "previous service identity", + startup_snapshot_info->seqno); + }, + [&]() { install_recovery_snapshot_and_start_unsafe(); }); + if (!direct_verification_error.has_value()) { return; } @@ -851,132 +739,71 @@ namespace ccf if (segments.receipt.empty() || segments.receipt[0] != 0xD2) { fallback_from_recovery_snapshot_unsafe(fmt::format( - "old-style snapshot receipt cannot be augmented: {}", - *preparation_error)); + "old-style snapshot receipt cannot use an endorsement chain: {}", + *direct_verification_error)); return; } LOG_INFO_FMT( - "Recovery snapshot {} is not directly signed by the configured " + "Recovery snapshot at {} is not directly signed by the configured " "previous service identity ({}); scanning the public ledger suffix " "for COSE endorsements", - startup_snapshot_path->string(), - *preparation_error); - - if (!recovery_snapshot_directory_is_writable_unsafe()) - { - fallback_from_recovery_snapshot_unsafe( - "the selected snapshot is in a read-only directory and has no valid " - "endorsements sidecar"); - return; - } - - if ( - startup_snapshot_info->seqno == - std::numeric_limits::max()) - { - fallback_from_recovery_snapshot_unsafe( - "snapshot seqno cannot be incremented for ledger scanning"); - return; - } - - std::optional scan = std::nullopt; - try - { - scan = scan_recovery_snapshot_ledger_files( - config.ledger, - network.tables->get_encryptor(), - startup_snapshot_info->seqno); - } - catch (const std::exception& e) - { - fallback_from_recovery_snapshot_unsafe(e.what()); - return; - } - finish_recovery_snapshot_endorsements_scan_unsafe(std::move(*scan)); - } - - void finish_recovery_snapshot_endorsements_scan_unsafe( - RecoverySnapshotLedgerScan&& scan) - { - if ( - !startup_snapshot_info || !startup_snapshot_path.has_value() || - !config.recover.previous_service_identity.has_value()) - { - fallback_from_recovery_snapshot_unsafe( - "snapshot scan completed without a selected snapshot or target " - "identity"); - return; - } - - const auto snapshot_seqno = startup_snapshot_info->seqno; - ccf::SerialisedCoseEndorsements serialised_endorsements; - auto sidecar_path = - snapshots::get_snapshot_endorsements_path(*startup_snapshot_path); - try - { - if ( - scan.last_signed_idx <= - static_cast<::consensus::Index>(snapshot_seqno)) - { - throw std::logic_error( - "no committed ledger suffix was found after the snapshot"); - } - if (!scan.latest_service_info.has_value()) - { - throw std::logic_error( - "no service identity was found in the committed ledger suffix"); - } - - const ccf::crypto::Pem target_identity( - *config.recover.previous_service_identity); - const auto& latest_service = scan.latest_service_info->second; - if (latest_service.cert != target_identity) - { - throw std::logic_error( - "latest ledger service identity does not match the configured " - "previous service identity"); - } - if (!latest_service.current_service_create_txid.has_value()) - { - throw std::logic_error( - "latest ledger service identity has no creation TxID"); - } - - const auto target_key = ccf::crypto::public_key_der_from_cert( - ccf::crypto::cert_pem_to_der(target_identity)); - serialised_endorsements = validate_and_serialise_collected_endorsements( - scan.endorsements, - target_key, - snapshot_seqno, - *latest_service.current_service_create_txid); - verify_recovery_snapshot( - separate_segments(startup_snapshot_info->raw), - target_identity, - serialised_endorsements, - snapshot_seqno, - latest_service.current_service_create_txid); - write_cose_endorsements_sidecar(sidecar_path, serialised_endorsements); - } - catch (const std::exception& e) - { - fallback_from_recovery_snapshot_unsafe(e.what()); - return; - } + startup_snapshot_info->seqno, + *direct_verification_error); + + const auto chain_verification_error = + try_verify_and_install_recovery_snapshot( + [&]() { + const auto snapshot_seqno = startup_snapshot_info->seqno; + const auto scan = scan_recovery_snapshot_ledger_files( + config.ledger, network.tables->get_encryptor(), snapshot_seqno); + if ( + scan.last_signed_idx <= + static_cast<::consensus::Index>(snapshot_seqno)) + { + throw std::logic_error( + "No committed ledger suffix was found after the snapshot"); + } + if (!scan.latest_service_info.has_value()) + { + throw std::logic_error( + "No service identity was found in the committed ledger suffix"); + } - LOG_INFO_FMT( - "Persisted {} verified recovery snapshot endorsement(s) to {}", - serialised_endorsements.size(), - sidecar_path.string()); + const auto& latest_service = scan.latest_service_info->second; + if (latest_service.cert != target_identity) + { + throw std::logic_error( + "Latest ledger service identity does not match the configured " + "previous service identity"); + } + if (!latest_service.current_service_create_txid.has_value()) + { + throw std::logic_error( + "Latest ledger service identity has no creation TxID"); + } - try - { - install_recovery_snapshot_and_start_unsafe(); - } - catch (const std::exception&) + const auto target_key = ccf::crypto::public_key_der_from_cert( + ccf::crypto::cert_pem_to_der(target_identity)); + const auto endorsements = build_recovery_snapshot_endorsement_chain( + scan.endorsements, + target_key, + snapshot_seqno, + *latest_service.current_service_create_txid); + verify_recovery_snapshot( + segments, + target_identity, + endorsements, + snapshot_seqno, + latest_service.current_service_create_txid); + LOG_INFO_FMT( + "Validated {} recovery snapshot endorsement(s) in memory", + endorsements.size()); + }, + [&]() { install_recovery_snapshot_and_start_unsafe(); }); + if (chain_verification_error.has_value()) { - std::ignore = drop_recovery_snapshot_sidecar_unsafe(sidecar_path); - throw; + fallback_from_recovery_snapshot_unsafe(*chain_verification_error); } } @@ -1628,15 +1455,14 @@ namespace ccf { // The legacy httpclient path silently dropped a failed // connection and relied on the periodic join timer to retry - // when the target could not yet be reached, while treating - // TLS handshake failures (e.g. an untrusted service - // certificate) as fatal. Preserve both behaviours: transient - // transport errors are retried, everything else is fatal. + // when the target could not yet be reached, while treating TLS + // handshake failures (e.g. an untrusted service certificate) as + // fatal. Preserve both behaviours: transient transport errors + // are retried, everything else is fatal. if (ccf::curl::is_transient_transport_error(curl_response)) { LOG_INFO_FMT( - "Transient error contacting {} to join: {} ({}). The " - "join " + "Transient error contacting {} to join: {} ({}). The join " "timer will retry.", target_address, curl_easy_strerror(curl_response), @@ -1644,16 +1470,14 @@ namespace ccf return; } - // CURLE_WRITE_ERROR here means our own write callback - // rejected the response body, which for the join can only be - // the body exceeding max_join_response_size. Surface a clear, - // actionable message rather than curl's generic "write - // error". + // CURLE_WRITE_ERROR here means our own write callback rejected + // the response body, which for the join can only be the body + // exceeding max_join_response_size. Surface a clear, actionable + // message rather than curl's generic "write error". if (curl_response == CURLE_WRITE_ERROR) { auto error_msg = fmt::format( - "Join response from {} exceeded the maximum permitted " - "size " + "Join response from {} exceeded the maximum permitted size " "of {} bytes. Shutting down node gracefully...", target_address, max_join_response_size); @@ -1675,8 +1499,7 @@ namespace ccf curl_response == CURLE_PEER_FAILED_VERIFICATION || curl_response == CURLE_SSL_CACERT_BADFILE; auto error_msg = fmt::format( - "Early error when joining existing network at {}: {}{} " - "({}). " + "Early error when joining existing network at {}: {}{} ({}). " "Shutting down node gracefully...", target_address, tls_certificate_trust_check_failed ? @@ -1717,8 +1540,7 @@ namespace ccf { // Leave error_response == nullopt LOG_FAIL_FMT( - "Join request returned {}, body is not " - "ODataErrorResponse: " + "Join request returned {}, body is not ODataErrorResponse: " "{}", status, std::string(data.begin(), data.end())); @@ -1731,16 +1553,15 @@ namespace ccf config.join.fetch_recent_snapshot) { LOG_INFO_FMT( - "Join request to {} returned {} error. Attempting to " - "fetch " + "Join request to {} returned {} error. Attempting to fetch " "fresher snapshot", target_address, ccf::errors::StartupSeqnoIsOld); - // If we've followed a redirect, it will have been updated - // in config.join. Note that this is fire-and-forget, it is - // assumed that it proceeds in the background, updating - // state when it completes, and the join timer separately + // If we've followed a redirect, it will have been updated in + // config.join. Note that this is fire-and-forget, it is + // assumed that it proceeds in the background, updating state + // when it completes, and the join timer separately // re-attempts join after this succeeds if ( snapshot_fetch_task != nullptr && @@ -1808,8 +1629,7 @@ namespace ccf catch (const std::exception& e) { LOG_FAIL_FMT( - "An error occurred while parsing the join network " - "response"); + "An error occurred while parsing the join network response"); LOG_DEBUG_FMT("Join network response error: {}", e.what()); LOG_DEBUG_FMT( @@ -1864,9 +1684,8 @@ namespace ccf std::vector view_history_ = {}; if (startup_snapshot_info) { - // It is only possible to deserialise the entire snapshot - // now, once the ledger secrets have been passed in by the - // network + // It is only possible to deserialise the entire snapshot now, + // once the ledger secrets have been passed in by the network ccf::kv::ConsensusHookPtrs hooks; network.tables->set_readiness( ccf::kv::StoreReadiness::InstallingSnapshot); @@ -1889,9 +1708,9 @@ namespace ccf if (!resp.network_info->public_only) { - // Only clear snapshot if not recovering. When joining - // the public network the snapshot is used later to - // initialise the recovery store + // Only clear snapshot if not recovering. When joining the + // public network the snapshot is used later to initialise + // the recovery store startup_snapshot_info.reset(); } diff --git a/src/node/recovery_snapshot_ledger.h b/src/node/recovery_snapshot_ledger.h index 05704bad5f49..71d730bed183 100644 --- a/src/node/recovery_snapshot_ledger.h +++ b/src/node/recovery_snapshot_ledger.h @@ -9,7 +9,6 @@ #include "kv/kv_serialiser.h" #include "kv/serialised_entry_format.h" #include "node/rpc/network_identity_chain_helpers.h" -#include "node/snapshot_serdes.h" #include "service/tables/previous_service_identity.h" #include "service/tables/signatures.h" @@ -22,6 +21,14 @@ namespace ccf { + static constexpr size_t MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_COUNT = 64; + static constexpr size_t MAX_RECOVERY_SNAPSHOT_ENDORSEMENT_SIZE = + size_t{1024} * 1024; + static constexpr size_t MAX_RECOVERY_SNAPSHOT_ENDORSEMENT_RECORD_SIZE = + 2 * MAX_RECOVERY_SNAPSHOT_ENDORSEMENT_SIZE; + static constexpr size_t MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE = + size_t{4} * 1024 * 1024; + struct RecoverySnapshotLedgerEntry { ccf::kv::Version version = 0; @@ -95,13 +102,13 @@ namespace ccf throw std::logic_error( "Invalid previous service identity endorsement table write"); } - if (value.size() > MAX_SNAPSHOT_ENDORSEMENT_RECORD_SIZE) + if (value.size() > MAX_RECOVERY_SNAPSHOT_ENDORSEMENT_RECORD_SIZE) { throw std::logic_error(fmt::format( "Serialised previous service identity endorsement is too large " "({} bytes; maximum {} bytes)", value.size(), - MAX_SNAPSHOT_ENDORSEMENT_RECORD_SIZE)); + MAX_RECOVERY_SNAPSHOT_ENDORSEMENT_RECORD_SIZE)); } result.endorsement = ccf::PreviousServiceIdentityEndorsement:: ValueSerialiser::from_serialised(value); @@ -410,30 +417,32 @@ namespace ccf if (parsed.endorsement.has_value()) { const auto endorsement_size = parsed.endorsement->endorsement.size(); - if (endorsement_size > MAX_SNAPSHOT_ENDORSEMENT_SIZE) + if (endorsement_size > MAX_RECOVERY_SNAPSHOT_ENDORSEMENT_SIZE) { throw std::logic_error(fmt::format( "Ledger endorsement at {} is too large ({} bytes; maximum {} " "bytes)", parsed.version, endorsement_size, - MAX_SNAPSHOT_ENDORSEMENT_SIZE)); + MAX_RECOVERY_SNAPSHOT_ENDORSEMENT_SIZE)); } - if (pending_endorsements.size() >= MAX_SNAPSHOT_ENDORSEMENTS_COUNT) + if ( + pending_endorsements.size() >= + MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_COUNT) { throw std::logic_error(fmt::format( "Ledger suffix contains too many pending endorsements (maximum " "{})", - MAX_SNAPSHOT_ENDORSEMENTS_COUNT)); + MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_COUNT)); } if ( - endorsement_size > MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE - + endorsement_size > MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE - pending_endorsements_payload_size) { throw std::logic_error(fmt::format( "Pending ledger endorsements payload is too large (maximum {} " "bytes)", - MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE)); + MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE)); } pending_endorsements_payload_size += endorsement_size; pending_endorsements.push_back( @@ -448,22 +457,22 @@ namespace ccf { if ( pending_endorsements.size() > - MAX_SNAPSHOT_ENDORSEMENTS_COUNT - scan.endorsements.size()) + MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_COUNT - scan.endorsements.size()) { throw std::logic_error(fmt::format( "Committed ledger suffix contains too many endorsements " "(maximum {})", - MAX_SNAPSHOT_ENDORSEMENTS_COUNT)); + MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_COUNT)); } if ( pending_endorsements_payload_size > - MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE - + MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE - committed_endorsements_payload_size) { throw std::logic_error(fmt::format( "Committed ledger endorsements payload is too large (maximum {} " "bytes)", - MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE)); + MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE)); } scan.endorsements.insert( diff --git a/src/node/snapshot_serdes.h b/src/node/snapshot_serdes.h index 6c24cdc18104..afe84d8e4c81 100644 --- a/src/node/snapshot_serdes.h +++ b/src/node/snapshot_serdes.h @@ -5,14 +5,10 @@ #include "ccf/crypto/cose.h" #include "ccf/crypto/cose_verifier.h" #include "ccf/crypto/pem.h" -#include "ccf/crypto/verifier.h" #include "ccf/ds/json.h" #include "ccf/historical_queries_adapter.h" -#include "ccf/receipt.h" #include "ccf/service/tables/nodes.h" -#include "crypto/cbor.h" #include "crypto/cose.h" -#include "ds/files.h" #include "ds/internal_logger.h" #include "ds/serialized.h" #include "kv/kv_types.h" @@ -21,9 +17,7 @@ #include "node/history.h" #include "node/rpc/network_identity_chain_helpers.h" #include "node/tx_receipt_impl.h" -#include "snapshots/filenames.h" -#include #include #include #include @@ -47,65 +41,6 @@ namespace ccf std::span receipt; }; - static constexpr size_t MAX_SNAPSHOT_ENDORSEMENTS_SIZE = - size_t{16} * 1024 * 1024; - static constexpr size_t MAX_SNAPSHOT_ENDORSEMENTS_COUNT = 64; - static constexpr size_t MIN_SNAPSHOT_ENDORSEMENT_SIZE = 64; - static constexpr size_t MAX_SNAPSHOT_ENDORSEMENT_SIZE = size_t{1024} * 1024; - static constexpr size_t MAX_SNAPSHOT_ENDORSEMENT_RECORD_SIZE = - 2 * MAX_SNAPSHOT_ENDORSEMENT_SIZE; - static constexpr size_t MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE = - size_t{4} * 1024 * 1024; - - template - static void validate_cose_endorsement_resource_limits( - const Endorsements& endorsements) - { - if ( - endorsements.empty() || - endorsements.size() > MAX_SNAPSHOT_ENDORSEMENTS_COUNT) - { - throw std::logic_error(fmt::format( - "Snapshot endorsements sidecar must contain between 1 and {} " - "endorsements, got {}", - MAX_SNAPSHOT_ENDORSEMENTS_COUNT, - endorsements.size())); - } - - size_t payload_size = 0; - for (size_t i = 0; i < endorsements.size(); ++i) - { - const auto endorsement_size = endorsements[i].size(); - if (endorsement_size < MIN_SNAPSHOT_ENDORSEMENT_SIZE) - { - throw std::logic_error(fmt::format( - "Snapshot endorsement at index {} is too small ({} bytes; minimum " - "{} bytes)", - i, - endorsement_size, - MIN_SNAPSHOT_ENDORSEMENT_SIZE)); - } - if (endorsement_size > MAX_SNAPSHOT_ENDORSEMENT_SIZE) - { - throw std::logic_error(fmt::format( - "Snapshot endorsement at index {} is too large ({} bytes; maximum " - "{} bytes)", - i, - endorsement_size, - MAX_SNAPSHOT_ENDORSEMENT_SIZE)); - } - if ( - endorsement_size > - MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE - payload_size) - { - throw std::logic_error(fmt::format( - "Snapshot endorsements payload is too large (maximum {} bytes)", - MAX_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE)); - } - payload_size += endorsement_size; - } - } - static SnapshotSegments separate_segments( const std::vector& snapshot) { @@ -144,66 +79,13 @@ namespace ccf return SnapshotSegments{header_and_body, receipt}; } - static std::vector serialise_cose_endorsements( - const ccf::SerialisedCoseEndorsements& endorsements) - { - validate_cose_endorsement_resource_limits(endorsements); - - std::vector items; - items.reserve(endorsements.size()); - for (const auto& endorsement : endorsements) - { - items.emplace_back(ccf::cbor::make_bytes(endorsement)); - } - - return ccf::cbor::serialize(ccf::cbor::make_array(std::move(items))); - } - - static ccf::SerialisedCoseEndorsements deserialise_cose_endorsements( - std::span serialised) - { - const auto parsed = ccf::cbor::rethrow_with_msg( - [&]() { - return ccf::cbor::parse( - serialised, - 16, - MAX_SNAPSHOT_ENDORSEMENTS_COUNT, - MAX_SNAPSHOT_ENDORSEMENTS_COUNT + 1); - }, - "Parse snapshot endorsements sidecar"); - if (!std::holds_alternative(parsed->value)) - { - throw std::logic_error( - "Snapshot endorsements sidecar must contain a CBOR array"); - } - - std::vector> endorsement_spans; - endorsement_spans.reserve(parsed->size()); - for (size_t i = 0; i < parsed->size(); ++i) - { - const auto bytes = ccf::cbor::rethrow_with_msg( - [&]() { return parsed->array_at(i)->as_bytes(); }, - fmt::format("Parse snapshot endorsement at index {}", i)); - endorsement_spans.emplace_back(bytes); - } - validate_cose_endorsement_resource_limits(endorsement_spans); - - ccf::SerialisedCoseEndorsements endorsements; - endorsements.reserve(endorsement_spans.size()); - for (const auto endorsement : endorsement_spans) - { - endorsements.emplace_back(endorsement.begin(), endorsement.end()); - } - return endorsements; - } - - template - static std::optional try_prepare_and_install_recovery_snapshot( - Prepare&& prepare, Install&& install) + template + static std::optional try_verify_and_install_recovery_snapshot( + Verify&& verify, Install&& install) { try { - std::forward(prepare)(); + std::forward(verify)(); } catch (const std::exception& e) { @@ -214,90 +96,6 @@ namespace ccf return std::nullopt; } - static ccf::SerialisedCoseEndorsements read_cose_endorsements_sidecar( - const std::filesystem::path& path) - { - std::error_code ec; - const auto file_size = std::filesystem::file_size(path, ec); - if (ec) - { - throw std::logic_error(fmt::format( - "Unable to read snapshot endorsements sidecar size {}: {}", - path.string(), - ec.message())); - } - if (file_size > MAX_SNAPSHOT_ENDORSEMENTS_SIZE) - { - throw std::logic_error(fmt::format( - "Snapshot endorsements sidecar {} is too large ({} bytes, maximum " - "{} bytes)", - path.string(), - file_size, - MAX_SNAPSHOT_ENDORSEMENTS_SIZE)); - } - - std::ifstream file(path, std::ios::binary); - if (!file) - { - throw std::logic_error(fmt::format( - "Unable to open snapshot endorsements sidecar {}", path.string())); - } - - std::vector serialised(static_cast(file_size)); - if (!serialised.empty()) - { - file.read( - reinterpret_cast(serialised.data()), - static_cast(serialised.size())); - if ( - !file || - file.gcount() != static_cast(serialised.size())) - { - throw std::logic_error(fmt::format( - "Unable to read complete snapshot endorsements sidecar {}", - path.string())); - } - } - - return deserialise_cose_endorsements(serialised); - } - - static void write_cose_endorsements_sidecar( - const std::filesystem::path& path, - const ccf::SerialisedCoseEndorsements& endorsements) - { - if (std::filesystem::exists(path)) - { - throw std::logic_error(fmt::format( - "Refusing to overwrite existing snapshot endorsements sidecar {}", - path.string())); - } - - const auto serialised = serialise_cose_endorsements(endorsements); - if (serialised.size() > MAX_SNAPSHOT_ENDORSEMENTS_SIZE) - { - throw std::logic_error(fmt::format( - "Snapshot endorsements sidecar is too large ({} bytes, maximum {} " - "bytes)", - serialised.size(), - MAX_SNAPSHOT_ENDORSEMENTS_SIZE)); - } - auto temporary_path = std::filesystem::path(path.string() + ".tmp"); - std::error_code ec; - std::filesystem::remove(temporary_path, ec); - - try - { - files::dump(serialised, temporary_path); - files::rename(temporary_path, path); - } - catch (const std::exception&) - { - std::filesystem::remove(temporary_path, ec); - throw; - } - } - static ccf::CoseEndorsement parse_serialised_cose_endorsement( const ccf::SerialisedCoseEndorsement& serialised) { @@ -319,7 +117,7 @@ namespace ccf .previous_version = ccf::kv::Version{0}}; } - static std::vector verify_serialised_cose_endorsements( + static std::vector verify_recovery_snapshot_endorsement_chain( const ccf::SerialisedCoseEndorsements& endorsements, std::span target_key, ccf::kv::Version snapshot_seqno, @@ -382,7 +180,7 @@ namespace ccf } static ccf::SerialisedCoseEndorsements - validate_and_serialise_collected_endorsements( + build_recovery_snapshot_endorsement_chain( const std::vector& collected, std::span target_key, ccf::kv::Version snapshot_seqno, @@ -458,8 +256,8 @@ namespace ccf endorsed_key != previous_endorsing_key) { throw std::logic_error(fmt::format( - "Collected endorsement at {} does not endorse the preceding " - "service identity", + "Collected endorsement at {} does not endorse the preceding service " + "identity", write_version)); } previous_endorsing_key = endorsement.endorsing_key; @@ -494,14 +292,14 @@ namespace ccf snapshot_seqno)); } - ccf::SerialisedCoseEndorsements serialised; - serialised.reserve(collected.size()); + ccf::SerialisedCoseEndorsements endorsements; + endorsements.reserve(collected.size()); for (const auto& collected_endorsement : std::ranges::reverse_view(collected)) { - serialised.emplace_back(collected_endorsement.endorsement.endorsement); + endorsements.emplace_back(collected_endorsement.endorsement.endorsement); } - return serialised; + return endorsements; } static auto decode_and_verify_cose_snapshot_receipt( @@ -646,8 +444,7 @@ namespace ccf else { throw std::logic_error(fmt::format( - "Invalid snapshot receipt: unrecognised format (first byte: " - "0x{:02X})", + "Invalid snapshot receipt: unrecognised format (first byte: 0x{:02X})", first_byte)); } } @@ -667,12 +464,12 @@ namespace ccf if (segments.receipt.empty() || segments.receipt[0] != 0xD2) { throw std::logic_error( - "Only snapshots with COSE receipts can use endorsement sidecars"); + "Only snapshots with COSE receipts can use an endorsement chain"); } const auto target_key = ccf::crypto::public_key_der_from_cert( ccf::crypto::cert_pem_to_der(target_identity)); - const auto snapshot_signer_key = verify_serialised_cose_endorsements( + const auto snapshot_signer_key = verify_recovery_snapshot_endorsement_chain( endorsements, target_key, snapshot_seqno, target_service_from); const auto receipt = decode_and_verify_cose_snapshot_receipt(segments); const auto verifier = diff --git a/src/node/test/network_identity_subsystem.cpp b/src/node/test/network_identity_subsystem.cpp index 3c7458b31c08..95c07b6dfcef 100644 --- a/src/node/test/network_identity_subsystem.cpp +++ b/src/node/test/network_identity_subsystem.cpp @@ -487,18 +487,15 @@ TEST_CASE("Recovery snapshot endorsements are validated newest-to-oldest") const auto target_key = cb.current_pkey_der(); const auto target_from = cb.synthesised_current_service_from(); - const auto serialised = ccf::validate_and_serialise_collected_endorsements( + const auto chain = ccf::build_recovery_snapshot_endorsement_chain( collected, target_key, 1, target_from); - REQUIRE(serialised.size() == 2); - REQUIRE(serialised[0] == cb.entries[2].endorsement); - REQUIRE(serialised[1] == cb.entries[1].endorsement); + REQUIRE(chain.size() == 2); + REQUIRE(chain[0] == cb.entries[2].endorsement); + REQUIRE(chain[1] == cb.entries[1].endorsement); - const auto snapshot_signer = ccf::verify_serialised_cose_endorsements( - serialised, target_key, 1, target_from); + const auto snapshot_signer = ccf::verify_recovery_snapshot_endorsement_chain( + chain, target_key, 1, target_from); REQUIRE(snapshot_signer == cb.service_keys.front()->public_key_der()); - - const auto sidecar = ccf::serialise_cose_endorsements(serialised); - REQUIRE(ccf::deserialise_cose_endorsements(sidecar) == serialised); } TEST_CASE("Recovery snapshot endorsements fail closed") @@ -511,32 +508,53 @@ TEST_CASE("Recovery snapshot endorsements fail closed") {cb.write_versions[2], cb.entries[2]}}; const auto target_key = cb.current_pkey_der(); const auto target_from = cb.synthesised_current_service_from(); - const auto serialised = ccf::validate_and_serialise_collected_endorsements( + const auto chain = ccf::build_recovery_snapshot_endorsement_chain( collected, target_key, 1, target_from); - auto out_of_order = serialised; + auto out_of_order = chain; std::reverse(out_of_order.begin(), out_of_order.end()); REQUIRE_THROWS_AS( - ccf::verify_serialised_cose_endorsements( + ccf::verify_recovery_snapshot_endorsement_chain( out_of_order, target_key, 1, target_from), std::logic_error); - auto tampered = serialised; + auto tampered = chain; tampered.front().back() ^= 0xff; REQUIRE_THROWS_AS( - ccf::verify_serialised_cose_endorsements( + ccf::verify_recovery_snapshot_endorsement_chain( tampered, target_key, 1, target_from), std::logic_error); REQUIRE_THROWS_AS( - ccf::verify_serialised_cose_endorsements( - serialised, target_key, 1000, target_from), + ccf::verify_recovery_snapshot_endorsement_chain( + chain, target_key, 1000, target_from), + std::logic_error); + + auto missing_newest = chain; + missing_newest.erase(missing_newest.begin()); + REQUIRE_THROWS_AS( + ccf::verify_recovery_snapshot_endorsement_chain( + missing_newest, target_key, 1, target_from), + std::logic_error); + + auto missing_oldest = chain; + missing_oldest.pop_back(); + REQUIRE_THROWS_AS( + ccf::verify_recovery_snapshot_endorsement_chain( + missing_oldest, target_key, 1, target_from), + std::logic_error); + + const auto wrong_target_key = + ccf::crypto::make_ec_key_pair()->public_key_der(); + REQUIRE_THROWS_AS( + ccf::build_recovery_snapshot_endorsement_chain( + collected, wrong_target_key, 1, target_from), std::logic_error); auto broken_back_pointer = collected; broken_back_pointer.back().endorsement.previous_version = 999; REQUIRE_THROWS_AS( - ccf::validate_and_serialise_collected_endorsements( + ccf::build_recovery_snapshot_endorsement_chain( broken_back_pointer, target_key, 1, target_from), std::logic_error); } diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index 809b13db5381..2b0e6fadd525 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -58,11 +58,21 @@ struct ScopedSnapshotDir } }; -std::vector make_test_endorsement(uint8_t marker) +void write_current_ledger_file( + const fs::path& path, const std::vector>& entries) { - std::vector endorsement(ccf::MIN_SNAPSHOT_ENDORSEMENT_SIZE, marker); - endorsement.front() = 0xd2; - return endorsement; + std::ofstream ledger_file(path, std::ios::binary); + REQUIRE(ledger_file); + const size_t positions_offset = 0; + ledger_file.write( + reinterpret_cast(&positions_offset), sizeof(positions_offset)); + for (const auto& entry : entries) + { + ledger_file.write( + reinterpret_cast(entry.data()), + static_cast(entry.size())); + } + REQUIRE(ledger_file); } TEST_CASE("Recovery snapshot installation failures do not request fallback") @@ -74,15 +84,15 @@ TEST_CASE("Recovery snapshot installation failures do not request fallback") REQUIRE_THROWS_AS( [&]() { - const auto preparation_error = - ccf::try_prepare_and_install_recovery_snapshot( + const auto verification_error = + ccf::try_verify_and_install_recovery_snapshot( []() {}, [&]() { install_started = true; store.set_readiness(ccf::kv::StoreReadiness::Failed); throw std::logic_error("snapshot installation failed"); }); - fallback_requested = preparation_error.has_value(); + fallback_requested = verification_error.has_value(); }(), std::logic_error); REQUIRE(install_started); @@ -90,99 +100,13 @@ TEST_CASE("Recovery snapshot installation failures do not request fallback") REQUIRE_FALSE(fallback_requested); bool install_called = false; - const auto preparation_error = ccf::try_prepare_and_install_recovery_snapshot( + const auto verification_error = ccf::try_verify_and_install_recovery_snapshot( []() { throw std::logic_error("snapshot verification failed"); }, [&]() { install_called = true; }); - REQUIRE(preparation_error.has_value()); + REQUIRE(verification_error.has_value()); REQUIRE_FALSE(install_called); } -TEST_CASE("Snapshot endorsement sidecar file lifecycle") -{ - ScopedSnapshotDir snapshot_dir; - const auto snapshot_path = snapshot_dir.path / "snapshot_42_43.committed"; - const std::vector snapshot_data{1, 2, 3, 4, 5}; - files::dump(snapshot_data, snapshot_path); - - const auto sidecar_path = - snapshots::get_snapshot_endorsements_path(snapshot_path); - REQUIRE(sidecar_path.filename() == "snapshot_42_43.committed.endorsements"); - REQUIRE_FALSE(snapshots::is_snapshot_file_committed(sidecar_path.filename())); - - const ccf::SerialisedCoseEndorsements endorsements{ - make_test_endorsement(0x01), make_test_endorsement(0x02)}; - ccf::write_cose_endorsements_sidecar(sidecar_path, endorsements); - REQUIRE(ccf::read_cose_endorsements_sidecar(sidecar_path) == endorsements); - REQUIRE(files::slurp(snapshot_path) == snapshot_data); - - const auto discovered = - snapshots::find_committed_snapshots_in_directories({snapshot_dir.path}); - REQUIRE(discovered.size() == 1); - REQUIRE(discovered.front().second == snapshot_path); - - REQUIRE_THROWS_AS( - ccf::write_cose_endorsements_sidecar(sidecar_path, endorsements), - std::logic_error); - - const std::vector tampered{0xff}; - files::dump(tampered, sidecar_path); - REQUIRE_THROWS(ccf::read_cose_endorsements_sidecar(sidecar_path)); - REQUIRE(files::slurp(snapshot_path) == snapshot_data); -} - -TEST_CASE("Snapshot endorsement sidecar resource limits") -{ - constexpr size_t pathological_endorsement_count = 1'000'000; - const std::vector too_many{ - 0x9a, - static_cast(pathological_endorsement_count >> 24), - static_cast(pathological_endorsement_count >> 16), - static_cast(pathological_endorsement_count >> 8), - static_cast(pathological_endorsement_count)}; - REQUIRE_THROWS(ccf::deserialise_cose_endorsements(too_many)); - - const std::vector empty_array{0x80}; - const std::vector undersized{0x81, 0x40}; - const std::vector map_instead_of_array{0xa0}; - const std::vector indefinite_array{0x9f, 0xff}; - REQUIRE_THROWS(ccf::deserialise_cose_endorsements(empty_array)); - REQUIRE_THROWS(ccf::deserialise_cose_endorsements(undersized)); - REQUIRE_THROWS(ccf::deserialise_cose_endorsements(map_instead_of_array)); - REQUIRE_THROWS(ccf::deserialise_cose_endorsements(indefinite_array)); - - std::vector nested_items{0x98, 0x40}; - for (size_t i = 0; i < ccf::MAX_SNAPSHOT_ENDORSEMENTS_COUNT; ++i) - { - nested_items.insert(nested_items.end(), {0x98, 0x40}); - nested_items.insert( - nested_items.end(), ccf::MAX_SNAPSHOT_ENDORSEMENTS_COUNT, 0x40); - } - REQUIRE_THROWS(ccf::deserialise_cose_endorsements(nested_items)); - - const std::vector oversized_endorsement{ - 0x81, 0x5a, 0x00, 0x10, 0x00, 0x01}; - REQUIRE_THROWS(ccf::deserialise_cose_endorsements(oversized_endorsement)); - - std::vector oversized_payload{0x85}; - for (size_t i = 0; i < 5; ++i) - { - oversized_payload.insert( - oversized_payload.end(), {0x5a, 0x00, 0x10, 0x00, 0x00}); - oversized_payload.resize( - oversized_payload.size() + ccf::MAX_SNAPSHOT_ENDORSEMENT_SIZE); - } - REQUIRE_THROWS(ccf::deserialise_cose_endorsements(oversized_payload)); - - ccf::SerialisedCoseEndorsements excessive_count( - ccf::MAX_SNAPSHOT_ENDORSEMENTS_COUNT + 1, make_test_endorsement(0x01)); - REQUIRE_THROWS(ccf::serialise_cose_endorsements(excessive_count)); - REQUIRE_THROWS(ccf::serialise_cose_endorsements({})); - REQUIRE_THROWS( - ccf::serialise_cose_endorsements({std::vector{0xd2}})); - REQUIRE_THROWS(ccf::serialise_cose_endorsements( - {std::vector(ccf::MAX_SNAPSHOT_ENDORSEMENT_SIZE + 1, 0xd2)})); -} - TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") { ScopedSnapshotDir ledger_dir; @@ -245,22 +169,7 @@ TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") entries.push_back(std::move(latest_entry)); } - const auto ledger_path = ledger_dir.path / "ledger_1"; - { - std::ofstream ledger_file(ledger_path, std::ios::binary); - REQUIRE(ledger_file); - const size_t positions_offset = 0; - ledger_file.write( - reinterpret_cast(&positions_offset), - sizeof(positions_offset)); - for (const auto& entry : entries) - { - ledger_file.write( - reinterpret_cast(entry.data()), - static_cast(entry.size())); - } - REQUIRE(ledger_file); - } + write_current_ledger_file(ledger_dir.path / "ledger_1", entries); ccf::CCFConfig::Ledger ledger_config; ledger_config.directory = ledger_dir.path.string(); @@ -285,7 +194,7 @@ TEST_CASE("Recovery snapshot endorsement scan bounds pending endorsements") source_store.initialise_term(2); std::vector> entries; - for (size_t i = 0; i < ccf::MAX_SNAPSHOT_ENDORSEMENTS_COUNT + 1; ++i) + for (size_t i = 0; i < ccf::MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_COUNT + 1; ++i) { ccf::CoseEndorsement endorsement; endorsement.endorsement = {0xd2, 0x01}; @@ -305,22 +214,7 @@ TEST_CASE("Recovery snapshot endorsement scan bounds pending endorsements") entries.push_back(std::move(latest_entry)); } - const auto ledger_path = ledger_dir.path / "ledger_1"; - { - std::ofstream ledger_file(ledger_path, std::ios::binary); - REQUIRE(ledger_file); - const size_t positions_offset = 0; - ledger_file.write( - reinterpret_cast(&positions_offset), - sizeof(positions_offset)); - for (const auto& entry : entries) - { - ledger_file.write( - reinterpret_cast(entry.data()), - static_cast(entry.size())); - } - REQUIRE(ledger_file); - } + write_current_ledger_file(ledger_dir.path / "ledger_1", entries); ccf::CCFConfig::Ledger ledger_config; ledger_config.directory = ledger_dir.path.string(); diff --git a/src/snapshots/filenames.h b/src/snapshots/filenames.h index 910d696e1ffa..aa7ac55ac7f8 100644 --- a/src/snapshots/filenames.h +++ b/src/snapshots/filenames.h @@ -19,7 +19,6 @@ namespace snapshots static constexpr auto snapshot_idx_delimiter = "_"; static constexpr auto snapshot_committed_suffix = ".committed"; static constexpr auto snapshot_ignored_file_suffix = "ignored"; - static constexpr auto snapshot_endorsements_suffix = ".endorsements"; static bool is_snapshot_file(const std::string& file_name) { @@ -36,20 +35,6 @@ namespace snapshots return file_name.ends_with(snapshot_ignored_file_suffix); } - static fs::path get_snapshot_endorsements_path(const fs::path& snapshot_path) - { - const auto file_name = snapshot_path.filename().string(); - if (!is_snapshot_file(file_name) || !is_snapshot_file_committed(file_name)) - { - throw std::logic_error(fmt::format( - "Cannot derive endorsements sidecar name from non-committed snapshot " - "{}", - snapshot_path.string())); - } - - return {snapshot_path.string() + snapshot_endorsements_suffix}; - } - static void ignore_snapshot_file( const fs::path& dir, const std::string& file_name) { diff --git a/tests/recovery_snapshot_endorsements.py b/tests/recovery_snapshot_endorsements.py index 10f1a8b85d23..d12613edaec3 100644 --- a/tests/recovery_snapshot_endorsements.py +++ b/tests/recovery_snapshot_endorsements.py @@ -6,7 +6,6 @@ import os import shutil -import ccf.cose import ccf.ledger import infra.e2e_args import infra.logging_app as app @@ -94,28 +93,22 @@ def _start_recovery_attempt( return attempt -def _copy_recovery_snapshot_cache(network, destination): - primary, _ = network.find_primary() - copied = network.get_committed_snapshots(primary, force_txs=False) +def _copy_ledger_prefix(source_dirs, destination, first_excluded_seqno): shutil.rmtree(destination, ignore_errors=True) - shutil.copytree(copied, destination) - return destination - - -def _verify_snapshot_cache( - snapshots_dir, snapshot_name, snapshot_digest, target_identity -): - snapshot_path = os.path.join(snapshots_dir, snapshot_name) - sidecar_path = ccf.ledger.snapshot_endorsements_path(snapshot_path) - assert os.path.isfile(sidecar_path), sidecar_path - with open(snapshot_path, "rb") as snapshot_file: - assert hashlib.sha256(snapshot_file.read()).digest() == snapshot_digest - - endorsements = ccf.ledger.read_snapshot_endorsements(sidecar_path) - assert len(endorsements) == 2, len(endorsements) - with ccf.ledger.Snapshot(snapshot_path) as snapshot: - snapshot.verify_cose_receipt(target_identity, endorsements) - return sidecar_path + os.makedirs(destination) + copied = 0 + for source_dir in source_dirs: + for name in os.listdir(source_dir): + if not ccf.ledger.is_ledger_chunk_committed(name): + continue + _, end_seqno = ccf.ledger.range_from_filename(name) + if end_seqno is None or end_seqno >= first_excluded_seqno: + continue + destination_path = os.path.join(destination, name) + if not os.path.exists(destination_path): + shutil.copy(os.path.join(source_dir, name), destination_path) + copied += 1 + assert copied > 0 def run_recovery_snapshot_endorsements(args): @@ -153,15 +146,15 @@ def run_recovery_snapshot_endorsements(args): assert snapshots snapshot_name = snapshots[-1] original_snapshot_path = os.path.join(committed_snapshots_dir, snapshot_name) - with open(original_snapshot_path, "rb") as snapshot_file: - snapshot_digest = hashlib.sha256(snapshot_file.read()).digest() source_snapshots_dir = os.path.join( initial_network.common_dir, "recovery_snapshot_endorsements_source" ) shutil.rmtree(source_snapshots_dir, ignore_errors=True) os.makedirs(source_snapshots_dir) - shutil.copy(original_snapshot_path, source_snapshots_dir) + source_snapshot_path = shutil.copy(original_snapshot_path, source_snapshots_dir) + with open(source_snapshot_path, "rb") as snapshot_file: + snapshot_digest = hashlib.sha256(snapshot_file.read()).digest() first_recovery, first_args = _recover_and_open( initial_network, args, f"{args.label}_identity_1" @@ -172,19 +165,19 @@ def run_recovery_snapshot_endorsements(args): second_recovery.save_service_identity(second_args) target_identity_file = second_args.previous_service_identity_file - with open(target_identity_file, "rb") as target_file: - target_identity = target_file.read() primary, _ = second_recovery.find_primary() + with primary.client() as client: + service_create_txid = client.get("/node/network").body.json()[ + "current_service_create_txid" + ] + service_create_seqno = int(service_create_txid.split(".")[1]) second_recovery.stop_all_nodes() current_ledger_dir, committed_ledger_dirs = primary.get_ledger() - cache_dir = os.path.join( - second_recovery.common_dir, "recovery_snapshot_endorsements_cache" - ) - first_attempt = _start_recovery_attempt( + valid_attempt = _start_recovery_attempt( second_recovery, second_args, - f"{args.label}_derive_sidecar", + f"{args.label}_in_memory_chain", current_ledger_dir, committed_ledger_dirs, source_snapshots_dir, @@ -192,105 +185,60 @@ def run_recovery_snapshot_endorsements(args): 100, ) try: - first_primary, _ = first_attempt.find_primary() - logs = _logs(first_primary) + valid_primary, _ = valid_attempt.find_primary() + logs = _logs(valid_primary) scan_log = "scanning the public ledger suffix for COSE endorsements" - persisted_log = "Persisted 2 verified recovery snapshot endorsement(s)" + validated_log = "Validated 2 recovery snapshot endorsement(s) in memory" snapshot_body_log = "Deserialising snapshot (size:" public_recovery_log = "Starting to read public ledger" assert ( logs.index(scan_log) - < logs.index(persisted_log) + < logs.index(validated_log) < logs.index(snapshot_body_log) < logs.index(public_recovery_log) ) - _copy_recovery_snapshot_cache(first_attempt, cache_dir) - sidecar_path = _verify_snapshot_cache( - cache_dir, snapshot_name, snapshot_digest, target_identity - ) + with open(source_snapshot_path, "rb") as snapshot_file: + assert hashlib.sha256(snapshot_file.read()).digest() == snapshot_digest finally: - _stop_incomplete_recovery(first_attempt) + _stop_incomplete_recovery(valid_attempt) - reuse_attempt = _start_recovery_attempt( - second_recovery, - second_args, - f"{args.label}_reuse_sidecar", - current_ledger_dir, - committed_ledger_dirs, - cache_dir, - target_identity_file, - 101, + incomplete_ledger_dir = os.path.join( + second_recovery.common_dir, "recovery_snapshot_incomplete_ledger" ) - try: - reuse_primary, _ = reuse_attempt.find_primary() - assert "Reusing verified snapshot endorsements sidecar" in _logs( - reuse_primary - ) - finally: - _stop_incomplete_recovery(reuse_attempt) - - with open(sidecar_path, "wb") as sidecar_file: - sidecar_file.write( - bytes([0x98, ccf.cose.MAX_SNAPSHOT_ENDORSEMENTS_COUNT + 1]) - ) - - tamper_attempt = _start_recovery_attempt( - second_recovery, - second_args, - f"{args.label}_replace_tampered_sidecar", - current_ledger_dir, - committed_ledger_dirs, - cache_dir, - target_identity_file, - 102, + shutil.rmtree(incomplete_ledger_dir, ignore_errors=True) + os.makedirs(incomplete_ledger_dir) + incomplete_committed_ledger_dir = os.path.join( + second_recovery.common_dir, + "recovery_snapshot_incomplete_committed_ledger", ) - try: - tamper_primary, _ = tamper_attempt.find_primary() - logs = _logs(tamper_primary) - assert "Snapshot endorsements sidecar" in logs - assert "is invalid" in logs - assert "Persisted 2 verified recovery snapshot endorsement(s)" in logs - _copy_recovery_snapshot_cache(tamper_attempt, cache_dir) - _verify_snapshot_cache( - cache_dir, snapshot_name, snapshot_digest, target_identity - ) - finally: - _stop_incomplete_recovery(tamper_attempt) - - fallback_dir = os.path.join( - second_recovery.common_dir, "recovery_snapshot_endorsements_fallback" + _copy_ledger_prefix( + [current_ledger_dir, *committed_ledger_dirs], + incomplete_committed_ledger_dir, + service_create_seqno, ) - shutil.rmtree(fallback_dir, ignore_errors=True) - os.makedirs(fallback_dir) - shutil.copy(os.path.join(cache_dir, snapshot_name), fallback_dir) - wrong_identity_file = second_recovery.consortium.user_cert_path("user0") - fallback_attempt = _start_recovery_attempt( second_recovery, second_args, - f"{args.label}_fallback", - current_ledger_dir, - committed_ledger_dirs, - fallback_dir, - wrong_identity_file, - 103, + f"{args.label}_incomplete_suffix", + incomplete_ledger_dir, + [incomplete_committed_ledger_dir], + source_snapshots_dir, + target_identity_file, + 101, ) try: fallback_primary, _ = fallback_attempt.find_primary() logs = _logs(fallback_primary) assert "Falling back to full-ledger recovery" in logs assert "Setting startup snapshot seqno" not in logs - assert not os.path.exists( - ccf.ledger.snapshot_endorsements_path( - os.path.join(fallback_dir, snapshot_name) - ) - ) + with open(source_snapshot_path, "rb") as snapshot_file: + assert hashlib.sha256(snapshot_file.read()).digest() == snapshot_digest finally: _stop_incomplete_recovery(fallback_attempt) LOG.success( - "Recovery snapshot sidecar derivation, verification, reuse, " - "tamper replacement, and fallback all succeeded" + "In-memory recovery snapshot endorsement validation and fallback " + "succeeded" ) @@ -298,7 +246,7 @@ def run_recovery_snapshot_endorsements(args): def add(parser): parser.description = ( - "Verify recovery snapshot COSE endorsement sidecars across multiple " + "Verify in-memory recovery snapshot endorsement chains across multiple " "disaster recoveries." ) From e7745b89c6583e00f8389cb683aad82c85c6ac14 Mon Sep 17 00:00:00 2001 From: achamayou Date: Thu, 23 Jul 2026 21:19:34 +0100 Subject: [PATCH 13/27] Authenticate recovery scan evidence Bind snapshot filenames to the authenticated body version, verify endorsement-bearing ledger entries against a COSE-signed Merkle boundary, cap raw entry allocation, and check node-local snapshot bytes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 Copilot-Session: 4d631113-a6ff-453d-a658-3b46aaaec95a Copilot-Session: 972cd758-c6d2-4d1b-b043-b482e129cb54 --- src/node/node_state.h | 12 ++ src/node/recovery_snapshot_ledger.h | 259 ++++++++++++++++++------ src/node/snapshot_serdes.h | 28 +++ src/node/test/snapshotter.cpp | 69 ++++++- tests/recovery_snapshot_endorsements.py | 20 +- 5 files changed, 314 insertions(+), 74 deletions(-) diff --git a/src/node/node_state.h b/src/node/node_state.h index 81487c2dce35..33d732a35d40 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -719,6 +719,18 @@ namespace ccf const auto segments = separate_segments(startup_snapshot_info->raw); const ccf::crypto::Pem target_identity( *config.recover.previous_service_identity); + try + { + verify_snapshot_seqno( + segments, + network.tables->get_encryptor(), + startup_snapshot_info->seqno); + } + catch (const std::exception& e) + { + fallback_from_recovery_snapshot_unsafe(e.what()); + return; + } const auto direct_verification_error = try_verify_and_install_recovery_snapshot( diff --git a/src/node/recovery_snapshot_ledger.h b/src/node/recovery_snapshot_ledger.h index 71d730bed183..c771fe35aa48 100644 --- a/src/node/recovery_snapshot_ledger.h +++ b/src/node/recovery_snapshot_ledger.h @@ -8,6 +8,9 @@ #include "host/ledger_filenames.h" #include "kv/kv_serialiser.h" #include "kv/serialised_entry_format.h" +#include "node/cose_common.h" +#include "node/history.h" +#include "node/rpc/claims.h" #include "node/rpc/network_identity_chain_helpers.h" #include "service/tables/previous_service_identity.h" #include "service/tables/signatures.h" @@ -28,13 +31,19 @@ namespace ccf 2 * MAX_RECOVERY_SNAPSHOT_ENDORSEMENT_SIZE; static constexpr size_t MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE = size_t{4} * 1024 * 1024; + static constexpr size_t MAX_RECOVERY_SNAPSHOT_LEDGER_ENTRY_SIZE = + size_t{64} * 1024 * 1024; struct RecoverySnapshotLedgerEntry { ccf::kv::Version version = 0; + ccf::kv::Term term = 0; bool is_signature = false; std::optional endorsement = std::nullopt; std::optional service_info = std::nullopt; + std::optional cose_signature = std::nullopt; + std::optional> serialised_tree = std::nullopt; + ccf::crypto::Sha256Hash leaf; }; struct RecoverySnapshotLedgerScan @@ -45,6 +54,65 @@ namespace ccf latest_service_info = std::nullopt; }; + static void parse_recovery_snapshot_ledger_write( + RecoverySnapshotLedgerEntry& result, + const std::string& map_name, + const ccf::kv::SerialisedKey& key, + const ccf::kv::SerialisedValue& value) + { + if (map_name == ccf::Tables::PREVIOUS_SERVICE_IDENTITY_ENDORSEMENT) + { + if ( + result.endorsement.has_value() || + key != ccf::PreviousServiceIdentityEndorsement::create_unit()) + { + throw std::logic_error( + "Invalid previous service identity endorsement table write"); + } + if (value.size() > MAX_RECOVERY_SNAPSHOT_ENDORSEMENT_RECORD_SIZE) + { + throw std::logic_error(fmt::format( + "Serialised previous service identity endorsement is too large " + "({} bytes; maximum {} bytes)", + value.size(), + MAX_RECOVERY_SNAPSHOT_ENDORSEMENT_RECORD_SIZE)); + } + result.endorsement = ccf::PreviousServiceIdentityEndorsement:: + ValueSerialiser::from_serialised(value); + } + else if (map_name == ccf::Tables::SERVICE) + { + if (result.service_info.has_value() || key != ccf::Service::create_unit()) + { + throw std::logic_error("Invalid service info table write"); + } + result.service_info = + ccf::Service::ValueSerialiser::from_serialised(value); + } + else if (map_name == ccf::Tables::COSE_SIGNATURES) + { + if ( + result.cose_signature.has_value() || + key != ccf::CoseSignatures::create_unit()) + { + throw std::logic_error("Invalid COSE signature table write"); + } + result.cose_signature = + ccf::CoseSignatures::ValueSerialiser::from_serialised(value); + } + else if (map_name == ccf::Tables::SERIALISED_MERKLE_TREE) + { + if ( + result.serialised_tree.has_value() || + key != ccf::SerialisedMerkleTree::create_unit()) + { + throw std::logic_error("Invalid serialised Merkle tree table write"); + } + result.serialised_tree = + ccf::SerialisedMerkleTree::ValueSerialiser::from_serialised(value); + } + } + static RecoverySnapshotLedgerEntry parse_recovery_snapshot_ledger_entry( const std::vector& entry, const std::shared_ptr& encryptor) @@ -63,6 +131,7 @@ namespace ccf RecoverySnapshotLedgerEntry result; result.version = *version; + result.term = term; size_t map_count = 0; bool has_signature = false; bool has_cose_signature = false; @@ -93,37 +162,7 @@ namespace ccf for (size_t i = 0; i < write_count; ++i) { auto [key, value] = deserialiser.deserialise_write(); - if (*map_name == ccf::Tables::PREVIOUS_SERVICE_IDENTITY_ENDORSEMENT) - { - if ( - result.endorsement.has_value() || - key != ccf::PreviousServiceIdentityEndorsement::create_unit()) - { - throw std::logic_error( - "Invalid previous service identity endorsement table write"); - } - if (value.size() > MAX_RECOVERY_SNAPSHOT_ENDORSEMENT_RECORD_SIZE) - { - throw std::logic_error(fmt::format( - "Serialised previous service identity endorsement is too large " - "({} bytes; maximum {} bytes)", - value.size(), - MAX_RECOVERY_SNAPSHOT_ENDORSEMENT_RECORD_SIZE)); - } - result.endorsement = ccf::PreviousServiceIdentityEndorsement:: - ValueSerialiser::from_serialised(value); - } - else if (*map_name == ccf::Tables::SERVICE) - { - if ( - result.service_info.has_value() || - key != ccf::Service::create_unit()) - { - throw std::logic_error("Invalid service info table write"); - } - result.service_info = - ccf::Service::ValueSerialiser::from_serialised(value); - } + parse_recovery_snapshot_ledger_write(result, *map_name, key, value); } const auto remove_count = deserialiser.deserialise_remove_header(); @@ -150,9 +189,86 @@ namespace ccf result.is_signature = has_serialised_tree && ((map_count == 2 && has_signature != has_cose_signature) || (map_count == 3 && has_signature && has_cose_signature)); + const auto claims_digest = deserialiser.consume_claims_digest(); + const auto commit_evidence_digest = + deserialiser.consume_commit_evidence_digest(); + result.leaf = ccf::entry_leaf(entry, commit_evidence_digest, claims_digest); return result; } + static void verify_recovery_snapshot_signature_boundary( + const RecoverySnapshotLedgerEntry& signature, + std::span signing_key, + const std::vector>& + endorsement_leaves, + const std::optional>& + service_info_leaf) + { + if ( + !signature.is_signature || !signature.cose_signature.has_value() || + !signature.serialised_tree.has_value()) + { + throw std::logic_error(fmt::format( + "Ledger signature boundary at {} has no COSE signature and Merkle tree", + signature.version)); + } + if (signature.version == 0) + { + throw std::logic_error("Ledger signature boundary cannot be at seqno 0"); + } + + const auto receipt = + ccf::cose::decode_ccf_receipt(*signature.cose_signature, false); + const auto receipt_txid = ccf::TxID::from_str(receipt.phdr.ccf.txid); + if ( + !receipt_txid.has_value() || receipt_txid->seqno != signature.version || + (signature.term != 0 && receipt_txid->view != signature.term)) + { + throw std::logic_error(fmt::format( + "Ledger signature boundary at {}.{} has unexpected COSE TxID {}", + signature.term, + signature.version, + receipt.phdr.ccf.txid)); + } + + ccf::MerkleTreeHistory tree(*signature.serialised_tree); + if (tree.end_index() != signature.version - 1) + { + throw std::logic_error(fmt::format( + "Ledger signature boundary at {} contains a Merkle tree ending at {}", + signature.version, + tree.end_index())); + } + + const auto verifier = ccf::crypto::make_cose_verifier_from_key(signing_key); + const auto root = tree.get_root(); + if (!verifier->verify_detached(*signature.cose_signature, root.h)) + { + throw std::logic_error(fmt::format( + "Ledger signature boundary at {} failed COSE signature verification", + signature.version)); + } + + const auto verify_leaf = [&](const auto& version_and_leaf) { + const auto& [version, expected_leaf] = version_and_leaf; + if (!tree.in_range(version) || tree.get_leaf(version) != expected_leaf) + { + throw std::logic_error(fmt::format( + "Ledger signature boundary at {} does not authenticate entry {}", + signature.version, + version)); + } + }; + for (const auto& endorsement_leaf : endorsement_leaves) + { + verify_leaf(endorsement_leaf); + } + if (service_info_leaf.has_value()) + { + verify_leaf(*service_info_leaf); + } + } + struct RecoverySnapshotLedgerFile { std::filesystem::path path; @@ -270,10 +386,14 @@ namespace ccf scan.last_signed_idx = snapshot_seqno; auto expected_seqno = snapshot_seqno + 1; std::vector pending_endorsements; + std::vector> + pending_endorsement_leaves; size_t committed_endorsements_payload_size = 0; size_t pending_endorsements_payload_size = 0; std::optional> latest_observed_service_info = std::nullopt; + std::optional> + pending_service_info_leaf = std::nullopt; for (const auto& ledger_file : find_recovery_snapshot_ledger_files(ledger_config)) @@ -367,6 +487,13 @@ namespace ccf "Committed ledger file {} contains a truncated entry", ledger_file.path.string())); } + if (header.size > MAX_RECOVERY_SNAPSHOT_LEDGER_ENTRY_SIZE) + { + throw std::logic_error(fmt::format( + "Ledger entry is too large ({} bytes; maximum {} bytes)", + static_cast(header.size), + MAX_RECOVERY_SNAPSHOT_LEDGER_ENTRY_SIZE)); + } std::vector entry(sizeof(header) + header.size); std::memcpy(entry.data(), &header, sizeof(header)); @@ -447,44 +574,58 @@ namespace ccf pending_endorsements_payload_size += endorsement_size; pending_endorsements.push_back( {parsed.version, std::move(*parsed.endorsement)}); + pending_endorsement_leaves.emplace_back(parsed.version, parsed.leaf); } if (parsed.service_info.has_value()) { latest_observed_service_info.emplace( parsed.version, std::move(*parsed.service_info)); + pending_service_info_leaf.emplace(parsed.version, parsed.leaf); } if (parsed.is_signature) { - if ( - pending_endorsements.size() > - MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_COUNT - scan.endorsements.size()) + if (!pending_endorsements.empty()) { - throw std::logic_error(fmt::format( - "Committed ledger suffix contains too many endorsements " - "(maximum {})", - MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_COUNT)); + verify_recovery_snapshot_signature_boundary( + parsed, + pending_endorsements.back().endorsement.endorsing_key, + pending_endorsement_leaves, + pending_service_info_leaf); + + if ( + pending_endorsements.size() > + MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_COUNT - + scan.endorsements.size()) + { + throw std::logic_error(fmt::format( + "Committed ledger suffix contains too many endorsements " + "(maximum {})", + MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_COUNT)); + } + if ( + pending_endorsements_payload_size > + MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE - + committed_endorsements_payload_size) + { + throw std::logic_error(fmt::format( + "Committed ledger endorsements payload is too large (maximum " + "{} bytes)", + MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE)); + } + + scan.endorsements.insert( + scan.endorsements.end(), + std::make_move_iterator(pending_endorsements.begin()), + std::make_move_iterator(pending_endorsements.end())); + pending_endorsements.clear(); + pending_endorsement_leaves.clear(); + committed_endorsements_payload_size += + pending_endorsements_payload_size; + pending_endorsements_payload_size = 0; + scan.latest_service_info = latest_observed_service_info; + pending_service_info_leaf.reset(); + scan.last_signed_idx = parsed.version; } - if ( - pending_endorsements_payload_size > - MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE - - committed_endorsements_payload_size) - { - throw std::logic_error(fmt::format( - "Committed ledger endorsements payload is too large (maximum {} " - "bytes)", - MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE)); - } - - scan.endorsements.insert( - scan.endorsements.end(), - std::make_move_iterator(pending_endorsements.begin()), - std::make_move_iterator(pending_endorsements.end())); - pending_endorsements.clear(); - committed_endorsements_payload_size += - pending_endorsements_payload_size; - pending_endorsements_payload_size = 0; - scan.latest_service_info = latest_observed_service_info; - scan.last_signed_idx = parsed.version; } if (expected_seqno == std::numeric_limits::max()) diff --git a/src/node/snapshot_serdes.h b/src/node/snapshot_serdes.h index afe84d8e4c81..cbd0173825eb 100644 --- a/src/node/snapshot_serdes.h +++ b/src/node/snapshot_serdes.h @@ -79,6 +79,34 @@ namespace ccf return SnapshotSegments{header_and_body, receipt}; } + static void verify_snapshot_seqno( + const SnapshotSegments& segments, + const std::shared_ptr& encryptor, + ccf::kv::Version expected_seqno) + { + auto deserialiser = ccf::kv::RawKvStoreDeserialiser( + encryptor, ccf::kv::SecurityDomain::PUBLIC); + ccf::kv::Term term = 0; + ccf::kv::EntryFlags flags = {}; + const auto snapshot_seqno = deserialiser.init( + segments.header_and_body.data(), + segments.header_and_body.size(), + term, + flags, + false); + if (!snapshot_seqno.has_value()) + { + throw std::logic_error("Failed to read version from recovery snapshot"); + } + if (*snapshot_seqno != expected_seqno) + { + throw std::logic_error(fmt::format( + "Recovery snapshot body is at seqno {}, but its file name claims {}", + *snapshot_seqno, + expected_seqno)); + } + } + template static std::optional try_verify_and_install_recovery_snapshot( Verify&& verify, Install&& install) diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index 2b0e6fadd525..67c076b8e0fe 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -110,11 +110,26 @@ TEST_CASE("Recovery snapshot installation failures do not request fallback") TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") { ScopedSnapshotDir ledger_dir; + ccf::NetworkIdentity target_identity( + "CN=Recovery snapshot ledger scan", + ccf::crypto::CurveID::SECP384R1, + "20240101000000Z", + 365); ccf::kv::Store source_store; auto encryptor = std::make_shared(); auto consensus = std::make_shared(); + const auto signing_node_kp = ccf::crypto::make_ec_key_pair(); + auto history = std::make_shared( + source_store, ccf::kv::test::PrimaryNodeId, *signing_node_kp); + history->set_endorsed_certificate(signing_node_kp->self_sign( + "CN=Recovery snapshot ledger signing node", + "20240101000000Z", + "20250101000000Z")); + history->set_service_signing_identity( + target_identity.get_key_pair(), ccf::COSESignaturesConfig{}); source_store.set_encryptor(encryptor); source_store.set_consensus(consensus); + source_store.set_history(history); source_store.initialise_term(2); std::vector> entries; @@ -128,11 +143,6 @@ TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") entries.push_back(std::move(latest_entry)); } - ccf::NetworkIdentity target_identity( - "CN=Recovery snapshot ledger scan", - ccf::crypto::CurveID::SECP384R1, - "20240101000000Z", - 365); { ccf::ServiceInfo service; service.cert = target_identity.cert; @@ -141,7 +151,8 @@ TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") ccf::CoseEndorsement endorsement; endorsement.endorsement = {0xd2, 0x01}; - endorsement.endorsing_key = {0x02, 0x03}; + endorsement.endorsing_key = + target_identity.get_key_pair()->public_key_der(); endorsement.endorsement_epoch_begin = {2, 1}; endorsement.endorsement_epoch_end = ccf::TxID{4, 1}; endorsement.previous_version = 1; @@ -158,17 +169,18 @@ TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") entries.push_back(std::move(latest_entry)); } { - auto tx = source_store.create_tx(); - tx.rw(ccf::Tables::COSE_SIGNATURES)->put({0xd2, 0x02}); - tx.rw(ccf::Tables::SERIALISED_MERKLE_TREE) - ->put({0x01}); - REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); + history->emit_signature(); auto latest_entry = consensus->get_latest_data().value_or(std::vector{}); REQUIRE_FALSE(latest_entry.empty()); entries.push_back(std::move(latest_entry)); } + const ccf::SnapshotSegments first_entry{ + std::span(entries.front()), {}}; + REQUIRE_NOTHROW(ccf::verify_snapshot_seqno(first_entry, encryptor, 1)); + REQUIRE_THROWS(ccf::verify_snapshot_seqno(first_entry, encryptor, 2)); + write_current_ledger_file(ledger_dir.path / "ledger_1", entries); ccf::CCFConfig::Ledger ledger_config; @@ -181,6 +193,15 @@ TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") REQUIRE(scan.latest_service_info.has_value()); REQUIRE(scan.latest_service_info->first == 2); REQUIRE(scan.latest_service_info->second.cert == target_identity.cert); + + ScopedSnapshotDir tampered_ledger_dir; + auto tampered_entries = entries; + tampered_entries.back().back() ^= 0xff; + write_current_ledger_file( + tampered_ledger_dir.path / "ledger_1", tampered_entries); + ledger_config.directory = tampered_ledger_dir.path.string(); + REQUIRE_THROWS( + ccf::scan_recovery_snapshot_ledger_files(ledger_config, encryptor, 1)); } TEST_CASE("Recovery snapshot endorsement scan bounds pending endorsements") @@ -222,6 +243,32 @@ TEST_CASE("Recovery snapshot endorsement scan bounds pending endorsements") ccf::scan_recovery_snapshot_ledger_files(ledger_config, encryptor, 0)); } +TEST_CASE("Recovery snapshot endorsement scan bounds ledger entry allocation") +{ + ScopedSnapshotDir ledger_dir; + const auto ledger_path = ledger_dir.path / "ledger_1"; + { + std::ofstream ledger_file(ledger_path, std::ios::binary); + REQUIRE(ledger_file); + const size_t positions_offset = 0; + ledger_file.write( + reinterpret_cast(&positions_offset), + sizeof(positions_offset)); + ccf::kv::SerialisedEntryHeader header{}; + header.size = ccf::MAX_RECOVERY_SNAPSHOT_LEDGER_ENTRY_SIZE + 1; + ledger_file.write(reinterpret_cast(&header), sizeof(header)); + ledger_file.seekp( + static_cast(header.size) - 1, std::ios::cur); + ledger_file.put(0); + REQUIRE(ledger_file); + } + + ccf::CCFConfig::Ledger ledger_config; + ledger_config.directory = ledger_dir.path.string(); + REQUIRE_THROWS(ccf::scan_recovery_snapshot_ledger_files( + ledger_config, std::make_shared(), 0)); +} + std::optional latest_committed_snapshot_path(const fs::path& dir) { return snapshots::find_latest_committed_snapshot_in_directory(dir); diff --git a/tests/recovery_snapshot_endorsements.py b/tests/recovery_snapshot_endorsements.py index d12613edaec3..3a10a1ab1699 100644 --- a/tests/recovery_snapshot_endorsements.py +++ b/tests/recovery_snapshot_endorsements.py @@ -111,6 +111,16 @@ def _copy_ledger_prefix(source_dirs, destination, first_excluded_seqno): assert copied > 0 +def _assert_node_snapshot_unchanged( + network, node, snapshot_name, expected_snapshot_digest +): + snapshots_dir = network.get_committed_snapshots(node, force_txs=False) + snapshot_path = os.path.join(snapshots_dir, snapshot_name) + assert os.path.isfile(snapshot_path), snapshot_path + with open(snapshot_path, "rb") as snapshot_file: + assert hashlib.sha256(snapshot_file.read()).digest() == expected_snapshot_digest + + def run_recovery_snapshot_endorsements(args): with infra.network.network( args.nodes, @@ -197,8 +207,9 @@ def run_recovery_snapshot_endorsements(args): < logs.index(snapshot_body_log) < logs.index(public_recovery_log) ) - with open(source_snapshot_path, "rb") as snapshot_file: - assert hashlib.sha256(snapshot_file.read()).digest() == snapshot_digest + _assert_node_snapshot_unchanged( + valid_attempt, valid_primary, snapshot_name, snapshot_digest + ) finally: _stop_incomplete_recovery(valid_attempt) @@ -231,8 +242,9 @@ def run_recovery_snapshot_endorsements(args): logs = _logs(fallback_primary) assert "Falling back to full-ledger recovery" in logs assert "Setting startup snapshot seqno" not in logs - with open(source_snapshot_path, "rb") as snapshot_file: - assert hashlib.sha256(snapshot_file.read()).digest() == snapshot_digest + _assert_node_snapshot_unchanged( + fallback_attempt, fallback_primary, snapshot_name, snapshot_digest + ) finally: _stop_incomplete_recovery(fallback_attempt) From f263eaa91fd251ddfa166c13d5afa7f0a2ab62c8 Mon Sep 17 00:00:00 2001 From: achamayou Date: Thu, 23 Jul 2026 21:51:15 +0100 Subject: [PATCH 14/27] Bound recovery Merkle tree decoding Validate serialized tree counts, ranges, arithmetic, and exact length before MerkleCPP allocation or parsing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 Copilot-Session: 4d631113-a6ff-453d-a658-3b46aaaec95a Copilot-Session: 972cd758-c6d2-4d1b-b043-b482e129cb54 --- src/node/recovery_snapshot_ledger.h | 62 +++++++++++++++++++++++++++++ src/node/test/snapshotter.cpp | 16 ++++++++ 2 files changed, 78 insertions(+) diff --git a/src/node/recovery_snapshot_ledger.h b/src/node/recovery_snapshot_ledger.h index c771fe35aa48..742470e14261 100644 --- a/src/node/recovery_snapshot_ledger.h +++ b/src/node/recovery_snapshot_ledger.h @@ -16,6 +16,7 @@ #include "service/tables/signatures.h" #include +#include #include #include #include @@ -196,6 +197,65 @@ namespace ccf return result; } + static void validate_recovery_snapshot_merkle_tree_encoding( + std::span serialised_tree, + ccf::kv::Version signature_version) + { + static constexpr size_t header_size = 2 * sizeof(uint64_t); + static constexpr uint64_t max_leaf_nodes = 1'000'000; + if (serialised_tree.size() < header_size) + { + throw std::logic_error("Serialised Merkle tree header is truncated"); + } + + const auto read_uint64 = [&](size_t offset) { + uint64_t value = 0; + for (size_t i = 0; i < sizeof(uint64_t); ++i) + { + value = (value << 8) | serialised_tree[offset + i]; + } + return value; + }; + const auto leaf_nodes = read_uint64(0); + const auto flushed_leaves = read_uint64(sizeof(uint64_t)); + if (leaf_nodes > max_leaf_nodes) + { + throw std::logic_error(fmt::format( + "Serialised Merkle tree contains too many leaf nodes ({}; maximum {})", + leaf_nodes, + max_leaf_nodes)); + } + if ( + flushed_leaves > signature_version || + leaf_nodes != signature_version - flushed_leaves) + { + throw std::logic_error(fmt::format( + "Serialised Merkle tree leaf range {} + {} does not end before " + "signature {}", + flushed_leaves, + leaf_nodes, + signature_version)); + } + + const auto extra_hashes = std::popcount(flushed_leaves); + const auto hash_count = leaf_nodes + extra_hashes; + const auto max_hashes = (std::numeric_limits::max() - header_size) / + ccf::crypto::Sha256Hash::SIZE; + if (hash_count > max_hashes) + { + throw std::logic_error("Serialised Merkle tree size overflows"); + } + const auto expected_size = header_size + + static_cast(hash_count) * ccf::crypto::Sha256Hash::SIZE; + if (serialised_tree.size() != expected_size) + { + throw std::logic_error(fmt::format( + "Serialised Merkle tree has {} bytes; expected {}", + serialised_tree.size(), + expected_size)); + } + } + static void verify_recovery_snapshot_signature_boundary( const RecoverySnapshotLedgerEntry& signature, std::span signing_key, @@ -231,6 +291,8 @@ namespace ccf receipt.phdr.ccf.txid)); } + validate_recovery_snapshot_merkle_tree_encoding( + *signature.serialised_tree, signature.version); ccf::MerkleTreeHistory tree(*signature.serialised_tree); if (tree.end_index() != signature.version - 1) { diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index 67c076b8e0fe..ca55fbb9cd5a 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -181,6 +181,22 @@ TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") REQUIRE_NOTHROW(ccf::verify_snapshot_seqno(first_entry, encryptor, 1)); REQUIRE_THROWS(ccf::verify_snapshot_seqno(first_entry, encryptor, 2)); + const auto signature = + ccf::parse_recovery_snapshot_ledger_entry(entries.back(), encryptor); + REQUIRE(signature.serialised_tree.has_value()); + REQUIRE_NOTHROW(ccf::validate_recovery_snapshot_merkle_tree_encoding( + *signature.serialised_tree, signature.version)); + + auto excessive_leaf_count = *signature.serialised_tree; + std::fill_n(excessive_leaf_count.begin(), sizeof(uint64_t), 0xff); + REQUIRE_THROWS(ccf::validate_recovery_snapshot_merkle_tree_encoding( + excessive_leaf_count, signature.version)); + + auto truncated_tree = *signature.serialised_tree; + truncated_tree.pop_back(); + REQUIRE_THROWS(ccf::validate_recovery_snapshot_merkle_tree_encoding( + truncated_tree, signature.version)); + write_current_ledger_file(ledger_dir.path / "ledger_1", entries); ccf::CCFConfig::Ledger ledger_config; From f176bc1a1aa322f2ddbbef0278bf9c13b12b4eb7 Mon Sep 17 00:00:00 2001 From: achamayou Date: Thu, 23 Jul 2026 22:08:46 +0100 Subject: [PATCH 15/27] Cover malformed recovery evidence Assert impossible Merkle ranges fail before decode and malformed ledger evidence never reaches snapshot installation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 Copilot-Session: 4d631113-a6ff-453d-a658-3b46aaaec95a Copilot-Session: 972cd758-c6d2-4d1b-b043-b482e129cb54 --- src/node/test/snapshotter.cpp | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index ca55fbb9cd5a..f7def781768d 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -197,6 +197,14 @@ TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") REQUIRE_THROWS(ccf::validate_recovery_snapshot_merkle_tree_encoding( truncated_tree, signature.version)); + auto impossible_flushed_count = *signature.serialised_tree; + std::fill_n( + impossible_flushed_count.begin() + sizeof(uint64_t), + sizeof(uint64_t), + 0xff); + REQUIRE_THROWS(ccf::validate_recovery_snapshot_merkle_tree_encoding( + impossible_flushed_count, signature.version)); + write_current_ledger_file(ledger_dir.path / "ledger_1", entries); ccf::CCFConfig::Ledger ledger_config; @@ -216,8 +224,15 @@ TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") write_current_ledger_file( tampered_ledger_dir.path / "ledger_1", tampered_entries); ledger_config.directory = tampered_ledger_dir.path.string(); - REQUIRE_THROWS( - ccf::scan_recovery_snapshot_ledger_files(ledger_config, encryptor, 1)); + bool install_called = false; + const auto verification_error = ccf::try_verify_and_install_recovery_snapshot( + [&]() { + std::ignore = + ccf::scan_recovery_snapshot_ledger_files(ledger_config, encryptor, 1); + }, + [&]() { install_called = true; }); + REQUIRE(verification_error.has_value()); + REQUIRE_FALSE(install_called); } TEST_CASE("Recovery snapshot endorsement scan bounds pending endorsements") From ad374c5a9abd72208acf90661e90b6e159b9b83c Mon Sep 17 00:00:00 2001 From: achamayou Date: Thu, 23 Jul 2026 22:49:11 +0100 Subject: [PATCH 16/27] Reject unsupported Merkle heights Bound flushed Merkle leaves below MerkleCPP's unsafe shift height and cover the crafted one-retained-leaf case. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 Copilot-Session: 4d631113-a6ff-453d-a658-3b46aaaec95a Copilot-Session: 972cd758-c6d2-4d1b-b043-b482e129cb54 --- src/node/recovery_snapshot_ledger.h | 9 +++++++++ src/node/test/snapshotter.cpp | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/node/recovery_snapshot_ledger.h b/src/node/recovery_snapshot_ledger.h index 742470e14261..063cb698fd2f 100644 --- a/src/node/recovery_snapshot_ledger.h +++ b/src/node/recovery_snapshot_ledger.h @@ -203,6 +203,7 @@ namespace ccf { static constexpr size_t header_size = 2 * sizeof(uint64_t); static constexpr uint64_t max_leaf_nodes = 1'000'000; + static constexpr uint64_t max_flushed_leaves = (uint64_t{1} << 30) - 1; if (serialised_tree.size() < header_size) { throw std::logic_error("Serialised Merkle tree header is truncated"); @@ -225,6 +226,14 @@ namespace ccf leaf_nodes, max_leaf_nodes)); } + if (flushed_leaves > max_flushed_leaves) + { + throw std::logic_error(fmt::format( + "Serialised Merkle tree contains too many flushed leaves ({}; maximum " + "{})", + flushed_leaves, + max_flushed_leaves)); + } if ( flushed_leaves > signature_version || leaf_nodes != signature_version - flushed_leaves) diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index f7def781768d..2f421b4fc54c 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -205,6 +205,20 @@ TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") REQUIRE_THROWS(ccf::validate_recovery_snapshot_merkle_tree_encoding( impossible_flushed_count, signature.version)); + std::vector unsupported_tree_height( + 2 * sizeof(uint64_t) + 2 * ccf::crypto::Sha256Hash::SIZE); + const auto write_big_endian_uint64 = [&](size_t offset, uint64_t value) { + for (size_t i = 0; i < sizeof(uint64_t); ++i) + { + unsupported_tree_height[offset + i] = + static_cast(value >> (8 * (sizeof(uint64_t) - i - 1))); + } + }; + write_big_endian_uint64(0, 1); + write_big_endian_uint64(sizeof(uint64_t), uint64_t{1} << 63); + REQUIRE_THROWS(ccf::validate_recovery_snapshot_merkle_tree_encoding( + unsupported_tree_height, (uint64_t{1} << 63) + 1)); + write_current_ledger_file(ledger_dir.path / "ledger_1", entries); ccf::CCFConfig::Ledger ledger_config; From 9279599608ed8138ca92598915a6520b796f5716 Mon Sep 17 00:00:00 2001 From: achamayou Date: Thu, 23 Jul 2026 23:19:55 +0100 Subject: [PATCH 17/27] Bound serialised KV lengths Reject public domains and size-prefixed entries that exceed their remaining buffers before recovery parses untrusted snapshot or ledger bytes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 Copilot-Session: 4d631113-a6ff-453d-a658-3b46aaaec95a Copilot-Session: 972cd758-c6d2-4d1b-b043-b482e129cb54 --- src/kv/generic_serialise_wrapper.h | 7 +++++++ src/kv/raw_serialise.h | 2 +- src/node/test/snapshotter.cpp | 26 ++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/kv/generic_serialise_wrapper.h b/src/kv/generic_serialise_wrapper.h index 13e5645fc165..43a0ceceaa3b 100644 --- a/src/kv/generic_serialise_wrapper.h +++ b/src/kv/generic_serialise_wrapper.h @@ -348,6 +348,13 @@ namespace ccf::kv serialized::skip(data_, size_, crypto_util->get_header_length()); auto public_domain_length = serialized::read(data_, size_); + if (public_domain_length > size_) + { + throw std::logic_error(fmt::format( + "Public domain length {} exceeds remaining entry size {}", + public_domain_length, + size_)); + } const auto* data_public = data_; public_reader.init(data_public, public_domain_length); diff --git a/src/kv/raw_serialise.h b/src/kv/raw_serialise.h index ef1b1104a2a6..176ceccd4272 100644 --- a/src/kv/raw_serialise.h +++ b/src/kv/raw_serialise.h @@ -148,8 +148,8 @@ namespace ccf::kv */ size_t read_size_prefixed_entry(size_t& start_offset) { - auto remainder = data_size - data_offset; auto entry_size = read_entry(); + const auto remainder = data_size - data_offset; if (remainder < entry_size) { diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index 2f421b4fc54c..29424e24f91e 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -6,6 +6,7 @@ #include "crypto/openssl/hash.h" #include "ds/files.h" #include "ds/internal_logger.h" +#include "kv/raw_serialise.h" #include "kv/test/null_encryptor.h" #include "kv/test/stub_consensus.h" #include "node/encryptor.h" @@ -181,6 +182,31 @@ TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") REQUIRE_NOTHROW(ccf::verify_snapshot_seqno(first_entry, encryptor, 1)); REQUIRE_THROWS(ccf::verify_snapshot_seqno(first_entry, encryptor, 2)); + auto invalid_public_domain = entries.front(); + const auto public_domain_size_offset = + sizeof(ccf::kv::SerialisedEntryHeader) + encryptor->get_header_length(); + const size_t oversized_public_domain = invalid_public_domain.size(); + std::memcpy( + invalid_public_domain.data() + public_domain_size_offset, + &oversized_public_domain, + sizeof(oversized_public_domain)); + REQUIRE_THROWS(ccf::parse_recovery_snapshot_ledger_entry( + invalid_public_domain, encryptor)); + const ccf::SnapshotSegments invalid_snapshot_entry{ + std::span(invalid_public_domain), {}}; + REQUIRE_THROWS( + ccf::verify_snapshot_seqno(invalid_snapshot_entry, encryptor, 1)); + + std::vector truncated_size_prefixed_entry(sizeof(size_t) + 1); + const size_t declared_entry_size = 2; + std::memcpy( + truncated_size_prefixed_entry.data(), + &declared_entry_size, + sizeof(declared_entry_size)); + ccf::kv::RawReader truncated_reader( + truncated_size_prefixed_entry.data(), truncated_size_prefixed_entry.size()); + REQUIRE_THROWS(truncated_reader.read_next>()); + const auto signature = ccf::parse_recovery_snapshot_ledger_entry(entries.back(), encryptor); REQUIRE(signature.serialised_tree.has_value()); From b4e63b80867f1d6fe2716c96a32f2201a5e1ac5f Mon Sep 17 00:00:00 2001 From: achamayou Date: Thu, 23 Jul 2026 23:46:54 +0100 Subject: [PATCH 18/27] Check fixed-size KV reads Reject truncated fixed-width entries before copying claims digests from untrusted recovery snapshot or ledger public domains. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 Copilot-Session: 4d631113-a6ff-453d-a658-3b46aaaec95a Copilot-Session: 972cd758-c6d2-4d1b-b043-b482e129cb54 --- src/kv/raw_serialise.h | 8 ++++++++ src/node/test/snapshotter.cpp | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/kv/raw_serialise.h b/src/kv/raw_serialise.h index 176ceccd4272..557a604f349a 100644 --- a/src/kv/raw_serialise.h +++ b/src/kv/raw_serialise.h @@ -202,6 +202,14 @@ namespace ccf::kv T ret{}; auto* data_ = reinterpret_cast(ret.data()); constexpr size_t size = ret.size() * sizeof(typename T::value_type); + const auto remainder = data_size - data_offset; + if (remainder < size) + { + throw std::runtime_error(fmt::format( + "Expected {} byte fixed-size entry, found only {}", + size, + remainder)); + } auto size_ = size; serialized::write(data_, size_, data_ptr + data_offset, size); data_offset += size; diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index 29424e24f91e..5be354315cc5 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -197,6 +197,38 @@ TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") REQUIRE_THROWS( ccf::verify_snapshot_seqno(invalid_snapshot_entry, encryptor, 1)); + constexpr size_t truncated_public_domain_size = + sizeof(ccf::kv::EntryType) + sizeof(ccf::kv::Version); + ccf::kv::SerialisedEntryHeader truncated_claims_header; + truncated_claims_header.set_size( + sizeof(size_t) + truncated_public_domain_size); + std::vector truncated_claims_entry( + sizeof(truncated_claims_header) + truncated_claims_header.size); + auto* truncated_claims_data = truncated_claims_entry.data(); + std::memcpy( + truncated_claims_data, + &truncated_claims_header, + sizeof(truncated_claims_header)); + truncated_claims_data += sizeof(truncated_claims_header); + std::memcpy( + truncated_claims_data, + &truncated_public_domain_size, + sizeof(truncated_public_domain_size)); + truncated_claims_data += sizeof(truncated_public_domain_size); + *truncated_claims_data++ = + static_cast(ccf::kv::EntryType::WriteSetWithClaims); + const ccf::kv::Version truncated_claims_version = 1; + std::memcpy( + truncated_claims_data, + &truncated_claims_version, + sizeof(truncated_claims_version)); + REQUIRE_THROWS(ccf::parse_recovery_snapshot_ledger_entry( + truncated_claims_entry, encryptor)); + const ccf::SnapshotSegments truncated_claims_snapshot{ + std::span(truncated_claims_entry), {}}; + REQUIRE_THROWS( + ccf::verify_snapshot_seqno(truncated_claims_snapshot, encryptor, 1)); + std::vector truncated_size_prefixed_entry(sizeof(size_t) + 1); const size_t declared_entry_size = 2; std::memcpy( From 727cf5fa59769ddccd51333ac190416c89f81d9f Mon Sep 17 00:00:00 2001 From: achamayou Date: Fri, 24 Jul 2026 00:32:00 +0100 Subject: [PATCH 19/27] Treat ledger endorsements as candidates Remove Merkle, signature-boundary, and service-info handling from the recovery pre-pass. Trust only a complete COSE chain anchored at the configured previous service identity. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 Copilot-Session: 4d631113-a6ff-453d-a658-3b46aaaec95a Copilot-Session: 972cd758-c6d2-4d1b-b043-b482e129cb54 --- CHANGELOG.md | 2 +- src/kv/generic_serialise_wrapper.h | 7 - src/kv/raw_serialise.h | 10 +- src/node/node_state.h | 37 +- src/node/recovery_snapshot_ledger.h | 352 ++----------------- src/node/snapshot_serdes.h | 19 +- src/node/test/network_identity_subsystem.cpp | 47 ++- src/node/test/snapshotter.cpp | 148 +------- 8 files changed, 77 insertions(+), 545 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8f408fe7397..d759e2cc7c9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added -- Recovery can now use a COSE snapshot signed by an earlier service identity after one or more disaster recoveries. Before deserialising the snapshot, the node derives and validates the previous-service-identity endorsement chain directly from the public ledger suffix and retains it only for the current recovery attempt. Invalid or incomplete chains fall back to full-ledger replay (#8092). +- Recovery can now use a COSE snapshot signed by an earlier service identity after one or more disaster recoveries. Before deserialising the snapshot, the node reads previous-service-identity endorsement candidates from the public ledger suffix, validates a complete chain against the operator-provided identity, and retains it only for the current recovery attempt. Invalid or incomplete chains fall back to full-ledger replay (#8092). ### Changed diff --git a/src/kv/generic_serialise_wrapper.h b/src/kv/generic_serialise_wrapper.h index 43a0ceceaa3b..13e5645fc165 100644 --- a/src/kv/generic_serialise_wrapper.h +++ b/src/kv/generic_serialise_wrapper.h @@ -348,13 +348,6 @@ namespace ccf::kv serialized::skip(data_, size_, crypto_util->get_header_length()); auto public_domain_length = serialized::read(data_, size_); - if (public_domain_length > size_) - { - throw std::logic_error(fmt::format( - "Public domain length {} exceeds remaining entry size {}", - public_domain_length, - size_)); - } const auto* data_public = data_; public_reader.init(data_public, public_domain_length); diff --git a/src/kv/raw_serialise.h b/src/kv/raw_serialise.h index 557a604f349a..ef1b1104a2a6 100644 --- a/src/kv/raw_serialise.h +++ b/src/kv/raw_serialise.h @@ -148,8 +148,8 @@ namespace ccf::kv */ size_t read_size_prefixed_entry(size_t& start_offset) { + auto remainder = data_size - data_offset; auto entry_size = read_entry(); - const auto remainder = data_size - data_offset; if (remainder < entry_size) { @@ -202,14 +202,6 @@ namespace ccf::kv T ret{}; auto* data_ = reinterpret_cast(ret.data()); constexpr size_t size = ret.size() * sizeof(typename T::value_type); - const auto remainder = data_size - data_offset; - if (remainder < size) - { - throw std::runtime_error(fmt::format( - "Expected {} byte fixed-size entry, found only {}", - size, - remainder)); - } auto size_ = size; serialized::write(data_, size_, data_ptr + data_offset, size); data_offset += size; diff --git a/src/node/node_state.h b/src/node/node_state.h index 33d732a35d40..5f457c5136ac 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -769,45 +769,12 @@ namespace ccf const auto snapshot_seqno = startup_snapshot_info->seqno; const auto scan = scan_recovery_snapshot_ledger_files( config.ledger, network.tables->get_encryptor(), snapshot_seqno); - if ( - scan.last_signed_idx <= - static_cast<::consensus::Index>(snapshot_seqno)) - { - throw std::logic_error( - "No committed ledger suffix was found after the snapshot"); - } - if (!scan.latest_service_info.has_value()) - { - throw std::logic_error( - "No service identity was found in the committed ledger suffix"); - } - - const auto& latest_service = scan.latest_service_info->second; - if (latest_service.cert != target_identity) - { - throw std::logic_error( - "Latest ledger service identity does not match the configured " - "previous service identity"); - } - if (!latest_service.current_service_create_txid.has_value()) - { - throw std::logic_error( - "Latest ledger service identity has no creation TxID"); - } - const auto target_key = ccf::crypto::public_key_der_from_cert( ccf::crypto::cert_pem_to_der(target_identity)); const auto endorsements = build_recovery_snapshot_endorsement_chain( - scan.endorsements, - target_key, - snapshot_seqno, - *latest_service.current_service_create_txid); + scan.endorsements, target_key, snapshot_seqno); verify_recovery_snapshot( - segments, - target_identity, - endorsements, - snapshot_seqno, - latest_service.current_service_create_txid); + segments, target_identity, endorsements, snapshot_seqno); LOG_INFO_FMT( "Validated {} recovery snapshot endorsement(s) in memory", endorsements.size()); diff --git a/src/node/recovery_snapshot_ledger.h b/src/node/recovery_snapshot_ledger.h index 063cb698fd2f..70c14a8292f5 100644 --- a/src/node/recovery_snapshot_ledger.h +++ b/src/node/recovery_snapshot_ledger.h @@ -3,24 +3,17 @@ #pragma once #include "ccf/node/startup_config.h" -#include "ccf/service/tables/service.h" #include "ds/internal_logger.h" #include "host/ledger_filenames.h" #include "kv/kv_serialiser.h" #include "kv/serialised_entry_format.h" -#include "node/cose_common.h" -#include "node/history.h" -#include "node/rpc/claims.h" #include "node/rpc/network_identity_chain_helpers.h" #include "service/tables/previous_service_identity.h" -#include "service/tables/signatures.h" #include -#include #include #include #include -#include #include namespace ccf @@ -33,87 +26,19 @@ namespace ccf static constexpr size_t MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE = size_t{4} * 1024 * 1024; static constexpr size_t MAX_RECOVERY_SNAPSHOT_LEDGER_ENTRY_SIZE = - size_t{64} * 1024 * 1024; + size_t{16} * 1024 * 1024; struct RecoverySnapshotLedgerEntry { ccf::kv::Version version = 0; - ccf::kv::Term term = 0; - bool is_signature = false; std::optional endorsement = std::nullopt; - std::optional service_info = std::nullopt; - std::optional cose_signature = std::nullopt; - std::optional> serialised_tree = std::nullopt; - ccf::crypto::Sha256Hash leaf; }; struct RecoverySnapshotLedgerScan { - ccf::kv::Version last_signed_idx = 0; std::vector endorsements; - std::optional> - latest_service_info = std::nullopt; }; - static void parse_recovery_snapshot_ledger_write( - RecoverySnapshotLedgerEntry& result, - const std::string& map_name, - const ccf::kv::SerialisedKey& key, - const ccf::kv::SerialisedValue& value) - { - if (map_name == ccf::Tables::PREVIOUS_SERVICE_IDENTITY_ENDORSEMENT) - { - if ( - result.endorsement.has_value() || - key != ccf::PreviousServiceIdentityEndorsement::create_unit()) - { - throw std::logic_error( - "Invalid previous service identity endorsement table write"); - } - if (value.size() > MAX_RECOVERY_SNAPSHOT_ENDORSEMENT_RECORD_SIZE) - { - throw std::logic_error(fmt::format( - "Serialised previous service identity endorsement is too large " - "({} bytes; maximum {} bytes)", - value.size(), - MAX_RECOVERY_SNAPSHOT_ENDORSEMENT_RECORD_SIZE)); - } - result.endorsement = ccf::PreviousServiceIdentityEndorsement:: - ValueSerialiser::from_serialised(value); - } - else if (map_name == ccf::Tables::SERVICE) - { - if (result.service_info.has_value() || key != ccf::Service::create_unit()) - { - throw std::logic_error("Invalid service info table write"); - } - result.service_info = - ccf::Service::ValueSerialiser::from_serialised(value); - } - else if (map_name == ccf::Tables::COSE_SIGNATURES) - { - if ( - result.cose_signature.has_value() || - key != ccf::CoseSignatures::create_unit()) - { - throw std::logic_error("Invalid COSE signature table write"); - } - result.cose_signature = - ccf::CoseSignatures::ValueSerialiser::from_serialised(value); - } - else if (map_name == ccf::Tables::SERIALISED_MERKLE_TREE) - { - if ( - result.serialised_tree.has_value() || - key != ccf::SerialisedMerkleTree::create_unit()) - { - throw std::logic_error("Invalid serialised Merkle tree table write"); - } - result.serialised_tree = - ccf::SerialisedMerkleTree::ValueSerialiser::from_serialised(value); - } - } - static RecoverySnapshotLedgerEntry parse_recovery_snapshot_ledger_entry( const std::vector& entry, const std::shared_ptr& encryptor) @@ -132,16 +57,10 @@ namespace ccf RecoverySnapshotLedgerEntry result; result.version = *version; - result.term = term; - size_t map_count = 0; - bool has_signature = false; - bool has_cose_signature = false; - bool has_serialised_tree = false; for (auto map_name = deserialiser.start_map(); map_name.has_value(); map_name = deserialiser.start_map()) { - ++map_count; std::ignore = deserialiser.deserialise_entry_version(); const auto read_count = deserialiser.deserialise_read_header(); @@ -151,32 +70,41 @@ namespace ccf } const auto write_count = deserialiser.deserialise_write_header(); - if (write_count > 0) - { - has_signature = has_signature || *map_name == ccf::Tables::SIGNATURES; - has_cose_signature = - has_cose_signature || *map_name == ccf::Tables::COSE_SIGNATURES; - has_serialised_tree = has_serialised_tree || - *map_name == ccf::Tables::SERIALISED_MERKLE_TREE; - } - for (size_t i = 0; i < write_count; ++i) { auto [key, value] = deserialiser.deserialise_write(); - parse_recovery_snapshot_ledger_write(result, *map_name, key, value); + if (*map_name != ccf::Tables::PREVIOUS_SERVICE_IDENTITY_ENDORSEMENT) + { + continue; + } + if ( + result.endorsement.has_value() || + key != ccf::PreviousServiceIdentityEndorsement::create_unit()) + { + throw std::logic_error( + "Invalid previous service identity endorsement table write"); + } + if (value.size() > MAX_RECOVERY_SNAPSHOT_ENDORSEMENT_RECORD_SIZE) + { + throw std::logic_error(fmt::format( + "Serialised previous service identity endorsement is too large " + "({} bytes; maximum {} bytes)", + value.size(), + MAX_RECOVERY_SNAPSHOT_ENDORSEMENT_RECORD_SIZE)); + } + result.endorsement = ccf::PreviousServiceIdentityEndorsement:: + ValueSerialiser::from_serialised(value); } const auto remove_count = deserialiser.deserialise_remove_header(); for (size_t i = 0; i < remove_count; ++i) { std::ignore = deserialiser.deserialise_remove(); - if ( - *map_name == ccf::Tables::PREVIOUS_SERVICE_IDENTITY_ENDORSEMENT || - *map_name == ccf::Tables::SERVICE) + if (*map_name == ccf::Tables::PREVIOUS_SERVICE_IDENTITY_ENDORSEMENT) { - throw std::logic_error(fmt::format( - "Unexpected removal from recovery snapshot scan table {}", - *map_name)); + throw std::logic_error( + "Unexpected removal from previous service identity endorsement " + "table"); } } } @@ -186,160 +114,9 @@ namespace ccf throw std::logic_error( "Public ledger entry contains trailing serialised data"); } - - result.is_signature = has_serialised_tree && - ((map_count == 2 && has_signature != has_cose_signature) || - (map_count == 3 && has_signature && has_cose_signature)); - const auto claims_digest = deserialiser.consume_claims_digest(); - const auto commit_evidence_digest = - deserialiser.consume_commit_evidence_digest(); - result.leaf = ccf::entry_leaf(entry, commit_evidence_digest, claims_digest); return result; } - static void validate_recovery_snapshot_merkle_tree_encoding( - std::span serialised_tree, - ccf::kv::Version signature_version) - { - static constexpr size_t header_size = 2 * sizeof(uint64_t); - static constexpr uint64_t max_leaf_nodes = 1'000'000; - static constexpr uint64_t max_flushed_leaves = (uint64_t{1} << 30) - 1; - if (serialised_tree.size() < header_size) - { - throw std::logic_error("Serialised Merkle tree header is truncated"); - } - - const auto read_uint64 = [&](size_t offset) { - uint64_t value = 0; - for (size_t i = 0; i < sizeof(uint64_t); ++i) - { - value = (value << 8) | serialised_tree[offset + i]; - } - return value; - }; - const auto leaf_nodes = read_uint64(0); - const auto flushed_leaves = read_uint64(sizeof(uint64_t)); - if (leaf_nodes > max_leaf_nodes) - { - throw std::logic_error(fmt::format( - "Serialised Merkle tree contains too many leaf nodes ({}; maximum {})", - leaf_nodes, - max_leaf_nodes)); - } - if (flushed_leaves > max_flushed_leaves) - { - throw std::logic_error(fmt::format( - "Serialised Merkle tree contains too many flushed leaves ({}; maximum " - "{})", - flushed_leaves, - max_flushed_leaves)); - } - if ( - flushed_leaves > signature_version || - leaf_nodes != signature_version - flushed_leaves) - { - throw std::logic_error(fmt::format( - "Serialised Merkle tree leaf range {} + {} does not end before " - "signature {}", - flushed_leaves, - leaf_nodes, - signature_version)); - } - - const auto extra_hashes = std::popcount(flushed_leaves); - const auto hash_count = leaf_nodes + extra_hashes; - const auto max_hashes = (std::numeric_limits::max() - header_size) / - ccf::crypto::Sha256Hash::SIZE; - if (hash_count > max_hashes) - { - throw std::logic_error("Serialised Merkle tree size overflows"); - } - const auto expected_size = header_size + - static_cast(hash_count) * ccf::crypto::Sha256Hash::SIZE; - if (serialised_tree.size() != expected_size) - { - throw std::logic_error(fmt::format( - "Serialised Merkle tree has {} bytes; expected {}", - serialised_tree.size(), - expected_size)); - } - } - - static void verify_recovery_snapshot_signature_boundary( - const RecoverySnapshotLedgerEntry& signature, - std::span signing_key, - const std::vector>& - endorsement_leaves, - const std::optional>& - service_info_leaf) - { - if ( - !signature.is_signature || !signature.cose_signature.has_value() || - !signature.serialised_tree.has_value()) - { - throw std::logic_error(fmt::format( - "Ledger signature boundary at {} has no COSE signature and Merkle tree", - signature.version)); - } - if (signature.version == 0) - { - throw std::logic_error("Ledger signature boundary cannot be at seqno 0"); - } - - const auto receipt = - ccf::cose::decode_ccf_receipt(*signature.cose_signature, false); - const auto receipt_txid = ccf::TxID::from_str(receipt.phdr.ccf.txid); - if ( - !receipt_txid.has_value() || receipt_txid->seqno != signature.version || - (signature.term != 0 && receipt_txid->view != signature.term)) - { - throw std::logic_error(fmt::format( - "Ledger signature boundary at {}.{} has unexpected COSE TxID {}", - signature.term, - signature.version, - receipt.phdr.ccf.txid)); - } - - validate_recovery_snapshot_merkle_tree_encoding( - *signature.serialised_tree, signature.version); - ccf::MerkleTreeHistory tree(*signature.serialised_tree); - if (tree.end_index() != signature.version - 1) - { - throw std::logic_error(fmt::format( - "Ledger signature boundary at {} contains a Merkle tree ending at {}", - signature.version, - tree.end_index())); - } - - const auto verifier = ccf::crypto::make_cose_verifier_from_key(signing_key); - const auto root = tree.get_root(); - if (!verifier->verify_detached(*signature.cose_signature, root.h)) - { - throw std::logic_error(fmt::format( - "Ledger signature boundary at {} failed COSE signature verification", - signature.version)); - } - - const auto verify_leaf = [&](const auto& version_and_leaf) { - const auto& [version, expected_leaf] = version_and_leaf; - if (!tree.in_range(version) || tree.get_leaf(version) != expected_leaf) - { - throw std::logic_error(fmt::format( - "Ledger signature boundary at {} does not authenticate entry {}", - signature.version, - version)); - } - }; - for (const auto& endorsement_leaf : endorsement_leaves) - { - verify_leaf(endorsement_leaf); - } - if (service_info_leaf.has_value()) - { - verify_leaf(*service_info_leaf); - } - } - struct RecoverySnapshotLedgerFile { std::filesystem::path path; @@ -454,17 +231,8 @@ namespace ccf } RecoverySnapshotLedgerScan scan; - scan.last_signed_idx = snapshot_seqno; auto expected_seqno = snapshot_seqno + 1; - std::vector pending_endorsements; - std::vector> - pending_endorsement_leaves; - size_t committed_endorsements_payload_size = 0; - size_t pending_endorsements_payload_size = 0; - std::optional> - latest_observed_service_info = std::nullopt; - std::optional> - pending_service_info_leaf = std::nullopt; + size_t endorsements_payload_size = 0; for (const auto& ledger_file : find_recovery_snapshot_ledger_files(ledger_config)) @@ -625,78 +393,24 @@ namespace ccf MAX_RECOVERY_SNAPSHOT_ENDORSEMENT_SIZE)); } if ( - pending_endorsements.size() >= + scan.endorsements.size() >= MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_COUNT) { throw std::logic_error(fmt::format( - "Ledger suffix contains too many pending endorsements (maximum " - "{})", + "Ledger suffix contains too many endorsements (maximum {})", MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_COUNT)); } if ( endorsement_size > MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE - - pending_endorsements_payload_size) + endorsements_payload_size) { throw std::logic_error(fmt::format( - "Pending ledger endorsements payload is too large (maximum {} " - "bytes)", + "Ledger endorsements payload is too large (maximum {} bytes)", MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE)); } - pending_endorsements_payload_size += endorsement_size; - pending_endorsements.push_back( + endorsements_payload_size += endorsement_size; + scan.endorsements.push_back( {parsed.version, std::move(*parsed.endorsement)}); - pending_endorsement_leaves.emplace_back(parsed.version, parsed.leaf); - } - if (parsed.service_info.has_value()) - { - latest_observed_service_info.emplace( - parsed.version, std::move(*parsed.service_info)); - pending_service_info_leaf.emplace(parsed.version, parsed.leaf); - } - if (parsed.is_signature) - { - if (!pending_endorsements.empty()) - { - verify_recovery_snapshot_signature_boundary( - parsed, - pending_endorsements.back().endorsement.endorsing_key, - pending_endorsement_leaves, - pending_service_info_leaf); - - if ( - pending_endorsements.size() > - MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_COUNT - - scan.endorsements.size()) - { - throw std::logic_error(fmt::format( - "Committed ledger suffix contains too many endorsements " - "(maximum {})", - MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_COUNT)); - } - if ( - pending_endorsements_payload_size > - MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE - - committed_endorsements_payload_size) - { - throw std::logic_error(fmt::format( - "Committed ledger endorsements payload is too large (maximum " - "{} bytes)", - MAX_RECOVERY_SNAPSHOT_ENDORSEMENTS_PAYLOAD_SIZE)); - } - - scan.endorsements.insert( - scan.endorsements.end(), - std::make_move_iterator(pending_endorsements.begin()), - std::make_move_iterator(pending_endorsements.end())); - pending_endorsements.clear(); - pending_endorsement_leaves.clear(); - committed_endorsements_payload_size += - pending_endorsements_payload_size; - pending_endorsements_payload_size = 0; - scan.latest_service_info = latest_observed_service_info; - pending_service_info_leaf.reset(); - scan.last_signed_idx = parsed.version; - } } if (expected_seqno == std::numeric_limits::max()) diff --git a/src/node/snapshot_serdes.h b/src/node/snapshot_serdes.h index cbd0173825eb..28eb8f605249 100644 --- a/src/node/snapshot_serdes.h +++ b/src/node/snapshot_serdes.h @@ -148,8 +148,7 @@ namespace ccf static std::vector verify_recovery_snapshot_endorsement_chain( const ccf::SerialisedCoseEndorsements& endorsements, std::span target_key, - ccf::kv::Version snapshot_seqno, - std::optional target_service_from = std::nullopt) + ccf::kv::Version snapshot_seqno) { if (target_key.empty()) { @@ -185,11 +184,6 @@ namespace ccf verify_endorsements_connected(parsed[i], parsed[i + 1]); } - if (target_service_from.has_value()) - { - validate_chain_front_connection(parsed.front(), *target_service_from); - } - const auto& oldest = parsed.back(); if ( !oldest.endorsement_epoch_end.has_value() || @@ -211,8 +205,7 @@ namespace ccf build_recovery_snapshot_endorsement_chain( const std::vector& collected, std::span target_key, - ccf::kv::Version snapshot_seqno, - const ccf::TxID& target_service_from) + ccf::kv::Version snapshot_seqno) { if (collected.empty()) { @@ -303,9 +296,6 @@ namespace ccf "previous service identity"); } - validate_chain_front_connection( - collected.back().endorsement, target_service_from); - const auto& oldest = collected.front().endorsement; if ( !oldest.endorsement_epoch_end.has_value() || @@ -481,8 +471,7 @@ namespace ccf const SnapshotSegments& segments, const ccf::crypto::Pem& target_identity, const ccf::SerialisedCoseEndorsements& endorsements, - ccf::kv::Version snapshot_seqno, - std::optional target_service_from = std::nullopt) + ccf::kv::Version snapshot_seqno) { if (endorsements.empty()) { @@ -498,7 +487,7 @@ namespace ccf const auto target_key = ccf::crypto::public_key_der_from_cert( ccf::crypto::cert_pem_to_der(target_identity)); const auto snapshot_signer_key = verify_recovery_snapshot_endorsement_chain( - endorsements, target_key, snapshot_seqno, target_service_from); + endorsements, target_key, snapshot_seqno); const auto receipt = decode_and_verify_cose_snapshot_receipt(segments); const auto verifier = ccf::crypto::make_cose_verifier_from_key(snapshot_signer_key); diff --git a/src/node/test/network_identity_subsystem.cpp b/src/node/test/network_identity_subsystem.cpp index 95c07b6dfcef..ccb102feb466 100644 --- a/src/node/test/network_identity_subsystem.cpp +++ b/src/node/test/network_identity_subsystem.cpp @@ -485,16 +485,15 @@ TEST_CASE("Recovery snapshot endorsements are validated newest-to-oldest") {cb.write_versions[1], cb.entries[1]}, {cb.write_versions[2], cb.entries[2]}}; const auto target_key = cb.current_pkey_der(); - const auto target_from = cb.synthesised_current_service_from(); - const auto chain = ccf::build_recovery_snapshot_endorsement_chain( - collected, target_key, 1, target_from); + const auto chain = + ccf::build_recovery_snapshot_endorsement_chain(collected, target_key, 1); REQUIRE(chain.size() == 2); REQUIRE(chain[0] == cb.entries[2].endorsement); REQUIRE(chain[1] == cb.entries[1].endorsement); - const auto snapshot_signer = ccf::verify_recovery_snapshot_endorsement_chain( - chain, target_key, 1, target_from); + const auto snapshot_signer = + ccf::verify_recovery_snapshot_endorsement_chain(chain, target_key, 1); REQUIRE(snapshot_signer == cb.service_keys.front()->public_key_der()); } @@ -507,55 +506,69 @@ TEST_CASE("Recovery snapshot endorsements fail closed") {cb.write_versions[1], cb.entries[1]}, {cb.write_versions[2], cb.entries[2]}}; const auto target_key = cb.current_pkey_der(); - const auto target_from = cb.synthesised_current_service_from(); - const auto chain = ccf::build_recovery_snapshot_endorsement_chain( - collected, target_key, 1, target_from); + const auto chain = + ccf::build_recovery_snapshot_endorsement_chain(collected, target_key, 1); auto out_of_order = chain; std::reverse(out_of_order.begin(), out_of_order.end()); REQUIRE_THROWS_AS( ccf::verify_recovery_snapshot_endorsement_chain( - out_of_order, target_key, 1, target_from), + out_of_order, target_key, 1), std::logic_error); auto tampered = chain; tampered.front().back() ^= 0xff; REQUIRE_THROWS_AS( - ccf::verify_recovery_snapshot_endorsement_chain( - tampered, target_key, 1, target_from), + ccf::verify_recovery_snapshot_endorsement_chain(tampered, target_key, 1), std::logic_error); REQUIRE_THROWS_AS( - ccf::verify_recovery_snapshot_endorsement_chain( - chain, target_key, 1000, target_from), + ccf::verify_recovery_snapshot_endorsement_chain(chain, target_key, 1000), std::logic_error); auto missing_newest = chain; missing_newest.erase(missing_newest.begin()); REQUIRE_THROWS_AS( ccf::verify_recovery_snapshot_endorsement_chain( - missing_newest, target_key, 1, target_from), + missing_newest, target_key, 1), std::logic_error); auto missing_oldest = chain; missing_oldest.pop_back(); REQUIRE_THROWS_AS( ccf::verify_recovery_snapshot_endorsement_chain( - missing_oldest, target_key, 1, target_from), + missing_oldest, target_key, 1), std::logic_error); const auto wrong_target_key = ccf::crypto::make_ec_key_pair()->public_key_der(); REQUIRE_THROWS_AS( ccf::build_recovery_snapshot_endorsement_chain( - collected, wrong_target_key, 1, target_from), + collected, wrong_target_key, 1), std::logic_error); auto broken_back_pointer = collected; broken_back_pointer.back().endorsement.previous_version = 999; REQUIRE_THROWS_AS( ccf::build_recovery_snapshot_endorsement_chain( - broken_back_pointer, target_key, 1, target_from), + broken_back_pointer, target_key, 1), + std::logic_error); + + auto epoch_mismatch = collected; + epoch_mismatch.front().endorsement.endorsement_epoch_end->seqno -= 1; + REQUIRE_THROWS_AS( + ccf::build_recovery_snapshot_endorsement_chain( + epoch_mismatch, target_key, 1), + std::logic_error); + + auto injected = collected; + auto injected_endorsement = collected.back(); + ++injected_endorsement.write_version; + injected_endorsement.endorsement.previous_version = + collected.back().write_version; + injected.emplace_back(std::move(injected_endorsement)); + REQUIRE_THROWS_AS( + ccf::build_recovery_snapshot_endorsement_chain(injected, target_key, 1), std::logic_error); } diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index 5be354315cc5..57f888a63527 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -6,12 +6,10 @@ #include "crypto/openssl/hash.h" #include "ds/files.h" #include "ds/internal_logger.h" -#include "kv/raw_serialise.h" #include "kv/test/null_encryptor.h" #include "kv/test/stub_consensus.h" #include "node/encryptor.h" #include "node/history.h" -#include "node/identity.h" #include "node/recovery_snapshot_ledger.h" #include "node/snapshot_serdes.h" #include "snapshots/filenames.h" @@ -111,26 +109,11 @@ TEST_CASE("Recovery snapshot installation failures do not request fallback") TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") { ScopedSnapshotDir ledger_dir; - ccf::NetworkIdentity target_identity( - "CN=Recovery snapshot ledger scan", - ccf::crypto::CurveID::SECP384R1, - "20240101000000Z", - 365); ccf::kv::Store source_store; auto encryptor = std::make_shared(); auto consensus = std::make_shared(); - const auto signing_node_kp = ccf::crypto::make_ec_key_pair(); - auto history = std::make_shared( - source_store, ccf::kv::test::PrimaryNodeId, *signing_node_kp); - history->set_endorsed_certificate(signing_node_kp->self_sign( - "CN=Recovery snapshot ledger signing node", - "20240101000000Z", - "20250101000000Z")); - history->set_service_signing_identity( - target_identity.get_key_pair(), ccf::COSESignaturesConfig{}); source_store.set_encryptor(encryptor); source_store.set_consensus(consensus); - source_store.set_history(history); source_store.initialise_term(2); std::vector> entries; @@ -143,23 +126,15 @@ TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") REQUIRE_FALSE(latest_entry.empty()); entries.push_back(std::move(latest_entry)); } - { - ccf::ServiceInfo service; - service.cert = target_identity.cert; - service.status = ccf::ServiceStatus::OPEN; - service.current_service_create_txid = ccf::TxID{6, 2}; - ccf::CoseEndorsement endorsement; endorsement.endorsement = {0xd2, 0x01}; - endorsement.endorsing_key = - target_identity.get_key_pair()->public_key_der(); + endorsement.endorsing_key = {0x02, 0x03}; endorsement.endorsement_epoch_begin = {2, 1}; endorsement.endorsement_epoch_end = ccf::TxID{4, 1}; endorsement.previous_version = 1; auto tx = source_store.create_tx(); - tx.rw(ccf::Tables::SERVICE)->put(service); tx.rw( ccf::Tables::PREVIOUS_SERVICE_IDENTITY_ENDORSEMENT) ->put(endorsement); @@ -169,145 +144,34 @@ TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") REQUIRE_FALSE(latest_entry.empty()); entries.push_back(std::move(latest_entry)); } - { - history->emit_signature(); - auto latest_entry = - consensus->get_latest_data().value_or(std::vector{}); - REQUIRE_FALSE(latest_entry.empty()); - entries.push_back(std::move(latest_entry)); - } const ccf::SnapshotSegments first_entry{ std::span(entries.front()), {}}; REQUIRE_NOTHROW(ccf::verify_snapshot_seqno(first_entry, encryptor, 1)); REQUIRE_THROWS(ccf::verify_snapshot_seqno(first_entry, encryptor, 2)); - auto invalid_public_domain = entries.front(); - const auto public_domain_size_offset = - sizeof(ccf::kv::SerialisedEntryHeader) + encryptor->get_header_length(); - const size_t oversized_public_domain = invalid_public_domain.size(); - std::memcpy( - invalid_public_domain.data() + public_domain_size_offset, - &oversized_public_domain, - sizeof(oversized_public_domain)); - REQUIRE_THROWS(ccf::parse_recovery_snapshot_ledger_entry( - invalid_public_domain, encryptor)); - const ccf::SnapshotSegments invalid_snapshot_entry{ - std::span(invalid_public_domain), {}}; - REQUIRE_THROWS( - ccf::verify_snapshot_seqno(invalid_snapshot_entry, encryptor, 1)); - - constexpr size_t truncated_public_domain_size = - sizeof(ccf::kv::EntryType) + sizeof(ccf::kv::Version); - ccf::kv::SerialisedEntryHeader truncated_claims_header; - truncated_claims_header.set_size( - sizeof(size_t) + truncated_public_domain_size); - std::vector truncated_claims_entry( - sizeof(truncated_claims_header) + truncated_claims_header.size); - auto* truncated_claims_data = truncated_claims_entry.data(); - std::memcpy( - truncated_claims_data, - &truncated_claims_header, - sizeof(truncated_claims_header)); - truncated_claims_data += sizeof(truncated_claims_header); - std::memcpy( - truncated_claims_data, - &truncated_public_domain_size, - sizeof(truncated_public_domain_size)); - truncated_claims_data += sizeof(truncated_public_domain_size); - *truncated_claims_data++ = - static_cast(ccf::kv::EntryType::WriteSetWithClaims); - const ccf::kv::Version truncated_claims_version = 1; - std::memcpy( - truncated_claims_data, - &truncated_claims_version, - sizeof(truncated_claims_version)); - REQUIRE_THROWS(ccf::parse_recovery_snapshot_ledger_entry( - truncated_claims_entry, encryptor)); - const ccf::SnapshotSegments truncated_claims_snapshot{ - std::span(truncated_claims_entry), {}}; - REQUIRE_THROWS( - ccf::verify_snapshot_seqno(truncated_claims_snapshot, encryptor, 1)); - - std::vector truncated_size_prefixed_entry(sizeof(size_t) + 1); - const size_t declared_entry_size = 2; - std::memcpy( - truncated_size_prefixed_entry.data(), - &declared_entry_size, - sizeof(declared_entry_size)); - ccf::kv::RawReader truncated_reader( - truncated_size_prefixed_entry.data(), truncated_size_prefixed_entry.size()); - REQUIRE_THROWS(truncated_reader.read_next>()); - - const auto signature = - ccf::parse_recovery_snapshot_ledger_entry(entries.back(), encryptor); - REQUIRE(signature.serialised_tree.has_value()); - REQUIRE_NOTHROW(ccf::validate_recovery_snapshot_merkle_tree_encoding( - *signature.serialised_tree, signature.version)); - - auto excessive_leaf_count = *signature.serialised_tree; - std::fill_n(excessive_leaf_count.begin(), sizeof(uint64_t), 0xff); - REQUIRE_THROWS(ccf::validate_recovery_snapshot_merkle_tree_encoding( - excessive_leaf_count, signature.version)); - - auto truncated_tree = *signature.serialised_tree; - truncated_tree.pop_back(); - REQUIRE_THROWS(ccf::validate_recovery_snapshot_merkle_tree_encoding( - truncated_tree, signature.version)); - - auto impossible_flushed_count = *signature.serialised_tree; - std::fill_n( - impossible_flushed_count.begin() + sizeof(uint64_t), - sizeof(uint64_t), - 0xff); - REQUIRE_THROWS(ccf::validate_recovery_snapshot_merkle_tree_encoding( - impossible_flushed_count, signature.version)); - - std::vector unsupported_tree_height( - 2 * sizeof(uint64_t) + 2 * ccf::crypto::Sha256Hash::SIZE); - const auto write_big_endian_uint64 = [&](size_t offset, uint64_t value) { - for (size_t i = 0; i < sizeof(uint64_t); ++i) - { - unsupported_tree_height[offset + i] = - static_cast(value >> (8 * (sizeof(uint64_t) - i - 1))); - } - }; - write_big_endian_uint64(0, 1); - write_big_endian_uint64(sizeof(uint64_t), uint64_t{1} << 63); - REQUIRE_THROWS(ccf::validate_recovery_snapshot_merkle_tree_encoding( - unsupported_tree_height, (uint64_t{1} << 63) + 1)); - write_current_ledger_file(ledger_dir.path / "ledger_1", entries); ccf::CCFConfig::Ledger ledger_config; ledger_config.directory = ledger_dir.path.string(); const auto scan = ccf::scan_recovery_snapshot_ledger_files(ledger_config, encryptor, 1); - REQUIRE(scan.last_signed_idx == 3); REQUIRE(scan.endorsements.size() == 1); REQUIRE(scan.endorsements.front().write_version == 2); - REQUIRE(scan.latest_service_info.has_value()); - REQUIRE(scan.latest_service_info->first == 2); - REQUIRE(scan.latest_service_info->second.cert == target_identity.cert); - - ScopedSnapshotDir tampered_ledger_dir; - auto tampered_entries = entries; - tampered_entries.back().back() ^= 0xff; - write_current_ledger_file( - tampered_ledger_dir.path / "ledger_1", tampered_entries); - ledger_config.directory = tampered_ledger_dir.path.string(); + bool install_called = false; const auto verification_error = ccf::try_verify_and_install_recovery_snapshot( [&]() { - std::ignore = - ccf::scan_recovery_snapshot_ledger_files(ledger_config, encryptor, 1); + const auto target_key = ccf::crypto::make_ec_key_pair()->public_key_der(); + std::ignore = ccf::build_recovery_snapshot_endorsement_chain( + scan.endorsements, target_key, 1); }, [&]() { install_called = true; }); REQUIRE(verification_error.has_value()); REQUIRE_FALSE(install_called); } -TEST_CASE("Recovery snapshot endorsement scan bounds pending endorsements") +TEST_CASE("Recovery snapshot endorsement scan bounds candidate endorsements") { ScopedSnapshotDir ledger_dir; ccf::kv::Store source_store; From 2b4ebdfcc5e9d9b32744e421dfd9fd368ea5fdd5 Mon Sep 17 00:00:00 2001 From: achamayou Date: Fri, 24 Jul 2026 01:25:26 +0100 Subject: [PATCH 20/27] Harden recovery KV deserialization Validate raw fixed-size, size-prefixed, vector, and public-domain lengths before pointer advances, allocation, or copying. Add malformed recovery regressions and pin Ruff to the last repository-clean release after 0.16.0 broke the VMSS check on pre-existing findings. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 Copilot-Session: 4d631113-a6ff-453d-a658-3b46aaaec95a Copilot-Session: 972cd758-c6d2-4d1b-b043-b482e129cb54 --- scripts/python-lint-checks.sh | 5 +- src/kv/generic_serialise_wrapper.h | 9 +- src/kv/raw_serialise.h | 127 +++++++++++++++++++++------ src/kv/test/kv_serialisation.cpp | 136 +++++++++++++++++++++++++++++ src/node/test/snapshotter.cpp | 41 +++++++++ 5 files changed, 289 insertions(+), 29 deletions(-) diff --git a/scripts/python-lint-checks.sh b/scripts/python-lint-checks.sh index 45db15974a23..0b5a3d15d21d 100755 --- a/scripts/python-lint-checks.sh +++ b/scripts/python-lint-checks.sh @@ -22,8 +22,9 @@ if [ ! -x "$(command -v uv)" ]; then exit 1 fi +RUFF_VERSION="0.15.7" if [ $FIX -ne 0 ]; then - uvx ruff check --fix python/ tests/ + uvx --from "ruff==$RUFF_VERSION" ruff check --fix python/ tests/ else - uvx ruff check python/ tests/ + uvx --from "ruff==$RUFF_VERSION" ruff check python/ tests/ fi diff --git a/src/kv/generic_serialise_wrapper.h b/src/kv/generic_serialise_wrapper.h index 13e5645fc165..b2a128f4d2a5 100644 --- a/src/kv/generic_serialise_wrapper.h +++ b/src/kv/generic_serialise_wrapper.h @@ -347,7 +347,14 @@ namespace ccf::kv } serialized::skip(data_, size_, crypto_util->get_header_length()); - auto public_domain_length = serialized::read(data_, size_); + const auto public_domain_length = serialized::read(data_, size_); + if (public_domain_length > size_) + { + throw std::logic_error(fmt::format( + "Public domain length {} exceeds remaining entry size {}", + public_domain_length, + size_)); + } const auto* data_public = data_; public_reader.init(data_public, public_domain_length); diff --git a/src/kv/raw_serialise.h b/src/kv/raw_serialise.h index ef1b1104a2a6..6ac43be675a3 100644 --- a/src/kv/raw_serialise.h +++ b/src/kv/raw_serialise.h @@ -6,7 +6,9 @@ #include "generic_serialise_wrapper.h" #include +#include #include +#include #include namespace ccf::kv @@ -126,21 +128,69 @@ namespace ccf::kv class RawReader { - public: - const uint8_t* data_ptr; + private: + [[nodiscard]] size_t remaining_bytes() const + { + if (data_offset > data_size) + { + throw std::logic_error(fmt::format( + "Raw reader offset {} exceeds data size {}", data_offset, data_size)); + } + return data_size - data_offset; + } + + void require_bytes(size_t required, const char* description) const + { + const auto remaining = remaining_bytes(); + if (required > remaining) + { + throw std::runtime_error(fmt::format( + "Expected {} bytes for {}, found only {}", + required, + description, + remaining)); + } + } + + [[nodiscard]] const uint8_t* current_data() const + { + if (data_ptr == nullptr) + { + if (data_size != 0) + { + throw std::logic_error("Raw reader has non-zero size with null data"); + } + return nullptr; + } + return data_ptr + data_offset; + } + + void advance(size_t size) + { + require_bytes(size, "reader advance"); + data_offset += size; + } + + const uint8_t* data_ptr{nullptr}; size_t data_offset{0}; - size_t data_size; + size_t data_size{0}; + public: /** Reads the next entry, advancing data_offset */ template T read_entry() { - auto remainder = data_size - data_offset; - const auto* data = data_ptr + data_offset; + require_bytes(sizeof(T), "fixed-size entry"); + const auto before = remaining_bytes(); + auto remainder = before; + const auto* data = current_data(); const auto entry = serialized::read(data, remainder); - const auto bytes_read = data_size - data_offset - remainder; - data_offset += bytes_read; + if (remainder > before) + { + throw std::logic_error("Raw reader remaining size increased"); + } + advance(before - remainder); return entry; } @@ -148,17 +198,11 @@ namespace ccf::kv */ size_t read_size_prefixed_entry(size_t& start_offset) { - auto remainder = data_size - data_offset; - auto entry_size = read_entry(); - - if (remainder < entry_size) - { - throw std::runtime_error(fmt::format( - "Expected {} byte entry, found only {}", entry_size, remainder)); - } + const auto entry_size = read_entry(); + require_bytes(entry_size, "size-prefixed entry"); start_offset = data_offset; - data_offset += entry_size; + advance(entry_size); return entry_size; } @@ -166,13 +210,18 @@ namespace ccf::kv RawReader(const RawReader& other) = delete; RawReader& operator=(const RawReader& other) = delete; - RawReader(const uint8_t* data_in_ptr = nullptr, size_t data_in_size = 0) : - data_ptr(data_in_ptr), - data_size(data_in_size) - {} + RawReader(const uint8_t* data_in_ptr = nullptr, size_t data_in_size = 0) + { + init(data_in_ptr, data_in_size); + } void init(const uint8_t* data_in_ptr, size_t data_in_size) { + if (data_in_ptr == nullptr && data_in_size != 0) + { + throw std::invalid_argument( + "Cannot initialise raw reader with null data and non-zero size"); + } data_offset = 0; data_ptr = data_in_ptr; data_size = data_in_size; @@ -186,9 +235,30 @@ namespace ccf::kv std::is_same_v) { size_t entry_offset = 0; - size_t entry_size = read_size_prefixed_entry(entry_offset); + const auto entry_size = read_size_prefixed_entry(entry_offset); + using Element = typename T::value_type; + if (entry_size % sizeof(Element) != 0) + { + throw std::runtime_error(fmt::format( + "Size-prefixed entry of {} bytes is not divisible by element size " + "{}", + entry_size, + sizeof(Element))); + } - T ret(entry_size / sizeof(typename T::value_type)); + T ret; + const auto element_count = entry_size / sizeof(Element); + if (element_count > ret.max_size()) + { + throw std::length_error(fmt::format( + "Size-prefixed entry contains too many elements ({})", + element_count)); + } + ret.resize(element_count); + if (entry_size == 0) + { + return ret; + } auto* data_dest = reinterpret_cast(ret.data()); auto capacity = entry_size; // NOLINTNEXTLINE(readability-suspicious-call-argument) @@ -201,10 +271,15 @@ namespace ccf::kv { T ret{}; auto* data_ = reinterpret_cast(ret.data()); - constexpr size_t size = ret.size() * sizeof(typename T::value_type); + constexpr auto element_count = std::tuple_size_v; + static_assert( + element_count <= + std::numeric_limits::max() / sizeof(typename T::value_type)); + constexpr size_t size = element_count * sizeof(typename T::value_type); + require_bytes(size, "fixed-size array"); auto size_ = size; - serialized::write(data_, size_, data_ptr + data_offset, size); - data_offset += size; + serialized::write(data_, size_, current_data(), size); + advance(size); return ret; } @@ -240,7 +315,7 @@ namespace ccf::kv [[nodiscard]] bool is_eos() const { - return data_offset >= data_size; + return remaining_bytes() == 0; } }; diff --git a/src/kv/test/kv_serialisation.cpp b/src/kv/test/kv_serialisation.cpp index 92078bc24784..c41d1949482f 100644 --- a/src/kv/test/kv_serialisation.cpp +++ b/src/kv/test/kv_serialisation.cpp @@ -2,12 +2,16 @@ // Licensed under the Apache 2.0 License. #include "ds/internal_logger.h" #include "kv/kv_serialiser.h" +#include "kv/raw_serialise.h" #include "kv/store.h" #include "kv/test/null_encryptor.h" #include "kv/test/stub_consensus.h" #include #undef FAIL +#include +#include +#include #include #include @@ -19,6 +23,138 @@ struct MapTypes using StringNum = ccf::kv::Map; }; +static std::vector make_size_prefixed_bytes( + size_t declared_size, size_t actual_size) +{ + std::vector bytes(sizeof(size_t) + actual_size); + std::memcpy(bytes.data(), &declared_size, sizeof(declared_size)); + return bytes; +} + +TEST_CASE( + "Raw reader rejects truncated entries" * doctest::test_suite("serialisation")) +{ + SUBCASE("Fixed-size integral") + { + std::vector bytes(sizeof(uint64_t) - 1); + ccf::kv::RawReader reader(bytes.data(), bytes.size()); + REQUIRE_THROWS(reader.read_next()); + } + + SUBCASE("Fixed-size array") + { + std::vector bytes(ccf::crypto::Sha256Hash::SIZE - 1); + ccf::kv::RawReader reader(bytes.data(), bytes.size()); + REQUIRE_THROWS(reader.read_next()); + } + + SUBCASE("Short size prefix") + { + std::vector bytes(sizeof(size_t) - 1); + ccf::kv::RawReader reader(bytes.data(), bytes.size()); + REQUIRE_THROWS(reader.read_next>()); + } + + SUBCASE("Prefix without payload") + { + auto bytes = make_size_prefixed_bytes(1, 0); + ccf::kv::RawReader reader(bytes.data(), bytes.size()); + REQUIRE_THROWS(reader.read_next>()); + } + + SUBCASE("Nine bytes remaining declare two payload bytes") + { + auto bytes = make_size_prefixed_bytes(2, 1); + ccf::kv::RawReader reader(bytes.data(), bytes.size()); + REQUIRE_THROWS(reader.read_next>()); + } + + SUBCASE("Impossible payload length") + { + auto bytes = + make_size_prefixed_bytes(std::numeric_limits::max(), 0); + ccf::kv::RawReader reader(bytes.data(), bytes.size()); + REQUIRE_THROWS(reader.read_next>()); + } + + SUBCASE("Payload is not a whole number of elements") + { + auto bytes = make_size_prefixed_bytes(1, 1); + ccf::kv::RawReader reader(bytes.data(), bytes.size()); + REQUIRE_THROWS(reader.read_next>()); + } + + SUBCASE("Null data with non-zero size") + { + REQUIRE_THROWS(ccf::kv::RawReader(nullptr, 1)); + } +} + +static std::vector make_public_domain_entry( + size_t declared_public_domain_size, const std::vector& public_domain) +{ + ccf::kv::SerialisedEntryHeader header; + header.set_size(sizeof(size_t) + public_domain.size()); + std::vector entry(sizeof(header) + header.size); + auto* data = entry.data(); + auto remaining = entry.size(); + serialized::write(data, remaining, header); + serialized::write(data, remaining, declared_public_domain_size); + serialized::write( + data, remaining, public_domain.data(), public_domain.size()); + return entry; +} + +TEST_CASE( + "KV deserialiser rejects invalid public domains" * + doctest::test_suite("serialisation")) +{ + auto encryptor = std::make_shared(); + + const auto initialise = [&](const std::vector& entry) { + ccf::kv::RawKvStoreDeserialiser deserialiser( + encryptor, ccf::kv::SecurityDomain::PUBLIC); + ccf::kv::Term term = 0; + ccf::kv::EntryFlags flags = {}; + return deserialiser.init(entry.data(), entry.size(), term, flags, false); + }; + + SUBCASE("Public domain exceeds remaining entry") + { + const auto entry = make_public_domain_entry(2, {0}); + REQUIRE_THROWS(initialise(entry)); + } + + SUBCASE("Public domain length prefix is truncated") + { + ccf::kv::SerialisedEntryHeader header; + header.set_size(sizeof(size_t) - 1); + std::vector entry(sizeof(header) + header.size); + std::memcpy(entry.data(), &header, sizeof(header)); + REQUIRE_THROWS(initialise(entry)); + } + + SUBCASE("Public domain length is impossible") + { + const auto entry = + make_public_domain_entry(std::numeric_limits::max(), {}); + REQUIRE_THROWS(initialise(entry)); + } + + SUBCASE("Claims digest is truncated") + { + std::vector public_domain( + sizeof(ccf::kv::EntryType) + sizeof(ccf::kv::Version)); + auto* data = public_domain.data(); + *data++ = static_cast(ccf::kv::EntryType::WriteSetWithClaims); + const ccf::kv::Version version = 1; + std::memcpy(data, &version, sizeof(version)); + const auto entry = + make_public_domain_entry(public_domain.size(), public_domain); + REQUIRE_THROWS(initialise(entry)); + } +} + TEST_CASE( "Serialise/deserialise public map only" * doctest::test_suite("serialisation")) diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index 57f888a63527..99dcb367c064 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -150,6 +150,47 @@ TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") REQUIRE_NOTHROW(ccf::verify_snapshot_seqno(first_entry, encryptor, 1)); REQUIRE_THROWS(ccf::verify_snapshot_seqno(first_entry, encryptor, 2)); + auto malformed_entry = entries.front(); + const auto public_domain_size_offset = + sizeof(ccf::kv::SerialisedEntryHeader) + encryptor->get_header_length(); + const auto invalid_public_domain_size = malformed_entry.size(); + std::memcpy( + malformed_entry.data() + public_domain_size_offset, + &invalid_public_domain_size, + sizeof(invalid_public_domain_size)); + + ccf::kv::Store untouched_store; + const auto original_version = untouched_store.current_version(); + const auto original_readiness = untouched_store.get_readiness(); + + bool snapshot_install_called = false; + const ccf::SnapshotSegments malformed_snapshot{ + std::span(malformed_entry), {}}; + const auto snapshot_error = ccf::try_verify_and_install_recovery_snapshot( + [&]() { ccf::verify_snapshot_seqno(malformed_snapshot, encryptor, 1); }, + [&]() { snapshot_install_called = true; }); + REQUIRE(snapshot_error.has_value()); + REQUIRE_FALSE(snapshot_install_called); + REQUIRE(untouched_store.current_version() == original_version); + REQUIRE(untouched_store.get_readiness() == original_readiness); + + ScopedSnapshotDir malformed_ledger_dir; + write_current_ledger_file( + malformed_ledger_dir.path / "ledger_1", {malformed_entry}); + ccf::CCFConfig::Ledger malformed_ledger_config; + malformed_ledger_config.directory = malformed_ledger_dir.path.string(); + bool scanner_install_called = false; + const auto scanner_error = ccf::try_verify_and_install_recovery_snapshot( + [&]() { + std::ignore = ccf::scan_recovery_snapshot_ledger_files( + malformed_ledger_config, encryptor, 0); + }, + [&]() { scanner_install_called = true; }); + REQUIRE(scanner_error.has_value()); + REQUIRE_FALSE(scanner_install_called); + REQUIRE(untouched_store.current_version() == original_version); + REQUIRE(untouched_store.get_readiness() == original_readiness); + write_current_ledger_file(ledger_dir.path / "ledger_1", entries); ccf::CCFConfig::Ledger ledger_config; From f40ed1245fbbac8034f028d80157585706fc7710 Mon Sep 17 00:00:00 2001 From: achamayou Date: Fri, 24 Jul 2026 02:42:32 +0100 Subject: [PATCH 21/27] Fallback from malformed recovery snapshots Treat recovery snapshot discovery and structural validation as untrusted candidate checks before Store installation, while preserving fatal Join behavior and install exception propagation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 Copilot-Session: 4d631113-a6ff-453d-a658-3b46aaaec95a Copilot-Session: 972cd758-c6d2-4d1b-b043-b482e129cb54 --- CHANGELOG.md | 2 +- src/node/node_state.h | 97 ++++++++++++++++--------- src/node/test/snapshotter.cpp | 34 +++++++++ tests/recovery_snapshot_endorsements.py | 97 ++++++++++++++++++++++++- 4 files changed, 192 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d759e2cc7c9c..a3fc22fa6017 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added -- Recovery can now use a COSE snapshot signed by an earlier service identity after one or more disaster recoveries. Before deserialising the snapshot, the node reads previous-service-identity endorsement candidates from the public ledger suffix, validates a complete chain against the operator-provided identity, and retains it only for the current recovery attempt. Invalid or incomplete chains fall back to full-ledger replay (#8092). +- Recovery can now use a COSE snapshot signed by an earlier service identity after one or more disaster recoveries. Before deserialising the snapshot, the node reads previous-service-identity endorsement candidates from the public ledger suffix, validates a complete chain against the operator-provided identity, and retains it only for the current recovery attempt. Malformed snapshot candidates and invalid or incomplete chains fall back to full-ledger replay (#8092). ### Changed diff --git a/src/node/node_state.h b/src/node/node_state.h index 5f457c5136ac..ba6209ed9075 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -517,10 +517,69 @@ namespace ccf directories.emplace_back(read_only_dir.value()); } - const auto committed_snapshots = - snapshots::find_committed_snapshots_in_directories(directories); + std::vector> committed_snapshots; + if (start_type == StartType::Recover) + { + try + { + committed_snapshots = + snapshots::find_committed_snapshots_in_directories(directories); + } + catch (const std::exception& e) + { + LOG_FAIL_FMT( + "Unable to discover a valid recovery snapshot: {}. Recovering " + "without a startup snapshot.", + e.what()); + return; + } + } + else + { + committed_snapshots = + snapshots::find_committed_snapshots_in_directories(directories); + } + for (const auto& [snapshot_seqno, snapshot_path] : committed_snapshots) { + if (start_type == StartType::Recover) + { + try + { + auto snapshot_data = files::slurp(snapshot_path); + + LOG_INFO_FMT( + "Found latest local snapshot file: {} (size: {})", + snapshot_path, + snapshot_data.size()); + + const auto segments = separate_segments(snapshot_data); + // Validate the receipt structure and claims now, but defer the + // identity check until the public-ledger endorsement scan. + verify_snapshot(segments); + + startup_snapshot_info = std::make_unique( + snapshot_seqno, std::move(snapshot_data)); + LOG_INFO_FMT( + "Selected recovery snapshot {} for previous-service-identity " + "verification", + snapshot_path.string()); + return; + } + catch (const std::exception& e) + { + LOG_FAIL_FMT( + "Error while verifying recovery snapshot candidate {}: {}", + snapshot_path.string(), + e.what()); + LOG_INFO_FMT( + "Leaving unusable recovery snapshot {} untouched and looking " + "for the next candidate", + snapshot_path.string()); + continue; + } + } + auto snapshot_data = files::slurp(snapshot_path); LOG_INFO_FMT( @@ -528,37 +587,18 @@ namespace ccf snapshot_path, snapshot_data.size()); - // A structurally invalid snapshot is a fatal startup error, rather than - // something to skip after potentially consuming an ambiguous prefix. + // Structurally invalid snapshots remain fatal for Join. const auto segments = separate_segments(snapshot_data); try { - if (start_type == StartType::Recover) - { - // Validate the receipt structure and claims now, but defer the - // identity check until the public-ledger endorsement scan. - verify_snapshot(segments); - } - else - { - verify_snapshot(segments, config.recover.previous_service_identity); - } + verify_snapshot(segments, config.recover.previous_service_identity); } catch (const std::exception& e) { LOG_FAIL_FMT( "Error while verifying {}: {}", snapshot_path.string(), e.what()); - if (start_type == StartType::Recover) - { - LOG_INFO_FMT( - "Leaving unusable recovery snapshot {} untouched and looking " - "for the next candidate", - snapshot_path.string()); - continue; - } - const auto dir = snapshot_path.parent_path(); const auto file_name = snapshot_path.filename(); @@ -590,17 +630,6 @@ namespace ccf continue; } - if (start_type == StartType::Recover) - { - startup_snapshot_info = std::make_unique( - snapshot_seqno, std::move(snapshot_data)); - LOG_INFO_FMT( - "Selected recovery snapshot {} for previous-service-identity " - "verification", - snapshot_path.string()); - return; - } - set_startup_snapshot(snapshot_seqno, std::move(snapshot_data)); return; } diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index 99dcb367c064..272212689b05 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -106,6 +106,40 @@ TEST_CASE("Recovery snapshot installation failures do not request fallback") REQUIRE_FALSE(install_called); } +TEST_CASE("Malformed recovery snapshot candidates do not install") +{ + std::vector zeroed_snapshot(64); + std::vector truncated_header(4); + + ccf::kv::SerialisedEntryHeader oversized_header; + oversized_header.set_size(1024); + std::vector oversized_body(sizeof(oversized_header)); + std::memcpy( + oversized_body.data(), &oversized_header, sizeof(oversized_header)); + + for (const auto& snapshot : + {zeroed_snapshot, truncated_header, oversized_body}) + { + ccf::kv::Store store; + const auto original_version = store.current_version(); + const auto original_readiness = store.get_readiness(); + bool install_called = false; + + const auto verification_error = + ccf::try_verify_and_install_recovery_snapshot( + [&]() { + const auto segments = ccf::separate_segments(snapshot); + ccf::verify_snapshot(segments); + }, + [&]() { install_called = true; }); + + REQUIRE(verification_error.has_value()); + REQUIRE_FALSE(install_called); + REQUIRE(store.current_version() == original_version); + REQUIRE(store.get_readiness() == original_readiness); + } +} + TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") { ScopedSnapshotDir ledger_dir; diff --git a/tests/recovery_snapshot_endorsements.py b/tests/recovery_snapshot_endorsements.py index 3a10a1ab1699..1235a1eeeb71 100644 --- a/tests/recovery_snapshot_endorsements.py +++ b/tests/recovery_snapshot_endorsements.py @@ -121,6 +121,51 @@ def _assert_node_snapshot_unchanged( assert hashlib.sha256(snapshot_file.read()).digest() == expected_snapshot_digest +def _assert_malformed_snapshot_falls_back( + base_network, + args, + label, + ledger_dir, + committed_ledger_dirs, + target_identity_file, + snapshot_name, + snapshot_bytes, + next_node_id, + expected_log, +): + snapshots_dir = os.path.join(base_network.common_dir, label) + shutil.rmtree(snapshots_dir, ignore_errors=True) + os.makedirs(snapshots_dir) + snapshot_path = os.path.join(snapshots_dir, snapshot_name) + with open(snapshot_path, "wb") as snapshot_file: + snapshot_file.write(snapshot_bytes) + snapshot_digest = hashlib.sha256(snapshot_bytes).digest() + + attempt = _start_recovery_attempt( + base_network, + args, + label, + ledger_dir, + committed_ledger_dirs, + snapshots_dir, + target_identity_file, + next_node_id, + ) + try: + primary, _ = attempt.find_primary() + logs = _logs(primary) + assert expected_log in logs + assert "Starting to read public ledger" in logs + assert "Setting startup snapshot seqno" not in logs + assert "Deserialising snapshot (size:" not in logs + assert "Snapshot successfully deserialised" not in logs + _assert_node_snapshot_unchanged( + attempt, primary, snapshot_name, snapshot_digest + ) + finally: + _stop_incomplete_recovery(attempt) + + def run_recovery_snapshot_endorsements(args): with infra.network.network( args.nodes, @@ -164,7 +209,8 @@ def run_recovery_snapshot_endorsements(args): os.makedirs(source_snapshots_dir) source_snapshot_path = shutil.copy(original_snapshot_path, source_snapshots_dir) with open(source_snapshot_path, "rb") as snapshot_file: - snapshot_digest = hashlib.sha256(snapshot_file.read()).digest() + source_snapshot_bytes = snapshot_file.read() + snapshot_digest = hashlib.sha256(source_snapshot_bytes).digest() first_recovery, first_args = _recover_and_open( initial_network, args, f"{args.label}_identity_1" @@ -248,9 +294,54 @@ def run_recovery_snapshot_endorsements(args): finally: _stop_incomplete_recovery(fallback_attempt) + malformed_snapshots = ( + ( + "zeroed_snapshot", + snapshot_name, + bytes(64), + "transaction size should not be zero", + ), + ( + "truncated_snapshot_header", + snapshot_name, + source_snapshot_bytes[:4], + "Insufficient space", + ), + ( + "oversized_snapshot_body", + snapshot_name, + source_snapshot_bytes[:8], + "exceeds available buffer size", + ), + ( + "malformed_snapshot_filename", + "snapshot_not-a-seqno_1.committed", + source_snapshot_bytes, + "Unable to discover a valid recovery snapshot", + ), + ) + for offset, ( + malformed_label, + malformed_name, + malformed_bytes, + expected_log, + ) in enumerate(malformed_snapshots, start=102): + _assert_malformed_snapshot_falls_back( + second_recovery, + second_args, + f"{args.label}_{malformed_label}", + current_ledger_dir, + committed_ledger_dirs, + target_identity_file, + malformed_name, + malformed_bytes, + offset, + expected_log, + ) + LOG.success( - "In-memory recovery snapshot endorsement validation and fallback " - "succeeded" + "In-memory recovery snapshot endorsement validation, malformed " + "candidate rejection, and fallback succeeded" ) From 2c087e5b7cb712e74cc955d75375a680f0903b4d Mon Sep 17 00:00:00 2001 From: achamayou Date: Fri, 24 Jul 2026 03:17:36 +0100 Subject: [PATCH 22/27] Throw on recovery snapshot read failures Use a recovery-specific exact-length reader so missing, non-regular, raced, or unreadable snapshot candidates are rejected by the pre-install fallback path rather than terminating the process. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 Copilot-Session: 4d631113-a6ff-453d-a658-3b46aaaec95a Copilot-Session: 972cd758-c6d2-4d1b-b043-b482e129cb54 --- src/node/node_state.h | 3 +- src/node/snapshot_serdes.h | 68 +++++++++++++++++++++++++ src/node/test/snapshotter.cpp | 8 +++ tests/recovery_snapshot_endorsements.py | 36 ++++++++++--- 4 files changed, 108 insertions(+), 7 deletions(-) diff --git a/src/node/node_state.h b/src/node/node_state.h index ba6209ed9075..1c9d11087ff0 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -546,7 +546,8 @@ namespace ccf { try { - auto snapshot_data = files::slurp(snapshot_path); + auto snapshot_data = + read_recovery_snapshot_candidate(snapshot_path); LOG_INFO_FMT( "Found latest local snapshot file: {} (size: {})", diff --git a/src/node/snapshot_serdes.h b/src/node/snapshot_serdes.h index 28eb8f605249..eb8452608dcc 100644 --- a/src/node/snapshot_serdes.h +++ b/src/node/snapshot_serdes.h @@ -18,6 +18,9 @@ #include "node/rpc/network_identity_chain_helpers.h" #include "node/tx_receipt_impl.h" +#include +#include +#include #include #include #include @@ -41,6 +44,71 @@ namespace ccf std::span receipt; }; + static std::vector read_recovery_snapshot_candidate( + const std::filesystem::path& path) + { + std::error_code ec; + const auto is_regular_file = std::filesystem::is_regular_file(path, ec); + if (ec || !is_regular_file) + { + throw std::logic_error(fmt::format( + "Recovery snapshot candidate {} is not a regular file{}", + path.string(), + ec ? fmt::format(": {}", ec.message()) : "")); + } + + std::ifstream file(path, std::ios::binary | std::ios::ate); + if (!file) + { + throw std::logic_error(fmt::format( + "Unable to open recovery snapshot candidate {}", path.string())); + } + + const auto end = file.tellg(); + if (end < 0) + { + throw std::logic_error(fmt::format( + "Unable to determine recovery snapshot candidate size {}", + path.string())); + } + const auto size = static_cast(end); + if constexpr (sizeof(size_t) < sizeof(uint64_t)) + { + if (size > std::numeric_limits::max()) + { + throw std::logic_error(fmt::format( + "Recovery snapshot candidate {} is too large", path.string())); + } + } + if ( + size > static_cast(std::numeric_limits::max())) + { + throw std::logic_error(fmt::format( + "Recovery snapshot candidate {} is too large", path.string())); + } + + std::vector data(static_cast(size)); + file.seekg(0, std::ios::beg); + if (!file) + { + throw std::logic_error(fmt::format( + "Unable to seek recovery snapshot candidate {}", path.string())); + } + if (!data.empty()) + { + file.read( + reinterpret_cast(data.data()), + static_cast(data.size())); + if (!file || file.gcount() != static_cast(data.size())) + { + throw std::logic_error(fmt::format( + "Unable to read complete recovery snapshot candidate {}", + path.string())); + } + } + return data; + } + static SnapshotSegments separate_segments( const std::vector& snapshot) { diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index 272212689b05..9d8d37a98534 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -138,6 +138,14 @@ TEST_CASE("Malformed recovery snapshot candidates do not install") REQUIRE(store.current_version() == original_version); REQUIRE(store.get_readiness() == original_readiness); } + + ScopedSnapshotDir snapshot_dir; + REQUIRE_THROWS(ccf::read_recovery_snapshot_candidate( + snapshot_dir.path / "missing_snapshot")); + const auto directory_candidate = snapshot_dir.path / "snapshot_1_2.committed"; + fs::create_directory(directory_candidate); + files::dump(std::vector{1}, directory_candidate / "contents"); + REQUIRE_THROWS(ccf::read_recovery_snapshot_candidate(directory_candidate)); } TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") diff --git a/tests/recovery_snapshot_endorsements.py b/tests/recovery_snapshot_endorsements.py index 1235a1eeeb71..b2c3b6eece81 100644 --- a/tests/recovery_snapshot_endorsements.py +++ b/tests/recovery_snapshot_endorsements.py @@ -132,14 +132,21 @@ def _assert_malformed_snapshot_falls_back( snapshot_bytes, next_node_id, expected_log, + snapshot_is_directory=False, ): snapshots_dir = os.path.join(base_network.common_dir, label) shutil.rmtree(snapshots_dir, ignore_errors=True) os.makedirs(snapshots_dir) snapshot_path = os.path.join(snapshots_dir, snapshot_name) - with open(snapshot_path, "wb") as snapshot_file: - snapshot_file.write(snapshot_bytes) - snapshot_digest = hashlib.sha256(snapshot_bytes).digest() + if snapshot_is_directory: + os.makedirs(snapshot_path) + marker_path = os.path.join(snapshot_path, "contents") + with open(marker_path, "wb") as marker_file: + marker_file.write(snapshot_bytes) + else: + with open(snapshot_path, "wb") as snapshot_file: + snapshot_file.write(snapshot_bytes) + snapshot_digest = hashlib.sha256(snapshot_bytes).digest() attempt = _start_recovery_attempt( base_network, @@ -159,9 +166,13 @@ def _assert_malformed_snapshot_falls_back( assert "Setting startup snapshot seqno" not in logs assert "Deserialising snapshot (size:" not in logs assert "Snapshot successfully deserialised" not in logs - _assert_node_snapshot_unchanged( - attempt, primary, snapshot_name, snapshot_digest - ) + if snapshot_is_directory: + with open(marker_path, "rb") as marker_file: + assert marker_file.read() == snapshot_bytes + else: + _assert_node_snapshot_unchanged( + attempt, primary, snapshot_name, snapshot_digest + ) finally: _stop_incomplete_recovery(attempt) @@ -300,24 +311,35 @@ def run_recovery_snapshot_endorsements(args): snapshot_name, bytes(64), "transaction size should not be zero", + False, ), ( "truncated_snapshot_header", snapshot_name, source_snapshot_bytes[:4], "Insufficient space", + False, ), ( "oversized_snapshot_body", snapshot_name, source_snapshot_bytes[:8], "exceeds available buffer size", + False, ), ( "malformed_snapshot_filename", "snapshot_not-a-seqno_1.committed", source_snapshot_bytes, "Unable to discover a valid recovery snapshot", + False, + ), + ( + "unreadable_snapshot_candidate", + snapshot_name, + b"not a snapshot", + "is not a regular file", + True, ), ) for offset, ( @@ -325,6 +347,7 @@ def run_recovery_snapshot_endorsements(args): malformed_name, malformed_bytes, expected_log, + snapshot_is_directory, ) in enumerate(malformed_snapshots, start=102): _assert_malformed_snapshot_falls_back( second_recovery, @@ -337,6 +360,7 @@ def run_recovery_snapshot_endorsements(args): malformed_bytes, offset, expected_log, + snapshot_is_directory, ) LOG.success( From 983111e4ffeab95cd737952ed173aafa6171b8b7 Mon Sep 17 00:00:00 2001 From: achamayou Date: Fri, 24 Jul 2026 04:24:53 +0100 Subject: [PATCH 23/27] Validate COSE receipt proof hashes Require exact SHA-256 lengths for receipt leaf digests and every Merkle proof sibling before fixed-span construction, with unit and real recovery fallback regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 Copilot-Session: 4d631113-a6ff-453d-a658-3b46aaaec95a Copilot-Session: 972cd758-c6d2-4d1b-b043-b482e129cb54 --- src/crypto/test/cose.cpp | 68 +++++++++++++++++++++++++ src/node/cose_common.h | 58 +++++++++++---------- tests/recovery_snapshot_endorsements.py | 23 +++++++++ 3 files changed, 123 insertions(+), 26 deletions(-) diff --git a/src/crypto/test/cose.cpp b/src/crypto/test/cose.cpp index 0bec8349bb23..0eae26f97b81 100644 --- a/src/crypto/test/cose.cpp +++ b/src/crypto/test/cose.cpp @@ -10,6 +10,7 @@ #include "crypto/openssl/cose_verifier.h" #include "node/cose_common.h" +#include #include #include #include @@ -232,6 +233,50 @@ TEST_CASE("Decode CCF COSE receipt") const auto receipt_bytes = ccf::ds::from_hex(receipt_hex); + enum class ProofHashField + { + WriteSetDigest, + ClaimsDigest, + Sibling, + }; + const auto with_proof_hash_size = [&](ProofHashField field, size_t size) { + using namespace ccf::cbor; + + auto receipt = parse(receipt_bytes); + const auto& envelope = receipt->tag_at(ccf::cbor::tag::COSE_SIGN_1); + const auto& unprotected = envelope->array_at(1); + const auto& vdp = + unprotected->map_at(make_signed(ccf::cose::header::iana::VDP)); + const auto& proofs = + vdp->map_at(make_signed(ccf::cose::header::iana::INCLUSION_PROOFS)); + auto proof = parse(proofs->array_at(0)->as_bytes()); + + std::vector replacement(size, 0x42); + std::array empty_replacement{}; + const std::span replacement_span = replacement.empty() ? + std::span(empty_replacement.data(), 0) : + std::span(replacement); + if (field == ProofHashField::Sibling) + { + const auto& path = proof->map_at( + make_signed(ccf::MerkleProofLabel::MERKLE_PROOF_PATH_LABEL)); + const auto& link = path->array_at(0); + std::get(link->value).items.at(1) = make_bytes(replacement_span); + } + else + { + const auto& leaf = proof->map_at( + make_signed(ccf::MerkleProofLabel::MERKLE_PROOF_LEAF_LABEL)); + const auto index = field == ProofHashField::WriteSetDigest ? 0 : 2; + std::get(leaf->value).items.at(index) = + make_bytes(replacement_span); + } + + auto serialised_proof = serialize(proof); + std::get(proofs->value).items.at(0) = make_bytes(serialised_proof); + return serialize(receipt); + }; + auto receipt = ccf::cose::decode_ccf_receipt(receipt_bytes, /*recompute_root*/ true); @@ -249,6 +294,29 @@ TEST_CASE("Decode CCF COSE receipt") REQUIRE( ccf::ds::to_hex(receipt.merkle_root) == "209f5aefb0f45d7647c917337044c44a1b848fe833fa2869d016bea797d79a9e"); + + for (const auto size : {size_t{0}, size_t{1}, size_t{31}, size_t{33}}) + { + const auto malformed = with_proof_hash_size(ProofHashField::Sibling, size); + REQUIRE_THROWS_AS( + ccf::cose::decode_ccf_receipt(malformed, true), + ccf::cose::COSEDecodeError); + } + + const auto valid_sibling = with_proof_hash_size(ProofHashField::Sibling, 32); + REQUIRE_NOTHROW(ccf::cose::decode_ccf_receipt(valid_sibling, true)); + + for (const auto field : + {ProofHashField::WriteSetDigest, ProofHashField::ClaimsDigest}) + { + for (const auto size : {size_t{0}, size_t{31}, size_t{33}}) + { + const auto malformed = with_proof_hash_size(field, size); + REQUIRE_THROWS_AS( + ccf::cose::decode_ccf_receipt(malformed, true), + ccf::cose::COSEDecodeError); + } + } } TEST_CASE("make_cose_verifier_any_cert with PEM and DER certificates") diff --git a/src/node/cose_common.h b/src/node/cose_common.h index a25bdc7ed8c1..a37dd442036e 100644 --- a/src/node/cose_common.h +++ b/src/node/cose_common.h @@ -254,47 +254,50 @@ namespace ccf::cose std::vector claims_digest; }; - static std::vector recompute_merkle_root(const MerkleProof& proof) + static void validate_sha256_bytes( + std::span bytes, std::string_view field) { - auto ce_digest = ccf::crypto::Sha256Hash(proof.leaf.commit_evidence); - - if (proof.leaf.write_set_digest.size() != ccf::crypto::Sha256Hash::SIZE) + if (bytes.size() != ccf::crypto::Sha256Hash::SIZE) { throw COSEDecodeError(fmt::format( - "Unsupported write set digest size in Merkle proof leaf: {}", - proof.leaf.write_set_digest.size())); - } - if (proof.leaf.claims_digest.size() != ccf::crypto::Sha256Hash::SIZE) - { - throw COSEDecodeError(fmt::format( - "Unsupported claims digest size in Merkle proof leaf: {}", - proof.leaf.claims_digest.size())); + "Unsupported {} size: {} (expected {})", + field, + bytes.size(), + ccf::crypto::Sha256Hash::SIZE)); } + } + + static ccf::crypto::Sha256Hash sha256_from_bytes( + std::span bytes, std::string_view field) + { + validate_sha256_bytes(bytes, field); + const std::span fixed{ + bytes.data(), ccf::crypto::Sha256Hash::SIZE}; + return ccf::crypto::Sha256Hash::from_span(fixed); + } + + static std::vector recompute_merkle_root(const MerkleProof& proof) + { + auto ce_digest = ccf::crypto::Sha256Hash(proof.leaf.commit_evidence); - std::span wsd{ - proof.leaf.write_set_digest.data(), ccf::crypto::Sha256Hash::SIZE}; - std::span cd{ - proof.leaf.claims_digest.data(), ccf::crypto::Sha256Hash::SIZE}; auto leaf_digest = ccf::crypto::Sha256Hash( - ccf::crypto::Sha256Hash::from_span(wsd), + sha256_from_bytes( + proof.leaf.write_set_digest, "Merkle proof write set digest"), ce_digest, - ccf::crypto::Sha256Hash::from_span(cd)); + sha256_from_bytes( + proof.leaf.claims_digest, "Merkle proof claims digest")); for (const auto& element : proof.path) { + const auto sibling = + sha256_from_bytes(element.second, "Merkle proof sibling"); if (element.first != 0) { - std::span sibling{ - element.second.data(), ccf::crypto::Sha256Hash::SIZE}; - leaf_digest = ccf::crypto::Sha256Hash( - ccf::crypto::Sha256Hash::from_span(sibling), leaf_digest); + leaf_digest = ccf::crypto::Sha256Hash(sibling, leaf_digest); } else { - std::span sibling{ - element.second.data(), ccf::crypto::Sha256Hash::SIZE}; - leaf_digest = ccf::crypto::Sha256Hash( - leaf_digest, ccf::crypto::Sha256Hash::from_span(sibling)); + leaf_digest = ccf::crypto::Sha256Hash(leaf_digest, sibling); } } @@ -415,6 +418,7 @@ namespace ccf::cose [&]() { const auto& bytes = leaf->array_at(ccf::MerkleProofPathBranch::LEFT)->as_bytes(); + validate_sha256_bytes(bytes, "Merkle proof write set digest"); proof.leaf.write_set_digest.assign(bytes.begin(), bytes.end()); }, "Parse leaf at wsd"); @@ -428,6 +432,7 @@ namespace ccf::cose rethrow_with_msg( [&]() { const auto& bytes = leaf->array_at(2)->as_bytes(); + validate_sha256_bytes(bytes, "Merkle proof claims digest"); proof.leaf.claims_digest.assign(bytes.begin(), bytes.end()); }, "Parse leaf at cd"); @@ -460,6 +465,7 @@ namespace ccf::cose rethrow_with_msg( [&]() { const auto& bytes = link->array_at(1)->as_bytes(); + validate_sha256_bytes(bytes, "Merkle proof sibling"); path_item.second.assign(bytes.begin(), bytes.end()); }, "Parse path element at hash"); diff --git a/tests/recovery_snapshot_endorsements.py b/tests/recovery_snapshot_endorsements.py index b2c3b6eece81..71590f0f741b 100644 --- a/tests/recovery_snapshot_endorsements.py +++ b/tests/recovery_snapshot_endorsements.py @@ -6,6 +6,7 @@ import os import shutil +import cbor2 import ccf.ledger import infra.e2e_args import infra.logging_app as app @@ -121,6 +122,21 @@ def _assert_node_snapshot_unchanged( assert hashlib.sha256(snapshot_file.read()).digest() == expected_snapshot_digest +def _snapshot_with_proof_sibling_size(snapshot_bytes, sibling_size): + header_size = ccf.ledger.TransactionHeader.get_size() + header = ccf.ledger.TransactionHeader(snapshot_bytes[:header_size]) + receipt_pos = header_size + header.size + receipt = cbor2.loads(snapshot_bytes[receipt_pos:]) + proofs = receipt.value[1][396][-1] + assert proofs + proof = cbor2.loads(proofs[0]) + path = proof[2] + assert path + path[0][1] = bytes(sibling_size) + proofs[0] = cbor2.dumps(proof) + return snapshot_bytes[:receipt_pos] + cbor2.dumps(receipt) + + def _assert_malformed_snapshot_falls_back( base_network, args, @@ -341,6 +357,13 @@ def run_recovery_snapshot_endorsements(args): "is not a regular file", True, ), + ( + "short_merkle_proof_sibling", + snapshot_name, + _snapshot_with_proof_sibling_size(source_snapshot_bytes, 1), + "Unsupported Merkle proof sibling size", + False, + ), ) for offset, ( malformed_label, From 9a1f22b5b48ab1baa8004d6c55b786b7904fcd18 Mon Sep 17 00:00:00 2001 From: achamayou Date: Fri, 24 Jul 2026 08:44:56 +0100 Subject: [PATCH 24/27] Restore unpinned Python lint Remove the PR-only Ruff version pin and return the lint entrypoint to current-main behavior without changing functional recovery code or tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 Copilot-Session: 4d631113-a6ff-453d-a658-3b46aaaec95a Copilot-Session: 972cd758-c6d2-4d1b-b043-b482e129cb54 --- scripts/python-lint-checks.sh | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/python-lint-checks.sh b/scripts/python-lint-checks.sh index 0b5a3d15d21d..45db15974a23 100755 --- a/scripts/python-lint-checks.sh +++ b/scripts/python-lint-checks.sh @@ -22,9 +22,8 @@ if [ ! -x "$(command -v uv)" ]; then exit 1 fi -RUFF_VERSION="0.15.7" if [ $FIX -ne 0 ]; then - uvx --from "ruff==$RUFF_VERSION" ruff check --fix python/ tests/ + uvx ruff check --fix python/ tests/ else - uvx --from "ruff==$RUFF_VERSION" ruff check python/ tests/ + uvx ruff check python/ tests/ fi From fb4942a9edaec3d183df3f59efc27cdd1abc4ff4 Mon Sep 17 00:00:00 2001 From: achamayou Date: Fri, 24 Jul 2026 14:22:21 +0100 Subject: [PATCH 25/27] Remove generic receipt proof hardening Restore shared COSE receipt decoding and its existing tests to current-main behavior, and remove only the malformed proof-sibling recovery scenario for separate follow-up work. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 Copilot-Session: 4d631113-a6ff-453d-a658-3b46aaaec95a Copilot-Session: 972cd758-c6d2-4d1b-b043-b482e129cb54 --- src/crypto/test/cose.cpp | 68 ------------------------- src/node/cose_common.h | 58 ++++++++++----------- tests/recovery_snapshot_endorsements.py | 23 --------- 3 files changed, 26 insertions(+), 123 deletions(-) diff --git a/src/crypto/test/cose.cpp b/src/crypto/test/cose.cpp index 0eae26f97b81..0bec8349bb23 100644 --- a/src/crypto/test/cose.cpp +++ b/src/crypto/test/cose.cpp @@ -10,7 +10,6 @@ #include "crypto/openssl/cose_verifier.h" #include "node/cose_common.h" -#include #include #include #include @@ -233,50 +232,6 @@ TEST_CASE("Decode CCF COSE receipt") const auto receipt_bytes = ccf::ds::from_hex(receipt_hex); - enum class ProofHashField - { - WriteSetDigest, - ClaimsDigest, - Sibling, - }; - const auto with_proof_hash_size = [&](ProofHashField field, size_t size) { - using namespace ccf::cbor; - - auto receipt = parse(receipt_bytes); - const auto& envelope = receipt->tag_at(ccf::cbor::tag::COSE_SIGN_1); - const auto& unprotected = envelope->array_at(1); - const auto& vdp = - unprotected->map_at(make_signed(ccf::cose::header::iana::VDP)); - const auto& proofs = - vdp->map_at(make_signed(ccf::cose::header::iana::INCLUSION_PROOFS)); - auto proof = parse(proofs->array_at(0)->as_bytes()); - - std::vector replacement(size, 0x42); - std::array empty_replacement{}; - const std::span replacement_span = replacement.empty() ? - std::span(empty_replacement.data(), 0) : - std::span(replacement); - if (field == ProofHashField::Sibling) - { - const auto& path = proof->map_at( - make_signed(ccf::MerkleProofLabel::MERKLE_PROOF_PATH_LABEL)); - const auto& link = path->array_at(0); - std::get(link->value).items.at(1) = make_bytes(replacement_span); - } - else - { - const auto& leaf = proof->map_at( - make_signed(ccf::MerkleProofLabel::MERKLE_PROOF_LEAF_LABEL)); - const auto index = field == ProofHashField::WriteSetDigest ? 0 : 2; - std::get(leaf->value).items.at(index) = - make_bytes(replacement_span); - } - - auto serialised_proof = serialize(proof); - std::get(proofs->value).items.at(0) = make_bytes(serialised_proof); - return serialize(receipt); - }; - auto receipt = ccf::cose::decode_ccf_receipt(receipt_bytes, /*recompute_root*/ true); @@ -294,29 +249,6 @@ TEST_CASE("Decode CCF COSE receipt") REQUIRE( ccf::ds::to_hex(receipt.merkle_root) == "209f5aefb0f45d7647c917337044c44a1b848fe833fa2869d016bea797d79a9e"); - - for (const auto size : {size_t{0}, size_t{1}, size_t{31}, size_t{33}}) - { - const auto malformed = with_proof_hash_size(ProofHashField::Sibling, size); - REQUIRE_THROWS_AS( - ccf::cose::decode_ccf_receipt(malformed, true), - ccf::cose::COSEDecodeError); - } - - const auto valid_sibling = with_proof_hash_size(ProofHashField::Sibling, 32); - REQUIRE_NOTHROW(ccf::cose::decode_ccf_receipt(valid_sibling, true)); - - for (const auto field : - {ProofHashField::WriteSetDigest, ProofHashField::ClaimsDigest}) - { - for (const auto size : {size_t{0}, size_t{31}, size_t{33}}) - { - const auto malformed = with_proof_hash_size(field, size); - REQUIRE_THROWS_AS( - ccf::cose::decode_ccf_receipt(malformed, true), - ccf::cose::COSEDecodeError); - } - } } TEST_CASE("make_cose_verifier_any_cert with PEM and DER certificates") diff --git a/src/node/cose_common.h b/src/node/cose_common.h index a37dd442036e..a25bdc7ed8c1 100644 --- a/src/node/cose_common.h +++ b/src/node/cose_common.h @@ -254,50 +254,47 @@ namespace ccf::cose std::vector claims_digest; }; - static void validate_sha256_bytes( - std::span bytes, std::string_view field) + static std::vector recompute_merkle_root(const MerkleProof& proof) { - if (bytes.size() != ccf::crypto::Sha256Hash::SIZE) + auto ce_digest = ccf::crypto::Sha256Hash(proof.leaf.commit_evidence); + + if (proof.leaf.write_set_digest.size() != ccf::crypto::Sha256Hash::SIZE) { throw COSEDecodeError(fmt::format( - "Unsupported {} size: {} (expected {})", - field, - bytes.size(), - ccf::crypto::Sha256Hash::SIZE)); + "Unsupported write set digest size in Merkle proof leaf: {}", + proof.leaf.write_set_digest.size())); + } + if (proof.leaf.claims_digest.size() != ccf::crypto::Sha256Hash::SIZE) + { + throw COSEDecodeError(fmt::format( + "Unsupported claims digest size in Merkle proof leaf: {}", + proof.leaf.claims_digest.size())); } - } - - static ccf::crypto::Sha256Hash sha256_from_bytes( - std::span bytes, std::string_view field) - { - validate_sha256_bytes(bytes, field); - const std::span fixed{ - bytes.data(), ccf::crypto::Sha256Hash::SIZE}; - return ccf::crypto::Sha256Hash::from_span(fixed); - } - - static std::vector recompute_merkle_root(const MerkleProof& proof) - { - auto ce_digest = ccf::crypto::Sha256Hash(proof.leaf.commit_evidence); + std::span wsd{ + proof.leaf.write_set_digest.data(), ccf::crypto::Sha256Hash::SIZE}; + std::span cd{ + proof.leaf.claims_digest.data(), ccf::crypto::Sha256Hash::SIZE}; auto leaf_digest = ccf::crypto::Sha256Hash( - sha256_from_bytes( - proof.leaf.write_set_digest, "Merkle proof write set digest"), + ccf::crypto::Sha256Hash::from_span(wsd), ce_digest, - sha256_from_bytes( - proof.leaf.claims_digest, "Merkle proof claims digest")); + ccf::crypto::Sha256Hash::from_span(cd)); for (const auto& element : proof.path) { - const auto sibling = - sha256_from_bytes(element.second, "Merkle proof sibling"); if (element.first != 0) { - leaf_digest = ccf::crypto::Sha256Hash(sibling, leaf_digest); + std::span sibling{ + element.second.data(), ccf::crypto::Sha256Hash::SIZE}; + leaf_digest = ccf::crypto::Sha256Hash( + ccf::crypto::Sha256Hash::from_span(sibling), leaf_digest); } else { - leaf_digest = ccf::crypto::Sha256Hash(leaf_digest, sibling); + std::span sibling{ + element.second.data(), ccf::crypto::Sha256Hash::SIZE}; + leaf_digest = ccf::crypto::Sha256Hash( + leaf_digest, ccf::crypto::Sha256Hash::from_span(sibling)); } } @@ -418,7 +415,6 @@ namespace ccf::cose [&]() { const auto& bytes = leaf->array_at(ccf::MerkleProofPathBranch::LEFT)->as_bytes(); - validate_sha256_bytes(bytes, "Merkle proof write set digest"); proof.leaf.write_set_digest.assign(bytes.begin(), bytes.end()); }, "Parse leaf at wsd"); @@ -432,7 +428,6 @@ namespace ccf::cose rethrow_with_msg( [&]() { const auto& bytes = leaf->array_at(2)->as_bytes(); - validate_sha256_bytes(bytes, "Merkle proof claims digest"); proof.leaf.claims_digest.assign(bytes.begin(), bytes.end()); }, "Parse leaf at cd"); @@ -465,7 +460,6 @@ namespace ccf::cose rethrow_with_msg( [&]() { const auto& bytes = link->array_at(1)->as_bytes(); - validate_sha256_bytes(bytes, "Merkle proof sibling"); path_item.second.assign(bytes.begin(), bytes.end()); }, "Parse path element at hash"); diff --git a/tests/recovery_snapshot_endorsements.py b/tests/recovery_snapshot_endorsements.py index 71590f0f741b..b2c3b6eece81 100644 --- a/tests/recovery_snapshot_endorsements.py +++ b/tests/recovery_snapshot_endorsements.py @@ -6,7 +6,6 @@ import os import shutil -import cbor2 import ccf.ledger import infra.e2e_args import infra.logging_app as app @@ -122,21 +121,6 @@ def _assert_node_snapshot_unchanged( assert hashlib.sha256(snapshot_file.read()).digest() == expected_snapshot_digest -def _snapshot_with_proof_sibling_size(snapshot_bytes, sibling_size): - header_size = ccf.ledger.TransactionHeader.get_size() - header = ccf.ledger.TransactionHeader(snapshot_bytes[:header_size]) - receipt_pos = header_size + header.size - receipt = cbor2.loads(snapshot_bytes[receipt_pos:]) - proofs = receipt.value[1][396][-1] - assert proofs - proof = cbor2.loads(proofs[0]) - path = proof[2] - assert path - path[0][1] = bytes(sibling_size) - proofs[0] = cbor2.dumps(proof) - return snapshot_bytes[:receipt_pos] + cbor2.dumps(receipt) - - def _assert_malformed_snapshot_falls_back( base_network, args, @@ -357,13 +341,6 @@ def run_recovery_snapshot_endorsements(args): "is not a regular file", True, ), - ( - "short_merkle_proof_sibling", - snapshot_name, - _snapshot_with_proof_sibling_size(source_snapshot_bytes, 1), - "Unsupported Merkle proof sibling size", - False, - ), ) for offset, ( malformed_label, From 4610fdb3420212373537756e6537d1a77bb3f97b Mon Sep 17 00:00:00 2001 From: achamayou Date: Sat, 25 Jul 2026 15:33:52 +0100 Subject: [PATCH 26/27] Remove redundant recovery endorsement reparse/reverify loop The collected, already-validated endorsements were copied into a reversed SerialisedCoseEndorsements vector, immediately reparsed, every COSE signature reverified, used once, then discarded. The oldest collected endorsement already yields the snapshot signer key during validation, so retain and return it directly and verify the receipt with that key. Removes parse_serialised_cose_endorsement, verify_recovery_snapshot_endorsement_chain, the reverse-copy intermediate vector, and the duplicate crypto work. Renames build_recovery_snapshot_endorsement_chain to validate_recovery_snapshot_endorsement_chain (now returns the signer key) and replaces verify_recovery_snapshot with verify_recovery_snapshot_receipt. All security checks (write ordering, back-pointers, algorithm binding, epoch range adjacency, every signature, previous-identity anchoring, snapshot coverage, direct identity validity) are preserved. Tests that only exercised the removed serialised intermediate are dropped; build-side negatives are retained. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 --- src/node/node_state.h | 13 +- src/node/snapshot_serdes.h | 119 +++---------------- src/node/test/network_identity_subsystem.cpp | 49 ++------ src/node/test/snapshotter.cpp | 2 +- 4 files changed, 33 insertions(+), 150 deletions(-) diff --git a/src/node/node_state.h b/src/node/node_state.h index 1c9d11087ff0..efc405a8726b 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -765,8 +765,7 @@ namespace ccf const auto direct_verification_error = try_verify_and_install_recovery_snapshot( [&]() { - verify_recovery_snapshot( - segments, target_identity, {}, startup_snapshot_info->seqno); + verify_snapshot(segments, target_identity.raw()); LOG_INFO_FMT( "Recovery snapshot at {} is directly signed by the configured " "previous service identity", @@ -801,13 +800,13 @@ namespace ccf config.ledger, network.tables->get_encryptor(), snapshot_seqno); const auto target_key = ccf::crypto::public_key_der_from_cert( ccf::crypto::cert_pem_to_der(target_identity)); - const auto endorsements = build_recovery_snapshot_endorsement_chain( - scan.endorsements, target_key, snapshot_seqno); - verify_recovery_snapshot( - segments, target_identity, endorsements, snapshot_seqno); + const auto snapshot_signer_key = + validate_recovery_snapshot_endorsement_chain( + scan.endorsements, target_key, snapshot_seqno); + verify_recovery_snapshot_receipt(segments, snapshot_signer_key); LOG_INFO_FMT( "Validated {} recovery snapshot endorsement(s) in memory", - endorsements.size()); + scan.endorsements.size()); }, [&]() { install_recovery_snapshot_and_start_unsafe(); }); if (chain_verification_error.has_value()) diff --git a/src/node/snapshot_serdes.h b/src/node/snapshot_serdes.h index eb8452608dcc..7387573e2ba6 100644 --- a/src/node/snapshot_serdes.h +++ b/src/node/snapshot_serdes.h @@ -22,7 +22,6 @@ #include #include #include -#include #include namespace ccf @@ -192,85 +191,12 @@ namespace ccf return std::nullopt; } - static ccf::CoseEndorsement parse_serialised_cose_endorsement( - const ccf::SerialisedCoseEndorsement& serialised) - { - const auto [from, to] = - ccf::crypto::extract_cose_endorsement_validity(serialised); - const auto from_txid = ccf::TxID::from_str(from); - const auto to_txid = ccf::TxID::from_str(to); - if (!from_txid.has_value() || !to_txid.has_value()) - { - throw std::logic_error( - fmt::format("Invalid COSE endorsement epoch range {} - {}", from, to)); - } - - return ccf::CoseEndorsement{ - .endorsement = serialised, - .endorsing_key = {}, - .endorsement_epoch_begin = *from_txid, - .endorsement_epoch_end = to_txid, - .previous_version = ccf::kv::Version{0}}; - } - - static std::vector verify_recovery_snapshot_endorsement_chain( - const ccf::SerialisedCoseEndorsements& endorsements, - std::span target_key, - ccf::kv::Version snapshot_seqno) - { - if (target_key.empty()) - { - throw std::logic_error( - "Cannot verify snapshot endorsements with an empty target key"); - } - if (endorsements.empty()) - { - return {target_key.begin(), target_key.end()}; - } - - std::vector current_key(target_key.begin(), target_key.end()); - std::vector parsed; - parsed.reserve(endorsements.size()); - - for (const auto& endorsement : endorsements) - { - auto parsed_endorsement = parse_serialised_cose_endorsement(endorsement); - if (has_ill_formed_epoch_range(parsed_endorsement)) - { - throw std::logic_error(fmt::format( - "COSE endorsement has an ill-formed epoch range {} - {}", - parsed_endorsement.endorsement_epoch_begin.to_str(), - format_epoch(parsed_endorsement.endorsement_epoch_end))); - } - - current_key = verify_cose_endorsement_signature(endorsement, current_key); - parsed.emplace_back(std::move(parsed_endorsement)); - } - - for (size_t i = 0; i + 1 < parsed.size(); ++i) - { - verify_endorsements_connected(parsed[i], parsed[i + 1]); - } - - const auto& oldest = parsed.back(); - if ( - !oldest.endorsement_epoch_end.has_value() || - oldest.endorsement_epoch_begin.seqno > snapshot_seqno || - oldest.endorsement_epoch_end->seqno < snapshot_seqno) - { - throw std::logic_error(fmt::format( - "Oldest COSE endorsement range {} - {} does not cover snapshot seqno " - "{}", - oldest.endorsement_epoch_begin.to_str(), - format_epoch(oldest.endorsement_epoch_end), - snapshot_seqno)); - } - - return current_key; - } - - static ccf::SerialisedCoseEndorsements - build_recovery_snapshot_endorsement_chain( + // Validates the collected COSE endorsement chain against the configured + // previous service identity (target_key) and returns the snapshot signer + // key: the identity, active at snapshot_seqno, that signed the snapshot + // receipt. The oldest endorsement covers snapshot_seqno and endorses that + // key, so it is captured as the endorsed key of the first (oldest) link. + static std::vector validate_recovery_snapshot_endorsement_chain( const std::vector& collected, std::span target_key, ccf::kv::Version snapshot_seqno) @@ -282,6 +208,7 @@ namespace ccf "snapshot"); } + std::vector snapshot_signer_key; std::vector previous_endorsing_key; for (size_t i = 0; i < collected.size(); ++i) { @@ -340,6 +267,12 @@ namespace ccf const auto endorsed_key = verify_cose_endorsement_signature( endorsement.endorsement, endorsement.endorsing_key); + if (i == 0) + { + // The oldest endorsement covers snapshot_seqno; the key it endorses is + // the identity that signed the snapshot receipt. + snapshot_signer_key = endorsed_key; + } if ( !previous_endorsing_key.empty() && endorsed_key != previous_endorsing_key) @@ -378,14 +311,7 @@ namespace ccf snapshot_seqno)); } - ccf::SerialisedCoseEndorsements endorsements; - endorsements.reserve(collected.size()); - for (const auto& collected_endorsement : - std::ranges::reverse_view(collected)) - { - endorsements.emplace_back(collected_endorsement.endorsement.endorsement); - } - return endorsements; + return snapshot_signer_key; } static auto decode_and_verify_cose_snapshot_receipt( @@ -535,27 +461,18 @@ namespace ccf } } - static void verify_recovery_snapshot( + // Verify the recovery snapshot receipt is signed by snapshot_signer_key, the + // snapshot service identity established by validating the endorsement chain. + static void verify_recovery_snapshot_receipt( const SnapshotSegments& segments, - const ccf::crypto::Pem& target_identity, - const ccf::SerialisedCoseEndorsements& endorsements, - ccf::kv::Version snapshot_seqno) + std::span snapshot_signer_key) { - if (endorsements.empty()) - { - verify_snapshot(segments, target_identity.raw()); - return; - } if (segments.receipt.empty() || segments.receipt[0] != 0xD2) { throw std::logic_error( "Only snapshots with COSE receipts can use an endorsement chain"); } - const auto target_key = ccf::crypto::public_key_der_from_cert( - ccf::crypto::cert_pem_to_der(target_identity)); - const auto snapshot_signer_key = verify_recovery_snapshot_endorsement_chain( - endorsements, target_key, snapshot_seqno); const auto receipt = decode_and_verify_cose_snapshot_receipt(segments); const auto verifier = ccf::crypto::make_cose_verifier_from_key(snapshot_signer_key); diff --git a/src/node/test/network_identity_subsystem.cpp b/src/node/test/network_identity_subsystem.cpp index ccb102feb466..305241b78409 100644 --- a/src/node/test/network_identity_subsystem.cpp +++ b/src/node/test/network_identity_subsystem.cpp @@ -486,14 +486,8 @@ TEST_CASE("Recovery snapshot endorsements are validated newest-to-oldest") {cb.write_versions[2], cb.entries[2]}}; const auto target_key = cb.current_pkey_der(); - const auto chain = - ccf::build_recovery_snapshot_endorsement_chain(collected, target_key, 1); - REQUIRE(chain.size() == 2); - REQUIRE(chain[0] == cb.entries[2].endorsement); - REQUIRE(chain[1] == cb.entries[1].endorsement); - const auto snapshot_signer = - ccf::verify_recovery_snapshot_endorsement_chain(chain, target_key, 1); + ccf::validate_recovery_snapshot_endorsement_chain(collected, target_key, 1); REQUIRE(snapshot_signer == cb.service_keys.front()->public_key_der()); } @@ -506,58 +500,31 @@ TEST_CASE("Recovery snapshot endorsements fail closed") {cb.write_versions[1], cb.entries[1]}, {cb.write_versions[2], cb.entries[2]}}; const auto target_key = cb.current_pkey_der(); - const auto chain = - ccf::build_recovery_snapshot_endorsement_chain(collected, target_key, 1); - - auto out_of_order = chain; - std::reverse(out_of_order.begin(), out_of_order.end()); - REQUIRE_THROWS_AS( - ccf::verify_recovery_snapshot_endorsement_chain( - out_of_order, target_key, 1), - std::logic_error); - - auto tampered = chain; - tampered.front().back() ^= 0xff; - REQUIRE_THROWS_AS( - ccf::verify_recovery_snapshot_endorsement_chain(tampered, target_key, 1), - std::logic_error); - - REQUIRE_THROWS_AS( - ccf::verify_recovery_snapshot_endorsement_chain(chain, target_key, 1000), - std::logic_error); - - auto missing_newest = chain; - missing_newest.erase(missing_newest.begin()); - REQUIRE_THROWS_AS( - ccf::verify_recovery_snapshot_endorsement_chain( - missing_newest, target_key, 1), - std::logic_error); - auto missing_oldest = chain; - missing_oldest.pop_back(); + // Snapshot seqno inconsistent with the collected endorsement chain. REQUIRE_THROWS_AS( - ccf::verify_recovery_snapshot_endorsement_chain( - missing_oldest, target_key, 1), + ccf::validate_recovery_snapshot_endorsement_chain( + collected, target_key, 1000), std::logic_error); const auto wrong_target_key = ccf::crypto::make_ec_key_pair()->public_key_der(); REQUIRE_THROWS_AS( - ccf::build_recovery_snapshot_endorsement_chain( + ccf::validate_recovery_snapshot_endorsement_chain( collected, wrong_target_key, 1), std::logic_error); auto broken_back_pointer = collected; broken_back_pointer.back().endorsement.previous_version = 999; REQUIRE_THROWS_AS( - ccf::build_recovery_snapshot_endorsement_chain( + ccf::validate_recovery_snapshot_endorsement_chain( broken_back_pointer, target_key, 1), std::logic_error); auto epoch_mismatch = collected; epoch_mismatch.front().endorsement.endorsement_epoch_end->seqno -= 1; REQUIRE_THROWS_AS( - ccf::build_recovery_snapshot_endorsement_chain( + ccf::validate_recovery_snapshot_endorsement_chain( epoch_mismatch, target_key, 1), std::logic_error); @@ -568,7 +535,7 @@ TEST_CASE("Recovery snapshot endorsements fail closed") collected.back().write_version; injected.emplace_back(std::move(injected_endorsement)); REQUIRE_THROWS_AS( - ccf::build_recovery_snapshot_endorsement_chain(injected, target_key, 1), + ccf::validate_recovery_snapshot_endorsement_chain(injected, target_key, 1), std::logic_error); } diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index 9d8d37a98534..a8be7dc15409 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -246,7 +246,7 @@ TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") const auto verification_error = ccf::try_verify_and_install_recovery_snapshot( [&]() { const auto target_key = ccf::crypto::make_ec_key_pair()->public_key_der(); - std::ignore = ccf::build_recovery_snapshot_endorsement_chain( + std::ignore = ccf::validate_recovery_snapshot_endorsement_chain( scan.endorsements, target_key, 1); }, [&]() { install_called = true; }); From f8f16c8a02e3b41d97caaba442ebc197f1b57bb1 Mon Sep 17 00:00:00 2001 From: achamayou Date: Sat, 25 Jul 2026 15:59:40 +0100 Subject: [PATCH 27/27] Restore current-main malformed-snapshot handling Removes the unrelated malformed-snapshot discovery/read/header fallback policy expansion. Recovery snapshot discovery, reads (files::slurp), and structural/header failures now follow current-main behavior instead of being converted to leave-untouched/try-next/full-replay. Drops the recovery-only exact-length reader read_recovery_snapshot_candidate and the dedicated malformed snapshot-file unit and e2e coverage. The endorsement-specific boundary is retained: for Recover the snapshot is not installed at selection (structure-only verification defers the previous-service-identity check to the public-ledger endorsement scan); suffix-scan and chain-validation failures fall back to full-ledger replay before Store mutation, and failures during verified snapshot install or deserialisation mark the Store failed and propagate. Malformed ledger endorsement-candidate handling and the ledger-suffix RawReader hardening are retained. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 994c382a-54f4-4c5e-9358-39d445aff209 --- CHANGELOG.md | 2 +- src/node/node_state.h | 78 +++------------- src/node/snapshot_serdes.h | 68 -------------- src/node/test/snapshotter.cpp | 42 --------- tests/recovery_snapshot_endorsements.py | 118 +----------------------- 5 files changed, 18 insertions(+), 290 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c48b8579e005..51d479c14d26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added -- Recovery can now use a COSE snapshot signed by an earlier service identity after one or more disaster recoveries. Before deserialising the snapshot, the node reads previous-service-identity endorsement candidates from the public ledger suffix, validates a complete chain against the operator-provided identity, and retains it only for the current recovery attempt. Malformed snapshot candidates and invalid or incomplete chains fall back to full-ledger replay (#8092). +- Recovery can now use a COSE snapshot signed by an earlier service identity after one or more disaster recoveries. Before deserialising the snapshot, the node reads previous-service-identity endorsement candidates from the public ledger suffix, validates a complete chain against the operator-provided identity, and retains it only for the current recovery attempt. Invalid or incomplete endorsement chains fall back to full-ledger replay (#8092). ### Changed diff --git a/src/node/node_state.h b/src/node/node_state.h index efc405a8726b..e1bab4162664 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -517,70 +517,10 @@ namespace ccf directories.emplace_back(read_only_dir.value()); } - std::vector> committed_snapshots; - if (start_type == StartType::Recover) - { - try - { - committed_snapshots = - snapshots::find_committed_snapshots_in_directories(directories); - } - catch (const std::exception& e) - { - LOG_FAIL_FMT( - "Unable to discover a valid recovery snapshot: {}. Recovering " - "without a startup snapshot.", - e.what()); - return; - } - } - else - { - committed_snapshots = - snapshots::find_committed_snapshots_in_directories(directories); - } - + const auto committed_snapshots = + snapshots::find_committed_snapshots_in_directories(directories); for (const auto& [snapshot_seqno, snapshot_path] : committed_snapshots) { - if (start_type == StartType::Recover) - { - try - { - auto snapshot_data = - read_recovery_snapshot_candidate(snapshot_path); - - LOG_INFO_FMT( - "Found latest local snapshot file: {} (size: {})", - snapshot_path, - snapshot_data.size()); - - const auto segments = separate_segments(snapshot_data); - // Validate the receipt structure and claims now, but defer the - // identity check until the public-ledger endorsement scan. - verify_snapshot(segments); - - startup_snapshot_info = std::make_unique( - snapshot_seqno, std::move(snapshot_data)); - LOG_INFO_FMT( - "Selected recovery snapshot {} for previous-service-identity " - "verification", - snapshot_path.string()); - return; - } - catch (const std::exception& e) - { - LOG_FAIL_FMT( - "Error while verifying recovery snapshot candidate {}: {}", - snapshot_path.string(), - e.what()); - LOG_INFO_FMT( - "Leaving unusable recovery snapshot {} untouched and looking " - "for the next candidate", - snapshot_path.string()); - continue; - } - } - auto snapshot_data = files::slurp(snapshot_path); LOG_INFO_FMT( @@ -588,9 +528,21 @@ namespace ccf snapshot_path, snapshot_data.size()); - // Structurally invalid snapshots remain fatal for Join. + // Structurally invalid snapshots remain fatal. const auto segments = separate_segments(snapshot_data); + if (start_type == StartType::Recover) + { + // Validate the receipt structure and claims now, but defer the + // previous-service-identity check to the public-ledger endorsement + // scan. The snapshot is not installed until that verification + // succeeds, so the Store is not mutated by an unverified snapshot. + verify_snapshot(segments); + startup_snapshot_info = std::make_unique( + snapshot_seqno, std::move(snapshot_data)); + return; + } + try { verify_snapshot(segments, config.recover.previous_service_identity); diff --git a/src/node/snapshot_serdes.h b/src/node/snapshot_serdes.h index 7387573e2ba6..c2b776da50f7 100644 --- a/src/node/snapshot_serdes.h +++ b/src/node/snapshot_serdes.h @@ -18,9 +18,6 @@ #include "node/rpc/network_identity_chain_helpers.h" #include "node/tx_receipt_impl.h" -#include -#include -#include #include #include @@ -43,71 +40,6 @@ namespace ccf std::span receipt; }; - static std::vector read_recovery_snapshot_candidate( - const std::filesystem::path& path) - { - std::error_code ec; - const auto is_regular_file = std::filesystem::is_regular_file(path, ec); - if (ec || !is_regular_file) - { - throw std::logic_error(fmt::format( - "Recovery snapshot candidate {} is not a regular file{}", - path.string(), - ec ? fmt::format(": {}", ec.message()) : "")); - } - - std::ifstream file(path, std::ios::binary | std::ios::ate); - if (!file) - { - throw std::logic_error(fmt::format( - "Unable to open recovery snapshot candidate {}", path.string())); - } - - const auto end = file.tellg(); - if (end < 0) - { - throw std::logic_error(fmt::format( - "Unable to determine recovery snapshot candidate size {}", - path.string())); - } - const auto size = static_cast(end); - if constexpr (sizeof(size_t) < sizeof(uint64_t)) - { - if (size > std::numeric_limits::max()) - { - throw std::logic_error(fmt::format( - "Recovery snapshot candidate {} is too large", path.string())); - } - } - if ( - size > static_cast(std::numeric_limits::max())) - { - throw std::logic_error(fmt::format( - "Recovery snapshot candidate {} is too large", path.string())); - } - - std::vector data(static_cast(size)); - file.seekg(0, std::ios::beg); - if (!file) - { - throw std::logic_error(fmt::format( - "Unable to seek recovery snapshot candidate {}", path.string())); - } - if (!data.empty()) - { - file.read( - reinterpret_cast(data.data()), - static_cast(data.size())); - if (!file || file.gcount() != static_cast(data.size())) - { - throw std::logic_error(fmt::format( - "Unable to read complete recovery snapshot candidate {}", - path.string())); - } - } - return data; - } - static SnapshotSegments separate_segments( const std::vector& snapshot) { diff --git a/src/node/test/snapshotter.cpp b/src/node/test/snapshotter.cpp index a8be7dc15409..9c6e7914cae7 100644 --- a/src/node/test/snapshotter.cpp +++ b/src/node/test/snapshotter.cpp @@ -106,48 +106,6 @@ TEST_CASE("Recovery snapshot installation failures do not request fallback") REQUIRE_FALSE(install_called); } -TEST_CASE("Malformed recovery snapshot candidates do not install") -{ - std::vector zeroed_snapshot(64); - std::vector truncated_header(4); - - ccf::kv::SerialisedEntryHeader oversized_header; - oversized_header.set_size(1024); - std::vector oversized_body(sizeof(oversized_header)); - std::memcpy( - oversized_body.data(), &oversized_header, sizeof(oversized_header)); - - for (const auto& snapshot : - {zeroed_snapshot, truncated_header, oversized_body}) - { - ccf::kv::Store store; - const auto original_version = store.current_version(); - const auto original_readiness = store.get_readiness(); - bool install_called = false; - - const auto verification_error = - ccf::try_verify_and_install_recovery_snapshot( - [&]() { - const auto segments = ccf::separate_segments(snapshot); - ccf::verify_snapshot(segments); - }, - [&]() { install_called = true; }); - - REQUIRE(verification_error.has_value()); - REQUIRE_FALSE(install_called); - REQUIRE(store.current_version() == original_version); - REQUIRE(store.get_readiness() == original_readiness); - } - - ScopedSnapshotDir snapshot_dir; - REQUIRE_THROWS(ccf::read_recovery_snapshot_candidate( - snapshot_dir.path / "missing_snapshot")); - const auto directory_candidate = snapshot_dir.path / "snapshot_1_2.committed"; - fs::create_directory(directory_candidate); - files::dump(std::vector{1}, directory_candidate / "contents"); - REQUIRE_THROWS(ccf::read_recovery_snapshot_candidate(directory_candidate)); -} - TEST_CASE("Recovery snapshot endorsement scan reads ledger files directly") { ScopedSnapshotDir ledger_dir; diff --git a/tests/recovery_snapshot_endorsements.py b/tests/recovery_snapshot_endorsements.py index b2c3b6eece81..16b71c3c62aa 100644 --- a/tests/recovery_snapshot_endorsements.py +++ b/tests/recovery_snapshot_endorsements.py @@ -121,62 +121,6 @@ def _assert_node_snapshot_unchanged( assert hashlib.sha256(snapshot_file.read()).digest() == expected_snapshot_digest -def _assert_malformed_snapshot_falls_back( - base_network, - args, - label, - ledger_dir, - committed_ledger_dirs, - target_identity_file, - snapshot_name, - snapshot_bytes, - next_node_id, - expected_log, - snapshot_is_directory=False, -): - snapshots_dir = os.path.join(base_network.common_dir, label) - shutil.rmtree(snapshots_dir, ignore_errors=True) - os.makedirs(snapshots_dir) - snapshot_path = os.path.join(snapshots_dir, snapshot_name) - if snapshot_is_directory: - os.makedirs(snapshot_path) - marker_path = os.path.join(snapshot_path, "contents") - with open(marker_path, "wb") as marker_file: - marker_file.write(snapshot_bytes) - else: - with open(snapshot_path, "wb") as snapshot_file: - snapshot_file.write(snapshot_bytes) - snapshot_digest = hashlib.sha256(snapshot_bytes).digest() - - attempt = _start_recovery_attempt( - base_network, - args, - label, - ledger_dir, - committed_ledger_dirs, - snapshots_dir, - target_identity_file, - next_node_id, - ) - try: - primary, _ = attempt.find_primary() - logs = _logs(primary) - assert expected_log in logs - assert "Starting to read public ledger" in logs - assert "Setting startup snapshot seqno" not in logs - assert "Deserialising snapshot (size:" not in logs - assert "Snapshot successfully deserialised" not in logs - if snapshot_is_directory: - with open(marker_path, "rb") as marker_file: - assert marker_file.read() == snapshot_bytes - else: - _assert_node_snapshot_unchanged( - attempt, primary, snapshot_name, snapshot_digest - ) - finally: - _stop_incomplete_recovery(attempt) - - def run_recovery_snapshot_endorsements(args): with infra.network.network( args.nodes, @@ -305,67 +249,9 @@ def run_recovery_snapshot_endorsements(args): finally: _stop_incomplete_recovery(fallback_attempt) - malformed_snapshots = ( - ( - "zeroed_snapshot", - snapshot_name, - bytes(64), - "transaction size should not be zero", - False, - ), - ( - "truncated_snapshot_header", - snapshot_name, - source_snapshot_bytes[:4], - "Insufficient space", - False, - ), - ( - "oversized_snapshot_body", - snapshot_name, - source_snapshot_bytes[:8], - "exceeds available buffer size", - False, - ), - ( - "malformed_snapshot_filename", - "snapshot_not-a-seqno_1.committed", - source_snapshot_bytes, - "Unable to discover a valid recovery snapshot", - False, - ), - ( - "unreadable_snapshot_candidate", - snapshot_name, - b"not a snapshot", - "is not a regular file", - True, - ), - ) - for offset, ( - malformed_label, - malformed_name, - malformed_bytes, - expected_log, - snapshot_is_directory, - ) in enumerate(malformed_snapshots, start=102): - _assert_malformed_snapshot_falls_back( - second_recovery, - second_args, - f"{args.label}_{malformed_label}", - current_ledger_dir, - committed_ledger_dirs, - target_identity_file, - malformed_name, - malformed_bytes, - offset, - expected_log, - snapshot_is_directory, - ) - LOG.success( - "In-memory recovery snapshot endorsement validation, malformed " - "candidate rejection, and fallback succeeded" + "In-memory recovery snapshot endorsement validation and " + "incomplete-suffix fallback succeeded" )