Skip to content

Key object rework prototype: AESKey<P, M> and HmacKey<H> producing their workers through role traits - #170

Closed
dghgit wants to merge 46 commits into
release/0.1.3alphafrom
feature/key-rework
Closed

dghgit wants to merge 46 commits into
release/0.1.3alphafrom
feature/key-rework

Conversation

@dghgit

@dghgit dghgit commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Issue Link

No issue filed yet. This is the prototype behind the key object rework document circulated for review (AES128Key<GCM>, "Death to init()"), opened so the design can be discussed against working code alongside #167.

Summary

Prototype of mode-bound symmetric key objects: AESKey<P, M> and HmacKey<H> are built once from KeyMaterial and produce their encryptors, decryptors, generators and verifiers through role traits in core.

Description

  • What: core gains FromKeyMaterial<KEY_LEN> / FromAnyKeyMaterial, the AEAD and MAC role pairs (AeadEncryptionKey / AeadDecryptionKey, the packet pair, MacGenerationKey / MacVerificationKey) and the hazmat Mode<P> traits a mode implements over a permutation. cipher gets GCM, CCM, CMAC and GMAC as mode types over a key state. aes gets one AESKey<P, M> with AES128Key<M> / AES192Key<M> / AES256Key<M> aliases, the forwarding role impls, and a check at construction that the material's KeyType is the one the mode requires (SymmetricCipherKey for the cipher modes, MACKey for CMAC and GMAC), ahead of the permutation's own length and strength checks. There is no algorithm-name binding: KeyMaterial carries no such field, so one KeyMaterial<16> keys AES128Key<GCM>, AES128Key<GCM<12>> and AES128Key<CCM<N, T>> alike, and one MACKey both <CMAC> and <GMAC>. hmac gets HmacKey<H> holding the two pad states, and hkdf one HmacKey per PRK. The aes*-ccm CLI commands and the CCM memory bench run through the key path.
  • Why this shape: validation and schedule expansion happen once per key rather than per message; one key under two modes is a compile error; mode-derived state (GCM's H, CMAC's subkeys) lives in the key; and a token-backed key can implement the same role traits, which no constructor taking key bytes allows. The reasoning and the alternatives considered are the document's numbered decisions.
  • Alternatives: keeping the static constructors behind the key path, permutation-level keys with the mode as a value, and typed newtypes over KeyMaterial with the constructors kept on the traits (the shape of SymmetricCipherKey: dedicated key types per algorithm #167). Each is discussed in the document.
  • Scope of the prototype: AES under GCM, CCM, CMAC and GMAC, plus HMAC. Not yet covered: CBC, CFB, CFB8, CTR and ECB, the other block ciphers, Ascon and ChaCha, KMAC, and removal of the old do_*_init constructors, which stay in place so both routes coexist on this branch.
  • Tests: key tests per mode in crypto/aes/tests, CCM and GCM one-shot and packet tests in cipher, HMAC key tests covering keys shorter than, equal to and longer than the block, and the key-type refusals. Every commit builds and passes cargo test --workspace; 1187 tests at the tip.

Scope and Risk

  • Packages: core, cipher, aes, hmac, hkdf, sha2, sha3, sm3, core-test-framework, cli, mem_usage_benches. 59 files, +7333 / -1630.
  • Runtime behaviour: additive. The existing constructors and one-shots are unchanged; the key path is a second route to the same workers. The HMAC worker no longer carries a key buffer, so HMAC<H> is two hash states.
  • Fallibility: quality_stats.sh against the base, unwraps in core code 805 → 818 and Err() 458 → 481: the new fallible one-shots and mode types in cipher and core.
  • Regression likelihood: low on existing APIs, since they are not modified; the new path is covered by the tests above and the existing vectors run through it where the CLI does.
  • Worst case: a key constructed on the new path accepting material the old route would have refused. The key-type, length and strength checks are tested for the refusals and acceptances.

Validation

cargo build --workspace
cargo test --workspace
cargo test -p bouncycastle-aes aes_key          # key construction, binding and role tests
cargo test -p bouncycastle-hmac hmac_key_tests  # any-length keys
cargo run --release -p mem_usage_benches --bin bench_ccm_mem_usage

Up to date with feature/simple-ciphers: its tip (the CCM do_*_inplace rename, 7084adb) is merged in as 69e24ba, with the rename carried onto the reworked packet type here, and 1185 tests passing on the result.

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

Assisted-by: Claude:claude-mythos-5-1

dghgit and others added 29 commits October 24, 2026 20:15
…de traits (prototype)

The key-object layer of the symmetric key rework, built beside the existing constructors.
symmetric_key.rs holds FromKeyMaterial<KEY_LEN> (from_key_material plus a provided generate(rng)
that tags the draw with the implementor's KEY_TYPE), FromAnyKeyMaterial for constructions that
take a key of any length (the only trait with a weak-key form), the AeadEncryptionKey /
AeadDecryptionKey pair with the one-shots provided over create_encryptor(rng) /
create_decryptor(nonce), and the MacGenerationKey / MacVerificationKey pair with mac, mac_out
and verify provided over create_generator() / create_verifier(). hazmat gains Mode<P> (KeyState
and KEY_TYPE) and the AeadEncryptMode / AeadDecryptMode constructors the role impls forward to.

No method instantiates a DRBG for the caller: everything that draws randomness takes
&mut dyn RNG, and the create_* names carry no _rng suffix because they have no twin.
KeyMaterial::from_rng accepts an unsized RNG so the provided generate can use it.

Two departures from the issue draft, both forced by the tree as it stands: the role and mode
traits carry KEY_LEN while the cipher traits still do, so that Encryptor and Decryptor can be
bounded by them; and the MAC role pair bounds its workers on MAC rather than on a MACGenerator /
MACVerifier split, because putting the same final names on two traits while MAC still has them
makes every call site with both in scope ambiguous. Both resolve when the constructors leave the
worker traits.

Assisted-by: Claude:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…er a key state (prototype)

GCM<TAG_LEN = 16> is the mode type a mode-bound key names, AES128Key<GCM>. GcmKeyState<P> is
what such a key stores: the keyed permutation and the hash subkey H = CIPH_K(0^128) (SP 800-38D
Algorithm 4 step 1), derived once per key rather than once per message. Gcm's setup is split so
that the per-message half (J0, CIPH_K(J0), the counter start) runs over an existing state, and
Gcm<P, Encrypting, ..>::from_key_state(state, rng) / Gcm<P, Decrypting, ..>::from_key_state(state,
nonce) are the constructors a permutation crate's mode impls call; the decrypt side is infallible.
do_encrypt_init_rng and do_decrypt_init are unchanged in behaviour and still expand per message.

The previous prototype's GcmMode marker, GCM constant and default-RNG from_perm are gone: the
mode is a type, not a value, and the key path offers no form that instantiates a DRBG.

Assisted-by: Claude:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he packet bench (prototype)

AESKey<P: AESParams, M: Mode<AESInternal<P>>> stores M::KeyState and nothing else; AES128Key<M>,
AES192Key<M> and AES256Key<M> are the aliases, so AES128Key<GCM> and AES128Key<GCM<12>> are
distinct key types over the same material and a key under a second mode is a second
from_key_material call. FromKeyMaterial<16 | 24 | 32> is implemented per key length (the
KeyMaterial<N> argument cannot be sized from P::KEY_LEN on stable), checks M::KEY_TYPE and then
runs the existing AESInternal validation and expansion once. The AeadEncryptionKey /
AeadDecryptionKey impls forward to whichever mode traits M implements, so the mode decides the
role. No Debug, Clone written out so M need not be Clone.

The Mode / AeadEncryptMode / AeadDecryptMode impls for GCM over the three AES permutations live
here, one set per key length, because a generic impl in cipher over P: ElectronicCodeBook<KEY_LEN,
16> leaves KEY_LEN unconstrained on Mode<P> (E0207) and the per-P form needs a const expression;
they collapse into cipher once KEY_LEN leaves the cipher traits.

key_tests pins that create_encryptor(rng) produces the same nonce, ciphertext and tag as
do_encrypt_init_rng from the same material and RNG, that the role-trait one-shots match the
cipher traits' key-taking one-shots byte for byte and zeroize on a failed tag, interoperation of
the two paths at every key length, the GCM<12> key type, generate (including refusal of an RNG
below the key strength), the construction-time errors, and Clone; a compile_fail doctest shows a
wrong-length nonce refused by the type. aes_gcm_packet_benches compares the two paths over
16 KiB packets, with the OS-DRBG-per-packet arms now explicit on both sides.

Assisted-by: Claude:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… buffer (prototype)

HmacKey<H> is the key object: H after absorbing K XOR ipad and H after absorbing K XOR opad,
the precomputation RFC 2104 Section 4 describes ("can be precomputed only once at the time of
generation of the key K, or before its first use", saving "the application of the compression
function of H on two B-byte blocks" per message). It is built once from key material of any
length through FromAnyKeyMaterial (from_key_material, from_key_material_allow_weak, and
generate(rng), which draws the hash's output length), implements MacGenerationKey and
MacVerificationKey, and holds no key bytes. create_generator() and create_verifier() clone the
two states into a fresh HMAC<H>, so any number of workers can be in flight from one key.

HMAC<H> becomes { hasher, outer }: the KEY_BUF_LEN parameter, the key buffer and the key length
go, and the aliases in sha2, sha3 and sm3 are HMAC<SHA256> and so on. MAC::new and
new_allow_weak_key build an HmacKey and take one worker from it, so every existing caller and
vector suite runs through the new pads unchanged; do_final finishes the inner hash into the
stored outer state, and from_suspended rebuilds the outer state from the re-supplied key with
the state bytes unchanged. The padded key block is built in a Secret scratch rather than a vec,
which removes two of the three heap allocations per message the old path made.

Sizes (size_of, x86-64): HMAC<SHA256> and HmacKey<SHA256> 224 (worker was 184), the SHA-512
family 416 (was 344), the SHA-3 family 880 for all four (was 520 to 592); HKDF_SHA256 256 (was
296), HKDF_SHA384/512 448 (was 392). The memory tables in the alias modules and sha2::hkdf are
updated to these figures. hmac_key_tests pins key-against-constructor agreement for short,
block-length and over-length keys on SHA-256, SHA-512 and SHA3-256, independent workers from one
key, the one-shots, the key-type and strength policy, generate, suspend/resume and Clone.

Assisted-by: Claude:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Expand builds HmacKey<H> from the PRK once and calls create_generator() for each T(i), which is
the many-messages-one-key case the pad cache is for; do_extract_init builds the salt key with
from_key_material_allow_weak, as it used new_allow_weak_key before. Output bytes and the
suspended-state layout are unchanged, and the HKDF vector suites pass as before.

Assisted-by: Claude:claude-fable-5-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e keys (prototype)

AeadEncryptMode and AeadDecryptMode gain encrypt_detached_in_place(state, rng, aad, buf) and
decrypt_detached_in_place(state, nonce, aad, buf, tag), provided over encrypt_init /
decrypt_init; AeadEncryptionKey and AeadDecryptionKey gain the same pair over create_encryptor /
create_decryptor. The provided bodies (in_place.rs) copy the message through a 64-byte stack
buffer and encrypt each chunk back into the bytes it came from, which needs no second buffer the
size of the message because a streaming worker never emits more than it has consumed. The
decryptor zeroizes the buffer on any error, as the _out forms do.

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
…ed (prototype)

CCM<NONCE_LEN, TAG_LEN, AAD_LEN, DATA_LEN> is the M of a mode-bound CCM key; the frame is on the
mode because the streaming worker a key produces is the fixed-frame CcmEncryptor / CcmDecryptor.
CcmPacket::from_perm and random_nonce, and CcmEncryptor::from_perm / CcmDecryptor::from_perm,
build from an already-expanded schedule; the existing do_*_init forms now go through the latter.

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
…cket (prototype)

The CCM Mode / AeadEncryptMode / AeadDecryptMode impls for the three AES permutations, per key
length as for GCM. The role impls on AESKey forward the in-place one-shots to the mode, and the
CCM impls override them to run a CcmPacket committed to the message's own length, so a CCM key
takes any length in place while its streaming worker and the provided _out one-shots over it
stay fixed-frame. key_tests covers both paths at lengths around the chunk size, the fixed-frame
refusal, agreement with the packet API, and zeroization on a bad tag.

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
…e mode traits (prototype)

in_place.rs becomes provided.rs and gains encrypt_inline / encrypt_detached over an
already-built worker; the role keys' two _out one-shots call them. No behaviour change: this is
so the supplied-nonce forms that follow can share the bodies with the drawing forms.

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
…(prototype)

AeadEncryptMode gains encrypt_init_nonce(state, nonce), infallible as decrypt_init is, and a
provided encrypt_detached_in_place_nonce over it. AeadEncryptionKey gains create_encryptor_nonce
and the _nonce twins of its one-shots (encrypt_with_aad_nonce[_out], encrypt_detached_nonce[_out],
encrypt_detached_in_place_nonce), provided over the shared bodies; the trait docs say the nonce
is drawn by default and what a caller of a _nonce form takes on.

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
…CCM encryptors (prototype)

Gcm::from_key_state_nonce and CcmEncryptor::from_perm_nonce, the constructors a permutation
crate's encrypt_init_nonce impl calls; the drawing from_perm now goes through the CCM one.

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
… the in-place nonce form (prototype)

AESKey forwards create_encryptor_nonce and encrypt_detached_in_place_nonce to the mode. The CCM
impls run the in-place nonce form through CcmPacket at any A.1 nonce length, so a key such as
AES128Key<CCM<8, 8, 0, 0>> is usable through the supplied-nonce forms while the drawing forms,
which carry the 12-byte floor, are never instantiated for it. key_tests checks every _nonce
form against the drawing form's bytes on GCM and CCM, and the short-nonce key against the
packet API.

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
The command builds an AES128Key<CCM<N, T, 0, 0>> (and the 192/256 forms) and runs
encrypt_detached_in_place_nonce / decrypt_detached_in_place on the input it read, so it no
longer names CcmPacket. A small CcmKeys trait with a generic associated type lets `run` stay
generic over the key length while choosing the nonce and tag lengths at run time. The AAD is
read whole now, since the one-shots take it as a slice; the declared-length streaming of a
regular --aad-file goes, along with its help text. The q-limit message computes the limit
itself. test_aes_ccm.sh passes as before.

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
…_LEN on the mode type (prototype)

Mode<P> for CCM needs no KEY_LEN (the key state is the schedule itself), and the two AEAD mode
traits carry it, so the impls live here once rather than per key length in each permutation
crate. The in-place one-shot overrides move with them. CCM::MAX_PAYLOAD_LEN is the A.1 limit
for the nonce length, for callers that no longer see the packet type.

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
…rototype)

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
…fixed-frame stream (prototype)

The structural tests no longer name CcmPacket: the one-shots are CCM::encrypt_detached_in_place_nonce
/ decrypt_detached_in_place over the toy permutation, and the chunked and frame-holding checks run
through CcmEncryptor / CcmDecryptor at the message's length. The toys derive Clone, which the mode
impls ask of a key state. The suspend tests are untouched for now.

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
… the trait pair (prototype)

sp800_38c_tests, ccm_bc-test-data, ccm_wycheproof, key_tests and the criterion benches no longer
name CcmPacket. The Appendix C vectors go through CCM::encrypt_detached_in_place_nonce /
decrypt_detached_in_place and the pair's any-length inline one-shots, with the chunking sweep on
the pair's streams at each example's frame; the progressive-AAD check feeds the pair's
do_update_aad in pieces, with C.4's 64 KiB AAD as its capacity. The test of new_with_lengths'
declared-AAD accounting goes with that constructor, which has no public counterpart; the pair's
own AAD and frame bounds are already pinned. The memory table keeps the pair's sizes.

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
… one-shot (prototype)

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
…/ CcmDecryptor (prototype)

The packet type is now reachable only through the pair and the CCM mode's in-place one-shots,
so its SuspendableKeyed impl moves to the pair: the adapter's state is the packet's plus the
held-back AAD, its length and the phase flag, and the decryptor adds the held-back tag. A
resumed state must owe no more than the frame, and one that says the header is not yet
formatted must have absorbed nothing. new_with_lengths and do_update_aad on the packet had no
caller left and go. The module docs and the modes overview are rewritten over the pair, the
A.1 shape compile_fail cases move to CcmEncryptor, and the suspend tests run over the pair,
with the AAD buffer, phase flag and tag bounds pinned alongside the packet's.

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
…de-bound key (prototype)

The module docs describe one family, AES_CCM_128 and friends, with the supplied-nonce and
in-place examples; the memory tables are re-measured (key schedule, pair sizes, massif peaks
for the in-place, streaming and one-shot paths). The alg-name and suspend tests use the pair.

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
…ototype)

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
… lengths up front (prototype)

AeadEncryptWorker / AeadDecryptWorker are the cipher traits' streaming methods without their
constructors; the role and mode traits bound their workers on them, so a worker a key produces
need not implement a constructor it cannot have. AeadPacketEncryptMode / AeadPacketDecryptMode
and AeadPacketEncryptionKey / AeadPacketDecryptionKey are the forms whose constructors take the
AAD and payload lengths, for a mode that must commit to them before its first byte; their
one-shots have the lengths in hand and are provided over the shared bodies, which now include
the decrypting _out forms and the inline-layout length check.

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
…/ CcmPacketDecryptor as its workers (prototype)

A CCM key's constructors now take the AAD and payload lengths, so the frame leaves the mode
type and the workers hold nothing back: the encryptor is the packet state alone and the
decryptor adds only the inline tag it holds past the declared payload. The in-place one-shots
run the packet directly. Gcm's workers forward to the cipher traits, named by path since the
method names coincide. The declared-AAD update returns to the packet, crate-private.

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
…e (prototype)

AES128Key<CCM<12, 16>> and friends implement AeadPacketEncryptionKey / AeadPacketDecryptionKey
by forwarding to the mode. ccm_key_tests covers every form at any length against the pair, the
streams holding to the declared lengths, the up-front refusals, zeroization and the short-nonce
key, with the worker sizes the docs quote pinned. key_tests keeps GCM, with its generic helpers
naming the worker traits by path.

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
…ype)

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
…th of the caller's key, so a MAC keyed by a block cipher can pass it (prototype)

Ported from feature/cmac-gmac 0d004a9e: the weak key takes the caller's key length when it fits the buffer, raised to the shortest length that can carry the lowered strength; keys that are empty or longer than 64 bytes keep the old per-strength lengths.

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
… MAC role keys forward to (prototype)

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
…AC_ALG_NAME on ModeNames (prototype)

Ported from feature/cmac-gmac 572c4d09 and reshaped for the key objects: the keyed permutation
and the two subkeys are a CmacKeyState, derived once per key, and a Cmac worker is built from
it by from_key_state or, for one message, by MAC::new from the material as before. The mode
reports ModeNames::CMAC_ALG_NAME like the others; the AES permutations' and the benches'
ModeNames impls gain the constant here so that the tree builds at this commit. The known-answer
suites move to the aes crate, since cipher tests only over toys.

Assisted-by: Claude:claude-mythos-5-1
Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>
@dghgit
dghgit force-pushed the feature/key-rework branch from 69e24ba to a615121 Compare October 24, 2026 10:19
@dghgit dghgit mentioned this pull request Oct 31, 2026
1 task
@dghgit
dghgit requested a review from mwcw October 31, 2026 03:34
…buffers, and the CLI's optional info and key-file precedence

Squash of fix/hkdf-review. From local/hkdf_review_44682f23.md and local/hkdf_fixes_plan.md
(phases 2-7, the parts a33d9d5 had not already done).

hkdf:
- A salt of any key type but Unknown keys the extract. do_extract_init handed the salt to
  HmacKey, which takes only MACKey or Zeroized material, so a CryptographicRandom or Seed
  salt failed with InvalidKeyType against docs that promised any KeyType. The salt and the
  PRK are now re-typed through one helper, mac_key_copy, sized by HMAC's
  LARGEST_HASHER_BLOCK_LEN; bytes longer than the hash block are hashed first, as HMAC
  does with such a key (RFC 2104 Section 3), so the output is unchanged. KeyType::Unknown
  stays refused: promoting bytes of unknown provenance is a hazardous operation.
  test_entropy_tracking asserted the old rejection of a random-typed salt and now asserts
  acceptance with an Unknown output.
- derive_key_from_multiple_out ran a second entropy tracker and set the output strength
  unconditionally, which set_security_strength rejects on an Unknown key, so a
  partial-entropy input errored where derive_key returned an Unknown key. The keys go
  through do_extract_update_key and the object's own tracker types the PRK and output.
- expand_out requires a PRK of at least HashLen (RFC 5869 Section 2.3) and accepts one of
  any length above it; 16- and 31-byte PRKs were accepted and 65-byte ones refused before.
  T(i) lives in a Secret, the HMAC key is built before any byte of okm is written, and a
  failure after that zeroizes the partial output.
- Citations: "RFC5896" is RFC 5869; the default-salt note attributed "HashLen zeros" to
  SP 800-56Cr2, where Sections 4.1 and 5.1 say one hash input block of zeros and HashLen
  zeros is RFC 5869 Section 2.2, with the comment now saying why the empty salt keys HMAC
  the same as either; the seeding threshold is stated as library policy, with SP 800-133r2
  Section 6.3 note (g) as the nearest standard text.

hmac:
- LARGEST_HASHER_BLOCK_LEN is public, for the copies above.
- The inner digest is finished into a Secret array rather than a Vec: key-derived state
  no longer sits in freed heap memory, and the finalisation path makes no allocation.
  Output is unchanged; no test can observe the zeroization.

cli:
- hkdf-sha*: --additional-input is optional and defaults to empty (RFC 5869 Section 2.3),
  so Appendix A.3 runs as written; the salt bound is the 1024-byte buffer the command
  holds, not a 128-byte figure described as 1024 bits; the help names the RFC's steps.
- hmac-*: the key comes from --key-file when both it and --key are given, as the help text
  says and the other loaders do.

Tests: derive_key_from_multiple agrees with derive_key on a 16-byte key; extract gives one
PRK for a salt typed MACKey, CryptographicRandom, Seed or SymmetricCipherKey, and a 100-byte
salt gives the PRK its hash gives; PRKs of 31 and 63 bytes are refused and 32 and 64
accepted; a refused expand leaves a pre-filled buffer as it was; PRKs of 64 to 1000 bytes
expand to T(1) || T(2) as computed with HmacKey directly; shell tests pin RFC 5869 A.3 and
both file-over-hex precedences. cargo mutants on crypto/hkdf/src/lib.rs: 153 mutants, 107
caught, 45 unviable, 1 missed (a HkdfStates::try_from arm, untouched here).

Assisted-by: Claude:claude-mythos-5-1
…nto a new HashOneShot: Hash + Default trait with provided bodies, called as SHA256::hash(msg) with no instance

Hash carried hash(self, data) and hash_out(self, data, output), one-shots that needed an
instance to call them -- SHA3_256::new().hash(&data) -- and that every one of the 19
implementors wrote out by hand (do_update then do_final, or an equivalent squeeze). As
by-value receivers they could never have been called through a trait object either.

HashOneShot: Hash + Default now holds them as receiver-less provided functions, so the
one-shot is SHA256::hash(msg) and an implementation is `impl HashOneShot for X {}`. It is
implemented where Default is the function itself: SHA-2 (incl. SHA512t<T>), SHA-3, SHAKE,
SM3, Ascon-Hash256 and Ascon-XOF128. The parameterised functions -- cSHAKE, KMAC, TupleHash,
ParallelHash (fixed and XOF forms) and Ascon-CXOF128 -- and the HashFactory / XOFFactory,
where an uncustomised default would be a different function under the same name, keep an
inherent instance method hash(self, data) / hash_out(self, data, out) with the streaming
body, so existing call sites on them are unchanged. Hash itself is left with only
receiver-taking methods, one step towards a dyn-compatible Hash.

Bounds that were `Hash + Default` because they called the one-shot on a fresh instance are
now `HashOneShot`: HMAC / HmacKey / HMACParams, HKDF, MLDSAParams::PreHash in both ML-DSA
crates, the hash_df / hashgen helpers of Hash_DRBG, and the test framework's test_hash and
the pre-hash signature tests. Call sites of the form X::new().hash(..) / X::default().hash(..)
become X::hash(..) throughout crypto/, the crate docs and INTRODUCTION.md. The framework's
output-buffer and XOF checks no longer exercise a one-shot (there is none on a parameterised
type); the ParallelHash sample-value tests take the inherent one-shots as closures so they
stay covered. No digest, suspended-state byte or CLI output changes.

Assisted-by: Claude:claude-mythos-5-1
…and a shared crate for what the two implementations have in common

Squash of fix/mldsa-drop-sha256-pairings (29 commits). From local/mldsa_review_44682f23.md,
local/mldsa-lowmemory_review_44682f23.md and local/mldsa_shared_crate_plan.md.
The branch was based on 4576915, before HashOneShot (0e391c4); the squash merge conflicted
only on the trait imports of the two params.rs and hash_mldsa.rs files, resolved to the
HashOneShot bound on PreHash with the branch's new mldsa-common imports.

Both crates:
- HashML-DSA-65 and -87 with SHA-256 are gone. FIPS 204 Section 5.4 requires the pre-hash to
  give "at least lambda bits of classical security strength against both collision and second
  preimage attacks" (footnote 6: a digest of at least 2 lambda bits); SHA-256's 128 bits meet
  lambda = 128 for ML-DSA-44 only. The two pairings had no OID, no CLI subcommand and no
  known-answer vector, and weaker_of capped their strength at 128 bits, which hid the gap.
  The 44 pairing stays and says why it is the only SHA-256 one.
- do_verify_final on a context built by do_sign_init returned GenericError("No public key set
  on streaming verifier.") from MLDSA in the main crate and panicked on an assert in the other
  three places (HashMLDSA in both crates, MLDSA in lowmemory); all four return the error.
- Every signing _out zeroes its output before any check; the Option handling is if-let and
  let-else rather than is_some/unwrap.
- decompose returned Algorithm 36's (r1, r0) under the names (r0, r1); the names match the
  algorithm, and the "by the powers of someone much more clever than me" lines are replaced by
  the equivalence to the reference implementation's rounding.c (pq-crystals/dilithium): the
  fixed-point rounding, the wrap of the top value to 0 as steps 3-4, the final subtraction of q
  as step 2's mod± and step 5's -1; use_hint is Algorithm 40 lines 3-4 with mod m as the wrap
  at the ends. make_hint takes the reference implementation's (a0, a1), now named (low, high),
  with the mapping to Algorithm 39 stated. coeff_from_half_byte's constant-time TODO is
  closed with the argument (the three-way range split is independent of the coefficient it
  produces; the rejection branch is the one every rejection sampler has).
- HASH_ML_DSA_65/87_WITH_SHA512_NAME are _with_, as the 44 constant and the type names.
- Docs: the algorithm-name constants are documented; the headings are "Usage Examples" and
  "Memory Usage"; "hedged" is the fresh-rnd variant of FIPS 204 Section 3.4, not the
  deterministic one; the Security Considerations say what is and is not zeroized (FIPS 204
  Section 3.6.3); ML-KEM leftovers ("encaps", "MLKEMPublicKey") and the first-person and
  "my laptop" notes go; the lowmemory crate's stale NTT and inverse-NTT comments are
  corrected and the Dilithium Section 5.1 shortcut note says the code applies it.
- Tests: the 568-line commented-out bc-java harness port leaves each mldsa_tests.rs; the
  extensive framework run is an #[ignore] test with the current framework signature; the
  vector suites take clippy's suggestions. Both crates are clippy clean in src, tests and
  benches.

mldsa:
- sign_ph_with_expanded_key discarded the result of its _out call and returned Ok, so a
  256-byte ctx gave an all-zero signature as a success; the result is propagated.
- The merged keygen-and-sign path (sign_mu_deterministic_from_seed_out, 295 lines of
  Algorithms 6 and 7 fused, recomputing NTT(s1), A_hat * NTT(s1), NTT(s2) and t0 on every
  rejection pass) is keygen_internal then sign_internal, and sign_init_from_seed keeps the
  expanded key rather than running key generation at init and again at final. Its claimed
  memory and 25% speed advantages were never measured and the sizes do not support them;
  Sign_from_seed bench before -> after: ML-DSA-44 308 -> 323 us, -65 448 -> 444 us,
  -87 645 -> 679 us. The module docs say signing from a seed costs a key generation.
- sigEncode's inlined HintBitPack wrote y[omega + i] inside the inner loop, 256 times per
  row; once per row, as Algorithm 20 line 10 reads.

mldsa-lowmemory:
- keygen_from_seed_and_encoded took &[u8; SK_LEN], and SK_LEN is 32 here, so it compared the
  seed with itself. It takes the FULL_SK_LEN Algorithm 24 encoding and compares
  encode_full_sk() against it in constant time.
- The seed key holds tr = H(pk, 64), computed once when the key is built (176 to 240 bytes,
  table updated), instead of deriving the public key on every tr() call: Signer::sign,
  do_sign_init, HashML-DSA's mu, encode_full_sk and Display each paid a key generation.
  Sign bench before -> after: ML-DSA-44 920 -> 844 us, -65 1.59 -> 1.47 ms,
  -87 2.72 -> 2.51 ms.
- verify_mu computes NTT(c) once rather than once per row. Verify bench, 1000 signatures:
  ML-DSA-44 84.4 -> 81.7 ms, -65 147 -> 141 ms, -87 254 -> 245 ms. The per-row unpacking
  of z stays, with the comment saying why (holding z_hat is l KiB).
- sign_mu_deterministic_from_seed called SK::from_keymaterial twice and dropped the first
  result; the non-_out form delegates to the _out form.
- from_bytes documents that it tags any 32 bytes as a full-strength Seed and that
  keygen_from_seed on typed KeyMaterial is the checked path; sk_decode's unwrap is justified
  at the site.
- A cross-implementation test (tests/cross_check_tests.rs, bouncycastle-mldsa as a
  dev-dependency) pins that one seed gives the same pkEncode and skEncode bytes in both
  crates, the same mu and rnd the same signature bytes, and that each verifies the other's.
- The crate docs name what produced the memory tables and that the peak figures predate the
  tr cache, and say why this crate has no CLI subcommand.

mldsa-common:
- A new crate, bouncycastle-mldsa-common, for what the two crates carried as identical copies
  with no shared source. Everything in it is #[doc(hidden)] and it is not re-exported from the
  umbrella crate: the FIPS 204 constants and algorithm names; MLDSAParamsBase with the Table 1
  and Table 2 values, OIDs, strength, derived constants and the three marker types, pinned by
  the Table 1/2 tests once; Polynomial with the NTT, inverse NTT, reductions, ZETAS and norm
  check; the Section 7 samplers, per-coordinate bit packing and unpacking, Power2Round,
  Decompose, HighBits, LowBits, MakeHint and UseHint; MuBuilder, plus compute_mu_ph for the
  HashML-DSA M' that each crate built inline in two places.
- Each crate's MLDSAParams extends the base with only what it needs (the vector and matrix
  types; the packed-vector buffers and the 32-byte SK_LEN) and is sealed through its
  supertrait. Where the crates had one operation in two shapes, common has both, the in-place
  or caller-buffer form as the implementation and the value form wrapping it, with the value
  forms #[must_use]: high_bits / high_bits_inplace, low_bits / low_bits_inplace,
  bitpack_gamma1 / bitpack_gamma1_out, bit_unpack_eta / bit_unpack_eta_out, multiply_ntt as
  method and free function. coeff_from_three_bytes and coeff_from_half_byte return Option.
  The shared constants and MuBuilder reach each crate's public API and docs through
  #[doc(inline)] pub use, so no public item of either crate changes name, path or shape.
- bouncycastle-mldsa keeps matrix.rs, the vector-level aux functions, its keys, MLDSA and
  HashMLDSA; bouncycastle-mldsa-lowmemory keeps low_memory_helpers.rs, the row-wise signature
  unpacking, its seed key, MLDSA and HashMLDSA. Net over the three crates: 1,718 lines added,
  3,052 removed. A shared HashMLDSA and shared key traits wait for the key-object rework.

Tests: both crates' ACVP, Wycheproof, framework and key suites, the cross-check test and the
workspace doctests pass; rustdoc with warnings denied is clean for all three crates.
cargo mutants on crypto/mldsa-common, tested by the three crates' suites: 1807 mutants, 1768
caught, 0 missed, 30 unviable, 9 timeouts. A first pass with the ACVP and Wycheproof suites
skipped left 67 missed; the --iterate pass with the full suites caught all 67. The 9 timeouts
are rejection-sampler mutants that never terminate (coeff_from_three_bytes / coeff_from_half_byte
returning None or with a flipped comparison, += to *= on the fill index in rej_ntt_poly and
rej_bounded_poly), so they are kills by hang rather than survivors.

Assisted-by: Claude:claude-mythos-5-1
…thm's constants; Hash, KDF and MAC take it as a supertrait and lose their own max_security_strength(&self)

Squash of feature/173-algorithm-info (2 commits).

Hash, KDF and MAC each declared fn max_security_strength(&self), the same fact as
Algorithm::MAX_SECURITY_STRENGTH written out again by hand in 24 implementations (SHA-2 and
SHA-3 computed it from OUTPUT_LEN / 2, SHAKE from PARAMS::SIZE, KMAC stored it in a field, HMAC
read it off the hash instance), with nothing tying any of them to the constant. AlgorithmInfo
carries the two Algorithm constants as &self methods, alg_name() and max_security_strength(),
with a blanket impl for every Algorithm (first piece of the dyn sweep for issue #173,
local/dyn_sweep_plan_173.md step 2.1: a trait object can be asked what a constant cannot be).
With it the method is its blanket impl, so the three
declarations and all 24 bodies go, and the three traits take AlgorithmInfo as a supertrait
(MAC keeps Sized for now; it goes with the rest of the dyn sweep). Callers write the same
h.max_security_strength() with AlgorithmInfo in scope, and gain alg_name() beside it.

Two things fell out of the supertrait:

- Hash: AlgorithmInfo + Clone replaces Hash: Algorithm + Clone. HashFactory and XOFFactory
  implemented Algorithm only because Hash demanded it, with ALG_NAME = "TODO" and
  MAX_SECURITY_STRENGTH = None and a TODO comment asking for exactly this rework; the blanket
  impl would have reported those placeholders. Both now implement AlgorithmInfo directly,
  dispatching on the variant, as do KDFFactory and MACFactoryWorker, so a factory finally
  reports the name and strength of the algorithm it selected. Concrete hashes still implement
  Algorithm; generic code that reads H::ALG_NAME through a Hash bound did so via HashAlgParams,
  which is unchanged, so no bound outside hmac/hkdf moved.
- HKDF had no Algorithm impl and so no name. HMACParams gains HKDF_ALG_NAME beside
  HMAC_ALG_NAME, set from the existing HKDF_SHA256/384/512_NAME constants and new ones for the
  other eight hashes (HKDF-SHA224, HKDF-SHA512/224, HKDF-SHA512/256, HKDF-SHA3-*, HKDF-SM3,
  following the HMAC spelling), and HKDF<H: HMACParams> implements Algorithm with that name and
  the hash's strength. HKDF's and HmacKey/HMAC's hash bound is therefore HMACParams rather than
  HashOneShot (+ HashAlgParams); every hash that satisfied the old bound already implements
  HMACParams. HMAC's weak-key check compares against HASH::MAX_SECURITY_STRENGTH directly.

KMAC's strength field, read only by the deleted method, is removed from the key, the MAC and
the XOF structs; the key-strength check at construction keeps computing it. Tests: a core test asserts the blanket impl through &dyn AlgorithmInfo; the factory
tests assert alg_name() and max_security_strength() for every variant of all four factories;
hkdf asserts HKDF's ALG_NAME and strength; the two-view asserts in the sha3 tests collapse to
one. The sha2 and sm3 hmac modules carry the new name constants.

Assisted-by: Claude:claude-mythos-5-1
@hubot
hubot force-pushed the feature/key-rework branch from 711a571 to 9aa1631 Compare November 2, 2026 13:55
…a shared crate for what the two implementations have in common, and an expanded seed key

Squash of fix/mlkem-review (10 commits). From local/mlkem_review_44682f23.md and
local/mlkem-lowmemory_review_44682f23.md, both of which have an "Addressed" section.

mlkem:
- decaps and encaps no longer clone the key through the expanded path (decaps peak stack
  27,112 -> 20,920 B on massif); seed() builds the KeyMaterial type-first inside the hazardous
  block rather than re-tagging afterwards.
- The ML-DSA residue in docs and comments is corrected against FIPS 203 (aux_functions header,
  matrix.rs's "Algorithm 48" for the Section 2.4.7 product with MultiplyNTTs, pkDecode is
  Algorithm 14, pkEncode Algorithm 13); the commented-out reference code goes; clippy clean.
  The crate docs' peak-stack table is re-measured with massif: KeyGen 13,960 / 20,520 / 30,152,
  Encaps 16,424 / 27,256 / 38,840, Decaps 20,920 / 33,560 / 47,240 B.
- 565 lines of commented-out ML-DSA test code ported from bc-java and a commented-out KAT
  generator leave mlkem_tests.rs; the full bit-flipping framework run is an #[ignore] test.

mlkem-lowmemory:
- The seed key stores H(ek) (160 bytes, was 161 with the seed on disk 64), so decapsulation
  no longer derives the public key hash: 19.0 -> 13.5 / 32.7 -> 22.9 / 52.4 -> 36.3 ms per 256
  operations across the three sets, keygen 5-8% slower for the one extra hash; seed() no longer
  panics on an all-zero seed; keygen_from_seed_and_encoded takes the FULL_SK_LEN encoding it
  compares against; tests/cross_check_tests.rs pins byte-identical keys, ciphertexts and shared
  secrets against bouncycastle-mlkem.
- MLKEMSeedPrivateKeyExpanded holds the packed t-hat a seed key would otherwise derive inside
  every decapsulation (a key generation's worth of sampling), with
  MLKEMTrait::decaps_with_expanded_key: 928 / 1312 / 1696 bytes against the seed key's 160,
  decaps 13.6 -> 8.5 / 23.0 -> 13.9 / 36.4 -> 21.5 ms per 256 operations. It implements the
  seed key's traits, encodes to the same 64-byte seed, compares by seed and prints only H(ek).
- The same ML-DSA residue and clippy pass as the main crate; the crate docs' memory tables are
  re-measured (KeyGen 7.7 / 7.7 / 8.8, Encaps 10.8 / 12.6 / 14.8, Decaps 11.8 / 13.7 / 15.7 kB),
  which makes the headline "about half the peak memory" rather than a third, and the Security
  Considerations say that from_bytes takes any 64 bytes as a full-strength seed.

mlkem-common (new crate, bouncycastle-mlkem-common, every item #[doc(hidden)], not re-exported
from the umbrella): the FIPS 203 constants, length constants, the G/H/J aliases, the names and
the parameter sets as MLKEMParamsBase with the three marker types, which each crate's
MLKEMParams extends with what it alone needs; Polynomial with the NTT, inverse NTT, base-case
multiplication, message encoding and single-polynomial compression; the reductions and twiddle
tables; ByteEncode / ByteDecode; SampleNTT and SamplePolyCBD. Where the crates had one
operation in two shapes the common crate has both, the in-place form as the implementation.
ByteEncode keeps cond_sub_q after the floor-variant barrett_reduce, which returns q for a
negative multiple of q: the lowmemory crate's unreduced NTT outputs reach it, and the ACVP
keyGen suite caught the version that had dropped it. No public item of either crate changes
name, path or shape; net over the three crates the two implementations lose 1,650 lines and the
common crate is 980.

Tests: both crates' ACVP, Wycheproof, framework and key suites, the cross-check and the
workspace doctests pass; rustdoc with warnings denied is clean for all three crates. cargo
mutants with the full suites: mlkem-common 538 mutants, 510 caught, 24 missed, 3 unviable,
1 timeout (inv_ntt += to *=, a loop that never ends); mlkem 585, 407 caught, 27 missed, 151
unviable; mlkem-lowmemory 547, 395 caught, 31 missed, 121 unviable. The survivors that were
not | to ^ on disjoint bit fields got tests: Decompress_d's rounding (every rounding mutant
passed the ACVP decapsulation vectors because decryption tolerates a coefficient off by a
little) is checked against FIPS 203 eq. (4.7) and (4.8) for d = 4, 5, 10 and 11; a
decapsulation key whose first coefficient is q is refused by the range check itself; the
expanded seed key's encode, from_bytes, equality and display are exercised. A scoped re-run
over those functions leaves only the disjoint-bit-field mutants.

Assisted-by: Claude:claude-mythos-5-1
…s and benches

From local/sha2_review_44682f23.md (review of the crate at 44682f2):

- `from_suspended` accepted a state whose byte count and buffer offset
  disagreed modulo the block length, one that describes no message, and
  resumed it into a digest. Both families now refuse it with `InvalidData`,
  as `do_update` keeps the two congruent. The hand-written state in
  `sha512_length_field_carries_the_high_word` sets the offset to match, and a
  new test feeds each family a disagreeing state.
- The stale TODO about `<` and `<=` in SHA-256's `do_update` and a
  commented-out line in SHA-512's are gone; `block_bitlen` returns
  `BLOCK_LEN * 8` instead of a second copy of the number.
- The two parameter traits are public and sealed (`SHA256InitValue`,
  `SHA512InitValue`: `sealed::Sealed`), which lets the crate-wide
  `allow(private_bounds)` go.
- Docs: the `Usage Examples` heading, the message-length sentence (2^61 - 1
  bytes for SHA-224/256, the standard's limit, and 2^64 - 1 bytes for the
  SHA-512 family), the memory table row, the HMAC module's citations and
  typos, and the nine empty `///` on the `*_NAME` constants. The review's
  `HMAC<SHA512t<8>>` panic no longer exists as such: since the `HMACParams`
  bound, HMAC is instantiable only over the two approved truncations, which
  the `SHA512tParams` docs now say, pinned by a `compile_fail,E0277`
  doctest.
- Benches: the hash bench covers all six members; the HMAC bench labels lose
  a wrong `::hashes()`; the HKDF key-fed extract benches say what they
  measure. A `bench_sha2_mem_usage` stack harness joins the SHA-3 one and
  reproduces the documented 112/208, 108/204 and 224/416.

`max_security_strength()` had already gone with `AlgorithmInfo`.

Assisted-by: Claude:claude-mythos-5-1
… ParallelHash block size, FIPS 202 citations, sealed parameter traits, SP 800-185 benches

From local/sha3_review_44682f23.md (review of the crate at 44682f2):

- KMAC accepted any output length, including zero. SP 800-185 Sec 8.4.2
  says applications "shall not select an output length L that is less
  than 32 bits", so `new_with_params` refuses anything under the new
  `KMAC_MIN_OUTPUT_LEN` (4 bytes) with `InvalidLength`, and a suspended
  state naming a shorter length is `InvalidData`. The keyed cSHAKE both
  constructors start from is one private function, so KMACXOF no longer
  gets its sponge by asking KMAC for a zero-length instance.
  `do_final_out` zeroizes the buffer before the length check, as HMAC
  does, and `mac_out` no longer zero-fills a second time.
- ParallelHash's block size is a `NonZeroUsize`: Sec 6.2's `0 < B` is on
  the type, the constructor's assert and the panic test go, resume refuses
  a zero through the same type, and the CLI's check produces the value.
- `keccak.rs` cites FIPS 202 at each step: the round constants (Algorithm
  6 steps 2-3 via Algorithm 5), the lane mapping (Sec 3.1.2, lane (x, y)
  at `buf[x + 5y]`), theta, the fused rho/pi, chi and iota (Algorithms 1
  to 6, with the register offsets the unrolling introduces), and the
  permutation as KECCAK-p[1600, 24]. `KeccakSize::_288` is gone and
  `KeccakSize::rate_bits()` replaces the eight copies of `1600 - 2c`.
- `SHA3Params` and `SHAKEParams` are public and sealed, which lets the
  crate-wide `allow(private_bounds)` go; `KeccakSize` is re-exported with
  them. `CSHAKESqueezer`'s constant and tag are named after the type
  (`SUSPENDED_CSHAKE_SQUEEZER_STATE_LEN`, `CSHAKE_SQUEEZER_STATE_TAG`),
  as are the comments that still said `LengthBoundSqueezer`.
- SHAKE: `mix_key_internal` cannot fail, so it returns nothing; the
  derive-key truncation uses the nominal length instead of a match with
  an `unreachable!` arm; the "citation needed" thresholds in SHAKE's and
  SHA-3's KDF paths are stated as this library's own rule; the struct
  docs say that SHAKE does implement `Hash` and why FIPS 202 Sec 7 does
  not call it one. A no-op `set_key_len` and two stale TODOs in `sha3.rs`
  are gone.
- Docs: the crate's cSHAKE paragraph presents it as a usable XOF whose
  `N` is NIST's (Sec 3.2), the ParallelHash module no longer opens with
  the Recommendation's Sec 6.1 pasted in, the TupleHash example names the
  right function, and the four `HMAC_SHA3_*_NAME` constants have one-line
  docs.
- The SP 800-185 functions had no criterion bench; cSHAKE128, KMAC128,
  TupleHash128 and ParallelHash128 each get one over the 16 KiB input the
  SHA-3 benches use. The clippy warnings in `src/` (`right_encode`'s
  "unused until" note and `dead_code` allow, two index loops, a
  `mem::replace` that is `mem::take`, a test module before an impl) and
  in the test files are fixed; the crate's `--all-targets` clippy run is
  clean.

Assisted-by: Claude:claude-mythos-5-1
…its are dyn-compatible

Step 1 of the dyn sweep for issue #173 (local/dyn_sweep_plan_173.md,
Sec 2.2 to 2.4), after `AlgorithmInfo` and the Hash/KDF/MAC half of it
landed in 711a571. On this branch the worker traits still carry their
constructors and one-shots, so the fence list is longer than the plan's.

- `SymmetricCipherEncryptor`, `SymmetricCipherDecryptor`,
  `BlockCipherEncryptor`, `BlockCipherDecryptor` and `hazmat::KeyStream`
  are bounded on `AlgorithmInfo` instead of `Algorithm + Sized`, and
  `MAC` loses its `Sized`. The AEAD and stream traits inherit the change.
- Every method a trait object cannot carry gets `where Self: Sized`:
  the receiver-less constructors (`do_*_init`, `do_*_init_rng`,
  `do_encrypt_init_nonce`, `KeyStream::new`, `MAC::new`,
  `MAC::new_allow_weak_key`), the receiver-less one-shots (`encrypt*`,
  `decrypt*`, `encrypt_detached*`, `decrypt_detached*`,
  `encrypt_with_aad*`, `decrypt_with_aad*`, `*_inplace`), the four
  `*_out_len` functions, and the four provided `final` conveniences that
  take `self` by value (`do_*_final_out`, `do_*_final_detached`). What
  remains dispatchable is the streaming core: `do_update_aad`,
  `do_*_out_len(&self, ..)`, `do_*_out`, the required by-value finals,
  `alg_name()` and `max_security_strength()`.
- `PaddedBlockCipherEncryptor`/`Decryptor` and `StreamCipher` forward
  their inner worker's `ALG_NAME` and `MAX_SECURITY_STRENGTH`, so their
  impls bound the inner type on `Algorithm` as well; the test framework's
  key-strength checks read the constant the same way and take the same
  bound. Every mode and keystream in the workspace satisfies it, and the
  static names on the AES aliases are unchanged.
- `crypto/core/tests/dyn_compatible_tests.rs` takes each worker trait as
  `&mut dyn` and compiles; `crypto/aes/tests/dyn_worker_tests.rs` streams
  AES-CBC-PKCS7 and AES-GCM through `&mut dyn SymmetricCipherEncryptor` /
  `&mut dyn AEADCipherEncryptor` (AAD included), reads `alg_name()` and
  `max_security_strength()` through the object, finishes on the concrete
  value and decrypts with the one-shot.

`BlockPermutation` keeps `Algorithm + Sized`: it is the primitive the
modes are generic over, not a worker, and its constants are read through
that bound.

Assisted-by: Claude:claude-mythos-5-1
…nto the types

`Hash: AlgorithmInfo + Clone` became `Hash: AlgorithmInfo`. `Clone`
implies `Sized`, so the supertrait was the one thing keeping `Hash`, and
`XOF` with it, from being a trait object; every hash type in the library
derives `Clone` and goes on doing so, and forking a computation
mid-stream works exactly as before. The code that forks a hash says so
where it needs it: `HMACParams` is `HashOneShot + HashAlgParams + Clone`,
and the test framework's `test_hash` and `test_xof`, which check that a
clone and its original finish alike and diverge once fed different
input, are bounded on `+ Clone`. No other generic code cloned a hash.

The trait docs' "Forking is part of this trait" section becomes "Forking
is on the types, not the trait", and QUALITY_AND_STYLE's Architecture
section states the rule: `Clone` on the types, not on the `core` traits,
and for any trait meant to be held as `dyn` no `Clone`, `Default` or
`Sized` supertrait, no associated constant, and `where Self: Sized` on
receiver-less or provided by-value methods.

`crypto/core/tests/dyn_compatible_tests.rs` now also takes `&mut dyn
Hash` and `&mut dyn XOF<Squeezer = S>`. A `dyn Hash` can be fed and
queried but not finished, since the finals take `self` by value; that is
the same gap the planned boxed finals close for the cipher workers.

Assisted-by: Claude:claude-mythos-5-1
Mutation runs on the ML-DSA crates were taking a minute or more per
mutant: under `dev` the low-memory suite runs for 160 s because
unoptimised signing re-expands everything per step, and under `release`
each mutant pays a full rebuild plus the thin-LTO link of six test
binaries, about 80 s. `[profile.mutants]` inherits `release`, turns LTO
off and incremental codegen on (16 codegen units); `.cargo/mutants.toml`
selects it. Measured on `bouncycastle-mldsa-lowmemory` after a real
one-line change: 0.8 s rebuild, 3.8 s test run, so a mutant costs about
5 s instead of 60 to 100. CLAUDE.md's mutation-testing notes say so.

Assisted-by: Claude:claude-mythos-5-1
@dghgit dghgit mentioned this pull request Nov 2, 2026
… cli: signing and verifying move onto the keys

The signer/verifier init-removal plan (local/signer_verifier_init_removal_plan.md),
decisions D1 to D7, squashed from feature/signature-keys. The cipher and MAC traits
already had construction on the key objects; the signature traits now do too.

core
- `signature_key`: the role traits `SigningKey<A>`, `DeterministicSigningKey<A>`,
  `VerifyingKey<A>` and their pre-hash forms, generic over the pre-hash `H` at the
  method (`create_ph_signer::<SHA512>`), with the worker as a GAT borrowing the key
  (`type Signer<'k>`) and the RNG drawn at `create_signer(rng, ctx)`. The algorithm
  is the trait parameter, so one key encoding serves ML-DSA and HashML-DSA.
- `Signer` becomes `SignatureSigner` and, with `SignatureVerifier`, keeps only the
  streaming half; the workers' methods take the MAC worker's names (`do_update`,
  `do_final`, `do_verify_final`). The static `do_sign_init` / `do_verify_init`
  constructors and the static one-shots are gone.
- `core-test-framework`: the signature suite runs over the key role traits, with
  deterministic and pre-hash sub-suites.

rng
- `FixedSeedRNG` moves from the test framework to `bouncycastle-rng`, generic over
  what holds its bytes (`FixedSeedRNG<B: AsRef<[u8]>>`, `new(bytes)`), still cycling.
  KATs feed `rnd` through it and the ordinary `create_signer`; the `_rnd` methods
  are deleted.

mldsa, mldsa-lowmemory, mldsa-common
- `MLDSA<P>` and `HashMLDSA*_with_*` are algorithm markers; the keys do the work.
  The key types carry only their parameter set (`MLDSAPublicKey<P>`); the encoded
  lengths are reached through sealed per-length traits (`PublicKeyLen`,
  `PrivateKeyLen`, `SignatureLen`, `FullPrivateKeyLen`), one impl per parameter
  set, so `sk.sign(&mut rng, msg, ctx)` needs no annotation; `P::PkEncoded` /
  `P::SkEncoded` supply whole-encoding buffers where no const is in scope.
- The external-mu API is on the key traits: `pk.create_mu_builder(ctx)`,
  `pk.verify_mu(&mu, &sig)`, `sk.sign_mu(&mu, &mut rng)`,
  `sk.sign_mu_deterministic(&mu)`; `MuBuilder::from_tr` replaces `do_init` and
  `compute_mu_ph` is a free function in mldsa-common. The deterministic variant
  (rnd = 0) is reachable only through core's `DeterministicSigningKey`.
- The expanded keys borrow the key they expand (`MLDSAPublicKeyExpanded<'k, P>`,
  `key()`), so they hold the matrix and a reference rather than a second copy of
  the key: 16392 / 30728 / 57352 bytes for both directions, down from 20512 /
  36896 / 65568 (public) and 28848 / 48304 / 81072 (private). `A_hat`, `pk_decode`
  and `sk_decode` leave the public key traits.
- The streaming pre-hash verifier is tested on a bad signature, message and
  context; the low-memory seed-key docs no longer promise a merged
  decode-and-sign.

cli, mem_usage_benches
- The ML-DSA subcommands and the stack harness use the key API.

docs
- QUALITY_AND_STYLE and CLAUDE.md record the lessons (lengths the parameter set
  fixes do not go on the key type; algorithm-level operations go on the key
  trait; same-named methods from two traits are ambiguous; a cycling test RNG
  defeats a "two signatures differ" check); release notes updated.

Mutation testing (apollo, mutants profile): bouncycastle-mldsa 612 mutants, 265
caught, 4 missed, 7 timeouts, 336 unviable; bouncycastle-mldsa-lowmemory 624
mutants, 279 caught, 6 missed, 2 timeouts, 337 unviable. The unviable ones are
`Ok(Default::default())` key constructions that do not compile. The misses are
the `kappa > 1000 * k` iteration cap in `sign_internal`, unreachable with a valid
key, and the `&` to `|` mutations of `MLDSASeedPrivateKey::eq`, whose fields all
derive from the seed; the timeouts are `kappa +=` mutations that spin the
rejection loop, which are kills.

Assisted-by: Claude:claude-mythos-5-1
… caller's RNG

`MLDSATrait::keygen()` and `MLKEMTrait::keygen()` built an OS-seeded
`HashDRBG_SHA512` inside the call and delegated to `keygen_from_rng`;
the no-argument form goes and `keygen(rng: &mut dyn RNG)` is the one
key-generation entry point besides the seed forms, as the ECDSA and SM2
crates and everything else that draws randomness now have. The strength
check on the RNG is unchanged. The CLI's `keygen` actions pass
`HashDRBG_SHA512::new_from_os()`; tests, benches, the stack harnesses and
the doc examples pass an RNG, and the shared suites' `fn() -> ..` keygen
parameter is met with a closure. ML-KEM's `encaps()` convenience, which
also instantiates the default DRBG, is left for its own change.

Assisted-by: Claude:claude-mythos-5-1
…han one draw

The randomized signature suite seeds its FixedSeedRNG with a 64-byte counting
pattern. FixedSeedRNG emits its seed cyclically, so an algorithm that draws 64
bytes per signature, RSASSA-PSS with SHA-512's 64-byte salt, gets the same
bytes twice and the "two signatures of one message must differ" check fails
when the RSA crate is run through the suite.

The seed is now 256 bytes, longer than any one draw, and the lengths helper
takes `&mut dyn RNG` rather than naming the seed array's size. CLAUDE.md's
note on FixedSeedRNG says the pattern must outlast a draw.

Assisted-by: Claude:claude-mythos-5-1
dghgit added a commit that referenced this pull request Nov 3, 2026
…work: mode-bound symmetric keys (AESKey<P, M>, HmacKey<H>) producing their workers through role traits, GCM/CCM/CMAC/GMAC as mode types over a key state, FromKeyMaterial and the AEAD, packet and MAC role pairs in core; the signing and verifying key traits (SigningKey, DeterministicSigningKey, VerifyingKey and the pre-hash forms) with ML-DSA and HashML-DSA moved onto them; the ML-DSA and ML-KEM review rounds with the shared mldsa-common and mlkem-common crates and the expanded ML-KEM seed key; the SHA-2 and SHA-3 review rounds (KMAC output-length floor, ParallelHash block size, FIPS 202 citations, sealed parameter traits, suspended-state checks); HKDF review fixes; HashOneShot and AlgorithmInfo, Clone off the Hash trait, dyn-compatible cipher and MAC worker traits; keygen taking the caller's RNG; FixedSeedRNG in bouncycastle-rng; the mutants cargo profile. Created by David Hook. Assisted-by: Claude:claude-mythos-5-1
@dghgit

dghgit commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Merged by hand as squash commit.

@dghgit dghgit closed this Nov 3, 2026
dghgit added a commit that referenced this pull request Nov 3, 2026
…f the key object rework: the symmetric cipher and MAC traits lose their constructors and key-taking one-shots (do_encrypt_init, do_decrypt_init, the worker one-shots), construction is a key's create_* and the one-shots are provided on the role traits; every mode gets a key path (CBC, CFB, CFB8, CTR, ECB, CCM, CCM_Packet, GCM, GMAC, CMAC as mode types), with AsconAEAD128Key and KMAC128Key/KMAC256Key beside AESKey and HmacKey, and the MAC factory as a key enum handing out a worker enum; workers borrow the key through a GAT Encryptor<'k>, so creating a worker copies no schedule; BlockPermutation splits from ElectronicCodeBook; resume takes the key object; keys compare in constant time; the CLI, the test framework's key runners and the memory benches on keys; the fixes from the 2026-10-30 and 2026-10-31 reviews of PRs #170 and #172. Created by David Hook. Assisted-by: Claude:claude-mythos-5-1
@hubot
hubot deleted the feature/key-rework branch November 3, 2026 13:16
@hubot
hubot restored the feature/key-rework branch November 3, 2026 14:53
dghgit added a commit that referenced this pull request Nov 3, 2026
The notes left out the symmetric key-object rework (PRs #170 and
#172), the SP 800-185 functions, AES-CMAC and AES-GMAC, the generic
HMAC/HKDF with their new aliases (#122), the ML-DSA-87 reduction fix
(#137), the removed ML-DSA-65/87 SHA-256 pairings, the sealed
parameter traits (#117), keygen taking the caller's RNG, the
non_exhaustive errors (#81), the unsigned Condition (#63), the CLI,
the contributor docs and CI, and the people and issues behind them.
The signature-keys entry was a 25-line method listing. Rewritten with
one entry per change, grouped as the 0.1.2 notes are, with issue
numbers and thanks, and the ML-KEM low-memory decaps line fixed.

Assisted-by: Claude:claude-fable-5-1
@hubot
hubot deleted the feature/key-rework branch November 4, 2026 22:43
@hubot
hubot restored the feature/key-rework branch November 5, 2026 00:20
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