Skip to content

PQ endorsement fetching: fetch, validate and serve the ML-DSA endorsement chain and signing keys - #8768

Draft
Max (maxtropets) wants to merge 4 commits into
maxtropets-scaling-fishstickfrom
maxtropets-fluffy-fiesta
Draft

Max (maxtropets) wants to merge 4 commits into
maxtropets-scaling-fishstickfrom
maxtropets-fluffy-fiesta

Conversation

@maxtropets

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

Copy link
Copy Markdown
Collaborator

Relates to #7848. Stacked on #8765.

The network identity subsystem fetches and validates the CLASSICAL service endorsement chain once a node is part of an open service, and serves it with the trusted service keys to build receipts for transactions signed by earlier service identities. #8765 writes a PQ chain next to it. This layer gives the PQ chain the same subsystem support, which PQ receipts need (goal 5 of #7848), and serves the chain and the trusted keys of each identity type through ccf::NetworkIdentitySubsystemInterface:

  • get_cose_endorsements_chain(seqno, identity_type)
  • get_trusted_key_der_for(seqno, identity_type)
  • get_trusted_keys_der(identity_type), with TrustedKeysDer = std::map<SeqNo, std::vector<uint8_t>>

For CLASSICAL they return what get_cose_endorsements_chain(seqno), get_trusted_identity_for(seqno) and get_trusted_keys() return. For PQ they return std::nullopt, or no keys, for a service without a PQ identity and for sequence numbers before the PQ chain starts: at the latest service which had a PQ identity when the service before it, if any, did not. ccf::IdentityType moves to the public header ccf/service/identity_type.h, which the interface needs.

The PQ chain is walked in lockstep with the CLASSICAL one, from the same historical states, so it costs no extra fetch. get_endorsements_at(seqno) returns the endorsements of both types written at a version, each PQ back-endorsement must carry the epochs and previous_version of the CLASSICAL endorsement written with it, and the walk ends at the PQ self-endorsement of the service which started the PQ chain. On completion the PQ chain gets the CLASSICAL checks: epoch contiguity, connection to the current service, signatures, key linkage, and the newest endorsing key equal to the node's PQ service key.

This layer has no plan item of its own in #7848; it was inserted after #8765 at the reviewer's request. Its first version was written in the PQ receipts layer, which rebases onto it.

Choices for review

  • An inconsistent PQ chain fails fetching, as an inconsistent CLASSICAL chain does (fail_fetching, which stops the node). Readers then see validated chains only, and std::nullopt from a PQ getter means that no PQ identity covers the sequence number, or in Partial that it is below the validated suffix. No supported ledger reaches these checks: PQ endorsement chain: endorse the previous ML-DSA service identity when a service opens #8765 writes both endorsements in one transaction over the same epochs, and restarts the PQ chain with a self-endorsement when the previous [PQ] entry was not written with the previous [CLASSICAL] one; snapshots keep per-key write versions; every node of a PQ service holds the PQ key; and a live upgrade of a 7.0.24 service that has an ML-DSA certificate stops earlier, at check_signing_identity_matches_service_cert, before any endorsement is read. A failure therefore means a corrupted ledger or a bug. The first version dropped the PQ chain with a LOG_FAIL and kept serving CLASSICAL; going back to that changes one function, if CLASSICAL availability should win.
  • SPKI DER getters per identity type. They keep the interface independent of the PQ algorithm, which is node configuration, and DER is what make_cose_verifier_from_key() takes for EC and ML-DSA keys alike. The alternative was typed getters returning MLDSAPublicKeyPtr, mirroring TrustedKeys, with one more map type per algorithm.
  • No e2e test of its own. No endpoint serves PQ chains or keys before PQ receipts (the layer above), and endpoints serving signing keys are a later plan item. On ML-DSA builds every node validates the PQ chain and stops if it is inconsistent, so the existing recovery e2e tests exercise the walk on real ledgers; the served chain is tested end to end with PQ receipts.

Safety and compatibility

Builds without ML-DSA skip the PQ chain, and the CLASSICAL getters are unchanged. NetworkIdentitySubsystemInterface gains three pure virtual methods, as it did for get_trusted_keys() (#7690): applications calling it are unaffected, and an implementation of the interface outside CCF would need them. read_current_service_identity returns the PQ endorsement only when it was written at the version of the CLASSICAL one, so the [PQ] entry of an older service is ignored. On ML-DSA builds a node with an inconsistent PQ chain stops, as it does with an inconsistent CLASSICAL chain.

Validation

ML-DSA build (OpenSSL 3.5.4, -DTEST_MLDSA=ON, Debug) on Azure Linux 3:

  • Unit, on the current base: network_identity_subsystem_test 47/47, including 11 PQ cases. Each of the 10 inconsistencies of "An inconsistent PQ chain fails fetching", and its Partial variant, asserts its own error message.
  • On the previous base of this branch, with the same patches: built logging, logging_cose_only, logging_cose_only_allow_join_dual and js_generic (all of libccf); e2e (tests.sh) recovery_test, recovery_stale_snapshot_join_test, recovery_intermediate_snapshot_join_test and recovery_snapshot_endorsements_test pass. In their node logs, all 38 nodes which settled fetching validated a PQ chain of 0 to 5 PQ endorsements, and none failed.
  • clang-tidy 18 with .clang-tidy on src/node/historical_queries_adapter.cpp, which includes the subsystem header: clean. clang-tidier.py on it and on the unit test: clean.
  • clang-format 18.1.8 on the changed C++ files, prettier on the change note, and the includes, release-notes, doc-newline, ascii, copyright and todo checks: clean.

Not run, left to PR CI: any default (AL3) build or test; other unit tests and e2e suites; scripts/ci-checks.sh; clang-tidy on other translation units; the Sphinx docs build. ci-al4.yml (ML-DSA) runs on schedule or workflow_dispatch.

Max (maxtropets) and others added 4 commits November 14, 2026 04:25
The network identity subsystem fetches the PQ endorsement chain with the
CLASSICAL one, from the same historical states: a PQ back-endorsement is
written with a CLASSICAL one, over the same epochs and with the same
previous version, and the chain ends with the PQ self-endorsement of the
first service with a PQ identity. The PQ chain is validated as the
CLASSICAL one is (epochs, signatures, key linkage, current PQ service
key). An inconsistent PQ chain is dropped and logged, which leaves the
CLASSICAL chain and its readers unchanged.

- NetworkIdentitySubsystemInterface gains
  get_cose_endorsements_chain(seqno, identity_type),
  get_trusted_key_der_for(seqno, identity_type) and
  get_trusted_keys_der(identity_type); for CLASSICAL they return what the
  existing getters return
- ccf::IdentityType moves to the public header ccf/service/identity_type.h
- read_current_service_identity also returns the PQ endorsement written
  with the CLASSICAL one, and IHistoricalStateAccessor::get_endorsements_at
  returns the endorsements of both types written at a version
- Builds without ML-DSA skip the PQ chain

Relates to #7848.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 25bc4ac502844f069dc9238bf2de0fb7
The network identity subsystem validates the PQ endorsement chain as it
validates the CLASSICAL one, and fails fetching if either is
inconsistent. The getters serve validated chains only, and std::nullopt
from a PQ getter means that no validated PQ chain covers the sequence
number.

- A PQ endorsement written when the current service opened, on a node
  without a PQ key, fails fetching
- The interface documentation states where the PQ chain starts,
  including restarts after a service without a PQ identity, and that
  chains are newest endorsement first
- Tests: each inconsistency fails fetching for its own reason, in Done
  and in Partial; the PQ chain restarts after a service without a PQ
  identity; a joiner whose live KV lags reads the PQ chain once the KV
  reaches the current service; the services of the CLASSICAL tests have
  no PQ identity

Relates to #7848.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0b2873f93c5a4e22adff30641d412183
Relates to #7848.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0b2873f93c5a4e22adff30641d412183
clang-tidy (bugprone-unchecked-optional-access) rejects the unchecked
current_service_from accesses in start_pq_chain, complete_pq_chain and
build_pq_trusted_key_chain, in every library translation unit that
includes the subsystem. The callers pass the TxID they have checked.

Relates to #7848.

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

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