Skip to content

Simple ciphers: stream + symmetric cipher unification off the release branch, plus SHA-512/t for any t - #133

Open
dghgit wants to merge 214 commits into
release/0.1.3alphafrom
feature/simple-ciphers
Open

dghgit wants to merge 214 commits into
release/0.1.3alphafrom
feature/simple-ciphers

Conversation

@dghgit

@dghgit dghgit commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Consolidates the stacked pair #113 (feature/stream-cipher → release/0.1.3alpha) and #115
(feature/symmetric-cipher → feature/stream-cipher) into a single branch off the release
branch, and adds the SHA-512/t work on top.

feature/simple-ciphers was built by branching from release/0.1.3alpha and merging
feature/stream-cipher then feature/symmetric-cipher; both merges fast-forwarded, so the
content below the two new commits is exactly what #113 and #115 already carried — this is a
re-packaging, not new review surface. #113 (+21,938) and #115 (+2,310) sum to roughly the
+23,499 here.

New in this PR, on top of those two

core: SecurityStrength::from_bits / from_bytes become const fn (3912434) — two
keywords, no behaviour change. Needed so an associated const can derive a strength from a const
generic; split out ahead of the feature per CLAUDE.md's scope-of-changes rule.

sha2: SHA512t<T> is usable for every t FIPS 180-4 s. 5.3.6 defines a hash for (785dbef).
Previously only T = 224 and T = 256 had parameter impls, so nothing else could be named.

  • The t-value checks are back, and they are the spec's. b11f8f6 had narrowed sha512t_h0 to
    100 <= t < 512 and emitted a fixed three digits, since only 224 and 256 were reachable. With
    arbitrary t reachable that would produce the "0256" spelling s. 5.3.6 explicitly forbids —
    and hence the wrong IV — for every t below 100. The one- and two-digit branches are restored
    and check_t asserts the section's own rule: positive, < 512, not 384.
  • One deviation, documented: t must be a multiple of 8, because Hash is byte-oriented and
    a 100-bit digest has no representation here. BC Java's SHA512tDigest imposes the identical
    restriction, so the two libraries accept the same set of truncations.
  • Unapproved truncations are gated the way ElectronicCodeBook::ENCRYPTION_APPROVED gates
    two-key TDEA: SHA512tParams::<T>::FIPS_APPROVED is public and SHA512Internal::new asserts it
    in an inline const, so an unapproved t is a compile error at the call site and
    new_allow_unapproved_t() is the deliberate way in. That also blocks Default, which keeps an
    unapproved truncation out of generic code by accident.
  • ALG_NAME, OUTPUT_LEN and MAX_SECURITY_STRENGTH are derived from t, with const
    assertions pinning them to the values 224 and 256 previously had by hand.

Verification

  • cargo test --workspace: 943 passed, 0 failed (was 923). cargo fmt --check clean; no new
    clippy warnings. Both new commits build independently.
  • Spec text read from a freshly downloaded FIPS 180-4, not from recall.
  • Test vectors cross-checked against BC Java's SHA512tDigest, an independent implementation:
    eight truncations (8, 16, 24, 88, 96, 104, 264, 504) spanning all three decimal branches, over
    the FIPS 180-4 Appendix C messages plus the one-million-'a' case. The 224/256 rows in the same
    table match the NIST-published values.
  • cargo mutants on the changed files: 259 mutants — 180 caught, 5 timeout-kills, 69 unviable,
    5 missed. All five missed are the pre-existing XOR/OR equivalences already annotated at their
    sites in ch, maj and do_final_internal; no new missed mutants.

Note for reviewers

Adding const to a public core function is a forward compatibility commitment, and
SHA512tParams is currently its only caller. The alternative was a private copy of the rounding
ladder inside sha2, free to drift from the real one — happy to switch if the API-surface cost is
the greater worry.

🤖 Generated with Claude Code

dghgit and others added 30 commits September 6, 2026 12:02
…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
…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
…24-AES-lightengine-GCM-mode' into feature/officialfrancismendoza/124-AES-lightengine-GCM-mode
A local Claude Code permissions file, committed with the initial GCM add
(7a6e2a4); checked in, it pre-approves git rebase/add for every
contributor's session in the repo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The crate docs predated Gcm, or described its pre-788fd06 shape:
- an "inherent detached-tag API", made private by 788fd06; the AEAD
  traits are Gcm's whole API
- "For a new design, use Ccm", and a Security Considerations section
  that exempted only CCM from being unauthenticated
- GCM listed under Not yet implemented; replaced with the two GCM
  options Gcm deliberately omits (non-96-bit IVs, 32/64-bit tags)
- the CLI section counted six modes and had no -gcm framing

Also adds a GCM alias and a doctested detached-tag round trip alongside
the CCM ones. Spec references checked against SP 800-38D (Sec 5.2.1.1,
5.2.1.2, 8.3, Algorithm 4 step 2, Appendices A and C).

Assisted-by: Claude:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k copies (#124)

setup built H = CIPH_K(0^128) and CIPH_K(J0) in plain stack arrays before
moving them into their Secrets, tag_block copied the mask back out with
`*self.ek_j0`, and Ghash::absorb copied Y out with `*self.y` -- none of
which were zeroized. SP 800-38D Sec 5.3 requires GCM intermediates to stay
secret, and Appendix A: H recovered is authentication lost. The module
docs already claimed all of these lived in Secret.

H and CIPH_K(J0) are now encrypted in place inside their Secrets; Ghash
updates Y in place, and absorb is an associated function over the
fields so the pending block is passed by reference rather than copied.
Ghash::finish and Gcm::tag_block write into a caller's Secret instead of
returning an array: on the decrypting side that value is the expected
tag T', which is a forgery for the rejected ciphertext if it survives
a failed comparison.

cargo mutants over gcm.rs and ghash.rs: 277 mutants, 222 caught,
46 unviable, 1 timeout, 8 missed -- all equivalent (check_shape's const
assert, the documented | vs ^ in impl_mul64, and > vs >= guards on
zero-length copies).

Assisted-by: Claude:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
load_aad used read_from_file, whose hex-or-raw guess changes what the tag
covers without any error: an AAD file of "cafe" was authenticated as 2
bytes, sixteen zero bytes (which the hex decoder skips) as empty AAD, and
a file ending in a backslash panicked out of bounds in hex::decode_out.
The tag then fails against any other GCM implementation given the same
file. Same fix as 3dd3266 made for CCM's --nonce-file: read_from_file_raw.

New test aad_file_is_raw_bytes_not_hex_decoded covers all three cases;
it fails before this change and passes after.

Assisted-by: Claude:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… encrypting in place in the Secret and apply_batch/apply_one holding their transient blocks in Secrets, the same fix 2161a04 made for CCM's batch path; benches within noise (at most a few percent on batched decrypt), cargo mutants on ctr.rs 70 tested, 48 caught, 22 unviable, 0 missed

Assisted-by: Claude:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in f8f7a2e, which keeps Ctr's keystream out of unzeroized stack
arrays (refill, apply_batch, apply_one). Merged cleanly; no conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/officialfrancismendoza/124-AES-lightengine-GCM-mode

Brings in f8f7a2e (via 8575ea7), which keeps Ctr's keystream out of
unzeroized stack arrays -- GCM's GCTR runs through Ctr, so this closes the
last of the stack copies behind gcm.rs's claim that the CTR keystream lives
in Secret. Merged cleanly; this branch's only ctr.rs change is start_at.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…/officialfrancismendoza/125-AES-lightengine-CCM-mode

Brings in f8f7a2e (via 8575ea7), feature/simple-ciphers' fix for Ctr's
keystream being left in unzeroized stack arrays -- the same leak 985eb94
already fixed on this branch. The one conflict, ctr.rs's refill,
apply_batch and apply_one, takes this branch's 985eb94 versions (one
Secret per batch width held for the whole apply() call, rather than one
per batch), keeping f8f7a2e's module-doc sentence; the crate docs' Memory
Usage note is reworded to match. Resolving it here means the same
conflict does not recur when this branch lands on feature/xof-cshake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…4-AES-lightengine-GCM-mode

Brings in the CCM branch's review fixes since 174563c, including 985eb94's
Ctr keystream zeroization, and its merge of feature/xof-cshake, so this
branch now contains the CCM branch's tip and PR #132 can be based on it:
the diff against it is the GCM work alone. Merged cleanly; ctr.rs is the
CCM branch's version plus this branch's start_at.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread crypto/padding/src/lib.rs
//! ```
//!
//! `NoPadding` never writes a byte: asking it to is the error that tells the caller their data was
//! not block-aligned, and a "padded" block is all data.

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.

Maybe it's me, but I can't figure out what this sentence is trying to tell me.

Could you try wording it a different way?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It say's it throws an error if it's asked to pad (so it's used with the arbitrary length construct to ensure the data is block aligned). Would you tell me what the point of confusion is?

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.

The point of confusion is that I read the sentence 5 times and had no idea what it was trying to tell me. lol.

The sentence is:

NoPadding never writes a byte: asking it to is the error that tells the caller their data was not block-aligned, and a "padded" block is all data.

The implementation of NoPadding always returns an error. Calling it is an error. Period. That is its purpose. So what does "tells the caller their data was not block-aligned" mean? What does "and a "padded" block is all data" mean? I literally don't understand what that's trying to tell me.

Wouldn't it be clearer to replace this sentence with:

NoPadding is a padding mode that always fails, it exists to ...

but I'm struggling to understand the point of a function that always fails, so I don't even know how to finish the sentence.

return Err(SymmetricCipherError::OutputBufferTooSmall(out_len));
}
// out_len is a multiple of BLOCK_LEN, so the remainder of this split is empty.
let (mut out_blocks, _) = ciphertext[..out_len].as_chunks_mut::<BLOCK_LEN>();

@ounsworth ounsworth Sep 27, 2026 •

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.

I'm always a little bit leery of syntactic sugar when I'm not 100% sure that it does.
In this case, I'm leery about this creating a deep-copy under the hood (which would be bad on a GB-sized data.

This one seems ok, but just for the notes: the docs for as_chunks_mut weren't clear about whether this can ever result in a copy, so I had Fable weigh in and check the assembly. This seems ok, so I think that going forward, as_chunks is safe to use.

fn wrap(inner: E) -> Self {
Self { inner, buf: Secret::new(), buf_len: 0, _padding: PhantomData }
}
}

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.

There's an asymmetry between how this constructor for the Encryptor works, and the anagous constructor of the Decryptor.

I think I prefer the pattern used in the Decryptor, so I'm going to make this match.

Comment thread crypto/padding/tests/pkcs7_tests.rs Outdated
}
let original = block;

<PKCS7 as BlockCipherPadding<K>>::pad(&mut block, data_len).unwrap();

@ounsworth ounsworth Sep 27, 2026 •

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.

The fact that you have to do a "turbofish" here -- ie the <>:: pattern, is a bit of a code smell. It means that the PKCS7 struct is impl'ing more than one trait that defines a pad() function. I think we should avoid having multiple traits define functions with the same name, both because it's hard to read, and because if we do this in the public API, then we're begging for users to grab for the wrong one and end up confused.

Perhaps this should go into QUALITY_AND_STYLE.md ?

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.

Oh, I see, the turbofish here is actually serving a different purpose: it's to bind K. But it turns out that the compiler can deduce that from context, so this line can be simplified to:

PKCS7::pad(&mut block, data_len).unwrap();

I think the point about potentially amending QUALITY_AND_STYLE.md to prefer not overloading functions might still be a good idea.

ounsworth and others added 3 commits September 27, 2026 15:34
…-mode: AES LightEngine CCM Mode) into feature/xof-cshake

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…-mode: AES LightEngine GCM Mode) into feature/xof-cshake

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread crypto/modes/Cargo.toml
bouncycastle-aes.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.

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.

Note-to-self: remove the comments.

Brings in the SP 800-185 work, the core AEAD traits and ascon wiring, and
PRs #126 (AES CCM) and #132 (AES GCM). Merged cleanly: git's rename
detection carried xof-cshake's padding changes into 013d806's
padded_block_cipher.rs, and no xof-cshake file uses the old
PaddedEncryptor/PaddedDecryptor names in code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread crypto/modes/src/lib.rs Outdated
Comment thread crypto/modes/src/lib.rs
//! | 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`), i.e. CFB128 for AES |
//! | CFB8 | [`Cfb8`] | SP 800-38A Sec 6.3 | Cipher Feedback, 8-bit segment (`s = 8`) |
//! | CTR | [`Ctr`] | SP 800-38A Sec 6.5 | Counter. Nonce plus counter, both directions parallel |

@ounsworth ounsworth Sep 28, 2026 •

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.

Struct names like Ctr are idiomatic rust, but in keeping with the library's QUALITY_AND_STYLE.md, these should be capitalized to match SP 800-38A, which would be ECB, CBC, CFB, CFB8, and CTR.

That just a refactor of the struct names, which my IDE can handle easily, but will affect dependent PRs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Sure.

Comment thread crypto/modes/src/cbc.rs
/// `Dir` is [`Encrypting`] or [`Decrypting`]. [`BlockCipherEncryptor`] is implemented only for the
/// former and [`BlockCipherDecryptor`] only for the latter, so a `Cbc<_, Encrypting, _, _>` has no
/// decryption methods at all -- using one in the wrong direction is a compile error rather than a
/// runtime check.

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.

Clever!

Comment thread crypto/modes/src/lib.rs Outdated
//! type Aes128Ccm<Dir> = Ccm<AES128Internal, Dir, 16, 16, 12, 16>;
//! type Aes256Ccm<Dir> = Ccm<AES256Internal, Dir, 32, 16, 12, 16>;
//! // A 13-byte nonce leaves q = 2, so a payload of at most 64 KiB - 1; 802.11 CCMP's pair.
//! type Aes128CcmShortTag<Dir> = Ccm<AES128Internal, Dir, 16, 16, 13, 8>;

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.

Aes128CcmShortTag seems like an odd example to put here since it doesn't exist in the AES crate, and I don't think it really adds to this tutorial about how to use the CCM<> struct to create type aliases.

Question: if Aes128CcmShortTag is a real thing that's used in the wild, then should it be defined in the AES crate?

Comment thread crypto/modes/src/ecb.rs
}

impl<P, const KEY_LEN: usize, const BLOCK_LEN: usize> BlockCipherEncryptor<KEY_LEN, 0, BLOCK_LEN>
for Ecb<P, Encrypting, KEY_LEN, BLOCK_LEN>

@ounsworth ounsworth Sep 28, 2026 •

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.

Is this a good idea?
If ECB is really meant as an internal building block, and maybe a toy for students / researchers, but otherwise hazmat, then maybe we shouldn't expose it through the BlockCipherEncryptor trait so that people don't get the wrong idea?

I think I'm suggesting just deleting these impl's.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It's a knotty one... FIPS still test for it, so I'd leave it in. It's not that the mode is inherently insecure (after all you could argue that every other block cipher mode is built on it!) it's more that you need to know what you're doing if you use it. It's probably also better to leave it in so there's a way of doing it - the alternative is we'll find people using ElectronicCodeBook directly (as they say, life will always find a way...), which would definitely suck!

@ounsworth ounsworth Sep 28, 2026 •

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.

But, like, it's not even a good fit for the BlockCipher API because that API forces it to return an IV, which for ECB will always be a zero-length array.

let (bytes_written, no_iv): (usize, [u8; 0]) = Aes128Ecb::<Encrypting>::encrypt(&key, &mut data).expect("encryption");
assert_eq!(no_iv.len(), 0, "EBC mode returns the IV as an empty array");
assert_eq!(data[..16], data[16..], "equal plaintext blocks give equal ciphertext blocks");

and on the decrypt side, you have an init() that has to be handed something that the compiler knows is the empty array.

    fn do_decrypt_init(
        key: &KeyMaterial<KEY_LEN>,
        _init_data: &[u8; 0],
    ) 

which you have to call like this:

Aes128Ecb::<Decrypting>::decrypt(&key, &[], &mut data)

That just feels clunky.


To me, we have two reasons not to impl BlockCipher on ECB:

  1. It's misleading; we'll end up having to include AES_ECB, Aria_ECB, etc in the BlockCipherFactory, which is just wrong cause we really don't want people finding them and using them by accident.
  2. ECB is a bad fit for the BlockCipher API anyway.

If someone really wants to use AES_ECB and knows what they're doing, they can use it through the ElectronicCodeBook trait, which is pub.

What's the advantage of also exposing an API via BlockCipher?
Is there some structural thing that needs ECB to be exposed as a clunky BlockCipher? Does anything break if we just delete it?

ounsworth and others added 2 commits September 27, 2026 21:23
…-- ecb.rs trailing whitespace (the rustfmt failure) and the batching sentence's lost object; lib.rs restores the required "Usage Examples" section name, moves the init-data and CFB/CFB8 paragraphs out from under "Notes on GCM Mode", repairs the AEAD and GCM opening sentences, says it is Gcm and not SP 800-38D that fixes the nonce at 12 bytes (Sec 5.2.1.1 only recommends 96 bits), fixes CBF8 and a leftover CCM comment fragment, drops the alias doctest's unused imports and wraps at 100 columns; and the last prose mentions of PaddedEncryptor/PaddedDecryptor take 013d806's new names

Assisted-by: Claude:claude-opus-5-5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread crypto/modes/src/ccm.rs
Ccm<P, Encrypting, KEY_LEN, BLOCK_LEN, NONCE_LEN, TAG_LEN>
where
P: ElectronicCodeBook<KEY_LEN, BLOCK_LEN>,
{

@ounsworth ounsworth Sep 28, 2026 •

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.

This impl block seems like it's basically duplicating all the functions of the SymmetricCipherEncryptor / Decryptor traits, but as inherents. I suggest that this should instead actually impl SymmetricCipher.

This branch has not been deployed

No deployments
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.

3 participants