---
title: "aadhar.sh/garage/pqc: Post-quantum signatures, priced"
description: "This site's TLS is already post-quantum and its signatures aren't. What ML-DSA-44 and hash-based signatures cost on its own crawler's RFC 9421 requests."
path: "/garage/pqc"
section: "garage"
kind: "content"
updated: "2026-10-01"
source: "https://aadhar.sh/garage/pqc"
---

> Site index: https://aadhar.sh/llms.txt
> Section index: https://aadhar.sh/garage/llms.txt
> This is the Markdown twin of a page on aadhar.sh. The HTML at the source
> URL below is the original, and is hand-written and unminified on purpose.

# Post-quantum signatures, priced

Half of this site's [TLS handshake][tls-handshake] is already [post-quantum][post-quantum-cryptography] and I did nothing to make that happen. The other half, authentication, moves on a completely different schedule, because signatures live in certificates and registries that a personal site does not control. So I went looking for the signature I *do* control, and measured what it would cost to migrate it.

## What is already done, and what it covers

Ask the live site what it negotiated:

```
$ openssl s_client -connect aadhar.sh:443 -groups X25519MLKEM768 -tls1_3

Negotiated TLS1.3 group: X25519MLKEM768
Peer signature type:     ecdsa_secp256r1_sha256
Certificate:             ecdsa-with-SHA256, P-256
```

The [key agreement][key-agreement] is post-quantum. The certificate and the handshake signature are classical, and they will stay that way for a while: no public [CA][certificate-authority] issues [ML-DSA][ml-dsa] certificates into the WebPKI. Cloudflare's own position is that ML-DSA chains add roughly 14KB per handshake, which is why they are pushing [Merkle Tree Certificates](https://blog.cloudflare.com/ml-dsa-will-have-to-do/) instead.

That split matters more than it looks. Key agreement is where **[harvest now, decrypt later][harvest-now-decrypt-later]** bites: an attacker recording today's ciphertext can decrypt it whenever a quantum computer arrives, so the migration had to happen years early. A signature has no such property. Forging a 2026 signature in 2035 proves nothing about 2026, because by then the request is long over and nobody is still trusting it. The urgency argument for key agreement simply does not transfer.

## Three tiers of signature, and only one is mine

| Surface | Algorithm | Whose problem |
| --- | --- | --- |
| TLS to visitors | [ECDSA P-256][ecdsa] | The CA and browser ecosystem |
| DNSSEC on the zone | ECDSA P-256 SHA-256 | The registry, plus DNS response sizes |
| Booking links, cover URLs | [HMAC-SHA256][hmac] | Already fine. Symmetric, so [Grover][grovers-algorithm] halves it to 128-bit |
| AadharshBot request signing | Ed25519 | **Mine.** About 30 lines in one file |

[AadharshBot](https://aadhar.sh/bot) signs every outbound request per [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) using the [Web Bot Auth][web-bot-auth] profile, so receiving sites can check that a request really came from here. That signature is the only asymmetric one this site owns end to end. The whole post-quantum question collapses to it.

## What the candidates actually cost

When I first measured this in July, Cloudflare's runtime had no post-quantum algorithm in [WebCrypto][web-crypto]. I checked the shipping binary rather than the docs: `workerd` 1.20260722 contained the strings `Ed25519`, `X25519`, and `ECDSA`, and no `ML-DSA` at any parameter size. So the blue bars are pure JavaScript, via [@noble/post-quantum](https://github.com/paulmillr/noble-post-quantum), signing the site's real signature base on an M3 Max under Node 26. The amber bar arrived in October, and the last section explains it.

Cost of one signature

Flip the toggle before you read on and predict where each bar lands. The point of the exercise is that the ordering *changes*. [SLH-DSA-128s][slh-dsa] has the smallest public key of anything here at 32 bytes, and it takes 2.65 seconds to produce one signature. Its sibling 128f signs in 132ms and spends 22KB of header doing it. No row is small on both axes, and that is the state of post-quantum signatures, whichever library computes them.

### How much of that 8.58ms is the algorithm?

Less than it looks, and the panel above will prove it on your own hardware if it can. Chrome ships ML-DSA in WebCrypto behind `chrome://flags/#webcrypto-pqc`, implementing the same [WICG proposal](https://wicg.github.io/webcrypto-modern-algos/) whose JWK shape this site already publishes. If that flag is on, the panel offers a button that generates an ML-DSA-44 key in your browser, signs this page's signature base 200 times natively, and drops the result in beside the JavaScript number. The amber workerd bar answers the same question for Cloudflare's runtime.

This matters for how you read the whole table. The 8.58ms is a *ceiling* set by running a [lattice scheme][lattice-cryptography] in a JIT, and the byte counts underneath it are properties of FIPS 204 that no implementation will improve. Sizes are the permanent cost. Speed is the temporary one, and it goes away on its own when the runtimes catch up.

## Why the hash-based options lost

I wanted SLH-DSA to work. It rests on hash functions alone, so it survives even if lattices turn out to be broken, and its 32-byte public key is charming. It fails here twice over.

The header is the harder failure. A 128f signature is 22,784 bytes once base64'd into a structured-fields value, and most servers cap total request headers around 8KB, so the crawl would start collecting 431s instead of pages. The latency is the other one: 2.65 seconds of single-threaded work per signature is not something to put in front of an HTTP request, and a Worker bills CPU time.

Stateful hash-based signatures (LMS, XMSS) fix the size problem and introduce a worse one. Reusing a one-time index is total key compromise, and a [Worker isolate][worker-isolate] is close to the worst place to keep a monotonic counter. I would need a Durable Object doing nothing but handing out indices, and a single replay would end the key. ML-DSA-44 is the only candidate that survives contact with the constraints.

## Shipping it without a codepoint

Here is the part that made this cheap. The [IANA HTTP Signature Algorithms registry](https://www.iana.org/assignments/http-message-signature/http-message-signature.xhtml) has exactly six entries, and every one of them is classical. Cloudflare's Web Bot Auth verifier accepts Ed25519 only and ignores other key types in your directory. An ML-DSA-only signature would be a header nobody on the internet checks.

But `Signature-Input` and `Signature` are structured-fields *Dictionaries*. A second label appends rather than replaces, so both signatures ride the same request over the same covered components:

```
Signature-Input: sig1=("@authority" "signature-agent");created=1785186311;
                   keyid="091z_BcIsuIgfzzKCKOie6JcSkz7UhVlbiakxjV5E8o";alg="ed25519";tag="web-bot-auth",
                 sig2=("@authority" "signature-agent");created=1785186311;
                   keyid="rn-mldsa-2026-07-27";alg="ml-dsa-44";tag="web-bot-auth"
```

A verifier that only knows Ed25519 reads `sig1` and skips the label it does not recognise. Nothing breaks, and the cost of ignoring `sig2` is zero. That property is what makes it defensible to ship an algorithm the registry has not blessed. The token `ml-dsa-44` is this site's spelling, chosen to match the registry's existing convention so it slots in if a real registration lands, and [/bot](https://aadhar.sh/bot) says plainly that you should verify `sig1` and ignore `sig2`.

The public key is published under the same JWKS as an `AKP` key per [RFC 9964](https://www.rfc-editor.org/rfc/rfc9964.html), which standardised ML-DSA for JOSE and COSE in May 2026. So the key format is a real standard even though the wire token is not:

Live, from this site's key directory

reading /.well-known/http-message-signatures-directory…

## What it costs in production

About 8.6ms of CPU and 3,228 bytes of request header per signed fetch, plus 11.96KB gzip of lattice code in the Worker bundle, which is the price of carrying an implementation the runtime should be providing. Zero bytes reach a browser, because this is a server-to-server signature and no page loads it. The [/around](https://aadhar.sh/around) crawl signs roughly 21 origins per refresh, so a full cycle gains around 180ms of CPU on a cached background path.

Two of those three costs are temporary. When `workerd` gains native ML-DSA, the bundle cost goes to zero and the signing cost drops to whatever the native bar in the panel above showed you. The 3,228 bytes of header stay forever. That asymmetry is the argument for migrating on the size budget now and letting the speed sort itself out.

One implementation detail worth stealing: ML-DSA-44 keygen expands a 32-byte seed into a 2,560-byte secret key and costs about 1.4ms. Worker isolates outlive a request, so the expanded key is derived once and cached in module scope, keyed by the raw secret so a rotation inside a live isolate re-derives instead of signing with a retired key.

## The objection I think is right

The strongest reading of this page is that it is theatre. Nothing verifies `sig2`. Signatures have no harvest-now-decrypt-later exposure, so there is no clock forcing the migration. By that argument the honest move was to write the analysis and ship nothing.

I mostly agree, and it is why `sig2` is second rather than first, why a missing key degrades to Ed25519 alone instead of failing the request, and why [/bot](https://aadhar.sh/bot) tells verifiers to ignore it. What I get in exchange is a migration already paid for and measured, on a surface where being wrong costs a crawl rather than a login. When the registry does land a codepoint, the remaining work is renaming a string.

What would change my mind: a receiving server rejecting requests because the extra 3.2KB pushes headers past its limit, or a verifier that chokes on a dictionary label it does not know. Either result would mean a second label is not free after all, and `sig2` would come straight back out. The crawl logs are where that would show up first.

## What actually happened: retired after 19 days

Neither of those things went wrong. No server rejected the headers, and no verifier choked on the extra label. The prediction above was accurate, but it was aimed in the wrong direction, because it priced the cost to *other people* and treated the cost to this site as a settled line item: 8.58ms, listed once, in a table.

The number that mattered was 8.58ms *multiplied by fan-out, against a per-invocation budget*. This site runs on the Workers free plan, which allows **10ms of CPU per request**. So one signature spent most of a request's entire budget, and every surface that signs more than once had already blown it:

| Surface | Signatures per request | ML-DSA CPU |
| --- | --- | --- |
| A single crawl of one origin | 1 | ~8.5ms of a 10ms budget |
| The [/rn](https://aadhar.sh/rn) playlist scrape | 21, one per track | **~180ms** |
| [/lens](https://aadhar.sh/lens) discovery | 28 probes | **~240ms** |

Both of those were dark and neither said so. The playlist scrape catches a failed track embed per track and degrades that one track to no cover, so 21 signatures timing out rendered as a tracklist that had quietly stopped showing album art. On `/lens` it was louder but no better attributed: sampling production logs on 2026-08-15, **31 of 51 requests died `exceededCpu`**, nearly all of them `/lens/fetch` and `/lens/tools`.

Removing `sig2` took the scrape from 379ms of CPU to 95ms, measured on the same 21-track playlist. The key is out of the JWKS too, because publishing a key nothing signs with is the dangling-pointer problem this site refuses elsewhere.

The honest reading is that the objection above was right for a reason it did not name. Shipping the migration early was cheap to *build* and the build was never the risk. What it cost was a recurring tax on every outbound request, on the one axis a free-tier Worker has least of, and the page that measured the tax in isolation is the same page that missed it in aggregate. **A per-request cost is only a number once you know how many requests, and how much budget.**

None of this changes the sizing argument, which is the part worth keeping. The 3,228 bytes are still permanent, the speed is still temporary, and when `workerd` ships native ML-DSA the CPU objection evaporates and `sig2` can come back with no code to write beyond deleting this section. Until then the migration is measured, documented, and turned off.

## Then the runtime caught up

That last paragraph made a prediction, and on 2026-10-01 I got to check it. Cloudflare has put [ML-KEM][ml-kem] and ML-DSA into WebCrypto on Workers. They run on BoringSSL, and each Worker opts in with the `webcrypto_modern_algorithms` compatibility flag ([the announcement](https://blog.cloudflare.com/workers-ml-kem-ml-dsa-support/)). This site already pins a `workerd` build from 2026-09-26 that carries both, so I measured it on the same M3 Max that drew the JavaScript bars.

| Same laptop, same signature base | JavaScript | workerd, native |
| --- | --- | --- |
| Sign once with ML-DSA-44 | 8.58ms | **0.12ms** |
| Expand the 32-byte seed into a signing key | ~1.4ms | 0.03ms |
| Signature in the header | 3,228 bytes | 3,228 bytes |

Signing got 72 times faster, and the header didn't lose a single byte. That's the asymmetry this page bet on in July. Natively, one ML-DSA-44 signature costs about as much as 10 Ed25519 signatures (11.3µs each on the same rig), where the JavaScript version cost as much as 170.

Here is the table that retired `sig2`, repriced:

| Surface | Signatures per request | JavaScript | Native, on the laptop |
| --- | --- | --- | --- |
| A single crawl of one origin | 1 | ~8.5ms | ~0.12ms |
| The [/rn](https://aadhar.sh/rn) playlist scrape | 21 | ~180ms | **~2.5ms** |
| [/lens](https://aadhar.sh/lens) discovery | 28 | ~240ms | **~3.4ms** |

On the laptop, one signature takes about 1% of a 10ms request budget, and the worst fan-out on the site would sign everything it needs in about a third of a single budget. Hold that projection loosely; production priced it at 3 times that.

### How I checked it

A 72x speedup deserves a control, so I ran three.

Without the flag, the same runtime refuses outright: `Unrecognized key generation algorithm "ML-DSA-44" requested`. With it, a key shaped like the one still sitting in this site's secrets (a bare 32-byte seed in an `AKP` JWK, with no public half) imports and signs. And Node 26's WebCrypto, which runs on OpenSSL instead of BoringSSL, verifies workerd's signature and rejects it the moment one byte of the signed base changes.

A Worker's clock only advances across I/O, so a Worker can't time its own crypto. The timings are wall-clock from outside instead: 2,000 signatures inside one request, minus an empty request so the dispatch cost cancels, median of 9 runs. The browser button above times yours the same way.

### What it leaves open

The objection I think is right survives intact. Nothing on the internet verifies `sig2`, the IANA registry still lists six classical algorithms, and every signed request would still carry 3.2KB more header for a signature with no harvest-now clock behind it.

The retirement section also promised `sig2` could return "with no code to write." That was wrong. The library left with the label, so bringing it back means a compatibility flag on the whole Worker plus a signing path through `crypto.subtle`. It's a small change, and a real one.

### What production said

Those were laptop numbers, and production bills CPU on different hardware, under a clamp that tightens with load. That is exactly where this page went wrong the first time. So before `sig2` came back, I deployed it at 0% beside the live version and pinned six `/lens` scans to it, each against a fresh origin so no cache could skip the signing.

| Version | /lens scan CPU, median of 6 |
| --- | --- |
| Live, sig1 only | 22ms |
| sig2 on every request | **32ms** |
| sig2 on primary fetches only | 22ms (live read 20ms in the same run) |

Signing everything cost 10ms a scan, about 0.33ms a signature, which is 3 times the laptop figure. August's lesson applied a second time: the per-signature price was right, but the price that mattered was that number on production's CPU, multiplied by fan-out.

So `sig2` now rides on the requests that are this crawler's identity (the page being scanned, [/around](https://aadhar.sh/around), the playlist) and skips the 28 discovery probes and the `robots.txt` reads that only look around. That costs about 2ms a scan, inside the noise between two runs of the live version.

The flag carries its own risk, since Cloudflare gates it "while the specification is still moving." If the API breaks, this site loses the second label and nothing else: any ML-DSA failure drops `sig2`, logs once, and `sig1` ships alone.

In July this page priced three costs: CPU, bundle size, and header bytes. Two belonged to the runtime, and the runtime has now paid them. The 3,228 bytes belong to FIPS 204, so they're the only number left to decide on. That's the rule I'd hand anyone timing a post-quantum migration: decide on the sizes, because the speeds come down on someone else's schedule.

shipped · the understanding check is part of the page, not a gate

[tls-handshake]: https://www.rfc-editor.org/rfc/rfc8446.html "The opening exchange of a secure web connection, where browser and server agree on encryption keys and the server proves its identity."
[post-quantum-cryptography]: https://en.wikipedia.org/wiki/Post-quantum_cryptography "Cryptography designed to stay secure against large quantum computers, which could break the public-key schemes in common use today."
[key-agreement]: https://en.wikipedia.org/wiki/Key-agreement_protocol "The step where two parties who share no secret yet create one together over an open network, then use it to encrypt what follows."
[certificate-authority]: https://en.wikipedia.org/wiki/Certificate_authority "An organization browsers trust to issue certificates that vouch a public key belongs to a given website."
[ml-dsa]: https://csrc.nist.gov/pubs/fips/204/final "A post-quantum digital signature standard from NIST (FIPS 204), built on lattice math. Its keys and signatures are far larger than classical ones."
[harvest-now-decrypt-later]: https://en.wikipedia.org/wiki/Harvest_now,_decrypt_later "An attack where encrypted traffic is recorded today and stored until a future quantum computer can decrypt it."
[ecdsa]: https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm "A widely used digital signature scheme built on elliptic curves. P-256 is a standard curve commonly paired with it."
[hmac]: https://en.wikipedia.org/wiki/HMAC "A way to tag a message using a shared secret key and a hash function, so anyone holding the key can check the message wasn't changed."
[grovers-algorithm]: https://en.wikipedia.org/wiki/Grover%27s_algorithm "A quantum search method that speeds up guessing a secret key, roughly halving its strength: a 256-bit key gives about 128 bits of security."
[web-bot-auth]: https://developers.cloudflare.com/bots/reference/bot-verification/web-bot-auth/ "A proposed standard where a bot signs each HTTP request with its private key and publishes the public key, so websites can check which bot sent it."
[web-crypto]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API "The browser's built-in cryptography toolkit for scripts, offering hashing, encryption and signatures."
[slh-dsa]: https://csrc.nist.gov/pubs/fips/205/final "A post-quantum digital signature standard from NIST (FIPS 205) that relies only on hash functions. Its public keys are tiny; its signatures are large and slow to make."
[lattice-cryptography]: https://en.wikipedia.org/wiki/Lattice-based_cryptography "Cryptography built on hard problems about points in a grid with hundreds of dimensions, believed hard even for quantum computers. ML-KEM and ML-DSA use it."
[worker-isolate]: https://developers.cloudflare.com/workers/reference/how-workers-works/ "A lightweight sandbox inside one runtime process where a Cloudflare Worker's code runs, walled off from other code and reused across requests."
[ml-kem]: https://csrc.nist.gov/pubs/fips/203/final "A post-quantum standard from NIST (FIPS 203) for two parties to set up a shared secret key, built on lattice math."

Source: https://aadhar.sh/garage/pqc
