Skip to content

Part 7: Stream cipher traits, CFB as a stream cipher, and new CFB8 and CTR modes - #113

Open
dghgit wants to merge 53 commits into
release/0.1.3alphafrom
feature/stream-cipher
Open

Part 7: Stream cipher traits, CFB as a stream cipher, and new CFB8 and CTR modes#113
dghgit wants to merge 53 commits into
release/0.1.3alphafrom
feature/stream-cipher

Conversation

@dghgit

@dghgit dghgit commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Issue Link

No linked issue.

Summary

Replaces the never-implemented StreamCipher trait with a split StreamCipherEncryptor /
StreamCipherDecryptor pair shaped like the block cipher pair, moves Cfb onto it, and adds two
new modes: Cfb8 and Ctr.

Description

What this PR contains. Six commits, in order:

Commit
5936674 core: StreamCipher replaced by the split encryptor/decryptor pair; TestFrameworkStreamCipher implemented in place of its todo!()
8285686 modes: Cfb becomes a stream cipher, Cfb8 added, with AES_CFB8_* aliases, aes*-cfb8 CLI and a shared stream-mode CLI
ea612a9 release notes for the above
afa976e modes: single-call vs chunked equivalence pinned against real AES, not only the toy permutation
2a36645 modes: Ctr added, with AES_CTR_* aliases and aes*-ctr CLI
93ee992 modes: Ctr cross-checked against BC Java's SICBlockCipher

What I did, and why.

The trait. StreamCipher carried both directions on one trait and put a BLOCK_LEN const
parameter on every data method, which a stream cipher has no use for. It had no implementors and its
test-framework suite was a todo!(). It is replaced by StreamCipherEncryptor /
StreamCipherDecryptor, mirroring BlockCipherEncryptor / BlockCipherDecryptor: direction encoded
in the type so a policy can permit decryption while forbidding new encryption, in-place data methods
taking a &mut [u8] of any length, init data generated by the constructor and never supplied, and
one-shots provided over a single implementor hook per direction.

CFB. CFB never puts data through the cipher, only the input block, so it is a stream cipher and now
implements the new pair. A message that is not a whole number of blocks gets a short final segment,
taking the s = 8r step of the Sec 6.3 equations for that segment alone; the module docs derive
this, and it is what makes ciphertexts interoperate with other streaming CFB128 implementations. The
mode keeps one block that serves as input block, output block and next input block in turn, which is
why it costs one usize more than Cbc and no second buffer.

CFB8. A separate type, because CFB8 and CFB128 are different, non-interoperable modes: their
ciphertexts agree on the first byte and diverge from the second. Its shift register is Sec 6.3's own
alternative description, a rotate followed by writing the ciphertext byte into the last position.
It costs one forward cipher per byte, 16x CFB on AES, which the docs say plainly.

CTR. The init data is the nonce and the counter takes whatever the nonce leaves, so
INIT_DATA_LEN picks the counter width. This is Appendix B.2's Tj = N | [j]m. The counter is
capped at 4 bytes and must be at least 1, both compile-time assertions, so a nonce outside 12..15
bytes on AES is a compile error. Running out of counter returns SymmetricCipherError::StateError
and consumes nothing, because the whole call is checked up front; this is the first use in the crate
of the Result the data methods have always returned. CTR is the only mode here whose encryption is
parallel too, so both directions batch.

Alternatives considered.

  • Counter starting at 1. Appendix B.2 reads for j = 1...n, so its example starts at 1. This
    implementation starts at 0. Appendix B presents B.2 as one of "two examples of approaches" and
    allows "other methods and approaches", and the normative rule in Sec 6.5 is only that counter
    blocks be distinct, so both are permitted. Zero was chosen because of the vectors: of the 2138
    ACVP AES-CTR cases, 1853 have an initial counter block ending in four zero bytes and none ends
    in 00000001, so starting at zero is the difference between 1853 official known-answer vectors and
    none. It also makes a message identical to one from an implementation handed nonce || 00000000
    as a whole-block IV, which is how CTR is usually driven.
  • A settable initial counter, to reach the Appendix F.5 vectors, was rejected as extra API surface
    in a crate that has deliberately kept init data out of the caller's hands.
  • A wider counter than 4 bytes. BC Java allows up to 8. Kept at 4 as specified; nonce bits are the
    scarcer resource and 2^32 blocks is 64 GiB per message.

Tests. Every mode has structural tests against a toy permutation, known-answer tests, chunking
equivalence at byte granularity in both directions, and mutation testing.

  • ACVP: 2138 CFB128 cases, 2138 CFB8 cases, and 1853 of 2138 CTR cases, each in four call groupings.
    The 285 CTR cases skipped begin at a non-zero counter and cannot be expressed through a
    nonce-plus-zero-counter API; the count is reported.
  • SP 800-38A: F.3.13-F.3.18 for CFB128 and F.3.7-F.3.12 for CFB8, the latter including all 18
    tabulated input and output blocks of F.3.7 checked three ways, which pins the shift register
    against the spec's own table.
  • Two things I want to flag, because they are the reason CTR has more scaffolding than the others.
    Every ACVP CTR case is a single block, so none of them exercises the counter increment at all:
    a deliberately little-endian counter was run against the whole 1853-case set while these tests were
    written, and it passed. That gap is closed by OpenSSL-generated five-block vectors and by checking
    the counter blocks against the raw permutation at all four counter widths. The width sweep
    matters because a wrong counter slice is invisible to a round-trip test, since both directions
    build the same wrong block and still recover the plaintext.
  • Ctr is also cross-checked against BC Java's SICBlockCipher, which shares the
    nonce-plus-counter construction and so can reach the narrow counters OpenSSL cannot. Agreement is
    exact on the 69-byte vectors, on 5000 bytes across the 255-to-256 carry at all three key lengths,
    and on where the counter limit falls at both the 1-byte and 2-byte widths.

Scope and Risk

Packages impacted: bouncycastle-core (trait replaced), bouncycastle-core-test-framework
(suite implemented), bouncycastle-modes (CFB rewritten, CFB8 and CTR added, bouncycastle-utils
added as a dependency for Secret), bouncycastle-aes-lowmemory (alias modules), cli (new
stream-mode plumbing and six new subcommands).

Runtime behaviour that could change.

  • Cfb no longer implements BlockCipherEncryptor / BlockCipherDecryptor. Callers using it
    through the block traits, or wrapping it in PaddedEncryptor / PaddedDecryptor, will not
    compile. That is intended: CFB needs no padding layer.
  • aes*-cfb no longer rejects unaligned input. It previously errored; it now encrypts any
    length. This changes the observable behaviour of an existing command, and the ciphertext for an
    unaligned message is new output that had no predecessor.
  • StreamCipher is gone. It had no implementors, so nothing in tree breaks.

Likelihood of regression: low for CBC, ECB and the padding layer, which are untouched. The real
risk is concentrated in CFB, which was rewritten rather than extended: its ciphertext for
block-aligned data must be unchanged, and that is pinned by the F.3 vectors and the 2138 ACVP cases,
which pass unchanged.

Worst case: a keystream mode that repeated keystream would be a confidentiality failure rather
than a corruption. The three ways that could happen are all tested directly: a repeated nonce or IV
(each do_encrypt_init draws from the DRBG, and freshness is asserted), a counter that wrapped (CTR
refuses, at two widths, in both directions), and chunking that desynchronised the keystream (checked
as a full cross-product of call sizes in both directions, against a single-call reference, with real
AES as well as the toy).

Validation

cargo test --workspace          # 870 tests
cargo fmt --all --check
RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace
cargo mutants -p bouncycastle-modes --jobs 3 --timeout 300

Mutation testing reports 0 surviving mutants over the modes crate: 220 mutants, 108 caught, 112
unviable. One needed the tests to reach past runtime behaviour, since stubbing out CTR's
compile-time counter-width guard cannot fail a runtime test; the compile_fail doctests on Ctr are
what kill it.

The ACVP suites need bc-test-data cloned alongside this repository. Without it they print a warning
and pass, so cargo test stays green on a bare clone.

To check the interoperability claims independently:

# CFB128, CFB8 and CTR against OpenSSL, on a deliberately unaligned message
head -c 37 /dev/urandom > pt.bin
bc-rust aes128-ctr encrypt --key 2b7e151628aed2a6abf7158809cf4f3c < pt.bin > out.bin
# first 12 bytes are the nonce; feed <nonce>00000000 to openssl as the -iv
openssl enc -aes-128-ctr -K 2b7e151628aed2a6abf7158809cf4f3c -iv <nonce>00000000 -in pt.bin

AI Usage Statement

Did you use AI in creating this pull request:

  • No
  • Yes, indirectly - no submitted code was generated by AI (e.g., answering questions, performing a review, suggestions, etc.)
  • Yes, trivial code changes were generated by AI (e.g., autocompletion of a single line, reformatting, or spell-checking)
  • Yes, non-trivial code changes were generated by AI

If submitted code changes were generated by AI, fill in the following declaration:
Assisted-by: Claude Code:claude-fable-5-1

…ptor with multi-block and one-shot methods (PR #107)
…me lengths, AES_CBC_* aliases, simpler CLI (PR #109)
…locks8, SymmetricCipherEncryptor/Decryptor (from feature/sm4); CFB follows suit
…cb CLI subcommands; block-mode CLI generic over INIT_DATA_LEN
…S_PADS; SymmetricCipherEncryptor::do_final reports its output length
@dghgit dghgit changed the title Stream cipher traits, CFB as a stream cipher, and new CFB8 and CTR modes Part 7: Stream cipher traits, CFB as a stream cipher, and new CFB8 and CTR modes Sep 6, 2026
ounsworth and others added 7 commits September 8, 2026 07:34
…rams/HashMLDSAParams/MLKEMParams traits, one impl per parameter set (#117)
…eamCipherDecryptor pair, shaped like the block cipher pair (in place, any length, generated init data); TestFrameworkStreamCipher implemented in place of its todo!()
… and Cfb8 (SP 800-38A Sec 6.3, s = 8) is added, with AES_CFB8_* aliases, aes*-cfb8 CLI subcommands and a shared stream-mode CLI
…, CFB8 is added, and the StreamCipher trait is replaced by the split encryptor/decryptor pair; re-measured throughput and mutation figures
…inst real AES at all three key lengths, not only the toy permutation
…th picks the counter width (max 4 bytes) and which errors rather than repeat a counter, with AES_CTR_* aliases and aes*-ctr CLI subcommands
… the nonce-plus-counter construction, pinning the 1, 2 and 3-byte counter widths that the ACVP and OpenSSL vectors cannot reach
@hubot
hubot force-pushed the feature/stream-cipher branch from 93ee992 to 0404ab9 Compare September 8, 2026 03:56
…cb CLI subcommands; block-mode CLI generic over INIT_DATA_LEN
…S_PADS; SymmetricCipherEncryptor::do_final reports its output length
…eamCipherDecryptor pair, shaped like the block cipher pair (in place, any length, generated init data); TestFrameworkStreamCipher implemented in place of its todo!()
… and Cfb8 (SP 800-38A Sec 6.3, s = 8) is added, with AES_CFB8_* aliases, aes*-cfb8 CLI subcommands and a shared stream-mode CLI
…, CFB8 is added, and the StreamCipher trait is replaced by the split encryptor/decryptor pair; re-measured throughput and mutation figures
…inst real AES at all three key lengths, not only the toy permutation
…th picks the counter width (max 4 bytes) and which errors rather than repeat a counter, with AES_CTR_* aliases and aes*-ctr CLI subcommands
… the nonce-plus-counter construction, pinning the 1, 2 and 3-byte counter widths that the ACVP and OpenSSL vectors cannot reach
@ounsworth

Copy link
Copy Markdown
Contributor

I am starting to review this. Note that +23,025 -571 Lines changed is a lot to review, so I can't promise how quickly I'll finish, especially as other interruptions come up.

I had left a number of review / discussion comments on #105. I will try to copy the relevant ones over to this PR as I go.

@ounsworth
ounsworth self-requested a review September 9, 2026 16:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants