Skip to content

Sign and verify ledger signatures with every service signing identity - #8766

Draft
Max (maxtropets) wants to merge 4 commits into
maxtropets-fluffy-fiestafrom
maxtropets-sturdy-bassoon
Draft

Max (maxtropets) wants to merge 4 commits into
maxtropets-fluffy-fiestafrom
maxtropets-sturdy-bassoon

Conversation

@maxtropets

@maxtropets Max (maxtropets) commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator

Relates to #7848. Stacked on #8768.

Every signature transaction carries one COSE signature per service signing key, and nodes verify all of them when they replay the ledger. This delivers the MVP goals "every signature transaction carries a CLASSICAL and a PQ COSE_Sign1" and "C++ verifies both on replay".

  • MerkleTreeHistoryPendingTx::call signs the root with the EC key into cose_signatures[CLASSICAL] and, when the history holds an ML-DSA key, with that key into cose_signatures[PQ]. Both share the protected header (iat, iss, sub, txid, vds); alg follows the key and kid is kid_from_key of its SPKI DER.
  • verify_root_signatures verifies every cose_signatures entry written at the version with get_service_signing_identity(tx, type) (CLASSICAL keeps the service.info.cert fallback), through one cached verifier per type, and requires a signature for every type published in signing_identities.
  • A signature of a type the build cannot verify (PQ on a build without ML-DSA, or an unknown type) is logged at FAIL and throws ccf::kv::UnverifiableSignatureType, and public recovery rethrows it, so the node aborts at that transaction.

Choices to review

  • Abort on an unverifiable type. Public recovery treats a signature transaction that fails verification as the end of the ledger, and recovers the prefix before it. For a ledger whose classical history is followed by a PQ service, a build without ML-DSA would then recover the classical prefix and drop every committed transaction of the PQ service. The exception keeps the abort that the compatibility matrix specifies, at the first PQ-signed transaction; the existing check at the end of the public ledger still covers a recovery that replays none, such as one from a snapshot with a PQ identity. The type check runs before any signature is verified.
  • Strict presence covers CLASSICAL too. Once signing_identities[CLASSICAL] is published, a signature transaction carrying only a node signature fails. 7.x publishes that entry and, like 8.x, always writes a CLASSICAL COSE signature; the checked-in tests/testdata ledgers publish no signing identities; no 8.x release exists. AL4 ledgers written by main between Publish the PQ service signing identity #8696 and this PR publish signing_identities[PQ] with CLASSICAL-only signatures, and fail replay from here on.
  • Sign with every key the history holds. Strict presence then relies on two existing invariants: every node of a PQ service holds the PQ key (join check), and signing starts after the create transaction that publishes the identities.

Safety and compatibility

  • Builds without ML-DSA sign as before; their verification adds strict presence for CLASSICAL.
  • On ML-DSA builds each signature transaction grows by an ML-DSA COSE_Sign1 (3309-byte signature for ML-DSA-65, base64-encoded in the ledger).
  • Readers that select CLASSICAL keep their behaviour: snapshot receipts, historical receipts (select_described_cose_signature), resolve_latest_sig_view, the last signed seqno of GET /node/state, and the Python SDK, which reads key 0 of cose_signatures.
Validation (scoped to this layer, at the reviewer's request for the draft stack)

Ran:

  • history_test on an ML-DSA build (OpenSSL 3.5.4, -DTEST_MLDSA=ON) and on a build without ML-DSA (OpenSSL 3.3.5): 16/16 pass on both. New cases cover the shared header, tampered and foreign PQ signatures, strict presence for PQ and CLASSICAL, a PQ signature without a PQ identity, and unknown identity types; on the build without ML-DSA a PQ signature raises UnverifiableSignatureType.
  • snapshotter_test on the build without ML-DSA: 21/21 pass.
  • historical_queries_test on the ML-DSA build: 45 pass, 2 skipped by design.
  • A build of every target on the ML-DSA configuration.
  • e2e recovery_test through ./tests.sh on the ML-DSA build: passed in 210 s. Every signature transaction in the ledgers it wrote carries cose_signatures keys 0 and 1, and the teardown ledger validation passed.
  • clang-tidy on src/node/recovery_decision_protocol.cpp (includes history.h, node_state.h, kv_types.h) with the ML-DSA compile database: one finding, the existing ML-DSA-only dead store prev_service_has_pq_cert in create_service.
  • cpp-format-checks.sh, doc-newline-checks.sh, ascii-checks.sh, todo-checks.sh, release-notes-checks.sh, prettier on the change note.

Not run: e2e on a build without ML-DSA, other e2e on the ML-DSA build, clang-tidy with the AL3 compile database, Sphinx build, full scripts/ci-checks.sh. The recovery abort in recover_public_ledger_entries has no automated test: it needs a build without ML-DSA recovering a PQ-signed ledger.

@maxtropets Max (maxtropets) self-assigned this Nov 13, 2026
@maxtropets Max (maxtropets) mentioned this pull request Nov 13, 2026
11 of 23 tasks
@maxtropets
Max (maxtropets) force-pushed the maxtropets-sturdy-bassoon branch from 610c884 to 3e43911 Compare November 13, 2026 15:38
@maxtropets
Max (maxtropets) force-pushed the maxtropets-sturdy-bassoon branch from 3e43911 to a02fc1f Compare November 13, 2026 15:46
@maxtropets
Max (maxtropets) removed this pull request from stack #8767 November 13, 2026 15:47
@maxtropets
Max (maxtropets) changed the base branch from maxtropets-scaling-fishstick to maxtropets-fluffy-fiesta November 13, 2026 15:47
@maxtropets
Max (maxtropets) force-pushed the maxtropets-sturdy-bassoon branch from a02fc1f to 8c1c6da Compare November 13, 2026 18:13
Max (maxtropets) and others added 4 commits November 14, 2026 04:25
Signature transactions carry one COSE signature per service signing key:
CLASSICAL with the EC key and, for a service with an ML-DSA identity, PQ
with the ML-DSA key, with the same protected header except alg and kid.

verify_root_signatures verifies every COSE signature written by the
transaction with the signing identity of its type, keeping one cached
verifier per type, and requires a signature for every identity published
in signing_identities. A signature of a type the build cannot verify
throws UnverifiableSignatureType, and public recovery stops at that
transaction.

Relates to #7848.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4440247d330d4afc9d8d425064fa47be
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4440247d330d4afc9d8d425064fa47be
Raft logs exceptions from apply() at DEBUG, so the cause is logged by
verify_root_signatures for every caller, and public recovery logs only
that it aborts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4440247d330d4afc9d8d425064fa47be
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4440247d330d4afc9d8d425064fa47be

This branch has not been deployed

No deployments
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.

1 participant