Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/sntrup-kem.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ jobs:
- run: cargo test --no-default-features
- run: cargo test
- run: cargo test --all-features
# Stable exercises SIMD; the pre-1.95 x86 MSRV uses its scalar fallback.
- run: cargo test --features kem,serde,std
- run: cargo test --features serde,force-scalar

cross:
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions sntrup-kem/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@ authors = ["Michael Lodder <redmike7@gmail.com>"]
license = "MIT OR Apache-2.0"
keywords = ["sntrup", "kem", "post-quantum", "cryptography", "NTRU"]
description = "Pure Rust implementation of the Streamlined NTRU Prime KEM for all parameter sizes"
documentation = "https://docs.rs/sntrup-kem"
homepage = "https://github.com/RustCrypto/KEMs/tree/master/sntrup-kem"
repository = "https://github.com/RustCrypto/KEMs"
categories = ["algorithms", "cryptography"]
readme = "README.md"
edition = "2024"
rust-version = "1.85"

[package.metadata.docs.rs]
features = ["kem", "serde", "std", "alloc"]

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

[dependencies]
hex = "0.4"
rand = "0.10.0"
rand_chacha = "0.10.0"
subtle = "2"
getrandom = { version = "0.4", optional = true }
hybrid-array = { version = "0.4.14", features = ["extra-sizes"], optional = true }
kem = { version = "0.3", optional = true }
rand_core = { version = "0.10", optional = true }
serde = { version = "1", optional = true, default-features = false }
serdect = { version = "0.4", optional = true }
# sha2 0.11 dropped the `asm` feature; hardware SHA acceleration is now selected
Expand All @@ -40,9 +50,15 @@ zeroize = { version = "1", features = ["derive"] }
criterion = "0.7"
serde_json = "1"

[[example]]
name = "kem_traits"
path = "examples/kem_traits.rs"
required-features = ["kem"]

[[bench]]
name = "mod"
harness = false
required-features = ["kgen", "ecap", "dcap"]

[lints.rust]
missing_docs = "deny"
Expand Down
81 changes: 79 additions & 2 deletions sntrup-kem/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ All key and ciphertext sizes are in bytes. Sizes are fixed per parameter set usi
- All six parameter sizes: sntrup653, sntrup761, sntrup857, sntrup953, sntrup1013, sntrup1277
- IND-CCA2 secure with implicit rejection
- Constant-time operations throughout (branchless sort, constant-time comparison and selection)
- SIMD acceleration (AVX2 on x86_64, NEON on aarch64) with automatic detection
- SIMD acceleration with automatic run-time detection: AVX-512 and AVX2 (plus AVX-VNNI where present) on x86_64, NEON on aarch64
- Optional `serde` support via the `serde` feature
- Deterministic key generation from a 32-byte seed

Expand All @@ -48,10 +48,17 @@ The KEM API is split into three default features so downstream crates can pull i
| `kgen` | **yes** | Key generation: `SntrupKem::generate_key`, `SntrupKem::generate_key_deterministic` |
| `ecap` | **yes** | Encapsulation: `EncapsulationKey::encapsulate` |
| `dcap` | **yes** | Decapsulation: `DecapsulationKey::decapsulate` |
| `force-scalar` | no | Disable SIMD (AVX2/NEON) and use pure-Rust scalar code |
| `alloc` | no | Allocator-dependent APIs |
| `std` | no | Standard-library integration; implies `alloc` |
| `force-scalar` | no | Compile out every SIMD kernel and use the portable scalar code paths only |
| `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`. |
| `serde` | no | Enables `Serialize`/`Deserialize` for all key and ciphertext types (via `serdect` for constant-time hex encoding) |
| `js` | no | Enables WebAssembly support for `wasm32-unknown-unknown` by configuring `getrandom` to use JavaScript's `crypto.getRandomValues()` |

The synchronized x86_64 SIMD implementation requires Rust 1.95 or newer. Builds
with the Rust 1.85 MSRV automatically use the portable scalar paths; AArch64
builds retain NEON acceleration on Rust 1.85.

To use only a subset of the KEM API, disable defaults and pick the features you need:

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

### `kem` crate integration

With the `kem` feature enabled, the [`kem`](https://docs.rs/kem) module implements that crate's
traits for every parameter set, so Streamlined NTRU Prime can be used in generic code alongside
other KEMs. The traits and the parameter-set marker types are re-exported there, so no direct
dependency on the `kem` crate is needed:

```rust
# #[cfg(feature = "kem")] {
use sntrup_kem::kem::{Decapsulate, Encapsulate, Kem, Sntrup761Params};
use rand::SeedableRng;
use rand::rngs::{StdRng, SysRng};

let mut rng = StdRng::try_from_rng(&mut SysRng).expect("OS randomness");

let (dk, ek) = Sntrup761Params::generate_keypair_from_rng(&mut rng);
let (ct, sent) = ek.encapsulate_with_rng(&mut rng);
assert_eq!(dk.decapsulate(&ct), sent);
# }
```

Run `cargo run --release --example kem_traits --features kem` for KEM-generic code and key
export.

## WebAssembly

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

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.**

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

#### Algorithm

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.

## Performance

`cargo bench` runs this crate's Criterion suite (`benches/mod.rs`) across all six parameter
sets. The synchronized standalone implementation also has a
[comparison harness](https://github.com/mikelodder7/sntrup/tree/main/benches/comparison) for
sntrup761 — the parameter set with independent PQClean and liboqs implementations.

This crate is faster than both C references on every operation, on both
architectures, while also zeroizing every secret-derived scratch buffer — which neither C
reference does.

On x86_64 (AMD Ryzen AI 9 HX 370, Zen 5), sntrup761, against liboqs's AVX2 build:

| Operation | sntrup | liboqs | PQClean |
|-----------|-------:|-------:|--------:|
| keypair | 106.6 µs | 107.9 µs (0.99x) | 4545.7 µs (42.7x) |
| encapsulate | 10.5 µs | 11.5 µs (0.91x) | 239.1 µs (22.9x) |
| decapsulate | 8.4 µs | 8.4 µs (1.00x) | 607.0 µs (72.6x) |

On aarch64 (Apple M2 Max), sntrup761, against their portable C builds: keypair 684 µs
(2.7x), encapsulate 37.0 µs (1.40x), decapsulate 77.1 µs (1.19x).

Two things drive the x86_64 numbers. Key generation runs the Bernstein–Yang divstep inversion
through **AVX-512**, 32 coefficients per step — neither PQClean nor liboqs has a 512-bit path
for this KEM. Encapsulation and decapsulation run sntrup761's polynomial multiply as a
number-theoretic transform (Good's 3x512 decomposition over the primes 7681 and 10753,
recombined by CRT). Every other parameter set, and all of aarch64, uses a schoolbook kernel
that computes each output coefficient as a contiguous dot product spread across eight
independent widening multiply-accumulate chains (`smlal`-family on NEON, `pmaddwd`/`vpdpwssd`
on x86_64) — a shape taken from disassembling what clang's autovectorizer produces for
PQClean's reference C and then out-tuning it.

See the standalone implementation's
[benchmark results](https://github.com/mikelodder7/sntrup/blob/main/benches/comparison/RESULTS.md)
for the full investigation narrative, including machine and build details.

**A SIMD-testing gotcha every contributor should read:** `--all-features` enables
`force-scalar`, which silently compiles the SIMD kernels out of the test binary. The permanent
kernel-vs-scalar differential tests in `src/rq.rs` and `src/r3.rs` only exercise SIMD when
built with a feature set that leaves `force-scalar` off, e.g. `--features kem,serde,std`.

# License

Licensed under either of
Expand Down
2 changes: 1 addition & 1 deletion sntrup-kem/benches/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#![allow(missing_docs)]
#![allow(missing_docs, clippy::mod_module_files)]

use criterion::{Criterion, criterion_group, criterion_main};
use sntrup_kem::*;
Expand Down
36 changes: 36 additions & 0 deletions sntrup-kem/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//! Compiler-version compatibility configuration for x86 SIMD kernels.

use std::{env, process::Command};

fn rustc_version() -> Option<(u32, u32)> {
let rustc = env::var_os("RUSTC")?;
let output = Command::new(rustc).arg("--version").output().ok()?;
let stdout = String::from_utf8(output.stdout).ok()?;
let version = stdout.split_whitespace().nth(1)?;
let mut components = version.split('.');
let major = components.next()?.parse().ok()?;
let minor = components.next()?.parse().ok()?;
Some((major, minor))
}

fn main() {
println!("cargo:rerun-if-env-changed=RUSTC");
println!("cargo:rerun-if-env-changed=TARGET");

if env::var("CARGO_CFG_TARGET_ARCH").as_deref() != Ok("x86_64") {
return;
}

// AVX-512/AVX-VNNI intrinsics and the safe `target_feature` calling rules
// used by the synchronized implementation require Rust 1.95. Older x86
// compilers use the same constant-time scalar paths as `force-scalar`.
let supports_simd = match rustc_version() {
Some((1, minor)) => minor >= 95,
Some((major, _)) => major > 1,
None => false,
};

if !supports_simd {
println!("cargo:rustc-cfg=feature=\"force-scalar\"");
}
}
63 changes: 63 additions & 0 deletions sntrup-kem/examples/kem_traits.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
Copyright Michael Lodder. All Rights Reserved.
SPDX-License-Identifier: MIT OR Apache-2.0
*/
//! Streamlined NTRU Prime through the [`kem`](https://docs.rs/kem) crate traits, in code that
//! is generic over the parameter set and would work just as well over any other KEM.
//!
//! Run with:
//!
//! ```sh
//! cargo run --release --example kem_traits --features kem
//! ```

use rand::SeedableRng;
use rand::rngs::{StdRng, SysRng};
use rand_core::CryptoRng;
use sntrup_kem::kem::{
Decapsulate, DecapsulationKey, Decapsulator, Encapsulate, EncapsulationKey, Generate, Kem,
KemSizes, KeyExport, Sntrup653Params, Sntrup761Params, Sntrup1277Params, TryKeyInit,
};

/// Establish a shared secret and hand back the sizes involved.
///
/// Nothing here names Streamlined NTRU Prime: the same function compiles against any KEM
/// whose key types implement the `kem` traits.
fn round_trip<K>(mut rng: impl CryptoRng) -> (usize, usize)
where
K: KemSizes
+ Kem<EncapsulationKey = EncapsulationKey<K>, DecapsulationKey = DecapsulationKey<K>>,
{
let (dk, ek) = K::generate_keypair_from_rng(&mut rng);
let (ct, sent) = ek.encapsulate_with_rng(&mut rng);
let received = dk.decapsulate(&ct);
assert_eq!(sent, received);
(ct.len(), received.len())
}

fn main() {
// A cryptographically secure generator, seeded once from the operating system.
let mut rng = StdRng::try_from_rng(&mut SysRng).expect("the OS RNG is available");

for (name, (ct, ss)) in [
("Sntrup653Params", round_trip::<Sntrup653Params>(&mut rng)),
("Sntrup761Params", round_trip::<Sntrup761Params>(&mut rng)),
("Sntrup1277Params", round_trip::<Sntrup1277Params>(&mut rng)),
] {
println!("{name}: ciphertext {ct} bytes, shared secret {ss} bytes");
}

// Exporting or importing a key moves the whole key by value. Streamlined NTRU Prime keys
// are small (at most a few kilobytes), so unlike KEMs with megabyte-scale keys this needs
// no special thread-stack handling.
let dk = DecapsulationKey::<Sntrup761Params>::generate_from_rng(&mut rng);
let ek = dk.encapsulation_key();
let exported = ek.to_bytes();
let imported =
EncapsulationKey::<Sntrup761Params>::new(&exported).expect("exported key round-trips");
assert_eq!(&imported, dk.encapsulation_key());
println!(
"Sntrup761Params: exported and reimported {} bytes",
exported.len()
);
}
92 changes: 92 additions & 0 deletions sntrup-kem/src/cpu.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//! Runtime detection of AVX2 support, cached after the first check.
//!
//! The AVX2 kernels throughout this crate are marked `#[target_feature(enable = "avx2")]`,
//! which lets them compile regardless of the crate's ambient compilation flags. Whether to
//! *call* them is decided here, at runtime, so a default `cargo build --release` uses AVX2 on
//! any capable x86_64 host instead of silently falling back to scalar unless the caller passes
//! `RUSTFLAGS="-C target-feature=+avx2"` (or `target-cpu=native`).
//!
//! aarch64 needs no equivalent: NEON is a baseline guarantee of the architecture.

#[cfg(all(target_arch = "x86_64", not(feature = "force-scalar")))]
use core::sync::atomic::{AtomicU8, Ordering};

#[cfg(all(target_arch = "x86_64", not(feature = "force-scalar")))]
static AVX2_STATE: AtomicU8 = AtomicU8::new(0);

#[cfg(all(target_arch = "x86_64", not(feature = "force-scalar")))]
static AVXVNNI_STATE: AtomicU8 = AtomicU8::new(0);

#[cfg(all(target_arch = "x86_64", not(feature = "force-scalar")))]
static AVX512_STATE: AtomicU8 = AtomicU8::new(0);

/// Returns `true` if the host CPU supports AVX2.
#[cfg(all(target_arch = "x86_64", not(feature = "force-scalar")))]
#[inline]
pub(crate) fn has_avx2() -> bool {
match AVX2_STATE.load(Ordering::Relaxed) {
1 => true,
2 => false,
_ => {
let detected = std::is_x86_feature_detected!("avx2");
AVX2_STATE.store(if detected { 1 } else { 2 }, Ordering::Relaxed);
detected
}
}
}

/// Returns `true` if the host CPU supports AVX-VNNI (the VEX-encoded `vpdpwssd`
/// family — Zen 5, Alder Lake and later).
#[cfg(all(target_arch = "x86_64", not(feature = "force-scalar")))]
#[inline]
pub(crate) fn has_avxvnni() -> bool {
match AVXVNNI_STATE.load(Ordering::Relaxed) {
1 => true,
2 => false,
_ => {
let detected = std::is_x86_feature_detected!("avxvnni");
AVXVNNI_STATE.store(if detected { 1 } else { 2 }, Ordering::Relaxed);
detected
}
}
}

/// Returns `true` if the host CPU supports the AVX-512 subsets the 512-bit
/// kernels use: `F` for the base instruction set, `BW` for 16-bit lane
/// arithmetic and `VL` so 256-bit forms remain available alongside.
#[cfg(all(target_arch = "x86_64", not(feature = "force-scalar")))]
#[inline]
pub(crate) fn has_avx512() -> bool {
match AVX512_STATE.load(Ordering::Relaxed) {
1 => true,
2 => false,
_ => {
let detected = std::is_x86_feature_detected!("avx512f")
&& std::is_x86_feature_detected!("avx512bw")
&& std::is_x86_feature_detected!("avx512vl");
AVX512_STATE.store(if detected { 1 } else { 2 }, Ordering::Relaxed);
detected
}
}
}

#[cfg(all(test, target_arch = "x86_64", not(feature = "force-scalar")))]
mod tests {
use super::*;

#[test]
fn detection_is_cached_and_consistent() {
let first = has_avx2();
for _ in 0..8 {
assert_eq!(has_avx2(), first);
}
let first = has_avxvnni();
for _ in 0..8 {
assert_eq!(has_avxvnni(), first);
}
let first = has_avx512();
for _ in 0..8 {
assert_eq!(has_avx512(), first);
}
}
}
Loading