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