Optimized rust-based modular arithmetic and NTT for lattice cryptography. Includes ML-KEM-512 and ML-KEM-768 (FIPS 203) with an ARM64 NEON-accelerated int16 NTT in constant time, pure ML-DSA-65 (FIPS 204) signatures, as WASM, and with optional no_std. The kem and dsa modules need the std feature (on by default); the no_std builds provide the modular arithmetic and NTT only.
moduletto::kem::ml_kem_512 and moduletto::kem::ml_kem_768 (feature std, on by default):
use moduletto::kem::ml_kem_768;
// d, z and m come from a CSPRNG; the crate has no RNG dependency.
let (ek, dk) = ml_kem_768::keygen_derand(&d, &z);
let (c, k_sender) = ml_kem_768::encaps_derand(&ek, &m)?; // checks ek (FIPS 203 7.2)
let k_receiver = ml_kem_768::decaps(&dk, &c)?; // checks dk (FIPS 203 7.3)
assert_eq!(k_sender, k_receiver);tests/kem_kat.rs runs the NIST ACVP vectors for both parameter sets against
these entry points: 25 keyGen, 25 encapsulation and 10 decapsulation cases
each, plus 10 encapsulation-key and 10 decapsulation-key checks each.
tests/kat/extract.mjs regenerates the vector files from an ACVP-Server
checkout.
moduletto::dsa::ml_dsa_65 (feature std, on by default) is pure
ML-DSA-65 (FIPS 204): public key 1952 bytes, private key 4032 bytes,
signature 3309 bytes.
use moduletto::dsa::ml_dsa_65;
// xi and rnd come from a CSPRNG; rnd = [0; 32] selects deterministic signing.
let (pk, sk) = ml_dsa_65::keygen_internal(&xi); // Algorithm 6
let sig = ml_dsa_65::sign(&sk, b"message", b"context", &rnd)?; // Algorithm 2
assert!(ml_dsa_65::verify(&pk, b"message", b"context", &sig)); // Algorithm 3The context string may be at most 255 bytes. sign_internal and
verify_internal take the formatted message M′ (Algorithms 7 and 8).
external_mu, sign_mu and verify_mu support a message representative μ
computed elsewhere. HashML-DSA (the pre-hash variant, Algorithms 4 and 5) is
out of scope and not provided.
tests/dsa_kat.rs runs the NIST ACVP vectors for ML-DSA-65: 25 keyGen
cases, 90 sigGen cases and 45 sigVer cases. The sigGen cases cover the
pure, internal and external-μ interfaces, each deterministic and hedged. The
sigVer cases include 36 negative cases. The HashML-DSA groups are skipped.
The same file checks the context limit, input lengths and single-bit changes
to signatures, keys, messages and contexts.
-
Randomness. The
*_derandfunctions taked,zandmfrom the caller, who must draw them from a CSPRNG. With thegetrandomfeature,keygen()andencaps(ek)draw them from the operating system. ML-DSA takes ξ andrndfrom the caller in the same way; withgetrandom,ml_dsa_65::keygen(),ml_dsa_65::keygen_intoandml_dsa_65::sign_hedged()draw them from the operating system. Preferkeygen_into, which leaves no copy of the private key above the scrub. -
Secrets in memory. Seeds, noise polynomials, Keccak states and the re-encryption state of decapsulation are wiped with
zeroize; the internal key structs wipe themselves on drop; polynomials are transformed in place rather than copied. After each ML-KEM operation returns, a 32 KiB stack scrub overwrites the region its frames used, so spilled registers and compiler temporaries are cleared too (libsodium'ssodium_stackzeroapproach; best effort, not a guarantee). The returned decapsulation key and shared secret are plain arrays: wrap them inzeroize::Zeroizingor wipe them yourself. ML-DSA does the same for ρ′, K, s₁, s₂, t₀, ρ″, μ, the mask y, the rejected candidate signatures and the Keccak states. It scrubs 192 KiB of stack after key generation and signing. Measured from the scrubbing frame on Apple M5, signing uses about 71 KiB in release builds and 133 KiB in debug builds;tests/stack_scrub.rschecks the coverage.ml_dsa_65::keygen_internalandml_dsa_65::keygen()return the private key as a plain array. The by-value return can leave copies of it in frames above the scrub.ml_dsa_65::keygen_internal_intoandml_dsa_65::keygen_intowrite it straight into storage you own instead. Verification wipes its buffers for tr, μ, w′, the w₁′ encoding and the recomputed c̃ (FIPS 204 section 3.6.3), and computes μ in place. It does not scrub the stack. Copies in callee frames, such as the Keccak permutation's locals, or in compiler spill slots can remain after it returns. -
Timing: code shape. Decapsulation has no secret-dependent branch, memory access or division. The implicit-rejection comparison and selection go through
subtle, and the rounding divisions by q in message decoding and compression are fixed-point multiplications (the KyberSlash pattern). Thect_*operations inmodn_ctandnttare branchless by construction. ML-DSA key generation and signing have no secret-dependent branch, memory index or division either.DecomposeandPower2Roundare fixed-point, andSampleInBallplaces each ±1 with a masked pass over the polynomial. The four rejection checks run over every coefficient and fold into one bit. Three decisions are branched on. The first is whether a signing iteration is rejected, since FIPS 204 lets the loop count vary. The other two are whether a candidate inRejBoundedPolyorSampleInBallis rejected. This crate treats those as public, because rejected candidates are independent of the accepted values. The loop count dominates signing time, and its distribution depends on the private key, so two keys can differ in mean signing time. The claim is that signing time does not depend on secrets at a given loop count.ExpandAover the public seed ρ is variable-time, as in ML-KEM. Verification is not constant-time: it treats its inputs as public. -
Timing: hardware. Branch-free code is constant-time only if the instructions are. On AArch64 that holds for the listed instructions only while
PSTATE.DITis set: on Apple M5 a dependent chain of 64-bit multiplies takes ~25% longer on zero operands than on random ones with DIT clear, and the same time with DIT set. The ML-KEM and ML-DSA entry points therefore run undermoduletto::dit::with_dit, which sets the bit for the call and restores it (a no-op off AArch64, or where FEAT_DIT is absent). Wrap your own secret computations onmodn_ct/nttinwith_dittoo. On Apple M3 and later DIT also disables the data-memory-dependent prefetcher (GoFetch). -
Timing: verification. Two checks, both configured in CI:
examples/ct_dudect.rsis a dudect statistical test (Welch's t on interleaved input classes; |t| > 4.5 means a leak) over decapsulation valid-vs-tampered and fixed-vs-random message, encapsulation, ML-DSA-65 signing under two private keys at the same rejection-loop count (featurect-bench), the NTT and the field operations, plus a CPU-only bench that shows the multiplier effect above with and without DIT.examples/valgrind_ct.rsis a ctgrind-style check: secrets are marked undefined for Valgrind memcheck, which reports any branch or memory address that depends on them (featurevalgrind-ct, x86-64 Linux, scalar path). It covers ML-KEM key generation, encapsulation and decapsulation, and ML-DSA-65 key generation and signing, with rejected iterations included. The ML-DSA cases first passed under Valgrind in the CI constant-time job on 2026-10-28 (GitHub Actions run 40753126905). Measured ML-KEM results are in audits/. The ML-DSA additions have no audit entry yet; the dudect figures measured while writing them are in the header ofexamples/ct_dudect.rs.cargo run --release --features ct-bench --example ct_dudect # all benches once cargo run --release --features ct-bench --example ct_dudect -- \ --continuous decaps_valid_vs_tampered_768 # x86-64 Linux: cargo build --release --features valgrind-ct --example valgrind_ct valgrind --tool=memcheck --error-exitcode=1 -q target/release/examples/valgrind_ct
-
Reviews. Security reviews and their remediation are recorded in audits/. Report vulnerabilities as described in SECURITY.md.
The Kyber-512 implementation in examples/kyber_benchmark.rs is validated against
the official NIST ACVP known-answer tests for ML-KEM-512 (FIPS 203) —
60 cases covering all three operations:
| Operation | Cases | Checks |
|---|---|---|
keyGen (AFT) |
25 | (d, z) → encapsulation and decapsulation keys, byte for byte |
encapsulation (AFT) |
25 | (ek, m) → ciphertext and shared secret |
decapsulation (VAL) |
10 | (dk, c) → shared secret, including modified-ciphertext cases that must yield the implicit-rejection key |
Vectors are in tests/kat/, extracted verbatim from
usnistgov/ACVP-Server. The harness runs
on every cargo run --release --example kyber_benchmark and exits non-zero on any
mismatch.
All three backends are validated — 180 cases per run — through a shared byte-level interface, so the NEON int16 path and both i64 paths are covered, not only the one that gets benchmarked:
FIPS 203 KATs (NIST ACVP ML-KEM-512), all backends:
NEON i16 NTT + inline Keccak: PASSED (25 keygen, 25 encaps, 10 decaps)
i64 NTT + inline Keccak: PASSED (25 keygen, 25 encaps, 10 decaps)
i64 NTT + sha3 crate (asm): PASSED (25 keygen, 25 encaps, 10 decaps)
The self-consistency check the example previously relied on — encapsulate, decapsulate, confirm the shared secrets agree — passes for any internally coherent scheme. Running the official vectors showed this code was implementing round-3 Kyber rather than FIPS 203 ML-KEM, plus a parameter error:
G(d)instead ofG(d ‖ k)— FIPS 203's key-generation domain separation- encapsulation hashed
mbefore use; FIPS 203 feeds it in directly - round-3's final
KDF(K ‖ H(c)); FIPS 203 usesKas the shared secret - implicit rejection via
SHA3-256(z ‖ H(c))instead ofJ(z ‖ c) - the implicit-rejection seed
zwas hardcoded to zero. This is a security defect, not only a conformance one: a predictablezmakes the rejection key computable by anyone holding the ciphertext - η₁ = 2 where ML-KEM-512 requires η₁ = 3
All are fixed and the KATs pass. The corrections also reduced runtime: dropping round-3's KDF removes a SHA3-256 over the full 768-byte ciphertext (six Keccak permutations) from every encapsulation, which exceeds the added cost of η₁ = 3 noise sampling.
Full ML-KEM-512 KEM session (keygen + encaps + decaps) on Apple M5. LibOQS was rebuilt and re-measured on the same machine, in the same session, with the same 10,000-iteration methodology. Both implementations pass the same NIST ACVP vectors:
| Phase | Moduletto NEON i16 | LibOQS 0.16.0 | LibOQS 0.15.0 | Kyber C Reference |
|---|---|---|---|---|
| Key generation | 4.07 us | 4.72 us | 5.80 us | 14.93 us |
| Encapsulation | 3.84 us | 5.25 us | 6.60 us | 14.57 us |
| Decapsulation | 5.80 us | 6.22 us | 8.03 us | 18.37 us |
| Total | 13.7–14.1 us | 16.19 us | 20.43 us | 47.87 us |
| Sessions/sec | ~72,000 | 61,800 | 48,900 | 20,900 |
moduletto is ~1.18x faster than LibOQS 0.16.0, and faster in all three phases.
LibOQS 0.16.0 (released 2026-07-09) is itself 1.26x faster than 0.15.0 at ML-KEM-512, so comparisons against the older release overstate our lead. The Kyber C Reference column is carried over from an earlier session and was not re-measured.
Library primitives (Criterion, q = 3329, n = 256):
| Operation | Variable-time | Constant-time |
|---|---|---|
forward_ntt |
176 ns | 177 ns |
inverse_ntt |
239 ns | 239 ns |
mul_ntt |
503 ns | 504 ns |
poly_add / poly_sub |
63 ns | 63 ns |
The constant-time transforms cost the same as the variable-time ones because they are the same code: the kernel is branchless straight-line arithmetic with no data-dependent branch and no conditional move, so no optimisation barrier is required. See BENCHMARKS.md for the measurement history.
modn.rs-- GenericModN<N>type for modular arithmetic over any modulus < 2^31. Variable-time operations using i64 native register arithmetic (3x faster than i128).modn_ct.rs-- Constant-time variant ofModNwith side-channel resistant operations, backed bysubtleoptimisation barriers (built withcore_hint_black_box, so the barrier is a register fence rather than a stack round-trip).ntt.rs-- Number Theoretic Transform forModN<N>polynomials (degree 256). Cooley-Tukey/Gentleman-Sande butterflies over a lazy, redundant coefficient representation, with a fully vectorised ARM64 NEON int16 backend and a portable i64 fallback. Both backends are cross-checked against each other in the test suite.dit.rs--with_dit: runs a closure with AArch64PSTATE.DITset, so the instructions the architecture lists are data-independent in timing. Used by every ML-KEM and ML-DSA entry point.keccak.rs-- Keccak-f[1600], SHA-3 and SHAKE (FIPS 202), translated from XKCP, with a two-lane FEAT_SHA3 variant on AArch64. Shared bykemanddsa.kem.rs-- ML-KEM-512 and ML-KEM-768 (FIPS 203): the NEON int16 path with inline Keccak, zeroization, stack scrub and DIT. Optional RNG-backed API behindgetrandom.dsa.rs-- Pure ML-DSA-65 (FIPS 204): a 32-bit Montgomery NTT over q = 8380417 with the FIPS 204 zetas, the pure, internal and external-μ interfaces, deterministic and hedged signing, zeroization, stack scrub and DIT. Optional RNG-backed API behindgetrandom.valgrind.rs-- Valgrind client requests (poison, declassify) for the ctgrind-style check; featurevalgrind-ct.wasm.rs-- Optional WebAssembly bindings viawasm-bindgen.
A standalone Kyber-512 KEM implementation featuring:
- ARM64 NEON int16 NTT -- Montgomery multiplication via
vmull_s16/vmovn_s32/vshrn_n_s32, processing 8 coefficients per butterfly - Inline Keccak-f[1600] -- Pure-Rust implementation translated from XKCP
- SHA3-256/512, SHAKE-128/256 -- Complete hash suite for Kyber key derivation and sampling
- Full KEM flow -- keygen, encapsulation, decapsulation with implicit rejection (FO transform)
ML-KEM-512 + AES-256-GCM, the key-encapsulation pattern used in TLS 1.3 and
Signal's PQXDH, built on the library's moduletto::kem::ml_kem_512:
cargo run --release --example hybrid_pq_aes- ML-KEM-512 establishes a 256-bit shared secret (768-byte ciphertext);
d,z,mand the GCM nonce come from the OS CSPRNG - AES-256-GCM encrypts arbitrary plaintext with the shared secret
- Tamper detection: a changed AES ciphertext fails the GCM tag; a changed KEM ciphertext yields the implicit-rejection key, so the tag fails too
- Decapsulation keys and shared secrets are held in
Zeroizingand wiped on drop
use moduletto::ModN;
// Kyber-512 modulus
type Mod3329 = ModN<3329>;
let a = Mod3329::new(1234);
let b = Mod3329::new(5678);
let c = a.ct_mul(b);
// Polynomial operations
use moduletto::ntt;
let mut poly = [Mod3329::zero(); 256];
// ... populate poly ...
// NTT-based polynomial multiplication available via ntt moduleuse moduletto::modn_ct::ModN as ModNCT;
type F = ModNCT<3329>;
let a = F::new(42);
let b = F::new(99);
// No data-dependent branches or memory access
let sum = a.ct_add(b);
let selected = F::ct_select(a, b, true); // constant-time conditional# Full Kyber-512 KEM benchmark (NEON i16 NTT on ARM64)
cargo run --release --example kyber_benchmark
# Criterion microbenchmarks for polynomial primitives
cargo bench
# no_std compatibility check
cargo test --lib --no-default-features --release
# Timing checks (see Security)
cargo run --release --features ct-bench --example ct_dudect| Feature | Description |
|---|---|
std (default) |
Standard library support; enables the kem and dsa modules |
alloc |
Heap allocation + libm for no_std math |
wasm |
WebAssembly bindings via wasm-bindgen |
getrandom |
kem::*::keygen(), encaps(ek), dsa::ml_dsa_65::keygen(), keygen_into and sign_hedged() backed by the OS CSPRNG |
valgrind-ct |
Valgrind memcheck hooks for examples/valgrind_ct.rs (x86-64 Linux); not for production builds |
ct-bench |
Exposes the public ML-DSA signing-loop count to examples/ct_dudect.rs. Not for production |
# Default (std)
moduletto = "0.1"
# no_std without allocator
moduletto = { version = "0.1", default-features = false }
# no_std with allocator
moduletto = { version = "0.1", default-features = false, features = ["alloc"] }
# WebAssembly
moduletto = { version = "0.1", features = ["wasm"] }
# ML-KEM and ML-DSA with OS randomness
moduletto = { version = "0.1", features = ["getrandom"] }- ARM64 (Apple Silicon, Cortex-A): NEON int16 NTT for Kyber, i64 scalar for generic ModN
- x86-64: i64 scalar (no SIMD NTT yet)
- WebAssembly: Supported via
wasmfeature flag
For moduli < 2^31, i64 is 3x faster than i128 on 64-bit platforms. i64 maps to native register width -- add/sub/mul are single instructions. i128 requires register pairs and multi-instruction sequences.
| Operation (n=256) | i64 (this) | i128 | Speedup |
|---|---|---|---|
| poly_add | 63 ns | ~295 ns | 3.2x |
| poly_sub | 72 ns | ~370 ns | 3.5x |
Kyber's modulus q=3329 fits in 12 bits. Using i16 coefficients with ARM64 NEON intrinsics (int16x8_t) processes 8 coefficients per vector instruction. Montgomery multiplication (fqmul) uses vmull_s16 -> vmovn_s32 -> vshrn_n_s32 to compute a*b*R^{-1} mod q entirely in NEON registers.
The arithmetic behind the constant-time layer is proved functionally correct in Coq (Rocq 9.1), with an accompanying OCaml test harness. The proofs cover what the branchless formulas compute, not the timing of compiled code (see Security).
The proofs cover the Kyber-parameter arithmetic (q = 3329) and NTT. They do
not yet cover ML-DSA: its NTT over q = 8380417, the Montgomery reduction and
the Decompose and Power2Round formulas in src/dsa.rs are checked by
unit tests instead. Those tests compare the NTT with a schoolbook product
and with Algorithm 41 as written, and compare Decompose and Power2Round
with Algorithms 36 and 35 for every input in [0, q).
# macOS (Homebrew)
brew install rocq opam
# Linux (apt) — install opam, then use it for Rocq
sudo apt install opam
opam init
opam install rocq-proverRequires Rocq/Coq >= 9.0 and OCaml >= 5.0.
# Compile all Coq proofs and run OCaml tests
cd proofs && make
# Coq proofs only (type-checks all theorems)
make coq
# OCaml runtime tests only (27,000+ test cases)
make ocaml
# Clean build artifacts
make cleanA successful make coq means every theorem has been machine-checked by the
Rocq kernel. The development uses no axioms and has no Admitted lemmas.
Build outputs (.vo, .glob, …) are not committed; make coq regenerates
them. CI builds the proofs and runs the OCaml harness on every push in a
Rocq 9.1 container (proofs job in .github/workflows/ci.yml).
ModularArithmetic.v-- Correctness of branchless CT add/sub/neg (equivalence to branching versions, correctness mod N, range closure, algebraic properties)BarrettReduction.v-- Barrett reduction producesx mod Nfor inputs < N^2, with quotient approximation bounds and Kyber-3329 instantiationConstantTime.v-- ct_select, ct_swap (XOR swap), ct_lt, ct_is_zero: functional correctness of all branchless primitivesNTT.v-- Kyber parameter verification: zeta=17 is a primitive 256th root of unity mod 3329, 128^(-1) = 3303 mod 3329, primality of 3329; additivity of the NTT evaluation map (proved by induction over the coefficient list)
test_moduletto.ml-- 27,000+ runtime tests validating VT/CT agreement, Barrett reduction, CT primitives, and NTT root-of-unity properties across sampled Kyber coefficient ranges
Coverage-guided fuzzing via cargo-fuzz (libFuzzer). Requires a nightly toolchain.
cargo install cargo-fuzz
rustup toolchain install nightly# Run a specific target (runs until stopped with Ctrl-C)
cargo +nightly fuzz run fuzz_ct_arith
# Run for a fixed duration
cargo +nightly fuzz run fuzz_barrett -- -max_total_time=60
# List all available targets
cargo +nightly fuzz list
# Run all targets for 30 seconds each
for target in $(cargo +nightly fuzz list); do
echo "=== $target ==="
cargo +nightly fuzz run "$target" -- -max_total_time=30
done| Target | What it tests |
|---|---|
fuzz_ct_arith |
CT vs VT equivalence for add/sub/mul/neg, range invariants, algebraic properties (identity, inverse, roundtrip) |
fuzz_barrett |
Barrett reduction correctness across six different moduli (7, 127, 251, 257, 3329, 65537) |
fuzz_ntt_roundtrip |
NTT -> INTT roundtrip for arbitrary polynomials, CT vs VT NTT agreement |
fuzz_ct_primitives |
ct_select, ct_swap, ct_eq, ct_lt: specification compliance, reflexivity, double-swap identity |
fuzz_poly_mul |
NTT polynomial multiplication vs schoolbook (ground truth), CT vs VT agreement |
fuzz_dsa_sig_decode |
ML-DSA-65 signature decoding (sigDecode, HintBitUnpack): arbitrary and spliced signatures verify only if they equal the real one |
fuzz_dsa_key_decode |
ML-DSA-65 key decoding: arbitrary and spliced public keys verify only if they equal the real one; spliced private keys sign deterministically without panicking |
fuzz_dsa_roundtrip |
ML-DSA-65 key generation, signing and verification from fuzzed ξ, rnd, context and message, across the pure, internal and external-μ interfaces |
CI (the fuzz job in .github/workflows/ci.yml) builds every target with
nightly-2026-10-24 and runs the three ML-DSA targets for 30 seconds each.
Crash artifacts are saved to fuzz/artifacts/<target>/ and can be replayed with:
cargo +nightly fuzz run fuzz_ct_arith fuzz/artifacts/fuzz_ct_arith/<crash-file>Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or https://opensource.org/licenses/MIT)
at your option. NOTICE carries the attribution notices that the Apache License asks redistributors to preserve, the full list of third-party material and dependencies with their licences, the licensing history, and a development disclosure.
Releases before this change were published under the PolyForm Noncommercial License 1.0.0.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.
- The inline Keccak-f[1600] in
src/keccak.rsis translated from the XKCP (eXtended Keccak Code Package), released to the public domain. - The int16 NTT constants and structure follow the pqcrystals Kyber reference implementation (CC0-1.0 or Apache-2.0).
- The ML-DSA zetas are those of FIPS 204 Appendix B. The
reduce32andDecomposeformulas insrc/dsa.rsfollow the pqcrystals Dilithium reference implementation (CC0-1.0 or Apache-2.0). tests/kat/holds NIST ACVP-Server test vectors (a US Government work).