Skip to content

Commit 7923c17

Browse files
committed
simd: add ndarray::simd::chacha20_block — the crypto ARX hardware-accel primitive
The load-bearing piece of the ogar-crypto-SIMD unification: the ChaCha20 block function as an ndarray::simd primitive, so the Ada `encryption` crate (XChaCha20-Poly1305) can draw its hot-path keystream from the ONE SIMD polyfill (server AVX-512 / browser wasm128) per the workspace "all SIMD from ndarray::simd" invariant, instead of a per-consumer copy. Why safe to hand-vectorize (unlike AES): ChaCha20 is ARX — every op is a 32-bit wrapping_add / xor / fixed rotate_left, with NO secret-dependent branch or memory index, so the block function is constant-time by construction and a straight-line SIMD form preserves that (the reason RFC 8439 chose it for constant-time software). This rev lands the SCALAR reference + the correctness anchor: - `chacha20_block(&[u32;16]) -> [u8;64]` (RFC 8439 §2.3.1, 20-round double-round). - `chacha20_state(key, counter, nonce)` state assembler. - RFC 8439 §2.3.2 block KAT + §2.1.1 quarter-round KAT — both green. The AVX-512 (4-way) / wasm128 / NEON backends are the next increment behind the SAME signature, each a drop-in that MUST reproduce this KAT (parity gate) — "reference + KAT, then vectorize with parity", the W1a discipline. clippy clean.
1 parent 9c41b3a commit 7923c17

2 files changed

Lines changed: 170 additions & 0 deletions

File tree

src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,13 @@ pub(crate) mod simd_avx512;
246246
#[allow(clippy::all, missing_docs, dead_code, unused_variables, unused_imports)]
247247
pub mod simd_avx2;
248248

249+
// Crypto ARX primitives (ChaCha20 block; Poly1305 / Blake2 to follow) — the
250+
// hardware-acceleration layer the Ada `encryption` crate draws its hot-path
251+
// keystream from. Scalar reference + RFC 8439 KAT land first (arch-agnostic,
252+
// no cfg gate); the AVX-512 / wasm128 / NEON vectorized backends slot in behind
253+
// the same signature, parity-checked against the KAT (W1a contract).
254+
pub mod simd_crypto;
255+
249256
// Portable-SIMD backend — nightly-only. Wraps `core::simd::*` so miri can
250257
// execute the polyfill paths (intrinsic-based backends are opaque to
251258
// miri). Gated behind `nightly-simd` feature; the file itself requires

src/simd_crypto.rs

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
//! Crypto ARX primitives for the `ndarray::simd` polyfill — the hardware
2+
//! acceleration layer the Ada `encryption` crate (Argon2id / XChaCha20-Poly1305
3+
//! / Ed25519 / SHA-384) draws its hot-path keystream from.
4+
//!
5+
//! # Why these live in `ndarray::simd`
6+
//!
7+
//! The workspace invariant (`AdaWorldAPI/lance-graph` `simd-savant`): **all SIMD
8+
//! comes from `ndarray::simd` via the polyfill — `simd.rs` + `simd_ops.rs` >
9+
//! `simd_{arch}.rs`**. The crypto keystream is a SIMD hot path like any other, so
10+
//! its accelerated form belongs here (server AVX-512 / browser wasm128), not
11+
//! re-hidden inside a per-consumer copy. The consumer-facing crypto surface
12+
//! (`ogar-encryption` → the `encryption` crate) composes AEAD/KDF framing on top;
13+
//! this module supplies the vectorizable core.
14+
//!
15+
//! # Why a SIMD ChaCha20 is *safe* to hand-vectorize (unlike, say, AES)
16+
//!
17+
//! ChaCha20 is an **ARX** cipher: every operation is a 32-bit `wrapping_add`,
18+
//! `xor`, or fixed-distance `rotate_left`. There are **no secret-dependent
19+
//! branches and no secret-dependent memory indices** (no S-boxes, no T-tables),
20+
//! so the block function is **constant-time by construction** — a property a
21+
//! straight-line SIMD implementation preserves automatically. That is exactly
22+
//! why RFC 8439 chose ChaCha20 for constant-time software, and why an
23+
//! `ndarray::simd` vectorization does not introduce a timing side channel the
24+
//! scalar path lacked. (AES in software, by contrast, is NOT safe to hand-roll
25+
//! this way — its table lookups are secret-indexed; that is deliberately out of
26+
//! scope here, and browsers already expose hardware AES via WebCrypto.)
27+
//!
28+
//! # Backend ladder (W1a consumer contract)
29+
//!
30+
//! | Backend | ChaCha20 block strategy |
31+
//! |------------------|----------------------------------------------------------|
32+
//! | scalar (this rev)| the RFC 8439 reference double-round — the CORRECTNESS anchor |
33+
//! | AVX-512 (server) | 4 states in parallel across the 4 columns/diagonals (next) |
34+
//! | wasm128 (browser)| v128 `u32x4` lanes, one state, SIMD quarter-round (next) |
35+
//! | NEON (edge) | `uint32x4_t` lanes (next) |
36+
//!
37+
//! The scalar reference lands FIRST with the RFC 8439 §2.3.2 Known-Answer-Test:
38+
//! every future vectorized backend is a drop-in that MUST reproduce this exact
39+
//! keystream (parity test), so correctness is pinned before performance. This is
40+
//! the "reference + KAT, then vectorize with parity" discipline the W1a contract
41+
//! mandates for any new `ndarray::simd` primitive.
42+
43+
/// The ChaCha20 constants — the ASCII of `"expand 32-byte k"` as four
44+
/// little-endian `u32` words (RFC 8439 §2.3).
45+
const CHACHA20_CONSTANTS: [u32; 4] = [0x6170_7865, 0x3320_646e, 0x7962_2d32, 0x6b20_6574];
46+
47+
/// One ChaCha20 quarter-round on four working words (RFC 8439 §2.1). Pure ARX:
48+
/// `wrapping_add` / `xor` / `rotate_left` — no branch, no memory index, so it is
49+
/// constant-time regardless of the (secret) word values.
50+
#[inline(always)]
51+
fn quarter_round(state: &mut [u32; 16], a: usize, b: usize, c: usize, d: usize) {
52+
state[a] = state[a].wrapping_add(state[b]);
53+
state[d] = (state[d] ^ state[a]).rotate_left(16);
54+
state[c] = state[c].wrapping_add(state[d]);
55+
state[b] = (state[b] ^ state[c]).rotate_left(12);
56+
state[a] = state[a].wrapping_add(state[b]);
57+
state[d] = (state[d] ^ state[a]).rotate_left(8);
58+
state[c] = state[c].wrapping_add(state[d]);
59+
state[b] = (state[b] ^ state[c]).rotate_left(7);
60+
}
61+
62+
/// Compute one 64-byte ChaCha20 keystream block from a fully-populated 16-word
63+
/// input `state` (RFC 8439 §2.3.1): 20 rounds (10 column + diagonal
64+
/// double-rounds), then add the original input state, then serialize each word
65+
/// little-endian.
66+
///
67+
/// `state` is the caller-assembled block state — words `0..4` the constants,
68+
/// `4..12` the 256-bit key, word `12` the block counter, `13..16` the 96-bit
69+
/// nonce. Building that state (and the XChaCha `HChaCha20` nonce extension) is
70+
/// the caller's job; this is the pure, vectorizable block core.
71+
///
72+
/// **Constant-time:** straight-line ARX, no data-dependent control flow.
73+
/// This scalar form is the reference every SIMD backend is parity-checked
74+
/// against (see the module KAT).
75+
#[must_use]
76+
pub fn chacha20_block(state: &[u32; 16]) -> [u8; 64] {
77+
let mut w = *state;
78+
// 10 double-rounds = 20 rounds.
79+
for _ in 0..10 {
80+
// Column round.
81+
quarter_round(&mut w, 0, 4, 8, 12);
82+
quarter_round(&mut w, 1, 5, 9, 13);
83+
quarter_round(&mut w, 2, 6, 10, 14);
84+
quarter_round(&mut w, 3, 7, 11, 15);
85+
// Diagonal round.
86+
quarter_round(&mut w, 0, 5, 10, 15);
87+
quarter_round(&mut w, 1, 6, 11, 12);
88+
quarter_round(&mut w, 2, 7, 8, 13);
89+
quarter_round(&mut w, 3, 4, 9, 14);
90+
}
91+
let mut out = [0u8; 64];
92+
for (i, word) in w.iter().enumerate() {
93+
let sum = word.wrapping_add(state[i]);
94+
out[i * 4..i * 4 + 4].copy_from_slice(&sum.to_le_bytes());
95+
}
96+
out
97+
}
98+
99+
/// Assemble a ChaCha20 block state from a 256-bit `key`, a 32-bit block
100+
/// `counter`, and a 96-bit `nonce` (RFC 8439 §2.3). Little-endian word packing.
101+
/// Convenience for callers and for the KAT; the vectorized backends operate on
102+
/// the assembled `[u32; 16]` from [`chacha20_block`].
103+
#[must_use]
104+
pub fn chacha20_state(key: &[u8; 32], counter: u32, nonce: &[u8; 12]) -> [u32; 16] {
105+
let mut s = [0u32; 16];
106+
s[0..4].copy_from_slice(&CHACHA20_CONSTANTS);
107+
for i in 0..8 {
108+
s[4 + i] = u32::from_le_bytes([key[i * 4], key[i * 4 + 1], key[i * 4 + 2], key[i * 4 + 3]]);
109+
}
110+
s[12] = counter;
111+
for i in 0..3 {
112+
s[13 + i] = u32::from_le_bytes([nonce[i * 4], nonce[i * 4 + 1], nonce[i * 4 + 2], nonce[i * 4 + 3]]);
113+
}
114+
s
115+
}
116+
117+
#[cfg(test)]
118+
mod tests {
119+
use super::*;
120+
121+
/// RFC 8439 §2.3.2 Known-Answer-Test: the canonical ChaCha20 block. Key =
122+
/// `00,01,…,1f`, block counter = 1, nonce = `00 00 00 09 00 00 00 4a 00 00
123+
/// 00 00`. This pins the scalar reference; every SIMD backend added later
124+
/// MUST reproduce this exact 64-byte keystream (the parity gate).
125+
#[test]
126+
fn chacha20_block_rfc8439_kat() {
127+
let mut key = [0u8; 32];
128+
for (i, b) in key.iter_mut().enumerate() {
129+
*b = i as u8;
130+
}
131+
let nonce: [u8; 12] = [0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00, 0x4a, 0x00, 0x00, 0x00, 0x00];
132+
let state = chacha20_state(&key, 1, &nonce);
133+
let block = chacha20_block(&state);
134+
135+
// RFC 8439 §2.3.2 serialized keystream (64 bytes).
136+
let expected: [u8; 64] = [
137+
0x10, 0xf1, 0xe7, 0xe4, 0xd1, 0x3b, 0x59, 0x15, 0x50, 0x0f, 0xdd, 0x1f, 0xa3, 0x20, 0x71, 0xc4, 0xc7, 0xd1,
138+
0xf4, 0xc7, 0x33, 0xc0, 0x68, 0x03, 0x04, 0x22, 0xaa, 0x9a, 0xc3, 0xd4, 0x6c, 0x4e, 0xd2, 0x82, 0x64, 0x46,
139+
0x07, 0x9f, 0xaa, 0x09, 0x14, 0xc2, 0xd7, 0x05, 0xd9, 0x8b, 0x02, 0xa2, 0xb5, 0x12, 0x9c, 0xd1, 0xde, 0x16,
140+
0x4e, 0xb9, 0xcb, 0xd0, 0x83, 0xe8, 0xa2, 0x50, 0x3c, 0x4e,
141+
];
142+
assert_eq!(block, expected, "ChaCha20 block must match RFC 8439 §2.3.2");
143+
}
144+
145+
/// The quarter-round test vector (RFC 8439 §2.1.1) — the ARX core in
146+
/// isolation, so a backend can be debugged at the round level.
147+
#[test]
148+
fn quarter_round_rfc8439_vector() {
149+
// §2.1.1: inputs a,b,c,d and expected outputs, placed in a 16-word state
150+
// at indices 0..4 so `quarter_round` operates on them.
151+
let mut s = [0u32; 16];
152+
s[0] = 0x1111_1111;
153+
s[1] = 0x0102_0304;
154+
s[2] = 0x9b8d_6f43;
155+
s[3] = 0x0123_4567;
156+
quarter_round(&mut s, 0, 1, 2, 3);
157+
assert_eq!(
158+
[s[0], s[1], s[2], s[3]],
159+
[0xea2a_92f4, 0xcb1c_f8ce, 0x4581_472e, 0x5881_c4bb],
160+
"quarter-round must match RFC 8439 §2.1.1"
161+
);
162+
}
163+
}

0 commit comments

Comments
 (0)