Back to Cases
MediumCWE-20

ML-KEM Encapsulation Key Modulus Check Bypass

noble-post-quantum2026-02-23CWE-20Medium

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:76compress(d) returns identity for d >= 12, preventing modular reduction
  • src/ml-kem.ts:92polyCoder(12) uses the identity compress, creating a vacuous round-trip
  • src/ml-kem.ts:298-306 — modulus check in encapsulate always 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

  1. Generate a valid ML-KEM keypair: const { publicKey } = ml_kem768.keygen()
  2. 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
    
  3. Call ml_kem768.encapsulate(malPK) — it succeeds instead of throwing the expected modulus check error.
  4. 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:

  1. FIPS-203 non-compliance: The implementation does not conform to the standard's input validation requirements. Deployments requiring FIPS compliance cannot use this implementation.
  2. 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 mod function does reduce intermediate results modulo Q during NTT operations, the initial NTT representation of the public key contains non-canonical values.
  3. 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');
    }
  }
}