diff --git a/Cargo.toml b/Cargo.toml
index f5b8c7a..1dac7ac 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,6 +11,7 @@ version = "0.1.3"
bouncycastle = { path = "./" }
bouncycastle-aes-lowmemory = { path = "./crypto/aes-lowmemory" }
bouncycastle-base64 = { path = "./crypto/base64" }
+bouncycastle-modes = { path = "./crypto/modes" }
bouncycastle-core = { path = "crypto/core" }
bouncycastle-core-test-framework = { path = "./crypto/core-test-framework" }
bouncycastle-factory = { path = "./crypto/factory" }
@@ -53,6 +54,7 @@ bouncycastle-mldsa.workspace = true
bouncycastle-mldsa-lowmemory.workspace = true
bouncycastle-mlkem.workspace = true
bouncycastle-mlkem-lowmemory.workspace = true
+bouncycastle-modes.workspace = true
bouncycastle-rng.workspace = true
bouncycastle-sha2.workspace = true
bouncycastle-sha3.workspace = true
diff --git a/alpha_0.1.3_release_notes.md b/alpha_0.1.3_release_notes.md
index 3e70e43..dd263ec 100644
--- a/alpha_0.1.3_release_notes.md
+++ b/alpha_0.1.3_release_notes.md
@@ -27,6 +27,83 @@ permutation (NIST FIPS 197), re-exported from the umbrella crate.
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-modes` (`bouncycastle::modes`): block cipher modes of operation
+(NIST SP 800-38A), currently **CBC** (Sec 6.2). Re-exported from the umbrella crate.
+
+* `Cbc
` over any `BlockPermutation`, so the crate depends on no
+ concrete cipher. The direction is a type parameter: `BlockCipherEncryptor` is implemented only
+ for `Cbc<_, Encrypting, _, _>` and `BlockCipherDecryptor` only for `Cbc<_, Decrypting, _, _>`,
+ making a wrong-direction call a compile error rather than a runtime check.
+* **The IV is generated, never accepted.** SP 800-38A Sec 5.3 requires the CBC IV to be
+ *unpredictable*, not merely unique, so `do_encrypt_init` draws one from the library's default
+ OS-backed DRBG (Appendix C's second recommended method) and returns it; there is no API for
+ supplying your own. Known-answer tests drive `do_encrypt_init_rng` with a fixed-output test RNG.
+* **Parallel decryption.** Sec 6.2 notes CBC decryption's inverse cipher calls can run in
+ parallel, so `do_decrypt_blocks[_out]` walks the ciphertext in pairs through
+ `BlockPermutation::decrypt_blocks2`, with a one-block remainder for odd `N`. Measured against an
+ otherwise identical permutation that does not override the pair methods, this is **1.83x** the
+ decryption throughput (67.9 vs 37.1 MiB/s, AES-128, 16 KiB, N=8). CBC encryption is serial by
+ construction and does not use it.
+* Strictly block-aligned, as Sec 5.2 requires of CBC. Arbitrary-length data needs a padding layer,
+ which does not exist in this workspace yet; when it lands, CBC gets it by being wrapped.
+* Verified against all six SP 800-38A Appendix F.2 vectors (CBC-AES128/192/256, Encrypt and
+ Decrypt), each checked in one call, one block at a time, in a `3 + 1` grouping that exercises the
+ pair remainder, and through the `_out` variant. Appendix D error propagation is tested
+ exhaustively for the IV (every one of the 128 bit positions flips exactly its own bit of P1) and
+ for a ciphertext bit error (affects exactly two blocks).
+* Also verified against the **2150 NIST ACVP `ACVP-AES-CBC` AFT cases** from `bc-test-data` (all
+ three key lengths, both directions, 60 of them spanning 2-10 blocks). Each case is run twice --
+ block by block, and in pairs with a one-block remainder -- so the `decrypt_blocks2` path is
+ exercised against real vectors, not only against the toy permutation. Unlike the ECB response
+ file, the CBC one carries only the answer against a `tcId`, so the request and response files are
+ joined; the 6 MCT groups are skipped and the count reported. These vectors were already in
+ `bc-test-data` and previously unused.
+* No CFB yet -- see the crate docs' "Not yet implemented".
+
+`cli`: three new subcommands, `aes128-cbc`, `aes192-cbc` and `aes256-cbc`, each taking `encrypt` or
+`decrypt` and streaming stdin to stdout in 1 KiB chunks.
+
+* Key from `--key` (hex) or `--key-file` (binary or hex), with the usual note that secrets on the
+ command line end up in shell history. The key length must match the variant exactly.
+* **The IV travels in the ciphertext**: since there is no API for supplying one, `encrypt` writes
+ the generated IV as the first 16 bytes of its output and `decrypt` reads it back from the first
+ 16 bytes of its input, so `encrypt | decrypt` composes with no `--iv` flag anywhere. The IV need
+ not be secret (SP 800-38A Sec 5.3), so this is sound.
+* Input must be a whole number of 16-byte blocks. Unaligned input is rejected with a message
+ pointing at the missing padding layer rather than being silently padded.
+* Reads do not respect block boundaries, so a block split across two reads is carried over;
+ verified by round-tripping 64 KiB through `dd bs=3`.
+* Verified against SP 800-38A F.2: prepending the spec's IV to the spec's ciphertext and running
+ `decrypt` reproduces the spec's plaintext for all three key lengths. The `encrypt` direction was
+ cross-checked against an independent CBC implementation under the IV the CLI generated.
+* `cli/tests/aes_cbc_cli_tests.rs` (16 tests) drives the built binary as a subprocess via
+ `CARGO_BIN_EXE_bc-rust`, so all of the above is asserted by `cargo test` rather than by hand:
+ the F.2 vectors, round trips across the chunk boundary, a fresh IV per invocation, hex/binary
+ agreement, `--key-file` in both hex and binary, and every error path with its message.
+
+`core`: new `BlockPermutation` trait (`crypto/core/src/traits.rs`), the raw
+keyed permutation -- `CIPH_K` / `CIPH^-1_K` of SP 800-38A Sec 5.1 -- that a mode is built on.
+`new`, `encrypt_block`, `decrypt_block`, plus provided `encrypt_blocks2` / `decrypt_blocks2` that
+default to two single-block calls and which bit-sliced implementations override. The block methods
+are infallible; only `new` can fail, and only on the key. `bouncycastle-aes-lowmemory` implements
+it for all three key lengths (and `BlockCipher`, which is metadata only and is
+`BlockPermutation`'s supertrait; the data-encryption traits are still deliberately not
+implemented there).
+
+Testing:
+
+* `core-test-framework` gains `TestFrameworkBlockPermutation`, which pins the trait contract:
+ both directions are inverses either way round, the permutation is injective, and the pair
+ methods are indistinguishable from two single-block calls **including their order** -- the check
+ that makes an override safe.
+* Fixed a latent bug in `TestFrameworkBlockCipher`: it unwrapped `set_security_strength` at all
+ five strengths, which a key shorter than 32 bytes cannot carry, so the framework panicked for
+ any 16- or 24-byte key. It now skips the strengths the key length cannot hold. The bug was
+ invisible until now because nothing in the workspace implemented the block cipher traits. The
+ identical loop in `TestFrameworkSymmetricCipher` and `TestFrameworkAEADCipher` is still unfixed;
+ both still have no implementors, so it stays latent. (`TestFrameworkStreamCipher` has no
+ security-strength handling at all and is unaffected.)
+
## Minor features / bug fixes
* bug fixes to the way SHA3/SHAKE handled absorbing and squeezing a partial final byte.
diff --git a/cli/src/aes_cbc_cmd.rs b/cli/src/aes_cbc_cmd.rs
new file mode 100644
index 0000000..40727c8
--- /dev/null
+++ b/cli/src/aes_cbc_cmd.rs
@@ -0,0 +1,323 @@
+//! AES-CBC encryption and decryption, streaming stdin to stdout.
+//!
+//! # The IV travels in the ciphertext
+//!
+//! There is no `--iv` flag, and that is deliberate: `bouncycastle-modes` has no API for a
+//! caller-supplied IV, because NIST SP 800-38A Sec 5.3 requires the CBC IV to be *unpredictable*
+//! rather than merely unique. `encrypt` therefore generates one from the OS-backed DRBG and writes
+//! it as the **first block of the output**; `decrypt` reads it back from the **first block of the
+//! input**. So the two compose directly:
+//!
+//! ```text
+//! bc-rust aes128-cbc encrypt --key-file k.bin < plain.bin > cipher.bin
+//! bc-rust aes128-cbc decrypt --key-file k.bin < cipher.bin > plain.bin
+//! ```
+//!
+//! The IV is not secret (Sec 5.3), so shipping it in the clear is correct. Its *integrity* is not
+//! protected, and neither is the ciphertext's -- see the warning below.
+//!
+//! # Input must be block-aligned
+//!
+//! CBC is defined only on whole blocks (SP 800-38A Sec 5.2), and this workspace has no padding
+//! layer yet, so input that is not a multiple of 16 bytes is rejected rather than silently padded.
+//! Padding is the caller's business until `PaddedEncryptor`/`PaddedDecryptor` land.
+//!
+//! # Binary in, binary out
+//!
+//! stdin is read as binary so the commands compose in a pipeline. `-x` renders the *output* as hex.
+//! For hex input, pipe through `hex-decode` first:
+//!
+//! ```text
+//! cat cipher.hex | bc-rust hex-decode | bc-rust aes256-cbc decrypt --key-file k.bin
+//! ```
+
+use crate::helpers::write_bytes_or_hex;
+use bouncycastle::aes_lowmemory::{Aes128, Aes192, Aes256};
+use bouncycastle::core::key_material::{
+ KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations,
+};
+use bouncycastle::core::traits::{
+ BlockCipherDecryptor, BlockCipherEncryptor, BlockPermutation, SecurityStrength,
+};
+use bouncycastle::hex;
+use bouncycastle::modes::{Cbc, Decrypting, Encrypting};
+use clap::ValueEnum;
+use std::io::{Read, Write};
+use std::process::exit;
+use std::{fs, io};
+
+/// The AES block length in bytes.
+const BLOCK_LEN: usize = 16;
+
+/// Blocks processed per call: 64 blocks = 1 KiB, matching the other streaming commands.
+///
+/// A whole chunk goes through `do_*_blocks[_out]::` in one call, which for decryption
+/// means 32 pairs down the `decrypt_blocks2` path. The at-most-63-block tail at end of input is
+/// flushed one block at a time; it is bounded, so its cost does not scale with the input.
+const CHUNK_BLOCKS: usize = 64;
+
+#[derive(ValueEnum, Clone, Debug)]
+pub(crate) enum AESCBCAction {
+ /// Encrypt stdin to stdout under CBC mode.
+ /// A freshly generated IV is written as the first 16 bytes of the output, so that `decrypt`
+ /// can read it back. Input length must be a multiple of 16 bytes.
+ Encrypt,
+ /// Decrypt stdin to stdout under CBC mode.
+ /// The first 16 bytes of input are taken as the IV, as written by `encrypt`. The remaining
+ /// length must be a multiple of 16 bytes.
+ Decrypt,
+}
+
+pub(crate) fn aes128_cbc_cmd(
+ action: &AESCBCAction,
+ key: &Option,
+ key_file: &Option,
+ output_hex: bool,
+) {
+ let key = load_key::<16>(key, key_file, "AES-128");
+ match action {
+ AESCBCAction::Encrypt => encrypt_stream::(&key, output_hex),
+ AESCBCAction::Decrypt => decrypt_stream::(&key, output_hex),
+ }
+}
+
+pub(crate) fn aes192_cbc_cmd(
+ action: &AESCBCAction,
+ key: &Option,
+ key_file: &Option,
+ output_hex: bool,
+) {
+ let key = load_key::<24>(key, key_file, "AES-192");
+ match action {
+ AESCBCAction::Encrypt => encrypt_stream::(&key, output_hex),
+ AESCBCAction::Decrypt => decrypt_stream::(&key, output_hex),
+ }
+}
+
+pub(crate) fn aes256_cbc_cmd(
+ action: &AESCBCAction,
+ key: &Option,
+ key_file: &Option,
+ output_hex: bool,
+) {
+ let key = load_key::<32>(key, key_file, "AES-256");
+ match action {
+ AESCBCAction::Encrypt => encrypt_stream::(&key, output_hex),
+ AESCBCAction::Decrypt => decrypt_stream::(&key, output_hex),
+ }
+}
+
+/// Loads the key from `--key` (hex) or `--key-file` (binary or hex), and checks its length.
+///
+/// `KEY_LEN` is exact: AES has three key lengths and the command selects one, so a key of the
+/// wrong length is a mistake rather than something to truncate or pad.
+fn load_key(
+ key: &Option,
+ key_file: &Option,
+ alg: &str,
+) -> KeyMaterial {
+ let key_bytes: Vec = if let Some(key_file) = key_file {
+ // A file may hold raw bytes or hex; try hex first, as the other commands do.
+ let raw = fs::read(key_file).unwrap_or_else(|e| {
+ eprintln!("Error: couldn't read key file '{key_file}': {e}");
+ exit(-1);
+ });
+ match hex::decode(&raw) {
+ Ok(decoded) => decoded,
+ Err(_) => raw,
+ }
+ } else if let Some(key) = key {
+ hex::decode(key).unwrap_or_else(|_| {
+ eprintln!("Error: `--key` must be hex. Use `--key-file` for raw bytes.");
+ exit(-1);
+ })
+ } else {
+ eprintln!("Error: either `--key` or `--key-file` must be supplied.");
+ exit(-1);
+ };
+
+ if key_bytes.len() != KEY_LEN {
+ eprintln!("Error: {alg} needs a {KEY_LEN}-byte key, got {} bytes.", key_bytes.len());
+ exit(-1);
+ }
+
+ // `from_bytes_as_type` tags the key at the strength its length implies, which is exactly what
+ // the engine requires -- except for an all-zero key, which it marks Zeroized instead.
+ let mut key =
+ KeyMaterial::::from_bytes_as_type(&key_bytes, KeyType::SymmetricCipherKey)
+ .unwrap_or_else(|e| {
+ eprintln!("Error: couldn't load the key: {e:?}");
+ exit(-1);
+ });
+
+ if key.key_type() != KeyType::SymmetricCipherKey {
+ // Same stance as `helpers::parse_seed`: warn, then do what was asked. A CLI is used for
+ // test vectors and scripting, where an all-zero key is a legitimate thing to want.
+ eprintln!(
+ "Warning: all-zero (or otherwise zeroized) key provided. Proceeding, but this is not secure."
+ );
+ do_hazardous_operations(&mut key, |key| {
+ key.set_key_type(KeyType::SymmetricCipherKey)?;
+ key.set_security_strength(SecurityStrength::from_bytes(KEY_LEN))
+ })
+ .unwrap_or_else(|e| {
+ eprintln!("Error: couldn't tag the key: {e:?}");
+ exit(-1);
+ });
+ }
+
+ key
+}
+
+/// Encrypts stdin to stdout, writing the generated IV first.
+fn encrypt_stream(key: &KeyMaterial, output_hex: bool)
+where
+ P: BlockPermutation,
+{
+ let (mut enc, iv) = Cbc::::do_encrypt_init(key)
+ .unwrap_or_else(|e| {
+ eprintln!("Error: couldn't start encryption: {e:?}");
+ exit(-1);
+ });
+
+ // The IV goes out ahead of the ciphertext, so `decrypt` can pick it up.
+ write_bytes_or_hex(&iv, output_hex);
+
+ let mut out = [[0u8; BLOCK_LEN]; CHUNK_BLOCKS];
+
+ stream_blocks(|blocks| match <&[[u8; BLOCK_LEN]; CHUNK_BLOCKS]>::try_from(blocks) {
+ Ok(full_chunk) => {
+ // Cannot fail: the mode's block methods are infallible for a constructed value.
+ enc.do_encrypt_blocks_out(full_chunk, &mut out).unwrap();
+ write_blocks(&out, output_hex);
+ }
+ Err(_) => {
+ // The bounded tail at end of input.
+ for block in blocks.iter() {
+ let [c] = enc.do_encrypt_blocks(&[*block]).unwrap();
+ write_bytes_or_hex(&c, output_hex);
+ }
+ }
+ });
+
+ finish(output_hex);
+}
+
+/// Decrypts stdin to stdout, taking the IV from the first block of input.
+fn decrypt_stream
(key: &KeyMaterial, output_hex: bool)
+where
+ P: BlockPermutation,
+{
+ // The leading block is the IV, not ciphertext.
+ let mut iv = [0u8; BLOCK_LEN];
+ if let Err(e) = io::stdin().read_exact(&mut iv) {
+ eprintln!(
+ "Error: input too short to contain the {BLOCK_LEN}-byte IV that `encrypt` writes \
+ as its first block ({e})."
+ );
+ exit(-1);
+ }
+
+ let mut dec = Cbc::::do_decrypt_init(key, &iv)
+ .unwrap_or_else(|e| {
+ eprintln!("Error: couldn't start decryption: {e:?}");
+ exit(-1);
+ });
+
+ let mut out = [[0u8; BLOCK_LEN]; CHUNK_BLOCKS];
+
+ stream_blocks(|blocks| match <&[[u8; BLOCK_LEN]; CHUNK_BLOCKS]>::try_from(blocks) {
+ Ok(full_chunk) => {
+ // A full chunk is 32 pairs, so this is the `decrypt_blocks2` path.
+ dec.do_decrypt_blocks_out(full_chunk, &mut out).unwrap();
+ write_blocks(&out, output_hex);
+ }
+ Err(_) => {
+ for block in blocks.iter() {
+ let [p] = dec.do_decrypt_blocks(&[*block]).unwrap();
+ write_bytes_or_hex(&p, output_hex);
+ }
+ }
+ });
+
+ finish(output_hex);
+}
+
+/// Reads stdin a block at a time, calling `process` with a full `CHUNK_BLOCKS` slice whenever one
+/// is available and once more at end of input with whatever whole blocks remain.
+///
+/// `process` therefore sees a slice of exactly `CHUNK_BLOCKS` for every call but the last, which is
+/// how the callers can hand a fixed-size array to `do_*_blocks_out::` and fall back
+/// to single blocks only for the bounded tail.
+///
+/// Reads do not respect block boundaries, so a block can arrive split across two reads; the
+/// partial block is carried over rather than assumed complete. Input whose total length is not a
+/// multiple of `BLOCK_LEN` is an error, because CBC is not defined on a partial block and there is
+/// no padding layer to appeal to.
+fn stream_blocks(mut process: impl FnMut(&[[u8; BLOCK_LEN]])) {
+ let mut staged = [[0u8; BLOCK_LEN]; CHUNK_BLOCKS];
+ let mut read_buf = [0u8; BLOCK_LEN * CHUNK_BLOCKS];
+ let mut partial = [0u8; BLOCK_LEN];
+ let mut partial_len = 0usize;
+ let mut blocks = 0usize;
+
+ loop {
+ let n = io::stdin().read(&mut read_buf).unwrap_or_else(|e| {
+ eprintln!("Error: failed to read from stdin: {e}");
+ exit(-1);
+ });
+ if n == 0 {
+ break;
+ }
+
+ let mut src = &read_buf[..n];
+ while !src.is_empty() {
+ let take = core::cmp::min(BLOCK_LEN - partial_len, src.len());
+ partial[partial_len..partial_len + take].copy_from_slice(&src[..take]);
+ partial_len += take;
+ src = &src[take..];
+
+ if partial_len == BLOCK_LEN {
+ staged[blocks] = partial;
+ blocks += 1;
+ partial_len = 0;
+
+ if blocks == CHUNK_BLOCKS {
+ process(&staged);
+ blocks = 0;
+ }
+ }
+ }
+ }
+
+ if partial_len != 0 {
+ eprintln!(
+ "Error: input is not a whole number of {BLOCK_LEN}-byte blocks ({partial_len} \
+ trailing byte(s)). CBC is defined only on whole blocks (SP 800-38A Sec 5.2), and \
+ this build has no padding layer, so the input must be padded by the caller."
+ );
+ exit(-1);
+ }
+
+ if blocks != 0 {
+ process(&staged[..blocks]);
+ }
+}
+
+/// Writes a run of whole blocks.
+fn write_blocks(blocks: &[[u8; BLOCK_LEN]], output_hex: bool) {
+ for block in blocks.iter() {
+ write_bytes_or_hex(block, output_hex);
+ }
+}
+
+/// Flushes stdout, and adds the trailing newline the hex-output commands all emit.
+fn finish(output_hex: bool) {
+ if output_hex {
+ println!();
+ }
+ io::stdout().flush().unwrap_or_else(|e| {
+ eprintln!("Error: failed to flush stdout: {e}");
+ exit(-1);
+ });
+}
diff --git a/cli/src/main.rs b/cli/src/main.rs
index c72af13..b7ade95 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -1,3 +1,4 @@
+mod aes_cbc_cmd;
mod encoders_cmd;
mod helpers;
mod hkdf_cmd;
@@ -8,6 +9,7 @@ mod rng_cmd;
mod sha2_cmd;
mod sha3_cmd;
+use crate::aes_cbc_cmd::AESCBCAction;
use crate::mac_cmd::HMACVariant;
use crate::mldsa_cmd::MLDSAAction;
use clap::{Parser, Subcommand};
@@ -271,6 +273,83 @@ enum Subcommands {
x: bool,
},
+ /// AES-128 in CBC mode (NIST SP 800-38A Sec 6.2), streaming stdin to stdout.
+ ///
+ /// On `encrypt`, a fresh unpredictable IV is generated and written as the FIRST 16 BYTES of
+ /// the output; on `decrypt` it is read back from the first 16 bytes of the input, so the two
+ /// compose directly in a pipeline. There is deliberately no `--iv` flag.
+ ///
+ /// Input must be a whole number of 16-byte blocks: CBC is defined only on whole blocks and
+ /// this build has no padding layer, so unaligned input is rejected rather than padded.
+ ///
+ /// WARNING: CBC provides confidentiality only. It does not detect tampering, and neither the
+ /// ciphertext nor the IV is authenticated. Do not decrypt data you have not authenticated
+ /// separately.
+ ///
+ /// Note: in production uses, secrets should not be passed on the command-line because they get
+ /// logged in shell history. Use the file-based input instead.
+ AES128_CBC {
+ action: AESCBCAction,
+
+ /// The 16-byte AES key in hex.
+ /// The `key_file` option is preferred to avoid leaving key material in command history.
+ #[arg(long)]
+ key: Option,
+
+ /// A file containing the 16-byte AES key, in binary or hex.
+ /// If both key and key_file options are provided, the file will be used.
+ #[arg(short, long)]
+ key_file: Option,
+
+ #[arg(short)]
+ /// Output in hex format.
+ x: bool,
+ },
+
+ /// AES-192 in CBC mode (NIST SP 800-38A Sec 6.2), streaming stdin to stdout.
+ ///
+ /// See `aes128-cbc` for the IV convention, block-alignment requirement and warnings; only the
+ /// key length differs.
+ AES192_CBC {
+ action: AESCBCAction,
+
+ /// The 24-byte AES key in hex.
+ /// The `key_file` option is preferred to avoid leaving key material in command history.
+ #[arg(long)]
+ key: Option,
+
+ /// A file containing the 24-byte AES key, in binary or hex.
+ /// If both key and key_file options are provided, the file will be used.
+ #[arg(short, long)]
+ key_file: Option,
+
+ #[arg(short)]
+ /// Output in hex format.
+ x: bool,
+ },
+
+ /// AES-256 in CBC mode (NIST SP 800-38A Sec 6.2), streaming stdin to stdout.
+ ///
+ /// See `aes128-cbc` for the IV convention, block-alignment requirement and warnings; only the
+ /// key length differs.
+ AES256_CBC {
+ action: AESCBCAction,
+
+ /// The 32-byte AES key in hex.
+ /// The `key_file` option is preferred to avoid leaving key material in command history.
+ #[arg(long)]
+ key: Option,
+
+ /// A file containing the 32-byte AES key, in binary or hex.
+ /// If both key and key_file options are provided, the file will be used.
+ #[arg(short, long)]
+ key_file: Option,
+
+ #[arg(short)]
+ /// Output in hex format.
+ x: bool,
+ },
+
/// The ML-KEM-512 key encapsulation algorithm.
MLKEM512 {
action: mlkem_cmd::MLKEMAction,
@@ -564,6 +643,15 @@ fn main() {
*len, *x,
),
Some(Subcommands::RNG { len, x }) => rng_cmd::rng_cmd(*len, *x),
+ Some(Subcommands::AES128_CBC { action, key, key_file, x }) => {
+ aes_cbc_cmd::aes128_cbc_cmd(action, key, key_file, *x);
+ }
+ Some(Subcommands::AES192_CBC { action, key, key_file, x }) => {
+ aes_cbc_cmd::aes192_cbc_cmd(action, key, key_file, *x);
+ }
+ Some(Subcommands::AES256_CBC { action, key, key_file, x }) => {
+ aes_cbc_cmd::aes256_cbc_cmd(action, key, key_file, *x);
+ }
Some(Subcommands::MLKEM512 { action, skfile, pkfile, ctfile, x }) => {
mlkem_cmd::mlkem512_cmd(action, skfile, pkfile, ctfile, *x);
}
diff --git a/cli/tests/aes_cbc_cli_tests.rs b/cli/tests/aes_cbc_cli_tests.rs
new file mode 100644
index 0000000..9dcea30
--- /dev/null
+++ b/cli/tests/aes_cbc_cli_tests.rs
@@ -0,0 +1,362 @@
+//! Tests for the `aes128-cbc` / `aes192-cbc` / `aes256-cbc` subcommands.
+//!
+//! These drive the built `bc-rust` binary as a subprocess, because the behaviour worth testing is
+//! the command-line contract itself -- the IV riding in the first block, block-alignment
+//! enforcement, exit codes, key loading -- none of which is reachable from the library API.
+//!
+//! `CARGO_BIN_EXE_bc-rust` is set by cargo for integration tests and points at the binary for the
+//! current profile, so there is nothing to build or locate by hand.
+
+use std::io::Write;
+use std::process::{Command, Output, Stdio};
+
+/// The path to the binary under test, resolved by cargo.
+const BC_RUST: &str = env!("CARGO_BIN_EXE_bc-rust");
+
+/// SP 800-38A Appendix F IV, shared by every F.2 subsection.
+const IV: &str = "000102030405060708090a0b0c0d0e0f";
+
+/// The four SP 800-38A Appendix F plaintext blocks.
+const PLAINTEXT: &str = concat!(
+ "6bc1bee22e409f96e93d7e117393172a",
+ "ae2d8a571e03ac9c9eb76fac45af8e51",
+ "30c81c46a35ce411e5fbc1191a0a52ef",
+ "f69f2445df4f9b17ad2b417be66c3710",
+);
+
+const KEY_128: &str = "2b7e151628aed2a6abf7158809cf4f3c";
+const KEY_192: &str = "8e73b0f7da0e6452c810f32b809079e562f8ead2522c6b7b";
+const KEY_256: &str = "603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4";
+
+/// F.2.1 CBC-AES128.Encrypt ciphertext.
+const CT_128: &str = concat!(
+ "7649abac8119b246cee98e9b12e9197d",
+ "5086cb9b507219ee95db113a917678b2",
+ "73bed6b8e3c1743b7116e69e22229516",
+ "3ff1caa1681fac09120eca307586e1a7",
+);
+/// F.2.3 CBC-AES192.Encrypt ciphertext.
+const CT_192: &str = concat!(
+ "4f021db243bc633d7178183a9fa071e8",
+ "b4d9ada9ad7dedf4e5e738763f69145a",
+ "571b242012fb7ae07fa9baac3df102e0",
+ "08b0e27988598881d920a9e64f5615cd",
+);
+/// F.2.5 CBC-AES256.Encrypt ciphertext.
+const CT_256: &str = concat!(
+ "f58c4c04d6e5f1ba779eabfb5f7bfbd6",
+ "9cfc4e967edb808d679f777bc6702c7d",
+ "39f23369a9d9bacfa530e26304231461",
+ "b2eb05e2c39be9fcda6c19078c6a9d1b",
+);
+
+/// Runs `bc-rust ` with `stdin_bytes` on stdin and returns the completed output.
+fn run(args: &[&str], stdin_bytes: &[u8]) -> Output {
+ let mut child = Command::new(BC_RUST)
+ .args(args)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .expect("failed to spawn bc-rust");
+
+ child
+ .stdin
+ .as_mut()
+ .expect("stdin piped")
+ .write_all(stdin_bytes)
+ .expect("failed to write to stdin");
+
+ child.wait_with_output().expect("failed to wait for bc-rust")
+}
+
+/// Runs a command that is expected to succeed, returning stdout.
+fn run_ok(args: &[&str], stdin_bytes: &[u8]) -> Vec {
+ let out = run(args, stdin_bytes);
+ assert!(
+ out.status.success(),
+ "expected success from {args:?}, got {:?}\nstderr: {}",
+ out.status,
+ String::from_utf8_lossy(&out.stderr)
+ );
+ out.stdout
+}
+
+/// Runs a command that is expected to fail, returning stderr as a string.
+fn run_err(args: &[&str], stdin_bytes: &[u8]) -> String {
+ let out = run(args, stdin_bytes);
+ assert!(
+ !out.status.success(),
+ "expected failure from {args:?}, but it succeeded\nstdout: {:?}",
+ String::from_utf8_lossy(&out.stdout)
+ );
+ String::from_utf8_lossy(&out.stderr).into_owned()
+}
+
+fn unhex(s: &str) -> Vec {
+ assert!(s.len().is_multiple_of(2), "hex string must have even length");
+ (0..s.len())
+ .step_by(2)
+ .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex"))
+ .collect()
+}
+
+fn tohex(bytes: &[u8]) -> String {
+ bytes.iter().map(|b| format!("{b:02x}")).collect()
+}
+
+/// Deterministic pseudo-random bytes, so the tests do not depend on an RNG or on `/dev/urandom`.
+fn pseudo_random(len: usize, seed: u32) -> Vec {
+ let mut state = seed.wrapping_mul(2_654_435_761).wrapping_add(1);
+ (0..len)
+ .map(|_| {
+ state ^= state << 13;
+ state ^= state >> 17;
+ state ^= state << 5;
+ (state >> 24) as u8
+ })
+ .collect()
+}
+
+// ---- the SP 800-38A F.2 vectors, through the CLI -----------------------------------------
+
+/// `decrypt` reproduces the spec plaintext when handed the spec's IV followed by the spec's
+/// ciphertext.
+///
+/// This is the direction that can be pinned exactly: `encrypt` picks its own IV, so it cannot be
+/// asked to reproduce a published ciphertext. `encrypt` is covered by the round-trip tests below
+/// and, at the library level, by `crypto/modes/tests/sp800_38a_tests.rs`.
+#[test]
+fn decrypt_matches_sp800_38a_f2_vectors() {
+ for (cmd, key, ct) in [
+ ("aes128-cbc", KEY_128, CT_128),
+ ("aes192-cbc", KEY_192, CT_192),
+ ("aes256-cbc", KEY_256, CT_256),
+ ] {
+ // The CLI expects the IV as the first block of its input, which is exactly how `encrypt`
+ // emits it.
+ let input = unhex(&format!("{IV}{ct}"));
+ let out = run_ok(&[cmd, "decrypt", "--key", key], &input);
+ assert_eq!(
+ tohex(&out),
+ PLAINTEXT,
+ "{cmd} decrypt should reproduce the Appendix F.2 plaintext"
+ );
+ }
+}
+
+/// The same, with `-x`, which should give the identical answer in hex plus a trailing newline.
+#[test]
+fn hex_output_matches_binary_output() {
+ let input = unhex(&format!("{IV}{CT_128}"));
+ let binary = run_ok(&["aes128-cbc", "decrypt", "--key", KEY_128], &input);
+ let hex_out = run_ok(&["aes128-cbc", "decrypt", "--key", KEY_128, "-x"], &input);
+
+ let hex_str = String::from_utf8(hex_out).expect("hex output is text");
+ assert_eq!(hex_str.trim_end(), tohex(&binary));
+ assert_eq!(hex_str.trim_end(), PLAINTEXT);
+}
+
+// ---- round trips ------------------------------------------------------------------------
+
+/// `encrypt | decrypt` recovers the input, for all three key lengths.
+///
+/// Also checks the output length: the ciphertext is one block longer than the plaintext, because
+/// the IV is prepended.
+#[test]
+fn encrypt_then_decrypt_round_trips() {
+ for (cmd, key) in [("aes128-cbc", KEY_128), ("aes192-cbc", KEY_192), ("aes256-cbc", KEY_256)] {
+ let plaintext = unhex(PLAINTEXT);
+ let ciphertext = run_ok(&[cmd, "encrypt", "--key", key], &plaintext);
+ assert_eq!(
+ ciphertext.len(),
+ plaintext.len() + 16,
+ "{cmd}: output should be the 16-byte IV plus the ciphertext"
+ );
+
+ let recovered = run_ok(&[cmd, "decrypt", "--key", key], &ciphertext);
+ assert_eq!(recovered, plaintext, "{cmd}: round trip");
+ }
+}
+
+/// Round trips at sizes that straddle the 1 KiB streaming chunk and the block boundary.
+///
+/// 1024 is exactly one chunk; 1040 is a chunk plus one block, which exercises the tail path; 4112
+/// is four chunks plus a block; 65536 is many chunks.
+#[test]
+fn round_trips_across_chunk_boundaries() {
+ for size in [16usize, 32, 1024, 1040, 4096, 4112, 65536] {
+ let plaintext = pseudo_random(size, size as u32);
+ let ciphertext = run_ok(&["aes128-cbc", "encrypt", "--key", KEY_128], &plaintext);
+ let recovered = run_ok(&["aes128-cbc", "decrypt", "--key", KEY_128], &ciphertext);
+ assert_eq!(recovered, plaintext, "{size} bytes should round trip");
+ }
+}
+
+/// A fresh IV per invocation, so the same plaintext under the same key gives different output.
+///
+/// This is the operational requirement CBC lives or dies by, and the CLI is where it is easiest to
+/// get wrong (e.g. by seeding from a fixed value).
+#[test]
+fn each_invocation_uses_a_fresh_iv() {
+ let plaintext = unhex(PLAINTEXT);
+ let mut seen = std::collections::BTreeSet::new();
+
+ for _ in 0..8 {
+ let ciphertext = run_ok(&["aes128-cbc", "encrypt", "--key", KEY_128], &plaintext);
+ let iv = ciphertext[..16].to_vec();
+ assert!(seen.insert(iv), "the CLI reused an IV across invocations");
+ // ...and the body differs too, not just the IV.
+ let recovered = run_ok(&["aes128-cbc", "decrypt", "--key", KEY_128], &ciphertext);
+ assert_eq!(recovered, plaintext);
+ }
+}
+
+// ---- key handling -----------------------------------------------------------------------
+
+/// `--key-file` accepts both a hex file and a raw binary file, and agrees with `--key`.
+#[test]
+fn key_file_accepts_hex_and_binary() {
+ let dir = std::env::temp_dir().join(format!("bc_rust_cli_key_{}", std::process::id()));
+ std::fs::create_dir_all(&dir).expect("create temp dir");
+
+ let hex_path = dir.join("key.hex");
+ let bin_path = dir.join("key.bin");
+ std::fs::write(&hex_path, KEY_128).expect("write hex key");
+ std::fs::write(&bin_path, unhex(KEY_128)).expect("write binary key");
+
+ let input = unhex(&format!("{IV}{CT_128}"));
+ let expected = unhex(PLAINTEXT);
+
+ for path in [&hex_path, &bin_path] {
+ let out = run_ok(&["aes128-cbc", "decrypt", "--key-file", path.to_str().unwrap()], &input);
+ assert_eq!(out, expected, "--key-file {path:?}");
+ }
+
+ std::fs::remove_dir_all(&dir).ok();
+}
+
+/// A key of the wrong length for the chosen variant is rejected, naming both lengths.
+#[test]
+fn a_key_of_the_wrong_length_is_rejected() {
+ let stderr = run_err(&["aes256-cbc", "encrypt", "--key", KEY_128], &unhex(PLAINTEXT));
+ assert!(stderr.contains("32-byte key"), "stderr should name the expected length: {stderr}");
+ assert!(stderr.contains("16 bytes"), "stderr should name the supplied length: {stderr}");
+}
+
+/// Omitting the key entirely is an error, not a default.
+#[test]
+fn a_missing_key_is_rejected() {
+ let stderr = run_err(&["aes128-cbc", "encrypt"], &unhex(PLAINTEXT));
+ assert!(stderr.contains("--key"), "stderr should mention the key options: {stderr}");
+}
+
+/// An all-zero key warns but proceeds, matching `helpers::parse_seed`'s stance. NIST publishes
+/// all-zero-key vectors, so refusing outright would make some of them untestable from the CLI.
+#[test]
+fn an_all_zero_key_warns_but_proceeds() {
+ let zero_key = "0".repeat(32);
+ let out = run(&["aes128-cbc", "encrypt", "--key", &zero_key], &unhex(PLAINTEXT));
+ assert!(out.status.success(), "an all-zero key should still work");
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ assert!(stderr.to_lowercase().contains("warning"), "an all-zero key should warn: {stderr}");
+ assert_eq!(out.stdout.len(), 16 + 64, "IV plus four ciphertext blocks");
+}
+
+// ---- block alignment and framing --------------------------------------------------------
+
+/// Input that is not a whole number of blocks is rejected, with a message that explains why
+/// rather than just failing. CBC has no answer for a partial block and there is no padding layer.
+#[test]
+fn unaligned_input_is_rejected_with_an_explanation() {
+ for extra in [1usize, 7, 15] {
+ let plaintext = pseudo_random(32 + extra, extra as u32);
+ let stderr = run_err(&["aes128-cbc", "encrypt", "--key", KEY_128], &plaintext);
+ assert!(
+ stderr.contains("whole number of 16-byte blocks"),
+ "stderr should explain the alignment requirement: {stderr}"
+ );
+ assert!(
+ stderr.contains("padding"),
+ "stderr should point at the missing padding layer: {stderr}"
+ );
+ }
+}
+
+/// Decrypt input shorter than the IV it must start with is rejected, and says so.
+#[test]
+fn decrypt_input_shorter_than_the_iv_is_rejected() {
+ for len in [0usize, 1, 15] {
+ let stderr = run_err(&["aes128-cbc", "decrypt", "--key", KEY_128], &pseudo_random(len, 1));
+ assert!(
+ stderr.contains("IV"),
+ "stderr should explain the missing IV (len {len}): {stderr}"
+ );
+ }
+}
+
+/// Decrypt input that carries the IV but then an unaligned body is rejected too.
+#[test]
+fn decrypt_rejects_an_unaligned_body() {
+ let mut input = unhex(IV);
+ input.extend_from_slice(&pseudo_random(20, 3)); // 20 is not a multiple of 16
+ let stderr = run_err(&["aes128-cbc", "decrypt", "--key", KEY_128], &input);
+ assert!(
+ stderr.contains("whole number of 16-byte blocks"),
+ "stderr should explain the alignment requirement: {stderr}"
+ );
+}
+
+/// Empty input to `encrypt` produces just the IV: zero blocks in, zero blocks out.
+///
+/// Worth pinning because it is the one input length that is block-aligned but has no blocks, and
+/// it is easy for a streaming loop to mishandle.
+#[test]
+fn empty_input_produces_only_the_iv() {
+ let out = run_ok(&["aes128-cbc", "encrypt", "--key", KEY_128], &[]);
+ assert_eq!(out.len(), 16, "empty input should yield exactly the IV");
+
+ // ...and feeding that straight back gives empty output.
+ let back = run_ok(&["aes128-cbc", "decrypt", "--key", KEY_128], &out);
+ assert!(back.is_empty(), "decrypting an IV with no body should give nothing");
+}
+
+// ---- cross-variant behaviour ------------------------------------------------------------
+
+/// Decrypting with a different key length than was used to encrypt cannot succeed silently.
+#[test]
+fn the_three_variants_are_not_interchangeable() {
+ let plaintext = unhex(PLAINTEXT);
+ let ciphertext = run_ok(&["aes128-cbc", "encrypt", "--key", KEY_128], &plaintext);
+
+ // Right length, wrong key: decryption "succeeds" but must not recover the plaintext. CBC is
+ // unauthenticated, so garbage out is the expected behaviour, not an error -- which is exactly
+ // why the crate docs insist on authenticating separately.
+ let wrong_key = "ff".repeat(16);
+ let out = run_ok(&["aes128-cbc", "decrypt", "--key", &wrong_key], &ciphertext);
+ assert_ne!(out, plaintext, "a wrong key must not recover the plaintext");
+ assert_eq!(out.len(), plaintext.len(), "but the length is unchanged: CBC is unauthenticated");
+}
+
+/// The subcommands appear in `--help`, so they are discoverable.
+#[test]
+fn the_subcommands_are_listed_in_help() {
+ let out = run_ok(&["--help"], &[]);
+ let help = String::from_utf8_lossy(&out);
+ for cmd in ["aes128-cbc", "aes192-cbc", "aes256-cbc"] {
+ assert!(help.contains(cmd), "`--help` should list {cmd}");
+ }
+}
+
+/// Each subcommand's own help names the two actions and the IV convention.
+#[test]
+fn per_command_help_documents_the_iv_convention() {
+ let out = run_ok(&["aes128-cbc", "--help"], &[]);
+ let help = String::from_utf8_lossy(&out);
+ assert!(help.contains("encrypt"), "help should list the encrypt action");
+ assert!(help.contains("decrypt"), "help should list the decrypt action");
+ assert!(
+ help.contains("FIRST 16 BYTES") || help.contains("first 16 bytes"),
+ "help should explain where the IV goes: {help}"
+ );
+}
diff --git a/crypto/aes-lowmemory/Cargo.toml b/crypto/aes-lowmemory/Cargo.toml
index 07fdc78..93316d4 100644
--- a/crypto/aes-lowmemory/Cargo.toml
+++ b/crypto/aes-lowmemory/Cargo.toml
@@ -8,6 +8,7 @@ bouncycastle-core.workspace = true
bouncycastle-utils.workspace = true
[dev-dependencies]
+bouncycastle-core-test-framework.workspace = true
bouncycastle-hex.workspace = true
bouncycastle-rng.workspace = true
criterion.workspace = true
diff --git a/crypto/aes-lowmemory/src/aes.rs b/crypto/aes-lowmemory/src/aes.rs
index b1003cf..1b889ab 100644
--- a/crypto/aes-lowmemory/src/aes.rs
+++ b/crypto/aes-lowmemory/src/aes.rs
@@ -6,7 +6,7 @@ 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_core::traits::{Algorithm, BlockCipher, BlockPermutation, SecurityStrength};
use bouncycastle_utils::secret::Secret;
/// The AES block length in bytes: 16 (FIPS 197 Sec 3.4, `Nb` = 4 words).
@@ -221,6 +221,89 @@ impl Algorithm for Aes256 {
const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_256bit;
}
+// `BlockCipher` here is metadata only -- it declares `MAX_SECURITY_STRENGTH` and nothing else, and
+// it is the supertrait `BlockPermutation` requires. It is *not* one of the data-encryption traits
+// (`SymmetricCipher`, `BlockCipherEncryptor`, `BlockCipherDecryptor`, `AEADCipher`), which this
+// crate still deliberately does not implement: those are mode-of-operation concerns. See the crate
+// docs.
+//
+// Both `Algorithm` and `BlockCipher` declare `MAX_SECURITY_STRENGTH`, so a bare
+// `Aes128::MAX_SECURITY_STRENGTH` is ambiguous; qualify it as `::...` or
+// `::...` at the use site.
+
+impl BlockCipher for Aes128 {
+ const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit;
+}
+
+impl BlockCipher for Aes192 {
+ const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_192bit;
+}
+
+impl BlockCipher for Aes256 {
+ const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_256bit;
+}
+
+// The three `BlockPermutation` impls are one-line delegations to the inherent methods above. They
+// are written out longhand rather than generated, for the `cargo mutants` reason given above.
+//
+// Each overrides `encrypt_blocks2` / `decrypt_blocks2`, because a pair of blocks is exactly what
+// the bit-sliced state holds: the pair form costs barely more than one block, where the default
+// (two single-block calls) would do four blocks' worth of work.
+
+impl BlockPermutation<16, BLOCK_LEN> for Aes128 {
+ fn new(key: &KeyMaterial<16>) -> Result {
+ Aes128::new(key)
+ }
+ fn encrypt_block(&self, block: &mut Block) {
+ Aes::encrypt_block(self, block)
+ }
+ fn decrypt_block(&self, block: &mut Block) {
+ Aes::decrypt_block(self, block)
+ }
+ fn encrypt_blocks2(&self, blocks: &mut [Block; 2]) {
+ Aes::encrypt_blocks2(self, blocks)
+ }
+ fn decrypt_blocks2(&self, blocks: &mut [Block; 2]) {
+ Aes::decrypt_blocks2(self, blocks)
+ }
+}
+
+impl BlockPermutation<24, BLOCK_LEN> for Aes192 {
+ fn new(key: &KeyMaterial<24>) -> Result {
+ Aes192::new(key)
+ }
+ fn encrypt_block(&self, block: &mut Block) {
+ Aes::encrypt_block(self, block)
+ }
+ fn decrypt_block(&self, block: &mut Block) {
+ Aes::decrypt_block(self, block)
+ }
+ fn encrypt_blocks2(&self, blocks: &mut [Block; 2]) {
+ Aes::encrypt_blocks2(self, blocks)
+ }
+ fn decrypt_blocks2(&self, blocks: &mut [Block; 2]) {
+ Aes::decrypt_blocks2(self, blocks)
+ }
+}
+
+impl BlockPermutation<32, BLOCK_LEN> for Aes256 {
+ fn new(key: &KeyMaterial<32>) -> Result {
+ Aes256::new(key)
+ }
+ fn encrypt_block(&self, block: &mut Block) {
+ Aes::encrypt_block(self, block)
+ }
+ fn decrypt_block(&self, block: &mut Block) {
+ Aes::decrypt_block(self, block)
+ }
+ fn encrypt_blocks2(&self, blocks: &mut [Block; 2]) {
+ Aes::encrypt_blocks2(self, blocks)
+ }
+ fn decrypt_blocks2(&self, blocks: &mut [Block; 2]) {
+ Aes::decrypt_blocks2(self, blocks)
+ }
+}
+
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 {
diff --git a/crypto/aes-lowmemory/summary.md b/crypto/aes-lowmemory/summary.md
index 4933c65..20ab8fc 100644
--- a/crypto/aes-lowmemory/summary.md
+++ b/crypto/aes-lowmemory/summary.md
@@ -1,7 +1,7 @@
# `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`.
+A constant-time, table-free AES block cipher engine (NIST FIPS 197), added on branch
+`feature/officialfrancismendoza/100-AES-lightengine-CBC-mode`.
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
@@ -259,6 +259,16 @@ Two details worth knowing:
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.
+### Only the ECB file belongs to this crate
+
+`bc-test-data` ships thirteen ACVP AES vector sets, one per mode. This crate consumes only
+`ACVP-AES-ECB`, because that is the set that tests the permutation rather than a mode.
+`ACVP-AES-CBC` is consumed by [`crypto/modes/tests/acvp_tests.rs`](../modes/tests/acvp_tests.rs)
+(2150 AFT cases). The remaining eleven — `CBC-CS1/2/3`, `CFB8`, `CFB128`, `OFB`, `CTR`, `KW`,
+`KWP`, `FF1`, `FF3-1` — are unused because those modes are unimplemented, not because they are
+untested. The table in the ACVP test module's docs records which file goes where, so adding a mode
+includes wiring up its file.
+
### Constant-time hygiene audit
Mechanically checked, not merely claimed:
@@ -386,7 +396,7 @@ 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
+to add them. `bc-test-data` **already** ships NIST ACVP AES vectors for every mode, including
`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
diff --git a/crypto/aes-lowmemory/tests/acvp_tests.rs b/crypto/aes-lowmemory/tests/acvp_tests.rs
index 0ab0b43..b54d9f0 100644
--- a/crypto/aes-lowmemory/tests/acvp_tests.rs
+++ b/crypto/aes-lowmemory/tests/acvp_tests.rs
@@ -5,12 +5,30 @@
//! 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
+//! # Why ECB, and where the other ACVP AES files are used
//!
//! 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.
//!
+//! `bc-test-data` ships thirteen ACVP AES vector sets, one per mode. This file deliberately
+//! consumes only `ACVP-AES-ECB`, because that is the one that tests the permutation rather than a
+//! mode. The others belong with whatever implements the mode:
+//!
+//! | Vector set | Consumed by |
+//! |---|---|
+//! | `ACVP-AES-ECB` | this file |
+//! | `ACVP-AES-CBC` | `crypto/modes/tests/acvp_tests.rs` |
+//! | `ACVP-AES-CBC-CS1` / `-CS2` / `-CS3` | nothing yet (ciphertext stealing is unimplemented) |
+//! | `ACVP-AES-CFB8` / `-CFB128` | nothing yet (CFB is unimplemented) |
+//! | `ACVP-AES-OFB` | nothing yet (OFB is unimplemented) |
+//! | `ACVP-AES-CTR` | nothing yet (CTR is unimplemented) |
+//! | `ACVP-AES-KW` / `-KWP` | nothing yet (key wrap is unimplemented) |
+//! | `ACVP-AES-FF1` / `-FF3-1` | nothing yet (format-preserving encryption is unimplemented) |
+//!
+//! So an unused vector set here means an unimplemented mode, not an untested one. Adding a mode
+//! should include wiring up its file.
+//!
//! 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
diff --git a/crypto/aes-lowmemory/tests/block_permutation_tests.rs b/crypto/aes-lowmemory/tests/block_permutation_tests.rs
new file mode 100644
index 0000000..d6119d9
--- /dev/null
+++ b/crypto/aes-lowmemory/tests/block_permutation_tests.rs
@@ -0,0 +1,25 @@
+//! `BlockPermutation` trait conformance, via the shared test framework.
+//!
+//! The framework checks the properties every implementor must have -- both directions are
+//! inverses, the permutation is injective, the pair methods are indistinguishable from two
+//! single-block calls *including their order*, and the key checks behave. That last pair of
+//! properties matters here specifically: this crate overrides `encrypt_blocks2` and
+//! `decrypt_blocks2`, so the default implementation is not what runs.
+
+use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256, BLOCK_LEN};
+use bouncycastle_core_test_framework::block_permutation::TestFrameworkBlockPermutation;
+
+#[test]
+fn aes128_conforms_to_block_permutation() {
+ TestFrameworkBlockPermutation::new().test::<16, BLOCK_LEN, Aes128>();
+}
+
+#[test]
+fn aes192_conforms_to_block_permutation() {
+ TestFrameworkBlockPermutation::new().test::<24, BLOCK_LEN, Aes192>();
+}
+
+#[test]
+fn aes256_conforms_to_block_permutation() {
+ TestFrameworkBlockPermutation::new().test::<32, BLOCK_LEN, Aes256>();
+}
diff --git a/crypto/core-test-framework/src/block_permutation.rs b/crypto/core-test-framework/src/block_permutation.rs
new file mode 100644
index 0000000..6eed66f
--- /dev/null
+++ b/crypto/core-test-framework/src/block_permutation.rs
@@ -0,0 +1,166 @@
+//! Shared conformance tests for [`BlockPermutation`] implementors.
+
+use crate::DUMMY_SEED;
+use bouncycastle_core::errors::SymmetricCipherError;
+use bouncycastle_core::key_material::{
+ KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations,
+};
+use bouncycastle_core::traits::{BlockCipher, BlockPermutation, SecurityStrength};
+
+/// Instance of the test framework.
+pub struct TestFrameworkBlockPermutation {
+ // Put any config options here
+}
+
+impl Default for TestFrameworkBlockPermutation {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl TestFrameworkBlockPermutation {
+ ///
+ pub fn new() -> Self {
+ Self {}
+ }
+
+ /// Exercises the trait contract for one implementor.
+ ///
+ /// Checks, in order:
+ /// * `decrypt_block` inverts `encrypt_block` on every block of [`DUMMY_SEED`];
+ /// * the permutation actually permutes (a block is not left unchanged);
+ /// * distinct inputs give distinct outputs, i.e. it is injective on the blocks tested;
+ /// * `encrypt_blocks2` agrees with two `encrypt_block` calls **including their order**, and
+ /// likewise for `decrypt_blocks2` -- this is what pins an override to the default's
+ /// semantics, and it is the reason the pair methods are worth having in the trait at all;
+ /// * the pair methods round-trip each other;
+ /// * a key of the wrong [`KeyType`] is rejected;
+ /// * the security-strength policy matches [`BlockCipher::MAX_SECURITY_STRENGTH`].
+ pub fn test<
+ const KEY_LEN: usize,
+ const BLOCK_LEN: usize,
+ P: BlockPermutation,
+ >(
+ &self,
+ ) {
+ let key = KeyMaterial::::from_bytes_as_type(
+ &DUMMY_SEED[..KEY_LEN],
+ KeyType::SymmetricCipherKey,
+ )
+ .unwrap();
+ let perm = P::new(&key).unwrap();
+
+ let blocks = DUMMY_SEED.as_chunks::().0;
+
+ // encrypt / decrypt are inverses, and the permutation is not the identity.
+ for block in blocks.iter() {
+ let mut buf = *block;
+ perm.encrypt_block(&mut buf);
+ assert_ne!(&buf, block, "encrypt_block must not be the identity");
+ perm.decrypt_block(&mut buf);
+ assert_eq!(&buf, block, "decrypt_block must invert encrypt_block");
+
+ // ...and the other way round, since a mode may call either direction first.
+ let mut buf = *block;
+ perm.decrypt_block(&mut buf);
+ assert_ne!(&buf, block, "decrypt_block must not be the identity");
+ perm.encrypt_block(&mut buf);
+ assert_eq!(&buf, block, "encrypt_block must invert decrypt_block");
+ }
+
+ // Distinct inputs must give distinct outputs. A permutation is injective, so this catches
+ // an implementation that collapses inputs (e.g. one that masks part of the block away).
+ for pair in blocks.as_chunks::<2>().0.iter() {
+ let [a, b] = pair;
+ assert_ne!(a, b, "DUMMY_SEED blocks should differ; test setup problem");
+ let mut ea = *a;
+ let mut eb = *b;
+ perm.encrypt_block(&mut ea);
+ perm.encrypt_block(&mut eb);
+ assert_ne!(ea, eb, "distinct blocks must encrypt to distinct blocks");
+ }
+
+ // The pair methods must be indistinguishable from the single-block ones, in both slots.
+ // An override that swapped the two results, or that processed only one of them, fails here.
+ for pair in blocks.as_chunks::<2>().0.iter() {
+ let [a, b] = pair;
+
+ let mut singly = [*a, *b];
+ perm.encrypt_block(&mut singly[0]);
+ perm.encrypt_block(&mut singly[1]);
+ let mut paired = [*a, *b];
+ perm.encrypt_blocks2(&mut paired);
+ assert_eq!(paired, singly, "encrypt_blocks2 must match two encrypt_block calls");
+
+ let mut singly = [*a, *b];
+ perm.decrypt_block(&mut singly[0]);
+ perm.decrypt_block(&mut singly[1]);
+ let mut paired = [*a, *b];
+ perm.decrypt_blocks2(&mut paired);
+ assert_eq!(paired, singly, "decrypt_blocks2 must match two decrypt_block calls");
+
+ // Round-trip through the pair methods alone.
+ let mut buf = [*a, *b];
+ perm.encrypt_blocks2(&mut buf);
+ perm.decrypt_blocks2(&mut buf);
+ assert_eq!(buf, [*a, *b], "decrypt_blocks2 must invert encrypt_blocks2");
+ }
+
+ // A pair of *identical* blocks must give a pair of identical outputs. This catches an
+ // implementation whose two lanes are not actually independent.
+ let block = blocks[0];
+ let mut buf = [block, block];
+ perm.encrypt_blocks2(&mut buf);
+ assert_eq!(buf[0], buf[1], "identical inputs must give identical outputs");
+ let mut single = block;
+ perm.encrypt_block(&mut single);
+ assert_eq!(buf[0], single);
+
+ // error case: KeyMaterial of the wrong type
+ let mac_key =
+ KeyMaterial::::from_bytes_as_type(&DUMMY_SEED[..KEY_LEN], KeyType::MACKey)
+ .unwrap();
+ match P::new(&mac_key) {
+ Err(SymmetricCipherError::KeyMaterialError(_)) => { /* good */ }
+ _ => panic!("A key that is not a SymmetricCipherKey should have been rejected"),
+ };
+
+ // error case: security strengths too weak, and strong enough
+ let mut key = KeyMaterial::::from_bytes_as_type(
+ &DUMMY_SEED[..KEY_LEN],
+ KeyType::SymmetricCipherKey,
+ )
+ .unwrap();
+ let security_strengths = [
+ SecurityStrength::None,
+ SecurityStrength::_112bit,
+ SecurityStrength::_128bit,
+ SecurityStrength::_192bit,
+ SecurityStrength::_256bit,
+ ];
+ for ss in security_strengths.iter() {
+ // `set_security_strength` enforces its key-length guard even inside a
+ // do_hazardous_operations() closure, so skip the strengths a KEY_LEN-byte key cannot
+ // carry. Do NOT relax that guard in `KeyMaterial`: core's
+ // `test_hazardous_ops_error_handling` requires it to stay enforced.
+ if ss > &SecurityStrength::from_bytes(KEY_LEN) {
+ continue;
+ }
+
+ // Tag the key at an arbitrary strength for the purpose of this test.
+ do_hazardous_operations(&mut key, |key| key.set_security_strength(ss.clone())).unwrap();
+
+ match P::new(&key) {
+ Ok(_) => assert!(
+ ss >= &::MAX_SECURITY_STRENGTH,
+ "should have required a key at least as strong as the algorithm"
+ ),
+ Err(SymmetricCipherError::KeyMaterialError(_)) => assert!(
+ ss < &
::MAX_SECURITY_STRENGTH,
+ "should not have rejected a key strong enough for the algorithm"
+ ),
+ _ => panic!("Unexpected error"),
+ };
+ }
+ }
+}
diff --git a/crypto/core-test-framework/src/lib.rs b/crypto/core-test-framework/src/lib.rs
index 2dced83..f5519d9 100644
--- a/crypto/core-test-framework/src/lib.rs
+++ b/crypto/core-test-framework/src/lib.rs
@@ -14,6 +14,7 @@
// properly document everything.
#![forbid(missing_docs)]
+pub mod block_permutation;
pub mod hash;
pub mod kdf;
pub mod kem;
diff --git a/crypto/core-test-framework/src/symmetric_ciphers.rs b/crypto/core-test-framework/src/symmetric_ciphers.rs
index 6e1c853..180e585 100644
--- a/crypto/core-test-framework/src/symmetric_ciphers.rs
+++ b/crypto/core-test-framework/src/symmetric_ciphers.rs
@@ -238,9 +238,17 @@ impl TestFrameworkBlockCipher {
SecurityStrength::_256bit,
];
for ss in security_strengths.iter() {
- // Tag the key at an arbitrary strength for the purpose of this test. Inside a
- // do_hazardous_operations() closure, set_security_strength() raises the strength
- // (and bypasses the key-length guard) without complaining.
+ // `set_security_strength` enforces its key-length guard even inside a
+ // do_hazardous_operations() closure -- a KEY_LEN-byte key cannot be tagged at a
+ // strength above `from_bytes(KEY_LEN)` -- so skip the strengths this key cannot carry
+ // rather than unwrapping an error. (A 16-byte key can reach 128-bit and no higher.)
+ // Do NOT "fix" this by relaxing that guard in `KeyMaterial`: core's
+ // `test_hazardous_ops_error_handling` requires it to stay enforced.
+ if ss > &SecurityStrength::from_bytes(KEY_LEN) {
+ continue;
+ }
+
+ // Tag the key at an arbitrary strength for the purpose of this test.
do_hazardous_operations(&mut key, |key| key.set_security_strength(ss.clone())).unwrap();
match E::do_encrypt_init(&key) {
diff --git a/crypto/core-test-framework/summary.md b/crypto/core-test-framework/summary.md
new file mode 100644
index 0000000..dcd404e
--- /dev/null
+++ b/crypto/core-test-framework/summary.md
@@ -0,0 +1,189 @@
+# `crypto/core-test-framework` — changes for `BlockPermutation` and CBC
+
+Changes made on branch `feature/officialfrancismendoza/100-AES-lightengine-CBC-mode` while adding
+`crypto/aes-lowmemory` and `crypto/modes`. Two things: a **new** per-trait suite for
+`core::traits::BlockPermutation`, and a **bug fix** to the existing `TestFrameworkBlockCipher`.
+
+For what this crate is for in general, see its [`src/lib.rs`](src/lib.rs) docs: one KAT-style
+harness per `core` trait, so that behaviour which should be consistent across implementations of a
+trait — error handling, input/output lengths, `KeyMaterial` entropy enforcement — is asserted once
+here rather than re-written per implementation.
+
+---
+
+## 1. New: `TestFrameworkBlockPermutation`
+
+[`src/block_permutation.rs`](src/block_permutation.rs), registered as `pub mod block_permutation;`
+in [`src/lib.rs`](src/lib.rs).
+
+`core::traits::BlockPermutation` is new in this branch: the raw keyed
+permutation (`CIPH_K` / `CIPH^-1_K` of SP 800-38A Sec 5.1) that a mode of operation is built on.
+It needed a conformance suite like every other `core` trait.
+
+```rust
+TestFrameworkBlockPermutation::new().test::();
+```
+
+### What it checks, and why each check exists
+
+| Check | What it catches |
+|---|---|
+| `decrypt_block` inverts `encrypt_block`, **and vice versa** | A direction implemented only one way round. A mode may call either direction first, so both orders are exercised. |
+| Neither direction is the identity | A stub, or a key schedule that never got applied. |
+| Distinct blocks give distinct outputs | An implementation that is not injective — e.g. one masking part of the block away. A permutation must be. |
+| `encrypt_blocks2` == two `encrypt_block` calls, **including their order**; same for decrypt | The whole reason the pair methods are safe to override. See below. |
+| The pair methods round-trip each other | A pair path correct in one direction only. |
+| Identical inputs give identical outputs from `*_blocks2` | Lanes that are not actually independent — a real hazard for a bit-sliced implementation that interleaves two blocks in one word. |
+| A key of the wrong `KeyType` is rejected | A seed or MAC key being reused as a cipher key. |
+| The security-strength policy matches `BlockCipher::MAX_SECURITY_STRENGTH` | A `new()` that accepts a key weaker than the algorithm, or rejects one strong enough. |
+
+### The order check is the load-bearing one
+
+`BlockPermutation::encrypt_blocks2` and `decrypt_blocks2` are *provided* methods: the default is
+two single-block calls, and implementations are free to override them. `bouncycastle-aes-lowmemory`
+does, because a pair of blocks is exactly what its bit-sliced state holds, so the pair form costs
+barely more than one block.
+
+An override is therefore a place where an implementation can silently disagree with the trait's
+semantics — most easily by returning the two results in the wrong order, which round-trips
+perfectly and so passes any test that only checks encrypt-then-decrypt. Asserting equality against
+two explicit single-block calls, slot by slot, is what makes an override trustworthy. That check is
+the reason this suite is worth having rather than leaving each implementor to test itself.
+
+The mirror image of this check lives in `crypto/modes/tests/common/mod.rs` as `SwappedPairToy`, a
+permutation whose pair methods deliberately swap their results, used to prove the *mode* really
+takes the pair path.
+
+### Current implementors
+
+* `crypto/aes-lowmemory/tests/block_permutation_tests.rs` — AES-128, AES-192, AES-256.
+* `crypto/modes/tests/cbc_tests.rs` — the toy permutation, checked before anything is concluded
+ from it.
+
+---
+
+## 2. Fixed: `TestFrameworkBlockCipher` panicked for any key under 32 bytes
+
+### The bug
+
+`TestFrameworkBlockCipher::test` ended with a loop that tagged the test key at each of the five
+`SecurityStrength` values and checked the `_init` constructor's accept/reject decision against
+`MAX_SECURITY_STRENGTH`:
+
+```rust
+for ss in security_strengths.iter() {
+ do_hazardous_operations(&mut key, |key| key.set_security_strength(ss.clone())).unwrap();
+ // ...
+}
+```
+
+`KeyMaterial::set_security_strength` enforces a key-length guard — a key cannot be tagged at a
+strength its own length cannot carry — and it enforces it **even inside a
+`do_hazardous_operations` closure**. So for a 16-byte key the loop reached `_192bit`, got
+`Err(SecurityStrength("Security strength cannot be larger than key length."))`, and the `unwrap()`
+panicked. The comment above the loop asserted the opposite ("bypasses the key-length guard"), which
+is what made it look correct.
+
+The result: the harness was unusable for AES-128 or AES-192, i.e. for most block ciphers.
+
+### Why nobody had noticed
+
+Nothing in the workspace implemented `BlockCipherEncryptor`/`BlockCipherDecryptor`. The traits
+landed in PR #96 with the harness written against them but no implementor — the toy XOR-CBC cipher
+that would have exercised it lives in `crypto/padding`, which is PR #97 and has not merged to this
+branch. `crypto/modes`' CBC is the first implementor in the tree, and it hit the panic immediately.
+
+### The fix
+
+Skip the strengths the key length cannot hold, rather than unwrapping the error:
+
+```rust
+if ss > &SecurityStrength::from_bytes(KEY_LEN) {
+ continue;
+}
+```
+
+For a 16-byte key this tests `None`, `_112bit` and `_128bit` — which still spans the
+`MAX_SECURITY_STRENGTH` boundary for AES-128, so the accept/reject decision is still exercised on
+both sides. Nothing is lost; the skipped cases were never reachable.
+
+### What **not** to do instead
+
+Do not relax the guard in `KeyMaterial::set_security_strength`. `core`'s
+`test_hazardous_ops_error_handling` requires it to stay enforced even inside
+`do_hazardous_operations`. A comment at the fix says so, because "make the setter permissive" is
+the tempting one-line alternative and it breaks a core test. This is the same conclusion reached
+independently on the ASCON branch.
+
+---
+
+## 3. Still outstanding: the same bug, twice more
+
+The identical loop appears in two other suites in
+[`src/symmetric_ciphers.rs`](src/symmetric_ciphers.rs) and is **not** fixed:
+
+| Suite | Loop at | Implementors in tree | Status |
+|---|---|---|---|
+| `TestFrameworkSymmetricCipher` | line 87 | 0 | latent, unfixed |
+| `TestFrameworkBlockCipher` | line 240 | 1 (`crypto/modes`) | **fixed** |
+| `TestFrameworkAEADCipher` | line 386 | 0 | latent, unfixed |
+| `TestFrameworkStreamCipher` | — | 0 | unaffected (no strength handling) |
+
+Both unfixed suites will panic the first time anything implements their trait with a key shorter
+than 32 bytes — which for `AEADCipher` includes ASCON-128 and AES-128-GCM. They were left alone to
+keep this change scoped to what CBC needed; the fix is the same three lines in each. Worth doing
+before the next implementor arrives rather than after.
+
+Note that `TestFrameworkStreamCipher` is a different case: it has no security-strength handling at
+all, so there is nothing to fix there and nothing being checked either.
+
+---
+
+## 4. Unchanged but newly exercised: `FixedSeedRNG`
+
+[`src/fixed_seed_rng.rs`](src/fixed_seed_rng.rs) already existed and was not modified. It is worth
+recording that it is now what makes CBC's known-answer tests possible.
+
+`Cbc` deliberately has no API for a caller-supplied IV — SP 800-38A Sec 5.3 requires the CBC IV to
+be *unpredictable*, so `do_encrypt_init` generates one and returns it. That leaves a problem for
+testing: Appendix F.2 specifies the IV, and there is no way to pass it in.
+
+`BlockCipherEncryptor::do_encrypt_init_rng(key, &mut dyn RNG)` is the seam.
+`FixedSeedRNG::<16>::new(iv)` emits the vector's IV as its first sixteen bytes, so the test can pin
+the IV without the production API ever accepting one. `crypto/modes/tests/sp800_38a_tests.rs`
+asserts the returned init data really is the expected IV before comparing any ciphertext, so a
+change that ignored the RNG could not pass silently.
+
+This is the pattern to reuse for CFB, OFB and CTR when they land.
+
+---
+
+## 5. Verification
+
+```sh
+cargo build -p bouncycastle-core-test-framework
+cargo test --workspace # 517 tests, 0 failures
+cargo fmt --all -- --check
+```
+
+This crate has no tests of its own — it *is* tests — so it is verified by its consumers. The two
+new suites are exercised by:
+
+* `cargo test -p bouncycastle-aes-lowmemory --test block_permutation_tests` (3 tests)
+* `cargo test -p bouncycastle-modes --test cbc_tests` (11 tests, including
+ `cbc_conforms_to_the_block_cipher_framework`, which is what the §2 fix unblocked, and
+ `the_toy_permutation_conforms_to_the_trait`)
+
+---
+
+## 6. Open items
+
+1. **Fix the same loop in `TestFrameworkSymmetricCipher` and `TestFrameworkAEADCipher`** (§3).
+ Three lines each, and the next implementor of either trait will otherwise hit the panic.
+2. **Decide whether the `Default` impl added to `TestFrameworkBlockPermutation` should be added to
+ the other suites** for consistency — they all have `new()` and no `Default`, which clippy
+ flags on new code but not on existing code.
+3. When `crypto/padding` (PR #97) merges, its toy XOR-CBC cipher becomes a second
+ `TestFrameworkBlockCipher` implementor. Worth re-running that suite then: an XOR-based cipher has
+ `encrypt_block == decrypt_block`, which is exactly the property `crypto/modes`' non-XOR toy was
+ chosen to avoid, so it may expose gaps this branch's tests do not.
diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs
index 495eb1f..4f12b97 100644
--- a/crypto/core/src/traits.rs
+++ b/crypto/core/src/traits.rs
@@ -87,6 +87,65 @@ pub trait BlockCipher {
const MAX_SECURITY_STRENGTH: SecurityStrength;
}
+/// A keyed block permutation: the `CIPH_K` / `CIPH^-1_K` of NIST SP 800-38A Sec 5.1.
+///
+/// This is the raw primitive a mode of operation is built on, not something to encrypt data with.
+/// It transforms exactly one block, so applying it directly to data is ECB, which is not
+/// confidential. [`BlockCipherEncryptor`] and [`BlockCipherDecryptor`] are the *mode* traits --
+/// they carry initialization data and chaining state; this one carries only a key schedule.
+///
+/// Implementors are expected to hold that key schedule in a zeroize-on-drop wrapper
+/// (`bouncycastle_utils::secret::Secret`), so it is scrubbed when the value is dropped.
+///
+/// # Why the block methods are infallible
+///
+/// Every length here is fixed by a type, and a constructed value is always ready to use, so there
+/// is nothing a caller can get wrong once [`BlockPermutation::new`] has returned. Only `new` can
+/// fail, and only because of the key.
+pub trait BlockPermutation:
+ BlockCipher + Sized
+{
+ /// Expands the key.
+ ///
+ /// # Errors
+ /// Rejects a key whose [`KeyType`] is not [`KeyType::SymmetricCipherKey`], and one whose
+ /// security strength is below [`BlockCipher::MAX_SECURITY_STRENGTH`], both as a
+ /// [`SymmetricCipherError::KeyMaterialError`].
+ fn new(key: &KeyMaterial) -> Result;
+
+ /// The forward cipher function, in place.
+ fn encrypt_block(&self, block: &mut [u8; BLOCK_LEN]);
+
+ /// The inverse cipher function, in place.
+ fn decrypt_block(&self, block: &mut [u8; BLOCK_LEN]);
+
+ /// The forward cipher function on two *independent* blocks, in place.
+ ///
+ /// Provided as two [`BlockPermutation::encrypt_block`] calls. Bit-sliced implementations
+ /// override it, because a pair of blocks is their natural unit of work and costs barely more
+ /// than one; see `bouncycastle-aes-lowmemory`.
+ ///
+ /// Overrides must be indistinguishable from the default, including the order of the two
+ /// results. `TestFrameworkBlockPermutation` pins that.
+ ///
+ /// Modes whose structure is parallel -- CBC decryption, CFB decryption, CTR -- should prefer
+ /// this. CBC and CFB *encryption* cannot use it: each input block depends on the previous
+ /// output.
+ fn encrypt_blocks2(&self, blocks: &mut [[u8; BLOCK_LEN]; 2]) {
+ let [a, b] = blocks;
+ self.encrypt_block(a);
+ self.encrypt_block(b);
+ }
+
+ /// The inverse cipher function on two *independent* blocks, in place.
+ /// See [`BlockPermutation::encrypt_blocks2`].
+ fn decrypt_blocks2(&self, blocks: &mut [[u8; BLOCK_LEN]; 2]) {
+ let [a, b] = blocks;
+ self.decrypt_block(a);
+ self.decrypt_block(b);
+ }
+}
+
/// 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.
diff --git a/crypto/modes/Cargo.toml b/crypto/modes/Cargo.toml
new file mode 100644
index 0000000..1aca516
--- /dev/null
+++ b/crypto/modes/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "bouncycastle-modes"
+version.workspace = true
+edition.workspace = true
+
+[dependencies]
+bouncycastle-core.workspace = true
+# Only for the default OS-backed DRBG that generates the IV in `do_encrypt_init`.
+bouncycastle-rng.workspace = true
+
+[dev-dependencies]
+bouncycastle-aes-lowmemory.workspace = true
+bouncycastle-core-test-framework.workspace = true
+bouncycastle-hex.workspace = true
+criterion.workspace = true
+serde_json = "1.0"
+
+[[bench]]
+name = "modes_benches"
+harness = false
diff --git a/crypto/modes/benches/modes_benches.rs b/crypto/modes/benches/modes_benches.rs
new file mode 100644
index 0000000..66cdaea
--- /dev/null
+++ b/crypto/modes/benches/modes_benches.rs
@@ -0,0 +1,245 @@
+//! Criterion benchmarks for the modes.
+//!
+//! The number to watch is the **decrypt/encrypt throughput ratio at N >= 2**. CBC encryption is
+//! serial by construction (SP 800-38A Sec 6.2: each forward cipher input depends on the previous
+//! output), so it can only ever use the single-block path. CBC *decryption* is parallel, and this
+//! implementation hands blocks to `decrypt_blocks2` in pairs. With the bit-sliced AES, whose
+//! two-block path costs barely more than one block, decryption should therefore run at roughly
+//! twice the throughput of encryption. That gap is the entire justification for the pair methods
+//! on `BlockPermutation`, so if it disappears, something has stopped taking the pair path.
+//!
+//! `N = 1` is included to show the effect vanishing: with one block there is no pair to form, so
+//! decryption falls back to the single-block path and the ratio should be about 1.
+
+use bouncycastle_aes_lowmemory::{Aes128, Aes256};
+use bouncycastle_core::errors::SymmetricCipherError;
+use bouncycastle_core::key_material::{KeyMaterial, KeyType};
+use bouncycastle_core::traits::{
+ BlockCipher, BlockCipherDecryptor, BlockCipherEncryptor, BlockPermutation, SecurityStrength,
+};
+use bouncycastle_modes::{Cbc, Decrypting, Encrypting};
+use criterion::{Criterion, Throughput, criterion_group, criterion_main};
+use std::hint::black_box;
+
+const BLOCK_LEN: usize = 16;
+/// 16 KiB, i.e. 1024 AES blocks.
+const NUM_BLOCKS: usize = 1024;
+const DATA_LEN: usize = NUM_BLOCKS * BLOCK_LEN;
+
+type Aes128Cbc = Cbc;
+type Aes256Cbc = Cbc;
+
+/// AES-128 with the pair methods **not** overridden, so they fall back to the trait defaults of
+/// two single-block calls.
+///
+/// This exists purely to isolate the value of the pair path. Comparing `Cbc` against
+/// `Cbc` at the *same* `N` holds everything else fixed -- same cipher, same
+/// call granularity, same amount of data movement -- so the difference is attributable to
+/// `decrypt_blocks2` and nothing else.
+///
+/// Comparing `N = 1` against `N = 8` does *not* isolate it: encryption, which can never pair, also
+/// speeds up substantially between those two, so call granularity dominates that comparison.
+struct UnpairedAes128(Aes128);
+
+impl BlockCipher for UnpairedAes128 {
+ const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit;
+}
+
+impl BlockPermutation<16, BLOCK_LEN> for UnpairedAes128 {
+ fn new(key: &KeyMaterial<16>) -> Result {
+ Ok(Self(>::new(key)?))
+ }
+ fn encrypt_block(&self, block: &mut [u8; BLOCK_LEN]) {
+ >::encrypt_block(&self.0, block)
+ }
+ fn decrypt_block(&self, block: &mut [u8; BLOCK_LEN]) {
+ >::decrypt_block(&self.0, block)
+ }
+ // encrypt_blocks2 / decrypt_blocks2 deliberately left as the trait defaults.
+}
+
+type UnpairedAes128Cbc = Cbc;
+
+fn key() -> KeyMaterial {
+ let bytes: [u8; N] = core::array::from_fn(|i| (i as u8).wrapping_mul(7).wrapping_add(1));
+ KeyMaterial::::from_bytes_as_type(&bytes, KeyType::SymmetricCipherKey).unwrap()
+}
+
+fn data() -> Vec<[u8; BLOCK_LEN]> {
+ (0..NUM_BLOCKS)
+ .map(|i| core::array::from_fn(|j| (i.wrapping_mul(31).wrapping_add(j)) as u8))
+ .collect()
+}
+
+fn bench_aes128(c: &mut Criterion) {
+ let k = key::<16>();
+ let blocks = data();
+
+ let mut group = c.benchmark_group("modes::cbc::Aes128");
+ group.throughput(Throughput::Bytes(DATA_LEN as u64));
+
+ // ---- encryption: serial, one block at a time is all it can do ----
+ group.bench_function("16KiB encrypt -- N=1", |b| {
+ b.iter(|| {
+ let (mut enc, _) = Aes128Cbc::::do_encrypt_init(&k).unwrap();
+ for block in blocks.iter() {
+ black_box(enc.do_encrypt_blocks(&[*block]).unwrap());
+ }
+ })
+ });
+
+ group.bench_function("16KiB encrypt -- N=8", |b| {
+ b.iter(|| {
+ let (mut enc, _) = Aes128Cbc::::do_encrypt_init(&k).unwrap();
+ for chunk in blocks.chunks_exact(8) {
+ let arr: &[[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap();
+ black_box(enc.do_encrypt_blocks(arr).unwrap());
+ }
+ })
+ });
+
+ // ---- decryption: parallel, uses decrypt_blocks2 for every pair ----
+ let (mut enc, iv) = Aes128Cbc::::do_encrypt_init(&k).unwrap();
+ let ciphertext: Vec<[u8; BLOCK_LEN]> = blocks
+ .chunks_exact(8)
+ .flat_map(|chunk| {
+ let arr: &[[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap();
+ enc.do_encrypt_blocks(arr).unwrap()
+ })
+ .collect();
+
+ // N=1 never forms a pair, so this is the single-block path: the ratio against encrypt should
+ // be about 1.
+ group.bench_function("16KiB decrypt -- N=1 (no pairing)", |b| {
+ b.iter(|| {
+ let mut dec = Aes128Cbc::::do_decrypt_init(&k, &iv).unwrap();
+ for block in ciphertext.iter() {
+ black_box(dec.do_decrypt_blocks(&[*block]).unwrap());
+ }
+ })
+ });
+
+ // N=2 and N=8 are all pairs, so every block goes through decrypt_blocks2.
+ group.bench_function("16KiB decrypt -- N=2 (all pairs)", |b| {
+ b.iter(|| {
+ let mut dec = Aes128Cbc::::do_decrypt_init(&k, &iv).unwrap();
+ for chunk in ciphertext.chunks_exact(2) {
+ let arr: &[[u8; BLOCK_LEN]; 2] = chunk.try_into().unwrap();
+ black_box(dec.do_decrypt_blocks(arr).unwrap());
+ }
+ })
+ });
+
+ group.bench_function("16KiB decrypt -- N=8 (all pairs)", |b| {
+ b.iter(|| {
+ let mut dec = Aes128Cbc::::do_decrypt_init(&k, &iv).unwrap();
+ for chunk in ciphertext.chunks_exact(8) {
+ let arr: &[[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap();
+ black_box(dec.do_decrypt_blocks(arr).unwrap());
+ }
+ })
+ });
+
+ // N=9 is four pairs plus a one-block remainder, so it exercises the tail path too.
+ group.bench_function("16KiB decrypt -- N=9 (pairs + remainder)", |b| {
+ b.iter(|| {
+ let mut dec = Aes128Cbc::::do_decrypt_init(&k, &iv).unwrap();
+ for chunk in ciphertext.chunks_exact(9) {
+ let arr: &[[u8; BLOCK_LEN]; 9] = chunk.try_into().unwrap();
+ black_box(dec.do_decrypt_blocks(arr).unwrap());
+ }
+ })
+ });
+
+ // The controlled comparison: identical N, identical cipher, pair methods overridden vs not.
+ // This pair of numbers -- and only this pair -- measures what `decrypt_blocks2` buys.
+ group.bench_function("16KiB decrypt -- N=8, pair path (blocks2 overridden)", |b| {
+ b.iter(|| {
+ let mut dec = Aes128Cbc::::do_decrypt_init(&k, &iv).unwrap();
+ for chunk in ciphertext.chunks_exact(8) {
+ let arr: &[[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap();
+ black_box(dec.do_decrypt_blocks(arr).unwrap());
+ }
+ })
+ });
+
+ group.bench_function("16KiB decrypt -- N=8, no pair path (trait default)", |b| {
+ b.iter(|| {
+ let mut dec = UnpairedAes128Cbc::::do_decrypt_init(&k, &iv).unwrap();
+ for chunk in ciphertext.chunks_exact(8) {
+ let arr: &[[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap();
+ black_box(dec.do_decrypt_blocks(arr).unwrap());
+ }
+ })
+ });
+
+ group.finish();
+}
+
+fn bench_aes256(c: &mut Criterion) {
+ let k = key::<32>();
+ let blocks = data();
+
+ let mut group = c.benchmark_group("modes::cbc::Aes256");
+ group.throughput(Throughput::Bytes(DATA_LEN as u64));
+
+ group.bench_function("16KiB encrypt -- N=8", |b| {
+ b.iter(|| {
+ let (mut enc, _) = Aes256Cbc::::do_encrypt_init(&k).unwrap();
+ for chunk in blocks.chunks_exact(8) {
+ let arr: &[[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap();
+ black_box(enc.do_encrypt_blocks(arr).unwrap());
+ }
+ })
+ });
+
+ let (mut enc, iv) = Aes256Cbc::::do_encrypt_init(&k).unwrap();
+ let ciphertext: Vec<[u8; BLOCK_LEN]> = blocks
+ .chunks_exact(8)
+ .flat_map(|chunk| {
+ let arr: &[[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap();
+ enc.do_encrypt_blocks(arr).unwrap()
+ })
+ .collect();
+
+ group.bench_function("16KiB decrypt -- N=8 (all pairs)", |b| {
+ b.iter(|| {
+ let mut dec = Aes256Cbc::::do_decrypt_init(&k, &iv).unwrap();
+ for chunk in ciphertext.chunks_exact(8) {
+ let arr: &[[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap();
+ black_box(dec.do_decrypt_blocks(arr).unwrap());
+ }
+ })
+ });
+
+ group.finish();
+}
+
+/// `do_*_init` includes a key expansion, and for encryption also an IV draw from the OS-backed
+/// DRBG. Worth its own measurement, because for short messages it dominates.
+fn bench_init(c: &mut Criterion) {
+ let k128 = key::<16>();
+ let k256 = key::<32>();
+ let iv = [0u8; BLOCK_LEN];
+
+ let mut group = c.benchmark_group("modes::cbc::init");
+
+ group.bench_function("Aes128 do_encrypt_init (key schedule + IV)", |b| {
+ b.iter(|| black_box(Aes128Cbc::::do_encrypt_init(black_box(&k128)).unwrap().1))
+ });
+ group.bench_function("Aes128 do_decrypt_init (key schedule only)", |b| {
+ b.iter(|| {
+ black_box(Aes128Cbc::::do_decrypt_init(black_box(&k128), &iv).unwrap())
+ })
+ });
+ group.bench_function("Aes256 do_decrypt_init (key schedule only)", |b| {
+ b.iter(|| {
+ black_box(Aes256Cbc::::do_decrypt_init(black_box(&k256), &iv).unwrap())
+ })
+ });
+
+ group.finish();
+}
+
+criterion_group!(benches, bench_aes128, bench_aes256, bench_init);
+criterion_main!(benches);
diff --git a/crypto/modes/src/cbc.rs b/crypto/modes/src/cbc.rs
new file mode 100644
index 0000000..996c441
--- /dev/null
+++ b/crypto/modes/src/cbc.rs
@@ -0,0 +1,232 @@
+//! The Cipher Block Chaining mode of operation (NIST SP 800-38A Sec 6.2).
+//!
+//! # The specification
+//!
+//! SP 800-38A Sec 6.2 defines the mode as, quoting verbatim:
+//!
+//! ```text
+//! CBC Encryption: C1 = CIPH_K(P1 XOR IV);
+//! Cj = CIPH_K(Pj XOR Cj-1) for j = 2 ... n.
+//!
+//! CBC Decryption: P1 = CIPH^-1_K(C1) XOR IV;
+//! Pj = CIPH^-1_K(Cj) XOR Cj-1 for j = 2 ... n.
+//! ```
+//!
+//! The `j = 1` and `j >= 2` cases differ only in that the first one uses the IV where the others
+//! use the previous ciphertext block. So this implementation keeps a single `chain` field holding
+//! "whatever gets XORed next", initialised to the IV and replaced by each ciphertext block as it
+//! is produced or consumed. That is the equivalence being used, and it is why there is no special
+//! case for the first block anywhere below.
+//!
+//! # Parallel decryption
+//!
+//! Sec 6.2 notes that in CBC decryption "the input blocks for the inverse cipher function, i.e.,
+//! the ciphertext blocks, are immediately available, so that multiple inverse cipher operations can
+//! be performed in parallel", whereas in encryption "the input block to each forward cipher
+//! operation (except the first) depends on the result of the previous forward cipher operation, so
+//! the forward cipher operations cannot be performed in parallel".
+//!
+//! This implementation uses that: decryption walks the ciphertext two blocks at a time and hands
+//! both to [`BlockPermutation::decrypt_blocks2`], which a bit-sliced engine computes for barely
+//! more than the cost of one block. Encryption cannot, and does not.
+
+use crate::iv::random_iv;
+use crate::{Decrypting, Encrypting};
+use bouncycastle_core::errors::SymmetricCipherError;
+use bouncycastle_core::key_material::KeyMaterial;
+use bouncycastle_core::traits::{
+ BlockCipher, BlockCipherDecryptor, BlockCipherEncryptor, BlockPermutation, RNG,
+ SecurityStrength,
+};
+use bouncycastle_rng::HashDRBG_SHA512;
+use core::marker::PhantomData;
+
+/// CBC mode over any [`BlockPermutation`], with the direction encoded in the type.
+///
+/// `Dir` is [`Encrypting`] or [`Decrypting`]. [`BlockCipherEncryptor`] is implemented only for the
+/// former and [`BlockCipherDecryptor`] only for the latter, so a `Cbc<_, Encrypting, _, _>` has no
+/// decryption methods at all -- using one in the wrong direction is a compile error rather than a
+/// runtime check.
+///
+/// The initialization data is one block, so `INIT_DATA_LEN == BLOCK_LEN`.
+///
+/// # State
+///
+/// Two fields: the permutation (which owns the key schedule, and is responsible for keeping it in
+/// a zeroize-on-drop wrapper) and one block of chaining value. The chaining value is an IV or a
+/// ciphertext block, both of which are public, so it is deliberately not wrapped in a `Secret`.
+pub struct Cbc
+where
+ P: BlockPermutation,
+{
+ perm: P,
+ /// `Cj-1`, initialised to the IV. See the module docs on why there is only one field for both.
+ chain: [u8; BLOCK_LEN],
+ _dir: PhantomData,
+}
+
+impl Cbc
+where
+ P: BlockPermutation,
+{
+ /// `Cj = CIPH_K(Pj XOR Cj-1)`, then `Cj` becomes the next chaining value.
+ #[inline]
+ fn encrypt_one(&mut self, plaintext: &[u8; BLOCK_LEN], ciphertext: &mut [u8; BLOCK_LEN]) {
+ for (out, (p, chain)) in ciphertext.iter_mut().zip(plaintext.iter().zip(self.chain.iter()))
+ {
+ *out = *p ^ *chain;
+ }
+ self.perm.encrypt_block(ciphertext);
+ self.chain = *ciphertext;
+ }
+
+ /// `Pj = CIPH^-1_K(Cj) XOR Cj-1`, then `Cj` becomes the next chaining value.
+ #[inline]
+ fn decrypt_one(&mut self, ciphertext: &[u8; BLOCK_LEN], plaintext: &mut [u8; BLOCK_LEN]) {
+ *plaintext = *ciphertext;
+ self.perm.decrypt_block(plaintext);
+ for (out, chain) in plaintext.iter_mut().zip(self.chain.iter()) {
+ *out ^= *chain;
+ }
+ self.chain = *ciphertext;
+ }
+
+ /// Decrypts two consecutive blocks with one [`BlockPermutation::decrypt_blocks2`] call.
+ ///
+ /// Writing the pair as `Cj, Cj+1` with `Cj-1` the incoming chaining value, Sec 6.2 gives
+ ///
+ /// ```text
+ /// Pj = CIPH^-1_K(Cj) XOR Cj-1
+ /// Pj+1 = CIPH^-1_K(Cj+1) XOR Cj
+ /// ```
+ ///
+ /// Neither inverse cipher depends on the other's *output* -- only on ciphertext, which is
+ /// already in hand -- so computing them together changes nothing. The two XOR operands do
+ /// differ, and the second one is `Cj`, so both are read out of `ciphertext` before the
+ /// chaining value is advanced to `Cj+1`.
+ #[inline]
+ fn decrypt_pair(
+ &mut self,
+ ciphertext: &[[u8; BLOCK_LEN]; 2],
+ plaintext: &mut [[u8; BLOCK_LEN]; 2],
+ ) {
+ *plaintext = *ciphertext;
+ self.perm.decrypt_blocks2(plaintext);
+
+ let (first, rest) = plaintext.split_at_mut(1);
+ for (out, chain) in first[0].iter_mut().zip(self.chain.iter()) {
+ *out ^= *chain; // XOR Cj-1
+ }
+ for (out, prev) in rest[0].iter_mut().zip(ciphertext[0].iter()) {
+ *out ^= *prev; // XOR Cj
+ }
+
+ self.chain = ciphertext[1];
+ }
+}
+
+impl BlockCipher
+ for Cbc
+where
+ P: BlockPermutation,
+{
+ /// A mode does not change the strength of the underlying cipher.
+ const MAX_SECURITY_STRENGTH: SecurityStrength = ::MAX_SECURITY_STRENGTH;
+}
+
+impl
+ BlockCipherEncryptor for Cbc
+where
+ P: BlockPermutation,
+{
+ /// Begins an encryption flow, generating the IV from the library's default OS-backed DRBG.
+ fn do_encrypt_init(
+ key: &KeyMaterial,
+ ) -> Result<(Self, [u8; BLOCK_LEN]), SymmetricCipherError> {
+ let mut rng = HashDRBG_SHA512::new_from_os();
+ Self::do_encrypt_init_rng(key, &mut rng)
+ }
+
+ /// As [`BlockCipherEncryptor::do_encrypt_init`], but takes the IV from the provided RNG.
+ fn do_encrypt_init_rng(
+ key: &KeyMaterial,
+ rng: &mut dyn RNG,
+ ) -> Result<(Self, [u8; BLOCK_LEN]), SymmetricCipherError> {
+ let perm = P::new(key)?;
+ let iv = random_iv::(rng)?;
+ Ok((Self { perm, chain: iv, _dir: PhantomData }, iv))
+ }
+
+ fn do_encrypt_blocks(
+ &mut self,
+ plaintext: &[[u8; BLOCK_LEN]; N],
+ ) -> Result<[[u8; BLOCK_LEN]; N], SymmetricCipherError> {
+ let mut ciphertext = [[0u8; BLOCK_LEN]; N];
+ self.do_encrypt_blocks_out(plaintext, &mut ciphertext)?;
+ Ok(ciphertext)
+ }
+
+ /// The real implementation; the by-value variant above is a wrapper over it.
+ ///
+ /// Strictly serial: `Cj` is the input to block `j + 1`, so there is no pair path here. See the
+ /// module docs.
+ fn do_encrypt_blocks_out(
+ &mut self,
+ plaintext: &[[u8; BLOCK_LEN]; N],
+ ciphertext: &mut [[u8; BLOCK_LEN]; N],
+ ) -> Result {
+ for (p, c) in plaintext.iter().zip(ciphertext.iter_mut()) {
+ self.encrypt_one(p, c);
+ }
+ Ok(N * BLOCK_LEN)
+ }
+}
+
+impl
+ BlockCipherDecryptor for Cbc
+where
+ P: BlockPermutation,
+{
+ /// Begins a decryption flow from the IV returned by
+ /// [`BlockCipherEncryptor::do_encrypt_init`].
+ fn do_decrypt_init(
+ key: &KeyMaterial,
+ init_data: &[u8; BLOCK_LEN],
+ ) -> Result {
+ let perm = P::new(key)?;
+ Ok(Self { perm, chain: *init_data, _dir: PhantomData })
+ }
+
+ fn do_decrypt_blocks(
+ &mut self,
+ ciphertext: &[[u8; BLOCK_LEN]; N],
+ ) -> Result<[[u8; BLOCK_LEN]; N], SymmetricCipherError> {
+ let mut plaintext = [[0u8; BLOCK_LEN]; N];
+ self.do_decrypt_blocks_out(ciphertext, &mut plaintext)?;
+ Ok(plaintext)
+ }
+
+ /// The real implementation; the by-value variant above is a wrapper over it.
+ ///
+ /// Walks the input in pairs so the permutation's two-block path is used, with an at-most-one
+ /// block remainder for odd `N`. `as_chunks` splits into exactly that shape with no runtime
+ /// length check and no indexing arithmetic; `N` is a compile-time constant, so for even `N` the
+ /// tail loop is empty and for `N = 1` the pair loop is.
+ fn do_decrypt_blocks_out(
+ &mut self,
+ ciphertext: &[[u8; BLOCK_LEN]; N],
+ plaintext: &mut [[u8; BLOCK_LEN]; N],
+ ) -> Result {
+ let (ct_pairs, ct_tail) = ciphertext.as_chunks::<2>();
+ let (pt_pairs, pt_tail) = plaintext.as_chunks_mut::<2>();
+
+ for (ct_pair, pt_pair) in ct_pairs.iter().zip(pt_pairs.iter_mut()) {
+ self.decrypt_pair(ct_pair, pt_pair);
+ }
+ for (c, p) in ct_tail.iter().zip(pt_tail.iter_mut()) {
+ self.decrypt_one(c, p);
+ }
+
+ Ok(N * BLOCK_LEN)
+ }
+}
diff --git a/crypto/modes/src/iv.rs b/crypto/modes/src/iv.rs
new file mode 100644
index 0000000..d2b60c0
--- /dev/null
+++ b/crypto/modes/src/iv.rs
@@ -0,0 +1,26 @@
+//! Initialization-vector generation, shared by the modes that need one.
+
+use bouncycastle_core::errors::SymmetricCipherError;
+use bouncycastle_core::traits::RNG;
+
+/// Generates a random initialization vector.
+///
+/// NIST SP 800-38A Appendix C gives two recommended methods for producing the unpredictable IVs
+/// that CBC and CFB require. This is the second one verbatim: "to generate a random data block
+/// using a FIPS-approved random number generator".
+///
+/// The first method -- applying the forward cipher function to a nonce under the same key -- is not
+/// implemented, because it needs a nonce the caller has to guarantee unique, and the API
+/// deliberately does not accept caller-supplied initialization data at all.
+///
+/// Appendix C also notes the IV "need not be secret", so this is not wrapped in a `Secret`: it is
+/// returned to the caller to transmit alongside the ciphertext. Its *integrity* is a different
+/// matter -- see the `cbc` module docs on Appendix D.
+pub(crate) fn random_iv(
+ rng: &mut dyn RNG,
+) -> Result<[u8; N], SymmetricCipherError> {
+ let mut iv = [0u8; N];
+ // `RNGError` converts into `SymmetricCipherError` via the `From` impl in core::errors.
+ rng.next_bytes_out(&mut iv)?;
+ Ok(iv)
+}
diff --git a/crypto/modes/src/lib.rs b/crypto/modes/src/lib.rs
new file mode 100644
index 0000000..a25468a
--- /dev/null
+++ b/crypto/modes/src/lib.rs
@@ -0,0 +1,200 @@
+//! Block cipher modes of operation (NIST SP 800-38A).
+//!
+//! A mode turns a keyed block permutation -- `bouncycastle-aes-lowmemory`'s `Aes128` and friends,
+//! or anything else implementing [`BlockPermutation`] -- into something that can encrypt more than
+//! one block. This crate currently provides **CBC** ([`Cbc`], SP 800-38A Sec 6.2).
+//!
+//! The crate is deliberately cipher-agnostic: it depends on no concrete block cipher, only on the
+//! trait. Define a one-line alias for the combination you use:
+//!
+//! ```
+//! use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256};
+//! use bouncycastle_modes::Cbc;
+//!
+//! type Aes128Cbc = Cbc;
+//! type Aes192Cbc = Cbc;
+//! type Aes256Cbc = Cbc;
+//! ```
+//!
+//! # Usage Examples
+//!
+//! The direction is part of the type: [`Cbc`](Cbc) implements
+//! [`BlockCipherEncryptor`] and nothing else, and [`Cbc
`](Cbc) implements
+//! [`BlockCipherDecryptor`] and nothing else. The IV is generated for you and returned; there is no
+//! API for supplying your own (see [Security Considerations](#security-considerations)).
+//!
+//! ```
+//! use bouncycastle_aes_lowmemory::Aes128;
+//! use bouncycastle_core::key_material::{KeyMaterial, KeyType};
+//! use bouncycastle_core::traits::{BlockCipherDecryptor, BlockCipherEncryptor};
+//! use bouncycastle_modes::{Cbc, Decrypting, Encrypting};
+//!
+//! type Aes128Cbc
= Cbc;
+//!
+//! let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42; 16], KeyType::SymmetricCipherKey)
+//! .expect("a 16-byte symmetric cipher key");
+//!
+//! let plaintext = [[0u8; 16], [1u8; 16], [2u8; 16]];
+//!
+//! // One shot: encrypts under a freshly generated IV, which is returned alongside the ciphertext.
+//! let (iv, ciphertext) =
+//! Aes128Cbc::::encrypt_blocks(&key, &plaintext).expect("encryption");
+//!
+//! let recovered =
+//! Aes128Cbc::::decrypt_blocks(&key, &iv, &ciphertext).expect("decryption");
+//! assert_eq!(recovered, plaintext);
+//! ```
+//!
+//! Streaming, for data that arrives in pieces. A sequence of calls is equivalent to one call over
+//! the concatenation:
+//!
+//! ```
+//! use bouncycastle_aes_lowmemory::Aes256;
+//! use bouncycastle_core::key_material::{KeyMaterial, KeyType};
+//! use bouncycastle_core::traits::{BlockCipherDecryptor, BlockCipherEncryptor};
+//! use bouncycastle_modes::{Cbc, Decrypting, Encrypting};
+//!
+//! type Aes256Cbc = Cbc;
+//!
+//! let key = KeyMaterial::<32>::from_bytes_as_type(&[0x07; 32], KeyType::SymmetricCipherKey)
+//! .expect("a 32-byte symmetric cipher key");
+//!
+//! let (mut encryptor, iv) =
+//! Aes256Cbc::::do_encrypt_init(&key).expect("encrypt init");
+//! let first = encryptor.do_encrypt_blocks(&[[0xAAu8; 16]]).expect("block 1");
+//! let rest = encryptor.do_encrypt_blocks(&[[0xBBu8; 16], [0xCCu8; 16]]).expect("blocks 2-3");
+//!
+//! let mut decryptor = Aes256Cbc::::do_decrypt_init(&key, &iv).expect("decrypt init");
+//! assert_eq!(decryptor.do_decrypt_blocks(&first).unwrap(), [[0xAAu8; 16]]);
+//! assert_eq!(decryptor.do_decrypt_blocks(&rest).unwrap(), [[0xBBu8; 16], [0xCCu8; 16]]);
+//! ```
+//!
+//! Using the wrong direction does not compile:
+//!
+//! ```compile_fail
+//! use bouncycastle_aes_lowmemory::Aes128;
+//! use bouncycastle_core::key_material::{KeyMaterial, KeyType};
+//! use bouncycastle_core::traits::BlockCipherDecryptor;
+//! use bouncycastle_modes::{Cbc, Encrypting};
+//!
+//! type Aes128Cbc = Cbc;
+//! let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42; 16], KeyType::SymmetricCipherKey).unwrap();
+//!
+//! // `Encrypting` does not implement `BlockCipherDecryptor`.
+//! let _ = Aes128Cbc::::do_decrypt_init(&key, &[0u8; 16]);
+//! ```
+//!
+//! # Block alignment
+//!
+//! These types are **strictly block-aligned**: whole blocks in, whole blocks out, no finalization
+//! step. SP 800-38A Sec 5.2 requires exactly that of CBC ("the total number of bits in the
+//! plaintext must be a multiple of the block size"), and Appendix A puts the formatting of
+//! non-aligned data outside the scope of the recommendation.
+//!
+//! Arbitrary-length data therefore needs a padding layer on top. That layer is *not* in this
+//! crate, and at the time of writing is not in the workspace at all -- see
+//! [Not yet implemented](#not-yet-implemented).
+//!
+//! # Memory Usage
+//!
+//! No heap allocation, and no lookup tables of its own. A mode value is the permutation plus one
+//! block of chaining value:
+//!
+//! ```text
+//! size_of::>() == size_of::() + BLOCK_LEN
+//! ```
+//!
+//! | Combination | Permutation | Chain | Total |
+//! |---|---|---|---|
+//! | AES-128 CBC | 176 B | 16 B | 192 B |
+//! | AES-192 CBC | 208 B | 16 B | 224 B |
+//! | AES-256 CBC | 240 B | 16 B | 256 B |
+//!
+//! `do_*_blocks_out::` adds nothing; the by-value `do_*_blocks::` adds `N * BLOCK_LEN` of
+//! stack for the returned array. [`Encrypting`] and [`Decrypting`] are zero-sized and held in a
+//! `PhantomData`, so encoding the direction in the type is free. The table is pinned by
+//! `sizes_match_the_documented_memory_table` in `tests/cbc_tests.rs`.
+//!
+//! # Security Considerations
+//!
+//! ## CBC is not authenticated
+//!
+//! CBC provides confidentiality only. It does not detect tampering, and it is malleable in
+//! specific, exploitable ways -- SP 800-38A Appendix D: flipping a bit of `Cj` flips the same bit
+//! of the decryption of `Cj+1`, and randomises the decryption of `Cj` itself. **Authenticate the
+//! ciphertext.** Prefer an AEAD; if you must use CBC, MAC the ciphertext *and* the IV, and verify
+//! before decrypting.
+//!
+//! Combining CBC decryption with a padding check is the classic padding-oracle setup. Do not
+//! report padding failures distinguishably, and do not decrypt unauthenticated ciphertext.
+//!
+//! ## The IV must be unpredictable, and this crate generates it
+//!
+//! SP 800-38A Sec 5.3 requires that "for the CBC and CFB modes, the IV for any particular execution
+//! of the encryption process must be unpredictable" -- not merely unique. Appendix C spells out
+//! that "for any given plaintext, it must not be possible to predict the IV that will be associated
+//! to the plaintext in advance of the generation of the IV".
+//!
+//! Rather than accept an IV and hope, [`BlockCipherEncryptor::do_encrypt_init`] generates one from
+//! the library's default OS-backed DRBG and returns it. There is deliberately **no** API for
+//! supplying your own. Known-answer tests drive [`BlockCipherEncryptor::do_encrypt_init_rng`] with
+//! a fixed-output test RNG instead.
+//!
+//! ## IV integrity
+//!
+//! Appendix D: "for the CBC mode, the decryption of the first ciphertext block is vulnerable to the
+//! (deliberate) introduction of bit errors in specific bit positions of the IV if the integrity of
+//! the IV is not protected". A flipped IV bit flips exactly that bit of `P1`. The IV need not be
+//! secret, but it must be authenticated along with the ciphertext.
+//!
+//! ## Key and IV reuse
+//!
+//! Nothing here stops one key being used for many messages, which is fine for CBC provided each
+//! gets a fresh unpredictable IV. It is the IV, not the key, that must not repeat.
+//!
+//! # Not yet implemented
+//!
+//! * **Padding.** There is no `Padding` trait, `PKCS7`, `PaddedEncryptor` or `PaddedDecryptor` in
+//! this workspace yet, so arbitrary-length CBC is not available. When that layer lands, CBC gets
+//! it for free by being wrapped -- no padding logic belongs in this crate.
+//! * **CFB** (SP 800-38A Sec 6.3), and the other three modes of the recommendation (ECB, OFB, CTR).
+//!
+//! # Command line
+//!
+//! The `bc-rust` CLI exposes CBC as `aes128-cbc`, `aes192-cbc` and `aes256-cbc`, each taking
+//! `encrypt` or `decrypt` and streaming stdin to stdout. Because there is no API for a
+//! caller-supplied IV, `encrypt` writes the generated IV as the first block of its output and
+//! `decrypt` reads it back from the first block of its input, so the two compose:
+//!
+//! ```text
+//! bc-rust aes256-cbc encrypt --key-file k.bin < plain.bin > cipher.bin
+//! bc-rust aes256-cbc decrypt --key-file k.bin < cipher.bin | cmp - plain.bin
+//! ```
+//!
+//! Input must be block-aligned there too, for the reason given above.
+
+#![no_std]
+#![forbid(unsafe_code)]
+#![forbid(missing_docs)]
+
+mod cbc;
+mod iv;
+
+pub use cbc::Cbc;
+
+// Imports needed for docs
+#[allow(unused_imports)]
+use bouncycastle_core::traits::{BlockCipherDecryptor, BlockCipherEncryptor, BlockPermutation};
+// end of imports needed for docs
+
+/// Direction marker for a mode that encrypts. See [`Cbc`].
+///
+/// Zero-sized: encoding the direction in the type costs no memory.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Encrypting;
+
+/// Direction marker for a mode that decrypts. See [`Cbc`].
+///
+/// Zero-sized: encoding the direction in the type costs no memory.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Decrypting;
diff --git a/crypto/modes/tests/acvp_tests.rs b/crypto/modes/tests/acvp_tests.rs
new file mode 100644
index 0000000..47f8b50
--- /dev/null
+++ b/crypto/modes/tests/acvp_tests.rs
@@ -0,0 +1,303 @@
+//! Known-answer tests against the NIST ACVP `ACVP-AES-CBC` 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 test prints a warning and passes,
+//! matching the convention used by the ML-KEM, ML-DSA and `aes-lowmemory` suites -- `cargo test`
+//! must stay green for someone who has only cloned this repository.
+//!
+//! These are the counterpart to `crypto/aes-lowmemory/tests/acvp_tests.rs`, which consumes the
+//! `ACVP-AES-ECB` file to test the raw permutation. CBC is a mode, so its vectors belong here.
+//!
+//! # Joining the request and response files
+//!
+//! Unlike the ECB response file, which echoes `key`, `pt` and `ct` for every case, the CBC response
+//! file carries **only the answer** (`ct` for an encrypt group, `pt` for a decrypt group) against a
+//! `tcId`. The key, IV and input live in the request file, and the group metadata that says which
+//! direction a case is -- `direction` and `keyLen` -- lives only there too. So both files are read
+//! and joined on `tcId`; there is no way to drive this from the response file alone.
+//!
+//! # Coverage
+//!
+//! 2150 AFT (Algorithm Functional Test) cases across all three key lengths and both directions,
+//! including 60 whose payload spans 2 to 10 blocks. Every case is run **twice**: once block by
+//! block, and once in pairs with a one-block remainder for odd lengths. The second pass is what
+//! puts the multi-block cases through `BlockPermutation::decrypt_blocks2`, so the pair path is
+//! exercised against real vectors and not only against the toy in `cbc_tests.rs`.
+//!
+//! The 6 MCT (Monte Carlo Test) groups are **not** implemented: their expected output is a
+//! `resultsArray` produced by a chained update rule defined in the ACVP AES specification rather
+//! than in SP 800-38A, and implementing it from anything else would be guesswork. The test reports
+//! how many it skipped so the gap stays visible.
+
+use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256};
+use bouncycastle_core::key_material::{
+ KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations,
+};
+use bouncycastle_core::traits::{
+ BlockCipherDecryptor, BlockCipherEncryptor, BlockPermutation, SecurityStrength,
+};
+use bouncycastle_core_test_framework::FixedSeedRNG;
+use bouncycastle_hex as hex;
+use bouncycastle_modes::{Cbc, Decrypting, Encrypting};
+use serde_json::Value;
+use std::collections::BTreeMap;
+use std::fs;
+use std::path::{Path, PathBuf};
+
+const BLOCK_LEN: usize = 16;
+
+/// 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 REQUEST_FILE: &str = "ACVP-AES-CBC.4014528.req.json";
+const RESPONSE_FILE: &str = "ACVP-AES-CBC.4014528.rsp.json";
+
+fn test_data_dir() -> Option {
+ for candidate in TEST_DATA_PATHS {
+ let path = Path::new(candidate);
+ if path.join(REQUEST_FILE).exists() && 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-CBC 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. `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 -- so this opts in explicitly rather than the engine 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
+}
+
+/// How to walk the blocks of one case.
+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+enum Grouping {
+ /// One block per call. Never forms a pair.
+ Single,
+ /// Two blocks per call, with a one-block remainder for odd lengths. Uses the pair path.
+ Pairs,
+}
+
+/// Runs one CBC case in one direction, for a given permutation, under the given grouping.
+///
+/// Encryption is driven through `do_encrypt_init_rng` with a `FixedSeedRNG` emitting the vector's
+/// IV, and the returned init data is checked against that IV before any ciphertext is compared --
+/// so a change that ignored the RNG could not pass silently.
+fn run_case(
+ key_bytes: &[u8],
+ iv: [u8; BLOCK_LEN],
+ input: &[[u8; BLOCK_LEN]],
+ encrypt: bool,
+ grouping: Grouping,
+) -> Vec<[u8; BLOCK_LEN]>
+where
+ P: BlockPermutation,
+{
+ let key = cipher_key::(key_bytes);
+ let mut out: Vec<[u8; BLOCK_LEN]> = Vec::with_capacity(input.len());
+
+ if encrypt {
+ let (mut enc, got_iv) = Cbc::::do_encrypt_init_rng(
+ &key,
+ &mut FixedSeedRNG::::new(iv),
+ )
+ .expect("encrypt init");
+ assert_eq!(got_iv, iv, "the pinned RNG should reproduce the vector's IV");
+
+ match grouping {
+ Grouping::Single => {
+ for block in input {
+ let [c] = enc.do_encrypt_blocks(&[*block]).unwrap();
+ out.push(c);
+ }
+ }
+ Grouping::Pairs => {
+ let (pairs, tail) = input.as_chunks::<2>();
+ for pair in pairs {
+ out.extend_from_slice(&enc.do_encrypt_blocks(pair).unwrap());
+ }
+ for block in tail {
+ let [c] = enc.do_encrypt_blocks(&[*block]).unwrap();
+ out.push(c);
+ }
+ }
+ }
+ } else {
+ let mut dec =
+ Cbc::::do_decrypt_init(&key, &iv).expect("dec init");
+
+ match grouping {
+ Grouping::Single => {
+ for block in input {
+ let [p] = dec.do_decrypt_blocks(&[*block]).unwrap();
+ out.push(p);
+ }
+ }
+ Grouping::Pairs => {
+ let (pairs, tail) = input.as_chunks::<2>();
+ for pair in pairs {
+ out.extend_from_slice(&dec.do_decrypt_blocks(pair).unwrap());
+ }
+ for block in tail {
+ let [p] = dec.do_decrypt_blocks(&[*block]).unwrap();
+ out.push(p);
+ }
+ }
+ }
+ }
+
+ out
+}
+
+/// Dispatches on key length, which is what selects the AES parameter set.
+fn run_case_for_key_len(
+ key_bytes: &[u8],
+ iv: [u8; BLOCK_LEN],
+ input: &[[u8; BLOCK_LEN]],
+ encrypt: bool,
+ grouping: Grouping,
+) -> Vec<[u8; BLOCK_LEN]> {
+ match key_bytes.len() {
+ 16 => run_case::(key_bytes, iv, input, encrypt, grouping),
+ 24 => run_case::(key_bytes, iv, input, encrypt, grouping),
+ 32 => run_case::(key_bytes, iv, input, encrypt, grouping),
+ other => panic!("ACVP AES vectors should only use 16, 24 or 32 byte keys, got {other}"),
+ }
+}
+
+fn to_blocks(bytes: &[u8]) -> Vec<[u8; BLOCK_LEN]> {
+ assert_eq!(bytes.len() % BLOCK_LEN, 0, "ACVP CBC payloads are block-aligned");
+ bytes.chunks(BLOCK_LEN).map(|c| c.try_into().unwrap()).collect()
+}
+
+fn decode(value: &Value, field: &str, tc_id: u64) -> Vec {
+ let s = value
+ .get(field)
+ .and_then(Value::as_str)
+ .unwrap_or_else(|| panic!("tcId {tc_id}: missing field {field}"));
+ hex::decode(s).unwrap_or_else(|_| panic!("tcId {tc_id}: bad hex in {field}"))
+}
+
+#[test]
+fn acvp_aes_cbc_known_answer_tests() {
+ let Some(dir) = test_data_dir() else { return };
+
+ let req: Value = serde_json::from_str(
+ &fs::read_to_string(dir.join(REQUEST_FILE)).expect("readable request file"),
+ )
+ .expect("valid ACVP request JSON");
+ let rsp: Value = serde_json::from_str(
+ &fs::read_to_string(dir.join(RESPONSE_FILE)).expect("readable response file"),
+ )
+ .expect("valid ACVP response JSON");
+
+ // The response file carries only the answer, against a tcId. Index it.
+ let mut answers: BTreeMap = BTreeMap::new();
+ for group in rsp
+ .get(1)
+ .and_then(|s| s.get("testGroups"))
+ .and_then(Value::as_array)
+ .expect("response testGroups")
+ {
+ for test in group.get("tests").and_then(Value::as_array).expect("response tests") {
+ let tc_id = test.get("tcId").and_then(Value::as_u64).expect("tcId");
+ answers.insert(tc_id, test.clone());
+ }
+ }
+
+ let groups = req
+ .get(1)
+ .and_then(|s| s.get("testGroups"))
+ .and_then(Value::as_array)
+ .expect("request testGroups");
+
+ let mut checked = 0usize;
+ let mut multi_block = 0usize;
+ let mut skipped_mct = 0usize;
+ let mut per_kind: BTreeMap = BTreeMap::new();
+
+ for group in groups {
+ let test_type = group.get("testType").and_then(Value::as_str).expect("testType");
+ let direction = group.get("direction").and_then(Value::as_str).expect("direction");
+ let encrypt = match direction {
+ "encrypt" => true,
+ "decrypt" => false,
+ other => panic!("unexpected direction {other}"),
+ };
+
+ for test in group.get("tests").and_then(Value::as_array).expect("tests") {
+ let tc_id = test.get("tcId").and_then(Value::as_u64).expect("tcId");
+
+ if test_type == "MCT" {
+ skipped_mct += 1;
+ continue;
+ }
+
+ let answer = answers.get(&tc_id).unwrap_or_else(|| panic!("tcId {tc_id}: no answer"));
+ if answer.get("resultsArray").is_some() {
+ skipped_mct += 1;
+ continue;
+ }
+
+ let key_bytes = decode(test, "key", tc_id);
+ let iv: [u8; BLOCK_LEN] = decode(test, "iv", tc_id).try_into().expect("a 16-byte IV");
+
+ // Input comes from the request, expected output from the response.
+ let (input_field, output_field) = if encrypt { ("pt", "ct") } else { ("ct", "pt") };
+ let input = to_blocks(&decode(test, input_field, tc_id));
+ let expected = to_blocks(&decode(answer, output_field, tc_id));
+
+ assert_eq!(input.len(), expected.len(), "tcId {tc_id}: length mismatch");
+ if input.len() > 1 {
+ multi_block += 1;
+ }
+
+ for grouping in [Grouping::Single, Grouping::Pairs] {
+ let got = run_case_for_key_len(&key_bytes, iv, &input, encrypt, grouping);
+ assert_eq!(
+ got,
+ expected,
+ "tcId {tc_id}: AES-{} CBC {direction}, {} blocks, {grouping:?} grouping",
+ key_bytes.len() * 8,
+ input.len()
+ );
+ }
+
+ *per_kind.entry(format!("AES-{} {direction}", key_bytes.len() * 8)).or_default() += 1;
+ checked += 1;
+ }
+ }
+
+ for (kind, n) in &per_kind {
+ println!("ACVP AES-CBC {kind}: {n} cases");
+ }
+ println!(
+ "ACVP AES-CBC: {checked} AFT cases checked in two groupings each \
+ ({multi_block} of them multi-block); {skipped_mct} MCT cases skipped"
+ );
+
+ // Guard against a silently-empty or partial run.
+ assert!(checked > 2000, "expected the full ACVP AFT set, only checked {checked}");
+ assert!(multi_block >= 60, "expected the multi-block cases, found {multi_block}");
+ assert_eq!(per_kind.len(), 6, "expected all three key lengths in both directions");
+}
diff --git a/crypto/modes/tests/cbc_tests.rs b/crypto/modes/tests/cbc_tests.rs
new file mode 100644
index 0000000..b243010
--- /dev/null
+++ b/crypto/modes/tests/cbc_tests.rs
@@ -0,0 +1,298 @@
+//! Structural tests for CBC, driven by a toy permutation.
+//!
+//! These check the properties of the *mode* -- chaining, call sequencing, the pair/remainder split,
+//! direction typing, SP 800-38A Appendix D error propagation -- independently of any real cipher.
+//! The known-answer tests against SP 800-38A Appendix F.2 are in `sp800_38a_tests.rs`.
+
+mod common;
+
+use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256};
+use bouncycastle_core::key_material::{KeyMaterial, KeyType};
+use bouncycastle_core::traits::{BlockCipherDecryptor, BlockCipherEncryptor};
+use bouncycastle_core_test_framework::block_permutation::TestFrameworkBlockPermutation;
+use bouncycastle_core_test_framework::symmetric_ciphers::TestFrameworkBlockCipher;
+use bouncycastle_modes::{Cbc, Decrypting, Encrypting};
+use common::{SwappedPairToy, TOY_LEN, Toy, toy_key};
+
+type ToyCbc = Cbc;
+type SwappedCbc = Cbc;
+
+// ---- the toy itself, and the mode, against the shared frameworks -------------------------
+
+/// The toy must be a real permutation before any conclusion drawn from it is worth anything.
+#[test]
+fn the_toy_permutation_conforms_to_the_trait() {
+ TestFrameworkBlockPermutation::new().test::();
+}
+
+#[test]
+fn cbc_conforms_to_the_block_cipher_framework() {
+ TestFrameworkBlockCipher::new()
+ .test::, ToyCbc>();
+}
+
+// ---- chaining and call sequencing --------------------------------------------------------
+
+/// Encrypting `n` blocks must not depend on how the calls are grouped, and likewise for
+/// decryption. This is the "a sequence of calls is equivalent to one call over the concatenation"
+/// contract of the trait, and for CBC it is entirely about the chaining value surviving across
+/// calls.
+///
+/// The odd groupings matter for decryption specifically: `N = 3` and `N = 5` leave a one-block
+/// remainder after the pair loop, and `N = 1` skips the pair loop altogether.
+#[test]
+fn call_grouping_does_not_change_the_result() {
+ let key = toy_key();
+ let plaintext: [[u8; TOY_LEN]; 8] =
+ core::array::from_fn(|i| core::array::from_fn(|j| (i * TOY_LEN + j) as u8));
+
+ // Both encryption runs must use the same IV to be comparable, so pin it with the fixed RNG
+ // rather than letting `do_encrypt_init` generate a fresh one.
+ let iv: [u8; TOY_LEN] = core::array::from_fn(|i| 0xF0 ^ (i as u8));
+ let pinned_rng = || bouncycastle_core_test_framework::FixedSeedRNG::::new(iv);
+
+ // Reference: all eight blocks in one call.
+ let (mut enc, got_iv) =
+ ToyCbc::::do_encrypt_init_rng(&key, &mut pinned_rng()).unwrap();
+ assert_eq!(got_iv, iv, "the pinned RNG should reproduce the IV");
+ let reference = enc.do_encrypt_blocks(&plaintext).unwrap();
+
+ // The same eight blocks, grouped every way that exercises a different code path.
+ let (mut enc, _) = ToyCbc::::do_encrypt_init_rng(&key, &mut pinned_rng()).unwrap();
+ let mut got = [[0u8; TOY_LEN]; 8];
+ let a = enc.do_encrypt_blocks(&[plaintext[0]]).unwrap(); // N = 1
+ let b = enc.do_encrypt_blocks(&[plaintext[1], plaintext[2]]).unwrap(); // N = 2
+ let c = enc.do_encrypt_blocks(&[plaintext[3], plaintext[4], plaintext[5]]).unwrap(); // N = 3
+ let d = enc.do_encrypt_blocks(&[plaintext[6], plaintext[7]]).unwrap(); // N = 2
+ got[0] = a[0];
+ got[1..3].copy_from_slice(&b);
+ got[3..6].copy_from_slice(&c);
+ got[6..8].copy_from_slice(&d);
+
+ assert_eq!(got, reference, "grouping must not change the ciphertext");
+
+ // Now the decrypt side: one call vs several groupings, all from the same ciphertext.
+ let ct = reference;
+
+ let mut dec = ToyCbc::::do_decrypt_init(&key, &iv).unwrap();
+ let all_at_once = dec.do_decrypt_blocks(&ct).unwrap();
+ assert_eq!(all_at_once, plaintext);
+
+ for grouping in [1usize, 2, 4] {
+ let mut dec = ToyCbc::::do_decrypt_init(&key, &iv).unwrap();
+ let mut out = [[0u8; TOY_LEN]; 8];
+ let mut at = 0;
+ while at < 8 {
+ match grouping {
+ 1 => {
+ let [p] = dec.do_decrypt_blocks(&[ct[at]]).unwrap();
+ out[at] = p;
+ }
+ 2 => {
+ let p = dec.do_decrypt_blocks(&[ct[at], ct[at + 1]]).unwrap();
+ out[at..at + 2].copy_from_slice(&p);
+ }
+ _ => {
+ let p = dec
+ .do_decrypt_blocks(&[ct[at], ct[at + 1], ct[at + 2], ct[at + 3]])
+ .unwrap();
+ out[at..at + 4].copy_from_slice(&p);
+ }
+ }
+ at += grouping;
+ }
+ assert_eq!(out, plaintext, "decrypting in groups of {grouping}");
+ }
+
+ // N = 3 and N = 5 both leave a one-block remainder after the pair loop.
+ let mut dec = ToyCbc::::do_decrypt_init(&key, &iv).unwrap();
+ let three = dec.do_decrypt_blocks(&[ct[0], ct[1], ct[2]]).unwrap();
+ let five = dec.do_decrypt_blocks(&[ct[3], ct[4], ct[5], ct[6], ct[7]]).unwrap();
+ assert_eq!(three, [plaintext[0], plaintext[1], plaintext[2]]);
+ assert_eq!(five, [plaintext[3], plaintext[4], plaintext[5], plaintext[6], plaintext[7]]);
+}
+
+/// The pair path in `do_decrypt_blocks_out` must actually be taken.
+///
+/// [`SwappedPairToy`] returns its two pair results in the wrong order while its single-block
+/// methods are correct. So a CBC decryptor that uses `decrypt_blocks2` gives the wrong answer for
+/// even-length input, and the right answer for a single block. If both came out right, the pair
+/// path would be dead code and every claim about it would be untested.
+#[test]
+fn the_pair_path_is_really_used() {
+ let key = toy_key();
+ let plaintext = [[0xA5u8; TOY_LEN], [0x5Au8; TOY_LEN]];
+
+ // The correct toy round-trips.
+ let (mut enc, iv) = ToyCbc::::do_encrypt_init(&key).unwrap();
+ let ct = enc.do_encrypt_blocks(&plaintext).unwrap();
+ let mut dec = ToyCbc::::do_decrypt_init(&key, &iv).unwrap();
+ assert_eq!(dec.do_decrypt_blocks(&ct).unwrap(), plaintext);
+
+ // The swapped-pair toy encrypts identically (encryption is serial and never pairs)...
+ let (mut enc, iv) = SwappedCbc::::do_encrypt_init(&key).unwrap();
+ let ct = enc.do_encrypt_blocks(&plaintext).unwrap();
+
+ // ...but decrypting the pair together must now be wrong, because the pair path is used.
+ let mut dec = SwappedCbc::::do_decrypt_init(&key, &iv).unwrap();
+ assert_ne!(
+ dec.do_decrypt_blocks(&ct).unwrap(),
+ plaintext,
+ "decrypting a pair must go through decrypt_blocks2"
+ );
+
+ // Decrypting one block at a time avoids the pair path, so it is correct even for this toy.
+ let mut dec = SwappedCbc::::do_decrypt_init(&key, &iv).unwrap();
+ let [p0] = dec.do_decrypt_blocks(&[ct[0]]).unwrap();
+ let [p1] = dec.do_decrypt_blocks(&[ct[1]]).unwrap();
+ assert_eq!([p0, p1], plaintext, "the single-block path must not pair");
+}
+
+/// The `_out` variants must agree with the by-value ones and report the byte count.
+#[test]
+fn out_variants_agree_with_by_value() {
+ let key = toy_key();
+ let plaintext = [[0x11u8; TOY_LEN], [0x22u8; TOY_LEN], [0x33u8; TOY_LEN]];
+
+ let (mut enc, iv) = ToyCbc::::do_encrypt_init(&key).unwrap();
+ let by_value = enc.do_encrypt_blocks(&plaintext).unwrap();
+
+ let (mut enc, iv2) = ToyCbc::::do_encrypt_init_rng(
+ &key,
+ &mut bouncycastle_core_test_framework::FixedSeedRNG::::new(iv),
+ )
+ .unwrap();
+ assert_eq!(iv2, iv, "the pinned RNG should reproduce the IV");
+ let mut out = [[0u8; TOY_LEN]; 3];
+ let n = enc.do_encrypt_blocks_out(&plaintext, &mut out).unwrap();
+ assert_eq!(n, 3 * TOY_LEN);
+ assert_eq!(out, by_value);
+
+ let mut dec = ToyCbc::::do_decrypt_init(&key, &iv).unwrap();
+ let mut back = [[0u8; TOY_LEN]; 3];
+ let n = dec.do_decrypt_blocks_out(&out, &mut back).unwrap();
+ assert_eq!(n, 3 * TOY_LEN);
+ assert_eq!(back, plaintext);
+}
+
+// ---- SP 800-38A Appendix D error propagation ---------------------------------------------
+
+/// Appendix D: "In the CBC mode, if bit errors occur in the IV, then the first ciphertext block
+/// will be decrypted incorrectly, and bit errors will occur in exactly the same bit positions as
+/// in the IV; the decryptions of the other ciphertext blocks are not affected."
+///
+/// This is a property of the construction (`P1 = CIPH^-1(C1) XOR IV`), so it holds for any
+/// permutation, and getting it wrong would mean the IV is not being XOR-ed where the spec says.
+#[test]
+fn an_iv_bit_error_flips_exactly_that_bit_of_the_first_block() {
+ let key = toy_key();
+ let plaintext = [[0x00u8; TOY_LEN], [0x11u8; TOY_LEN], [0x22u8; TOY_LEN]];
+
+ let (mut enc, iv) = ToyCbc::::do_encrypt_init(&key).unwrap();
+ let ct = enc.do_encrypt_blocks(&plaintext).unwrap();
+
+ for byte in 0..TOY_LEN {
+ for bit in 0..8 {
+ let mut corrupt_iv = iv;
+ corrupt_iv[byte] ^= 1 << bit;
+
+ let mut dec = ToyCbc::::do_decrypt_init(&key, &corrupt_iv).unwrap();
+ let got = dec.do_decrypt_blocks(&ct).unwrap();
+
+ let mut expected = plaintext;
+ expected[0][byte] ^= 1 << bit;
+ assert_eq!(
+ got, expected,
+ "IV byte {byte} bit {bit}: only that bit of P1 should change"
+ );
+ }
+ }
+}
+
+/// Appendix D, the ciphertext half: bit errors in `Cj` randomise the decryption of `Cj` and flip
+/// the same bit positions of `Cj+1`'s decryption, leaving later blocks alone.
+#[test]
+fn a_ciphertext_bit_error_affects_only_two_blocks() {
+ let key = toy_key();
+ let plaintext = [[0x00u8; TOY_LEN], [0x11u8; TOY_LEN], [0x22u8; TOY_LEN], [0x33u8; TOY_LEN]];
+
+ let (mut enc, iv) = ToyCbc::::do_encrypt_init(&key).unwrap();
+ let ct = enc.do_encrypt_blocks(&plaintext).unwrap();
+
+ let mut corrupt = ct;
+ corrupt[1][3] ^= 0b0010_0000;
+
+ let mut dec = ToyCbc::::do_decrypt_init(&key, &iv).unwrap();
+ let got = dec.do_decrypt_blocks(&corrupt).unwrap();
+
+ assert_eq!(got[0], plaintext[0], "P1 depends only on C1 and the IV");
+ assert_ne!(got[1], plaintext[1], "P2 comes from the corrupted C2");
+ // P3 = CIPH^-1(C3) XOR C2, so the flipped bit of C2 appears verbatim in P3.
+ let mut expected_p3 = plaintext[2];
+ expected_p3[3] ^= 0b0010_0000;
+ assert_eq!(got[2], expected_p3, "P3 should show the same bit flipped, and nothing else");
+ assert_eq!(got[3], plaintext[3], "P4 is unaffected");
+}
+
+// ---- IV handling -------------------------------------------------------------------------
+
+/// Two encryption flows under the same key must not reuse an IV. The framework checks this too;
+/// repeated here because for CBC it is the single most important operational requirement.
+#[test]
+fn each_encryption_gets_a_fresh_iv() {
+ let key = toy_key();
+ let mut seen = std::collections::BTreeSet::new();
+ for _ in 0..64 {
+ let (_, iv) = ToyCbc::::do_encrypt_init(&key).unwrap();
+ assert!(seen.insert(iv), "IV repeated across encryptions: {iv:02x?}");
+ }
+}
+
+/// Identical plaintext under the same key must give different ciphertext, because the IV differs.
+/// This is the property ECB lacks and the reason CBC needs an IV at all.
+#[test]
+fn identical_plaintext_gives_different_ciphertext() {
+ let key = toy_key();
+ let plaintext = [[0x77u8; TOY_LEN], [0x77u8; TOY_LEN]];
+
+ let (_, first) = ToyCbc::::encrypt_blocks(&key, &plaintext).unwrap();
+ let (_, second) = ToyCbc::::encrypt_blocks(&key, &plaintext).unwrap();
+ assert_ne!(first, second);
+
+ // ...and, within one message, two identical plaintext blocks must not give identical
+ // ciphertext blocks either, because the chaining value differs.
+ assert_ne!(first[0], first[1], "chaining should break the ECB pattern within a message");
+}
+
+// ---- key handling ------------------------------------------------------------------------
+
+#[test]
+fn a_key_of_the_wrong_type_is_rejected() {
+ let bytes: [u8; TOY_LEN] = core::array::from_fn(|i| (i as u8) + 1);
+ let seed = KeyMaterial::::from_bytes_as_type(&bytes, KeyType::Seed).unwrap();
+ assert!(ToyCbc::::do_encrypt_init(&seed).is_err());
+ assert!(ToyCbc::::do_decrypt_init(&seed, &[0u8; TOY_LEN]).is_err());
+}
+
+// ---- memory ------------------------------------------------------------------------------
+
+/// Pins the "Memory Usage" table in the crate docs.
+#[test]
+fn sizes_match_the_documented_memory_table() {
+ use core::mem::size_of;
+
+ assert_eq!(size_of::>(), 176 + 16);
+ assert_eq!(size_of::>(), 208 + 16);
+ assert_eq!(size_of::>(), 240 + 16);
+
+ // The direction marker is free, and does not change the layout.
+ assert_eq!(
+ size_of::>(),
+ size_of::>()
+ );
+ assert_eq!(size_of::(), 0);
+ assert_eq!(size_of::(), 0);
+
+ // ...and the general rule the docs state.
+ assert_eq!(size_of::>(), size_of::() + 16);
+}
diff --git a/crypto/modes/tests/common/mod.rs b/crypto/modes/tests/common/mod.rs
new file mode 100644
index 0000000..fcb52c5
--- /dev/null
+++ b/crypto/modes/tests/common/mod.rs
@@ -0,0 +1,121 @@
+//! Toy [`BlockPermutation`] implementations, for testing the mode independently of any real cipher.
+//!
+//! These are **not** cryptography. They exist so the structural properties of a mode -- chaining,
+//! sequencing, the pair/remainder split, direction typing -- can be tested without an AES
+//! dependency and without a real cipher's vectors getting in the way. The real known-answer tests
+//! are in `sp800_38a_tests.rs`.
+//!
+//! # Why not XOR
+//!
+//! The obvious toy, `block[i] ^= key[i]`, is its own inverse. That would make `encrypt_block` and
+//! `decrypt_block` the same function, which hides exactly the bugs these tests are for: a CBC
+//! decryptor that called the forward function, or an encryptor that called the inverse, would still
+//! round-trip. [`Toy`] is therefore asymmetric: it rotates before XOR-ing, so the two directions are
+//! genuinely different functions.
+
+use bouncycastle_core::errors::{KeyMaterialError, SymmetricCipherError};
+use bouncycastle_core::key_material::{KeyMaterial, KeyMaterialTrait, KeyType};
+use bouncycastle_core::traits::{BlockCipher, BlockPermutation, SecurityStrength};
+
+/// Block and key length of the toy ciphers, chosen to match AES so the tests exercise the same
+/// shapes the real thing will.
+pub const TOY_LEN: usize = 16;
+
+/// Shared key validation, so the toys reject the same keys a real permutation would and the
+/// framework's key-handling checks are meaningful.
+fn validate(key: &dyn KeyMaterialTrait) -> Result<(), SymmetricCipherError> {
+ if key.key_type() != KeyType::SymmetricCipherKey {
+ return Err(
+ KeyMaterialError::InvalidKeyType("toy cipher needs a SymmetricCipherKey").into()
+ );
+ }
+ if key.key_len() != TOY_LEN {
+ return Err(KeyMaterialError::InvalidLength.into());
+ }
+ if key.security_strength() < SecurityStrength::_128bit {
+ return Err(KeyMaterialError::SecurityStrength("toy cipher needs a 128-bit key").into());
+ }
+ Ok(())
+}
+
+/// An asymmetric toy permutation: `encrypt` is `rotate_left(1)` then XOR with the key byte.
+///
+/// A true permutation on each byte, so it is a true permutation on the block, and its inverse is
+/// distinctly different code (XOR then `rotate_right(1)`).
+pub struct Toy {
+ key: [u8; TOY_LEN],
+}
+
+impl BlockCipher for Toy {
+ const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit;
+}
+
+impl BlockPermutation for Toy {
+ fn new(key: &KeyMaterial) -> Result {
+ validate(key)?;
+ let mut bytes = [0u8; TOY_LEN];
+ bytes.copy_from_slice(key.ref_to_bytes());
+ Ok(Self { key: bytes })
+ }
+
+ fn encrypt_block(&self, block: &mut [u8; TOY_LEN]) {
+ for (b, k) in block.iter_mut().zip(self.key.iter()) {
+ *b = b.rotate_left(1) ^ *k;
+ }
+ }
+
+ fn decrypt_block(&self, block: &mut [u8; TOY_LEN]) {
+ for (b, k) in block.iter_mut().zip(self.key.iter()) {
+ *b = (*b ^ *k).rotate_right(1);
+ }
+ }
+}
+
+/// A deliberately broken toy whose pair methods **swap** their two results.
+///
+/// Used to prove that the mode really does take the pair path: with this permutation, a CBC
+/// decryptor that uses `decrypt_blocks2` must produce something other than the correct plaintext.
+/// If a test using this still round-trips, the pair path is dead code and the coverage claimed for
+/// it is false.
+///
+/// Its single-block methods are identical to [`Toy`]'s, so the two agree on odd-length input.
+pub struct SwappedPairToy {
+ inner: Toy,
+}
+
+impl BlockCipher for SwappedPairToy {
+ const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit;
+}
+
+impl BlockPermutation for SwappedPairToy {
+ fn new(key: &KeyMaterial) -> Result {
+ Ok(Self { inner: Toy::new(key)? })
+ }
+
+ fn encrypt_block(&self, block: &mut [u8; TOY_LEN]) {
+ self.inner.encrypt_block(block);
+ }
+
+ fn decrypt_block(&self, block: &mut [u8; TOY_LEN]) {
+ self.inner.decrypt_block(block);
+ }
+
+ fn encrypt_blocks2(&self, blocks: &mut [[u8; TOY_LEN]; 2]) {
+ self.inner.encrypt_block(&mut blocks[0]);
+ self.inner.encrypt_block(&mut blocks[1]);
+ blocks.swap(0, 1);
+ }
+
+ fn decrypt_blocks2(&self, blocks: &mut [[u8; TOY_LEN]; 2]) {
+ self.inner.decrypt_block(&mut blocks[0]);
+ self.inner.decrypt_block(&mut blocks[1]);
+ blocks.swap(0, 1);
+ }
+}
+
+/// Builds a `KeyMaterial` for the toys from a fixed non-zero pattern.
+pub fn toy_key() -> KeyMaterial {
+ let bytes: [u8; TOY_LEN] = core::array::from_fn(|i| (i as u8).wrapping_mul(7).wrapping_add(1));
+ KeyMaterial::::from_bytes_as_type(&bytes, KeyType::SymmetricCipherKey)
+ .expect("a valid toy key")
+}
diff --git a/crypto/modes/tests/sp800_38a_tests.rs b/crypto/modes/tests/sp800_38a_tests.rs
new file mode 100644
index 0000000..9bc24fd
--- /dev/null
+++ b/crypto/modes/tests/sp800_38a_tests.rs
@@ -0,0 +1,261 @@
+//! Known-answer tests from NIST SP 800-38A Appendix F.2, "CBC Example Vectors".
+//!
+//! Sections F.2.1 through F.2.6: CBC-AES128, CBC-AES192 and CBC-AES256, Encrypt and Decrypt. All
+//! six share the same IV and the same four plaintext blocks (Appendix F preamble); only the key and
+//! the resulting ciphertext differ. The three keys are the same three used by FIPS 197 Appendix A
+//! and SP 800-38A F.1, so these vectors also re-check each AES key expansion through a second
+//! construction.
+//!
+//! Transcribed from the published SP 800-38A PDF (2001 edition).
+//!
+//! # Driving the IV
+//!
+//! There is no API for supplying an IV -- see the crate docs. Encryption is therefore driven
+//! through [`BlockCipherEncryptor::do_encrypt_init_rng`] with a [`FixedSeedRNG`] whose stream is
+//! the vector's IV, and the test asserts the returned init data really is that IV before comparing
+//! any ciphertext. Decryption takes the IV directly, as init data.
+
+use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256};
+use bouncycastle_core::key_material::{KeyMaterial, KeyType};
+use bouncycastle_core::traits::{BlockCipherDecryptor, BlockCipherEncryptor, BlockPermutation};
+use bouncycastle_core_test_framework::FixedSeedRNG;
+use bouncycastle_hex as hex;
+use bouncycastle_modes::{Cbc, Decrypting, Encrypting};
+
+const BLOCK_LEN: usize = 16;
+
+/// The IV shared by every Appendix F.2 subsection.
+const IV: &str = "000102030405060708090a0b0c0d0e0f";
+
+/// The four plaintext blocks shared by every Appendix F subsection (Appendix F preamble).
+const PLAINTEXTS: [&str; 4] = [
+ "6bc1bee22e409f96e93d7e117393172a",
+ "ae2d8a571e03ac9c9eb76fac45af8e51",
+ "30c81c46a35ce411e5fbc1191a0a52ef",
+ "f69f2445df4f9b17ad2b417be66c3710",
+];
+
+/// F.2.1 / F.2.2 key.
+const KEY_128: &str = "2b7e151628aed2a6abf7158809cf4f3c";
+/// F.2.1 CBC-AES128.Encrypt output blocks.
+const CIPHERTEXTS_128: [&str; 4] = [
+ "7649abac8119b246cee98e9b12e9197d",
+ "5086cb9b507219ee95db113a917678b2",
+ "73bed6b8e3c1743b7116e69e22229516",
+ "3ff1caa1681fac09120eca307586e1a7",
+];
+
+/// F.2.3 / F.2.4 key.
+const KEY_192: &str = "8e73b0f7da0e6452c810f32b809079e562f8ead2522c6b7b";
+/// F.2.3 CBC-AES192.Encrypt output blocks.
+const CIPHERTEXTS_192: [&str; 4] = [
+ "4f021db243bc633d7178183a9fa071e8",
+ "b4d9ada9ad7dedf4e5e738763f69145a",
+ "571b242012fb7ae07fa9baac3df102e0",
+ "08b0e27988598881d920a9e64f5615cd",
+];
+
+/// F.2.5 / F.2.6 key.
+const KEY_256: &str = "603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4";
+/// F.2.5 CBC-AES256.Encrypt output blocks.
+const CIPHERTEXTS_256: [&str; 4] = [
+ "f58c4c04d6e5f1ba779eabfb5f7bfbd6",
+ "9cfc4e967edb808d679f777bc6702c7d",
+ "39f23369a9d9bacfa530e26304231461",
+ "b2eb05e2c39be9fcda6c19078c6a9d1b",
+];
+
+fn block(hex_str: &str) -> [u8; BLOCK_LEN] {
+ hex::decode(hex_str).expect("valid hex").try_into().expect("16 bytes")
+}
+
+fn blocks(hex_strs: &[&str; 4]) -> [[u8; BLOCK_LEN]; 4] {
+ core::array::from_fn(|i| block(hex_strs[i]))
+}
+
+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")
+}
+
+/// Runs one Appendix F.2 encrypt subsection.
+///
+/// Checks the whole message in one call, then again one block at a time, then again through the
+/// `_out` variant -- the vector should not care how the calls are grouped.
+fn check_encrypt(section: &str, key_hex: &str, expected: &[&str; 4])
+where
+ P: BlockPermutation,
+{
+ let key = key_material::(key_hex);
+ let iv = block(IV);
+ let pt = blocks(&PLAINTEXTS);
+ let ct = blocks(expected);
+
+ // All four blocks in one call.
+ let (mut enc, got_iv) = Cbc::::do_encrypt_init_rng(
+ &key,
+ &mut FixedSeedRNG::::new(iv),
+ )
+ .unwrap();
+ assert_eq!(got_iv, iv, "{section}: the pinned RNG should produce the vector's IV");
+ assert_eq!(enc.do_encrypt_blocks(&pt).unwrap(), ct, "{section}: four blocks in one call");
+
+ // One block at a time.
+ let (mut enc, _) = Cbc::::do_encrypt_init_rng(
+ &key,
+ &mut FixedSeedRNG::::new(iv),
+ )
+ .unwrap();
+ for (i, (p, c)) in pt.iter().zip(ct.iter()).enumerate() {
+ let [got] = enc.do_encrypt_blocks(&[*p]).unwrap();
+ assert_eq!(&got, c, "{section}: block #{}", i + 1);
+ }
+
+ // Through the `_out` variant.
+ let (mut enc, _) = Cbc::::do_encrypt_init_rng(
+ &key,
+ &mut FixedSeedRNG::::new(iv),
+ )
+ .unwrap();
+ let mut out = [[0u8; BLOCK_LEN]; 4];
+ let n = enc.do_encrypt_blocks_out(&pt, &mut out).unwrap();
+ assert_eq!(n, 4 * BLOCK_LEN);
+ assert_eq!(out, ct, "{section}: _out variant");
+}
+
+/// Runs one Appendix F.2 decrypt subsection.
+///
+/// Checks one call, one block at a time, and the odd grouping `3 + 1` -- which is the grouping that
+/// leaves a one-block remainder after the pair loop in `do_decrypt_blocks_out`.
+fn check_decrypt(section: &str, key_hex: &str, ciphertext: &[&str; 4])
+where
+ P: BlockPermutation,
+{
+ let key = key_material::(key_hex);
+ let iv = block(IV);
+ let pt = blocks(&PLAINTEXTS);
+ let ct = blocks(ciphertext);
+
+ type Dec = Cbc
;
+
+ // All four blocks in one call (two pairs, no remainder).
+ let mut dec = Dec::
::do_decrypt_init(&key, &iv).unwrap();
+ assert_eq!(dec.do_decrypt_blocks(&ct).unwrap(), pt, "{section}: four blocks in one call");
+
+ // One block at a time (never takes the pair path).
+ let mut dec = Dec::
::do_decrypt_init(&key, &iv).unwrap();
+ for (i, (c, p)) in ct.iter().zip(pt.iter()).enumerate() {
+ let [got] = dec.do_decrypt_blocks(&[*c]).unwrap();
+ assert_eq!(&got, p, "{section}: block #{}", i + 1);
+ }
+
+ // 3 + 1: one pair plus a remainder, then a lone block.
+ let mut dec = Dec::
::do_decrypt_init(&key, &iv).unwrap();
+ let three = dec.do_decrypt_blocks(&[ct[0], ct[1], ct[2]]).unwrap();
+ let one = dec.do_decrypt_blocks(&[ct[3]]).unwrap();
+ assert_eq!(three, [pt[0], pt[1], pt[2]], "{section}: blocks 1-3");
+ assert_eq!(one, [pt[3]], "{section}: block 4");
+
+ // Through the `_out` variant.
+ let mut dec = Dec::
::do_decrypt_init(&key, &iv).unwrap();
+ let mut out = [[0u8; BLOCK_LEN]; 4];
+ let n = dec.do_decrypt_blocks_out(&ct, &mut out).unwrap();
+ assert_eq!(n, 4 * BLOCK_LEN);
+ assert_eq!(out, pt, "{section}: _out variant");
+}
+
+#[test]
+fn f_2_1_cbc_aes128_encrypt() {
+ check_encrypt::("F.2.1", KEY_128, &CIPHERTEXTS_128);
+}
+
+#[test]
+fn f_2_2_cbc_aes128_decrypt() {
+ check_decrypt::("F.2.2", KEY_128, &CIPHERTEXTS_128);
+}
+
+#[test]
+fn f_2_3_cbc_aes192_encrypt() {
+ check_encrypt::("F.2.3", KEY_192, &CIPHERTEXTS_192);
+}
+
+#[test]
+fn f_2_4_cbc_aes192_decrypt() {
+ check_decrypt::("F.2.4", KEY_192, &CIPHERTEXTS_192);
+}
+
+#[test]
+fn f_2_5_cbc_aes256_encrypt() {
+ check_encrypt::("F.2.5", KEY_256, &CIPHERTEXTS_256);
+}
+
+#[test]
+fn f_2_6_cbc_aes256_decrypt() {
+ check_decrypt::("F.2.6", KEY_256, &CIPHERTEXTS_256);
+}
+
+/// The one-shot API must agree with the vectors too, on the decrypt side where the IV is an input.
+#[test]
+fn the_one_shot_api_matches_the_vectors() {
+ let iv = block(IV);
+ let pt = blocks(&PLAINTEXTS);
+
+ assert_eq!(
+ Cbc::::decrypt_blocks(
+ &key_material::<16>(KEY_128),
+ &iv,
+ &blocks(&CIPHERTEXTS_128)
+ )
+ .unwrap(),
+ pt
+ );
+ assert_eq!(
+ Cbc::::decrypt_blocks(
+ &key_material::<24>(KEY_192),
+ &iv,
+ &blocks(&CIPHERTEXTS_192)
+ )
+ .unwrap(),
+ pt
+ );
+ assert_eq!(
+ Cbc::::decrypt_blocks(
+ &key_material::<32>(KEY_256),
+ &iv,
+ &blocks(&CIPHERTEXTS_256)
+ )
+ .unwrap(),
+ pt
+ );
+}
+
+/// The IV really is what distinguishes CBC from ECB here: the same key and plaintext under the
+/// F.1 (ECB) conditions gives the F.1 ciphertext, and under F.2 gives a different one.
+///
+/// F.1.1 block #1 for this key is `3ad77bb40d7a3660a89ecaf32466ef97`; F.2.1 block #1 is
+/// `7649abac8119b246cee98e9b12e9197d`. They differ solely because CBC XORs the IV in first.
+#[test]
+fn cbc_differs_from_ecb_by_the_iv() {
+ let key = key_material::<16>(KEY_128);
+ let iv = block(IV);
+
+ // The raw permutation on P1 alone is the ECB answer from F.1.1.
+ let mut ecb = block(PLAINTEXTS[0]);
+ >::encrypt_block(
+ &>::new(&key).unwrap(),
+ &mut ecb,
+ );
+ assert_eq!(ecb, block("3ad77bb40d7a3660a89ecaf32466ef97"), "F.1.1 block #1");
+
+ // CBC's C1 = CIPH_K(P1 XOR IV) is the F.2.1 answer, and differs.
+ let (mut enc, _) = Cbc::::do_encrypt_init_rng(
+ &key,
+ &mut FixedSeedRNG::<16>::new(iv),
+ )
+ .unwrap();
+ let [cbc] = enc.do_encrypt_blocks(&[block(PLAINTEXTS[0])]).unwrap();
+ assert_eq!(cbc, block(CIPHERTEXTS_128[0]), "F.2.1 block #1");
+ assert_ne!(cbc, ecb);
+}
diff --git a/src/lib.rs b/src/lib.rs
index ca2fb14..fc69d39 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -9,6 +9,7 @@ pub use bouncycastle_mldsa as mldsa;
pub use bouncycastle_mldsa_lowmemory as mldsa_lowmemory;
pub use bouncycastle_mlkem as mlkem;
pub use bouncycastle_mlkem_lowmemory as mlkem_lowmemory;
+pub use bouncycastle_modes as modes;
pub use bouncycastle_rng as rng;
pub use bouncycastle_sha2 as sha2;
pub use bouncycastle_sha3 as sha3;