Amar Akshat · github.com/amarshat
Post-quantum signatures are going into secure elements before the
implementations have settled. A one-character change in a deployed
ML-DSA crate’s hint decoder, < to <=,
made it accept non-canonical signatures. It passed the test vectors and
became CVE-2026-24850. That is the gap between “tested” and “correct on
all inputs”, and it is why we check the code against the spec instead of
trusting the vectors.
We are building Q-SEAL, an eSIM attestation layer: a key inside the
secure element signs a challenge-bound statement, and the suite is
hybrid, ECDSA P-256 and ML-DSA-44, both required. The point of this post
is that more of the path is checked than just the signature primitive.
Everything below is in the repository and
reproduces from a make target.
The ML-DSA reference C (PQClean, unmodified) computes the FIPS 204
forward and inverse NTT. The proof runs from the C through SAW to a
Cryptol model, then cryptol-to-isabelle lifts that model into Isabelle,
and Isabelle shows the lifted model equals the FIPS 204 transform. No
sorry, oops, or admit in the transform
theorems. It is the toolchain Apple used on corecrypto, pointed at the
reference C that everyone else derives their understanding from rather
than at our own code.
On top of that, all seven protocol properties in the spec’s verification-targets list. Six are a Cryptol model of a spec rule, a C reference written to be verifiable, and a SAW proof that the C equals the model, each carrying an injected mutant the proof rejects (a sensitivity check that the proof depends on the clause it checks). The seventh is a reachability property and is checked in ProVerif instead. Each runs in a second or two:
| Property | Command | Time |
|---|---|---|
| The 231-byte transcript serializer is a bijection (no malleability) | make qseal-tbs |
0.4 s |
| The C (de)serializer matches that model, field for field | make qseal-ref |
1.6 s |
| The assertion binds the verifier’s challenge; the applet fills its own identity | make qseal-assert |
0.5 s |
| Acceptance requires both signatures over the same bytes (no downgrade) | make qseal-hybrid |
0.7 s |
| A consumed request_id is not accepted twice, in the sequential case | make qseal-nonce |
1.6 s |
| Field values outside the spec enumerations are rejected before signing | make qseal-validate |
1.0 s |
| Fragmented evidence reassembles to the exact bytes or fails closed | make qseal-evidence |
2.0 s |
| An observed-action assertion is unreachable through a host APDU path (ProVerif) | make qseal-reachability |
0.1 s |
A few are worth a sentence. The transcript is fixed-length with no
optional fields, and the proof is that its serializer is injective: two
different transcripts can never produce the same bytes. That is an
analogous property, one layer up from where CVE-2026-24850 lived. The
hybrid check leaves the two signature verifiers uninterpreted and proves
the accept logic equal to “both verify, over the same transcript”; a
variant that accepts on either signature fails the same proof, so the
hybrid cannot quietly degrade to classical-only. The single-use proof is
the first stateful one: a request whose request_id was already consumed
is rejected, and a verifier that drops the consume step is shown to
accept the same request twice. The evidence proof is a round-trip:
splitting a blob into response fragments and reassembling recovers
exactly the original bytes, while a dropped fragment fails closed rather
than being silently zero-filled. The last one is different in kind: the
observed-action assertion type is meant to come only from a trusted
internal callback, never a handset command, so it is a reachability
question. In ProVerif, with the host APDU channel attacker-controlled
and the internal callback private, an observed-type signing provably
happens only after the internal callback; drop the host-path guard and
ProVerif finds the attack. The hybrid one, verbatim from
make qseal-hybrid:
Proof succeeded! qseal_hybrid_accept
VERIFIED: qseal_hybrid_accept == vE(pk_c,tbs,sig_c) AND vM(pk_pq,tbs,sig_pq) -- both required, same transcript
MUTATION CAUGHT: the downgrade variant (accept on either signature) is REJECTED by the both-required spec
All of the above holds a design of mine to targets of mine, so here
is the same method pointed at the actual bug this post opened with.
CVE-2026-24850 lived in the FIPS 204 hint encoding: a decoder that used
<= where the spec requires a strict < on
the indices within a polynomial block, so it accepted a non-canonical
signature with a repeated index. The rule is FIPS 204’s, not Q-SEAL’s. I
model the decoder’s accept/reject predicate in Cryptol, write a
verifiable C reference of it, and prove them equal; then the
<= variant is shown to fail that same proof, on a
concrete encoding with a repeated index. That is the exact defect,
machine-checked and rejected (make cve-anchor):
VERIFIED: qseal_hint_canonical == Cryptol canonical (FIPS 204 hint-decode canonicity)
CVE CAUGHT: qseal_hint_canonical_lax (<= instead of <) is REJECTED by the canonical spec -- this is CVE-2026-24850 / GHSA-5x2r-hc65-25f9
The honest boundary: this checks a verifiable reference decoder of the FIPS rule, not the shipped Rust crate, whose slice access defeats the symbolic simulator. So the claim is that the method catches the CVE-2026-24850 class on a faithful reference of the same rule, not that it verifies the deployed code.
These are scoped results, and the scope matters more than the count.
The inverse NTT equivalence is proved under a non-negative input
window, coefficients in [0, Q), which does not cover the
signed, centered coefficients the reference actually feeds it. Extending
that is open. The hybrid proof abstracts the verifiers, so it is about
the acceptance logic, not whether ECDSA or ML-DSA verification is itself
correct. One step in the ML-DSA chain, that the -fwrapv
build has no signed-overflow undefined behavior, is argued from a
machine-checked bound rather than mechanized as one theorem.
On the protocol side: the single-use proof is safety only (no
double-accept), on a fixed, small store that fails closed when full and
has no expiry-based eviction, and it is the sequential case, so the
check-and-consume race under concurrency is assumed away, not verified.
The validation proof rejects out-of-enumeration field values;
it does not cover length parsing of the incoming APDU (that is upstream
of the typed request it starts from) or cross-field constraints. It does
now reject the one authorization case the reachability property (5)
cares about: the verified C refuses a profile-observed assertion type
(0x04) on the host path, which is the guard property 5
proves necessary. But property 5 itself is a symbolic (Dolev-Yao) model
of the command surface, with cryptography idealized and no C == model
link: it assumes the host channel is what the attacker controls and the
internal callback is private, and it abstracts the applet to the
assertion-type dispatch, so the code-level check and the reachability
argument meet only at that one guard, not through a shared model. The
evidence proof is the byte-level reassembly identity, not the APDU
transport state machine and not the evidence content (that the
reassembled transcript and signatures are the ones the applet signed is
the binding and hybrid properties, not re-checked there). The mutants
above are hand-injected, not bugs found in the wild, so catching them
shows sensitivity to one clause, not adequacy. To measure adequacy I ran
a systematic pass (make qseal-mutants): apply the CVE’s
operator class (< vs <=, ==
vs !=, && vs ||, and so
on) to each C reference, one mutation at a time, and rerun the matching
proof. The proofs kill 39 of 41 such mutants; the two survivors are
equivalent mutants (a loop bound whose extra iteration a downstream
guard makes a no-op), so no proof or test could kill them. And the C
references are written to be verifiable; the shipped Rust deserializer,
where the CVE lived, is outside the verified set because of a tool limit
on how it slices memory, so that path is covered by tests, not a proof,
and the repo says so. The CVE anchor above checks the FIPS rule on a
reference decoder, which is why it catches the bug class rather than the
exact crate.
Everything above reproduces from a make target. The
primitive chain has 341 Isabelle lemmas (most are supporting lemmas), 12
SAW proofs on the C, and 37 on the Rust.
The runnable demo needs only Rust, no proof toolchain. It signs the verified transcript with real ECDSA P-256 and ML-DSA-44 and walks six outcomes: a valid attestation accepts; a tampered transcript rejects; a downgrade (valid classical signature, no valid post-quantum one) rejects; a replayed request rejects; a malformed request is refused before signing; and fragmented evidence reassembles exactly, while a dropped fragment is rejected. The demo binary is not itself verified; each piece is a Rust twin of a rule proved in the SAW scripts.
git clone https://github.com/amarshat/pqc-assay
cd pqc-assay/qseal/demo
cargo run
The proofs reproduce from make targets
(make qseal-tbs qseal-ref qseal-assert qseal-hybrid qseal-nonce qseal-validate qseal-evidence,
a second or two each, need cryptol and SAW). The ML-DSA primitive chain
(make verify) needs the pinned SAW + Isabelle toolchain;
see the repo’s setup script.