Skip to content

Feature/x25519 - #186

Open
ounsworth wants to merge 52 commits into
bcgit:release/0.1.4alphafrom
ounsworth:feature/x25519
Open

ounsworth wants to merge 52 commits into
bcgit:release/0.1.4alphafrom
ounsworth:feature/x25519

Conversation

@ounsworth

@ounsworth ounsworth commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

State at the current head (2026-11-14)

X25519 (RFC 7748) and DHKEM(X25519, HKDF-SHA256) (RFC 9180 Section 4.1) in the new crates bouncycastle-ed and bouncycastle-hpke, on the release's key agreement and KEM roles, with the CLI x25519 command and the stack harness. Merged up to release/0.1.4alpha f213c9e (the EC Curve trait, generic ECDSA and ECC CDH, and the KDF description). What follows is the record of the two rounds since Mike's original description below.

Onto the release's key agreement API (2026-11-12)

The branch's own KeyAgreement trait went in favour of the roles PR #193 put in core:

  • core, framework: the release's AgreementKey<Params, SS_LEN> on the private key, the KeyAgreement<PK, SS_LEN> worker with agree_out, KeyAgreementPrivateKey / KeyAgreementPublicKey, KeyAgreementError and the framework's agreement suite; KEMError::InvalidPublicKey from this branch stays, for the DHKEM's refusal of a small-order recipient key.
  • ed: X25519PrivateKey implements AgreementKey<(), 32> through X25519Agreement<'k>, writing K into the caller's buffer typed SharedSecret at 128 bits (zeroized first, left so on error) and refusing the all-zero K of a small-order peer key as AgreementFailed; and AgreementKey<KdfParams<'p, HkdfDescription<'s, SHA256>, LEN>, LEN> as X25519_HKDF_SHA256, K through HKDF-SHA256 with the caller's info (RFC 7748 Section 6.1's KDF over K and both public keys), the engine built from the description for each derivation as the release's ECC CDH pairing does. keygen(rng) takes the caller's RNG; keygen_from_seed stays. Gone, with no twin: the OS-seeded keygen(), keypair_consistency_check / consistency_check (sk.pk() == pk), X25519DerivedKey, and the three-party chaining example.
  • hpke: the labelled extract and expand run HMAC through its hazmat halves, as HKDF does since The MAC worker splits into MACGenerator and MACVerifier, with the tag length in the type #194.
  • cli: x25519 agree goes through the role and writes the raw shared secret for a KDF; check-consistency compares the derived public key.

The review round (2026-11-12, commits fc84c80, c9f5e71, 4c04b86, 88ec18d)

A combined review of a5b5faa found the mathematics sound (about 480,000 differential cases, all 518 Wycheproof vectors, RFC 7748's 1,000,000-iteration test, the twelve X25519 sets of RFC 9180 Appendix A) and raised one bug, eleven hardening items and twelve notes. All are addressed:

  • The bug, and the normalize domain: normalize is total, reduce(0).reduce(0).reduce(1).reduce(-1) with its domain argued in its doc; the debug_assert that fired on a valid agreement is gone, with the reproducing input as a regression test in debug and release.
  • Secrets on the stack: FieldElement25519 zeroizes itself on drop; scalar_mult_into writes into the caller's array, so K never travels in the public key type; keys are built in place, the scalar going straight into the key's own Secret and the RNG drawing into it; the DHKEM has encap_rng_out / decap_out / extract_and_expand_into with the by-value forms as wrappers; one HmacKey per expand. What the trait signatures' Result moves leave is disclosed in the crate docs' Security Considerations.
  • Tests: worker tests pinning the zeroize-first lines; crypto/hpke/tests/dhkem_x25519_tests.rs; direct field arithmetic tests (mul, sqr, mul_u32, inversion against x^(p-2), cswap, bounds, a normalize sweep); RFC 9180 A.2.1, A.2.2, A.7.1 and A.7.2 beside A.1; the framework's bit-flipping on.
  • hpke's generic DHKEM refuses instantiations it would get wrong: a const check that the KDF's hash is at least twice the KEM's strength, and DERIVE_KEY_PAIR_BY_EXPAND as a const on the trait with a const assert.
  • cli: the private key's encoding lives in a Secret; the help texts say how hex is read.
  • Docs: non-canonical public key encodings (RFC 9180 to the letter), the all-zero seed versus all-zero key bytes, the HMAC-on-zero-PRK and all-zero-output cases, the arithmetic doc errors, the i128 products on 32-bit targets, the derived key's strength at short lengths. ed_todo.md and the commented-out Ed25519 code are deleted (Ed25519 lands as reviewed code in its own PR).
  • Mutants: ed 411 (333 caught, 61 unviable, 3 timeouts, 14 missed, all equivalents after the longest-key test); hpke 55 (29 caught, 20 unviable, 6 missed, the private strength table, pinned by a unit test).
  • Stack table: re-measured on the roles after the wipe, as the deepest frame chain below each harness closure (x86-64, rustc 1.97.0-nightly): keygen 1784 B, from_bytes 1704 B, agree 1816 B, encaps 2792 B, decaps 2264 B.

Verified at the head: cargo test --workspace (2910 passed), cargo test --workspace --all-targets --no-run, rustdoc with warnings denied on ed and hpke, nightly rustfmt, cli/tests/test_x25519.sh 14 of 14.

Still open from below: the crate name (bouncycastle-ed).


Creating as a draft. This is very much not ready for review, but this is the easiest way for me to see the diff.


Plan

I'm planning to do this feature in stages:

  1. Implement x25519, including all the new traits, cli and factory stuff for KeyAgreement. (this PR)
  2. Add curve448 as a separate PR, stacked on 1.
  3. Add the signature modes Ed25519 and Ed448 as a separate PR stacked on 2.
  4. Integrate with David / Luis' ECDSA / ECDH work which I think will be a lot of de-duplicating the core elliptic curve and finite-field stuff into a bouncycastle-math crate, or something. (in theory, this could happen in parallel any time after 1)

How to review this PR

  1. Start with the crate docs -- build cargo doc -p bouncycastle-ed. The sample code is where an end-user would start, and probably also where a reviewer should start.
  2. new KeyAgreement traits in crypto/core/src/traits.rs.
  3. Then crypto/ed/src/curve25519.rs. This is the core math. Don't worry about all the commented-out code that's waiting for ed25519; just skip it and it'll be in a future PR.
  4. The public API is in crypto/ed/src/x25519.rs.
  5. As you'll see in the docs sample code in x25519.rs, actually using the KeyAgreement trait to obtain a shared symmetric key is surprisingly tricky. So I wanted to impl the KEM traits over a DHKEM construction to hide the details, but then the question becomes: which DHKEM construction to use? RFC9180 HPKE is the only standardized one, so I decided to bite the bullet and add a new crate crypto/hpke that provides the generic DHKEM construction, and expose it by impl'ing KEMEcapsulator and KEMDecapsulator on the X25519 struct.
  6. There's a file in there, crypto/ed/src/ed_todo.md that tracks all the stuff I've done. It's also tracking the x448, ed25519, and ed448 work that I want to add as separate PRs stacked on top of this one.

Open Questions

  1. bouncycastle-ed is not a great name for the crate, because technically only the signature variants ed25519 / ed448 use Twisted Edwards curves while x25519 / x448 are Montgomery curves. That said, there doesn't seem to be a generally-accepted overarching term for {x25519, x448, ed25519, ed448}. Maybe xcurves or edcurves? I don't want to split them up into separate crates just because there doesn't exist a good name.

TODO before merging

  • Delete crypto/ed/src/ed_todo.md.

@dghgit

dghgit commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

In bc-java we've found the formulation completely different - I don't think there's any common code between the Edwards and the Weierstrass form - the basis chosen is usually based on efficiency and simplicity as well, so while it's technically possible, it's unlikely to be worth it. I think we have a Weierstrass representation of Curve 25519 somewhere, but I think it's only their for compatibility (usage wise) with someone else's implementation...

@npajkovsky

Copy link
Copy Markdown
Collaborator

Can you do me a favor and add perf tests?

Comment thread crypto/core/src/traits.rs Outdated
type SharedSecretType: AsRef<[u8]>;

/// Perform a key agreement
fn key_agreement(pk: &PK, sk: SK) -> Result<Self::SharedSecretType, KeyAgreementError>;

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.

sk in this context may vary - in the sense it could be unavailable to the software layer and hardware only. I'd suggest only passing pk, and having the key agreement constructed with sk, and what ever other parameters might be required (for what we're doing it might be useful to be able to apply KDFs as well).

This might actually be a good place for using KeyMaterial as well - ideally we'd make someone do extra work if they want to use a 128 bit secure secret to create a 256 bit shared secret.

@ounsworth ounsworth Oct 30, 2026 •

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.

Yes, I know that you want to suggest a refactor like that, and we'll get to that, but for now I'm making the new KeyAgreement trait match the existing KEMEncapsulator trait.


Do you mean having key_agreement(..) -> KeyMaterial instead of returning a DH public key?
I tried this, but:

  1. A lot of things really do assume that the output of key_agreement is another public key. For example, the iterated tests in RFC 7748 section 5.2 work that way. Also, I think that group protocols (and maybe Signal / MLS) chain DH's together using the property that the output of DH is a usable public key.
  2. If you are going to hash an X25519PublicKey to get a usable shared KeyMaterial, then which KDF do you use? And what do you include in the KDF? RFC7748 suggests all three values: K, K_A, K_B. But since DH is symmetric, the crypto library doesn't know who is Alice and who is Bob, so it's going to get the order of K_A, K_B wrong.
  3. To do this robustly, you really need to wrap it in a standardized KEM, both to establish direction, to stardardize the inputs to the KDF, and which KDF to use. That's why I'm also adding a new crate crypto/hpke and using that to impl KEMEncryptor / KEMDecryptor on the X25519 object.

Does that make sense?

Also, I'm still actively building this. I only put up a PR so that I could see the diff. While I appreciate the enthusiasm, maybe everyone should hold off on review until I've finished thinking this through and it's ready to be reviewed.

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.

No, it doesn't really make sense.

Concerning the suggestion, yes, 6 times, now 7. We've already gotten to that re-factor so I don't really understand why something would be getting designed with the idea that we haven't - PR #170 does this and we even spent the afternoon reviewing it in Prague.

Also, ECCDH only returns one coordinate, at least as defined in FIPS, strictly speaking so does XDH, although the RFC does hint more is possible. It's possible to create multi-party agreement, but not the way that's suggested, it needs a phased approach so you can tell when the right number of parties have come through the door. For most things, such as HPKE, 2 party is enough. I would only start returning public keys if I was trying to implement multi-party up front, which is something, at the moment at least, I would definitely not suggest we do, but if I was we would need an API which reflected the need to provide phases - it's the reason the Java KeyAgreement API has phases.

Concerning KeyMaterial, I noted that seemed to be the first thing being produced in the doc code. The security strength was correct, but we'd be able to take the guess work out if we simply returned it.

@ounsworth

ounsworth commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Can you do me a favor and add perf tests?

Yes that's on my TODO list! That'll be in place before I mark this PR ready for review.


UPDATE: done

KeyGen/X25519          
                        time:   [1.2698 ms 1.2719 ms 1.2742 ms]
                        thrpt:  [25.113 Kelem/s 25.159 Kelem/s 25.200 Kelem/s]

KeyAgreement/X25519
                        time:   [1.2705 ms 1.2815 ms 1.2943 ms]
                        thrpt:  [24.724 Kelem/s 24.972 Kelem/s 25.188 Kelem/s]

Encaps/DHKEM(X25519, HKDF-SHA256)
                        time:   [2.6005 ms 2.6078 ms 2.6164 ms]
                        thrpt:  [12.231 Kelem/s 12.271 Kelem/s 12.305 Kelem/s]

Decaps/DHKEM(X25519, HKDF-SHA256)
                        time:   [1.3371 ms 1.3405 ms 1.3445 ms]
                        thrpt:  [23.800 Kelem/s 23.872 Kelem/s 23.933 Kelem/s]

Everything is about the same (basically a single x25519 scalar mult), except the KEM Encaps, which is a KeyGen + a KeyAgreement, so is about 1/2. The DHKEM's also include a round of HKDF to derive the output shared secret. Neat that that is not really noticeable next to the core x25519 op.

@ounsworth
ounsworth marked this pull request as ready for review October 31, 2026 01:01
@ounsworth ounsworth added this to the 0.1.4alpha milestone Oct 31, 2026
hpke's DHKEM reaches HMAC through HmacKey (the empty-salt extract via from_key_material_allow_weak, each expand block via from_key_material and create_generator), and DHKEMParams::KDFHash is bounded on HMACParams. FixedSeedRNG comes from bouncycastle-rng in the ed tests and bench. The x25519 CLI reads keys and ciphertexts through the 0.1.4 helpers, private keys as secrets, and so follows the --hex-input rule: a file or stdin is raw bytes unless --hex-input is given, where the old helper guessed. test_x25519.sh passes --hex-input wherever it feeds hex, and feeds binary where the private key is a binary file.

Assisted-by: Claude:claude-opus-5-5
@dghgit
dghgit changed the base branch from release/0.1.3alpha to release/0.1.4alpha November 7, 2026 21:54
dghgit added 13 commits November 8, 2026 16:08
…objects) into feature/x25519

One conflict, in crypto/core/src/errors.rs, where both sides added a From impl after RNGError's: KeyAgreementError's From<RNGError> and KEMError's From<KDFError> are both kept. The port of X25519's DHKEM onto the new KEM roles follows in the next commit.

Assisted-by: Claude:claude-opus-5-5
PR bcgit#192 replaced the static KEMEncapsulator / KEMDecapsulator traits with the EncapsulatingKey / DecapsulatingKey roles and their borrowing workers. X25519PublicKey and X25519PrivateKey now implement the roles over (), handing out X25519Encapsulator / X25519Decapsulator, which call hpke's DHKEM encap_rng and decap as before; X25519 stays the algorithm marker, now a KEMAlgorithm, so the reported name is unchanged. The worker _out forms zeroize their buffers first. Dropped with the old traits: the OS-DRBG encaps(pk) (callers pass the RNG) and the slice decaps with KEMError::LengthError (the ciphertext is a [u8; 32]). The DHKEM tests, the criterion bench, the x25519 CLI command and its stack harness follow; the crate's KEM example and the hpke docs name the roles; the KeyAgreement example passes HKDF's new output length.

Assisted-by: Claude:claude-opus-5-5
… plus the MAC rework, the supplied nonce, the BLAKE hashes, two-party key agreement and ECC CDH with the X9.63 and concatenation KDFs, and the post-bcgit#193 cleanups

Conflicts: core's traits take the release's agreement traits (AgreementKey on the private key,
the KeyAgreement worker with agree_out, and the two key-encoding traits) in place of this
branch's static KeyAgreement trait and its own key traits; core's errors keep the release's
KeyAgreementError (this branch's twin goes; its InvalidPublicKey on KEMError stays, the
DHKEM's refusal of a small-order recipient key); the test framework takes the release's
agreement suite in place of this branch's; CLAUDE.md takes the release's trait list; and the
stack-bench manifest keeps the X25519 bench beside the BLAKE2 and BLAKE3 ones. The ed crate,
its tests and benches, the hpke crate and the x25519 CLI command still name the old trait at
this commit and do not build; the next commits port them.
…pairing

The branch's own static KeyAgreement trait and its key traits went with the merge of
release/0.1.4alpha; X25519 now takes the shape ECC CDH has. X25519PrivateKey implements
AgreementKey over () through the X25519Agreement worker, whose agree_out writes K into the
caller's buffer typed SharedSecret at 128 bits, zeroized first and left so on the error path,
and refuses the all-zero K of a small-order peer key as AgreementFailed (RFC 7748 Section 6.1's
optional check, always made, in constant time). The same key implements the role a second
time over KdfParams<HKDF_SHA256, LEN> as X25519_HKDF_SHA256, K through HKDF-SHA256 with the
caller's info, which is the KDF Section 6.1 asks for over K and both public keys, so a caller
no longer assembles that by hand; the DHKEM's dh runs through the same primitive. keygen takes
the caller's RNG, as the other key generators do, and keygen_from_seed keeps its name; the
OS-seeded keygen, keypair_consistency_check and consistency_check (a public key's equality
with pk() says the same) and X25519DerivedKey (K is a KeyMaterial now) are gone, and a bad
seed is reported through KeyMaterialError rather than a variant of the branch's own. The
module docs, the agreement, key, Wycheproof and DHKEM suites, the criterion benches and the
crate manifest (rng is a dev-dependency only) follow: the framework's agreement suite runs
over () and over the HKDF pairing, the pairing is checked against HKDF run by hand over the
raw K, and the all-zero case checks the buffer is left zeroized.

Assisted-by: Claude:claude-mythos-5-1
After the MAC rework (PR bcgit#194), HmacKey's create_generator is a role bound on a const tag
length, which a KDF generic over its hash does not have; the labelled extract and expand run
HMAC through inner_state and finish_out, as HKDF's expand loop does. The early return with ?
on a plain error is a plain return.

Assisted-by: Claude:claude-mythos-5-1
keygen draws from the OS-seeded DRBG through the crate's keygen, check-consistency compares
the private key's public key with the one given, agree writes the raw shared secret from
AgreementKey::agree for a KDF, and the DHKEM arms are unchanged; the shell suite expects
AgreementFailed for a small-order peer key.

Assisted-by: Claude:claude-mythos-5-1
Assisted-by: Claude:claude-mythos-5-1
…generator per curve in bouncycastle-ec's hazmat, which the ecdsa and eccdh keygens and derive_pks now wrap) into feature/x25519
…e built in place, and the field is tested directly

Round one of the PR bcgit#186 review (B1, H1 to H3, H6 to H9, N2 to N4, N6, N7):
- B1 and H7: normalize's two carry passes were wrong for a value in [p - 19, p - 1] held with
  a top limb of 2^51 or more, and its debug assertion rejected the valid outputs of the last
  multiply whose value lay in [2^255, 2^255 + p), so a valid agreement panicked in a debug build
  (Alice's RFC 7748 key against the u whose K is 21). normalize is now four passes, two plain
  folds then the +19 / -19 pair, with its domain (every limb in [-2^53, 2^53], which the
  arithmetic's outputs satisfy with room) argued in its doc and the assertion gone; the
  regression input is a test through the public API in debug and release, and the unit tests
  hold the wide-top-limb case, 2^255, p, p + 1, -1 and a sweep of limb patterns over the
  domain's edges against two other representations of each value.
- H1: FieldElement25519 wipes its limbs with volatile stores when it drops, so the ladder's
  registers, the inversion chain and the result are cleared at the end of their lifetime (the
  const fns go, since a type with Drop has no const temporaries); the dead commented-out
  Ed25519 code and constants go with it, to land as reviewed code in their own PR.
- H2: scalar_mult writes its encoding into the caller's buffer and the shared secret never
  passes through X25519PublicKey; raw_shared_secret fills a caller's Secret and leaves it
  zeroized on the small-order refusal.
- H3: every constructor builds the key in place, the scalar written into the key's own Secret
  (keygen draws straight into it); encode_out and PartialEq clamp into caller-owned or local
  Secrets. What remains is the pair's move out through the Result, which the module docs now
  disclose together with the by-value one-shots, encode's plain array and HKDF's frame.
- H6: the KDF worker's doc no longer claims K lives only in its frame.
- H8: the two agreement workers are driven directly with a stale buffer and a small-order
  peer, and leave it zeroized.
- H9: the field has direct tests of mul, sqr, mul_u32, inv (against x^(p-2) by
  square-and-multiply), cswap and the product bounds the normalize domain relies on; the DHKEM
  suite takes RFC 9180's A.2.1, A.2.2, A.7.1 and A.7.2 sets beside A.1.1 and A.1.2 and flips
  every bit of enc, since enc is in kem_context; the KEM workers are driven directly as above.
- N2, N3, N4, N6, N7: the docs say that public keys compare as bytes and what that means for a
  non-canonical encoding, that keygen_from_seed refuses an all-zero seed as ActingOnZeroizedKey
  while from_bytes takes any bytes, the inversion's multiply count, the inverse and exponent
  claims as tests, mul_u32's constant, the ladder seeding, the tcId attribution, and the
  "negligible probability" sentence that the review showed false; ed_todo.md is deleted; the
  32-bit note and the pairing's strength at short lengths are stated.

cargo mutants on the crate (411 mutants: 333 caught, 61 unviable, 3 timeouts, 14 missed)
found one gap, that no test derived a key longer than 287 bytes through the HKDF-SHA256
pairing, so `255 * 32` in HKDF_SHA256_MAX_LEN survived as `255 + 32`; a test now derives the
full 255 * HashLen bytes on both sides. The other misses are equivalents: the zeroize and
Drop bodies replaced with (), `|` for `^` on the disjoint bit ranges of decode64_255 and
encode64_255, `zero()` for Default, the debug-only range check under the release mutants
profile, the two defensive post-conditions on HKDF's written length that the KDF cannot
violate, and the KeyMaterialError arm of generate_key_pair_rng, which keygen never returns.
The three timeouts are the sqr_n loop made endless.

Assisted-by: Claude:claude-mythos-5-1
…ck, a gated DeriveKeyPair, and tests of its own

Round one of the PR bcgit#186 review (H4, H5, H9, H10, N5):
- H4: encap_rng_out and decap_out write the shared secret and enc into the caller's buffers,
  zeroized first and left so on error; the by-value forms are wrappers that say they move
  their result out; extract_and_expand writes into the caller's KeyMaterial.
- H5: the PRK keys one HmacKey for the whole expand rather than one per block; the residue
  HMAC and SHA-256 leave below this crate's frames (the PRK recoverable from the message
  schedule of prk XOR opad) is stated in the Security Considerations as hmac's and sha2's.
- H10: PARAMS_CHECK requires the KDF's hash to offer twice the group's strength, as RFC 9180
  Section 7.1 Table 2 pairs them, so a 256-bit group with SHA-224 no longer compiles; the
  group declares DERIVE_KEY_PAIR_BY_EXPAND and derive_key_pair const-asserts it, since it
  implements only the X25519 / X448 form of Section 7.1.3. DHKEMParams::dh becomes dh_into.
- H9: the crate has tests of its own, the generic DHKEM over X25519 against RFC 9180 A.1.1 and
  A.1.2 by value and through the out forms, DeriveKeyPair, the small-order refusal with the
  buffers left zeroized, and the weak-RNG refusal (dev-dependencies on ed, rng and hex).
- N5: the "cannot fail" comments on the all-zero PRK and output say what the one failure is.

cargo mutants on the crate (55 mutants: 29 caught, 20 unviable, 6 missed) could not see
`strength_bits` change: it is private, and its only reader is the compile-time PARAMS_CHECK,
whose failure a mutants run cannot observe. A unit test in dhkem.rs pins its five values, as
the testing notes ask for any const assertion.

Assisted-by: Claude:claude-mythos-5-1
…p says how hex is read

Round one of the PR bcgit#186 review (H11): keygen encodes into a Secret and writes from it rather
than through a plain array, and the three file options say "binary, or hex under
--hex-input" in place of "in hex or binary", which suggested detection the CLI does not do.
The hex string the shared writer builds and the exit paths with a key live are the CLI's
generic helpers' and predate the branch.

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

dghgit commented Nov 12, 2026

Copy link
Copy Markdown
Contributor

Audit of a5b5faa and the fixes now on the branch

The port at a5b5faa went through a review pass (three reviewing agents over two workers, one on correctness and one on buffers, uncleared secrets and constant time). The verdict was not ready to approve: 1 bug, 11 hardening items, 12 notes. The mathematics was found right: about 480,000 differential cases against independent models, all 518 Wycheproof vectors, the RFC 7748 vectors including the 10^6 iteration test, and all twelve X25519 sets of RFC 9180 Appendix A gave no wrong output in release or debug. Buffer handling and constant time (arm64) were sound. What blocked approval was a debug-build panic on a valid agreement, secrets left on the dead stack after almost every operation, and ed_todo.md still in the tree.

Every reported item is addressed in the four commits from 98d06e2 to 88ec18d, one per crate.

fc84c80 ed: the field element wipes itself, normalize is total, keys and K are built in place, and the field is tested directly

  • B1 + H7 (the bug). normalize had two carry passes, which were wrong for a value in [p - 19, p - 1] held with a top limb of 2^51 or more, and its debug_assert rejected valid outputs of the last multiply in [2^255, 2^255 + p). A valid agreement (Alice's RFC 7748 key against the u whose K is 21) panicked in a debug build. It is now four passes with its domain (every limb in [-2^53, 2^53]) argued in the doc and the assertion gone. The regression input is a test through the public API, run in debug and release; unit tests cover the wide-top-limb case, 2^255, p, p + 1, -1 and a sweep of limb patterns over the domain's edges against two other representations.
  • H1. FieldElement25519 wipes its limbs when it drops, so the ladder's registers, the inversion chain and the result are cleared at the end of their lifetime (the repository's rule in QUALITY_AND_STYLE.md). The commented-out Ed25519 code and constants go with it, to land as reviewed code in their own PR.
  • H2. scalar_mult writes into the caller's buffer and K never passes through X25519PublicKey; raw_shared_secret fills a caller's Secret and leaves it zeroized on the small-order refusal.
  • H3. Every constructor builds the key in place, the scalar written into the key's own Secret (keygen draws straight into it). What remains is the pair's move out through the Result, which the module docs disclose together with the by-value one-shots and HKDF's frame.
  • H6. The KDF worker's doc no longer claims K lives only in its frame.
  • H8. The two agreement workers are driven directly with a stale buffer and a small-order peer, and leave it zeroized.
  • H9. Direct tests of mul, sqr, mul_u32, inv (against x^(p-2)), cswap and the product bounds the normalize domain relies on; the DHKEM suite takes RFC 9180 A.2.1, A.2.2, A.7.1 and A.7.2 beside A.1.1 and A.1.2 and flips every bit of enc.
  • N2, N3, N4, N6, N7. Docs on byte equality of public keys and non-canonical encodings, all-zero seed refused as ActingOnZeroizedKey, the clamping difference between from_bytes and the seed path, the multiply count (12), the inverse claims turned into tests, the ladder seeding, tcId attribution and the "negligible probability" sentence; ed_todo.md deleted; the 32-bit multiply note (N8) and the pairing's strength at short lengths (N9) stated.
  • Mutants: 411 mutants, 333 caught, 61 unviable, 3 timeouts (the sqr_n loop made endless), 14 missed. One miss was real, a longest-key test now covers it; the rest are equivalents (zeroize/Drop bodies, | for ^ on disjoint bit ranges, zero() for Default, a debug-only check under the release profile, two post-conditions HKDF cannot violate, an error arm keygen never returns).

c9f5e71 hpke: out-forms for the DHKEM, one HmacKey per expand, a strength check, a gated DeriveKeyPair, and tests of its own

  • H4. encap_rng_out and decap_out write the shared secret and enc into the caller's buffers, zeroized first and left so on error; the by-value forms are wrappers that say they move their result out; extract_and_expand writes into the caller's KeyMaterial.
  • H5. The PRK keys one HmacKey for the whole expand rather than one per block; the residue below this crate's frames (HMAC pad states, SHA-256 message schedule) is stated in the Security Considerations as hmac's and sha2's, outside this diff.
  • H10. PARAMS_CHECK requires the KDF's hash to offer twice the group's strength (RFC 9180 Section 7.1, Table 2), so a 256-bit group with SHA-224 no longer compiles; the group declares DERIVE_KEY_PAIR_BY_EXPAND and derive_key_pair const-asserts it, since only the X25519/X448 form of Section 7.1.3 is implemented.
  • H9. The crate has tests of its own: the generic DHKEM over X25519 against A.1.1 and A.1.2 by value and through the out forms, DeriveKeyPair, the small-order refusal with buffers left zeroized, and the weak-RNG refusal.
  • N5. The "cannot fail" comments on the all-zero PRK and output say what the one failure is.
  • Mutants: 55 mutants, 29 caught, 20 unviable, 6 missed, all in the private strength table that only the compile-time check reads; a unit test now pins its values.

4c04b86 cli: the x25519 private key's encoding lives in a Secret, and the help says how hex is read

  • H11. Keygen encodes into a Secret and writes from it rather than through a plain array; the three file options say "binary, or hex under --hex-input" in place of text that suggested detection the CLI does not do. The hex string the shared writer builds and the exit paths with a key live are the CLI's generic helpers' and predate the branch.

88ec18d release notes: the X25519 entry names the RFC 9180 sets the tests use

  • N7. The entry names A.1.1, A.1.2, A.2.1, A.2.2, A.7.1 and A.7.2.

Not changed

  • N1 (no_std declared while core, hmac and hkdf still use std): the workspace-wide Vec removal noted in crypto/core/src/lib.rs, not this PR.
  • N10 (stack table): the agreement row has not been re-measured since the port.
  • N12 (derived PartialEq on the public key): left as is; H2 removed the one path by which a secret could reach it.

Checks after the changes: workspace tests (3020 passed), all-targets build, cli/tests/test_x25519.sh 14/14, rustfmt, clippy clean in the changed code, rustdoc with warnings denied. Agreement timing is within noise of the pre-fix branch (32.2 us before, 32.9 us after).

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

The release's KDF description (4ce9853) makes KdfParams borrow a KdfDescription
rather than a running KDF, so the X25519 HKDF-SHA256 pairing takes
KdfParams<'p, HkdfDescription<'s, SHA256>, LEN> as eccdh's P-256 pairing does:
X25519KdfAgreement carries the salt's lifetime and builds the engine from the
description for each derivation; bouncycastle-ed depends on bouncycastle-hkdf for
the description type; the crate example and the tests build an HkdfDescription in
place of an HKDF_SHA256 instance, the by-hand check deriving through its engine.
No other conflict. Verified: the workspace suite (2910 passed), the all-targets
build, rustdoc with warnings denied on ed and hpke, nightly rustfmt, and the x25519
CLI shell suite (14 cases).

Assisted-by: Claude:claude-mythos-5-1
…ent's wipe

The crate docs' per-call stack table was from before the move onto the key
agreement roles and before the review fixes (the field element's Drop wipe,
keys and K built in place). Re-measured as the deepest frame chain below each
measure closure of mem_usage_benches' x25519 harness (release build, x86-64
Linux, rustc 1.97.0-nightly, frames from -C remark=prologepilog and the call
graph from objdump, 8 bytes of return address per hop, the from_bytes row rooted
at the harness's key-load helper): keygen_from_seed 1784 B, from_bytes 1704 B,
agree 1816 B, encaps 2792 B, decaps 2264 B. The three ladder entry points run
through scalar_mult_into (920 B), the ladder (600 B) and a field multiplication;
the KEM figures through the DHKEM, HMAC and SHA-256 compression.

Assisted-by: Claude:claude-mythos-5-1
The derivation a software pairing runs over its shared secret (zeroize the
caller's buffer, build the engine from the description, derive_key_out, check
the length) was written out in eccdh's kdf.rs and in ed's X25519 HKDF pairing,
the same dozen lines in two crates. KdfParams::derive_out is that derivation
once, on the parameters it reads: a KDFError, the engine's or the derivation's,
or GenericError if the KDF writes anything but LEN bytes, with `out` left
zeroized on every error. The length checks stay with the pairings, which own
the KDF's MAX_LEN.

Assisted-by: Claude:claude-mythos-5-1
derive keeps the pairing's length check and hands the derivation to the
parameters; a wrong-length write now comes back as KeyAgreementError::KDFError
over the KDF's GenericError rather than the agreement's own GenericError. The
module doc's link to KDF is spelled by path, since the import went with the
inlined body.

Assisted-by: Claude:claude-mythos-5-1
… doc nits

X25519KdfAgreement::agree_out hands K to KdfParams::derive_out, the same lines
eccdh's P-256 pairing runs, so the two cannot drift. A test runs the pairing
under a salted HkdfDescription: the framework suite, both sides agreeing, the
key equal to HKDF-SHA256 extract-and-expand by hand over K with that salt, the
absent salt and another info deriving different keys, and a salt typed Unknown
refused at the description.

Doc nits from the review of 2b4b29e: normalize's doc opened with the two-pass
paragraph it replaced, now one summary and the four-pass argument with the RFC
7748 Section 5 sentence folded in; raw_shared_secret and clamped_into each
carried their pre-_out summary above the current one; sqr_n said "Declared
const" and is not; four links named scalar_mult, now scalar_mult_into; the TODO
above inv cited a P64 constant that no longer exists.

Assisted-by: Claude:claude-mythos-5-1
…ummary each

Each carried its by-value predecessor's summary and "# Errors" heading above
the _out form's; the three docs are folded to one summary, keeping the key
type and strength the old lines stated.

Assisted-by: Claude:claude-mythos-5-1
…HA256>, LEN>

The X25519 and ECC CDH entries still named KdfParams<HKDF_SHA256, LEN>, the
type from before the KDF description (4ce9853).

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

dghgit commented Nov 14, 2026

Copy link
Copy Markdown
Contributor

Merge review follow-ups, five commits from 626e00b to 3e3b532

The review of the merge 7afeed1 found it sound (0 Bug, 0 Hardening, 4 Notes), and the re-check of the fixes at 2b4b29e left doc nits. N1 to N3 and the nits are addressed here, one commit per crate; N4 is the HKDF residue already accepted as outside this PR.

  • 626e00b core. KdfParams::derive_out: the derivation a software pairing runs over its shared secret (zeroize the caller's buffer, build the engine from the description, derive_key_out, check the length), once, on the parameters it reads. The length checks stay with the pairings, which own the KDF's MAX_LEN.
  • ed80cb6 eccdh (N3). derive keeps its length check and calls derive_out. A wrong-length write, a defensive branch, now comes back as KeyAgreementError::KDFError over the KDF's GenericError.
  • 9839cbd ed (N3, N2, nits). The X25519 pairing calls derive_out, so it and eccdh's P-256 pairing cannot drift. A test runs the pairing under a salted HkdfDescription: the framework suite, both sides agreeing, the key checked against HKDF-SHA256 extract-and-expand by hand over K with that salt, the absent salt and another info deriving different keys, and a salt typed Unknown refused at the description. The doc nits: normalize's two-pass opening is gone, with the RFC 7748 Section 5 sentence folded into the four-pass text; raw_shared_secret and clamped_into have one summary each; sqr_n no longer says it is const; the four scalar_mult links name scalar_mult_into; inv's TODO no longer cites the deleted P64.
  • 212d8c8 hpke (nits). encap_rng_out, decap_out and extract_and_expand_into have one summary and one Errors section each.
  • 3e3b532 release notes (N1). The X25519 and ECC CDH entries say KdfParams<HkdfDescription<SHA256>, LEN>.

The ML-KEM pairing in mlkem-common has a third copy of the same lines, with a strength step on top; it is left for its own change rather than widening this one.

Verified: nightly rustfmt, the all-targets build, rustdoc with warnings denied on core, eccdh, ed and hpke, the workspace suite (2911 passed, 0 failed, 9 ignored), and the x25519 (14), eccdh (5) and hkdf (17) CLI shell suites.

Not in these commits: the two residue remainders from the re-check, H1 (the inversion's two field elements moved out of pow_p_sub5_div8's frame) and H3 (the scalar copies key generation leaves), both in the ed crate.

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

pow_p_sub5_div8 returned (x^3, x^((p-5)/8)) by value, and a move out of a
frame runs no Drop, so the two field elements, which together give x^-1 and
so the ladder's final z2, stayed on the dead stack after every scalar
multiplication (the H1 remainder of the review at 2b4b29e). It now writes
them into two buffers inv holds, which inv's frame wipes when they drop; the
chain's intermediates were temporaries already, wiped as they drop. The doc's
reference to a sqrt_ratio_var that is not in the tree goes with it. The
exponent arithmetic is unchanged and the inversion tests pin both powers.

Assisted-by: Claude:claude-mythos-5-1
…e caller holds

keygen and keygen_from_seed built the key in a local and returned
Ok((sk.pk, sk)): two moves, into the pair and into the Result, and a move
wipes nothing, so two images of the scalar stayed on the dead stack (the H3
remainder of the review at 2b4b29e, which measured two where the Security
Considerations promised one). keygen_out(rng, &mut sk) and
keygen_from_seed_out(seed, &mut sk) write into a key the caller holds, made by
X25519PrivateKey::zeroized(): the RNG draws straight into the key's own Secret,
the key is zeroized first and left so on a refused RNG or seed, and the public
key comes back by value. The by-value forms are wrappers over them and say what
a by-value return leaves; the Security Considerations drops the count and names
the out forms. A test overwrites a stale key through each out form, checks the
result against the by-value form for the same RNG output or seed, and checks
the key is all zero after a refusal.

Assisted-by: Claude:claude-mythos-5-1
keygen_out into a zeroized key in place of the by-value keygen, so the command
stages no copy of the scalar on its way to the encoding Secret.

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

dghgit commented Nov 14, 2026

Copy link
Copy Markdown
Contributor

The two residue remainders, three commits from aab8e0b to 5015f1f

The re-check of the fixes at 2b4b29e left two items from the residue probe. Both are in the ed crate and both are the same mechanism: a value returned by value is moved out of the frame that built it, and a move runs no Drop, so the self-wiping types never reach the source.

  • aab8e0b ed (H1). pow_p_sub5_div8 returned (x^3, x^((p-5)/8)) as a tuple, so the two field elements, which together give x^-1 and so the ladder's final z2, stayed on the dead stack after every scalar multiplication. It now writes them into two buffers inv holds, wiped when inv's frame drops them; the chain's intermediates were temporaries already. The exponent arithmetic is unchanged and the inversion tests pin both powers. The doc's reference to a sqrt_ratio_var that is not in the tree goes with it.
  • 68a5b76 ed (H3). keygen and keygen_from_seed returned Ok((sk.pk, sk)): two moves, into the pair and into the Result, which is the two images the probe measured against the one the Security Considerations promised. keygen_out(rng, &mut sk) and keygen_from_seed_out(seed, &mut sk) write into a key the caller holds, made by X25519PrivateKey::zeroized(): the RNG draws straight into the key's own Secret, the key is zeroized first and left so on a refused RNG or seed, and the public key comes back by value. The by-value forms are wrappers over them and say what a by-value return leaves, as the DHKEM's by-value forms do; the Security Considerations drops the count and names the out forms. A test overwrites a stale key through each out form, checks the result against the by-value form for the same RNG output or seed, and checks the key is all zero after a refusal.
  • 5015f1f cli. x25519 keygen draws into a key it holds through keygen_out.

Not changed: the trait's from_bytes and the DHKEM's derive_key_pair return by value by their signatures and are covered by the corrected sentence; the partial skE on the DHKEM's encaps error path is the same mechanism inside hpke and is left for the probe to re-measure after these changes.

Verified: nightly rustfmt, the all-targets build, rustdoc with warnings denied on ed including private items, the ed and hpke suites in debug and release, the cli suite, the workspace suite (2912 passed, 0 failed, 9 ignored) and the x25519 CLI shell suite (14 cases). Mutants on ed are being re-run and the residue probe has been asked for; both numbers will follow here.

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

The one real miss of the ed mutants run at 5015f1f (416 mutants: 332 caught,
68 unviable, 3 timeouts in sqr_n's loop, 13 missed): the constant's product
replaced by a sum derived a 287-byte key just as well. The rest are the
equivalents recorded before: the Drop and zeroize bodies, OR for XOR on
disjoint bit ranges in decode64_255 and encode64_255, zero for Default, the
debug-only range check, and the KeyMaterialError arm that keygen never returns.

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

dghgit commented Nov 14, 2026

Copy link
Copy Markdown
Contributor

Mutants on ed at 5015f1f, on apollo: 416 mutants, 332 caught, 68 unviable, 3 timeouts (the sqr_n loop made endless, as before), 13 missed. Twelve of the misses are the equivalents recorded at 88ec18d: the Drop and zeroize bodies, OR for XOR on disjoint bit ranges in decode64_255 and encode64_255, zero for Default, the debug-only range check, and the KeyMaterialError arm that keygen never returns. The one real miss was 255 * 32 to 255 + 32 in HKDF_SHA256_MAX_LEN, which derived a 287-byte key just as well; 1110935 pins the constant in the longest-key test. The residue probe is with the reviewer.

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

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.

4 participants