From db6f4472e25911a324211736b36a05438e5e3761 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Mon, 31 Aug 2026 16:20:10 +0700 Subject: [PATCH 1/7] Added all src files for aes-lowmemory (#98) --- crypto/aes-lowmemory/src/aes.rs | 276 +++++++++++++++ crypto/aes-lowmemory/src/bitslice.rs | 210 +++++++++++ crypto/aes-lowmemory/src/lib.rs | 175 +++++++++ crypto/aes-lowmemory/src/round.rs | 507 +++++++++++++++++++++++++++ crypto/aes-lowmemory/src/sbox.rs | 381 ++++++++++++++++++++ crypto/aes-lowmemory/src/schedule.rs | 461 ++++++++++++++++++++++++ 6 files changed, 2010 insertions(+) create mode 100644 crypto/aes-lowmemory/src/aes.rs create mode 100644 crypto/aes-lowmemory/src/bitslice.rs create mode 100644 crypto/aes-lowmemory/src/lib.rs create mode 100644 crypto/aes-lowmemory/src/round.rs create mode 100644 crypto/aes-lowmemory/src/sbox.rs create mode 100644 crypto/aes-lowmemory/src/schedule.rs diff --git a/crypto/aes-lowmemory/src/aes.rs b/crypto/aes-lowmemory/src/aes.rs new file mode 100644 index 00000000..b1003cff --- /dev/null +++ b/crypto/aes-lowmemory/src/aes.rs @@ -0,0 +1,276 @@ +//! CIPHER() and INVCIPHER() (FIPS 197 Sec 5.1 and Sec 5.3), and the public engine types. + +use crate::bitslice::{Block, Planes, pack, unpack}; +use crate::round::{add_round_key, inv_mix_columns, inv_shift_rows, mix_columns, shift_rows}; +use crate::sbox::{inv_sbox, sbox}; +use crate::schedule::{Aes128Params, Aes192Params, Aes256Params, AesParams, expand, round_key}; +use bouncycastle_core::errors::{KeyMaterialError, SymmetricCipherError}; +use bouncycastle_core::key_material::{KeyMaterial, KeyMaterialTrait, KeyType}; +use bouncycastle_core::traits::{Algorithm, SecurityStrength}; +use bouncycastle_utils::secret::Secret; + +/// The AES block length in bytes: 16 (FIPS 197 Sec 3.4, `Nb` = 4 words). +pub const BLOCK_LEN: usize = 16; + +/// The AES keyed permutation, parameterised by key length. +/// +/// Use the aliases [`Aes128`], [`Aes192`] and [`Aes256`] rather than naming this directly. +/// `P` is sealed to the three parameter sets of FIPS 197 Sec 6.1, so no fourth instantiation +/// exists. +/// +/// The only state is the key schedule, held in a [`Secret`] so that it is zeroized on drop and +/// redacted from `Debug`. There is no direction flag and no initialisation state: both directions +/// work from the same schedule (see [`Aes::decrypt_blocks2`]), and a constructed value is always +/// ready to use, so there is no `init()` or `reset()`. +pub struct Aes { + schedule: Secret, +} + +/// AES-128: 16-byte key, 10 rounds (FIPS 197 Sec 6.1). +pub type Aes128 = Aes; +/// AES-192: 24-byte key, 12 rounds (FIPS 197 Sec 6.1). +pub type Aes192 = Aes; +/// AES-256: 32-byte key, 14 rounds (FIPS 197 Sec 6.1). +pub type Aes256 = Aes; + +impl Aes

{ + /// Checks a key is fit to use before it is expanded. + /// + /// The key must be tagged [`KeyType::SymmetricCipherKey`], must be exactly `P::KEY_LEN` bytes + /// of the buffer, and must carry a [`SecurityStrength`] at least equal to its own length -- + /// which is what a key of this length from a correctly-instantiated RNG or KDF will have. + /// The checks exist to catch a key that arrived from somewhere it should not have: a seed + /// reused as a cipher key, or a 32-byte buffer holding material only derived at the 128-bit + /// strength. + /// + /// Takes `&dyn KeyMaterialTrait` so the three constructors, whose `KeyMaterial` capacities + /// differ, can share one implementation. + fn validate(key: &dyn KeyMaterialTrait) -> Result<(), SymmetricCipherError> { + if key.key_type() != KeyType::SymmetricCipherKey { + return Err(KeyMaterialError::InvalidKeyType( + "AES requires a key of type KeyType::SymmetricCipherKey.", + ) + .into()); + } + if key.key_len() != P::KEY_LEN { + return Err(KeyMaterialError::InvalidLength.into()); + } + if key.security_strength() < SecurityStrength::from_bytes(P::KEY_LEN) { + return Err(KeyMaterialError::SecurityStrength( + "The provided key has a lower security strength than the AES key length implies.", + ) + .into()); + } + Ok(()) + } + + /// CIPHER() on two blocks at once (FIPS 197 Sec 5.1, Algorithm 1). + /// + /// Algorithm 1 line by line: line 3 is the initial ADDROUNDKEY() with `w[0..3]`; lines 4-9 are + /// the `Nr - 1` full rounds; lines 10-13 are the final round, which omits MIXCOLUMNS(). + fn encrypt2(&self, q: &mut Planes) { + // line 3: state = state XOR w[0..3] + add_round_key(q, &round_key::

(&self.schedule, 0)); + + // lines 4-9: for round from 1 to Nr - 1 + for round in 1..P::NR { + sbox(q); // line 5, SUBBYTES() + shift_rows(q); // line 6, SHIFTROWS() + mix_columns(q); // line 7, MIXCOLUMNS() + add_round_key(q, &round_key::

(&self.schedule, round)); // line 8 + } + + // lines 10-12: the final round has no MIXCOLUMNS() + sbox(q); + shift_rows(q); + add_round_key(q, &round_key::

(&self.schedule, P::NR)); + } + + /// INVCIPHER() on two blocks at once (FIPS 197 Sec 5.3, Algorithm 3). + /// + /// This is the **straight** inverse cipher of Algorithm 3, not the equivalent inverse cipher + /// of Sec 5.3.5. That matters: Algorithm 3 applies INVMIXCOLUMNS() *after* ADDROUNDKEY(), + /// which lets it use the ordinary key schedule, whereas Sec 5.3.5 reorders the round to put + /// the two the other way round and needs a separate schedule with INVMIXCOLUMNS() applied to + /// each round key (Algorithm 5, KEYEXPANSIONEIC()). + /// + /// Following Algorithm 3 is therefore what allows one [`Aes`] value to encrypt *and* decrypt + /// from a single stored schedule, with no second copy and no transformation at construction + /// time -- which is the whole reason this crate can offer both directions at 176-240 bytes of + /// state. + /// + /// Line by line: line 3 is ADDROUNDKEY() with the last round key; lines 4-9 are the + /// `Nr - 1` full inverse rounds; lines 10-13 are the final one, which omits INVMIXCOLUMNS(). + fn decrypt2(&self, q: &mut Planes) { + // line 3: state = state XOR w[4*Nr .. 4*Nr+3] + add_round_key(q, &round_key::

(&self.schedule, P::NR)); + + // lines 4-9: for round from Nr - 1 down to 1 + for round in (1..P::NR).rev() { + inv_shift_rows(q); // line 5, INVSHIFTROWS() + inv_sbox(q); // line 6, INVSUBBYTES() + add_round_key(q, &round_key::

(&self.schedule, round)); // line 7 + inv_mix_columns(q); // line 8, INVMIXCOLUMNS() + } + + // lines 10-12: the final inverse round has no INVMIXCOLUMNS() + inv_shift_rows(q); + inv_sbox(q); + add_round_key(q, &round_key::

(&self.schedule, 0)); + } + + /// Encrypts two blocks in place. + /// + /// This is the natural unit of work: the bit-sliced state holds two blocks, so two blocks cost + /// almost exactly what one does. Prefer this over two [`Aes::encrypt_block`] calls whenever + /// two blocks are available and independent -- which, for a mode of operation, means CTR, or + /// the decryption direction of CBC and CFB, but *not* CBC encryption, whose blocks are + /// serially dependent. + /// + /// Infallible: a constructed [`Aes`] is always usable and every input length is fixed. + pub fn encrypt_blocks2(&self, blocks: &mut [Block; 2]) { + let mut q = pack(&blocks[0], &blocks[1]); + self.encrypt2(&mut q); + let (a, b) = blocks.split_at_mut(1); + unpack(&q, &mut a[0], &mut b[0]); + } + + /// Decrypts two blocks in place. See [`Aes::encrypt_blocks2`]. + pub fn decrypt_blocks2(&self, blocks: &mut [Block; 2]) { + let mut q = pack(&blocks[0], &blocks[1]); + self.decrypt2(&mut q); + let (a, b) = blocks.split_at_mut(1); + unpack(&q, &mut a[0], &mut b[0]); + } + + /// Encrypts one block in place. + /// + /// The bit-sliced state always holds two blocks, so a single-block call duplicates the block + /// into both halves and discards one result: it does twice the necessary work. Use + /// [`Aes::encrypt_blocks2`] where two blocks are available. + /// + /// Duplicating the block costs exactly what filling the unused half with zeros would, and it + /// buys a free self-check: the two halves must come out equal, which `debug_assert` verifies. + /// That is the whole reason for the choice -- it is not a security property, since the unused + /// half is never returned either way. + pub fn encrypt_block(&self, block: &mut Block) { + let mut q = pack(block, block); + self.encrypt2(&mut q); + let mut discard = [0u8; BLOCK_LEN]; + unpack(&q, block, &mut discard); + debug_assert_eq!(*block, discard, "the two interleaved halves must agree"); + } + + /// Decrypts one block in place. See [`Aes::encrypt_block`] for the two-blocks-at-once caveat. + pub fn decrypt_block(&self, block: &mut Block) { + let mut q = pack(block, block); + self.decrypt2(&mut q); + let mut discard = [0u8; BLOCK_LEN]; + unpack(&q, block, &mut discard); + debug_assert_eq!(*block, discard, "the two interleaved halves must agree"); + } +} + +// The three constructors and `Algorithm` impls below are written out longhand rather than +// generated with `macro_rules!`: `cargo mutants` cannot see into macro bodies, so a macro would +// hide the key checks and the security-strength constants from mutation testing (see CLAUDE.md). +// Each `new` differs only in the `KeyMaterial` capacity it accepts, which is what makes a +// wrong-length key a compile error at the call site rather than a runtime error. + +impl Aes128 { + /// Expands a 16-byte key into an AES-128 schedule. + /// + /// # Errors + /// * [`KeyMaterialError::InvalidKeyType`] if the key is not [`KeyType::SymmetricCipherKey`]. + /// * [`KeyMaterialError::InvalidLength`] if the key is not 16 bytes long. + /// * [`KeyMaterialError::SecurityStrength`] if the key carries a strength below 128 bits. + pub fn new(key: &KeyMaterial<16>) -> Result { + Self::validate(key)?; + Ok(Self { schedule: expand::(key.ref_to_bytes()) }) + } +} + +impl Aes192 { + /// Expands a 24-byte key into an AES-192 schedule. See [`Aes128::new`] for the error cases. + pub fn new(key: &KeyMaterial<24>) -> Result { + Self::validate(key)?; + Ok(Self { schedule: expand::(key.ref_to_bytes()) }) + } +} + +impl Aes256 { + /// Expands a 32-byte key into an AES-256 schedule. See [`Aes128::new`] for the error cases. + pub fn new(key: &KeyMaterial<32>) -> Result { + Self::validate(key)?; + Ok(Self { schedule: expand::(key.ref_to_bytes()) }) + } +} + +impl Algorithm for Aes128 { + const ALG_NAME: &'static str = Aes128Params::ALG_NAME; + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit; +} + +impl Algorithm for Aes192 { + const ALG_NAME: &'static str = Aes192Params::ALG_NAME; + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_192bit; +} + +impl Algorithm for Aes256 { + const ALG_NAME: &'static str = Aes256Params::ALG_NAME; + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_256bit; +} + +impl core::fmt::Debug for Aes

{ + /// Prints the algorithm name only. The key schedule is secret and is never formatted. + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(P::ALG_NAME) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_engine_sizes_match_the_documented_memory_table() { + // The "Memory Usage" table in the crate docs quotes these, and the whole point of the + // crate is that they are this small: 4 * (Nr + 1) words of schedule, nothing else, and no + // tables anywhere. If the representation grows, the docs are wrong -- fix both. + assert_eq!(size_of::(), 176, "AES-128: 4 * (10 + 1) words"); + assert_eq!(size_of::(), 208, "AES-192: 4 * (12 + 1) words"); + assert_eq!(size_of::(), 240, "AES-256: 4 * (14 + 1) words"); + } + + #[test] + fn test_engine_size_is_exactly_the_schedule() { + // No round counter, no direction flag, no initialised marker: the schedule is all there + // is, which is what makes both directions available from one value at no extra cost. + assert_eq!(size_of::(), size_of::<::Schedule>()); + assert_eq!(size_of::(), size_of::<::Schedule>()); + assert_eq!(size_of::(), size_of::<::Schedule>()); + } + + #[test] + fn test_alg_names() { + assert_eq!(::ALG_NAME, "AES-128"); + assert_eq!(::ALG_NAME, "AES-192"); + assert_eq!(::ALG_NAME, "AES-256"); + } + + #[test] + fn test_max_security_strength_matches_the_key_length() { + assert_eq!( + ::MAX_SECURITY_STRENGTH, + SecurityStrength::from_bytes(Aes128Params::KEY_LEN) + ); + assert_eq!( + ::MAX_SECURITY_STRENGTH, + SecurityStrength::from_bytes(Aes192Params::KEY_LEN) + ); + assert_eq!( + ::MAX_SECURITY_STRENGTH, + SecurityStrength::from_bytes(Aes256Params::KEY_LEN) + ); + } +} diff --git a/crypto/aes-lowmemory/src/bitslice.rs b/crypto/aes-lowmemory/src/bitslice.rs new file mode 100644 index 00000000..08ef77ff --- /dev/null +++ b/crypto/aes-lowmemory/src/bitslice.rs @@ -0,0 +1,210 @@ +//! Conversion between AES blocks and the bit-sliced representation the round functions act on. +//! +//! # What "bit-sliced" means here +//! +//! The round functions in [`crate::round`] and the S-box in [`crate::sbox`] do not operate on +//! bytes. They operate on eight `u32` *bit-planes*, `q[0]..q[7]`, where plane `q[k]` collects +//! bit `k` of every byte of the state. That is what lets the S-box be a Boolean circuit: one +//! `&` or `^` on a plane applies that gate to all sixteen byte positions at once, and no memory +//! access is ever indexed by a secret value. +//! +//! Eight 32-bit planes hold 256 bits = 32 bytes, which is *two* 16-byte AES blocks. Both blocks +//! are always processed together; see the crate docs for why, and [`crate::aes`] for how a +//! single-block call fills the unused half. +//! +//! # The layout, derived +//! +//! [`ortho`] transposes, within each byte-lane of the eight words, the 8x8 bit matrix indexed by +//! (word number, bit number within the lane): +//! +//! ```text +//! after ortho: q[k] bit (8L + i) == before ortho: q[i] bit (8L + k) +//! ``` +//! +//! [`pack`] loads block A as four little-endian `u32`s into the even words and block B into the +//! odd words, so before `ortho` byte-lane `L` of word `2c` holds `A[4c + L]`. Substituting +//! `j = 4c + L` for the byte index, and FIPS 197 Eq (3.6) `s[r,c] = in[r + 4c]` -- which makes +//! `r = j mod 4` and `c = j div 4` -- gives the layout every mask in this crate depends on: +//! +//! ```text +//! q[k] bit (8r + 2c) == bit k of s[r,c] of block A +//! q[k] bit (8r + 2c + 1) == bit k of s[r,c] of block B +//! ``` +//! +//! In words: **the byte-lane of the word selects the state row `r`, and the bit-pair within that +//! lane selects the state column `c`; the low bit of the pair is block A and the high bit is +//! block B.** Written out, the bit position of `s[r,c]` within every plane is: +//! +//! ```text +//! c=0 c=1 c=2 c=3 +//! r=0 | 0 2 4 6 +//! r=1 | 8 10 12 14 (bit position of block A; +//! r=2 | 16 18 20 22 add 1 for block B) +//! r=3 | 24 26 28 30 +//! ``` +//! +//! This is why SHIFTROWS() becomes a rotation *within* a byte-lane (row `r` lives entirely in +//! lane `r`, and one column step is two bit positions), and why MIXCOLUMNS() uses rotations by +//! 8 and 16 (one and two rows). Both are derived from this table in [`crate::round`]. +//! +//! `test_layout_matches_the_documented_table` below pins the table exhaustively; every mask in +//! this crate is only correct relative to it. +//! +//! # Provenance +//! +//! The three-stage masked-swap transpose and the even/odd two-block packing are translated from +//! BearSSL `src/symcipher/aes_ct.c` (`br_aes_ct_ortho`) and `aes_ct_cbcdec.c` (the `q[0]`, +//! `q[2]`, `q[4]`, `q[6]` load order), by Thomas Pornin, MIT licensed. + +/// One 16-byte AES block, in the order of FIPS 197 Eq (3.6): `block[r + 4c] == s[r,c]`. +pub type Block = [u8; crate::BLOCK_LEN]; + +/// The eight bit-planes holding two blocks. See the module docs for the layout. +pub(crate) type Planes = [u32; 8]; + +/// Transposes bytes into bit-planes, and back -- it is its own inverse. +/// +/// Three stages of masked swaps exchange bit-fields of width 1, 2 and 4 between pairs of words, +/// which together transpose the 8x8 bit matrix inside each byte-lane. See the module docs for +/// the resulting layout. +/// +/// Translated from BearSSL `aes_ct.c:br_aes_ct_ortho` (the `SWAP2`/`SWAP4`/`SWAP8` macros). +pub(crate) fn ortho(q: &mut Planes) { + /// One masked swap: exchanges the `cl`-selected fields of `y` into `x` and the `ch`-selected + /// fields of `x` into `y`, moving them by `s` bit positions. + /// + /// `cl` and `ch` are complementary, and `s` is exactly the field width, so in each returned + /// word the two combined operands occupy disjoint bits: `(x & cl)` and `(y & cl) << s` cannot + /// both be set in the same position. `|` and `^` therefore compute the same function here, + /// which is why `cargo mutants` reports the `| -> ^` mutants in this function as surviving -- + /// they are equivalent programs. `test_ortho_is_an_involution` and + /// `test_layout_matches_the_documented_table` are what actually pin this code. + #[inline(always)] + fn swap(cl: u32, ch: u32, s: u32, x: u32, y: u32) -> (u32, u32) { + ((x & cl) | ((y & cl) << s), ((x & ch) >> s) | (y & ch)) + } + + // Stage 1: swap single bits between adjacent words (0x55 = even bits, 0xAA = odd bits). + for (a, b) in [(0, 1), (2, 3), (4, 5), (6, 7)] { + (q[a], q[b]) = swap(0x5555_5555, 0xAAAA_AAAA, 1, q[a], q[b]); + } + // Stage 2: swap 2-bit fields between words two apart. + for (a, b) in [(0, 2), (1, 3), (4, 6), (5, 7)] { + (q[a], q[b]) = swap(0x3333_3333, 0xCCCC_CCCC, 2, q[a], q[b]); + } + // Stage 3: swap nibbles between words four apart. + for (a, b) in [(0, 4), (1, 5), (2, 6), (3, 7)] { + (q[a], q[b]) = swap(0x0F0F_0F0F, 0xF0F0_F0F0, 4, q[a], q[b]); + } +} + +/// Loads two blocks into the bit-planes. +/// +/// Block `a` goes into the even words and block `b` into the odd words as little-endian `u32`s, +/// then [`ortho`] transposes them into planes. +pub(crate) fn pack(a: &Block, b: &Block) -> Planes { + let mut q = [0u32; 8]; + for c in 0..4 { + // `try_into` cannot fail: the slice is a fixed 4-byte window of a 16-byte array. + q[2 * c] = u32::from_le_bytes(a[4 * c..4 * c + 4].try_into().unwrap()); + q[2 * c + 1] = u32::from_le_bytes(b[4 * c..4 * c + 4].try_into().unwrap()); + } + ortho(&mut q); + q +} + +/// Reads two blocks back out of the bit-planes; the exact inverse of [`pack`]. +pub(crate) fn unpack(q: &Planes, a: &mut Block, b: &mut Block) { + let mut q = *q; + ortho(&mut q); + for c in 0..4 { + a[4 * c..4 * c + 4].copy_from_slice(&q[2 * c].to_le_bytes()); + b[4 * c..4 * c + 4].copy_from_slice(&q[2 * c + 1].to_le_bytes()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A deterministic byte generator, so the tests do not depend on an RNG crate. + pub(crate) fn pseudo_random_block(seed: u32) -> Block { + let mut state = seed.wrapping_mul(2_654_435_761).wrapping_add(1); + let mut out = [0u8; 16]; + for byte in out.iter_mut() { + // xorshift32; quality is irrelevant, only that it varies every bit position. + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + *byte = (state >> 24) as u8; + } + out + } + + #[test] + fn test_layout_matches_the_documented_table() { + // Pins the module doc table: q[k] bit (8r + 2c) is bit k of s[r,c] of block A, and + // bit (8r + 2c + 1) is bit k of s[r,c] of block B. Every mask in `round` depends on it. + let a = pseudo_random_block(1); + let b = pseudo_random_block(2); + let q = pack(&a, &b); + + for j in 0..16 { + let (r, c) = (j % 4, j / 4); + let pos = 8 * r + 2 * c; + for (k, plane) in q.iter().enumerate() { + assert_eq!( + (plane >> pos) & 1, + u32::from((a[j] >> k) & 1), + "block A: plane {k} bit {pos} should be bit {k} of byte {j}" + ); + assert_eq!( + (plane >> (pos + 1)) & 1, + u32::from((b[j] >> k) & 1), + "block B: plane {k} bit {} should be bit {k} of byte {j}", + pos + 1 + ); + } + } + } + + #[test] + fn test_ortho_is_an_involution() { + let mut q = [ + 0x0123_4567, 0x89AB_CDEF, 0xFEDC_BA98, 0x7654_3210, 0xDEAD_BEEF, 0x0000_0001, + 0xFFFF_FFFF, 0xA5A5_5A5A, + ]; + let original = q; + ortho(&mut q); + assert_ne!(q, original, "ortho should actually move bits"); + ortho(&mut q); + assert_eq!(q, original); + } + + #[test] + fn test_unpack_inverts_pack() { + for seed in 0..64 { + let a = pseudo_random_block(seed); + let b = pseudo_random_block(seed + 1000); + let mut out_a = [0u8; 16]; + let mut out_b = [0u8; 16]; + unpack(&pack(&a, &b), &mut out_a, &mut out_b); + assert_eq!(out_a, a); + assert_eq!(out_b, b); + } + } + + #[test] + fn test_the_two_halves_are_independent() { + // Changing block B must not disturb block A anywhere in the round-function pipeline; + // this pins that the interleave really is bit-parallel and not overlapping. + let a = pseudo_random_block(7); + let mut out_a1 = [0u8; 16]; + let mut out_a2 = [0u8; 16]; + let mut scratch = [0u8; 16]; + unpack(&pack(&a, &[0u8; 16]), &mut out_a1, &mut scratch); + unpack(&pack(&a, &pseudo_random_block(9)), &mut out_a2, &mut scratch); + assert_eq!(out_a1, out_a2); + assert_eq!(out_a1, a); + } +} diff --git a/crypto/aes-lowmemory/src/lib.rs b/crypto/aes-lowmemory/src/lib.rs new file mode 100644 index 00000000..866a5167 --- /dev/null +++ b/crypto/aes-lowmemory/src/lib.rs @@ -0,0 +1,175 @@ +//! A constant-time, table-free AES block cipher engine (NIST FIPS 197). +//! +//! This crate provides the raw AES keyed permutation -- [`Aes128`], [`Aes192`] and [`Aes256`] -- +//! implemented as a Boolean circuit over bit-planes rather than as byte substitutions through a +//! lookup table. That makes it both smaller and constant-time; see [Design](#design). +//! +//! It is a *permutation*, not a cipher you can encrypt data with. See +//! [Security Considerations](#security-considerations). +//! +//! # Usage Examples +//! +//! ## Encrypting and decrypting a single block +//! +//! ``` +//! use bouncycastle_aes_lowmemory::Aes128; +//! use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +//! +//! let key = KeyMaterial::<16>::from_bytes_as_type( +//! &[0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, +//! 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c], +//! KeyType::SymmetricCipherKey, +//! ).expect("a 16-byte symmetric cipher key"); +//! +//! let aes = Aes128::new(&key).expect("a valid AES-128 key"); +//! +//! // FIPS 197 Appendix B. +//! let mut block = [0x32, 0x43, 0xf6, 0xa8, 0x88, 0x5a, 0x30, 0x8d, +//! 0x31, 0x31, 0x98, 0xa2, 0xe0, 0x37, 0x07, 0x34]; +//! aes.encrypt_block(&mut block); +//! assert_eq!(block, [0x39, 0x25, 0x84, 0x1d, 0x02, 0xdc, 0x09, 0xfb, +//! 0xdc, 0x11, 0x85, 0x97, 0x19, 0x6a, 0x0b, 0x32]); +//! +//! // The same value decrypts, from the same schedule -- there is no separate decryptor. +//! aes.decrypt_block(&mut block); +//! assert_eq!(block, [0x32, 0x43, 0xf6, 0xa8, 0x88, 0x5a, 0x30, 0x8d, +//! 0x31, 0x31, 0x98, 0xa2, 0xe0, 0x37, 0x07, 0x34]); +//! ``` +//! +//! ## Two blocks at a time +//! +//! The bit-sliced state holds two blocks, so two independent blocks cost barely more than one. +//! Where a caller has two, [`Aes::encrypt_blocks2`] is roughly twice the throughput of two +//! [`Aes::encrypt_block`] calls: +//! +//! ``` +//! use bouncycastle_aes_lowmemory::Aes256; +//! use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +//! +//! let key = KeyMaterial::<32>::from_bytes_as_type(&[0x42; 32], KeyType::SymmetricCipherKey) +//! .expect("a 32-byte symmetric cipher key"); +//! let aes = Aes256::new(&key).expect("a valid AES-256 key"); +//! +//! let mut blocks = [[0u8; 16], [1u8; 16]]; +//! aes.encrypt_blocks2(&mut blocks); +//! aes.decrypt_blocks2(&mut blocks); +//! assert_eq!(blocks, [[0u8; 16], [1u8; 16]]); +//! ``` +//! +//! There is no one-shot static on the permutation, because `Aes128::new(&key)?.encrypt_block(..)` +//! already *is* the one shot. Data-level one-shots belong to the modes of operation, which take +//! arbitrary-length input and generate their own initialisation data. +//! +//! # Design +//! +//! ## Why not a lookup table +//! +//! FIPS 197 Sec 5.1.1 presents the S-box as a table (Table 4), and almost every AES +//! implementation stores it as one -- 256 bytes, or 2-8 KiB for the "T-table" variants that fold +//! MIXCOLUMNS() in. The trouble is that a table indexed by a byte of the state is indexed by +//! secret data, so on any CPU with a data cache the memory access pattern, and hence the timing, +//! depends on the key. That is a practical, repeatedly-demonstrated attack, and it is not fixable +//! while the lookup remains. +//! +//! Bouncy Castle's `AESLightEngine` in the Java and C# ports keeps two 256-byte S-box tables for +//! exactly this reason -- to be *small*, not to be constant-time -- and leaks through both the +//! cipher and the key schedule. +//! +//! ## Bit-slicing +//! +//! This crate has no tables at all. The state is transposed so that each of eight `u32` words +//! holds one *bit position* of every byte: word `q[k]` collects bit `k` of all the bytes. In that +//! form the S-box becomes a fixed Boolean circuit -- 32 AND, 77 XOR and 4 XNOR gates, the +//! 113-gate straight-line program of Boyar and Peralta -- and one `&` or `^` applies a gate to +//! every byte position at once. Nothing is ever indexed by a secret, and nothing branches on one. +//! +//! Eight 32-bit words hold 32 bytes, which is two AES blocks, so blocks are processed in pairs. +//! SHIFTROWS() and MIXCOLUMNS() become masks and rotations in the same representation, and the +//! key schedule is stored bit-sliced too, so no transposition happens inside the round loop. The +//! exact bit layout, and the derivation of every mask from it, is documented in the `bitslice` +//! and `round` modules -- those two module docs are the place to start when reading the source. +//! +//! Decryption follows FIPS 197 Algorithm 3, the straight inverse cipher, rather than the +//! equivalent inverse cipher of Sec 5.3.5. Algorithm 3 puts INVMIXCOLUMNS() after ADDROUNDKEY(), +//! so it uses the *unmodified* key schedule; the equivalent inverse cipher would need a second +//! schedule with each round key transformed. One [`Aes`] value therefore encrypts and decrypts +//! from one stored schedule. +//! +//! # Memory Usage +//! +//! There are no lookup tables and no heap allocation. The only persistent state is the key +//! schedule, which is `4 * (Nr + 1)` words -- exactly the size FIPS 197 Sec 5.2 defines, with the +//! bit-sliced form compressed so that bit-slicing costs nothing in space: +//! +//! | Type | Key | `Nr` | Schedule (persistent) | Tables | +//! |---|---|---|---|---| +//! | [`Aes128`] | 16 B | 10 | 176 B | 0 B | +//! | [`Aes192`] | 24 B | 12 | 208 B | 0 B | +//! | [`Aes256`] | 32 B | 14 | 240 B | 0 B | +//! +//! Per-call stack usage is independent of key length: 32 bytes of bit-sliced state for the two +//! blocks, 32 bytes for the round key expanded from its compressed form, plus the S-box circuit's +//! temporaries, most of which the compiler keeps in registers. +//! +//! For comparison, `AESLightEngine` carries 512 bytes of tables and a T-table implementation +//! carries 2-8 KiB, in both cases *on top of* a key schedule of this same size. +//! +//! Measure with `cargo run --release -p mem_usage_benches --bin bench_aes_mem_usage`. +//! +//! # Security Considerations +//! +//! ## A block permutation is not a cipher +//! +//! [`Aes128`] and friends transform exactly 16 bytes. Using them directly on data means ECB, +//! which is not confidential: identical plaintext blocks produce identical ciphertext blocks, so +//! structure in the plaintext survives encryption. **Do not do it.** Use a mode of operation, and +//! prefer an authenticated one so that ciphertext tampering is detected. +//! +//! ## Constant-time properties +//! +//! By construction there is no secret-dependent memory access and no secret-dependent branch, +//! in the cipher *or* in the key schedule -- SUBWORD() goes through the same circuit as +//! SUBBYTES(). The only branches are the round loops, which count over the public `Nr`. +//! +//! Caveats worth stating plainly: +//! +//! * The Rust compiler makes no guarantee it will preserve this. The code is written so that the +//! natural code generation is straight-line, and `#![forbid(unsafe_code)]` rules out the usual +//! ways of forcing the issue, but the property is not contractual. +//! * The 32-byte working state is not scrubbed after a block. Only the key schedule is wrapped in +//! `Secret`, and so only it is guaranteed to be zeroized on drop. +//! * Constant-time execution says nothing about power or electromagnetic side channels. +//! +//! # Provenance +//! +//! * Normative reference: **NIST FIPS 197** (Advanced Encryption Standard), including Update 1. +//! Every transformation cites its section, algorithm and equation numbers. +//! * The S-box circuit is the 113-gate straight-line program `SLP_AES_113.txt` from Peralta's +//! circuit collection, described in J. Boyar and R. Peralta, "A new combinational logic +//! minimization technique with applications to cryptology", +//! . +//! * The bit-sliced two-block structure, the transpose, and the SHIFTROWS()/MIXCOLUMNS() mask and +//! rotation constants are translated from BearSSL's `aes_ct` implementation by Thomas Pornin +//! (MIT licence). Each constant is re-derived from the documented bit layout in the comments, +//! and each is pinned by a test against a byte-wise reference written from the FIPS 197 +//! equations. +//! * Verified against FIPS 197 Appendix A (all three key expansions, every word), FIPS 197 +//! Appendix B, NIST SP 800-38A Appendix F.1 (ECB, all three key lengths, both directions), and +//! the NIST ACVP `ACVP-AES-ECB` vectors. + +#![no_std] +#![forbid(unsafe_code)] +#![forbid(missing_docs)] +// `AesParams` is deliberately sealed with a private supertrait so that no fourth parameter set can +// be added outside this crate; that is what triggers this lint. +#![allow(private_bounds)] + +mod aes; +mod bitslice; +mod round; +mod sbox; +mod schedule; + +pub use aes::{Aes, Aes128, Aes192, Aes256, BLOCK_LEN}; +pub use bitslice::Block; +pub use schedule::{Aes128Params, Aes192Params, Aes256Params, AesParams}; diff --git a/crypto/aes-lowmemory/src/round.rs b/crypto/aes-lowmemory/src/round.rs new file mode 100644 index 00000000..b42406cf --- /dev/null +++ b/crypto/aes-lowmemory/src/round.rs @@ -0,0 +1,507 @@ +//! The three linear round transformations, on bit-planes. +//! +//! | Function | FIPS 197 | Inverse | FIPS 197 | +//! |---|---|---|---| +//! | [`add_round_key`] | Sec 5.1.4, Eq 5.9 | itself (XOR) | Sec 5.3.4 | +//! | [`shift_rows`] | Sec 5.1.2, Eq 5.5 | [`inv_shift_rows`] | Sec 5.3.1, Eq 5.12 | +//! | [`mix_columns`] | Sec 5.1.3, Eq 5.8 | [`inv_mix_columns`] | Sec 5.3.3, Eq 5.15 | +//! +//! SUBBYTES() is in [`crate::sbox`], because it is the only non-linear step and the only one that +//! needs a circuit rather than masks and rotations. +//! +//! Everything here is XOR, AND with a constant mask, and rotation by a constant. No operation +//! depends on the data, so all of it is inherently constant-time. +//! +//! # How the layout turns row and column arithmetic into shifts +//! +//! From the layout derived in [`crate::bitslice`], within every plane the bit holding `s[r,c]` +//! of block A sits at bit position `8r + 2c` (and block B at `8r + 2c + 1`). Two consequences +//! drive every constant below: +//! +//! * **A row is a byte-lane.** All of row `r` lives in bits `8r..8r+8` of every plane, and +//! stepping one column along that row is a step of two bit positions. So SHIFTROWS(), which +//! only permutes within rows, is a rotation *inside* each byte-lane, by `2r` positions. +//! * **Rotating a whole plane by 8 changes the row.** `x.rotate_right(8)` brings the contents of +//! lane `r+1` into lane `r`, so `rotate_right(8)` reads "the next row down" and +//! `rotate_right(16)` reads "two rows down". MIXCOLUMNS(), which combines the four rows of a +//! column, is therefore expressible with those two rotations and no shuffling at all. +//! +//! Provenance: the mask and rotation constants are translated from BearSSL +//! `src/symcipher/aes_ct_enc.c` and `aes_ct_dec.c` (MIT, Thomas Pornin). Each is re-derived from +//! the layout in the comments below, and each is pinned by a test in this file against a +//! byte-wise reference written directly from the FIPS 197 equations. + +use crate::bitslice::Planes; + +/// ADDROUNDKEY(): XORs a round key into the state (FIPS 197 Sec 5.1.4, Eq 5.9). +/// +/// Eq 5.9 XORs word `w[4*round + c]` into column `c`. Here the round key has already been +/// bit-sliced into the same plane layout as the state by [`crate::schedule`], so the whole +/// transformation -- all four columns of both blocks -- is eight XORs. +/// +/// This is its own inverse, which is why FIPS 197 Sec 5.3.4 needs no separate INVADDROUNDKEY(). +#[inline(always)] +pub(crate) fn add_round_key(q: &mut Planes, round_key: &Planes) { + for (plane, key_plane) in q.iter_mut().zip(round_key.iter()) { + *plane ^= *key_plane; + } +} + +/// SHIFTROWS(): cyclically shifts row `r` left by `r` columns (FIPS 197 Sec 5.1.2, Eq 5.5). +/// +/// Eq 5.5 is `s'[r,c] = s[r,(c + r) mod 4]`. Row `r` occupies byte-lane `r` of every plane and +/// one column is two bit positions, so the new column `c` must take what is two-bits-times-`r` +/// further up the lane: a **rotate right by `2r` within lane `r`**. Rotating right, not left, +/// because taking from a higher column index means pulling data down towards bit 0. +/// +/// Written out per lane rather than as a loop, so the shift amounts stay compile-time constants: +/// +/// * lane 0 (`r = 0`): rotate by 0, so bits `0..8` pass through untouched. +/// * lane 1 (`r = 1`): rotate right by 2. Bits 10..16 drop to 8..14; bits 8..10 wrap to 14..16. +/// * lane 2 (`r = 2`): rotate right by 4. Bits 20..24 drop to 16..20; bits 16..20 wrap up. +/// * lane 3 (`r = 3`): rotate right by 6. Bits 30..32 drop to 24..26; bits 24..30 wrap up. +/// +/// Both interleaved blocks move together, since a column step of two positions carries the A and +/// B bits of that column as a pair. +/// +/// Translated from BearSSL `aes_ct_enc.c:shift_rows`. +#[inline(always)] +pub(crate) fn shift_rows(q: &mut Planes) { + for plane in q.iter_mut() { + let x = *plane; + *plane = (x & 0x0000_00FF) + | ((x & 0x0000_FC00) >> 2) + | ((x & 0x0000_0300) << 6) + | ((x & 0x00F0_0000) >> 4) + | ((x & 0x000F_0000) << 4) + | ((x & 0xC000_0000) >> 6) + | ((x & 0x3F00_0000) << 2); + } +} + +/// INVSHIFTROWS(): cyclically shifts row `r` right by `r` columns +/// (FIPS 197 Sec 5.3.1, Eq 5.12). +/// +/// Eq 5.12 is `s'[r,c] = s[r,(c - r) mod 4]`, so this is [`shift_rows`] with every lane rotation +/// reversed: **rotate left by `2r` within lane `r`**. The masks are the complementary halves of +/// the forward ones. +/// +/// Translated from BearSSL `aes_ct_dec.c:inv_shift_rows`. +#[inline(always)] +pub(crate) fn inv_shift_rows(q: &mut Planes) { + for plane in q.iter_mut() { + let x = *plane; + *plane = (x & 0x0000_00FF) + | ((x & 0x0000_3F00) << 2) + | ((x & 0x0000_C000) >> 6) + | ((x & 0x000F_0000) << 4) + | ((x & 0x00F0_0000) >> 4) + | ((x & 0x0300_0000) << 6) + | ((x & 0xFC00_0000) >> 2); + } +} + +/// MIXCOLUMNS(): multiplies every column by the fixed matrix of Eq 5.7 +/// (FIPS 197 Sec 5.1.3). +/// +/// # Derivation +/// +/// Eq 5.8 gives each output byte of a column. Collecting the four rows, and writing `s[r]` for +/// the byte in row `r` of the column being processed, every row obeys the same rule: +/// +/// ```text +/// s'[r] = {02}.s[r] ^ {03}.s[r+1] ^ s[r+2] ^ s[r+3] (rows mod 4) +/// = {02}.(s[r] ^ s[r+1]) ^ s[r+1] ^ s[r+2] ^ s[r+3] +/// ``` +/// +/// using `{03} = {02} ^ {01}`. Because "the next row" is `rotate_right(8)` and "two rows down" is +/// `rotate_right(16)` (see the module docs), with `p` the state planes and `r` = `p` rotated by 8: +/// +/// * `p[k]` is bit `k` of `s[r]`, `r[k]` is bit `k` of `s[r+1]`, +/// * `rotate_right(16)` of those two gives bit `k` of `s[r+2]` and of `s[r+3]`. +/// +/// So `s[r+2] ^ s[r+3]` is `(p[k] ^ r[k]).rotate_right(16)`, which is the `rotr16(..)` term in +/// every line below, and `s[r+1]` is the bare `r[k]`. +/// +/// The remaining `{02}.(s[r] ^ s[r+1])` is XTIMES() (Eq 4.5) in the plane basis. Multiplying by +/// `x` shifts every bit up one plane, and the degree-8 term that falls off the top is reduced by +/// XOR-ing `{1b} = 0b0001_1011` -- bits 0, 1, 3 and 4. So with `v[k] = p[k] ^ r[k]`, plane `k` of +/// `{02}.v` is: +/// +/// * `v[k-1]` from the shift, for `k >= 1` (plane 0 gets nothing from the shift), and +/// * `v[7]`, the reduction, for `k` in {0, 1, 3, 4} only. +/// +/// That is exactly where the extra `p[7] ^ r[7]` terms appear below: in the lines for planes 0, 1, +/// 3 and 4, and nowhere else. Plane 0 is the one line with no `p[k-1] ^ r[k-1]` term. +/// +/// Translated from BearSSL `aes_ct_enc.c:mix_columns`; the equivalence to Eq 5.8 is pinned by +/// `test_mix_columns_matches_equation_5_8`. +#[inline(always)] +pub(crate) fn mix_columns(q: &mut Planes) { + let p = *q; + // r[k] holds the same bit position of the next row down. + let r: Planes = core::array::from_fn(|k| p[k].rotate_right(8)); + + // The `p[7] ^ r[7]` term is the {1b} reduction, present only in planes 0, 1, 3 and 4. + q[0] = p[7] ^ r[7] ^ r[0] ^ (p[0] ^ r[0]).rotate_right(16); + q[1] = p[0] ^ r[0] ^ p[7] ^ r[7] ^ r[1] ^ (p[1] ^ r[1]).rotate_right(16); + q[2] = p[1] ^ r[1] ^ r[2] ^ (p[2] ^ r[2]).rotate_right(16); + q[3] = p[2] ^ r[2] ^ p[7] ^ r[7] ^ r[3] ^ (p[3] ^ r[3]).rotate_right(16); + q[4] = p[3] ^ r[3] ^ p[7] ^ r[7] ^ r[4] ^ (p[4] ^ r[4]).rotate_right(16); + q[5] = p[4] ^ r[4] ^ r[5] ^ (p[5] ^ r[5]).rotate_right(16); + q[6] = p[5] ^ r[5] ^ r[6] ^ (p[6] ^ r[6]).rotate_right(16); + q[7] = p[6] ^ r[6] ^ r[7] ^ (p[7] ^ r[7]).rotate_right(16); +} + +/// INVMIXCOLUMNS(): multiplies every column by the inverse matrix of Eq 5.14 +/// (FIPS 197 Sec 5.3.3). +/// +/// The same shape as [`mix_columns`] -- `r` is the next row down, `rotate_right(16)` reaches two +/// rows further -- but the defining word of Sec 4.3 is `[{0e},{09},{0d},{0b}]` (Eq 5.13) instead +/// of `[{02},{01},{01},{03}]` (Eq 5.6). Those have degree up to 3, so expanding each product +/// through XTIMES() +/// in the plane basis produces many more terms than the forward direction, and the per-plane term +/// lists below are that expansion of Eq 5.15 rather than something readable line by line. +/// +/// The reduction terms are not confined to planes 0, 1, 3 and 4 here, because the higher-degree +/// coefficients feed carries into every plane. +/// +/// Translated from BearSSL `aes_ct_dec.c:inv_mix_columns`. Rather than trust the expansion by +/// inspection, `test_inv_mix_columns_matches_equation_5_15` checks it against a byte-wise +/// reference written straight from Eq 5.15, and `test_inv_mix_columns_inverts_mix_columns` +/// checks the two are inverses. +#[inline(always)] +#[rustfmt::skip] +pub(crate) fn inv_mix_columns(q: &mut Planes) { + let p = *q; + let r: Planes = core::array::from_fn(|k| p[k].rotate_right(8)); + + q[0] = p[5] ^ p[6] ^ p[7] ^ r[0] ^ r[5] ^ r[7] + ^ (p[0] ^ p[5] ^ p[6] ^ r[0] ^ r[5]).rotate_right(16); + q[1] = p[0] ^ p[5] ^ r[0] ^ r[1] ^ r[5] ^ r[6] ^ r[7] + ^ (p[1] ^ p[5] ^ p[7] ^ r[1] ^ r[5] ^ r[6]).rotate_right(16); + q[2] = p[0] ^ p[1] ^ p[6] ^ r[1] ^ r[2] ^ r[6] ^ r[7] + ^ (p[0] ^ p[2] ^ p[6] ^ r[2] ^ r[6] ^ r[7]).rotate_right(16); + q[3] = p[0] ^ p[1] ^ p[2] ^ p[5] ^ p[6] ^ r[0] ^ r[2] ^ r[3] ^ r[5] + ^ (p[0] ^ p[1] ^ p[3] ^ p[5] ^ p[6] ^ p[7] ^ r[0] ^ r[3] ^ r[5] ^ r[7]).rotate_right(16); + q[4] = p[1] ^ p[2] ^ p[3] ^ p[5] ^ r[1] ^ r[3] ^ r[4] ^ r[5] ^ r[6] ^ r[7] + ^ (p[1] ^ p[2] ^ p[4] ^ p[5] ^ p[7] ^ r[1] ^ r[4] ^ r[5] ^ r[6]).rotate_right(16); + q[5] = p[2] ^ p[3] ^ p[4] ^ p[6] ^ r[2] ^ r[4] ^ r[5] ^ r[6] ^ r[7] + ^ (p[2] ^ p[3] ^ p[5] ^ p[6] ^ r[2] ^ r[5] ^ r[6] ^ r[7]).rotate_right(16); + q[6] = p[3] ^ p[4] ^ p[5] ^ p[7] ^ r[3] ^ r[5] ^ r[6] ^ r[7] + ^ (p[3] ^ p[4] ^ p[6] ^ p[7] ^ r[3] ^ r[6] ^ r[7]).rotate_right(16); + q[7] = p[4] ^ p[5] ^ p[6] ^ r[4] ^ r[6] ^ r[7] + ^ (p[4] ^ p[5] ^ p[7] ^ r[4] ^ r[7]).rotate_right(16); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bitslice::{pack, unpack}; + + /// Runs a plane transformation over one block placed in both halves, returning the A half. + fn apply(f: fn(&mut Planes), block: [u8; 16]) -> [u8; 16] { + let mut q = pack(&block, &block); + f(&mut q); + let mut a = [0u8; 16]; + let mut b = [0u8; 16]; + unpack(&q, &mut a, &mut b); + assert_eq!(a, b, "the two interleaved blocks must transform identically"); + a + } + + /// A block whose bytes are all distinct, so any mask error that moves a byte to the wrong + /// position is visible. + fn distinct_block() -> [u8; 16] { + core::array::from_fn(|i| (i as u8).wrapping_mul(17).wrapping_add(3)) + } + + // ---- byte-wise references, written from the FIPS 197 equations ---------------------- + // These use `state[r + 4c] == s[r,c]` (Eq 3.6). They exist only to check the plane + // implementations and are deliberately naive. + + /// Eq 5.5: `s'[r,c] = s[r,(c + r) mod 4]`. + fn ref_shift_rows(s: &[u8; 16]) -> [u8; 16] { + let mut o = [0u8; 16]; + for r in 0..4 { + for c in 0..4 { + o[r + 4 * c] = s[r + 4 * ((c + r) % 4)]; + } + } + o + } + + /// Eq 5.12: `s'[r,c] = s[r,(c - r) mod 4]`. + fn ref_inv_shift_rows(s: &[u8; 16]) -> [u8; 16] { + let mut o = [0u8; 16]; + for r in 0..4 { + for c in 0..4 { + o[r + 4 * c] = s[r + 4 * ((c + 4 - r) % 4)]; + } + } + o + } + + /// Eq 4.5 XTIMES(): multiply by `{02}` in GF(2^8). + fn xtimes(b: u8) -> u8 { + (b << 1) ^ if b & 0x80 != 0 { 0x1b } else { 0 } + } + + /// General GF(2^8) multiplication. Test-only; it branches on `b` and must never see secrets. + fn gf_mul(mut a: u8, mut b: u8) -> u8 { + let mut product = 0u8; + for _ in 0..8 { + if b & 1 != 0 { + product ^= a; + } + b >>= 1; + a = xtimes(a); + } + product + } + + /// Multiplication of a column by a fixed matrix, exactly as FIPS 197 Sec 4.3 defines it. + /// + /// Eq 4.8 gives the output word `[d0,d1,d2,d3]` from the input word `[b0,b1,b2,b3]` and the + /// matrix word `[a0,a1,a2,a3]`: + /// + /// ```text + /// d0 = (a0.b0) + (a3.b1) + (a2.b2) + (a1.b3) + /// d1 = (a1.b0) + (a0.b1) + (a3.b2) + (a2.b3) + /// d2 = (a2.b0) + (a1.b1) + (a0.b2) + (a3.b3) + /// d3 = (a3.b0) + (a2.b1) + (a1.b2) + (a0.b3) + /// ``` + /// + /// so entry `(r,k)` of the matrix is `a[(r - k) mod 4]`, which is what the indexing below is. + /// Both MIXCOLUMNS() and INVMIXCOLUMNS() use this same convention; only the word differs. + fn ref_mix_columns(s: &[u8; 16], coeffs: [u8; 4]) -> [u8; 16] { + let mut o = [0u8; 16]; + for c in 0..4 { + for r in 0..4 { + let mut v = 0u8; + for k in 0..4 { + v ^= gf_mul(s[k + 4 * c], coeffs[(r + 4 - k) % 4]); + } + o[r + 4 * c] = v; + } + } + o + } + + /// Eq 5.6: `[a0, a1, a2, a3] = [{02}, {01}, {01}, {03}]`. + /// + /// Note the order: it is *not* `[{02},{03},{01},{01}]`, which is the first row of the matrix + /// in Eq 5.7 rather than the defining word. Feeding the matrix row in here instead of the + /// word silently transposes the matrix, which happens to leave INVMIXCOLUMNS() passing, so + /// this is a comment worth keeping. + const MIX_COEFFS: [u8; 4] = [0x02, 0x01, 0x01, 0x03]; + /// Eq 5.13: `[a0, a1, a2, a3] = [{0e}, {09}, {0d}, {0b}]`. + const INV_MIX_COEFFS: [u8; 4] = [0x0e, 0x09, 0x0d, 0x0b]; + + /// Eq 5.8, transcribed literally, as a cross-check on [`ref_mix_columns`]. + /// + /// ```text + /// s'0,c = ({02}.s0,c) + ({03}.s1,c) + s2,c + s3,c + /// s'1,c = s0,c + ({02}.s1,c) + ({03}.s2,c) + s3,c + /// s'2,c = s0,c + s1,c + ({02}.s2,c) + ({03}.s3,c) + /// s'3,c = ({03}.s0,c) + s1,c + s2,c + ({02}.s3,c) + /// ``` + #[rustfmt::skip] + fn ref_mix_columns_literal(s: &[u8; 16]) -> [u8; 16] { + let mut o = [0u8; 16]; + for c in 0..4 { + let (s0, s1, s2, s3) = (s[4 * c], s[4 * c + 1], s[4 * c + 2], s[4 * c + 3]); + o[4 * c] = gf_mul(0x02, s0) ^ gf_mul(0x03, s1) ^ s2 ^ s3; + o[4 * c + 1] = s0 ^ gf_mul(0x02, s1) ^ gf_mul(0x03, s2) ^ s3; + o[4 * c + 2] = s0 ^ s1 ^ gf_mul(0x02, s2) ^ gf_mul(0x03, s3); + o[4 * c + 3] = gf_mul(0x03, s0) ^ s1 ^ s2 ^ gf_mul(0x02, s3); + } + o + } + + /// Eq 5.15, transcribed literally, as a cross-check on [`ref_mix_columns`]. + /// + /// ```text + /// s'0,c = ({0e}.s0,c) + ({0b}.s1,c) + ({0d}.s2,c) + ({09}.s3,c) + /// s'1,c = ({09}.s0,c) + ({0e}.s1,c) + ({0b}.s2,c) + ({0d}.s3,c) + /// s'2,c = ({0d}.s0,c) + ({09}.s1,c) + ({0e}.s2,c) + ({0b}.s3,c) + /// s'3,c = ({0b}.s0,c) + ({0d}.s1,c) + ({09}.s2,c) + ({0e}.s3,c) + /// ``` + #[rustfmt::skip] + fn ref_inv_mix_columns_literal(s: &[u8; 16]) -> [u8; 16] { + let mut o = [0u8; 16]; + for c in 0..4 { + let (s0, s1, s2, s3) = (s[4 * c], s[4 * c + 1], s[4 * c + 2], s[4 * c + 3]); + o[4 * c] = gf_mul(0x0e, s0) ^ gf_mul(0x0b, s1) ^ gf_mul(0x0d, s2) ^ gf_mul(0x09, s3); + o[4 * c + 1] = gf_mul(0x09, s0) ^ gf_mul(0x0e, s1) ^ gf_mul(0x0b, s2) ^ gf_mul(0x0d, s3); + o[4 * c + 2] = gf_mul(0x0d, s0) ^ gf_mul(0x09, s1) ^ gf_mul(0x0e, s2) ^ gf_mul(0x0b, s3); + o[4 * c + 3] = gf_mul(0x0b, s0) ^ gf_mul(0x0d, s1) ^ gf_mul(0x09, s2) ^ gf_mul(0x0e, s3); + } + o + } + + // ---- tests -------------------------------------------------------------------------- + + #[test] + fn test_the_two_reference_forms_agree() { + // Eq 5.7 (matrix, via the Sec 4.3 convention) against Eq 5.8 (explicit bytes), and the + // same for Eq 5.14 against Eq 5.15. This is what pins the coefficient word order: get + // MIX_COEFFS wrong and these disagree, independently of the plane implementation. + for seed in 0..32u8 { + let block: [u8; 16] = core::array::from_fn(|i| (i as u8).wrapping_mul(37) ^ seed); + assert_eq!(ref_mix_columns(&block, MIX_COEFFS), ref_mix_columns_literal(&block)); + assert_eq!( + ref_mix_columns(&block, INV_MIX_COEFFS), + ref_inv_mix_columns_literal(&block) + ); + } + } + + #[test] + fn test_xtimes_reference_matches_the_spec_example() { + // FIPS 197 Sec 4.2 works through {57} . {13}; the intermediate XTIMES() chain from + // Eq 4.5 is {57}, {ae}, {47}, {8e}, {07}. + assert_eq!(xtimes(0x57), 0xae); + assert_eq!(xtimes(0xae), 0x47); + assert_eq!(xtimes(0x47), 0x8e); + assert_eq!(xtimes(0x8e), 0x07); + // and the product itself, {57} . {13} = {fe}. + assert_eq!(gf_mul(0x57, 0x13), 0xfe); + } + + #[test] + fn test_shift_rows_matches_equation_5_5() { + for seed in 0..32u8 { + let block: [u8; 16] = core::array::from_fn(|i| (i as u8).wrapping_mul(31) ^ seed); + assert_eq!(apply(shift_rows, block), ref_shift_rows(&block)); + } + assert_eq!(apply(shift_rows, distinct_block()), ref_shift_rows(&distinct_block())); + } + + #[test] + fn test_inv_shift_rows_matches_equation_5_12() { + for seed in 0..32u8 { + let block: [u8; 16] = core::array::from_fn(|i| (i as u8).wrapping_mul(31) ^ seed); + assert_eq!(apply(inv_shift_rows, block), ref_inv_shift_rows(&block)); + } + } + + #[test] + fn test_inv_shift_rows_inverts_shift_rows() { + let block = distinct_block(); + let mut q = pack(&block, &block); + shift_rows(&mut q); + inv_shift_rows(&mut q); + let mut a = [0u8; 16]; + let mut b = [0u8; 16]; + unpack(&q, &mut a, &mut b); + assert_eq!(a, block); + } + + #[test] + fn test_shift_rows_is_a_bit_permutation() { + // Push a single set bit through and require exactly one bit out, with the induced map on + // bit positions a bijection. That is the real invariant behind the seven masked terms: + // their destination ranges are pairwise disjoint and together cover all 32 bits. + // + // It also explains a known `cargo mutants` result. The `| -> ^` mutants in [`shift_rows`] + // and [`inv_shift_rows`] survive, because on disjoint operands `|` and `^` compute the + // same function -- they are equivalent programs, not a gap in the tests, and no test can + // kill them. What *would* be a bug is masks that overlap or fail to cover, and this test + // is what rules that out. + for (name, f) in [ + ("shift_rows", shift_rows as fn(&mut Planes)), + ("inv_shift_rows", inv_shift_rows as fn(&mut Planes)), + ] { + let mut destinations = [false; 32]; + for bit in 0..32 { + let mut q: Planes = [1u32 << bit; 8]; + f(&mut q); + for plane in q { + assert_eq!( + plane.count_ones(), + 1, + "{name}: bit {bit} must map to exactly one bit, got {plane:#034b}" + ); + } + let dest = q[0].trailing_zeros() as usize; + assert!(!destinations[dest], "{name}: two source bits both map to bit {dest}"); + destinations[dest] = true; + } + assert!( + destinations.iter().all(|&hit| hit), + "{name}: the masks must cover all 32 bit positions" + ); + } + } + + #[test] + fn test_shift_rows_leaves_row_zero_alone() { + // Row 0 is bytes 0, 4, 8, 12 in the Eq 3.6 layout, and Eq 5.5 does not move it. + let block = distinct_block(); + let out = apply(shift_rows, block); + for c in 0..4 { + assert_eq!(out[4 * c], block[4 * c], "row 0, column {c}"); + } + } + + #[test] + fn test_mix_columns_matches_equation_5_8() { + for seed in 0..32u8 { + let block: [u8; 16] = core::array::from_fn(|i| (i as u8).wrapping_mul(37) ^ seed); + assert_eq!(apply(mix_columns, block), ref_mix_columns(&block, MIX_COEFFS)); + } + assert_eq!( + apply(mix_columns, distinct_block()), + ref_mix_columns(&distinct_block(), MIX_COEFFS) + ); + } + + #[test] + fn test_inv_mix_columns_matches_equation_5_15() { + for seed in 0..32u8 { + let block: [u8; 16] = core::array::from_fn(|i| (i as u8).wrapping_mul(37) ^ seed); + assert_eq!(apply(inv_mix_columns, block), ref_mix_columns(&block, INV_MIX_COEFFS)); + } + } + + #[test] + fn test_inv_mix_columns_inverts_mix_columns() { + let block = distinct_block(); + let mut q = pack(&block, &block); + mix_columns(&mut q); + inv_mix_columns(&mut q); + let mut a = [0u8; 16]; + let mut b = [0u8; 16]; + unpack(&q, &mut a, &mut b); + assert_eq!(a, block); + } + + #[test] + fn test_add_round_key_is_its_own_inverse() { + let block = distinct_block(); + let key = pack(&[0xA5u8; 16], &[0x5Au8; 16]); + let mut q = pack(&block, &block); + add_round_key(&mut q, &key); + add_round_key(&mut q, &key); + let mut a = [0u8; 16]; + let mut b = [0u8; 16]; + unpack(&q, &mut a, &mut b); + assert_eq!(a, block); + } + + #[test] + fn test_add_round_key_xors_the_expected_bytes() { + let block = distinct_block(); + let key_block = [0xA5u8; 16]; + let key = pack(&key_block, &key_block); + let mut q = pack(&block, &block); + add_round_key(&mut q, &key); + let mut a = [0u8; 16]; + let mut b = [0u8; 16]; + unpack(&q, &mut a, &mut b); + for i in 0..16 { + assert_eq!(a[i], block[i] ^ key_block[i]); + } + } +} diff --git a/crypto/aes-lowmemory/src/sbox.rs b/crypto/aes-lowmemory/src/sbox.rs new file mode 100644 index 00000000..8e68d2e3 --- /dev/null +++ b/crypto/aes-lowmemory/src/sbox.rs @@ -0,0 +1,381 @@ +//! SUBBYTES() and INVSUBBYTES() as a Boolean circuit (FIPS 197 Sec 5.1.1 and Sec 5.3.2). +//! +//! # Why a circuit and not a table +//! +//! FIPS 197 Sec 5.1.1 presents the S-box as a 256-entry lookup table (Table 4). A table lookup +//! indexed by a byte of the state is indexed by *secret data*, and on any CPU with a data cache +//! the access pattern -- hence the timing -- depends on that secret. That is the standard AES +//! cache-timing side channel, and it cannot be closed while keeping the lookup. +//! +//! So this module does not have a table. It computes the same function as Table 4 with AND, XOR +//! and XNOR gates applied to the bit-planes described in [`crate::bitslice`]. Every operation is +//! a straight-line word operation on public *positions*, so there is no secret-dependent memory +//! access and no secret-dependent branch. The two functions here are the only place in the crate +//! where secret data meets non-linear logic; everything else is XOR, rotate and mask. +//! +//! Because the planes hold sixteen byte positions of two blocks at once, one pass of the circuit +//! substitutes all 32 bytes -- the whole SUBBYTES() transformation of two blocks -- rather than +//! one byte. +//! +//! # What the circuit computes +//! +//! FIPS 197 Sec 5.1.1 defines the S-box as inversion in GF(2^8) followed by an affine map +//! (Eq. 5.2), tabulated in Table 4. The circuit below is the 113-gate straight-line program of +//! Boyar and Peralta -- 32 AND, 77 XOR and 4 XNOR gates -- which computes exactly that, +//! including the affine map and its `{63}` constant (the constant is folded into the four XNORs +//! at the end of the bottom linear transformation). +//! +//! Sources: +//! * The straight-line program `SLP_AES_113.txt`, from Peralta's circuit collection. +//! * J. Boyar and R. Peralta, "A new combinational logic minimization technique with +//! applications to cryptology", . +//! * The same circuit appears in BearSSL `aes_ct.c:br_aes_ct_bitslice_Sbox` (MIT, Thomas +//! Pornin), whose variable naming is kept here so the two can be diffed. BearSSL re-associates +//! two gates in the non-linear section (its `t17`/`t21` differ from the SLP file, computing the +//! same `t21`) and uses a different but equivalent bottom linear transformation; where they +//! disagree this file follows `SLP_AES_113.txt`. +//! +//! The gate list is a mechanical transcription of `SLP_AES_113.txt`: `+` became `^`, `x` became +//! `&`, `#` became `!(.. ^ ..)`, and the SLP variable names are unchanged apart from case. It is +//! not independently meaningful line by line and should not be "tidied"; it is verified as a +//! whole by `test_sbox_matches_fips197_table_4`, which checks all 256 inputs against Table 4. +//! +//! # Bit numbering +//! +//! The SLP numbers its inputs `U0..U7` and outputs `S0..S7` with **`U0` as the most significant +//! bit** of the byte, which is the reverse of the plane index. So `U0` is plane `q[7]` and `U7` +//! is plane `q[0]`, and likewise for the outputs. `test_sbox_matches_fips197_table_4` is what +//! pins this down -- reversing it produces a wrong S-box, not a subtly different one. + +use crate::bitslice::Planes; + +/// SUBBYTES(): applies the AES S-box to every byte position of both blocks in `q` +/// (FIPS 197 Sec 5.1.1, the transformation tabulated in Table 4). +/// +/// The 113-gate Boyar-Peralta circuit, transcribed from `SLP_AES_113.txt`. See the module docs. +pub(crate) fn sbox(q: &mut Planes) { + // SLP inputs U0..U7, most-significant bit first, so U0 is the highest plane. + let u0 = q[7]; + let u1 = q[6]; + let u2 = q[5]; + let u3 = q[4]; + let u4 = q[3]; + let u5 = q[2]; + let u6 = q[1]; + let u7 = q[0]; + + // Top linear transformation (23 gates): the input basis change. + let y14 = u3 ^ u5; + let y13 = u0 ^ u6; + let y9 = u0 ^ u3; + let y8 = u0 ^ u5; + let t0 = u1 ^ u2; + let y1 = t0 ^ u7; + let y4 = y1 ^ u3; + let y12 = y13 ^ y14; + let y2 = y1 ^ u0; + let y5 = y1 ^ u6; + let y3 = y5 ^ y8; + let t1 = u4 ^ y12; + let y15 = t1 ^ u5; + let y20 = t1 ^ u1; + let y6 = y15 ^ u7; + let y10 = y15 ^ t0; + let y11 = y20 ^ y9; + let y7 = u7 ^ y11; + let y17 = y10 ^ y11; + let y19 = y10 ^ y8; + let y16 = t0 ^ y11; + let y21 = y13 ^ y16; + let y18 = u0 ^ y16; + + // Non-linear section (62 gates): the GF(2^8) inversion, and the only ANDs in the circuit. + let t2 = y12 & y15; + let t3 = y3 & y6; + let t4 = t3 ^ t2; + let t5 = y4 & u7; + let t6 = t5 ^ t2; + let t7 = y13 & y16; + let t8 = y5 & y1; + let t9 = t8 ^ t7; + let t10 = y2 & y7; + let t11 = t10 ^ t7; + let t12 = y9 & y11; + let t13 = y14 & y17; + let t14 = t13 ^ t12; + let t15 = y8 & y10; + let t16 = t15 ^ t12; + let t17 = t4 ^ y20; + let t18 = t6 ^ t16; + let t19 = t9 ^ t14; + let t20 = t11 ^ t16; + let t21 = t17 ^ t14; + let t22 = t18 ^ y19; + let t23 = t19 ^ y21; + let t24 = t20 ^ y18; + let t25 = t21 ^ t22; + let t26 = t21 & t23; + let t27 = t24 ^ t26; + let t28 = t25 & t27; + let t29 = t28 ^ t22; + let t30 = t23 ^ t24; + let t31 = t22 ^ t26; + let t32 = t31 & t30; + let t33 = t32 ^ t24; + let t34 = t23 ^ t33; + let t35 = t27 ^ t33; + let t36 = t24 & t35; + // `cargo mutants` reports the `^ -> |` mutant on the next line as surviving. That is a true + // equivalence, not a gap: `t36` and `t34` are never both 1 for any of the 256 possible input + // bytes, so XOR and OR agree here. It is the only one of the circuit's 77 XOR gates with that + // property -- every other `^ -> |` mutant is killed by `test_sbox_matches_fips197_table_4`. + let t37 = t36 ^ t34; + let t38 = t27 ^ t36; + let t39 = t29 & t38; + let t40 = t25 ^ t39; + let t41 = t40 ^ t37; + let t42 = t29 ^ t33; + let t43 = t29 ^ t40; + let t44 = t33 ^ t37; + let t45 = t42 ^ t41; + let z0 = t44 & y15; + let z1 = t37 & y6; + let z2 = t33 & u7; + let z3 = t43 & y16; + let z4 = t40 & y1; + let z5 = t29 & y7; + let z6 = t42 & y11; + let z7 = t45 & y17; + let z8 = t41 & y10; + let z9 = t44 & y12; + let z10 = t37 & y3; + let z11 = t33 & y4; + let z12 = t43 & y13; + let z13 = t40 & y5; + let z14 = t29 & y2; + let z15 = t42 & y9; + let z16 = t45 & y14; + let z17 = t41 & y8; + + // Bottom linear transformation (28 gates): the output basis change and the affine map of + // Eq. 5.2, whose `{63}` constant is the four XNORs below. + let tc1 = z15 ^ z16; + let tc2 = z10 ^ tc1; + let tc3 = z9 ^ tc2; + let tc4 = z0 ^ z2; + let tc5 = z1 ^ z0; + let tc6 = z3 ^ z4; + let tc7 = z12 ^ tc4; + let tc8 = z7 ^ tc6; + let tc9 = z8 ^ tc7; + let tc10 = tc8 ^ tc9; + let tc11 = tc6 ^ tc5; + let tc12 = z3 ^ z5; + let tc13 = z13 ^ tc1; + let tc14 = tc4 ^ tc12; + let s3 = tc3 ^ tc11; + let tc16 = z6 ^ tc8; + let tc17 = z14 ^ tc10; + let tc18 = tc13 ^ tc14; + let s7 = !(z12 ^ tc18); + let tc20 = z15 ^ tc16; + let tc21 = tc2 ^ z11; + let s0 = tc3 ^ tc16; + let s6 = !(tc10 ^ tc18); + let s4 = tc14 ^ s3; + let s1 = !(s3 ^ tc16); + let tc26 = tc17 ^ tc20; + let s2 = !(tc26 ^ z17); + let s5 = tc21 ^ tc17; + + // SLP outputs S0..S7, most-significant bit first, mirroring the input mapping. + q[7] = s0; + q[6] = s1; + q[5] = s2; + q[4] = s3; + q[3] = s4; + q[2] = s5; + q[1] = s6; + q[0] = s7; +} + +/// INVSUBBYTES(): applies the inverse AES S-box to every byte position of both blocks in `q` +/// (FIPS 197 Sec 5.3.2, the transformation tabulated in Table 6). +/// +/// Rather than a second 113-gate circuit, this reuses [`sbox`] by conjugating it with the +/// inverse of its affine layer. Writing the S-box of Eq. 5.2 as `S(x) = A(I(x)) ^ {63}`, where +/// `I` is inversion in GF(2^8) and `A` the linear part, and letting `B` be the inverse of `A`: +/// +/// ```text +/// iS(x) = B(S(B(x ^ {63})) ^ {63}) +/// ``` +/// +/// which holds because `I` is an involution: +/// `iS(S(y)) = B(A(I(B(A(I(y)) ^ {63} ^ {63}))) ^ {63} ^ {63}) = y`. +/// +/// So applying [`inv_affine`], then the forward circuit, then [`inv_affine`] again yields the +/// inverse S-box, at the cost of 16 extra XORs and 8 complements instead of a whole second +/// circuit. Verified exhaustively against Table 6 by `test_inv_sbox_matches_fips197_table_6`. +/// +/// The derivation and the layer below are from BearSSL `aes_ct_dec.c` +/// (`br_aes_ct_bitslice_invSbox`). +pub(crate) fn inv_sbox(q: &mut Planes) { + inv_affine(q); + sbox(q); + inv_affine(q); +} + +/// `B(x ^ {63})`: the inverse of the affine layer of Eq. 5.2, composed with the constant. +/// +/// The complements on planes 0, 1, 5 and 6 are the `^ {63}`; the eight three-term XORs are `B`. +/// Translated from BearSSL `aes_ct_dec.c:br_aes_ct_bitslice_invSbox`. +fn inv_affine(q: &mut Planes) { + let q0 = !q[0]; + let q1 = !q[1]; + let q2 = q[2]; + let q3 = q[3]; + let q4 = q[4]; + let q5 = !q[5]; + let q6 = !q[6]; + let q7 = q[7]; + q[7] = q1 ^ q4 ^ q6; + q[6] = q0 ^ q3 ^ q5; + q[5] = q7 ^ q2 ^ q4; + q[4] = q6 ^ q1 ^ q3; + q[3] = q5 ^ q0 ^ q2; + q[2] = q4 ^ q7 ^ q1; + q[1] = q3 ^ q6 ^ q0; + q[0] = q2 ^ q5 ^ q7; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bitslice::{pack, unpack}; + + /// FIPS 197 Table 4 (SBOX), transcribed from the published PDF. Test-only: the + /// implementation evaluates the S-box as a Boolean circuit and never indexes a table. + #[rustfmt::skip] + const SBOX_TABLE_4: [u8; 256] = [ + 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, + 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, + 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, + 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, + 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, + 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, + 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, + 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, + 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, + 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, + 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, + 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, + 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, + 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, + 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, + 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16, + ]; + + /// FIPS 197 Table 6 (INVSBOX), transcribed from the published PDF. Test-only. + #[rustfmt::skip] + const INVSBOX_TABLE_6: [u8; 256] = [ + 0x52, 0x09, 0x6a, 0xd5, 0x30, 0x36, 0xa5, 0x38, 0xbf, 0x40, 0xa3, 0x9e, 0x81, 0xf3, 0xd7, 0xfb, + 0x7c, 0xe3, 0x39, 0x82, 0x9b, 0x2f, 0xff, 0x87, 0x34, 0x8e, 0x43, 0x44, 0xc4, 0xde, 0xe9, 0xcb, + 0x54, 0x7b, 0x94, 0x32, 0xa6, 0xc2, 0x23, 0x3d, 0xee, 0x4c, 0x95, 0x0b, 0x42, 0xfa, 0xc3, 0x4e, + 0x08, 0x2e, 0xa1, 0x66, 0x28, 0xd9, 0x24, 0xb2, 0x76, 0x5b, 0xa2, 0x49, 0x6d, 0x8b, 0xd1, 0x25, + 0x72, 0xf8, 0xf6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xd4, 0xa4, 0x5c, 0xcc, 0x5d, 0x65, 0xb6, 0x92, + 0x6c, 0x70, 0x48, 0x50, 0xfd, 0xed, 0xb9, 0xda, 0x5e, 0x15, 0x46, 0x57, 0xa7, 0x8d, 0x9d, 0x84, + 0x90, 0xd8, 0xab, 0x00, 0x8c, 0xbc, 0xd3, 0x0a, 0xf7, 0xe4, 0x58, 0x05, 0xb8, 0xb3, 0x45, 0x06, + 0xd0, 0x2c, 0x1e, 0x8f, 0xca, 0x3f, 0x0f, 0x02, 0xc1, 0xaf, 0xbd, 0x03, 0x01, 0x13, 0x8a, 0x6b, + 0x3a, 0x91, 0x11, 0x41, 0x4f, 0x67, 0xdc, 0xea, 0x97, 0xf2, 0xcf, 0xce, 0xf0, 0xb4, 0xe6, 0x73, + 0x96, 0xac, 0x74, 0x22, 0xe7, 0xad, 0x35, 0x85, 0xe2, 0xf9, 0x37, 0xe8, 0x1c, 0x75, 0xdf, 0x6e, + 0x47, 0xf1, 0x1a, 0x71, 0x1d, 0x29, 0xc5, 0x89, 0x6f, 0xb7, 0x62, 0x0e, 0xaa, 0x18, 0xbe, 0x1b, + 0xfc, 0x56, 0x3e, 0x4b, 0xc6, 0xd2, 0x79, 0x20, 0x9a, 0xdb, 0xc0, 0xfe, 0x78, 0xcd, 0x5a, 0xf4, + 0x1f, 0xdd, 0xa8, 0x33, 0x88, 0x07, 0xc7, 0x31, 0xb1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xec, 0x5f, + 0x60, 0x51, 0x7f, 0xa9, 0x19, 0xb5, 0x4a, 0x0d, 0x2d, 0xe5, 0x7a, 0x9f, 0x93, 0xc9, 0x9c, 0xef, + 0xa0, 0xe0, 0x3b, 0x4d, 0xae, 0x2a, 0xf5, 0xb0, 0xc8, 0xeb, 0xbb, 0x3c, 0x83, 0x53, 0x99, 0x61, + 0x17, 0x2b, 0x04, 0x7e, 0xba, 0x77, 0xd6, 0x26, 0xe1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0c, 0x7d, + ]; + + /// Runs a plane transformation over a block placed in both halves, returning the A half. + /// + /// Filling both halves means a wrong interleave shows up as a difference between the two + /// blocks rather than silently passing. + fn apply(f: fn(&mut Planes), block: [u8; 16]) -> [u8; 16] { + let mut q = pack(&block, &block); + f(&mut q); + let mut a = [0u8; 16]; + let mut b = [0u8; 16]; + unpack(&q, &mut a, &mut b); + assert_eq!(a, b, "the two interleaved blocks must transform identically"); + a + } + + #[test] + fn test_sbox_matches_fips197_table_4() { + // Exhaustive over the whole domain: this is the test that makes the 113 gates + // trustworthy, so it must stay exhaustive. + for x in 0..=255u8 { + let out = apply(sbox, [x; 16]); + assert!( + out.iter().all(|&b| b == out[0]), + "all 16 byte positions must substitute alike, x={x:#04x}" + ); + assert_eq!( + out[0], SBOX_TABLE_4[x as usize], + "SBOX({x:#04x}) should be {:#04x}", + SBOX_TABLE_4[x as usize] + ); + } + } + + #[test] + fn test_inv_sbox_matches_fips197_table_6() { + for x in 0..=255u8 { + let out = apply(inv_sbox, [x; 16]); + assert_eq!( + out[0], INVSBOX_TABLE_6[x as usize], + "INVSBOX({x:#04x}) should be {:#04x}", + INVSBOX_TABLE_6[x as usize] + ); + } + } + + #[test] + fn test_inv_sbox_inverts_sbox() { + for x in 0..=255u8 { + let mut q = pack(&[x; 16], &[x.wrapping_add(1); 16]); + sbox(&mut q); + inv_sbox(&mut q); + let mut a = [0u8; 16]; + let mut b = [0u8; 16]; + unpack(&q, &mut a, &mut b); + assert_eq!(a, [x; 16]); + assert_eq!(b, [x.wrapping_add(1); 16]); + } + } + + #[test] + fn test_sbox_worked_example_from_section_5_1_1() { + // FIPS 197 Sec 5.1.1: "if s(r,c) = {53} ... s'(r,c) = {ed}". + assert_eq!(apply(sbox, [0x53; 16])[0], 0xed); + assert_eq!(SBOX_TABLE_4[0x53], 0xed); + } + + #[test] + fn test_the_two_spec_tables_are_inverses() { + // Guards the transcription of both tables against a typo in either one. + for x in 0..=255u8 { + assert_eq!(INVSBOX_TABLE_6[SBOX_TABLE_4[x as usize] as usize], x); + } + } + + #[test] + fn test_sbox_operates_on_each_byte_position_independently() { + // A block of distinct values, so a mask error that mixes byte positions is caught. + let block: [u8; 16] = core::array::from_fn(|i| (i as u8) * 17); + let out = apply(sbox, block); + for i in 0..16 { + assert_eq!(out[i], SBOX_TABLE_4[block[i] as usize], "byte position {i}"); + } + } +} diff --git a/crypto/aes-lowmemory/src/schedule.rs b/crypto/aes-lowmemory/src/schedule.rs new file mode 100644 index 00000000..9ae50e38 --- /dev/null +++ b/crypto/aes-lowmemory/src/schedule.rs @@ -0,0 +1,461 @@ +//! KEYEXPANSION() (FIPS 197 Sec 5.2, Algorithm 2) and the per-key-length parameters. +//! +//! # Storage +//! +//! The schedule is `4 * (Nr + 1)` words -- 44, 52 or 60 -- exactly as FIPS 197 Sec 5.2 defines +//! it, so 176, 208 or 240 bytes. It is stored in a **compressed** bit-sliced form: because +//! bit-slicing is a permutation of bits it does not change the size, and because both interleaved +//! blocks are encrypted under the same key the two halves of a bit-sliced round key are +//! identical, so only one of every pair of words needs keeping. [`round_key`] re-doubles a single +//! round key onto the stack when the round loop needs it. +//! +//! The alternative -- storing the doubled 8-plane form -- would need 352, 416 or 480 bytes, and +//! holding the classical schedule *and* a bit-sliced copy would be worse still. Since low memory +//! is the point of this crate, neither is done: [`expand`] writes the classical schedule into the +//! final array and then rewrites it in place, one round key at a time, using eight words of +//! stack. In particular it does not mirror BearSSL's `uint32_t skey[120]` (480-byte) scratch +//! buffer. +//! +//! # Constant-time +//! +//! The key is secret, so SUBWORD() in the expansion has the same table-lookup problem as +//! SUBBYTES() in the cipher, and gets the same treatment: [`sub_word`] routes the word through +//! the bit-sliced circuit in [`crate::sbox`]. A table-driven "light" AES that only removes the +//! tables from the cipher, and not from the key schedule, still leaks through the schedule. + +use crate::bitslice::{Planes, ortho}; +use crate::sbox::sbox; +use bouncycastle_utils::secret::{Secret, ZeroizablePrimitive}; + +/// FIPS 197 Sec 5.2, Table 5: the round constants, `Rcon[j]` for `1 <= j <= 10`. +/// +/// Table 5 gives each as the word `[x, 00, 00, 00]`; only the leftmost byte is ever non-zero, and +/// words are held little-endian here, so the word `Rcon[j]` is just this byte. Indexing is shifted +/// by one against the spec: `RCON[j - 1]` is the spec's `Rcon[j]`, since the spec counts from 1. +const RCON: [u32; 10] = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36]; + +/// Prevents a fourth parameter set from being added outside this crate. +/// +/// FIPS 197 Sec 6.1 defines exactly three: AES-128, AES-192 and AES-256. Because [`AesParams`] +/// has this private supertrait, only the three types in this module can implement it, so no +/// downstream crate can instantiate the cipher with an unapproved key length or round count. +trait AesParamsSealed {} + +/// The per-key-length constants of FIPS 197 Sec 6.1. +/// +/// This is a trait rather than const generic parameters because the schedule length +/// `4 * (Nr + 1)` cannot be written as an expression over another const parameter on stable +/// const-generics; each implementation spells its own array type out instead. The same pattern is +/// used by the `HashDRBG80090AParams_*` types in `bouncycastle-rng`. +/// +/// Sealed via a private supertrait, so the three types below are the only implementations. +pub trait AesParams: AesParamsSealed { + /// Key length in bytes: 16, 24 or 32 (FIPS 197 Sec 6.1). + const KEY_LEN: usize; + /// `Nk`, the key length in 32-bit words: 4, 6 or 8 (FIPS 197 Sec 6.1). + const NK: usize; + /// `Nr`, the number of rounds: 10, 12 or 14 (FIPS 197 Sec 6.1). + const NR: usize; + /// The algorithm name, as reported by `Algorithm::ALG_NAME`. + const ALG_NAME: &'static str; + /// `[u32; 4 * (NR + 1)]` -- the compressed schedule. See the module docs. + type Schedule: ZeroizablePrimitive + AsRef<[u32]> + AsMut<[u32]>; +} + +/// AES-128 parameters: 16-byte key, `Nk` = 4, `Nr` = 10 (FIPS 197 Sec 6.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Aes128Params; +/// AES-192 parameters: 24-byte key, `Nk` = 6, `Nr` = 12 (FIPS 197 Sec 6.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Aes192Params; +/// AES-256 parameters: 32-byte key, `Nk` = 8, `Nr` = 14 (FIPS 197 Sec 6.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Aes256Params; + +impl AesParamsSealed for Aes128Params {} +impl AesParamsSealed for Aes192Params {} +impl AesParamsSealed for Aes256Params {} + +impl AesParams for Aes128Params { + const KEY_LEN: usize = 16; + const NK: usize = 4; + const NR: usize = 10; + const ALG_NAME: &'static str = "AES-128"; + type Schedule = [u32; 44]; // 4 * (10 + 1) +} + +impl AesParams for Aes192Params { + const KEY_LEN: usize = 24; + const NK: usize = 6; + const NR: usize = 12; + const ALG_NAME: &'static str = "AES-192"; + type Schedule = [u32; 52]; // 4 * (12 + 1) +} + +impl AesParams for Aes256Params { + const KEY_LEN: usize = 32; + const NK: usize = 8; + const NR: usize = 14; + const ALG_NAME: &'static str = "AES-256"; + type Schedule = [u32; 60]; // 4 * (14 + 1) +} + +/// ROTWORD(): `[a0,a1,a2,a3] -> [a1,a2,a3,a0]` (FIPS 197 Sec 5.2, Eq 5.10). +/// +/// Words are held little-endian, so `a0` is the low byte. Moving `a1` down into the low byte and +/// wrapping `a0` to the top is a rotate right by 8 of the whole word. +#[inline(always)] +fn rot_word(word: u32) -> u32 { + word.rotate_right(8) +} + +/// SUBWORD(): applies the S-box to each of the four bytes of a word +/// (FIPS 197 Sec 5.2, Eq 5.11). +/// +/// The key is secret, so this must not be a table lookup. It reuses the bit-sliced circuit +/// instead, by replicating `word` into all eight planes before transposing: +/// +/// after [`ortho`], plane `q[k]` bit `8L + i` equals bit `8L + k` of the *input* word `q[i]` -- +/// and every input word is the same `word`, so that bit is bit `k` of byte `L` of `word` +/// regardless of `i`. In the layout of [`crate::bitslice`], the bit positions `8L + i` for +/// `i = 0..8` are all four columns of row `L`, in both blocks. So the transposed state holds byte +/// `L` of `word` in every position of row `L`, one S-box pass substitutes all four bytes (sixteen +/// times over, redundantly), and transposing back reassembles the word. All eight planes then +/// hold the same result, so `q[0]` is SUBWORD(`word`); `test_sub_word_fills_every_plane` checks +/// that. +/// +/// It costs a full 113-gate S-box evaluation to substitute four bytes, which is wasteful, but it +/// happens `Nr` or so times per key rather than per block. Translated from BearSSL +/// `aes_ct.c:sub_word`. +fn sub_word(word: u32) -> u32 { + let mut q: Planes = [word; 8]; + ortho(&mut q); + sbox(&mut q); + ortho(&mut q); + q[0] +} + +/// KEYEXPANSION() (FIPS 197 Sec 5.2, Algorithm 2), returning the compressed bit-sliced schedule. +/// +/// `key` must be exactly `P::KEY_LEN` bytes; [`crate::aes`] checks that before calling, so this +/// cannot fail and takes no `Result`. +/// +/// Algorithm 2 is followed literally -- lines 2-6 copy the key into `w[0..Nk]`, lines 7-16 derive +/// the rest -- and then the finished schedule is rewritten in place into the storage form +/// described in the module docs. Verified against the worked expansions in FIPS 197 +/// Appendix A.1, A.2 and A.3 by the tests at the bottom of this file, which decompress the +/// stored schedule and compare every w[i]. +pub(crate) fn expand(key: &[u8]) -> Secret { + debug_assert_eq!(key.len(), P::KEY_LEN); + + let mut schedule = Secret::::new(); + let w = (*schedule).as_mut(); + + // Algorithm 2 lines 2-6: w[i] = key[4i .. 4i+3] for i < Nk. + for i in 0..P::NK { + // Cannot fail: `key` is P::KEY_LEN == 4 * P::NK bytes, so this window is in bounds. + w[i] = u32::from_le_bytes(key[4 * i..4 * i + 4].try_into().unwrap()); + } + + // Algorithm 2 lines 7-16. + let mut temp = w[P::NK - 1]; // line 8, hoisted: w[i-1] is the temp from the previous pass + for i in P::NK..w.len() { + if i % P::NK == 0 { + // line 10: temp = SUBWORD(ROTWORD(temp)) XOR Rcon[i / Nk] + temp = sub_word(rot_word(temp)) ^ RCON[i / P::NK - 1]; + } else if P::NK > 6 && i % P::NK == 4 { + // lines 11-12: the extra substitution that only AES-256 reaches + temp = sub_word(temp); + } + // line 14: w[i] = w[i - Nk] XOR temp + temp ^= w[i - P::NK]; + w[i] = temp; + } + + // Rewrite in place into the compressed bit-sliced form, one 4-word round key at a time. + // Both interleaved blocks use the same key, so each round key is bit-sliced with the word + // duplicated into both halves; the two halves are then identical and one bit of each pair is + // redundant, so the even-position bits of the first word and the odd-position bits of the + // second are packed into a single stored word. + for base in (0..w.len()).step_by(4) { + let mut q: Planes = [0u32; 8]; + for j in 0..4 { + q[2 * j] = w[base + j]; + q[2 * j + 1] = w[base + j]; + } + ortho(&mut q); + for j in 0..4 { + // The two masks are complementary, so the operands are disjoint and `|` and `^` agree. + // That is why `cargo mutants` reports the `| -> ^` mutant here as surviving. + w[base + j] = (q[2 * j] & 0x5555_5555) | (q[2 * j + 1] & 0xAAAA_AAAA); + } + } + + schedule +} + +/// Re-doubles round key `round` of a compressed schedule into its eight-plane form. +/// +/// The inverse of the packing at the end of [`expand`]: the even-position bits are spread back +/// over both positions of each pair, and likewise the odd-position bits, giving the two identical +/// halves that [`crate::round::add_round_key`] expects. Eight words of stack, built fresh each +/// round rather than stored. +/// +/// Translated from BearSSL `aes_ct.c:br_aes_ct_skey_expand`. +#[inline(always)] +pub(crate) fn round_key(schedule: &P::Schedule, round: usize) -> Planes { + debug_assert!(round <= P::NR); + let w = schedule.as_ref(); + let mut sk: Planes = [0u32; 8]; + for j in 0..4 { + let packed = w[4 * round + j]; + let even = packed & 0x5555_5555; + let odd = packed & 0xAAAA_AAAA; + // `even` occupies only even bit positions and `even << 1` only odd ones (and vice versa + // for `odd`), so both spreads combine disjoint operands and `|` and `^` agree. Hence the + // two `| -> ^` mutants `cargo mutants` reports here as surviving. + sk[2 * j] = even | (even << 1); + sk[2 * j + 1] = odd | (odd >> 1); + } + sk +} + +#[cfg(test)] +mod tests { + use super::*; + + /// FIPS 197 Appendix A.1: every w[i] of the AES-128 key expansion, as printed + /// (i.e. the byte sequence [a0,a1,a2,a3] read left to right). + #[rustfmt::skip] + const APPENDIX_A1_WORDS: [u32; 44] = [ + 0x2b7e1516, 0x28aed2a6, 0xabf71588, 0x09cf4f3c, + 0xa0fafe17, 0x88542cb1, 0x23a33939, 0x2a6c7605, + 0xf2c295f2, 0x7a96b943, 0x5935807a, 0x7359f67f, + 0x3d80477d, 0x4716fe3e, 0x1e237e44, 0x6d7a883b, + 0xef44a541, 0xa8525b7f, 0xb671253b, 0xdb0bad00, + 0xd4d1c6f8, 0x7c839d87, 0xcaf2b8bc, 0x11f915bc, + 0x6d88a37a, 0x110b3efd, 0xdbf98641, 0xca0093fd, + 0x4e54f70e, 0x5f5fc9f3, 0x84a64fb2, 0x4ea6dc4f, + 0xead27321, 0xb58dbad2, 0x312bf560, 0x7f8d292f, + 0xac7766f3, 0x19fadc21, 0x28d12941, 0x575c006e, + 0xd014f9a8, 0xc9ee2589, 0xe13f0cc8, 0xb6630ca6, + ]; + + /// FIPS 197 Appendix A.2: every w[i] of the AES-192 key expansion, as printed. + #[rustfmt::skip] + const APPENDIX_A2_WORDS: [u32; 52] = [ + 0x8e73b0f7, 0xda0e6452, 0xc810f32b, 0x809079e5, + 0x62f8ead2, 0x522c6b7b, 0xfe0c91f7, 0x2402f5a5, + 0xec12068e, 0x6c827f6b, 0x0e7a95b9, 0x5c56fec2, + 0x4db7b4bd, 0x69b54118, 0x85a74796, 0xe92538fd, + 0xe75fad44, 0xbb095386, 0x485af057, 0x21efb14f, + 0xa448f6d9, 0x4d6dce24, 0xaa326360, 0x113b30e6, + 0xa25e7ed5, 0x83b1cf9a, 0x27f93943, 0x6a94f767, + 0xc0a69407, 0xd19da4e1, 0xec1786eb, 0x6fa64971, + 0x485f7032, 0x22cb8755, 0xe26d1352, 0x33f0b7b3, + 0x40beeb28, 0x2f18a259, 0x6747d26b, 0x458c553e, + 0xa7e1466c, 0x9411f1df, 0x821f750a, 0xad07d753, + 0xca400538, 0x8fcc5006, 0x282d166a, 0xbc3ce7b5, + 0xe98ba06f, 0x448c773c, 0x8ecc7204, 0x01002202, + ]; + + /// FIPS 197 Appendix A.3: every w[i] of the AES-256 key expansion, as printed. + #[rustfmt::skip] + const APPENDIX_A3_WORDS: [u32; 60] = [ + 0x603deb10, 0x15ca71be, 0x2b73aef0, 0x857d7781, + 0x1f352c07, 0x3b6108d7, 0x2d9810a3, 0x0914dff4, + 0x9ba35411, 0x8e6925af, 0xa51a8b5f, 0x2067fcde, + 0xa8b09c1a, 0x93d194cd, 0xbe49846e, 0xb75d5b9a, + 0xd59aecb8, 0x5bf3c917, 0xfee94248, 0xde8ebe96, + 0xb5a9328a, 0x2678a647, 0x98312229, 0x2f6c79b3, + 0x812c81ad, 0xdadf48ba, 0x24360af2, 0xfab8b464, + 0x98c5bfc9, 0xbebd198e, 0x268c3ba7, 0x09e04214, + 0x68007bac, 0xb2df3316, 0x96e939e4, 0x6c518d80, + 0xc814e204, 0x76a9fb8a, 0x5025c02d, 0x59c58239, + 0xde136967, 0x6ccc5a71, 0xfa256395, 0x9674ee15, + 0x5886ca5d, 0x2e2f31d7, 0x7e0af1fa, 0x27cf73c3, + 0x749c47ab, 0x18501dda, 0xe2757e4f, 0x7401905a, + 0xcafaaae3, 0xe4d59b34, 0x9adf6ace, 0xbd10190d, + 0xfe4890d1, 0xe6188d0b, 0x046df344, 0x706c631e, + ]; + + /// Recovers the classical `w[i]` from a stored schedule. + /// + /// [`round_key`] undoes the pair-compression, and [`ortho`] then undoes the bit-slicing, + /// leaving the duplicated pre-slicing words with `w[4*round + j]` in position `2j`. This is + /// what lets the Appendix A vectors test the real [`expand`] output rather than a + /// reimplementation of it. + fn classical_word(schedule: &P::Schedule, i: usize) -> u32 { + let mut q = round_key::

(schedule, i / 4); + ortho(&mut q); + let j = i % 4; + assert_eq!(q[2 * j], q[2 * j + 1], "both interleaved halves hold the same round key"); + q[2 * j] + } + + /// Compares a whole expansion against an Appendix A table. + /// + /// Appendix A prints a word as the byte sequence `[a0,a1,a2,a3]` left to right, so the + /// tabulated `u32` has `a0` in its *most* significant byte; words are held little-endian + /// here, so `swap_bytes` is the conversion. + fn assert_expansion_matches(key: &[u8], expected: &[u32], label: &str) { + let schedule = expand::

(key); + assert_eq!(expected.len(), 4 * (P::NR + 1), "{label}: table length"); + for (i, &want) in expected.iter().enumerate() { + let got = classical_word::

(&schedule, i).swap_bytes(); + assert_eq!(got, want, "{label}: w[{i}] should be {want:#010x}, got {got:#010x}"); + } + } + + #[test] + fn test_key_expansion_matches_fips197_appendix_a1() { + let key = [ + 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, + 0x4f, 0x3c, + ]; + assert_expansion_matches::(&key, &APPENDIX_A1_WORDS, "Appendix A.1"); + } + + #[test] + fn test_key_expansion_matches_fips197_appendix_a2() { + let key = [ + 0x8e, 0x73, 0xb0, 0xf7, 0xda, 0x0e, 0x64, 0x52, 0xc8, 0x10, 0xf3, 0x2b, 0x80, 0x90, + 0x79, 0xe5, 0x62, 0xf8, 0xea, 0xd2, 0x52, 0x2c, 0x6b, 0x7b, + ]; + assert_expansion_matches::(&key, &APPENDIX_A2_WORDS, "Appendix A.2"); + } + + #[test] + fn test_key_expansion_matches_fips197_appendix_a3() { + let key = [ + 0x60, 0x3d, 0xeb, 0x10, 0x15, 0xca, 0x71, 0xbe, 0x2b, 0x73, 0xae, 0xf0, 0x85, 0x7d, + 0x77, 0x81, 0x1f, 0x35, 0x2c, 0x07, 0x3b, 0x61, 0x08, 0xd7, 0x2d, 0x98, 0x10, 0xa3, + 0x09, 0x14, 0xdf, 0xf4, + ]; + assert_expansion_matches::(&key, &APPENDIX_A3_WORDS, "Appendix A.3"); + } + + #[test] + fn test_the_first_nk_schedule_words_are_the_key_itself() { + // Algorithm 2 lines 2-6, and a check that the expansion is reading the key + // little-endian consistently with how Appendix A prints it. + let key = [ + 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, + 0x4f, 0x3c, + ]; + let schedule = expand::(&key); + for i in 0..Aes128Params::NK { + let got = classical_word::(&schedule, i); + assert_eq!(got.to_le_bytes(), key[4 * i..4 * i + 4]); + } + } + + #[test] + fn test_rot_word_matches_equation_5_10() { + // FIPS 197 Eq 5.10 on the byte sequence [a0,a1,a2,a3] = [0x09,0xcf,0x4f,0x3c], which is + // the temp at i = 4 of Appendix A.1, whose ROTWORD() the appendix gives as cf4f3c09. + let word = u32::from_le_bytes([0x09, 0xcf, 0x4f, 0x3c]); + assert_eq!(rot_word(word).to_le_bytes(), [0xcf, 0x4f, 0x3c, 0x09]); + } + + #[test] + fn test_sub_word_matches_the_appendix_a1_example() { + // Appendix A.1, i = 4: "After ROTWORD()" is cf4f3c09 and "After SUBWORD()" is 8a84eb01. + // The appendix prints a word as the byte sequence [a0,a1,a2,a3]; words are held + // little-endian here, so `a0` is the low byte. + let after_rot = u32::from_le_bytes([0xcf, 0x4f, 0x3c, 0x09]); + assert_eq!(sub_word(after_rot).to_le_bytes(), [0x8a, 0x84, 0xeb, 0x01]); + } + + #[test] + fn test_sub_word_fills_every_plane() { + // The doc comment claims all eight planes end up holding SUBWORD(word); if that ever + // stopped being true, picking q[0] would be an arbitrary choice rather than a correct one. + let word = 0x1234_5678u32; + let mut q: Planes = [word; 8]; + ortho(&mut q); + sbox(&mut q); + ortho(&mut q); + assert!(q.iter().all(|&plane| plane == q[0])); + assert_eq!(q[0], sub_word(word)); + } + + #[test] + fn test_round_key_inverts_the_compression() { + // Round-tripping a known schedule: expand(), then round_key() for every round, and check + // the recovered planes match bit-slicing the classical words directly. + let key = [ + 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, + 0x4f, 0x3c, + ]; + let schedule = expand::(&key); + + // Recompute the classical schedule without the compression step. + let mut w = [0u32; 44]; + for i in 0..4 { + w[i] = u32::from_le_bytes(key[4 * i..4 * i + 4].try_into().unwrap()); + } + let mut temp = w[3]; + for i in 4..44 { + if i % 4 == 0 { + temp = sub_word(rot_word(temp)) ^ RCON[i / 4 - 1]; + } + temp ^= w[i - 4]; + w[i] = temp; + } + + for round in 0..=Aes128Params::NR { + let got = round_key::(&schedule, round); + let mut expected: Planes = [0u32; 8]; + for j in 0..4 { + expected[2 * j] = w[4 * round + j]; + expected[2 * j + 1] = w[4 * round + j]; + } + ortho(&mut expected); + assert_eq!(got, expected, "round {round}"); + } + } + + #[test] + fn test_schedule_lengths_match_four_times_nr_plus_one() { + // FIPS 197 Sec 5.2: the schedule is 4 * (Nr + 1) words. The array types are written out + // by hand per parameter set, so this guards against a typo in one of them. + assert_eq!( + size_of::<::Schedule>() / 4, + 4 * (Aes128Params::NR + 1) + ); + assert_eq!( + size_of::<::Schedule>() / 4, + 4 * (Aes192Params::NR + 1) + ); + assert_eq!( + size_of::<::Schedule>() / 4, + 4 * (Aes256Params::NR + 1) + ); + } + + #[test] + fn test_key_len_is_four_times_nk() { + // FIPS 197 Sec 6.1 ties the two together; both are declared independently above. + assert_eq!(Aes128Params::KEY_LEN, 4 * Aes128Params::NK); + assert_eq!(Aes192Params::KEY_LEN, 4 * Aes192Params::NK); + assert_eq!(Aes256Params::KEY_LEN, 4 * Aes256Params::NK); + } + + #[test] + fn test_rcon_table_5_values() { + // FIPS 197 Sec 5.2: "for j > 0, these bytes may be generated by successively applying + // XTIMES() to the byte represented by x^(j-1)". Derive the table and compare, so a typo + // in the transcription of Table 5 shows up here. + let mut expected = [0u32; 10]; + let mut v: u8 = 0x01; + for slot in expected.iter_mut() { + *slot = u32::from(v); + v = (v << 1) ^ if v & 0x80 != 0 { 0x1b } else { 0 }; + } + assert_eq!(RCON, expected); + // Spot-check the two values from Table 5 that are not plain powers of two. + assert_eq!(RCON[8], 0x1b); + assert_eq!(RCON[9], 0x36); + } +} From b1895b0ae93d1224cc1c91eb33dca41b9a4e1a2f Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Mon, 31 Aug 2026 16:20:37 +0700 Subject: [PATCH 2/7] Added tests for aes-lowmemory (#98) --- crypto/aes-lowmemory/tests/acvp_tests.rs | 266 ++++++++++++++++++ crypto/aes-lowmemory/tests/fips197_tests.rs | 230 +++++++++++++++ crypto/aes-lowmemory/tests/sp800_38a_tests.rs | 176 ++++++++++++ 3 files changed, 672 insertions(+) create mode 100644 crypto/aes-lowmemory/tests/acvp_tests.rs create mode 100644 crypto/aes-lowmemory/tests/fips197_tests.rs create mode 100644 crypto/aes-lowmemory/tests/sp800_38a_tests.rs diff --git a/crypto/aes-lowmemory/tests/acvp_tests.rs b/crypto/aes-lowmemory/tests/acvp_tests.rs new file mode 100644 index 00000000..0ab0b431 --- /dev/null +++ b/crypto/aes-lowmemory/tests/acvp_tests.rs @@ -0,0 +1,266 @@ +//! Known-answer tests against the NIST ACVP `ACVP-AES-ECB` vectors from the `bc-test-data` repo. +//! +//! Requires `bc-test-data` to be cloned alongside this repository, i.e. at `../bc-test-data` +//! relative to the root of this git project. If it is absent the tests print a warning and pass, +//! matching the convention used by the ML-KEM and ML-DSA test suites -- `cargo test` must stay +//! green for someone who has only cloned this repository. +//! +//! # Why ACVP ECB vectors +//! +//! ECB applies the raw permutation to each block independently, so an ECB test vector *is* a +//! block-permutation test vector -- which is the only reason ECB is mentioned in this crate. See +//! the crate docs on why you must never use ECB to encrypt data. +//! +//! The response file records `key`, `pt` and `ct` for every test case regardless of the group's +//! declared direction, so each case is checked in **both** directions: encrypting `pt` must give +//! `ct` and decrypting `ct` must give `pt`. That is strictly stronger than honouring the declared +//! direction, and it means the group metadata in the request file is not needed. +//! +//! # Coverage and one gap +//! +//! The AFT (Algorithm Functional Test) groups cover all three key lengths in both directions, +//! including cases whose plaintext spans several blocks. The six MCT (Monte Carlo Test) groups +//! are **not** implemented: their expected output is a `resultsArray` produced by a chained +//! key/plaintext update rule defined in the ACVP AES specification rather than in FIPS 197, and +//! implementing it from anything other than that specification would be guesswork. The test +//! reports how many it skipped so the gap is visible rather than silent. + +use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256, BLOCK_LEN}; +use bouncycastle_core::key_material::{ + KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, +}; +use bouncycastle_core::traits::SecurityStrength; +use bouncycastle_hex as hex; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Candidate locations, covering `cargo test` run from the crate root or from the repo root. +const TEST_DATA_PATHS: [&str; 2] = [ + "../../../bc-test-data/crypto/aes_tdes_vectors/AES", + "../bc-test-data/crypto/aes_tdes_vectors/AES", +]; + +const RESPONSE_FILE: &str = "ACVP-AES-ECB.4014527.rsp.json"; + +/// Locates the ACVP AES directory, or `None` if `bc-test-data` is not checked out. +fn test_data_dir() -> Option { + for candidate in TEST_DATA_PATHS { + let path = Path::new(candidate); + if path.join(RESPONSE_FILE).exists() { + return Some(path.to_path_buf()); + } + } + println!( + "WARNING: bc-test-data not found (looked in {TEST_DATA_PATHS:?}); \ + ACVP AES-ECB tests will be skipped" + ); + None +} + +/// Builds a `KeyMaterial` from raw ACVP key bytes, including the all-zero keys. +/// +/// The ACVP set deliberately includes an all-zero key (the GFSbox-style groups vary only the +/// plaintext under a zero key). `KeyMaterial` tags an all-zero buffer as [`KeyType::Zeroized`] +/// and will not promote it outside a [`do_hazardous_operations`] closure, which is the right +/// default -- an all-zero key normally means a broken RNG, and `Aes128::new` rejecting it is +/// tested in `fips197_tests.rs`. Here the zero key is deliberate and comes from NIST, so this +/// opts in explicitly rather than the library weakening its guard. +fn cipher_key(bytes: &[u8]) -> KeyMaterial { + assert_eq!(bytes.len(), N, "key length should match the parameter set"); + let mut key = KeyMaterial::::from_bytes_as_type(bytes, KeyType::SymmetricCipherKey) + .expect("ACVP key bytes fit the buffer"); + + if key.key_type() != KeyType::SymmetricCipherKey { + do_hazardous_operations(&mut key, |k| { + k.set_key_type(KeyType::SymmetricCipherKey)?; + k.set_security_strength(SecurityStrength::from_bytes(N)) + }) + .expect("promoting a NIST all-zero test key"); + } + + key +} + +/// A single-block transformation, resolved once per test case rather than per block. +type BlockTransform = Box; + +/// Encrypts or decrypts `data` block by block, i.e. ECB, dispatching on the key length. +fn ecb(key: &[u8], data: &[u8], encrypt: bool) -> Vec { + assert_eq!(data.len() % BLOCK_LEN, 0, "ACVP ECB data must be block-aligned"); + + let transform: BlockTransform = match key.len() { + 16 => { + let km = cipher_key::<16>(key); + let aes = Aes128::new(&km).expect("valid AES-128 key"); + if encrypt { + Box::new(move |b| aes.encrypt_block(b)) + } else { + Box::new(move |b| aes.decrypt_block(b)) + } + } + 24 => { + let km = cipher_key::<24>(key); + let aes = Aes192::new(&km).expect("valid AES-192 key"); + if encrypt { + Box::new(move |b| aes.encrypt_block(b)) + } else { + Box::new(move |b| aes.decrypt_block(b)) + } + } + 32 => { + let km = cipher_key::<32>(key); + let aes = Aes256::new(&km).expect("valid AES-256 key"); + if encrypt { + Box::new(move |b| aes.encrypt_block(b)) + } else { + Box::new(move |b| aes.decrypt_block(b)) + } + } + other => panic!("ACVP AES vectors should only use 16, 24 or 32 byte keys, got {other}"), + }; + + let mut out = Vec::with_capacity(data.len()); + for chunk in data.chunks(BLOCK_LEN) { + // Cannot fail: the length is asserted block-aligned above. + let mut block: [u8; BLOCK_LEN] = chunk.try_into().unwrap(); + transform(&mut block); + out.extend_from_slice(&block); + } + out +} + +/// The same, using the two-block entry points where a pair is available. +fn ecb_pairwise(key: &[u8], data: &[u8], encrypt: bool) -> Vec { + assert_eq!(data.len() % BLOCK_LEN, 0, "ACVP ECB data must be block-aligned"); + let mut blocks: Vec<[u8; BLOCK_LEN]> = + data.chunks(BLOCK_LEN).map(|c| c.try_into().unwrap()).collect(); + + match key.len() { + 16 => { + let km = cipher_key::<16>(key); + let aes = Aes128::new(&km).unwrap(); + run_pairwise(&mut blocks, encrypt, |p, e| { + if e { aes.encrypt_blocks2(p) } else { aes.decrypt_blocks2(p) } + }); + } + 24 => { + let km = cipher_key::<24>(key); + let aes = Aes192::new(&km).unwrap(); + run_pairwise(&mut blocks, encrypt, |p, e| { + if e { aes.encrypt_blocks2(p) } else { aes.decrypt_blocks2(p) } + }); + } + 32 => { + let km = cipher_key::<32>(key); + let aes = Aes256::new(&km).unwrap(); + run_pairwise(&mut blocks, encrypt, |p, e| { + if e { aes.encrypt_blocks2(p) } else { aes.decrypt_blocks2(p) } + }); + } + other => panic!("ACVP AES vectors should only use 16, 24 or 32 byte keys, got {other}"), + } + + blocks.concat() +} + +/// Walks `blocks` two at a time, leaving a trailing odd block to a duplicated pair. +fn run_pairwise( + blocks: &mut [[u8; BLOCK_LEN]], + encrypt: bool, + transform: impl Fn(&mut [[u8; BLOCK_LEN]; 2], bool), +) { + let mut chunks = blocks.chunks_exact_mut(2); + for pair in &mut chunks { + // Cannot fail: `chunks_exact_mut(2)` yields slices of length 2. + let pair: &mut [[u8; BLOCK_LEN]; 2] = pair.try_into().unwrap(); + transform(pair, encrypt); + } + // An odd trailing block still has to go through the two-block path. + if let [last] = chunks.into_remainder() { + let mut pair = [*last, *last]; + transform(&mut pair, encrypt); + *last = pair[0]; + } +} + +#[test] +fn acvp_aes_ecb_known_answer_tests() { + let Some(dir) = test_data_dir() else { return }; + + let contents = fs::read_to_string(dir.join(RESPONSE_FILE)).expect("readable response file"); + let parsed: Value = serde_json::from_str(&contents).expect("valid ACVP JSON"); + + // The ACVP file is an array: element 0 is the version header, element 1 the vector set. + let groups = parsed + .get(1) + .and_then(|set| set.get("testGroups")) + .and_then(Value::as_array) + .expect("testGroups array"); + + let mut checked = 0usize; + let mut skipped_mct = 0usize; + let mut by_key_len = [0usize; 3]; // 128, 192, 256 + + for group in groups { + let tests = group.get("tests").and_then(Value::as_array).expect("tests array"); + for test in tests { + let tc_id = test.get("tcId").and_then(Value::as_u64).expect("tcId"); + + // Monte Carlo groups carry a chained resultsArray instead of a single pt/ct pair. + if test.get("resultsArray").is_some() { + skipped_mct += 1; + continue; + } + + let get = |name: &str| -> Vec { + let s = test + .get(name) + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("tcId {tc_id}: missing field {name}")); + hex::decode(s).unwrap_or_else(|_| panic!("tcId {tc_id}: bad hex in {name}")) + }; + + let key = get("key"); + let pt = get("pt"); + let ct = get("ct"); + + assert_eq!(pt.len(), ct.len(), "tcId {tc_id}: pt and ct differ in length"); + + assert_eq!(ecb(&key, &pt, true), ct, "tcId {tc_id}: AES-{} encrypt", key.len() * 8); + assert_eq!(ecb(&key, &ct, false), pt, "tcId {tc_id}: AES-{} decrypt", key.len() * 8); + + // The two-block path must agree with the single-block path on real vectors too. + assert_eq!( + ecb_pairwise(&key, &pt, true), + ct, + "tcId {tc_id}: AES-{} encrypt via encrypt_blocks2", + key.len() * 8 + ); + assert_eq!( + ecb_pairwise(&key, &ct, false), + pt, + "tcId {tc_id}: AES-{} decrypt via decrypt_blocks2", + key.len() * 8 + ); + + by_key_len[match key.len() { + 16 => 0, + 24 => 1, + _ => 2, + }] += 1; + checked += 1; + } + } + + println!( + "ACVP AES-ECB: {checked} test cases checked in both directions \ + (AES-128: {}, AES-192: {}, AES-256: {}); {skipped_mct} MCT cases skipped", + by_key_len[0], by_key_len[1], by_key_len[2] + ); + + // Guard against a silently-empty run: the published vector set has thousands of AFT cases + // across all three key lengths. + assert!(checked > 1000, "expected the full ACVP AFT set, only checked {checked}"); + assert!(by_key_len.iter().all(|&n| n > 0), "every key length should be covered"); +} diff --git a/crypto/aes-lowmemory/tests/fips197_tests.rs b/crypto/aes-lowmemory/tests/fips197_tests.rs new file mode 100644 index 00000000..d1261b8d --- /dev/null +++ b/crypto/aes-lowmemory/tests/fips197_tests.rs @@ -0,0 +1,230 @@ +//! Known-answer tests from NIST FIPS 197 itself. +//! +//! Appendix B -- the worked single-block AES-128 encryption -- plus its inverse, the two-block +//! path, and key-handling behaviour. +//! +//! The Appendix A key expansions are **not** tested here. The key schedule is deliberately not +//! public API (it is a `Secret` field), and a round-trip through the cipher cannot check it: a +//! wrong `w[i]` is used by encryption and decryption alike, so the round trip still succeeds. +//! Every word of all three expansions is instead checked against Appendix A inside +//! `src/schedule.rs`, where the stored schedule can be decompressed and compared directly. +//! +//! Known-answer coverage for AES-192 and AES-256, which Appendix B does not reach, is in +//! `sp800_38a_tests.rs` and `acvp_tests.rs`. +//! +//! All values here are transcribed from the published FIPS 197 (Update 1) PDF. + +use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256}; +use bouncycastle_core::key_material::{KeyMaterial, KeyMaterialTrait, KeyType}; +use bouncycastle_core::traits::SecurityStrength; + +/// Appendix A.1 / Appendix B key: `2b7e151628aed2a6abf7158809cf4f3c`. +const KEY_128: [u8; 16] = [ + 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c, +]; + +/// Appendix A.2 key: `8e73b0f7da0e6452c810f32b809079e562f8ead2522c6b7b`. +const KEY_192: [u8; 24] = [ + 0x8e, 0x73, 0xb0, 0xf7, 0xda, 0x0e, 0x64, 0x52, 0xc8, 0x10, 0xf3, 0x2b, 0x80, 0x90, 0x79, 0xe5, + 0x62, 0xf8, 0xea, 0xd2, 0x52, 0x2c, 0x6b, 0x7b, +]; + +/// Appendix A.3 key: +/// `603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4`. +const KEY_256: [u8; 32] = [ + 0x60, 0x3d, 0xeb, 0x10, 0x15, 0xca, 0x71, 0xbe, 0x2b, 0x73, 0xae, 0xf0, 0x85, 0x7d, 0x77, 0x81, + 0x1f, 0x35, 0x2c, 0x07, 0x3b, 0x61, 0x08, 0xd7, 0x2d, 0x98, 0x10, 0xa3, 0x09, 0x14, 0xdf, 0xf4, +]; + +fn key_material(bytes: &[u8; N]) -> KeyMaterial { + KeyMaterial::::from_bytes_as_type(bytes, KeyType::SymmetricCipherKey) + .expect("a valid symmetric cipher key") +} + +#[test] +fn appendix_b_encrypts_the_documented_block() { + // Appendix B: Input = 32 43 f6 a8 88 5a 30 8d 31 31 98 a2 e0 37 07 34 + // Key = 2b 7e 15 16 28 ae d2 a6 ab f7 15 88 09 cf 4f 3c + // The final state printed as "output" reads, column by column (Eq 3.7): + // 39 25 84 1d 02 dc 09 fb dc 11 85 97 19 6a 0b 32 + let aes = Aes128::new(&key_material(&KEY_128)).unwrap(); + + let mut block = [ + 0x32, 0x43, 0xf6, 0xa8, 0x88, 0x5a, 0x30, 0x8d, 0x31, 0x31, 0x98, 0xa2, 0xe0, 0x37, 0x07, + 0x34, + ]; + aes.encrypt_block(&mut block); + assert_eq!( + block, + [ + 0x39, 0x25, 0x84, 0x1d, 0x02, 0xdc, 0x09, 0xfb, 0xdc, 0x11, 0x85, 0x97, 0x19, 0x6a, + 0x0b, 0x32 + ] + ); +} + +#[test] +fn appendix_b_decrypts_back_to_the_documented_input() { + let aes = Aes128::new(&key_material(&KEY_128)).unwrap(); + + let mut block = [ + 0x39, 0x25, 0x84, 0x1d, 0x02, 0xdc, 0x09, 0xfb, 0xdc, 0x11, 0x85, 0x97, 0x19, 0x6a, 0x0b, + 0x32, + ]; + aes.decrypt_block(&mut block); + assert_eq!( + block, + [ + 0x32, 0x43, 0xf6, 0xa8, 0x88, 0x5a, 0x30, 0x8d, 0x31, 0x31, 0x98, 0xa2, 0xe0, 0x37, + 0x07, 0x34 + ] + ); +} + +#[test] +fn appendix_b_two_block_path_agrees_with_the_single_block_path() { + let aes = Aes128::new(&key_material(&KEY_128)).unwrap(); + let input = [ + 0x32, 0x43, 0xf6, 0xa8, 0x88, 0x5a, 0x30, 0x8d, 0x31, 0x31, 0x98, 0xa2, 0xe0, 0x37, 0x07, + 0x34, + ]; + let expected = [ + 0x39, 0x25, 0x84, 0x1d, 0x02, 0xdc, 0x09, 0xfb, 0xdc, 0x11, 0x85, 0x97, 0x19, 0x6a, 0x0b, + 0x32, + ]; + + // Pairing the Appendix B block with an unrelated one must not disturb either half. + let other = [0xAAu8; 16]; + let mut other_alone = other; + aes.encrypt_block(&mut other_alone); + + let mut pair = [input, other]; + aes.encrypt_blocks2(&mut pair); + assert_eq!(pair[0], expected); + assert_eq!(pair[1], other_alone); + + // ...and in the other slot, which is a different bit position in the interleave. + let mut pair = [other, input]; + aes.encrypt_blocks2(&mut pair); + assert_eq!(pair[0], other_alone); + assert_eq!(pair[1], expected); +} + +/// Encryption and decryption are inverses, under each Appendix A key. +/// +/// This checks `decrypt_block` really inverts `encrypt_block` from the same stored schedule, +/// which is the load-bearing claim of following FIPS 197 Algorithm 3 rather than Sec 5.3.5. It +/// deliberately makes no claim about the schedule being *correct* -- see the module docs. +#[test] +fn encryption_and_decryption_are_inverses_for_all_three_key_lengths() { + let aes128 = Aes128::new(&key_material(&KEY_128)).unwrap(); + let aes192 = Aes192::new(&key_material(&KEY_192)).unwrap(); + let aes256 = Aes256::new(&key_material(&KEY_256)).unwrap(); + + for block in [[0u8; 16], [0xFFu8; 16], core::array::from_fn(|i| i as u8)] { + let mut b = block; + aes128.encrypt_block(&mut b); + assert_ne!(b, block, "AES-128 must actually transform the block"); + aes128.decrypt_block(&mut b); + assert_eq!(b, block, "AES-128 round trip with the Appendix A.1 key"); + + let mut b = block; + aes192.encrypt_block(&mut b); + assert_ne!(b, block, "AES-192 must actually transform the block"); + aes192.decrypt_block(&mut b); + assert_eq!(b, block, "AES-192 round trip with the Appendix A.2 key"); + + let mut b = block; + aes256.encrypt_block(&mut b); + assert_ne!(b, block, "AES-256 must actually transform the block"); + aes256.decrypt_block(&mut b); + assert_eq!(b, block, "AES-256 round trip with the Appendix A.3 key"); + } +} + +/// The three key lengths must give different results for the same input. +/// +/// Guards against a parameter set silently using another set's `Nr` or `Nk`. +#[test] +fn the_three_key_lengths_are_distinct_permutations() { + // A key whose first 16 bytes are shared, so only Nk/Nr and the extra key bytes differ. + let shared = [0x11u8; 32]; + let aes128 = Aes128::new(&key_material::<16>(&shared[..16].try_into().unwrap())).unwrap(); + let aes192 = Aes192::new(&key_material::<24>(&shared[..24].try_into().unwrap())).unwrap(); + let aes256 = Aes256::new(&key_material(&shared)).unwrap(); + + let block = [0x42u8; 16]; + let mut b128 = block; + let mut b192 = block; + let mut b256 = block; + aes128.encrypt_block(&mut b128); + aes192.encrypt_block(&mut b192); + aes256.encrypt_block(&mut b256); + + assert_ne!(b128, b192); + assert_ne!(b192, b256); + assert_ne!(b128, b256); +} + +// ---- key handling ----------------------------------------------------------------------- + +#[test] +fn a_key_of_the_wrong_type_is_rejected() { + // KeyType::Seed is not a cipher key: a seed reused directly as an AES key is a real mistake + // and the type system tracks enough to catch it. + let key = KeyMaterial::<16>::from_bytes_as_type(&[0x01; 16], KeyType::Seed).unwrap(); + assert!(Aes128::new(&key).is_err()); + + let key = KeyMaterial::<16>::from_bytes_as_type(&[0x01; 16], KeyType::MACKey).unwrap(); + assert!(Aes128::new(&key).is_err()); +} + +#[test] +fn a_key_of_the_wrong_length_is_rejected() { + // The capacity is right but only part of it is populated, so `key_len()` disagrees with the + // parameter set. This is the one length error the const generic cannot catch by itself. + let key = + KeyMaterial::<32>::from_bytes_as_type(&[0x01; 16], KeyType::SymmetricCipherKey).unwrap(); + assert!(Aes256::new(&key).is_err()); +} + +#[test] +fn a_key_carrying_too_low_a_security_strength_is_rejected() { + // A full-length key whose material was only ever derived at a lower security strength must + // not be usable at the strength its length implies. `from_bytes_as_type` tags a 32-byte key + // as 256-bit, so lower it deliberately -- lowering does not need a hazardous closure, only + // raising does. + let mut key = + KeyMaterial::<32>::from_bytes_as_type(&[0x01; 32], KeyType::SymmetricCipherKey).unwrap(); + assert_eq!(key.security_strength(), SecurityStrength::_256bit); + + key.set_security_strength(SecurityStrength::_128bit).unwrap(); + assert!( + Aes256::new(&key).is_err(), + "AES-256 must reject a 32-byte key only derived at the 128-bit strength" + ); + + // The same key at its full strength is fine, so the rejection is about the strength tag and + // not about anything else having gone wrong with the key. + let good = + KeyMaterial::<32>::from_bytes_as_type(&[0x01; 32], KeyType::SymmetricCipherKey).unwrap(); + assert!(Aes256::new(&good).is_ok()); +} + +#[test] +fn a_correctly_typed_key_of_each_length_is_accepted() { + assert!(Aes128::new(&key_material(&KEY_128)).is_ok()); + assert!(Aes192::new(&key_material(&KEY_192)).is_ok()); + assert!(Aes256::new(&key_material(&KEY_256)).is_ok()); +} + +#[test] +fn debug_does_not_print_the_key_schedule() { + // The schedule is secret; `Debug` must not be a way to leak it. + let aes = Aes128::new(&key_material(&KEY_128)).unwrap(); + let rendered = format!("{aes:?}"); + assert_eq!(rendered, "AES-128"); + // No byte of the key should appear as hex in the output. + assert!(!rendered.contains("2b")); + assert!(!rendered.contains("7e")); +} diff --git a/crypto/aes-lowmemory/tests/sp800_38a_tests.rs b/crypto/aes-lowmemory/tests/sp800_38a_tests.rs new file mode 100644 index 00000000..8e975eca --- /dev/null +++ b/crypto/aes-lowmemory/tests/sp800_38a_tests.rs @@ -0,0 +1,176 @@ +//! Known-answer tests from NIST SP 800-38A Appendix F.1, "ECB Example Vectors". +//! +//! These are the only NIST-published known-answer vectors for AES-192 and AES-256 that live in a +//! specification document rather than a separate vector file -- FIPS 197 Appendix B only covers +//! AES-128, and FIPS 197 (Update 1) removed the Appendix C example vectors in favour of a pointer +//! to the CSRC website. `acvp_tests.rs` covers far more cases, but only when the `bc-test-data` +//! repository is present, so these vectors are the always-available known-answer floor. +//! +//! ECB applies the raw permutation to each block independently, so an ECB example vector *is* a +//! block-permutation test vector. (That is the only reason ECB appears in this crate; see the +//! crate docs on why you must not use it to encrypt anything.) +//! +//! The keys are the same three keys as FIPS 197 Appendix A.1, A.2 and A.3, so these vectors also +//! pin each key expansion against a NIST-published answer, in both directions. +//! +//! Transcribed from the published SP 800-38A PDF, sections F.1.1 through F.1.6. + +use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256, BLOCK_LEN}; +use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +use bouncycastle_hex as hex; + +/// The four plaintext blocks shared by every F.1 subsection. +const PLAINTEXTS: [&str; 4] = [ + "6bc1bee22e409f96e93d7e117393172a", + "ae2d8a571e03ac9c9eb76fac45af8e51", + "30c81c46a35ce411e5fbc1191a0a52ef", + "f69f2445df4f9b17ad2b417be66c3710", +]; + +/// F.1.1 / F.1.2 key. +const KEY_128: &str = "2b7e151628aed2a6abf7158809cf4f3c"; +/// F.1.1 ECB-AES128.Encrypt output blocks. +const CIPHERTEXTS_128: [&str; 4] = [ + "3ad77bb40d7a3660a89ecaf32466ef97", + "f5d3d58503b9699de785895a96fdbaaf", + "43b1cd7f598ece23881b00e3ed030688", + "7b0c785e27e8ad3f8223207104725dd4", +]; + +/// F.1.3 / F.1.4 key. +const KEY_192: &str = "8e73b0f7da0e6452c810f32b809079e562f8ead2522c6b7b"; +/// F.1.3 ECB-AES192.Encrypt output blocks. +const CIPHERTEXTS_192: [&str; 4] = [ + "bd334f1d6e45f25ff712a214571fa5cc", + "974104846d0ad3ad7734ecb3ecee4eef", + "ef7afd2270e2e60adce0ba2face6444e", + "9a4b41ba738d6c72fb16691603c18e0e", +]; + +/// F.1.5 / F.1.6 key. +const KEY_256: &str = "603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4"; +/// F.1.5 ECB-AES256.Encrypt output blocks. +const CIPHERTEXTS_256: [&str; 4] = [ + "f3eed1bdb5d2a03c064b5a7e3db181f8", + "591ccb10d410ed26dc5ba74a31362870", + "b6ed21b99ca6f4f9f153e7b1beafed1d", + "23304b7a39f9f3ff067d8d8f9e24ecc7", +]; + +fn block(hex_str: &str) -> [u8; BLOCK_LEN] { + hex::decode(hex_str).expect("valid hex").try_into().expect("16 bytes") +} + +fn key_material(hex_str: &str) -> KeyMaterial { + let bytes = hex::decode(hex_str).expect("valid hex"); + assert_eq!(bytes.len(), N, "key length"); + KeyMaterial::::from_bytes_as_type(&bytes, KeyType::SymmetricCipherKey) + .expect("a valid symmetric cipher key") +} + +// ---- F.1.1 / F.1.2 ECB-AES128 ------------------------------------------------------------- + +#[test] +fn f_1_1_ecb_aes128_encrypt() { + let aes = Aes128::new(&key_material::<16>(KEY_128)).unwrap(); + for (i, (pt, ct)) in PLAINTEXTS.iter().zip(CIPHERTEXTS_128.iter()).enumerate() { + let mut b = block(pt); + aes.encrypt_block(&mut b); + assert_eq!(b, block(ct), "F.1.1 block #{}", i + 1); + } +} + +#[test] +fn f_1_2_ecb_aes128_decrypt() { + let aes = Aes128::new(&key_material::<16>(KEY_128)).unwrap(); + for (i, (pt, ct)) in PLAINTEXTS.iter().zip(CIPHERTEXTS_128.iter()).enumerate() { + let mut b = block(ct); + aes.decrypt_block(&mut b); + assert_eq!(b, block(pt), "F.1.2 block #{}", i + 1); + } +} + +// ---- F.1.3 / F.1.4 ECB-AES192 ------------------------------------------------------------- + +#[test] +fn f_1_3_ecb_aes192_encrypt() { + let aes = Aes192::new(&key_material::<24>(KEY_192)).unwrap(); + for (i, (pt, ct)) in PLAINTEXTS.iter().zip(CIPHERTEXTS_192.iter()).enumerate() { + let mut b = block(pt); + aes.encrypt_block(&mut b); + assert_eq!(b, block(ct), "F.1.3 block #{}", i + 1); + } +} + +#[test] +fn f_1_4_ecb_aes192_decrypt() { + let aes = Aes192::new(&key_material::<24>(KEY_192)).unwrap(); + for (i, (pt, ct)) in PLAINTEXTS.iter().zip(CIPHERTEXTS_192.iter()).enumerate() { + let mut b = block(ct); + aes.decrypt_block(&mut b); + assert_eq!(b, block(pt), "F.1.4 block #{}", i + 1); + } +} + +// ---- F.1.5 / F.1.6 ECB-AES256 ------------------------------------------------------------- + +#[test] +fn f_1_5_ecb_aes256_encrypt() { + let aes = Aes256::new(&key_material::<32>(KEY_256)).unwrap(); + for (i, (pt, ct)) in PLAINTEXTS.iter().zip(CIPHERTEXTS_256.iter()).enumerate() { + let mut b = block(pt); + aes.encrypt_block(&mut b); + assert_eq!(b, block(ct), "F.1.5 block #{}", i + 1); + } +} + +#[test] +fn f_1_6_ecb_aes256_decrypt() { + let aes = Aes256::new(&key_material::<32>(KEY_256)).unwrap(); + for (i, (pt, ct)) in PLAINTEXTS.iter().zip(CIPHERTEXTS_256.iter()).enumerate() { + let mut b = block(ct); + aes.decrypt_block(&mut b); + assert_eq!(b, block(pt), "F.1.6 block #{}", i + 1); + } +} + +// ---- the two-block path against the same vectors ------------------------------------------- + +/// The two-block entry points must produce exactly the single-block answers. +/// +/// This is the test that pins the interleave: a mistake in which bit of each pair belongs to +/// which block shows up here and nowhere in the single-block tests, because a single-block call +/// puts the same data in both halves. +#[test] +fn two_block_path_matches_the_f_1_vectors() { + let aes = Aes128::new(&key_material::<16>(KEY_128)).unwrap(); + + // Blocks 1 and 2 as a pair, then 3 and 4. + for chunk in 0..2 { + let (i, j) = (chunk * 2, chunk * 2 + 1); + let mut pair = [block(PLAINTEXTS[i]), block(PLAINTEXTS[j])]; + aes.encrypt_blocks2(&mut pair); + assert_eq!(pair[0], block(CIPHERTEXTS_128[i]), "pair {chunk} slot 0"); + assert_eq!(pair[1], block(CIPHERTEXTS_128[j]), "pair {chunk} slot 1"); + + aes.decrypt_blocks2(&mut pair); + assert_eq!(pair[0], block(PLAINTEXTS[i])); + assert_eq!(pair[1], block(PLAINTEXTS[j])); + } +} + +/// Swapping the two slots must swap the two results, and nothing else. +#[test] +fn two_block_path_is_slot_symmetric() { + let aes = Aes256::new(&key_material::<32>(KEY_256)).unwrap(); + + let mut forward = [block(PLAINTEXTS[0]), block(PLAINTEXTS[1])]; + let mut reversed = [block(PLAINTEXTS[1]), block(PLAINTEXTS[0])]; + aes.encrypt_blocks2(&mut forward); + aes.encrypt_blocks2(&mut reversed); + + assert_eq!(forward[0], reversed[1]); + assert_eq!(forward[1], reversed[0]); + assert_eq!(forward[0], block(CIPHERTEXTS_256[0])); + assert_eq!(forward[1], block(CIPHERTEXTS_256[1])); +} From 26467b716987ba0d31798c14c6c2969f0f9101fd Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Mon, 31 Aug 2026 16:21:13 +0700 Subject: [PATCH 3/7] Added benchmarking for aes-lowmemory (#98) --- crypto/aes-lowmemory/benches/aes_benches.rs | 183 ++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 crypto/aes-lowmemory/benches/aes_benches.rs diff --git a/crypto/aes-lowmemory/benches/aes_benches.rs b/crypto/aes-lowmemory/benches/aes_benches.rs new file mode 100644 index 00000000..82d81003 --- /dev/null +++ b/crypto/aes-lowmemory/benches/aes_benches.rs @@ -0,0 +1,183 @@ +//! Criterion benchmarks for the bit-sliced AES engine. +//! +//! The comparison that matters here is `encrypt_block` against `encrypt_blocks2` over the same +//! number of bytes. The bit-sliced state holds two blocks, so a single-block call does twice the +//! necessary work; the two-block path should be close to twice the throughput. That ratio is the +//! argument for modes of operation using the two-block entry points wherever their blocks are +//! independent (CTR, and the decrypt direction of CBC and CFB). + +use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256, BLOCK_LEN}; +use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +use bouncycastle_core::traits::RNG; +use bouncycastle_rng as rng; +use criterion::{Criterion, Throughput, criterion_group, criterion_main}; +use std::hint::black_box; + +/// 16 KiB of data, i.e. 1024 AES blocks. +const NUM_BLOCKS: usize = 1024; +const DATA_LEN: usize = NUM_BLOCKS * BLOCK_LEN; + +fn random_blocks() -> Vec<[u8; BLOCK_LEN]> { + let mut blocks = vec![[0u8; BLOCK_LEN]; NUM_BLOCKS]; + let mut generator = rng::DefaultRNG::default(); + for block in blocks.iter_mut() { + generator.next_bytes_out(block).unwrap(); + } + blocks +} + +fn key() -> KeyMaterial { + let mut bytes = [0u8; N]; + rng::DefaultRNG::default().next_bytes_out(&mut bytes).unwrap(); + KeyMaterial::::from_bytes_as_type(&bytes, KeyType::SymmetricCipherKey).unwrap() +} + +fn bench_key_expansion(c: &mut Criterion) { + let mut group = c.benchmark_group("aes_lowmemory::key expansion"); + + let key128 = key::<16>(); + group.bench_function("Aes128::new()", |b| { + b.iter(|| black_box(Aes128::new(black_box(&key128)).unwrap())) + }); + + let key192 = key::<24>(); + group.bench_function("Aes192::new()", |b| { + b.iter(|| black_box(Aes192::new(black_box(&key192)).unwrap())) + }); + + let key256 = key::<32>(); + group.bench_function("Aes256::new()", |b| { + b.iter(|| black_box(Aes256::new(black_box(&key256)).unwrap())) + }); + + group.finish(); +} + +fn bench_aes128(c: &mut Criterion) { + let aes = Aes128::new(&key::<16>()).unwrap(); + let blocks = random_blocks(); + + let mut group = c.benchmark_group("aes_lowmemory::Aes128"); + group.throughput(Throughput::Bytes(DATA_LEN as u64)); + + group.bench_function("16KiB -- .encrypt_block() x1024", |b| { + b.iter(|| { + let mut buf = blocks.clone(); + for block in buf.iter_mut() { + aes.encrypt_block(black_box(block)); + } + black_box(&buf); + }) + }); + + group.bench_function("16KiB -- .encrypt_blocks2() x512", |b| { + b.iter(|| { + let mut buf = blocks.clone(); + for pair in buf.chunks_exact_mut(2) { + // `try_into` cannot fail: `chunks_exact_mut(2)` yields slices of length 2. + let pair: &mut [[u8; BLOCK_LEN]; 2] = pair.try_into().unwrap(); + aes.encrypt_blocks2(black_box(pair)); + } + black_box(&buf); + }) + }); + + group.bench_function("16KiB -- .decrypt_block() x1024", |b| { + b.iter(|| { + let mut buf = blocks.clone(); + for block in buf.iter_mut() { + aes.decrypt_block(black_box(block)); + } + black_box(&buf); + }) + }); + + group.bench_function("16KiB -- .decrypt_blocks2() x512", |b| { + b.iter(|| { + let mut buf = blocks.clone(); + for pair in buf.chunks_exact_mut(2) { + let pair: &mut [[u8; BLOCK_LEN]; 2] = pair.try_into().unwrap(); + aes.decrypt_blocks2(black_box(pair)); + } + black_box(&buf); + }) + }); + + group.finish(); +} + +fn bench_aes192(c: &mut Criterion) { + let aes = Aes192::new(&key::<24>()).unwrap(); + let blocks = random_blocks(); + + let mut group = c.benchmark_group("aes_lowmemory::Aes192"); + group.throughput(Throughput::Bytes(DATA_LEN as u64)); + + group.bench_function("16KiB -- .encrypt_block() x1024", |b| { + b.iter(|| { + let mut buf = blocks.clone(); + for block in buf.iter_mut() { + aes.encrypt_block(black_box(block)); + } + black_box(&buf); + }) + }); + + group.bench_function("16KiB -- .encrypt_blocks2() x512", |b| { + b.iter(|| { + let mut buf = blocks.clone(); + for pair in buf.chunks_exact_mut(2) { + let pair: &mut [[u8; BLOCK_LEN]; 2] = pair.try_into().unwrap(); + aes.encrypt_blocks2(black_box(pair)); + } + black_box(&buf); + }) + }); + + group.finish(); +} + +fn bench_aes256(c: &mut Criterion) { + let aes = Aes256::new(&key::<32>()).unwrap(); + let blocks = random_blocks(); + + let mut group = c.benchmark_group("aes_lowmemory::Aes256"); + group.throughput(Throughput::Bytes(DATA_LEN as u64)); + + group.bench_function("16KiB -- .encrypt_block() x1024", |b| { + b.iter(|| { + let mut buf = blocks.clone(); + for block in buf.iter_mut() { + aes.encrypt_block(black_box(block)); + } + black_box(&buf); + }) + }); + + group.bench_function("16KiB -- .encrypt_blocks2() x512", |b| { + b.iter(|| { + let mut buf = blocks.clone(); + for pair in buf.chunks_exact_mut(2) { + let pair: &mut [[u8; BLOCK_LEN]; 2] = pair.try_into().unwrap(); + aes.encrypt_blocks2(black_box(pair)); + } + black_box(&buf); + }) + }); + + group.bench_function("16KiB -- .decrypt_blocks2() x512", |b| { + b.iter(|| { + let mut buf = blocks.clone(); + for pair in buf.chunks_exact_mut(2) { + let pair: &mut [[u8; BLOCK_LEN]; 2] = pair.try_into().unwrap(); + aes.decrypt_blocks2(black_box(pair)); + } + black_box(&buf); + }) + }); + + group.finish(); +} + +criterion_group!(benches, bench_key_expansion, bench_aes128, bench_aes192, bench_aes256); +criterion_main!(benches); From f1868351e73991ffc13ee8d11f71fc3a085e1271 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Mon, 31 Aug 2026 16:21:39 +0700 Subject: [PATCH 4/7] Added Cargo.toml and summary.md (#98) --- crypto/aes-lowmemory/Cargo.toml | 18 ++ crypto/aes-lowmemory/summary.md | 475 ++++++++++++++++++++++++++++++++ 2 files changed, 493 insertions(+) create mode 100644 crypto/aes-lowmemory/Cargo.toml create mode 100644 crypto/aes-lowmemory/summary.md diff --git a/crypto/aes-lowmemory/Cargo.toml b/crypto/aes-lowmemory/Cargo.toml new file mode 100644 index 00000000..07fdc784 --- /dev/null +++ b/crypto/aes-lowmemory/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "bouncycastle-aes-lowmemory" +version.workspace = true +edition.workspace = true + +[dependencies] +bouncycastle-core.workspace = true +bouncycastle-utils.workspace = true + +[dev-dependencies] +bouncycastle-hex.workspace = true +bouncycastle-rng.workspace = true +criterion.workspace = true +serde_json = "1.0" + +[[bench]] +name = "aes_benches" +harness = false diff --git a/crypto/aes-lowmemory/summary.md b/crypto/aes-lowmemory/summary.md new file mode 100644 index 00000000..4933c652 --- /dev/null +++ b/crypto/aes-lowmemory/summary.md @@ -0,0 +1,475 @@ +# `crypto/aes-lowmemory` — implementation summary + +A constant-time, table-free AES block cipher engine (NIST FIPS 197), added 2026-08-31 on branch +`feature/officialfrancismendoza/98-AES-lowmemory`. + +This document is the reviewer's orientation: what was built, why the design is the way it is, what +was verified and how, and — importantly — the three places where the working plan or model recall +turned out to be wrong. For end-user documentation see the crate docs in +[`src/lib.rs`](src/lib.rs); for the reasoning behind each individual constant, see the module docs +in [`src/bitslice.rs`](src/bitslice.rs) and [`src/round.rs`](src/round.rs), which are the right +place to start reading the source. + +--- + +## 1. What this crate is (and is not) + +It provides the **raw AES keyed permutation** — `Aes128`, `Aes192`, `Aes256` — transforming exactly +16 bytes at a time. It is not something you can encrypt data with: used directly on data it *is* +ECB, which is not confidential. Modes of operation and padding are separate layers. + +Consistent with the earlier scoping decision for the AES engine, the crate deliberately ships: + +* **no CLI subcommand** — a bare permutation can only offer ECB, +* **no factory registration**, +* **no `core` cipher-trait implementations** (`SymmetricCipher` / `BlockCipherEncryptor` / + `BlockCipherDecryptor`) — those traits are about encrypting *data* and generating initialisation + data, which are mode-of-operation concerns, +* **no `AlgorithmOID`** — NIST CSOR assigns AES OIDs per mode, never to the bare cipher. + +It does implement `core::traits::Algorithm` (name and maximum security strength), which is +metadata rather than a data-encryption API. + +--- + +## 2. Design + +### 2.1 Why there is no lookup table + +FIPS 197 Sec 5.1.1 presents the S-box as a 256-entry table (Table 4), and almost every AES +implementation stores it as one — 256 bytes, or 2–8 KiB for the "T-table" variants that fold +MixColumns in. A table indexed by a byte of the state is indexed by **secret data**, so on any CPU +with a data cache the access pattern, and therefore the timing, depends on the key. That is the +standard, repeatedly-demonstrated AES cache-timing attack, and it cannot be fixed while the lookup +remains. + +Bouncy Castle's `AESLightEngine` in the Java and C# ports keeps two 256-byte S-box tables in order +to be *small*, not to be constant-time, and leaks through both the cipher and the key schedule. + +This crate has no tables at all. The consequence worth stating plainly: **the low-memory AES and +the constant-time AES are the same implementation here.** Removing the tables is what makes it both. + +### 2.2 Bit-slicing + +The state is transposed so that each of eight `u32` words holds one *bit position* of every byte: +word `q[k]` collects bit `k` of all the bytes. In that representation the S-box becomes a fixed +Boolean circuit and one `&` or `^` applies a gate to every byte position at once. Nothing is ever +indexed by a secret and nothing branches on one. + +Eight 32-bit words hold 256 bits = 32 bytes = **two** AES blocks, so blocks are processed in pairs. +ShiftRows and MixColumns become masks and rotations in the same representation, and the key +schedule is stored already bit-sliced, so no transposition happens inside the round loop. + +### 2.3 The bit layout — derived, not assumed + +`ortho` transposes, within each byte-lane of the eight words, the 8×8 bit matrix indexed by +(word number, bit number within the lane): + +``` +after ortho: q[k] bit (8L + i) == before ortho: q[i] bit (8L + k) +``` + +`pack` loads block A as four little-endian `u32`s into the even words and block B into the odd +words, so before `ortho` byte-lane `L` of word `2c` holds `A[4c + L]`. Substituting `j = 4c + L` +and FIPS 197 Eq (3.6) `s[r,c] = in[r + 4c]` — which makes `r = j mod 4`, `c = j div 4` — gives: + +``` +q[k] bit (8r + 2c) == bit k of s[r,c] of block A +q[k] bit (8r + 2c + 1) == bit k of s[r,c] of block B +``` + +**The byte-lane of the word selects the state row `r`; the bit-pair within that lane selects the +state column `c`; the low bit of the pair is block A and the high bit is block B.** + +``` + c=0 c=1 c=2 c=3 + r=0 | 0 2 4 6 + r=1 | 8 10 12 14 (bit position of block A; + r=2 | 16 18 20 22 add 1 for block B) + r=3 | 24 26 28 30 +``` + +Everything else follows from this table: + +* **ShiftRows** only permutes within rows, and a row is a byte-lane, so it is a rotation *inside* + each byte-lane by `2r` positions (one column = two bit positions). +* **MixColumns** combines the four rows of a column, and `rotate_right(8)` moves one row, so it is + expressible with rotations by 8 and 16 plus the `{1b}` reduction, with no shuffling. + +`test_layout_matches_the_documented_table` pins this exhaustively. Every mask in the crate is only +correct relative to it, which is why it is written down rather than left implicit. + +### 2.4 Both directions from one key schedule + +Decryption follows **FIPS 197 Algorithm 3** (the straight inverse cipher), not the equivalent +inverse cipher of Sec 5.3.5. Algorithm 3 applies InvMixColumns *after* AddRoundKey, so it uses the +**unmodified** key schedule; Sec 5.3.5 reorders the round and needs a separate schedule with +InvMixColumns applied to every round key (Algorithm 5, `KEYEXPANSIONEIC()`). + +Following Algorithm 3 is what lets one `Aes` value encrypt *and* decrypt from a single stored +schedule — no second copy, no transformation at construction time, no direction flag. That is the +whole reason both directions are available at 176–240 bytes of state. + +### 2.5 Typing the three key sizes + +The schedule length `4·(Nr+1)` (44/52/60 words) cannot be written as an expression over another +const generic parameter, so a params trait is used instead — the same pattern as the +`HashDRBG80090AParams_*` types in `bouncycastle-rng`: + +```rust +pub trait AesParams: AesParamsSealed { + const KEY_LEN: usize; // 16 | 24 | 32 (FIPS 197 Sec 6.1) + const NK: usize; // 4 | 6 | 8 + const NR: usize; // 10 | 12 | 14 + const ALG_NAME: &'static str; + type Schedule: ZeroizablePrimitive + AsRef<[u32]> + AsMut<[u32]>; +} +``` + +`AesParams` has a **private** supertrait, so only the three types in `schedule.rs` can implement +it and no downstream crate can instantiate the cipher with an unapproved key length or round count. +(This is what `#![allow(private_bounds)]` in `lib.rs` is for.) + +The three `new` constructors and `Algorithm` impls are written out **longhand rather than with +`macro_rules!`**, because `cargo mutants` cannot see into macro bodies and a macro would hide the +key checks and security-strength constants from mutation testing. + +### 2.6 Memory + +No lookup tables, no heap allocation. The only persistent state is the key schedule, stored in a +compressed bit-sliced form: bit-slicing is a permutation of bits so it does not change the size, and +because both interleaved blocks use the same key the two halves of a bit-sliced round key are +identical, so one word of each pair is redundant. `round_key` re-doubles a single round key onto the +stack when the round loop needs it. + +| Type | Key | `Nr` | Schedule (persistent) | Tables | +|---|---|---|---|---| +| `Aes128` | 16 B | 10 | 176 B | 0 B | +| `Aes192` | 24 B | 12 | 208 B | 0 B | +| `Aes256` | 32 B | 14 | 240 B | 0 B | + +These are **measured**, not asserted — `cargo run --release -p mem_usage_benches --bin bench_aes_mem_usage` +prints exactly 176/208/240, and `test_engine_sizes_match_the_documented_memory_table` pins them so +the doc table cannot drift. + +Two things deliberately avoided: storing the doubled 8-plane schedule (352/416/480 B), and +mirroring BearSSL's `uint32_t skey[120]` 480-byte scratch buffer during expansion. `expand` writes +the classical schedule into the final array and then rewrites it in place, one round key at a time, +using eight words of stack. + +Per-call stack usage is independent of key length: 32 B of bit-sliced state for the two blocks, +32 B for the expanded round key, plus circuit temporaries that mostly stay in registers. + +### 2.7 API surface + +```rust +Aes128::new(&KeyMaterial<16>) -> Result // and 24 / 32 +aes.encrypt_block(&mut [u8; 16]) // infallible +aes.decrypt_block(&mut [u8; 16]) +aes.encrypt_blocks2(&mut [[u8; 16]; 2]) // the natural unit of work +aes.decrypt_blocks2(&mut [[u8; 16]; 2]) +``` + +No `init()`, no `reset()`, no direction flag: constructors set up state and a constructed value is +always ready. There are no one-shot statics on the permutation because +`Aes128::new(&key)?.encrypt_block(..)` already *is* the one shot; data-level one-shots belong to the +modes, which take arbitrary-length input and generate their own initialisation data. + +`encrypt_blocks2` / `decrypt_blocks2` are the pair form and roughly double throughput. A +single-block call duplicates the block into both halves and discards one result, so it does twice +the necessary work — modes whose blocks are independent (CTR, and the decrypt direction of CBC and +CFB) should prefer the pair form; CBC *encryption* cannot, since its blocks are serially dependent. + +Duplicating rather than zero-filling the unused half costs the same and buys a free self-check (the +two halves must agree, which `debug_assert` verifies). It is not a security property — the unused +half is never returned either way. + +--- + +## 3. Files + +### New crate + +| File | Lines | Contents | +|---|---|---| +| `Cargo.toml` | 18 | deps: `core`, `utils`; dev-deps: `hex`, `rng`, `criterion`, `serde_json` | +| [`src/lib.rs`](src/lib.rs) | 175 | Crate docs: Usage Examples, Design, Memory Usage, Security Considerations, Provenance | +| [`src/bitslice.rs`](src/bitslice.rs) | 210 | `ortho`, `pack`, `unpack`; the layout table and its exhaustive test | +| [`src/sbox.rs`](src/sbox.rs) | 377 | The 113-gate circuit; `inv_sbox`; Tables 4 and 6 for tests | +| [`src/round.rs`](src/round.rs) | 507 | AddRoundKey, ShiftRows, MixColumns and inverses; byte-wise references | +| [`src/schedule.rs`](src/schedule.rs) | 456 | `AesParams`, `expand` (Alg 2), `round_key`; Appendix A tables | +| [`src/aes.rs`](src/aes.rs) | 276 | `Aes

`, the three aliases, Alg 1 and Alg 3, key validation | +| [`tests/fips197_tests.rs`](tests/fips197_tests.rs) | 230 | Appendix B; two-block path; key handling | +| [`tests/sp800_38a_tests.rs`](tests/sp800_38a_tests.rs) | 176 | SP 800-38A F.1.1–F.1.6 | +| [`tests/acvp_tests.rs`](tests/acvp_tests.rs) | 266 | NIST ACVP `ACVP-AES-ECB` loader | +| [`benches/aes_benches.rs`](benches/aes_benches.rs) | 183 | criterion; key expansion and 16 KiB throughput, 1-block vs 2-block | + +### Changed elsewhere + +* `Cargo.toml` — `bouncycastle-aes-lowmemory` in `workspace.dependencies` and in the umbrella + `[dependencies]`. +* `src/lib.rs` — `pub use bouncycastle_aes_lowmemory as aes_lowmemory;`. +* `mem_usage_benches/bench_aes_mem_usage.rs` (new, 131 lines), plus its `[[bin]]` entry in + `mem_usage_benches/Cargo.toml` and a `mod` line in `mem_usage_benches/lib.rs`. +* `alpha_0.1.3_release_notes.md` — a "Major features" entry. + +--- + +## 4. Verification + +58 tests, all passing. The strategy is that **no expected value anywhere was written from +recall** — every one is transcribed from a downloaded specification PDF or an official vector file. + +| Source | What is checked | +|---|---| +| FIPS 197 Table 4 / Table 6 | **Exhaustive**: all 256 inputs to `sbox` and `inv_sbox`. This is what makes the 113 gates trustworthy, so it must stay exhaustive. | +| FIPS 197 Sec 5.1.1 | The worked example `S[{53}] = {ed}`. | +| FIPS 197 Eq 5.5 / 5.8 / 5.12 / 5.15 | ShiftRows and MixColumns and their inverses, against byte-wise references written from the equations — plus a second literal transcription of Eq 5.8/5.15 cross-checking the matrix form. | +| FIPS 197 Sec 4.2 / Eq 4.5 | The test-only `xtimes`/`gf_mul` helpers against the Sec 4.2 worked chain and `{57}·{13} = {fe}`. | +| FIPS 197 Table 5 | `RCON` re-derived by repeated XTIMES and compared. | +| FIPS 197 Appendix A.1/A.2/A.3 | **Every one of the 156 schedule words**, for all three key lengths. | +| FIPS 197 Appendix B | The worked AES-128 block, both directions, and via the two-block path in both slots. | +| SP 800-38A F.1.1–F.1.6 | ECB known answers, all three key lengths, both directions. | +| NIST ACVP `ACVP-AES-ECB` | **2138 cases** (AES-128: 588, AES-192: 720, AES-256: 830), each checked in *both* directions and through both the single-block and two-block paths. | + +### Why Appendix A is tested inside `src/schedule.rs` + +The key schedule is deliberately not public API (a `Secret` field). A round-trip through the cipher +**cannot** validate it: a wrong `w[i]` is used by encryption and decryption alike, so the round trip +still succeeds. The Appendix A tests therefore live in the module, where `round_key` + `ortho` +decompress the stored schedule back to classical words so every `w[i]` can be compared against the +appendix directly. `tests/fips197_tests.rs` says so explicitly, so nobody mistakes its round-trip +test for schedule validation. + +### The ACVP loader + +Vectors come from `bc-test-data` at `crypto/aes_tdes_vectors/AES/ACVP-AES-ECB.4014527.rsp.json`. +If that repository is not checked out the test prints a warning and passes, matching the ML-KEM / +ML-DSA convention — `cargo test` stays green for someone who has only cloned this repo. A +`checked > 1000` assertion guards against a silently-empty run. + +The response file records `key`, `pt` and `ct` for every case regardless of the group's declared +direction, so each is checked both ways; the request file's group metadata is not needed. + +Two details worth knowing: + +* Some AFT cases have multi-block plaintexts, so the loader iterates blocks (ECB). +* The set includes **all-zero keys** (the GFSbox-style groups). `KeyMaterial` tags an all-zero + buffer `Zeroized` and refuses to promote it outside a hazardous closure — which is the right + default, and `Aes128::new` rejecting it is itself tested. The *test* opts in via + `do_hazardous_operations`; the engine's guard was **not** weakened to accommodate NIST. + +### Constant-time hygiene audit + +Mechanically checked, not merely claimed: + +* **Every** indexing expression in non-test code is a literal constant (`q[0]`…`q[7]`), a loop + counter over a fixed public range, or `4*round + j` where `round` counts over the public `Nr`. + Not one index is derived from key or state bytes. +* The only branches in non-test code are on `i % Nk` and `Nk > 6` (public parameters) in the key + expansion, and on key *metadata* (type, length, security strength) once at construction. None on + key or state bytes. +* `SUBWORD()` in the key expansion goes through the same bit-sliced circuit as `SUBBYTES()`. A + table-driven "light" AES that removes the tables only from the cipher still leaks through the + schedule; this one does not. + +Caveats are stated in the crate docs rather than glossed: the compiler is not contractually obliged +to preserve straight-line codegen; the 32-byte working state is not scrubbed after a block (only the +schedule is `Secret`); and constant-time execution says nothing about power or EM side channels. + +### Gates + +* `cargo fmt --all -- --check` — clean. +* `cargo build --workspace`, `cargo test --workspace` — clean, no failures. +* `cargo doc -p bouncycastle-aes-lowmemory --no-deps` — **zero warnings**. +* `cargo clippy -p bouncycastle-aes-lowmemory --all-targets` — **zero warnings** for this crate. +* `./dev_scripts/quality_stats.sh ./crypto/aes-lowmemory` — `Err()` in core code: **3**, exactly the + three key rejections in `validate`. `unwrap()` in core code: 4, each a + `try_into()` on a fixed-size window of a fixed-size array with a preceding justification comment. + (Note: `cloc` and `bc` are not installed locally, so the line-count and ratio fields print 0.) + +### Mutation testing + +`cargo mutants -p bouncycastle-aes-lowmemory` — complete run, 32 minutes: + +``` +791 mutants tested: 762 caught, 19 missed, 10 unviable, 0 timeouts +``` + +Every one of the 19 misses was investigated. **18 are provable XOR/OR equivalences and no test can +kill them; 1 was a real coverage gap, since fixed.** + +#### The 18 equivalences + +| Count | Site | Mutation | +|---|---|---| +| 6 | `round.rs` `shift_rows` | `\|` → `^` | +| 6 | `round.rs` `inv_shift_rows` | `\|` → `^` | +| 2 | `bitslice.rs` `ortho::swap` | `\|` → `^` | +| 2 | `schedule.rs` `round_key` | `\|` → `^` | +| 1 | `schedule.rs` `expand` | `\|` → `^` | +| 1 | `sbox.rs` `sbox` (the `t37` gate) | `^` → `\|` | + +`a | b` and `a ^ b` differ only where both operands have a set bit, so wherever the operands are +provably disjoint the two are the same function and no test can distinguish them. This is the +"XOR/OR equivalences in crypto code are acceptable" category named in `CLAUDE.md`. Each site is +disjoint for a different reason: + +* **`shift_rows` / `inv_shift_rows`** — the seven masked terms have pairwise-disjoint destination + bit ranges that together cover all 32 bits. +* **`ortho::swap`** — the masks are complementary and the shift equals the field width. +* **`expand`** — the compression combines `& 0x5555_5555` with `& 0xAAAA_AAAA`, complementary masks. +* **`round_key`** — `even` occupies only even bit positions and `even << 1` only odd ones (and + conversely for `odd`). +* **`sbox`, the `t37 = t36 ^ t34` gate** — the interesting one, because it is a gate *inside* the + circuit rather than a mask combination, and because a surviving mutant there would suggest the + exhaustive Table 4 test had a hole. It does not: brute-forcing all 256 inputs shows `t36` and + `t34` are **never both 1**, so XOR and OR agree, and the mutant changes the output for 0 of 256 + inputs. Sweeping the same mutation across every XOR gate confirms `t37` is the **only one of the + 77** with that property — every other `^ → |` mutant in the circuit is killed. So the exhaustive + test is exactly as strong as claimed; this gate just happens to have disjoint operands. + +Rather than leave the `shift_rows` case as an assertion, the underlying invariant is now tested: +`test_shift_rows_is_a_bit_permutation` pushes a single set bit through and requires exactly one bit +out, with the induced map a bijection on all 32 positions — precisely the disjointness and coverage +property, and it *would* fail if a mask ever overlapped or failed to cover. Every one of the six +sites also carries an in-code comment explaining why its mutant survives, so the next reader does +not have to repeat this investigation. + +#### The one real gap, fixed + +**`< → >` in `Aes

::validate`.** There was no test for a key whose security strength is *below* +the level its length implies; because `from_bytes_as_type` always tags a key at its length-implied +strength, neither `<` nor `>` was ever true and the two comparisons behaved identically. +`a_key_carrying_too_low_a_security_strength_is_rejected` now covers it (a 32-byte key lowered to +128-bit must be rejected by `Aes256::new`), and the fix was confirmed by hand-applying the mutation +and watching that test fail, then reverting. + +This mutant still appears in the run output above, which analysed the pre-fix source — the fix +landed while the run was in flight. Re-running `cargo mutants` should therefore report **18 missed, +763 caught**, all 18 being the documented equivalences. + +#### Unviable + +The 10 unviable mutants are all `replace with Err(...)` / `with ()` on functions whose return +type does not admit the substituted value (`validate`, `Debug::fmt`, `encrypt2`). `cargo mutants` +counts these as unviable rather than missed; they are a property of the config's `error_values` +list, not a coverage gap. + +--- + +## 5. Three corrections worth flagging to reviewers + +### 5.1 The working plan's bit-layout claim is wrong + +`bc-rust-aes-lowmemory-plan.md` §2 states the layout is "`q[k]` bit `2·j` is bit k of byte j of +block A". That is **false**. The correct layout, derived in §2.3 above and pinned exhaustively, is +`q[k]` bit `(8r + 2c)`. Anyone checking the ShiftRows or MixColumns constants against the plan's +version will conclude, wrongly, that they are all broken. The plan's own instruction — "Any place +BearSSL's constants and your FIPS 197 derivation disagree: the spec wins; re-derive, then look for +the misunderstanding (it will be in the layout table)" — turned out to point at the plan itself. + +### 5.2 FIPS 197 Eq 5.6 is `[{02},{01},{01},{03}]` + +Not `[{02},{03},{01},{01}]`, which is the first *row* of the Eq 5.7 matrix rather than the defining +word of Sec 4.3. Sec 4.3 Eq (4.8) defines matrix entry `(r,k)` as `a[(r-k) mod 4]`, and both +MixColumns and InvMixColumns use that same convention — Eq 5.13's `[{0e},{09},{0d},{0b}]` is +correct as printed. + +This one was written into a test constant from memory and caught by the failing test. It is worth +recording because of *how* it fails: supplying the matrix row instead of the defining word silently +transposes the matrix, which leaves the InvMixColumns test **passing**, so only the forward test +detects it. A literal transcription of Eq 5.8 and Eq 5.15 was added as a second, independent +reference (`test_the_two_reference_forms_agree`) so the convention is pinned from both directions, +and `MIX_COEFFS` carries a comment about the trap. + +### 5.3 The plan's "PR B" is unnecessary + +The plan calls for downloading CAVP AESAVS `.rsp` files and opening a PR against `bcgit/bc-test-data` +to add them. `bc-test-data` **already** ships NIST ACVP AES vectors at +`crypto/aes_tdes_vectors/AES/ACVP-AES-ECB.4014527.{req,rsp}.json` — 2138 AFT cases across all three +key lengths, more coverage than the AESAVS KAT/MMT files would have provided. No PR to +`bc-test-data` is needed. `serde_json` as a dev-dependency is the established way to read these +files (see the ML-KEM and ML-DSA suites). + +--- + +## 6. Scope deliberately not implemented + +| Item | Why | +|---|---| +| `BlockPermutation` trait impls, and `encrypt_blocks2`/`decrypt_blocks2` as trait methods | The trait does not exist in `crypto/core`, which has the mode-level `BlockCipher` / `BlockCipherEncryptor` / `BlockCipherDecryptor`. Introducing it is the plan's separate "PR A". The two-block entry points are inherent methods for now; promoting them to provided trait methods is a one-line delegation once the trait lands. | +| `core-test-framework` conformance test | Follows from the above — there is no test suite for a raw permutation yet. | +| ACVP MCT (Monte Carlo) groups — 6 cases | Their expected `resultsArray` comes from a chained key/plaintext update rule defined in the ACVP AES specification, not in FIPS 197. Implementing it from anything other than that specification would be guesswork. The test reports the skip count so the gap is visible rather than silent. | +| CLI subcommand | A bare permutation only does ECB. `aes128-cbc-*` / `-cfb-*` belong with the modes crate. | +| Factory registration | No `BlockCipherFactory` exists; not adding one here. | +| bc-java `AESLightEngine` cross-check | The plan marks it developer-local rather than committed, and 2138 ACVP vectors plus the spec appendices make it redundant. | + +--- + +## 7. Provenance and attribution + +* **Normative reference: NIST FIPS 197** (including Update 1). Every transformation cites its + section, algorithm and equation numbers, verified against a freshly downloaded copy of the PDF. +* **The S-box circuit** is the 113-gate straight-line program `SLP_AES_113.txt` from Peralta's + circuit collection — 32 AND, 77 XOR, 4 XNOR — described in J. Boyar and R. Peralta, "A new + combinational logic minimization technique with applications to cryptology", + . The gate list was transcribed **mechanically** from the + SLP file (`+` → `^`, `x` → `&`, `#` → `!(..^..)`, names unchanged apart from case) and the result + diffed against the generator output to rule out transcription error. It is not meaningful line by + line and should not be "tidied"; it is verified as a whole by the exhaustive Table 4 test. +* **The bit-sliced two-block structure**, the transpose, and the ShiftRows/MixColumns mask and + rotation constants are translated from BearSSL's `aes_ct` implementation by Thomas Pornin + (`src/symcipher/aes_ct.c`, `aes_ct_enc.c`, `aes_ct_dec.c`, `aes_ct_cbcdec.c`), **MIT licensed**. + Each constant is re-derived from the documented layout in the comments and pinned by a test + against a byte-wise reference written from the FIPS 197 equations. + +Two notes on where the sources disagree, both resolved in favour of the SLP file: + +* Its bottom linear transformation (`tc1..tc26`) **differs from** BearSSL's (`t46..t67`), and its + `t17`/`t21` are re-associated relative to BearSSL's. Both compute the same S-box. +* The SLP numbers inputs and outputs with `U0`/`S0` as the **most significant** bit, so `U0` is + plane `q[7]`. Reversing this produces a wrong S-box, not a subtly different one; the exhaustive + Table 4 test is what pins it. + +**Open question for maintainers:** how attribution for the BearSSL translation and the +Boyar–Peralta circuit should be recorded — file headers only (current state), a top-level `NOTICE` +file, or both. This is a licensing/policy call rather than a technical one. + +--- + +## 8. Reproducing the checks + +```sh +cargo build -p bouncycastle-aes-lowmemory +cargo test -p bouncycastle-aes-lowmemory # 58 tests +cargo test -p bouncycastle-aes-lowmemory --test acvp_tests -- --nocapture # prints the ACVP count +cargo doc -p bouncycastle-aes-lowmemory --no-deps # expect zero warnings +cargo clippy -p bouncycastle-aes-lowmemory --all-targets +cargo fmt --all -- --check +cargo bench -p bouncycastle-aes-lowmemory +cargo mutants -p bouncycastle-aes-lowmemory +./dev_scripts/quality_stats.sh ./crypto/aes-lowmemory + +# struct sizes; add the massif recipe in the file header for stack measurement +cargo run --release -p mem_usage_benches --bin bench_aes_mem_usage +``` + +The ACVP tests additionally need `bc-test-data` cloned as a sibling of this repository; without it +they print a warning and pass. + +--- + +## 9. Open items before merge + +1. **Decide the attribution form** for the BearSSL translation and the Boyar–Peralta circuit (§7): + file headers only (current state), a top-level `NOTICE`, or both. A licensing/policy call rather + than a technical one. +2. **Confirm the PR base branch.** The plan specifies `release/0.1.3alpha`, set explicitly — GitHub + defaults to `main`. +3. Decide whether `BlockPermutation` (plan PR A) lands before or after this crate, since it + determines whether the two-block entry points become trait methods now or later (§6). +4. Note in the PR description that the plan's layout claim (§5.1) and PR B (§5.3) are superseded, so + the plan document does not mislead the next reader. +5. Optionally re-run `cargo mutants` to confirm the expected 18 missed / 763 caught (§4). The 19th + miss was fixed while the recorded run was in flight, so the numbers above under-report by one. From 6ee424f8c0f0952999637cd487015979aecd164b Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Mon, 31 Aug 2026 18:07:26 +0700 Subject: [PATCH 5/7] Added updated release notes, .toml, mem_usage_benches, and other misc files --- .claude/settings.json | 32 ++++++ Cargo.toml | 2 + alpha_0.1.3_release_notes.md | 25 +++++ mem_usage_benches/Cargo.toml | 4 + mem_usage_benches/bench_aes_mem_usage.rs | 131 +++++++++++++++++++++++ mem_usage_benches/lib.rs | 1 + src/lib.rs | 1 + 7 files changed, 196 insertions(+) create mode 100644 .claude/settings.json create mode 100644 mem_usage_benches/bench_aes_mem_usage.rs diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..d81f2e1a --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,32 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo install *)", + "Bash(cargo mutants *)", + "Bash(cat custom_mutants_output/mutants.out/timeout.txt)", + "Bash(python -c \"print\\(f'{801/\\(801+48\\)*100:.1f}% of viable mutants caught \\(801/{801+48}\\)'\\)\")", + "Read(//c/Users/fmendoza/Work/bc-test-data/crypto/ascon/**)", + "Bash(awk '/^Count = \\(1|2|5|33|68|69|153\\)$/{p=1} p&&/^\\(Count|Key|Nonce|PT|AD|CT\\) /{print} /^$/{p=0}' asconaead128/LWC_AEAD_KAT_128_128.txt)", + "Bash(awk '/^Count = \\(1|2|9|17|33\\)$/{p=1} p&&/^\\(Count|Msg|MD\\) /{print} /^$/{p=0}' asconhash256/LWC_HASH_KAT_256.txt)", + "Bash(awk '/^Count = \\(1|2|9|17|33\\)$/{p=1} p&&/^\\(Count|Msg|MD\\) /{print} /^$/{p=0}' asconxof128/LWC_XOF_KAT_128_512.txt)", + "Bash(awk '/^Count = \\(1|2|3\\)$/{p=1} p&&/^\\(Count|Msg|Z|MD\\) /{print} /^$/{p=0}' asconcxof128/LWC_CXOF_KAT_128_512.txt)", + "Bash(awk 'BEGIN{RS=\"\";FS=\"\\\\n\"} /Msg = [0-9A-F]/ && /Z = [0-9A-F]/ {print; c++} c>=2{exit}' asconcxof128/LWC_CXOF_KAT_128_512.txt)", + "Bash(awk 'BEGIN{RS=\"\";FS=\"\\\\n\"} {pt=\"\"} {for\\(i=1;i<=NF;i++\\) if\\($i ~ /^PT = /\\){pt=substr\\($i,6\\)}} length\\(pt\\)==64 {print; exit}' asconaead128/LWC_AEAD_KAT_128_128.txt)", + "Bash(rm -f tests/test_vector.rs tests/behavior.rs)", + "Bash(rm -rf tests/data)", + "Bash(awk '{p+=$4; f+=$6} END{print \"ascon passed:\",p,\" failed:\",f}')", + "Bash(sed -i -E 's/^\\(\\\\s*x\\\\.absorb\\\\\\(&msg\\\\\\)\\);/\\\\1.unwrap\\(\\);/; s/^\\(\\\\s*xc\\\\.absorb\\\\\\(piece\\\\\\)\\);/\\\\1.unwrap\\(\\);/' xof128_tests.rs)", + "Bash(sed -i -E 's/^\\(\\\\s*x\\\\.absorb\\\\\\(&msg\\\\\\)\\);/\\\\1.unwrap\\(\\);/; s/^\\(\\\\s*xc\\\\.absorb\\\\\\(piece\\\\\\)\\);/\\\\1.unwrap\\(\\);/; s/^\\(\\\\s*c\\\\.absorb\\\\\\(&msg\\\\\\)\\);/\\\\1.unwrap\\(\\);/' cxof128_tests.rs)", + "Bash(git checkout *)", + "Bash(rm -f crypto/core-test-framework/src/aead.rs)", + "Bash(sed -n '/\\\\[dependencies\\\\]/,/\\\\[dev-dependencies\\\\]/p' crypto/mlkem/Cargo.toml)", + "Bash(sed -n '/\\\\[dependencies\\\\]/,/\\\\[dev-dependencies\\\\]/p' crypto/mldsa/Cargo.toml)", + "Bash(cargo doc *)", + "Bash(cp crypto/aes/src/lib.rs /tmp/lib.rs.bak)", + "Bash(sed -i 's|^pub mod key_schedule;|// pub mod key_schedule;|' crypto/aes/src/lib.rs)" + ], + "additionalDirectories": [ + "\\tmp" + ] + } +} diff --git a/Cargo.toml b/Cargo.toml index 82b379fe..f5b8c7a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ version = "0.1.3" # *** Internal Dependencies *** bouncycastle = { path = "./" } +bouncycastle-aes-lowmemory = { path = "./crypto/aes-lowmemory" } bouncycastle-base64 = { path = "./crypto/base64" } bouncycastle-core = { path = "crypto/core" } bouncycastle-core-test-framework = { path = "./crypto/core-test-framework" } @@ -41,6 +42,7 @@ version.workspace = true edition.workspace = true [dependencies] +bouncycastle-aes-lowmemory.workspace = true bouncycastle-base64.workspace = true bouncycastle-core.workspace = true bouncycastle-factory.workspace = true diff --git a/alpha_0.1.3_release_notes.md b/alpha_0.1.3_release_notes.md index 53df8182..3e70e435 100644 --- a/alpha_0.1.3_release_notes.md +++ b/alpha_0.1.3_release_notes.md @@ -2,6 +2,31 @@ ## Major features +New crate `bouncycastle-aes-lowmemory` (`bouncycastle::aes_lowmemory`): AES-128/192/256 as a raw keyed block +permutation (NIST FIPS 197), re-exported from the umbrella crate. + +* **Constant-time and table-free.** The S-box is evaluated as a Boolean circuit -- the 113-gate Boyar-Peralta + straight-line program, 32 AND / 77 XOR / 4 XNOR -- over eight `u32` bit-planes, so there is no secret-indexed + memory access and no secret-dependent branch anywhere, including in the key schedule. A table-driven "light" + AES that removes the tables only from the cipher still leaks through `SUBWORD()` in the expansion. +* **Low memory.** No lookup tables at all (0 bytes, against 512 bytes for BC Java's `AESLightEngine` and 2-8 KiB + for T-table engines) and no heap allocation. The only persistent state is the key schedule, stored bit-sliced + in a compressed form that is exactly the FIPS 197 Sec 5.2 size: `Aes128` 176 B, `Aes192` 208 B, `Aes256` 240 B. +* **Both directions from one value.** Decryption follows FIPS 197 Algorithm 3 (the straight inverse cipher) rather + than the equivalent inverse cipher of Sec 5.3.5, so it uses the unmodified key schedule -- one stored schedule + encrypts and decrypts, with no second copy and no transformation at construction time. +* **Two-block entry points.** The bit-sliced state holds two blocks, so `encrypt_blocks2` / `decrypt_blocks2` are + the natural unit of work and roughly double single-block throughput. `encrypt_block` / `decrypt_block` are + provided but do twice the necessary work; modes whose blocks are independent (CTR, and CBC/CFB decryption) + should prefer the pair form. +* Verified against FIPS 197 Appendix A.1/A.2/A.3 (every schedule word), FIPS 197 Appendix B, an exhaustive check + of all 256 S-box and inverse S-box inputs against Tables 4 and 6, SP 800-38A Appendix F.1 (ECB, all three key + lengths, both directions), and 2138 NIST ACVP `ACVP-AES-ECB` cases from `bc-test-data` (skipped with a warning + if that repository is not checked out). +* Deliberately ships no CLI subcommand, no factory entry and no `core` cipher-trait impls: a raw permutation can + only offer ECB, and those are mode-of-operation concerns. `Algorithm` is implemented (name and security + strength); per-mode OIDs and the `BlockCipherEncryptor` / `BlockCipherDecryptor` impls belong to the mode crates. + ## Minor features / bug fixes * bug fixes to the way SHA3/SHAKE handled absorbing and squeezing a partial final byte. diff --git a/mem_usage_benches/Cargo.toml b/mem_usage_benches/Cargo.toml index a3623aac..5d3e1aed 100644 --- a/mem_usage_benches/Cargo.toml +++ b/mem_usage_benches/Cargo.toml @@ -18,3 +18,7 @@ path = "bench_mlkem_mem_usage.rs" [[bin]] name = "bench_sha3_mem_usage" path = "bench_sha3_mem_usage.rs" + +[[bin]] +name = "bench_aes_mem_usage" +path = "bench_aes_mem_usage.rs" diff --git a/mem_usage_benches/bench_aes_mem_usage.rs b/mem_usage_benches/bench_aes_mem_usage.rs new file mode 100644 index 00000000..00d0acd3 --- /dev/null +++ b/mem_usage_benches/bench_aes_mem_usage.rs @@ -0,0 +1,131 @@ +//! The purpose of this binary is to perform a single run of the primitive under test so that +//! its peak memory usage can be measured with: +//! +//! valgrind --tool=massif --heap=no --stacks=yes -- target/release/bench_aes_mem_usage > /dev/null +//! +//! ms_print massif.out.835000 +//! +//! or, shoved all into one line: +//! +//! clear; clear; valgrind --tool=massif --heap=no --stacks=yes -- target/release/bench_aes_mem_usage > /dev/null; ms_print massif.out.*; rm massif.out.* +//! +//! Make sure you build in release mode! +//! +//! Note: print!() is used to force the compiler not to optimize away the actual code. +//! The important stuff for benchmarking goes to stderr so the junk can be piped to /dev/null. +//! +//! Main is at the bottom, and controls which of these actually runs -- measure one at a time, +//! because massif reports the peak across the whole process. +//! +//! # What to expect +//! +//! Unlike ML-KEM and ML-DSA, AES has no interesting stack profile: there is no polynomial +//! arithmetic and no sampling, so peak usage is a small constant plus the key schedule. The +//! numbers worth recording in the crate docs are the ones `print_struct_sizes` prints -- the +//! persistent size of each engine -- and the confirmation that per-block work is a fixed, small +//! amount of stack independent of key length. +//! +//! The point of comparison is that a table-driven AES adds 256 B (`AESLightEngine`) to 8 KiB +//! (T-tables) of static data on top of these numbers; this implementation adds zero. + +#![allow(dead_code)] +#![allow(unused_imports)] + +use bouncycastle::aes_lowmemory::{Aes128, Aes192, Aes256}; +use bouncycastle::core::key_material::{KeyMaterial, KeyType}; + +/// This exists so /usr/bin/time can measure the base memory footprint of the harness itself. +fn bench_do_nothing() { + eprintln!("DoNothing"); + + print!("{}", 1 + 1); +} + +/// Prints the in-memory size of each engine, i.e. the persistent cost of holding a key schedule. +fn print_struct_sizes() { + use core::mem::size_of; + + // FIPS 197 Sec 5.2: the schedule is 4 * (Nr + 1) words, so 176 / 208 / 240 bytes. The + // bit-sliced form is stored compressed, so bit-slicing adds nothing to these. + println!("size_of: {}", size_of::()); + println!("size_of: {}", size_of::()); + println!("size_of: {}", size_of::()); +} + +fn key() -> KeyMaterial { + // A fixed non-zero key: an all-zero buffer would be tagged KeyType::Zeroized and rejected. + let mut bytes = [0u8; N]; + for (i, b) in bytes.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(7).wrapping_add(1); + } + KeyMaterial::::from_bytes_as_type(&bytes, KeyType::SymmetricCipherKey).unwrap() +} + +fn bench_aes128_key_expansion() { + eprintln!("Aes128::new (key expansion)"); + + let aes = Aes128::new(&key::<16>()).unwrap(); + print!("{aes:?}"); +} + +fn bench_aes192_key_expansion() { + eprintln!("Aes192::new (key expansion)"); + + let aes = Aes192::new(&key::<24>()).unwrap(); + print!("{aes:?}"); +} + +fn bench_aes256_key_expansion() { + eprintln!("Aes256::new (key expansion)"); + + let aes = Aes256::new(&key::<32>()).unwrap(); + print!("{aes:?}"); +} + +fn bench_aes128_encrypt_block() { + eprintln!("Aes128::encrypt_block"); + + let aes = Aes128::new(&key::<16>()).unwrap(); + let mut block = [0x11u8; 16]; + aes.encrypt_block(&mut block); + print!("{block:x?}"); +} + +fn bench_aes256_encrypt_block() { + eprintln!("Aes256::encrypt_block"); + + let aes = Aes256::new(&key::<32>()).unwrap(); + let mut block = [0x11u8; 16]; + aes.encrypt_block(&mut block); + print!("{block:x?}"); +} + +fn bench_aes256_decrypt_block() { + eprintln!("Aes256::decrypt_block"); + + let aes = Aes256::new(&key::<32>()).unwrap(); + let mut block = [0x11u8; 16]; + aes.decrypt_block(&mut block); + print!("{block:x?}"); +} + +fn bench_aes256_encrypt_blocks2() { + eprintln!("Aes256::encrypt_blocks2"); + + let aes = Aes256::new(&key::<32>()).unwrap(); + let mut blocks = [[0x11u8; 16], [0x22u8; 16]]; + aes.encrypt_blocks2(&mut blocks); + print!("{blocks:x?}"); +} + +fn main() { + print_struct_sizes() + // bench_do_nothing() + // bench_aes128_key_expansion() + // bench_aes192_key_expansion() + // bench_aes256_key_expansion() + // bench_aes128_encrypt_block() + // bench_aes256_encrypt_block() + // bench_aes256_decrypt_block() + // bench_aes256_encrypt_blocks2() +} diff --git a/mem_usage_benches/lib.rs b/mem_usage_benches/lib.rs index a281a8b2..0445bb89 100644 --- a/mem_usage_benches/lib.rs +++ b/mem_usage_benches/lib.rs @@ -1,3 +1,4 @@ +mod bench_aes_mem_usage; mod bench_mldsa_mem_usage; mod bench_mlkem_mem_usage; mod bench_sha3_mem_usage; diff --git a/src/lib.rs b/src/lib.rs index b46df8cd..ca2fb145 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub use bouncycastle_aes_lowmemory as aes_lowmemory; pub use bouncycastle_base64 as base64; pub use bouncycastle_core as core; pub use bouncycastle_factory as factory; From 12ecfae035cacf2892f660b292e2aeeb7a961912 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Mon, 31 Aug 2026 18:23:32 +0700 Subject: [PATCH 6/7] Updated .gitignore --- .claude/settings.json | 32 -------------------------------- .gitignore | 10 ++++++++++ 2 files changed, 10 insertions(+), 32 deletions(-) delete mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index d81f2e1a..00000000 --- a/.claude/settings.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(cargo install *)", - "Bash(cargo mutants *)", - "Bash(cat custom_mutants_output/mutants.out/timeout.txt)", - "Bash(python -c \"print\\(f'{801/\\(801+48\\)*100:.1f}% of viable mutants caught \\(801/{801+48}\\)'\\)\")", - "Read(//c/Users/fmendoza/Work/bc-test-data/crypto/ascon/**)", - "Bash(awk '/^Count = \\(1|2|5|33|68|69|153\\)$/{p=1} p&&/^\\(Count|Key|Nonce|PT|AD|CT\\) /{print} /^$/{p=0}' asconaead128/LWC_AEAD_KAT_128_128.txt)", - "Bash(awk '/^Count = \\(1|2|9|17|33\\)$/{p=1} p&&/^\\(Count|Msg|MD\\) /{print} /^$/{p=0}' asconhash256/LWC_HASH_KAT_256.txt)", - "Bash(awk '/^Count = \\(1|2|9|17|33\\)$/{p=1} p&&/^\\(Count|Msg|MD\\) /{print} /^$/{p=0}' asconxof128/LWC_XOF_KAT_128_512.txt)", - "Bash(awk '/^Count = \\(1|2|3\\)$/{p=1} p&&/^\\(Count|Msg|Z|MD\\) /{print} /^$/{p=0}' asconcxof128/LWC_CXOF_KAT_128_512.txt)", - "Bash(awk 'BEGIN{RS=\"\";FS=\"\\\\n\"} /Msg = [0-9A-F]/ && /Z = [0-9A-F]/ {print; c++} c>=2{exit}' asconcxof128/LWC_CXOF_KAT_128_512.txt)", - "Bash(awk 'BEGIN{RS=\"\";FS=\"\\\\n\"} {pt=\"\"} {for\\(i=1;i<=NF;i++\\) if\\($i ~ /^PT = /\\){pt=substr\\($i,6\\)}} length\\(pt\\)==64 {print; exit}' asconaead128/LWC_AEAD_KAT_128_128.txt)", - "Bash(rm -f tests/test_vector.rs tests/behavior.rs)", - "Bash(rm -rf tests/data)", - "Bash(awk '{p+=$4; f+=$6} END{print \"ascon passed:\",p,\" failed:\",f}')", - "Bash(sed -i -E 's/^\\(\\\\s*x\\\\.absorb\\\\\\(&msg\\\\\\)\\);/\\\\1.unwrap\\(\\);/; s/^\\(\\\\s*xc\\\\.absorb\\\\\\(piece\\\\\\)\\);/\\\\1.unwrap\\(\\);/' xof128_tests.rs)", - "Bash(sed -i -E 's/^\\(\\\\s*x\\\\.absorb\\\\\\(&msg\\\\\\)\\);/\\\\1.unwrap\\(\\);/; s/^\\(\\\\s*xc\\\\.absorb\\\\\\(piece\\\\\\)\\);/\\\\1.unwrap\\(\\);/; s/^\\(\\\\s*c\\\\.absorb\\\\\\(&msg\\\\\\)\\);/\\\\1.unwrap\\(\\);/' cxof128_tests.rs)", - "Bash(git checkout *)", - "Bash(rm -f crypto/core-test-framework/src/aead.rs)", - "Bash(sed -n '/\\\\[dependencies\\\\]/,/\\\\[dev-dependencies\\\\]/p' crypto/mlkem/Cargo.toml)", - "Bash(sed -n '/\\\\[dependencies\\\\]/,/\\\\[dev-dependencies\\\\]/p' crypto/mldsa/Cargo.toml)", - "Bash(cargo doc *)", - "Bash(cp crypto/aes/src/lib.rs /tmp/lib.rs.bak)", - "Bash(sed -i 's|^pub mod key_schedule;|// pub mod key_schedule;|' crypto/aes/src/lib.rs)" - ], - "additionalDirectories": [ - "\\tmp" - ] - } -} diff --git a/.gitignore b/.gitignore index 6d42084d..fe17c9f5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,13 @@ mutants.out*/ .idea/ .vscode/ + +# Claude Code: ignore personal/local state, but share team tooling +# (skills, slash commands, subagents, and project settings.json). +.claude/* +!.claude/settings.json +!.claude/skills/ +!.claude/commands/ +!.claude/agents/ +.claude/settings.local.json +.claude 2/ \ No newline at end of file From 736b0ac4964ebb897739021d237e08cf5c042351 Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Sun, 6 Sep 2026 14:11:57 -0500 Subject: [PATCH 7/7] MikeO adjustments to aes-lowmemory while reviewing #105 --- QUALITY_AND_STYLE.md | 15 +- alpha_0.1.3_release_notes.md | 51 +- crypto/aes-lowmemory/Cargo.toml | 2 +- crypto/aes-lowmemory/benches/aes_benches.rs | 81 +-- crypto/aes-lowmemory/src/aes.rs | 210 ++++---- crypto/aes-lowmemory/src/bitslice.rs | 85 +--- crypto/aes-lowmemory/src/lib.rs | 150 ++---- crypto/aes-lowmemory/src/round.rs | 35 +- crypto/aes-lowmemory/src/sbox.rs | 56 +-- crypto/aes-lowmemory/src/schedule.rs | 161 +++--- crypto/aes-lowmemory/summary.md | 475 ------------------ .../tests/{acvp_tests.rs => bc-test-data.rs} | 22 +- crypto/aes-lowmemory/tests/fips197_tests.rs | 42 +- crypto/aes-lowmemory/tests/sp800_38a_tests.rs | 26 +- mem_usage_benches/bench_aes_mem_usage.rs | 32 +- 15 files changed, 365 insertions(+), 1078 deletions(-) delete mode 100644 crypto/aes-lowmemory/summary.md rename crypto/aes-lowmemory/tests/{acvp_tests.rs => bc-test-data.rs} (93%) diff --git a/QUALITY_AND_STYLE.md b/QUALITY_AND_STYLE.md index 65f7e7e0..eeadbbab 100644 --- a/QUALITY_AND_STYLE.md +++ b/QUALITY_AND_STYLE.md @@ -63,7 +63,20 @@ which parts were done for a very specific reason and should not be changed on a ## Naming Conventions -All normal rust naming convensions from clippy apply. In addition, some library-specific naming conventions: +All normal rust naming conventions from clippy apply, with the following exceptions: + +* Many bouncycastle crates use `#[allow(non_snake_case)]`, `#[allow(non_upper_case_globals)]` + `#[allow(non_camel_case_types)]` and so forth, either locally or crate-wide to indicate a preference for keeping the + exact capitalization from a spec (FIPS, RFC, etc) over following rust convention. For example, a struct implementing + the Advanced Encryption Standard (AES) in Galois Counter Mode (GCM) should be named `struct AES_GCM` to match the spec + even though rust convention might be `struct AesGcm`. The same goes for variable and constant names, for example, if a + spec uses a notation where `A` is a matrix and `a` is vector, then it is perfectly acceptable to do + `let A = Matrix;` and + `let a = Vector`, or the global constant `Rcon` should keep that capitalization and not `RCON` or `R_CON`. The + intention is to speed up human code review by a human familiar with the spec is more important than following rust + convention. + +In addition, some library-specific naming conventions: * In constants, "LEN" is the length of a value in bytes (typically used for sizing arrays), whereas "SIZE" is a value in bits (typically used as a security parameter). For example SHA256 could have constants `HASH_SIZE = 256` and diff --git a/alpha_0.1.3_release_notes.md b/alpha_0.1.3_release_notes.md index 3e70e435..5560df6b 100644 --- a/alpha_0.1.3_release_notes.md +++ b/alpha_0.1.3_release_notes.md @@ -2,56 +2,11 @@ ## Major features -New crate `bouncycastle-aes-lowmemory` (`bouncycastle::aes_lowmemory`): AES-128/192/256 as a raw keyed block -permutation (NIST FIPS 197), re-exported from the umbrella crate. - -* **Constant-time and table-free.** The S-box is evaluated as a Boolean circuit -- the 113-gate Boyar-Peralta - straight-line program, 32 AND / 77 XOR / 4 XNOR -- over eight `u32` bit-planes, so there is no secret-indexed - memory access and no secret-dependent branch anywhere, including in the key schedule. A table-driven "light" - AES that removes the tables only from the cipher still leaks through `SUBWORD()` in the expansion. -* **Low memory.** No lookup tables at all (0 bytes, against 512 bytes for BC Java's `AESLightEngine` and 2-8 KiB - for T-table engines) and no heap allocation. The only persistent state is the key schedule, stored bit-sliced - in a compressed form that is exactly the FIPS 197 Sec 5.2 size: `Aes128` 176 B, `Aes192` 208 B, `Aes256` 240 B. -* **Both directions from one value.** Decryption follows FIPS 197 Algorithm 3 (the straight inverse cipher) rather - than the equivalent inverse cipher of Sec 5.3.5, so it uses the unmodified key schedule -- one stored schedule - encrypts and decrypts, with no second copy and no transformation at construction time. -* **Two-block entry points.** The bit-sliced state holds two blocks, so `encrypt_blocks2` / `decrypt_blocks2` are - the natural unit of work and roughly double single-block throughput. `encrypt_block` / `decrypt_block` are - provided but do twice the necessary work; modes whose blocks are independent (CTR, and CBC/CFB decryption) - should prefer the pair form. -* Verified against FIPS 197 Appendix A.1/A.2/A.3 (every schedule word), FIPS 197 Appendix B, an exhaustive check - of all 256 S-box and inverse S-box inputs against Tables 4 and 6, SP 800-38A Appendix F.1 (ECB, all three key - lengths, both directions), and 2138 NIST ACVP `ACVP-AES-ECB` cases from `bc-test-data` (skipped with a warning - if that repository is not checked out). -* Deliberately ships no CLI subcommand, no factory entry and no `core` cipher-trait impls: a raw permutation can - only offer ECB, and those are mode-of-operation concerns. `Algorithm` is implemented (name and security - strength); per-mode OIDs and the `BlockCipherEncryptor` / `BlockCipherDecryptor` impls belong to the mode crates. +* New crate `bouncycastle-aes-lowmemory` (`bouncycastle::aes_lowmemory`): AES-128/192/256 as a raw keyed block + permutation (NIST FIPS 197). ## Minor features / bug fixes * bug fixes to the way SHA3/SHAKE handled absorbing and squeezing a partial final byte. * Design discussions about whether core::traits::XOF (in the abstract) should allow interleaving absorb -> squeeze -> - absorb (ie "absorb-after-squeeze). Outcome: absorb-after-squeeze forbidden. Could be changed in the future. - -Block cipher traits (PR #96): - -* The single `BlockCipher` streaming trait is split into `BlockCipherEncryptor` and `BlockCipherDecryptor` (mirroring - `KEMEncapsulator` / `KEMDecapsulator`) so the direction is encoded in the implementing type. A minimal `BlockCipher` - supertrait carries the shared `MAX_SECURITY_STRENGTH`; the `SymmetricCipher` one-shot API is no longer a supertrait. -* The single-block `do_{en,de}crypt_block[_out]` methods are replaced by multi-block - `do_{en,de}crypt_blocks[_out]`, taking `&[[u8; BLOCK_LEN]; N]` so the block count is compile-time and - input/output lengths cannot disagree. -* `do_encrypt_init_rng(key, &mut dyn RNG)` is added alongside `do_encrypt_init`, matching the `encaps` / `encaps_rng` - pattern. -* The `do_{en,de}crypt_final[_out]` methods are removed: the traits are now strictly block-aligned, and padding of - arbitrary-length data belongs to a separate `PaddedEncryptor` / `PaddedDecryptor` layer built on top. -* One-shot static APIs are provided (default) methods implemented once in the traits -- `encrypt_blocks`, - `encrypt_blocks_rng`, `encrypt_blocks_out`, `encrypt_blocks_out_rng` on `BlockCipherEncryptor` and `decrypt_blocks`, - `decrypt_blocks_out` on `BlockCipherDecryptor` -- so every block-aligned mode gets the house-standard - take-data-return-result API at no cost to implementors. - -Testing: - -* The core-test-framework block cipher test now takes separate encryptor/decryptor type parameters, exercises N = 1 and - N = 2 (including mixed single/multi-block encrypt vs decrypt sequences), and checks the one-shots agree with the - streaming API and round-trip. + absorb (ie "absorb-after-squeeze). Outcome: absorb-after-squeeze forbidden. Could be changed in the future. \ No newline at end of file diff --git a/crypto/aes-lowmemory/Cargo.toml b/crypto/aes-lowmemory/Cargo.toml index 07fdc784..04266615 100644 --- a/crypto/aes-lowmemory/Cargo.toml +++ b/crypto/aes-lowmemory/Cargo.toml @@ -11,7 +11,7 @@ bouncycastle-utils.workspace = true bouncycastle-hex.workspace = true bouncycastle-rng.workspace = true criterion.workspace = true -serde_json = "1.0" +serde_json = "1.0" # for parsing test vector files [[bench]] name = "aes_benches" diff --git a/crypto/aes-lowmemory/benches/aes_benches.rs b/crypto/aes-lowmemory/benches/aes_benches.rs index 82d81003..3ca6495a 100644 --- a/crypto/aes-lowmemory/benches/aes_benches.rs +++ b/crypto/aes-lowmemory/benches/aes_benches.rs @@ -1,12 +1,8 @@ //! Criterion benchmarks for the bit-sliced AES engine. -//! -//! The comparison that matters here is `encrypt_block` against `encrypt_blocks2` over the same -//! number of bytes. The bit-sliced state holds two blocks, so a single-block call does twice the -//! necessary work; the two-block path should be close to twice the throughput. That ratio is the -//! argument for modes of operation using the two-block entry points wherever their blocks are -//! independent (CTR, and the decrypt direction of CBC and CFB). - -use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256, BLOCK_LEN}; + +use bouncycastle_aes_lowmemory::{ + AES_128, AES_192, AES_256, AES128Params, AES192Params, AES256Params, AESParams, BLOCK_LEN, +}; use bouncycastle_core::key_material::{KeyMaterial, KeyType}; use bouncycastle_core::traits::RNG; use bouncycastle_rng as rng; @@ -36,37 +32,41 @@ fn bench_key_expansion(c: &mut Criterion) { let mut group = c.benchmark_group("aes_lowmemory::key expansion"); let key128 = key::<16>(); + group.throughput(Throughput::Bytes(::KEY_LEN as u64)); group.bench_function("Aes128::new()", |b| { - b.iter(|| black_box(Aes128::new(black_box(&key128)).unwrap())) + b.iter(|| black_box(AES_128::new(black_box(&key128)).unwrap())) }); let key192 = key::<24>(); + group.throughput(Throughput::Bytes(::KEY_LEN as u64)); group.bench_function("Aes192::new()", |b| { - b.iter(|| black_box(Aes192::new(black_box(&key192)).unwrap())) + b.iter(|| black_box(AES_192::new(black_box(&key192)).unwrap())) }); let key256 = key::<32>(); + group.throughput(Throughput::Bytes(::KEY_LEN as u64)); group.bench_function("Aes256::new()", |b| { - b.iter(|| black_box(Aes256::new(black_box(&key256)).unwrap())) + b.iter(|| black_box(AES_256::new(black_box(&key256)).unwrap())) }); group.finish(); } fn bench_aes128(c: &mut Criterion) { - let aes = Aes128::new(&key::<16>()).unwrap(); - let blocks = random_blocks(); + let aes = AES_128::new(&key::<16>()).unwrap(); + let mut blocks = random_blocks(); let mut group = c.benchmark_group("aes_lowmemory::Aes128"); group.throughput(Throughput::Bytes(DATA_LEN as u64)); group.bench_function("16KiB -- .encrypt_block() x1024", |b| { b.iter(|| { - let mut buf = blocks.clone(); - for block in buf.iter_mut() { + // So that we're not making copies within the measured loop, we'll just + // encrypt the ciphertext over and over again. + for block in blocks.iter_mut() { aes.encrypt_block(black_box(block)); } - black_box(&buf); + black_box(&blocks); }) }); @@ -76,7 +76,7 @@ fn bench_aes128(c: &mut Criterion) { for pair in buf.chunks_exact_mut(2) { // `try_into` cannot fail: `chunks_exact_mut(2)` yields slices of length 2. let pair: &mut [[u8; BLOCK_LEN]; 2] = pair.try_into().unwrap(); - aes.encrypt_blocks2(black_box(pair)); + aes.encrypt_2blocks(black_box(pair)); } black_box(&buf); }) @@ -97,7 +97,7 @@ fn bench_aes128(c: &mut Criterion) { let mut buf = blocks.clone(); for pair in buf.chunks_exact_mut(2) { let pair: &mut [[u8; BLOCK_LEN]; 2] = pair.try_into().unwrap(); - aes.decrypt_blocks2(black_box(pair)); + aes.decrypt_2blocks(black_box(pair)); } black_box(&buf); }) @@ -107,28 +107,50 @@ fn bench_aes128(c: &mut Criterion) { } fn bench_aes192(c: &mut Criterion) { - let aes = Aes192::new(&key::<24>()).unwrap(); - let blocks = random_blocks(); + let aes = AES_192::new(&key::<24>()).unwrap(); + let mut blocks = random_blocks(); let mut group = c.benchmark_group("aes_lowmemory::Aes192"); group.throughput(Throughput::Bytes(DATA_LEN as u64)); group.bench_function("16KiB -- .encrypt_block() x1024", |b| { + b.iter(|| { + // So that we're not making copies within the measured loop, we'll just + // encrypt the ciphertext over and over again. + for block in blocks.iter_mut() { + aes.encrypt_block(black_box(block)); + } + black_box(&blocks); + }) + }); + + group.bench_function("16KiB -- .encrypt_blocks2() x512", |b| { + b.iter(|| { + let mut buf = blocks.clone(); + for pair in buf.chunks_exact_mut(2) { + let pair: &mut [[u8; BLOCK_LEN]; 2] = pair.try_into().unwrap(); + aes.encrypt_2blocks(black_box(pair)); + } + black_box(&buf); + }) + }); + + group.bench_function("16KiB -- .decrypt_block() x1024", |b| { b.iter(|| { let mut buf = blocks.clone(); for block in buf.iter_mut() { - aes.encrypt_block(black_box(block)); + aes.decrypt_block(black_box(block)); } black_box(&buf); }) }); - group.bench_function("16KiB -- .encrypt_blocks2() x512", |b| { + group.bench_function("16KiB -- .decrypt_blocks2() x512", |b| { b.iter(|| { let mut buf = blocks.clone(); for pair in buf.chunks_exact_mut(2) { let pair: &mut [[u8; BLOCK_LEN]; 2] = pair.try_into().unwrap(); - aes.encrypt_blocks2(black_box(pair)); + aes.decrypt_2blocks(black_box(pair)); } black_box(&buf); }) @@ -138,19 +160,18 @@ fn bench_aes192(c: &mut Criterion) { } fn bench_aes256(c: &mut Criterion) { - let aes = Aes256::new(&key::<32>()).unwrap(); - let blocks = random_blocks(); + let aes = AES_256::new(&key::<32>()).unwrap(); + let mut blocks = random_blocks(); let mut group = c.benchmark_group("aes_lowmemory::Aes256"); group.throughput(Throughput::Bytes(DATA_LEN as u64)); group.bench_function("16KiB -- .encrypt_block() x1024", |b| { b.iter(|| { - let mut buf = blocks.clone(); - for block in buf.iter_mut() { + for block in blocks.iter_mut() { aes.encrypt_block(black_box(block)); } - black_box(&buf); + black_box(&blocks); }) }); @@ -159,7 +180,7 @@ fn bench_aes256(c: &mut Criterion) { let mut buf = blocks.clone(); for pair in buf.chunks_exact_mut(2) { let pair: &mut [[u8; BLOCK_LEN]; 2] = pair.try_into().unwrap(); - aes.encrypt_blocks2(black_box(pair)); + aes.encrypt_2blocks(black_box(pair)); } black_box(&buf); }) @@ -170,7 +191,7 @@ fn bench_aes256(c: &mut Criterion) { let mut buf = blocks.clone(); for pair in buf.chunks_exact_mut(2) { let pair: &mut [[u8; BLOCK_LEN]; 2] = pair.try_into().unwrap(); - aes.decrypt_blocks2(black_box(pair)); + aes.decrypt_2blocks(black_box(pair)); } black_box(&buf); }) diff --git a/crypto/aes-lowmemory/src/aes.rs b/crypto/aes-lowmemory/src/aes.rs index b1003cff..d8c26382 100644 --- a/crypto/aes-lowmemory/src/aes.rs +++ b/crypto/aes-lowmemory/src/aes.rs @@ -1,47 +1,45 @@ -//! CIPHER() and INVCIPHER() (FIPS 197 Sec 5.1 and Sec 5.3), and the public engine types. +//! CIPHER() and INVCIPHER() (FIPS 197 §5.1 and §5.3), and the public types. -use crate::bitslice::{Block, Planes, pack, unpack}; +use crate::bitslice::{Planes, pack, unpack}; use crate::round::{add_round_key, inv_mix_columns, inv_shift_rows, mix_columns, shift_rows}; use crate::sbox::{inv_sbox, sbox}; -use crate::schedule::{Aes128Params, Aes192Params, Aes256Params, AesParams, expand, round_key}; +use crate::schedule::{AES128Params, AES192Params, AES256Params, AESParams, expand, round_key}; use bouncycastle_core::errors::{KeyMaterialError, SymmetricCipherError}; use bouncycastle_core::key_material::{KeyMaterial, KeyMaterialTrait, KeyType}; use bouncycastle_core::traits::{Algorithm, SecurityStrength}; use bouncycastle_utils::secret::Secret; -/// The AES block length in bytes: 16 (FIPS 197 Sec 3.4, `Nb` = 4 words). +/// The AES block length in bytes: 16 (FIPS 197 §5, Table 3, `Nb` = 4 words). +/// AES-128, AES-192, and AES-256 all use a block length of 16 bytes. pub const BLOCK_LEN: usize = 16; +/// One 16-byte AES block, in the order of FIPS 197 Eq (3.6): `block[r + 4c] == s[r,c]`. +pub type Block = [u8; BLOCK_LEN]; + /// The AES keyed permutation, parameterised by key length. /// -/// Use the aliases [`Aes128`], [`Aes192`] and [`Aes256`] rather than naming this directly. -/// `P` is sealed to the three parameter sets of FIPS 197 Sec 6.1, so no fourth instantiation -/// exists. -/// -/// The only state is the key schedule, held in a [`Secret`] so that it is zeroized on drop and -/// redacted from `Debug`. There is no direction flag and no initialisation state: both directions -/// work from the same schedule (see [`Aes::decrypt_blocks2`]), and a constructed value is always -/// ready to use, so there is no `init()` or `reset()`. -pub struct Aes { +/// The core internal implementation of the ML-KEM algorithm. +/// This needs to be public for the compiler to be able to find it, +/// but is shouldn't ever need to be used directly. +/// Please use the named public types. +pub struct AES { schedule: Secret, } -/// AES-128: 16-byte key, 10 rounds (FIPS 197 Sec 6.1). -pub type Aes128 = Aes; -/// AES-192: 24-byte key, 12 rounds (FIPS 197 Sec 6.1). -pub type Aes192 = Aes; -/// AES-256: 32-byte key, 14 rounds (FIPS 197 Sec 6.1). -pub type Aes256 = Aes; +/// AES-128: 16-byte key, 10 rounds (FIPS 197 §5, Table 3). +pub type AES_128 = AES; +/// AES-192: 24-byte key, 12 rounds (FIPS 197 §5, Table 3). +pub type AES_192 = AES; +/// AES-256: 32-byte key, 14 rounds (FIPS 197 §5, Table 3). +pub type AES_256 = AES; -impl Aes

{ - /// Checks a key is fit to use before it is expanded. +impl AES

{ + /// Checks a key is fit to use before it is expanded: /// - /// The key must be tagged [`KeyType::SymmetricCipherKey`], must be exactly `P::KEY_LEN` bytes - /// of the buffer, and must carry a [`SecurityStrength`] at least equal to its own length -- - /// which is what a key of this length from a correctly-instantiated RNG or KDF will have. - /// The checks exist to catch a key that arrived from somewhere it should not have: a seed - /// reused as a cipher key, or a 32-byte buffer holding material only derived at the 128-bit - /// strength. + /// It must be: + /// * tagged [`KeyType::SymmetricCipherKey`], + /// * exactly `P::KEY_LEN` bytes, + /// * must carry a [`SecurityStrength`] at least equal to its own length. /// /// Takes `&dyn KeyMaterialTrait` so the three constructors, whose `KeyMaterial` capacities /// differ, can share one implementation. @@ -64,11 +62,8 @@ impl Aes

{ Ok(()) } - /// CIPHER() on two blocks at once (FIPS 197 Sec 5.1, Algorithm 1). - /// - /// Algorithm 1 line by line: line 3 is the initial ADDROUNDKEY() with `w[0..3]`; lines 4-9 are - /// the `Nr - 1` full rounds; lines 10-13 are the final round, which omits MIXCOLUMNS(). - fn encrypt2(&self, q: &mut Planes) { + /// CIPHER() (FIPS 197 Sec 5.1, Algorithm 1) acting on two blocks at once. + fn cipher2(&self, q: &mut Planes) { // line 3: state = state XOR w[0..3] add_round_key(q, &round_key::

(&self.schedule, 0)); @@ -86,22 +81,8 @@ impl Aes

{ add_round_key(q, &round_key::

(&self.schedule, P::NR)); } - /// INVCIPHER() on two blocks at once (FIPS 197 Sec 5.3, Algorithm 3). - /// - /// This is the **straight** inverse cipher of Algorithm 3, not the equivalent inverse cipher - /// of Sec 5.3.5. That matters: Algorithm 3 applies INVMIXCOLUMNS() *after* ADDROUNDKEY(), - /// which lets it use the ordinary key schedule, whereas Sec 5.3.5 reorders the round to put - /// the two the other way round and needs a separate schedule with INVMIXCOLUMNS() applied to - /// each round key (Algorithm 5, KEYEXPANSIONEIC()). - /// - /// Following Algorithm 3 is therefore what allows one [`Aes`] value to encrypt *and* decrypt - /// from a single stored schedule, with no second copy and no transformation at construction - /// time -- which is the whole reason this crate can offer both directions at 176-240 bytes of - /// state. - /// - /// Line by line: line 3 is ADDROUNDKEY() with the last round key; lines 4-9 are the - /// `Nr - 1` full inverse rounds; lines 10-13 are the final one, which omits INVMIXCOLUMNS(). - fn decrypt2(&self, q: &mut Planes) { + /// INVCIPHER() (FIPS 197 Sec 5.3, Algorithm 3) acting on two blocks at once. + fn inv_cipher2(&self, q: &mut Planes) { // line 3: state = state XOR w[4*Nr .. 4*Nr+3] add_round_key(q, &round_key::

(&self.schedule, P::NR)); @@ -119,65 +100,54 @@ impl Aes

{ add_round_key(q, &round_key::

(&self.schedule, 0)); } - /// Encrypts two blocks in place. - /// - /// This is the natural unit of work: the bit-sliced state holds two blocks, so two blocks cost - /// almost exactly what one does. Prefer this over two [`Aes::encrypt_block`] calls whenever - /// two blocks are available and independent -- which, for a mode of operation, means CTR, or - /// the decryption direction of CBC and CFB, but *not* CBC encryption, whose blocks are - /// serially dependent. - /// - /// Infallible: a constructed [`Aes`] is always usable and every input length is fixed. - pub fn encrypt_blocks2(&self, blocks: &mut [Block; 2]) { - let mut q = pack(&blocks[0], &blocks[1]); - self.encrypt2(&mut q); - let (a, b) = blocks.split_at_mut(1); - unpack(&q, &mut a[0], &mut b[0]); - } - - /// Decrypts two blocks in place. See [`Aes::encrypt_blocks2`]. - pub fn decrypt_blocks2(&self, blocks: &mut [Block; 2]) { - let mut q = pack(&blocks[0], &blocks[1]); - self.decrypt2(&mut q); - let (a, b) = blocks.split_at_mut(1); - unpack(&q, &mut a[0], &mut b[0]); - } - /// Encrypts one block in place. /// - /// The bit-sliced state always holds two blocks, so a single-block call duplicates the block - /// into both halves and discards one result: it does twice the necessary work. Use - /// [`Aes::encrypt_blocks2`] where two blocks are available. - /// - /// Duplicating the block costs exactly what filling the unused half with zeros would, and it - /// buys a free self-check: the two halves must come out equal, which `debug_assert` verifies. - /// That is the whole reason for the choice -- it is not a security property, since the unused - /// half is never returned either way. + /// The bit-sliced state always acts on two blocks, so this encrypts the provided block and a + /// dummy block. Use [`AES::encrypt_2blocks`] where two blocks are available. + // Dev note: This is acting on two copies of the provided block, which costs exactly what filling + // the unused half with zeros would, and it buys a free self-check: the two halves must come out + // equal, which `debug_assert` verifies. pub fn encrypt_block(&self, block: &mut Block) { let mut q = pack(block, block); - self.encrypt2(&mut q); + self.cipher2(&mut q); let mut discard = [0u8; BLOCK_LEN]; unpack(&q, block, &mut discard); debug_assert_eq!(*block, discard, "the two interleaved halves must agree"); } - /// Decrypts one block in place. See [`Aes::encrypt_block`] for the two-blocks-at-once caveat. + /// Encrypts two blocks in place. + /// + /// The internal bit-sliced state is constructed to act on two blocks simultaneously, so two + /// blocks cost almost exactly the same as one. + /// Prefer this over two [`AES::encrypt_block`] calls whenever two blocks are available and + /// independent -- which, for a mode of operation, means CTR, or the decryption direction of CBC + /// and CFB, but *not* CBC encryption, whose blocks are serially dependent. + pub fn encrypt_2blocks(&self, blocks: &mut [Block; 2]) { + let mut q = pack(&blocks[0], &blocks[1]); + self.cipher2(&mut q); + let (a, b) = blocks.split_at_mut(1); + unpack(&q, &mut a[0], &mut b[0]); + } + + /// Decrypts one block in place. See [`AES::encrypt_block`]. pub fn decrypt_block(&self, block: &mut Block) { let mut q = pack(block, block); - self.decrypt2(&mut q); + self.inv_cipher2(&mut q); let mut discard = [0u8; BLOCK_LEN]; unpack(&q, block, &mut discard); debug_assert_eq!(*block, discard, "the two interleaved halves must agree"); } -} -// The three constructors and `Algorithm` impls below are written out longhand rather than -// generated with `macro_rules!`: `cargo mutants` cannot see into macro bodies, so a macro would -// hide the key checks and the security-strength constants from mutation testing (see CLAUDE.md). -// Each `new` differs only in the `KeyMaterial` capacity it accepts, which is what makes a -// wrong-length key a compile error at the call site rather than a runtime error. + /// Decrypts two blocks in place. See [`AES::encrypt_2blocks`]. + pub fn decrypt_2blocks(&self, blocks: &mut [Block; 2]) { + let mut q = pack(&blocks[0], &blocks[1]); + self.inv_cipher2(&mut q); + let (a, b) = blocks.split_at_mut(1); + unpack(&q, &mut a[0], &mut b[0]); + } +} -impl Aes128 { +impl AES_128 { /// Expands a 16-byte key into an AES-128 schedule. /// /// # Errors @@ -186,42 +156,42 @@ impl Aes128 { /// * [`KeyMaterialError::SecurityStrength`] if the key carries a strength below 128 bits. pub fn new(key: &KeyMaterial<16>) -> Result { Self::validate(key)?; - Ok(Self { schedule: expand::(key.ref_to_bytes()) }) + Ok(Self { schedule: expand::(key.ref_to_bytes()) }) } } -impl Aes192 { - /// Expands a 24-byte key into an AES-192 schedule. See [`Aes128::new`] for the error cases. +impl AES_192 { + /// Expands a 24-byte key into an AES-192 schedule. See [`AES_128::new`] for the error cases. pub fn new(key: &KeyMaterial<24>) -> Result { Self::validate(key)?; - Ok(Self { schedule: expand::(key.ref_to_bytes()) }) + Ok(Self { schedule: expand::(key.ref_to_bytes()) }) } } -impl Aes256 { - /// Expands a 32-byte key into an AES-256 schedule. See [`Aes128::new`] for the error cases. +impl AES_256 { + /// Expands a 32-byte key into an AES-256 schedule. See [`AES_128::new`] for the error cases. pub fn new(key: &KeyMaterial<32>) -> Result { Self::validate(key)?; - Ok(Self { schedule: expand::(key.ref_to_bytes()) }) + Ok(Self { schedule: expand::(key.ref_to_bytes()) }) } } -impl Algorithm for Aes128 { - const ALG_NAME: &'static str = Aes128Params::ALG_NAME; +impl Algorithm for AES_128 { + const ALG_NAME: &'static str = AES128Params::ALG_NAME; const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit; } -impl Algorithm for Aes192 { - const ALG_NAME: &'static str = Aes192Params::ALG_NAME; +impl Algorithm for AES_192 { + const ALG_NAME: &'static str = AES192Params::ALG_NAME; const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_192bit; } -impl Algorithm for Aes256 { - const ALG_NAME: &'static str = Aes256Params::ALG_NAME; +impl Algorithm for AES_256 { + const ALG_NAME: &'static str = AES256Params::ALG_NAME; const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_256bit; } -impl core::fmt::Debug for Aes

{ +impl core::fmt::Debug for AES

{ /// Prints the algorithm name only. The key schedule is secret and is never formatted. fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.write_str(P::ALG_NAME) @@ -233,44 +203,44 @@ mod tests { use super::*; #[test] - fn test_engine_sizes_match_the_documented_memory_table() { + fn test_size_constants_match_the_documented_memory_table() { // The "Memory Usage" table in the crate docs quotes these, and the whole point of the // crate is that they are this small: 4 * (Nr + 1) words of schedule, nothing else, and no // tables anywhere. If the representation grows, the docs are wrong -- fix both. - assert_eq!(size_of::(), 176, "AES-128: 4 * (10 + 1) words"); - assert_eq!(size_of::(), 208, "AES-192: 4 * (12 + 1) words"); - assert_eq!(size_of::(), 240, "AES-256: 4 * (14 + 1) words"); + assert_eq!(size_of::(), 176, "AES-128: 4 * (10 + 1) words"); + assert_eq!(size_of::(), 208, "AES-192: 4 * (12 + 1) words"); + assert_eq!(size_of::(), 240, "AES-256: 4 * (14 + 1) words"); } #[test] fn test_engine_size_is_exactly_the_schedule() { // No round counter, no direction flag, no initialised marker: the schedule is all there // is, which is what makes both directions available from one value at no extra cost. - assert_eq!(size_of::(), size_of::<::Schedule>()); - assert_eq!(size_of::(), size_of::<::Schedule>()); - assert_eq!(size_of::(), size_of::<::Schedule>()); + assert_eq!(size_of::(), size_of::<::Schedule>()); + assert_eq!(size_of::(), size_of::<::Schedule>()); + assert_eq!(size_of::(), size_of::<::Schedule>()); } #[test] fn test_alg_names() { - assert_eq!(::ALG_NAME, "AES-128"); - assert_eq!(::ALG_NAME, "AES-192"); - assert_eq!(::ALG_NAME, "AES-256"); + assert_eq!(::ALG_NAME, "AES-128"); + assert_eq!(::ALG_NAME, "AES-192"); + assert_eq!(::ALG_NAME, "AES-256"); } #[test] fn test_max_security_strength_matches_the_key_length() { assert_eq!( - ::MAX_SECURITY_STRENGTH, - SecurityStrength::from_bytes(Aes128Params::KEY_LEN) + ::MAX_SECURITY_STRENGTH, + SecurityStrength::from_bytes(AES128Params::KEY_LEN) ); assert_eq!( - ::MAX_SECURITY_STRENGTH, - SecurityStrength::from_bytes(Aes192Params::KEY_LEN) + ::MAX_SECURITY_STRENGTH, + SecurityStrength::from_bytes(AES192Params::KEY_LEN) ); assert_eq!( - ::MAX_SECURITY_STRENGTH, - SecurityStrength::from_bytes(Aes256Params::KEY_LEN) + ::MAX_SECURITY_STRENGTH, + SecurityStrength::from_bytes(AES256Params::KEY_LEN) ); } } diff --git a/crypto/aes-lowmemory/src/bitslice.rs b/crypto/aes-lowmemory/src/bitslice.rs index 08ef77ff..43c1ad2e 100644 --- a/crypto/aes-lowmemory/src/bitslice.rs +++ b/crypto/aes-lowmemory/src/bitslice.rs @@ -1,73 +1,25 @@ -//! Conversion between AES blocks and the bit-sliced representation the round functions act on. +//! Conversion functions between AES blocks and the bit-sliced representation the round functions act on. //! -//! # What "bit-sliced" means here -//! -//! The round functions in [`crate::round`] and the S-box in [`crate::sbox`] do not operate on -//! bytes. They operate on eight `u32` *bit-planes*, `q[0]..q[7]`, where plane `q[k]` collects -//! bit `k` of every byte of the state. That is what lets the S-box be a Boolean circuit: one +//! The round functions in [`crate::round`] and the S-box in [`crate::sbox`] opperate accourding to +//! circuit `SLP_AES_113.txt` from +//! Peralta's circuit collection, described in J. Boyar and R. Peralta, "A new combinational logic +//! minimization technique with applications to cryptology", . +//! Its fundamental innovation is to take the 16 bytes of the state and effectively transpose it into +//! 8 u16's where the i'th u16 holds the i'th bit of each byte of the state. +//! That is what lets the S-box be a Boolean circuit: one //! `&` or `^` on a plane applies that gate to all sixteen byte positions at once, and no memory //! access is ever indexed by a secret value. -//! -//! Eight 32-bit planes hold 256 bits = 32 bytes, which is *two* 16-byte AES blocks. Both blocks -//! are always processed together; see the crate docs for why, and [`crate::aes`] for how a -//! single-block call fills the unused half. -//! -//! # The layout, derived -//! -//! [`ortho`] transposes, within each byte-lane of the eight words, the 8x8 bit matrix indexed by -//! (word number, bit number within the lane): -//! -//! ```text -//! after ortho: q[k] bit (8L + i) == before ortho: q[i] bit (8L + k) -//! ``` -//! -//! [`pack`] loads block A as four little-endian `u32`s into the even words and block B into the -//! odd words, so before `ortho` byte-lane `L` of word `2c` holds `A[4c + L]`. Substituting -//! `j = 4c + L` for the byte index, and FIPS 197 Eq (3.6) `s[r,c] = in[r + 4c]` -- which makes -//! `r = j mod 4` and `c = j div 4` -- gives the layout every mask in this crate depends on: -//! -//! ```text -//! q[k] bit (8r + 2c) == bit k of s[r,c] of block A -//! q[k] bit (8r + 2c + 1) == bit k of s[r,c] of block B -//! ``` -//! -//! In words: **the byte-lane of the word selects the state row `r`, and the bit-pair within that -//! lane selects the state column `c`; the low bit of the pair is block A and the high bit is -//! block B.** Written out, the bit position of `s[r,c]` within every plane is: -//! -//! ```text -//! c=0 c=1 c=2 c=3 -//! r=0 | 0 2 4 6 -//! r=1 | 8 10 12 14 (bit position of block A; -//! r=2 | 16 18 20 22 add 1 for block B) -//! r=3 | 24 26 28 30 -//! ``` -//! -//! This is why SHIFTROWS() becomes a rotation *within* a byte-lane (row `r` lives entirely in -//! lane `r`, and one column step is two bit positions), and why MIXCOLUMNS() uses rotations by -//! 8 and 16 (one and two rows). Both are derived from this table in [`crate::round`]. -//! -//! `test_layout_matches_the_documented_table` below pins the table exhaustively; every mask in -//! this crate is only correct relative to it. -//! -//! # Provenance -//! -//! The three-stage masked-swap transpose and the even/odd two-block packing are translated from -//! BearSSL `src/symcipher/aes_ct.c` (`br_aes_ct_ortho`) and `aes_ct_cbcdec.c` (the `q[0]`, -//! `q[2]`, `q[4]`, `q[6]` load order), by Thomas Pornin, MIT licensed. +//! This implementation handles two input blocks at a time, so the planes are in fact u32's still with +//! 8 lanes. -/// One 16-byte AES block, in the order of FIPS 197 Eq (3.6): `block[r + 4c] == s[r,c]`. -pub type Block = [u8; crate::BLOCK_LEN]; +use crate::aes::Block; +use bouncycastle_utils::secret::Secret; /// The eight bit-planes holding two blocks. See the module docs for the layout. pub(crate) type Planes = [u32; 8]; /// Transposes bytes into bit-planes, and back -- it is its own inverse. /// -/// Three stages of masked swaps exchange bit-fields of width 1, 2 and 4 between pairs of words, -/// which together transpose the 8x8 bit matrix inside each byte-lane. See the module docs for -/// the resulting layout. -/// /// Translated from BearSSL `aes_ct.c:br_aes_ct_ortho` (the `SWAP2`/`SWAP4`/`SWAP8` macros). pub(crate) fn ortho(q: &mut Planes) { /// One masked swap: exchanges the `cl`-selected fields of `y` into `x` and the `ch`-selected @@ -75,10 +27,9 @@ pub(crate) fn ortho(q: &mut Planes) { /// /// `cl` and `ch` are complementary, and `s` is exactly the field width, so in each returned /// word the two combined operands occupy disjoint bits: `(x & cl)` and `(y & cl) << s` cannot - /// both be set in the same position. `|` and `^` therefore compute the same function here, - /// which is why `cargo mutants` reports the `| -> ^` mutants in this function as surviving -- - /// they are equivalent programs. `test_ortho_is_an_involution` and - /// `test_layout_matches_the_documented_table` are what actually pin this code. + /// both be set in the same position. + /// + /// Mutants note: `|` and `^` compute the same function here. #[inline(always)] fn swap(cl: u32, ch: u32, s: u32, x: u32, y: u32) -> (u32, u32) { ((x & cl) | ((y & cl) << s), ((x & ch) >> s) | (y & ch)) @@ -102,8 +53,10 @@ pub(crate) fn ortho(q: &mut Planes) { /// /// Block `a` goes into the even words and block `b` into the odd words as little-endian `u32`s, /// then [`ortho`] transposes them into planes. -pub(crate) fn pack(a: &Block, b: &Block) -> Planes { - let mut q = [0u32; 8]; +/// +/// As this represents the working state of the block cipher, it is wrapped in [`Secret`]. +pub(crate) fn pack(a: &Block, b: &Block) -> Secret { + let mut q = Secret::<[u32; 8]>::new(); for c in 0..4 { // `try_into` cannot fail: the slice is a fixed 4-byte window of a 16-byte array. q[2 * c] = u32::from_le_bytes(a[4 * c..4 * c + 4].try_into().unwrap()); diff --git a/crypto/aes-lowmemory/src/lib.rs b/crypto/aes-lowmemory/src/lib.rs index 866a5167..60d2b1d9 100644 --- a/crypto/aes-lowmemory/src/lib.rs +++ b/crypto/aes-lowmemory/src/lib.rs @@ -1,6 +1,6 @@ -//! A constant-time, table-free AES block cipher engine (NIST FIPS 197). +//! A constant-time, table-free AES block cipher implementation according to NIST FIPS 197. //! -//! This crate provides the raw AES keyed permutation -- [`Aes128`], [`Aes192`] and [`Aes256`] -- +//! This crate provides the raw AES keyed permutation -- [`AES_128`], [`AES_192`] and [`AES_256`] -- //! implemented as a Boolean circuit over bit-planes rather than as byte substitutions through a //! lookup table. That makes it both smaller and constant-time; see [Design](#design). //! @@ -11,8 +11,12 @@ //! //! ## Encrypting and decrypting a single block //! +//! 🚨 Security Note 🚨 : This crate exposes only the single-block primitive (equivalent to ECB mode) +//! and is not generally secure to use on its own, but instead is a building block for higher-level +//! constructions such as AES_CBC or AES_GCM. +//! //! ``` -//! use bouncycastle_aes_lowmemory::Aes128; +//! use bouncycastle_aes_lowmemory::AES_128; //! use bouncycastle_core::key_material::{KeyMaterial, KeyType}; //! //! let key = KeyMaterial::<16>::from_bytes_as_type( @@ -21,9 +25,11 @@ //! KeyType::SymmetricCipherKey, //! ).expect("a 16-byte symmetric cipher key"); //! -//! let aes = Aes128::new(&key).expect("a valid AES-128 key"); +//! // Instantiate the key schedule and create an object ready to encrypt or decrypt. +//! let aes = AES_128::new(&key).expect("a valid AES-128 key"); //! -//! // FIPS 197 Appendix B. +//! // This is not quite a "plaintext" since it has to be exactly one block (16 bytes) wide. +//! // Example from FIPS 197 Appendix B. //! let mut block = [0x32, 0x43, 0xf6, 0xa8, 0x88, 0x5a, 0x30, 0x8d, //! 0x31, 0x31, 0x98, 0xa2, 0xe0, 0x37, 0x07, 0x34]; //! aes.encrypt_block(&mut block); @@ -39,123 +45,75 @@ //! ## Two blocks at a time //! //! The bit-sliced state holds two blocks, so two independent blocks cost barely more than one. -//! Where a caller has two, [`Aes::encrypt_blocks2`] is roughly twice the throughput of two -//! [`Aes::encrypt_block`] calls: +//! Where a caller has two, [`AES::encrypt_2blocks`] is roughly twice the throughput of two +//! [`AES::encrypt_block`] calls: //! //! ``` -//! use bouncycastle_aes_lowmemory::Aes256; +//! use bouncycastle_aes_lowmemory::AES_256; //! use bouncycastle_core::key_material::{KeyMaterial, KeyType}; //! //! let key = KeyMaterial::<32>::from_bytes_as_type(&[0x42; 32], KeyType::SymmetricCipherKey) //! .expect("a 32-byte symmetric cipher key"); -//! let aes = Aes256::new(&key).expect("a valid AES-256 key"); +//! let aes = AES_256::new(&key).expect("a valid AES-256 key"); //! //! let mut blocks = [[0u8; 16], [1u8; 16]]; -//! aes.encrypt_blocks2(&mut blocks); -//! aes.decrypt_blocks2(&mut blocks); +//! aes.encrypt_2blocks(&mut blocks); +//! aes.decrypt_2blocks(&mut blocks); //! assert_eq!(blocks, [[0u8; 16], [1u8; 16]]); //! ``` //! -//! There is no one-shot static on the permutation, because `Aes128::new(&key)?.encrypt_block(..)` -//! already *is* the one shot. Data-level one-shots belong to the modes of operation, which take -//! arbitrary-length input and generate their own initialisation data. -//! //! # Design //! -//! ## Why not a lookup table -//! -//! FIPS 197 Sec 5.1.1 presents the S-box as a table (Table 4), and almost every AES -//! implementation stores it as one -- 256 bytes, or 2-8 KiB for the "T-table" variants that fold -//! MIXCOLUMNS() in. The trouble is that a table indexed by a byte of the state is indexed by -//! secret data, so on any CPU with a data cache the memory access pattern, and hence the timing, -//! depends on the key. That is a practical, repeatedly-demonstrated attack, and it is not fixable -//! while the lookup remains. -//! -//! Bouncy Castle's `AESLightEngine` in the Java and C# ports keeps two 256-byte S-box tables for -//! exactly this reason -- to be *small*, not to be constant-time -- and leaks through both the -//! cipher and the key schedule. -//! -//! ## Bit-slicing +//! ## No lookup table! //! -//! This crate has no tables at all. The state is transposed so that each of eight `u32` words -//! holds one *bit position* of every byte: word `q[k]` collects bit `k` of all the bytes. In that -//! form the S-box becomes a fixed Boolean circuit -- 32 AND, 77 XOR and 4 XNOR gates, the -//! 113-gate straight-line program of Boyar and Peralta -- and one `&` or `^` applies a gate to -//! every byte position at once. Nothing is ever indexed by a secret, and nothing branches on one. +//! FIPS 197 Sec 5.1.1 presents the S-box as a table (Table 4), and most software AES +//! implementation store it as one. The trouble is that a table indexed by a byte of the state is +//! indexed by secret data, so on any CPU with a data cache the memory access pattern, and hence the +//! timing, depends on the key. That is a practical, repeatedly-demonstrated attack, and it is not +//! fixable with a lookup table-based implementation. //! -//! Eight 32-bit words hold 32 bytes, which is two AES blocks, so blocks are processed in pairs. -//! SHIFTROWS() and MIXCOLUMNS() become masks and rotations in the same representation, and the -//! key schedule is stored bit-sliced too, so no transposition happens inside the round loop. The -//! exact bit layout, and the derivation of every mask from it, is documented in the `bitslice` -//! and `round` modules -- those two module docs are the place to start when reading the source. +//! This implementation uses instead the 113-gate straight-line S-box circuit `SLP_AES_113.txt` from +//! Peralta's circuit collection, described in J. Boyar and R. Peralta, "A new combinational logic +//! minimization technique with applications to cryptology", . //! -//! Decryption follows FIPS 197 Algorithm 3, the straight inverse cipher, rather than the -//! equivalent inverse cipher of Sec 5.3.5. Algorithm 3 puts INVMIXCOLUMNS() after ADDROUNDKEY(), -//! so it uses the *unmodified* key schedule; the equivalent inverse cipher would need a second -//! schedule with each round key transformed. One [`Aes`] value therefore encrypts and decrypts -//! from one stored schedule. +//! See [Constant Time](#constant-time-properties) for more discussion. //! //! # Memory Usage //! -//! There are no lookup tables and no heap allocation. The only persistent state is the key -//! schedule, which is `4 * (Nr + 1)` words -- exactly the size FIPS 197 Sec 5.2 defines, with the -//! bit-sliced form compressed so that bit-slicing costs nothing in space: +//! This is an all-stack, no-heap implementation. The only persistent state within the [`AES`] struct +//! is the key schedule, which is `4 * (Nr + 1)` 32-bit words. //! -//! | Type | Key | `Nr` | Schedule (persistent) | Tables | +//! | Type | Key | AES struct | //! |---|---|---|---|---| -//! | [`Aes128`] | 16 B | 10 | 176 B | 0 B | -//! | [`Aes192`] | 24 B | 12 | 208 B | 0 B | -//! | [`Aes256`] | 32 B | 14 | 240 B | 0 B | +//! | [`AES_128`] | 16 B | 176 B | +//! | [`AES_192`] | 24 B | 208 B | +//! | [`AES_256`] | 32 B | 240 B | //! -//! Per-call stack usage is independent of key length: 32 bytes of bit-sliced state for the two -//! blocks, 32 bytes for the round key expanded from its compressed form, plus the S-box circuit's -//! temporaries, most of which the compiler keeps in registers. +//! Measured with `cargo run --release -p mem_usage_benches --bin bench_aes_mem_usage`. +// dev todo: if we can improve our testing framework to measure stack usages this small, +// it would be nice to add columns for stack usage of the encrypt and decrypt functions. //! -//! For comparison, `AESLightEngine` carries 512 bytes of tables and a T-table implementation -//! carries 2-8 KiB, in both cases *on top of* a key schedule of this same size. -//! -//! Measure with `cargo run --release -p mem_usage_benches --bin bench_aes_mem_usage`. -//! -//! # Security Considerations +//! # 🚨 Security Considerations 🚨 //! //! ## A block permutation is not a cipher //! -//! [`Aes128`] and friends transform exactly 16 bytes. Using them directly on data means ECB, -//! which is not confidential: identical plaintext blocks produce identical ciphertext blocks, so -//! structure in the plaintext survives encryption. **Do not do it.** Use a mode of operation, and -//! prefer an authenticated one so that ciphertext tampering is detected. +//! The [`AES`] function implemented in this crate is a building block for safe and secure AES +//! constructions, **but it must not be used directly to encrypt data**. +//! +//! It transforms exactly 16 bytes where the same input +//! block always gives the same output block; a mode called Electronic Code Book (ECB). +//! This by itself offers almost no security because even though the permutation is unique per key, +//! once an attacker who knows the structure or partial content of the plaintext message con relatively +//! easily build a dictionary of plaintext blocks to ciphertext blocks and fully decrypt the message. //! //! ## Constant-time properties //! //! By construction there is no secret-dependent memory access and no secret-dependent branch, -//! in the cipher *or* in the key schedule -- SUBWORD() goes through the same circuit as -//! SUBBYTES(). The only branches are the round loops, which count over the public `Nr`. -//! -//! Caveats worth stating plainly: -//! -//! * The Rust compiler makes no guarantee it will preserve this. The code is written so that the -//! natural code generation is straight-line, and `#![forbid(unsafe_code)]` rules out the usual -//! ways of forcing the issue, but the property is not contractual. -//! * The 32-byte working state is not scrubbed after a block. Only the key schedule is wrapped in -//! `Secret`, and so only it is guaranteed to be zeroized on drop. -//! * Constant-time execution says nothing about power or electromagnetic side channels. -//! -//! # Provenance -//! -//! * Normative reference: **NIST FIPS 197** (Advanced Encryption Standard), including Update 1. -//! Every transformation cites its section, algorithm and equation numbers. -//! * The S-box circuit is the 113-gate straight-line program `SLP_AES_113.txt` from Peralta's -//! circuit collection, described in J. Boyar and R. Peralta, "A new combinational logic -//! minimization technique with applications to cryptology", -//! . -//! * The bit-sliced two-block structure, the transpose, and the SHIFTROWS()/MIXCOLUMNS() mask and -//! rotation constants are translated from BearSSL's `aes_ct` implementation by Thomas Pornin -//! (MIT licence). Each constant is re-derived from the documented bit layout in the comments, -//! and each is pinned by a test against a byte-wise reference written from the FIPS 197 -//! equations. -//! * Verified against FIPS 197 Appendix A (all three key expansions, every word), FIPS 197 -//! Appendix B, NIST SP 800-38A Appendix F.1 (ECB, all three key lengths, both directions), and -//! the NIST ACVP `ACVP-AES-ECB` vectors. +//! in the cipher *or* in the key schedule. +//! The only branches are the round loops, which count over the public value `Nr`. +//! +//! As with all cryptography written in pure safe rust, the Rust compiler makes no guarantee it will +//! preserve constant-time behaviours through its optimizations. #![no_std] #![forbid(unsafe_code)] @@ -163,6 +121,9 @@ // `AesParams` is deliberately sealed with a private supertrait so that no fourth parameter set can // be added outside this crate; that is what triggers this lint. #![allow(private_bounds)] +// Turn off clippy on names because we want names to match exactly FIPS 197, even if that goes against +// Rust convention. +#![allow(non_camel_case_types)] mod aes; mod bitslice; @@ -170,6 +131,5 @@ mod round; mod sbox; mod schedule; -pub use aes::{Aes, Aes128, Aes192, Aes256, BLOCK_LEN}; -pub use bitslice::Block; -pub use schedule::{Aes128Params, Aes192Params, Aes256Params, AesParams}; +pub use aes::{AES, AES_128, AES_192, AES_256, BLOCK_LEN, Block}; +pub use schedule::{AES128Params, AES192Params, AES256Params, AESParams}; diff --git a/crypto/aes-lowmemory/src/round.rs b/crypto/aes-lowmemory/src/round.rs index b42406cf..449122b3 100644 --- a/crypto/aes-lowmemory/src/round.rs +++ b/crypto/aes-lowmemory/src/round.rs @@ -1,35 +1,7 @@ -//! The three linear round transformations, on bit-planes. -//! -//! | Function | FIPS 197 | Inverse | FIPS 197 | -//! |---|---|---|---| -//! | [`add_round_key`] | Sec 5.1.4, Eq 5.9 | itself (XOR) | Sec 5.3.4 | -//! | [`shift_rows`] | Sec 5.1.2, Eq 5.5 | [`inv_shift_rows`] | Sec 5.3.1, Eq 5.12 | -//! | [`mix_columns`] | Sec 5.1.3, Eq 5.8 | [`inv_mix_columns`] | Sec 5.3.3, Eq 5.15 | -//! -//! SUBBYTES() is in [`crate::sbox`], because it is the only non-linear step and the only one that -//! needs a circuit rather than masks and rotations. +//! Implements AddRoundKey(), ShiftRows(), and MixColumns() from FIPS 197. //! //! Everything here is XOR, AND with a constant mask, and rotation by a constant. No operation //! depends on the data, so all of it is inherently constant-time. -//! -//! # How the layout turns row and column arithmetic into shifts -//! -//! From the layout derived in [`crate::bitslice`], within every plane the bit holding `s[r,c]` -//! of block A sits at bit position `8r + 2c` (and block B at `8r + 2c + 1`). Two consequences -//! drive every constant below: -//! -//! * **A row is a byte-lane.** All of row `r` lives in bits `8r..8r+8` of every plane, and -//! stepping one column along that row is a step of two bit positions. So SHIFTROWS(), which -//! only permutes within rows, is a rotation *inside* each byte-lane, by `2r` positions. -//! * **Rotating a whole plane by 8 changes the row.** `x.rotate_right(8)` brings the contents of -//! lane `r+1` into lane `r`, so `rotate_right(8)` reads "the next row down" and -//! `rotate_right(16)` reads "two rows down". MIXCOLUMNS(), which combines the four rows of a -//! column, is therefore expressible with those two rotations and no shuffling at all. -//! -//! Provenance: the mask and rotation constants are translated from BearSSL -//! `src/symcipher/aes_ct_enc.c` and `aes_ct_dec.c` (MIT, Thomas Pornin). Each is re-derived from -//! the layout in the comments below, and each is pinned by a test in this file against a -//! byte-wise reference written directly from the FIPS 197 equations. use crate::bitslice::Planes; @@ -166,10 +138,7 @@ pub(crate) fn mix_columns(q: &mut Planes) { /// The reduction terms are not confined to planes 0, 1, 3 and 4 here, because the higher-degree /// coefficients feed carries into every plane. /// -/// Translated from BearSSL `aes_ct_dec.c:inv_mix_columns`. Rather than trust the expansion by -/// inspection, `test_inv_mix_columns_matches_equation_5_15` checks it against a byte-wise -/// reference written straight from Eq 5.15, and `test_inv_mix_columns_inverts_mix_columns` -/// checks the two are inverses. +/// Translated from BearSSL `aes_ct_dec.c:inv_mix_columns`. #[inline(always)] #[rustfmt::skip] pub(crate) fn inv_mix_columns(q: &mut Planes) { diff --git a/crypto/aes-lowmemory/src/sbox.rs b/crypto/aes-lowmemory/src/sbox.rs index 8e68d2e3..e676fdff 100644 --- a/crypto/aes-lowmemory/src/sbox.rs +++ b/crypto/aes-lowmemory/src/sbox.rs @@ -1,51 +1,4 @@ //! SUBBYTES() and INVSUBBYTES() as a Boolean circuit (FIPS 197 Sec 5.1.1 and Sec 5.3.2). -//! -//! # Why a circuit and not a table -//! -//! FIPS 197 Sec 5.1.1 presents the S-box as a 256-entry lookup table (Table 4). A table lookup -//! indexed by a byte of the state is indexed by *secret data*, and on any CPU with a data cache -//! the access pattern -- hence the timing -- depends on that secret. That is the standard AES -//! cache-timing side channel, and it cannot be closed while keeping the lookup. -//! -//! So this module does not have a table. It computes the same function as Table 4 with AND, XOR -//! and XNOR gates applied to the bit-planes described in [`crate::bitslice`]. Every operation is -//! a straight-line word operation on public *positions*, so there is no secret-dependent memory -//! access and no secret-dependent branch. The two functions here are the only place in the crate -//! where secret data meets non-linear logic; everything else is XOR, rotate and mask. -//! -//! Because the planes hold sixteen byte positions of two blocks at once, one pass of the circuit -//! substitutes all 32 bytes -- the whole SUBBYTES() transformation of two blocks -- rather than -//! one byte. -//! -//! # What the circuit computes -//! -//! FIPS 197 Sec 5.1.1 defines the S-box as inversion in GF(2^8) followed by an affine map -//! (Eq. 5.2), tabulated in Table 4. The circuit below is the 113-gate straight-line program of -//! Boyar and Peralta -- 32 AND, 77 XOR and 4 XNOR gates -- which computes exactly that, -//! including the affine map and its `{63}` constant (the constant is folded into the four XNORs -//! at the end of the bottom linear transformation). -//! -//! Sources: -//! * The straight-line program `SLP_AES_113.txt`, from Peralta's circuit collection. -//! * J. Boyar and R. Peralta, "A new combinational logic minimization technique with -//! applications to cryptology", . -//! * The same circuit appears in BearSSL `aes_ct.c:br_aes_ct_bitslice_Sbox` (MIT, Thomas -//! Pornin), whose variable naming is kept here so the two can be diffed. BearSSL re-associates -//! two gates in the non-linear section (its `t17`/`t21` differ from the SLP file, computing the -//! same `t21`) and uses a different but equivalent bottom linear transformation; where they -//! disagree this file follows `SLP_AES_113.txt`. -//! -//! The gate list is a mechanical transcription of `SLP_AES_113.txt`: `+` became `^`, `x` became -//! `&`, `#` became `!(.. ^ ..)`, and the SLP variable names are unchanged apart from case. It is -//! not independently meaningful line by line and should not be "tidied"; it is verified as a -//! whole by `test_sbox_matches_fips197_table_4`, which checks all 256 inputs against Table 4. -//! -//! # Bit numbering -//! -//! The SLP numbers its inputs `U0..U7` and outputs `S0..S7` with **`U0` as the most significant -//! bit** of the byte, which is the reverse of the plane index. So `U0` is plane `q[7]` and `U7` -//! is plane `q[0]`, and likewise for the outputs. `test_sbox_matches_fips197_table_4` is what -//! pins this down -- reversing it produces a wrong S-box, not a subtly different one. use crate::bitslice::Planes; @@ -64,7 +17,6 @@ pub(crate) fn sbox(q: &mut Planes) { let u6 = q[1]; let u7 = q[0]; - // Top linear transformation (23 gates): the input basis change. let y14 = u3 ^ u5; let y13 = u0 ^ u6; let y9 = u0 ^ u3; @@ -89,7 +41,6 @@ pub(crate) fn sbox(q: &mut Planes) { let y21 = y13 ^ y16; let y18 = u0 ^ y16; - // Non-linear section (62 gates): the GF(2^8) inversion, and the only ANDs in the circuit. let t2 = y12 & y15; let t3 = y3 & y6; let t4 = t3 ^ t2; @@ -125,10 +76,11 @@ pub(crate) fn sbox(q: &mut Planes) { let t34 = t23 ^ t33; let t35 = t27 ^ t33; let t36 = t24 & t35; - // `cargo mutants` reports the `^ -> |` mutant on the next line as surviving. That is a true + + // Mutants note: mutants reports the `^ -> |` mutant on the next line as surviving. That is a true // equivalence, not a gap: `t36` and `t34` are never both 1 for any of the 256 possible input // bytes, so XOR and OR agree here. It is the only one of the circuit's 77 XOR gates with that - // property -- every other `^ -> |` mutant is killed by `test_sbox_matches_fips197_table_4`. + // property. let t37 = t36 ^ t34; let t38 = t27 ^ t36; let t39 = t29 & t38; @@ -157,8 +109,6 @@ pub(crate) fn sbox(q: &mut Planes) { let z16 = t45 & y14; let z17 = t41 & y8; - // Bottom linear transformation (28 gates): the output basis change and the affine map of - // Eq. 5.2, whose `{63}` constant is the four XNORs below. let tc1 = z15 ^ z16; let tc2 = z10 ^ tc1; let tc3 = z9 ^ tc2; diff --git a/crypto/aes-lowmemory/src/schedule.rs b/crypto/aes-lowmemory/src/schedule.rs index 9ae50e38..5ef51fa3 100644 --- a/crypto/aes-lowmemory/src/schedule.rs +++ b/crypto/aes-lowmemory/src/schedule.rs @@ -1,6 +1,6 @@ //! KEYEXPANSION() (FIPS 197 Sec 5.2, Algorithm 2) and the per-key-length parameters. //! -//! # Storage +//! # Memory usage and representation //! //! The schedule is `4 * (Nr + 1)` words -- 44, 52 or 60 -- exactly as FIPS 197 Sec 5.2 defines //! it, so 176, 208 or 240 bytes. It is stored in a **compressed** bit-sliced form: because @@ -8,20 +8,6 @@ //! blocks are encrypted under the same key the two halves of a bit-sliced round key are //! identical, so only one of every pair of words needs keeping. [`round_key`] re-doubles a single //! round key onto the stack when the round loop needs it. -//! -//! The alternative -- storing the doubled 8-plane form -- would need 352, 416 or 480 bytes, and -//! holding the classical schedule *and* a bit-sliced copy would be worse still. Since low memory -//! is the point of this crate, neither is done: [`expand`] writes the classical schedule into the -//! final array and then rewrites it in place, one round key at a time, using eight words of -//! stack. In particular it does not mirror BearSSL's `uint32_t skey[120]` (480-byte) scratch -//! buffer. -//! -//! # Constant-time -//! -//! The key is secret, so SUBWORD() in the expansion has the same table-lookup problem as -//! SUBBYTES() in the cipher, and gets the same treatment: [`sub_word`] routes the word through -//! the bit-sliced circuit in [`crate::sbox`]. A table-driven "light" AES that only removes the -//! tables from the cipher, and not from the key schedule, still leaks through the schedule. use crate::bitslice::{Planes, ortho}; use crate::sbox::sbox; @@ -32,51 +18,44 @@ use bouncycastle_utils::secret::{Secret, ZeroizablePrimitive}; /// Table 5 gives each as the word `[x, 00, 00, 00]`; only the leftmost byte is ever non-zero, and /// words are held little-endian here, so the word `Rcon[j]` is just this byte. Indexing is shifted /// by one against the spec: `RCON[j - 1]` is the spec's `Rcon[j]`, since the spec counts from 1. -const RCON: [u32; 10] = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36]; +#[allow(non_upper_case_globals)] +const Rcon: [u32; 10] = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36]; -/// Prevents a fourth parameter set from being added outside this crate. -/// -/// FIPS 197 Sec 6.1 defines exactly three: AES-128, AES-192 and AES-256. Because [`AesParams`] -/// has this private supertrait, only the three types in this module can implement it, so no -/// downstream crate can instantiate the cipher with an unapproved key length or round count. -trait AesParamsSealed {} +/// A crate-private (aka "sealed") trait that prevents a new AES parameter set from being defined +/// outside this crate. +trait AESParamsInternalTrait {} -/// The per-key-length constants of FIPS 197 Sec 6.1. -/// -/// This is a trait rather than const generic parameters because the schedule length -/// `4 * (Nr + 1)` cannot be written as an expression over another const parameter on stable -/// const-generics; each implementation spells its own array type out instead. The same pattern is -/// used by the `HashDRBG80090AParams_*` types in `bouncycastle-rng`. +/// The per-key-length constants of FIPS 197 §5, Table 3. /// /// Sealed via a private supertrait, so the three types below are the only implementations. -pub trait AesParams: AesParamsSealed { - /// Key length in bytes: 16, 24 or 32 (FIPS 197 Sec 6.1). +pub trait AESParams: AESParamsInternalTrait { + /// Key length in bytes: 16, 24 or 32 (FIPS 197 §5, Table 3). const KEY_LEN: usize; - /// `Nk`, the key length in 32-bit words: 4, 6 or 8 (FIPS 197 Sec 6.1). + /// `Nk`, the key length in 32-bit words: 4, 6 or 8 (FIPS 197 §5, Table 3). const NK: usize; - /// `Nr`, the number of rounds: 10, 12 or 14 (FIPS 197 Sec 6.1). + /// `Nr`, the number of rounds: 10, 12 or 14 (FIPS 197 §5, Table 3). const NR: usize; /// The algorithm name, as reported by `Algorithm::ALG_NAME`. const ALG_NAME: &'static str; - /// `[u32; 4 * (NR + 1)]` -- the compressed schedule. See the module docs. + /// The compressed schedule of size `[u32; 4 * (NR + 1)]`. See the module docs. type Schedule: ZeroizablePrimitive + AsRef<[u32]> + AsMut<[u32]>; } -/// AES-128 parameters: 16-byte key, `Nk` = 4, `Nr` = 10 (FIPS 197 Sec 6.1). +/// AES-128 parameters: 16-byte key, `Nk` = 4, `Nr` = 10 (FIPS 197 §5, Table 3). #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Aes128Params; -/// AES-192 parameters: 24-byte key, `Nk` = 6, `Nr` = 12 (FIPS 197 Sec 6.1). +pub struct AES128Params; +/// AES-192 parameters: 24-byte key, `Nk` = 6, `Nr` = 12 (FIPS 197 §5, Table 3). #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Aes192Params; -/// AES-256 parameters: 32-byte key, `Nk` = 8, `Nr` = 14 (FIPS 197 Sec 6.1). +pub struct AES192Params; +/// AES-256 parameters: 32-byte key, `Nk` = 8, `Nr` = 14 (FIPS 197 §5, Table 3). #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Aes256Params; +pub struct AES256Params; -impl AesParamsSealed for Aes128Params {} -impl AesParamsSealed for Aes192Params {} -impl AesParamsSealed for Aes256Params {} +impl AESParamsInternalTrait for AES128Params {} +impl AESParamsInternalTrait for AES192Params {} +impl AESParamsInternalTrait for AES256Params {} -impl AesParams for Aes128Params { +impl AESParams for AES128Params { const KEY_LEN: usize = 16; const NK: usize = 4; const NR: usize = 10; @@ -84,7 +63,7 @@ impl AesParams for Aes128Params { type Schedule = [u32; 44]; // 4 * (10 + 1) } -impl AesParams for Aes192Params { +impl AESParams for AES192Params { const KEY_LEN: usize = 24; const NK: usize = 6; const NR: usize = 12; @@ -92,7 +71,7 @@ impl AesParams for Aes192Params { type Schedule = [u32; 52]; // 4 * (12 + 1) } -impl AesParams for Aes256Params { +impl AESParams for AES256Params { const KEY_LEN: usize = 32; const NK: usize = 8; const NR: usize = 14; @@ -112,40 +91,36 @@ fn rot_word(word: u32) -> u32 { /// SUBWORD(): applies the S-box to each of the four bytes of a word /// (FIPS 197 Sec 5.2, Eq 5.11). /// -/// The key is secret, so this must not be a table lookup. It reuses the bit-sliced circuit -/// instead, by replicating `word` into all eight planes before transposing: +/// It copies and acts on the provided word 8 times, which costs a full 8-word 113-gate S-box evaluation +/// which is wasteful, but it happens `Nr` or so times per key rather than per block. /// -/// after [`ortho`], plane `q[k]` bit `8L + i` equals bit `8L + k` of the *input* word `q[i]` -- -/// and every input word is the same `word`, so that bit is bit `k` of byte `L` of `word` -/// regardless of `i`. In the layout of [`crate::bitslice`], the bit positions `8L + i` for -/// `i = 0..8` are all four columns of row `L`, in both blocks. So the transposed state holds byte -/// `L` of `word` in every position of row `L`, one S-box pass substitutes all four bytes (sixteen -/// times over, redundantly), and transposing back reassembles the word. All eight planes then -/// hold the same result, so `q[0]` is SUBWORD(`word`); `test_sub_word_fills_every_plane` checks -/// that. -/// -/// It costs a full 113-gate S-box evaluation to substitute four bytes, which is wasteful, but it -/// happens `Nr` or so times per key rather than per block. Translated from BearSSL -/// `aes_ct.c:sub_word`. +/// Translated from BearSSL `aes_ct.c:sub_word`. fn sub_word(word: u32) -> u32 { let mut q: Planes = [word; 8]; ortho(&mut q); sbox(&mut q); ortho(&mut q); + + // Check that the redundant 8 copies of the word all came out the same. + debug_assert!( + q[0] == q[1] + && q[0] == q[2] + && q[0] == q[3] + && q[0] == q[4] + && q[0] == q[5] + && q[0] == q[6] + && q[0] == q[7] + ); + q[0] } /// KEYEXPANSION() (FIPS 197 Sec 5.2, Algorithm 2), returning the compressed bit-sliced schedule. +/// Algorithm 2 is followed literally. /// /// `key` must be exactly `P::KEY_LEN` bytes; [`crate::aes`] checks that before calling, so this /// cannot fail and takes no `Result`. -/// -/// Algorithm 2 is followed literally -- lines 2-6 copy the key into `w[0..Nk]`, lines 7-16 derive -/// the rest -- and then the finished schedule is rewritten in place into the storage form -/// described in the module docs. Verified against the worked expansions in FIPS 197 -/// Appendix A.1, A.2 and A.3 by the tests at the bottom of this file, which decompress the -/// stored schedule and compare every w[i]. -pub(crate) fn expand(key: &[u8]) -> Secret { +pub(crate) fn expand(key: &[u8]) -> Secret { debug_assert_eq!(key.len(), P::KEY_LEN); let mut schedule = Secret::::new(); @@ -162,7 +137,7 @@ pub(crate) fn expand(key: &[u8]) -> Secret { for i in P::NK..w.len() { if i % P::NK == 0 { // line 10: temp = SUBWORD(ROTWORD(temp)) XOR Rcon[i / Nk] - temp = sub_word(rot_word(temp)) ^ RCON[i / P::NK - 1]; + temp = sub_word(rot_word(temp)) ^ Rcon[i / P::NK - 1]; } else if P::NK > 6 && i % P::NK == 4 { // lines 11-12: the extra substitution that only AES-256 reaches temp = sub_word(temp); @@ -203,7 +178,7 @@ pub(crate) fn expand(key: &[u8]) -> Secret { /// /// Translated from BearSSL `aes_ct.c:br_aes_ct_skey_expand`. #[inline(always)] -pub(crate) fn round_key(schedule: &P::Schedule, round: usize) -> Planes { +pub(crate) fn round_key(schedule: &P::Schedule, round: usize) -> Planes { debug_assert!(round <= P::NR); let w = schedule.as_ref(); let mut sk: Planes = [0u32; 8]; @@ -285,7 +260,7 @@ mod tests { /// leaving the duplicated pre-slicing words with `w[4*round + j]` in position `2j`. This is /// what lets the Appendix A vectors test the real [`expand`] output rather than a /// reimplementation of it. - fn classical_word(schedule: &P::Schedule, i: usize) -> u32 { + fn classical_word(schedule: &P::Schedule, i: usize) -> u32 { let mut q = round_key::

(schedule, i / 4); ortho(&mut q); let j = i % 4; @@ -298,7 +273,7 @@ mod tests { /// Appendix A prints a word as the byte sequence `[a0,a1,a2,a3]` left to right, so the /// tabulated `u32` has `a0` in its *most* significant byte; words are held little-endian /// here, so `swap_bytes` is the conversion. - fn assert_expansion_matches(key: &[u8], expected: &[u32], label: &str) { + fn assert_expansion_matches(key: &[u8], expected: &[u32], label: &str) { let schedule = expand::

(key); assert_eq!(expected.len(), 4 * (P::NR + 1), "{label}: table length"); for (i, &want) in expected.iter().enumerate() { @@ -313,7 +288,7 @@ mod tests { 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c, ]; - assert_expansion_matches::(&key, &APPENDIX_A1_WORDS, "Appendix A.1"); + assert_expansion_matches::(&key, &APPENDIX_A1_WORDS, "Appendix A.1"); } #[test] @@ -322,7 +297,7 @@ mod tests { 0x8e, 0x73, 0xb0, 0xf7, 0xda, 0x0e, 0x64, 0x52, 0xc8, 0x10, 0xf3, 0x2b, 0x80, 0x90, 0x79, 0xe5, 0x62, 0xf8, 0xea, 0xd2, 0x52, 0x2c, 0x6b, 0x7b, ]; - assert_expansion_matches::(&key, &APPENDIX_A2_WORDS, "Appendix A.2"); + assert_expansion_matches::(&key, &APPENDIX_A2_WORDS, "Appendix A.2"); } #[test] @@ -332,7 +307,7 @@ mod tests { 0x77, 0x81, 0x1f, 0x35, 0x2c, 0x07, 0x3b, 0x61, 0x08, 0xd7, 0x2d, 0x98, 0x10, 0xa3, 0x09, 0x14, 0xdf, 0xf4, ]; - assert_expansion_matches::(&key, &APPENDIX_A3_WORDS, "Appendix A.3"); + assert_expansion_matches::(&key, &APPENDIX_A3_WORDS, "Appendix A.3"); } #[test] @@ -343,9 +318,9 @@ mod tests { 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c, ]; - let schedule = expand::(&key); - for i in 0..Aes128Params::NK { - let got = classical_word::(&schedule, i); + let schedule = expand::(&key); + for i in 0..AES128Params::NK { + let got = classical_word::(&schedule, i); assert_eq!(got.to_le_bytes(), key[4 * i..4 * i + 4]); } } @@ -388,7 +363,7 @@ mod tests { 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c, ]; - let schedule = expand::(&key); + let schedule = expand::(&key); // Recompute the classical schedule without the compression step. let mut w = [0u32; 44]; @@ -398,14 +373,14 @@ mod tests { let mut temp = w[3]; for i in 4..44 { if i % 4 == 0 { - temp = sub_word(rot_word(temp)) ^ RCON[i / 4 - 1]; + temp = sub_word(rot_word(temp)) ^ Rcon[i / 4 - 1]; } temp ^= w[i - 4]; w[i] = temp; } - for round in 0..=Aes128Params::NR { - let got = round_key::(&schedule, round); + for round in 0..=AES128Params::NR { + let got = round_key::(&schedule, round); let mut expected: Planes = [0u32; 8]; for j in 0..4 { expected[2 * j] = w[4 * round + j]; @@ -421,25 +396,25 @@ mod tests { // FIPS 197 Sec 5.2: the schedule is 4 * (Nr + 1) words. The array types are written out // by hand per parameter set, so this guards against a typo in one of them. assert_eq!( - size_of::<::Schedule>() / 4, - 4 * (Aes128Params::NR + 1) + size_of::<::Schedule>() / 4, + 4 * (AES128Params::NR + 1) ); assert_eq!( - size_of::<::Schedule>() / 4, - 4 * (Aes192Params::NR + 1) + size_of::<::Schedule>() / 4, + 4 * (AES192Params::NR + 1) ); assert_eq!( - size_of::<::Schedule>() / 4, - 4 * (Aes256Params::NR + 1) + size_of::<::Schedule>() / 4, + 4 * (AES256Params::NR + 1) ); } #[test] fn test_key_len_is_four_times_nk() { - // FIPS 197 Sec 6.1 ties the two together; both are declared independently above. - assert_eq!(Aes128Params::KEY_LEN, 4 * Aes128Params::NK); - assert_eq!(Aes192Params::KEY_LEN, 4 * Aes192Params::NK); - assert_eq!(Aes256Params::KEY_LEN, 4 * Aes256Params::NK); + // FIPS 197 §5, Table 3 ties the two together; both are declared independently above. + assert_eq!(AES128Params::KEY_LEN, 4 * AES128Params::NK); + assert_eq!(AES192Params::KEY_LEN, 4 * AES192Params::NK); + assert_eq!(AES256Params::KEY_LEN, 4 * AES256Params::NK); } #[test] @@ -453,9 +428,9 @@ mod tests { *slot = u32::from(v); v = (v << 1) ^ if v & 0x80 != 0 { 0x1b } else { 0 }; } - assert_eq!(RCON, expected); + assert_eq!(Rcon, expected); // Spot-check the two values from Table 5 that are not plain powers of two. - assert_eq!(RCON[8], 0x1b); - assert_eq!(RCON[9], 0x36); + assert_eq!(Rcon[8], 0x1b); + assert_eq!(Rcon[9], 0x36); } } diff --git a/crypto/aes-lowmemory/summary.md b/crypto/aes-lowmemory/summary.md deleted file mode 100644 index 4933c652..00000000 --- a/crypto/aes-lowmemory/summary.md +++ /dev/null @@ -1,475 +0,0 @@ -# `crypto/aes-lowmemory` — implementation summary - -A constant-time, table-free AES block cipher engine (NIST FIPS 197), added 2026-08-31 on branch -`feature/officialfrancismendoza/98-AES-lowmemory`. - -This document is the reviewer's orientation: what was built, why the design is the way it is, what -was verified and how, and — importantly — the three places where the working plan or model recall -turned out to be wrong. For end-user documentation see the crate docs in -[`src/lib.rs`](src/lib.rs); for the reasoning behind each individual constant, see the module docs -in [`src/bitslice.rs`](src/bitslice.rs) and [`src/round.rs`](src/round.rs), which are the right -place to start reading the source. - ---- - -## 1. What this crate is (and is not) - -It provides the **raw AES keyed permutation** — `Aes128`, `Aes192`, `Aes256` — transforming exactly -16 bytes at a time. It is not something you can encrypt data with: used directly on data it *is* -ECB, which is not confidential. Modes of operation and padding are separate layers. - -Consistent with the earlier scoping decision for the AES engine, the crate deliberately ships: - -* **no CLI subcommand** — a bare permutation can only offer ECB, -* **no factory registration**, -* **no `core` cipher-trait implementations** (`SymmetricCipher` / `BlockCipherEncryptor` / - `BlockCipherDecryptor`) — those traits are about encrypting *data* and generating initialisation - data, which are mode-of-operation concerns, -* **no `AlgorithmOID`** — NIST CSOR assigns AES OIDs per mode, never to the bare cipher. - -It does implement `core::traits::Algorithm` (name and maximum security strength), which is -metadata rather than a data-encryption API. - ---- - -## 2. Design - -### 2.1 Why there is no lookup table - -FIPS 197 Sec 5.1.1 presents the S-box as a 256-entry table (Table 4), and almost every AES -implementation stores it as one — 256 bytes, or 2–8 KiB for the "T-table" variants that fold -MixColumns in. A table indexed by a byte of the state is indexed by **secret data**, so on any CPU -with a data cache the access pattern, and therefore the timing, depends on the key. That is the -standard, repeatedly-demonstrated AES cache-timing attack, and it cannot be fixed while the lookup -remains. - -Bouncy Castle's `AESLightEngine` in the Java and C# ports keeps two 256-byte S-box tables in order -to be *small*, not to be constant-time, and leaks through both the cipher and the key schedule. - -This crate has no tables at all. The consequence worth stating plainly: **the low-memory AES and -the constant-time AES are the same implementation here.** Removing the tables is what makes it both. - -### 2.2 Bit-slicing - -The state is transposed so that each of eight `u32` words holds one *bit position* of every byte: -word `q[k]` collects bit `k` of all the bytes. In that representation the S-box becomes a fixed -Boolean circuit and one `&` or `^` applies a gate to every byte position at once. Nothing is ever -indexed by a secret and nothing branches on one. - -Eight 32-bit words hold 256 bits = 32 bytes = **two** AES blocks, so blocks are processed in pairs. -ShiftRows and MixColumns become masks and rotations in the same representation, and the key -schedule is stored already bit-sliced, so no transposition happens inside the round loop. - -### 2.3 The bit layout — derived, not assumed - -`ortho` transposes, within each byte-lane of the eight words, the 8×8 bit matrix indexed by -(word number, bit number within the lane): - -``` -after ortho: q[k] bit (8L + i) == before ortho: q[i] bit (8L + k) -``` - -`pack` loads block A as four little-endian `u32`s into the even words and block B into the odd -words, so before `ortho` byte-lane `L` of word `2c` holds `A[4c + L]`. Substituting `j = 4c + L` -and FIPS 197 Eq (3.6) `s[r,c] = in[r + 4c]` — which makes `r = j mod 4`, `c = j div 4` — gives: - -``` -q[k] bit (8r + 2c) == bit k of s[r,c] of block A -q[k] bit (8r + 2c + 1) == bit k of s[r,c] of block B -``` - -**The byte-lane of the word selects the state row `r`; the bit-pair within that lane selects the -state column `c`; the low bit of the pair is block A and the high bit is block B.** - -``` - c=0 c=1 c=2 c=3 - r=0 | 0 2 4 6 - r=1 | 8 10 12 14 (bit position of block A; - r=2 | 16 18 20 22 add 1 for block B) - r=3 | 24 26 28 30 -``` - -Everything else follows from this table: - -* **ShiftRows** only permutes within rows, and a row is a byte-lane, so it is a rotation *inside* - each byte-lane by `2r` positions (one column = two bit positions). -* **MixColumns** combines the four rows of a column, and `rotate_right(8)` moves one row, so it is - expressible with rotations by 8 and 16 plus the `{1b}` reduction, with no shuffling. - -`test_layout_matches_the_documented_table` pins this exhaustively. Every mask in the crate is only -correct relative to it, which is why it is written down rather than left implicit. - -### 2.4 Both directions from one key schedule - -Decryption follows **FIPS 197 Algorithm 3** (the straight inverse cipher), not the equivalent -inverse cipher of Sec 5.3.5. Algorithm 3 applies InvMixColumns *after* AddRoundKey, so it uses the -**unmodified** key schedule; Sec 5.3.5 reorders the round and needs a separate schedule with -InvMixColumns applied to every round key (Algorithm 5, `KEYEXPANSIONEIC()`). - -Following Algorithm 3 is what lets one `Aes` value encrypt *and* decrypt from a single stored -schedule — no second copy, no transformation at construction time, no direction flag. That is the -whole reason both directions are available at 176–240 bytes of state. - -### 2.5 Typing the three key sizes - -The schedule length `4·(Nr+1)` (44/52/60 words) cannot be written as an expression over another -const generic parameter, so a params trait is used instead — the same pattern as the -`HashDRBG80090AParams_*` types in `bouncycastle-rng`: - -```rust -pub trait AesParams: AesParamsSealed { - const KEY_LEN: usize; // 16 | 24 | 32 (FIPS 197 Sec 6.1) - const NK: usize; // 4 | 6 | 8 - const NR: usize; // 10 | 12 | 14 - const ALG_NAME: &'static str; - type Schedule: ZeroizablePrimitive + AsRef<[u32]> + AsMut<[u32]>; -} -``` - -`AesParams` has a **private** supertrait, so only the three types in `schedule.rs` can implement -it and no downstream crate can instantiate the cipher with an unapproved key length or round count. -(This is what `#![allow(private_bounds)]` in `lib.rs` is for.) - -The three `new` constructors and `Algorithm` impls are written out **longhand rather than with -`macro_rules!`**, because `cargo mutants` cannot see into macro bodies and a macro would hide the -key checks and security-strength constants from mutation testing. - -### 2.6 Memory - -No lookup tables, no heap allocation. The only persistent state is the key schedule, stored in a -compressed bit-sliced form: bit-slicing is a permutation of bits so it does not change the size, and -because both interleaved blocks use the same key the two halves of a bit-sliced round key are -identical, so one word of each pair is redundant. `round_key` re-doubles a single round key onto the -stack when the round loop needs it. - -| Type | Key | `Nr` | Schedule (persistent) | Tables | -|---|---|---|---|---| -| `Aes128` | 16 B | 10 | 176 B | 0 B | -| `Aes192` | 24 B | 12 | 208 B | 0 B | -| `Aes256` | 32 B | 14 | 240 B | 0 B | - -These are **measured**, not asserted — `cargo run --release -p mem_usage_benches --bin bench_aes_mem_usage` -prints exactly 176/208/240, and `test_engine_sizes_match_the_documented_memory_table` pins them so -the doc table cannot drift. - -Two things deliberately avoided: storing the doubled 8-plane schedule (352/416/480 B), and -mirroring BearSSL's `uint32_t skey[120]` 480-byte scratch buffer during expansion. `expand` writes -the classical schedule into the final array and then rewrites it in place, one round key at a time, -using eight words of stack. - -Per-call stack usage is independent of key length: 32 B of bit-sliced state for the two blocks, -32 B for the expanded round key, plus circuit temporaries that mostly stay in registers. - -### 2.7 API surface - -```rust -Aes128::new(&KeyMaterial<16>) -> Result // and 24 / 32 -aes.encrypt_block(&mut [u8; 16]) // infallible -aes.decrypt_block(&mut [u8; 16]) -aes.encrypt_blocks2(&mut [[u8; 16]; 2]) // the natural unit of work -aes.decrypt_blocks2(&mut [[u8; 16]; 2]) -``` - -No `init()`, no `reset()`, no direction flag: constructors set up state and a constructed value is -always ready. There are no one-shot statics on the permutation because -`Aes128::new(&key)?.encrypt_block(..)` already *is* the one shot; data-level one-shots belong to the -modes, which take arbitrary-length input and generate their own initialisation data. - -`encrypt_blocks2` / `decrypt_blocks2` are the pair form and roughly double throughput. A -single-block call duplicates the block into both halves and discards one result, so it does twice -the necessary work — modes whose blocks are independent (CTR, and the decrypt direction of CBC and -CFB) should prefer the pair form; CBC *encryption* cannot, since its blocks are serially dependent. - -Duplicating rather than zero-filling the unused half costs the same and buys a free self-check (the -two halves must agree, which `debug_assert` verifies). It is not a security property — the unused -half is never returned either way. - ---- - -## 3. Files - -### New crate - -| File | Lines | Contents | -|---|---|---| -| `Cargo.toml` | 18 | deps: `core`, `utils`; dev-deps: `hex`, `rng`, `criterion`, `serde_json` | -| [`src/lib.rs`](src/lib.rs) | 175 | Crate docs: Usage Examples, Design, Memory Usage, Security Considerations, Provenance | -| [`src/bitslice.rs`](src/bitslice.rs) | 210 | `ortho`, `pack`, `unpack`; the layout table and its exhaustive test | -| [`src/sbox.rs`](src/sbox.rs) | 377 | The 113-gate circuit; `inv_sbox`; Tables 4 and 6 for tests | -| [`src/round.rs`](src/round.rs) | 507 | AddRoundKey, ShiftRows, MixColumns and inverses; byte-wise references | -| [`src/schedule.rs`](src/schedule.rs) | 456 | `AesParams`, `expand` (Alg 2), `round_key`; Appendix A tables | -| [`src/aes.rs`](src/aes.rs) | 276 | `Aes

`, the three aliases, Alg 1 and Alg 3, key validation | -| [`tests/fips197_tests.rs`](tests/fips197_tests.rs) | 230 | Appendix B; two-block path; key handling | -| [`tests/sp800_38a_tests.rs`](tests/sp800_38a_tests.rs) | 176 | SP 800-38A F.1.1–F.1.6 | -| [`tests/acvp_tests.rs`](tests/acvp_tests.rs) | 266 | NIST ACVP `ACVP-AES-ECB` loader | -| [`benches/aes_benches.rs`](benches/aes_benches.rs) | 183 | criterion; key expansion and 16 KiB throughput, 1-block vs 2-block | - -### Changed elsewhere - -* `Cargo.toml` — `bouncycastle-aes-lowmemory` in `workspace.dependencies` and in the umbrella - `[dependencies]`. -* `src/lib.rs` — `pub use bouncycastle_aes_lowmemory as aes_lowmemory;`. -* `mem_usage_benches/bench_aes_mem_usage.rs` (new, 131 lines), plus its `[[bin]]` entry in - `mem_usage_benches/Cargo.toml` and a `mod` line in `mem_usage_benches/lib.rs`. -* `alpha_0.1.3_release_notes.md` — a "Major features" entry. - ---- - -## 4. Verification - -58 tests, all passing. The strategy is that **no expected value anywhere was written from -recall** — every one is transcribed from a downloaded specification PDF or an official vector file. - -| Source | What is checked | -|---|---| -| FIPS 197 Table 4 / Table 6 | **Exhaustive**: all 256 inputs to `sbox` and `inv_sbox`. This is what makes the 113 gates trustworthy, so it must stay exhaustive. | -| FIPS 197 Sec 5.1.1 | The worked example `S[{53}] = {ed}`. | -| FIPS 197 Eq 5.5 / 5.8 / 5.12 / 5.15 | ShiftRows and MixColumns and their inverses, against byte-wise references written from the equations — plus a second literal transcription of Eq 5.8/5.15 cross-checking the matrix form. | -| FIPS 197 Sec 4.2 / Eq 4.5 | The test-only `xtimes`/`gf_mul` helpers against the Sec 4.2 worked chain and `{57}·{13} = {fe}`. | -| FIPS 197 Table 5 | `RCON` re-derived by repeated XTIMES and compared. | -| FIPS 197 Appendix A.1/A.2/A.3 | **Every one of the 156 schedule words**, for all three key lengths. | -| FIPS 197 Appendix B | The worked AES-128 block, both directions, and via the two-block path in both slots. | -| SP 800-38A F.1.1–F.1.6 | ECB known answers, all three key lengths, both directions. | -| NIST ACVP `ACVP-AES-ECB` | **2138 cases** (AES-128: 588, AES-192: 720, AES-256: 830), each checked in *both* directions and through both the single-block and two-block paths. | - -### Why Appendix A is tested inside `src/schedule.rs` - -The key schedule is deliberately not public API (a `Secret` field). A round-trip through the cipher -**cannot** validate it: a wrong `w[i]` is used by encryption and decryption alike, so the round trip -still succeeds. The Appendix A tests therefore live in the module, where `round_key` + `ortho` -decompress the stored schedule back to classical words so every `w[i]` can be compared against the -appendix directly. `tests/fips197_tests.rs` says so explicitly, so nobody mistakes its round-trip -test for schedule validation. - -### The ACVP loader - -Vectors come from `bc-test-data` at `crypto/aes_tdes_vectors/AES/ACVP-AES-ECB.4014527.rsp.json`. -If that repository is not checked out the test prints a warning and passes, matching the ML-KEM / -ML-DSA convention — `cargo test` stays green for someone who has only cloned this repo. A -`checked > 1000` assertion guards against a silently-empty run. - -The response file records `key`, `pt` and `ct` for every case regardless of the group's declared -direction, so each is checked both ways; the request file's group metadata is not needed. - -Two details worth knowing: - -* Some AFT cases have multi-block plaintexts, so the loader iterates blocks (ECB). -* The set includes **all-zero keys** (the GFSbox-style groups). `KeyMaterial` tags an all-zero - buffer `Zeroized` and refuses to promote it outside a hazardous closure — which is the right - default, and `Aes128::new` rejecting it is itself tested. The *test* opts in via - `do_hazardous_operations`; the engine's guard was **not** weakened to accommodate NIST. - -### Constant-time hygiene audit - -Mechanically checked, not merely claimed: - -* **Every** indexing expression in non-test code is a literal constant (`q[0]`…`q[7]`), a loop - counter over a fixed public range, or `4*round + j` where `round` counts over the public `Nr`. - Not one index is derived from key or state bytes. -* The only branches in non-test code are on `i % Nk` and `Nk > 6` (public parameters) in the key - expansion, and on key *metadata* (type, length, security strength) once at construction. None on - key or state bytes. -* `SUBWORD()` in the key expansion goes through the same bit-sliced circuit as `SUBBYTES()`. A - table-driven "light" AES that removes the tables only from the cipher still leaks through the - schedule; this one does not. - -Caveats are stated in the crate docs rather than glossed: the compiler is not contractually obliged -to preserve straight-line codegen; the 32-byte working state is not scrubbed after a block (only the -schedule is `Secret`); and constant-time execution says nothing about power or EM side channels. - -### Gates - -* `cargo fmt --all -- --check` — clean. -* `cargo build --workspace`, `cargo test --workspace` — clean, no failures. -* `cargo doc -p bouncycastle-aes-lowmemory --no-deps` — **zero warnings**. -* `cargo clippy -p bouncycastle-aes-lowmemory --all-targets` — **zero warnings** for this crate. -* `./dev_scripts/quality_stats.sh ./crypto/aes-lowmemory` — `Err()` in core code: **3**, exactly the - three key rejections in `validate`. `unwrap()` in core code: 4, each a - `try_into()` on a fixed-size window of a fixed-size array with a preceding justification comment. - (Note: `cloc` and `bc` are not installed locally, so the line-count and ratio fields print 0.) - -### Mutation testing - -`cargo mutants -p bouncycastle-aes-lowmemory` — complete run, 32 minutes: - -``` -791 mutants tested: 762 caught, 19 missed, 10 unviable, 0 timeouts -``` - -Every one of the 19 misses was investigated. **18 are provable XOR/OR equivalences and no test can -kill them; 1 was a real coverage gap, since fixed.** - -#### The 18 equivalences - -| Count | Site | Mutation | -|---|---|---| -| 6 | `round.rs` `shift_rows` | `\|` → `^` | -| 6 | `round.rs` `inv_shift_rows` | `\|` → `^` | -| 2 | `bitslice.rs` `ortho::swap` | `\|` → `^` | -| 2 | `schedule.rs` `round_key` | `\|` → `^` | -| 1 | `schedule.rs` `expand` | `\|` → `^` | -| 1 | `sbox.rs` `sbox` (the `t37` gate) | `^` → `\|` | - -`a | b` and `a ^ b` differ only where both operands have a set bit, so wherever the operands are -provably disjoint the two are the same function and no test can distinguish them. This is the -"XOR/OR equivalences in crypto code are acceptable" category named in `CLAUDE.md`. Each site is -disjoint for a different reason: - -* **`shift_rows` / `inv_shift_rows`** — the seven masked terms have pairwise-disjoint destination - bit ranges that together cover all 32 bits. -* **`ortho::swap`** — the masks are complementary and the shift equals the field width. -* **`expand`** — the compression combines `& 0x5555_5555` with `& 0xAAAA_AAAA`, complementary masks. -* **`round_key`** — `even` occupies only even bit positions and `even << 1` only odd ones (and - conversely for `odd`). -* **`sbox`, the `t37 = t36 ^ t34` gate** — the interesting one, because it is a gate *inside* the - circuit rather than a mask combination, and because a surviving mutant there would suggest the - exhaustive Table 4 test had a hole. It does not: brute-forcing all 256 inputs shows `t36` and - `t34` are **never both 1**, so XOR and OR agree, and the mutant changes the output for 0 of 256 - inputs. Sweeping the same mutation across every XOR gate confirms `t37` is the **only one of the - 77** with that property — every other `^ → |` mutant in the circuit is killed. So the exhaustive - test is exactly as strong as claimed; this gate just happens to have disjoint operands. - -Rather than leave the `shift_rows` case as an assertion, the underlying invariant is now tested: -`test_shift_rows_is_a_bit_permutation` pushes a single set bit through and requires exactly one bit -out, with the induced map a bijection on all 32 positions — precisely the disjointness and coverage -property, and it *would* fail if a mask ever overlapped or failed to cover. Every one of the six -sites also carries an in-code comment explaining why its mutant survives, so the next reader does -not have to repeat this investigation. - -#### The one real gap, fixed - -**`< → >` in `Aes

::validate`.** There was no test for a key whose security strength is *below* -the level its length implies; because `from_bytes_as_type` always tags a key at its length-implied -strength, neither `<` nor `>` was ever true and the two comparisons behaved identically. -`a_key_carrying_too_low_a_security_strength_is_rejected` now covers it (a 32-byte key lowered to -128-bit must be rejected by `Aes256::new`), and the fix was confirmed by hand-applying the mutation -and watching that test fail, then reverting. - -This mutant still appears in the run output above, which analysed the pre-fix source — the fix -landed while the run was in flight. Re-running `cargo mutants` should therefore report **18 missed, -763 caught**, all 18 being the documented equivalences. - -#### Unviable - -The 10 unviable mutants are all `replace with Err(...)` / `with ()` on functions whose return -type does not admit the substituted value (`validate`, `Debug::fmt`, `encrypt2`). `cargo mutants` -counts these as unviable rather than missed; they are a property of the config's `error_values` -list, not a coverage gap. - ---- - -## 5. Three corrections worth flagging to reviewers - -### 5.1 The working plan's bit-layout claim is wrong - -`bc-rust-aes-lowmemory-plan.md` §2 states the layout is "`q[k]` bit `2·j` is bit k of byte j of -block A". That is **false**. The correct layout, derived in §2.3 above and pinned exhaustively, is -`q[k]` bit `(8r + 2c)`. Anyone checking the ShiftRows or MixColumns constants against the plan's -version will conclude, wrongly, that they are all broken. The plan's own instruction — "Any place -BearSSL's constants and your FIPS 197 derivation disagree: the spec wins; re-derive, then look for -the misunderstanding (it will be in the layout table)" — turned out to point at the plan itself. - -### 5.2 FIPS 197 Eq 5.6 is `[{02},{01},{01},{03}]` - -Not `[{02},{03},{01},{01}]`, which is the first *row* of the Eq 5.7 matrix rather than the defining -word of Sec 4.3. Sec 4.3 Eq (4.8) defines matrix entry `(r,k)` as `a[(r-k) mod 4]`, and both -MixColumns and InvMixColumns use that same convention — Eq 5.13's `[{0e},{09},{0d},{0b}]` is -correct as printed. - -This one was written into a test constant from memory and caught by the failing test. It is worth -recording because of *how* it fails: supplying the matrix row instead of the defining word silently -transposes the matrix, which leaves the InvMixColumns test **passing**, so only the forward test -detects it. A literal transcription of Eq 5.8 and Eq 5.15 was added as a second, independent -reference (`test_the_two_reference_forms_agree`) so the convention is pinned from both directions, -and `MIX_COEFFS` carries a comment about the trap. - -### 5.3 The plan's "PR B" is unnecessary - -The plan calls for downloading CAVP AESAVS `.rsp` files and opening a PR against `bcgit/bc-test-data` -to add them. `bc-test-data` **already** ships NIST ACVP AES vectors at -`crypto/aes_tdes_vectors/AES/ACVP-AES-ECB.4014527.{req,rsp}.json` — 2138 AFT cases across all three -key lengths, more coverage than the AESAVS KAT/MMT files would have provided. No PR to -`bc-test-data` is needed. `serde_json` as a dev-dependency is the established way to read these -files (see the ML-KEM and ML-DSA suites). - ---- - -## 6. Scope deliberately not implemented - -| Item | Why | -|---|---| -| `BlockPermutation` trait impls, and `encrypt_blocks2`/`decrypt_blocks2` as trait methods | The trait does not exist in `crypto/core`, which has the mode-level `BlockCipher` / `BlockCipherEncryptor` / `BlockCipherDecryptor`. Introducing it is the plan's separate "PR A". The two-block entry points are inherent methods for now; promoting them to provided trait methods is a one-line delegation once the trait lands. | -| `core-test-framework` conformance test | Follows from the above — there is no test suite for a raw permutation yet. | -| ACVP MCT (Monte Carlo) groups — 6 cases | Their expected `resultsArray` comes from a chained key/plaintext update rule defined in the ACVP AES specification, not in FIPS 197. Implementing it from anything other than that specification would be guesswork. The test reports the skip count so the gap is visible rather than silent. | -| CLI subcommand | A bare permutation only does ECB. `aes128-cbc-*` / `-cfb-*` belong with the modes crate. | -| Factory registration | No `BlockCipherFactory` exists; not adding one here. | -| bc-java `AESLightEngine` cross-check | The plan marks it developer-local rather than committed, and 2138 ACVP vectors plus the spec appendices make it redundant. | - ---- - -## 7. Provenance and attribution - -* **Normative reference: NIST FIPS 197** (including Update 1). Every transformation cites its - section, algorithm and equation numbers, verified against a freshly downloaded copy of the PDF. -* **The S-box circuit** is the 113-gate straight-line program `SLP_AES_113.txt` from Peralta's - circuit collection — 32 AND, 77 XOR, 4 XNOR — described in J. Boyar and R. Peralta, "A new - combinational logic minimization technique with applications to cryptology", - . The gate list was transcribed **mechanically** from the - SLP file (`+` → `^`, `x` → `&`, `#` → `!(..^..)`, names unchanged apart from case) and the result - diffed against the generator output to rule out transcription error. It is not meaningful line by - line and should not be "tidied"; it is verified as a whole by the exhaustive Table 4 test. -* **The bit-sliced two-block structure**, the transpose, and the ShiftRows/MixColumns mask and - rotation constants are translated from BearSSL's `aes_ct` implementation by Thomas Pornin - (`src/symcipher/aes_ct.c`, `aes_ct_enc.c`, `aes_ct_dec.c`, `aes_ct_cbcdec.c`), **MIT licensed**. - Each constant is re-derived from the documented layout in the comments and pinned by a test - against a byte-wise reference written from the FIPS 197 equations. - -Two notes on where the sources disagree, both resolved in favour of the SLP file: - -* Its bottom linear transformation (`tc1..tc26`) **differs from** BearSSL's (`t46..t67`), and its - `t17`/`t21` are re-associated relative to BearSSL's. Both compute the same S-box. -* The SLP numbers inputs and outputs with `U0`/`S0` as the **most significant** bit, so `U0` is - plane `q[7]`. Reversing this produces a wrong S-box, not a subtly different one; the exhaustive - Table 4 test is what pins it. - -**Open question for maintainers:** how attribution for the BearSSL translation and the -Boyar–Peralta circuit should be recorded — file headers only (current state), a top-level `NOTICE` -file, or both. This is a licensing/policy call rather than a technical one. - ---- - -## 8. Reproducing the checks - -```sh -cargo build -p bouncycastle-aes-lowmemory -cargo test -p bouncycastle-aes-lowmemory # 58 tests -cargo test -p bouncycastle-aes-lowmemory --test acvp_tests -- --nocapture # prints the ACVP count -cargo doc -p bouncycastle-aes-lowmemory --no-deps # expect zero warnings -cargo clippy -p bouncycastle-aes-lowmemory --all-targets -cargo fmt --all -- --check -cargo bench -p bouncycastle-aes-lowmemory -cargo mutants -p bouncycastle-aes-lowmemory -./dev_scripts/quality_stats.sh ./crypto/aes-lowmemory - -# struct sizes; add the massif recipe in the file header for stack measurement -cargo run --release -p mem_usage_benches --bin bench_aes_mem_usage -``` - -The ACVP tests additionally need `bc-test-data` cloned as a sibling of this repository; without it -they print a warning and pass. - ---- - -## 9. Open items before merge - -1. **Decide the attribution form** for the BearSSL translation and the Boyar–Peralta circuit (§7): - file headers only (current state), a top-level `NOTICE`, or both. A licensing/policy call rather - than a technical one. -2. **Confirm the PR base branch.** The plan specifies `release/0.1.3alpha`, set explicitly — GitHub - defaults to `main`. -3. Decide whether `BlockPermutation` (plan PR A) lands before or after this crate, since it - determines whether the two-block entry points become trait methods now or later (§6). -4. Note in the PR description that the plan's layout claim (§5.1) and PR B (§5.3) are superseded, so - the plan document does not mislead the next reader. -5. Optionally re-run `cargo mutants` to confirm the expected 18 missed / 763 caught (§4). The 19th - miss was fixed while the recorded run was in flight, so the numbers above under-report by one. diff --git a/crypto/aes-lowmemory/tests/acvp_tests.rs b/crypto/aes-lowmemory/tests/bc-test-data.rs similarity index 93% rename from crypto/aes-lowmemory/tests/acvp_tests.rs rename to crypto/aes-lowmemory/tests/bc-test-data.rs index 0ab0b431..a5e26555 100644 --- a/crypto/aes-lowmemory/tests/acvp_tests.rs +++ b/crypto/aes-lowmemory/tests/bc-test-data.rs @@ -25,7 +25,7 @@ //! implementing it from anything other than that specification would be guesswork. The test //! reports how many it skipped so the gap is visible rather than silent. -use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256, BLOCK_LEN}; +use bouncycastle_aes_lowmemory::{AES_128, AES_192, AES_256, BLOCK_LEN}; use bouncycastle_core::key_material::{ KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, }; @@ -43,7 +43,7 @@ const TEST_DATA_PATHS: [&str; 2] = [ const RESPONSE_FILE: &str = "ACVP-AES-ECB.4014527.rsp.json"; -/// Locates the ACVP AES directory, or `None` if `bc-test-data` is not checked out. +/// Locates the bc-test-data AES directory, or `None` if `bc-test-data` is not checked out. fn test_data_dir() -> Option { for candidate in TEST_DATA_PATHS { let path = Path::new(candidate); @@ -92,7 +92,7 @@ fn ecb(key: &[u8], data: &[u8], encrypt: bool) -> Vec { let transform: BlockTransform = match key.len() { 16 => { let km = cipher_key::<16>(key); - let aes = Aes128::new(&km).expect("valid AES-128 key"); + let aes = AES_128::new(&km).expect("valid AES-128 key"); if encrypt { Box::new(move |b| aes.encrypt_block(b)) } else { @@ -101,7 +101,7 @@ fn ecb(key: &[u8], data: &[u8], encrypt: bool) -> Vec { } 24 => { let km = cipher_key::<24>(key); - let aes = Aes192::new(&km).expect("valid AES-192 key"); + let aes = AES_192::new(&km).expect("valid AES-192 key"); if encrypt { Box::new(move |b| aes.encrypt_block(b)) } else { @@ -110,7 +110,7 @@ fn ecb(key: &[u8], data: &[u8], encrypt: bool) -> Vec { } 32 => { let km = cipher_key::<32>(key); - let aes = Aes256::new(&km).expect("valid AES-256 key"); + let aes = AES_256::new(&km).expect("valid AES-256 key"); if encrypt { Box::new(move |b| aes.encrypt_block(b)) } else { @@ -139,23 +139,23 @@ fn ecb_pairwise(key: &[u8], data: &[u8], encrypt: bool) -> Vec { match key.len() { 16 => { let km = cipher_key::<16>(key); - let aes = Aes128::new(&km).unwrap(); + let aes = AES_128::new(&km).unwrap(); run_pairwise(&mut blocks, encrypt, |p, e| { - if e { aes.encrypt_blocks2(p) } else { aes.decrypt_blocks2(p) } + if e { aes.encrypt_2blocks(p) } else { aes.decrypt_2blocks(p) } }); } 24 => { let km = cipher_key::<24>(key); - let aes = Aes192::new(&km).unwrap(); + let aes = AES_192::new(&km).unwrap(); run_pairwise(&mut blocks, encrypt, |p, e| { - if e { aes.encrypt_blocks2(p) } else { aes.decrypt_blocks2(p) } + if e { aes.encrypt_2blocks(p) } else { aes.decrypt_2blocks(p) } }); } 32 => { let km = cipher_key::<32>(key); - let aes = Aes256::new(&km).unwrap(); + let aes = AES_256::new(&km).unwrap(); run_pairwise(&mut blocks, encrypt, |p, e| { - if e { aes.encrypt_blocks2(p) } else { aes.decrypt_blocks2(p) } + if e { aes.encrypt_2blocks(p) } else { aes.decrypt_2blocks(p) } }); } other => panic!("ACVP AES vectors should only use 16, 24 or 32 byte keys, got {other}"), diff --git a/crypto/aes-lowmemory/tests/fips197_tests.rs b/crypto/aes-lowmemory/tests/fips197_tests.rs index d1261b8d..2dd84e92 100644 --- a/crypto/aes-lowmemory/tests/fips197_tests.rs +++ b/crypto/aes-lowmemory/tests/fips197_tests.rs @@ -14,7 +14,7 @@ //! //! All values here are transcribed from the published FIPS 197 (Update 1) PDF. -use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256}; +use bouncycastle_aes_lowmemory::{AES_128, AES_192, AES_256}; use bouncycastle_core::key_material::{KeyMaterial, KeyMaterialTrait, KeyType}; use bouncycastle_core::traits::SecurityStrength; @@ -47,7 +47,7 @@ fn appendix_b_encrypts_the_documented_block() { // Key = 2b 7e 15 16 28 ae d2 a6 ab f7 15 88 09 cf 4f 3c // The final state printed as "output" reads, column by column (Eq 3.7): // 39 25 84 1d 02 dc 09 fb dc 11 85 97 19 6a 0b 32 - let aes = Aes128::new(&key_material(&KEY_128)).unwrap(); + let aes = AES_128::new(&key_material(&KEY_128)).unwrap(); let mut block = [ 0x32, 0x43, 0xf6, 0xa8, 0x88, 0x5a, 0x30, 0x8d, 0x31, 0x31, 0x98, 0xa2, 0xe0, 0x37, 0x07, @@ -65,7 +65,7 @@ fn appendix_b_encrypts_the_documented_block() { #[test] fn appendix_b_decrypts_back_to_the_documented_input() { - let aes = Aes128::new(&key_material(&KEY_128)).unwrap(); + let aes = AES_128::new(&key_material(&KEY_128)).unwrap(); let mut block = [ 0x39, 0x25, 0x84, 0x1d, 0x02, 0xdc, 0x09, 0xfb, 0xdc, 0x11, 0x85, 0x97, 0x19, 0x6a, 0x0b, @@ -83,7 +83,7 @@ fn appendix_b_decrypts_back_to_the_documented_input() { #[test] fn appendix_b_two_block_path_agrees_with_the_single_block_path() { - let aes = Aes128::new(&key_material(&KEY_128)).unwrap(); + let aes = AES_128::new(&key_material(&KEY_128)).unwrap(); let input = [ 0x32, 0x43, 0xf6, 0xa8, 0x88, 0x5a, 0x30, 0x8d, 0x31, 0x31, 0x98, 0xa2, 0xe0, 0x37, 0x07, 0x34, @@ -99,13 +99,13 @@ fn appendix_b_two_block_path_agrees_with_the_single_block_path() { aes.encrypt_block(&mut other_alone); let mut pair = [input, other]; - aes.encrypt_blocks2(&mut pair); + aes.encrypt_2blocks(&mut pair); assert_eq!(pair[0], expected); assert_eq!(pair[1], other_alone); // ...and in the other slot, which is a different bit position in the interleave. let mut pair = [other, input]; - aes.encrypt_blocks2(&mut pair); + aes.encrypt_2blocks(&mut pair); assert_eq!(pair[0], other_alone); assert_eq!(pair[1], expected); } @@ -117,9 +117,9 @@ fn appendix_b_two_block_path_agrees_with_the_single_block_path() { /// deliberately makes no claim about the schedule being *correct* -- see the module docs. #[test] fn encryption_and_decryption_are_inverses_for_all_three_key_lengths() { - let aes128 = Aes128::new(&key_material(&KEY_128)).unwrap(); - let aes192 = Aes192::new(&key_material(&KEY_192)).unwrap(); - let aes256 = Aes256::new(&key_material(&KEY_256)).unwrap(); + let aes128 = AES_128::new(&key_material(&KEY_128)).unwrap(); + let aes192 = AES_192::new(&key_material(&KEY_192)).unwrap(); + let aes256 = AES_256::new(&key_material(&KEY_256)).unwrap(); for block in [[0u8; 16], [0xFFu8; 16], core::array::from_fn(|i| i as u8)] { let mut b = block; @@ -149,9 +149,9 @@ fn encryption_and_decryption_are_inverses_for_all_three_key_lengths() { fn the_three_key_lengths_are_distinct_permutations() { // A key whose first 16 bytes are shared, so only Nk/Nr and the extra key bytes differ. let shared = [0x11u8; 32]; - let aes128 = Aes128::new(&key_material::<16>(&shared[..16].try_into().unwrap())).unwrap(); - let aes192 = Aes192::new(&key_material::<24>(&shared[..24].try_into().unwrap())).unwrap(); - let aes256 = Aes256::new(&key_material(&shared)).unwrap(); + let aes128 = AES_128::new(&key_material::<16>(&shared[..16].try_into().unwrap())).unwrap(); + let aes192 = AES_192::new(&key_material::<24>(&shared[..24].try_into().unwrap())).unwrap(); + let aes256 = AES_256::new(&key_material(&shared)).unwrap(); let block = [0x42u8; 16]; let mut b128 = block; @@ -173,10 +173,10 @@ fn a_key_of_the_wrong_type_is_rejected() { // KeyType::Seed is not a cipher key: a seed reused directly as an AES key is a real mistake // and the type system tracks enough to catch it. let key = KeyMaterial::<16>::from_bytes_as_type(&[0x01; 16], KeyType::Seed).unwrap(); - assert!(Aes128::new(&key).is_err()); + assert!(AES_128::new(&key).is_err()); let key = KeyMaterial::<16>::from_bytes_as_type(&[0x01; 16], KeyType::MACKey).unwrap(); - assert!(Aes128::new(&key).is_err()); + assert!(AES_128::new(&key).is_err()); } #[test] @@ -185,7 +185,7 @@ fn a_key_of_the_wrong_length_is_rejected() { // parameter set. This is the one length error the const generic cannot catch by itself. let key = KeyMaterial::<32>::from_bytes_as_type(&[0x01; 16], KeyType::SymmetricCipherKey).unwrap(); - assert!(Aes256::new(&key).is_err()); + assert!(AES_256::new(&key).is_err()); } #[test] @@ -200,7 +200,7 @@ fn a_key_carrying_too_low_a_security_strength_is_rejected() { key.set_security_strength(SecurityStrength::_128bit).unwrap(); assert!( - Aes256::new(&key).is_err(), + AES_256::new(&key).is_err(), "AES-256 must reject a 32-byte key only derived at the 128-bit strength" ); @@ -208,20 +208,20 @@ fn a_key_carrying_too_low_a_security_strength_is_rejected() { // not about anything else having gone wrong with the key. let good = KeyMaterial::<32>::from_bytes_as_type(&[0x01; 32], KeyType::SymmetricCipherKey).unwrap(); - assert!(Aes256::new(&good).is_ok()); + assert!(AES_256::new(&good).is_ok()); } #[test] fn a_correctly_typed_key_of_each_length_is_accepted() { - assert!(Aes128::new(&key_material(&KEY_128)).is_ok()); - assert!(Aes192::new(&key_material(&KEY_192)).is_ok()); - assert!(Aes256::new(&key_material(&KEY_256)).is_ok()); + assert!(AES_128::new(&key_material(&KEY_128)).is_ok()); + assert!(AES_192::new(&key_material(&KEY_192)).is_ok()); + assert!(AES_256::new(&key_material(&KEY_256)).is_ok()); } #[test] fn debug_does_not_print_the_key_schedule() { // The schedule is secret; `Debug` must not be a way to leak it. - let aes = Aes128::new(&key_material(&KEY_128)).unwrap(); + let aes = AES_128::new(&key_material(&KEY_128)).unwrap(); let rendered = format!("{aes:?}"); assert_eq!(rendered, "AES-128"); // No byte of the key should appear as hex in the output. diff --git a/crypto/aes-lowmemory/tests/sp800_38a_tests.rs b/crypto/aes-lowmemory/tests/sp800_38a_tests.rs index 8e975eca..5629c64f 100644 --- a/crypto/aes-lowmemory/tests/sp800_38a_tests.rs +++ b/crypto/aes-lowmemory/tests/sp800_38a_tests.rs @@ -15,7 +15,7 @@ //! //! Transcribed from the published SP 800-38A PDF, sections F.1.1 through F.1.6. -use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256, BLOCK_LEN}; +use bouncycastle_aes_lowmemory::{AES_128, AES_192, AES_256, BLOCK_LEN}; use bouncycastle_core::key_material::{KeyMaterial, KeyType}; use bouncycastle_hex as hex; @@ -72,7 +72,7 @@ fn key_material(hex_str: &str) -> KeyMaterial { #[test] fn f_1_1_ecb_aes128_encrypt() { - let aes = Aes128::new(&key_material::<16>(KEY_128)).unwrap(); + let aes = AES_128::new(&key_material::<16>(KEY_128)).unwrap(); for (i, (pt, ct)) in PLAINTEXTS.iter().zip(CIPHERTEXTS_128.iter()).enumerate() { let mut b = block(pt); aes.encrypt_block(&mut b); @@ -82,7 +82,7 @@ fn f_1_1_ecb_aes128_encrypt() { #[test] fn f_1_2_ecb_aes128_decrypt() { - let aes = Aes128::new(&key_material::<16>(KEY_128)).unwrap(); + let aes = AES_128::new(&key_material::<16>(KEY_128)).unwrap(); for (i, (pt, ct)) in PLAINTEXTS.iter().zip(CIPHERTEXTS_128.iter()).enumerate() { let mut b = block(ct); aes.decrypt_block(&mut b); @@ -94,7 +94,7 @@ fn f_1_2_ecb_aes128_decrypt() { #[test] fn f_1_3_ecb_aes192_encrypt() { - let aes = Aes192::new(&key_material::<24>(KEY_192)).unwrap(); + let aes = AES_192::new(&key_material::<24>(KEY_192)).unwrap(); for (i, (pt, ct)) in PLAINTEXTS.iter().zip(CIPHERTEXTS_192.iter()).enumerate() { let mut b = block(pt); aes.encrypt_block(&mut b); @@ -104,7 +104,7 @@ fn f_1_3_ecb_aes192_encrypt() { #[test] fn f_1_4_ecb_aes192_decrypt() { - let aes = Aes192::new(&key_material::<24>(KEY_192)).unwrap(); + let aes = AES_192::new(&key_material::<24>(KEY_192)).unwrap(); for (i, (pt, ct)) in PLAINTEXTS.iter().zip(CIPHERTEXTS_192.iter()).enumerate() { let mut b = block(ct); aes.decrypt_block(&mut b); @@ -116,7 +116,7 @@ fn f_1_4_ecb_aes192_decrypt() { #[test] fn f_1_5_ecb_aes256_encrypt() { - let aes = Aes256::new(&key_material::<32>(KEY_256)).unwrap(); + let aes = AES_256::new(&key_material::<32>(KEY_256)).unwrap(); for (i, (pt, ct)) in PLAINTEXTS.iter().zip(CIPHERTEXTS_256.iter()).enumerate() { let mut b = block(pt); aes.encrypt_block(&mut b); @@ -126,7 +126,7 @@ fn f_1_5_ecb_aes256_encrypt() { #[test] fn f_1_6_ecb_aes256_decrypt() { - let aes = Aes256::new(&key_material::<32>(KEY_256)).unwrap(); + let aes = AES_256::new(&key_material::<32>(KEY_256)).unwrap(); for (i, (pt, ct)) in PLAINTEXTS.iter().zip(CIPHERTEXTS_256.iter()).enumerate() { let mut b = block(ct); aes.decrypt_block(&mut b); @@ -143,17 +143,17 @@ fn f_1_6_ecb_aes256_decrypt() { /// puts the same data in both halves. #[test] fn two_block_path_matches_the_f_1_vectors() { - let aes = Aes128::new(&key_material::<16>(KEY_128)).unwrap(); + let aes = AES_128::new(&key_material::<16>(KEY_128)).unwrap(); // Blocks 1 and 2 as a pair, then 3 and 4. for chunk in 0..2 { let (i, j) = (chunk * 2, chunk * 2 + 1); let mut pair = [block(PLAINTEXTS[i]), block(PLAINTEXTS[j])]; - aes.encrypt_blocks2(&mut pair); + aes.encrypt_2blocks(&mut pair); assert_eq!(pair[0], block(CIPHERTEXTS_128[i]), "pair {chunk} slot 0"); assert_eq!(pair[1], block(CIPHERTEXTS_128[j]), "pair {chunk} slot 1"); - aes.decrypt_blocks2(&mut pair); + aes.decrypt_2blocks(&mut pair); assert_eq!(pair[0], block(PLAINTEXTS[i])); assert_eq!(pair[1], block(PLAINTEXTS[j])); } @@ -162,12 +162,12 @@ fn two_block_path_matches_the_f_1_vectors() { /// Swapping the two slots must swap the two results, and nothing else. #[test] fn two_block_path_is_slot_symmetric() { - let aes = Aes256::new(&key_material::<32>(KEY_256)).unwrap(); + let aes = AES_256::new(&key_material::<32>(KEY_256)).unwrap(); let mut forward = [block(PLAINTEXTS[0]), block(PLAINTEXTS[1])]; let mut reversed = [block(PLAINTEXTS[1]), block(PLAINTEXTS[0])]; - aes.encrypt_blocks2(&mut forward); - aes.encrypt_blocks2(&mut reversed); + aes.encrypt_2blocks(&mut forward); + aes.encrypt_2blocks(&mut reversed); assert_eq!(forward[0], reversed[1]); assert_eq!(forward[1], reversed[0]); diff --git a/mem_usage_benches/bench_aes_mem_usage.rs b/mem_usage_benches/bench_aes_mem_usage.rs index 00d0acd3..23002fa3 100644 --- a/mem_usage_benches/bench_aes_mem_usage.rs +++ b/mem_usage_benches/bench_aes_mem_usage.rs @@ -19,19 +19,15 @@ //! //! # What to expect //! -//! Unlike ML-KEM and ML-DSA, AES has no interesting stack profile: there is no polynomial -//! arithmetic and no sampling, so peak usage is a small constant plus the key schedule. The -//! numbers worth recording in the crate docs are the ones `print_struct_sizes` prints -- the +//! Peak usage is a small constant plus the key schedule. +//! The numbers worth recording in the crate docs are the ones `print_struct_sizes` prints -- the //! persistent size of each engine -- and the confirmation that per-block work is a fixed, small //! amount of stack independent of key length. -//! -//! The point of comparison is that a table-driven AES adds 256 B (`AESLightEngine`) to 8 KiB -//! (T-tables) of static data on top of these numbers; this implementation adds zero. #![allow(dead_code)] #![allow(unused_imports)] -use bouncycastle::aes_lowmemory::{Aes128, Aes192, Aes256}; +use bouncycastle::aes_lowmemory::{AES_128, AES_192, AES_256}; use bouncycastle::core::key_material::{KeyMaterial, KeyType}; /// This exists so /usr/bin/time can measure the base memory footprint of the harness itself. @@ -47,9 +43,9 @@ fn print_struct_sizes() { // FIPS 197 Sec 5.2: the schedule is 4 * (Nr + 1) words, so 176 / 208 / 240 bytes. The // bit-sliced form is stored compressed, so bit-slicing adds nothing to these. - println!("size_of: {}", size_of::()); - println!("size_of: {}", size_of::()); - println!("size_of: {}", size_of::()); + println!("size_of: {}", size_of::()); + println!("size_of: {}", size_of::()); + println!("size_of: {}", size_of::()); } fn key() -> KeyMaterial { @@ -64,28 +60,28 @@ fn key() -> KeyMaterial { fn bench_aes128_key_expansion() { eprintln!("Aes128::new (key expansion)"); - let aes = Aes128::new(&key::<16>()).unwrap(); + let aes = AES_128::new(&key::<16>()).unwrap(); print!("{aes:?}"); } fn bench_aes192_key_expansion() { eprintln!("Aes192::new (key expansion)"); - let aes = Aes192::new(&key::<24>()).unwrap(); + let aes = AES_192::new(&key::<24>()).unwrap(); print!("{aes:?}"); } fn bench_aes256_key_expansion() { eprintln!("Aes256::new (key expansion)"); - let aes = Aes256::new(&key::<32>()).unwrap(); + let aes = AES_256::new(&key::<32>()).unwrap(); print!("{aes:?}"); } fn bench_aes128_encrypt_block() { eprintln!("Aes128::encrypt_block"); - let aes = Aes128::new(&key::<16>()).unwrap(); + let aes = AES_128::new(&key::<16>()).unwrap(); let mut block = [0x11u8; 16]; aes.encrypt_block(&mut block); print!("{block:x?}"); @@ -94,7 +90,7 @@ fn bench_aes128_encrypt_block() { fn bench_aes256_encrypt_block() { eprintln!("Aes256::encrypt_block"); - let aes = Aes256::new(&key::<32>()).unwrap(); + let aes = AES_256::new(&key::<32>()).unwrap(); let mut block = [0x11u8; 16]; aes.encrypt_block(&mut block); print!("{block:x?}"); @@ -103,7 +99,7 @@ fn bench_aes256_encrypt_block() { fn bench_aes256_decrypt_block() { eprintln!("Aes256::decrypt_block"); - let aes = Aes256::new(&key::<32>()).unwrap(); + let aes = AES_256::new(&key::<32>()).unwrap(); let mut block = [0x11u8; 16]; aes.decrypt_block(&mut block); print!("{block:x?}"); @@ -112,9 +108,9 @@ fn bench_aes256_decrypt_block() { fn bench_aes256_encrypt_blocks2() { eprintln!("Aes256::encrypt_blocks2"); - let aes = Aes256::new(&key::<32>()).unwrap(); + let aes = AES_256::new(&key::<32>()).unwrap(); let mut blocks = [[0x11u8; 16], [0x22u8; 16]]; - aes.encrypt_blocks2(&mut blocks); + aes.encrypt_2blocks(&mut blocks); print!("{blocks:x?}"); }