ratchet: hybrid ML-KEM ratchet steps (KDF_RK v2, 2312-byte header, state v2) #185
Loading…
Reference in a new issue
No description provided.
Delete branch "wt/t_fdce78d2"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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 feedKDF_RKunder 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, soss_kemdiffers for every dial and for the two peers' first sends. Binding epoch 0 to it breaks convergence in two ways:ss_kemwhile 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
ratframe 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
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. Oneratframe is one Noise body, sofilchunk pacing does not apply and the accept token bucket (counts accepts, not bytes) is unchanged.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.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(literal0x02, 211-byte prefix, flag bits),ratchet_state_foreign_version_is_rejected_not_misread(v1 included).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.cargo test --lockedandcargo clippy --locked --all-targets -- -D warningsboth clean, so the path-dependency build stays green.Rebase onto padding
main(fec68e3, PR #184)Rebased head
7a3c005(b22abd7is the hybrid commit squashed ontofec68e3;7a3c005adds 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-correctRatchet (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.mdauto-merged.encode_chat_framestill pads inside the AEAD andingest_chat_framestill unpads after decrypt, with the hybrid header outside that blob; epoch 0, the v1KDF_RKform and the classical path are untouched.Frame floors (padding is inside the header)
0x01:rat4 + version 1 + header 40 + nonce 12 + bucket 256 + tag 16 = 329-byteratframe; +16 Noise tag +5 framing = 350 bytes on the wire. The 350 figure is now explicitly scoped to this case.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.mdunder Length buckets.Ceiling and
filLargest legal hybrid frame: 4 + 1 + 2312 + 12 + 32768 + 16 = 35113 bytes, +16 Noise tag = 35129 <= 65535 body cap and <= the documented 65519 maximum.
filis untouched (nosrc/file.rschange): chunk sizing, 3 s pacing, 3 attempts and the 1 MiB reconstructed-file cap are unchanged.Tests
tests/padding.rs: 13 passed — newa_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 — includingboth_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 workspacecargo test --locked+ clippy: clean. No release, no tag.Docs
docs/PROTOCOL.md: frame versions and sizes,KDF_RKv1/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.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.cb046083a333e0efc98133e0efc9817a3c0055b3