feat: hybrid KEM (ML-KEM-768 + ECDH) for quantum-resistant note encryption#3
feat: hybrid KEM (ML-KEM-768 + ECDH) for quantum-resistant note encryption#3QuantumExplorer wants to merge 19 commits into
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThis PR introduces hybrid post-quantum key encapsulation to Orchard by combining classical ECDH with deterministic ML-KEM-768. It updates dependencies (MSRV 1.70→1.85.1, adds ml-kem), implements a new hybrid KEM module, redesigns addresses to distinguish classical and PQ components, extends key material with PQ seeds, updates note encryption with hybrid KDF and per-diversifier PQ operations, integrates changes into builders and PCZT, and provides comprehensive design documentation. ChangesHybrid Post-Quantum Key Encapsulation
Sequence Diagram(s)sequenceDiagram
participant Sender
participant HybridKEM
participant OrchardDomain
participant ECDH
participant Recipient
Sender->>OrchardDomain: Note::new(Address with ek_pq)
OrchardDomain->>OrchardDomain: extract ek_pq, convert to RawAddress
Sender->>ECDH: derive ephemeral keypair
ECDH->>OrchardDomain: (epk, esk.ecdh)
Sender->>HybridKEM: derive_pq_encaps_randomness(rseed, rho, ek_pq)
HybridKEM->>OrchardDomain: randomness
Sender->>HybridKEM: encapsulate_deterministic(ek_pq, randomness)
HybridKEM->>OrchardDomain: (ct_pq, ss_pq)
Sender->>OrchardDomain: hybrid_kdf(ss_ecdh, ss_pq, ct_pq, epk)
OrchardDomain->>OrchardDomain: symmetric_key [32 bytes]
Sender->>OrchardDomain: outgoing_plaintext_bytes(pk_d, esk.ecdh, ss_pq)
Sender->>OrchardDomain: AEAD encrypt note
Sender->>Recipient: (epk, ct_pq, diversifier_hint, enc_ciphertext, out_ciphertext)
Recipient->>HybridKEM: decrypt_diversifier_hint(ss_ecdh, epk, hint)
HybridKEM->>OrchardDomain: diversifier
Recipient->>HybridKEM: derive_pq_seed_for_diversifier(master_seed, diversifier)
HybridKEM->>OrchardDomain: pq_seed
Recipient->>HybridKEM: generate_pq_keypair_for_diversifier(pq_seed, diversifier)
HybridKEM->>OrchardDomain: (ek_pq, dk_pq)
Recipient->>HybridKEM: decapsulate(dk_pq, ct_pq)
HybridKEM->>OrchardDomain: ss_pq
Recipient->>OrchardDomain: hybrid_kdf(ss_ecdh, ss_pq, ct_pq, epk)
OrchardDomain->>Recipient: symmetric_key
Recipient->>OrchardDomain: AEAD decrypt note
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (7)
src/address.rs (1)
1-130:⚠️ Potential issue | 🟡 MinorRun rustfmt for this file to unblock CI.
This file is listed in the current formatting failures.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/address.rs` around lines 1 - 130, The file containing the Address struct and its impl (symbols: Address, from_parts, from_parts_with_pq, to_raw_address_bytes, from_raw_address_bytes) is not rustfmt-formatted; run the Rust formatter (cargo fmt / rustfmt) on the crate to reformat that file and commit the changes so CI passes, ensuring you format the module that defines Address and its associated methods (including the testing module and any cfg-annotated items).src/pczt/verify.rs (1)
1-221:⚠️ Potential issue | 🟡 MinorRun rustfmt for this file to unblock CI.
The pipeline already reports
cargo fmt -- --checkdiffs in this file; please format and re-push.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pczt/verify.rs` around lines 1 - 221, Run rustfmt (e.g., cargo fmt) to apply standard formatting to this file and commit the resulting changes; specifically ensure formatting is applied across the impls and items such as Action::verify_cv_net, Spend::fvk_for_validation, Spend::verify_nullifier, Spend::verify_rk, Output::verify_note_commitment, and the VerifyError enum/Display impl so match arms, spacing, and wrapping conform to rustfmt's rules, then push the formatted file to unblock the CI check.src/keys.rs (1)
475-499:⚠️ Potential issue | 🟠 MajorHybrid-KEM material is lost on key serialization round-trip, with no documented alternative.
The
to_bytes()andfrom_bytes()methods forFullViewingKey(lines 475–499) andIncomingViewingKey(lines 731–746) serialize to the Zcash Protocol Spec formats (96 and 64 bytes respectively), which predate PQ extensions. Deserialization unconditionally setsek_pqanddk_pqtoNone, discarding any hybrid-KEM material.In hybrid mode, users who persist and reload viewing keys will silently lose PQ capability, potentially downgrading security or breaking hybrid-aware transaction processing. While direct derivation methods like
internal_ivk()do preserve PQ fields, serialization does not, creating an inconsistent user experience with no documented workaround.Add either a versioned, PQ-preserving serialization path (or a separate hybrid key export/import API) with tests verifying PQ material survives persistence and reload.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/keys.rs` around lines 475 - 499, The to_bytes/from_bytes implementations for FullViewingKey (and analogous IncomingViewingKey) currently serialize only the legacy 96/64-byte fields and unconditionally set ek_pq/dk_pq to None, losing hybrid-KEM material; update serialization to preserve PQ fields by introducing a versioned encoding or separate hybrid export/import API: add new methods (e.g., to_bytes_v2/from_bytes_v2 or export_hybrid/import_hybrid) that include ek_pq and dk_pq when the "hybrid-kem" feature is enabled, tag the byte stream with a small version header so from_bytes can detect and parse legacy vs v2 formats, and add tests that round-trip FullViewingKey::ek_pq/dk_pq (and IncomingViewingKey equivalents) to ensure PQ material survives persistence and reload while keeping the existing to_bytes/from_bytes behavior backward-compatible.src/pczt.rs (1)
1-1:⚠️ Potential issue | 🟡 MinorFix formatting issues flagged by CI.
The pipeline indicates
cargo fmt -- --checkdetected formatting diffs. Runcargo fmtto resolve.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/pczt.rs` at line 1, CI flagged formatting diffs; run rustfmt (cargo fmt) across the crate to reformat src/pczt.rs and other files, then commit the changes. Specifically, apply cargo fmt so the module-level doc comment ("PCZT support for Orchard") and surrounding code in pczt.rs conform to rustfmt rules; if you use rustfmt config, ensure it's applied consistently and include the resulting changes in your PR.src/builder.rs (1)
1-1:⚠️ Potential issue | 🟡 MinorFix formatting issues flagged by CI.
The pipeline indicates
cargo fmt -- --checkdetected formatting diffs. Runcargo fmtto resolve.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/builder.rs` at line 1, CI reported formatting diffs for src/builder.rs; run rustfmt (cargo fmt) to apply the expected formatting changes, then re-run cargo fmt -- --check to confirm. Specifically, format the module/file that contains the Orchard transaction-building logic (src/builder.rs) so that comments, docstrings (e.g., the //! module-level comment) and all function/struct definitions in that file conform to rustfmt rules; commit the resulting changes.src/note_encryption.rs (2)
675-682:⚠️ Potential issue | 🟠 MajorHybrid compact flow is incomplete:
CompactActiondropsct_pq, so compact decryption cannot work.At Line 675 and Line 690,
CompactActiondoes not store/copy PQ ciphertext; at Line 703 it also does not exposepq_ciphertext(). Downstream impact is visible in Line 1032+ where hybrid compact decryption is expected to fail.Proposed fix sketch
pub struct CompactAction { nullifier: Nullifier, cmx: ExtractedNoteCommitment, ephemeral_key: EphemeralKeyBytes, enc_ciphertext: [u8; COMPACT_NOTE_SIZE], + #[cfg(feature = "hybrid-kem")] + ct_pq: [u8; PQ_CT_SIZE], } impl<T, M: MemoSize> From<&Action<T, M>> for CompactAction { fn from(action: &Action<T, M>) -> Self { CompactAction { nullifier: *action.nullifier(), cmx: *action.cmx(), ephemeral_key: action.ephemeral_key(), enc_ciphertext: action.encrypted_note().enc_ciphertext.as_ref()[..COMPACT_NOTE_SIZE] .try_into() .unwrap(), + #[cfg(feature = "hybrid-kem")] + ct_pq: action.encrypted_note().ct_pq, } } } impl<M: MemoSize> ShieldedOutput<OrchardDomain<M>> for CompactAction { @@ fn enc_ciphertext_compact(&self) -> NoteBytesData<COMPACT_NOTE_SIZE> { NoteBytesData(self.enc_ciphertext) } + + #[cfg(feature = "hybrid-kem")] + fn pq_ciphertext(&self) -> Option<&[u8]> { + Some(&self.ct_pq) + } }Also applies to: 690-699, 703-719, 1032-1091
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/note_encryption.rs` around lines 675 - 682, CompactAction currently omits the PQ hybrid ciphertext (ct_pq), so hybrid compact decryption cannot access the PQ payload; add a field to CompactAction (e.g., ct_pq: [u8; PQ_CIPHERTEXT_SIZE] or Vec<u8> matching how PQ ciphertexts are represented), populate/copy that field wherever CompactAction instances are constructed (ensure the constructor/new creation code copies the PQ ciphertext into the struct), implement and export an accessor pq_ciphertext() on CompactAction that returns the stored PQ ciphertext (by slice or fixed-size array reference), and update Clone/serde/serialization logic if present so ct_pq is preserved during clone/serialize/deserialize operations; reference the CompactAction struct, enc_ciphertext, and pq_ciphertext() to locate where to add and wire this field.
4-1192:⚠️ Potential issue | 🟡 MinorCI is currently failing on formatting for this file.
cargo fmt -- --checkreports diffs insrc/note_encryption.rs. Please runcargo fmtbefore merging.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/note_encryption.rs` around lines 4 - 1192, The file fails rustfmt checks; run rustfmt/cargo fmt to apply formatting changes across the note encryption implementation (e.g. around OrchardDomain, prf_ock_orchard, CompactAction, and associated impls). Fix by running `cargo fmt` (or `rustup component add rustfmt` then `cargo fmt`) and committing the resulting whitespace/format diffs so the implementations (including functions like prf_ock_orchard, orchard_parse_note_plaintext_without_memo, OrchardDomain::note_plaintext_bytes, outgoing_plaintext_bytes, and the CompactAction impls) are style-compliant.
🧹 Nitpick comments (1)
src/note.rs (1)
327-333: Use shared constants instead of hard-coded PQ sizes.Line 329, Line 333, Line 362, and Line 363 embed protocol sizes directly. Prefer
crate::hybrid_kem::PQ_CT_SIZEand a single constant for hybrid out-ciphertext length to prevent drift.Also applies to: 362-364
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/note.rs` around lines 327 - 333, Replace hard-coded PQ lengths with shared constants: change the ct_pq field declaration (symbol ct_pq) to use crate::hybrid_kem::PQ_CT_SIZE instead of 1088, and replace the out_ciphertext field (symbol out_ciphertext) to use a single shared constant (e.g. crate::hybrid_kem::PQ_OUT_CT_SIZE) instead of 112; also update the other occurrences that embed 1088/112 (the references around the later block where the same sizes are used) to use the same two constants so all PQ ciphertext and hybrid out-ciphertext sizes come from crate::hybrid_kem constants.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@Cargo.toml`:
- Line 45: The zcash_note_encryption dependency in Cargo.toml is using a local
path which breaks CI; replace the path entries for zcash_note_encryption (both
occurrences currently specifying path = "../zcash_note_encryption" and the
variant with features) with a resolvable source such as a crates.io version or a
git = "..." reference (and keep the features key if needed), and remove the path
attribute from the committed Cargo.toml; for local development, document or add
a developer-only override using .cargo/config.toml or a [patch.crates-io] entry
to point to the local path so CI and non-local builds resolve correctly.
In `@src/builder.rs`:
- Around line 390-412: The code currently inserts a zeroed ct_pq when
recipient.ek_pq() is None, producing undecryptable ciphertext; replace that
silent fallback with a development-time check by adding a debug_assert that
recipient.ek_pq().is_some() (or equivalent) before constructing ct_pq so
developers are alerted if an address lacks ek_pq, then keep the existing
behavior in release (i.e., still use the zeroed array only if assertions are
disabled). Update the block around recipient.ek_pq() /
TransmittedNoteCiphertext::from_parts in the builder so the debug_assert
references recipient.ek_pq(), note.esk(), and the created ct_pq to make the
failure point clear (matching other code paths that panic in this scenario).
In `@src/hybrid_kem.rs`:
- Around line 1-327: CI is failing because this file is not rustfmt-formatted;
run the formatter (cargo fmt) to reformat the module containing derive_pq_seed,
generate_pq_keypair, derive_pq_encaps_randomness, encapsulate_deterministic,
decapsulate, hybrid_kdf and the tests, then add the resulting changes to the PR
(or run rustfmt with your project's config if custom) so cargo fmt -- --check
passes.
---
Outside diff comments:
In `@src/address.rs`:
- Around line 1-130: The file containing the Address struct and its impl
(symbols: Address, from_parts, from_parts_with_pq, to_raw_address_bytes,
from_raw_address_bytes) is not rustfmt-formatted; run the Rust formatter (cargo
fmt / rustfmt) on the crate to reformat that file and commit the changes so CI
passes, ensuring you format the module that defines Address and its associated
methods (including the testing module and any cfg-annotated items).
In `@src/builder.rs`:
- Line 1: CI reported formatting diffs for src/builder.rs; run rustfmt (cargo
fmt) to apply the expected formatting changes, then re-run cargo fmt -- --check
to confirm. Specifically, format the module/file that contains the Orchard
transaction-building logic (src/builder.rs) so that comments, docstrings (e.g.,
the //! module-level comment) and all function/struct definitions in that file
conform to rustfmt rules; commit the resulting changes.
In `@src/keys.rs`:
- Around line 475-499: The to_bytes/from_bytes implementations for
FullViewingKey (and analogous IncomingViewingKey) currently serialize only the
legacy 96/64-byte fields and unconditionally set ek_pq/dk_pq to None, losing
hybrid-KEM material; update serialization to preserve PQ fields by introducing a
versioned encoding or separate hybrid export/import API: add new methods (e.g.,
to_bytes_v2/from_bytes_v2 or export_hybrid/import_hybrid) that include ek_pq and
dk_pq when the "hybrid-kem" feature is enabled, tag the byte stream with a small
version header so from_bytes can detect and parse legacy vs v2 formats, and add
tests that round-trip FullViewingKey::ek_pq/dk_pq (and IncomingViewingKey
equivalents) to ensure PQ material survives persistence and reload while keeping
the existing to_bytes/from_bytes behavior backward-compatible.
In `@src/note_encryption.rs`:
- Around line 675-682: CompactAction currently omits the PQ hybrid ciphertext
(ct_pq), so hybrid compact decryption cannot access the PQ payload; add a field
to CompactAction (e.g., ct_pq: [u8; PQ_CIPHERTEXT_SIZE] or Vec<u8> matching how
PQ ciphertexts are represented), populate/copy that field wherever CompactAction
instances are constructed (ensure the constructor/new creation code copies the
PQ ciphertext into the struct), implement and export an accessor pq_ciphertext()
on CompactAction that returns the stored PQ ciphertext (by slice or fixed-size
array reference), and update Clone/serde/serialization logic if present so ct_pq
is preserved during clone/serialize/deserialize operations; reference the
CompactAction struct, enc_ciphertext, and pq_ciphertext() to locate where to add
and wire this field.
- Around line 4-1192: The file fails rustfmt checks; run rustfmt/cargo fmt to
apply formatting changes across the note encryption implementation (e.g. around
OrchardDomain, prf_ock_orchard, CompactAction, and associated impls). Fix by
running `cargo fmt` (or `rustup component add rustfmt` then `cargo fmt`) and
committing the resulting whitespace/format diffs so the implementations
(including functions like prf_ock_orchard,
orchard_parse_note_plaintext_without_memo, OrchardDomain::note_plaintext_bytes,
outgoing_plaintext_bytes, and the CompactAction impls) are style-compliant.
In `@src/pczt.rs`:
- Line 1: CI flagged formatting diffs; run rustfmt (cargo fmt) across the crate
to reformat src/pczt.rs and other files, then commit the changes. Specifically,
apply cargo fmt so the module-level doc comment ("PCZT support for Orchard") and
surrounding code in pczt.rs conform to rustfmt rules; if you use rustfmt config,
ensure it's applied consistently and include the resulting changes in your PR.
In `@src/pczt/verify.rs`:
- Around line 1-221: Run rustfmt (e.g., cargo fmt) to apply standard formatting
to this file and commit the resulting changes; specifically ensure formatting is
applied across the impls and items such as Action::verify_cv_net,
Spend::fvk_for_validation, Spend::verify_nullifier, Spend::verify_rk,
Output::verify_note_commitment, and the VerifyError enum/Display impl so match
arms, spacing, and wrapping conform to rustfmt's rules, then push the formatted
file to unblock the CI check.
---
Nitpick comments:
In `@src/note.rs`:
- Around line 327-333: Replace hard-coded PQ lengths with shared constants:
change the ct_pq field declaration (symbol ct_pq) to use
crate::hybrid_kem::PQ_CT_SIZE instead of 1088, and replace the out_ciphertext
field (symbol out_ciphertext) to use a single shared constant (e.g.
crate::hybrid_kem::PQ_OUT_CT_SIZE) instead of 112; also update the other
occurrences that embed 1088/112 (the references around the later block where the
same sizes are used) to use the same two constants so all PQ ciphertext and
hybrid out-ciphertext sizes come from crate::hybrid_kem constants.
ℹ️ Review info
Configuration used: defaults
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (18)
Cargo.tomlbenches/circuit.rsbenches/note_decryption.rsrust-toolchain.tomlsrc/action.rssrc/address.rssrc/builder.rssrc/bundle/commitments.rssrc/hybrid_kem.rssrc/keys.rssrc/lib.rssrc/note.rssrc/note_encryption.rssrc/pczt.rssrc/pczt/parse.rssrc/pczt/prover.rssrc/pczt/verify.rstests/builder.rs
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
src/note_encryption.rs (1)
329-334: Hybrid output path currently repeats ML‑KEM encapsulation work.
ka_agree_encencapsulates once,outgoing_plaintext_bytesencapsulates again, and output assembly computesct_pqagain in builder. This is avoidable cost and increases drift risk if one path changes.Refactor direction
- Compute
(ct_pq, ss_pq)once per note.- Reuse that material for:
- hybrid shared-secret/KDF path,
outgoing_plaintext_bytes(ss_pq),- transmitted
ct_pq.Also applies to: 467-473
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/note_encryption.rs` around lines 329 - 334, The hybrid ML‑KEM encapsulation is being run multiple times; change ka_agree_enc, outgoing_plaintext_bytes, and the builder assembly so you call hybrid_kem::encapsulate_deterministic(&ek_pq.0, &esk.pq_randomness) exactly once per note and capture its result (ct_pq, ss_pq), then pass ss_pq into the HybridSharedSecret construction (instead of re-encapsulating), into outgoing_plaintext_bytes, and finally use the same ct_pq when assembling the transmitted ciphertext; apply the same single-call refactor for the other occurrence around the ka_agree_enc/outgoing_plaintext_bytes/builder code at the later block (lines referenced 467-473) so both paths reuse the same (ct_pq, ss_pq) tuple.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@Cargo.toml`:
- Line 45: Replace the git dependency declaration for zcash_note_encryption that
currently uses branch = "feat/hybrid-kem-support" with an immutable rev =
"db0eef3c62367d72e6d3728cefa507b8c374b248" so the Cargo.toml entry for the
zcash_note_encryption dependency (symbol: zcash_note_encryption) is pinned to
the exact commit instead of a moving branch; update both occurrences that
reference that branch to use the rev value to ensure reproducible,
tamper-resistant builds.
In `@src/builder.rs`:
- Around line 398-402: Replace the hard panic caused by expect on
crate::hybrid_kem::encapsulate_deterministic with proper error handling: call
encapsulate_deterministic for ek_pq (using ek_pq.0 and esk.pq_randomness) and
propagate or handle the Result instead of unwrapping—either return an Err from
the surrounding output-build function (propagate with ? or map_err) or
gracefully fall back (log the error and skip/mark ct_pq) consistent with the
hybrid fallback behavior used elsewhere; ensure ct_pq remains correctly set when
success and the function signature is updated to return a Result if needed.
---
Nitpick comments:
In `@src/note_encryption.rs`:
- Around line 329-334: The hybrid ML‑KEM encapsulation is being run multiple
times; change ka_agree_enc, outgoing_plaintext_bytes, and the builder assembly
so you call hybrid_kem::encapsulate_deterministic(&ek_pq.0, &esk.pq_randomness)
exactly once per note and capture its result (ct_pq, ss_pq), then pass ss_pq
into the HybridSharedSecret construction (instead of re-encapsulating), into
outgoing_plaintext_bytes, and finally use the same ct_pq when assembling the
transmitted ciphertext; apply the same single-call refactor for the other
occurrence around the ka_agree_enc/outgoing_plaintext_bytes/builder code at the
later block (lines referenced 467-473) so both paths reuse the same (ct_pq,
ss_pq) tuple.
ℹ️ Review info
Configuration used: defaults
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (7)
Cargo.tomlsrc/address.rssrc/builder.rssrc/hybrid_kem.rssrc/note_encryption.rssrc/pczt.rssrc/pczt/verify.rs
🚧 Files skipped from review as they are similar to previous changes (1)
- src/pczt.rs
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@book/src/design/hybrid-kem.md`:
- Around line 19-21: The fenced code blocks in hybrid-kem.md are missing
language specifiers which triggers markdownlint MD040; update each
triple-backtick fence containing expressions like pq_seed =
BLAKE2b-512("DashPQ_KeyDerive", sk), pq_seed_d = BLAKE2b-512("DashPQ_DivSeed__",
pq_seed || d), and the mask/hint block to use a language tag (suggest "text" or
"none") so the blocks become ```text ... ``` (apply the same change for the
other occurrences noted around lines 24-26 and 113-116).
In `@src/hybrid_kem.rs`:
- Around line 175-181: The public error enum PqError should be marked
non-exhaustive to allow adding variants later; update the PqError declaration by
adding the #[non_exhaustive] attribute above the enum (refer to PqError) so
external consumers cannot exhaustively match on it and future variants won't be
a breaking change.
In `@src/keys.rs`:
- Around line 408-411: The methods FullViewingKey::address_at and
FullViewingKey::address call wrap_raw_address which expects pq_seed and thus can
panic for FVKs constructed via from_bytes (pq_seed: None); change these APIs to
be fallible by returning Option/Result or add non-panicking variants (e.g.,
try_address_at and try_address) that return an error/None when pq_seed is
missing, update wrap_raw_address to return a fallible type or provide a
try_wrap_raw_address helper that checks self.pq_seed, and replace downstream
call sites to use the new fallible methods (or propagate the Result/Option) so
deserialized hybrid-mode FVKs no longer panic; apply the same changes to the
other affected methods referenced in the review (the address/address_at pairs
around lines 414-422, 429-437, 489-500).
In `@src/note_encryption.rs`:
- Around line 327-337: The fallback branch that silences errors from
hybrid_kem::encapsulate_deterministic currently returns HybridSharedSecret with
zeroed pq_ss and ct_pq, which makes the recipient's PQ decapsulation path fail
deterministically; instead propagate the encapsulation error (i.e., return a
Result::Err or bubble the error) from the enclosing function rather than
constructing zeroed values so the caller can handle/abort encryption; update
both occurrences around the HybridSharedSecret construction (the match on
hybrid_kem::encapsulate_deterministic producing (ct_pq, pq_ss) and the second
similar block at lines ~394-423) to return the error using the function's error
type instead of returning zeroed pq_ss/ct_pq.
ℹ️ Review info
Configuration used: defaults
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (18)
.github/workflows/ci.yml.github/workflows/lints-stable.ymlCargo.tomlbook/src/SUMMARY.mdbook/src/design/hybrid-kem.mdbook/src/user/keys.mdsrc/action.rssrc/address.rssrc/builder.rssrc/bundle.rssrc/constants/fixed_bases.rssrc/hybrid_kem.rssrc/keys.rssrc/lib.rssrc/note.rssrc/note_encryption.rssrc/pczt.rssrc/pczt/parse.rs
✅ Files skipped from review due to trivial changes (1)
- book/src/SUMMARY.md
🚧 Files skipped from review as they are similar to previous changes (1)
- src/action.rs
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (3)
src/builder.rs (1)
391-399:⚠️ Potential issue | 🟠 MajorReplace panic-based ML-KEM handling with a typed build error.
Line 397 currently uses
.expect(...), which can abort bundle construction instead of returningBuildError. This is still a reliability footgun in the output-build path.Suggested direction
- let (ct_pq, _) = - crate::hybrid_kem::encapsulate_deterministic(&ek_pq.0, &esk.pq_randomness).expect( - "ML-KEM encapsulation must succeed for keys derived from a valid spending key", - ); + let (ct_pq, _) = crate::hybrid_kem::encapsulate_deterministic( + &ek_pq.0, + &esk.pq_randomness, + ) + .map_err(BuildError::HybridKemEncapsulation)?;You’d then propagate
Resultfrom this build path and add aBuildErrorvariant for PQ encapsulation failure.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/builder.rs` around lines 391 - 399, Replace the panic (.expect) in the ML-KEM deterministic encapsulation path with a propagated BuildError: change the block that computes encrypted_note (calling crate::hybrid_kem::encapsulate_deterministic(&ek_pq.0, &esk.pq_randomness)) to handle the Result and on Err return Err(BuildError::MlKemEncapsulationFailure { source: err }) (or add a suitably named variant to the BuildError enum), and update the surrounding build function signature/return flow to propagate Result so the failure from encapsulate_deterministic is returned instead of panicking; reference encapsulate_deterministic, encrypted_note, note.esk(), self.recipient.ek_pq(), and the BuildError enum when making the change.src/hybrid_kem.rs (1)
175-181:⚠️ Potential issue | 🟠 MajorMark
PqErroras non-exhaustive.
PqErroris public and should not be exhaustively matchable by downstream crates.🔧 Suggested patch
+#[non_exhaustive] #[derive(Debug)] pub enum PqError {As per coding guidelines
**/*.rs: Make all error enums#[non_exhaustive].🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/hybrid_kem.rs` around lines 175 - 181, The public error enum PqError should be marked non-exhaustive so downstream crates can't match it exhaustively; add the #[non_exhaustive] attribute directly above the pub enum PqError declaration in hybrid_kem.rs (leaving the existing variants EncapsulationFailed and DecapsulationFailed unchanged) so future variants can be added without breaking consumers.book/src/design/hybrid-kem.md (1)
19-21:⚠️ Potential issue | 🟡 MinorAdd language identifiers to fenced code blocks (MD040).
These fences still omit a language tag and will continue to fail markdownlint.
🛠️ Suggested patch
- ``` + ```text pq_seed = BLAKE2b-512("DashPQ_KeyDerive", sk) ``` - ``` + ```text pq_seed_d = BLAKE2b-512("DashPQ_DivSeed__", pq_seed || d) ``` - ``` + ```text mask = BLAKE2b-256("DashPQ_DivHint__", ss_ecdh_bytes || epk_bytes)[..11] hint = diversifier XOR mask ```Also applies to: 24-26, 113-116
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@book/src/design/hybrid-kem.md` around lines 19 - 21, The Markdown fenced code blocks in hybrid-kem.md are missing language identifiers (e.g., the blocks containing pq_seed = BLAKE2b-512("DashPQ_KeyDerive", sk), pq_seed_d = BLAKE2b-512("DashPQ_DivSeed__", pq_seed || d), and the block with mask = BLAKE2b-256(...)/hint = diversifier XOR mask); update each triple-backtick fence to include a language tag such as text (```text) so markdownlint rule MD040 passes, and apply the same change to the other occurrences noted (lines around 24–26 and 113–116).
🧹 Nitpick comments (1)
src/note_encryption.rs (1)
1037-1059: Consider extracting a shared hybrid-test fixture/helper.The same
ct_pqderivation +diversifier_hint+Action::from_partssetup is repeated in multiple tests. A small helper would reduce drift and make future field changes safer.Also applies to: 1102-1123, 1169-1190, 1260-1281, 1323-1344, 1391-1412
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/note_encryption.rs` around lines 1037 - 1059, Multiple tests repeat the ct_pq derivation, diversifier_hint computation, and Action::from_parts construction (see ct_pq derived via crate::hybrid_kem::encapsulate_deterministic, compute_test_hint, and crate::action::Action::from_parts with crate::note::TransmittedNoteCiphertext::from_parts); extract those steps into a small test helper (e.g., a function like make_test_action or build_hybrid_action) placed in the test module or a test utils file that takes the varying inputs (note, recipient, epk_bytes, nf, cmx, enc_ciphertext, out_ciphertext, cv_net) and returns the constructed Action and/or ct_pq and diversifier_hint so tests at the listed locations can call the helper instead of duplicating the logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/keys.rs`:
- Around line 550-567: The PqSeed type is crate-private (PqSeed(pub(crate) [u8;
64])) so external code cannot construct one to use APIs like set_pq_seed,
with_pq_seed, or pass bytes from SpendingKey::pq_seed(); fix this by making a
public constructor and/or conversion: add a public associated constructor (e.g.,
PqSeed::new(bytes: [u8;64]) -> Self) or implement From<[u8;64]>/TryFrom<&[u8]>
for PqSeed and make the tuple struct public (pub struct PqSeed(pub [u8;64])) or
keep tuple privacy but expose a pub fn from_bytes/try_from_bytes; ensure the
chosen API is referenced by name (PqSeed::new, From<[u8;64]> for PqSeed,
TryFrom<&[u8]> for PqSeed) so callers can convert SpendingKey::pq_seed() output
into a PqSeed and then call set_pq_seed/with_pq_seed.
---
Duplicate comments:
In `@book/src/design/hybrid-kem.md`:
- Around line 19-21: The Markdown fenced code blocks in hybrid-kem.md are
missing language identifiers (e.g., the blocks containing pq_seed =
BLAKE2b-512("DashPQ_KeyDerive", sk), pq_seed_d = BLAKE2b-512("DashPQ_DivSeed__",
pq_seed || d), and the block with mask = BLAKE2b-256(...)/hint = diversifier XOR
mask); update each triple-backtick fence to include a language tag such as text
(```text) so markdownlint rule MD040 passes, and apply the same change to the
other occurrences noted (lines around 24–26 and 113–116).
In `@src/builder.rs`:
- Around line 391-399: Replace the panic (.expect) in the ML-KEM deterministic
encapsulation path with a propagated BuildError: change the block that computes
encrypted_note (calling crate::hybrid_kem::encapsulate_deterministic(&ek_pq.0,
&esk.pq_randomness)) to handle the Result and on Err return
Err(BuildError::MlKemEncapsulationFailure { source: err }) (or add a suitably
named variant to the BuildError enum), and update the surrounding build function
signature/return flow to propagate Result so the failure from
encapsulate_deterministic is returned instead of panicking; reference
encapsulate_deterministic, encrypted_note, note.esk(), self.recipient.ek_pq(),
and the BuildError enum when making the change.
In `@src/hybrid_kem.rs`:
- Around line 175-181: The public error enum PqError should be marked
non-exhaustive so downstream crates can't match it exhaustively; add the
#[non_exhaustive] attribute directly above the pub enum PqError declaration in
hybrid_kem.rs (leaving the existing variants EncapsulationFailed and
DecapsulationFailed unchanged) so future variants can be added without breaking
consumers.
---
Nitpick comments:
In `@src/note_encryption.rs`:
- Around line 1037-1059: Multiple tests repeat the ct_pq derivation,
diversifier_hint computation, and Action::from_parts construction (see ct_pq
derived via crate::hybrid_kem::encapsulate_deterministic, compute_test_hint, and
crate::action::Action::from_parts with
crate::note::TransmittedNoteCiphertext::from_parts); extract those steps into a
small test helper (e.g., a function like make_test_action or
build_hybrid_action) placed in the test module or a test utils file that takes
the varying inputs (note, recipient, epk_bytes, nf, cmx, enc_ciphertext,
out_ciphertext, cv_net) and returns the constructed Action and/or ct_pq and
diversifier_hint so tests at the listed locations can call the helper instead of
duplicating the logic.
ℹ️ Review info
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 3f81e208-bff3-4997-b615-a425945b3524
📒 Files selected for processing (5)
book/src/design/hybrid-kem.mdsrc/builder.rssrc/hybrid_kem.rssrc/keys.rssrc/note_encryption.rs
Address review feedback on zcash#480
a64598d to
aa1d43f
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## dashified #3 +/- ##
=============================================
+ Coverage 77.71% 79.87% +2.16%
=============================================
Files 40 41 +1
Lines 3715 4124 +409
=============================================
+ Hits 2887 3294 +407
- Misses 828 830 +2 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
898258d to
ae18c28
Compare
…ncryption Adds post-quantum key encapsulation alongside classical ECDH to defend against "Harvest Now, Decrypt Later" attacks. The hybrid approach derives symmetric encryption keys from both an ECDH shared secret and an ML-KEM-768 shared secret, so breaking either mechanism alone is insufficient. Feature-gated behind `hybrid-kem` — when enabled, all encryption uses the hybrid path; classic mode is fully preserved without the feature flag. Key changes: - New `hybrid_kem` module: ML-KEM-768 keygen, encaps, decaps, hybrid KDF - Key hierarchy: PQ keys derived deterministically from spending key - Address extended with optional PQ encapsulation key (1184 bytes) - Note encryption: hybrid KDF binds ss_ecdh, ss_pq, ct_pq, epk (X-Wing) - Encapsulation randomness bound to rseed + rho + ek_pq - OVK recovery path threads ct_pq for correct hybrid key derivation - PCZT support for hybrid ciphertexts - TransmittedNoteCiphertext carries ct_pq (1088 bytes) and larger out_ciphertext (112 bytes) - 12 new hybrid-specific tests, all 65 tests pass in hybrid mode - All 54 classic-mode tests continue to pass Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace local path dependency for zcash_note_encryption with git reference to dashpay/zcash_note_encryption feat/hybrid-kem-support branch (fixes CI builds) - Add debug_assert for missing ek_pq in builder hybrid-kem path - Run cargo fmt to fix all formatting issues Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix clippy manual_div_ceil lint by using .div_ceil() in fixed_bases.rs - Update wasm32-wasi to wasm32-wasip1 for Rust 1.85 compatibility - Pin zcash_note_encryption to rev instead of branch for reproducible builds - Replace expect() panic with graceful error handling in PQ encapsulation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Addresses are reconstructed without ek_pq in several code paths (from_raw_address_bytes, note decryption recovery), so equality must compare only (d, pk_d) to avoid false mismatches. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Introduce RawAddress (43-byte on-chain form) as the internal type used by Note, PCZT, and decryption paths. Address now wraps RawAddress with a mandatory PqEncapsulationKey when hybrid-kem is enabled, enforcing at the type level that the builder always has the PQ key available for hybrid encryption. When hybrid-kem is off, RawAddress is a type alias for Address. Also adds book documentation covering the hybrid KEM design (key derivation, address encoding with Base58, QR considerations, encryption flow, security properties) and user-facing docs for creating keys and addresses. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the single ML-KEM-768 keypair per spending key with per-diversifier keypairs derived from a master pq_seed, restoring address unlinkability that was broken when all diversified addresses shared the same ek_pq. Key changes: - Two-level key derivation: sk → pq_seed → per-diversifier pq_seed_d → ML-KEM keypair - PqSeed type replaces ek_pq/dk_pq pairs in FVK/IVK/PreparedIVK - 11-byte ECDH-encrypted diversifier hint on-chain (no MAC for quantum privacy) - Scanning decrypts hint → derives per-diversifier dk → decapsulates Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add set_pq_seed/with_pq_seed to FVK and IVK so deserialized keys can re-attach PQ material (prevents panic and silent decryption failure) - Make PQ encapsulation failure a hard error instead of silently falling back to ECDH-only (which produced undecryptable notes) - Add combined keypair_for_diversifier to avoid double ML-KEM KeyGen - Rename to_bytes → as_bytes on PqSeed/PqEncapsulationKey (returns ref) - Remove redundant zero-init + copy in ka_agree_dec_with_pq - Fix doc inaccuracies: KDF inputs, personalization labels, add serialization/PQ-seed section - Add tests: wrong-IVK failure, non-default diversifier round-trip, deserialized IVK with re-attached PQ seed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Make OrchardDTKPq::None in ka_agree_enc unreachable (was silently degrading to ECDH-only, defeating PQ protection) - Make outgoing_plaintext_bytes expect ek_pq and successful encapsulation (was silently zeroing ss_pq, breaking OVK recovery) - Fix HybridSharedSecret doc comment: KDF binds all four inputs (both shared secrets, ct_pq, and epk), not just shared secrets - Rename PqDecapsulationKey::to_bytes → as_bytes for consistency Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Test MissingPqCiphertext and MissingDiversifierHint error variants in Output::parse when ct_pq or diversifier_hint are None. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adapts the hybrid-kem test code to API changes introduced when rebasing onto orchard 0.13.1: - `NoteValue::zero()` was replaced by the `NoteValue::ZERO` const. - `Action::from_parts` now returns `Option` (it rejects an identity rk); test call sites unwrap with `.expect`. - Gate the new `dummy_other_fields` test helper so it builds the hybrid-sized `TransmittedNoteCiphertext` under the feature. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Points the dependency at the fork commit that adds the per-diversifier hybrid KEM trait extension (diversifier_hint + 5-param ka_agree_dec_with_pq), which the hybrid-kem feature requires. The previously pinned rev (db0eef3c) only had the basic hybrid trait, so a clean build of hybrid-kem failed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a `decrypt-10k` benchmark group measuring 10,000 full trial decryptions in both the realistic "miss" (foreign note) and "hit" (owned note) cases, and fixes the bench to derive the recipient via `FullViewingKey::address_at` so it compiles under `hybrid-kem` (where `ivk.address_at` returns a `RawAddress` that the builder won't accept). Updates the hybrid-kem design doc with the measured results: the post-quantum chain adds ~63 µs/note (~2x) for the dominant foreign-note scanning case — roughly an order of magnitude below the earlier ~1 ms estimate — replacing the conservative "3-6x / ~1ms" figures with benchmark-backed numbers and a reproduction command. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- rustfmt: format the decrypt-10k bench and the hybrid-gated test helper so `cargo fmt -- --check` passes. - book: add `text` language tags to the BLAKE2b pseudo-code fences in the hybrid-kem design doc; `mdbook test` treated the untagged blocks as Rust and failed to compile them. - hybrid_kem: mark `PqError` `#[non_exhaustive]` per crate guidelines. - keys: add `PqSeed::from_bytes` and `impl From<[u8; 64]> for PqSeed` so external callers can re-attach a PQ seed (e.g. from `SpendingKey::pq_seed`). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
aa1d43f to
90c0c60
Compare
Adds unit tests for the previously-untested hybrid-kem public surface that codecov/patch flagged (keys.rs was the dominant gap): - PqSeed: from_bytes / From<[u8;64]> / as_bytes / per-diversifier keygen / Debug - PqEncapsulationKey & PqDecapsulationKey: from_bytes / as_bytes / Eq / Ord / Debug - FullViewingKey & IncomingViewingKey: pq_seed / set_pq_seed / with_pq_seed, including the deserialize-then-reattach flow - Address / RawAddress: accessors and the 43-byte raw serialization round-trip Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hybrid post-quantum KEM — production-readiness checklist
🔴 Blocking (must-have before mainnet)
🟠 Should-have
🟡 Nice-to-have / follow-ups
🤖 Generated with Claude Code |
Adds non-panicking counterparts to FullViewingKey::address_at/address for the hybrid-kem feature, returning Result<Address, MissingPqSeed> instead of panicking when the FVK has no PQ seed attached (e.g. after from_bytes without re-attaching it). The ergonomic address_at/address remain for the common case (FVK derived from a SpendingKey) and now document their panic behavior and point to the try_* variants. This keeps classic (non-hybrid) Orchard's address_at/address API unchanged for upstream compatibility, while giving hybrid callers a safe path — addressing the panic-on-deserialized-FVK review finding. - New: try_address_at, try_address, and the MissingPqSeed error type (Display + std::error::Error, #[non_exhaustive]). - wrap_raw_address now delegates to a fallible try_wrap_raw_address. - Test: a seed-less deserialized FVK returns Err(MissingPqSeed), not a panic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 11-byte diversifier hint is transmitted on-chain in each hybrid action but was not covered by hash_bundle_txid_data (only ct_pq was). That left it malleable: an attacker could flip the hint without changing the txid/bundle commitment, causing the recipient to recover the wrong diversifier, derive the wrong decapsulation key, and fail to decrypt the note (a recipient-side DoS / griefing vector). Include diversifier_hint in the non-compact action hash alongside ct_pq so any mutation invalidates the commitment. Adds a regression test that flipping only the hint changes the txid commitment. Reported by Codex review (P1). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds opt-in serialization that retains hybrid viewing/sending capability across a byte round-trip, fixing the silent "missing funds" / dropped-PQ-key footguns (Codex P1/P2). The canonical upstream formats are unchanged. - FullViewingKey::to_bytes_with_pq / from_bytes_with_pq (96-byte FVK || 64-byte pq_seed = 160 bytes). - IncomingViewingKey::to_bytes_with_pq / from_bytes_with_pq (64 || 64 = 128 bytes). - Address::to_bytes / from_bytes for the full 1227-byte hybrid address (43-byte RawAddress || 1184-byte ek_pq), with an exported HYBRID_ADDRESS_SIZE const. RawAddress::to_raw_address_bytes still emits only the 43-byte classical form. - Round-trip tests for all three. - Update the hybrid-kem design doc: PQ-preserving serialization, the fallible try_address_at API, the full Address byte API, and that the diversifier hint is now committed in the bundle (txid) hash. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The existing design/hybrid-kem.md is a construction/reference spec. This adds a from-first-principles companion chapter explaining the underlying cryptography: the harvest-now-decrypt-later threat, ECDH key agreement, what a KEM is, ML-KEM-768 and MLWE, how the hybrid combiner is secure if either primitive holds (X-Wing-style KDF binding), deterministic encapsulation, and the privacy reasoning behind the per-diversifier keys and diversifier hint. Nested under the hybrid-kem chapter in SUMMARY.md, with a cross-link from the spec. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The doc comment referenced `to_raw_address_bytes` without a path, which rustdoc could not resolve and `#![deny(rustdoc::broken_intra_doc_links)]` rejected (failing the Intra-doc links CI check). Qualify it as `Self::to_raw_address_bytes`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
hybrid-kem— all encryption uses the hybrid path when enabled; classic mode fully preserved without the flagWhat is included
src/hybrid_kem.rs(new)PqErrorerror typesrc/keys.rsPqEncapsulationKey,PqDecapsulationKey),HybridSharedSecret,EphemeralSecretKeywith PQ randomness,PreparedIncomingViewingKeywith PQ dksrc/address.rsAddressextended with optionalek_pq(1184 bytes), serialization updatedsrc/note.rsTransmittedNoteCiphertextgainsct_pq(1088 bytes) and widerout_ciphertext(112 bytes),Note::esk()derives PQ randomnesssrc/note_encryption.rsOrchardDomainhybrid impl:ka_agree_enc/ka_agree_decwith ML-KEM, hybrid KDF, OVK recovery withct_pqthreading, version byte0x03, 5 hybrid integration testssrc/builder.rsOutputInfo::buildperforms ML-KEM encapsulation, graceful fallback for classic addressessrc/bundle/commitments.rsct_pqincluded in noncompact tx ID hashsrc/pczt/parse.rsct_pqand largerout_ciphertextCargo.tomlml-kemdependency,hybrid-kemfeature flagAddressclone semantics in hybrid modeDesign decisions
rseed + rho + ek_pqvia BLAKE2b, consistent with Orchards deterministic-from-rseed philosophyhybrid_kdf(ss_ecdh, ss_pq, ct_pq, epk)prevents transcript binding attacks even if ML-KEMs IND-CCA2 is weakenedss_pqin out_ciphertext — OVK recovery needs the ML-KEM shared secret; out plaintext grows from 64 to 96 bytes (+16 tag = 112)ct_pqin CompactAction — light clients get full PQ protection at cost of ~1088 extra bytesek_pqbound in encaps randomness — prevents cross-key randomness reuseencapsulate_deterministicreturnsResult<_, PqError>instead of panicking;decapsulateis infallible (ML-KEM implicit rejection)Test plan
cargo test --features hybrid-kem)cargo test)manual_div_ceilinfixed_bases.rs)hybrid_kemunit + 5note_encryption::hybrid_testsGenerated with Claude Code
Summary by CodeRabbit
Release Notes
New Features
Documentation
Chores