Skip to content

Validate recovery snapshot endorsement chains in memory#8092

Draft
achamayou wants to merge 24 commits into
mainfrom
achamayou-recovery-endorsement-sidecars
Draft

Validate recovery snapshot endorsement chains in memory#8092
achamayou wants to merge 24 commits into
mainfrom
achamayou-recovery-endorsement-sidecars

Conversation

@achamayou

@achamayou achamayou commented Jul 22, 2026

Copy link
Copy Markdown
Member

Summary

Implements the recovery-snapshot endorsement-chain design from Discussion #8080, simplified in response to Eddy Ashton’s review: the chain is retained only in memory for the current recovering node. No endorsement sidecar is created, read, cached, or reused.

  • Applies only to StartType::Recover with a local startup snapshot.
  • Before snapshot KV deserialisation and before PartOfPublicNetwork, scans configured ledger files directly, without ringbuffer requests or a temporary KV store.
  • Extracts bounded previous-service-identity endorsement candidates from complete public-domain entries after snapshot seqno S.
  • Accepts a snapshot directly valid under operator-provided recover.previous_service_identity; otherwise requires a non-empty complete newest-to-oldest COSE chain from that trusted identity to the snapshot signer.
  • Keeps snapshot installation outside verification catches. Verification failures fall back before Store mutation; install/deserialisation failures mark Store failed and propagate.
  • Leaves snapshot and ledger files unchanged.

Trust model

config.recover.previous_service_identity is the sole target trust anchor In.

The ledger suffix is only untrusted transport for candidate CoseEndorsement records. Acceptance does not rely on suffix service state, signature transactions, ledger inclusion, or serialized Merkle trees. The complete candidate list must prove:

  • all candidate writes are after S;
  • previous_version ordering is complete and exact;
  • signed COSE epoch headers match record metadata;
  • adjacent epoch ranges are contiguous;
  • every signature endorses the preceding service key;
  • the newest signing key equals In; and
  • the oldest range covers S.

The derived oldest key verifies the snapshot COSE receipt claims digest, inclusion proof/root, and detached signature. Injected, missing, reordered, or tampered candidates break the complete anchored chain and force full replay.

JSON receipt augmentation remains intentionally out of scope. JSON snapshots are accepted only when directly valid under In.

Untrusted candidate boundaries

For Recover, snapshot discovery/name/seqno parsing, file reads, transaction/segment headers, and pre-install receipt structure/claims are candidate verification. Malformed discovery metadata selects normal no-snapshot recovery; malformed individual files remain untouched while the next candidate is considered.

Recovery uses a throwing exact-length reader rather than process-terminating files::slurp, so missing, non-regular, raced, unreadable, oversized, short-read, and seek failures fall back before Store mutation. Join retains its previous fatal structural behavior and corrupt-receipt ignore/rename behavior.

The shared raw KV reader validates fixed-width reads, size prefixes, payload lengths, vector element counts, cursor advances, and public-domain slices before pointer arithmetic, allocation, or copying.

Receipt proof hardening

All fixed SHA-256 fields in decoded COSE Merkle proofs are checked before any fixed-extent span, copy, or hash construction:

  • leaf write-set digest;
  • leaf claims digest; and
  • every path sibling.

Each must be exactly Sha256Hash::SIZE. Decode-time and defensive root-recomputation checks reject short or oversized fields with COSEDecodeError. Valid 32-byte fields and existing receipt root/signature verification are unchanged.

Tests

  • Chain tests cover ordering, tampering, missing links, wrong In, back-pointers, signed epoch metadata, injected candidates, adjacency, and snapshot coverage.
  • Scanner tests cover direct candidate collection without signature transactions, count/item/aggregate/raw-entry limits, and malformed evidence before install.
  • KV tests cover truncated fixed fields, short/prefix-only values, the 9-byte/declared-length-2 regression, impossible lengths, non-divisible vectors, invalid null input, invalid public-domain lengths, and truncated claims digests.
  • COSE tests mutate a valid receipt sibling to lengths 0, 1, 31, 32, and 33; only 32 succeeds. Adjacent write-set and claims digest length mutations are also rejected.
  • Snapshot boundary tests cover zeroed/truncated/oversized-body snapshots, malformed names, missing/non-regular files, unchanged Store state before install, and install-exception propagation.
  • recovery_snapshot_endorsements_test covers real two-DR in-memory recovery, incomplete suffix fallback, actual node-local byte preservation, malformed discovery/read/header candidates, and a real snapshot receipt with a one-byte proof sibling. Every malformed case enters full-ledger recovery without snapshot installation.
  • Focused builds/units, production clang-tidy, Python formatting, shellcheck, and all non-Ruff repository checks pass.

Lint tooling is unchanged from current main; this PR contains no Ruff/Black/mypy/pyproject/setup/tox/pre-commit/workflow configuration changes or lint-only Python cleanup. The only changed Python file is the functional recovery e2e above.

With restored current-main behavior, unpinned Ruff 0.16.0 fails on both a clean current-main checkout and the exact PR merge. Local normalized JSON comparison finds 505 diagnostics in each, with identical (file, rule, message, location) sets. GitHub VMSS Virtual A job 89429343314, step 6, reports those findings plus one runner-only EXE002 on executable tests/infra/platform_detection.py; that file has identical mode 100755 and blob 5b7e89711d38c191d70a26b8839f07749d2961f8 in current main and the PR merge. No lint pin, configuration workaround, or mass cleanup is included.

Limitations

  • JSON receipt augmentation is not implemented.
  • The pre-pass extracts only public endorsement candidates; it does not replay application state or establish ledger commitment for those candidates. Trust comes solely from the complete COSE chain anchored at In.

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
Copilot AI review requested due to automatic review settings July 22, 2026 22:08
@achamayou
achamayou requested a review from a team as a code owner July 22, 2026 22:08
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread python/src/ccf/ledger.py Outdated
@achamayou
achamayou marked this pull request as draft July 23, 2026 05:46
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
@achamayou
achamayou marked this pull request as ready for review July 23, 2026 05:55
…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
@achamayou
achamayou marked this pull request as draft July 23, 2026 10:27
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
@achamayou
achamayou marked this pull request as ready for review July 23, 2026 10:40

@eddyashton eddyashton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@achamayou
achamayou marked this pull request as draft July 23, 2026 20:51
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
@achamayou achamayou changed the title Add recovery snapshot endorsement sidecars Validate recovery snapshot endorsement chains in memory Jul 23, 2026
@achamayou
achamayou marked this pull request as ready for review July 23, 2026 23:16
@achamayou
achamayou marked this pull request as draft July 23, 2026 23:17
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
@achamayou
achamayou marked this pull request as ready for review July 23, 2026 23:48
@achamayou
achamayou marked this pull request as draft July 24, 2026 00:04
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
@achamayou
achamayou marked this pull request as ready for review July 24, 2026 00:45
@achamayou
achamayou marked this pull request as draft July 24, 2026 01:30
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
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
@achamayou
achamayou marked this pull request as ready for review July 24, 2026 02:31
@achamayou
achamayou marked this pull request as draft July 24, 2026 03:09
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
@achamayou
achamayou marked this pull request as ready for review July 24, 2026 03:40
@achamayou
achamayou marked this pull request as draft July 24, 2026 07:35
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
@achamayou
achamayou marked this pull request as ready for review July 24, 2026 07:51
@achamayou
achamayou marked this pull request as draft July 24, 2026 08:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants