Skip to content

PQ receipts: serve and verify COSE receipts of the ML-DSA service identity - #8770

Draft
Max (maxtropets) wants to merge 4 commits into
maxtropets-sturdy-bassoonfrom
maxtropets-bookish-happiness
Draft

Max (maxtropets) wants to merge 4 commits into
maxtropets-sturdy-bassoonfrom
maxtropets-bookish-happiness

Conversation

@maxtropets

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

Copy link
Copy Markdown
Collaborator

Relates to #7848. Stacked on #8766.

A service with an ML-DSA identity signs every signature transaction with its classical and its ML-DSA service keys, and each ML-DSA identity endorses the previous one, but receipts were classical only. This PR serves the ML-DSA COSE receipt of a transaction and verifies it offline, also across disaster recoveries through the ML-DSA endorsement chain fetched by #8768 (goal 5 and the "PQ receipts" item of #7848).

Endpoint shape (TBD in #7848, please redirect if needed): an optional identity_type query parameter, CLASSICAL (the default and the current behaviour) or PQ, for example GET /node/receipt/cose?transaction_id=2.643&identity_type=PQ.

  • It applies to /receipt/cose of the node, app and governance frontends, and to the logging sample's /log/public/cose_receipt, /log/public/cose_signature and /log/public/cose_endorsements.
  • The historical handler validates it, as the logging sample's historical endpoints validate their id parameter: an unknown value returns 400 InvalidQueryParameterValue once the transaction's state is available. A transaction with no receipt of that type returns 404, which includes every transaction signed before the first service with an ML-DSA identity.
  • /log/public/verify_cose_receipt selects the identity type from the receipt's alg.

Building blocks:

  • C++: describe_cose_receipt_v2, describe_cose_signature_v2 and describe_cose_endorsements_v2 take the identity type, and the _v1 functions keep describing the CLASSICAL receipt. Historical receipts carry the endorsement chain of each identity type, and verify_self_issued_receipt verifies ML-DSA receipts with the trusted PQ key of their seqno.
  • Python: ccf.receipt.verify_cose accepts ML-DSA keys and an optional endorsements chain, which ccf.receipt.verify_cose_endorsements verifies from the trusted current key (signatures, key linkage, and epochs that follow each other as recoveries leave them); the receipt must then be signed for a transaction in the epoch of the endorsed key. ccf.cose.verify_cose_sign1_mldsa(spki_der, cose_sign1, payload), the signature planned in ML-DSA signatures in CCF #7848, verifies pure ML-DSA COSE_Sign1 messages (RFC 9964).

Alternatives

  • Separate PQ endpoints (/node/receipt/cose/pq), or one response with both receipts: rejected, as a query parameter is additive, keeps the default response, and selects receipts, signatures and endorsements the same way.
  • An identity type parameter on the describe_cose_*_v1 functions: rejected, as a versioned name denotes a fixed contract, as describe_receipt_v1 and describe_receipt_v2 show, so the _v2 functions take it.
  • An empty PQ chain for receipts of the current service, so that a missing PQ chain is distinguishable from it: rejected, as CLASSICAL answers 404 for those receipts, and with PQ endorsement fetching: fetch, validate and serve the ML-DSA endorsement chain and signing keys #8768 failing on an inconsistent PQ chain, a receipt with an ML-DSA signature lacks a PQ chain only after an unsupported 7.x recovery between ML-DSA services. Verifying such a receipt against the current ML-DSA key fails closed on its kid.

Safety and compatibility

  • Requests without identity_type get the same CLASSICAL receipts. Builds without ML-DSA have no PQ signatures, so identity_type=PQ returns 404 there.
  • API versions: node 5.0.11, governance 5.0.2, logging sample 2.8.7, with doc/schemas regenerated. The describe_cose_*_v1 functions are unchanged.
  • The SDK keeps cryptography >= 46: ML-DSA is imported where it is used, so classical receipts verify with 46 and 47, and PQ receipts need 48, which also loads the key from pq_service_certificate. The floor moves with the "Python SDK verifies every signature present" item of ML-DSA signatures in CCF #7848.
  • /log/transparent_statement and the commit-time receipt of make_respond_with_receipt_on_commit stay CLASSICAL.
Validation (scoped to this layer, at the reviewer's request for the draft stack)

ML-DSA build (OpenSSL 3.5.4, -DTEST_MLDSA=ON, Debug).

At acebd5f (after review round 2, Python only):

  • Python: cd python && pytest, 713 passed with cryptography 50.0.2, including a test that malformed CBOR raises ValueError from verify_cose_sign1_mldsa. test_receipt_identity_types.py and test_receipt.py pass with cryptography 48.0.0 and 48.0.1, and with 46.0.7, where the ML-DSA tests skip.
  • With cryptography 48.0.0 and 48.0.1, the pq_service_cert.pem of an ML-DSA e2e_logging run loads, and its key verifies the run's PQ receipt with ccf.receipt.verify_cose.
  • black, ruff, pyright and ascii checks.

At cef7c59 (after review round 1):

  • Unit: historical_queries_test 46/46, with cases for the _v1 and _v2 descriptions and for a PQ-signed historical receipt that verifies with the ML-DSA key and not with the EC key.
  • Python: cd python && pytest, 712 passed, including 14 tests of EC and ML-DSA receipts and endorsement chains, one of which removes frozendict from cbor2 (as on Python 3.15) and verifies ML-DSA messages.
  • e2e: the cpp run of e2e_logging (all 33 tests; the PQ receipt test checks the receipt and signature CDDL, self-verification, the sample endpoints, 400 and 404), and the recovery run of recovery_test.
  • clang-tidy 18 on historical_queries_adapter.cpp; clang-format, black, ruff, pyright, doc-newline and prettier checks.

At 5fa4cdb (the first commit, on a02fc1f):

  • e2e: the recovery, recovery_from_double_sealed_service and recovery_from_acme_containing_service runs of recovery_test: across two recoveries the PQ receipts and chains verify in Python exactly as the CLASSICAL ones do, the classical-only history has no PQ receipt, and the recovered service's transactions have one. schema_test.
  • clang-tidy 18 on the changed library and app translation units; openapi, ascii, todo, copyright and release-notes checks.

Not run: any build or test without ML-DSA (AL3), other e2e suites, the Sphinx build, scripts/ci-checks.sh.

@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-bookish-happiness branch from 5fa4cdb to a68b3ef Compare November 13, 2026 18:36
Max (maxtropets) and others added 4 commits November 14, 2026 04:25
Historical receipts carry the COSE signature and endorsement chain of
each service identity type, and the receipt endpoints select one with an
optional identity_type query parameter, CLASSICAL by default or PQ.

- describe_cose_receipt_v1, describe_cose_signature_v1 and
  describe_cose_endorsements_v1 take the identity type, CLASSICAL by
  default; TxReceiptImpl holds an endorsement chain per identity type
- populate_cose_service_endorsements adds the PQ chain of a receipt with
  a PQ signature, when the PQ identities are endorsed from its epoch
- verify_self_issued_receipt verifies an ML-DSA receipt with the trusted
  PQ key of its seqno
- GET /receipt/cose (node, app and gov frontends) and the logging
  sample's /log/public/cose_receipt, cose_signature and
  cose_endorsements take identity_type; an unknown value is a 400, and a
  transaction without a receipt of that type a 404. Node API 5.0.11,
  governance API 5.0.2, logging app API 2.8.7
- ccf.cose.verify_cose_sign1_mldsa verifies pure ML-DSA COSE_Sign1
  messages, and verify_cose_sign1_with_key dispatches ML-DSA keys to it
- ccf.receipt.verify_cose accepts ML-DSA keys, and with endorsements
  verifies a receipt of a previous service identity through its chain;
  ccf.receipt.verify_cose_endorsements verifies the chain
- Unit tests, Python tests, e2e coverage of PQ receipts in e2e_logging
  and across recoveries in recovery_test, docs and change note

Relates to #7848.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 25bc4ac502844f069dc9238bf2de0fb7
- verify_cose_sign1_mldsa registers a decoder for the COSE_Sign1 tag, so
  that cbor2 decodes the message contents as a mutable list and dict. It
  no longer refers to cbor2.frozendict, which cbor2 only exports before
  Python 3.15, so ML-DSA verification works on 3.15 too
- verify_cose_endorsements and verify_cose raise TypeError when
  endorsements is a single message (bytes, bytearray, memoryview or str)
  rather than a sequence of COSE_Sign1 messages
- verify_cose_endorsements requires each epoch to end two views before
  the start of the next one, as the node does

Relates to #7848.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 25bc4ac502844f069dc9238bf2de0fb7
describe_cose_receipt_v1, describe_cose_signature_v1 and
describe_cose_endorsements_v1 keep their signature, and describe the
CLASSICAL receipt. describe_cose_receipt_v2, describe_cose_signature_v2
and describe_cose_endorsements_v2 take the identity type, and the receipt
endpoints and the logging sample call them.

Relates to #7848.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 25bc4ac502844f069dc9238bf2de0fb7
cbor2.CBORDecodeError derives from Exception only, so a truncated
message or protected header escaped the documented ValueError.

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

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