pykyber implements cryptographically-sensitive code, and its users run it to protect secrets. Please treat potential weakness in it accordingly.
Do not open a public issue for a security problem. Use GitHub's private vulnerability reporting, which is enabled on this repository:
https://github.com/Zikithezikit/pykyber/security/advisories/new
(reachable from the repo's Security tab, "Report a vulnerability").
What helps us triage fastest:
- A clear description of the weakness and your assessment of impact.
- Affected version(s) and/or commit, and which parameter set is involved
(
Kyber512/768/1024, from 2.0 on alsoML_KEM_*). - A minimal reproduction (script or test patch), passing random keys. Please do not paste real key material, seeds, or shared secrets into the report.
- For timing findings: how you demonstrated or estimated the leak (code path, compiler/optimizer version, measurement setting).
- A suggested fix, if you have one.
- This is an independently maintained, unfunded, open-source project: we acknowledge reports on a best-effort basis, aiming for a few days.
- We prefer coordinated disclosure. We will keep you informed, credit you in the advisory if you wish (including anonymously is fine), and publish the advisory once a fix ships.
- We will handle timing-attack claims by the standard in this repo: suspect secret-dependent behavior must be backed by generated assembly or a dynamic measurement, not just code reading.
In scope: the published pykyber package and everything under src/:
timing side channels, secret zeroization and memory hygiene, input
validation, randomness source and failure handling, panics reachable from
Python, cryptographic correctness against the reference documents (round-3
CRYSTALS-Kyber for the Kyber* classes, FIPS 203 for the ML_KEM_*
classes), and wrong-key-family/parameter confusion in the Python API.
Out of scope: issues only exploitable by the party that already controls
the key or the machine; availability of the benchmark harness in
performance-tests/; breakages of third-party libraries that merely import
pykyber; social engineering.
Audits and hardening work performed on this codebase are logged here so the history of known issues and their resolutions stays visible to every contributor. Ongoing 2.0 security work is tracked issue-by-issue.
The log below was migrated on 2026-10-29 from
.opencode/SECURITY.md, a maintainer/agent working log that was
gitignored and therefore invisible to other contributors. The item content
is unchanged; each item is fixed in the current release.
This log tracks identified security concerns, cryptographic vulnerabilities,
and NIST compliance issues within the pykyber implementation.
The implementation used the standard division operator (/) on sensitive data in several performance-critical functions.
- Affected Functions:
poly_compress,poly_tomsg,polyvec_compress, etc. - Resolution: Replaced all divisions with constant-time modular arithmetic using high-precision reciprocals and bit-shifts.
- Status: FIXED.
The implementation hardcoded the noise parameter KYBER_ETA1 to 2 for all security levels.
- Affected Constants:
KYBER_512_K,KYBER_ETA1insrc/kyber/params.rs. - Resolution: Updated
ETA1to 3 for Kyber-512 and implementedcbd3noise generation. - Status: FIXED.
The implementation used incorrect compression parameters for Kyber-1024 (
- Resolution: Corrected compression parameters and updated byte size constants. Implemented variant-specific compression/decompression logic for Kyber-1024.
- Status: FIXED.
Sensitive data could persist in memory due to Python's garbage collection and Rust's default drop behavior.
- Resolution: Integrated the
zeroizecrate. Implemented automatic zeroing on drop forPoly,Polyvec,Keypair, andKeccakStatestructs. Manually zeroed temporary local buffers in cryptographic functions. - Status: FIXED.
The keccak_squeeze function used store_u64 blindly, which could write past the end of the output buffer if the requested length was not a multiple of 8.
- Resolution: Refactored
keccak_squeezeto handle arbitrary lengths safely while maintaining data-independent timing. - Status: FIXED.
The 7-layer Number Theoretic Transform (NTT) used i16 intermediate values. While mathematically bounded, it was close to the limit.
- Resolution: Hardened NTT butterfly logic using explicit
i32arithmetic and updatedbarrett_reduceto handle a wider input range. - Status: FIXED.
Audit and fixes for the round-3 Kyber* code, done on 2026-10-30 on branch
wp9-legacy-security. The result is evidence, not a proof, and this was done
by the project's own tooling, not by a third party.
Method and limits. Assembly was read from release builds (opt-level = 3,
one codegen unit) of rustc 1.95.0 with llvm-objdump, for x86_64 and aarch64
(and i686 and armv7 for the division scan). One compiler version, one flag set
and a disassembly show what that compiler did; they do not show that another
compiler, flag set or CPU will do the same, and nothing in this repo guarantees
constant time. The barriers below (core::hint::black_box) are best effort.
There is no dynamic timing test for the legacy code (that is WP-8, not merged).
Memory hygiene is limited to what safe Rust can do: moves and register spills
leave copies that cannot be wiped without unsafe, and every secret that
crosses into Python becomes a Python bytes object that cannot be wiped.
No finding is High or above.
poly_frommsg builds -(bit) from a message bit, the pattern behind
CVE-2024-37880 ("Clangover"). In the release builds checked here (rustc 1.95,
x86_64 and aarch64) it was vectorized with no branch on message bits, so the
leak was not observed. The bit and the mask now go through black_box.
Disassembly after the change: the only branches are the bounds check and the
loop counter. Output is unchanged. Status: HARDENED.
Before the change the compare ended in setg and the select was a vector
and/andn/or, with no branch on the result. The accumulated difference, the
flag and the mask now go through black_box, as mlkem::ct does. Disassembly
after the change shows setne/cset and a vector select; in crypto_kem_dec
the flag is stored, reloaded and passed to the select with no branch on it.
Status: HARDENED.
Legacy key generation and encapsulation used rand::thread_rng(), and the KEM
functions called fill_bytes, which panics when the generator fails. They now
use OsRng and try_fill_bytes, return KyberError::RandomBytesGeneration,
and Python raises RandomnessError. No fallback source exists. The half-built
secret key is wiped if the second draw fails. Checked by Rust tests with a
generator that fails, and end to end with strace -e inject=getrandom:error=EIO
(all six Python entry points raised RandomnessError, no panic). Status: FIXED.
m', the KDF input and the re-encrypted ciphertext were wiped only on the
normal exit path, the reject flag was never wiped, and the PRF output buffers in
poly_getnoise_* (the secret noise: the key in key generation, r in
encryption) were never wiped. They are now Zeroizing, so they are wiped on
unwinding too. The release disassembly shows the flag and buffer wipes on both
the normal and the cleanup path. There is no runtime test of the wipe (it needs
unsafe or an allocator hook); the implicit-rejection output is covered by a
new test. Status: FIXED, with the limits stated above.
Kyber512/768/1024.from_keys accepted any public key and secret key of the
right sizes, so a pair from two key pairs decapsulated to a different secret
with no error (the class of bug in CVE-2026-115408 for another library). It now
raises KyberError when the public key is not the copy in the secret key
(sk = sk_pke || pk || H(pk) || z). H(pk) and z are not checked.
ML_KEM_*.from_keys has the same gap and is not changed here. Status: FIXED
for Kyber*.
The bindings returned secret keys and shared secrets as Vec<u8>, freed with
the secret still in the allocation after PyO3 copied it into bytes. They are
now wiped after the copy. Status: FIXED. The Python bytes objects remain.
crypto_kem_keypair* and crypto_kem_enc* indexed caller buffers and seeds
with no length check and panicked on a short one. The Python bindings size
every buffer, so no Python path reached this. They now return InvalidInput.
DummyRng (the stand-in on the seeded path, never asked for bytes) returned a
panic from try_fill_bytes; it now returns an error. api::public still
slices its argument and would panic on a short sk; no Python path calls it.
Status: FIXED, one Rust-only note left.
polyvec_compress_1024 (du = 11) gives a result one too high at x = 1875 and
3125 compared with exact rounding (found in #28, pinned by
legacy_compress_11_deviates_from_spec_at_two_residues). It uses the d = 4/5/10
multiplier 10321345 >> 35, which is not exact for d = 11.
A fix was written and tested on 2026-10-31 and then reverted by the maintainer's
decision, to keep Kyber-1024 output compatible with v1.0.5. The replacement was
(((x << 11) + 1664) * 645084) >> 31, which matches exact rounding at every
residue (checked over all of Z_q). Its cost was: golden vector #6 and #7
(Kyber-1024) in tests/vectors/legacy-kyber-v1.0.5.json changed one or more
ciphertext bytes, and by estimate about 46% of Kyber-1024 ciphertexts
(1 - (1 - 2/3329)^1024, assuming uniform coefficients; not measured) contain a
coefficient at one of the two residues, so a 1.x ciphertext would decapsulate to
the implicit-rejection secret across the change and the reverse.
What stays correct: ML-KEM-1024 does not use this routine. kpke calls
compress::poly_compress with d = 11 and the exact multiplier 645084 >> 31,
which is tested against FIPS 203 (4.7) for every residue and negative
representative, by the ACVP and Wycheproof vectors, and by the differential test
against the ml-kem crate. Both ends of one legacy version use the same routine,
so Kyber-1024 round trips work; only interoperability with other round-3
implementations (which use exact rounding) is affected at those two residues.
Status: NOT FIXED, by decision. Anyone who needs exact Compress_11 should use
ML_KEM_1024.
What the issue is. keccakf1600_statepermute runs the 24 rounds with the state
in registers and stack temporaries. Zeroizing the 25-word state array after the
hash (s.zeroize() in fips202.rs) clears the array, not the temporaries. The
temporaries depend on the hashed input: m, z, the PRF seed and the
decapsulation intermediates all pass through SHA3 or SHAKE. A reader of freed
stack memory (a core dump, a swapped page, a memory-disclosure bug, cold boot)
could find parts of them after the call returns.
What was measured. Release build of this branch, rustc 1.95.0, llvm-objdump
on x86_64 (2026-10-31): one copy of the permutation, six callee-saved pushes, a
subq $0x78, %rsp frame and 22 mov stores to (%rsp). The #28 figures
(0xf8-byte frame, about 70 spill stores) did not reproduce on this build, so the
size of the leak is not stable; its existence is. aarch64, i686 and armv7 were
not looked at.
Why it is not fixed.
- Safe Rust cannot reach it. The temporaries exist only inside a compiler-chosen
frame that is dead after
ret; clearing them needs a volatile write to addresses the compiler picked (unsafe, forbidden here) or hand-written assembly (alsounsafe). - The safe workaround, a stack-scrub call that writes a large zeroed local
through
black_boxafter each hash, is a guess about where the old frame was. Frame size and layout change with compiler version, target and inlining (the 0x78 against 0xf8 above is an example), so the scrub would be too small on some builds and hide the problem with no way to test it. It also costs a memset per hash. I judged a mitigation that cannot be verified worse than a documented limit. - It is not the only copy. Registers spilled by callers, moves of secret arrays,
and the Python
bytesobjects holding keys and shared secrets all leave copies that cannot be wiped either (see "Method and limits" above), so fixing this one would not make the process memory clean. - The permutation is shared with ML-KEM (
src/mlkem/mod.rs); any change to it belongs in its own reviewed change with new assembly evidence, not in a security log commit.
What would close it. A permutation written to keep the state in a fixed
Zeroizing buffer end to end (no temporaries the compiler can spill, which
cannot be guaranteed in safe Rust), or an unsafe stack scrub gated on a
maintainer decision to allow unsafe in one audited function.
Status: NOT FIXED, documented limit.
Queried OSV (which carries RustSec) on 2026-10-30 for the locked runtime
dependencies; cargo audit is not installed here, so this is not its output.
rand 0.8.5 has RUSTSEC-2026-0097 (needs the log feature, which is off):
Cargo.lock moved to 0.8.6. pyo3 0.27.2 has RUSTSEC-2026-0176 (nth on list
and tuple iterators) and RUSTSEC-2026-0177 (PyCFunction::new_closure), both
fixed only in 0.29.0; this code uses neither API, and the 0.29 upgrade is left
to the maintainer. zeroize, rand_core, getrandom, rand_chacha: none.
- Panics reachable from Python: none. About 1000 inputs (random, all-0xFF and
coefficient-edge public keys, secret keys and ciphertexts, and wrong sizes for
every argument and seed) went through the
_pykyberfunctions in a build with overflow checks on; every result was a success or a plain exception. - Integer overflow: sizes are compile-time constants or
len()of a checked slice; the fuzz above had overflow checks on. Not a proof for adversarial coefficients beyond those tried. - Length checks before secret work:
crypto_kem_dec*and every binding checkct,sk,pkand the seed before touching secrets. - Ciphertext compare covers the full ciphertext (see CVE-2026-6330 below).
- KyberSlash scan: see the table.
Dates are 2026-10-30. "Primary" means the issuing page or the paper itself.
| Item | Status | Evidence |
|---|---|---|
KyberSlash 1 (division in poly_tomsg) and 2 (poly_compress, polyvec_compress), https://eprint.iacr.org/2024/1049 (primary, read) |
Not affected | The kyberslash.cr.yp.to page could not be fetched (transport error); the paper and search results agree the bug is a division by q on secret data. The code uses multiply-and-shift (* 10321345 >> 35). Whole-library release disassembly has no div/idiv/udiv/sdiv and no __aeabi_*div/__udiv* relocation on x86_64, aarch64, i686 and armv7; a control function with a runtime divisor does show them. This covers all three levels, compress, decompress, poly_tomsg and the ML-KEM code. The constant divisions became multiplies; their timing on cores with a data-dependent multiplier was not assessed. |
| CVE-2024-14041 (KyberSlash in Bouncy Castle Java) | Not affected | Other library; found in search (mondoo), not on the primary page. |
CVE-2024-37880 (Clangover, poly_frommsg) |
Mitigated | Not observed with rustc 1.95 on x86_64 and aarch64; barrier added (item 7). Record read via mirrors (Debian tracker, OSV, INCIBE); the NVD page itself did not render. The CVE names LLVM Clang through 18; Rust also uses LLVM, so the class applies. |
| CVE-2026-115408 (crypto11 ML-KEM key pair lookup) | Not affected, class addressed | Another library (Go). Read on OpenCVE (NVD status "Received"); not yet on NVD proper. Same class as from_keys: fixed for Kyber* (item 11), still open for ML_KEM_*.from_keys. |
| CVE-2026-6330 (wolfSSL ML-KEM ARM64 NEON compares half the ciphertext) | Not affected | CVE record read via the MITRE API listing and Debian tracker, not NVD. Our compare covers the whole ciphertext; the rejection test damages the first, middle and last bytes at all three levels. An AVX2 variant that compares 1536 of 1568 bytes appears in one vendor-blog result, unconfirmed. |
| 2026 SEALSQ and LightSec work on ML-KEM key generation and encoding | Unconfirmed, out of scope | Press release only (thequantuminsider, 2026-10-14); the paper was not read. It concerns hardware leakage through profiling plus lattice reduction; power and EM attacks are out of scope for this project. |
| Airtight side-channel testing framework (AFIT, https://scholar.afit.edu/facpub/3612) | Not assessed | The AFIT page was not found by search; a paper with that title exists at ECCWS (academic-conferences.org). The framework was not run; dynamic timing tests are WP-8, which is not merged. |
| Reference implementations (Go, BoringSSL, RustCrypto, libcrux) | Not assessed | Comparison only; no code was copied. |
python/tests/test_kyber.py -k performance, 100 iterations per op, median of
three alternating runs, one machine, noisy: decapsulation about the same at all
levels (+0.9%, 0.0%, -4.2%); key generation and encapsulation slower by up to
about 0.02 ms (Kyber768 keygen 0.051 to 0.074 ms, Kyber1024 keygen 0.087 to
0.113 ms, encapsulation +7% to +13%), most likely the getrandom call that
replaced thread_rng. The full key exchange moved -14% to +2%, inside the noise.