transport: ML-KEM-768 hybrid prelude and onionwire:v2 invites #183

Merged
sirius merged 1 commit from wt/t_8bb448b5 into main 2026-10-20 16:25:13 +00:00
Owner

What

Every OnionWire session can now carry an ML-KEM-768 (FIPS 203) secret alongside X25519.

  • New install key: ML-KEM-768 keypair. The 64-byte decapsulation seed lives in the SQLCipher store beside identity_sk; the 1184-byte encapsulation key travels in a v2 invite as kem=.

  • onionwire:v2 invites: kem=<base64url-unpadded 1184-byte pk> (~1.9 KB, cap stays 4096). Signature domain is onionwire-invite-v2 || 0x00 || k || 0x00 || o || 0x00 || spk || 0x00 || kem. Decode still accepts onionwire:v1 (no kem) and yields a classical friend; encode emits v2 only.

  • Wire: frame version = 2 is the PQ prelude, body uint16 ct_len (BE) + exactly 1088 bytes of ML-KEM ciphertext. The initiator sends one prelude, then Noise msg 1 with version = 1.

  • Prologue when a prelude is present is exactly:

    onionwire-v2 || 0x00 || ct (1088) || 0x00 || ss_kem (32)

    (onionwire-v2 is 12 ASCII bytes; the classical prologue stays onionwire-v1, also 12 bytes — the old PROTOCOL.md said 13, corrected here.)

  • No silent downgrade, decided by the roster row: friend has kem_pk and the opening is version 1 -> hard fail prelude required; friend has no kem_pk and a prelude arrives -> hard fail unexpected prelude. Legacy friends are labelled classical in the roster.

  • F8 rotates the KEM key together with the prekey: hard cut, stored ratchet state dropped, every friend re-pastes the invite.

Tests that prove the anti-downgrade / anti-strip properties

  • invite_v2_kem_strip_fails_signature_and_v1_downgrade_is_rejected (tests/qr.rs) — removing kem= from v2 is missing kem; relabelling v2 as v1 fails bad signature; substituting the kem bytes fails bad signature.
  • invite_v1_still_decodes_to_a_classical_friend_row (tests/qr.rs).
  • a_prelude_less_session_against_a_kem_friend_is_rejected (src/node.rs) — real handle_incoming, no application frame accepted.
  • a_classical_opening_to_a_kem_friend_is_refused and a_prelude_to_a_classical_friend_row_is_refused (tests/kem_transport.rs).
  • tampered_prelude_fails_the_handshake (tests/kem_transport.rs) — one ct byte flipped; responder never completes.
  • hybrid_prologue_has_the_documented_layout pins the byte layout and length.
  • pq_prelude_session_round_trips_a_real_rat_frame (src/node.rs) — a genuine rat frame encoded by the peer's ratchet is ingested end to end after a hybrid handshake.
  • the_outbox_hands_the_kem_key_from_the_friend_row_to_the_dialer (tests/outbox.rs).
  • kem_encaps_decaps_round_trip_through_encode_decode (tests/kem.rs).

Verification

  • cargo test --locked — 37 test targets, exit 0.
  • cargo clippy --locked --all-targets -- -D warnings — clean.
  • SDK (separate workspace): cargo test --locked 53 unit tests + integration, exit 0; cargo clippy --locked --all-targets -- -D warnings clean; crates/onionwire-sdk/Cargo.lock regenerated and committed for the new dependency.
  • Android: cargo ndk -t arm64-v8a -P 26 check --locked in crates/onionwire-sdk — exit 0 with ml-kem in the graph.

Docs in this PR

docs/PROTOCOL.md (keys, framing v2, prologue layouts, no-downgrade rule, invite v2 + legacy accept + cap), docs/THREAT_MODEL.md (what PQ transport buys and does not: epoch 0 is still prekey-derived and classical; ed25519 auth is classical; legacy friends get nothing), README.md (invite size/format, F8, classical label, no-PQ caveat).

Honest residual gap

The chat ratchet bootstrap epoch 0 is still derived only from the two long-lived X25519 prekeys, so a future quantum adversary holding a recorded rat chain and both prekeys can reconstruct the first epoch; later ratchet steps are X25519 too. This PR protects the one-shot Noise transport only. PQ ratchet steps are a follow-up. A restore from a backup also keeps the current install KEM key rather than the backed-up one, so peers re-paste after a restore — same hard-cut semantics as F8, and backups do not carry the KEM seed.

## What Every OnionWire session can now carry an ML-KEM-768 (FIPS 203) secret alongside X25519. - New install key: ML-KEM-768 keypair. The 64-byte decapsulation seed lives in the SQLCipher store beside `identity_sk`; the 1184-byte encapsulation key travels in a v2 invite as `kem=`. - `onionwire:v2` invites: `kem=<base64url-unpadded 1184-byte pk>` (~1.9 KB, cap stays 4096). Signature domain is `onionwire-invite-v2 || 0x00 || k || 0x00 || o || 0x00 || spk || 0x00 || kem`. Decode still accepts `onionwire:v1` (no `kem`) and yields a classical friend; encode emits v2 only. - Wire: frame `version = 2` is the PQ prelude, body `uint16 ct_len` (BE) + exactly 1088 bytes of ML-KEM ciphertext. The initiator sends one prelude, then Noise msg 1 with `version = 1`. - Prologue when a prelude is present is exactly: `onionwire-v2 || 0x00 || ct (1088) || 0x00 || ss_kem (32)` (`onionwire-v2` is 12 ASCII bytes; the classical prologue stays `onionwire-v1`, also 12 bytes — the old PROTOCOL.md said 13, corrected here.) - No silent downgrade, decided by the roster row: friend has `kem_pk` and the opening is version 1 -> hard fail `prelude required`; friend has no `kem_pk` and a prelude arrives -> hard fail `unexpected prelude`. Legacy friends are labelled `classical` in the roster. - `F8` rotates the KEM key together with the prekey: hard cut, stored ratchet state dropped, every friend re-pastes the invite. ## Tests that prove the anti-downgrade / anti-strip properties - `invite_v2_kem_strip_fails_signature_and_v1_downgrade_is_rejected` (`tests/qr.rs`) — removing `kem=` from v2 is `missing kem`; relabelling v2 as v1 fails `bad signature`; substituting the kem bytes fails `bad signature`. - `invite_v1_still_decodes_to_a_classical_friend_row` (`tests/qr.rs`). - `a_prelude_less_session_against_a_kem_friend_is_rejected` (`src/node.rs`) — real `handle_incoming`, no application frame accepted. - `a_classical_opening_to_a_kem_friend_is_refused` and `a_prelude_to_a_classical_friend_row_is_refused` (`tests/kem_transport.rs`). - `tampered_prelude_fails_the_handshake` (`tests/kem_transport.rs`) — one ct byte flipped; responder never completes. - `hybrid_prologue_has_the_documented_layout` pins the byte layout and length. - `pq_prelude_session_round_trips_a_real_rat_frame` (`src/node.rs`) — a genuine `rat ` frame encoded by the peer's ratchet is ingested end to end after a hybrid handshake. - `the_outbox_hands_the_kem_key_from_the_friend_row_to_the_dialer` (`tests/outbox.rs`). - `kem_encaps_decaps_round_trip_through_encode_decode` (`tests/kem.rs`). ## Verification - `cargo test --locked` — 37 test targets, exit 0. - `cargo clippy --locked --all-targets -- -D warnings` — clean. - SDK (separate workspace): `cargo test --locked` 53 unit tests + integration, exit 0; `cargo clippy --locked --all-targets -- -D warnings` clean; `crates/onionwire-sdk/Cargo.lock` regenerated and committed for the new dependency. - Android: `cargo ndk -t arm64-v8a -P 26 check --locked` in `crates/onionwire-sdk` — exit 0 with `ml-kem` in the graph. ## Docs in this PR `docs/PROTOCOL.md` (keys, framing v2, prologue layouts, no-downgrade rule, invite v2 + legacy accept + cap), `docs/THREAT_MODEL.md` (what PQ transport buys and does not: epoch 0 is still prekey-derived and classical; ed25519 auth is classical; legacy friends get nothing), `README.md` (invite size/format, F8, classical label, no-PQ caveat). ## Honest residual gap The chat ratchet bootstrap epoch 0 is still derived only from the two long-lived X25519 prekeys, so a future quantum adversary holding a recorded `rat ` chain and both prekeys can reconstruct the first epoch; later ratchet steps are X25519 too. This PR protects the one-shot Noise transport only. PQ ratchet steps are a follow-up. A restore from a backup also keeps the current install KEM key rather than the backed-up one, so peers re-paste after a restore — same hard-cut semantics as F8, and backups do not carry the KEM seed.
transport: ML-KEM-768 hybrid prelude and onionwire:v2 invites
All checks were successful
ci / test (pull_request) Successful in 18m32s
android-ci / android (pull_request) Successful in 20m14s
ci / sdk (pull_request) Successful in 3m11s
c741b660e4
Adds an ML-KEM-768 install key beside the ed25519 identity and x25519
prekey. A friend added from a v2 invite always gets a version-2 PQ
prelude before Noise msg 1, and both sides bind (ct, ss_kem) into the
Noise prologue (onionwire-v2 \0 ct \0 ss_kem). The row decides: a
kem_pk friend without a prelude is rejected with 'prelude required',
a prelude for a classical row with 'unexpected prelude'.

Invites are now onionwire:v2 with kem=<base64url pk>; decode still
accepts v1 (no kem) and yields a classical friend. F8 rotates the
KEM key with the prekey and hard-cuts stored ratchet state. The TUI
labels a legacy friend 'classical'.

Docs: PROTOCOL (keys, framing, prologues, no-downgrade, invite v2),
THREAT_MODEL (what PQ transport does and does not buy; epoch 0 stays
classical), README (invite size, F8, legacy caveat). SDK exposes pq on
InviteInfo/FriendInfo and gains a v2 decode round-trip test.
sirius merged commit e26813809a into main 2026-10-20 16:25:13 +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!183
No description provided.