HighAPI contractOpen
externalMu is documented as a public option and works on neither public entry point
The 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.
Recommendation
Support it publicly (skip the message wrapping when set, forward opts in verify, add it to SigOpts and VerOpts), or document it as internal-only and have the public wrappers reject it loudly.
HighInteroperabilityOpen
A key from keygen() can never be exported in the seed format the ecosystem standardised on
Called 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.
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.
Recommendation
Return the seed from keygen, or document prominently that callers should generate and retain it themselves.
MediumMisuse resistanceOpen
Unknown option keys are silently ignored, so a typo costs domain separation
validateOpts 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.
Recommendation
Throw on keys outside the accepted set. This is the highest-leverage single change in the round.
MediumStandards conformanceOpen
Pre-hash accepts bare SHAKE256, emitting a fixed-length OID over a half-length digest
FIPS 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.
Recommendation
For XOF OIDs, require the output length the OID denotes rather than the generic collision bound.
MediumTest coverageOpen
Two Wycheproof families are skipped for a reason that does not hold
The 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.
Recommendation
Add both families to the fetch list and wire them directly to decapsulate and sign.
MediumTest integrityOpen
A failed vector fetch caches an empty file, after which the suite passes with zero vectors
The 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.
Recommendation
Download to a temporary file and move on success, pin a commit rather than a branch, and assert a non-zero case count per vector file.
MediumInteroperabilityOpen
combineSigners is a home-grown composite scheme and only an internal comment says so
The 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.
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.
Recommendation
Promote the warning into the JSDoc and the README.
MediumMisuse resistanceOpen
encapsulate's second argument is undocumented, deterministic, and named msg
A 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.
Recommendation
Document it explicitly as fresh secret randomness, and move it onto an internal sub-object as the other two families do.
LowError handlingOpen
Falcon attached.open leaks internal codec errors instead of a stable rejection
Corrupting 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.
Recommendation
Wrap the decode in open and rethrow a single invalid-signature error, keeping the original as cause.
LowSecret hygieneOpen
Every rejected ML-DSA signing iteration abandons un-wiped secret material
The 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.
Recommendation
Wipe the per-iteration buffers before each continue, and wipe partially decoded polynomials on the decode-failure path.
LowSecret hygieneOpen
ML-KEM never wipes its own generated seed or encapsulation message, while ML-DSA and Falcon do
ML-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.
Recommendation
Mirror the ML-DSA pattern and wipe the library-owned buffer after last use.
InfoClaim accuracyOpen
Dependencies are tilde ranges while the README says strictly pinned
All 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.
Recommendation
Pin exactly, or soften the wording to match.