Carr Digital

Measurement · September 2, 2026

The 87-byte certificate

I minted a real Merkle Tree Certificate with one of the draft authors' own tooling and measured 87 bytes, then 727 bytes in a batch of a million. The more useful finding is that the failure I captured, a verifier refusing a certificate because it had fallen behind, was designed out of the specification fourteen months before I ran it.

On the evening of 31 August I minted a real Merkle Tree Certificate on my own bench, for a lab domain, with the Go implementation written by one of the draft’s authors. It measured 87 bytes.

For scale, one ML-DSA-87 signature is 4,627 bytes, fifty-three times the whole certificate.

If you have been told that post-quantum authentication means a fatter handshake, that number is the industry’s counterargument, and you should be suspicious of it for a reason that has nothing to do with the arithmetic. The tool that produced it implements a version of the design the specification left behind fourteen months ago. I did not know that when I started. Finding it out was worth more than the byte count.

Where the signature went

An X.509 certificate is a passport. The security features are printed on the document, and every copy carries them at full weight. A Merkle Tree Certificate is a coat-check ticket. It holds the claim, this domain and this key, plus directions to your spot on the rack.

The rack is a Merkle tree. The certificate authority collects the requests it has received, hashes each one into a leaf, hashes pairs of leaves into parents, and repeats until one hash sits at the top. That top hash is the only thing the CA signs. Your ticket carries the sibling hashes along the path from your leaf up to the top, and anyone holding the signed top hash can replay that path and check you were in the tree.

So the signature is not in the certificate. It never was. The batch mine landed in held exactly one entry, which makes a tree with one leaf, which needs zero siblings to prove. The path was empty and the file was 87 bytes.

Cloudflare made the same reveal a year ago, and better, by running a real one through a standard tool. From their October 2025 post: “OpenSSL appears to not recognize the signature algorithm of the issuer and just prints the raw OID and bytes of the signature. There’s a good reason for this: the MTC does not have a signature in it at all!”

Where it went is the shared page. This CA signs a validity window, a run of tree heads covering every batch currently alive. Mine measured 5,017 bytes and comes apart cleanly:

4,627  ML-DSA-87 signature
  384  12 tree heads at 32 bytes each
    6  framing
-----
5,017  bytes

Two things pin the algorithm without my assuming it. The CA announces ml-dsa-87 in its own parameters, and its private key file on disk is 4,896 bytes. I generated a fresh ML-DSA-87 key with local OpenSSL 3.5.5 and signed a message with it: the expanded private key is 4,896 bytes and the signature is 4,627. Both land on the nose.

The 12 is arithmetic, not magic. This CA cuts a batch every 5 minutes and its assertions live one hour, so twelve batches are alive at any moment and twelve heads ride in every window.

Decomposing a validity window this way is Maximilian Pohl’s move, in a Radboud University master’s thesis from January 2025, against the same generation of the design. He splits it the same way and adds the rule I would have had to find the hard way: every doubling of the certificate population adds exactly 32 bytes to a proof, because the tree gets one level deeper. I am reporting a measurement of one tool’s output. He published the anatomy.

The CA is a clock, not a desk

Seven minutes passed between creating my CA and issuing, which crossed a five-minute boundary, so the tool issued two batches instead of one. The first was empty. It got a full 5,017-byte signed window anyway, twelve identical hashes of an empty tree.

Nobody had applied for anything. The clock issued a certificate batch because it was time to issue a certificate batch.

That is the part of this design that will surprise operations people, and my accident is a fair demonstration of it. Issuance is a scheduled event, not a response to your request. You get in the tray, and the tray goes out when the timer says so.

A million neighbors, and the toy gets graded

A month earlier I had built a toy version of this in about 150 lines of Python and used it to predict what a proof would cost at web scale. A batch of 2^20 certificates, the toy said, needs a 640-byte proof.

So I filled the tray. One real assertion, 1,048,575 machine-generated neighbors, one batch:

entries file        90,114,982 bytes
validity window          5,017 bytes   (unchanged)
certificate                727 bytes
authentication path         20 hashes
index                  208,133

Seven hundred twenty-seven minus the 87-byte base is 640 bytes of path, 20 hashes at 32 bytes. The toy called it exactly. That is not a coincidence and it is not impressive, it is log base 2 of a million, and the point of checking was that I had never confirmed my toy was honest rather than merely confident.

The window did not move. One 4,627-byte signature now vouches for 1,048,576 certificates, which is about 0.004 bytes of certificate authority signature per certificate. Doing the same job with X.509 spends 4,627 bytes on each one. Closing that gap is what the PLANTS working group was chartered to chase.

One small thing I did not expect: my real assertion landed at index 208,133, not index 0. Reading the CA’s source, each queued assertion is stored under a hash of itself and the queue is walked in key order, so 208,133 is just where my hash fell. Order belongs to the notary, and the notary does not care when you arrived.

The bouncer I captured no longer works this way

Verification failed twice that evening, once by accident and once on purpose, and the two errors are different in a way that matters:

error: Batch number (1) out of range [0, 0]
error: Authentication path invalid

The first came from checking a batch-1 certificate against batch 0’s window. The certificate is genuine and the verifier is out of date. The second came from flipping a single byte in the certificate, which is an actual forgery, and the math caught it.

I wrote that up as the operational story: a relying party has to keep fetching fresh windows forever, and one that stops updating stops being able to say yes. Then I did the prior-art work, and two things came apart.

The story is told. Cloudflare posed it as an open question in that same October 2025 post: “Getting the performance benefit of MTCs requires the clients and servers to be roughly in sync with one another.” Five months later Luke Valenta answered it with production numbers at IETF 125, from the live Chrome and Cloudflare experiment: most clients update within 23 hours, and in steady state 0.5 to 1.5 percent are stale, flagged on the slide as an upper bound. A paper on Kubernetes and 5G cores works the same failure through pod scheduling and lagging tree servers.

And the failure I captured is gone from the specification. In June 2025 the design was rewritten. David Benjamin named the new iteration “Photosynthesis”, a pun on Certificate Transparency’s long habit of naming things after sunlight, and owned it plainly: “we’re converting sunlight into certificates.” The working group got its plant theme five weeks later, when the work reached a mailing list called PLANTS. Batches became a continuously growing log, one per CA then and a numbered series per CA in the current draft. Validity windows became landmarks, subtree hashes pushed to browsers out of band roughly hourly. My tool stores everything under a path that says v04b and its README opens with a warning that it predates the rewrite.

So my stale verifier refusing to answer is a behavior of the design as it stood in March 2025.

What a stale browser costs now

It does not refuse. It gets served something heavier.

Under the current draft the server holds two certificates for the same log entry. The small one is landmark-relative: an inclusion proof and nothing else, which the draft sizes at 736 bytes for a subtree of about 4.4 million certificates. The other is standalone, and it carries the proof plus enough cosignatures to satisfy whatever the relying party demands. When those cosignatures are post-quantum, the bytes the design exists to remove come back for that handshake.

The browser does not choose. It confesses. It sends one identifier in the trust_anchors extension that encodes the CA, the log, and the most recent landmark it holds, and the draft’s own example is 32473.1.2.8.42 for a client current as of landmark 42 of log 8. The server reads that, and if it can serve the small certificate it does. Otherwise it reaches for the fat one. Cloudflare’s plain-English version: the client tells the server which tree heads it has.

Two consequences if you are going to operate this.

Falling behind is now a performance problem rather than an outage, which is why a measured 0.5 to 1.5 percent staleness rate reads as a tuning number instead of an incident. In the experiment the landmark path was about 9 percent faster at the median, 105 milliseconds against 116, with classical signatures on both sides.

And renewal moves. The specification recommends renewing about 75 percent of the way through a certificate’s life and keeping both certificates until the old one expires, because a freshly issued certificate only gets its small landmark-relative form once the next landmark is allocated and reaches browsers. The standalone form is available immediately, and it is the expensive one. The habit of renewing at the last safe moment is the habit this design punishes hardest.

The diff is the document

I found five public implementations and ran one. The tool I used sits at -04 plus some unmerged pull requests, by its own README. Cloudflare’s certificate authority crate describes its log entry format as “approximately draft-davidben-tls-merkle-tree-certs-09” and says it is “intentionally frozen at the bootstrap protocol version and will not be updated to track the IETF draft.” DigiCert’s playground says it implements draft-ietf-plants-merkle-tree-certs-01. Pohl’s Rustls fork is against -03, and the Kubernetes paper’s implementation against -02. The working group is at -05, published 6 July 2026.

None of them is current. That is not a criticism of anyone, it is what a specification under active development looks like from the outside, and it is the honest frame for every number above. These measurements grade a design, teach the machinery, and describe a wire format that has already changed.

Which makes the interesting document the one I cannot write yet. When tooling catches up to the current draft, the same evening’s work runs again and the two sets of numbers get subtracted. Let’s Encrypt is targeting a staging environment late this year and production in 2027. Chrome has published a three-phase plan that ends in a root store accepting nothing else.

The 87 bytes were never the finding. The finding is that I could hold a working artifact of a design that no longer exists, and it took a prior-art sweep rather than a compiler to tell me so.

Provenance

Measured here (Verified). Session run 2026-08-31 on WSL2 Ubuntu 26.04, using bwesterb/mtc at commit 36baf0c (2025-11-05), built from a clone with go1.26.0. Every byte count above comes from captured tool output or from the artifacts still on disk: certificate sizes 87 and 727 bytes, the 640-byte authentication path at 20 hashes, the 5,017-byte validity window and its 4,627 + 384 + 6 decomposition, index 208,133, and both verification errors quoted verbatim are in the captures; the 90,114,982-byte entries file and the neighbour count are measured from the CA directory the session left behind. The 4,627 + 384 + 6 split identifies two components independently and takes the remaining 6 bytes as framing, which is a decomposition rather than a parse of the wire format. The ML-DSA-87 signature size (4,627 bytes) and expanded private key size (4,896 bytes) were generated with local OpenSSL 3.5.5 on 2026-09-02 rather than copied from FIPS 204, and the lab CA’s key on disk matches at 4,896 bytes. The 12 tree heads follow from the CA’s own parameters, a 5-minute batch duration against a 1-hour lifetime.

Cited, not claimed (Reported). Every source below was downloaded and searched in full rather than read through a summary, on 2026-09-02. The anatomy of a validity window and the 32-bytes-per-doubling rule are Pohl’s thesis (Radboud University, 2025-01-30), written against the same design generation. The signatureless reveal and the client-freshness question are Cloudflare’s (2025-10-28). The staleness and latency measurements are Valenta’s IETF 125 slides, from an experiment covering 1,000 Cloudflare-proxied domains and half of Chrome Beta 146 and later. The deck itself carries no date; IETF 125 opened 2026-03-14, per the Datatracker. Landmark staleness in cluster deployments is arXiv 2604.04191 (2026-04-05). The 736-byte landmark proof estimate, the trust_anchors landmark group identifier, the two-certificate selection rule, and the 75 percent renewal recommendation are draft-ietf-plants-merkle-tree-certs-05 (2026-07-06), sections 6.5, 8.2, 8.2.1, and 10.4. That a standalone certificate is issued without the landmark’s processing delay is section 6.3. The “Photosynthesis” name is David Benjamin on the chromium.org ct-policy list (2025-06-30). Implementation versions are quoted from each project’s own README rather than read off its source; the tool I ran states its own revision, “we differ from -04 and main branch of the specification, by including some unmerged PRs”. Draft revision dates come from the IETF Datatracker’s revision API, saved alongside the other sources. Deployment timelines are Let’s Encrypt’s (2026-06-03) and Google’s (2026-02).

Inferred, not stated by a source (Proposed). That the tool’s v04b storage path is the same -04 its README names is my reading, and the README’s “plus unmerged PRs” means the artifacts I measured are near that revision rather than exactly it. That queue ordering explains index 208,133 is my reading of the CA’s source, not a documented behavior. The claim that no available implementation tracks the current draft rests on five projects’ own descriptions of themselves, only one of which I built and ran. It is a statement about my search, not about the world.

What this article stopped claiming. The first draft called the stale-verifier failure the operational story nobody was telling. A published measurement of exactly that had been sitting in a PDF on my own disk since 13 August, three pages after a line I had already quoted from it in another piece. I had written the negative without searching for its counterexample. The prior-art record for this article, including that correction, is kept with the rest of the sweep.