Skip to content

PQ endorsement chain: endorse the previous ML-DSA service identity when a service opens - #8765

Draft
Max (maxtropets) wants to merge 5 commits into
maxtropets-glowing-enginefrom
maxtropets-scaling-fishstick
Draft

Max (maxtropets) wants to merge 5 commits into
maxtropets-glowing-enginefrom
maxtropets-scaling-fishstick

Conversation

@maxtropets

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

Copy link
Copy Markdown
Collaborator

Relates to #7848. Stacked on #8764.

Receipts for PQ-signed transactions have to stay verifiable across disaster recoveries (goal 5 of #7848), so each ML-DSA service identity endorses the previous one, as each classical identity already does. When a service with an ML-DSA identity opens, it writes previous_service_identity_endorsement[PQ] in the transaction that writes [CLASSICAL]: a COSE endorsement, signed with the ML-DSA service key, of the key that signed the previous PQ endorsement. A PQ back-endorsement has the epochs, previous_version and epoch.end.merkle.root of the CLASSICAL one, and its protected header differs only in alg, so the two chains line up and the PQ chain can be walked through the same historical states as the CLASSICAL one. A service whose previous service had no PQ identity (a service started on an ML-DSA build, or a classical-only ledger recovered on one) starts the PQ chain with a self-endorsement.

Reading of the plan item

  • cose::sign_endorsement over MLDSAKeyPair: the EC and ML-DSA overloads share one protected-header builder, with alg from the parameter set (RFC 9964). The EC header bytes are unchanged.
  • endorse_previous_identity per identity type: endorse_previous_identity(tx, service_key, pq_service_key) takes a nullable MLDSAKeyPairPtr, as TxHistory::set_service_signing_identity does, and so do open_service and open_recovered_service. Each endorsing key is checked against signing_identities[type], which get_service_signing_identity checks against its certificate; a published PQ identity with no PQ key throws. Both endorsements are signed before either is written, so an opening writes both or neither. They share one iat.
  • Continuing the PQ chain: the PQ chain continues from a PQ endorsement written by the same transaction as the previous CLASSICAL one (same write version), that is by the opening of the previous service. Otherwise the previous service had no PQ identity, for example after a 7.x recovery that dropped it (unsupported in the compatibility matrix), and the PQ chain restarts with a self-endorsement. This keeps every PQ back-endorsement on the CLASSICAL epochs.
  • Snapshot recovery: I concluded that this layer needs no code change there. After a snapshot recovery, the opening reads the same previous_service_identity_endorsement state as after a ledger recovery, because snapshots keep per-key write versions. The startup check of the stapled receipt stays CLASSICAL in verify_recovery_snapshot_candidate_unsafe, because the receipt is CLASSICAL and the configured previous identity is the classical certificate. Its ledger scan collects CLASSICAL writes only, so PQ writes in the same entries leave it unchanged. The difference for PQ is that its chain can start after the snapshot (a classical-only ledger recovered on AL4). Startup verification of a PQ snapshot receipt ("Carry classical and PQ snapshot receipts") will therefore need to handle a chain that begins with a self-endorsement inside the suffix, which validate_recovery_snapshot_endorsement_chain rejects today. The new e2e test recovers from a snapshot older than the previous identity, with both types in the suffix.
  • Tests: a chain across two recoveries, one from the ledger and one from that older snapshot, verified from the ledger (ML-DSA signatures, previous_version links, CLASSICAL epochs and header, CDDL, newest key matching pq_service_cert.pem). The classical-only test ledgers of recovery_from_* start the PQ chain with exactly one self-endorsement on ML-DSA builds and have no PQ endorsement on AL3. Unit tests cover a classical-only service followed by PQ services, the restart, and a published identity of each type that does not match its certificate.

If the item meant something else, say so and this layer will follow.

Safety and compatibility

On builds without ML-DSA, openings pass no PQ key and write the same [CLASSICAL] endorsement. On ML-DSA builds, each opening transaction gains one PQ endorsement (about 13 KB of JSON with ML-DSA-65). Readers of [CLASSICAL], the receipt endorsement chain and the startup snapshot scan, read the same entries. The error for a mismatched endorsing key now names the identity type. doc/audit/builtin_maps.rst documents the identity-type key of the table, which still said "Sentinel value 0".

Validation

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

  • Unit: internal_tables_access_test, open_service_test, node_frontend_test, cose_test, and the ML-DSA COSE_Sign1* cases of crypto_test: pass.
  • e2e: from recovery.py, the sub-tests recovery_pq_endorsement_chain (3 PQ endorsements; the last recovery logs the in-memory validation of the older snapshot) and recovery_from_{expired,double_sealed,sgx,cose_flipflop,acme_containing}_service (1 PQ self-endorsement each); recovery_snapshot_endorsements_test: pass.
  • clang-tidy 18 on src/crypto/cose.cpp, src/node/recovery_decision_protocol.cpp, src/host/run.cpp and src/node/local_sealing.cpp: no finding in changed code. The one finding, a dead store in create_service, is pre-existing and appears only on ML-DSA builds, where mldsa_supported() is constant.
  • clang-format 18.1.8, black, ruff, prettier on the change note, and doc-newline, ascii, todo, builtin-maps, copyright and release-notes checks: clean.
  • Review round 1 fixes, rebuilt on the current base: internal_tables_access_test, open_service_test and the recovery_pq_endorsement_chain sub-test: pass; clang-format, black and ruff: clean.

Not run, left to PR CI: any default (AL3) build or test, including the AL3 runs of the new e2e checks; the other recovery.py sub-tests and other e2e suites; scripts/ci-checks.sh; clang-tidier.py; the Sphinx docs build. ci-al4.yml (ML-DSA) runs on schedule or workflow_dispatch.

Max (maxtropets) and others added 5 commits November 14, 2026 04:25
A service with an ML-DSA identity signs, when it opens, a COSE endorsement
of the previous PQ service identity with its ML-DSA key, in
previous_service_identity_endorsement[PQ] next to the CLASSICAL one. A PQ
back-endorsement has the epochs, previous version and previous root of the
CLASSICAL endorsement written with it. The PQ chain starts with a
self-endorsement when the previous service had no PQ identity.

- cose::sign_endorsement over MLDSAKeyPair, with the EC protected header
  except alg
- InternalTablesAccess::endorse_previous_identity takes the nullable PQ
  service key, checks it against signing_identities[PQ], and signs every
  identity type before writing any
- open_service and open_recovered_service take the PQ service key, which
  NodeState passes from NetworkIdentity::get_pq_key_pair()
- Unit tests, e2e recovery tests of the PQ chain, audit and migration docs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e71570e226fd4a1ea74eecf57a790614
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e71570e226fd4a1ea74eecf57a790614
get_service_signing_identity checks the published identity of each type
against its certificate, on every path of endorse_previous_identity, so the
whole-table check before it is dropped. A test covers a mismatched
published identity of each type, and of PQ with and without a PQ key.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e71570e226fd4a1ea74eecf57a790614
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e71570e226fd4a1ea74eecf57a790614
The transaction holds the mismatched published identity and is never
committed, so the test checks in it that no endorsement was written.

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

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