From 1f69c951a6cccc231330c7a4c77168e8d4272783 Mon Sep 17 00:00:00 2001 From: David Hook Date: Sun, 30 Aug 2026 17:24:33 +1000 Subject: [PATCH 01/12] core: split BlockCipher into block-aligned BlockCipherEncryptor/Decryptor Rework the block cipher streaming traits ahead of the first mode implementations: - Split the single BlockCipher trait into BlockCipherEncryptor and BlockCipherDecryptor (mirroring KEMEncapsulator/KEMDecapsulator) so the direction can be 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. - Replace the single-block do_{en,de}crypt_block[_out] with do_{en,de}crypt_blocks[_out], taking &[[u8; BLOCK_LEN]; N] so the block count is compile-time and in/out lengths cannot disagree. - Add do_encrypt_init_rng(key, &mut dyn RNG) alongside do_encrypt_init, matching the encaps/encaps_rng pattern. - Remove the do_{en,de}crypt_final[_out] methods. The traits are now strictly block-aligned; padding of arbitrary-length data belongs to a separate PaddedEncryptor/PaddedDecryptor layer to be built on top. Update the core-test-framework block cipher test to take separate encryptor/decryptor type parameters and to exercise N = 1 and N = 2, including mixed single/multi-block encrypt vs decrypt sequences. Co-Authored-By: Claude Fable 5 --- .../src/symmetric_ciphers.rs | 73 +++++++++---- crypto/core/src/traits.rs | 103 +++++++++--------- 2 files changed, 107 insertions(+), 69 deletions(-) diff --git a/crypto/core-test-framework/src/symmetric_ciphers.rs b/crypto/core-test-framework/src/symmetric_ciphers.rs index 57fc0ee1..67f10793 100644 --- a/crypto/core-test-framework/src/symmetric_ciphers.rs +++ b/crypto/core-test-framework/src/symmetric_ciphers.rs @@ -6,7 +6,7 @@ use bouncycastle_core::key_material::{ KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, }; use bouncycastle_core::traits::{ - AEADCipher, BlockCipher, SecurityStrength, StreamCipher, SymmetricCipher, + AEADCipher, BlockCipherDecryptor, BlockCipherEncryptor, SecurityStrength, StreamCipher, SymmetricCipher, }; /// Instance of the test framework. @@ -124,7 +124,8 @@ impl TestFrameworkBlockCipher { const KEY_LEN: usize, const INIT_DATA_LEN: usize, const BLOCK_LEN: usize, - C: BlockCipher, + E: BlockCipherEncryptor, + D: BlockCipherDecryptor, >( &self, ) { @@ -135,42 +136,76 @@ impl TestFrameworkBlockCipher { .unwrap(); // to test blocks, we'll chunk our dummy seed - let (mut encryptor, iv) = C::do_encrypt_init(&key).unwrap(); - let mut decryptor = C::do_decrypt_init(&key, &iv).unwrap(); + let (mut encryptor, iv) = E::do_encrypt_init(&key).unwrap(); + let mut decryptor = D::do_decrypt_init(&key, &iv).unwrap(); + // one block at a time (N = 1) for msg_chunk in DUMMY_SEED.as_chunks::().0.iter() { - let ct = encryptor.do_encrypt_block(msg_chunk).unwrap(); - let pt = decryptor.do_decrypt_block(&ct).unwrap(); + let ct = encryptor.do_encrypt_blocks(&[*msg_chunk]).unwrap(); + let [pt] = decryptor.do_decrypt_blocks(&ct).unwrap(); assert_eq!(msg_chunk, &pt); } // do it again using the _out versions - let (mut encryptor, iv) = C::do_encrypt_init(&key).unwrap(); - let mut decryptor = C::do_decrypt_init(&key, &iv).unwrap(); + let (mut encryptor, iv) = E::do_encrypt_init(&key).unwrap(); + let mut decryptor = D::do_decrypt_init(&key, &iv).unwrap(); - let mut ct = [0u8; BLOCK_LEN]; - let mut pt = [0u8; BLOCK_LEN]; + let mut ct = [[0u8; BLOCK_LEN]; 1]; + let mut pt = [[0u8; BLOCK_LEN]; 1]; for msg_chunk in DUMMY_SEED.as_chunks::().0.iter() { - let ct_bytes_written = encryptor.do_encrypt_block_out(msg_chunk, &mut ct).unwrap(); + let ct_bytes_written = encryptor + .do_encrypt_blocks_out(&[*msg_chunk], &mut ct) + .unwrap(); assert_eq!(ct_bytes_written, BLOCK_LEN); - let pt_bytes_written = decryptor.do_decrypt_block_out(&ct, &mut pt).unwrap(); + let pt_bytes_written = decryptor.do_decrypt_blocks_out(&ct, &mut pt).unwrap(); assert_eq!(pt_bytes_written, BLOCK_LEN); - assert_eq!(msg_chunk, &pt); + assert_eq!(msg_chunk, &pt[0]); + } + + // multi-block (N = 2): blocks encrypted together must decrypt both together and one at a time, + // and blocks encrypted one at a time must decrypt together. + let (mut encryptor, iv) = E::do_encrypt_init(&key).unwrap(); + let mut decryptor = D::do_decrypt_init(&key, &iv).unwrap(); + + let mut ct = [[0u8; BLOCK_LEN]; 2]; + let mut pt = [[0u8; BLOCK_LEN]; 2]; + for msg_pair in DUMMY_SEED.as_chunks::().0.as_chunks::<2>().0.iter() { + // encrypt together, decrypt together (by value) + let ct_by_value = encryptor.do_encrypt_blocks(msg_pair).unwrap(); + let pt_by_value = decryptor.do_decrypt_blocks(&ct_by_value).unwrap(); + assert_eq!(msg_pair, &pt_by_value); + + // encrypt together (_out), decrypt one at a time + let ct_bytes_written = encryptor.do_encrypt_blocks_out(msg_pair, &mut ct).unwrap(); + assert_eq!(ct_bytes_written, 2 * BLOCK_LEN); + for (msg_chunk, ct_chunk) in msg_pair.iter().zip(ct.iter()) { + let [pt] = decryptor.do_decrypt_blocks(&[*ct_chunk]).unwrap(); + assert_eq!(msg_chunk, &pt); + } + + // encrypt one at a time, decrypt together (_out) + for (msg_chunk, ct_chunk) in msg_pair.iter().zip(ct.iter_mut()) { + let [c] = encryptor.do_encrypt_blocks(&[*msg_chunk]).unwrap(); + *ct_chunk = c; + } + let pt_bytes_written = decryptor.do_decrypt_blocks_out(&ct, &mut pt).unwrap(); + assert_eq!(pt_bytes_written, 2 * BLOCK_LEN); + assert_eq!(msg_pair, &pt); } // test that the iv is random (ie not the same on two runs) - let (_encryptor, iv1) = C::do_encrypt_init(&key).unwrap(); - let (_encryptor, iv2) = C::do_encrypt_init(&key).unwrap(); + let (_encryptor, iv1) = E::do_encrypt_init(&key).unwrap(); + let (_encryptor, iv2) = E::do_encrypt_init(&key).unwrap(); assert_ne!(iv1, iv2); // error case: KeyMaterial of wrong type let mac_key = KeyMaterial::::from_bytes_as_type(&DUMMY_SEED[..KEY_LEN], KeyType::MACKey) .unwrap(); - match C::do_encrypt_init(&mac_key) { + match E::do_encrypt_init(&mac_key) { Err(SymmetricCipherError::KeyMaterialError(_)) => { /* good */ } _ => panic!("Unexpected error"), }; @@ -194,15 +229,15 @@ impl TestFrameworkBlockCipher { // (and bypasses the key-length guard) without complaining. do_hazardous_operations(&mut key, |key| key.set_security_strength(ss.clone())).unwrap(); - match C::do_encrypt_init(&key) { + match E::do_encrypt_init(&key) { Ok(_) => { - if ss >= &C::MAX_SECURITY_STRENGTH { /* good */ + if ss >= &E::MAX_SECURITY_STRENGTH { /* good */ } else { panic!("Should have been a strong enough key"); } } Err(SymmetricCipherError::KeyMaterialError(_)) => { - if ss < &C::MAX_SECURITY_STRENGTH { /* good */ + if ss < &E::MAX_SECURITY_STRENGTH { /* good */ } else { panic!("Should not have accepted a key weaker than algorithm"); } diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index f839a27e..6e14ce35 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -80,72 +80,75 @@ pub trait SymmetricCipher: Alg ) -> Result; } -/// The basic functions of a block cipher. +/// Metadata shared by [`BlockCipherEncryptor`] and [`BlockCipherDecryptor`]. +pub trait BlockCipher { + /// Maximum security strength supported by the algorithm; keys tagged with a lower strength are + /// rejected by the `_init` constructors. + const MAX_SECURITY_STRENGTH: SecurityStrength; +} + +/// The encryption half of a block cipher's streaming API. Strictly block-aligned: whole blocks in, whole +/// blocks out, no finalization step. Padding of non-block-aligned data is handled by a separate layer +/// (`PaddedEncryptor` / `PaddedDecryptor`) built on top of this trait. +/// +/// Encryption and decryption are separate traits (as with [`KEMEncapsulator`] / [`KEMDecapsulator`]) so +/// that the direction can be encoded in the type, and so that a policy can permit decryption of an +/// algorithm while forbidding new encryptions. +/// /// This trait allows for a block cipher to generate initialization data, such as an Initialization Vector (IV) or Counter (CTR) /// which is not technically part of the ciphertext, but must be transmitted along with the ciphertext in order for the /// recipient to perform successful decryption. The length of the initialization data is specified by the implementing struct /// via the `INIT_DATA_LEN` constant. -/// In order for these one-shot APIs to be usable securely in all contexts, the init data will be generated +/// In order for these APIs to be usable securely in all contexts, the init data will be generated /// securely by the block cipher implementation and returned along with the ciphertext, and there is no API for the /// user to provide the init data. If you require this functionality, see the documentation for the underlying implementation. -pub trait BlockCipher: - SymmetricCipher + Sized +pub trait BlockCipherEncryptor: + BlockCipher + Sized { - /// Constructor that begins a flow of the streaming API for encrypting one block at a time. - /// Allows for the implementation to return init data such as an IV which is generated prior to encrypting the first block. + /// Begins a streaming encryption flow, returning the generated init data (e.g. IV). + /// Sources randomness from the library's default OS-backed RNG. fn do_encrypt_init( key: &KeyMaterial, ) -> Result<(Self, [u8; INIT_DATA_LEN]), SymmetricCipherError>; - /// Encrypts a single block of plaintext. - fn do_encrypt_block( - &mut self, - plaintext: &[u8; BLOCK_LEN], - ) -> Result<[u8; BLOCK_LEN], SymmetricCipherError>; - /// Encrypts a single block of plaintext and writes the ciphertext to the provided buffer. - fn do_encrypt_block_out( - &mut self, - plaintext: &[u8; BLOCK_LEN], - ciphertext: &mut [u8; BLOCK_LEN], - ) -> Result; - /// Encrypts the final block of plaintext. - fn do_encrypt_final( + /// As [`BlockCipherEncryptor::do_encrypt_init`], but sources randomness from the provided RNG. + fn do_encrypt_init_rng( + key: &KeyMaterial, + rng: &mut dyn RNG, + ) -> Result<(Self, [u8; INIT_DATA_LEN]), SymmetricCipherError>; + /// Encrypts `N` consecutive blocks of plaintext. A sequence of calls is equivalent to one call over + /// the concatenation. + fn do_encrypt_blocks( &mut self, - plaintext: &[u8; BLOCK_LEN], - ) -> Result<[u8; BLOCK_LEN], SymmetricCipherError>; - /// Encrypts the final block of plaintext and writes the ciphertext to the provided buffer. - fn do_encrypt_final_out( + plaintext: &[[u8; BLOCK_LEN]; N], + ) -> Result<[[u8; BLOCK_LEN]; N], SymmetricCipherError>; + /// Encrypts `N` consecutive blocks of plaintext into the provided buffer. Returns `N * BLOCK_LEN`. + fn do_encrypt_blocks_out( &mut self, - plaintext: &[u8; BLOCK_LEN], - ciphertext: &mut [u8; BLOCK_LEN], + plaintext: &[[u8; BLOCK_LEN]; N], + ciphertext: &mut [[u8; BLOCK_LEN]; N], ) -> Result; - /// Constructor that begins a flow of the streaming API for decryption one block at a time. +} + +/// The decryption half of a block cipher's streaming API; see [`BlockCipherEncryptor`]. +pub trait BlockCipherDecryptor: + BlockCipher + Sized +{ + /// Begins a streaming decryption flow from the init data returned by [`BlockCipherEncryptor::do_encrypt_init`]. fn do_decrypt_init( key: &KeyMaterial, init_data: &[u8; INIT_DATA_LEN], ) -> Result; - /// Decrypts a single block of ciphertext. - fn do_decrypt_block( - &mut self, - ciphertext: &[u8; BLOCK_LEN], - ) -> Result<[u8; BLOCK_LEN], SymmetricCipherError>; - /// Decrypts a single block of ciphertext and writes the plaintext to the provided buffer. - fn do_decrypt_block_out( + /// Decrypts `N` consecutive blocks of ciphertext. A sequence of calls is equivalent to one call over + /// the concatenation. + fn do_decrypt_blocks( &mut self, - ciphertext: &[u8; BLOCK_LEN], - plaintext: &mut [u8; BLOCK_LEN], - ) -> Result; - /// Decrypts the final block of ciphertext. - /// This is the decryption counterpart to [`BlockCipher::do_encrypt_final`] and is where an - /// implementation validates and strips any padding (or otherwise finalizes the flow). - fn do_decrypt_final( + ciphertext: &[[u8; BLOCK_LEN]; N], + ) -> Result<[[u8; BLOCK_LEN]; N], SymmetricCipherError>; + /// Decrypts `N` consecutive blocks of ciphertext into the provided buffer. Returns `N * BLOCK_LEN`. + fn do_decrypt_blocks_out( &mut self, - ciphertext: &[u8; BLOCK_LEN], - ) -> Result<[u8; BLOCK_LEN], SymmetricCipherError>; - /// Decrypts the final block of ciphertext and writes the plaintext to the provided buffer. - fn do_decrypt_final_out( - &mut self, - ciphertext: &[u8; BLOCK_LEN], - plaintext: &mut [u8; BLOCK_LEN], + ciphertext: &[[u8; BLOCK_LEN]; N], + plaintext: &mut [[u8; BLOCK_LEN]; N], ) -> Result; } @@ -170,7 +173,7 @@ pub trait AEADCipher, @@ -178,7 +181,7 @@ pub trait AEADCipher Result<([u8; NONCE_LEN], usize, [u8; TAG_LEN]), SymmetricCipherError>; - /// All AEAD ciphers will also be either a [`BlockCipher`] or a [`StreamCipher`], and so will already + /// All AEAD ciphers will also be either a block cipher ([`BlockCipherEncryptor`] / [`BlockCipherDecryptor`]) or a [`StreamCipher`], and so will already /// have a streaming API. /// This allows you to finish either style of streaming API flow with AEAD specific do_final() /// that computes and returns the authentication tag. @@ -207,7 +210,7 @@ pub trait AEADCipher Result; - /// All AEAD ciphers will also be either a [`BlockCipher`] or a [`StreamCipher`], and so will already + /// All AEAD ciphers will also be either a block cipher ([`BlockCipherEncryptor`] / [`BlockCipherDecryptor`]) or a [`StreamCipher`], and so will already /// have a streaming API. /// This allows you to finish either style of streaming API flow with AEAD specific do_final() /// that computes and returns the authentication tag. From 0f7c4eac1f3b0295fd04a5d0580e61cd66a82301 Mon Sep 17 00:00:00 2001 From: David Hook Date: Sun, 30 Aug 2026 18:03:56 +1000 Subject: [PATCH 02/12] rustfmt: wrap long trait signatures and imports Formatting for the previous commit; no semantic change. Co-Authored-By: Claude Fable 5 --- .../core-test-framework/src/symmetric_ciphers.rs | 7 +++---- crypto/core/src/traits.rs | 14 ++++++++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/crypto/core-test-framework/src/symmetric_ciphers.rs b/crypto/core-test-framework/src/symmetric_ciphers.rs index 67f10793..14f0eed7 100644 --- a/crypto/core-test-framework/src/symmetric_ciphers.rs +++ b/crypto/core-test-framework/src/symmetric_ciphers.rs @@ -6,7 +6,8 @@ use bouncycastle_core::key_material::{ KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, }; use bouncycastle_core::traits::{ - AEADCipher, BlockCipherDecryptor, BlockCipherEncryptor, SecurityStrength, StreamCipher, SymmetricCipher, + AEADCipher, BlockCipherDecryptor, BlockCipherEncryptor, SecurityStrength, StreamCipher, + SymmetricCipher, }; /// Instance of the test framework. @@ -154,9 +155,7 @@ impl TestFrameworkBlockCipher { let mut ct = [[0u8; BLOCK_LEN]; 1]; let mut pt = [[0u8; BLOCK_LEN]; 1]; for msg_chunk in DUMMY_SEED.as_chunks::().0.iter() { - let ct_bytes_written = encryptor - .do_encrypt_blocks_out(&[*msg_chunk], &mut ct) - .unwrap(); + let ct_bytes_written = encryptor.do_encrypt_blocks_out(&[*msg_chunk], &mut ct).unwrap(); assert_eq!(ct_bytes_written, BLOCK_LEN); let pt_bytes_written = decryptor.do_decrypt_blocks_out(&ct, &mut pt).unwrap(); diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index 6e14ce35..66988c7e 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -102,8 +102,11 @@ pub trait BlockCipher { /// In order for these APIs to be usable securely in all contexts, the init data will be generated /// securely by the block cipher implementation and returned along with the ciphertext, and there is no API for the /// user to provide the init data. If you require this functionality, see the documentation for the underlying implementation. -pub trait BlockCipherEncryptor: - BlockCipher + Sized +pub trait BlockCipherEncryptor< + const KEY_LEN: usize, + const INIT_DATA_LEN: usize, + const BLOCK_LEN: usize, +>: BlockCipher + Sized { /// Begins a streaming encryption flow, returning the generated init data (e.g. IV). /// Sources randomness from the library's default OS-backed RNG. @@ -130,8 +133,11 @@ pub trait BlockCipherEncryptor: - BlockCipher + Sized +pub trait BlockCipherDecryptor< + const KEY_LEN: usize, + const INIT_DATA_LEN: usize, + const BLOCK_LEN: usize, +>: BlockCipher + Sized { /// Begins a streaming decryption flow from the init data returned by [`BlockCipherEncryptor::do_encrypt_init`]. fn do_decrypt_init( From 27b49d66d458aa585f19b7594f902d61c392e837 Mon Sep 17 00:00:00 2001 From: David Hook Date: Sun, 30 Aug 2026 20:47:27 +1000 Subject: [PATCH 03/12] core: add one-shot encrypt_blocks/decrypt_blocks to the block cipher traits Provided (default) methods on BlockCipherEncryptor -- encrypt_blocks, encrypt_blocks_rng, encrypt_blocks_out, encrypt_blocks_out_rng -- and on BlockCipherDecryptor -- decrypt_blocks, decrypt_blocks_out -- implemented once in the trait as init + blocks, so every block-aligned mode gets the house-standard take-data-return-result static API at no cost to implementors. Arbitrary-length one-shots remain the padding layer's job. The core-test-framework block cipher test now checks the one-shots agree with the streaming API and round-trip. Co-Authored-By: Claude Fable 5 --- .../src/symmetric_ciphers.rs | 15 +++++ crypto/core/src/traits.rs | 56 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/crypto/core-test-framework/src/symmetric_ciphers.rs b/crypto/core-test-framework/src/symmetric_ciphers.rs index 14f0eed7..6e1c8534 100644 --- a/crypto/core-test-framework/src/symmetric_ciphers.rs +++ b/crypto/core-test-framework/src/symmetric_ciphers.rs @@ -195,6 +195,21 @@ impl TestFrameworkBlockCipher { assert_eq!(msg_pair, &pt); } + // one-shot API: must agree with the streaming API for the same key, and round-trip + let two_blocks: &[[u8; BLOCK_LEN]; 2] = + &DUMMY_SEED.as_chunks::().0.as_chunks::<2>().0[0]; + let (iv, ct) = E::encrypt_blocks(&key, two_blocks).unwrap(); + assert_eq!(D::decrypt_blocks(&key, &iv, &ct).unwrap(), *two_blocks); + let mut streamed = D::do_decrypt_init(&key, &iv).unwrap(); + assert_eq!(streamed.do_decrypt_blocks(&ct).unwrap(), *two_blocks); + + let mut ct = [[0u8; BLOCK_LEN]; 2]; + let mut pt = [[0u8; BLOCK_LEN]; 2]; + let (iv, n) = E::encrypt_blocks_out(&key, two_blocks, &mut ct).unwrap(); + assert_eq!(n, 2 * BLOCK_LEN); + assert_eq!(D::decrypt_blocks_out(&key, &iv, &ct, &mut pt).unwrap(), 2 * BLOCK_LEN); + assert_eq!(pt, *two_blocks); + // test that the iv is random (ie not the same on two runs) let (_encryptor, iv1) = E::do_encrypt_init(&key).unwrap(); let (_encryptor, iv2) = E::do_encrypt_init(&key).unwrap(); diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index 66988c7e..495eb1fc 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -130,6 +130,44 @@ pub trait BlockCipherEncryptor< plaintext: &[[u8; BLOCK_LEN]; N], ciphertext: &mut [[u8; BLOCK_LEN]; N], ) -> Result; + + /// One-shot: encrypts `N` blocks under a fresh init. Returns the generated init data and the ciphertext. + fn encrypt_blocks( + key: &KeyMaterial, + plaintext: &[[u8; BLOCK_LEN]; N], + ) -> Result<([u8; INIT_DATA_LEN], [[u8; BLOCK_LEN]; N]), SymmetricCipherError> { + let (mut enc, init_data) = Self::do_encrypt_init(key)?; + Ok((init_data, enc.do_encrypt_blocks(plaintext)?)) + } + /// As [`BlockCipherEncryptor::encrypt_blocks`], but sources randomness from the provided RNG. + fn encrypt_blocks_rng( + key: &KeyMaterial, + rng: &mut dyn RNG, + plaintext: &[[u8; BLOCK_LEN]; N], + ) -> Result<([u8; INIT_DATA_LEN], [[u8; BLOCK_LEN]; N]), SymmetricCipherError> { + let (mut enc, init_data) = Self::do_encrypt_init_rng(key, rng)?; + Ok((init_data, enc.do_encrypt_blocks(plaintext)?)) + } + /// One-shot: encrypts `N` blocks under a fresh init into the provided buffer. + /// Returns the generated init data and `N * BLOCK_LEN`. + fn encrypt_blocks_out( + key: &KeyMaterial, + plaintext: &[[u8; BLOCK_LEN]; N], + ciphertext: &mut [[u8; BLOCK_LEN]; N], + ) -> Result<([u8; INIT_DATA_LEN], usize), SymmetricCipherError> { + let (mut enc, init_data) = Self::do_encrypt_init(key)?; + Ok((init_data, enc.do_encrypt_blocks_out(plaintext, ciphertext)?)) + } + /// As [`BlockCipherEncryptor::encrypt_blocks_out`], but sources randomness from the provided RNG. + fn encrypt_blocks_out_rng( + key: &KeyMaterial, + rng: &mut dyn RNG, + plaintext: &[[u8; BLOCK_LEN]; N], + ciphertext: &mut [[u8; BLOCK_LEN]; N], + ) -> Result<([u8; INIT_DATA_LEN], usize), SymmetricCipherError> { + let (mut enc, init_data) = Self::do_encrypt_init_rng(key, rng)?; + Ok((init_data, enc.do_encrypt_blocks_out(plaintext, ciphertext)?)) + } } /// The decryption half of a block cipher's streaming API; see [`BlockCipherEncryptor`]. @@ -156,6 +194,24 @@ pub trait BlockCipherDecryptor< ciphertext: &[[u8; BLOCK_LEN]; N], plaintext: &mut [[u8; BLOCK_LEN]; N], ) -> Result; + + /// One-shot: decrypts `N` blocks from the given init data. + fn decrypt_blocks( + key: &KeyMaterial, + init_data: &[u8; INIT_DATA_LEN], + ciphertext: &[[u8; BLOCK_LEN]; N], + ) -> Result<[[u8; BLOCK_LEN]; N], SymmetricCipherError> { + Self::do_decrypt_init(key, init_data)?.do_decrypt_blocks(ciphertext) + } + /// One-shot: decrypts `N` blocks from the given init data into the provided buffer. Returns `N * BLOCK_LEN`. + fn decrypt_blocks_out( + key: &KeyMaterial, + init_data: &[u8; INIT_DATA_LEN], + ciphertext: &[[u8; BLOCK_LEN]; N], + plaintext: &mut [[u8; BLOCK_LEN]; N], + ) -> Result { + Self::do_decrypt_init(key, init_data)?.do_decrypt_blocks_out(ciphertext, plaintext) + } } /// The basic functions of an Authenticated Encryption with Addititional Data cipher. From 7f0380fc579f81080a636a27cf62ed81c949abd8 Mon Sep 17 00:00:00 2001 From: David Hook Date: Mon, 31 Aug 2026 11:55:51 +1000 Subject: [PATCH 04/12] Add 0.1.3 release notes for the block cipher trait changes (PR #96) Co-Authored-By: Claude Fable 5 --- alpha_0.1.3_release_notes.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/alpha_0.1.3_release_notes.md b/alpha_0.1.3_release_notes.md index 57f9e97c..53df8182 100644 --- a/alpha_0.1.3_release_notes.md +++ b/alpha_0.1.3_release_notes.md @@ -7,3 +7,26 @@ * 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. From db6f4472e25911a324211736b36a05438e5e3761 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Mon, 31 Aug 2026 16:20:10 +0700 Subject: [PATCH 05/12] 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 06/12] 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 07/12] 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 08/12] 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 09/12] 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 10/12] 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 11/12] 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?}"); } From 6e94eff62531b1a910fbc470984ed1984e5e581e Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Sun, 6 Sep 2026 19:18:25 -0500 Subject: [PATCH 12/12] Refactored MLDSAParams to remove the hideous turbofish. --- crypto/mldsa/src/aux_functions.rs | 302 +++++++------- crypto/mldsa/src/hash_mldsa.rs | 566 ++++---------------------- crypto/mldsa/src/lib.rs | 3 +- crypto/mldsa/src/matrix.rs | 164 ++++++-- crypto/mldsa/src/mldsa.rs | 636 ++++++++---------------------- crypto/mldsa/src/mldsa_keys.rs | 609 +++++++++++++--------------- crypto/mldsa/src/params.rs | 548 +++++++++++++++++++++++++ crypto/mldsa/src/polynomial.rs | 63 ++- crypto/mldsa/tests/mldsa_tests.rs | 40 ++ 9 files changed, 1437 insertions(+), 1494 deletions(-) create mode 100644 crypto/mldsa/src/params.rs diff --git a/crypto/mldsa/src/aux_functions.rs b/crypto/mldsa/src/aux_functions.rs index 9eadbb89..37d6aa7b 100644 --- a/crypto/mldsa/src/aux_functions.rs +++ b/crypto/mldsa/src/aux_functions.rs @@ -1,14 +1,14 @@ //! Implements auxiliary functions for ML-DSA as defined in Section 7 of FIPS 204. -use crate::matrix::{Matrix, Vector}; -use crate::mldsa::{G, H, q_inv}; -use crate::mldsa::{ - MLDSA44_GAMMA1, MLDSA44_GAMMA2, MLDSA65_GAMMA1, MLDSA65_GAMMA2, N, POLY_T0PACKED_LEN, - POLY_T1PACKED_LEN, d, q, +use crate::matrix::{MatrixTrait, VectorTrait}; +use crate::mldsa::{G, H, N, POLY_T0PACKED_LEN, POLY_T1PACKED_LEN, d, q, q_inv}; +use crate::params::{ + GAMMA1_2_POW_17, GAMMA1_2_POW_19, GAMMA2_Q_MINUS_1_OVER_32, GAMMA2_Q_MINUS_1_OVER_88, + MLDSAParams, }; use crate::polynomial::Polynomial; use bouncycastle_core::traits::XOF; -use bouncycastle_utils::secret::Secret; +use bouncycastle_utils::secret::{Secret, ZeroizablePrimitive}; /// Algorithm 14 CoeffFromThreeBytes(𝑏0, 𝑏1, 𝑏2) /// Output: An integer modulo π‘ž or βŠ₯. @@ -34,8 +34,8 @@ pub(crate) fn coeff_from_three_bytes(b: &[u8; 3]) -> Result { /// Input: Integer 𝑏 ∈ {0, 1, … , 15}. /// Output: An integer between βˆ’πœ‚ and πœ‚, or βŠ₯. #[inline(always)] -pub(crate) fn coeff_from_half_byte(b: u8) -> Result { - if ETA == 2 && b < 15 { +pub(crate) fn coeff_from_half_byte(b: u8) -> Result { + if P::ETA == 2 && b < 15 { // Original code is bad because '%' is not constant-time. // Ok(2 - (b % 5) as i32) // I'm still not convinced this is constant-time, but maybe it's closer? And I can't come up with anything better. @@ -46,7 +46,7 @@ pub(crate) fn coeff_from_half_byte(b: u8) -> Result { }; Ok(2 - b as i32) } else { - if ETA == 4 && b < 9 { Ok(4 - b as i32) } else { Err(()) } + if P::ETA == 4 && b < 9 { Ok(4 - b as i32) } else { Err(()) } } } @@ -84,13 +84,13 @@ pub const fn bitlen_eta(eta: usize) -> usize { // the hope here is that the compiler will aggressively inline this function, // and optimize away the branching. #[inline(always)] -pub(crate) fn bit_pack_eta(w: &Polynomial, r: &mut [u8]) { - debug_assert!(r.len() >= bitlen_eta(ETA)); +pub(crate) fn bit_pack_eta(w: &Polynomial, r: &mut [u8]) { + debug_assert!(r.len() >= bitlen_eta(P::ETA)); // temp swap space let mut t: [u8; 8] = [0; 8]; - match ETA { + match P::ETA { // MLDSA44 and MLDSA87 2 => { let eta: i32 = 2; @@ -166,19 +166,21 @@ pub(crate) fn bit_pack_t0(t0: &Polynomial) -> [u8; POLY_T0PACKED_LEN] { } /// A variant of Algorithm 17 specific to packing z in the signature value in \[βˆ’π›Ύ1 + 1, 𝛾1]. -pub(crate) fn bitpack_gamma1( - z: &Polynomial, -) -> [u8; POLY_Z_PACKED_LEN] { - let mut r = [0u8; POLY_Z_PACKED_LEN]; +pub(crate) fn bitpack_gamma1(z: &Polynomial) -> P::PolyZPacked { + let mut out = ::ZEROED; + let r = out.as_mut(); let mut t: [u32; 4] = [0; 4]; - match GAMMA1 { - MLDSA44_GAMMA1 => { + // One layout per distinct 𝛾1 rather than one per parameter set; `P::GAMMA1` is a constant + // after monomorphization, so only the matching arm survives. + match P::GAMMA1 { + // MLDSA-44 + GAMMA1_2_POW_17 => { for i in 0..N / 4 { - t[0] = (GAMMA1 - z[4 * i]) as u32; - t[1] = (GAMMA1 - z[4 * i + 1]) as u32; - t[2] = (GAMMA1 - z[4 * i + 2]) as u32; - t[3] = (GAMMA1 - z[4 * i + 3]) as u32; + t[0] = (P::GAMMA1 - z[4 * i]) as u32; + t[1] = (P::GAMMA1 - z[4 * i + 1]) as u32; + t[2] = (P::GAMMA1 - z[4 * i + 2]) as u32; + t[3] = (P::GAMMA1 - z[4 * i + 3]) as u32; r[9 * i] = t[0] as u8; r[9 * i + 1] = (t[0] >> 8) as u8; @@ -191,11 +193,11 @@ pub(crate) fn bitpack_gamma1( r[9 * i + 8] = (t[3] >> 10) as u8; } } - // MLDSA-65 and 87 have the same GAMMA1 value - MLDSA65_GAMMA1 => { + // MLDSA-65 and -87 have the same GAMMA1 value + GAMMA1_2_POW_19 => { for i in 0..N / 2 { - t[0] = (GAMMA1 - z[2 * i]) as u32; - t[1] = (GAMMA1 - z[2 * i + 1]) as u32; + t[0] = (P::GAMMA1 - z[2 * i]) as u32; + t[1] = (P::GAMMA1 - z[2 * i + 1]) as u32; r[5 * i] = t[0] as u8; r[5 * i + 1] = (t[0] >> 8) as u8; @@ -209,7 +211,7 @@ pub(crate) fn bitpack_gamma1( } } - r + out } /// A specific instantiation of Algorithm 18 SimpleBitUnpack(v, 𝑏) with the constants set for unpacking the t1 vector @@ -241,12 +243,12 @@ pub(crate) fn simple_bit_unpack_t1(v: &[u8; POLY_T1PACKED_LEN]) -> Polynomial { // the hope here is that the compiler will aggressively inline this function, // and optimize away the branching. #[inline(always)] -pub(crate) fn bit_unpack_eta(v: &[u8]) -> Polynomial { - debug_assert_eq!(v.len(), bitlen_eta(ETA)); +pub(crate) fn bit_unpack_eta(v: &[u8]) -> Polynomial { + debug_assert_eq!(v.len(), bitlen_eta(P::ETA)); let mut w = Polynomial::new(); - match ETA { + match P::ETA { // MLDSA44 and MLDSA87 2 => { let eta: i32 = 2; @@ -331,11 +333,12 @@ pub(crate) fn bit_unpack_t0(a: &[u8; POLY_T0PACKED_LEN]) -> Polynomial { /// When π‘Ž + 𝑏 + 1 is a power of 2, the coefficients are in [βˆ’π‘Ž, 𝑏]. /// /// Note: caller is responsible for ensuring correct input array size -pub(crate) fn bit_unpack_gamma1(v: &[u8]) -> Polynomial { +pub(crate) fn bit_unpack_gamma1(v: &[u8]) -> Polynomial { let mut w = Polynomial::new(); - match GAMMA1 { - MLDSA44_GAMMA1 => { + match P::GAMMA1 { + // MLDSA-44 + GAMMA1_2_POW_17 => { for i in 0..N / 4 { w[4 * i] = (((v[9 * i] as i32) | ((v[9 * i + 1] as i32) << 8)) | ((v[9 * i + 2] as i32) << 16)) @@ -350,14 +353,14 @@ pub(crate) fn bit_unpack_gamma1(v: &[u8]) -> Polynomial { | ((v[9 * i + 8] as i32) << 10)) & 0x3FFFF; - w[4 * i] = GAMMA1 - w[4 * i]; - w[4 * i + 1] = GAMMA1 - w[4 * i + 1]; - w[4 * i + 2] = GAMMA1 - w[4 * i + 2]; - w[4 * i + 3] = GAMMA1 - w[4 * i + 3]; + w[4 * i] = P::GAMMA1 - w[4 * i]; + w[4 * i + 1] = P::GAMMA1 - w[4 * i + 1]; + w[4 * i + 2] = P::GAMMA1 - w[4 * i + 2]; + w[4 * i + 3] = P::GAMMA1 - w[4 * i + 3]; } } - // MLDSA-65 and 87 have the same GAMMA1 value - MLDSA65_GAMMA1 => { + // MLDSA-65 and -87 have the same GAMMA1 value + GAMMA1_2_POW_19 => { for i in 0..N / 2 { w[2 * i] = (((v[5 * i] as i32) | ((v[5 * i + 1] as i32) << 8)) | ((v[5 * i + 2] as i32) << 16)) @@ -366,8 +369,8 @@ pub(crate) fn bit_unpack_gamma1(v: &[u8]) -> Polynomial { | ((v[5 * i + 4] as i32) << 12)) & 0xFFFFF; - w[2 * i] = GAMMA1 - w[2 * i]; - w[2 * i + 1] = GAMMA1 - w[2 * i + 1]; + w[2 * i] = P::GAMMA1 - w[2 * i]; + w[2 * i + 1] = P::GAMMA1 - w[2 * i + 1]; } } _ => { @@ -384,43 +387,36 @@ pub(crate) fn bit_unpack_gamma1(v: &[u8]) -> Polynomial { /// Output: Signature 𝜎 ∈ π”Ήπœ†/4+β„“β‹…32β‹…(1+bitlen (𝛾1βˆ’1))+πœ”+π‘˜. /// /// Returns the number of bytes written to the output buffer. -pub(crate) fn sig_encode< - const GAMMA1: i32, - const k: usize, - const l: usize, - const LAMBDA_over_4: usize, - const OMEGA: i32, - const POLY_Z_PACKED_LEN: usize, - const SIG_LEN: usize, ->( - c_tilde: &[u8; LAMBDA_over_4], - z: &Vector, - h: &Vector, +pub(crate) fn sig_encode( + c_tilde: &P::SigCTilde, + z: &P::VecL, + h: &P::VecK, output: &mut [u8; SIG_LEN], ) -> usize { + debug_assert_eq!(SIG_LEN, P::SIG_LEN); output.fill(0); let mut pos = 0; - output[..LAMBDA_over_4].copy_from_slice(c_tilde); - pos += LAMBDA_over_4; + output[..P::C_TILDE_LEN].copy_from_slice(c_tilde.as_ref()); + pos += P::C_TILDE_LEN; - for i in 0..l { - output[pos..pos + POLY_Z_PACKED_LEN] - .copy_from_slice(&bitpack_gamma1::(&z.elems[i])); - pos += POLY_Z_PACKED_LEN; + for i in 0..P::l { + output[pos..pos + P::POLY_Z_PACKED_LEN] + .copy_from_slice(bitpack_gamma1::

(&z.elems()[i]).as_ref()); + pos += P::POLY_Z_PACKED_LEN; } // This inlines Algorithm 20 HintBitPack(𝐑) let mut m: usize = 0; - for i in 0..k { + for i in 0..P::k { for j in 0..N { - if h.elems[i][j] != 0 { + if h.elems()[i][j] != 0 { output[pos + m] = j as u8; m += 1; } - output[pos + OMEGA as usize + i] = m as u8; + output[pos + P::OMEGA as usize + i] = m as u8; } } @@ -432,29 +428,22 @@ pub(crate) fn sig_encode< /// Input: Signature 𝜎 ∈ π”Ήπœ†/4+β„“β‹…32β‹…(1+bitlen (𝛾1βˆ’1))+πœ”+π‘˜. /// Output: 𝑐 ∈ π”Ήπœ†/4, 𝐳 ∈ 𝑅ℓ with coefficients in \[βˆ’π›Ύ1 + 1, 𝛾1], 𝐑 ∈ π‘…π‘˜, or βŠ₯. /// Output: (c_tilde, z, h) -pub(crate) fn sig_decode< - const GAMMA1: i32, - const k: usize, - const l: usize, - const LAMBDA_over_4: usize, - const OMEGA: i32, - const POLY_Z_PACKED_LEN: usize, - const SIG_LEN: usize, ->( +pub(crate) fn sig_decode( sig: &[u8; SIG_LEN], -) -> Result<([u8; LAMBDA_over_4], Vector, Vector), ()> { - let mut c_tilde = [0u8; LAMBDA_over_4]; - let mut z: Vector = Vector::::new(); - let mut h: Vector = Vector::::new(); +) -> Result<(P::SigCTilde, P::VecL, P::VecK), ()> { + debug_assert_eq!(SIG_LEN, P::SIG_LEN); + let mut c_tilde = ::ZEROED; + let mut z = P::VecL::new(); + let mut h = P::VecK::new(); let mut pos: usize = 0; - c_tilde.copy_from_slice(&sig[..LAMBDA_over_4]); - pos += LAMBDA_over_4; + c_tilde.as_mut().copy_from_slice(&sig[..P::C_TILDE_LEN]); + pos += P::C_TILDE_LEN; - for i in 0..l { - z.elems[i] = bit_unpack_gamma1::(&sig[pos..pos + POLY_Z_PACKED_LEN]); - pos += POLY_Z_PACKED_LEN; + for i in 0..P::l { + z.elems_mut()[i] = bit_unpack_gamma1::

(&sig[pos..pos + P::POLY_Z_PACKED_LEN]); + pos += P::POLY_Z_PACKED_LEN; } // This inlines Algorithm 21 HintBitUnpack(𝑦) @@ -465,12 +454,12 @@ pub(crate) fn sig_decode< // 3: for 𝑖 from 0 to π‘˜ βˆ’ 1 do // β–· reconstruct 𝐑[𝑖] - for i in 0..k { + for i in 0..P::k { // 4: if 𝑦[πœ” + 𝑖] < Index or 𝑦[πœ” + 𝑖] > πœ” then return βŠ₯ // todo: this needs a specific test for malformed signature values. Maybe crucible coveres this case? // ... could hide an assert here and see if it triggers. - if sig[pos + (OMEGA as usize) + i] < (idx as u8) - || sig[pos + (OMEGA as usize) + i] > OMEGA as u8 + if sig[pos + (P::OMEGA as usize) + i] < (idx as u8) + || sig[pos + (P::OMEGA as usize) + i] > P::OMEGA as u8 { return Err(()); } @@ -478,7 +467,7 @@ pub(crate) fn sig_decode< // 6: First ← Index // 7: while Index < 𝑦[πœ” + 𝑖] do // β–· 𝑦[πœ” + 𝑖] says how far one can advance Index - for j in idx..sig[pos + OMEGA as usize + i] as usize { + for j in idx..sig[pos + P::OMEGA as usize + i] as usize { // 8: if Index > First then // 9: if 𝑦[Index βˆ’ 1] β‰₯ 𝑦[Index] then return βŠ₯ // β–· malformed input @@ -486,17 +475,17 @@ pub(crate) fn sig_decode< return Err(()); } // 12: 𝐑[𝑖]_𝑦[Index] ← 1 - h.elems[i][sig[pos + j] as usize] = 1; + h.elems_mut()[i][sig[pos + j] as usize] = 1; // 13: Index ← Index + 1 // > done by for loop } - idx = sig[pos + OMEGA as usize + i] as usize; + idx = sig[pos + P::OMEGA as usize + i] as usize; } // β–· read any leftover bytes in the first πœ” bytes of 𝑦 for malformed (nonzero) bytes - for j in idx..OMEGA as usize { + for j in idx..P::OMEGA as usize { if sig[pos + j] != 0 { return Err(()); } @@ -509,9 +498,7 @@ pub(crate) fn sig_decode< /// Samples a polynomial 𝑐 ∈ 𝑅 with coefficients from {βˆ’1, 0, 1} and Hamming weight 𝜏 ≀ 64. /// Input: A seed 𝜌 ∈ π”Ήπœ†/4 /// Output: A polynomial 𝑐 in 𝑅. -pub(crate) fn sample_in_ball( - rho: &[u8; LAMBDA_over_4], -) -> Polynomial { +pub(crate) fn sample_in_ball(rho: &P::SigCTilde) -> Polynomial { // 1: 𝑐 ← 0 let mut c = Polynomial::new(); @@ -519,7 +506,7 @@ pub(crate) fn sample_in_ball( // 3: ctx ← H.Absorb(ctx, 𝜌) // 4: (ctx, 𝑠) ← H.Squeeze(ctx, 8) let mut h = H::new(); - h.absorb(rho).expect("absorb before squeeze is infallible"); + h.absorb(rho.as_ref()).expect("absorb before squeeze is infallible"); let mut s = [0u8; 8]; h.squeeze_out(&mut s); @@ -535,7 +522,7 @@ pub(crate) fn sample_in_ball( // let mut pos = 8; // let mut b; let mut j = [0u8]; - for i in (N - TAU as usize)..N { + for i in (N - P::TAU as usize)..N { // 7: (ctx, 𝑗) ← H.Squeeze(ctx, 1) // Note: Even though it may appear that pre-squeezing a buffer outside the loop would be faster, // testing it both ways doesn't make a noticeable difference, so this has been left as is @@ -624,7 +611,7 @@ pub(crate) fn rej_ntt_poly(rho: &[u8; 32], nonce: &[u8; 2]) -> Polynomial { /// This is supposed to take a rho: [u8; 66], which is: 𝜌||IntegerToBytes(𝑠, 1)||IntegerToBytes(π‘Ÿ, 1) /// but to avoid needing to copy bytes and allocate more memory, /// that is split into a [u8;64] and a [u8;2] -pub(crate) fn rej_bounded_poly(rho: &[u8; 64], nonce: &[u8; 2]) -> Polynomial { +pub(crate) fn rej_bounded_poly(rho: &[u8; 64], nonce: &[u8; 2]) -> Polynomial { let mut a = Polynomial::new(); let mut j: usize = 0; let mut h = H::new(); @@ -640,8 +627,8 @@ pub(crate) fn rej_bounded_poly(rho: &[u8; 64], nonce: &[u8; 2] let mut idx: usize = 0; while j < N { - let z0 = coeff_from_half_byte::(z_arr[idx] & 0x0F); // equiv to % 16 (but faster, and more importantly, constant-time) - let z1 = coeff_from_half_byte::(z_arr[idx] >> 4); // equiv to div_floor(16) (but faster, and more importantly, constant-time) + let z0 = coeff_from_half_byte::

(z_arr[idx] & 0x0F); // equiv to % 16 (but faster, and more importantly, constant-time) + let z1 = coeff_from_half_byte::

(z_arr[idx] >> 4); // equiv to div_floor(16) (but faster, and more importantly, constant-time) if z0.is_ok() { a[j] = z0.unwrap(); @@ -667,12 +654,12 @@ pub(crate) fn rej_bounded_poly(rho: &[u8; 64], nonce: &[u8; 2] /// in other words: derives the public matrix from the public seed. /// Input: A seed 𝜌 ∈ 𝔹32 .Μ‚ /// Output: Matrix Γ‚ ∈ (π‘‡π‘ž)π‘˜Γ—β„“ . -pub(crate) fn expandA(rho: &[u8; 32]) -> Matrix { - let mut A_hat = Matrix::::new(); +pub(crate) fn expandA(rho: &[u8; 32]) -> P::MatrixA { + let mut A_hat = P::MatrixA::new(); - for r in 0..k { - for s in 0..l { - A_hat.elems[r][s] = rej_ntt_poly(rho, &[s as u8, r as u8]); + for r in 0..P::k { + for s in 0..P::l { + A_hat.set_elem(r, s, rej_ntt_poly(rho, &[s as u8, r as u8])); } } @@ -685,18 +672,16 @@ pub(crate) fn expandA(rho: &[u8; 32]) -> Matrix< /// Input: A seed 𝜌 ∈ 𝔹64 . /// Output: Vectors 𝐬1, 𝐬2 of secret polynomials in 𝑅 /// Note that this returns Secret> because s1, s2 are always part of a private key. -pub(crate) fn expandS( - rho: &[u8; 64], -) -> (Secret>, Secret>) { - let mut s1: Secret> = Secret::new(); - let mut s2: Secret> = Secret::new(); - - for r in 0..l { - s1.elems[r] = rej_bounded_poly::(rho, &(r as u16).to_le_bytes()); +pub(crate) fn expandS(rho: &[u8; 64]) -> (Secret, Secret) { + let mut s1: Secret = Secret::new(); + let mut s2: Secret = Secret::new(); + + for r in 0..P::l { + s1.elems_mut()[r] = rej_bounded_poly::

(rho, &(r as u16).to_le_bytes()); } - for r in 0..k { - s2.elems[r] = rej_bounded_poly::(rho, &(r as u16 + l as u16).to_le_bytes()); + for r in 0..P::k { + s2.elems_mut()[r] = rej_bounded_poly::

(rho, &(r as u16 + P::l as u16).to_le_bytes()); } (s1, s2) @@ -704,13 +689,13 @@ pub(crate) fn expandS( /// Implements the meta-function described in FIPS 204 section 7.4 for applying power_2_round to a vector. /// ((𝐫1\[𝑖])𝑗, (𝐫0\[𝑖])𝑗) = Power2Round((𝐫\[𝑖])𝑗). -pub(crate) fn power_2_round_vec(v: &Vector) -> (Vector, Vector) { - let mut r1 = Vector::::new(); - let mut r0 = Vector::::new(); +pub(crate) fn power_2_round_vec(v: &V) -> (V, V) { + let mut r1 = V::new(); + let mut r0 = V::new(); - for i in 0..LEN { + for i in 0..V::LEN { for j in 0..N { - (r1.elems[i][j], r0.elems[i][j]) = power_2_round(v.elems[i][j]); + (r1.elems_mut()[i][j], r0.elems_mut()[i][j]) = power_2_round(v.elems()[i][j]); } } @@ -721,17 +706,15 @@ pub(crate) fn power_2_round_vec(v: &Vector) -> (Vector( - rho: &[u8; 64], - mu: u16, -) -> Vector { - let mut y = Vector::::new(); +pub(crate) fn expand_mask(rho: &[u8; 64], mu: u16) -> P::VecL { + let mut y = P::VecL::new(); // 1: 𝑐 ← 1 + bitlen (𝛾1 βˆ’ 1) // β–· 𝛾1 is always a power of 2 - // 32c = GAMMA1_MASK_LEN; + // The 32𝑐 bytes squeezed on line 4 are exactly `P::POLY_Z_PACKED_LEN`, so the buffer for them + // is `P::PolyZPacked`; see the docs on [`MLDSAParams::POLY_Z_PACKED_LEN`]. - for r in 0..l { + for r in 0..P::l { // 3: πœŒβ€² ← 𝜌||IntegerToBytes(πœ‡ + π‘Ÿ, 2) // 4: 𝑣 ← H(πœŒβ€², 32𝑐) let v = { @@ -739,13 +722,13 @@ pub(crate) fn expand_mask::ZEROED; + h.squeeze_out(v.as_mut()); v }; // 5: 𝐲[π‘Ÿ] ← BitUnpack(𝑣, 𝛾1 βˆ’ 1, 𝛾1) - y.elems[r] = bit_unpack_gamma1::(&v); + y.elems_mut()[r] = bit_unpack_gamma1::

(v.as_ref()); } y @@ -788,7 +771,7 @@ fn test_power_2_round() { /// Decomposes π‘Ÿ into (π‘Ÿ1, π‘Ÿ0) such that π‘Ÿ ≑ π‘Ÿ1(2𝛾2) + π‘Ÿ0 mod π‘ž. /// Input: π‘Ÿ ∈ β„€π‘ž. /// Output: Integers (π‘Ÿ1, π‘Ÿ0). -pub(crate) fn decompose(r: i32) -> (i32, i32) { +pub(crate) fn decompose(r: i32) -> (i32, i32) { // 1: π‘Ÿ+ ← π‘Ÿ mod π‘ž // 2: π‘Ÿ0 ← π‘Ÿ+ modΒ±(2𝛾2) // 3: if π‘Ÿ+ βˆ’ π‘Ÿ0 = π‘ž βˆ’ 1 then @@ -803,14 +786,15 @@ pub(crate) fn decompose(r: i32) -> (i32, i32) { let mut r1: i32; let mut r0 = (r + 127) >> 7; - match GAMMA2 { - MLDSA44_GAMMA2 => { + match P::GAMMA2 { + // MLDSA-44 + GAMMA2_Q_MINUS_1_OVER_88 => { // (q - 1) / 88 r0 = (r0 * 11275 + (1 << 23)) >> 24; r0 ^= ((43 - r0) >> 31) & r0; } - // ML-DSA65 and 87 have the same GAMMA2 - MLDSA65_GAMMA2 => { + // ML-DSA-65 and -87 have the same GAMMA2 + GAMMA2_Q_MINUS_1_OVER_32 => { // (q - 1) / 32; r0 = (r0 * 1025 + (1 << 21)) >> 22; r0 &= 15; @@ -821,7 +805,7 @@ pub(crate) fn decompose(r: i32) -> (i32, i32) { } } - r1 = r - r0 * 2 * GAMMA2; + r1 = r - r0 * 2 * P::GAMMA2; // mutants note: the choice of (q - 1) is a bit arbitrary in that after doing the bit-shifting, // this seems to work out mathematically equivalent if doing q/2, or (q+3)/2, but here it is left as (q-1)/2 @@ -835,10 +819,10 @@ pub(crate) fn decompose(r: i32) -> (i32, i32) { /// Returns π‘Ÿ1 from the output of Decompose (π‘Ÿ). /// Input: π‘Ÿ ∈ β„€π‘ž. /// Output: Integer π‘Ÿ1. -pub(crate) fn high_bits(r: i32) -> i32 { +pub(crate) fn high_bits(r: i32) -> i32 { // 1: (π‘Ÿ1, π‘Ÿ0) ← Decompose(π‘Ÿ) // 2: return π‘Ÿ1 - let (r1, _) = decompose::(r); + let (r1, _) = decompose::

(r); r1 } @@ -846,10 +830,10 @@ pub(crate) fn high_bits(r: i32) -> i32 { /// Returns π‘Ÿ0 from the output of Decompose (π‘Ÿ). /// Input: π‘Ÿ ∈ β„€π‘ž. /// Output: Integer π‘Ÿ0. -pub(crate) fn low_bits(r: i32) -> i32 { +pub(crate) fn low_bits(r: i32) -> i32 { // 1: (π‘Ÿ1, π‘Ÿ0) ← Decompose(π‘Ÿ) // 2: return π‘Ÿ0 - let (_, r0) = decompose::(r); + let (_, r0) = decompose::

(r); r0 } @@ -857,32 +841,29 @@ pub(crate) fn low_bits(r: i32) -> i32 { /// Computes hint bit indicating whether adding 𝑧 to π‘Ÿ alters the high bits of π‘Ÿ. /// Input: 𝑧, π‘Ÿ ∈ β„€π‘ž. /// Output: Boolean. -pub(crate) fn make_hint(z: i32, r: i32) -> i32 { +pub(crate) fn make_hint(z: i32, r: i32) -> i32 { // // 1: π‘Ÿ1 ← HighBits(π‘Ÿ) - // let r1 = high_bits::(r); + // let r1 = high_bits::

(r); // // // 2: 𝑣1 ← HighBits(π‘Ÿ + 𝑧) - // let v1 = high_bits::(r + z); + // let v1 = high_bits::

(r + z); // // // 3: return [[π‘Ÿ1 β‰  𝑣1]] // if r1 != v1 { 1 } else { 0 } // By the powers of someone much more clever than me, this is equivalent. // mutants note: we do not have KATs that exercise all branches of this if - if z <= GAMMA2 || z > q - GAMMA2 || (z == q - GAMMA2 && r == 0) { 0 } else { 1 } + if z <= P::GAMMA2 || z > q - P::GAMMA2 || (z == q - P::GAMMA2 && r == 0) { 0 } else { 1 } } /// Creates the hint vector from two Vector's, and also returns its hamming weight (ie the number of 1's). -pub(crate) fn make_hint_vecs( - r: &Vector, - s: &Vector, -) -> (Vector, i32) { - let mut out = Vector::::new(); +pub(crate) fn make_hint_vecs(r: &P::VecK, s: &P::VecK) -> (P::VecK, i32) { + let mut out = P::VecK::new(); let mut count = 0i32; - for i in 0..k { - let (w, c) = r.elems[i].make_hint::(&s.elems[i]); - out.elems[i] = w; + for i in 0..P::k { + let (w, c) = r.elems()[i].make_hint::

(&s.elems()[i]); + out.elems_mut()[i] = w; // mutants note: this chains up to hint_hamming_weight > OMEGA and there is no test KAT that triggers this branch count += c; @@ -895,8 +876,8 @@ pub(crate) fn make_hint_vecs( /// Returns the high bits of π‘Ÿ adjusted according to hint β„Ž. /// Input: Boolean β„Ž, π‘Ÿ ∈ β„€π‘ž. /// Output: π‘Ÿ1 ∈ β„€ with 0 ≀ π‘Ÿ1 ≀ (π‘žβˆ’1) / 2*gamma2). -pub(super) fn use_hint(a: i32, hint: i32) -> i32 { - let (a0, a1) = decompose::(a); +pub(super) fn use_hint(a: i32, hint: i32) -> i32 { + let (a0, a1) = decompose::

(a); if hint == 0 { return a0; @@ -904,8 +885,8 @@ pub(super) fn use_hint(a: i32, hint: i32) -> i32 { debug_assert!(hint == 1); - match GAMMA2 { - MLDSA44_GAMMA2 => { + match P::GAMMA2 { + GAMMA2_Q_MINUS_1_OVER_88 => { // mutants note: this passes unit tests if it's a1 >= 0 // it is left like this because it matches the spec if a1 > 0 { @@ -915,7 +896,7 @@ pub(super) fn use_hint(a: i32, hint: i32) -> i32 { } } // ML-DSA65 and 87 have the same GAMMA2 - MLDSA65_GAMMA2 => { + GAMMA2_Q_MINUS_1_OVER_32 => { // mutants note: this passes unit tests if it's a1 >= 0 // it is left like this because it matches the spec if a1 > 0 { (a0 + 1) & 15 } else { (a0 - 1) & 15 } @@ -926,23 +907,22 @@ pub(super) fn use_hint(a: i32, hint: i32) -> i32 { } } -pub(crate) fn use_hint_polys( +pub(crate) fn use_hint_polys( wp_approx: &Polynomial, h: &Polynomial, out: &mut Polynomial, ) { for i in 0..N { - out[i] = use_hint::(wp_approx[i], h[i]); + out[i] = use_hint::

(wp_approx[i], h[i]); } } -pub(crate) fn use_hint_vecs( - h: &Vector, - wp_approx: &Vector, -) -> Vector { - let mut out = Vector::::new(); - for i in 0..k { - use_hint_polys::(&wp_approx.elems[i], &h.elems[i], &mut out.elems[i]); +pub(crate) fn use_hint_vecs(h: &P::VecK, wp_approx: &P::VecK) -> P::VecK { + let mut out = P::VecK::new(); + for i in 0..P::k { + let hint = h.elems()[i]; + let approx = wp_approx.elems()[i]; + use_hint_polys::

(&approx, &hint, &mut out.elems_mut()[i]); } out diff --git a/crypto/mldsa/src/hash_mldsa.rs b/crypto/mldsa/src/hash_mldsa.rs index 5e1b535a..a4018adb 100644 --- a/crypto/mldsa/src/hash_mldsa.rs +++ b/crypto/mldsa/src/hash_mldsa.rs @@ -66,29 +66,15 @@ //! But a simple [`HashMLDSA::keygen`] is provided. use crate::mldsa::{H, MLDSA_MU_LEN, MLDSA_RND_LEN, MLDSATrait}; -use crate::mldsa::{ - MLDSA44_BETA, MLDSA44_C_TILDE, MLDSA44_ETA, MLDSA44_GAMMA1, MLDSA44_GAMMA1_MASK_LEN, - MLDSA44_GAMMA1_MINUS_BETA, MLDSA44_GAMMA2, MLDSA44_GAMMA2_MINUS_BETA, MLDSA44_LAMBDA, - MLDSA44_LAMBDA_over_4, MLDSA44_OMEGA, MLDSA44_PK_LEN, MLDSA44_POLY_W1_PACKED_LEN, - MLDSA44_POLY_Z_PACKED_LEN, MLDSA44_SIG_LEN, MLDSA44_SK_LEN, MLDSA44_TAU, MLDSA44_k, MLDSA44_l, -}; -use crate::mldsa::{ - MLDSA65_BETA, MLDSA65_C_TILDE, MLDSA65_ETA, MLDSA65_GAMMA1, MLDSA65_GAMMA1_MASK_LEN, - MLDSA65_GAMMA1_MINUS_BETA, MLDSA65_GAMMA2, MLDSA65_GAMMA2_MINUS_BETA, MLDSA65_LAMBDA, - MLDSA65_LAMBDA_over_4, MLDSA65_OMEGA, MLDSA65_PK_LEN, MLDSA65_POLY_W1_PACKED_LEN, - MLDSA65_POLY_Z_PACKED_LEN, MLDSA65_SIG_LEN, MLDSA65_SK_LEN, MLDSA65_TAU, MLDSA65_k, MLDSA65_l, -}; -use crate::mldsa::{ - MLDSA87_BETA, MLDSA87_C_TILDE, MLDSA87_ETA, MLDSA87_GAMMA1, MLDSA87_GAMMA1_MASK_LEN, - MLDSA87_GAMMA1_MINUS_BETA, MLDSA87_GAMMA2, MLDSA87_GAMMA2_MINUS_BETA, MLDSA87_LAMBDA, - MLDSA87_LAMBDA_over_4, MLDSA87_OMEGA, MLDSA87_PK_LEN, MLDSA87_POLY_W1_PACKED_LEN, - MLDSA87_POLY_Z_PACKED_LEN, MLDSA87_SIG_LEN, MLDSA87_SK_LEN, MLDSA87_TAU, MLDSA87_k, MLDSA87_l, -}; +use crate::mldsa::{MLDSA44_PK_LEN, MLDSA44_SIG_LEN, MLDSA44_SK_LEN}; +use crate::mldsa::{MLDSA65_PK_LEN, MLDSA65_SIG_LEN, MLDSA65_SK_LEN}; +use crate::mldsa::{MLDSA87_PK_LEN, MLDSA87_SIG_LEN, MLDSA87_SK_LEN}; use crate::mldsa_keys::{MLDSAPrivateKeyInternalTrait, MLDSAPublicKeyInternalTrait}; +use crate::params::{MLDSA44Params, MLDSA65Params, MLDSA87Params, MLDSAParams}; use crate::{ MLDSA, MLDSA44PrivateKey, MLDSA44PublicKey, MLDSA65PrivateKey, MLDSA65PublicKey, MLDSA87PrivateKey, MLDSA87PublicKey, MLDSAPrivateKeyExpanded, MLDSAPrivateKeyTrait, - MLDSAPublicKeyExpanded, MLDSAPublicKeyTrait, Matrix, + MLDSAPublicKeyExpanded, MLDSAPublicKeyTrait, }; use bouncycastle_core::errors::SignatureError; use bouncycastle_core::key_material::KeyMaterial; @@ -124,29 +110,14 @@ pub const HASH_ML_DSA_87_WITH_SHA512_NAME: &str = "HashML-DSA-87_with_SHA512"; /// The HashML-DSA-44_with_SHA512 signature algorithm. #[allow(non_camel_case_types)] pub type HashMLDSA44_with_SHA256 = HashMLDSA< + MLDSA44Params, SHA256, + MLDSA44PublicKey, + MLDSA44PrivateKey, 32, MLDSA44_PK_LEN, MLDSA44_SK_LEN, MLDSA44_SIG_LEN, - MLDSA44PublicKey, - MLDSA44PrivateKey, - MLDSA44_TAU, - MLDSA44_LAMBDA, - MLDSA44_GAMMA1, - MLDSA44_GAMMA2, - MLDSA44_k, - MLDSA44_l, - MLDSA44_ETA, - MLDSA44_BETA, - MLDSA44_OMEGA, - MLDSA44_C_TILDE, - MLDSA44_POLY_Z_PACKED_LEN, - MLDSA44_POLY_W1_PACKED_LEN, - MLDSA44_LAMBDA_over_4, - MLDSA44_GAMMA1_MINUS_BETA, - MLDSA44_GAMMA2_MINUS_BETA, - MLDSA44_GAMMA1_MASK_LEN, >; impl Algorithm for HashMLDSA44_with_SHA256 { @@ -157,29 +128,14 @@ impl Algorithm for HashMLDSA44_with_SHA256 { /// The HashML-DSA-65_with_SHA256 signature algorithm. #[allow(non_camel_case_types)] pub type HashMLDSA65_with_SHA256 = HashMLDSA< + MLDSA65Params, SHA256, + MLDSA65PublicKey, + MLDSA65PrivateKey, 32, MLDSA65_PK_LEN, MLDSA65_SK_LEN, MLDSA65_SIG_LEN, - MLDSA65PublicKey, - MLDSA65PrivateKey, - MLDSA65_TAU, - MLDSA65_LAMBDA, - MLDSA65_GAMMA1, - MLDSA65_GAMMA2, - MLDSA65_k, - MLDSA65_l, - MLDSA65_ETA, - MLDSA65_BETA, - MLDSA65_OMEGA, - MLDSA65_C_TILDE, - MLDSA65_POLY_Z_PACKED_LEN, - MLDSA65_POLY_W1_PACKED_LEN, - MLDSA65_LAMBDA_over_4, - MLDSA65_GAMMA1_MINUS_BETA, - MLDSA65_GAMMA2_MINUS_BETA, - MLDSA65_GAMMA1_MASK_LEN, >; impl Algorithm for HashMLDSA65_with_SHA256 { @@ -190,29 +146,14 @@ impl Algorithm for HashMLDSA65_with_SHA256 { /// The HashML-DSA-87_with_SHA256 signature algorithm. #[allow(non_camel_case_types)] pub type HashMLDSA87_with_SHA256 = HashMLDSA< + MLDSA87Params, SHA256, + MLDSA87PublicKey, + MLDSA87PrivateKey, 32, MLDSA87_PK_LEN, MLDSA87_SK_LEN, MLDSA87_SIG_LEN, - MLDSA87PublicKey, - MLDSA87PrivateKey, - MLDSA87_TAU, - MLDSA87_LAMBDA, - MLDSA87_GAMMA1, - MLDSA87_GAMMA2, - MLDSA87_k, - MLDSA87_l, - MLDSA87_ETA, - MLDSA87_BETA, - MLDSA87_OMEGA, - MLDSA87_C_TILDE, - MLDSA87_POLY_Z_PACKED_LEN, - MLDSA87_POLY_W1_PACKED_LEN, - MLDSA87_LAMBDA_over_4, - MLDSA87_GAMMA1_MINUS_BETA, - MLDSA87_GAMMA2_MINUS_BETA, - MLDSA87_GAMMA1_MASK_LEN, >; impl Algorithm for HashMLDSA87_with_SHA256 { @@ -223,29 +164,14 @@ impl Algorithm for HashMLDSA87_with_SHA256 { /// The HashML-DSA-44_with_SHA512 signature algorithm. #[allow(non_camel_case_types)] pub type HashMLDSA44_with_SHA512 = HashMLDSA< + MLDSA44Params, SHA512, + MLDSA44PublicKey, + MLDSA44PrivateKey, 64, MLDSA44_PK_LEN, MLDSA44_SK_LEN, MLDSA44_SIG_LEN, - MLDSA44PublicKey, - MLDSA44PrivateKey, - MLDSA44_TAU, - MLDSA44_LAMBDA, - MLDSA44_GAMMA1, - MLDSA44_GAMMA2, - MLDSA44_k, - MLDSA44_l, - MLDSA44_ETA, - MLDSA44_BETA, - MLDSA44_OMEGA, - MLDSA44_C_TILDE, - MLDSA44_POLY_Z_PACKED_LEN, - MLDSA44_POLY_W1_PACKED_LEN, - MLDSA44_LAMBDA_over_4, - MLDSA44_GAMMA1_MINUS_BETA, - MLDSA44_GAMMA2_MINUS_BETA, - MLDSA44_GAMMA1_MASK_LEN, >; impl Algorithm for HashMLDSA44_with_SHA512 { @@ -262,29 +188,14 @@ impl AlgorithmOID for HashMLDSA44_with_SHA512 { /// The HashML-DSA-65_with_SHA512 signature algorithm. #[allow(non_camel_case_types)] pub type HashMLDSA65_with_SHA512 = HashMLDSA< + MLDSA65Params, SHA512, + MLDSA65PublicKey, + MLDSA65PrivateKey, 64, MLDSA65_PK_LEN, MLDSA65_SK_LEN, MLDSA65_SIG_LEN, - MLDSA65PublicKey, - MLDSA65PrivateKey, - MLDSA65_TAU, - MLDSA65_LAMBDA, - MLDSA65_GAMMA1, - MLDSA65_GAMMA2, - MLDSA65_k, - MLDSA65_l, - MLDSA65_ETA, - MLDSA65_BETA, - MLDSA65_OMEGA, - MLDSA65_C_TILDE, - MLDSA65_POLY_Z_PACKED_LEN, - MLDSA65_POLY_W1_PACKED_LEN, - MLDSA65_LAMBDA_over_4, - MLDSA65_GAMMA1_MINUS_BETA, - MLDSA65_GAMMA2_MINUS_BETA, - MLDSA65_GAMMA1_MASK_LEN, >; impl Algorithm for HashMLDSA65_with_SHA512 { @@ -301,29 +212,14 @@ impl AlgorithmOID for HashMLDSA65_with_SHA512 { /// The HashML-DSA-87_with_SHA512 signature algorithm. #[allow(non_camel_case_types)] pub type HashMLDSA87_with_SHA512 = HashMLDSA< + MLDSA87Params, SHA512, + MLDSA87PublicKey, + MLDSA87PrivateKey, 64, MLDSA87_PK_LEN, MLDSA87_SK_LEN, MLDSA87_SIG_LEN, - MLDSA87PublicKey, - MLDSA87PrivateKey, - MLDSA87_TAU, - MLDSA87_LAMBDA, - MLDSA87_GAMMA1, - MLDSA87_GAMMA2, - MLDSA87_k, - MLDSA87_l, - MLDSA87_ETA, - MLDSA87_BETA, - MLDSA87_OMEGA, - MLDSA87_C_TILDE, - MLDSA87_POLY_Z_PACKED_LEN, - MLDSA87_POLY_W1_PACKED_LEN, - MLDSA87_LAMBDA_over_4, - MLDSA87_GAMMA1_MINUS_BETA, - MLDSA87_GAMMA2_MINUS_BETA, - MLDSA87_GAMMA1_MASK_LEN, >; impl Algorithm for HashMLDSA87_with_SHA512 { @@ -344,32 +240,16 @@ impl AlgorithmOID for HashMLDSA87_with_SHA512 { /// by specifying the hash function to use (in the verifier), and specifying the bytes of the OID to /// to use as its domain separator in constructing the message representative M'. pub struct HashMLDSA< + P: MLDSAParams, HASH: Hash + AlgorithmOID + Default, - const HASH_LEN: usize, + PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, + const PH_LEN: usize, const PK_LEN: usize, const SK_LEN: usize, const SIG_LEN: usize, - PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, - SK: MLDSAPrivateKeyTrait - + MLDSAPrivateKeyInternalTrait, - const TAU: i32, - const LAMBDA: i32, - const GAMMA1: i32, - const GAMMA2: i32, - const k: usize, - const l: usize, - const ETA: usize, - const BETA: i32, - const OMEGA: i32, - const C_TILDE: usize, - const POLY_Z_PACKED_LEN: usize, - const POLY_W1_PACKED_LEN: usize, - const LAMBDA_over_4: usize, - const GAMMA1_MINUS_BETA: i32, - const GAMMA2_MINUS_BETA: i32, - const GAMMA1_MASK_LEN: usize, > { - _phantom: PhantomData<(PK, SK)>, + _phantom: PhantomData<(P, PK, SK)>, signer_rnd: Option<[u8; MLDSA_RND_LEN]>, @@ -392,56 +272,15 @@ pub struct HashMLDSA< } impl< + P: MLDSAParams, HASH: Hash + AlgorithmOID + Default, + PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, const PH_LEN: usize, const PK_LEN: usize, const SK_LEN: usize, const SIG_LEN: usize, - PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, - SK: MLDSAPrivateKeyTrait - + MLDSAPrivateKeyInternalTrait, - const TAU: i32, - const LAMBDA: i32, - const GAMMA1: i32, - const GAMMA2: i32, - const k: usize, - const l: usize, - const ETA: usize, - const BETA: i32, - const OMEGA: i32, - const C_TILDE: usize, - const POLY_Z_PACKED_LEN: usize, - const POLY_W1_PACKED_LEN: usize, - const LAMBDA_over_4: usize, - const GAMMA1_MASK_LEN: usize, - const GAMMA1_MINUS_BETA: i32, - const GAMMA2_MINUS_BETA: i32, -> - HashMLDSA< - HASH, - PH_LEN, - PK_LEN, - SK_LEN, - SIG_LEN, - PK, - SK, - TAU, - LAMBDA, - GAMMA1, - GAMMA2, - k, - l, - ETA, - BETA, - OMEGA, - C_TILDE, - POLY_Z_PACKED_LEN, - POLY_W1_PACKED_LEN, - LAMBDA_over_4, - GAMMA1_MINUS_BETA, - GAMMA2_MINUS_BETA, - GAMMA1_MASK_LEN, - > +> HashMLDSA { /// Generate a keypair, sourcing randomness from bouncycastle's default os-backed RNG. /// @@ -450,60 +289,16 @@ impl< /// Keygen, and keys in general, are interchangeable between MLDSA and HashMLDSA. /// Error condition: basically only on RNG failures. pub fn keygen() -> Result<(PK, SK), SignatureError> { - MLDSA::< - PK_LEN, - SK_LEN, - SIG_LEN, - PK, - SK, - TAU, - LAMBDA, - GAMMA1, - GAMMA2, - k, - l, - ETA, - BETA, - OMEGA, - C_TILDE, - POLY_Z_PACKED_LEN, - POLY_W1_PACKED_LEN, - LAMBDA_over_4, - GAMMA1_MINUS_BETA, - GAMMA2_MINUS_BETA, - GAMMA1_MASK_LEN, - >::keygen() + MLDSA::::keygen() } /// Imports a secret key from a seed. pub fn keygen_from_seed(seed: &KeyMaterial<32>) -> Result<(PK, SK), SignatureError> { - MLDSA::< - PK_LEN, - SK_LEN, - SIG_LEN, - PK, - SK, - TAU, - LAMBDA, - GAMMA1, - GAMMA2, - k, - l, - ETA, - BETA, - OMEGA, - C_TILDE, - POLY_Z_PACKED_LEN, - POLY_W1_PACKED_LEN, - LAMBDA_over_4, - GAMMA1_MINUS_BETA, - GAMMA2_MINUS_BETA, - GAMMA1_MASK_LEN, - >::keygen_internal(seed) + MLDSA::::keygen_internal(seed) } /// Same as [`Signer::sign`], but signs from an [`MLDSAPrivateKeyExpanded`]. pub fn sign_with_expanded_key( - sk: &MLDSAPrivateKeyExpanded, + sk: &MLDSAPrivateKeyExpanded, msg: &[u8], ctx: Option<&[u8]>, ) -> Result<[u8; SIG_LEN], SignatureError> { @@ -514,7 +309,7 @@ impl< } /// Same as [`Signer::sign_out`], but signs from an [`MLDSAPrivateKeyExpanded`]. pub fn sign_with_expanded_key_out( - sk: &MLDSAPrivateKeyExpanded, + sk: &MLDSAPrivateKeyExpanded, msg: &[u8], ctx: Option<&[u8]>, output: &mut [u8; SIG_LEN], @@ -527,7 +322,7 @@ impl< } /// Same as [`PHSigner::sign_ph`], but signs from an [`MLDSAPrivateKeyExpanded`]. pub fn sign_ph_with_expanded_key( - sk: &MLDSAPrivateKeyExpanded, + sk: &MLDSAPrivateKeyExpanded, ph: &[u8; PH_LEN], ctx: Option<&[u8]>, ) -> Result<[u8; SIG_LEN], SignatureError> { @@ -538,7 +333,7 @@ impl< } /// Same as [`PHSigner::sign_ph_out`], but signs from an [`MLDSAPrivateKeyExpanded`]. pub fn sign_ph_with_expanded_key_out( - sk: &MLDSAPrivateKeyExpanded, + sk: &MLDSAPrivateKeyExpanded, ph: &[u8; PH_LEN], ctx: Option<&[u8]>, output: &mut [u8; SIG_LEN], @@ -564,7 +359,7 @@ impl< /// prevent accidental nonce reuse, this function moves `rnd`. pub fn sign_ph_deterministic( sk: &SK, - A_hat: Option<&Matrix>, + A_hat: Option<&P::MatrixA>, ctx: Option<&[u8]>, ph: &[u8; PH_LEN], rnd: [u8; 32], @@ -587,7 +382,7 @@ impl< /// Returns the number of bytes written to the output buffer. Can be called with an oversized buffer. pub fn sign_ph_deterministic_out( sk: &SK, - A_hat: Option<&Matrix>, + A_hat: Option<&P::MatrixA>, ctx: Option<&[u8]>, ph: &[u8; PH_LEN], rnd: [u8; 32], @@ -625,29 +420,9 @@ impl< }; // 24: 𝜎 ← ML-DSA.Sign_internal(π‘ π‘˜, 𝑀', π‘Ÿπ‘›π‘‘) - let bytes_written = MLDSA::< - PK_LEN, - SK_LEN, - SIG_LEN, - PK, - SK, - TAU, - LAMBDA, - GAMMA1, - GAMMA2, - k, - l, - ETA, - BETA, - OMEGA, - C_TILDE, - POLY_Z_PACKED_LEN, - POLY_W1_PACKED_LEN, - LAMBDA_over_4, - GAMMA1_MINUS_BETA, - GAMMA2_MINUS_BETA, - GAMMA1_MASK_LEN, - >::sign_mu_deterministic_out(sk, A_hat, &mu, rnd, output)?; + let bytes_written = MLDSA::::sign_mu_deterministic_out( + sk, A_hat, &mu, rnd, output, + )?; Ok(bytes_written) } @@ -695,7 +470,7 @@ impl< } /// Same as [`SignatureVerifier::verify`], but verifies from an [`MLDSAPublicKeyExpanded`]. pub fn verify_with_expanded_key( - pk: &MLDSAPublicKeyExpanded, + pk: &MLDSAPublicKeyExpanded, msg: &[u8], ctx: Option<&[u8]>, sig: &[u8], @@ -708,7 +483,7 @@ impl< fn verify_ph_internal( pk: &PK, - A_hat: Option<&Matrix>, + A_hat: Option<&P::MatrixA>, ph: &[u8; PH_LEN], ctx: Option<&[u8]>, sig: &[u8], @@ -747,107 +522,32 @@ impl< }; match A_hat { - Some(A_hat) => MLDSA::< - PK_LEN, - SK_LEN, - SIG_LEN, - PK, - SK, - TAU, - LAMBDA, - GAMMA1, - GAMMA2, - k, - l, - ETA, - BETA, - OMEGA, - C_TILDE, - POLY_Z_PACKED_LEN, - POLY_W1_PACKED_LEN, - LAMBDA_over_4, - GAMMA1_MINUS_BETA, - GAMMA2_MINUS_BETA, - GAMMA1_MASK_LEN, - >::verify_mu(pk, Some(A_hat), &mu, sig_sized), - None => MLDSA::< - PK_LEN, - SK_LEN, - SIG_LEN, - PK, - SK, - TAU, - LAMBDA, - GAMMA1, - GAMMA2, - k, - l, - ETA, - BETA, - OMEGA, - C_TILDE, - POLY_Z_PACKED_LEN, - POLY_W1_PACKED_LEN, - LAMBDA_over_4, - GAMMA1_MINUS_BETA, - GAMMA2_MINUS_BETA, - GAMMA1_MASK_LEN, - >::verify_mu(pk, Some(&pk.A_hat()), &mu, sig_sized), + Some(A_hat) => MLDSA::::verify_mu( + pk, + Some(A_hat), + &mu, + sig_sized, + ), + None => MLDSA::::verify_mu( + pk, + Some(&pk.A_hat()), + &mu, + sig_sized, + ), } } } impl< + P: MLDSAParams, HASH: Hash + AlgorithmOID + Default, - PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, - SK: MLDSAPrivateKeyTrait - + MLDSAPrivateKeyInternalTrait, + PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, const PH_LEN: usize, const PK_LEN: usize, const SK_LEN: usize, const SIG_LEN: usize, - const TAU: i32, - const LAMBDA: i32, - const GAMMA1: i32, - const GAMMA2: i32, - const k: usize, - const l: usize, - const ETA: usize, - const BETA: i32, - const OMEGA: i32, - const C_TILDE: usize, - const POLY_Z_PACKED_LEN: usize, - const POLY_W1_PACKED_LEN: usize, - const LAMBDA_over_4: usize, - const GAMMA1_MINUS_BETA: i32, - const GAMMA2_MINUS_BETA: i32, - const GAMMA1_MASK_LEN: usize, -> Signer - for HashMLDSA< - HASH, - PH_LEN, - PK_LEN, - SK_LEN, - SIG_LEN, - PK, - SK, - TAU, - LAMBDA, - GAMMA1, - GAMMA2, - k, - l, - ETA, - BETA, - OMEGA, - C_TILDE, - POLY_Z_PACKED_LEN, - POLY_W1_PACKED_LEN, - LAMBDA_over_4, - GAMMA1_MINUS_BETA, - GAMMA2_MINUS_BETA, - GAMMA1_MASK_LEN, - > +> Signer for HashMLDSA { /// Algorithm 4 HashML-DSA.Sign(π‘ π‘˜, 𝑀 , 𝑐𝑑π‘₯, PH) /// Generate a β€œpre-hash” ML-DSA signature. @@ -946,56 +646,16 @@ impl< } impl< + P: MLDSAParams, HASH: Hash + AlgorithmOID + Default, - PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, - SK: MLDSAPrivateKeyTrait - + MLDSAPrivateKeyInternalTrait, + PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, const PH_LEN: usize, const PK_LEN: usize, const SK_LEN: usize, const SIG_LEN: usize, - const TAU: i32, - const LAMBDA: i32, - const GAMMA1: i32, - const GAMMA2: i32, - const k: usize, - const l: usize, - const ETA: usize, - const BETA: i32, - const OMEGA: i32, - const C_TILDE: usize, - const POLY_Z_PACKED_LEN: usize, - const POLY_W1_PACKED_LEN: usize, - const LAMBDA_over_4: usize, - const GAMMA1_MINUS_BETA: i32, - const GAMMA2_MINUS_BETA: i32, - const GAMMA1_MASK_LEN: usize, > SignatureVerifier - for HashMLDSA< - HASH, - PH_LEN, - PK_LEN, - SK_LEN, - SIG_LEN, - PK, - SK, - TAU, - LAMBDA, - GAMMA1, - GAMMA2, - k, - l, - ETA, - BETA, - OMEGA, - C_TILDE, - POLY_Z_PACKED_LEN, - POLY_W1_PACKED_LEN, - LAMBDA_over_4, - GAMMA1_MINUS_BETA, - GAMMA2_MINUS_BETA, - GAMMA1_MASK_LEN, - > + for HashMLDSA { fn verify(pk: &PK, msg: &[u8], ctx: Option<&[u8]>, sig: &[u8]) -> Result<(), SignatureError> { let mut ph_m = [0u8; PH_LEN]; @@ -1033,56 +693,16 @@ impl< } impl< + P: MLDSAParams, HASH: Hash + AlgorithmOID + Default, + PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, const PH_LEN: usize, const PK_LEN: usize, const SK_LEN: usize, const SIG_LEN: usize, - PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, - SK: MLDSAPrivateKeyTrait - + MLDSAPrivateKeyInternalTrait, - const TAU: i32, - const LAMBDA: i32, - const GAMMA1: i32, - const GAMMA2: i32, - const k: usize, - const l: usize, - const ETA: usize, - const BETA: i32, - const OMEGA: i32, - const C_TILDE: usize, - const POLY_Z_PACKED_LEN: usize, - const POLY_W1_PACKED_LEN: usize, - const LAMBDA_over_4: usize, - const GAMMA1_MASK_LEN: usize, - const GAMMA1_MINUS_BETA: i32, - const GAMMA2_MINUS_BETA: i32, > PHSigner - for HashMLDSA< - HASH, - PH_LEN, - PK_LEN, - SK_LEN, - SIG_LEN, - PK, - SK, - TAU, - LAMBDA, - GAMMA1, - GAMMA2, - k, - l, - ETA, - BETA, - OMEGA, - C_TILDE, - POLY_Z_PACKED_LEN, - POLY_W1_PACKED_LEN, - LAMBDA_over_4, - GAMMA1_MINUS_BETA, - GAMMA2_MINUS_BETA, - GAMMA1_MASK_LEN, - > + for HashMLDSA { fn sign_ph( sk: &SK, @@ -1114,56 +734,16 @@ impl< } impl< + P: MLDSAParams, HASH: Hash + AlgorithmOID + Default, + PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, const PH_LEN: usize, const PK_LEN: usize, const SK_LEN: usize, const SIG_LEN: usize, - PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, - SK: MLDSAPrivateKeyTrait - + MLDSAPrivateKeyInternalTrait, - const TAU: i32, - const LAMBDA: i32, - const GAMMA1: i32, - const GAMMA2: i32, - const k: usize, - const l: usize, - const ETA: usize, - const BETA: i32, - const OMEGA: i32, - const C_TILDE: usize, - const POLY_Z_PACKED_LEN: usize, - const POLY_W1_PACKED_LEN: usize, - const LAMBDA_over_4: usize, - const GAMMA1_MASK_LEN: usize, - const GAMMA1_MINUS_BETA: i32, - const GAMMA2_MINUS_BETA: i32, > PHSignatureVerifier - for HashMLDSA< - HASH, - PH_LEN, - PK_LEN, - SK_LEN, - SIG_LEN, - PK, - SK, - TAU, - LAMBDA, - GAMMA1, - GAMMA2, - k, - l, - ETA, - BETA, - OMEGA, - C_TILDE, - POLY_Z_PACKED_LEN, - POLY_W1_PACKED_LEN, - LAMBDA_over_4, - GAMMA1_MINUS_BETA, - GAMMA2_MINUS_BETA, - GAMMA1_MASK_LEN, - > + for HashMLDSA { fn verify_ph( pk: &PK, diff --git a/crypto/mldsa/src/lib.rs b/crypto/mldsa/src/lib.rs index 2bea9873..20711aa6 100644 --- a/crypto/mldsa/src/lib.rs +++ b/crypto/mldsa/src/lib.rs @@ -148,6 +148,7 @@ pub mod hash_mldsa; mod matrix; pub mod mldsa; mod mldsa_keys; +mod params; mod polynomial; /*** Exported types ***/ @@ -187,4 +188,4 @@ pub use mldsa::{MLDSA87_PK_LEN, MLDSA87_SIG_LEN, MLDSA87_SK_LEN}; pub use mldsa::SUSPENDED_MU_BUILDER_STATE_LEN; -pub use matrix::Matrix; +pub use params::{MLDSA44Params, MLDSA65Params, MLDSA87Params}; diff --git a/crypto/mldsa/src/matrix.rs b/crypto/mldsa/src/matrix.rs index 916fe47d..e08bb62f 100644 --- a/crypto/mldsa/src/matrix.rs +++ b/crypto/mldsa/src/matrix.rs @@ -3,11 +3,32 @@ use crate::aux_functions::multiply_ntt; use crate::mldsa::H; +use crate::params::MLDSAParams; use crate::polynomial::Polynomial; use bouncycastle_core::traits::XOF; use bouncycastle_utils::secret::ZeroizablePrimitive; use core::ops::{Index, IndexMut}; +/// The operations this crate performs on the public matrix 𝐀̂. +/// +/// [`Matrix`] is the only implementation; see the module docs for why the trait exists. +pub(crate) trait MatrixTrait: Sized + Clone { + /// The vector this matrix can be applied to: an element of 𝑅^β„“. + type VecL: VectorTrait; + /// The vector applying this matrix produces: an element of 𝑅^π‘˜. + type VecK: VectorTrait; + + /// A matrix with every coefficient set to zero. + fn new() -> Self; + + /// Overwrites the polynomial at `elems[row][col]`. + fn set_elem(&mut self, row: usize, col: usize, p: Polynomial); + + /// Algorithm 48 MatrixVectorNTT(𝐌, 𝐯) + /// Computes the product 𝐌 βˆ˜Μ‚ 𝐯_hat of a matrix 𝐌_hat and a vector 𝐯_hat over π‘‡π‘ž. + fn matrix_vector_ntt(&self, v: &Self::VecL) -> Self::VecK; +} + /// A matrix over the ML-DSA ring. #[derive(Clone)] pub struct Matrix { @@ -47,8 +68,88 @@ impl Matrix { } } +impl MatrixTrait for Matrix { + type VecL = Vector; + type VecK = Vector; + + fn new() -> Self { + Matrix::new() + } + + fn set_elem(&mut self, row: usize, col: usize, p: Polynomial) { + self.elems[row][col] = p; + } + + fn matrix_vector_ntt(&self, v: &Vector) -> Vector { + Matrix::matrix_vector_ntt(self, v) + } +} + +/// The operations this crate performs on a vector of polynomials, i.e. on an element of 𝑅^LEN. +/// +/// [`Vector`] is the only implementation; the trait exists so that code generic over a parameter +/// set can operate on [`MLDSAParams::VecK`] and [`MLDSAParams::VecL`] without knowing their length. +pub trait VectorTrait: + Sized + Copy + ZeroizablePrimitive + Index + IndexMut +{ + /// The number of polynomial coordinates, i.e. π‘˜ or β„“. + const LEN: usize; + + /// A vector with every coefficient set to zero. + fn new() -> Self; + + /// The coordinates, for iteration and chunking. + fn elems(&self) -> &[Polynomial]; + /// The coordinates, for iteration and chunking. + fn elems_mut(&mut self) -> &mut [Polynomial]; + + /// Algorithm 46 AddVectorNTT(𝐯, 𝐰)Μ‚ + /// Computes the sum 𝐯_hat + 𝐰_hat of two vectors 𝐯_hat, 𝐰_hat over π‘‡π‘ž. + fn add_vector_ntt(&mut self, s: &Self); + + /// Subtracts another vector from this one, coordinatewise. + fn sub_vector(&self, s: &Self) -> Self; + + /// Algorithm 47 ScalarVectorNTT(𝑐,Μ‚ 𝐯)Μ‚ + /// Computes the product 𝑐_hat * 𝐯_hat of a scalar 𝑐_hat and a vector 𝐯_hat over π‘‡π‘ž. + fn scalar_vector_ntt(&self, w: &Polynomial) -> Self; + + /// Adds π‘ž to every negative coefficient. + fn conditional_add_q(&mut self); + + /// Montgomery-reduces every coefficient. + fn reduce(&mut self); + + /// Applies Algorithm 41 NTT(𝑀) to every coordinate. + fn ntt(&mut self); + + /// Applies Algorithm 42 NTTβˆ’1(𝑀_hat) to every coordinate. + fn inv_ntt(&mut self); + + /// Applies Algorithm 37 HighBits(π‘Ÿ) coefficientwise. + fn high_bits(&self) -> Self; + + /// Applies Algorithm 38 LowBits(π‘Ÿ) coefficientwise. + fn low_bits(&self) -> Self; + + /// Multiplies every coefficient by 2^𝑑. + fn shift_left_d(&self) -> Self; + + /// Tests whether any coefficient of any coordinate has absolute value at least `bound`. + /// See `Polynomial::check_norm` for why `bound` is not a const generic. + fn check_norm(&self, bound: i32) -> bool; + + /// Algorithm 28 w1Encode(𝐰1), fed straight into `h` rather than into a buffer. + fn w1_encode_and_hash(&self, h: &mut H); +} + +/// A vector of `LEN` polynomials, i.e. an element of 𝑅^LEN. +/// +/// Public only because it is the value of [`MLDSAParams::VecK`] and [`MLDSAParams::VecL`]; its +/// fields and operations are crate-private, so from outside it is an opaque handle. Reach it +/// through [`VectorTrait`]. #[derive(Clone, Copy)] -pub(crate) struct Vector { +pub struct Vector { pub(crate) elems: [Polynomial; LEN], } @@ -75,21 +176,37 @@ impl Vector { pub(crate) const fn new() -> Self { Self { elems: [Polynomial::new(); LEN] } } +} + +impl VectorTrait for Vector { + const LEN: usize = LEN; + + fn new() -> Self { + Vector::new() + } + + fn elems(&self) -> &[Polynomial] { + &self.elems + } + + fn elems_mut(&mut self) -> &mut [Polynomial] { + &mut self.elems + } /// Algorithm 46 AddVectorNTT(𝐯, 𝐰)Μ‚ /// Computes the sum 𝐯_hat + 𝐰_hat of two vectors 𝐯_hat, 𝐰_hat over π‘‡π‘ž. /// Input: β„“ ∈ β„•, v_hat ∈ T^β„“, w_hat ∈ 𝑇^β„“ /// Output: u_hat ∈ T^β„“_π‘ž. /// Add another vector to this vector - pub(crate) fn add_vector_ntt(&mut self, s: &Self) { + fn add_vector_ntt(&mut self, s: &Self) { for i in 0..LEN { // perform montgomery addition of each polynomial in the vector self[i].add_ntt(&s[i]); } } - pub(crate) fn sub_vector(&self, s: &Self) -> Self { - let mut out = self.clone(); + fn sub_vector(&self, s: &Self) -> Self { + let mut out = *self; for i in 0..LEN { out[i].sub(&s[i]); } @@ -100,8 +217,8 @@ impl Vector { /// Computes the product 𝑐_hat * 𝐯_hat of a scalar 𝑐_hat and a vector 𝐯_hat over π‘‡π‘ž. /// Input: 𝑐_hat ∈ π‘‡π‘ž, β„“ ∈ β„•, 𝐯_hat ∈ 𝑇^β„“ /// Output: π‘ž . - pub(crate) fn scalar_vector_ntt(&self, w: &Polynomial) -> Self { - let mut s_hat = Self::new(); + fn scalar_vector_ntt(&self, w: &Polynomial) -> Self { + let mut s_hat = Vector::::new(); for i in 0..LEN { s_hat[i] = multiply_ntt(&self[i], &w); } @@ -109,63 +226,63 @@ impl Vector { s_hat } - pub(crate) fn conditional_add_q(&mut self) { + fn conditional_add_q(&mut self) { for i in 0..LEN { self[i].conditional_add_q(); } } - pub(crate) fn reduce(&mut self) { + fn reduce(&mut self) { for i in 0..LEN { self[i].reduce(); } } - pub(crate) fn ntt(&mut self) { + fn ntt(&mut self) { for i in 0..LEN { self[i].ntt(); } } - pub(crate) fn inv_ntt(&mut self) { + fn inv_ntt(&mut self) { for i in 0..LEN { self[i].inv_ntt(); } } - pub(crate) fn high_bits(&self) -> Self { - let mut s = Self::new(); + fn high_bits(&self) -> Self { + let mut s = Vector::::new(); for i in 0..LEN { - s[i] = self[i].high_bits::(); + s[i] = self[i].high_bits::

(); } s } - pub(crate) fn low_bits(&self) -> Self { - let mut s = Self::new(); + fn low_bits(&self) -> Self { + let mut s = Vector::::new(); for i in 0..LEN { - s[i] = self[i].low_bits::(); + s[i] = self[i].low_bits::

(); } s } - pub(crate) fn shift_left(&self) -> Self { - let mut out = self.clone(); + fn shift_left_d(&self) -> Self { + let mut out = *self; for i in 0..LEN { - out[i].shift_left::(); + out[i].shift_left_d(); } out } - pub(crate) fn check_norm(&self) -> bool { + fn check_norm(&self, bound: i32) -> bool { // Fine that this is not constant-time because it is used in a rejection loop -- the early quit leads to rejection. for x in self.elems.iter() { - if x.check_norm::() { + if x.check_norm(bound) { return true; } } @@ -177,7 +294,7 @@ impl Vector { /// Input: 𝐰1 ∈ π‘…π‘˜ whose polynomial coordinates have coefficients in \[0, (π‘ž βˆ’ 1)/(2𝛾2) βˆ’ 1]. /// Output: A byte string representation 𝐰1_tilde ∈ 𝔹32π‘˜β‹…bitlen ((π‘žβˆ’1)/(2𝛾2)βˆ’1) /// Optimized from FIPS 204 to feed into the hash one row at a time to reduce overall memory footprint. - pub(crate) fn w1_encode_and_hash(&self, h: &mut H) { + fn w1_encode_and_hash(&self, h: &mut H) { // 1: 𝐰̃1 ← () // Nothing needs to be allocated since it is being fed into the hash row-wise @@ -185,8 +302,7 @@ impl Vector { // 3: 𝐰̃1 ← 𝐰̃1 || SimpleBitPack (𝐰1[𝑖], (π‘ž βˆ’ 1)/(2𝛾2) βˆ’ 1) // 4: end for for w in self.elems.iter() { - h.absorb(&w.w1_encode::()) - .expect("absorb before squeeze is infallible"); + h.absorb(w.w1_encode::

().as_ref()).expect("absorb before squeeze is infallible"); } } } diff --git a/crypto/mldsa/src/mldsa.rs b/crypto/mldsa/src/mldsa.rs index 842841a4..2e58854f 100644 --- a/crypto/mldsa/src/mldsa.rs +++ b/crypto/mldsa/src/mldsa.rs @@ -479,9 +479,10 @@ use crate::aux_functions::{ expand_mask, expandA, expandS, make_hint_vecs, power_2_round_vec, sample_in_ball, sig_decode, sig_encode, use_hint_vecs, }; -use crate::matrix::{Matrix, Vector}; +use crate::matrix::{MatrixTrait, VectorTrait}; use crate::mldsa_keys::{MLDSAPrivateKeyInternalTrait, MLDSAPrivateKeyTrait}; use crate::mldsa_keys::{MLDSAPublicKeyInternalTrait, MLDSAPublicKeyTrait}; +use crate::params::{MLDSA44Params, MLDSA65Params, MLDSA87Params, MLDSAParams}; use crate::{ MLDSA44PrivateKey, MLDSA44PublicKey, MLDSA65PrivateKey, MLDSA65PublicKey, MLDSA87PrivateKey, MLDSA87PublicKey, MLDSAPrivateKeyExpanded, MLDSAPublicKeyExpanded, @@ -493,7 +494,7 @@ use bouncycastle_core::traits::{ }; use bouncycastle_rng::HashDRBG_SHA512; use bouncycastle_sha3::{SHAKE128, SHAKE256, SUSPENDED_SHA3_STATE_LEN}; -use bouncycastle_utils::secret::Secret; +use bouncycastle_utils::secret::{Secret, ZeroizablePrimitive}; use core::marker::PhantomData; // imports needed just for docs @@ -529,95 +530,28 @@ pub const MLDSA_MU_LEN: usize = 64; pub(crate) const POLY_T0PACKED_LEN: usize = 416; pub(crate) const POLY_T1PACKED_LEN: usize = 320; -/* ML-DSA-44 params */ +/*** Re-exporting length constants that a caller will need instead of the entire Params objects which contains a bunch of internal algorithm detail ***/ /// Length of the \[u8] holding a ML-DSA-44 public key. -pub const MLDSA44_PK_LEN: usize = 1312; +pub const MLDSA44_PK_LEN: usize = MLDSA44Params::PK_LEN; /// Length of the \[u8] holding a ML-DSA-44 private key. -pub const MLDSA44_SK_LEN: usize = 2560; +pub const MLDSA44_SK_LEN: usize = MLDSA44Params::SK_LEN; /// Length of the \[u8] holding a ML-DSA-44 signature value. -pub const MLDSA44_SIG_LEN: usize = 2420; -pub(crate) const MLDSA44_TAU: i32 = 39; -pub(crate) const MLDSA44_LAMBDA: i32 = 128; -pub(crate) const MLDSA44_GAMMA1: i32 = 1 << 17; -pub(crate) const MLDSA44_GAMMA2: i32 = (q - 1) / 88; // mutants note: because of the bitshifting, the "- 1" ends up not mattering -pub(crate) const MLDSA44_k: usize = 4; -pub(crate) const MLDSA44_l: usize = 4; -pub(crate) const MLDSA44_ETA: usize = 2; -pub(crate) const MLDSA44_BETA: i32 = 78; -pub(crate) const MLDSA44_OMEGA: i32 = 80; - -// Useful derived values -pub(crate) const MLDSA44_C_TILDE: usize = 32; -pub(crate) const MLDSA44_POLY_Z_PACKED_LEN: usize = 576; -pub(crate) const MLDSA44_POLY_W1_PACKED_LEN: usize = 192; -pub(crate) const MLDSA44_LAMBDA_over_4: usize = 128 / 4; -pub(crate) const MLDSA44_GAMMA1_MINUS_BETA: i32 = MLDSA44_GAMMA1 - MLDSA44_BETA; -pub(crate) const MLDSA44_GAMMA2_MINUS_BETA: i32 = MLDSA44_GAMMA2 - MLDSA44_BETA; - -// Alg 32 -// 1: 𝑐 ← 1 + bitlen (𝛾1 βˆ’ 1) -pub(crate) const MLDSA44_GAMMA1_MASK_LEN: usize = 576; // 32*(1 + bitlen (𝛾1 βˆ’ 1) ) - -/* ML-DSA-65 params */ +pub const MLDSA44_SIG_LEN: usize = MLDSA44Params::SIG_LEN; /// Length of the \[u8] holding a ML-DSA-65 public key. -pub const MLDSA65_PK_LEN: usize = 1952; +pub const MLDSA65_PK_LEN: usize = MLDSA65Params::PK_LEN; /// Length of the \[u8] holding a ML-DSA-65 private key. -pub const MLDSA65_SK_LEN: usize = 4032; +pub const MLDSA65_SK_LEN: usize = MLDSA65Params::SK_LEN; /// Length of the \[u8] holding a ML-DSA-65 signature value. -pub const MLDSA65_SIG_LEN: usize = 3309; -pub(crate) const MLDSA65_TAU: i32 = 49; -pub(crate) const MLDSA65_LAMBDA: i32 = 192; -pub(crate) const MLDSA65_GAMMA1: i32 = 1 << 19; -pub(crate) const MLDSA65_GAMMA2: i32 = (q - 1) / 32; // mutants note: because of the bitshifting, the "- 1" ends up not mattering -pub(crate) const MLDSA65_k: usize = 6; -pub(crate) const MLDSA65_l: usize = 5; -pub(crate) const MLDSA65_ETA: usize = 4; -pub(crate) const MLDSA65_BETA: i32 = 196; -pub(crate) const MLDSA65_OMEGA: i32 = 55; - -// Useful derived values -pub(crate) const MLDSA65_C_TILDE: usize = 48; -pub(crate) const MLDSA65_POLY_Z_PACKED_LEN: usize = 640; -pub(crate) const MLDSA65_POLY_W1_PACKED_LEN: usize = 128; -pub(crate) const MLDSA65_LAMBDA_over_4: usize = 192 / 4; -pub(crate) const MLDSA65_GAMMA1_MINUS_BETA: i32 = MLDSA65_GAMMA1 - MLDSA65_BETA; -pub(crate) const MLDSA65_GAMMA2_MINUS_BETA: i32 = MLDSA65_GAMMA2 - MLDSA65_BETA; - -// Alg 32 -// 1: 𝑐 ← 1 + bitlen (𝛾1 βˆ’ 1) -pub(crate) const MLDSA65_GAMMA1_MASK_LEN: usize = 640; - -/* ML-DSA-87 params */ +pub const MLDSA65_SIG_LEN: usize = MLDSA65Params::SIG_LEN; /// Length of the \[u8] holding a ML-DSA-87 public key. -pub const MLDSA87_PK_LEN: usize = 2592; +pub const MLDSA87_PK_LEN: usize = MLDSA87Params::PK_LEN; /// Length of the \[u8] holding a ML-DSA-87 private key. -pub const MLDSA87_SK_LEN: usize = 4896; +pub const MLDSA87_SK_LEN: usize = MLDSA87Params::SK_LEN; /// Length of the \[u8] holding a ML-DSA-87 signature value. -pub const MLDSA87_SIG_LEN: usize = 4627; -pub(crate) const MLDSA87_TAU: i32 = 60; -pub(crate) const MLDSA87_LAMBDA: i32 = 256; -pub(crate) const MLDSA87_GAMMA1: i32 = 1 << 19; -pub(crate) const MLDSA87_GAMMA2: i32 = (q - 1) / 32; // mutants note: because of the bitshifting, the "- 1" ends up not mattering -pub(crate) const MLDSA87_k: usize = 8; -pub(crate) const MLDSA87_l: usize = 7; -pub(crate) const MLDSA87_ETA: usize = 2; -pub(crate) const MLDSA87_BETA: i32 = 120; -pub(crate) const MLDSA87_OMEGA: i32 = 75; - -// Useful derived values -pub(crate) const MLDSA87_C_TILDE: usize = 64; -pub(crate) const MLDSA87_POLY_Z_PACKED_LEN: usize = 640; -pub(crate) const MLDSA87_POLY_W1_PACKED_LEN: usize = 128; -pub(crate) const MLDSA87_LAMBDA_over_4: usize = 256 / 4; -pub(crate) const MLDSA87_GAMMA1_MINUS_BETA: i32 = MLDSA87_GAMMA1 - MLDSA87_BETA; -pub(crate) const MLDSA87_GAMMA2_MINUS_BETA: i32 = MLDSA87_GAMMA2 - MLDSA87_BETA; - -// Alg 32 -// 1: 𝑐 ← 1 + bitlen (𝛾1 βˆ’ 1) -pub(crate) const MLDSA87_GAMMA1_MASK_LEN: usize = 640; +pub const MLDSA87_SIG_LEN: usize = MLDSA87Params::SIG_LEN; // Typedefs just to make the algorithms look more like the FIPS 204 sample code. pub(crate) type H = SHAKE256; @@ -627,110 +561,63 @@ pub(crate) type G = SHAKE128; /// The ML-DSA-44 algorithm. pub type MLDSA44 = MLDSA< + MLDSA44Params, + MLDSA44PublicKey, + MLDSA44PrivateKey, MLDSA44_PK_LEN, MLDSA44_SK_LEN, MLDSA44_SIG_LEN, - MLDSA44PublicKey, - MLDSA44PrivateKey, - MLDSA44_TAU, - MLDSA44_LAMBDA, - MLDSA44_GAMMA1, - MLDSA44_GAMMA2, - MLDSA44_k, - MLDSA44_l, - MLDSA44_ETA, - MLDSA44_BETA, - MLDSA44_OMEGA, - MLDSA44_C_TILDE, - MLDSA44_POLY_Z_PACKED_LEN, - MLDSA44_POLY_W1_PACKED_LEN, - MLDSA44_LAMBDA_over_4, - MLDSA44_GAMMA1_MINUS_BETA, - MLDSA44_GAMMA2_MINUS_BETA, - MLDSA44_GAMMA1_MASK_LEN, >; -impl Algorithm for MLDSA44 { - const ALG_NAME: &'static str = ML_DSA_44_NAME; - const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit; -} -/// Assigned by NIST in the Computer Security Objects Register: id-ml-dsa-44 { sigAlgs 17 } -impl AlgorithmOID for MLDSA44 { - const OID: &'static [u32] = &[2, 16, 840, 1, 101, 3, 4, 3, 17]; - const OID_DER: &'static [u8] = - &[0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x03, 0x11]; -} - /// The ML-DSA-65 algorithm. pub type MLDSA65 = MLDSA< + MLDSA65Params, + MLDSA65PublicKey, + MLDSA65PrivateKey, MLDSA65_PK_LEN, MLDSA65_SK_LEN, MLDSA65_SIG_LEN, - MLDSA65PublicKey, - MLDSA65PrivateKey, - MLDSA65_TAU, - MLDSA65_LAMBDA, - MLDSA65_GAMMA1, - MLDSA65_GAMMA2, - MLDSA65_k, - MLDSA65_l, - MLDSA65_ETA, - MLDSA65_BETA, - MLDSA65_OMEGA, - MLDSA65_C_TILDE, - MLDSA65_POLY_Z_PACKED_LEN, - MLDSA65_POLY_W1_PACKED_LEN, - MLDSA65_LAMBDA_over_4, - MLDSA65_GAMMA1_MINUS_BETA, - MLDSA65_GAMMA2_MINUS_BETA, - MLDSA65_GAMMA1_MASK_LEN, >; -impl Algorithm for MLDSA65 { - const ALG_NAME: &'static str = ML_DSA_65_NAME; - const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_192bit; -} -/// Assigned by NIST in the Computer Security Objects Register: id-ml-dsa-65 { sigAlgs 18 } -impl AlgorithmOID for MLDSA65 { - const OID: &'static [u32] = &[2, 16, 840, 1, 101, 3, 4, 3, 18]; - const OID_DER: &'static [u8] = - &[0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x03, 0x12]; -} - /// The ML-DSA-87 algorithm. pub type MLDSA87 = MLDSA< + MLDSA87Params, + MLDSA87PublicKey, + MLDSA87PrivateKey, MLDSA87_PK_LEN, MLDSA87_SK_LEN, MLDSA87_SIG_LEN, - MLDSA87PublicKey, - MLDSA87PrivateKey, - MLDSA87_TAU, - MLDSA87_LAMBDA, - MLDSA87_GAMMA1, - MLDSA87_GAMMA2, - MLDSA87_k, - MLDSA87_l, - MLDSA87_ETA, - MLDSA87_BETA, - MLDSA87_OMEGA, - MLDSA87_C_TILDE, - MLDSA87_POLY_Z_PACKED_LEN, - MLDSA87_POLY_W1_PACKED_LEN, - MLDSA87_LAMBDA_over_4, - MLDSA87_GAMMA1_MINUS_BETA, - MLDSA87_GAMMA2_MINUS_BETA, - MLDSA87_GAMMA1_MASK_LEN, >; -impl Algorithm for MLDSA87 { - const ALG_NAME: &'static str = ML_DSA_87_NAME; - const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_256bit; +/// The name and claimed strength of an ML-DSA algorithm are properties of its parameter set, so +/// one impl covers all three; `MLDSAParams` is sealed, so those are the only three that exist. +impl< + P: MLDSAParams, + PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, + const PK_LEN: usize, + const SK_LEN: usize, + const SIG_LEN: usize, +> Algorithm for MLDSA +{ + const ALG_NAME: &'static str = P::ALG_NAME; + const MAX_SECURITY_STRENGTH: SecurityStrength = P::MAX_SECURITY_STRENGTH; } -/// Assigned by NIST in the Computer Security Objects Register: id-ml-dsa-87 { sigAlgs 19 } -impl AlgorithmOID for MLDSA87 { - const OID: &'static [u32] = &[2, 16, 840, 1, 101, 3, 4, 3, 19]; - const OID_DER: &'static [u8] = - &[0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x03, 0x13]; + +/// The OIDs NIST assigned in the Computer Security Objects Register: id-ml-dsa-44 { sigAlgs 17 }, +/// id-ml-dsa-65 { sigAlgs 18 } and id-ml-dsa-87 { sigAlgs 19 }. As with [`Algorithm`], the values +/// belong to the parameter set, so one impl covers all three. +impl< + P: MLDSAParams, + PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, + const PK_LEN: usize, + const SK_LEN: usize, + const SIG_LEN: usize, +> AlgorithmOID for MLDSA +{ + const OID: &'static [u32] = P::OID; + const OID_DER: &'static [u8] = P::OID_DER; } /// The core internal implementation of the ML-DSA algorithm. @@ -738,30 +625,14 @@ impl AlgorithmOID for MLDSA87 { /// but it shouldn't ever need to be used directly. /// Please use the named public types [`MLDSA44`], [`MLDSA65`], [`MLDSA87`] instead. pub struct MLDSA< + P: MLDSAParams, + PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, const PK_LEN: usize, const SK_LEN: usize, const SIG_LEN: usize, - PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, - SK: MLDSAPrivateKeyTrait - + MLDSAPrivateKeyInternalTrait, - const TAU: i32, - const LAMBDA: i32, - const GAMMA1: i32, - const GAMMA2: i32, - const k: usize, - const l: usize, - const ETA: usize, - const BETA: i32, - const OMEGA: i32, - const C_TILDE: usize, - const POLY_VEC_H_PACKED_LEN: usize, - const POLY_W1_PACKED_LEN: usize, - const LAMBDA_over_4: usize, - const GAMMA1_MINUS_BETA: i32, - const GAMMA2_MINUS_BETA: i32, - const GAMMA1_MASK_LEN: usize, > { - _phantom: PhantomData<(PK, SK)>, + _phantom: PhantomData<(P, PK, SK)>, /// used for streaming the message for both signing and verifying mu_builder: MuBuilder, @@ -779,52 +650,13 @@ pub struct MLDSA< } impl< + P: MLDSAParams, + PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, const PK_LEN: usize, const SK_LEN: usize, const SIG_LEN: usize, - PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, - SK: MLDSAPrivateKeyTrait - + MLDSAPrivateKeyInternalTrait, - const TAU: i32, - const LAMBDA: i32, - const GAMMA1: i32, - const GAMMA2: i32, - const k: usize, - const l: usize, - const ETA: usize, - const BETA: i32, - const OMEGA: i32, - const C_TILDE: usize, - const POLY_Z_PACKED_LEN: usize, - const POLY_W1_PACKED_LEN: usize, - const LAMBDA_over_4: usize, - const GAMMA1_MINUS_BETA: i32, - const GAMMA2_MINUS_BETA: i32, - const GAMMA1_MASK_LEN: usize, -> - MLDSA< - PK_LEN, - SK_LEN, - SIG_LEN, - PK, - SK, - TAU, - LAMBDA, - GAMMA1, - GAMMA2, - k, - l, - ETA, - BETA, - OMEGA, - C_TILDE, - POLY_Z_PACKED_LEN, - POLY_W1_PACKED_LEN, - LAMBDA_over_4, - GAMMA1_MINUS_BETA, - GAMMA2_MINUS_BETA, - GAMMA1_MASK_LEN, - > +> MLDSA { /// Implements Algorithm 6 of FIPS 204 /// Note: NIST has made a special exception in the FIPS 204 FAQ that this _internal function @@ -844,7 +676,7 @@ impl< )); } - if seed.security_strength() < SecurityStrength::from_bits(LAMBDA as usize) { + if seed.security_strength() < SecurityStrength::from_bits(P::LAMBDA as usize) { return Err(SignatureError::KeyGenError( "Seed SecurityStrength must match algorithm security strength", )); @@ -859,8 +691,8 @@ impl< // scope for h let mut h = H::default(); h.absorb(seed.ref_to_bytes()).expect("absorb before squeeze is infallible"); - h.absorb(&(k as u8).to_le_bytes()).expect("absorb before squeeze is infallible"); - h.absorb(&(l as u8).to_le_bytes()).expect("absorb before squeeze is infallible"); + h.absorb(&(P::k as u8).to_le_bytes()).expect("absorb before squeeze is infallible"); + h.absorb(&(P::l as u8).to_le_bytes()).expect("absorb before squeeze is infallible"); let bytes_written = h.squeeze_out(&mut rho); debug_assert_eq!(bytes_written, 32); let mut rho_prime: [u8; 64] = [0u8; 64]; @@ -870,7 +702,7 @@ impl< debug_assert_eq!(bytes_written, 32); // 4: (𝐬1, 𝐬2) ← ExpandS(πœŒβ€²) - let (mut s1, s2) = expandS::(&rho_prime); + let (mut s1, s2) = expandS::

(&rho_prime); s1.ntt(); (s1, s2) @@ -879,7 +711,7 @@ impl< let t_hat = { // scope for s1_hat // 3: 𝐀_hat ← ExpandA(𝜌) β–· 𝐀 is generated and stored in NTT representation as 𝐀 - let A_hat = expandA::(&rho); + let A_hat = expandA::

(&rho); // 5: 𝐭 ← NTTβˆ’1(𝐀 ∘ NTT(𝐬1)) + 𝐬2 // β–· compute 𝐭 = 𝐀𝐬1 + 𝐬2 @@ -896,7 +728,7 @@ impl< // 6: (𝐭1, 𝐭0) ← Power2Round(𝐭) // β–· compress 𝐭 // β–· PowerTwoRound is applied componentwise (see explanatory text in Section 7.4) - power_2_round_vec::(&t) + power_2_round_vec(&t) }; // 8: π‘π‘˜ ← pkEncode(𝜌, 𝐭1) @@ -928,7 +760,7 @@ impl< /// modified to take an externally-computed mu instead of M', and to take the public matrix A_hat fn sign_internal( sk: &SK, - A_hat: &Matrix, + A_hat: &P::MatrixA, mu: &[u8; 64], rnd: [u8; 32], output: &mut [u8; SIG_LEN], @@ -972,22 +804,22 @@ impl< // β–· rejection sampling loop // these need to be outside the loop because they form the encoded signature value - let mut sig_val_c_tilde = [0u8; LAMBDA_over_4]; - let mut sig_val_z: Vector; - let mut sig_val_h: Vector; + let mut sig_val_c_tilde = ::ZEROED; + let mut sig_val_z: P::VecL; + let mut sig_val_h: P::VecK; loop { // FIPS 204 s. 6.2 allows: // "Implementations may limit the number of iterations in this loop to not exceed a finite maximum value." // mutants note: there is no test for this because, at this point, // we don't know of a KAT that will exceed this limit. - if kappa > 1000 * k as u16 { + if kappa > 1000 * P::k as u16 { return Err(SignatureError::GenericError( "Rejection sampling loop exceeded max iterations, try again with a different signing nonce.", )); } // 11: 𝐲 ∈ 𝑅^β„“ ← ExpandMask(πœŒβ€³, πœ…) - let mut y = expand_mask::(&rho_p_p, kappa); + let mut y = expand_mask::

(&rho_p_p, kappa); let w = { // scope for y_hat @@ -1002,7 +834,7 @@ impl< // 13: 𝐰1 ← HighBits(𝐰) // β–· signer’s commitment - let w1 = w.high_bits::(); + let w1 = w.high_bits::

(); { // scope for h @@ -1010,15 +842,15 @@ impl< // β–· commitment hash let mut hash = H::new(); hash.absorb(mu).expect("absorb before squeeze is infallible"); - w1.w1_encode_and_hash::(&mut hash); - hash.squeeze_out(&mut sig_val_c_tilde); + w1.w1_encode_and_hash::

(&mut hash); + hash.squeeze_out(sig_val_c_tilde.as_mut()); } // 16: 𝑐 ∈ π‘…π‘ž ← SampleInBall(c_tilde) // β–· verifier’s challenge let c_hat = { // scope for c - let mut c = sample_in_ball::(&sig_val_c_tilde); + let mut c = sample_in_ball::

(&sig_val_c_tilde); // 17: 𝑐_hat ← NTT(𝑐) c.ntt(); @@ -1038,8 +870,8 @@ impl< // β–· validity checks // This is done out-of-order on purpose for performance reasons: // rejection sampling check is done before any extra heavy computation - if sig_val_z.check_norm::() { - kappa += l as u16; + if sig_val_z.check_norm(P::GAMMA1_MINUS_BETA) { + kappa += P::l as u16; continue; }; @@ -1048,7 +880,7 @@ impl< cs2.inv_ntt(); // 21: 𝐫0 ← LowBits(𝐰 βˆ’ βŸ¨βŸ¨π‘π¬2⟩⟩) - let mut r0 = w.sub_vector(&cs2).low_bits::(); + let mut r0 = w.sub_vector(&cs2).low_bits::

(); // 23 (second half): if ||𝐳||∞ β‰₯ 𝛾1 βˆ’ 𝛽 or ||𝐫0||∞ β‰₯ 𝛾2 βˆ’ 𝛽 then (z, h) ← βŠ₯ // β–· validity checks @@ -1058,8 +890,8 @@ impl< // and checking whether β€–r0β€–βˆž < Ξ³2 βˆ’ Ξ² and r1 = w1, it is equivalent to just check that // β€–w0 βˆ’ cs2β€–βˆž < Ξ³2 βˆ’ Ξ², where w0 is the low part of w. If this check passes, w0 βˆ’ cs2 // is the low part of w βˆ’ cs2." - if r0.check_norm::() { - kappa += l as u16; + if r0.check_norm(P::GAMMA2_MINUS_BETA) { + kappa += P::l as u16; continue; }; @@ -1071,8 +903,8 @@ impl< // This is done out-of-order on purpose for performance reasons: // rejection sampling check is done before any extra heavy computation // mutants note: there is currently no unit test that triggers this branch - if ct0.check_norm::() { - kappa += l as u16; + if ct0.check_norm(P::GAMMA2) { + kappa += P::l as u16; continue; }; @@ -1083,15 +915,15 @@ impl< let hint_hamming_weight: i32; sig_val_h = { // scope for hint - let (hint, inner_hint_hamming_weight) = make_hint_vecs::(&r0, &w1); + let (hint, inner_hint_hamming_weight) = make_hint_vecs::

(&r0, &w1); hint_hamming_weight = inner_hint_hamming_weight; hint }; // 28 (second half): if ||βŸ¨βŸ¨π‘π­0⟩⟩||∞ β‰₯ 𝛾2 or the number of 1’s in 𝐑 is greater than πœ”, then (z, h) ← βŠ₯ // mutants note: there is no test KAT that triggers this branch - if hint_hamming_weight > OMEGA { - kappa += l as u16; + if hint_hamming_weight > P::OMEGA { + kappa += P::l as u16; continue; }; @@ -1106,9 +938,7 @@ impl< // 33: 𝜎 ← sigEncode(𝑐, 𝐳̃ modΒ±π‘ž, 𝐑) let bytes_written = - sig_encode::( - &sig_val_c_tilde, &sig_val_z, &sig_val_h, output, - ); + sig_encode::(&sig_val_c_tilde, &sig_val_z, &sig_val_h, output); Ok(bytes_written) } @@ -1119,7 +949,7 @@ impl< /// Input: Signature 𝜎 ∈ π”Ήπœ†/4+β„“β‹…32β‹…(1+bitlen (𝛾1βˆ’1))+πœ”+π‘˜. fn verify_internal( pk: &PK, - A_hat: &Matrix, + A_hat: &P::MatrixA, mu: &[u8; 64], sig: &[u8; SIG_LEN], ) -> Result<(), SignatureError> { @@ -1129,12 +959,11 @@ impl< // 2: (𝑐_tilde, 𝐳, 𝐑) ← sigDecode(𝜎) // β–· signer’s commitment hash c_tilde, response 𝐳, and hint 𝐑 // 3: if 𝐑 = βŠ₯ then return false - let (c_tilde, z, h) = - sig_decode::(&sig) - .map_err(|_| SignatureError::SignatureVerificationFailed)?; + let (c_tilde, z, h) = sig_decode::(&sig) + .map_err(|_| SignatureError::SignatureVerificationFailed)?; // 13 (first half) return [[ ||𝐳||∞ < 𝛾1 βˆ’ 𝛽]] - if z.check_norm::() { + if z.check_norm(P::GAMMA1_MINUS_BETA) { return Err(SignatureError::SignatureVerificationFailed); } @@ -1151,7 +980,7 @@ impl< // 8: 𝑐 ∈ π‘…π‘ž ← SampleInBall(c_tilde) let c_hat = { - let mut c = sample_in_ball::(&c_tilde); + let mut c = sample_in_ball::

(&c_tilde); c.ntt(); c @@ -1173,7 +1002,7 @@ impl< }; let ct1 = { // potential optimization -- pre-compute this on key load? - let mut t1_shift_hat = pk.t1().shift_left::(); + let mut t1_shift_hat = pk.t1().shift_left_d(); t1_shift_hat.ntt(); t1_shift_hat.scalar_vector_ntt(&c_hat) }; @@ -1183,23 +1012,23 @@ impl< // 10: 𝐰1β€² ← UseHint(𝐑, 𝐰'_approx) // β–· reconstruction of signer’s commitment - use_hint_vecs::(&h, &wp_approx) + use_hint_vecs::

(&h, &wp_approx) }; // 12: 𝑐_tilde_p ← H(πœ‡||w1Encode(𝐰1'), πœ†/4) // β–· hash it; this should match 𝑐_tilde let c_tilde_p = { - let mut c_tilde_p = [0u8; LAMBDA_over_4]; + let mut c_tilde_p = ::ZEROED; let mut hash = H::new(); hash.absorb(mu).expect("absorb before squeeze is infallible"); - w1p.w1_encode_and_hash::(&mut hash); - hash.squeeze_out(&mut c_tilde_p); + w1p.w1_encode_and_hash::

(&mut hash); + hash.squeeze_out(c_tilde_p.as_mut()); c_tilde_p }; // verification probably doesn't technically need to be constant-time, but why not? // 13 (second half): return [[ ||𝐳||∞ < 𝛾1 βˆ’ 𝛽]] and [[𝑐 Μƒ = 𝑐′ ]] - if bouncycastle_utils::ct::ct_eq_bytes(&c_tilde, &c_tilde_p) { + if bouncycastle_utils::ct::ct_eq_bytes(c_tilde.as_ref(), c_tilde_p.as_ref()) { Ok(()) } else { Err(SignatureError::SignatureVerificationFailed) @@ -1208,52 +1037,13 @@ impl< } impl< + P: MLDSAParams, + PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, const PK_LEN: usize, const SK_LEN: usize, const SIG_LEN: usize, - PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, - SK: MLDSAPrivateKeyTrait - + MLDSAPrivateKeyInternalTrait, - const TAU: i32, - const LAMBDA: i32, - const GAMMA1: i32, - const GAMMA2: i32, - const k: usize, - const l: usize, - const ETA: usize, - const BETA: i32, - const OMEGA: i32, - const C_TILDE: usize, - const POLY_Z_PACKED_LEN: usize, - const POLY_W1_PACKED_LEN: usize, - const LAMBDA_over_4: usize, - const GAMMA1_MINUS_BETA: i32, - const GAMMA2_MINUS_BETA: i32, - const GAMMA1_MASK_LEN: usize, -> MLDSATrait - for MLDSA< - PK_LEN, - SK_LEN, - SIG_LEN, - PK, - SK, - TAU, - LAMBDA, - GAMMA1, - GAMMA2, - k, - l, - ETA, - BETA, - OMEGA, - C_TILDE, - POLY_Z_PACKED_LEN, - POLY_W1_PACKED_LEN, - LAMBDA_over_4, - GAMMA1_MINUS_BETA, - GAMMA2_MINUS_BETA, - GAMMA1_MASK_LEN, - > +> MLDSATrait for MLDSA { fn keygen_from_seed(seed: &KeyMaterial<32>) -> Result<(PK, SK), SignatureError> { Self::keygen_internal(seed) @@ -1290,21 +1080,21 @@ impl< MuBuilder::compute_mu(tr, msg, ctx) } fn compute_mu_from_pk( - pk: &impl MLDSAPublicKeyTrait, + pk: &impl MLDSAPublicKeyTrait, msg: &[u8], ctx: Option<&[u8]>, ) -> Result<[u8; 64], SignatureError> { MuBuilder::compute_mu(&pk.compute_tr(), msg, ctx) } fn compute_mu_from_sk( - sk: &impl MLDSAPrivateKeyTrait, + sk: &impl MLDSAPrivateKeyTrait, msg: &[u8], ctx: Option<&[u8]>, ) -> Result<[u8; 64], SignatureError> { MuBuilder::compute_mu(&sk.tr(), msg, ctx) } fn sign_with_expanded_key( - sk: &MLDSAPrivateKeyExpanded, + sk: &MLDSAPrivateKeyExpanded, msg: &[u8], ctx: Option<&[u8]>, ) -> Result<[u8; SIG_LEN], SignatureError> { @@ -1313,7 +1103,7 @@ impl< } fn sign_with_expanded_key_out( - sk: &MLDSAPrivateKeyExpanded, + sk: &MLDSAPrivateKeyExpanded, msg: &[u8], ctx: Option<&[u8]>, out: &mut [u8; SIG_LEN], @@ -1326,7 +1116,7 @@ impl< fn sign_mu( sk: &SK, - A_hat: Option<&Matrix>, + A_hat: Option<&P::MatrixA>, mu: &[u8; 64], ) -> Result<[u8; SIG_LEN], SignatureError> { let mut out: [u8; SIG_LEN] = [0u8; SIG_LEN]; @@ -1336,7 +1126,7 @@ impl< } fn sign_mu_out( sk: &SK, - A_hat: Option<&Matrix>, + A_hat: Option<&P::MatrixA>, mu: &[u8; 64], output: &mut [u8; SIG_LEN], ) -> Result { @@ -1348,8 +1138,8 @@ impl< Self::sign_mu_deterministic_out(sk, A_hat, mu, rnd, output) } fn sign_mu_with_expanded_key( - sk: &MLDSAPrivateKeyExpanded, - A_hat: Option<&Matrix>, + sk: &MLDSAPrivateKeyExpanded, + A_hat: Option<&P::MatrixA>, mu: &[u8; 64], ) -> Result<[u8; SIG_LEN], SignatureError> { let mut out: [u8; SIG_LEN] = [0u8; SIG_LEN]; @@ -1358,8 +1148,8 @@ impl< Ok(out) } fn sign_mu_with_expanded_key_out( - sk: &MLDSAPrivateKeyExpanded, - A_hat: Option<&Matrix>, + sk: &MLDSAPrivateKeyExpanded, + A_hat: Option<&P::MatrixA>, mu: &[u8; 64], out: &mut [u8; SIG_LEN], ) -> Result { @@ -1370,7 +1160,7 @@ impl< fn sign_mu_deterministic( sk: &SK, - A_hat: Option<&Matrix>, + A_hat: Option<&P::MatrixA>, mu: &[u8; 64], rnd: [u8; 32], ) -> Result<[u8; SIG_LEN], SignatureError> { @@ -1381,7 +1171,7 @@ impl< } fn sign_mu_deterministic_out( sk: &SK, - A_hat: Option<&Matrix>, + A_hat: Option<&P::MatrixA>, mu: &[u8; 64], rnd: [u8; 32], output: &mut [u8; SIG_LEN], @@ -1409,7 +1199,7 @@ impl< /// This is a middle ground between keygen_from_seed()+sign_mu() and /// the fully streamed low-memory implementation. // TODO: benchmark peak memory + runtime against - // keygen_from_seed() + sign_mu_deterministic() to confirm the separate path earns being kept. + // keygen_from_seed() + sign_mu_deterministic() to confirm the separate path earns being kept. // Note: this path intentionally avoids the public key entirely // (no pkEncode / tr = H(pk)) since ΞΌ is supplied externally. fn sign_mu_deterministic_from_seed_out( @@ -1436,7 +1226,7 @@ impl< )); } - if seed.security_strength() < SecurityStrength::from_bits(LAMBDA as usize) { + if seed.security_strength() < SecurityStrength::from_bits(P::LAMBDA as usize) { return Err(SignatureError::KeyGenError( "Seed SecurityStrength must match algorithm security strength: 128-bit (ML-DSA-44), 192-bit (ML-DSA-65), or 256-bit (ML-DSA-87).", )); @@ -1453,8 +1243,8 @@ impl< let (rho, rho_prime, K) = { let mut h = H::default(); h.absorb(seed.ref_to_bytes()).expect("absorb before squeeze is infallible"); - h.absorb(&(k as u8).to_le_bytes()).expect("absorb before squeeze is infallible"); - h.absorb(&(l as u8).to_le_bytes()).expect("absorb before squeeze is infallible"); + h.absorb(&(P::k as u8).to_le_bytes()).expect("absorb before squeeze is infallible"); + h.absorb(&(P::l as u8).to_le_bytes()).expect("absorb before squeeze is infallible"); let mut rho = [0u8; 32]; let bytes_written = h.squeeze_out(&mut rho); debug_assert_eq!(bytes_written, 32); @@ -1481,7 +1271,7 @@ impl< }; // 4: (𝐬1, 𝐬2) ← ExpandS(πœŒβ€²) - let (s1, s2) = expandS::(&rho_prime); + let (s1, s2) = expandS::

(&rho_prime); (rho, rho_p_p, s1, s2) }; @@ -1494,7 +1284,7 @@ impl< // as 20 or even 80 times. So moving expandA() inside the loop would be a pretty drastic speed-for-memory tradeoff // whose generality falls out of the scope of this implementation. // It is left as an optimization that can be made by users that require further reduction of memory usage - let A_hat = expandA::(&rho); + let A_hat = expandA::

(&rho); // Alg 7; 8: πœ… ← 0 // β–· initialize counter πœ… @@ -1507,21 +1297,21 @@ impl< // β–· rejection sampling loop // these need to be outside the loop because they form the encoded signature value - let mut sig_val_c_tilde = [0u8; LAMBDA_over_4]; - let mut sig_val_z: Vector; - let mut sig_val_h: Vector; + let mut sig_val_c_tilde = ::ZEROED; + let mut sig_val_z: P::VecL; + let mut sig_val_h: P::VecK; loop { // FIPS 204 s. 6.2 allows: // "Implementations may limit the number of iterations in this loop to not exceed a finite maximum value." // mutants note: there is no test for this because we don't know of a KAT that will exceed this limit. - if kappa > 1000 * k as u16 { + if kappa > 1000 * P::k as u16 { return Err(SignatureError::GenericError( "Rejection sampling loop exceeded max iterations, try again with a different signing nonce.", )); } // Alg 7; 11: 𝐲 ∈ 𝑅^β„“ ← ExpandMask(πœŒβ€³, πœ…) - let mut y = expand_mask::(&rho_p_p, kappa); + let mut y = expand_mask::

(&rho_p_p, kappa); let w = { // scope for y_hat @@ -1536,7 +1326,7 @@ impl< // Alg 7; 13: 𝐰1 ← HighBits(𝐰) // β–· signer’s commitment - let w1 = w.high_bits::(); + let w1 = w.high_bits::

(); { // scope for h @@ -1544,22 +1334,22 @@ impl< // β–· commitment hash let mut hash = H::new(); hash.absorb(mu).expect("absorb before squeeze is infallible"); - w1.w1_encode_and_hash::(&mut hash); - hash.squeeze_out(&mut sig_val_c_tilde); + w1.w1_encode_and_hash::

(&mut hash); + hash.squeeze_out(sig_val_c_tilde.as_mut()); } // Alg 7; 16: 𝑐 ∈ π‘…π‘ž ← SampleInBall(c_tilde) // β–· verifier’s challenge let c_hat = { // scope for c - let mut c = sample_in_ball::(&sig_val_c_tilde); + let mut c = sample_in_ball::

(&sig_val_c_tilde); // 17: 𝑐_hat ← NTT(𝑐) c.ntt(); c }; - let t_hat: Vector; + let t_hat: P::VecK; sig_val_z = { // scope for s1_hat, cs1 // Alg 7; 2: 𝐬1Μ‚_hat ← NTT(𝐬1) @@ -1591,13 +1381,13 @@ impl< // β–· validity checks // This is done out-of-order on purpose for performance reasons: // rejection sampling check is done before any extra heavy computation - if sig_val_z.check_norm::() { - kappa += l as u16; + if sig_val_z.check_norm(P::GAMMA1_MINUS_BETA) { + kappa += P::l as u16; continue; }; - let t0: Vector; - let mut r0: Vector = { + let t0: P::VecK; + let mut r0: P::VecK = { // scope for s2_hat and cs2 // 3: 𝐬2Μ‚_hat ← NTT(𝐬2) let mut s2_hat = s2.clone(); @@ -1608,7 +1398,7 @@ impl< cs2.inv_ntt(); // 21: 𝐫0 ← LowBits(𝐰 βˆ’ βŸ¨βŸ¨π‘π¬2⟩⟩) - let r0 = w.sub_vector(&cs2).low_bits::(); + let r0 = w.sub_vector(&cs2).low_bits::

(); // while s2_hat is in scope, derive t0 let mut t = t_hat; @@ -1619,7 +1409,7 @@ impl< // 6: (𝐭1, 𝐭0) ← Power2Round(𝐭) // β–· compress 𝐭 // β–· PowerTwoRound is applied componentwise (see explanatory text in Section 7.4) - let (_t1tmp, t0tmp) = power_2_round_vec::(&t); + let (_t1tmp, t0tmp) = power_2_round_vec(&t); t0 = t0tmp; r0 @@ -1627,14 +1417,14 @@ impl< // Alg 7; 23 (second half): if ||𝐳||∞ β‰₯ 𝛾1 βˆ’ 𝛽 or ||𝐫0||∞ β‰₯ 𝛾2 βˆ’ 𝛽 then (z, h) ← βŠ₯ // β–· validity checks - if r0.check_norm::() { + if r0.check_norm(P::GAMMA2_MINUS_BETA) { // mutants note: mutants thinks this can be replaced with -=, but in practice that makes // the rejection sampling loop go forever, so is a false positive. - kappa += l as u16; + kappa += P::l as u16; continue; }; - let ct0: Vector = { + let ct0: P::VecK = { // scope for t0_hat // 4: 𝐭0Μ‚_hat ← NTT(𝐭0)Μ‚ let mut t0_hat = t0.clone(); @@ -1650,8 +1440,8 @@ impl< // out-of-order on purpose for performance reasons: // might as well do the rejection sampling check before any extra heavy computation // mutants note: there is currently no unit test that triggers this branch - if ct0.check_norm::() { - kappa += l as u16; + if ct0.check_norm(P::GAMMA2) { + kappa += P::l as u16; continue; }; @@ -1662,15 +1452,15 @@ impl< let hint_hamming_weight: i32; sig_val_h = { // scope for hint - let (hint, inner_hint_hamming_weight) = make_hint_vecs::(&r0, &w1); + let (hint, inner_hint_hamming_weight) = make_hint_vecs::

(&r0, &w1); hint_hamming_weight = inner_hint_hamming_weight; hint }; // Alg 7; 28 (second half): if ||βŸ¨βŸ¨π‘π­0⟩⟩||∞ β‰₯ 𝛾2 or the number of 1’s in 𝐑 is greater than πœ”, then (z, h) ← βŠ₯ // mutants note: there is currently no unit test that triggers this branch - if hint_hamming_weight > OMEGA { - kappa += l as u16; + if hint_hamming_weight > P::OMEGA { + kappa += P::l as u16; continue; }; @@ -1686,9 +1476,7 @@ impl< // Alg 7; 33: 𝜎 ← sigEncode(𝑐, 𝐳̃ modΒ±π‘ž, 𝐑) let bytes_written = - sig_encode::( - &sig_val_c_tilde, &sig_val_z, &sig_val_h, output, - ); + sig_encode::(&sig_val_c_tilde, &sig_val_z, &sig_val_h, output); Ok(bytes_written) } @@ -1711,7 +1499,7 @@ impl< } fn verify_with_expanded_key( - pk: &MLDSAPublicKeyExpanded, + pk: &MLDSAPublicKeyExpanded, msg: &[u8], ctx: Option<&[u8]>, sig: &[u8], @@ -1725,7 +1513,7 @@ impl< fn verify_mu( pk: &PK, - A_hat: Option<&Matrix>, + A_hat: Option<&P::MatrixA>, mu: &[u8; 64], sig: &[u8; SIG_LEN], ) -> Result<(), SignatureError> { @@ -1738,16 +1526,12 @@ impl< /// Trait for all three of the ML-DSA algorithm variants. pub trait MLDSATrait< + P: MLDSAParams, + PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, const PK_LEN: usize, const SK_LEN: usize, const SIG_LEN: usize, - PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, - SK: MLDSAPrivateKeyTrait - + MLDSAPrivateKeyInternalTrait, - const LAMBDA: i32, - const k: usize, - const l: usize, - const ETA: usize, >: Sized { /// Runs a key generation using the library's default RNG, seeded from the OS. @@ -1762,7 +1546,7 @@ pub trait MLDSATrait< // Should still be ok in FIPS mode, provided that you're using the FIPS-approved RNG. fn keygen_from_rng(rng: &mut dyn RNG) -> Result<(PK, SK), SignatureError> { // Source the seed from the provided RNG - if rng.security_strength() < SecurityStrength::from_bits(LAMBDA as usize) { + if rng.security_strength() < SecurityStrength::from_bits(P::LAMBDA as usize) { return Err(RNGError::SecurityStrengthInsufficientForAlgorithm)?; } let mut seed = KeyMaterial256::new(); @@ -1829,26 +1613,26 @@ pub trait MLDSATrait< ) -> Result<[u8; 64], SignatureError>; /// Same as [`MLDSATrait::compute_mu_from_tr`], but extracts tr from the public key. fn compute_mu_from_pk( - pk: &impl MLDSAPublicKeyTrait, + pk: &impl MLDSAPublicKeyTrait, msg: &[u8], ctx: Option<&[u8]>, ) -> Result<[u8; 64], SignatureError>; /// Same as [`MLDSATrait::compute_mu_from_tr`], but extracts tr from the private key. // dev note: defined sk this way so that it accepts either MLDSAPrivateKey or MLDSAPRivateKeyExpanded fn compute_mu_from_sk( - sk: &impl MLDSAPrivateKeyTrait, + sk: &impl MLDSAPrivateKeyTrait, msg: &[u8], ctx: Option<&[u8]>, ) -> Result<[u8; 64], SignatureError>; /// Same as [`Signer::sign`], but signs from an [`MLDSAPrivateKeyExpanded`]. fn sign_with_expanded_key( - sk: &MLDSAPrivateKeyExpanded, + sk: &MLDSAPrivateKeyExpanded, msg: &[u8], ctx: Option<&[u8]>, ) -> Result<[u8; SIG_LEN], SignatureError>; /// Same as [`MLDSATrait::sign_with_expanded_key`], but takes an output array. fn sign_with_expanded_key_out( - sk: &MLDSAPrivateKeyExpanded, + sk: &MLDSAPrivateKeyExpanded, msg: &[u8], ctx: Option<&[u8]>, out: &mut [u8; SIG_LEN], @@ -1861,7 +1645,7 @@ pub trait MLDSATrait< /// Optionally, takes a pre-expanded public matrix `A_hat`. fn sign_mu( sk: &SK, - A_hat: Option<&Matrix>, + A_hat: Option<&P::MatrixA>, mu: &[u8; 64], ) -> Result<[u8; SIG_LEN], SignatureError>; /// Performs an ML-DSA signature using the provided external message representative `mu`. @@ -1876,20 +1660,20 @@ pub trait MLDSATrait< /// Returns the number of bytes written to the output buffer. Can be called with an oversized buffer. fn sign_mu_out( sk: &SK, - A_hat: Option<&Matrix>, + A_hat: Option<&P::MatrixA>, mu: &[u8; 64], output: &mut [u8; SIG_LEN], ) -> Result; /// Same as [`MLDSATrait::sign_mu`], but signs from an [`MLDSAPrivateKeyExpanded`]. fn sign_mu_with_expanded_key( - sk: &MLDSAPrivateKeyExpanded, - A_hat: Option<&Matrix>, + sk: &MLDSAPrivateKeyExpanded, + A_hat: Option<&P::MatrixA>, mu: &[u8; 64], ) -> Result<[u8; SIG_LEN], SignatureError>; /// Same as [`MLDSATrait::sign_mu_out`], but signs from an [`MLDSAPrivateKeyExpanded`]. fn sign_mu_with_expanded_key_out( - sk: &MLDSAPrivateKeyExpanded, - A_hat: Option<&Matrix>, + sk: &MLDSAPrivateKeyExpanded, + A_hat: Option<&P::MatrixA>, mu: &[u8; 64], output: &mut [u8; SIG_LEN], ) -> Result; @@ -1916,7 +1700,7 @@ pub trait MLDSATrait< /// prevent accidental nonce reuse, this function moves `rnd`. fn sign_mu_deterministic( sk: &SK, - A_hat: Option<&Matrix>, + A_hat: Option<&P::MatrixA>, mu: &[u8; 64], rnd: [u8; 32], ) -> Result<[u8; SIG_LEN], SignatureError>; @@ -1945,7 +1729,7 @@ pub trait MLDSATrait< /// Returns the number of bytes written to the output buffer. Can be called with an oversized buffer. fn sign_mu_deterministic_out( sk: &SK, - A_hat: Option<&Matrix>, + A_hat: Option<&P::MatrixA>, mu: &[u8; 64], rnd: [u8; 32], output: &mut [u8; SIG_LEN], @@ -1978,7 +1762,7 @@ pub trait MLDSATrait< ) -> Result; /// Same as [`SignatureVerifier::verify`], but signs from an expanded key object. fn verify_with_expanded_key( - pk: &MLDSAPublicKeyExpanded, + pk: &MLDSAPublicKeyExpanded, msg: &[u8], ctx: Option<&[u8]>, sig: &[u8], @@ -1989,59 +1773,20 @@ pub trait MLDSATrait< /// Optionally, takes a pre-expanded public matrix `A_hat`. fn verify_mu( pk: &PK, - A_hat: Option<&Matrix>, + A_hat: Option<&P::MatrixA>, mu: &[u8; 64], sig: &[u8; SIG_LEN], ) -> Result<(), SignatureError>; } impl< + P: MLDSAParams, + PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, const PK_LEN: usize, const SK_LEN: usize, const SIG_LEN: usize, - PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, - SK: MLDSAPrivateKeyTrait - + MLDSAPrivateKeyInternalTrait, - const TAU: i32, - const LAMBDA: i32, - const GAMMA1: i32, - const GAMMA2: i32, - const k: usize, - const l: usize, - const ETA: usize, - const BETA: i32, - const OMEGA: i32, - const C_TILDE: usize, - const POLY_Z_PACKED_LEN: usize, - const POLY_W1_PACKED_LEN: usize, - const LAMBDA_over_4: usize, - const GAMMA1_MINUS_BETA: i32, - const GAMMA2_MINUS_BETA: i32, - const GAMMA1_MASK_LEN: usize, -> Signer - for MLDSA< - PK_LEN, - SK_LEN, - SIG_LEN, - PK, - SK, - TAU, - LAMBDA, - GAMMA1, - GAMMA2, - k, - l, - ETA, - BETA, - OMEGA, - C_TILDE, - POLY_Z_PACKED_LEN, - POLY_W1_PACKED_LEN, - LAMBDA_over_4, - GAMMA1_MINUS_BETA, - GAMMA2_MINUS_BETA, - GAMMA1_MASK_LEN, - > +> Signer for MLDSA { fn sign(sk: &SK, msg: &[u8], ctx: Option<&[u8]>) -> Result<[u8; SIG_LEN], SignatureError> { let mut out = [0u8; SIG_LEN]; @@ -2125,52 +1870,13 @@ impl< } impl< + P: MLDSAParams, + PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, const PK_LEN: usize, const SK_LEN: usize, const SIG_LEN: usize, - PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, - SK: MLDSAPrivateKeyTrait - + MLDSAPrivateKeyInternalTrait, - const TAU: i32, - const LAMBDA: i32, - const GAMMA1: i32, - const GAMMA2: i32, - const k: usize, - const l: usize, - const ETA: usize, - const BETA: i32, - const OMEGA: i32, - const C_TILDE: usize, - const POLY_Z_PACKED_LEN: usize, - const POLY_W1_PACKED_LEN: usize, - const LAMBDA_over_4: usize, - const GAMMA1_MINUS_BETA: i32, - const GAMMA2_MINUS_BETA: i32, - const GAMMA1_MASK_LEN: usize, -> SignatureVerifier - for MLDSA< - PK_LEN, - SK_LEN, - SIG_LEN, - PK, - SK, - TAU, - LAMBDA, - GAMMA1, - GAMMA2, - k, - l, - ETA, - BETA, - OMEGA, - C_TILDE, - POLY_Z_PACKED_LEN, - POLY_W1_PACKED_LEN, - LAMBDA_over_4, - GAMMA1_MINUS_BETA, - GAMMA2_MINUS_BETA, - GAMMA1_MASK_LEN, - > +> SignatureVerifier for MLDSA { fn verify(pk: &PK, msg: &[u8], ctx: Option<&[u8]>, sig: &[u8]) -> Result<(), SignatureError> { let mu = MuBuilder::compute_mu(&pk.compute_tr(), msg, ctx)?; diff --git a/crypto/mldsa/src/mldsa_keys.rs b/crypto/mldsa/src/mldsa_keys.rs index 456a26c8..f84f199a 100644 --- a/crypto/mldsa/src/mldsa_keys.rs +++ b/crypto/mldsa/src/mldsa_keys.rs @@ -2,13 +2,10 @@ use crate::aux_functions::{ bit_pack_eta, bit_pack_t0, bit_unpack_eta, bit_unpack_t0, bitlen_eta, expandA, power_2_round_vec, simple_bit_pack_t1, simple_bit_unpack_t1, }; -use crate::matrix::{Matrix, Vector}; +use crate::matrix::{MatrixTrait, VectorTrait}; use crate::mldsa::H; -use crate::mldsa::{MLDSA44_ETA, MLDSA44_PK_LEN, MLDSA44_SK_LEN, MLDSA44_k, MLDSA44_l}; -use crate::mldsa::{MLDSA65_ETA, MLDSA65_PK_LEN, MLDSA65_SK_LEN, MLDSA65_k, MLDSA65_l}; -use crate::mldsa::{MLDSA87_ETA, MLDSA87_PK_LEN, MLDSA87_SK_LEN, MLDSA87_k, MLDSA87_l}; use crate::mldsa::{POLY_T0PACKED_LEN, POLY_T1PACKED_LEN}; -use crate::{ML_DSA_44_NAME, ML_DSA_65_NAME, ML_DSA_87_NAME}; +use crate::params::{MLDSA44Params, MLDSA65Params, MLDSA87Params, MLDSAParams}; use bouncycastle_core::errors::SignatureError; use bouncycastle_core::key_material::KeyMaterial; use bouncycastle_core::traits::{SignaturePrivateKey, SignaturePublicKey, XOF}; @@ -25,71 +22,83 @@ use crate::polynomial::Polynomial; /* Pub Types */ /// ML-DSA-44 Public Key -pub type MLDSA44PublicKey = MLDSAPublicKey; +pub type MLDSA44PublicKey = MLDSAPublicKey; /// ML-DSA-44 Private Key pub type MLDSA44PrivateKey = - MLDSAPrivateKey; + MLDSAPrivateKey; /// ML-DSA-65 Public Key -pub type MLDSA65PublicKey = MLDSAPublicKey; +pub type MLDSA65PublicKey = MLDSAPublicKey; /// ML-DSA-65 Private Key pub type MLDSA65PrivateKey = - MLDSAPrivateKey; + MLDSAPrivateKey; /// ML-DSA-87 Public Key -pub type MLDSA87PublicKey = MLDSAPublicKey; +pub type MLDSA87PublicKey = MLDSAPublicKey; /// ML-DSA-87 Private Key pub type MLDSA87PrivateKey = - MLDSAPrivateKey; + MLDSAPrivateKey; /* Pre-expanded keys for repeated operations */ /// ML-DSA-44 Public Key with a pre-expanded public matrix A for repeated encaps operations. pub type MLDSA44PublicKeyExpanded = - MLDSAPublicKeyExpanded; + MLDSAPublicKeyExpanded; /// ML-DSA-44 Private Key with a pre-expanded public matrix A for repeated decaps operations. pub type MLDSA44PrivateKeyExpanded = MLDSAPrivateKeyExpanded< - MLDSA44_k, - MLDSA44_l, - MLDSA44_ETA, + MLDSA44Params, MLDSA44PublicKey, MLDSA44PrivateKey, - MLDSA44_SK_LEN, - MLDSA44_PK_LEN, + { MLDSA44Params::SK_LEN }, + { MLDSA44Params::PK_LEN }, >; /// ML-DSA-65 Public Key with a pre-expanded public matrix A for repeated encaps operations. pub type MLDSA65PublicKeyExpanded = - MLDSAPublicKeyExpanded; + MLDSAPublicKeyExpanded; /// ML-DSA-65 Private Key with a pre-expanded public matrix A for repeated decaps operations. pub type MLDSA65PrivateKeyExpanded = MLDSAPrivateKeyExpanded< - MLDSA65_k, - MLDSA65_l, - MLDSA65_ETA, + MLDSA65Params, MLDSA65PublicKey, MLDSA65PrivateKey, - MLDSA65_SK_LEN, - MLDSA65_PK_LEN, + { MLDSA65Params::SK_LEN }, + { MLDSA65Params::PK_LEN }, >; /// ML-DSA-87 Public Key with a pre-expanded public matrix A for repeated encaps operations. pub type MLDSA87PublicKeyExpanded = - MLDSAPublicKeyExpanded; + MLDSAPublicKeyExpanded; /// ML-DSA-87 Private Key with a pre-expanded public matrix A for repeated decaps operations. pub type MLDSA87PrivateKeyExpanded = MLDSAPrivateKeyExpanded< - MLDSA87_k, - MLDSA87_l, - MLDSA87_ETA, + MLDSA87Params, MLDSA87PublicKey, MLDSA87PrivateKey, - MLDSA87_SK_LEN, - MLDSA87_PK_LEN, + { MLDSA87Params::SK_LEN }, + { MLDSA87Params::PK_LEN }, >; /// An ML-DSA public key. -#[derive(Clone)] -pub struct MLDSAPublicKey { +/// +/// `PK_LEN` duplicates [`MLDSAParams::PK_LEN`]; it has to be carried separately because +/// [`SignaturePublicKey`] takes the encoded length as a const generic parameter, and an associated +/// const of a type parameter may not be used as a const generic argument. The two are checked +/// against each other at compile time -- see the `ASSERT_PK_LEN` const on this type. +pub struct MLDSAPublicKey { rho: [u8; 32], - t1: Vector, + t1: P::VecK, +} + +// Written out rather than derived: `#[derive(Clone)]` would demand `P: Clone`, and `P` is a +// marker for the parameter set that is never stored, only used to name the field types. +impl Clone for MLDSAPublicKey { + fn clone(&self) -> Self { + Self { rho: self.rho, t1: self.t1 } + } } -impl MLDSAPublicKey { +impl MLDSAPublicKey { + /// Fails to evaluate, and so fails the build, if this type is instantiated with a `PK_LEN` + /// that is not the parameter set's own. Referenced from every constructor so that it is + /// reached during monomorphization. + const ASSERT_PK_LEN: () = + assert!(PK_LEN == P::PK_LEN, "PK_LEN does not match MLDSAParams::PK_LEN"); + /// Algorithm 22 pkEncode(𝜌, 𝐭1) /// Encodes a public key for ML-DSA into a byte string. /// Input:𝜌 ∈ 𝔹32, 𝐭1 ∈ π‘…π‘˜ with coefficients in [0, 2bitlen (π‘žβˆ’1)βˆ’π‘‘ βˆ’ 1]. @@ -102,11 +111,11 @@ impl MLDSAPublicKey(); // that should divide evenly the remainder of the array - debug_assert_eq!(pk_chunks.len(), k); + debug_assert_eq!(pk_chunks.len(), P::k); debug_assert_eq!(last_chunk.len(), 0); - for (pk_chunk, t1_i) in pk_chunks.into_iter().zip(&self.t1.elems) { - pk_chunk.copy_from_slice(&simple_bit_pack_t1(&t1_i)); + for (pk_chunk, t1_i) in pk_chunks.into_iter().zip(self.t1.elems()) { + pk_chunk.copy_from_slice(&simple_bit_pack_t1(t1_i)); } PK_LEN @@ -114,7 +123,7 @@ impl MLDSAPublicKey: +pub trait MLDSAPublicKeyTrait: SignaturePublicKey { /// Algorithm 23 pkDecode(π‘π‘˜) @@ -124,7 +133,7 @@ pub trait MLDSAPublicKeyTrait Self; /// Get a copy of the expanded public matrix A_hat - fn A_hat(&self) -> Matrix; + fn A_hat(&self) -> P::MatrixA; /// Compute the public key hash (tr) from the public key. /// @@ -135,43 +144,43 @@ pub trait MLDSAPublicKeyTrait [u8; 64]; } -pub(crate) trait MLDSAPublicKeyInternalTrait: +pub(crate) trait MLDSAPublicKeyInternalTrait: SignaturePublicKey { /// Not exposing a constructor publicly because you should have to get an instance either by /// running a keygen, or by decoding an existing key. - fn new(rho: [u8; 32], t1: Vector) -> Self; + fn new(rho: [u8; 32], t1: P::VecK) -> Self; /// Get a ref to t1 - fn t1(&self) -> &Vector; + fn t1(&self) -> &P::VecK; } -impl MLDSAPublicKeyTrait - for MLDSAPublicKey +impl MLDSAPublicKeyTrait + for MLDSAPublicKey { // todo: block a t1 of all zeros? Maybe add to consistency_check() ? fn pk_decode(pk: &[u8; PK_LEN]) -> Self { let rho = pk[0..32].try_into().unwrap(); - let mut t1 = Vector::::new(); + let mut t1 = P::VecK::new(); let (pk_chunks, last_chunk) = pk[32..].as_chunks::(); // that should divide evenly the remainder of the array - debug_assert_eq!(pk_chunks.len(), k); + debug_assert_eq!(pk_chunks.len(), P::k); debug_assert_eq!(last_chunk.len(), 0); - for (t1_i, pk_chunk) in t1.elems.iter_mut().zip(pk_chunks) { + for (t1_i, pk_chunk) in t1.elems_mut().iter_mut().zip(pk_chunks) { // 3: 𝐭1[𝑖] ← SimpleBitUnpack(𝑧𝑖, 2bitlen (π‘žβˆ’1)βˆ’π‘‘ βˆ’ 1) // β–· This is always in the correct range // Therefore, we don't need to check that the coeeffs are in range t1_i.coeffs.copy_from_slice(&simple_bit_unpack_t1(pk_chunk).coeffs); } - Self::new(rho, t1) + >::new(rho, t1) } - fn A_hat(&self) -> Matrix { - expandA::(&self.rho) + fn A_hat(&self) -> P::MatrixA { + expandA::

(&self.rho) } fn compute_tr(&self) -> [u8; 64] { @@ -182,21 +191,20 @@ impl MLDSAPublicKeyTrait MLDSAPublicKeyInternalTrait - for MLDSAPublicKey +impl MLDSAPublicKeyInternalTrait + for MLDSAPublicKey { - fn new(rho: [u8; 32], t1: Vector) -> Self { + fn new(rho: [u8; 32], t1: P::VecK) -> Self { + let () = Self::ASSERT_PK_LEN; Self { rho, t1 } } - fn t1(&self) -> &Vector { + fn t1(&self) -> &P::VecK { &self.t1 } } -impl SignaturePublicKey - for MLDSAPublicKey -{ +impl SignaturePublicKey for MLDSAPublicKey { fn encode(&self) -> [u8; PK_LEN] { let mut pk = [0u8; PK_LEN]; let bytes_written = self.encode_out(&mut pk); @@ -218,15 +226,13 @@ impl SignaturePublicKey>::pk_decode(&bytes_sized)) } } -impl Eq for MLDSAPublicKey {} +impl Eq for MLDSAPublicKey {} -impl PartialEq - for MLDSAPublicKey -{ +impl PartialEq for MLDSAPublicKey { fn eq(&self, other: &Self) -> bool { let self_encoded = self.encode(); let other_encoded = other.encode(); @@ -234,50 +240,54 @@ impl PartialEq } } -impl Debug for MLDSAPublicKey { +impl Debug for MLDSAPublicKey { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let alg = match k { - 4 => ML_DSA_44_NAME, - 6 => ML_DSA_65_NAME, - 8 => ML_DSA_87_NAME, - _ => panic!("Unsupported key length"), - }; - write!(f, "MLDSAPublicKey {{ alg: {}, pub_key_hash (tr): {:x?} }}", alg, self.compute_tr(),) + write!( + f, + "MLDSAPublicKey {{ alg: {}, pub_key_hash (tr): {:x?} }}", + P::ALG_NAME, + >::compute_tr(self), + ) } } -impl Display for MLDSAPublicKey { +impl Display for MLDSAPublicKey { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - let alg = match k { - 4 => ML_DSA_44_NAME, - 6 => ML_DSA_65_NAME, - 8 => ML_DSA_87_NAME, - _ => panic!("Unsupported key length"), - }; - write!(f, "MLDSAPublicKey {{ alg: {}, pub_key_hash (tr): {:x?} }}", alg, self.compute_tr(),) + write!( + f, + "MLDSAPublicKey {{ alg: {}, pub_key_hash (tr): {:x?} }}", + P::ALG_NAME, + >::compute_tr(self), + ) } } /// A fully expanded ML-DSA public key that includes the intermediate values needed for performing /// multiple verification operations against the same public key, which causes the public key struct /// to take up more memory, but results in more efficient repeated verify() operations. -#[derive(Clone)] pub struct MLDSAPublicKeyExpanded< - const k: usize, - const l: usize, - PK: MLDSAPublicKeyInternalTrait, + P: MLDSAParams, + PK: MLDSAPublicKeyInternalTrait, const PK_LEN: usize, > { pub(crate) pk: PK, - pub(crate) A_hat: Matrix, + pub(crate) A_hat: P::MatrixA, +} + +/// See the note on [`MLDSAPublicKey`]'s `Clone` for why this is not derived. +impl, const PK_LEN: usize> Clone + for MLDSAPublicKeyExpanded +{ + fn clone(&self) -> Self { + Self { pk: self.pk.clone(), A_hat: self.A_hat.clone() } + } } impl< - const k: usize, - const l: usize, - PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, + P: MLDSAParams, + PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, const PK_LEN: usize, -> SignaturePublicKey for MLDSAPublicKeyExpanded +> SignaturePublicKey for MLDSAPublicKeyExpanded { fn encode(&self) -> [u8; PK_LEN] { self.pk.encode() @@ -296,16 +306,15 @@ impl< )); } let bytes_sized: [u8; PK_LEN] = bytes[..PK_LEN].try_into().unwrap(); - Ok(Self::pk_decode(&bytes_sized)) + Ok(>::pk_decode(&bytes_sized)) } } impl< - const k: usize, - const l: usize, - PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, + P: MLDSAParams, + PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, const PK_LEN: usize, -> PartialEq for MLDSAPublicKeyExpanded +> PartialEq for MLDSAPublicKeyExpanded { fn eq(&self, other: &Self) -> bool { self.pk.eq(&other.pk) @@ -313,66 +322,50 @@ impl< } impl< - const k: usize, - const l: usize, - PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, + P: MLDSAParams, + PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, const PK_LEN: usize, -> Eq for MLDSAPublicKeyExpanded +> Eq for MLDSAPublicKeyExpanded { } impl< - const k: usize, - const l: usize, - PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, + P: MLDSAParams, + PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, const PK_LEN: usize, -> Debug for MLDSAPublicKeyExpanded +> Debug for MLDSAPublicKeyExpanded { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - let alg = match k { - 4 => ML_DSA_44_NAME, - 6 => ML_DSA_65_NAME, - 8 => ML_DSA_87_NAME, - _ => panic!("Unsupported key length"), - }; write!( f, "MLDSAPublicKeyExpanded {{ alg: {}, pub_key_hash (tr): {:x?} }}", - alg, - self.compute_tr(), + P::ALG_NAME, + self.pk.compute_tr(), ) } } impl< - const k: usize, - const l: usize, - PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, + P: MLDSAParams, + PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, const PK_LEN: usize, -> Display for MLDSAPublicKeyExpanded +> Display for MLDSAPublicKeyExpanded { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - let alg = match k { - 4 => ML_DSA_44_NAME, - 6 => ML_DSA_65_NAME, - 8 => ML_DSA_87_NAME, - _ => panic!("Unsupported key length"), - }; write!( f, "MLDSAPublicKeyExpanded {{ alg: {}, pub_key_hash (tr): {:x?} }}", - alg, - self.compute_tr(), + P::ALG_NAME, + self.pk.compute_tr(), ) } } impl< - const k: usize, - const l: usize, - PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, + P: MLDSAParams, + PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, const PK_LEN: usize, -> From<&PK> for MLDSAPublicKeyExpanded +> From<&PK> for MLDSAPublicKeyExpanded { /// Fully expands the intermediate values needed for performing multiple encaps operations /// against the same public key, which causes the MLKEMPublicKey struct to take up @@ -384,11 +377,10 @@ impl< } impl< - const k: usize, - const l: usize, - PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, + P: MLDSAParams, + PK: MLDSAPublicKeyTrait + MLDSAPublicKeyInternalTrait, const PK_LEN: usize, -> MLDSAPublicKeyTrait for MLDSAPublicKeyExpanded +> MLDSAPublicKeyTrait for MLDSAPublicKeyExpanded { fn pk_decode(pk: &[u8; PK_LEN]) -> Self { let pk1 = PK::pk_decode(pk); @@ -396,7 +388,7 @@ impl< Self { pk: pk1, A_hat } } - fn A_hat(&self) -> Matrix { + fn A_hat(&self) -> P::MatrixA { self.A_hat.clone() } @@ -407,15 +399,10 @@ impl< /// An ML-DSA private key. /// +/// See [`MLDSAPublicKey`] for why `SK_LEN` and `PK_LEN` are carried alongside `P`. +// // Dev note: This will automatically inherit the [`Secret`] protections because [`Polynomial`] wraps the underlying data with [`Secret`]. -#[derive(Clone)] -pub struct MLDSAPrivateKey< - const k: usize, - const l: usize, - const eta: usize, - const SK_LEN: usize, - const PK_LEN: usize, -> { +pub struct MLDSAPrivateKey { rho: [u8; 32], K: Secret<[u8; 32]>, tr: [u8; 64], @@ -425,16 +412,31 @@ pub struct MLDSAPrivateKey< // So we are going to hold them as s1_hat, s2_hat, and t0_hat. // Note: these are not necessarily in their reduced form; so you'll need to reduce them before // inv_ntt()'ing them or hashing them. - s1_hat: Secret>, - s2_hat: Secret>, - t0_hat: Vector, + s1_hat: Secret, + s2_hat: Secret, + t0_hat: P::VecK, // note: KeyMaterial is inherently Secret seed: Option>, } -impl - MLDSAPrivateKey +/// See the note on [`MLDSAPublicKey`]'s `Clone` for why this is not derived. +impl Clone + for MLDSAPrivateKey { + fn clone(&self) -> Self { + Self { + rho: self.rho, + K: self.K.clone(), + tr: self.tr, + s1_hat: self.s1_hat.clone(), + s2_hat: self.s2_hat.clone(), + t0_hat: self.t0_hat, + seed: self.seed.clone(), + } + } +} + +impl MLDSAPrivateKey { /// Algorithm 24 skEncode(𝜌, 𝐾, π‘‘π‘Ÿ, 𝐬1, 𝐬2, 𝐭0) /// Encodes a secret key for ML-DSA into a byte string. /// Input: 𝜌 ∈ 𝔹32, 𝐾 ∈ 𝔹32, π‘‘π‘Ÿ ∈ 𝔹64 , 𝐬1 ∈ 𝑅ℓ with coefficients in [βˆ’πœ‚, πœ‚], 𝐬2 ∈ π‘…π‘˜ with @@ -452,47 +454,44 @@ impl(&s1_i, &mut buf); + bit_pack_eta::

(&s1_i, &mut buf); sk_chunk.copy_from_slice(&buf[..eta_pack_len]); } - off += l * bitlen_eta(eta); + off += P::l * eta_pack_len; - let sk_chunks = out[off..off + k * bitlen_eta(eta)].chunks_mut(bitlen_eta(eta)); - debug_assert_eq!(sk_chunks.len(), k); - for (sk_chunk, s2_hat_i) in sk_chunks.into_iter().zip(&self.s2_hat.elems) { + let sk_chunks = out[off..off + P::k * eta_pack_len].chunks_mut(eta_pack_len); + debug_assert_eq!(sk_chunks.len(), P::k); + for (sk_chunk, s2_hat_i) in sk_chunks.into_iter().zip(self.s2_hat.elems()) { // Deviation from the FIPS: // We are holding these in ntt form, so need to convert back to standard form - let mut s2_hat_i = s2_hat_i.clone(); - s2_hat_i.reduce(); - s2_hat_i.inv_ntt(); - let s2_i = s2_hat_i; + let mut s2_i = *s2_hat_i; + s2_i.reduce(); + s2_i.inv_ntt(); - bit_pack_eta::(&s2_i, &mut buf); + bit_pack_eta::

(&s2_i, &mut buf); sk_chunk.copy_from_slice(&buf[..eta_pack_len]); } - off += k * bitlen_eta(eta); + off += P::k * eta_pack_len; - let sk_chunks = out[off..off + k * POLY_T0PACKED_LEN].chunks_mut(POLY_T0PACKED_LEN); - debug_assert_eq!(sk_chunks.len(), k); - for (sk_chunk, t0_hat_i) in sk_chunks.into_iter().zip(&self.t0_hat.elems) { + let sk_chunks = out[off..off + P::k * POLY_T0PACKED_LEN].chunks_mut(POLY_T0PACKED_LEN); + debug_assert_eq!(sk_chunks.len(), P::k); + for (sk_chunk, t0_hat_i) in sk_chunks.into_iter().zip(self.t0_hat.elems()) { // Deviation from the FIPS: // We are holding these in ntt form, so need to convert back to standard form - let mut t0_hat_i = t0_hat_i.clone(); - t0_hat_i.reduce(); - t0_hat_i.inv_ntt(); - let t0_i = t0_hat_i; + let mut t0_i = *t0_hat_i; + t0_i.reduce(); + t0_i.inv_ntt(); sk_chunk.copy_from_slice(&bit_pack_t0(&t0_i)); } @@ -502,13 +501,8 @@ impl: SignaturePrivateKey +pub trait MLDSAPrivateKeyTrait: + SignaturePrivateKey { /// Get a ref to the seed, if there is one stored with this private key fn seed(&self) -> Option<&KeyMaterial<32>>; @@ -517,10 +511,10 @@ pub trait MLDSAPrivateKeyTrait< fn tr(&self) -> &[u8; 64]; /// Get the public matrix A_hat. - fn A_hat(&self) -> Matrix; + fn A_hat(&self) -> P::MatrixA; /// This is a partial implementation of keygen_internal(), and probably not allowed in FIPS mode. - fn derive_pk(&self) -> MLDSAPublicKey; + fn derive_pk(&self) -> MLDSAPublicKey; /// Algorithm 25 skDecode(π‘ π‘˜) /// Reverses the procedure skEncode. /// Input: Private key π‘ π‘˜ ∈ 𝔹32+32+64+32β‹…((β„“+π‘˜)β‹…bitlen (2πœ‚)+π‘‘π‘˜). @@ -533,9 +527,7 @@ pub trait MLDSAPrivateKeyTrait< } pub(crate) trait MLDSAPrivateKeyInternalTrait< - const k: usize, - const l: usize, - const eta: usize, + P: MLDSAParams, const SK_LEN: usize, const PK_LEN: usize, > @@ -546,23 +538,23 @@ pub(crate) trait MLDSAPrivateKeyInternalTrait< rho: [u8; 32], K: Secret<[u8; 32]>, tr: [u8; 64], - s1_hat: Secret>, - s2_hat: Secret>, - t0_hat: Vector, + s1_hat: Secret, + s2_hat: Secret, + t0_hat: P::VecK, seed: Option>, ) -> Self; /// Get a ref to K fn K(&self) -> &Secret<[u8; 32]>; /// Get a ref to s1 - fn s1_hat(&self) -> &Vector; + fn s1_hat(&self) -> &P::VecL; /// Get a ref to s2 - fn s2_hat(&self) -> &Vector; + fn s2_hat(&self) -> &P::VecK; /// Get a ref to t0 - fn t0_hat(&self) -> &Vector; + fn t0_hat(&self) -> &P::VecK; } -impl - MLDSAPrivateKeyTrait for MLDSAPrivateKey +impl + MLDSAPrivateKeyTrait for MLDSAPrivateKey { fn seed(&self) -> Option<&KeyMaterial<32>> { match self.seed { @@ -575,18 +567,18 @@ impl Matrix { - expandA::(&self.rho) + fn A_hat(&self) -> P::MatrixA { + expandA::

(&self.rho) } - fn derive_pk(&self) -> MLDSAPublicKey { + fn derive_pk(&self) -> MLDSAPublicKey { // 5: 𝐭 ← NTTβˆ’1(𝐀 ∘ NTT(𝐬1)) + 𝐬2 // β–· compute 𝐭 = 𝐀𝐬1 + 𝐬2 let mut t = { // scope for A_hat // 3: 𝐀 ← ExpandA(𝜌) // β–· 𝐀 is generated and stored in NTT representation as 𝐀 - let A_hat = expandA::(&self.rho); + let A_hat = expandA::

(&self.rho); let mut t_ntt = A_hat.matrix_vector_ntt(&self.s1_hat); t_ntt.inv_ntt(); @@ -596,7 +588,7 @@ impl(&t); + let (t1, _) = power_2_round_vec(&t); - MLDSAPublicKey::::new(self.rho.clone(), t1) + as MLDSAPublicKeyInternalTrait>::new(self.rho, t1) } fn sk_decode(sk: &[u8; SK_LEN]) -> Result { // Construct the (Secret-protected) key up front and unpack each field directly into it, @@ -621,23 +613,25 @@ impl::new(), + t0_hat: P::VecK::new(), seed: None, }; key.K.copy_from_slice(&sk[32..64]); let mut off = 128; + let eta_pack_len = bitlen_eta(P::ETA); + let eta = P::ETA as i32; // unpack s1 directly into key.s1_hat so that we don't make additional non-Secret copies. - let sk_chunks = sk[off..off + (l * bitlen_eta(eta))].chunks(bitlen_eta(eta)); - debug_assert_eq!(sk_chunks.len(), l); - for (s1_i, sk_chunk) in key.s1_hat.elems.iter_mut().zip(sk_chunks) { + let sk_chunks = sk[off..off + (P::l * eta_pack_len)].chunks(eta_pack_len); + debug_assert_eq!(sk_chunks.len(), P::l); + for (s1_i, sk_chunk) in key.s1_hat.elems_mut().iter_mut().zip(sk_chunks) { // 3: 𝐬1[𝑖] ← BitUnpack(𝑦𝑖, πœ‚, πœ‚) // β–· this may lie outside [βˆ’πœ‚, πœ‚] if input is malformed - s1_i.coeffs.copy_from_slice(&bit_unpack_eta::(&sk_chunk).coeffs); + s1_i.coeffs.copy_from_slice(&bit_unpack_eta::

(sk_chunk).coeffs); // check that the coefficients are within the expected range for coeff in s1_i.coeffs.iter() { - if *coeff < -(eta as i32) || *coeff > (eta as i32) { + if *coeff < -eta || *coeff > eta { return Err(SignatureError::DecodingError("Invalid or corrupted key")); } } @@ -645,19 +639,19 @@ impl(&sk_chunk).coeffs); + s2_i.coeffs.copy_from_slice(&bit_unpack_eta::

(sk_chunk).coeffs); // check that the coefficients are within the expected range for coeff in s2_i.coeffs.iter() { - if *coeff < -(eta as i32) || *coeff > (eta as i32) { + if *coeff < -eta || *coeff > eta { return Err(SignatureError::DecodingError("Invalid or corrupted key")); } } @@ -665,17 +659,17 @@ impl(); + sk[off..off + (P::k * POLY_T0PACKED_LEN)].as_chunks::(); // that should divide evenly the remainder of the array - debug_assert_eq!(sk_chunks.len(), k); + debug_assert_eq!(sk_chunks.len(), P::k); debug_assert_eq!(last_chunk.len(), 0); - for (t0_i, sk_chunk) in key.t0_hat.elems.iter_mut().zip(sk_chunks) { + for (t0_i, sk_chunk) in key.t0_hat.elems_mut().iter_mut().zip(sk_chunks) { t0_i.coeffs.copy_from_slice(&bit_unpack_t0(sk_chunk).coeffs); } // Deviation from the FIPS: @@ -686,49 +680,40 @@ impl - MLDSAPrivateKeyInternalTrait - for MLDSAPrivateKey +impl + MLDSAPrivateKeyInternalTrait for MLDSAPrivateKey { fn new( rho: [u8; 32], K: Secret<[u8; 32]>, tr: [u8; 64], - s1_hat: Secret>, - s2_hat: Secret>, - t0_hat: Vector, + s1_hat: Secret, + s2_hat: Secret, + t0_hat: P::VecK, seed: Option>, ) -> Self { - Self { - rho: rho.clone(), - K: K.clone(), - tr: tr.clone(), - s1_hat: s1_hat.clone(), - s2_hat: s2_hat.clone(), - t0_hat: t0_hat.clone(), - seed: seed.clone(), - } + Self { rho, K, tr, s1_hat, s2_hat, t0_hat, seed } } fn K(&self) -> &Secret<[u8; 32]> { &self.K } - fn s1_hat(&self) -> &Vector { + fn s1_hat(&self) -> &P::VecL { &self.s1_hat } - fn s2_hat(&self) -> &Vector { + fn s2_hat(&self) -> &P::VecK { &self.s2_hat } - fn t0_hat(&self) -> &Vector { + fn t0_hat(&self) -> &P::VecK { &self.t0_hat } } -impl - SignaturePrivateKey for MLDSAPrivateKey +impl SignaturePrivateKey + for MLDSAPrivateKey { fn encode(&self) -> [u8; SK_LEN] { let mut out = [0u8; SK_LEN]; @@ -752,17 +737,17 @@ impl>::sk_decode(&bytes_sized) } } -impl Eq - for MLDSAPrivateKey +impl Eq + for MLDSAPrivateKey { } -impl - PartialEq for MLDSAPrivateKey +impl PartialEq + for MLDSAPrivateKey { fn eq(&self, other: &Self) -> bool { let self_encoded = self.encode(); @@ -772,20 +757,14 @@ impl - fmt::Debug for MLDSAPrivateKey +impl fmt::Debug + for MLDSAPrivateKey { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let alg = match k { - 4 => ML_DSA_44_NAME, - 6 => ML_DSA_65_NAME, - 8 => ML_DSA_87_NAME, - _ => panic!("Unsupported key length"), - }; write!( f, "MLDSAPrivateKey {{ alg: {}, pub_key_hash (tr): {:x?}, has_seed: {} }}", - alg, + P::ALG_NAME, self.tr, self.seed.is_some(), ) @@ -793,20 +772,14 @@ impl - Display for MLDSAPrivateKey +impl Display + for MLDSAPrivateKey { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let alg = match k { - 4 => ML_DSA_44_NAME, - 6 => ML_DSA_65_NAME, - 8 => ML_DSA_87_NAME, - _ => panic!("Unsupported key length"), - }; write!( f, "MLDSAPrivateKey {{ alg: {}, pub_key_hash (tr): {:x?}, has_seed: {} }}", - alg, + P::ALG_NAME, self.tr, self.seed.is_some(), ) @@ -816,32 +789,39 @@ impl, - SK: MLDSAPrivateKeyTrait - + MLDSAPrivateKeyInternalTrait, + P: MLDSAParams, + PK: MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, const SK_LEN: usize, const PK_LEN: usize, > { _phantom: core::marker::PhantomData, pub(crate) sk: SK, - pub(crate) A_hat: Matrix, + pub(crate) A_hat: P::MatrixA, } +/// See the note on [`MLDSAPublicKey`]'s `Clone` for why this is not derived. impl< - const k: usize, - const l: usize, - const eta: usize, - PK: MLDSAPublicKeyInternalTrait, - SK: MLDSAPrivateKeyTrait - + MLDSAPrivateKeyInternalTrait, + P: MLDSAParams, + PK: MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, const SK_LEN: usize, const PK_LEN: usize, -> PartialEq for MLDSAPrivateKeyExpanded +> Clone for MLDSAPrivateKeyExpanded +{ + fn clone(&self) -> Self { + Self { _phantom: core::marker::PhantomData, sk: self.sk.clone(), A_hat: self.A_hat.clone() } + } +} + +impl< + P: MLDSAParams, + PK: MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, + const SK_LEN: usize, + const PK_LEN: usize, +> PartialEq for MLDSAPrivateKeyExpanded { fn eq(&self, other: &Self) -> bool { self.sk.eq(&other.sk) @@ -849,40 +829,28 @@ impl< } impl< - const k: usize, - const l: usize, - const eta: usize, - PK: MLDSAPublicKeyInternalTrait, - SK: MLDSAPrivateKeyTrait - + MLDSAPrivateKeyInternalTrait, + P: MLDSAParams, + PK: MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, const SK_LEN: usize, const PK_LEN: usize, -> Eq for MLDSAPrivateKeyExpanded +> Eq for MLDSAPrivateKeyExpanded { } impl< - const k: usize, - const l: usize, - const eta: usize, - PK: MLDSAPublicKeyInternalTrait, - SK: MLDSAPrivateKeyTrait - + MLDSAPrivateKeyInternalTrait, + P: MLDSAParams, + PK: MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, const SK_LEN: usize, const PK_LEN: usize, -> Debug for MLDSAPrivateKeyExpanded +> Debug for MLDSAPrivateKeyExpanded { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - let alg = match k { - 4 => ML_DSA_44_NAME, - 6 => ML_DSA_65_NAME, - 8 => ML_DSA_87_NAME, - _ => panic!("Unsupported key length"), - }; write!( f, "MLDSAPrivateKeyExpanded {{ alg: {}, pub_key_hash (tr): {:x?}, has_seed: {} }}", - alg, + P::ALG_NAME, self.sk.tr(), self.sk.seed().is_some(), ) @@ -890,27 +858,18 @@ impl< } impl< - const k: usize, - const l: usize, - const eta: usize, - PK: MLDSAPublicKeyInternalTrait, - SK: MLDSAPrivateKeyTrait - + MLDSAPrivateKeyInternalTrait, + P: MLDSAParams, + PK: MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, const SK_LEN: usize, const PK_LEN: usize, -> Display for MLDSAPrivateKeyExpanded +> Display for MLDSAPrivateKeyExpanded { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - let alg = match k { - 4 => ML_DSA_44_NAME, - 6 => ML_DSA_65_NAME, - 8 => ML_DSA_87_NAME, - _ => panic!("Unsupported key length"), - }; write!( f, "MLDSAPrivateKeyExpanded {{ alg: {}, pub_key_hash (tr): {:x?}, has_seed: {} }}", - alg, + P::ALG_NAME, self.sk.tr(), self.sk.seed().is_some(), ) @@ -918,35 +877,30 @@ impl< } impl< - const k: usize, - const l: usize, - const eta: usize, - PK: MLDSAPublicKeyInternalTrait, - SK: MLDSAPrivateKeyTrait - + MLDSAPrivateKeyInternalTrait, + P: MLDSAParams, + PK: MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, const SK_LEN: usize, const PK_LEN: usize, -> From<&SK> for MLDSAPrivateKeyExpanded +> From<&SK> for MLDSAPrivateKeyExpanded { /// Fully expands the intermediate values needed for performing multiple encaps operations /// against the same public key, which causes the MLKEMPublicKey struct to take up fn from(sk: &SK) -> Self { - let A_hat = sk.derive_pk().A_hat(); + let A_hat = + as MLDSAPublicKeyTrait>::A_hat(&sk.derive_pk()); Self { _phantom: core::marker::PhantomData, sk: sk.clone(), A_hat } } } impl< - const k: usize, - const l: usize, - const eta: usize, - PK: MLDSAPublicKeyInternalTrait, - SK: MLDSAPrivateKeyTrait - + MLDSAPrivateKeyInternalTrait, + P: MLDSAParams, + PK: MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, const SK_LEN: usize, const PK_LEN: usize, -> SignaturePrivateKey for MLDSAPrivateKeyExpanded +> SignaturePrivateKey for MLDSAPrivateKeyExpanded { fn encode(&self) -> [u8; SK_LEN] { self.sk.encode() @@ -965,16 +919,12 @@ impl< } impl< - const k: usize, - const l: usize, - const eta: usize, - PK: MLDSAPublicKeyInternalTrait, - SK: MLDSAPrivateKeyTrait - + MLDSAPrivateKeyInternalTrait, + P: MLDSAParams, + PK: MLDSAPublicKeyInternalTrait, + SK: MLDSAPrivateKeyTrait + MLDSAPrivateKeyInternalTrait, const SK_LEN: usize, const PK_LEN: usize, -> MLDSAPrivateKeyTrait - for MLDSAPrivateKeyExpanded +> MLDSAPrivateKeyTrait for MLDSAPrivateKeyExpanded { fn seed(&self) -> Option<&KeyMaterial<32>> { self.sk.seed() @@ -984,17 +934,18 @@ impl< self.sk.tr() } - fn A_hat(&self) -> Matrix { + fn A_hat(&self) -> P::MatrixA { self.sk.A_hat() } - fn derive_pk(&self) -> MLDSAPublicKey { + fn derive_pk(&self) -> MLDSAPublicKey { self.sk.derive_pk() } fn sk_decode(sk: &[u8; SK_LEN]) -> Result { let sk1 = SK::sk_decode(sk)?; - let A_hat = sk1.derive_pk().A_hat(); + let A_hat = + as MLDSAPublicKeyTrait>::A_hat(&sk1.derive_pk()); Ok(Self { _phantom: core::marker::PhantomData, sk: sk1, A_hat }) } diff --git a/crypto/mldsa/src/params.rs b/crypto/mldsa/src/params.rs new file mode 100644 index 00000000..2e394302 --- /dev/null +++ b/crypto/mldsa/src/params.rs @@ -0,0 +1,548 @@ +//! The three ML-DSA parameter sets of FIPS 204, Section 4, as a sealed trait with one type per set. +//! +//! # Derived parameters +//! +//! FIPS 204, Table 1 assigns eight independent values per set (𝜏, πœ†, 𝛾1, 𝛾2, (π‘˜, β„“), πœ‚, πœ”) plus the +//! three sizes of Table 2. Everything else this implementation needs is a function of those, so it +//! is written once as a defaulted associated const rather than three times as a hand-computed +//! number. `params::tests` checks every derivation against the values tabulated in FIPS 204. + +use crate::hash_mldsa::{ + HASH_ML_DSA_44_with_SHA256_NAME, HASH_ML_DSA_44_with_SHA512_NAME, + HASH_ML_DSA_65_WITH_SHA256_NAME, HASH_ML_DSA_65_WITH_SHA512_NAME, + HASH_ML_DSA_87_WITH_SHA512_NAME, HASH_ML_DSA_87_with_SHA256_NAME, +}; +use crate::matrix::{Matrix, MatrixTrait, Vector, VectorTrait}; +use crate::mldsa::{ML_DSA_44_NAME, ML_DSA_65_NAME, ML_DSA_87_NAME, q}; +use bouncycastle_core::traits::{Algorithm, AlgorithmOID, Hash, HashAlgParams, SecurityStrength}; +use bouncycastle_sha2::{SHA256, SHA512}; +use bouncycastle_utils::secret::ZeroizablePrimitive; + +/// `bitlen π‘₯`, the length of the binary expansion of π‘₯ (FIPS 204, Section 2.3). +/// +/// `bitlen 0` is 0; every use below has a positive argument. +pub(crate) const fn bitlen(x: u32) -> usize { + if x == 0 { 0 } else { x.ilog2() as usize + 1 } +} + +/// A fixed-size byte buffer whose length depends on the parameter set. +/// +/// [`ZeroizablePrimitive`] rather than [`Default`] supplies the all-zero value, because `Default` +/// for arrays stops at 32 elements and every buffer here is longer than that. +// todo -- why pub? Are we returning it somewhere in the public API surface? +pub trait ByteBuffer: ZeroizablePrimitive + AsRef<[u8]> + AsMut<[u8]> {} +impl ByteBuffer for [u8; N] {} + +/// A crate-private (aka "sealed") trait that prevents a new ML-DSA parameter set from being defined +/// outside this crate. +trait MLDSAParamsInternalTrait {} + +/// One ML-DSA parameter set: the values of FIPS 204, Table 1 and Table 2, and the types whose size +/// they determine. +/// +/// Sealed via a private supertrait, so [`MLDSA44Params`], [`MLDSA65Params`] and [`MLDSA87Params`] +/// are the only implementations. +pub trait MLDSAParams: MLDSAParamsInternalTrait { + /* FIPS 204, Table 1: the values assigned by each parameter set. */ + + /// 𝜏, the number of Β±1's in the polynomial 𝑐. + const TAU: i32; + /// πœ†, the collision strength of 𝑐̃, in bits. + const LAMBDA: i32; + /// 𝛾1, the coefficient range of 𝐲. Always a power of two. + const GAMMA1: i32; + /// 𝛾2, the low-order rounding range. + const GAMMA2: i32; + /// π‘˜, the number of rows of 𝐀. + const k: usize; + /// β„“, the number of columns of 𝐀. + const l: usize; + /// πœ‚, the private key range. + const ETA: usize; + /// πœ”, the maximum number of 1's in the hint 𝐑. + const OMEGA: i32; + + /* FIPS 204, Table 2: sizes in bytes of keys and signatures. */ + + /// The length of an encoded public key. + const PK_LEN: usize; + /// The length of an encoded private key. + const SK_LEN: usize; + /// The length of a signature. + const SIG_LEN: usize; + + /* Algorithm meta-data. */ + + /// The algorithm name, as reported by `Algorithm::ALG_NAME`. + const ALG_NAME: &'static str; + /// The strength claimed for this parameter set, as reported by `Algorithm::MAX_SECURITY_STRENGTH`. + const MAX_SECURITY_STRENGTH: SecurityStrength; + /// The OID in component form, as reported by `AlgorithmOID::OID`. + const OID: &'static [u32]; + /// The DER encoding of [`MLDSAParams::OID`], as reported by `AlgorithmOID::OID_DER`. + const OID_DER: &'static [u8]; + + /* Derived. Never written out per parameter set -- see the module docs. */ + + /// 𝛽, which FIPS 204, Table 1 defines as "𝛽 = 𝜏 β‹… πœ‚". + const BETA: i32 = Self::TAU * Self::ETA as i32; + + /// The length of the commitment hash 𝑐̃, which FIPS 204, Algorithm 26 (sigEncode) gives as + /// 𝑐̃ ∈ 𝔹^(πœ†/4). + const C_TILDE_LEN: usize = Self::LAMBDA as usize / 4; + + /// The packed length of one coordinate of 𝐳: FIPS 204, Algorithm 26 (sigEncode) writes each of + /// the β„“ coordinates as 𝔹^(32β‹…(1+bitlen (𝛾1βˆ’1))). + /// + /// This is also the number of bytes ExpandMask squeezes per coordinate: FIPS 204, + /// Algorithm 34, line 1 sets 𝑐 ← 1 + bitlen (𝛾1 βˆ’ 1) and line 4 squeezes 32𝑐 bytes. + const POLY_Z_PACKED_LEN: usize = 32 * (1 + bitlen(Self::GAMMA1 as u32 - 1)); + + /// The packed length of one coordinate of 𝐰1: FIPS 204, Algorithm 28 (w1Encode) outputs + /// 𝔹^(32π‘˜β‹…bitlen ((π‘žβˆ’1)/(2𝛾2)βˆ’1)) for all π‘˜ coordinates together. + const POLY_W1_PACKED_LEN: usize = 32 * bitlen(((q - 1) / (2 * Self::GAMMA2)) as u32 - 1); + + /// 𝛾1 βˆ’ 𝛽, the rejection bound on β€–π³β€–βˆž (FIPS 204, Algorithm 7, line 23). + const GAMMA1_MINUS_BETA: i32 = Self::GAMMA1 - Self::BETA; + + /// 𝛾2 βˆ’ 𝛽, the rejection bound on ‖𝐫0β€–βˆž (FIPS 204, Algorithm 7, line 23). + const GAMMA2_MINUS_BETA: i32 = Self::GAMMA2 - Self::BETA; + + /* Types whose size depends on the parameter set. */ + + /// A vector of π‘˜ polynomials, i.e. an element of 𝑅^π‘˜. + type VecK: VectorTrait; + /// A vector of β„“ polynomials, i.e. an element of 𝑅^β„“. + type VecL: VectorTrait; + /// The π‘˜ Γ— β„“ public matrix 𝐀̂. + type MatrixA: MatrixTrait; + + /// The commitment hash 𝑐̃, of [`MLDSAParams::C_TILDE_LEN`] bytes. + type SigCTilde: ByteBuffer; + /// One packed coordinate of 𝐳, of [`MLDSAParams::POLY_Z_PACKED_LEN`] bytes. + /// + /// ExpandMask squeezes into a buffer of this same length; see + /// [`MLDSAParams::POLY_Z_PACKED_LEN`]. + type PolyZPacked: ByteBuffer; + /// One packed coordinate of 𝐰1, of [`MLDSAParams::POLY_W1_PACKED_LEN`] bytes. + type PolyW1Packed: ByteBuffer; +} + +/// The ML-DSA-44 parameter set (FIPS 204, Table 1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MLDSA44Params; +/// The ML-DSA-65 parameter set (FIPS 204, Table 1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MLDSA65Params; +/// The ML-DSA-87 parameter set (FIPS 204, Table 1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MLDSA87Params; + +impl MLDSAParamsInternalTrait for MLDSA44Params {} +impl MLDSAParamsInternalTrait for MLDSA65Params {} +impl MLDSAParamsInternalTrait for MLDSA87Params {} + +impl MLDSAParams for MLDSA44Params { + const TAU: i32 = 39; + const LAMBDA: i32 = 128; + const GAMMA1: i32 = 1 << 17; + // mutants note: because 𝛾1 is applied by bit-shifting, the "- 1" ends up not mattering. + const GAMMA2: i32 = (q - 1) / 88; + const k: usize = 4; + const l: usize = 4; + const ETA: usize = 2; + const OMEGA: i32 = 80; + + const PK_LEN: usize = 1312; + const SK_LEN: usize = 2560; + const SIG_LEN: usize = 2420; + + const ALG_NAME: &'static str = ML_DSA_44_NAME; + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit; + /// Assigned by NIST in the Computer Security Objects Register: id-ml-dsa-44 { sigAlgs 17 } + const OID: &'static [u32] = &[2, 16, 840, 1, 101, 3, 4, 3, 17]; + const OID_DER: &'static [u8] = + &[0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x03, 0x11]; + + type VecK = Vector<4>; + type VecL = Vector<4>; + type MatrixA = Matrix<4, 4>; + + type SigCTilde = [u8; 32]; // πœ†/4 = 128/4 + type PolyZPacked = [u8; 576]; // 32 * (1 + bitlen(2^17 - 1)) = 32 * 18 + type PolyW1Packed = [u8; 192]; // 32 * bitlen(44 - 1) = 32 * 6 +} + +impl MLDSAParams for MLDSA65Params { + const TAU: i32 = 49; + const LAMBDA: i32 = 192; + const GAMMA1: i32 = 1 << 19; + // mutants note: because 𝛾1 is applied by bit-shifting, the "- 1" ends up not mattering. + const GAMMA2: i32 = (q - 1) / 32; + const k: usize = 6; + const l: usize = 5; + const ETA: usize = 4; + const OMEGA: i32 = 55; + + const PK_LEN: usize = 1952; + const SK_LEN: usize = 4032; + const SIG_LEN: usize = 3309; + + const ALG_NAME: &'static str = ML_DSA_65_NAME; + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_192bit; + /// Assigned by NIST in the Computer Security Objects Register: id-ml-dsa-65 { sigAlgs 18 } + const OID: &'static [u32] = &[2, 16, 840, 1, 101, 3, 4, 3, 18]; + const OID_DER: &'static [u8] = + &[0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x03, 0x12]; + + type VecK = Vector<6>; + type VecL = Vector<5>; + type MatrixA = Matrix<6, 5>; + + type SigCTilde = [u8; 48]; // πœ†/4 = 192/4 + type PolyZPacked = [u8; 640]; // 32 * (1 + bitlen(2^19 - 1)) = 32 * 20 + type PolyW1Packed = [u8; 128]; // 32 * bitlen(16 - 1) = 32 * 4 +} + +impl MLDSAParams for MLDSA87Params { + const TAU: i32 = 60; + const LAMBDA: i32 = 256; + const GAMMA1: i32 = 1 << 19; + // mutants note: because 𝛾1 is applied by bit-shifting, the "- 1" ends up not mattering. + const GAMMA2: i32 = (q - 1) / 32; + const k: usize = 8; + const l: usize = 7; + const ETA: usize = 2; + const OMEGA: i32 = 75; + + const PK_LEN: usize = 2592; + const SK_LEN: usize = 4896; + const SIG_LEN: usize = 4627; + + const ALG_NAME: &'static str = ML_DSA_87_NAME; + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_256bit; + /// Assigned by NIST in the Computer Security Objects Register: id-ml-dsa-87 { sigAlgs 19 } + const OID: &'static [u32] = &[2, 16, 840, 1, 101, 3, 4, 3, 19]; + const OID_DER: &'static [u8] = + &[0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x03, 0x13]; + + type VecK = Vector<8>; + type VecL = Vector<7>; + type MatrixA = Matrix<8, 7>; + + type SigCTilde = [u8; 64]; // πœ†/4 = 256/4 + type PolyZPacked = [u8; 640]; // 32 * (1 + bitlen(2^19 - 1)) = 32 * 20 + type PolyW1Packed = [u8; 128]; // 32 * bitlen(16 - 1) = 32 * 4 +} + +/// The two distinct values 𝛾1 takes across the three parameter sets (FIPS 204, Table 1). +/// +/// The bit-packing routines have one layout per distinct 𝛾1, so they dispatch on these rather than +/// on the parameter set. ML-DSA-65 and ML-DSA-87 share the second value. +pub(crate) const GAMMA1_2_POW_17: i32 = MLDSA44Params::GAMMA1; +/// See [`GAMMA1_2_POW_17`]. +pub(crate) const GAMMA1_2_POW_19: i32 = MLDSA65Params::GAMMA1; + +/// The two distinct values 𝛾2 takes across the three parameter sets (FIPS 204, Table 1). +/// +/// As with 𝛾1, the routines that depend on 𝛾2 have one form per distinct value rather than one per +/// parameter set. ML-DSA-65 and ML-DSA-87 share the second value. +pub(crate) const GAMMA2_Q_MINUS_1_OVER_88: i32 = MLDSA44Params::GAMMA2; +/// See [`GAMMA2_Q_MINUS_1_OVER_88`]. +pub(crate) const GAMMA2_Q_MINUS_1_OVER_32: i32 = MLDSA65Params::GAMMA2; + +// todo -- do we really need these? Surely the general test vectors will fail if there are problems here? +/// The weaker of two security strengths. +/// +/// [`SecurityStrength`]'s discriminants are assigned in increasing order of strength, so comparing +/// them as integers orders them. A `const fn` because the strength of a HashML-DSA pairing is a +/// defaulted associated const. +const fn weaker_of(a: SecurityStrength, b: SecurityStrength) -> SecurityStrength { + if (a as u8) <= (b as u8) { a } else { b } +} + +/// A crate-private (aka "sealed") trait that prevents a new HashML-DSA pairing from being defined +/// outside this crate. +trait HashMLDSAParamsInternalTrait {} + +/// One HashML-DSA algorithm: an ML-DSA parameter set paired with a pre-hash function. +/// +/// FIPS 204, Algorithm 4 (HashML-DSA.Sign) leaves the choice of PH open, so an implementation is a +/// pairing rather than a single parameter set. Everything that varies across the pairings lives +/// here, so the algorithm itself takes one type rather than a parameter set plus a hash plus a +/// digest length. +/// +/// Sealed via a private supertrait, so the six types below are the only implementations. +pub trait HashMLDSAParams: HashMLDSAParamsInternalTrait { + /// The ML-DSA parameter set underneath. + type MLDSA: MLDSAParams; + /// PH, the pre-hash function. + type PreHash: Hash + HashAlgParams + AlgorithmOID + Default; + + /// The algorithm name, as reported by `Algorithm::ALG_NAME`. + /// + /// Written out per pairing rather than derived: it is the two component names spliced + /// together, and `&'static str` cannot be concatenated in a const context. + const ALG_NAME: &'static str; + + /* Derived. Never written out per pairing. */ + + /// The length of the pre-hash `ph`, which is just PH's output length. + const PH_LEN: usize = ::OUTPUT_LEN; + + /// The strength claimed for the pairing, as reported by `Algorithm::MAX_SECURITY_STRENGTH`. + /// + /// A HashML-DSA signature is no stronger than either of its two components, so this is the + /// weaker of the two. That is what caps, for example, HashML-DSA-87_with_SHA256 at 128 bits. + const MAX_SECURITY_STRENGTH: SecurityStrength = weaker_of( + ::MAX_SECURITY_STRENGTH, + ::MAX_SECURITY_STRENGTH, + ); +} + +/// The HashML-DSA-44_with_SHA256 pairing. +#[allow(non_camel_case_types)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HashMLDSA44_with_SHA256Params; +/// The HashML-DSA-65_with_SHA256 pairing. +#[allow(non_camel_case_types)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HashMLDSA65_with_SHA256Params; +/// The HashML-DSA-87_with_SHA256 pairing. +#[allow(non_camel_case_types)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HashMLDSA87_with_SHA256Params; +/// The HashML-DSA-44_with_SHA512 pairing. +#[allow(non_camel_case_types)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HashMLDSA44_with_SHA512Params; +/// The HashML-DSA-65_with_SHA512 pairing. +#[allow(non_camel_case_types)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HashMLDSA65_with_SHA512Params; +/// The HashML-DSA-87_with_SHA512 pairing. +#[allow(non_camel_case_types)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HashMLDSA87_with_SHA512Params; + +impl HashMLDSAParamsInternalTrait for HashMLDSA44_with_SHA256Params {} +impl HashMLDSAParamsInternalTrait for HashMLDSA65_with_SHA256Params {} +impl HashMLDSAParamsInternalTrait for HashMLDSA87_with_SHA256Params {} +impl HashMLDSAParamsInternalTrait for HashMLDSA44_with_SHA512Params {} +impl HashMLDSAParamsInternalTrait for HashMLDSA65_with_SHA512Params {} +impl HashMLDSAParamsInternalTrait for HashMLDSA87_with_SHA512Params {} + +impl HashMLDSAParams for HashMLDSA44_with_SHA256Params { + type MLDSA = MLDSA44Params; + type PreHash = SHA256; + const ALG_NAME: &'static str = HASH_ML_DSA_44_with_SHA256_NAME; +} +impl HashMLDSAParams for HashMLDSA65_with_SHA256Params { + type MLDSA = MLDSA65Params; + type PreHash = SHA256; + const ALG_NAME: &'static str = HASH_ML_DSA_65_WITH_SHA256_NAME; +} +impl HashMLDSAParams for HashMLDSA87_with_SHA256Params { + type MLDSA = MLDSA87Params; + type PreHash = SHA256; + const ALG_NAME: &'static str = HASH_ML_DSA_87_with_SHA256_NAME; +} +impl HashMLDSAParams for HashMLDSA44_with_SHA512Params { + type MLDSA = MLDSA44Params; + type PreHash = SHA512; + const ALG_NAME: &'static str = HASH_ML_DSA_44_with_SHA512_NAME; +} +impl HashMLDSAParams for HashMLDSA65_with_SHA512Params { + type MLDSA = MLDSA65Params; + type PreHash = SHA512; + const ALG_NAME: &'static str = HASH_ML_DSA_65_WITH_SHA512_NAME; +} +impl HashMLDSAParams for HashMLDSA87_with_SHA512Params { + type MLDSA = MLDSA87Params; + type PreHash = SHA512; + const ALG_NAME: &'static str = HASH_ML_DSA_87_WITH_SHA512_NAME; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mldsa::d; + + /// FIPS 204, Table 1, transcribed column by column: the eight values each parameter set + /// assigns. `(tau, lambda, gamma1, gamma2, k, l, eta, omega)`. + const TABLE_1: [(i32, i32, i32, i32, usize, usize, usize, i32); 3] = [ + (39, 128, 131072, (q - 1) / 88, 4, 4, 2, 80), + (49, 192, 524288, (q - 1) / 32, 6, 5, 4, 55), + (60, 256, 524288, (q - 1) / 32, 8, 7, 2, 75), + ]; + + /// FIPS 204, Table 2, transcribed row by row: `(private key, public key, signature)` in bytes. + const TABLE_2: [(usize, usize, usize); 3] = + [(2560, 1312, 2420), (4032, 1952, 3309), (4896, 2592, 4627)]; + + /// FIPS 204, Table 1 also tabulates 𝛽, which it labels "𝛽 = 𝜏 β‹… πœ‚". + const TABLE_1_BETA: [i32; 3] = [78, 196, 120]; + + fn check_table_1(i: usize) { + let (tau, lambda, gamma1, gamma2, k, l, eta, omega) = TABLE_1[i]; + assert_eq!(P::TAU, tau, "{}: 𝜏", P::ALG_NAME); + assert_eq!(P::LAMBDA, lambda, "{}: πœ†", P::ALG_NAME); + assert_eq!(P::GAMMA1, gamma1, "{}: 𝛾1", P::ALG_NAME); + assert_eq!(P::GAMMA2, gamma2, "{}: 𝛾2", P::ALG_NAME); + assert_eq!(P::k, k, "{}: π‘˜", P::ALG_NAME); + assert_eq!(P::l, l, "{}: β„“", P::ALG_NAME); + assert_eq!(P::ETA, eta, "{}: πœ‚", P::ALG_NAME); + assert_eq!(P::OMEGA, omega, "{}: πœ”", P::ALG_NAME); + assert_eq!(P::BETA, TABLE_1_BETA[i], "{}: 𝛽 = 𝜏 β‹… πœ‚", P::ALG_NAME); + } + + fn check_table_2(i: usize) { + let (sk_len, pk_len, sig_len) = TABLE_2[i]; + assert_eq!(P::SK_LEN, sk_len, "{}: private key size", P::ALG_NAME); + assert_eq!(P::PK_LEN, pk_len, "{}: public key size", P::ALG_NAME); + assert_eq!(P::SIG_LEN, sig_len, "{}: signature size", P::ALG_NAME); + } + + /// Each of the three sizes of Table 2 also has a formula in FIPS 204, and the two must agree. + /// Table 2 is what is written down above; this is what re-derives it. + fn check_table_2_formulas() { + // Algorithm 22 (pkEncode): π‘π‘˜ ∈ 𝔹^(32+32π‘˜(bitlen (π‘žβˆ’1)βˆ’π‘‘)). + let pk_len = 32 + 32 * P::k * (bitlen((q - 1) as u32) - d as usize); + assert_eq!(P::PK_LEN, pk_len, "{}: Algorithm 22 output size", P::ALG_NAME); + + // Algorithm 24 (skEncode): π‘ π‘˜ ∈ 𝔹^(32+32+64+32β‹…((π‘˜+β„“)β‹…bitlen (2πœ‚)+π‘‘π‘˜)). + let sk_len = + 32 + 32 + 64 + 32 * ((P::k + P::l) * bitlen(2 * P::ETA as u32) + d as usize * P::k); + assert_eq!(P::SK_LEN, sk_len, "{}: Algorithm 24 output size", P::ALG_NAME); + + // Algorithm 26 (sigEncode): 𝜎 ∈ 𝔹^(πœ†/4+β„“β‹…32β‹…(1+bitlen (𝛾1βˆ’1))+πœ”+π‘˜). + let sig_len = P::LAMBDA as usize / 4 + + P::l * 32 * (1 + bitlen(P::GAMMA1 as u32 - 1)) + + P::OMEGA as usize + + P::k; + assert_eq!(P::SIG_LEN, sig_len, "{}: Algorithm 26 output size", P::ALG_NAME); + } + + /// The associated types must be exactly as long as the consts that describe them; they are + /// written out by hand per parameter set, so this guards against a typo in one of them. + fn check_associated_type_sizes() { + assert_eq!( + size_of::(), + P::C_TILDE_LEN, + "{}: SigCTilde vs C_TILDE_LEN", + P::ALG_NAME + ); + assert_eq!( + size_of::(), + P::POLY_Z_PACKED_LEN, + "{}: PolyZPacked vs POLY_Z_PACKED_LEN", + P::ALG_NAME + ); + assert_eq!( + size_of::(), + P::POLY_W1_PACKED_LEN, + "{}: PolyW1Packed vs POLY_W1_PACKED_LEN", + P::ALG_NAME + ); + assert_eq!(::LEN, P::k, "{}: VecK vs π‘˜", P::ALG_NAME); + assert_eq!(::LEN, P::l, "{}: VecL vs β„“", P::ALG_NAME); + } + + #[test] + fn test_parameter_sets_match_fips204_table_1() { + check_table_1::(0); + check_table_1::(1); + check_table_1::(2); + } + + #[test] + fn test_sizes_match_fips204_table_2() { + check_table_2::(0); + check_table_2::(1); + check_table_2::(2); + } + + #[test] + fn test_table_2_sizes_agree_with_the_encoding_formulas() { + check_table_2_formulas::(); + check_table_2_formulas::(); + check_table_2_formulas::(); + } + + #[test] + fn test_associated_types_are_the_length_their_consts_claim() { + check_associated_type_sizes::(); + check_associated_type_sizes::(); + check_associated_type_sizes::(); + } + + #[test] + fn test_derived_lengths_match_the_values_they_replaced() { + // These three were hand-written per parameter set before the derivations above existed. + // They are pinned here so that a change to a `bitlen` formula cannot silently move them. + assert_eq!( + [MLDSA44Params::C_TILDE_LEN, MLDSA65Params::C_TILDE_LEN, MLDSA87Params::C_TILDE_LEN], + [32, 48, 64] + ); + assert_eq!( + [ + MLDSA44Params::POLY_Z_PACKED_LEN, + MLDSA65Params::POLY_Z_PACKED_LEN, + MLDSA87Params::POLY_Z_PACKED_LEN + ], + [576, 640, 640] + ); + assert_eq!( + [ + MLDSA44Params::POLY_W1_PACKED_LEN, + MLDSA65Params::POLY_W1_PACKED_LEN, + MLDSA87Params::POLY_W1_PACKED_LEN + ], + [192, 128, 128] + ); + assert_eq!( + [ + MLDSA44Params::GAMMA1_MINUS_BETA, + MLDSA65Params::GAMMA1_MINUS_BETA, + MLDSA87Params::GAMMA1_MINUS_BETA + ], + [131072 - 78, 524288 - 196, 524288 - 120] + ); + assert_eq!( + [ + MLDSA44Params::GAMMA2_MINUS_BETA, + MLDSA65Params::GAMMA2_MINUS_BETA, + MLDSA87Params::GAMMA2_MINUS_BETA + ], + [(q - 1) / 88 - 78, (q - 1) / 32 - 196, (q - 1) / 32 - 120] + ); + } + + #[test] + fn test_bitlen_matches_its_definition() { + // FIPS 204 Section 2.3 defines bitlen π‘₯ as the length of the binary expansion of π‘₯. + assert_eq!(bitlen(0), 0); + assert_eq!(bitlen(1), 1); + assert_eq!(bitlen(2), 2); + assert_eq!(bitlen(3), 2); + assert_eq!(bitlen(4), 3); + // The two arguments the derivations above actually use, plus bitlen(π‘ž βˆ’ 1) = 23. + assert_eq!(bitlen((1 << 17) - 1), 17); + assert_eq!(bitlen((1 << 19) - 1), 19); + assert_eq!(bitlen((q - 1) as u32), 23); + } + + #[test] + fn test_gamma_dispatch_constants_cover_every_parameter_set() { + // The packing routines dispatch on these; a parameter set whose 𝛾 is neither value would + // fall through to a panic at runtime rather than fail to compile, so pin them here. + for gamma1 in [MLDSA44Params::GAMMA1, MLDSA65Params::GAMMA1, MLDSA87Params::GAMMA1] { + assert!(gamma1 == GAMMA1_2_POW_17 || gamma1 == GAMMA1_2_POW_19); + } + for gamma2 in [MLDSA44Params::GAMMA2, MLDSA65Params::GAMMA2, MLDSA87Params::GAMMA2] { + assert!(gamma2 == GAMMA2_Q_MINUS_1_OVER_88 || gamma2 == GAMMA2_Q_MINUS_1_OVER_32); + } + assert_ne!(GAMMA1_2_POW_17, GAMMA1_2_POW_19); + assert_ne!(GAMMA2_Q_MINUS_1_OVER_88, GAMMA2_Q_MINUS_1_OVER_32); + } +} diff --git a/crypto/mldsa/src/polynomial.rs b/crypto/mldsa/src/polynomial.rs index 98d47125..6c25c925 100644 --- a/crypto/mldsa/src/polynomial.rs +++ b/crypto/mldsa/src/polynomial.rs @@ -3,7 +3,9 @@ use crate::aux_functions::{ ZETAS, conditional_add_q, high_bits, low_bits, make_hint, montgomery_reduce, }; -use crate::mldsa::{MLDSA44_POLY_W1_PACKED_LEN, MLDSA65_POLY_W1_PACKED_LEN, N, q}; +use crate::mldsa::{N, d, q}; +use crate::params::{GAMMA2_Q_MINUS_1_OVER_32, GAMMA2_Q_MINUS_1_OVER_88, MLDSAParams}; +use bouncycastle_utils::secret::ZeroizablePrimitive; use core::ops::{Index, IndexMut}; /// A polynomial over the ML-DSA ring. @@ -12,8 +14,11 @@ use core::ops::{Index, IndexMut}; /// Polynomials themselves are not inherently secret since sometimes they are part of public keys /// and sometimes private keys. /// It is the responsibility of the caller to wrap sensitive instances in `Secret`. +/// +/// Public only because it appears in [`crate::VectorTrait`]'s signatures; its fields and +/// operations are crate-private, so from outside it is an opaque handle. #[derive(Clone, Copy)] -pub(crate) struct Polynomial { +pub struct Polynomial { pub(crate) coeffs: [i32; N], } @@ -34,7 +39,7 @@ impl IndexMut for Polynomial { impl Polynomial { /// Create a new polynomial with all coefficients set to zero. - pub const fn new() -> Self { + pub(crate) const fn new() -> Self { Self { coeffs: [0i32; N] } } @@ -65,25 +70,31 @@ impl Polynomial { } } - pub(crate) fn high_bits(&self) -> Self { + pub(crate) fn high_bits(&self) -> Self { let mut w = Self::new(); for i in 0..N { - w[i] = high_bits::(self[i]); + w[i] = high_bits::

(self[i]); } w } - pub(crate) fn low_bits(&self) -> Self { + pub(crate) fn low_bits(&self) -> Self { let mut w = Self::new(); for i in 0..N { - w[i] = low_bits::(self[i]); + w[i] = low_bits::

(self[i]); } w } - pub(crate) fn check_norm(&self) -> bool { + /// Tests whether any coefficient has absolute value at least `bound`. + /// + /// `bound` is a runtime argument rather than a const generic because every call site passes a + /// value derived from the parameter set (𝛾1 βˆ’ 𝛽, 𝛾2 βˆ’ 𝛽, or 𝛾2), and an associated const of a + /// type parameter cannot be used as a const generic argument. It is still a constant after + /// monomorphization, so this costs nothing. + pub(crate) fn check_norm(&self, bound: i32) -> bool { // It is acceptable that this function is not constant-time (returns true early) // The reason being because it is used in a rejection loop. // That is, the early quit here leads to rejection, dropping the secret values and @@ -95,33 +106,38 @@ impl Polynomial { // if bound > (q - 1) / 8 { // return true; // } - // but since BOUND is a constant here, a debug_assert is performed to make sure the value is what we expect. - debug_assert!(BOUND <= (q - 1) / 8); + // but since every caller passes a parameter-set constant, a debug_assert is performed + // instead to make sure the value is what we expect. + debug_assert!(bound <= (q - 1) / 8); let mut t: i32; for x in self.coeffs.iter() { t = *x >> 31; t = *x - (t & (2 * *x)); - if t >= BOUND { + if t >= bound { return true; } } false } - pub(crate) fn shift_left(&mut self) { + /// Multiplies every coefficient by 2^𝑑. + /// + /// 𝑑 is 13 for all three parameter sets (FIPS 204, Table 1), so it is read from the global + /// constant rather than being passed in. + pub(crate) fn shift_left_d(&mut self) { for x in self.coeffs.iter_mut() { *x <<= d; } } /// Creates the hint vector, and also returns its hamming weight (i.e. the number of 1's). - pub(crate) fn make_hint(&self, r: &Self) -> (Self, i32) { + pub(crate) fn make_hint(&self, r: &Self) -> (Self, i32) { let mut out = Polynomial::new(); let mut count = 0i32; for i in 0..N { - let x = make_hint::(self[i], r[i]); + let x = make_hint::

(self[i], r[i]); out[i] = x; // mutants note: this chains up to hint_hamming_weight > OMEGA and there is no test KAT that triggers this branch @@ -131,19 +147,24 @@ impl Polynomial { (out, count) } - pub(crate) fn w1_encode(&self) -> [u8; POLY_W1_PACKED_LEN] { - let mut r = [0u8; POLY_W1_PACKED_LEN]; + /// SimpleBitPack(𝐰1[𝑖], (π‘ž βˆ’ 1)/(2𝛾2) βˆ’ 1), the per-coordinate body of + /// FIPS 204, Algorithm 28 (w1Encode), line 3. + pub(crate) fn w1_encode(&self) -> P::PolyW1Packed { + let mut out = ::ZEROED; + let r = out.as_mut(); - match POLY_W1_PACKED_LEN { - MLDSA44_POLY_W1_PACKED_LEN => { + match P::GAMMA2 { + // ML-DSA-44: (π‘ž βˆ’ 1)/(2𝛾2) βˆ’ 1 = 43, so four 6-bit coefficients pack into three bytes. + GAMMA2_Q_MINUS_1_OVER_88 => { for i in 0..N / 4 { r[3 * i] = ((self[4 * i]) as u8) | ((self[4 * i + 1] << 6) as u8); r[3 * i + 1] = ((self[4 * i + 1] >> 2) as u8) | ((self[4 * i + 2] << 4) as u8); r[3 * i + 2] = ((self[4 * i + 2] >> 4) as u8) | ((self[4 * i + 3] << 2) as u8); } } - // ML-DSA65 and 87 share a POLY_W1_PACKED_LEN value - MLDSA65_POLY_W1_PACKED_LEN => { + // ML-DSA-65 and ML-DSA-87 share this 𝛾2: (π‘ž βˆ’ 1)/(2𝛾2) βˆ’ 1 = 15, so two 4-bit + // coefficients pack into one byte. + GAMMA2_Q_MINUS_1_OVER_32 => { for i in 0..N / 2 { r[i] = ((self[2 * i]) | (self[2 * i + 1] << 4)) as u8; } @@ -153,7 +174,7 @@ impl Polynomial { } } - r + out } /// Algorithm 41 NTT(𝑀) diff --git a/crypto/mldsa/tests/mldsa_tests.rs b/crypto/mldsa/tests/mldsa_tests.rs index 33aa8f9f..aebd3a06 100644 --- a/crypto/mldsa/tests/mldsa_tests.rs +++ b/crypto/mldsa/tests/mldsa_tests.rs @@ -1071,6 +1071,46 @@ mod mldsa_tests { _ => panic!("Expected an error when loading a SHAKE128 state into a MuBuilder"), } } + + #[test] + fn algorithm_names_and_oids() { + use bouncycastle_core::traits::{Algorithm, AlgorithmOID}; + + // `Algorithm` and `AlgorithmOID` are implemented once, generically over the parameter set, + // so nothing else states these per algorithm. Pinned here so that a wrong wiring of the + // blanket impls, or a typo in a parameter set, is a test failure rather than a silently + // mislabelled algorithm or an unparseable OID. + assert_eq!(MLDSA44::ALG_NAME, "ML-DSA-44"); + assert_eq!(MLDSA65::ALG_NAME, "ML-DSA-65"); + assert_eq!(MLDSA87::ALG_NAME, "ML-DSA-87"); + + assert_eq!(MLDSA44::MAX_SECURITY_STRENGTH, SecurityStrength::_128bit); + assert_eq!(MLDSA65::MAX_SECURITY_STRENGTH, SecurityStrength::_192bit); + assert_eq!(MLDSA87::MAX_SECURITY_STRENGTH, SecurityStrength::_256bit); + + // NIST's Computer Security Objects Register: id-ml-dsa-44 { sigAlgs 17 }, + // id-ml-dsa-65 { sigAlgs 18 }, id-ml-dsa-87 { sigAlgs 19 }, under + // joint-iso-itu-t(2) country(16) us(840) organization(1) gov(101) csor(3) nistAlgorithm(4) + // sigAlgs(3). + assert_eq!(MLDSA44::OID, &[2, 16, 840, 1, 101, 3, 4, 3, 17]); + assert_eq!(MLDSA65::OID, &[2, 16, 840, 1, 101, 3, 4, 3, 18]); + assert_eq!(MLDSA87::OID, &[2, 16, 840, 1, 101, 3, 4, 3, 19]); + + // The DER encodings must be the OBJECT IDENTIFIER (tag 0x06) encodings of those arcs: + // 9 content bytes, the first being 40*2 + 16 = 0x60, then 840 as the two-byte 0x86 0x48. + for (oid, der) in [ + (MLDSA44::OID, MLDSA44::OID_DER), + (MLDSA65::OID, MLDSA65::OID_DER), + (MLDSA87::OID, MLDSA87::OID_DER), + ] { + assert_eq!(der[0], 0x06, "DER tag must be OBJECT IDENTIFIER"); + assert_eq!(der[1] as usize, der.len() - 2, "DER length must match the content"); + assert_eq!( + &der[2..], + &[0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x03, *oid.last().unwrap() as u8] + ); + } + } } struct Kat {