ratchet: hybrid ML-KEM ratchet steps (KDF_RK v2, 2312-byte header, state v2) #185

Merged
sirius merged 2 commits from wt/t_fdce78d2 into main 2026-10-20 18:03:25 +00:00
Owner

What changed

Every Double-Ratchet keypair is now x25519 + ml-kem-768. On a rotation the stepper encapsulates to the receiver's current ratchet KEM key and both the X25519 DH and the ML-KEM secret feed KDF_RK under a new domain (onionwire-ratchet-v2-kdfrk, ikm = dh_out || ss_kem). A later X25519 break no longer opens a chain established by a hybrid step.

Files: src/ratchet.rs, src/kem.rs (one helper: pk_from_seed), src/node.rs (bootstrap + hard-cut load), tests/ratchet.rs, docs/PROTOCOL.md, docs/THREAT_MODEL.md, README.md.

Epoch-0 decision and mechanism

Epoch 0 stays classical. The bootstrap root is derived from the two long-lived signed prekeys so that neither side needs a wire field and both converge when both send first. The hybrid Noise session does now carry ss_kem, but each chat message is its own one-shot Noise session, so ss_kem differs for every dial and for the two peers' first sends. Binding epoch 0 to it breaks convergence in two ways:

  • two peers sending first derive different epoch-0 roots and cannot read each other at all;
  • even one direction breaks at message 2, which arrives on a different session with a different ss_kem while the receiver still holds message 1's root.

So the KEM secret enters at the first rotation after established, and that first rotation (including the bootstrap tie-break rotation) is hybrid. The convergence argument is unchanged: the epoch's shared input is now (X25519 DH, ss_kem) and both sides compute the same pair in the same causal order.

Residual HNDL window, stated exactly: the epoch-0 chain keys are not PQ. A future X25519 break reconstructs the prekey DH, hence the epoch-0 root and every key on that chain — everything sent before the first hybrid step, plus any outbox row encoded on epoch 0. It does not by itself open a recorded v2 transcript, because a v2 friend wraps every rat frame in the hybrid Noise session; the exposure becomes real when the rat frame itself is accessible (epoch-0 state snapshot, an old client that logged it, or a classical peer). Classical (v1) friends have no PQ layer anywhere. No full PQ forward secrecy is claimed.

Wire and state

  • Frame version 0x02 (hybrid): dh_pub[32] || N u32 || PN u32 || kem_pub[1184] || kem_ct[1088] = 2312 header bytes (vs 40), AAD = the whole header. Repeating the KEM section on every frame of the chain means a lost first frame is harmless. Empty-plaintext hybrid frame = 2345 bytes; max plaintext under the 65535 Noise cap = 63174. One rat frame is one Noise body, so fil chunk pacing does not apply and the accept token bucket (counts accepts, not bytes) is unchanged.
  • State blob 0x02: fixed 211-byte prefix + flags (0x01 remote_kem_pub, 0x02 pending_kem_ct, 0x04 recv_hybrid) + the flagged KEM fields. Hard cut: a v1 blob is deleted and re-bootstrapped (like F8). A corrupt v2 blob, or one from a newer version, is an error — not a silent reset.
  • No downgrade: a session holding the peer's KEM ratchet key rejects a step with no KEM section (BadFrame("hybrid step required")); a session with no KEM material rejects a hybrid step (BadFrame("unexpected hybrid step")). Once a chain was established hybrid, every frame on it must carry the hybrid header.

Tests proving it

  • hybrid_ratchet_step_derives_identical_roots_on_both_sides — real X25519 DH from both sides + real ML-KEM encaps/decaps, identical roots and both chain keys; asserts the v2 domain differs from classical.
  • both_peers_send_first_converges_with_hybrid_ratchet_keys — the convergence regression: both send on epoch 0, then two directions continue across hybrid steps.
  • a_hybrid_peer_will_not_accept_a_classical_only_ratchet_step — the explicit no-downgrade test (KEM section stripped, state byte-identical after rejection).
  • a_tampered_mlkem_header_component_fails_closed — kem_pub, kem_ct head and kem_ct tail each fail Authentication with state unchanged.
  • a_hybrid_step_against_a_classical_friend_is_rejected, a_classical_friend_still_ratchets_with_classical_frames.
  • max_skip_is_still_enforced_on_a_hybrid_chain (N = next+2001 rejected, +2000 reaches the AEAD), plus the existing 2000-key eviction test now runs on hybrid peers.
  • ratchet_state_blob_is_frozen_at_version_v2 (literal 0x02, 211-byte prefix, flag bits), ratchet_state_foreign_version_is_rejected_not_misread (v1 included).
  • Node-level: a_v1_ratchet_blob_is_dropped_and_rebuilt_not_misread, a_corrupt_current_ratchet_blob_is_an_error_not_a_silent_drop, a_future_ratchet_blob_is_an_error_not_a_silent_downgrade.

Verification

  • cargo test --locked: exit 0, 557 passed, 0 failed, 3 ignored (network) on the rebased head.
  • cargo clippy --locked -- -D warnings: clean.
  • SDK workspace (separate lock/workspace): cargo test --locked and cargo clippy --locked --all-targets -- -D warnings both clean, so the path-dependency build stays green.
  • No dependency or lockfile change. No release.

Rebase onto padding main (fec68e3, PR #184)

Rebased head 7a3c005 (b22abd7 is the hybrid commit squashed onto fec68e3; 7a3c005 adds the floor tests). Conflicts were resolved by combining both features, never by picking one side:

  • docs/PROTOCOL.md (2 hunks): the chat-frame cross-reference keeps main's padded-plaintext sentence and the now-correct Ratchet (chat layer) heading; the minimum-frame bullets keep main's bucket arithmetic and add the hybrid header, scoped so 350 stays the classical number.
  • docs/THREAT_MODEL.md (1 hunk): the length-buckets bullet absorbs the hybrid size-class note instead of the old "no padding" paragraph.
  • src/node.rs, README.md auto-merged. encode_chat_frame still pads inside the AEAD and ingest_chat_frame still unpads after decrypt, with the hybrid header outside that blob; epoch 0, the v1 KDF_RK form and the classical path are untouched.

Frame floors (padding is inside the header)

  • Classical 0x01: rat 4 + version 1 + header 40 + nonce 12 + bucket 256 + tag 16 = 329-byte rat frame; +16 Noise tag +5 framing = 350 bytes on the wire. The 350 figure is now explicitly scoped to this case.
  • Hybrid 0x02: 4 + 1 + 2312 + 12 + 256 + 16 = 2601-byte frame, 2622 bytes on the wire (2272 more header bytes).

Ladder floor decision: 256 stays

The ladder schedules the plaintext and is version-independent. A hybrid frame's size is dominated by its 2312-byte header, so the 256-byte bucket is a rounding error there whether or not the floor moves; raising it would only inflate classical/epoch-0 chat, and a per-version ladder would double the normative surface for no privacy gain. Stated in docs/PROTOCOL.md under Length buckets.

Ceiling and fil

Largest legal hybrid frame: 4 + 1 + 2312 + 12 + 32768 + 16 = 35113 bytes, +16 Noise tag = 35129 <= 65535 body cap and <= the documented 65519 maximum. fil is untouched (no src/file.rs change): chunk sizing, 3 s pacing, 3 attempts and the 1 MiB reconstructed-file cap are unchanged.

Tests

  • tests/padding.rs: 13 passed — new a_padded_plaintext_rides_inside_a_hybrid_header (2601 in Noise / 2622 on the wire through a real hybrid step, then decrypt + unpad), the_largest_bucket_still_fits_inside_a_hybrid_frame (35113), a_classical_frame_keeps_the_329_byte_floor (329/350).
  • tests/ratchet.rs: 26 passed — including both_peers_send_first_converges_with_hybrid_ratchet_keys, a_hybrid_peer_will_not_accept_a_classical_only_ratchet_step, a_tampered_mlkem_header_component_fails_closed.
  • cargo clippy --locked --all-targets -- -D warnings: clean. SDK workspace cargo test --locked + clippy: clean. No release, no tag.

Docs

docs/PROTOCOL.md: frame versions and sizes, KDF_RK v1/v2 input shape and info strings, the hybrid step walk-through, the epoch-0 answer, no-downgrade, state v2 + hard cut. docs/THREAT_MODEL.md: gains and honest limits rewritten, including the exact residual window and the 2272-byte frame-size metadata note. README.md: one honest sentence — hybrid steps, classical epoch 0, no full PQ FS claim.

## What changed Every Double-Ratchet keypair is now `x25519 + ml-kem-768`. On a rotation the stepper encapsulates to the receiver's current ratchet KEM key and both the X25519 DH and the ML-KEM secret feed `KDF_RK` under a **new domain** (`onionwire-ratchet-v2-kdfrk`, `ikm = dh_out || ss_kem`). A later X25519 break no longer opens a chain established by a hybrid step. Files: `src/ratchet.rs`, `src/kem.rs` (one helper: `pk_from_seed`), `src/node.rs` (bootstrap + hard-cut load), `tests/ratchet.rs`, `docs/PROTOCOL.md`, `docs/THREAT_MODEL.md`, `README.md`. ## Epoch-0 decision and mechanism **Epoch 0 stays classical.** The bootstrap root is derived from the two long-lived signed prekeys so that neither side needs a wire field and both converge when both send first. The hybrid Noise session does now carry `ss_kem`, but each chat message is its own one-shot Noise session, so `ss_kem` differs for every dial and for the two peers' first sends. Binding epoch 0 to it breaks convergence in two ways: - two peers sending first derive different epoch-0 roots and cannot read each other at all; - even one direction breaks at message 2, which arrives on a different session with a different `ss_kem` while the receiver still holds message 1's root. So the KEM secret enters at the **first rotation after `established`**, and that first rotation (including the bootstrap tie-break rotation) is hybrid. The convergence argument is unchanged: the epoch's shared input is now `(X25519 DH, ss_kem)` and both sides compute the same pair in the same causal order. Residual HNDL window, stated exactly: the epoch-0 *chain keys* are not PQ. A future X25519 break reconstructs the prekey DH, hence the epoch-0 root and every key on that chain — everything sent before the first hybrid step, plus any outbox row encoded on epoch 0. It does not by itself open a recorded v2 transcript, because a v2 friend wraps every `rat ` frame in the hybrid Noise session; the exposure becomes real when the rat frame itself is accessible (epoch-0 state snapshot, an old client that logged it, or a classical peer). Classical (v1) friends have no PQ layer anywhere. **No full PQ forward secrecy is claimed.** ## Wire and state - Frame version `0x02` (hybrid): `dh_pub[32] || N u32 || PN u32 || kem_pub[1184] || kem_ct[1088]` = **2312 header bytes** (vs 40), AAD = the whole header. Repeating the KEM section on every frame of the chain means a lost first frame is harmless. Empty-plaintext hybrid frame = 2345 bytes; max plaintext under the 65535 Noise cap = 63174. One `rat ` frame is one Noise body, so `fil` chunk pacing does not apply and the accept token bucket (counts accepts, not bytes) is unchanged. - State blob `0x02`: fixed 211-byte prefix + flags (`0x01 remote_kem_pub`, `0x02 pending_kem_ct`, `0x04 recv_hybrid`) + the flagged KEM fields. Hard cut: a **v1** blob is deleted and re-bootstrapped (like F8). A corrupt v2 blob, or one from a **newer** version, is an error — not a silent reset. - No downgrade: a session holding the peer's KEM ratchet key rejects a step with no KEM section (`BadFrame("hybrid step required")`); a session with no KEM material rejects a hybrid step (`BadFrame("unexpected hybrid step")`). Once a chain was established hybrid, every frame on it must carry the hybrid header. ## Tests proving it - `hybrid_ratchet_step_derives_identical_roots_on_both_sides` — real X25519 DH from both sides + real ML-KEM encaps/decaps, identical roots and both chain keys; asserts the v2 domain differs from classical. - `both_peers_send_first_converges_with_hybrid_ratchet_keys` — the convergence regression: both send on epoch 0, then two directions continue across hybrid steps. - `a_hybrid_peer_will_not_accept_a_classical_only_ratchet_step` — the explicit no-downgrade test (KEM section stripped, state byte-identical after rejection). - `a_tampered_mlkem_header_component_fails_closed` — kem_pub, kem_ct head and kem_ct tail each fail Authentication with state unchanged. - `a_hybrid_step_against_a_classical_friend_is_rejected`, `a_classical_friend_still_ratchets_with_classical_frames`. - `max_skip_is_still_enforced_on_a_hybrid_chain` (N = next+2001 rejected, +2000 reaches the AEAD), plus the existing 2000-key eviction test now runs on hybrid peers. - `ratchet_state_blob_is_frozen_at_version_v2` (literal `0x02`, 211-byte prefix, flag bits), `ratchet_state_foreign_version_is_rejected_not_misread` (v1 included). - Node-level: `a_v1_ratchet_blob_is_dropped_and_rebuilt_not_misread`, `a_corrupt_current_ratchet_blob_is_an_error_not_a_silent_drop`, `a_future_ratchet_blob_is_an_error_not_a_silent_downgrade`. ## Verification - `cargo test --locked`: exit 0, 557 passed, 0 failed, 3 ignored (network) on the rebased head. - `cargo clippy --locked -- -D warnings`: clean. - SDK workspace (separate lock/workspace): `cargo test --locked` and `cargo clippy --locked --all-targets -- -D warnings` both clean, so the path-dependency build stays green. - No dependency or lockfile change. No release. ## Rebase onto padding `main` (`fec68e3`, PR #184) Rebased head **`7a3c005`** (`b22abd7` is the hybrid commit squashed onto `fec68e3`; `7a3c005` adds the floor tests). Conflicts were resolved by combining both features, never by picking one side: - `docs/PROTOCOL.md` (2 hunks): the chat-frame cross-reference keeps main's padded-plaintext sentence and the now-correct `Ratchet (chat layer)` heading; the minimum-frame bullets keep main's bucket arithmetic and add the hybrid header, scoped so 350 stays the classical number. - `docs/THREAT_MODEL.md` (1 hunk): the length-buckets bullet absorbs the hybrid size-class note instead of the old "no padding" paragraph. - `src/node.rs`, `README.md` auto-merged. `encode_chat_frame` still pads **inside** the AEAD and `ingest_chat_frame` still unpads after decrypt, with the hybrid header outside that blob; epoch 0, the v1 `KDF_RK` form and the classical path are untouched. ### Frame floors (padding is inside the header) - Classical `0x01`: `rat ` 4 + version 1 + header 40 + nonce 12 + bucket 256 + tag 16 = **329-byte `rat ` frame**; +16 Noise tag +5 framing = **350 bytes on the wire**. The 350 figure is now explicitly scoped to this case. - Hybrid `0x02`: 4 + 1 + 2312 + 12 + 256 + 16 = **2601-byte frame**, **2622 bytes on the wire** (2272 more header bytes). ### Ladder floor decision: 256 stays The ladder schedules the plaintext and is version-independent. A hybrid frame's size is dominated by its 2312-byte header, so the 256-byte bucket is a rounding error there whether or not the floor moves; raising it would only inflate classical/epoch-0 chat, and a per-version ladder would double the normative surface for no privacy gain. Stated in `docs/PROTOCOL.md` under Length buckets. ### Ceiling and `fil ` Largest legal hybrid frame: 4 + 1 + 2312 + 12 + 32768 + 16 = **35113 bytes**, +16 Noise tag = 35129 <= 65535 body cap and <= the documented 65519 maximum. `fil ` is untouched (no `src/file.rs` change): chunk sizing, 3 s pacing, 3 attempts and the 1 MiB reconstructed-file cap are unchanged. ### Tests - `tests/padding.rs`: **13 passed** — new `a_padded_plaintext_rides_inside_a_hybrid_header` (2601 in Noise / 2622 on the wire through a real hybrid step, then decrypt + unpad), `the_largest_bucket_still_fits_inside_a_hybrid_frame` (35113), `a_classical_frame_keeps_the_329_byte_floor` (329/350). - `tests/ratchet.rs`: **26 passed** — including `both_peers_send_first_converges_with_hybrid_ratchet_keys`, `a_hybrid_peer_will_not_accept_a_classical_only_ratchet_step`, `a_tampered_mlkem_header_component_fails_closed`. - `cargo clippy --locked --all-targets -- -D warnings`: clean. SDK workspace `cargo test --locked` + clippy: clean. No release, no tag. ## Docs `docs/PROTOCOL.md`: frame versions and sizes, `KDF_RK` v1/v2 input shape and info strings, the hybrid step walk-through, the epoch-0 answer, no-downgrade, state v2 + hard cut. `docs/THREAT_MODEL.md`: gains and honest limits rewritten, including the exact residual window and the 2272-byte frame-size metadata note. `README.md`: one honest sentence — hybrid steps, classical epoch 0, no full PQ FS claim.
ratchet: hybrid ML-KEM steps (KDF_RK v2, 2312-byte header, state v2)
All checks were successful
ci / test (pull_request) Successful in 7s
ci / sdk (pull_request) Successful in 6s
cb046083a3
Every ratchet keypair is {x25519, ml-kem-768}. On a rotation the stepper
encapsulates to the receiver's current ratchet KEM key; both the X25519 DH
and the ML-KEM secret feed KDF_RK under the new onionwire-ratchet-v2-kdfrk
domain, so a later X25519 break does not open a chain established by a
hybrid step.

Epoch 0 stays classical by construction: the bootstrap root must be a pure
function of the two long-lived prekeys for both-send-first convergence, and
a per-session ss_kem differs for every one-shot Noise dial. The first
rotation (including the bootstrap tie-break rotation) is hybrid, so the
residual HNDL window is exactly the epoch-0 chain. docs/THREAT_MODEL.md
states that window; no full PQ forward secrecy is claimed.

Header 0x02 adds kem_pub[1184] || kem_ct[1088] to the 40-byte classical
header and is used on every frame of a hybrid chain, so a lost first frame
is harmless. A step with no KEM section is rejected (hybrid step required)
and a hybrid step at a classical friend is rejected (unexpected hybrid
step): no classical fallback.

State moves to blob v0.2: fixed 211-byte prefix plus flagged KEM fields.
A v1 blob is dropped and re-bootstrapped (hard cut, like F8); a corrupt v2
or a newer version is an error, not a silent reset.

Tests: hybrid root agreement on both sides, both-send-first convergence
with hybrid keys, no-downgrade, tampered kem_pub/kem_ct fail closed,
MAX_SKIP on a hybrid chain, classical friends still step classically,
state freeze at v0.2 and the node-level hard cut.
sirius force-pushed wt/t_fdce78d2 from cb046083a3
All checks were successful
ci / test (pull_request) Successful in 7s
ci / sdk (pull_request) Successful in 6s
to 33e0efc981
All checks were successful
ci / test (pull_request) Successful in 17m19s
ci / sdk (pull_request) Successful in 10s
2026-10-20 16:53:16 +00:00
Compare
sirius force-pushed wt/t_fdce78d2 from 33e0efc981
All checks were successful
ci / test (pull_request) Successful in 17m19s
ci / sdk (pull_request) Successful in 10s
to 7a3c0055b3
All checks were successful
ci / test (pull_request) Successful in 19m7s
ci / sdk (pull_request) Successful in 2m13s
2026-10-20 17:10:15 +00:00
Compare
sirius merged commit 4216a6731f into main 2026-10-20 18:03:25 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
sirius/onionwire!185
No description provided.