Skip to content

feat: release-assembly check and decaps timing test for the WP-8 gate (#16) - #40

Closed
Zikithezikit wants to merge 5 commits into
dev-v2from
wp8-asm-timing
Closed

Zikithezikit wants to merge 5 commits into
dev-v2from
wp8-asm-timing

Conversation

@Zikithezikit

Copy link
Copy Markdown
Owner

This is the compiler-level half of the WP-8 security gate (issue #16). It adds two tools, a CI job for one of them, and the results in SECURITY.md. Nothing under src/ or python/pykyber/ is touched, and there are no dependency changes. Role here was audit and tooling, so findings are reported and not fixed.

Commits

What the assembly script does

It builds the library as an rlib (cargo rustc --locked --release --lib --crate-type rlib --target T) for x86_64, aarch64, i686 and armv7, disassembles it with the llvm-objdump from the toolchain and starts at the decaps entry points (ML-KEM mlkem::kem::decaps, legacy crypto_kem_dec, _512, _1024). rustc puts every function in its own section, so no #[inline(never)] and no probe crate is needed. Because release builds inline poly_from_msg, the compress routines, verify and cmov into their callers, it follows call relocations and checks every function reachable from the roots (39 functions for ML-KEM and 47 for legacy on every target), and reports which of the named leaf functions exist as symbols of their own. An rlib needs no linker, so the cross targets build here.

Per function it looks for division (div, idiv, udiv, sdiv, division helpers such as __udivdi3 and __aeabi_idiv, and the divide-by-zero panic), memcmp-like calls, and computed jumps. A tool cannot tell a secret branch from a public one, so conditional branches are classified: backward branches are loops, branches into a block that ends in a panic call are bounds guards, and the rest are "other". Every "other" branch has to be covered by a ceiling per function and target in the allowlist, with a reason. I wrote the ceilings by reading the compare that feeds each branch (--show-other). More branches than the ceiling is a finding, so a compiler or source change forces a new review.

Timing test

scripts/timing_decaps.py times decaps for one fixed key on valid ciphertexts, ciphertexts with one flipped bit and random ciphertexts, through the private extension functions, for ML-KEM and legacy Kyber. 100 000 timings per class, seed 20261030, interleaved in seeded random order, Welch t on raw and cropped timings, flag only if |t| > 5.0 overall and in both halves with the same sign. It is a measurement tool, not a correctness test, so it is outside cargo test, pytest and scripts/check.sh (it is slow and machine dependent); how to run it is in SECURITY.md. The asm check runs in CI on a pinned rustc 1.95.0 and is not a required check and not in check.sh. The timing test is a manual step before a release, because shared runners are too noisy for it.

Findings (AGENTS.md format)

ID: SEC-001
Title: Legacy verify/cmov/poly_frommsg have no optimization barrier
Severity: Low (not demonstrated; the release assembly is branch-free on all four targets)
Location: src/kyber/verify.rs (verify, cmov); src/kyber/poly.rs (poly_frommsg)
Class: timing
Evidence: masks built with wrapping_neg and no black_box. rustc 1.95.0 compiles crypto_kem_dec with only the three length-check branches, indcpa_enc/indcpa_dec with counters only. Whether another compiler or flag set does the same is a hypothesis.
Related: CVE-2024-37880, https://pqshield.com/?p=3736
Legacy impact: no; a barrier changes no output
Suggested fix + test: route the masks through core::hint::black_box as src/mlkem/ct.rs does; the asm check in CI is the regression test.
Proposed for #18, not filed.

ID: SEC-002
Title: variable-time 32-byte compare in the dk hash check on armv7
Severity: Info (both sides derive from the public ek)
Location: src/mlkem/check.rs check_dk, inlined into kem::decaps
Class: timing
Evidence: a bcmp call at decaps+0x88 in the armv7 listing; the other targets compare words and branch.
Related: FIPS 203 section 7.3
Legacy impact: no
Suggested fix + test: none needed; allowlisted as one permitted call per decaps.

No finding in the ML-KEM code. For the done-criterion "ML-KEM passes": the tools found nothing in ML-KEM under the limits below, which is not the same as a guarantee.

Verified (how)

  • python scripts/asm_check.py on a clean clone of this branch: exit 0, no findings, 0 division instructions or helper calls on all four targets in both closures, 0 computed jumps, 1 bcmp site per ML-KEM decaps on armv7 (allowlisted). Output is pasted below.
  • Mutation check of the script on a scratch copy of the tree (not committed): a division by a non-constant in compress.rs is reported as div/udiv plus the divide-by-zero panic on x86_64 and aarch64, and replacing the select in reject_on_mismatch by if black_box(diff) != 0 raises the decaps branch count above its ceiling. A plain if diff != 0 produced no finding because LLVM turned it back into a select.
  • I found and fixed a bug in my own script before pushing: on x86 the calls are callq *(%rip) with GOTPCREL relocations, which my first version did not follow, so the x86_64 and i686 closures were far too small (4 and 8 functions) and the first "no findings" there was not meaningful. All four targets now give 39/47 functions, and I reviewed the new x86_64 and i686 branch listings before updating the allowlist.
  • Timing, --sets 768 -n 100000 --cpu 2 on a clean clone (i7-7700, CPython 3.14.4, powersave governor): no flag for ML-KEM-768 or Kyber-768, largest |t| 2.00. Full run for 512/768/1024 on the working tree: no flag, largest |t| 2.59. Detectable difference at |t| = 5, from the observed variance, was 300 to 920 ns on uncropped timings and as low as 16 ns on the 80th-percentile crop in the quietest runs. Positive control: 100 ns and 200 ns injected into the non-valid classes were flagged for both families at 768, on the cropped tests (uncropped at 100 ns reached only |t| of about 4).
  • cargo fmt --check; cargo clippy --all-targets -- -D warnings; the same with the newest installed toolchain (1.99.1); cargo test --locked --no-fail-fast; maturin develop and pytest python/tests (2464 passed); and git rebase --exec 'bash scripts/check.sh' origin/dev-v2, every commit. Note that cargo +stable is 1.95.0 on this machine, so the 1.99.1 run is the "recent stable" check.

Believed, not verified

  • Backward branches are loops on public counters and panic guards depend on public lengths. A secret-indexed lookup would look like a panic guard and would not be caught.
  • On i686 the shake256 compares against a stack slot are the output length (inferred from the listing, not traced).
  • The rlib is not the final cdylib, so LTO or a different profile could change codegen. The 32-bit and armv7 results are static only; only x86_64 was timed.
  • The valid ciphertexts in the timing test come from the OS RNG, so a rerun is not bit-identical (key, flip positions, random ciphertexts and order are seeded).

Not done

  • The valgrind/ctgrind variant was not run: valgrind is not installed and I could not install it. The exact steps and why they need an in-crate test are in SECURITY.md.
  • Not checked: s390x, ppc64le, Android, musl, Windows and macOS builds that are in the wheel matrix; floating point; zeroization in the generated code.

Out of scope, noted only

Sources checked, 2026-10-30

PR checklist

  • Spec sections cited in code for new crypto logic (n/a, no crypto code)
  • Legacy golden vectors pass unchanged (cargo test, every commit)
  • No secret-dependent branches, divisions, or indexing (asm checked: yes, with the limits above; no src change)
  • Secret intermediates zeroized on all paths (n/a, no src change)
  • Inputs validated before secret-dependent work (n/a)
  • No new runtime deps; no unsafe; no panics on untrusted input
  • No magic numbers (no Rust added; the Python tools name their constants)
  • fmt/clippy/test/pytest green on every commit (git rebase --exec)
  • Review fixes folded into the commits they fix (the x86 call-edge fix was autosquashed into the script commit); rebased on current dev-v2
  • CHANGELOG: nothing user-visible, no entry
  • Sources checked: see above

Real output of the assembly check (clean clone):

(condensed: per-function tables omitted)
rustc    : rustc 1.95.0 (59807616e 2026-04-14)
cargo    : cargo 1.95.0 (f2d3ce0bd 2026-03-21)
objdump  : /home/server-yoav/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/bin/llvm-objdump (LLVM version 22.1.2-rust-1.95.0-sta
RUSTFLAGS: ''  profile: release (opt-level 3, codegen-units 16, no LTO)
==== x86_64 (x86_64-unknown-linux-gnu, full check)
315 function symbols, 67482 instructions
leaf functions of interest (standalone symbols):
  poly_tomsg (legacy)            1 symbol(s)
  poly_frommsg (legacy)          inlined (checked inside its callers)
  poly_compress (legacy)         2 symbol(s)
  polyvec_compress (legacy)      3 symbol(s)
  poly_decompress (legacy)       1 symbol(s)
  verify (legacy)                inlined (checked inside its callers)
  cmov (legacy)                  1 symbol(s)
  poly_compress (ML-KEM)         inlined (checked inside its callers)
  poly_decompress (ML-KEM)       inlined (checked inside its callers)
  poly_from_msg (ML-KEM)         1 symbol(s)
  reject_on_mismatch (ML-KEM)    1 symbol(s)
[ML-KEM] 3 root symbol(s), 39 functions in the call closure, 23035 instructions
  division instructions/calls: 0, indirect branches: 0, memcmp-like calls: 0
[legacy Kyber] 3 root symbol(s), 47 functions in the call closure, 25574 instructions
  division instructions/calls: 0, indirect branches: 0, memcmp-like calls: 0
==== aarch64 (aarch64-unknown-linux-gnu, full check)
315 function symbols, 73399 instructions
leaf functions of interest (standalone symbols):
  poly_tomsg (legacy)            1 symbol(s)
  poly_frommsg (legacy)          inlined (checked inside its callers)
  poly_compress (legacy)         2 symbol(s)
  polyvec_compress (legacy)      3 symbol(s)
  poly_decompress (legacy)       1 symbol(s)
  verify (legacy)                inlined (checked inside its callers)
  cmov (legacy)                  1 symbol(s)
  poly_compress (ML-KEM)         inlined (checked inside its callers)
  poly_decompress (ML-KEM)       inlined (checked inside its callers)
  poly_from_msg (ML-KEM)         1 symbol(s)
  reject_on_mismatch (ML-KEM)    1 symbol(s)
[ML-KEM] 3 root symbol(s), 39 functions in the call closure, 20550 instructions
  division instructions/calls: 0, indirect branches: 0, memcmp-like calls: 0
[legacy Kyber] 3 root symbol(s), 47 functions in the call closure, 27124 instructions
  division instructions/calls: 0, indirect branches: 0, memcmp-like calls: 0
==== i686 (i686-unknown-linux-gnu, spot check)
316 function symbols, 73179 instructions
leaf functions of interest (standalone symbols):
  poly_tomsg (legacy)            1 symbol(s)
  poly_frommsg (legacy)          inlined (checked inside its callers)
  poly_compress (legacy)         2 symbol(s)
  polyvec_compress (legacy)      3 symbol(s)
  poly_decompress (legacy)       1 symbol(s)
  verify (legacy)                inlined (checked inside its callers)
  cmov (legacy)                  1 symbol(s)
  poly_compress (ML-KEM)         inlined (checked inside its callers)
  poly_decompress (ML-KEM)       inlined (checked inside its callers)
  poly_from_msg (ML-KEM)         1 symbol(s)
  reject_on_mismatch (ML-KEM)    1 symbol(s)
[ML-KEM] 3 root symbol(s), 39 functions in the call closure, 20410 instructions
  division instructions/calls: 0, indirect branches: 0, memcmp-like calls: 0
[legacy Kyber] 3 root symbol(s), 47 functions in the call closure, 27376 instructions
  division instructions/calls: 0, indirect branches: 0, memcmp-like calls: 0
==== armv7 (armv7-unknown-linux-gnueabihf, spot check)
316 function symbols, 44253 instructions
leaf functions of interest (standalone symbols):
  poly_tomsg (legacy)            1 symbol(s)
  poly_frommsg (legacy)          inlined (checked inside its callers)
  poly_compress (legacy)         2 symbol(s)
  polyvec_compress (legacy)      3 symbol(s)
  poly_decompress (legacy)       1 symbol(s)
  verify (legacy)                inlined (checked inside its callers)
  cmov (legacy)                  1 symbol(s)
  poly_compress (ML-KEM)         inlined (checked inside its callers)
  poly_decompress (ML-KEM)       inlined (checked inside its callers)
  poly_from_msg (ML-KEM)         1 symbol(s)
  reject_on_mismatch (ML-KEM)    1 symbol(s)
[ML-KEM] 3 root symbol(s), 39 functions in the call closure, 12435 instructions
  division instructions/calls: 0, indirect branches: 0, memcmp-like calls: 3
[legacy Kyber] 3 root symbol(s), 47 functions in the call closure, 12813 instructions
  division instructions/calls: 0, indirect branches: 0, memcmp-like calls: 0
== result
no findings (this is evidence for the toolchain and targets above, not a guarantee)

Real output of the timing test (clean clone, 768):

== environment
python   : CPython 3.14.4
machine  : x86_64, Intel(R) Core(TM) i7-7700 CPU @ 3.60GHz, governor powersave
pykyber  : ? from /tmp/claude-1000/-home-server-yoav-Projects-pykyber/6c20acc7-b64b-4bcc-be24-2ddb056727ef/scratchpad/clean/python/pykyber
count    : 100000 per class, seed 20261030, threshold |t| > 5.0, pool 256, warmup 2000

== mlkem 768: 100000 measurements per class, ciphertext 1088 bytes, threshold |t| > 5.0
  valid-class timing: median 68320 ns, p5 66463 ns, p95 113439 ns
  valid/flip     crop<=p100 t=   -0.69 (halves   -0.86   -0.38)  mean(valid)-mean(other)=    -80.7 ns  detectable at |t|=5.0:   585.0 ns  n=100000/100000  ok
  valid/flip     crop<=p95  t=   -0.13 (halves   +0.66   -0.68)  mean(valid)-mean(other)=     -7.0 ns  detectable at |t|=5.0:   259.8 ns  n=95033/94969  ok
  valid/flip     crop<=p80  t=   -1.14 (halves   -0.76   -0.87)  mean(valid)-mean(other)=    -16.7 ns  detectable at |t|=5.0:    73.0 ns  n=80049/79954  ok
  valid/random   crop<=p100 t=   -0.34 (halves   -0.81   -0.05)  mean(valid)-mean(other)=    -38.2 ns  detectable at |t|=5.0:   557.0 ns  n=100000/100000  ok
  valid/random   crop<=p95  t=   -0.88 (halves   +0.29   -1.42)  mean(valid)-mean(other)=    -46.0 ns  detectable at |t|=5.0:   260.0 ns  n=95006/94996  ok
  valid/random   crop<=p80  t=   -0.57 (halves   -1.15   +0.26)  mean(valid)-mean(other)=     -8.4 ns  detectable at |t|=5.0:    73.3 ns  n=80090/79913  ok

== kyber 768: 100000 measurements per class, ciphertext 1088 bytes, threshold |t| > 5.0
  valid-class timing: median 58440 ns, p5 56891 ns, p95 93733 ns
  valid/flip     crop<=p100 t=   +0.11 (halves   -0.12   +0.63)  mean(valid)-mean(other)=     +8.4 ns  detectable at |t|=5.0:   391.3 ns  n=100000/100000  ok
  valid/flip     crop<=p95  t=   -0.29 (halves   -0.24   -0.18)  mean(valid)-mean(other)=    -10.9 ns  detectable at |t|=5.0:   185.0 ns  n=95063/94938  ok
  valid/flip     crop<=p80  t=   +1.55 (halves   +0.43   +1.81)  mean(valid)-mean(other)=     +9.9 ns  detectable at |t|=5.0:    32.1 ns  n=80072/79930  ok
  valid/random   crop<=p100 t=   -0.16 (halves   -0.23   +0.27)  mean(valid)-mean(other)=    -13.9 ns  detectable at |t|=5.0:   432.7 ns  n=100000/100000  ok
  valid/random   crop<=p95  t=   -1.18 (halves   -0.14   -1.61)  mean(valid)-mean(other)=    -43.3 ns  detectable at |t|=5.0:   183.9 ns  n=94960/95041  ok
  valid/random   crop<=p80  t=   +0.98 (halves   +0.84   +0.75)  mean(valid)-mean(other)=     +6.3 ns  detectable at |t|=5.0:    32.1 ns  n=80039/79963  ok

== result
no timing difference found at the stated power (not a proof of constant time)

Generated with Claude Code

Zikithezikit and others added 5 commits October 30, 2026 18:06
scripts/asm_check.py builds the library as an rlib for x86_64, aarch64,
i686 and armv7, disassembles it with llvm-objdump and follows the call
relocations from the decapsulation entry points of ML-KEM and of the legacy
Kyber code. Every function reachable from them is checked for division
instructions and division helpers, memcmp-like calls and computed jumps.
Conditional branches are classified as loop, panic guard or other; "other"
branches must be in scripts/asm_check_allowlist.json with a per-target
ceiling and a reason, so a changed count forces a new review.

Nothing under src/ changes: rustc puts each function in its own section and
the closure walk does not depend on which helpers get inlined.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
scripts/timing_decaps.py times decapsulation of valid, one-bit-flipped and
random ciphertexts for a fixed key through the private extension functions,
for ML-KEM and for the legacy Kyber classes. It interleaves the classes in a
seeded random order, computes Welch's t on raw and cropped timings, and flags
a pair only when |t| exceeds the threshold overall and in both halves of the
run with the same sign. It prints the smallest difference it could have
detected, and --inject-ns adds a known delay as a positive control.

It is a measurement tool and is not part of cargo test, pytest or
scripts/check.sh: it is slow and depends on the machine.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The job pins rustc 1.95.0, the toolchain the allowlist was reviewed with, so
a new stable release cannot turn it red on its own; a bump comes with a new
run of scripts/asm_check.py. It stays out of scripts/check.sh and is not a
required check. The timing test remains a manual step.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Tool versions, targets, what scripts/asm_check.py and
scripts/timing_decaps.py checked and found, what they did not check, two
proposed findings (SEC-001 for the legacy code, SEC-002 informational), the
dated source check, and the commands to rerun them. The valgrind variant is
documented as not run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@Zikithezikit
Zikithezikit deleted the wp8-asm-timing branch October 30, 2026 16:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant