Independent post-quantum audit
Public review · Open findings
https://quantakrypto.com/audits/paulmillr/noble-post-quantum
Independent review · paulmillr/noble-post-quantum
noble-post-quantum — post-quantum security review
Paul Millr · Library · TypeScript · 12 findings
The short version
16 of 18 findings resolved as of 0.7.1; two open
Two reviews identified eighteen findings across API behavior, interoperability, secret cleanup, tests and documentation. Remediation rechecked on 12 September 2026 against the published @noble/post-quantum 0.7.1 package and current main at 48e4933: all six first-round findings and ten of twelve second-round findings are resolved. Version 0.7.1 was published on 27 August and includes the previously unreleased fixes. The externalMu API contract is now explicit and enforced: it is internal-only, and public calls reject it. Runtime dependencies are exactly pinned to 2.4.0. Two findings remain open: synchronous keygen() discards its generated seed without prominent seed-retention guidance, and combineSigners still lacks a public warning that it is a custom, non-standard composite scheme. This was a targeted remediation check, not a new full cryptographic audit.
What it gets right
Auditable, as the project claims
The README leads with auditability and the code earns it. Four of the six findings were closed by a pull request touching five files, with 41 lines added and 5 removed. A codebase where defects are that cheap to fix correctly is doing something right.
The primitives are in good shape
No key recovery, no forgery, no plaintext disclosure across ML-KEM, ML-DSA and SLH-DSA. Nothing found here breaks the cryptography, and the most serious finding is an availability bug.
Zeroization was already the intent
Two findings are gaps in cleanup the code was already trying to do, with comments stating the intention and a sibling function doing it correctly twenty lines away. The design was right and the implementation missed a buffer.
Fixed at a speed almost nobody matches
Four findings merged in a public pull request within two hours of the report, and the remaining two the same day. The Falcon issue was disclosed privately through the project's SECURITY.md and fixed the same day.
The fix that matters most
Use 0.7.1 and preserve seeds for portable keys
The previously unreleased fixes are available in @noble/post-quantum 0.7.1. For seed-based private-key export, generate and retain the seed before calling synchronous ML-KEM or ML-DSA keygen(); the no-argument call returns only expanded keys. The remaining upstream work is to return the seed or document that retention requirement prominently, and to identify combineSigners as a custom, non-standard scheme in its public documentation.
Findings
F1 · externalMu is documented as a public option and works on neither public entry point
HighAPI contractFixedThe README lists externalMu alongside context, extraEntropy and prehash, all of which are genuinely public options on sign and verify. Public sign can never accept it: the wrapper prepends the two-byte domain separator before the internal 64-byte check, so a correct 64-byte mu is rejected with an error reporting length=66, a length the caller never passed. Public verify silently drops the option and returns false for a valid external-mu signature. The shipped types reject the documented call as well, so TypeScript users cannot reproduce the README while JavaScript users get an unexplained false. The option works only on ml_dsa*.internal.
Impact
A caller following the README produces a full-length signature that the same library's own verify rejects: neither valid pure ML-DSA nor a correct ExternalMu signature.
Fix
Resolved in 0.7.1 through the documented internal-only option. The README explicitly directs externalMu callers to ml_dsa*.internal.sign and internal.verify; both public entry points throw an unexpected-option error. On 12 September 2026 we reproduced internal signing/verification and explicit rejection by both public entry points in the npm package.
Released: @noble/post-quantum 0.7.1 (27 August 2026): https://github.com/paulmillr/noble-post-quantum/releases/tag/0.7.1
0.7.1: externalMu is internal-only: https://github.com/paulmillr/noble-post-quantum/blob/0.7.1/README.md#ml-dsa--dilithium-signatures
F2 · A key from keygen() can never be exported in the seed format the ecosystem standardised on
HighInteroperabilityOpenCalled with no argument, ml_dsa*.keygen() generates the 32-byte seed, expands it, and wipes it, returning only the expanded secret key, from which the seed is not derivable. ml_kem*.keygen() discards its 64-byte seed the same way, as a parameter default. IETF LAMPS defines the PKCS#8 private key as a choice of seed, expanded key or both, with the seed form recommended; OpenSSL 3.5 exports seeds by default, Node's ML-KEM and ML-DSA PKCS#8 export defaults to seed-only, and BoringSSL's ML-KEM parser accepts only the seed form. The workaround is sound, since the library is fully deterministic from a supplied seed, but nothing states that the seed is the portable private key. The lengths object already advertises seed: 32, so the library names the value it will not return.
Evidence, measured rather than inferred
Rechecked 12 September 2026 against the 0.7.1 release and unchanged implementation in main 48e4933. Source: https://github.com/paulmillr/noble-post-quantum/blob/0.7.1/src/ml-kem.ts#L454; https://github.com/paulmillr/noble-post-quantum/blob/0.7.1/src/ml-dsa.ts#L480.
Impact
A key pair created through the ergonomic no-argument call cannot be written to a PKCS#8 file that BoringSSL, or a default OpenSSL or Node export, will load.
Fix
Still open after the 12 September 2026 recheck. Synchronous ML-KEM and ML-DSA keygen() return publicKey and secretKey only; the generated seed is not returned. Return the seed, or explain prominently that callers requiring seed-based export must generate and retain it themselves. The new WebCrypto raw-seed wrapper does not change this synchronous API behavior.
F3 · Unknown option keys are silently ignored, so a typo costs domain separation
MediumMisuse resistanceFixedvalidateOpts checks the types of options it recognises and ignores keys it does not. Passing {ctx: ...} instead of {context: ...} signs without a context and reports no error, and the resulting signature verifies for anyone who supplies no context at all. TypeScript catches this through excess-property checking; plain JavaScript does not. This is also the mechanism behind the externalMu leak above, so one change closes both.
Impact
A one-character mistake removes a security parameter the caller believes is applied, with no signal at any layer.
Fix
Resolved in 0.7.1. Public wrappers validate and snapshot accepted options; misspelled keys and custom-prototype option bags are rejected. Rechecked against the published package with a misspelled context and inherited options. Later hardening superseded the initial PR #49 option-key implementation.
Fix: commit e352e9e (Throw on unknown option keys): https://github.com/paulmillr/noble-post-quantum/commit/e352e9e05a6466455a2d7a03ae057da69e783303
Pull request #49: https://github.com/paulmillr/noble-post-quantum/pull/49
Released: @noble/post-quantum 0.7.1 (27 August 2026): https://github.com/paulmillr/noble-post-quantum/releases/tag/0.7.1
F4 · Pre-hash accepts bare SHAKE256, emitting a fixed-length OID over a half-length digest
MediumStandards conformanceFixedFIPS 204 5.4.1 and FIPS 205 10.2.2 tie the id-shake256 OID to SHAKE256(M, 512), a 64-byte digest, matching RFC 8702. checkHash gates only on collision strength, so noble-hashes' bare shake256, whose default output length is 32 bytes, passes at the 128-bit security level. getMessagePrehash then concatenates the hash's own OID with that 32-byte digest, so the signed message representative claims id-shake256 while carrying half the required output. The JSDoc already warns that bare SHAKE defaults are too short for the stronger modes, so the hazard is known; the guard simply does not catch the one level where it slips through.
Impact
A conformant HashML-DSA or HashSLH-DSA verifier recomputes 512 bits and rejects the signature. Only noble verifies noble.
Fix
Resolved in 0.7.1. XOF pre-hashes enforce the digest length identified by their OID. Rechecked in the published package: bare 32-byte-output SHAKE256 is rejected for ML-DSA-44 and SLH-DSA-SHA2-128f; a correctly configured 64-byte-output SHAKE256 signs and verifies successfully.
Fix: commit 394beeb (Pre-hash XOF digest length): https://github.com/paulmillr/noble-post-quantum/commit/394beeb43218529f8cf7d106159cbbcee7585b61
Pull request #49: https://github.com/paulmillr/noble-post-quantum/pull/49
Released: @noble/post-quantum 0.7.1 (27 August 2026): https://github.com/paulmillr/noble-post-quantum/releases/tag/0.7.1
F5 · Two Wycheproof families are skipped for a reason that does not hold
MediumTest coverageFixedThe skip comments state these vectors supply semi-expanded decapsulation keys rather than keygen seeds. Fetching the file shows dk is 2400 bytes, exactly the FIPS 203 expanded decapsulation key that ml_kem768.decapsulate already accepts, across nine tests flagged InvalidDecapsulationKey, IncorrectDecapsulationKeyLength, IncorrectCiphertextLength and MalleableCiphertext. The mldsa sign_noseed files likewise supply the expanded secret key that sign accepts. The coverage is missing only because the filenames are absent from the fetch list.
Impact
The expanded-key import path, which is the path a key arriving from liboqs, Bouncy Castle or Java 24 travels, has no negative-vector coverage.
Fix
Resolved in the 0.7.1 source release. The Wycheproof suite now includes expanded-key ML-KEM decapsulation and ML-DSA sign_noseed families. These are test-infrastructure fixes, not additional runtime API behavior.
Fix: commit 0769f2f (Run the skipped Wycheproof vectors): https://github.com/paulmillr/noble-post-quantum/commit/0769f2f1b096a5e0cfaeff3ce30c584ffe0cd463
Pull request #49: https://github.com/paulmillr/noble-post-quantum/pull/49
Released: @noble/post-quantum 0.7.1 (27 August 2026): https://github.com/paulmillr/noble-post-quantum/releases/tag/0.7.1
F6 · A failed vector fetch caches an empty file, after which the suite passes with zero vectors
MediumTest integrityFixedThe fetch script pipes curl into gzip with the destination as the redirect target, so the shell creates the file before curl runs. A network failure therefore leaves a valid gzip archive of nothing on disk, and the next run's cached-file guard skips it permanently. The loader finds no test groups, returns cleanly without tripping its own guard, and every vector loop runs zero iterations while reporting green. This bites only on a regeneration, since the vectors are vendored, but it is the same silent-pass shape as the SLH-DSA test found in the first round. The script also pulls from an unpinned branch with no checksum.
Impact
A regeneration that partially fails leaves the suite green while testing nothing.
Fix
Resolved as of the 0.7.1 source release. The vector loader rejects files with no test groups, and the suite uses the vector repository pinned by its git submodule. The zero-group guard and both formerly skipped families are present in the reviewed source.
Fix: commit 0769f2f (Run the skipped Wycheproof vectors): https://github.com/paulmillr/noble-post-quantum/commit/0769f2f1b096a5e0cfaeff3ce30c584ffe0cd463
Pull request #49: https://github.com/paulmillr/noble-post-quantum/pull/49
Released: @noble/post-quantum 0.7.1 (27 August 2026): https://github.com/paulmillr/noble-post-quantum/releases/tag/0.7.1
F7 · combineSigners is a home-grown composite scheme and only an internal comment says so
MediumInteroperabilityOpenThe source carries the line "There is no specs for this, but can be useful". The exported JSDoc immediately below presents the function neutrally, beside the draft-conformant KEM combiners. It produces plain-concatenation signatures over the raw message, while LAMPS Composite ML-DSA signs a domain-separated prefix with an OID binding and defines its own key encodings.
Evidence, measured rather than inferred
Rechecked 12 September 2026 against the 0.7.1 release and unchanged implementation in main 48e4933. Source: https://github.com/paulmillr/noble-post-quantum/blob/0.7.1/src/hybrid.ts#L589.
Impact
Signatures produced here verify nowhere else, and a reader comparing it to the adjacent standards-tracking combiners has no signal that it is different in kind.
Fix
Still open in 0.7.1 and main at 48e4933. Promote the custom, non-standard scheme warning from the implementation comment into exported JSDoc and the README. Additional input validation in 0.7.1 does not resolve this documentation and interoperability finding.
F8 · encapsulate's second argument is undocumented, deterministic, and named msg
MediumMisuse resistanceFixedA KEM encrypts a message, so the name invites a newcomer to pass their plaintext. Doing so raises no error and yields a shared secret derived deterministically from that plaintext; reusing the value reuses the ciphertext and the secret. The README never mentions the parameter, and the only warning is a JSDoc phrase, randomness/message seed, that hedges between the two readings. FIPS 203 3.3 states that this value shall come from an approved random bit generator and shall not be supplied by the application. ML-DSA and SLH-DSA segregate their derandomized inputs behind an internal surface; ML-KEM exposes it on the main API.
Impact
A caller can silently void the security argument of the scheme, and the naming makes that the intuitive mistake rather than an exotic one.
Fix
The documentation fix is included in 0.7.1: the optional msg argument is explicitly secret randomness, not plaintext, and omitting it draws fresh randomness. The deterministic argument remains on the public API; moving it to an internal surface was an additional recommendation and is not claimed as implemented.
Fix: commit d62a51d (Document encapsulate msg as randomness): https://github.com/paulmillr/noble-post-quantum/commit/d62a51db48f847fb159ca06f8f99d5aac62aee66
Pull request #49: https://github.com/paulmillr/noble-post-quantum/pull/49
Released: @noble/post-quantum 0.7.1 (27 August 2026): https://github.com/paulmillr/noble-post-quantum/releases/tag/0.7.1
F9 · Falcon attached.open leaks internal codec errors instead of a stable rejection
LowError handlingFixedCorrupting a single byte in a sealed attached signature produces at least five distinct error messages, including text such as "end of buffer: len=2 buf=0 lastByte=undefined" and "non-empty accumulator", which read as library defects rather than input rejection. Detached verify already treats every decode failure as false.
Impact
Code handling attacker-supplied signatures cannot match on a stable error, and error text becomes an unintended surface.
Fix
Resolved in 0.7.1. After argument-type validation, malformed attached signatures or public keys produce a uniform invalid signature error, with the underlying error retained as cause. Rechecked the malformed-key case against the published package.
Fix: commit 770e53d (Uniform Falcon attached.open failure): https://github.com/paulmillr/noble-post-quantum/commit/770e53d1106c7be5b31b08090e33371ff3fa91a3
Pull request #49: https://github.com/paulmillr/noble-post-quantum/pull/49
Released: @noble/post-quantum 0.7.1 (27 August 2026): https://github.com/paulmillr/noble-post-quantum/releases/tag/0.7.1
F10 · Every rejected ML-DSA signing iteration abandons un-wiped secret material
LowSecret hygieneFixedThe cleanBytes call listing the masking vectors, the NTT forms, the commitment and the per-coordinate products sits after the signature is encoded, on the success path only. All four rejection exits abandon those buffers with contents intact. Rejection is frequent by design, four to seven iterations on average, so a normal signature leaves several complete rejected transcripts in unreferenced heap awaiting collection. Rejected masking vectors are precisely the quantity the standard requires never be revealed. Exploitation needs a separate memory-disclosure primitive and JavaScript zeroization is best-effort, so this is reported because the library's own policy is to wipe exactly this material and it already does so one line later.
Impact
Secret intermediates outlive the operation that produced them, in the one code path where the library otherwise wipes them.
Fix
Resolved in 0.7.1. The published ML-DSA signing code wipes iteration-local secret buffers on rejection paths and cleans partially decoded material on failure. Verified by source inspection of the shipped implementation.
Fix: commit 93ef53e (Wipe library-generated secrets): https://github.com/paulmillr/noble-post-quantum/commit/93ef53e8282f4592dce8576a724c401180402179
Pull request #49: https://github.com/paulmillr/noble-post-quantum/pull/49
Released: @noble/post-quantum 0.7.1 (27 August 2026): https://github.com/paulmillr/noble-post-quantum/releases/tag/0.7.1
F11 · ML-KEM never wipes its own generated seed or encapsulation message, while ML-DSA and Falcon do
LowSecret hygieneFixedML-DSA and Falcon keygen both track whether the seed was generated internally and wipe it. ML-KEM keygen does not, so the 64-byte seed, sufficient to re-derive the whole key pair and containing the live implicit-rejection secret, survives. The generated 32-byte encapsulation message is as secret as the shared secret, since both are recomputable from it and the public key, and is left un-wiped while the symmetric value in decapsulate is wiped.
Impact
An inconsistency rather than a break: the same class of value is wiped in three places and not in these.
Fix
Resolved in 0.7.1, including the maintainer’s follow-up to the first patch. ML-KEM keygen, encapsulate and prepared encapsulation place cleanup in finally blocks so library-generated randomness is wiped on success and failure. Caller-supplied seeds and randomness remain caller-owned.
Fix: commit 93ef53e (Wipe library-generated secrets): https://github.com/paulmillr/noble-post-quantum/commit/93ef53e8282f4592dce8576a724c401180402179
Pull request #49: https://github.com/paulmillr/noble-post-quantum/pull/49
Released: @noble/post-quantum 0.7.1 (27 August 2026): https://github.com/paulmillr/noble-post-quantum/releases/tag/0.7.1
F12 · Dependencies are tilde ranges while the README says strictly pinned
InfoClaim accuracyFixedAll three runtime dependencies are declared as tilde ranges, so consumers resolve any future patch release; the lockfile protects only this repository's own development environment. All three are same-author, release rarely, and are themselves provenance-attested, so the practical risk is small. The finding is the mismatch between the claim and the manifest, on a line whose whole purpose is to describe supply-chain posture.
Impact
A reader auditing dependencies takes a stronger guarantee than the manifest provides.
Fix
Resolved in 0.7.1. The published package.json pins @noble/ciphers, @noble/curves and @noble/hashes exactly to 2.4.0, matching the README’s dependency-pinning claim.
Released: @noble/post-quantum 0.7.1 (27 August 2026): https://github.com/paulmillr/noble-post-quantum/releases/tag/0.7.1
0.7.1: exactly pinned runtime dependencies: https://github.com/paulmillr/noble-post-quantum/blob/0.7.1/package.json
Merged upstream
- Released: @noble/post-quantum 0.7.1 (27 August 2026): https://github.com/paulmillr/noble-post-quantum/releases/tag/0.7.1
- 0.7.1: externalMu is internal-only: https://github.com/paulmillr/noble-post-quantum/blob/0.7.1/README.md#ml-dsa--dilithium-signatures
- Fix: commit e352e9e (Throw on unknown option keys): https://github.com/paulmillr/noble-post-quantum/commit/e352e9e05a6466455a2d7a03ae057da69e783303
- Pull request #49: https://github.com/paulmillr/noble-post-quantum/pull/49
- Fix: commit 394beeb (Pre-hash XOF digest length): https://github.com/paulmillr/noble-post-quantum/commit/394beeb43218529f8cf7d106159cbbcee7585b61
- Fix: commit 0769f2f (Run the skipped Wycheproof vectors): https://github.com/paulmillr/noble-post-quantum/commit/0769f2f1b096a5e0cfaeff3ce30c584ffe0cd463
- Fix: commit d62a51d (Document encapsulate msg as randomness): https://github.com/paulmillr/noble-post-quantum/commit/d62a51db48f847fb159ca06f8f99d5aac62aee66
- Fix: commit 770e53d (Uniform Falcon attached.open failure): https://github.com/paulmillr/noble-post-quantum/commit/770e53d1106c7be5b31b08090e33371ff3fa91a3
- Fix: commit 93ef53e (Wipe library-generated secrets): https://github.com/paulmillr/noble-post-quantum/commit/93ef53e8282f4592dce8576a724c401180402179
- 0.7.1: exactly pinned runtime dependencies: https://github.com/paulmillr/noble-post-quantum/blob/0.7.1/package.json
Scope & method
Artifacts reviewed
- paulmillr/noble-post-quantum at commit eda26b7
- npm @noble/post-quantum 0.7.0
- C2SP/wycheproof vector sets, fetched directly
- Remediation check, 12 September 2026: npm @noble/post-quantum 0.7.1 (a237360), compared with main 48e4933
Method
Four independent reviews, one per lens, each excluding the first round's findings so the passes would not restate settled ground. Every behavioural claim was then re-executed against the audited tree independently of the review that raised it. Remediation recheck: verified the npm tarball against registry integrity metadata, inspected released code and tagged test fixes, and ran focused checks for externalMu, option rejection, SHAKE256 pre-hash lengths, Falcon rejection behavior and synchronous keygen return values.
Falcon's floating-point sampler internals were reviewed structurally rather than re-derived against the round-3 reference implementation; its round-3 known-answer tests pass. This is not a substitute for a funded independent audit, which the library correctly states it has not had. The September recheck was limited to the reported findings; it did not rerun the complete ACVP corpus or constitute a fresh cryptographic audit.
Reviewers