Validate recovery snapshot endorsement chains in memory#8092
Validate recovery snapshot endorsement chains in memory#8092achamayou wants to merge 19 commits into
Conversation
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
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
There was a problem hiding this comment.
Pull request overview
This PR implements recovery snapshot endorsement “sidecars” to allow recovery to start from a snapshot whose COSE receipt was signed by an older service identity, by deriving/verifying a contiguous endorsement chain anchored at the operator-provided recover.previous_service_identity and caching it alongside the snapshot.
Changes:
- Adds sidecar path derivation and lifecycle support (including cleanup when snapshots are deleted).
- Implements synchronous scanning of the public ledger suffix (without building/applying a KV) to collect and validate previous-service-identity COSE endorsements and persist them as a CBOR sidecar.
- Extends Python snapshot/COSE tooling and adds focused C++/Python/e2e tests, plus CI wiring for the new e2e test.
Custom instructions used:
.github/copilot-instructions.md.github/instructions/reviewing.instructions.md
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/recovery_snapshot_endorsements.py | New e2e coverage for sidecar derivation, reuse, tamper handling, and fallback. |
| tests/ci-buckets.txt | Adds the new e2e test to bucket_b. |
| src/snapshots/filenames.h | Adds helper to derive snapshot endorsement sidecar path. |
| src/node/test/snapshotter.cpp | Unit tests for sidecar lifecycle and direct ledger-file scanning behavior. |
| src/node/test/network_identity_subsystem.cpp | Unit tests for newest-to-oldest endorsement validation and fail-closed behavior. |
| src/node/snapshot_serdes.h | Adds CBOR (de)serialisation for endorsements, sidecar IO, and recovery snapshot verification using endorsement chains. |
| src/node/rpc/network_identity_subsystem.h | Refactors endorsement signature verification to use shared helper. |
| src/node/rpc/network_identity_chain_helpers.h | Introduces shared endorsement parsing/validation helpers used by recovery and identity subsystems. |
| src/node/recovery_snapshot_ledger.h | Adds direct ledger-file scanner for service info + endorsement collection after a snapshot. |
| src/node/node_state.h | Integrates sidecar verification/derivation into recovery startup flow and adds sidecar cleanup/fallback logic. |
| src/host/test/files_cleanup_test.cpp | Verifies snapshot cleanup also removes adjacent endorsement sidecars. |
| src/host/files_cleanup_timer.h | Deletes endorsement sidecars when deleting old snapshots. |
| python/tests/test_cose_sign.py | Adds Python tests for endorsement sidecar chain verification and strict CBOR decoding. |
| python/src/ccf/read_ledger.py | Adds CLI options to verify a snapshot receipt against a target cert + optional endorsements sidecar. |
| python/src/ccf/ledger.py | Adds sidecar path helper and reader; snapshot API caches receipt/digest for verification. |
| python/src/ccf/cose.py | Adds CBOR encoding/decoding for endorsements and endorsement-chain verification utilities. |
| CMakeLists.txt | Registers the new e2e test target. |
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
…g, 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
- 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
…on 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
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
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
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
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
eddyashton
left a comment
There was a problem hiding this comment.
I don't think this should be stored in a sidecar file. The endorsement chain can be retained in memory by the current node, and used by that node.
I accept there's significant work in creating this endorsement chain, that we could save if a future recovery attempt is able to use this file, but believe that case will be exceedingly rare. We eagerly create snapshots on successful recovery, so falling into this in the first place is unusual. If we do serial recoveries with no fresher snapshots, its likely that each attempt is actually using the next identity, so this off-by-one endorsement chain doesn't help us (an endorsement chain to Identity N won't help us at N +1, its equivalent to junk because we jump to a crypto check, there's no attempt to augment an existing partial endorsement sidecar with newer certs. And good, that's even more complex!).
Fundamentally, this is a rare-enough path that we should prefer it being as branchless (simple) as possible. The real goal is to read the ledger and recover from the valid snapshot+suffix, but much of the code in this PR is concerned with managing sidecar files and testing that we can correctly ignore corrupt ones - totally unnecessary work.
Discussing this with the robot, it's raised the point that the sidecar file can be reused by other tools, eg read_ledger.py. But I think that's slightly artificial - when the file is there, it looks like good coverage for all of our tooling to use it (including duplicating path construction logic etc). Before the file existed, the tooling took no snapshot signer arg, and simply didn't care about this. I think that's better! There's already a big difference between what the Python tooling does and what the node does (snapshot discovery!), I don't think this is a useful extra bit of verification.
Big rant! TL;DR: Dump the file, store the chain in-memory for the current node, delete half of this PR.
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
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
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
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
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
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
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
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
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
Summary
Implements the recovery-snapshot endorsement-chain design from Discussion #8080, simplified in response to Eddy Ashton’s review: the derived chain is retained only in memory for the current recovering node. No endorsement sidecar is created, read, cached, or reused.
StartType::Recoverwhen a local startup snapshot is selected.recover.previous_service_identity; otherwise requires a non-empty complete newest-to-oldest chain from that trusted identity to the snapshot signer.Trust model
config.recover.previous_service_identityis the sole target trust anchorIn.The ledger suffix is only an untrusted transport for candidate
CoseEndorsementrecords. Snapshot acceptance does not rely on suffix service state, signature transactions, ledger inclusion proofs, or serialized Merkle trees. Instead, the complete candidate list must validate cryptographically:S;S;previous_versionpoints exactly to the preceding candidate write;In; andS.The resulting oldest key must then verify the snapshot COSE receipt, including its claims digest, inclusion proof/Merkle root, and detached signature. Tampered or injected suffix records cannot become trusted independently; they break the complete anchored chain and force fallback.
JSON receipt augmentation remains intentionally out of scope. JSON snapshots are accepted only when directly valid under the configured identity.
Tests
In, broken back-pointers, signed epoch metadata mismatch, injected candidates, adjacency, and snapshot coverage.recovery_snapshot_endorsements_testperforms two disaster recoveries, recovers from the original stale snapshot through the in-memory two-link chain, checks verification occurs before snapshot deserialisation/replay, hashes the actual node-local snapshot bytes, and verifies a truncated suffix rejects the snapshot and falls back to full replay.ccf, the logging application,snapshotter_test, andnetwork_identity_subsystem_test; focused unit/e2e tests, clang-tidy, andscripts/ci-checks.shpass.Limitations
In.