diff --git a/alpha_0.1.3_release_notes.md b/alpha_0.1.3_release_notes.md
index d74c766d..7e2e41c3 100644
--- a/alpha_0.1.3_release_notes.md
+++ b/alpha_0.1.3_release_notes.md
@@ -26,26 +26,37 @@ permutation (NIST FIPS 197), re-exported from the umbrella crate.
* Deliberately ships no CLI subcommand, no factory entry and no `core` cipher-trait impls: a raw permutation can
only offer ECB, and those are mode-of-operation concerns. `Algorithm` is implemented (name and security
strength); per-mode OIDs and the `BlockCipherEncryptor` / `BlockCipherDecryptor` impls belong to the mode crates.
+* Ships the type aliases `AES_CBC_128` / `AES_CBC_192` / `AES_CBC_256` and `AES_CFB_128` /
+ `AES_CFB_192` / `AES_CFB_256`, which fill in the const parameters of `bouncycastle-modes`' `Cbc`
+ and `Cfb` and leave the direction as the type parameter. They are aliases only -- no new engine
+ code, and each one's doctest round-trips and shows that a misaligned length fails to compile.
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
+(NIST SP 800-38A), providing **CBC** (Sec 6.2) and **CFB128** (Sec 6.3). Re-exported from the
+umbrella crate.
+
+* `Cbc
` and `Cfb
` over any
+ `BlockPermutation`, so the crate depends on no concrete cipher. The direction is a type parameter:
+ `BlockCipherEncryptor` is implemented only for `<_, Encrypting, _, _>` and `BlockCipherDecryptor`
+ only for `<_, Decrypting, _, _>`, making a wrong-direction call a compile error rather than a
+ runtime check. The two types have identical APIs and identical size, so swapping one for the other
+ is a one-word change.
+* **The IV is generated, never accepted.** SP 800-38A Sec 5.3 requires the CBC *and CFB* 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.
+ This matters more for CFB than for CBC: CFB XORs a keystream, so a repeated key-and-IV pair leaks
+ `P1 XOR P1'` outright rather than merely whether the blocks were equal.
* **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.
+* Strictly block-aligned, as Sec 5.2 requires of CBC. Arbitrary-length data goes through
+ `bouncycastle-padding`'s `PaddedEncryptor` / `PaddedDecryptor`, which wrap either mode; no padding
+ logic lives in this crate. `crypto/modes/tests/cfb_tests.rs` round-trips every length from 0 to
+ `3 * BLOCK_LEN + 1` through PKCS7 to pin that the two crates compose.
* 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
@@ -58,29 +69,90 @@ New crate `bouncycastle-modes` (`bouncycastle::modes`): block cipher modes of op
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.
-
+CFB (`Cfb`), SP 800-38A Sec 6.3:
+
+* **Full-block segment only.** Sec 6.3 parameterises CFB by a segment size `s` with `1 <= s <= b`;
+ `Cfb` implements `s = b` -- CFB128 for AES -- because that is the only segment size that is
+ block-aligned and therefore the only one that fits `BlockCipherEncryptor` /
+ `BlockCipherDecryptor`. With `s = b` the spec's `LSB_{b-s}(I_{j-1}) | C#_{j-1}` collapses to
+ `Ij = C_{j-1}` and `MSB_s(Oj)` to `Oj`, which the module docs derive step by step. **CFB8 and
+ CFB1 are different, non-interoperable modes and are not provided**; they need a `StreamCipher`
+ shape, and both the crate docs and the CLI help say so explicitly.
+* **Decryption uses the forward cipher function.** Sec 6.3 applies `CIPH_K` in both directions, so
+ `Cfb<_, Decrypting, _, _>` never calls `decrypt_block` or `decrypt_blocks2`. This is pinned by a
+ test permutation whose inverse methods panic, run over both the pair and single-block paths -- so
+ the claim is enforced rather than merely documented.
+* **Parallel decryption**, via `encrypt_blocks2`: Sec 6.3 notes CFB decryption's forward cipher
+ calls "can be performed in parallel if the input blocks are first constructed (in series) from the
+ IV and the ciphertext", and with `s = b` those input blocks simply *are* the IV followed by the
+ ciphertext. Measured against an otherwise identical permutation that does not override the pair
+ methods, this is **2.08x** the decryption throughput (110.9 vs 53.3 MiB/s, AES-128, 16 KiB, N=8).
+ In the same run CFB decryption was **1.37x** CBC decryption (110.9 vs 80.8 MiB/s), because the
+ bit-sliced engine's forward direction is cheaper than its inverse and CFB only ever needs the
+ forward one. CFB encryption is serial by construction and does not use the pair path -- verified,
+ not assumed: the swapped-pair test permutation produces identical ciphertext under `Cfb` encrypt.
+* Same size as `Cbc` -- one permutation plus one block of feedback (192/224/256 B for
+ AES-128/192/256) -- because the keystream block `Oj` is recomputed per call and lives only in a
+ local, so no keystream outlives the call that used it.
+* Verified against all six SP 800-38A **Appendix F.3.13-F.3.18** vectors (CFB128-AES128/192/256,
+ Encrypt and Decrypt) in the same four groupings as CBC. F.3 additionally tabulates the *output
+ blocks* -- the keystream -- so those are checked against the raw permutation too
+ (`Oj == CIPH_K(I_j)` and `Cj == Pj XOR Oj` for all four segments of all three key lengths), which
+ pins the mode's internals and not just its final output. As a transcription cross-check, CFB128
+ is required to agree with **Appendix F.4.1 (OFB)** on the first block -- both compute
+ `C1 = P1 XOR CIPH_K(IV)` -- and to disagree from the second.
+* Also verified against the **2138 NIST ACVP `ACVP-AES-CFB128` AFT cases** from `bc-test-data` (all
+ three key lengths, both directions, 54 of them spanning 2-10 blocks), each run twice, block by
+ block and in pairs with a remainder. The 6 MCT groups are skipped and the count reported. These
+ vectors were already in `bc-test-data` and previously unused.
+* Appendix D error propagation is tested in the direction that distinguishes CFB from CBC. Table D.2
+ gives CFB "SBE in the decryption of Cj": every one of the 128 bit positions of `C2` is flipped and
+ required to flip *exactly* that bit of `P2` (the block the attacker aimed at, unlike CBC where it
+ lands in `P3`), to randomise `P3`, and to leave `P1` and `P4` untouched. The IV case is checked
+ with real AES, where a corrupted IV must *randomise* `P1` rather than flip a bit in place, and
+ must not affect any later block -- with `s = b`, Appendix D's "first `i/s` (rounding up)"
+ segments is one segment for every bit position.
+* Mutation-tested: `cargo mutants -p bouncycastle-modes` reports **0 surviving mutants** (72
+ mutants, 39 caught, 33 unviable), including every `^`-to-`|`/`&` substitution and every
+ keystream-stubbing mutant in `cfb.rs`.
+* Still not implemented, and listed in the crate docs: the CFB segment sizes below the block size
+ (`s = 8`, `s = 1`), and ECB, OFB and CTR.
+
+`cli`: six new subcommands -- `aes128-cbc`, `aes192-cbc`, `aes256-cbc`, `aes128-cfb`, `aes192-cfb`
+and `aes256-cfb` -- each taking `encrypt` or `decrypt` and streaming stdin to stdout in 1 KiB
+chunks.
+
+* All the mode-independent plumbing -- key loading, stdin framing, block-alignment enforcement,
+ hex/binary output -- lives once in `cli/src/block_mode_cmd.rs`, generic over the mode via
+ `BlockCipherEncryptor` / `BlockCipherDecryptor`. `aes_cbc_cmd.rs` and `aes_cfb_cmd.rs` are thin
+ dispatchers over it, so the two commands cannot drift apart on the parts that affect correctness.
* 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.
+* Input must be a whole number of 16-byte blocks. Unaligned input is rejected with a message saying
+ the commands apply no padding rather than being silently padded.
+* The `-cfb` commands are **CFB128**, and both the subcommand help and the alignment error name the
+ segment size, because `CFB8` and `CFB1` are different modes that would silently produce
+ incompatible output.
* Reads need not respect block boundaries: bytes accumulate in a 1 KiB buffer that goes through the flat
`do_*_out::<1024>` when full, and the whole-block remainder at end of input goes one block at a time; 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.
+* Verified against SP 800-38A F.2 (CBC) and F.3.13/F.3.15/F.3.17 (CFB128): prepending the spec's IV
+ to the spec's ciphertext and running `decrypt` reproduces the spec's plaintext for all three key
+ lengths in both modes. The CBC `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.
+* `cli/tests/aes_cfb_cli_tests.rs` (18 tests) mirrors that suite -- the shared plumbing is generic
+ over the mode, so a wiring mistake in the CFB dispatcher would not show up in the CBC tests -- and
+ adds three CFB-specific checks: the F.3 vectors, the Appendix D single-bit malleability observed
+ end to end through the pipe, and a guard that a CFB ciphertext does not decrypt as CBC or vice
+ versa (neither mode is authenticated, so the mismatch is otherwise silent).
`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.
diff --git a/cli/src/aes_cbc_cmd.rs b/cli/src/aes_cbc_cmd.rs
index d532a31a..1176026c 100644
--- a/cli/src/aes_cbc_cmd.rs
+++ b/cli/src/aes_cbc_cmd.rs
@@ -1,288 +1,64 @@
//! AES-CBC encryption and decryption, streaming stdin to stdout.
//!
-//! # The IV travels in the ciphertext
+//! Only the mode wiring lives here: the IV convention, key loading, stdin framing and
+//! block-alignment enforcement are all in [`crate::block_mode_cmd`], shared with the `aes*-cfb`
+//! commands. See that module for the command-line contract.
//!
-//! 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
-//! ```
+//! CBC (NIST SP 800-38A Sec 6.2) provides confidentiality only. It does not detect tampering, and
+//! neither the ciphertext nor the IV is authenticated -- a flipped ciphertext bit flips the same bit
+//! of the *next* block's plaintext (Appendix D). Do not decrypt data you have not authenticated
+//! separately.
-use crate::helpers::write_bytes_or_hex;
+use crate::block_mode_cmd::{BLOCK_LEN, BlockModeAction, decrypt_stream, encrypt_stream, load_key};
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::core::key_material::KeyMaterial;
+use bouncycastle::core::traits::BlockPermutation;
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;
-
-/// Bytes processed per call: 1 KiB = 64 blocks, matching the other streaming commands.
-///
-/// A full chunk goes through `do_*::` in one call, in place, which for decryption means
-/// 32 pairs down the `decrypt_blocks2` path. The at-most-63-block tail at end of input goes one
-/// block at a time; it is bounded, so its cost does not scale with the input.
-const CHUNK_LEN: usize = 64 * BLOCK_LEN;
-
-#[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,
-}
+/// Names the mode in error messages.
+const MODE: &str = "CBC";
pub(crate) fn aes128_cbc_cmd(
- action: &AESCBCAction,
+ action: &BlockModeAction,
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),
- }
+ run::(action, &load_key::<16>(key, key_file, "AES-128"), output_hex);
}
pub(crate) fn aes192_cbc_cmd(
- action: &AESCBCAction,
+ action: &BlockModeAction,
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),
- }
+ run::(action, &load_key::<24>(key, key_file, "AES-192"), output_hex);
}
pub(crate) fn aes256_cbc_cmd(
- action: &AESCBCAction,
+ action: &BlockModeAction,
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),
- }
+ run::(action, &load_key::<32>(key, key_file, "AES-256"), 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);
-
- // The cipher works in place: `data` holds plaintext on the way in and ciphertext on the way out.
- stream_aligned(|data| {
- if let Ok(chunk) = <&mut [u8; CHUNK_LEN]>::try_from(&mut *data) {
- // Cannot fail: CBC has no per-IV data limit.
- enc.do_encrypt(chunk).unwrap();
- } else {
- // The bounded tail at end of input: whole blocks, fewer than a chunk.
- for block in data.as_chunks_mut::().0 {
- enc.do_encrypt(block).unwrap();
- }
- }
- write_bytes_or_hex(data, 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
+/// Dispatches to the shared streaming loops with `Cbc` filled in as the mode.
+fn run(
+ action: &BlockModeAction,
+ 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);
- });
-
- stream_aligned(|data| {
- if let Ok(chunk) = <&mut [u8; CHUNK_LEN]>::try_from(&mut *data) {
- // A full chunk is 32 pairs, so this is the `decrypt_blocks2` path.
- dec.do_decrypt(chunk).unwrap();
- } else {
- for block in data.as_chunks_mut::().0 {
- dec.do_decrypt(block).unwrap();
- }
- }
- write_bytes_or_hex(data, output_hex);
- });
-
- finish(output_hex);
-}
-
-/// Reads stdin and hands it to `process` in block-aligned pieces, mutably so it can be transformed
-/// in place: a full `CHUNK_LEN` bytes each time one has accumulated, then once more at end of input
-/// with whatever whole blocks remain (fewer than a chunk). Reads need not respect block or chunk boundaries -- bytes simply accumulate in the
-/// buffer until it is full -- so a block split across two reads needs no special handling.
-///
-/// 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_aligned(mut process: impl FnMut(&mut [u8])) {
- let mut buf = [0u8; CHUNK_LEN];
- let mut filled = 0usize;
-
- loop {
- let n = io::stdin().read(&mut buf[filled..]).unwrap_or_else(|e| {
- eprintln!("Error: failed to read from stdin: {e}");
- exit(-1);
- });
- if n == 0 {
- break;
+ match action {
+ BlockModeAction::Encrypt => {
+ encrypt_stream::, KEY_LEN>(key, output_hex, MODE)
}
- filled += n;
- if filled == CHUNK_LEN {
- process(&mut buf);
- filled = 0;
+ BlockModeAction::Decrypt => {
+ decrypt_stream::, KEY_LEN>(key, output_hex, MODE)
}
}
-
- if !filled.is_multiple_of(BLOCK_LEN) {
- eprintln!(
- "Error: input is not a whole number of {BLOCK_LEN}-byte blocks ({} 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.",
- filled % BLOCK_LEN
- );
- exit(-1);
- }
- if filled != 0 {
- process(&mut buf[..filled]);
- }
-}
-
-/// 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/aes_cfb_cmd.rs b/cli/src/aes_cfb_cmd.rs
new file mode 100644
index 00000000..fac417ab
--- /dev/null
+++ b/cli/src/aes_cfb_cmd.rs
@@ -0,0 +1,75 @@
+//! AES-CFB128 encryption and decryption, streaming stdin to stdout.
+//!
+//! Only the mode wiring lives here: the IV convention, key loading, stdin framing and
+//! block-alignment enforcement are all in [`crate::block_mode_cmd`], shared with the `aes*-cbc`
+//! commands. See that module for the command-line contract.
+//!
+//! # Which CFB
+//!
+//! These commands are **CFB128**: the segment size is the full 16-byte block (`s = b` in NIST
+//! SP 800-38A Sec 6.3). That is the only segment size `bouncycastle-modes` provides, because it is
+//! the only block-aligned one. SP 800-38A also defines `s = 8` and `s = 1`, which are *not*
+//! interoperable with these commands -- if you need `CFB8` or `CFB1`, this is not it.
+//!
+//! # Warning
+//!
+//! CFB provides confidentiality only. It does not detect tampering, and neither the ciphertext nor
+//! the IV is authenticated. CFB's malleability is more directly exploitable than CBC's: Appendix D,
+//! Table D.2 gives "SBE in the decryption of Cj" -- flipping a ciphertext bit flips the *same* bit
+//! of the plaintext in the *same* block, so an attacker edits the block they aimed at, at the cost
+//! of randomising the next one. Do not decrypt data you have not authenticated separately.
+
+use crate::block_mode_cmd::{BLOCK_LEN, BlockModeAction, decrypt_stream, encrypt_stream, load_key};
+use bouncycastle::aes_lowmemory::{Aes128, Aes192, Aes256};
+use bouncycastle::core::key_material::KeyMaterial;
+use bouncycastle::core::traits::BlockPermutation;
+use bouncycastle::modes::{Cfb, Decrypting, Encrypting};
+
+/// Names the mode in error messages. Spelled with the segment size, because `CFB8` and `CFB1` are
+/// different modes and a bare "CFB" in a diagnostic would be ambiguous.
+const MODE: &str = "CFB128";
+
+pub(crate) fn aes128_cfb_cmd(
+ action: &BlockModeAction,
+ key: &Option,
+ key_file: &Option,
+ output_hex: bool,
+) {
+ run::(action, &load_key::<16>(key, key_file, "AES-128"), output_hex);
+}
+
+pub(crate) fn aes192_cfb_cmd(
+ action: &BlockModeAction,
+ key: &Option,
+ key_file: &Option,
+ output_hex: bool,
+) {
+ run::(action, &load_key::<24>(key, key_file, "AES-192"), output_hex);
+}
+
+pub(crate) fn aes256_cfb_cmd(
+ action: &BlockModeAction,
+ key: &Option,
+ key_file: &Option,
+ output_hex: bool,
+) {
+ run::(action, &load_key::<32>(key, key_file, "AES-256"), output_hex);
+}
+
+/// Dispatches to the shared streaming loops with `Cfb` filled in as the mode.
+fn run(
+ action: &BlockModeAction,
+ key: &KeyMaterial,
+ output_hex: bool,
+) where
+ P: BlockPermutation,
+{
+ match action {
+ BlockModeAction::Encrypt => {
+ encrypt_stream::, KEY_LEN>(key, output_hex, MODE)
+ }
+ BlockModeAction::Decrypt => {
+ decrypt_stream::, KEY_LEN>(key, output_hex, MODE)
+ }
+ }
+}
diff --git a/cli/src/block_mode_cmd.rs b/cli/src/block_mode_cmd.rs
new file mode 100644
index 00000000..e52ef826
--- /dev/null
+++ b/cli/src/block_mode_cmd.rs
@@ -0,0 +1,258 @@
+//! Shared plumbing for the block-cipher-mode subcommands: `aes{128,192,256}-{cbc,cfb}`.
+//!
+//! Everything here is mode-independent -- key loading, stdin framing, block-alignment enforcement,
+//! output formatting -- and is generic over the mode via [`BlockCipherEncryptor`] /
+//! [`BlockCipherDecryptor`]. `aes_cbc_cmd` and `aes_cfb_cmd` are thin dispatchers over it, so the
+//! two commands cannot drift apart on the parts that matter for correctness.
+//!
+//! # 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 and CFB 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 warnings on each subcommand.
+//!
+//! # Input must be block-aligned
+//!
+//! Both modes are defined here only on whole blocks (SP 800-38A Sec 5.2), and these commands apply
+//! no padding, so input that is not a multiple of 16 bytes is rejected rather than silently padded.
+//! Padding is the caller's business; the library offers `bouncycastle-padding` for it, but wiring a
+//! padding scheme into the CLI would change the on-the-wire format and is a separate decision.
+//!
+//! # 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::core::key_material::{
+ KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations,
+};
+use bouncycastle::core::traits::{BlockCipherDecryptor, BlockCipherEncryptor, SecurityStrength};
+use bouncycastle::hex;
+use clap::ValueEnum;
+use std::io::{Read, Write};
+use std::process::exit;
+use std::{fs, io};
+
+/// The AES block length in bytes.
+pub(crate) const BLOCK_LEN: usize = 16;
+
+/// Bytes processed per call: 1 KiB = 64 blocks, matching the other streaming commands.
+///
+/// A full chunk goes through `do_*::` in one call, in place, which for decryption means
+/// 32 pairs down the mode's two-block path. The at-most-63-block tail at end of input goes one
+/// block at a time; it is bounded, so its cost does not scale with the input.
+pub(crate) const CHUNK_LEN: usize = 64 * BLOCK_LEN;
+
+/// Which direction to run. Shared by every mode subcommand.
+#[derive(ValueEnum, Clone, Debug)]
+pub(crate) enum BlockModeAction {
+ /// Encrypt stdin to stdout.
+ /// 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.
+ /// 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,
+}
+
+/// 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.
+pub(crate) 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 under the mode `E`, writing the generated IV first.
+///
+/// `mode` names the mode in error messages ("CBC", "CFB128"); it has no effect on the output.
+pub(crate) fn encrypt_stream(
+ key: &KeyMaterial,
+ output_hex: bool,
+ mode: &str,
+) where
+ E: BlockCipherEncryptor,
+{
+ let (mut enc, iv) = E::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);
+
+ // The cipher works in place: `data` holds plaintext on the way in and ciphertext on the way out.
+ stream_aligned(mode, |data| {
+ if let Ok(chunk) = <&mut [u8; CHUNK_LEN]>::try_from(&mut *data) {
+ // Cannot fail: neither mode has a per-IV data limit.
+ enc.do_encrypt(chunk).unwrap();
+ } else {
+ // The bounded tail at end of input: whole blocks, fewer than a chunk.
+ for block in data.as_chunks_mut::().0 {
+ enc.do_encrypt(block).unwrap();
+ }
+ }
+ write_bytes_or_hex(data, output_hex);
+ });
+
+ finish(output_hex);
+}
+
+/// Decrypts stdin to stdout under the mode `D`, taking the IV from the first block of input.
+pub(crate) fn decrypt_stream(
+ key: &KeyMaterial,
+ output_hex: bool,
+ mode: &str,
+) where
+ D: BlockCipherDecryptor,
+{
+ // 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 = D::do_decrypt_init(key, &iv).unwrap_or_else(|e| {
+ eprintln!("Error: couldn't start decryption: {e:?}");
+ exit(-1);
+ });
+
+ stream_aligned(mode, |data| {
+ if let Ok(chunk) = <&mut [u8; CHUNK_LEN]>::try_from(&mut *data) {
+ // A full chunk is 32 pairs, so this is the mode's two-block path.
+ dec.do_decrypt(chunk).unwrap();
+ } else {
+ for block in data.as_chunks_mut::().0 {
+ dec.do_decrypt(block).unwrap();
+ }
+ }
+ write_bytes_or_hex(data, output_hex);
+ });
+
+ finish(output_hex);
+}
+
+/// Reads stdin and hands it to `process` in block-aligned pieces, mutably so it can be transformed
+/// in place: a full `CHUNK_LEN` bytes each time one has accumulated, then once more at end of input
+/// with whatever whole blocks remain (fewer than a chunk). Reads need not respect block or chunk boundaries -- bytes simply accumulate in the
+/// buffer until it is full -- so a block split across two reads needs no special handling.
+///
+/// Input whose total length is not a multiple of `BLOCK_LEN` is an error, because neither mode is
+/// defined on a partial block and these commands do not pad.
+fn stream_aligned(mode: &str, mut process: impl FnMut(&mut [u8])) {
+ let mut buf = [0u8; CHUNK_LEN];
+ let mut filled = 0usize;
+
+ loop {
+ let n = io::stdin().read(&mut buf[filled..]).unwrap_or_else(|e| {
+ eprintln!("Error: failed to read from stdin: {e}");
+ exit(-1);
+ });
+ if n == 0 {
+ break;
+ }
+ filled += n;
+ if filled == CHUNK_LEN {
+ process(&mut buf);
+ filled = 0;
+ }
+ }
+
+ if !filled.is_multiple_of(BLOCK_LEN) {
+ eprintln!(
+ "Error: input is not a whole number of {BLOCK_LEN}-byte blocks ({} trailing byte(s)). \
+ {mode} is defined only on whole blocks (SP 800-38A Sec 5.2), and these commands apply \
+ no padding, so the input must be padded by the caller.",
+ filled % BLOCK_LEN
+ );
+ exit(-1);
+ }
+ if filled != 0 {
+ process(&mut buf[..filled]);
+ }
+}
+
+/// 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 b7ade957..a2fdc8b9 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -1,4 +1,6 @@
mod aes_cbc_cmd;
+mod aes_cfb_cmd;
+mod block_mode_cmd;
mod encoders_cmd;
mod helpers;
mod hkdf_cmd;
@@ -9,7 +11,7 @@ mod rng_cmd;
mod sha2_cmd;
mod sha3_cmd;
-use crate::aes_cbc_cmd::AESCBCAction;
+use crate::block_mode_cmd::BlockModeAction;
use crate::mac_cmd::HMACVariant;
use crate::mldsa_cmd::MLDSAAction;
use clap::{Parser, Subcommand};
@@ -280,7 +282,7 @@ enum Subcommands {
/// 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.
+ /// these commands apply no padding, 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
@@ -289,7 +291,7 @@ enum Subcommands {
/// 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,
+ action: BlockModeAction,
/// The 16-byte AES key in hex.
/// The `key_file` option is preferred to avoid leaving key material in command history.
@@ -311,7 +313,7 @@ enum Subcommands {
/// See `aes128-cbc` for the IV convention, block-alignment requirement and warnings; only the
/// key length differs.
AES192_CBC {
- action: AESCBCAction,
+ action: BlockModeAction,
/// The 24-byte AES key in hex.
/// The `key_file` option is preferred to avoid leaving key material in command history.
@@ -333,7 +335,88 @@ enum Subcommands {
/// See `aes128-cbc` for the IV convention, block-alignment requirement and warnings; only the
/// key length differs.
AES256_CBC {
- action: AESCBCAction,
+ action: BlockModeAction,
+
+ /// 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,
+ },
+
+ /// AES-128 in CFB128 mode (NIST SP 800-38A Sec 6.3), streaming stdin to stdout.
+ ///
+ /// The segment size is the full block, i.e. CFB128. SP 800-38A's 8-bit and 1-bit CFB variants
+ /// are different modes and are NOT interoperable with this command.
+ ///
+ /// 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: this command is block-aligned and applies
+ /// no padding, so unaligned input is rejected rather than padded.
+ ///
+ /// WARNING: CFB provides confidentiality only. It does not detect tampering, and neither the
+ /// ciphertext nor the IV is authenticated. Flipping a ciphertext bit flips the same bit of the
+ /// plaintext in the same block, so tampering is directly exploitable. 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_CFB {
+ action: BlockModeAction,
+
+ /// 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 CFB128 mode (NIST SP 800-38A Sec 6.3), streaming stdin to stdout.
+ ///
+ /// See `aes128-cfb` for the IV convention, block-alignment requirement and warnings; only the
+ /// key length differs.
+ AES192_CFB {
+ action: BlockModeAction,
+
+ /// 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 CFB128 mode (NIST SP 800-38A Sec 6.3), streaming stdin to stdout.
+ ///
+ /// See `aes128-cfb` for the IV convention, block-alignment requirement and warnings; only the
+ /// key length differs.
+ AES256_CFB {
+ action: BlockModeAction,
/// The 32-byte AES key in hex.
/// The `key_file` option is preferred to avoid leaving key material in command history.
@@ -652,6 +735,15 @@ fn main() {
Some(Subcommands::AES256_CBC { action, key, key_file, x }) => {
aes_cbc_cmd::aes256_cbc_cmd(action, key, key_file, *x);
}
+ Some(Subcommands::AES128_CFB { action, key, key_file, x }) => {
+ aes_cfb_cmd::aes128_cfb_cmd(action, key, key_file, *x);
+ }
+ Some(Subcommands::AES192_CFB { action, key, key_file, x }) => {
+ aes_cfb_cmd::aes192_cfb_cmd(action, key, key_file, *x);
+ }
+ Some(Subcommands::AES256_CFB { action, key, key_file, x }) => {
+ aes_cfb_cmd::aes256_cfb_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
index 9dcea30c..d659c0cd 100644
--- a/cli/tests/aes_cbc_cli_tests.rs
+++ b/cli/tests/aes_cbc_cli_tests.rs
@@ -7,8 +7,9 @@
//! `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::io::{ErrorKind, Write};
use std::process::{Command, Output, Stdio};
+use std::thread;
/// The path to the binary under test, resolved by cargo.
const BC_RUST: &str = env!("CARGO_BIN_EXE_bc-rust");
@@ -51,6 +52,27 @@ const CT_256: &str = concat!(
);
/// Runs `bc-rust ` with `stdin_bytes` on stdin and returns the completed output.
+///
+/// # Why stdin is written from a thread
+///
+/// stdin, stdout and stderr are all pipes with a bounded buffer (typically 64 KiB). Writing all of
+/// stdin from *this* thread before reading any output deadlocks as soon as the payload is large
+/// enough: the child fills its stdout buffer and blocks, so it stops draining stdin, so our write
+/// blocks too, and neither side can move. That is a hang rather than a failure, so it would surface
+/// as a CI timeout. Writing on a separate thread leaves this one free to drain stdout and stderr
+/// via `wait_with_output`, which breaks the cycle. `a_payload_larger_than_the_pipe_buffer_round_trips`
+/// pins it.
+///
+/// Dropping the pipe when the write finishes is what signals EOF to the child, so the writer thread
+/// owns the handle (`take`, not `as_mut`) and must run to completion.
+///
+/// # Why `BrokenPipe` is ignored
+///
+/// The error-path tests hand a rejected key or a misaligned length to a command that `exit`s before
+/// it reads stdin, so the write races the child's exit and loses. That is an expected outcome, not a
+/// harness failure: those tests assert the exit status and stderr, both of which `wait_with_output`
+/// still returns. Any *other* write error is a real problem and still panics.
+/// `a_large_payload_on_an_error_path_does_not_break_the_harness` pins it.
fn run(args: &[&str], stdin_bytes: &[u8]) -> Output {
let mut child = Command::new(BC_RUST)
.args(args)
@@ -60,14 +82,22 @@ fn run(args: &[&str], stdin_bytes: &[u8]) -> Output {
.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")
+ let mut stdin = child.stdin.take().expect("stdin piped");
+ let payload = stdin_bytes.to_vec();
+ let writer = thread::spawn(move || {
+ match stdin.write_all(&payload) {
+ Ok(()) => {}
+ Err(e) if e.kind() == ErrorKind::BrokenPipe => {}
+ Err(e) => panic!("failed to write to stdin: {e}"),
+ }
+ // `stdin` drops here, closing the pipe so the child sees EOF and can exit.
+ });
+
+ // Drain stdout and stderr first: the writer may still be blocked on a full stdin buffer, and it
+ // cannot finish until the child consumes more, which it cannot do while its output is backed up.
+ let output = child.wait_with_output().expect("failed to wait for bc-rust");
+ writer.join().expect("the stdin writer thread panicked");
+ output
}
/// Runs a command that is expected to succeed, returning stdout.
@@ -118,6 +148,45 @@ fn pseudo_random(len: usize, seed: u32) -> Vec {
.collect()
}
+// ---- the harness itself ------------------------------------------------------------------
+//
+// These two pin `run`'s pipe handling. Both bugs they cover are timing-dependent: they pass on a
+// fast machine with a small payload and fail on a slow or loaded runner, which is exactly how the
+// first one reached CI. Forcing the condition with an oversized payload makes them deterministic
+// instead of waiting for a bad day. The same pair exists in `aes_cfb_cli_tests.rs`, because each
+// file has its own copy of `run`.
+
+/// Far beyond any pipe buffer, so a write cannot complete before the child has drained it.
+const OVERSIZED: usize = 4 * 1024 * 1024;
+
+/// An error path must not take the harness down with it.
+///
+/// `encrypt` with no `--key` prints its complaint and exits without reading stdin, so the write
+/// loses the race and the pipe breaks. Before `run` tolerated `ErrorKind::BrokenPipe` this panicked
+/// with "failed to write to stdin" (os error 109 on Windows, EPIPE elsewhere) instead of reporting
+/// the CLI's actual error, which is what the other error-path tests assert on.
+#[test]
+fn a_large_payload_on_an_error_path_does_not_break_the_harness() {
+ let stderr = run_err(&["aes128-cbc", "encrypt"], &vec![0u8; OVERSIZED]);
+ assert!(stderr.contains("--key"), "the CLI's own error must still be reported: {stderr}");
+}
+
+/// A payload larger than the pipe buffer must round-trip rather than deadlock.
+///
+/// This is the reason `run` writes stdin from a separate thread. Writing it inline wedges once both
+/// pipes fill: the child blocks writing stdout, so it stops reading stdin, so the harness blocks
+/// writing stdin. Nothing times out on its own -- the test just hangs until CI kills the job -- so
+/// this is the check that would have caught it.
+#[test]
+fn a_payload_larger_than_the_pipe_buffer_round_trips() {
+ let plaintext = pseudo_random(OVERSIZED, 0xC0FFEE);
+ let ciphertext = run_ok(&["aes128-cbc", "encrypt", "--key", KEY_128], &plaintext);
+ assert_eq!(ciphertext.len(), plaintext.len() + 16, "IV plus the ciphertext");
+
+ let recovered = run_ok(&["aes128-cbc", "decrypt", "--key", KEY_128], &ciphertext);
+ assert_eq!(recovered, plaintext, "{OVERSIZED} bytes should round trip");
+}
+
// ---- 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
diff --git a/cli/tests/aes_cfb_cli_tests.rs b/cli/tests/aes_cfb_cli_tests.rs
new file mode 100644
index 00000000..571cfebe
--- /dev/null
+++ b/cli/tests/aes_cfb_cli_tests.rs
@@ -0,0 +1,513 @@
+//! Tests for the `aes128-cfb` / `aes192-cfb` / `aes256-cfb` 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.
+//!
+//! The commands share all of that plumbing with `aes*-cbc` (`cli/src/block_mode_cmd.rs`), so this
+//! file deliberately repeats the CBC suite's coverage rather than assuming it: the shared code is
+//! generic over the mode, and a wiring mistake in the CFB dispatcher would not show up in the CBC
+//! tests. What is *not* shared, and is tested only here, is the F.3 vectors, the CFB-specific
+//! Appendix D error propagation, and the guard that CFB and CBC ciphertexts are not interchangeable.
+//!
+//! `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::{ErrorKind, Write};
+use std::process::{Command, Output, Stdio};
+use std::thread;
+
+/// 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.3 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.3.13 CFB128-AES128.Encrypt ciphertext.
+const CT_128: &str = concat!(
+ "3b3fd92eb72dad20333449f8e83cfb4a",
+ "c8a64537a0b3a93fcde3cdad9f1ce58b",
+ "26751f67a3cbb140b1808cf187a4f4df",
+ "c04b05357c5d1c0eeac4c66f9ff7f2e6",
+);
+/// F.3.15 CFB128-AES192.Encrypt ciphertext.
+const CT_192: &str = concat!(
+ "cdc80d6fddf18cab34c25909c99a4174",
+ "67ce7f7f81173621961a2b70171d3d7a",
+ "2e1e8a1dd59b88b1c8e60fed1efac4c9",
+ "c05f9f9ca9834fa042ae8fba584b09ff",
+);
+/// F.3.17 CFB128-AES256.Encrypt ciphertext.
+const CT_256: &str = concat!(
+ "dc7e84bfda79164b7ecd8486985d3860",
+ "39ffed143b28b1c832113c6331e5407b",
+ "df10132415e54b92a13ed0a8267ae2f9",
+ "75a385741ab9cef82031623d55b1e471",
+);
+
+/// F.2.1 CBC-AES128.Encrypt ciphertext, for the cross-mode guard.
+const CBC_CT_128: &str = concat!(
+ "7649abac8119b246cee98e9b12e9197d",
+ "5086cb9b507219ee95db113a917678b2",
+ "73bed6b8e3c1743b7116e69e22229516",
+ "3ff1caa1681fac09120eca307586e1a7",
+);
+
+/// Runs `bc-rust ` with `stdin_bytes` on stdin and returns the completed output.
+///
+/// # Why stdin is written from a thread
+///
+/// stdin, stdout and stderr are all pipes with a bounded buffer (typically 64 KiB). Writing all of
+/// stdin from *this* thread before reading any output deadlocks as soon as the payload is large
+/// enough: the child fills its stdout buffer and blocks, so it stops draining stdin, so our write
+/// blocks too, and neither side can move. That is a hang rather than a failure, so it would surface
+/// as a CI timeout. Writing on a separate thread leaves this one free to drain stdout and stderr
+/// via `wait_with_output`, which breaks the cycle. `a_payload_larger_than_the_pipe_buffer_round_trips`
+/// pins it.
+///
+/// Dropping the pipe when the write finishes is what signals EOF to the child, so the writer thread
+/// owns the handle (`take`, not `as_mut`) and must run to completion.
+///
+/// # Why `BrokenPipe` is ignored
+///
+/// The error-path tests hand a rejected key or a misaligned length to a command that `exit`s before
+/// it reads stdin, so the write races the child's exit and loses. That is an expected outcome, not a
+/// harness failure: those tests assert the exit status and stderr, both of which `wait_with_output`
+/// still returns. Any *other* write error is a real problem and still panics.
+/// `a_large_payload_on_an_error_path_does_not_break_the_harness` pins it.
+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");
+
+ let mut stdin = child.stdin.take().expect("stdin piped");
+ let payload = stdin_bytes.to_vec();
+ let writer = thread::spawn(move || {
+ match stdin.write_all(&payload) {
+ Ok(()) => {}
+ Err(e) if e.kind() == ErrorKind::BrokenPipe => {}
+ Err(e) => panic!("failed to write to stdin: {e}"),
+ }
+ // `stdin` drops here, closing the pipe so the child sees EOF and can exit.
+ });
+
+ // Drain stdout and stderr first: the writer may still be blocked on a full stdin buffer, and it
+ // cannot finish until the child consumes more, which it cannot do while its output is backed up.
+ let output = child.wait_with_output().expect("failed to wait for bc-rust");
+ writer.join().expect("the stdin writer thread panicked");
+ output
+}
+
+/// 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 harness itself ------------------------------------------------------------------
+//
+// These two pin `run`'s pipe handling. Both bugs they cover are timing-dependent: they pass on a
+// fast machine with a small payload and fail on a slow or loaded runner, which is exactly how the
+// first one reached CI. Forcing the condition with an oversized payload makes them deterministic
+// instead of waiting for a bad day. The same pair exists in `aes_cbc_cli_tests.rs`, because each
+// file has its own copy of `run`.
+
+/// Far beyond any pipe buffer, so a write cannot complete before the child has drained it.
+const OVERSIZED: usize = 4 * 1024 * 1024;
+
+/// An error path must not take the harness down with it.
+///
+/// `encrypt` with no `--key` prints its complaint and exits without reading stdin, so the write
+/// loses the race and the pipe breaks. Before `run` tolerated `ErrorKind::BrokenPipe` this panicked
+/// with "failed to write to stdin" (os error 109 on Windows, EPIPE elsewhere) instead of reporting
+/// the CLI's actual error, which is what the other error-path tests assert on.
+#[test]
+fn a_large_payload_on_an_error_path_does_not_break_the_harness() {
+ let stderr = run_err(&["aes128-cfb", "encrypt"], &vec![0u8; OVERSIZED]);
+ assert!(stderr.contains("--key"), "the CLI's own error must still be reported: {stderr}");
+}
+
+/// A payload larger than the pipe buffer must round-trip rather than deadlock.
+///
+/// This is the reason `run` writes stdin from a separate thread. Writing it inline wedges once both
+/// pipes fill: the child blocks writing stdout, so it stops reading stdin, so the harness blocks
+/// writing stdin. Nothing times out on its own -- the test just hangs until CI kills the job -- so
+/// this is the check that would have caught it.
+#[test]
+fn a_payload_larger_than_the_pipe_buffer_round_trips() {
+ let plaintext = pseudo_random(OVERSIZED, 0xC0FFEE);
+ let ciphertext = run_ok(&["aes128-cfb", "encrypt", "--key", KEY_128], &plaintext);
+ assert_eq!(ciphertext.len(), plaintext.len() + 16, "IV plus the ciphertext");
+
+ let recovered = run_ok(&["aes128-cfb", "decrypt", "--key", KEY_128], &ciphertext);
+ assert_eq!(recovered, plaintext, "{OVERSIZED} bytes should round trip");
+}
+
+// ---- the SP 800-38A F.3 vectors, through the CLI -----------------------------------------
+
+/// `decrypt` reproduces the spec plaintext when handed the spec's IV followed by the spec's
+/// ciphertext, for F.3.13/F.3.15/F.3.17 (CFB128-AES128/192/256).
+///
+/// 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_cfb_tests.rs`.
+#[test]
+fn decrypt_matches_sp800_38a_f3_vectors() {
+ for (cmd, key, ct) in [
+ ("aes128-cfb", KEY_128, CT_128),
+ ("aes192-cfb", KEY_192, CT_192),
+ ("aes256-cfb", 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.3 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-cfb", "decrypt", "--key", KEY_128], &input);
+ let hex_out = run_ok(&["aes128-cfb", "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-cfb", KEY_128), ("aes192-cfb", KEY_192), ("aes256-cfb", 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-cfb", "encrypt", "--key", KEY_128], &plaintext);
+ let recovered = run_ok(&["aes128-cfb", "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 matters even more for CFB than for CBC: CFB XORs a keystream, so a repeated key-and-IV pair
+/// leaks the XOR of the two plaintexts outright, not merely whether blocks were equal.
+#[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-cfb", "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-cfb", "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_cfb_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-cfb", "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-cfb", "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-cfb", "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-cfb", "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. These commands are the `s = b` CFB variant, so they need whole blocks and
+/// they do not pad.
+#[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-cfb", "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 padding being the caller's job: {stderr}"
+ );
+ assert!(stderr.contains("CFB128"), "stderr should name the mode: {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-cfb", "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-cfb", "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-cfb", "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-cfb", "decrypt", "--key", KEY_128], &out);
+ assert!(back.is_empty(), "decrypting an IV with no body should give nothing");
+}
+
+// ---- SP 800-38A Appendix D, through the CLI ----------------------------------------------
+
+/// Appendix D, Table D.2 for CFB: a bit error in `Cj` gives "SBE in the decryption of `Cj`" --
+/// **specific** bit errors, i.e. the very same bit position -- plus random bit errors in `Cj+1`,
+/// and nothing beyond that (with `s = b`, `b/s` is 1).
+///
+/// This is the property that makes CFB tampering directly exploitable, which is why the subcommand
+/// help warns about it, and it is also a sharp end-to-end check that the CLI is running CFB rather
+/// than CBC: under CBC the controlled flip would land in `Pj+1`, not `Pj`.
+#[test]
+fn a_ciphertext_bit_flip_flips_the_same_plaintext_bit() {
+ let plaintext = unhex(PLAINTEXT);
+ let mut input = unhex(&format!("{IV}{CT_128}"));
+
+ // Byte 3 of the second ciphertext block. Input layout is IV | C1 | C2 | C3 | C4, so C2 starts
+ // at offset 32.
+ const OFFSET: usize = 32 + 3;
+ const MASK: u8 = 0b0010_0000;
+ input[OFFSET] ^= MASK;
+
+ let out = run_ok(&["aes128-cfb", "decrypt", "--key", KEY_128], &input);
+ assert_eq!(out.len(), 64);
+
+ assert_eq!(&out[0..16], &plaintext[0..16], "P1 depends only on the IV, so it is unaffected");
+
+ let mut expected_p2 = plaintext[16..32].to_vec();
+ expected_p2[3] ^= MASK;
+ assert_eq!(&out[16..32], &expected_p2[..], "P2 should show exactly the flipped bit");
+
+ assert_ne!(&out[32..48], &plaintext[32..48], "P3 is randomised: C2 feeds the next cipher call");
+ assert_eq!(
+ &out[48..64],
+ &plaintext[48..64],
+ "P4 is unaffected: with s = b, damage stops at P3"
+ );
+}
+
+// ---- cross-variant and cross-mode 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-cfb", "encrypt", "--key", KEY_128], &plaintext);
+
+ // Right length, wrong key: decryption "succeeds" but must not recover the plaintext. CFB 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-cfb", "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: CFB is unauthenticated");
+}
+
+/// CFB and CBC ciphertexts are not interchangeable, in either direction.
+///
+/// The two commands take the same arguments and produce the same-shaped output, so nothing but this
+/// stops a caller pairing them up by mistake. Both spec ciphertexts are for the same key, IV and
+/// plaintext, so this is a clean comparison: each mode must reproduce the plaintext only from its
+/// own ciphertext.
+#[test]
+fn cfb_and_cbc_are_not_interchangeable() {
+ let plaintext = unhex(PLAINTEXT);
+ let cfb_input = unhex(&format!("{IV}{CT_128}"));
+ let cbc_input = unhex(&format!("{IV}{CBC_CT_128}"));
+
+ // Each mode with its own ciphertext: correct.
+ assert_eq!(run_ok(&["aes128-cfb", "decrypt", "--key", KEY_128], &cfb_input), plaintext);
+ assert_eq!(run_ok(&["aes128-cbc", "decrypt", "--key", KEY_128], &cbc_input), plaintext);
+
+ // Each mode with the other's ciphertext: wrong, but silently so -- neither mode is
+ // authenticated, so there is nothing to detect the mismatch.
+ let cfb_reads_cbc = run_ok(&["aes128-cfb", "decrypt", "--key", KEY_128], &cbc_input);
+ assert_ne!(cfb_reads_cbc, plaintext, "CFB must not decrypt a CBC ciphertext");
+
+ let cbc_reads_cfb = run_ok(&["aes128-cbc", "decrypt", "--key", KEY_128], &cfb_input);
+ assert_ne!(cbc_reads_cfb, plaintext, "CBC must not decrypt a CFB ciphertext");
+}
+
+// ---- discoverability --------------------------------------------------------------------
+
+/// 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-cfb", "aes192-cfb", "aes256-cfb"] {
+ assert!(help.contains(cmd), "`--help` should list {cmd}");
+ }
+}
+
+/// Each subcommand's own help names the two actions, the IV convention, and -- because `CFB8` and
+/// `CFB1` are different, non-interoperable modes -- the segment size.
+#[test]
+fn per_command_help_documents_the_iv_convention_and_the_segment_size() {
+ let out = run_ok(&["aes128-cfb", "--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}"
+ );
+ assert!(help.contains("CFB128"), "help should say which CFB variant this is: {help}");
+}
diff --git a/crypto/aes-lowmemory/src/cfb.rs b/crypto/aes-lowmemory/src/cfb.rs
new file mode 100644
index 00000000..5549188c
--- /dev/null
+++ b/crypto/aes-lowmemory/src/cfb.rs
@@ -0,0 +1,96 @@
+//! Type aliases for AES in CFB mode (NIST SP 800-38A Sec 6.3).
+//!
+//! `bouncycastle-modes` is deliberately cipher-agnostic, so `Cfb` takes the permutation, the
+//! direction, and the `KEY_LEN` / `BLOCK_LEN` const parameters. These aliases pin the AES values so
+//! callers never spell them out. They add nothing to the engine: the permutation still implements
+//! none of the data-encryption traits itself (see the crate docs), the mode does.
+//!
+//! The segment size is the full block, so these are **CFB128**. SP 800-38A's `s = 8` and `s = 1`
+//! variants are not block-aligned and are not implemented; see the `bouncycastle_modes::Cfb` docs.
+
+use crate::{Aes128, Aes192, Aes256, BLOCK_LEN};
+use bouncycastle_modes::Cfb;
+
+/// AES-128 in CFB128 mode. `Dir` is [`bouncycastle_modes::Encrypting`] or
+/// [`bouncycastle_modes::Decrypting`]; the wrong direction is a compile error, not a runtime check.
+///
+/// The IV is generated by encryption and returned; it is never supplied. Encryption and decryption
+/// work in place.
+///
+/// ```
+/// use bouncycastle_aes_lowmemory::AES_CFB_128;
+/// use bouncycastle_core::key_material::{KeyMaterial, KeyType};
+/// use bouncycastle_core::traits::{BlockCipherDecryptor, BlockCipherEncryptor};
+/// use bouncycastle_modes::{Decrypting, Encrypting};
+///
+/// let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42; 16], KeyType::SymmetricCipherKey)
+/// .expect("a 16-byte symmetric cipher key");
+/// // 48 bytes: three whole blocks. The length is checked at compile time.
+/// let message = [0u8; 48];
+/// let mut data = message;
+/// let iv = AES_CFB_128::::encrypt(&key, &mut data).unwrap();
+/// assert_ne!(data, message);
+/// AES_CFB_128::::decrypt(&key, &iv, &mut data).unwrap();
+/// assert_eq!(data, message);
+///
+/// // Streaming, a few blocks at a time:
+/// let (mut enc, iv) = AES_CFB_128::::do_encrypt_init(&key).unwrap();
+/// let mut first = [0u8; 16];
+/// let mut rest = [1u8; 32];
+/// enc.do_encrypt(&mut first).unwrap();
+/// enc.do_encrypt(&mut rest).unwrap();
+/// let mut dec = AES_CFB_128::::do_decrypt_init(&key, &iv).unwrap();
+/// dec.do_decrypt(&mut first).unwrap();
+/// dec.do_decrypt(&mut rest).unwrap();
+/// assert_eq!(first, [0u8; 16]);
+/// assert_eq!(rest, [1u8; 32]);
+/// ```
+///
+/// A length that is not a whole number of blocks is a **compile** error, not a runtime one:
+///
+/// ```compile_fail
+/// use bouncycastle_aes_lowmemory::AES_CFB_128;
+/// use bouncycastle_core::key_material::{KeyMaterial, KeyType};
+/// use bouncycastle_core::traits::BlockCipherEncryptor;
+/// use bouncycastle_modes::Encrypting;
+///
+/// let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42; 16], KeyType::SymmetricCipherKey).unwrap();
+/// // 47 bytes is not a multiple of 16: the inline const assertion in `encrypt` fails to compile.
+/// let _ = AES_CFB_128::::encrypt(&key, &mut [0u8; 47]);
+/// ```
+#[allow(non_camel_case_types)]
+pub type AES_CFB_128 = Cfb;
+
+/// AES-192 in CFB128 mode. See [`AES_CFB_128`].
+///
+/// ```
+/// use bouncycastle_aes_lowmemory::AES_CFB_192;
+/// use bouncycastle_core::key_material::{KeyMaterial, KeyType};
+/// use bouncycastle_core::traits::{BlockCipherDecryptor, BlockCipherEncryptor};
+/// use bouncycastle_modes::{Decrypting, Encrypting};
+///
+/// let key = KeyMaterial::<24>::from_bytes_as_type(&[0x42; 24], KeyType::SymmetricCipherKey).unwrap();
+/// let mut data = [0u8; 32];
+/// let iv = AES_CFB_192::::encrypt(&key, &mut data).unwrap();
+/// AES_CFB_192::::decrypt(&key, &iv, &mut data).unwrap();
+/// assert_eq!(data, [0u8; 32]);
+/// ```
+#[allow(non_camel_case_types)]
+pub type AES_CFB_192 = Cfb;
+
+/// AES-256 in CFB128 mode. See [`AES_CFB_128`].
+///
+/// ```
+/// use bouncycastle_aes_lowmemory::AES_CFB_256;
+/// use bouncycastle_core::key_material::{KeyMaterial, KeyType};
+/// use bouncycastle_core::traits::{BlockCipherDecryptor, BlockCipherEncryptor};
+/// use bouncycastle_modes::{Decrypting, Encrypting};
+///
+/// let key = KeyMaterial::<32>::from_bytes_as_type(&[0x42; 32], KeyType::SymmetricCipherKey).unwrap();
+/// let mut data = [0u8; 32];
+/// let iv = AES_CFB_256::::encrypt(&key, &mut data).unwrap();
+/// AES_CFB_256::::decrypt(&key, &iv, &mut data).unwrap();
+/// assert_eq!(data, [0u8; 32]);
+/// ```
+#[allow(non_camel_case_types)]
+pub type AES_CFB_256 = Cfb;
diff --git a/crypto/aes-lowmemory/src/lib.rs b/crypto/aes-lowmemory/src/lib.rs
index c7ede6c5..8a1f6786 100644
--- a/crypto/aes-lowmemory/src/lib.rs
+++ b/crypto/aes-lowmemory/src/lib.rs
@@ -56,11 +56,14 @@
//! assert_eq!(blocks, [[0u8; 16], [1u8; 16]]);
//! ```
//!
-//! ## CBC mode
+//! ## Modes of operation
//!
//! To encrypt more than one block, use a mode of operation from `bouncycastle-modes`. This crate
-//! provides [`AES_CBC_128`], [`AES_CBC_192`] and [`AES_CBC_256`] as aliases that fill in the const
-//! parameters, with the direction left as the type parameter:
+//! provides aliases that fill in the const parameters, with the direction left as the type
+//! parameter: [`AES_CBC_128`], [`AES_CBC_192`] and [`AES_CBC_256`] for CBC (SP 800-38A Sec 6.2),
+//! and [`AES_CFB_128`], [`AES_CFB_192`] and [`AES_CFB_256`] for CFB128 (Sec 6.3). The two are
+//! interchangeable at the call site -- swap `AES_CBC_256` for `AES_CFB_256` in the example below
+//! and nothing else changes:
//!
//! ```
//! use bouncycastle_aes_lowmemory::AES_CBC_256;
@@ -193,6 +196,7 @@
mod aes;
mod bitslice;
mod cbc;
+mod cfb;
mod round;
mod sbox;
mod schedule;
@@ -200,4 +204,5 @@ mod schedule;
pub use aes::{Aes, Aes128, Aes192, Aes256, BLOCK_LEN};
pub use bitslice::Block;
pub use cbc::{AES_CBC_128, AES_CBC_192, AES_CBC_256};
+pub use cfb::{AES_CFB_128, AES_CFB_192, AES_CFB_256};
pub use schedule::{Aes128Params, Aes192Params, Aes256Params, AesParams};
diff --git a/crypto/modes/Cargo.toml b/crypto/modes/Cargo.toml
index 1aca516f..bc020c7f 100644
--- a/crypto/modes/Cargo.toml
+++ b/crypto/modes/Cargo.toml
@@ -12,6 +12,8 @@ bouncycastle-rng.workspace = true
bouncycastle-aes-lowmemory.workspace = true
bouncycastle-core-test-framework.workspace = true
bouncycastle-hex.workspace = true
+# Only to prove the modes compose with the padding layer for arbitrary-length data; no runtime dep.
+bouncycastle-padding.workspace = true
criterion.workspace = true
serde_json = "1.0"
diff --git a/crypto/modes/benches/modes_benches.rs b/crypto/modes/benches/modes_benches.rs
index e7ea635e..da8d0440 100644
--- a/crypto/modes/benches/modes_benches.rs
+++ b/crypto/modes/benches/modes_benches.rs
@@ -1,18 +1,25 @@
//! 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.
+//! The number to watch is the **decrypt/encrypt throughput ratio at N >= 2**. Encryption in both
+//! CBC and CFB is serial by construction (SP 800-38A Sec 6.2 and Sec 6.3: each forward cipher input
+//! depends on the previous output), so it can only ever use the single-block path. *Decryption* in
+//! both is parallel, and this implementation hands blocks to the permutation's pair method -- for
+//! CBC that is `decrypt_blocks2`, for CFB it is `encrypt_blocks2`, since CFB uses the forward
+//! function in both directions. 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.
//!
//! The cipher works in place, so each measurement runs on a fresh copy of the data made in
//! criterion's untimed setup (`iter_batched`); the copy is not part of the timing.
+//!
+//! The `modes::cbc::Aes128` and `modes::cfb::Aes128` groups are directly comparable -- same cipher,
+//! same data, same call granularity -- so the difference between them is the cost of the mode. CFB
+//! never calls the inverse cipher, so on an engine whose inverse is slower than its forward
+//! direction, CFB decryption is expected to come out ahead of CBC decryption.
use bouncycastle_aes_lowmemory::{Aes128, Aes256};
use bouncycastle_core::errors::SymmetricCipherError;
@@ -20,7 +27,7 @@ use bouncycastle_core::key_material::{KeyMaterial, KeyType};
use bouncycastle_core::traits::{
Algorithm, BlockCipherDecryptor, BlockCipherEncryptor, BlockPermutation, SecurityStrength,
};
-use bouncycastle_modes::{Cbc, Decrypting, Encrypting};
+use bouncycastle_modes::{Cbc, Cfb, Decrypting, Encrypting};
use criterion::{BatchSize, Criterion, Throughput, criterion_group, criterion_main};
use std::hint::black_box;
@@ -31,6 +38,8 @@ const DATA_LEN: usize = NUM_BLOCKS * BLOCK_LEN;
type Aes128Cbc = Cbc;
type Aes256Cbc = Cbc;
+type Aes128Cfb = Cfb;
+type Aes256Cfb = Cfb;
/// AES-128 with the pair methods **not** overridden, so they fall back to the trait defaults of
/// two single-block calls.
@@ -63,6 +72,7 @@ impl BlockPermutation<16, BLOCK_LEN> for UnpairedAes128 {
}
type UnpairedAes128Cbc = Cbc;
+type UnpairedAes128Cfb = Cfb;
fn key() -> KeyMaterial {
let bytes: [u8; N] = core::array::from_fn(|i| (i as u8).wrapping_mul(7).wrapping_add(1));
@@ -273,6 +283,204 @@ fn bench_aes256(c: &mut Criterion) {
group.finish();
}
+fn bench_cfb_aes128(c: &mut Criterion) {
+ let k = key::<16>();
+ let blocks = data();
+
+ let mut group = c.benchmark_group("modes::cfb::Aes128");
+ group.throughput(Throughput::Bytes(DATA_LEN as u64));
+
+ // ---- encryption: serial. Oj+1 = CIPH_K(Cj), and Cj is the previous call's output ----
+ group.bench_function("16KiB encrypt -- N=1", |b| {
+ b.iter_batched(
+ || blocks.clone(),
+ |mut scratch| {
+ let (mut enc, _) = Aes128Cfb::::do_encrypt_init(&k).unwrap();
+ for block in scratch.iter_mut() {
+ enc.do_encrypt(block).unwrap();
+ }
+ black_box(&scratch);
+ },
+ BatchSize::LargeInput,
+ )
+ });
+
+ group.bench_function("16KiB encrypt -- N=8", |b| {
+ b.iter_batched(
+ || blocks.clone(),
+ |mut scratch| {
+ let (mut enc, _) = Aes128Cfb::::do_encrypt_init(&k).unwrap();
+ for chunk in scratch.chunks_exact_mut(8) {
+ let arr: &mut [u8; 8 * BLOCK_LEN] =
+ chunk.as_flattened_mut().try_into().unwrap();
+ enc.do_encrypt(arr).unwrap();
+ }
+ black_box(&scratch);
+ },
+ BatchSize::LargeInput,
+ )
+ });
+
+ // ---- decryption: parallel, and uses `encrypt_blocks2` -- the FORWARD pair method ----
+ let (mut enc, iv) = Aes128Cfb::::do_encrypt_init(&k).unwrap();
+ let mut ciphertext = blocks.clone();
+ for chunk in ciphertext.chunks_exact_mut(8) {
+ let arr: &mut [[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap();
+ enc.do_encrypt_blocks(arr).unwrap();
+ }
+
+ // 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_batched(
+ || ciphertext.clone(),
+ |mut scratch| {
+ let mut dec = Aes128Cfb::::do_decrypt_init(&k, &iv).unwrap();
+ for block in scratch.iter_mut() {
+ dec.do_decrypt(block).unwrap();
+ }
+ black_box(&scratch);
+ },
+ BatchSize::LargeInput,
+ )
+ });
+
+ // N=2 and N=8 are all pairs, so every block goes through encrypt_blocks2.
+ group.bench_function("16KiB decrypt -- N=2 (all pairs)", |b| {
+ b.iter_batched(
+ || ciphertext.clone(),
+ |mut scratch| {
+ let mut dec = Aes128Cfb::::do_decrypt_init(&k, &iv).unwrap();
+ for chunk in scratch.chunks_exact_mut(2) {
+ let arr: &mut [u8; 2 * BLOCK_LEN] =
+ chunk.as_flattened_mut().try_into().unwrap();
+ dec.do_decrypt(arr).unwrap();
+ }
+ black_box(&scratch);
+ },
+ BatchSize::LargeInput,
+ )
+ });
+
+ group.bench_function("16KiB decrypt -- N=8 (all pairs)", |b| {
+ b.iter_batched(
+ || ciphertext.clone(),
+ |mut scratch| {
+ let mut dec = Aes128Cfb::::do_decrypt_init(&k, &iv).unwrap();
+ for chunk in scratch.chunks_exact_mut(8) {
+ let arr: &mut [u8; 8 * BLOCK_LEN] =
+ chunk.as_flattened_mut().try_into().unwrap();
+ dec.do_decrypt(arr).unwrap();
+ }
+ black_box(&scratch);
+ },
+ BatchSize::LargeInput,
+ )
+ });
+
+ // 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_batched(
+ || ciphertext.clone(),
+ |mut scratch| {
+ let mut dec = Aes128Cfb::::do_decrypt_init(&k, &iv).unwrap();
+ for chunk in scratch.chunks_exact_mut(9) {
+ let arr: &mut [u8; 9 * BLOCK_LEN] =
+ chunk.as_flattened_mut().try_into().unwrap();
+ dec.do_decrypt(arr).unwrap();
+ }
+ black_box(&scratch);
+ },
+ BatchSize::LargeInput,
+ )
+ });
+
+ // The controlled comparison: identical N, identical cipher, pair methods overridden vs not.
+ // This pair of numbers -- and only this pair -- measures what `encrypt_blocks2` buys CFB.
+ group.bench_function("16KiB decrypt -- N=8, pair path (blocks2 overridden)", |b| {
+ b.iter_batched(
+ || ciphertext.clone(),
+ |mut scratch| {
+ let mut dec = Aes128Cfb::::do_decrypt_init(&k, &iv).unwrap();
+ for chunk in scratch.chunks_exact_mut(8) {
+ let arr: &mut [u8; 8 * BLOCK_LEN] =
+ chunk.as_flattened_mut().try_into().unwrap();
+ dec.do_decrypt(arr).unwrap();
+ }
+ black_box(&scratch);
+ },
+ BatchSize::LargeInput,
+ )
+ });
+
+ group.bench_function("16KiB decrypt -- N=8, no pair path (trait default)", |b| {
+ b.iter_batched(
+ || ciphertext.clone(),
+ |mut scratch| {
+ let mut dec = UnpairedAes128Cfb::::do_decrypt_init(&k, &iv).unwrap();
+ for chunk in scratch.chunks_exact_mut(8) {
+ let arr: &mut [u8; 8 * BLOCK_LEN] =
+ chunk.as_flattened_mut().try_into().unwrap();
+ dec.do_decrypt(arr).unwrap();
+ }
+ black_box(&scratch);
+ },
+ BatchSize::LargeInput,
+ )
+ });
+
+ group.finish();
+}
+
+fn bench_cfb_aes256(c: &mut Criterion) {
+ let k = key::<32>();
+ let blocks = data();
+
+ let mut group = c.benchmark_group("modes::cfb::Aes256");
+ group.throughput(Throughput::Bytes(DATA_LEN as u64));
+
+ group.bench_function("16KiB encrypt -- N=8", |b| {
+ b.iter_batched(
+ || blocks.clone(),
+ |mut scratch| {
+ let (mut enc, _) = Aes256Cfb::::do_encrypt_init(&k).unwrap();
+ for chunk in scratch.chunks_exact_mut(8) {
+ let arr: &mut [u8; 8 * BLOCK_LEN] =
+ chunk.as_flattened_mut().try_into().unwrap();
+ enc.do_encrypt(arr).unwrap();
+ }
+ black_box(&scratch);
+ },
+ BatchSize::LargeInput,
+ )
+ });
+
+ let (mut enc, iv) = Aes256Cfb::::do_encrypt_init(&k).unwrap();
+ let mut ciphertext = blocks.clone();
+ for chunk in ciphertext.chunks_exact_mut(8) {
+ let arr: &mut [[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap();
+ enc.do_encrypt_blocks(arr).unwrap();
+ }
+
+ group.bench_function("16KiB decrypt -- N=8 (all pairs)", |b| {
+ b.iter_batched(
+ || ciphertext.clone(),
+ |mut scratch| {
+ let mut dec = Aes256Cfb::::do_decrypt_init(&k, &iv).unwrap();
+ for chunk in scratch.chunks_exact_mut(8) {
+ let arr: &mut [u8; 8 * BLOCK_LEN] =
+ chunk.as_flattened_mut().try_into().unwrap();
+ dec.do_decrypt(arr).unwrap();
+ }
+ black_box(&scratch);
+ },
+ BatchSize::LargeInput,
+ )
+ });
+
+ 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) {
@@ -280,7 +488,7 @@ fn bench_init(c: &mut Criterion) {
let k256 = key::<32>();
let iv = [0u8; BLOCK_LEN];
- let mut group = c.benchmark_group("modes::cbc::init");
+ let mut group = c.benchmark_group("modes::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))
@@ -296,8 +504,22 @@ fn bench_init(c: &mut Criterion) {
})
});
+ // CFB does exactly the same work here -- one key expansion, plus an IV draw when encrypting --
+ // so these should match the CBC numbers. A divergence would mean one mode is doing something
+ // extra at construction time.
+ group.bench_function("Aes128 do_encrypt_init, CFB (key schedule + IV)", |b| {
+ b.iter(|| black_box(Aes128Cfb::::do_encrypt_init(black_box(&k128)).unwrap().1))
+ });
+ group.bench_function("Aes128 do_decrypt_init, CFB (key schedule only)", |b| {
+ b.iter(|| {
+ black_box(Aes128Cfb::::do_decrypt_init(black_box(&k128), &iv).unwrap())
+ })
+ });
+
group.finish();
}
-criterion_group!(benches, bench_aes128, bench_aes256, bench_init);
+criterion_group!(
+ benches, bench_aes128, bench_aes256, bench_cfb_aes128, bench_cfb_aes256, bench_init
+);
criterion_main!(benches);
diff --git a/crypto/modes/src/cfb.rs b/crypto/modes/src/cfb.rs
new file mode 100644
index 00000000..e8e82508
--- /dev/null
+++ b/crypto/modes/src/cfb.rs
@@ -0,0 +1,277 @@
+//! The Cipher Feedback mode of operation (NIST SP 800-38A Sec 6.3), full-block segment only.
+//!
+//! # The specification
+//!
+//! Sec 6.3 defines CFB against a segment size `s` with `1 <= s <= b`, where `b` is the block size.
+//! Quoting the equations verbatim:
+//!
+//! ```text
+//! CFB Encryption: I1 = IV;
+//! Ij = LSB_{b-s}(I_{j-1}) | C#_{j-1} for j = 2 ... n;
+//! Oj = CIPH_K(Ij) for j = 1, 2 ... n;
+//! C#_j = P#_j XOR MSB_s(Oj) for j = 1, 2 ... n.
+//!
+//! CFB Decryption: I1 = IV;
+//! Ij = LSB_{b-s}(I_{j-1}) | C#_{j-1} for j = 2 ... n;
+//! Oj = CIPH_K(Ij) for j = 1, 2 ... n;
+//! P#_j = C#_j XOR MSB_s(Oj) for j = 1, 2 ... n.
+//! ```
+//!
+//! # This type is the `s = b` specialisation
+//!
+//! [`Cfb`] implements **only** `s = b`, the variant Sec 6.3 says is "sometimes incorporated into
+//! the name of the mode", i.e. CFB128 for a 128-bit block. That is the only segment size which is
+//! block-aligned, and so the only one that fits [`BlockCipherEncryptor`] /
+//! [`BlockCipherDecryptor`]. Substituting `s = b` collapses the equations exactly:
+//!
+//! * `LSB_{b-s}(I_{j-1})` becomes `LSB_0(I_{j-1})`, the empty bit string, so the concatenation
+//! leaves `Ij = C_{j-1}`. Sec 6.3's alternative description agrees: the previous input block
+//! "circularly shift[s] s positions to the left, and then the ciphertext segment replaces the s
+//! least significant bits of the result" -- shifting a whole block by its own width and replacing
+//! every bit of it is just assignment.
+//! * `MSB_s(Oj)` becomes `MSB_b(Oj)`, which is `Oj`. No part of the output block is discarded, so
+//! there are no wasted cipher calls: one forward cipher per block, the same as CBC.
+//!
+//! leaving
+//!
+//! ```text
+//! I1 = IV; Ij = C_{j-1} (j >= 2); Oj = CIPH_K(Ij); Cj = Pj XOR Oj / Pj = Cj XOR Oj
+//! ```
+//!
+//! As in `Cbc`, the `j = 1` and `j >= 2` cases differ only in what gets fed to the cipher, so a
+//! single `chain` field holds `Ij` -- the IV to start with, then each ciphertext block as it is
+//! produced or consumed. That is why no code below special-cases the first block.
+//!
+//! CFB1 and CFB8 (the `s = 1` and `s = 8` variants, which SP 800-38A Appendix F.3 also gives
+//! vectors for) are deliberately **not** here: they are not block-aligned, so they belong to a
+//! `StreamCipher`-shaped API rather than this one.
+//!
+//! # Decryption uses the *forward* cipher function
+//!
+//! This is the thing about CFB that surprises a reader used to CBC: both directions apply
+//! `CIPH_K`. Sec 6.3 is explicit -- "In CFB decryption, the IV is the first input block, and each
+//! successive input block is formed as in CFB encryption [...] The *forward cipher* function is
+//! applied to each input block to produce the output blocks."
+//!
+//! So [`Cfb`](Cfb) never calls [`BlockPermutation::decrypt_block`] or
+//! [`BlockPermutation::decrypt_blocks2`]. A permutation could implement only the forward direction
+//! and still work here; `cfb_tests.rs` pins that with a toy whose inverse panics. The mode XORs a
+//! keystream in both directions, and the two directions differ only in which of the two buffers
+//! becomes the next chaining value.
+//!
+//! # Parallel decryption
+//!
+//! Sec 6.3: "In CFB encryption, like CBC encryption, the input block to each forward cipher
+//! function (except the first) depends on the result of the previous forward cipher function;
+//! therefore, multiple forward cipher operations cannot be performed in parallel. In CFB
+//! decryption, the required forward cipher operations can be performed in parallel if the input
+//! blocks are first constructed (in series) from the IV and the ciphertext."
+//!
+//! Constructing them "in series" is trivial here: with `s = b` the input blocks *are* the IV
+//! followed by the ciphertext blocks, already in hand. Decryption therefore walks the ciphertext in
+//! pairs through [`BlockPermutation::encrypt_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::{
+ Algorithm, BlockCipherDecryptor, BlockCipherEncryptor, BlockPermutation, RNG, SecurityStrength,
+};
+use bouncycastle_rng::HashDRBG_SHA512;
+use core::marker::PhantomData;
+
+/// CFB mode over any [`BlockPermutation`], with the direction encoded in the type.
+///
+/// The segment size is the full block (`s = b`, i.e. CFB128 for AES); see the module docs for why
+/// the other segment sizes are out of scope.
+///
+/// `Dir` is [`Encrypting`] or [`Decrypting`]. [`BlockCipherEncryptor`] is implemented only for the
+/// former and [`BlockCipherDecryptor`] only for the latter, so a `Cfb<_, 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
+///
+/// The same two fields as `Cbc`, and the same size: the permutation (which owns the key schedule,
+/// and is responsible for keeping it in a zeroize-on-drop wrapper) and one block holding `Ij`. `Ij`
+/// is an IV or a ciphertext block, both of which are public, so it is deliberately not wrapped in a
+/// `Secret`.
+///
+/// Note what is *not* stored: the output block `Oj`. It is recomputed from `chain` on each call and
+/// lives only in a local, so no keystream outlives the call that used it.
+pub struct Cfb
+where
+ P: BlockPermutation,
+{
+ perm: P,
+ /// `Ij`: the IV, then `C_{j-1}`. See the module docs on why there is only one field for both.
+ chain: [u8; BLOCK_LEN],
+ _dir: PhantomData,
+}
+
+impl Cfb
+where
+ P: BlockPermutation,
+{
+ /// `Oj = CIPH_K(Ij)`, the keystream block for the current position.
+ ///
+ /// The forward cipher function, in both directions -- see the module docs.
+ #[inline]
+ fn keystream(&self) -> [u8; BLOCK_LEN] {
+ let mut o = self.chain;
+ self.perm.encrypt_block(&mut o);
+ o
+ }
+
+ /// `Cj = Pj XOR Oj` in place, then `Cj` becomes the next input block.
+ #[inline]
+ fn encrypt_one(&mut self, block: &mut [u8; BLOCK_LEN]) {
+ let o = self.keystream();
+ for (b, o) in block.iter_mut().zip(o.iter()) {
+ *b ^= *o;
+ }
+ // I_{j+1} = Cj. Serial: this is the input to the next cipher call.
+ self.chain = *block;
+ }
+
+ /// `Pj = Cj XOR Oj` in place, then `Cj` -- the *ciphertext*, not the recovered plaintext --
+ /// becomes the next input block. `Cj` is overwritten by `Pj`, so it is copied first.
+ #[inline]
+ fn decrypt_one(&mut self, block: &mut [u8; BLOCK_LEN]) {
+ // `I_{j+1} = C#_j` of the spec equations: the ciphertext segment is what is fed back.
+ // Feeding back the plaintext instead would still decrypt the first block correctly and
+ // nothing after it, which is why `cfb_tests.rs` checks exactly that.
+ let cj = *block;
+ let o = self.keystream();
+ for (b, o) in block.iter_mut().zip(o.iter()) {
+ *b ^= *o;
+ }
+ self.chain = cj;
+ }
+
+ /// Decrypts two consecutive blocks with one [`BlockPermutation::encrypt_blocks2`] call.
+ ///
+ /// Writing the pair as `Cj, Cj+1` with `Ij` the incoming chaining value, the `s = b` equations
+ /// give
+ ///
+ /// ```text
+ /// Ij = chain Oj = CIPH_K(Ij) Pj = Cj XOR Oj
+ /// Ij+1 = Cj Oj+1 = CIPH_K(Ij+1) Pj+1 = Cj+1 XOR Oj+1
+ /// ```
+ ///
+ /// Both input blocks are known before either cipher call -- `Ij` is already held and `Ij+1` is
+ /// just `Cj`, which the caller supplied -- so the two forward ciphers are independent and
+ /// computing them together changes nothing. This is precisely the parallelism Sec 6.3 describes,
+ /// with the input blocks "first constructed (in series) from the IV and the ciphertext".
+ ///
+ /// In place: the two input blocks are the keystream buffer, so the ciphertext is never
+ /// overwritten before it has been read, and only `Cj+1` needs copying for the chaining value.
+ #[inline]
+ fn decrypt_pair(&mut self, blocks: &mut [[u8; BLOCK_LEN]; 2]) {
+ // The two input blocks, constructed in series: Ij (already held) and Ij+1 (= Cj).
+ let mut o = [self.chain, blocks[0]];
+ self.perm.encrypt_blocks2(&mut o);
+
+ // I_{j+2} = Cj+1, read before the XOR below turns it into Pj+1.
+ self.chain = blocks[1];
+
+ for (block, o) in blocks.iter_mut().zip(o.iter()) {
+ for (b, o) in block.iter_mut().zip(o.iter()) {
+ *b ^= *o;
+ }
+ }
+ }
+}
+
+impl Algorithm
+ for Cfb
+where
+ P: BlockPermutation,
+{
+ /// The underlying permutation's name. The mode is not appended: `&'static str`s cannot be
+ /// concatenated in a `const`, and the mode is already in the type.
+ const ALG_NAME: &'static str = P::ALG_NAME;
+ /// A mode does not change the strength of the underlying cipher.
+ const MAX_SECURITY_STRENGTH: SecurityStrength = P::MAX_SECURITY_STRENGTH;
+}
+
+impl
+ BlockCipherEncryptor for Cfb
+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)?;
+ // `I1 = IV`.
+ let iv = random_iv::(rng)?;
+ Ok((Self { perm, chain: iv, _dir: PhantomData }, iv))
+ }
+
+ /// The implementor hook (the flat `do_encrypt` is provided over it).
+ ///
+ /// Strictly serial: `Oj+1 = CIPH_K(Cj)` and `Cj` is the *output* of the previous cipher call, so
+ /// there is no pair path here. See the module docs. Never fails: CFB has no per-IV data limit.
+ fn do_encrypt_blocks(
+ &mut self,
+ blocks: &mut [[u8; BLOCK_LEN]; N],
+ ) -> Result<(), SymmetricCipherError> {
+ for block in blocks.iter_mut() {
+ self.encrypt_one(block);
+ }
+ Ok(())
+ }
+}
+
+impl
+ BlockCipherDecryptor for Cfb
+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)?;
+ // `I1 = IV`, exactly as on the encrypt side.
+ Ok(Self { perm, chain: *init_data, _dir: PhantomData })
+ }
+
+ /// The implementor hook (the flat `do_decrypt` is provided over it).
+ ///
+ /// Walks the input in pairs so the permutation's two-block *forward* path is used, with an
+ /// at-most-one block remainder for odd `N`. `as_chunks_mut` 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. Never fails: CFB has no
+ /// per-IV data limit.
+ fn do_decrypt_blocks(
+ &mut self,
+ blocks: &mut [[u8; BLOCK_LEN]; N],
+ ) -> Result<(), SymmetricCipherError> {
+ let (pairs, tail) = blocks.as_chunks_mut::<2>();
+ for pair in pairs.iter_mut() {
+ self.decrypt_pair(pair);
+ }
+ for block in tail.iter_mut() {
+ self.decrypt_one(block);
+ }
+ Ok(())
+ }
+}
diff --git a/crypto/modes/src/lib.rs b/crypto/modes/src/lib.rs
index 0680ee6d..9c1a9a9e 100644
--- a/crypto/modes/src/lib.rs
+++ b/crypto/modes/src/lib.rs
@@ -2,26 +2,41 @@
//!
//! 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).
+//! one block. This crate provides:
+//!
+//! | Mode | Type | Spec | Notes |
+//! |---|---|---|---|
+//! | CBC | [`Cbc`] | SP 800-38A Sec 6.2 | Cipher Block Chaining |
+//! | CFB | [`Cfb`] | SP 800-38A Sec 6.3 | Cipher Feedback, full-block segment (`s = b`) only |
+//!
+//! Both are strictly block-aligned and both generate their own IV; they differ only in how the
+//! block permutation is wired up, and the two types have identical APIs and identical size. See
+//! [Choosing between CBC and CFB](#choosing-between-cbc-and-cfb).
//!
//! 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:
+//! trait. Define a one-line alias for the combination you use -- or use the ready-made
+//! `AES_CBC_128` / `AES_CFB_128` and friends from `bouncycastle-aes-lowmemory`:
//!
//! ```
//! use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256};
-//! use bouncycastle_modes::Cbc;
+//! use bouncycastle_modes::{Cbc, Cfb};
//!
//! type Aes128Cbc = Cbc;
//! type Aes192Cbc = Cbc;
//! type Aes256Cbc = Cbc;
+//!
+//! type Aes128Cfb = Cfb;
+//! type Aes192Cfb = Cfb;
+//! type Aes256Cfb = Cfb;
//! ```
//!
//! # 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)).
+//! [`BlockCipherDecryptor`] and nothing else. [`Cfb`] is the same. 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;
@@ -74,6 +89,35 @@
//! assert_eq!(rest, [0xBBu8; 32]);
//! ```
//!
+//! CFB is a drop-in swap for CBC -- same methods, same IV convention, same block alignment. The
+//! only visible difference is the ciphertext:
+//!
+//! ```
+//! use bouncycastle_aes_lowmemory::Aes128;
+//! use bouncycastle_core::key_material::{KeyMaterial, KeyType};
+//! use bouncycastle_core::traits::{BlockCipherDecryptor, BlockCipherEncryptor};
+//! use bouncycastle_modes::{Cbc, Cfb, Decrypting, Encrypting};
+//!
+//! type Aes128Cbc
= Cbc;
+//! type Aes128Cfb = Cfb;
+//!
+//! let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42; 16], KeyType::SymmetricCipherKey)
+//! .expect("a 16-byte symmetric cipher key");
+//! let plaintext = [0x5Au8; 32];
+//!
+//! let mut ciphertext = plaintext;
+//! let iv = Aes128Cfb::::encrypt(&key, &mut ciphertext).expect("encryption");
+//! let mut recovered = ciphertext;
+//! Aes128Cfb::::decrypt(&key, &iv, &mut recovered).expect("decryption");
+//! assert_eq!(recovered, plaintext);
+//!
+//! // The modes are not interchangeable: a ciphertext must be decrypted with the mode that
+//! // produced it, and nothing at the type level stops you getting that wrong.
+//! let mut as_if_cbc = ciphertext;
+//! Aes128Cbc::::decrypt(&key, &iv, &mut as_if_cbc).expect("decryption");
+//! assert_ne!(as_if_cbc, plaintext);
+//! ```
+//!
//! Using the wrong direction does not compile:
//!
//! ```compile_fail
@@ -89,16 +133,61 @@
//! let _ = Aes128Cbc::::do_decrypt_init(&key, &[0u8; 16]);
//! ```
//!
+//! # Choosing between CBC and CFB
+//!
+//! Neither is authenticated, so the honest answer for new designs is "neither -- use an AEAD".
+//! Between the two:
+//!
+//! * **Error propagation differs**, and it is the sharpest practical difference. SP 800-38A
+//! Appendix D, Table D.2: a bit error in `Cj` gives CBC a *randomised* `Pj` plus the **same bit**
+//! flipped in `Pj+1`, and gives CFB the **same bit** flipped in `Pj` plus a randomised `Pj+1`.
+//! So under CFB an attacker who can flip a ciphertext bit flips the corresponding plaintext bit
+//! directly, in the block they targeted. Both are malleable; authenticate the ciphertext.
+//! * **CFB needs only the forward cipher function**, in both directions (Sec 6.3). That halves what
+//! a permutation has to provide, and where the inverse costs more than the forward direction it
+//! makes CFB decryption faster: with `bouncycastle-aes-lowmemory` this crate's benches measure CFB
+//! decryption at about 1.37x CBC decryption (AES-128, 16 KiB, `N = 8`). Encryption is the same
+//! speed in both, since both are serial and both use only the forward function.
+//! * **"CFB" alone is ambiguous.** SP 800-38A's `s = 8` and `s = 1` variants are also called CFB and
+//! are *not* interoperable with [`Cfb`], which is `s = b`. If you are matching an existing system,
+//! check which segment size it means before assuming this one. CBC has no such ambiguity.
+//! * Both encrypt serially and decrypt in parallel, so their scaling with `N` matches.
+//!
//! # 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.
+//! plaintext must be a multiple of the block size"); for CFB it requires the total to be a multiple
+//! of the segment size `s`, and this crate fixes `s = b`, so the requirement is the same. 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).
+//! Arbitrary-length data therefore needs a padding layer on top. That layer is *not* in this crate:
+//! it is `bouncycastle-padding`, whose `PaddedEncryptor` / `PaddedDecryptor` wrap any
+//! [`BlockCipherEncryptor`] / [`BlockCipherDecryptor`] pair, so both modes get arbitrary-length
+//! support by being wrapped rather than by growing padding logic of their own.
+//!
+//! ```
+//! use bouncycastle_aes_lowmemory::Aes128;
+//! use bouncycastle_core::key_material::{KeyMaterial, KeyType};
+//! use bouncycastle_modes::{Cfb, Decrypting, Encrypting};
+//! use bouncycastle_padding::{PKCS7, PaddedDecryptor, PaddedEncryptor};
+//!
+//! type Enc = PaddedEncryptor, PKCS7, 16, 16, 16>;
+//! type Dec = PaddedDecryptor, PKCS7, 16, 16, 16>;
+//!
+//! let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42; 16], KeyType::SymmetricCipherKey)
+//! .expect("a 16-byte symmetric cipher key");
+//!
+//! // 5 bytes: not a whole block, which the bare mode would refuse to compile.
+//! let message = b"hello";
+//! let mut ciphertext = [0u8; 16];
+//! let (iv, written) = Enc::encrypt_out(&key, message, &mut ciphertext).expect("encryption");
+//! assert_eq!(written, 16);
+//!
+//! let mut plaintext = [0u8; 16];
+//! let n = Dec::decrypt_out(&key, &iv, &ciphertext, &mut plaintext).expect("decryption");
+//! assert_eq!(&plaintext[..n], message);
+//! ```
//!
//! # Memory Usage
//!
@@ -107,31 +196,44 @@
//!
//! ```text
//! size_of::>() == size_of::() + BLOCK_LEN
+//! 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 |
+//! | AES-128 CBC or CFB | 176 B | 16 B | 192 B |
+//! | AES-192 CBC or CFB | 208 B | 16 B | 224 B |
+//! | AES-256 CBC or CFB | 240 B | 16 B | 256 B |
+//!
+//! CFB is the same size as CBC because it stores the same thing: one block of input to the next
+//! cipher call. Its keystream block `Oj` is recomputed per call and lives only in a local, so it
+//! costs `BLOCK_LEN` of transient stack and nothing persistent.
//!
-//! The data methods work in place and add nothing beyond the copy of the two ciphertext blocks
-//! `decrypt_pair` keeps for the chaining value. [`Encrypting`] and [`Decrypting`] are zero-sized and held in a
+//! The data methods work in place. The pair path in either mode's decryptor adds one
+//! `[[u8; BLOCK_LEN]; 2]` copy of the ciphertext it needs for the chaining value. [`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`.
+//! `sizes_match_the_documented_memory_table` in `tests/cbc_tests.rs` and `tests/cfb_tests.rs`.
//!
//! # Security Considerations
//!
-//! ## CBC is not authenticated
+//! ## Neither mode is authenticated
+//!
+//! Both provide confidentiality only. Neither detects tampering, and both are malleable in
+//! specific, exploitable ways -- SP 800-38A Appendix D, Table D.2:
+//!
+//! * **CBC:** flipping a bit of `Cj` flips the same bit of the decryption of `Cj+1`, and randomises
+//! the decryption of `Cj` itself.
+//! * **CFB:** flipping a bit of `Cj` flips the same bit of the decryption of `Cj` -- the block the
+//! attacker aimed at -- and randomises the decryption of `Cj+1`. So the controlled flip lands in
+//! the targeted block rather than the next one.
//!
-//! 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.
+//! **Authenticate the ciphertext.** Prefer an AEAD; if you must use either of these, 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.
+//! Combining decryption with a padding check is the classic padding-oracle setup, for either mode.
+//! Do not report padding failures distinguishably, and do not decrypt unauthenticated ciphertext.
+//! `bouncycastle-padding`'s `unpad` is constant-time for exactly this reason, but constant-time
+//! unpadding is not a substitute for authentication.
//!
//! ## The IV must be unpredictable, and this crate generates it
//!
@@ -149,56 +251,78 @@
//!
//! 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.
+//! the IV is not protected". Under CBC a flipped IV bit flips exactly that bit of `P1`.
+//!
+//! CFB damages `P1` too, but unpredictably rather than controllably: the IV is the first thing fed
+//! to the cipher, so Table D.2 gives *random* bit errors in the decryption of `C1` -- and, because
+//! this crate fixes `s = b`, in `C1` only (Appendix D's "the first `i/s` (rounding up) ciphertext
+//! segments" is one segment when `s = b`). Later blocks are unaffected in both modes.
+//!
+//! Either way 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.
+//! Nothing here stops one key being used for many messages, which is fine for either mode provided
+//! each gets a fresh unpredictable IV. It is the IV, not the key, that must not repeat.
+//!
+//! Repeating one matters more for CFB. CFB XORs a keystream, so two messages encrypted under the
+//! same key *and* IV satisfy `C1 XOR C1' == P1 XOR P1'` -- the plaintext XOR leaks directly, the
+//! classic two-time-pad failure, and it continues into later blocks for as long as the two
+//! ciphertexts agree. CBC under a repeated IV leaks only whether the blocks were equal, not their
+//! XOR. Since [`BlockCipherEncryptor::do_encrypt_init`] draws every IV from the DRBG, neither case
+//! arises through this API; it is a reason not to add an IV-accepting one.
//!
//! # 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).
+//! * **The CFB segment sizes below the block size** (`s = 1` and `s = 8`, for which SP 800-38A
+//! Appendix F.3 also gives vectors). They are not block-aligned, so they need a
+//! `StreamCipher`-shaped API rather than [`BlockCipherEncryptor`].
+//! * **ECB, OFB and CTR**, the other three modes of the recommendation. ECB is a raw permutation
+//! applied per block and is not confidential; OFB and CTR are keystream modes and, like CFB1/8,
+//! do not require block alignment.
//!
//! # 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:
+//! The `bc-rust` CLI exposes both modes for all three AES key lengths: `aes128-cbc`, `aes192-cbc`,
+//! `aes256-cbc`, `aes128-cfb`, `aes192-cfb` and `aes256-cfb`, 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
+//!
+//! bc-rust aes256-cfb encrypt --key-file k.bin < plain.bin > cipher.bin
+//! bc-rust aes256-cfb decrypt --key-file k.bin < cipher.bin | cmp - plain.bin
//! ```
//!
-//! Input must be block-aligned there too, for the reason given above.
+//! The `-cfb` commands are CFB128, matching [`Cfb`]. Input must be block-aligned there too, for the
+//! reason given above.
#![no_std]
#![forbid(unsafe_code)]
#![forbid(missing_docs)]
mod cbc;
+mod cfb;
mod iv;
pub use cbc::Cbc;
+pub use cfb::Cfb;
// 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`].
+/// Direction marker for a mode that encrypts. See [`Cbc`] and [`Cfb`].
///
/// 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`].
+/// Direction marker for a mode that decrypts. See [`Cbc`] and [`Cfb`].
///
/// Zero-sized: encoding the direction in the type costs no memory.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
diff --git a/crypto/modes/tests/acvp_cfb_tests.rs b/crypto/modes/tests/acvp_cfb_tests.rs
new file mode 100644
index 00000000..8d965fb7
--- /dev/null
+++ b/crypto/modes/tests/acvp_cfb_tests.rs
@@ -0,0 +1,313 @@
+//! Known-answer tests against the NIST ACVP `ACVP-AES-CFB128` 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, `aes-lowmemory` and AES-CBC suites --
+//! `cargo test` must stay green for someone who has only cloned this repository.
+//!
+//! This is the CFB counterpart to `acvp_tests.rs` (AES-CBC) and to
+//! `crypto/aes-lowmemory/tests/acvp_tests.rs` (AES-ECB, the raw permutation). The `CFB128` file is
+//! the one that matches [`Cfb`]: `ACVP-AES-CFB8` and `ACVP-AES-CFB1` are the sub-block segment
+//! sizes this crate does not implement, and are deliberately not read.
+//!
+//! # Joining the request and response files
+//!
+//! As with CBC, the 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`.
+//!
+//! # Coverage
+//!
+//! 2138 AFT (Algorithm Functional Test) cases across all three key lengths and both directions,
+//! including 54 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 the pair path -- which for CFB is
+//! [`BlockPermutation::encrypt_blocks2`], the *forward* function, even on the decrypt side -- so it
+//! is exercised against real vectors and not only against the toy in `cfb_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::{Cfb, 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-CFB128.4014530.req.json";
+const RESPONSE_FILE: &str = "ACVP-AES-CFB128.4014530.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-CFB128 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 CFB128 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) = Cfb::::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 mut c = *block;
+ enc.do_encrypt(&mut c).unwrap();
+ out.push(c);
+ }
+ }
+ Grouping::Pairs => {
+ let (pairs, tail) = input.as_chunks::<2>();
+ for pair in pairs {
+ let mut c = *pair;
+ enc.do_encrypt_blocks(&mut c).unwrap();
+ out.extend_from_slice(&c);
+ }
+ for block in tail {
+ let mut c = *block;
+ enc.do_encrypt(&mut c).unwrap();
+ out.push(c);
+ }
+ }
+ }
+ } else {
+ let mut dec =
+ Cfb::::do_decrypt_init(&key, &iv).expect("dec init");
+
+ match grouping {
+ Grouping::Single => {
+ for block in input {
+ let mut p = *block;
+ dec.do_decrypt(&mut p).unwrap();
+ out.push(p);
+ }
+ }
+ Grouping::Pairs => {
+ let (pairs, tail) = input.as_chunks::<2>();
+ for pair in pairs {
+ let mut p = *pair;
+ dec.do_decrypt_blocks(&mut p).unwrap();
+ out.extend_from_slice(&p);
+ }
+ for block in tail {
+ let mut p = *block;
+ dec.do_decrypt(&mut p).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 CFB128 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_cfb128_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-{} CFB128 {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-CFB128 {kind}: {n} cases");
+ }
+ println!(
+ "ACVP AES-CFB128: {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 >= 50, "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/cfb_tests.rs b/crypto/modes/tests/cfb_tests.rs
new file mode 100644
index 00000000..9a6cf1d0
--- /dev/null
+++ b/crypto/modes/tests/cfb_tests.rs
@@ -0,0 +1,627 @@
+//! Structural tests for CFB, driven by a toy permutation.
+//!
+//! These check the properties of the *mode* -- the keystream construction, chaining, call
+//! sequencing, the pair/remainder split, direction typing, SP 800-38A Appendix D error propagation,
+//! and the "forward cipher function only" rule of Sec 6.3 -- independently of any real cipher. The
+//! known-answer tests against SP 800-38A Appendix F.3.13-F.3.18 are in `sp800_38a_cfb_tests.rs`,
+//! and the ACVP CFB128 set is in `acvp_cfb_tests.rs`.
+//!
+//! The toy's own conformance to [`BlockPermutation`] is pinned once, by
+//! `the_toy_permutation_conforms_to_the_trait` in `cbc_tests.rs`; it is the same `Toy` here, so it
+//! is not re-run.
+
+mod common;
+
+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_core_test_framework::symmetric_ciphers::TestFrameworkBlockCipher;
+use bouncycastle_modes::{Cbc, Cfb, Decrypting, Encrypting};
+use bouncycastle_padding::{PKCS7, PaddedDecryptor, PaddedEncryptor};
+use common::{ForwardOnlyToy, SwappedPairToy, TOY_LEN, Toy, toy_key};
+
+type ToyCfb = Cfb;
+type SwappedCfb = Cfb;
+type ForwardOnlyCfb = Cfb;
+
+/// The implementor hook `do_encrypt_blocks`, by value, for tests whose data is block-shaped.
+fn enc_blocks(
+ enc: &mut impl BlockCipherEncryptor,
+ plaintext: &[[u8; TOY_LEN]; N],
+) -> [[u8; TOY_LEN]; N] {
+ let mut blocks = *plaintext;
+ enc.do_encrypt_blocks(&mut blocks).unwrap();
+ blocks
+}
+
+/// The implementor hook `do_decrypt_blocks`, by value.
+fn dec_blocks(
+ dec: &mut impl BlockCipherDecryptor,
+ ciphertext: &[[u8; TOY_LEN]; N],
+) -> [[u8; TOY_LEN]; N] {
+ let mut blocks = *ciphertext;
+ dec.do_decrypt_blocks(&mut blocks).unwrap();
+ blocks
+}
+
+/// The flat streaming method `do_encrypt`, by value.
+fn enc_flat(
+ enc: &mut impl BlockCipherEncryptor,
+ plaintext: &[u8; LEN],
+) -> [u8; LEN] {
+ let mut data = *plaintext;
+ enc.do_encrypt(&mut data).unwrap();
+ data
+}
+
+/// The flat streaming method `do_decrypt`, by value.
+fn dec_flat(
+ dec: &mut impl BlockCipherDecryptor,
+ ciphertext: &[u8; LEN],
+) -> [u8; LEN] {
+ let mut data = *ciphertext;
+ dec.do_decrypt(&mut data).unwrap();
+ data
+}
+
+/// A pinned IV, so two runs are comparable. Encryption never accepts one, so it is fed through the
+/// fixed-output RNG that `do_encrypt_init_rng` takes.
+fn pinned_iv() -> [u8; TOY_LEN] {
+ core::array::from_fn(|i| 0xF0 ^ (i as u8))
+}
+
+fn pinned_rng(iv: [u8; TOY_LEN]) -> FixedSeedRNG {
+ FixedSeedRNG::::new(iv)
+}
+
+// ---- the mode against the shared framework ------------------------------------------------
+
+#[test]
+fn cfb_conforms_to_the_block_cipher_framework() {
+ TestFrameworkBlockCipher::new()
+ .test::, ToyCfb>();
+}
+
+// ---- the spec equations -------------------------------------------------------------------
+
+/// CFB with `s = b` from SP 800-38A Sec 6.3, written out longhand against the raw permutation:
+///
+/// ```text
+/// I1 = IV; Ij = C_{j-1} (j >= 2); Oj = CIPH_K(Ij); Cj = Pj XOR Oj
+/// ```
+///
+/// This is the independent reference the mode is checked against below. It uses only
+/// [`BlockPermutation::encrypt_block`], because that is all the spec calls for.
+fn reference_cfb(
+ perm: &Toy,
+ iv: [u8; TOY_LEN],
+ input: &[[u8; TOY_LEN]],
+ encrypt: bool,
+) -> Vec<[u8; TOY_LEN]> {
+ let mut chain = iv; // I1 = IV
+ let mut out = Vec::with_capacity(input.len());
+ for block in input {
+ let mut o = chain;
+ perm.encrypt_block(&mut o); // Oj = CIPH_K(Ij)
+ let result: [u8; TOY_LEN] = core::array::from_fn(|k| block[k] ^ o[k]);
+ // I_{j+1} is always the *ciphertext* block, whichever direction we are going.
+ chain = if encrypt { result } else { *block };
+ out.push(result);
+ }
+ out
+}
+
+/// The mode must reproduce the Sec 6.3 equations exactly, in both directions.
+///
+/// A reference implementation is a weak test on its own -- both could be wrong the same way -- so
+/// this also pins the two anchors that follow directly from the equations and that no plausible
+/// mistake preserves: `C1 = P1 XOR CIPH_K(IV)`, and encrypting an all-zero block reveals the
+/// keystream block itself.
+#[test]
+fn the_mode_matches_the_spec_equations() {
+ let key = toy_key();
+ let iv = pinned_iv();
+ let perm = >::new(&key).unwrap();
+ let plaintext: [[u8; TOY_LEN]; 5] =
+ core::array::from_fn(|i| core::array::from_fn(|j| (i * 31 + j * 7 + 1) as u8));
+
+ let (mut enc, got_iv) =
+ ToyCfb::::do_encrypt_init_rng(&key, &mut pinned_rng(iv)).unwrap();
+ assert_eq!(got_iv, iv, "the pinned RNG should reproduce the IV");
+ let ct = enc_blocks(&mut enc, &plaintext);
+
+ assert_eq!(
+ ct.to_vec(),
+ reference_cfb(&perm, iv, &plaintext, true),
+ "encryption must match the Sec 6.3 equations"
+ );
+
+ let mut dec = ToyCfb::::do_decrypt_init(&key, &iv).unwrap();
+ let recovered = dec_blocks(&mut dec, &ct);
+ assert_eq!(recovered, plaintext, "round trip");
+ assert_eq!(
+ recovered.to_vec(),
+ reference_cfb(&perm, iv, &ct, false),
+ "decryption must match the Sec 6.3 equations"
+ );
+
+ // Anchor 1: `O1 = CIPH_K(IV)` and `C1 = P1 XOR O1`.
+ let mut o1 = iv;
+ perm.encrypt_block(&mut o1);
+ let expected_c1: [u8; TOY_LEN] = core::array::from_fn(|k| plaintext[0][k] ^ o1[k]);
+ assert_eq!(ct[0], expected_c1, "C1 = P1 XOR CIPH_K(IV)");
+
+ // Anchor 2: with `P1 = 0`, `C1 = O1`. CFB is a keystream mode, and this is what that means.
+ let (mut enc, _) =
+ ToyCfb::::do_encrypt_init_rng(&key, &mut pinned_rng(iv)).unwrap();
+ assert_eq!(enc_flat(&mut enc, &[0u8; TOY_LEN]), o1, "encrypting zero yields the keystream");
+
+ // ...and CFB is not CBC: CBC computes `CIPH_K(P1 XOR IV)`, CFB computes `P1 XOR CIPH_K(IV)`.
+ let (mut cbc, _) =
+ Cbc::::do_encrypt_init_rng(&key, &mut pinned_rng(iv))
+ .unwrap();
+ assert_ne!(enc_flat(&mut cbc, &plaintext[0]), ct[0], "CFB must not agree with CBC");
+}
+
+// ---- the forward-cipher-only rule ---------------------------------------------------------
+
+/// SP 800-38A Sec 6.3: "The *forward cipher* function is applied to each input block to produce the
+/// output blocks" -- in CFB *decryption* as well as encryption.
+///
+/// [`ForwardOnlyToy`] panics from both `decrypt_block` and `decrypt_blocks2`, so this test fails
+/// loudly if either direction of the mode ever reaches the inverse cipher. Both the pair path (even
+/// `N`) and the single-block path are exercised, and the result is required to agree with the plain
+/// [`Toy`] -- otherwise the test could pass by not really encrypting anything.
+#[test]
+fn neither_direction_uses_the_inverse_cipher() {
+ let key = toy_key();
+ let iv = pinned_iv();
+ let plaintext: [[u8; TOY_LEN]; 4] =
+ core::array::from_fn(|i| core::array::from_fn(|j| (i * 17 + j) as u8));
+
+ let (mut enc, _) =
+ ForwardOnlyCfb::::do_encrypt_init_rng(&key, &mut pinned_rng(iv)).unwrap();
+ let ct = enc_blocks(&mut enc, &plaintext);
+
+ // The pair path: N = 4 is two pairs, so `encrypt_blocks2` is used and `decrypt_blocks2` is not.
+ let mut dec = ForwardOnlyCfb::::do_decrypt_init(&key, &iv).unwrap();
+ assert_eq!(dec_blocks(&mut dec, &ct), plaintext, "pair path, forward cipher only");
+
+ // The single-block path.
+ let mut dec = ForwardOnlyCfb::::do_decrypt_init(&key, &iv).unwrap();
+ for (c, p) in ct.iter().zip(plaintext.iter()) {
+ assert_eq!(&dec_flat(&mut dec, c), p, "single-block path, forward cipher only");
+ }
+
+ // N = 3 leaves a remainder after the pair loop, so both paths run in one call.
+ let mut dec = ForwardOnlyCfb::::do_decrypt_init(&key, &iv).unwrap();
+ let three = dec_blocks(&mut dec, &[ct[0], ct[1], ct[2]]);
+ assert_eq!(three, [plaintext[0], plaintext[1], plaintext[2]], "pairs + remainder");
+
+ // The forward-only toy must agree with the real one, or the above proves nothing.
+ let (mut enc, _) =
+ ToyCfb::::do_encrypt_init_rng(&key, &mut pinned_rng(iv)).unwrap();
+ assert_eq!(enc_blocks(&mut enc, &plaintext), ct, "the two toys must agree going forward");
+}
+
+/// The decryptor must feed the **ciphertext** block back, not the plaintext it just recovered.
+///
+/// Getting this wrong is invisible in the first block -- `O1 = CIPH_K(IV)` either way -- and wrong
+/// from the second onwards. An encryptor run over ciphertext is exactly that mistake: it XORs the
+/// right keystream into block 1 and then chains on its own output. So block 1 agreeing while
+/// block 2 disagrees is the signature of the bug, and is what this asserts.
+#[test]
+fn the_decryptor_chains_on_ciphertext_not_plaintext() {
+ let key = toy_key();
+ let iv = pinned_iv();
+ let plaintext = [[0x11u8; TOY_LEN], [0x22u8; TOY_LEN], [0x33u8; TOY_LEN]];
+
+ let (mut enc, _) =
+ ToyCfb::::do_encrypt_init_rng(&key, &mut pinned_rng(iv)).unwrap();
+ let ct = enc_blocks(&mut enc, &plaintext);
+ assert_ne!(ct[0], plaintext[0], "the two feedback choices must actually differ here");
+
+ let (mut wrong, _) =
+ ToyCfb::::do_encrypt_init_rng(&key, &mut pinned_rng(iv)).unwrap();
+ let out = enc_blocks(&mut wrong, &ct);
+
+ assert_eq!(out[0], plaintext[0], "block 1 cannot tell the two apart");
+ assert_ne!(out[1], plaintext[1], "block 2 must, so the feedback source is pinned");
+}
+
+// ---- 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 CFB it is entirely about `Ij` 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 iv = pinned_iv();
+ let plaintext: [[u8; TOY_LEN]; 8] =
+ core::array::from_fn(|i| core::array::from_fn(|j| (i * TOY_LEN + j) as u8));
+
+ // Reference: all eight blocks in one call.
+ let (mut enc, got_iv) =
+ ToyCfb::::do_encrypt_init_rng(&key, &mut pinned_rng(iv)).unwrap();
+ assert_eq!(got_iv, iv, "the pinned RNG should reproduce the IV");
+ let reference = enc_blocks(&mut enc, &plaintext);
+
+ // The same eight blocks, grouped every way that exercises a different code path.
+ let (mut enc, _) =
+ ToyCfb::::do_encrypt_init_rng(&key, &mut pinned_rng(iv)).unwrap();
+ let mut got = [[0u8; TOY_LEN]; 8];
+ let a = enc_flat(&mut enc, &plaintext[0]); // one block, flat
+ let b = enc_blocks(&mut enc, &[plaintext[1], plaintext[2]]); // N = 2
+ let c = enc_blocks(&mut enc, &[plaintext[3], plaintext[4], plaintext[5]]); // N = 3
+ let d = enc_blocks(&mut enc, &[plaintext[6], plaintext[7]]); // N = 2
+ got[0] = a;
+ 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 = ToyCfb::::do_decrypt_init(&key, &iv).unwrap();
+ assert_eq!(dec_blocks(&mut dec, &ct), plaintext);
+
+ for grouping in [1usize, 2, 4] {
+ let mut dec = ToyCfb::::do_decrypt_init(&key, &iv).unwrap();
+ let mut out = [[0u8; TOY_LEN]; 8];
+ let mut at = 0;
+ while at < 8 {
+ match grouping {
+ 1 => {
+ out[at] = dec_flat(&mut dec, &ct[at]);
+ }
+ 2 => {
+ let p = dec_blocks(&mut dec, &[ct[at], ct[at + 1]]);
+ out[at..at + 2].copy_from_slice(&p);
+ }
+ _ => {
+ let p = dec_blocks(&mut dec, &[ct[at], ct[at + 1], ct[at + 2], ct[at + 3]]);
+ 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 = ToyCfb::::do_decrypt_init(&key, &iv).unwrap();
+ let three = dec_blocks(&mut dec, &[ct[0], ct[1], ct[2]]);
+ let five = dec_blocks(&mut dec, &[ct[3], ct[4], ct[5], ct[6], ct[7]]);
+ 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` must actually be taken.
+///
+/// [`SwappedPairToy`] returns its two pair results in the wrong order while its single-block methods
+/// are correct. CFB decryption pairs through `encrypt_blocks2`, so with this permutation a pair
+/// comes out wrong and a lone block comes out right. 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 iv = pinned_iv();
+ let plaintext = [[0xA5u8; TOY_LEN], [0x5Au8; TOY_LEN]];
+
+ // The correct toy round-trips.
+ let (mut enc, _) =
+ ToyCfb::::do_encrypt_init_rng(&key, &mut pinned_rng(iv)).unwrap();
+ let ct = enc_blocks(&mut enc, &plaintext);
+ let mut dec = ToyCfb::::do_decrypt_init(&key, &iv).unwrap();
+ assert_eq!(dec_blocks(&mut dec, &ct), plaintext);
+
+ // The swapped-pair toy encrypts identically -- CFB encryption is serial and never pairs, so its
+ // `encrypt_blocks2` override is not reached from the encryptor at all.
+ let (mut enc, _) =
+ SwappedCfb::::do_encrypt_init_rng(&key, &mut pinned_rng(iv)).unwrap();
+ let swapped_ct = enc_blocks(&mut enc, &plaintext);
+ assert_eq!(swapped_ct, ct, "CFB encryption must not use the pair path");
+
+ // ...but decrypting the pair together must now be wrong, because the pair path is used.
+ let mut dec = SwappedCfb::::do_decrypt_init(&key, &iv).unwrap();
+ assert_ne!(
+ dec_blocks(&mut dec, &swapped_ct),
+ plaintext,
+ "decrypting a pair must go through encrypt_blocks2"
+ );
+
+ // Decrypting one block at a time avoids the pair path, so it is correct even for this toy.
+ let mut dec = SwappedCfb::::do_decrypt_init(&key, &iv).unwrap();
+ let p0 = dec_flat(&mut dec, &swapped_ct[0]);
+ let p1 = dec_flat(&mut dec, &swapped_ct[1]);
+ assert_eq!([p0, p1], plaintext, "the single-block path must not pair");
+}
+
+/// The flat streaming method must agree with the block-shaped implementor hook.
+#[test]
+fn flat_streaming_agrees_with_the_block_hook() {
+ let key = toy_key();
+ let iv = pinned_iv();
+ let plaintext = [[0x11u8; TOY_LEN], [0x22u8; TOY_LEN], [0x33u8; TOY_LEN]];
+ let flat_plaintext: [u8; 3 * TOY_LEN] = plaintext.as_flattened().try_into().unwrap();
+
+ let (mut enc, _) =
+ ToyCfb::::do_encrypt_init_rng(&key, &mut pinned_rng(iv)).unwrap();
+ let flat_ct = enc_flat(&mut enc, &flat_plaintext);
+
+ let (mut enc, _) =
+ ToyCfb::::do_encrypt_init_rng(&key, &mut pinned_rng(iv)).unwrap();
+ let block_ct = enc_blocks(&mut enc, &plaintext);
+ assert_eq!(*block_ct.as_flattened(), flat_ct, "flat streaming must equal the block hook");
+
+ let mut dec = ToyCfb::::do_decrypt_init(&key, &iv).unwrap();
+ assert_eq!(dec_blocks(&mut dec, &block_ct), plaintext);
+ let mut dec = ToyCfb::::do_decrypt_init(&key, &iv).unwrap();
+ assert_eq!(dec_flat(&mut dec, &flat_ct), flat_plaintext);
+}
+
+/// The one-shots (`encrypt` / `decrypt` on a `[u8; LEN]`, in place) must produce exactly what the
+/// streaming API produces over the same blocks, for an odd block count (pairs plus a one-block
+/// tail) and an even one (pairs only), in both directions.
+#[test]
+fn one_shots_agree_with_the_streaming_api() {
+ let key = toy_key();
+ let iv = pinned_iv();
+
+ // 3 blocks = 48 bytes: one pair and a tail.
+ let flat3: [u8; 3 * TOY_LEN] = core::array::from_fn(|i| (i * 7) as u8);
+ let blocks3: [[u8; TOY_LEN]; 3] =
+ core::array::from_fn(|b| flat3[b * TOY_LEN..][..TOY_LEN].try_into().unwrap());
+ let (iv_a, ct_blocks) = {
+ let (mut enc, got) =
+ ToyCfb::::do_encrypt_init_rng(&key, &mut pinned_rng(iv)).unwrap();
+ (got, enc_blocks(&mut enc, &blocks3))
+ };
+ let mut buf = flat3;
+ let iv_b = ToyCfb::::encrypt_rng(&key, &mut pinned_rng(iv), &mut buf).unwrap();
+ assert_eq!(iv_a, iv_b);
+ assert_eq!(buf, *ct_blocks.as_flattened(), "3 blocks: one-shot must equal streaming");
+ ToyCfb::::decrypt(&key, &iv, &mut buf).unwrap();
+ assert_eq!(buf, flat3);
+
+ // 4 blocks = 64 bytes: pairs only, no tail.
+ let flat4: [u8; 4 * TOY_LEN] = core::array::from_fn(|i| (i * 13 + 1) as u8);
+ let blocks4: [[u8; TOY_LEN]; 4] =
+ core::array::from_fn(|b| flat4[b * TOY_LEN..][..TOY_LEN].try_into().unwrap());
+ let ct_blocks = {
+ let (mut enc, _) =
+ ToyCfb::::do_encrypt_init_rng(&key, &mut pinned_rng(iv)).unwrap();
+ enc_blocks(&mut enc, &blocks4)
+ };
+ let mut buf = flat4;
+ ToyCfb::::encrypt_rng(&key, &mut pinned_rng(iv), &mut buf).unwrap();
+ assert_eq!(buf, *ct_blocks.as_flattened(), "4 blocks: one-shot must equal streaming");
+ ToyCfb::::decrypt(&key, &iv, &mut buf).unwrap();
+ assert_eq!(buf, flat4);
+
+ // The OS-RNG variant round-trips too.
+ let mut buf = flat3;
+ let iv_fresh = ToyCfb::::encrypt(&key, &mut buf).unwrap();
+ assert_ne!(buf, flat3);
+ ToyCfb::::decrypt(&key, &iv_fresh, &mut buf).unwrap();
+ assert_eq!(buf, flat3);
+}
+
+// ---- SP 800-38A Appendix D error propagation ---------------------------------------------
+
+/// The parts of Appendix D that follow from the equations and hold for *any* permutation.
+///
+/// Table D.2 for CFB: a bit error in `Cj` gives "SBE in the decryption of `Cj`" -- specific bit
+/// errors, i.e. the same bit positions -- because `Pj = Cj XOR Oj` and `Oj = CIPH_K(C_{j-1})` does
+/// not depend on `Cj` at all. Earlier blocks are untouched, and with `s = b` the damage reaches
+/// exactly one block further (`Cj+1`, since `b/s = 1`).
+#[test]
+fn a_ciphertext_bit_error_flips_exactly_that_bit_of_its_own_block() {
+ let key = toy_key();
+ let iv = pinned_iv();
+ let plaintext = [[0x00u8; TOY_LEN], [0x11u8; TOY_LEN], [0x22u8; TOY_LEN], [0x33u8; TOY_LEN]];
+
+ let (mut enc, _) =
+ ToyCfb::::do_encrypt_init_rng(&key, &mut pinned_rng(iv)).unwrap();
+ let ct = enc_blocks(&mut enc, &plaintext);
+
+ // Every bit of C2, so the SBE claim is checked exhaustively rather than at one position.
+ for byte in 0..TOY_LEN {
+ for bit in 0..8 {
+ let mut corrupt = ct;
+ corrupt[1][byte] ^= 1 << bit;
+
+ let mut dec = ToyCfb::::do_decrypt_init(&key, &iv).unwrap();
+ let got = dec_blocks(&mut dec, &corrupt);
+
+ assert_eq!(got[0], plaintext[0], "P1 depends only on the IV and C1");
+
+ let mut expected_p2 = plaintext[1];
+ expected_p2[byte] ^= 1 << bit;
+ assert_eq!(
+ got[1], expected_p2,
+ "C2 byte {byte} bit {bit}: exactly that bit of P2 should change"
+ );
+
+ assert_ne!(got[2], plaintext[2], "P3 comes from CIPH_K of the corrupted C2");
+ assert_eq!(got[3], plaintext[3], "P4 is unaffected: b/s = 1, so damage stops at P3");
+ }
+ }
+}
+
+/// The parts of Appendix D that need a real cipher's diffusion, checked with AES-128.
+///
+/// Table D.2 for CFB says the *other* affected block gets "RBE" -- random bit errors, "bit errors
+/// occur independently in any bit position with an expected probability of 1/2". That is a property
+/// of the block cipher, not of the mode, so the toy (whose rounds are byte-local) cannot show it.
+///
+/// The point worth pinning is that CFB and CBC differ here, and in which direction: under CBC a
+/// corrupted IV flips *exactly* the corresponding bit of `P1` (Appendix D, and
+/// `an_iv_bit_error_flips_exactly_that_bit_of_the_first_block` in `cbc_tests.rs`), whereas under CFB
+/// the IV goes through the cipher first, so `P1` is randomised instead. Confusing the two would be a
+/// real bug and this is what catches it.
+#[test]
+fn an_iv_bit_error_randomises_only_the_first_block() {
+ type Aes128Cfb = Cfb;
+ const LEN: usize = 16;
+
+ let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42; 16], KeyType::SymmetricCipherKey)
+ .expect("a valid AES-128 key");
+ let iv: [u8; LEN] = core::array::from_fn(|i| 0x0F ^ (i as u8));
+ let plaintext = [[0x00u8; LEN], [0x11u8; LEN], [0x22u8; LEN]];
+
+ let (mut enc, got_iv) =
+ Aes128Cfb::::do_encrypt_init_rng(&key, &mut FixedSeedRNG::::new(iv))
+ .unwrap();
+ assert_eq!(got_iv, iv);
+ let mut ct = plaintext;
+ enc.do_encrypt_blocks(&mut ct).unwrap();
+
+ let mut first_blocks = std::collections::BTreeSet::new();
+
+ for byte in 0..LEN {
+ for bit in 0..8 {
+ let mut corrupt_iv = iv;
+ corrupt_iv[byte] ^= 1 << bit;
+
+ let mut dec = Aes128Cfb::::do_decrypt_init(&key, &corrupt_iv).unwrap();
+ let mut got = ct;
+ dec.do_decrypt_blocks(&mut got).unwrap();
+
+ // Only P1 is affected: with s = b, Appendix D's "first i/s (rounding up) ciphertext
+ // segments" is one segment for every bit position i.
+ assert_eq!(got[1], plaintext[1], "IV byte {byte} bit {bit}: P2 must be unaffected");
+ assert_eq!(got[2], plaintext[2], "IV byte {byte} bit {bit}: P3 must be unaffected");
+
+ // ...and it is randomised, not flipped in place. The CBC behaviour would be a
+ // single-bit difference in exactly the position that was corrupted.
+ let differing_bits: u32 =
+ got[0].iter().zip(plaintext[0].iter()).map(|(a, b)| (a ^ b).count_ones()).sum();
+ assert!(
+ differing_bits > 1,
+ "IV byte {byte} bit {bit}: P1 should be randomised, not flipped in place \
+ ({differing_bits} bit(s) differ)"
+ );
+
+ let mut cbc_style = plaintext[0];
+ cbc_style[byte] ^= 1 << bit;
+ assert_ne!(got[0], cbc_style, "CFB must not behave like CBC for a corrupted IV");
+
+ assert!(first_blocks.insert(got[0]), "distinct IVs should give distinct P1");
+ }
+ }
+
+ assert_eq!(first_blocks.len(), LEN * 8, "every corrupted IV should have been tried");
+}
+
+// ---- IV handling -------------------------------------------------------------------------
+
+/// Two encryption flows under the same key must not reuse an IV. The framework checks this too;
+/// repeated here because a repeated IV is worse for CFB than for CBC -- it leaks the XOR of the two
+/// plaintexts, not merely their equality (see the crate docs, "Key and IV reuse").
+#[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) = ToyCfb::::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.
+#[test]
+fn identical_plaintext_gives_different_ciphertext() {
+ let key = toy_key();
+ let plaintext = [0x77u8; 2 * TOY_LEN];
+
+ let mut first = plaintext;
+ ToyCfb::::encrypt(&key, &mut first).unwrap();
+ let mut second = plaintext;
+ ToyCfb::::encrypt(&key, &mut second).unwrap();
+ assert_ne!(first, second);
+
+ // ...and, within one message, two identical plaintext blocks must not give identical ciphertext
+ // blocks either, because the keystream block differs.
+ assert_ne!(
+ first[..TOY_LEN],
+ first[TOY_LEN..],
+ "feedback 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!(ToyCfb::::do_encrypt_init(&seed).is_err());
+ assert!(ToyCfb::::do_decrypt_init(&seed, &[0u8; TOY_LEN]).is_err());
+}
+
+// ---- composition with the padding layer --------------------------------------------------
+
+/// CFB is block-aligned by contract, so arbitrary-length data goes through `bouncycastle-padding`.
+/// Nothing in either crate knows about the other, so this is the test that they actually compose --
+/// across every length from empty to just past three blocks, which covers an exact multiple of the
+/// block size (where PKCS7 appends a whole extra block) and every partial block.
+#[test]
+fn the_padding_layer_round_trips_every_length() {
+ type Enc = PaddedEncryptor, PKCS7, TOY_LEN, TOY_LEN, TOY_LEN>;
+ type Dec = PaddedDecryptor