Skip to content

Feat: AES LightEngine CCM Mode - #126

Draft
officialfrancismendoza wants to merge 13 commits into
bcgit:feature/symmetric-cipherfrom
officialfrancismendoza:feature/officialfrancismendoza/125-AES-lightengine-CCM-mode
Draft

officialfrancismendoza wants to merge 13 commits into
bcgit:feature/symmetric-cipherfrom
officialfrancismendoza:feature/officialfrancismendoza/125-AES-lightengine-CCM-mode

Conversation

@officialfrancismendoza

@officialfrancismendoza officialfrancismendoza commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Builds off AEAD Cipher split PR (#120) to add CCM mode for AES LightEngine (#125)

@officialfrancismendoza officialfrancismendoza added the enhancement New feature or request label Sep 10, 2026
@officialfrancismendoza
officialfrancismendoza changed the base branch from feature/symmetric-cipher to release/0.1.3alpha September 14, 2026 05:35
@officialfrancismendoza
officialfrancismendoza changed the base branch from release/0.1.3alpha to feature/symmetric-cipher September 14, 2026 05:40
…in update_out_len and a FINAL_LEN final buffer so a buffering cipher or an inline ciphertext||tag layout can be expressed; TaggedEncryptor/TaggedDecryptor adapt any FINAL_LEN=0 pair to the SimpleCipherEncryptor/SimpleCipherDecryptor ciphertext||tag shape; the block, simple-cipher and AEAD strength sweeps assert they are not vacuous, and the AEAD streaming suite gains a genuinely-buffering toy plus undersized-buffer and std-one-shot coverage
…XOF128/CXOF128) implementing AEADCipherEncryptor/AEADCipherDecryptor via AsconAead128Encryptor/AsconAead128Decryptor, with HashFactory/XOFFactory registration and CLI wiring including a TaggedDecryptor-based decrypt stream
…aped for CCM

CCM was implemented in part to test whether the AEAD streaming traits could
support a packet cipher; it confirmed they cannot without buffering, since
SP 800-38C needs the total AAD and payload length before it can authenticate
anything, and the trait's do_encrypt_init/do_update_aad/do_update_out are
open-ended by design for the common case (Ascon-AEAD128, and GCM once it
exists) that never needs a total up front. Record the finding and the chosen
resolution -- buffer internally or ship a dedicated non-buffering API, not a
length parameter on the shared trait -- at the trait definition itself, cross
referenced from CcmEncryptor, so a future implementor doesn't have to
re-derive it.
@officialfrancismendoza
officialfrancismendoza force-pushed the feature/officialfrancismendoza/125-AES-lightengine-CCM-mode branch from 1d9bf20 to 63b9df6 Compare September 14, 2026 15:38

@dghgit dghgit left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like it's getting there. I've sent the report.

Make sure the update to .gitignore is removed - it's wrong! Delete the commit or revert it.

@officialfrancismendoza
officialfrancismendoza force-pushed the feature/officialfrancismendoza/125-AES-lightengine-CCM-mode branch from fcb9b75 to 63b9df6 Compare September 15, 2026 17:43
read_from_file's hex-or-raw heuristic is fine for a key, where a wrong guess
only produces a mismatch, but for a CCM nonce it can turn two distinct
binary nonce files into the same nonce value if both happen to be valid hex
text for it -- and a repeated nonce under one key breaks CCM's
authentication (SP 800-38C Appendix B). Add read_from_file_raw and use it
for --nonce-file specifically; --nonce (hex on the command line) is
unaffected. PR bcgit#126 review, finding F1.
…payload limit a compile error

Ccm::y held Yr (the raw tag before the S0 mask) and every intermediate
CBC-MAC chaining value in a plain array, unlike the keystream beside it,
which is a Secret for the same reason; wrap it and finish_mac's local S0
the same way. Separately, CcmEncryptor/CcmDecryptor's BUFFER_LEN could
exceed the payload limit NONCE_LEN implies (A.1's 2^8q - 1) and only fail
at do_*_final, after buffering the whole message for nothing; assert the
relationship at construction instead, which also makes MAX_PAYLOAD_LEN pub
and lets do_*_final's # Errors sections state the guarantee precisely.
Document the same capacity error as a general possibility on the trait's
do_update_aad/do_update_out. PR bcgit#126 review, findings F3 and F4.
apply_keystream generated one counter block per encrypt_block call, even
though A.3's Ctrj depends only on j and the counter blocks are exactly as
independent as CTR's -- only the CBC-MAC half is genuinely serial (Sec 6.1
step 3). Restructure it like Ctr::apply: finish any open keystream block
byte-wise, batch aligned whole blocks through encrypt_4blocks/encrypt_2blocks,
then finish the tail byte-wise. Measured ~35-38% throughput gain (26->36
MiB/s for AES-128, no AAD; matches the buffering pair too), all 480 ACVP
cases and 4 Appendix C vectors still pass.

That made three doc passages actively wrong, since they said this was
inherent: modes/src/lib.rs's mode comparison, modes_benches.rs's CCM doc
comment (both rewritten with the new ratios against CTR), and lib.rs's
"CCM takes no direction"/"there is no direction parameter" claims, which
were already false against the code (Dir is very much a parameter) and
predate this session. Also: fixed lib.rs's "264 B" vs the documented and
now-tested 256 B, added size_of assertions pinning Ccm/CcmEncryptor's sizes
against the memory table (previously undocumented by a test), and added
the CCM aliases to the AES crate's "Modes of operation" section, which
listed every other mode but this one. PR bcgit#126 review, findings F5 and F7.
… doesn't have

The Encrypt/Decrypt value help (rendered by clap under --help for every
mode subcommand, including the three CCM ones) said a fresh IV or nonce is
generated and written to the output. CCM's nonce is supplied via --nonce
and never written, so bc-rust aes128-ccm --help printed instructions that
produce "authentication failed" if followed. Trim the shared enum's help
to direction only and point at each subcommand's own --help, which already
documents its mode's exact framing (CBC/CFB/CFB8/CTR already do; CCM's own
help already explains the nonce is supplied, not generated).
PR bcgit#126 review, finding F6.
go() called the *_detached one-shots, each of which needs a fresh
ciphertext/plaintext buffer the size of the input on top of the input
buffer already read from stdin. Use Ccm::new plus do_*_update/do_*_final
directly on the buffer already in hand: input.len() is exactly the
declared payload length and is supplied in one call, so the two
do_*_update/do_*_final calls this replaces cannot fail, which the
.expect()s explain. Also: decrypt's tag split now goes through
split_last_chunk_mut, matching Ccm::decrypt's own reasoning for admitting
Clen == Tlen instead of restating the spec's stricter Clen <= Tlen and
then testing < anyway; and the payload-limit error message reads
Ccm::MAX_PAYLOAD_LEN (now pub) instead of re-deriving it. Documented the
packet-AEAD exception to CLAUDE.md's CLI-streams rule this relies on.
PR bcgit#126 review, finding F8 (buffer only; the pre-existing duplicated
nonce-range check is deliberate and stays, per its own comment).
… the redundant key check

CcmEncryptor and CcmDecryptor carried seven identical fields and
byte-for-byte identical do_update_aad, differing only in one error string
in do_update_out and in which Ccm direction do_*_final builds; the
"set data_started before the length check" comment was on the encryptor's
copy only. Factor the buffering itself into a private CcmBuffer that both
now wrap as newtypes (the same pattern bouncycastle-ascon uses for
AsconAead128Encryptor/Decryptor), so the shared behavior has one body.

Also: Ccm::checked_perm re-checked KeyType::SymmetricCipherKey, which
P::new (AES_128::new and friends) already checks per
ElectronicCodeBook::new's own documented contract -- confirmed no other
mode in this crate duplicates it, so it bought nothing but a second,
differently-worded error message for the same bad key. Removed, and
Ccm::new/CcmEncryptor/CcmDecryptor now call P::new(key) directly like
every other mode. CcmEncryptor's nonce draw now calls crate::iv::random_iv,
the same OS-backed draw Cbc/Cfb/Ctr already share, instead of a
CCM-specific copy of the same three lines.

No behavior or memory-layout change: CcmEncryptor/CcmDecryptor are still
8400 B at BUFFER_LEN=4096, all 480 ACVP cases and 4 Appendix C vectors
still pass. PR bcgit#126 review, finding F10.
…used no private API

crypto/modes/tests/wycheproof_ccm_tests.rs drives bc-test-data's vendored
aes_ccm_test.json (552 tests) through Ccm::encrypt_detached/decrypt_detached,
following the file/skip-with-warning convention acvp_ccm_tests.rs already
uses. Unlike the ACVP set (one nonce length, no malformed inputs), this one
is deliberately adversarial: every nonce length from 8 to 2144 bits, tag
sizes A.1 forbids, truncated and bit-flipped tags. Ccm's NONCE_LEN/TAG_LEN
are const generics restricted to A.1's sets, so a case whose sizes fall
outside them has no instantiation to dispatch to at all -- not a runtime
failure, a compile-time non-option -- and those are counted as skipped
rather than silently dropped. Locally: 486 of 552 cases run (405 valid, 81
invalid), 66 skipped across 63 out-of-range groups, all passing.

bc-test-data/crypto/wycheproof/ already vendors sm4_ccm_test.json for this
exact purpose; aes_ccm_test.json needs adding there too (copied from
https://github.com/C2SP/wycheproof, testvectors_v1) for this suite to run
anywhere but here -- that's a separate repository this PR cannot touch.

Also, per QUALITY_AND_STYLE.md's unit-vs-integration-test rule (a unit
test only where the behaviour cannot be reached from outside): moved
payload_longer_than_the_q_limit_is_refused and
a_short_or_long_payload_is_refused out of ccm.rs's #[cfg(test)] block into
sp800_38c_tests.rs (converted from the toy Identity permutation to
AES_128, matching that file's convention), since both exercise only
Ccm::new/do_encrypt_update/do_encrypt_final. Deleted
both_directions_mac_the_plaintext outright: it was byte-for-byte the same
check as sp800_38c_tests.rs's each_direction_has_its_own_methods, just
against Identity instead of AES_128. What remains in ccm.rs's own test
module is exactly what its module doc says it should be: the private
formatting helpers (format_b0, encode_aad_len, put_q_field) that no public
API exposes directly.

PR bcgit#126 review, finding F9.
…ry changes left

Scoped cargo-mutants (apply_keystream, counter_block, CcmBuffer) found 7
survivors after the F5/F7/F10 commits: 3 on apply_keystream's head_len
comparison/subtraction, 2 more on the same expression, and 2 on
CcmBuffer::do_update_aad/do_update_out's `end > BUFFER_LEN` checks.

The two BUFFER_LEN checks were genuinely untested at the exact boundary
(end == BUFFER_LEN, which must be accepted, not refused) -- added
the_buffering_pair_accepts_a_message_that_exactly_fills_its_buffer.

apply_keystream's gap needed an actual bug, caught it, then a second
attempt to test it: no existing test ever calls it with `ks_pos` genuinely
strictly between 0 and BLOCK_LEN followed by a chunk large enough to reach
the batched fours/pairs path -- every chunking sp800_38c_tests.rs sweeps is
uniform, and Appendix C.4's 32-byte payload (Plen = 256 *bits*, not bytes)
is too short regardless. Added
resuming_a_part_way_open_block_agrees_with_a_one_shot, a dedicated 123-byte
case; verified by hand-applying each surviving mutation and confirming it
now fails before restoring the correct code. One mutant remains and is
provably equivalent (`<` vs `<=` on `ks_pos < BLOCK_LEN`, since `ks_pos`
never exceeds `BLOCK_LEN` and both arms agree at that boundary) -- same
class as format_b0's documented `|`/`^` equivalence, now commented the
same way. Re-run: 34 caught, 116 unviable, 1 equivalent, 0 missed.
@ounsworth
ounsworth marked this pull request as draft September 16, 2026 02:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants