Read this first
Two reviews of @noble/post-quantum, eighteen findings, and not one of them is a break in the maths. Round one: six defects, all merged within a day, the worst an unbounded signing loop in Falcon. Round two, over the repaired code: twelve more, eight of which merged in pull request 49 on 21 August. Four are still open. Nothing from either round has shipped: npm still serves 0.7.0, published 9 August, before either pull request existed. The full review is at quantakrypto.com/audits/paulmillr/noble-post-quantum.
If you install a post-quantum library in JavaScript today, there is a good chance it is this one. @noble/post-quantum implements ML-KEM, ML-DSA, SLH-DSA and Falcon in pure TypeScript, with no native dependency and no WASM blob, which is exactly why it ends up in browser bundles and edge runtimes where the alternatives cannot go.
We reviewed it in August, twice. The headline is easy to state and worth stating first: the primitives are correct. Every FIPS 203 input-validation check is present. The FIPS 204 rejection bounds and hint canonicality are right. All twelve SLH-DSA parameter sets are correctly parameterized. X-Wing matches its draft byte for byte. Across eighteen findings there is no key recovery, no forgery, and no plaintext disclosure.
Everything we found lives at the edges: in the options a caller passes, in the paths the tests do not reach, and in the gap between what the README promises and what the code does.
Round one: a signing loop that never ends
The first review, on 15 August, turned up six defects. The one that mattered was in Falcon, and it is not a cryptographic break: it is an availability bug that makes sign() hang forever.
Falcon's sampler mutated two of its arguments in place. At the top sampling level those arguments are the sampler's own persistent state, so after the first rejection they held the wrong values, and the loop that was supposed to retry could never terminate. The detail that makes this a good finding rather than a lucky one: the same function already copied a third argument defensively, with a comment explaining exactly why. Two of the three were missing the same protection the third had been given.
- A hybrid decapsulation path left expanded child secret keys in memory when a child KEM threw, reachable from an attacker-supplied ciphertext.
- K-PKE decryption wiped three buffers but not the polynomial holding the decrypted message, one threshold away from the plaintext itself.
- An SLH-DSA conformance test used
returnwhere its two ML-DSA siblings usecontinue, so the first vector below the security level ended the whole suite while it still reported success. - A README promising a non-aliased copy from an API whose own source comment says it returns a view into the caller's buffer.
Paul Miller merged four of them in pull request 48 within two hours of receiving the report, and the other two the same day. That is a faster turnaround than most vendors manage for an acknowledgement.
Round two: reading the repaired code
Six days later we went back over the fixed tree, this time as four independent reviews covering FIPS conformance, interoperability, developer experience and implementation security. Twelve more findings, none of them in the primitives either.
The two that concern us most are documented paths that do not do what the documentation says. externalMu is presented as a public option and works on neither public entry point: a caller following the README produces a full-length signature that the same library's own verify rejects, so it is neither valid pure ML-DSA nor a correct ExternalMu signature. Separately, a key pair created through keygen() can never be exported in the 32-byte seed format the ecosystem standardised on, which means it cannot be written to a PKCS#8 file that BoringSSL, or a default OpenSSL or Node export, will load.
Then there is the option validator. Unknown keys were silently ignored, so a misspelled context removed a domain-separation parameter the caller believed was applied, with no signal at any layer. A one-character typo, and the security property is simply gone.
Two findings were in the test suite rather than the library, and they are the kind that hides the others. Two Wycheproof families covering the expanded-key import path were being skipped, which is the exact path a key arriving from liboqs, Bouncy Castle or Java 24 travels. And a failed vector download cached an empty file, after which the suite passed while testing nothing at all.
The part where the maintainer audits the auditor
We sent round two as patches rather than as a list. Eight of the twelve merged on 21 August in pull request 49, six commits covering the option validator, the pre-hash digest length, zeroization of library-generated secrets, the encapsulate documentation, a uniform Falcon rejection, and the skipped test vectors.
What happened next is the reason we are writing this up as a story rather than a scorecard. Miller read the patches and found two defects in them, both in fixes we had written:
- Our
checkOptKeys()only examines an object's own enumerable properties, while thevalidateOpts()it is meant to guard accepts class instances, inherited properties and non-enumerable ones. The check is narrower than the surface it protects. - In ML-KEM encapsulation the random
mis generated before the public key is validated, and wiped only on success. A malformed public key therefore throws aftermhas been allocated and before anything clears it. Key generation and prepared encapsulation follow the same success-only cleanup pattern, so the same shape of gap exists in three places.
merging as is, then fixing in main branch— Paul Miller, on pull request 49
The second one is a nice illustration of how zeroization findings behave. We reported that the library never wipes its own generated seed and encapsulation message. The fix wipes them. The remaining gap is not whether they are wiped but when, on which path, and the error path is the one that was still uncovered. A fix that is right in the happy case and wrong in the failure case is exactly the residue this class of bug leaves behind.
He fixed both forty-seven minutes later, in a commit called Hardening. checkOptKeys now walks the prototype chain with Reflect.ownKeys, so it sees inherited, non-enumerable and symbol keys, and handles the awkward cases that come with that: a nearer property shadowing one further up, a null-prototype bag that still carries options, and an Object.prototype arriving from another realm. ML-KEM keygen, encapsulate and prepared encapsulation each moved their cleanup into a finally, so the generated randomness is wiped whichever way the call ends.
Round two took six days from report to merged patches, and the two defects in those patches took under an hour from report to fix. We are recording that because it is the part of an audit nobody publishes: the review is only as good as the reviewing that happens after it, and here that ran in both directions.
What is still open, and what has shipped
Four findings remain open: the two documented-but-broken paths above, combineSigners being a home-grown composite scheme that only an internal comment identifies as non-standard, and a README claiming strictly pinned dependencies over a manifest of tilde ranges.
None of the eighteen has reached a user. npm serves 0.7.0, published on 9 August, before either pull request merged, and we confirmed against the published tarball that it still contains the pre-fix code. Our audit page says fixed, awaiting release rather than resolved, because those are different facts and the second one is what matters if you are installing the package this afternoon.
The point
The interesting finding of this review is not any single defect. It is where all eighteen of them were: never in the lattice arithmetic, always in the seam between the library and the person calling it. An option that is dropped, a format that cannot be exported, a test that passes on an empty file, a secret that is wiped on the success path only. Formal verification of the primitives would have caught none of them.
It is also the most useful review we have run, precisely because the maintainer pushed back on the patches. An audit that ends when the report is delivered gets you a list. One that continues into the diff gets you a second reviewer for your own work, and in this case it found two defects in ours before a single user could install them.
References
- The full review at quantakrypto.com, with every finding, its evidence and its status
- Pull request 48 and pull request 49 on github.com/paulmillr/noble-post-quantum
- FIPS 203, FIPS 204 and FIPS 205 at csrc.nist.gov
If you maintain post-quantum code and want it read this closely, talk to us. We publish the findings and the merges, and we are happy to be corrected in public.