Sound library — six findings, all fixed upstream, one still awaiting release
One of the most widely used post-quantum implementations in JavaScript, and the primitives are in good shape: no key recovery, no forgery, no plaintext disclosure. The most serious finding is an unbounded loop in Falcon signing that makes sign() hang forever once it triggers, an availability problem that an attacker cannot set off on demand. The rest are zeroization, test coverage and documentation. All six were fixed by the maintainer, four in a public pull request merged within two hours and two more the same day. Note that the Falcon fix is in main and not yet released: npm still serves 0.7.0, which does not contain it.
4Medium2Low
Status
Fixed, awaiting release
Every finding in this review was fixed upstream. At least one fix is merged but not yet in a published release, so it is not in users' hands until the maintainers cut one.
Falcon signing can hang forever after its first rejection
`FFSampler.ldlFFT` mutated its `g01t` and `g11t` arguments in place. It already copied `g00t` defensively, with the comment "can be same as g11t and everything will break!", but the other two got no such protection. At the top sampling level those arguments are the sampler's persistent state, so after the first sampling attempt they held LDL* values rather than the original Gram matrix. Falcon signing is a rejection loop by design and retries with no attempt cap, so every retry sampled against a corrupted basis. Measured on a forced rejection, the sampler landed roughly 50x over the acceptance bound and never recovered: sign() never returns and burns CPU indefinitely. Impact is availability only. There is no key recovery, no forgery and no secret exposure, and an attacker cannot trigger it on demand. It was missed by the test suite because all six KAT vector files succeed on the first attempt, so the retry path is never exercised. We measured no natural rejections in 60,000 falcon512 signatures, putting the rate under about 1e-4: rare per signature, but it accumulates, and a service signing at volume will eventually hang.
Recommendation Copy the mutated arrays rather than aliasing the sampler's persistent state. Fixed upstream in commit eda26b7, which extracts `ldlFFT` into its own function and copies all three arguments, so the invariant lives in one place. Reported privately under the project's SECURITY.md and fixed the same day. The fix is on main but not in a release: anyone on npm 0.7.0 is still affected.
Falcon signing can hang forever after its first rejection
`FFSampler.ldlFFT` mutated its `g01t` and `g11t` arguments in place. It already copied `g00t` defensively, with the comment "can be same as g11t and everything will break!", but the other two got no such protection. At the top sampling level those arguments are the sampler's persistent state, so after the first sampling attempt they held LDL* values rather than the original Gram matrix. Falcon signing is a rejection loop by design and retries with no attempt cap, so every retry sampled against a corrupted basis. Measured on a forced rejection, the sampler landed roughly 50x over the acceptance bound and never recovered: sign() never returns and burns CPU indefinitely. Impact is availability only. There is no key recovery, no forgery and no secret exposure, and an attacker cannot trigger it on demand. It was missed by the test suite because all six KAT vector files succeed on the first attempt, so the retry path is never exercised. We measured no natural rejections in 60,000 falcon512 signatures, putting the rate under about 1e-4: rare per signature, but it accumulates, and a service signing at volume will eventually hang.
Recommendation Copy the mutated arrays rather than aliasing the sampler's persistent state. Fixed upstream in commit eda26b7, which extracts `ldlFFT` into its own function and copies all three arguments, so the invariant lives in one place. Reported privately under the project's SECURITY.md and fixed the same day. The fix is on main but not in a release: anyone on npm 0.7.0 is still affected.
In `combineKEMS.decapsulate`, the child `decapsulate()` calls ran outside the `try`, so a throw skipped the `finally` cleanup that its own comment promises to perform "even on errors". Expanded child secret keys and any child shared secrets already produced were left in memory. The throw is reachable from an attacker-supplied ciphertext: a low-order X25519 point makes the child call throw. `encapsulate()` twenty lines above already accumulates inside its `try` for exactly this reason.
Recommendation Move the child calls inside the `try` so the existing cleanup covers them, and add a regression test that fails without the fix. Merged upstream in pull request #48.
In `K-PKE.decrypt`, the polynomial `v` holds the decrypted message after `polySub`, one 1-bit threshold away from the plaintext. `cleanBytes` wiped `tmp`, `sk` and `u` but not `v`. The intent was already clear elsewhere: `decapsulate` deliberately wipes the same plaintext in the byte array it decodes into, and `u` is decoded from public ciphertext and needs no wiping at all.
Recommendation Add `v` to the `cleanBytes` call. `encode()` allocates its own buffer, so the returned bytes do not alias it. Merged upstream in pull request #48.
SLH-DSA conformance test stopped early while reporting green
The weak-prehash guard in the SLH-DSA `sigVer` test used `return` where its two ML-DSA siblings use `continue`. It sits inside both the group loop and the vector loop, so the first vector whose hash fell below the parameter set's security level ended the entire test. Every later vector, in every later group and parameter set, went unrun while the suite still reported success. The hash list contains three 112-bit hashes, so the guard is reachable at every security level.
Recommendation Change the `return` to `continue`. The `sigGen` counterpart is correct as written, because it builds one test per vector at describe time. Merged upstream in pull request #48.
Documented example imports an export that does not exist
The module example imported `XWing` from `hybrid.js`. There is no such export, and the README says so outright: "There is no separate `XWing` alias." Anyone copying the example hit an import error before reaching any cryptography.
Recommendation Correct the example to the exported name. Merged upstream in pull request #48.
Documentation promised a copy where the code returns a view
The README stated that `attached.open(...)` "returns a fresh, non-aliased copy of the embedded message when it succeeds". It returns a subarray view into the caller's signature buffer, and the inline comment in the source says so: "Zero-copy API: returned message aliases the caller-provided signature buffer". A caller who zeroized or reused the signature buffer while still holding the returned message would have silently corrupted it. Not a vulnerability, but the two statements contradicted each other and only one of them was true.
Recommendation Document the aliasing and tell callers to copy when they need independent ownership. Fixed upstream in commit eda26b7.
If you own paulmillr/noble-post-quantum, claim this audit to bind it to your organization and mint a tamper-proof certificate. Claiming always proves ownership: the primary route installs the quantakrypto GitHub App on the repository (only a repository admin can do that), and the lighter route below asks you to commit a one-time token to the repository (only someone who can push can do that). Nobody else can claim it either way.
Show your post-quantum posture in your README. Three checks — scan (qScan), sieve (FIPS conformance), and probe (live readiness) — each shown A–F, or N/A until the check has run. Add this repo as a project and connect GitHub to populate it.