Engineering plan and Rust skeleton for reusing Arqueo's tree infrastructure and the
hbs-statestate guardian as the backend of a certification authority (CA) for Merkle Tree Certificates (MTC), the IETF proposal (draft-ietf-plants-merkle-tree-certs, PLANTS working group) so that post-quantum TLS does not pay three ML-DSA signatures per handshake.
This crate was born as the mtc/ directory of
Arqueo
and was extracted into its own repository with its history. It is a
standalone Cargo workspace with three dependencies (sha2, ml-dsa,
hbs-state) and no zero-knowledge machinery.
cargo test --release # draft vectors + end-to-end flow + the reference implementation's corpus
cargo run --release --example demo_ca
DEMO_DIR=/path/to/merkle-tree-certs/demo interop/run.sh /tmp/mtc-interop # both directions against the Go toolWhat running that verifies, and what it does not, is in section 7.
The name. mtc-core says what this is, the core of a Merkle Tree
Certificates CA, and nothing about who stands behind it. It is not an IETF,
PLANTS, Cloudflare, Google or Let's Encrypt project, and it is not endorsed
by any of them; their names appear because their specifications, code and
announcements are the subject matter. "MTC" is the generic abbreviation the
draft itself uses. The crate name was free on crates.io on 2026-09-30.
Where it comes from. This crate was born inside
Arqueo,
the author's engine of open conservation proofs for closed ledgers, as the
directory mtc/, and was extracted with git subtree split so that the
history travels with the code: the first commits of this repository are the
commits that created it there, with their messages, their gates and their
provenance trailers. What it takes from Arqueo is not the ledger and not the
zero-knowledge layer, but the tree infrastructure and the discipline around
it:
| from Arqueo | here |
|---|---|
zk-ssl-verify::mmr (RFC 6962 MTH, PATH, SUBPROOF) |
subtree, on SHA-256 and extended to subtrees |
zk-ssl-verify::inclusion (leaf → root → signed head) |
proof and verify |
zk-ssl-node::firma_cabeza (reserve, sign, self-verify) |
cosign::Cosigner |
zk-ssl-guardian, already extracted as hbs-state |
guard, as a dependency |
the idea of zk-ssl::sparse_tree (cached internal nodes) |
log, on an append-only tree |
the rule of zk-ssl-hash: one definition per format, shared by issuer and verifier |
the whole crate |
Nothing from the STARK layer, the winterfell fork or the ledger state machine travelled with it. Section 2 says, crate by crate, what was left behind and why.
How it was made. With a generative AI assistant, under the author's
direction; GENAI.md states the method, where it differs from
Arqueo's, and what that does and does not claim.
The hypothesis was: "Arqueo builds and updates the tree; hbs-state manages
its persistent state; it is enough to remove STARK and sums and change the
leaves". Having read the code of both repositories and the current draft, three
things must be corrected before designing anything:
-
hbs-statedoes not manage the tree's state. It is the index guardian for stateful hash-based signatures (XMSS/XMSS^MT): a monotonic counter persisted withfsync, a self-check that refuses to operate ontmpfs, and the four-state reconciliation after a restart. It iszk-ssl-guardianextracted from Arqueo, with zero dependencies. It has no cache and no tree. What it contributes to an MTC CA is an invariant, and it contributes it twice (section 2.3). -
The draft is no longer the 2023 "batches" one. The design that circulated with Cloudflare (
draft-davidben-…-04: an independent Merkle tree per batch, a signed "validity window",Assertionleaves in TLS format) was rewritten entirely in-05and is today a PLANTS working group document. In the current design:- the CA keeps append-only issuance logs in the style of RFC 9162, not per-batch trees;
- the leaf is an
MTCLogEntrywrapping a DER-encoded X.509TBSCertificateLogEntry, where the public key goes by its hash; - the CA signs subtrees
[start, end)with a cosignature format compatible with tlog witnesses (subtree/v1), and other cosigners (witnesses, mirrors) sign the same thing; - the certificate is an ordinary X.509 whose
signatureValuecarries anMTCProof(inclusion proof + cosignatures) and whosesignatureAlgorithmisid-alg-mtcProof; - there are two profiles: the standalone one (proof + cosignatures, immediate issuance) and the landmark-relative one (only the proof, with no signature at all, for clients that already have the subtree hash predistributed).
The premises of the hypothesis that do hold: no ZK, no sums, validity and extensions per certificate, inclusion proof under 1 KB.
-
What is reusable from Arqueo is not the sparse tree, it is what surrounds it. The MTC tree is a dense, grow-only RFC 9162 tree; the fixed-depth
SparseTreeofzk-ssldoes not fit (section 2.1). What does fit, almost line by line, iszk-ssl-verify::mmr(theMTH/PATH/SUBPROOFalgorithms of RFC 6962), the inclusion receipt that binds "leaf → root → signed head", the head signer that reserves the index before signing and verifies its own output, and the discipline that the issuer and the verifier compile the same definition of every format.
With ML-DSA-44, an X.509 chain with two Certificate Transparency SCTs adds
about 7.3 KB of signatures to the handshake (the draft's own figure). MTC
inverts the order: the CA certifies by recording in its log and signs one
checkpoint and two subtrees per cycle, not one certificate per request. A
certificate is then an inclusion proof of ceil(log2(n)) 32-byte hashes, plus
the cosignatures —or none, if the client already has the subtree as a
predistributed landmark—. Measured with this directory's example (section
4.4): 5,098 bytes for the standalone certificate with two ML-DSA-44
cosignatures, 274 bytes for the landmark-relative one of the same entry.
The starting note dated the problem with three milestones (NIST, Chrome, the Spanish press). They were checked against primary sources and corrected where needed. Method: the NIST, Google, Cloudflare and IETF servers are not reachable from the working environment, so the dates rest on (a) the git tags of the draft's repository, read directly, and (b) search snippets of the primary pages, cross-checked by a second independent reviewer. The "status" column says so row by row.
| date | milestone | primary source | status |
|---|---|---|---|
| 2023-03-10 | First individual draft draft-davidben-tls-merkle-tree-certs-00 (Google and Cloudflare): the batch design |
git tag -00 of the draft's repository |
confirmed |
| 2023-08-10 | Chromium Blog, "Protecting Chrome Traffic with Hybrid Kyber KEM": X25519Kyber768 in Chrome 116 as an experiment, not a rollout | blog.chromium.org/2023/08 | confirmed (day from snippets) |
| 2024-04-16 | Chrome 124 stable: X25519Kyber768Draft00 (code point 0x6399) by default on desktop only | chromereleases.googleblog.com, net/base/features.cc at tag 124.0.6367.60 |
confirmed |
| 2024-05-23 | Chromium Blog, "Advancing Our Amazing Bet on Asymmetric Cryptography" | blog.chromium.org/2024/05 | day unverified |
| 2024-08-13 | NIST publishes FIPS 203 (ML-KEM), FIPS 204 (ML-DSA) and FIPS 205 (SLH-DSA); effective on the 14th | nist.gov, Federal Register 2024-17956 | confirmed |
| 2024-09-13 | Google Security Blog, "A new path for Kyber on the web": Chrome 131 will move to X25519MLKEM768 (0x11EC) | security.googleblog.com | confirmed |
| 2024-11-12 | Chrome 131 stable: hybrid ML-KEM by default on desktop; on mobile from the 2024-12-04 commit | chromereleases.googleblog.com, kUseMLKEM at the 131.x tags |
confirmed |
| 2025-03-03 | draft-davidben-…-04, the last of the batch design |
git tag -04 |
confirmed |
| 2025-06-20 | draft-davidben-…-05: the redesign (issuance logs, subtrees, landmarks); Cloudflare and Geomys join as authors |
git tag -05 |
confirmed |
| 2025-10-28 | Cloudflare, "Keeping the Internet fast and secure: introducing Merkle Tree Certificates": intent to experiment with Chrome | blog.cloudflare.com | confirmed (day from snippets) |
| 2025-11 | PLANTS BoF at IETF 124 (Montreal) | datatracker, bofreq-westerbaan-…-plants |
day unverified |
| 2026-01 | The IESG charters the PLANTS working group (PKI, Logs, And Tree Signatures) | ietf-announce | day unverified |
| 2026-02-18 | Adoption: draft-ietf-plants-merkle-tree-certs-00 |
git tag | confirmed |
| 2026-02-27 | Google Security Blog, "Cultivating a robust and efficient quantum-safe HTTPS": MTC bootstrapping in the first quarter of 2027 and the Chrome Quantum-resistant Root Store in the third; no post-quantum X.509 in Chrome's root store | security.googleblog.com | confirmed |
| 2026-06-03 | Let's Encrypt, "A Post-Quantum Future for Let's Encrypt": MTC in staging at the end of 2026, production in 2027 | letsencrypt.org | confirmed |
| 2026-09-21 | draft-ietf-plants-merkle-tree-certs-06; this crate is written against the working repository as of 2026-09-29; first with its interim experimental OIDs (…47.5 for id-alg-mtcProof), and since AUDIT.md §18 with the IANA-assigned ones the working copy adopted that day (1.3.6.1.5.5.7.6.67, .25.3, .1.38), which are what the reference tool's -version plants-07 writes now |
git tag | confirmed |
| 2026-09-29 | Cloudflare announces its public CA and "Building a post-quantum certificate authority with Merkle Tree Certificates": experiment with 50 % of Chrome Beta 146, first MTCs in the first quarter of 2027 | blog.cloudflare.com | confirmed (day from snippets) |
| 2026-09-29 | Spanish press coverage of the Cloudflare announcement; the 20minutos headline matches that hook | infobae.com (same day) | unverified |
Corrections to the starting note:
- The 2023 Chromium post is titled "Protecting Chrome Traffic with Hybrid Kyber KEM", and Chrome 116 shipped it as an experiment to 1 % of Stable, not as a rollout.
- Chrome 124 came out on April 16, 2024, not the 17th, and enabled X25519Kyber768Draft00, a pre-standard draft: ML-KEM did not exist as a FIPS until August. The move to ML-KEM (X25519MLKEM768) was Chrome 131, in November. And on desktop only until December 2024.
- The NIST press release does not name RSA or ECC: it "urges starting the transition as soon as possible". The explicit deprecation of RSA/ECDSA/ECDH (2030 and 2035) is in the draft NIST IR 8547, of November 2024.
- The Chrome + Cloudflare experiment was announced by Cloudflare (October 2025) and ran in 2026 with Chrome Beta 146; there is no Chromium Blog post about MTC. The PLANTS group is from 2026, not 2025.
- The 20minutos piece does not appear to adapt NIST or Chrome press releases: the hook that matches its headline and its date is the Cloudflare announcement of September 29, 2026.
Unverified: the headline, the date and the byline (Portaltic/Europa Press, EFE or in-house staff) of the 20minutos article; no search returned its identifier and the domain is unreachable from here. The attribution to a news agency is plausible and not evidenced.
| Arqueo crate / module | destination | why |
|---|---|---|
stark-experiment, zk-ssl-air, winter-air / winter-prover / winter-verifier (the fork), zk-core, halo2-experiment, plonk-experiment, nova-experiment, ceremony, settlement-prover, settlement-layer, iso-bridge |
remove | The whole zero-knowledge proof layer and the five-backend comparison. MTC proves nothing in-circuit: privacy against the operator is not a goal (the log is public on purpose). |
zk-ssl (the layer: accounts, mint, burn, pending, freeze, governance, recovery, two_phase, instrumento_*, prueba_*, consumo, iso) |
remove | It is the accounting state machine. There are no balances, no two-phase payments, no conservation to prove. |
zk-ssl::sparse_tree::SparseTree |
adapt → log |
The idea (internal nodes cached, O(log n) per write, level-by-level rebuild on startup) is kept; the structure is not: MTC needs a dense, append-only RFC 9162 tree, where the cache is "one vector per level holding the complete nodes". |
zk-ssl-hash (native_merge, path_root, mmr_hoja/mmr_nodo, epoch_digest_*) |
replace → hash + cosign |
Rescue Prime over Goldilocks only made sense inside a STARK. The hash becomes SHA-256 with the 0x00/0x01 prefixes of RFC 6962. The rule is kept whole: one format decision, one single definition, shared by issuer and verifier. |
zk-ssl-verify::mmr (cima, prueba_de_inclusion, prueba_de_consistencia and their verifications) |
translate → subtree |
They are MTH, PATH and SUBPROOF of RFC 6962. They are translated to SHA-256 and extended to subtrees [start, end), which is what the draft adds. The property that verification is the mirror recursion of generation is kept. |
zk-ssl-verify::inclusion::ReciboInclusion |
translate → proof + verify |
The receipt "leaf → path → root → signed head" is exactly MTCProof: a bare root proves nothing; what proves is the cosignature over the subtree (or the predistributed subtree). |
zk-ssl-node::firma_cabeza::FirmanteCabeza |
translate → cosign::Cosigner |
Reserve the index, sign, verify one's own output with the same verifier a third party will use. With ML-DSA there is no index; with XMSS there is, and the interface (&mut self) allows it. |
zk-ssl-guardian |
already is hbs-state |
hbs-state is used whole as a dependency, not reimplemented (section 2.3). |
zk-ssl-node::latido (the epoch close) |
adapt → ca::run_checkpoint_job |
The latido (heartbeat) composes the head and signs it; the checkpoint job signs the checkpoint, covers what is new with two subtrees, collects cosignatures and issues. |
zk-ssl::persistence / snapshot (sled, encryption at rest) |
later | A CA's log lives on disk; the form (sled, append-only file, tlog-tiles) is decided in phase 1. Here IssuanceLog::from_entries leaves the hook. |
zk-ssl-wire (JSON-RPC DTOs, OpenRPC generated from the code) |
redo | MTC's wire is HTTP: ACME towards the requester, tlog-tiles towards monitors and mirrors. The discipline "the wire is generated from the code and frozen in vectors" is kept. |
zk-ssl-cli, zk-ssl-sdk |
redo | A CA operator's CLI looks nothing like a ledger's. |
tools/canon.sh, tools/conformidad.sh, the practice of vectors per version |
keep the methodology | The four accumulated vectors of the draft play here the role of spec/vectors/. |
Dependencies that leave: winterfell and its subcrates, sled,
chacha20poly1305, xmss (until phase 4). Dependencies that enter: sha2,
ml-dsa (optional, enabled by default) and hbs-state.
- Privacy against the operator. Arqueo hid the proof's witness; in MTC the log is public by design (transparency). There is nothing to hide and that is why there is no STARK.
- Conservation. Arqueo's invariant was "supply = balances + in flight". A CA's is "everything I issued is in my log and everything in my log I certified", and it is enforced by the cosigners and the monitors, not by a circuit.
- The epoch head as the unit of trust. In Arqueo every head travels signed. In MTC the landmark-relative certificate carries no signature at all: the trust is predistributed by the client's update channel.
It is kept whole and as a dependency, not as a copy. Its invariant is:
no signature may exist with an index greater than the persisted counter.
Applied to an MTC CA in two places:
| where | what it keeps | why it is the same invariant |
|---|---|---|
ca::run_checkpoint_job, step 0 |
the checkpoint number, reserved with IndexGuard::reserve (persisted with fsync) before signing |
What it gives, stated precisely: a counter that survives a crash and a diagnosis at startup. If the process dies between reserving and signing, the number is orphaned (CounterAhead, the normal case); if at startup the log's journal is ahead of the counter (KeyAhead), someone signed without going through the guardian and the CA does not start. What it does not give: the number does not enter the CosignedMessage (the draft has no place for it), so the guardian by itself does not prevent a CA from publishing two views of the log; that is detected by witnesses with consistency proofs. Avoiding a split view by accident requires the full order of phase 1: persist the entries with fsync, reserve, sign. |
| the CA's cosigner, if it is XMSS/LMS | the signature index, as in FirmanteCabeza |
The CA signs one checkpoint and two subtrees per cycle, not one certificate per request: that is the rate at which a stateful signature is viable. |
IndexGuard::open measures its own fsync and refuses on tmpfs; the
end-to-end test opens it in CARGO_TARGET_TMPDIR and, if the guardian refuses,
says so and does not measure, instead of pretending it measured.
The names follow the draft, not the hypothesis: where the latter said
MTCLeaf, here there is a working structure MtcLeaf and the entry that
is actually hashed, MtcLogEntry, because they are two different things that
have to agree.
/// What the CA certifies about a requester (working structure).
pub struct MtcLeaf {
pub version: u8, // 2 = v3
pub issuer: Vec<u8>, // Name DER: ALWAYS the CA ID
pub validity: Validity, // { not_before, not_after } POSIX
pub subject: Vec<u8>, // subject Name DER
pub spki: Vec<u8>, // SubjectPublicKeyInfo DER, whole
pub issuer_unique_id: Option<Vec<u8>>,
pub subject_unique_id: Option<Vec<u8>>,
pub extensions: Option<Vec<u8>>, // Extensions DER (SAN, key usage…)
}
/// What is recorded in the log (the draft's TLS serialization).
pub enum MtcLogEntry {
Null { extensions: Vec<LogEntryExtension> },
TbsCert { extensions: Vec<LogEntryExtension>, tbs_cert_entry_data: Vec<u8> },
}Compared with the sketch (public_key, subject, validity, extensions):
public_keydoes not go in the leaf: what goes issubjectPublicKeyAlgorithmplus the hash of the SPKI (subjectPublicKeyInfoHash, with the log's hash). It is the reason the log does not grow with ML-DSA. The whole key goes in the certificate.validityandextensionsare ordinary X.509 fields, per certificate.- There are two sets of extensions: the X.509 ones (inside the TBS) and the
log entry's (
LogEntryExtension, TLV, normally empty), which also travel in theMTCProof.
From an MtcLeaf come two encodings that a test cross-checks byte by byte:
tbs_cert_entry_data() (the fields of the TBSCertificateLogEntry
concatenated, without header, so that the verifier hashes in a single step) and
tbs_certificate(serial) (the TBSCertificate with serialNumber = (log << 48) | index and signature = id-alg-mtcProof).
/// A subtree [start, end): start is a multiple of BIT_CEIL(end - start).
pub struct Subtree { pub start: u64, pub end: u64 }
/// MTCProof: what goes in the certificate's signatureValue.
pub struct MtcProof {
pub extensions: Vec<LogEntryExtension>, // the entry's, copied
pub subtree: Subtree, // uint48 start, uint48 end
pub inclusion_proof: Vec<HashValue>, // at most ceil(log2(size)) hashes
pub signatures: Vec<SubtreeSignature>, // empty in a landmark-relative cert.
}
pub struct SubtreeSignature { pub cosigner_id: TrustAnchorId, pub signature: Vec<u8> }
/// The certificate: a DER-encoded X.509 TBSCertificate and its proof.
pub struct MtcCertificate { pub tbs_certificate: Vec<u8>, pub proof: MtcProof }Cosignatures go in canonical order by cosigner_id (shortest first, then
lexicographic) and the decoder rejects duplicates and misordering, as the draft
requires.
pub struct CosignedMessage {
pub cosigner_id: TrustAnchorId, // "oid/1.3.6.1.4.1.32473.1"
pub timestamp: u64, // 0 inside a certificate
pub log_id: TrustAnchorId, // caID.0.N
pub subtree: Subtree,
pub subtree_hash: HashValue,
}
// fixed label "subtree/v1\n\0" in front: compatible with tlog-witnesspub struct RelyingPartyConfig {
pub ca_id: TrustAnchorId,
pub ca_oids: OidSet, // the CA certificate's; its name attribute is compared
pub cosigners: Vec<(TrustAnchorId, Box<dyn CosignatureVerifier>)>,
pub required_cosigners: Vec<TrustAnchorId>, // witnesses/mirrors; the CA is always required
pub trusted_subtrees: Vec<TrustedSubtree>, // predistributed landmarks
pub revoked_ranges: Vec<(u64, u64)>, // [min, max] inclusive, like minSerial/maxSerial
}The CA's cosignature is always required (it is the certificate's signature); those from cosigners the configuration does not recognize —GREASE included— are ignored, as the draft mandates, and an ID of any shape is decoded without interpreting it.
CSR (PKCS#10) ┐
ACME challenge / validation ├─ upper layer: x509-cert + ACME (phase 2)
proof of possession ┘
│
▼ CertificateRequest { subject, spki, validity, extensions } already validated
┌──────────────────────────────────────────────────────────────────────┐
│ CertificationAuthority::submit │
│ MtcLeaf { issuer = Name(caID), … } │
│ → MtcLogEntry::TbsCert { tbs_cert_entry_data } │
│ → IssuanceLog::append (leaf = SHA256(0x00 || entry)) │
│ → index i │
└──────────────────────────────────────────────────────────────────────┘
│ … every few seconds, the checkpoint job …
▼
┌──────────────────────────────────────────────────────────────────────┐
│ CertificationAuthority::run_checkpoint_job(now) │
│ 0. guard.reserve() ← fsync BEFORE signing (hbs-state) │
│ 1. CA signs [0, tree_size) with timestamp = now (checkpoint) │
│ 2. (left, right) = covering_subtrees(last_checkpoint, tree_size) │
│ 3. CA signs each subtree with timestamp = 0 │
│ 4. each external cosigner signs each subtree │
│ 5. → SignedSubtree { subtree, hash, ordered signatures } │
└──────────────────────────────────────────────────────────────────────┘
│
├─▶ standalone_certificate(i): TBSCertificate(serial) +
│ MTCProof { subtree containing i, PATH(i), cosignatures }
│
└─▶ (hourly) allocate_landmark(now) → landmark L = current tree_size
landmark_relative_certificate(i): same TBS +
MTCProof { landmark's subtree containing i, PATH(i), ∅ }
In code, the full cycle fits on one screen (examples/demo_ca.rs):
let mut ca = CertificationAuthority::new(cfg, Box::new(ca_signer), IndexGuard::open(path)?)?;
ca.add_cosigner(Box::new(witness))?;
let i = ca.submit(request)?; // 3a · check and record in the log
let cp = ca.run_checkpoint_job(now)?; // 0-4 · reserve, sign, cover, cosign
let standalone = ca.standalone_certificate(i)?; // 5 · proof + cosignatures
let der = standalone.to_der()?; // X.509 with id-alg-mtcProof
ca.allocate_landmark(now)?; // hourly
let relative = ca.landmark_relative_certificate(i)?; // proof alone, no signaturesThe checkpoint hash is MTH(D[0:tree_size]). IssuanceLog keeps one vector
per level with the complete nodes, so append closes the pairs that become
complete (amortized O(log n)) and root() or subtree_hash([start, end)) walk
down the right edge in O(log n) lookups. Memory is 2n hashes. A test walks
every subtree of every tree up to 130 leaves and demands the same hash and the
same proofs as the literal RFC 9162 recursion.
signatureAlgorithmisid-alg-mtcProof; decode theMTCProofwith no trailing bytes.- Non-negative 64-bit
serial; reject if it falls in a revoked range. index = serial & (2^48-1),log_number = serial >> 48(zero: reject).issuermust be theNameof the configured CA: its CA ID (ca_id) under the trust anchor ID attribute of the setsignatureAlgorithmnames (one set per certificate), and that attribute must be the CA's (ca_oids, from its CA certificate); otherwiseUnknownIssuer.log_id = caID.0.N.- Rebuild the entry from the
TBSCertificate(version, issuer, validity, subject, key algorithm,OCTET STRING(SHA256(SPKI)), the rest) and hash it with0x00in front. - Evaluate the inclusion proof → expected subtree hash.
- If
(log_number, start, end)is a trusted subtree, compare hashes and finish. Otherwise, require a valid cosignature from each required cosigner overCosignedMessage { …, subtree_hash = expected }. - Continue with the rest of X.509 validation (here, expiry).
| subtree | hashes | cosignatures | DER bytes | |
|---|---|---|---|---|
| standalone for entry 6 | [6, 8) |
1 | 2 (CA + witness, ML-DSA-44) | 5,098 |
| relative to landmark 1 for the same entry | [4, 8) |
2 | 0 | 274 |
An ML-DSA-44 public key is 1,312 bytes and a signature 2,420: the standalone is almost all signatures, and that is why the draft insists that relying parties negotiate cosigners instead of requiring them all. The signatures are salted (the hedged variant FIPS 204 recommends), so two runs of the example give different bytes that verify the same.
| module | contents | comes from |
|---|---|---|
hash |
RFC 9162 MTH over SHA-256: hash_empty, hash_leaf, hash_node |
replaces zk-ssl-hash |
subtree |
Subtree, is_valid_subtree, covering_subtrees, inclusion_proof / evaluate_inclusion_proof / verify_inclusion_proof, consistency_proof / verify_consistency_proof, the TreeHashes trait and the LeafHashes reference |
zk-ssl-verify::mmr |
log |
IssuanceLog: append-only, complete nodes cached, from_entries for startup |
the idea of zk-ssl::sparse_tree |
entry |
MtcLeaf, MtcLogEntry, LogEntryExtension, Validity, entry_bytes_from_tbs |
new |
der |
the bare minimum of DER/X.509: TLV, INTEGER, OID, times, the CA ID's Name, parse_tbs, parse_certificate |
new |
cacert |
CaCertificate: the CA's own certificate (subject = CA ID, the cosigner's key, the critical MTCCertificationAuthority extension, key usage and basic constraints), written unsigned (RFC 9925) and read from any implementation |
new |
spki |
SubjectPublicKeyInfo of cosigner keys: parse, compose, the ML-DSA OIDs of RFC 9881 |
new |
pem |
PEM (RFC 7468) and strict base64, without dependencies | new |
tai |
TrustAnchorId: stores the binary form, tolerates any well-formed ID on the wire (GREASE), strict with what an operator types; arbitrary-precision ASCII, oid/…, log/landmark/group IDs, canonical order |
new |
cosign |
CosignedMessage (with its rules: timestamp only on checkpoints), Cosigner, CosignatureVerifier, SignedSubtree; mldsa::{MlDsaCosigner, MlDsaVerifier} with salted signing by default, seed zeroization and the tlog-cosignature key ID |
firma_cabeza |
guard |
SequenceGuard over hbs_state::IndexGuard; MemoryGuard for tests only |
hbs-state |
landmark |
LandmarkSequence: allocate, each landmark's subtrees, active ones, publish |
new |
proof |
MtcProof (TLS encoding) and MtcCertificate (DER) |
zk-ssl-verify::inclusion |
ca |
CertificationAuthority: submit, run_checkpoint_job, standalone_certificate, allocate_landmark, landmark_relative_certificate, active_landmark_subtrees |
zk-ssl-node |
verify |
verify_certificate for the relying party, without compiling the CA |
zk-ssl-verify |
How it is tested, in three layers:
- Against the draft:
tests/vectors.rsreproduces the four accumulated vectors of the test appendix (712 subtree hashes, 12,807 inclusion paths, 42,893 consistency paths and 8,646 coverings: every subtree of every tree up to 130 leaves), plus the validity and covering cases up to2^64-1; andtests/large_vectors.rsevaluates the 24 inclusion and 21 consistency vectors the draft's repository publishes for trees of2^48-1,2^63-1and2^64-1leaves, with a hand-written JSON and base64 reader. It includes the exercise the draft asks of the verifier: every proof evaluated, and rejected when shortened or lengthened by one hash (and by one byte, at theMTCProoflevel), and with one bit flipped in the path or in the subtree and tree hashes. - Against itself: the
IssuanceLogcache against the reference recursion; the entry the CA records against the one the verifier rebuilds; the encodedMTCProofagainst the decoded one, with canonical order also enforced on decoding; malformed DER (overflowing, indefinite, non-minimal lengths, impossible dates) rejected without panicking. - End to end:
tests/end_to_end.rsissues with ML-DSA-44 at the CA and at a witness, verifies standalone and landmark-relative, and checks that the following fail: SAN tampering, a missing cosignature (from the CA or the witness), the proof of another index, revocation by range, expiry and an unknown issuer; that a GREASE cosignature is ignored; that the CA rejects out-of-bounds validity, malformed DER, unknown entry extensions and duplicate cosigners; and that a landmark does not expire before its entries; and it opens a realIndexGuardon disk to check that the checkpoint number survives the process and reconciles (if the file system does not persist, the test says so and skips; any other error makes it fail). - Against the reference implementation:
tests/interop_corpus.rshands this crate's verifier the corpus thatdemo/in the draft's repository (Go) generated, 26 certificates with five deliberate negatives, and requires the 26 verdicts the Go verifier gave; it also rebuilds the Go tool's log of 2122 entries from its tiles and reproduces its checkpoint. The other direction (certificates from here through the Go verifier) needs Go and runs frominterop/run.sh(section 7 says what was measured and where).
Phase 0 — this directory. The verifiable core: tree, subtrees, proofs, entries, cosignatures, landmarks, in-memory CA and verifier. Done.
Phase 1 — persistence and startup. An append-only store of entries (a
file with fsync per checkpoint, or sled as in zk-ssl::persistence), with
the order that avoids a split view: entries persisted with fsync, number
reserved, signature. IssuanceLog::from_entries at startup, a CA constructor
from the store (leaves, size of the last checkpoint, landmarks) and the
reconciliation guard.reconcile(last_checkpoint_in_journal) with Arqueo's
policy: only KeyAhead prevents startup. Pruning of signed_subtrees with
prune_signed_subtrees_below when a landmark covers what was signed. Measure
startup with a million entries, as Arqueo's benchmark B.4 did.
Phase 2 — the real input. PKCS#10 CSR parsing and construction of
Name/Extensions with x509-cert; domain validation and proof of possession
via ACME (RFC 8555) with the draft's extension (the mtc-landmark-relative
link to later collect the relative certificate). This crate does not change: it
receives CertificateRequest.
Phase 3 — the log outward. Serve the log with tlog-tiles according to
C2SP's mtc-tlog profile (checkpoint signed by the CA as a note, landmarks
in text, URL prefix per log), and request cosignatures from real witnesses with
the tlog-witness protocol. The CosignedMessage here is byte for byte the
cosigned_message of tlog-cosignature for ML-DSA-44, and MlDsaCosigner
already computes the key ID and the timestamped_signature of the note line
(examples/interop.rs checkpoint reads the reference tool's note and tiles,
and finds that today the tool signs its checkpoint with timestamp zero and no
timestamp bytes on the line, which is not the tlog-cosignature line format;
a question for the working group, recorded in AUDIT.md §14); what remains is
the full note format (signed-note), the witness's HTTP client
(add-checkpoint, sign-subtree) and the check of the consistency proofs the
witness requires, which subtree already knows how to generate and verify.
Phase 4 — the CA as trust anchor. The CA's certificate with the
MTCCertificationAuthority { sigAlg, minSerial, maxSerial } extension is
cacert::CaCertificate (written unsigned, read from the reference tool and
by it); what remains is an XMSS/LMS cosigner with hbs-state in front (the
same Cosigner, &mut self already allows it) and the TLS side: trust_anchors with the landmark groups
(caID.2.N.L) in rustls, so that the server chooses between the standalone
and the relative one.
Phase 5 — the fork. New repository with this directory as root; from Arqueo the methodology is carried over (test canon per crate, vectors frozen per version, one ledger entry per change) and none of the STARK code.
- It is not audited. Neither this code, nor
ml-dsa(its own crate says so), norhbs-state. The OIDs are the IANA-assigned ones by default (CaConfig::oids), and a relying party here also accepts the two experimental sets of the1.3.6.1.4.1.44363.47arc that have been on the wire (AUDIT.md §18), one set per certificate and each under a CA whose name uses the same attribute (§22, §25); the draft may still change: the version read is the PLANTS working group's working repository as of September 29, 2026. It did pass an internal adversarial review (six reviewers per dimension, one skeptic per finding) against the draft, the reference implementation and the C2SP specifications; what was confirmed is corrected and covered by tests, and that is no substitute for an audit. - Interoperability is measured against three implementations. The
demo/directory of the draft's repository (a generator and a verifier in Go, commit99097c9e,-version plants-07) was the first measured. Since 2026-09-29 there are two more, announced on the working group's list with an end-to-end demonstration (AUDIT.md §17): OpenSSL's (pull requestopenssl/openssl#33014, the TLS client and server side in C) and Bob Beck's rewrite of Cloudflare's Go CA and mirror for-06(github.com/bob-beck/cloudflare-mtc). Against those two, atecf0476andc6cdfe2, withinterop/run-openssl.sh(§19): his verifier gives every certificate from here, with either OID set, the verdict expected of it; his CA's 20 certificates, issued through his mirror, and four negatives cut from them byte by byte get the same verdict here as from his verifier, in four configurations; OpenSSL'ss_clientaccepts this crate's standalone and landmark-relative certificates in a TLS 1.3 handshake with itss_server, which picks between them by trust anchor ID, and refuses the five negatives; and OpenSSL's own test corpus gets the same verdicts here as from his verifier, except one certificate that mixes the IANA signature algorithm with the experimental issuer, which both accept and this crate refuses (a policy difference, recorded in §19; the stricter reading is kept, and a test holds it, §22; the draft's principal author answered on the list that a CA is one draft and a draft one set of OIDs, §24). That run was made by the assistant in a container and repeated by the author on his machine, with the same result (§21). The OIDs changed meanwhile:demo/writes the IANA-assigned ones sincead4256b, and the measurement was repeated against it at38014f7, with the same verdicts (§18; repeated by the author on his machine, §23). Cloudflare's ownbwesterb/mtcstill follows the earlier batch design. Againstdemo/, in both directions and with negatives, everything measured agrees: its 26 verdicts are reproduced here (tests/interop_corpus.rs, offline), its log of 2122 entries is rebuilt from its tiles to the same root, and the Go verifier accepts the certificates from here, with this crate's CA certificate and with its own (the two CAs share a test key, so the ML-DSA-44 key derivation and signatures agree byte for byte). The run is recorded in AUDIT.md §14: it was made by the assistant in a container with Go 1.27.1 built from source, and repeated by the author on his machine with the same result (§15). Not measured: any other implementation, cosigners with ECDSA or Ed25519 keys (their cosignatures are ignored here), the Go tool's cosigner groups (this crate's policy is "the CA and all of these"), the witness and mirror protocols (this crate has neither), and anything of TLS beyond the certificate: both ends of the handshake are OpenSSL's. - There is no request validation:
CertificateRequestarrives validated. Certifying what arrives is this crate's job; that it is true, the operator's. - The log is not persisted, only the guardian's counter. And
MemoryGuardexists to be able to measure without disk: a CA that starts with it reuses checkpoint numbers after every crash. - The cosigner policy is the minimal one ("the CA and all of these"). Quorums, cosigner negotiation in TLS and mirrors are left for phase 3.
- It is not measured at scale. The cache is O(log n) by construction, but the timings with millions of entries have not been measured, and in Arqueo that measurement changed the design twice.