Repository navigation
Security pass over the legacy Kyber code (WP-9, #18) - #42
Merged
Merged
Conversation
The mask built from a message bit in the legacy poly_frommsg is the pattern behind CVE-2024-37880 (Clangover), where a compiler turned it into a branch. Route the bit and the mask through core::hint::black_box. Output is unchanged; the golden vectors pass. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Route the accumulated difference, the flag and the select mask through core::hint::black_box, the same idea as mlkem::ct, so the compiler cannot prove the mask is 0 or 0xff and turn the select into a branch. The release disassembly shows setne/cset and a vector select with no branch on the compare result. Output is unchanged; the golden vectors pass. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Key generation and encapsulation in the Python bindings used thread_rng(). Use rand::rngs::OsRng, call try_fill_bytes in the legacy KEM functions, and return KyberError::RandomBytesGeneration when the generator fails instead of panicking. Wipe the half-built secret key when the second draw (z) fails and hold the partly filled seed in Zeroizing. The Python wrappers keep the RandomnessError subclass instead of re-wrapping it. Seeded paths and the golden vectors are unchanged; the random-fill order and sizes are as before. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Hold m', the KDF input and the re-encrypted ciphertext of crypto_kem_dec* in Zeroizing so they are wiped on every exit, unwinding included, and keep the implicit-rejection flag in Zeroizing<u8>. The PRF output buffers in poly_getnoise_* are the secret noise (the key in key generation, r in encryption) and were never wiped; wipe them too. The release disassembly shows the wipe of the flag and the buffers on both the normal and the cleanup path. Add a test for the implicit-rejection output, derived from z and H(c), for all three levels; it also passes on the commit before this series. Output is unchanged and the golden vectors pass. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…the secret key Kyber512/768/1024.from_keys accepted any public key and secret key of the right sizes, so a pair from two different key pairs built a keypair whose decapsulate silently returned a different secret than encapsulate had produced. The round-3 secret key embeds the public key (sk_pke || pk || H(pk) || z), so compare the two (both are public) and raise KyberError on a mismatch. The offset comes from named constants. A wrong-size secret key keeps its existing size error at use. Approved by the maintainer in the discussion on issue #18; ML_KEM_*.from_keys is not changed. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The legacy bindings returned secret keys and shared secrets as Vec<u8>, which PyO3 turns into bytes and then frees with the secret still in the allocation. Hand them over as a SecretBytes wrapper that builds the bytes object and then wipes the Rust buffer, and wipe the fixed-size copy from the safe API. The Python object itself cannot be wiped. Output is unchanged. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The seeded derive path hands the KEM code a stand-in generator that is never asked for bytes. Now that the legacy code draws randomness through try_fill_bytes, make that method return an error, so a future change that reaches it gets RandomBytesGeneration and not a panic. No path from Python reaches it today. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ation functions crypto_kem_keypair* and crypto_kem_enc* indexed caller-supplied pk, sk, ct, ss and seed slices without checking their lengths, unlike decapsulation, so a Rust caller with a short buffer panicked. Return InvalidInput instead. The Python bindings already size every buffer, so no Python path reaches this. Output is unchanged; the golden vectors pass. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Lockfile-only patch bump of an existing dependency. The advisory needs the log feature, which pykyber does not enable, so it was not reachable here; the legacy bindings no longer use thread_rng at all. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Zikithezikit
force-pushed
the
wp9-legacy-security
branch
from
October 31, 2026 06:04
5e154e3 to
69b4d0d
Compare
…TY.md Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CXWjkNs3Htog36snV9FcXb
Zikithezikit
force-pushed
the
wp9-legacy-security
branch
from
October 31, 2026 07:30
e124e40 to
e034b47
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No finding in this pass is High or above, so nothing here needs to hold 2.0.0. Every finding is Low or Info.
This is the single PR for #18 (WP-9). It audits the legacy Kyber code and fixes what the earlier comments on #18 decided to fix, one commit each, and logs everything in SECURITY.md. It does not close #18. Kyber output is byte-identical to v1.0.5: the golden vectors in tests/vectors/legacy-kyber-v1.0.5.json are untouched and pass on every commit.
One thing changes behavior on purpose, as approved: Kyber512/768/1024.from_keys now raises KyberError when the public key is not the copy stored inside the secret key. It is in the CHANGELOG.
Commits, in order
Commits 6 to 9 are the extra checks the task asked for (panics, overflow, input lengths, advisories) that turned up real but small things. The two decided non-fixes, the Compress_11 deviation and the Keccak stack spills, are documented in SECURITY.md and not touched. I agree with both decisions and have no evidence to argue the other way.
Verified (how)
Believed, not verified
Findings
ID: SEC-001. Title: poly_frommsg mask from a message bit. Severity: Low (pattern of CVE-2024-37880, no branch seen with this compiler). Location: src/kyber/poly.rs poly_frommsg. Class: timing. Evidence: not demonstrated; hypothesis for other compilers. Related: CVE-2024-37880. Legacy impact: no. Fix: black_box on bit and mask. Test: golden vectors; the disassembly above.
ID: SEC-002. Title: verify and cmov without a barrier. Severity: Info (no branch seen). Location: src/kyber/verify.rs. Class: timing. Evidence: hypothesis. Related: CVE-2024-37880. Legacy impact: no. Fix: black_box as in mlkem::ct. Test: golden vectors, legacy_decaps_rejection.
ID: SEC-003. Title: thread_rng and panic on RNG failure. Severity: Low. Location: src/lib.rs, kem.rs and indcpa.rs fills. Class: rng. Evidence: demonstrated by reading the code (thread_rng, fill_bytes panics on failure). Related: FIPS 203 section 3.3 for the new code; PLAN-2.0 WP-3. Legacy impact: no (seeded paths and fill order and sizes unchanged). Fix: OsRng, try_fill_bytes, RandomBytesGeneration, RandomnessError, wipe on failure. Test: tests/legacy_rng_failure.rs, python/tests/test_kyber.py TestRandomnessFailure, strace run above.
ID: SEC-004. Title: decaps intermediates, the reject flag and the PRF noise buffers not wiped on every path. Severity: Low. Location: kem.rs crypto_kem_dec*, poly.rs poly_getnoise_*. Class: memory-hygiene. Evidence: demonstrated by code and disassembly (flag byte never zeroed; noise buffers never zeroed). Related: FIPS 203 section 3.3, section 6.3. Legacy impact: no. Fix: Zeroizing wrappers. Test: legacy_decaps_rejection (behavior only); the wipe is checked in the disassembly, not at runtime.
ID: SEC-005. Title: from_keys accepts a public key and secret key from different pairs. Severity: Low (silent misuse, leaks nothing). Location: python/pykyber/_kyber.py from_keys. Class: api-misuse. Evidence: demonstrated in the comment on #18 for 768; the same code is used at 512 and 1024. Related: CVE-2026-115408 (same class, other library). Legacy impact: no change to output; a deliberate change to what from_keys accepts. Fix: compare the key with the copy inside the secret key, offsets from named constants; a wrong-size secret key keeps its size error at use. Test: TestFromKeysBinding, all three levels.
ID: SEC-006. Title: secret keys and shared secrets returned to Python as Vec, freed unwiped. Severity: Low. Location: src/lib.rs. Class: memory-hygiene. Evidence: code reading. Related: FIPS 203 section 3.3. Legacy impact: no. Fix: SecretBytes wrapper that wipes after the bytes object is built. Test: the existing Python suite; the wipe is not tested at runtime.
ID: SEC-007. Title: no length checks in crypto_kem_keypair* and crypto_kem_enc*. Severity: Info (Rust API only; the bindings size every buffer). Location: src/kyber/kem.rs. Class: panic/DoS. Evidence: code reading (slice indexing on caller lengths); I did not run the new tests against the old code. Related: none. Legacy impact: no. Fix: InvalidInput. Test: tests/legacy_length_checks.rs.
ID: SEC-008. Title: DummyRng panics. Severity: Info (never reached from Python). Location: src/kyber/api.rs. Class: panic/DoS. Evidence: code reading. Legacy impact: no. Fix: try_fill_bytes returns an error. Test: derive tests in legacy_length_checks.rs (the stand-in itself is private).
ID: SEC-009. Title: Compress_11 differs from FIPS 203 at x = 1875 and 3125. Severity: Info. Location: polyvec_compress_1024. Class: spec-deviation. Evidence: demonstrated in #28. I wrote the exact fix ((x << 11) + 1664) * 645084 >> 31, which matches exact rounding over all of Z_q, and it moved golden vectors #6 and #7 (Kyber-1024); by estimate (not measured) about 46% of Kyber-1024 ciphertexts would stop matching v1.0.5. Decision: reverted, to keep Kyber-1024 compatible with v1.0.5. Legacy impact of the fix: yes. Not fixed; the pinned test is kept. ML-KEM-1024 uses its own exact routine (poly_compress, d = 11), tested against FIPS 203 at every residue, by ACVP and Wycheproof, and by the differential test against the ml-kem crate, so ML_KEM_1024 has the correct rounding.
ID: SEC-010. Title: Keccak permutation stack spills not wiped. Severity: Low. Location: fips202.rs keccakf1600_statepermute. Class: memory-hygiene. Evidence: re-measured on this branch (rustc 1.95.0 release, x86_64): a 0x78-byte frame and 22 stack stores, not the 0xf8 and about 70 from #28; other targets not looked at. Legacy impact: none expected. Not fixed: only unsafe or a stack scrub whose size depends on the compiler can clear it, the scrub cannot be tested, and other copies of the secrets remain anyway. The reasons are written out in SECURITY.md section 15.
ID: SEC-011. Title: rand 0.8.5, RUSTSEC-2026-0097. Severity: Info (needs the log feature, which is off). Class: supply-chain. Fix: lockfile to 0.8.6.
ID: SEC-012. Title: pyo3 0.27.2, RUSTSEC-2026-0176 and RUSTSEC-2026-0177. Severity: Info (the affected APIs are not used here). Class: supply-chain. Not fixed: the fix is pyo3 0.29.0, a dependency change for the maintainer to decide.
Areas checked with no finding: panics reachable from Python, integer overflow in size handling, length checks before secret-dependent work in decapsulation and the bindings, and the ciphertext compare covering all bytes.
Out of scope, not done
Sources checked, 2026-10-30
Benchmark
Release wheels of the base commit and this branch, python/tests/test_kyber.py -k performance, 100 iterations per op, median of three alternating runs, one noisy machine. Decapsulation is the same within noise (+0.9%, 0.0%, -4.2% at 1024, 512, 768). Key generation and encapsulation are slower by up to about 0.02 ms (Kyber768 keygen 0.051 to 0.074 ms, Kyber1024 keygen 0.087 to 0.113 ms), most likely the getrandom call that replaced thread_rng. Full key exchange moved between -14% and +2%, inside the noise. The first baseline I took was from a debug build and I discarded it.
Checklist
Generated with Claude Code