Skip to content

Commit b670a38

Browse files
committed
sntrup with kem and optimizations
Signed-off-by: Mike Lodder <redmike7@gmail.com>
1 parent c78ba44 commit b670a38

29 files changed

Lines changed: 8242 additions & 683 deletions

.github/workflows/sntrup-kem.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ jobs:
5353
- run: cargo test --no-default-features
5454
- run: cargo test
5555
- run: cargo test --all-features
56+
# Stable exercises SIMD; the pre-1.95 x86 MSRV uses its scalar fallback.
57+
- run: cargo test --features kem,serde,std
5658
- run: cargo test --features serde,force-scalar
5759

5860
cross:

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sntrup-kem/Cargo.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,16 @@ authors = ["Michael Lodder <redmike7@gmail.com>"]
55
license = "MIT OR Apache-2.0"
66
keywords = ["sntrup", "kem", "post-quantum", "cryptography", "NTRU"]
77
description = "Pure Rust implementation of the Streamlined NTRU Prime KEM for all parameter sizes"
8+
documentation = "https://docs.rs/sntrup-kem"
89
homepage = "https://github.com/RustCrypto/KEMs/tree/master/sntrup-kem"
910
repository = "https://github.com/RustCrypto/KEMs"
1011
categories = ["algorithms", "cryptography"]
1112
readme = "README.md"
1213
edition = "2024"
14+
rust-version = "1.85"
15+
16+
[package.metadata.docs.rs]
17+
features = ["kem", "serde", "std", "alloc"]
1318

1419
[features]
1520
default = ["kgen", "ecap", "dcap"]
@@ -21,13 +26,18 @@ force-scalar = []
2126
std = []
2227
serde = ["dep:serdect", "dep:serde"]
2328
js = ["getrandom/wasm_js"]
29+
# Implementations of the `kem` crate traits, covering all three operations at once.
30+
kem = ["dep:kem", "dep:hybrid-array", "dep:rand_core", "kgen", "ecap", "dcap"]
2431

2532
[dependencies]
2633
hex = "0.4"
2734
rand = "0.10.0"
2835
rand_chacha = "0.10.0"
2936
subtle = "2"
3037
getrandom = { version = "0.4", optional = true }
38+
hybrid-array = { version = "0.4.14", features = ["extra-sizes"], optional = true }
39+
kem = { version = "0.3", optional = true }
40+
rand_core = { version = "0.10", optional = true }
3141
serde = { version = "1", optional = true, default-features = false }
3242
serdect = { version = "0.4", optional = true }
3343
# sha2 0.11 dropped the `asm` feature; hardware SHA acceleration is now selected
@@ -40,9 +50,15 @@ zeroize = { version = "1", features = ["derive"] }
4050
criterion = "0.7"
4151
serde_json = "1"
4252

53+
[[example]]
54+
name = "kem_traits"
55+
path = "examples/kem_traits.rs"
56+
required-features = ["kem"]
57+
4358
[[bench]]
4459
name = "mod"
4560
harness = false
61+
required-features = ["kgen", "ecap", "dcap"]
4662

4763
[lints.rust]
4864
missing_docs = "deny"

sntrup-kem/README.md

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ All key and ciphertext sizes are in bytes. Sizes are fixed per parameter set usi
3535
- All six parameter sizes: sntrup653, sntrup761, sntrup857, sntrup953, sntrup1013, sntrup1277
3636
- IND-CCA2 secure with implicit rejection
3737
- Constant-time operations throughout (branchless sort, constant-time comparison and selection)
38-
- SIMD acceleration (AVX2 on x86_64, NEON on aarch64) with automatic detection
38+
- SIMD acceleration with automatic run-time detection: AVX-512 and AVX2 (plus AVX-VNNI where present) on x86_64, NEON on aarch64
3939
- Optional `serde` support via the `serde` feature
4040
- Deterministic key generation from a 32-byte seed
4141

@@ -48,10 +48,17 @@ The KEM API is split into three default features so downstream crates can pull i
4848
| `kgen` | **yes** | Key generation: `SntrupKem::generate_key`, `SntrupKem::generate_key_deterministic` |
4949
| `ecap` | **yes** | Encapsulation: `EncapsulationKey::encapsulate` |
5050
| `dcap` | **yes** | Decapsulation: `DecapsulationKey::decapsulate` |
51-
| `force-scalar` | no | Disable SIMD (AVX2/NEON) and use pure-Rust scalar code |
51+
| `alloc` | no | Allocator-dependent APIs |
52+
| `std` | no | Standard-library integration; implies `alloc` |
53+
| `force-scalar` | no | Compile out every SIMD kernel and use the portable scalar code paths only |
54+
| `kem` | no | Implements the [`kem`](https://docs.rs/kem) crate's traits (`Encapsulate`, `Decapsulate`, `Kem`, ...) so this crate can be used generically alongside other KEMs. See [`sntrup_kem::kem`](src/kem.rs) and `examples/kem_traits.rs`. |
5255
| `serde` | no | Enables `Serialize`/`Deserialize` for all key and ciphertext types (via `serdect` for constant-time hex encoding) |
5356
| `js` | no | Enables WebAssembly support for `wasm32-unknown-unknown` by configuring `getrandom` to use JavaScript's `crypto.getRandomValues()` |
5457

58+
The synchronized x86_64 SIMD implementation requires Rust 1.95 or newer. Builds
59+
with the Rust 1.85 MSRV automatically use the portable scalar paths; AArch64
60+
builds retain NEON acceleration on Rust 1.85.
61+
5562
To use only a subset of the KEM API, disable defaults and pick the features you need:
5663

5764
```toml
@@ -175,6 +182,30 @@ let ek2 = EncapsulationKey::<Sntrup761Params>::try_from(ek_bytes).unwrap();
175182
assert_eq!(ek, ek2);
176183
```
177184

185+
### `kem` crate integration
186+
187+
With the `kem` feature enabled, the [`kem`](https://docs.rs/kem) module implements that crate's
188+
traits for every parameter set, so Streamlined NTRU Prime can be used in generic code alongside
189+
other KEMs. The traits and the parameter-set marker types are re-exported there, so no direct
190+
dependency on the `kem` crate is needed:
191+
192+
```rust
193+
# #[cfg(feature = "kem")] {
194+
use sntrup_kem::kem::{Decapsulate, Encapsulate, Kem, Sntrup761Params};
195+
use rand::SeedableRng;
196+
use rand::rngs::{StdRng, SysRng};
197+
198+
let mut rng = StdRng::try_from_rng(&mut SysRng).expect("OS randomness");
199+
200+
let (dk, ek) = Sntrup761Params::generate_keypair_from_rng(&mut rng);
201+
let (ct, sent) = ek.encapsulate_with_rng(&mut rng);
202+
assert_eq!(dk.decapsulate(&ct), sent);
203+
# }
204+
```
205+
206+
Run `cargo run --release --example kem_traits --features kem` for KEM-generic code and key
207+
export.
208+
178209
## WebAssembly
179210

180211
To compile for `wasm32-unknown-unknown`, enable the `js` feature so that `getrandom` uses JavaScript's `crypto.getRandomValues()` for randomness:
@@ -206,10 +237,56 @@ For `wasm32-wasi` (or `wasm32-wasip1`), the `js` feature is **not** needed since
206237

207238
This implementation has not undergone any security auditing and while care has been taken no guarantees can be made for either correctness or the constant time running of the underlying functions. **Please use at your own risk.**
208239

240+
Secret-derived heap temporaries (multiply scratch, Euclidean-inversion state, sampling
241+
randomness, hash intermediates) are wiped with the [`zeroize`](https://docs.rs/zeroize) crate
242+
before being freed. One documented exception: `generate_key_deterministic`'s ChaCha20 RNG state
243+
cannot be wiped because `rand_chacha` offers no zeroization support.
244+
209245
#### Algorithm
210246

211247
Streamlined NTRU Prime was first published in 2016. The algorithm still requires careful security review. Please see [here](https://ntruprime.cr.yp.to/warnings.html) for further warnings from the authors regarding NTRU Prime and lattice-based encryption schemes.
212248

249+
## Performance
250+
251+
`cargo bench` runs this crate's Criterion suite (`benches/mod.rs`) across all six parameter
252+
sets. The synchronized standalone implementation also has a
253+
[comparison harness](https://github.com/mikelodder7/sntrup/tree/main/benches/comparison) for
254+
sntrup761 — the parameter set with independent PQClean and liboqs implementations.
255+
256+
This crate is faster than both C references on every operation, on both
257+
architectures, while also zeroizing every secret-derived scratch buffer — which neither C
258+
reference does.
259+
260+
On x86_64 (AMD Ryzen AI 9 HX 370, Zen 5), sntrup761, against liboqs's AVX2 build:
261+
262+
| Operation | sntrup | liboqs | PQClean |
263+
|-----------|-------:|-------:|--------:|
264+
| keypair | 106.6 µs | 107.9 µs (0.99x) | 4545.7 µs (42.7x) |
265+
| encapsulate | 10.5 µs | 11.5 µs (0.91x) | 239.1 µs (22.9x) |
266+
| decapsulate | 8.4 µs | 8.4 µs (1.00x) | 607.0 µs (72.6x) |
267+
268+
On aarch64 (Apple M2 Max), sntrup761, against their portable C builds: keypair 684 µs
269+
(2.7x), encapsulate 37.0 µs (1.40x), decapsulate 77.1 µs (1.19x).
270+
271+
Two things drive the x86_64 numbers. Key generation runs the Bernstein–Yang divstep inversion
272+
through **AVX-512**, 32 coefficients per step — neither PQClean nor liboqs has a 512-bit path
273+
for this KEM. Encapsulation and decapsulation run sntrup761's polynomial multiply as a
274+
number-theoretic transform (Good's 3x512 decomposition over the primes 7681 and 10753,
275+
recombined by CRT). Every other parameter set, and all of aarch64, uses a schoolbook kernel
276+
that computes each output coefficient as a contiguous dot product spread across eight
277+
independent widening multiply-accumulate chains (`smlal`-family on NEON, `pmaddwd`/`vpdpwssd`
278+
on x86_64) — a shape taken from disassembling what clang's autovectorizer produces for
279+
PQClean's reference C and then out-tuning it.
280+
281+
See the standalone implementation's
282+
[benchmark results](https://github.com/mikelodder7/sntrup/blob/main/benches/comparison/RESULTS.md)
283+
for the full investigation narrative, including machine and build details.
284+
285+
**A SIMD-testing gotcha every contributor should read:** `--all-features` enables
286+
`force-scalar`, which silently compiles the SIMD kernels out of the test binary. The permanent
287+
kernel-vs-scalar differential tests in `src/rq.rs` and `src/r3.rs` only exercise SIMD when
288+
built with a feature set that leaves `force-scalar` off, e.g. `--features kem,serde,std`.
289+
213290
# License
214291

215292
Licensed under either of

sntrup-kem/benches/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#![allow(missing_docs)]
1+
#![allow(missing_docs, clippy::mod_module_files)]
22

33
use criterion::{Criterion, criterion_group, criterion_main};
44
use sntrup_kem::*;

sntrup-kem/build.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
//! Compiler-version compatibility configuration for x86 SIMD kernels.
2+
3+
use std::{env, process::Command};
4+
5+
fn rustc_version() -> Option<(u32, u32)> {
6+
let rustc = env::var_os("RUSTC")?;
7+
let output = Command::new(rustc).arg("--version").output().ok()?;
8+
let stdout = String::from_utf8(output.stdout).ok()?;
9+
let version = stdout.split_whitespace().nth(1)?;
10+
let mut components = version.split('.');
11+
let major = components.next()?.parse().ok()?;
12+
let minor = components.next()?.parse().ok()?;
13+
Some((major, minor))
14+
}
15+
16+
fn main() {
17+
println!("cargo:rerun-if-env-changed=RUSTC");
18+
println!("cargo:rerun-if-env-changed=TARGET");
19+
20+
if env::var("CARGO_CFG_TARGET_ARCH").as_deref() != Ok("x86_64") {
21+
return;
22+
}
23+
24+
// AVX-512/AVX-VNNI intrinsics and the safe `target_feature` calling rules
25+
// used by the synchronized implementation require Rust 1.95. Older x86
26+
// compilers use the same constant-time scalar paths as `force-scalar`.
27+
let supports_simd = match rustc_version() {
28+
Some((1, minor)) => minor >= 95,
29+
Some((major, _)) => major > 1,
30+
None => false,
31+
};
32+
33+
if !supports_simd {
34+
println!("cargo:rustc-cfg=feature=\"force-scalar\"");
35+
}
36+
}

sntrup-kem/examples/kem_traits.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
Copyright Michael Lodder. All Rights Reserved.
3+
SPDX-License-Identifier: MIT OR Apache-2.0
4+
*/
5+
//! Streamlined NTRU Prime through the [`kem`](https://docs.rs/kem) crate traits, in code that
6+
//! is generic over the parameter set and would work just as well over any other KEM.
7+
//!
8+
//! Run with:
9+
//!
10+
//! ```sh
11+
//! cargo run --release --example kem_traits --features kem
12+
//! ```
13+
14+
use rand::SeedableRng;
15+
use rand::rngs::{StdRng, SysRng};
16+
use rand_core::CryptoRng;
17+
use sntrup_kem::kem::{
18+
Decapsulate, DecapsulationKey, Decapsulator, Encapsulate, EncapsulationKey, Generate, Kem,
19+
KemSizes, KeyExport, Sntrup653Params, Sntrup761Params, Sntrup1277Params, TryKeyInit,
20+
};
21+
22+
/// Establish a shared secret and hand back the sizes involved.
23+
///
24+
/// Nothing here names Streamlined NTRU Prime: the same function compiles against any KEM
25+
/// whose key types implement the `kem` traits.
26+
fn round_trip<K>(mut rng: impl CryptoRng) -> (usize, usize)
27+
where
28+
K: KemSizes
29+
+ Kem<EncapsulationKey = EncapsulationKey<K>, DecapsulationKey = DecapsulationKey<K>>,
30+
{
31+
let (dk, ek) = K::generate_keypair_from_rng(&mut rng);
32+
let (ct, sent) = ek.encapsulate_with_rng(&mut rng);
33+
let received = dk.decapsulate(&ct);
34+
assert_eq!(sent, received);
35+
(ct.len(), received.len())
36+
}
37+
38+
fn main() {
39+
// A cryptographically secure generator, seeded once from the operating system.
40+
let mut rng = StdRng::try_from_rng(&mut SysRng).expect("the OS RNG is available");
41+
42+
for (name, (ct, ss)) in [
43+
("Sntrup653Params", round_trip::<Sntrup653Params>(&mut rng)),
44+
("Sntrup761Params", round_trip::<Sntrup761Params>(&mut rng)),
45+
("Sntrup1277Params", round_trip::<Sntrup1277Params>(&mut rng)),
46+
] {
47+
println!("{name}: ciphertext {ct} bytes, shared secret {ss} bytes");
48+
}
49+
50+
// Exporting or importing a key moves the whole key by value. Streamlined NTRU Prime keys
51+
// are small (at most a few kilobytes), so unlike KEMs with megabyte-scale keys this needs
52+
// no special thread-stack handling.
53+
let dk = DecapsulationKey::<Sntrup761Params>::generate_from_rng(&mut rng);
54+
let ek = dk.encapsulation_key();
55+
let exported = ek.to_bytes();
56+
let imported =
57+
EncapsulationKey::<Sntrup761Params>::new(&exported).expect("exported key round-trips");
58+
assert_eq!(&imported, dk.encapsulation_key());
59+
println!(
60+
"Sntrup761Params: exported and reimported {} bytes",
61+
exported.len()
62+
);
63+
}

sntrup-kem/src/cpu.rs

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
//! Runtime detection of AVX2 support, cached after the first check.
2+
//!
3+
//! The AVX2 kernels throughout this crate are marked `#[target_feature(enable = "avx2")]`,
4+
//! which lets them compile regardless of the crate's ambient compilation flags. Whether to
5+
//! *call* them is decided here, at runtime, so a default `cargo build --release` uses AVX2 on
6+
//! any capable x86_64 host instead of silently falling back to scalar unless the caller passes
7+
//! `RUSTFLAGS="-C target-feature=+avx2"` (or `target-cpu=native`).
8+
//!
9+
//! aarch64 needs no equivalent: NEON is a baseline guarantee of the architecture.
10+
11+
#[cfg(all(target_arch = "x86_64", not(feature = "force-scalar")))]
12+
use core::sync::atomic::{AtomicU8, Ordering};
13+
14+
#[cfg(all(target_arch = "x86_64", not(feature = "force-scalar")))]
15+
static AVX2_STATE: AtomicU8 = AtomicU8::new(0);
16+
17+
#[cfg(all(target_arch = "x86_64", not(feature = "force-scalar")))]
18+
static AVXVNNI_STATE: AtomicU8 = AtomicU8::new(0);
19+
20+
#[cfg(all(target_arch = "x86_64", not(feature = "force-scalar")))]
21+
static AVX512_STATE: AtomicU8 = AtomicU8::new(0);
22+
23+
/// Returns `true` if the host CPU supports AVX2.
24+
#[cfg(all(target_arch = "x86_64", not(feature = "force-scalar")))]
25+
#[inline]
26+
pub(crate) fn has_avx2() -> bool {
27+
match AVX2_STATE.load(Ordering::Relaxed) {
28+
1 => true,
29+
2 => false,
30+
_ => {
31+
let detected = std::is_x86_feature_detected!("avx2");
32+
AVX2_STATE.store(if detected { 1 } else { 2 }, Ordering::Relaxed);
33+
detected
34+
}
35+
}
36+
}
37+
38+
/// Returns `true` if the host CPU supports AVX-VNNI (the VEX-encoded `vpdpwssd`
39+
/// family — Zen 5, Alder Lake and later).
40+
#[cfg(all(target_arch = "x86_64", not(feature = "force-scalar")))]
41+
#[inline]
42+
pub(crate) fn has_avxvnni() -> bool {
43+
match AVXVNNI_STATE.load(Ordering::Relaxed) {
44+
1 => true,
45+
2 => false,
46+
_ => {
47+
let detected = std::is_x86_feature_detected!("avxvnni");
48+
AVXVNNI_STATE.store(if detected { 1 } else { 2 }, Ordering::Relaxed);
49+
detected
50+
}
51+
}
52+
}
53+
54+
/// Returns `true` if the host CPU supports the AVX-512 subsets the 512-bit
55+
/// kernels use: `F` for the base instruction set, `BW` for 16-bit lane
56+
/// arithmetic and `VL` so 256-bit forms remain available alongside.
57+
#[cfg(all(target_arch = "x86_64", not(feature = "force-scalar")))]
58+
#[inline]
59+
pub(crate) fn has_avx512() -> bool {
60+
match AVX512_STATE.load(Ordering::Relaxed) {
61+
1 => true,
62+
2 => false,
63+
_ => {
64+
let detected = std::is_x86_feature_detected!("avx512f")
65+
&& std::is_x86_feature_detected!("avx512bw")
66+
&& std::is_x86_feature_detected!("avx512vl");
67+
AVX512_STATE.store(if detected { 1 } else { 2 }, Ordering::Relaxed);
68+
detected
69+
}
70+
}
71+
}
72+
73+
#[cfg(all(test, target_arch = "x86_64", not(feature = "force-scalar")))]
74+
mod tests {
75+
use super::*;
76+
77+
#[test]
78+
fn detection_is_cached_and_consistent() {
79+
let first = has_avx2();
80+
for _ in 0..8 {
81+
assert_eq!(has_avx2(), first);
82+
}
83+
let first = has_avxvnni();
84+
for _ in 0..8 {
85+
assert_eq!(has_avxvnni(), first);
86+
}
87+
let first = has_avx512();
88+
for _ in 0..8 {
89+
assert_eq!(has_avx512(), first);
90+
}
91+
}
92+
}

0 commit comments

Comments
 (0)