Repository navigation
Feature/x25519 - #186
Feature/x25519#186ounsworth wants to merge 52 commits into
Conversation
|
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... |
|
Can you do me a favor and add perf tests? |
| type SharedSecretType: AsRef<[u8]>; | ||
|
|
||
| /// Perform a key agreement | ||
| fn key_agreement(pk: &PK, sk: SK) -> Result<Self::SharedSecretType, KeyAgreementError>; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
- A lot of things really do assume that the output of
key_agreementis 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. - 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.
- 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/hpkeand using that to implKEMEncryptor/KEMDecryptoron 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.
There was a problem hiding this comment.
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.
Yes that's on my TODO list! That'll be in place before I mark this PR ready for review. UPDATE: done 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. |
…o/hpke/dhkem implementation. Also started crate docs.
…from ~1600 to ~1200 bytes.
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
…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
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
Assisted-by: Claude:claude-mythos-5-1
Audit of a5b5faa and the fixes now on the branchThe 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 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
c9f5e71 hpke: out-forms for the DHKEM, one HmacKey per expand, a strength check, a gated DeriveKeyPair, and tests of its own
4c04b86 cli: the x25519 private key's encoding lives in a Secret, and the help says how hex is read
88ec18d release notes: the X25519 entry names the RFC 9180 sets the tests use
Not changed
Checks after the changes: workspace tests (3020 passed), all-targets build, 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
Merge review follow-ups, five commits from 626e00b to 3e3b532The 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.
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 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
The two residue remainders, three commits from aab8e0b to 5015f1fThe 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
Not changed: the trait's 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
|
Mutants on ed at 5015f1f, on apollo: 416 mutants, 332 caught, 68 unviable, 3 timeouts (the Assisted-by: Claude:claude-mythos-5-1 |
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-edandbouncycastle-hpke, on the release's key agreement and KEM roles, with the CLIx25519command and the stack harness. Merged up torelease/0.1.4alphaf213c9e (the ECCurvetrait, 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
KeyAgreementtrait went in favour of the roles PR #193 put incore:AgreementKey<Params, SS_LEN>on the private key, theKeyAgreement<PK, SS_LEN>worker withagree_out,KeyAgreementPrivateKey/KeyAgreementPublicKey,KeyAgreementErrorand the framework's agreement suite;KEMError::InvalidPublicKeyfrom this branch stays, for the DHKEM's refusal of a small-order recipient key.X25519PrivateKeyimplementsAgreementKey<(), 32>throughX25519Agreement<'k>, writingKinto the caller's buffer typedSharedSecretat 128 bits (zeroized first, left so on error) and refusing the all-zeroKof a small-order peer key asAgreementFailed; andAgreementKey<KdfParams<'p, HkdfDescription<'s, SHA256>, LEN>, LEN>asX25519_HKDF_SHA256,Kthrough HKDF-SHA256 with the caller's info (RFC 7748 Section 6.1's KDF overKand 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_seedstays. Gone, with no twin: the OS-seededkeygen(),keypair_consistency_check/consistency_check(sk.pk() == pk),X25519DerivedKey, and the three-party chaining example.x25519 agreegoes through the role and writes the raw shared secret for a KDF;check-consistencycompares 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:
normalizedomain:normalizeis total,reduce(0).reduce(0).reduce(1).reduce(-1)with its domain argued in its doc; thedebug_assertthat fired on a valid agreement is gone, with the reproducing input as a regression test in debug and release.FieldElement25519zeroizes itself on drop;scalar_mult_intowrites into the caller's array, soKnever travels in the public key type; keys are built in place, the scalar going straight into the key's ownSecretand the RNG drawing into it; the DHKEM hasencap_rng_out/decap_out/extract_and_expand_intowith the by-value forms as wrappers; oneHmacKeyper expand. What the trait signatures'Resultmoves leave is disclosed in the crate docs' Security Considerations.crypto/hpke/tests/dhkem_x25519_tests.rs; direct field arithmetic tests (mul, sqr, mul_u32, inversion againstx^(p-2), cswap, bounds, anormalizesweep); RFC 9180 A.2.1, A.2.2, A.7.1 and A.7.2 beside A.1; the framework's bit-flipping on.constcheck that the KDF's hash is at least twice the KEM's strength, andDERIVE_KEY_PAIR_BY_EXPANDas a const on the trait with a const assert.Secret; the help texts say how hex is read.i128products on 32-bit targets, the derived key's strength at short lengths.ed_todo.mdand the commented-out Ed25519 code are deleted (Ed25519 lands as reviewed code in its own PR).from_bytes1704 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.sh14 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:
bouncycastle-mathcrate, or something. (in theory, this could happen in parallel any time after 1)How to review this PR
cargo doc -p bouncycastle-ed. The sample code is where an end-user would start, and probably also where a reviewer should start.crypto/core/src/traits.rs.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.crypto/ed/src/x25519.rs.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 cratecrypto/hpkethat provides the generic DHKEM construction, and expose it by impl'ingKEMEcapsulatorandKEMDecapsulatoron theX25519struct.crypto/ed/src/ed_todo.mdthat 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
bouncycastle-edis 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}. Maybexcurvesoredcurves? I don't want to split them up into separate crates just because there doesn't exist a good name.TODO before merging
crypto/ed/src/ed_todo.md.