Skip to content

Tutorial: from "we're adding ML-KEM" to a gated build

A worked end-to-end path through the toolkit, using one running example. Every command here is executed by a doc checker that diffs the real output against the transcript, so what you see is what you get. Run them from a clone of pqc-sizes.

The scenario. You maintain an 802.11 supplicant. Someone has asked for a post-quantum handshake: ML-KEM-768 for key establishment, ML-DSA-65 for authentication. Your reassembly buffer is 64 KiB and you expect at most 4 simultaneous handshakes.

Five steps, about ten minutes.


1. How big is the credential, really?

pip install git+https://github.com/nickharris808/pqc-sizes
$ pqc-sizes credential --kem ML-KEM-768 --sig ML-DSA-65
credential: ML-KEM-768 + ML-DSA-65
  ML-KEM-768 public key                  1,184 B
  ML-KEM-768 ciphertext                  1,088 B
  ML-DSA-65 public key                   1,952 B
  ML-DSA-65 signature                    3,309 B
  TOTAL                                  7,533 B

An X25519 share is 32 bytes. This is 7,533 — 235× larger, and that is the whole story. Everything below follows from it.

2. Does it still fit in a frame?

$ pqc-sizes fragments wifi-mgmt-frag --kem ML-KEM-768 --sig ML-DSA-65
ML-KEM-768+ML-DSA-65 credential over wifi-mgmt-frag (384 B/frame): 20 fragments
  -> fragmentation is mandatory; the receiver holds partial, unauthenticated
     state across 20 frames

No. It becomes 20 fragments, and the second line is the part that matters: your receiver now holds attacker-influenced partial state before it can authenticate any of it. You need a cap on reassembly memory.

3. Does a safe cap exist?

A cap has two boundaries, not one:

  • floor — the largest legitimate object you must accept, or you break honest peers.
  • ceiling — your memory budget ÷ worst-case concurrency, or concurrent sessions exhaust you.

At 64 KiB and 4 contexts:

$ pqc-sizes window --largest-object 7533 --budget 65536 --concurrency 4
window [7,533, 16,384] B; recommended cap 16,384 B (budget 65,536 B / 4
concurrent contexts)
  max safe concurrency at this object size: 8

Fine — cap at 16,384 B, and you have headroom to 8 contexts.

Now suppose product asks for a hash-based signature instead:

$ pqc-sizes window --largest-object 19392 --budget 65536 --concurrency 4
EMPTY WINDOW: floor 19,392 B > ceiling 16,384 B (short by 3,008 B). No
capacity cap is both feasible and safe. Raise the budget to at least 77,568 B,
reduce concurrency to at most 3, or choose a smaller credential.

No cap works at all. Not "pick carefully" — the interval is empty. That is a design answer, available before anyone writes code, and the tool gives you the three numbers that would fix it.

4. Put it in CI

Commit the parameters instead of retyping flags. Two runnable configs ship in examples/; examples/pqc.json is:

{
  "kem": "ML-KEM-768",
  "sig": "ML-DSA-65",
  "budget": 65536,
  "concurrency": 4
}
$ pqc-sizes check examples/pqc.json
examples/pqc.json: 7,533 B object, 65,536 B budget, 4 contexts
  window [7,533, 16,384] B; recommended cap 16,384 B (budget 65,536 B / 4
  concurrent contexts)
  max safe concurrency at this object size: 8

examples/pqc-empty-window.json is the same deployment with a hash-based signature, and exits 1:

$ pqc-sizes check examples/pqc-empty-window.json
examples/pqc-empty-window.json: 19,392 B object, 65,536 B budget, 4 contexts
  EMPTY WINDOW: floor 19,392 B > ceiling 16,384 B (short by 3,008 B). No
  capacity cap is both feasible and safe. Raise the budget to at least 77,568
  B, reduce concurrency to at most 3, or choose a smaller credential.

Exit code is 0 when a cap exists and 1 when the window is empty, so it gates a build as-is. In GitHub Actions, use pqc-guard-action:

- uses: nickharris808/pqc-guard-action@v1
  with:
    kem: ML-KEM-768
    sig: ML-DSA-65
    budget: "65536"
    concurrency: "4"

5. Memory exhaustion is 1 of 39 failure families

Sizing tells you the buffer is safe. It says nothing about downgrade binding, key reinstallation, fragment splicing, or roaming forward secrecy — all of which the size change reopens. pqc-mfb enumerates 39 families over 322 executed cases.

pip install git+https://github.com/nickharris808/pqc-mfb
pqc-mfb submit -o results.json      # all 322 case ids, pre-filled

Edit the ones your implementation holds, then:

pqc-mfb score results.json

The scaffold starts at the documented floor — 0% coverage, no regressions — so your first run is green and you raise coverage from there. To understand any family:

$ pqc-mfb explain fragment_splice

Two rules worth knowing before you submit:

  • Silence is not credit. An unanswered case makes the verdict INCOMPLETE (exit 3), never PASS.
  • Regressions are a hard fail. Ten cases pass even unrepaired; breaking one is FAIL (exit 1) no matter how high your coverage.

Where to go next

If you want to… Use
See the failure on real silicon pqc-dos-embedded — 169 lines of C under QEMU
Re-verify the bound yourself, on-device farkas-check — integer C, no solver
Let an AI agent do this pqc-migration-mcp — six MCP tools
Try it with no install pqc-explorer
Work in JS pqc-sizes-js

What you have, and have not, established

After all five steps you know that your configuration admits a safe cap, and which of 39 modelled failure families your implementation closes.

You have not established that your code enforces the cap, that your implementation matches the model, or that the 39 families are exhaustive for your protocol. Those are different questions, and no tool here answers them. See each README's Honest scope section.