Skip to content

ML-KEM: KEM layer, input checks and zeroization #10

Closed

Description

@Zikithezikit

Second half of the core: FIPS 203 Algorithms 16 to 21 on top of #9.

  • keygen(), keygen_from_seed(d, z), encaps(ek), decaps(dk, c)
  • Encaps: (K, r) = G(m || H(ek)). No hashing m first and no H(c) in the key, unlike our Kyber code
  • Decaps: implicit rejection with J(z || c), picked without a branch. The reject flag must never leave the function
  • Input checks from sections 7.2 and 7.3: ek length and modulus check, ciphertext length on every call, dk length and hash check. They run before anything touches secrets
  • Zeroize every secret intermediate on every return path, including errors
  • Randomness from the OS RNG; if it fails, return an error
  • The _internal functions (caller picks the randomness) stay pub(crate) and are only exposed behind cfg(any(test, feature = "acvp")). FIPS 203 section 3.3 says they're for testing only

Open question: do we expose ML-KEM in the public Rust crate API for 2.0, or keep the crate Python-only for now? I'm leaning towards Python-only, but I'm open to arguments.

Done when: round trips work for all three sets, every input check has a negative test, and the PR checklist is ticked.

Depends on: #9. Plan: WP-3.

Part of #3.

Activity

  1. added this to the 2.0.0 milestone on Oct 29, 2026
  2. added
    enhancementNew feature or request
    ml-kemML-KEM (FIPS 203) work
    securitySecurity review, hardening, findings
    on Oct 29, 2026
  3. Zikithezikit commented on Oct 29, 2026

    @Zikithezikit
    OwnerAuthor

    Open question: do we expose ML-KEM in the public Rust crate API for 2.0, or keep the crate Python-only for now? I'm leaning towards Python-only, but I'm open to arguments.

    In the meantime we'll keep it python only.

  4. Zikithezikit commented on Oct 30, 2026

    @Zikithezikit
    OwnerAuthor

    Notes for whoever picks this up, from WP-3 part one (#9, merged in #29). Read the module docs at the top of src/mlkem/mod.rs first.

    What exists now in src/mlkem/ (still crate-private, still under the dead_code allow in src/lib.rs, keep it until WP-5 wires something in):

    • kpke.rs: keygen::(d: &[u8; 32]) -> (ek_PKE Vec, dk_PKE Zeroizing), encrypt::(ek, m, r) -> Result<Vec, KyberError>, decrypt::(dk_PKE, c) -> Result<Zeroizing<[u8; 32]>, KyberError>. They only check lengths (KyberError::InvalidInput). The section 7.2 modulus check, the 7.3 hash check and the full dk length check are yours.
    • compress.rs: poly_compress(r, a, d), poly_decompress(r, a, d) and poly_from_msg. d is a runtime argument because stable Rust cannot take ParamSet constants as const generics. Call generic code with the pattern kpke.rs uses in its tests, kpke::keygen::<{ MlKem768::K }>(..).
    • fetch mechanism: scripts/fetch_vectors.py and tests/vectors/manifest.json (pinned ACVP-Server commit, SHA-256 per file), run by scripts/check.sh and the CI test job. Test ML-KEM against the NIST ACVP vectors #11 and Test ML-KEM against C2SP CCTV and Wycheproof, plus a differential test #12 extend it.
    • acvp_tests.rs: decaps_case is a test-only copy of Algorithm 18 built from kpke::decrypt and encrypt. Replace it with the real decaps_internal once it exists and keep the cases.

    Things you will have to write yourself:

    • The constant-time compare of c and c' and the K' / K-bar select. Do not use kyber/verify.rs (no barrier, caller-supplied length). poly_from_msg shows the idiom used so far (mask through core::hint::black_box). That is best effort, so check the release assembly for the compare and the select and put the grep output in the PR.
    • H is sha3_256, G is sha3_512, J(z || c) is shake256 with 32 bytes of output, all from kyber::fips202. Test code in acvp_tests.rs has small wrappers you can copy.
    • Randomness: rand 0.8 is already a dependency and has OsRng, so no new crate is needed. try_fill_bytes and return KyberError::RandomBytesGeneration on failure; do not use thread_rng for key material.
    • The reject flag must not be returned or stored, and dk, z, m, r, K and K-bar all need zeroizing on the error paths too.

    Things that will bite:

    The open question about a public Rust API stays as decided: Python-only for now.

  5. Zikithezikit commented on Oct 30, 2026

    @Zikithezikit
    OwnerAuthor

    Done in #30 (merged). Every box above is covered: keygen, keygen_from_seed, encaps and decaps in src/mlkem/kem.rs, the section 7.2 and 7.3 checks in check.rs, the branch-free select in ct.rs, OsRng with RandomBytesGeneration on failure, zeroization on the error paths, and the *_internal functions behind kem::acvp under cfg(any(test, feature = "acvp")). Round trips pass for all three sets and each input check has a negative test. The assembly evidence is in the PR.

    What is left from this issue is only what the PR description lists as believed: the barrier is best effort, armv7/i686 and a dynamic timing test belong to #16, and compiler-made stack copies of secrets belong with the Keccak-spill item in #18.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestml-kemML-KEM (FIPS 203) worksecuritySecurity review, hardening, findings

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions