ML-KEM Encapsulation Key Modulus Check Bypass
CVSS v4.0
Score: 6.3 (Medium) Vector: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:L/SA:N
Rationale: The vulnerability allows an attacker to bypass FIPS-203 mandated input validation by providing a crafted public key with non-canonical coefficients. While this does not directly lead to secret key recovery, it violates the security proof assumptions, causes interoperability failures with compliant implementations, and renders the implementation non-compliant with FIPS-203. The attack vector is network-accessible with low complexity and no prerequisites, but the direct impact is limited to integrity violations (bypassed validation and interoperability failure) rather than confidentiality or availability compromise.
CWE
CWE-20: Improper Input Validation
Affected Files
src/ml-kem.ts:76—compress(d)returns identity for d >= 12, preventing modular reductionsrc/ml-kem.ts:92—polyCoder(12)uses the identity compress, creating a vacuous round-tripsrc/ml-kem.ts:298-306— modulus check inencapsulatealways passes due to lossless round-trip
Description
FIPS-203 Section 7.2 requires a modulus check on the encapsulation key during ML-KEM.Encaps. The check performs ek' = ByteEncode12(ByteDecode12(ek)) and rejects the key if ek' != ek. The purpose is to ensure all polynomial coefficients are valid elements of Z_q (i.e., in the range [0, 3328], since q = 3329). Coefficients in the range [3329, 4095] represent invalid 12-bit values that are not members of Z_q.
In this implementation, polyCoder(12) uses bitsCoder(12, compress(12)) where compress(12) returns an identity function ({encode: i => i, decode: i => i}) because the code treats d >= 12 as a "no compression needed" special case. This means ByteDecode12 decodes 12-bit values to Z_{4096} instead of Z_q, and ByteEncode12 encodes them back losslessly. The round-trip encode(decode(ek)) always equals ek regardless of whether coefficients are valid elements of Z_q.
As a result, public keys with polynomial coefficients in the range [3329, 4095] pass the modulus check and are accepted by encapsulate. This violates the FIPS-203 specification which mandates rejection of such keys.
Reproduction Steps
- Generate a valid ML-KEM keypair:
const { publicKey } = ml_kem768.keygen() - Modify the public key bytes to encode a coefficient >= 3329. For example, set the first 12-bit coefficient to 3329 (0xD01):
const malPK = new Uint8Array(publicKey); malPK[0] = 0x01; // low 8 bits of 3329 malPK[1] = (malPK[1] & 0xF0) | 0x0D; // high 4 bits of 3329 - Call
ml_kem768.encapsulate(malPK)— it succeeds instead of throwing the expected modulus check error. - The same holds for any coefficient value in [3329, 4095], including the maximum 4095 (0xFFF).
Impact
An attacker can provide a crafted public key with non-canonical coefficients (>= q) that bypasses the FIPS-203 mandated input validation. This has several consequences:
- FIPS-203 non-compliance: The implementation does not conform to the standard's input validation requirements. Deployments requiring FIPS compliance cannot use this implementation.
- Security proof violation: The IND-CCA2 security proof for ML-KEM assumes all public key coefficients are in Z_q. Operations with out-of-range coefficients take place outside the algebraic structure assumed by the proof. While the
modfunction does reduce intermediate results modulo Q during NTT operations, the initial NTT representation of the public key contains non-canonical values. - Interoperability failure: Ciphertexts produced against non-canonical public keys may be rejected by other FIPS-203 compliant implementations that properly validate the encapsulation key, leading to silent incompatibility.
Suggested Fix
Modify the compress(d) function to perform modular reduction for d = 12 (or equivalently, add an explicit coefficient range check in the modulus check):
const compress = (d: number): Coder<number, number> => {
if (d >= 12) return {
encode: (i: number) => i % Q, // reduce mod Q on encode
decode: (i: number) => i,
};
// ... rest unchanged
};
Alternatively, add an explicit check after decoding:
const decoded = KPKESecretCoder.decode(copyBytes(eke));
for (const poly of decoded) {
for (let i = 0; i < poly.length; i++) {
if (poly[i] >= Q) {
throw new Error('ML-KEM.encapsulate: wrong publicKey modulus');
}
}
}