From 75c26f97dc451b883ef3a389ee1c333058ecf282 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Fri, 9 Oct 2026 14:33:14 +0530 Subject: [PATCH 1/7] feat(sealed): a peer can say it routes sealed service frames Adds svc_versions to the key package payload and SVC_SEALED_V1 = 1. Entry 1 means the peer routes a __SVC_REQ__ or __SVC_RESP__ found inside a decrypted 1:1 MLS plaintext into its service layer. Vectors regenerated from the generator; the negotiation chapter names the field, states the stated exception to rule 3, and the leaf advertises it empty. --- crates/offline-protocol-leaf/src/keypkg.rs | 6 ++-- .../tests/phone_interop.rs | 9 +++++ .../offline-protocol-sealed/src/keypackage.rs | 34 ++++++++++++++++++ crates/offline-protocol-sealed/src/lib.rs | 2 +- .../tests/data/key-package-v1.vectors.json | 10 ++++-- .../offline-protocol/src/protocol/blocking.rs | 1 + crates/offline-protocol/src/protocol/send.rs | 1 + .../src/protocol/tests/mod.rs | 16 +++++++++ docs/spec/capability-negotiation.md | 35 ++++++++++++++++++- tools/spec-vectors/generate.py | 8 +++-- 10 files changed, 114 insertions(+), 8 deletions(-) diff --git a/crates/offline-protocol-leaf/src/keypkg.rs b/crates/offline-protocol-leaf/src/keypkg.rs index bc41e4ae2..a6167dbd7 100644 --- a/crates/offline-protocol-leaf/src/keypkg.rs +++ b/crates/offline-protocol-leaf/src/keypkg.rs @@ -98,8 +98,9 @@ pub(crate) fn mint(client: &Client, now_unix_secs: u64) -> Resul /// /// The compact envelope and the binary hop encoding, because both are pure /// parsing work that saves radio time on a link that has very little. Nothing -/// else: `rich_versions` and `data_versions` stay empty, so a peer sends plain -/// text and no document sync frames, which by the protocol's own rule is a +/// else: `rich_versions`, `data_versions` and `svc_versions` stay empty, so a +/// peer sends plain text, no document sync frames and no sealed service frames +/// (a leaf has no service registry), which by the protocol's own rule is a /// downgrade to the floor rather than an error. A device that advertised a /// capability it does not implement would be sent frames it renders as /// literal text. @@ -129,6 +130,7 @@ pub(crate) fn payload( // legacy leaf to be compatible with: this crate's first release is // the one that introduced the device at all. ctrl_versions: alloc::vec![CTRL_SIGN_V2], + svc_versions: Vec::new(), nostr_pubkey: None, } } diff --git a/crates/offline-protocol-leaf/tests/phone_interop.rs b/crates/offline-protocol-leaf/tests/phone_interop.rs index fbc6d2955..97b74a655 100644 --- a/crates/offline-protocol-leaf/tests/phone_interop.rs +++ b/crates/offline-protocol-leaf/tests/phone_interop.rs @@ -508,6 +508,7 @@ fn a_key_package_that_claims_another_owner_is_refused() { rich_versions: vec![], data_versions: vec![], ctrl_versions: vec![offline_protocol_sealed::CTRL_SIGN_V2], + svc_versions: Vec::new(), nostr_pubkey: None, }; @@ -1137,6 +1138,7 @@ fn a_peer_that_already_has_a_key_package_is_not_sent_another() { rich_versions: vec![], data_versions: vec![], ctrl_versions: vec![offline_protocol_sealed::CTRL_SIGN_V2], + svc_versions: Vec::new(), nostr_pubkey: None, }; let frame = phone_control_frame( @@ -1720,6 +1722,7 @@ fn a_replayed_session_reset_does_not_tear_down_the_new_session() { rich_versions: vec![], data_versions: vec![], ctrl_versions: vec![offline_protocol_sealed::CTRL_SIGN_V2], + svc_versions: Vec::new(), nostr_pubkey: None, }; let reset_frame = phone_control_frame( @@ -1818,6 +1821,7 @@ fn a_flood_of_strangers_cannot_displace_an_established_peer() { rich_versions: vec![], data_versions: vec![], ctrl_versions: vec![offline_protocol_sealed::CTRL_SIGN_V2], + svc_versions: Vec::new(), nostr_pubkey: None, }; let frame = phone_control_frame( @@ -1849,6 +1853,7 @@ fn a_flood_of_strangers_cannot_displace_an_established_peer() { rich_versions: vec![], data_versions: vec![], ctrl_versions: vec![offline_protocol_sealed::CTRL_SIGN_V2], + svc_versions: Vec::new(), nostr_pubkey: None, }; let frame = phone_control_frame( @@ -2274,6 +2279,7 @@ fn advertisement_body(user_id: &str) -> String { rich_versions: vec![], data_versions: vec![], ctrl_versions: vec![offline_protocol_sealed::CTRL_SIGN_V2], + svc_versions: Vec::new(), nostr_pubkey: None, }; format!( @@ -3202,6 +3208,7 @@ fn a_reset_older_than_the_last_one_acted_on_is_refused() { rich_versions: vec![], data_versions: vec![], ctrl_versions: vec![offline_protocol_sealed::CTRL_SIGN_V2], + svc_versions: Vec::new(), nostr_pubkey: None, }; phone_control_frame_at( @@ -3262,6 +3269,7 @@ fn a_spent_reset_stays_spent_across_a_power_cycle() { rich_versions: vec![], data_versions: vec![], ctrl_versions: vec![offline_protocol_sealed::CTRL_SIGN_V2], + svc_versions: Vec::new(), nostr_pubkey: None, }; let reset_frame = phone_control_frame_at( @@ -3412,6 +3420,7 @@ fn a_session_reset_that_states_no_time_is_refused_its_teardown() { rich_versions: vec![], data_versions: vec![], ctrl_versions: vec![offline_protocol_sealed::CTRL_SIGN_V2], + svc_versions: Vec::new(), nostr_pubkey: None, }; let content = format!( diff --git a/crates/offline-protocol-sealed/src/keypackage.rs b/crates/offline-protocol-sealed/src/keypackage.rs index 95417f086..c94789004 100644 --- a/crates/offline-protocol-sealed/src/keypackage.rs +++ b/crates/offline-protocol-sealed/src/keypackage.rs @@ -160,6 +160,27 @@ pub struct KeyPackagePayload { #[serde(default)] pub ctrl_versions: Vec, + /// Sealed service frame versions the sender **routes** (e.g. `[1]` for + /// [`SVC_SEALED_V1`]): a `__SVC_REQ__` or `__SVC_RESP__` found inside a + /// decrypted 1:1 MLS plaintext is handed to its service layer rather than + /// surfaced as a chat message. Absent on legacy nodes + /// (`#[serde(default)]` gives empty), and toward them service bodies stay + /// signed plaintext, which is the floor for this frame family. + /// + /// End to end, like `rich_versions`: it describes the far end's parser, + /// not any carrier. + /// + /// Trust boundary: unlike the feature lists above, this one decides + /// whether a body is readable by every hop, so the receiver honours it + /// only from a signed key package, as it does `nostr_pubkey`. Stripping it + /// downgrades the peer's service bodies to signed plaintext; replaying an + /// older signed package inside the control-frame freshness window does the + /// same until a genuine one arrives. Forging it onto a legacy peer makes + /// us send sealed frames it surfaces as chat text, which only the + /// sender's own key could have produced, so it grants an attacker nothing. + #[serde(default)] + pub svc_versions: Vec, + /// This install's Nostr public key (x-only, 64-char lowercase hex), so a /// peer can seal Nostr gift wraps to a key only this install holds. /// @@ -218,6 +239,12 @@ pub const MLS_ENVELOPE_COMPACT_V1: u8 = 1; /// [`control_signing_payload_v2`]: crate::canonical::control_signing_payload_v2 pub const CTRL_SIGN_V2: u8 = 2; +/// Sealed service frame version advertised in +/// [`KeyPackagePayload::svc_versions`]: the sender routes a `__SVC_REQ__` or +/// `__SVC_RESP__` it finds inside a decrypted 1:1 MLS plaintext into its +/// service layer, with the MLS-authenticated sender as the requester. +pub const SVC_SEALED_V1: u8 = 1; + /// The conformance vectors for the key package payload. /// /// The chapter these pin is `docs/spec/capability-negotiation.md`, and they pin @@ -299,6 +326,7 @@ mod spec_vectors { ("rich_versions", &got.rich_versions), ("data_versions", &got.data_versions), ("ctrl_versions", &got.ctrl_versions), + ("svc_versions", &got.svc_versions), ] { assert_eq!(got, &u8s(&want[field]), "[{name}] {field}"); } @@ -336,6 +364,7 @@ mod spec_vectors { rich_versions: Vec::new(), data_versions: Vec::new(), ctrl_versions: Vec::new(), + svc_versions: Vec::new(), nostr_pubkey: None, }; let rendered = serde_json::to_string(&payload).expect("it serializes"); @@ -371,6 +400,7 @@ mod spec_vectors { "rich_versions", "data_versions", "ctrl_versions", + "svc_versions", "nostr_pubkey", "session_reset", ] { @@ -384,5 +414,9 @@ mod spec_vectors { text.contains(&format!("Entry {CTRL_SIGN_V2} means")), "the chapter no longer states what ctrl_versions entry {CTRL_SIGN_V2} means" ); + assert!( + text.contains(&format!("Entry {SVC_SEALED_V1} means the peer routes")), + "the chapter no longer states what svc_versions entry {SVC_SEALED_V1} means" + ); } } diff --git a/crates/offline-protocol-sealed/src/lib.rs b/crates/offline-protocol-sealed/src/lib.rs index 77c42c021..a6d976cc5 100644 --- a/crates/offline-protocol-sealed/src/lib.rs +++ b/crates/offline-protocol-sealed/src/lib.rs @@ -83,7 +83,7 @@ pub use identity_assertion::{ encode_identity_assertion, parse_identity_assertion, IdentityAssertion, ED25519_SIGNATURE_LEN, IDENTITY_ASSERTION_MIN_LEN, }; -pub use keypackage::{KeyPackagePayload, CTRL_SIGN_V2, MLS_ENVELOPE_COMPACT_V1}; +pub use keypackage::{KeyPackagePayload, CTRL_SIGN_V2, MLS_ENVELOPE_COMPACT_V1, SVC_SEALED_V1}; #[cfg(all(test, feature = "std"))] mod manifest_guard_tests { diff --git a/crates/offline-protocol-sealed/tests/data/key-package-v1.vectors.json b/crates/offline-protocol-sealed/tests/data/key-package-v1.vectors.json index 052b8967c..ca4336614 100644 --- a/crates/offline-protocol-sealed/tests/data/key-package-v1.vectors.json +++ b/crates/offline-protocol-sealed/tests/data/key-package-v1.vectors.json @@ -32,13 +32,14 @@ "rich_versions": [], "data_versions": [], "ctrl_versions": [], + "svc_versions": [], "nostr_pubkey": null }, "note": "Every absent list defaults to empty, and empty selects the floor. This is rule 2 of capability negotiation expressed as a parse: absence is never an error." }, { "name": "a fully capable peer", - "json": "{\"user_id\":\"off1abc\",\"key_package_data\":[],\"remaining_lifetime_ms\":2419200000,\"timestamp_ms\":1700000000000,\"session_reset\":true,\"wire_versions\":[1],\"env_versions\":[1],\"rich_versions\":[1],\"data_versions\":[1,2,3],\"ctrl_versions\":[2],\"nostr_pubkey\":\"abcd\"}", + "json": "{\"user_id\":\"off1abc\",\"key_package_data\":[],\"remaining_lifetime_ms\":2419200000,\"timestamp_ms\":1700000000000,\"session_reset\":true,\"wire_versions\":[1],\"env_versions\":[1],\"rich_versions\":[1],\"data_versions\":[1,2,3],\"ctrl_versions\":[2],\"svc_versions\":[1],\"nostr_pubkey\":\"abcd\"}", "expect": { "user_id": "off1abc", "key_package_data": [], @@ -62,6 +63,9 @@ "ctrl_versions": [ 2 ], + "svc_versions": [ + 1 + ], "nostr_pubkey": "abcd" }, "note": "data_versions entries are read independently, so [1,2,3] is not a single level." @@ -80,6 +84,7 @@ "rich_versions": [], "data_versions": [], "ctrl_versions": [], + "svc_versions": [], "nostr_pubkey": null }, "note": "An unknown field is ignored rather than refused. A decoder that rejects it cannot be sent a newer key package, which is the frame that carries every other capability." @@ -98,6 +103,7 @@ "rich_versions": [], "data_versions": [], "ctrl_versions": [], + "svc_versions": [], "nostr_pubkey": null }, "note": "Absent and null mean the same thing on the way in. On the way out the field is omitted rather than written null." @@ -105,7 +111,7 @@ ], "emission_is_not_pinned": { "note": "Illustrative only. A conforming peer parses this; it is not required to produce these bytes, and this file does not assert that it does.", - "example": "{\"user_id\":\"off1abc\",\"key_package_data\":[],\"remaining_lifetime_ms\":0,\"timestamp_ms\":0,\"session_reset\":false,\"wire_versions\":[],\"env_versions\":[],\"rich_versions\":[],\"data_versions\":[],\"ctrl_versions\":[]}", + "example": "{\"user_id\":\"off1abc\",\"key_package_data\":[],\"remaining_lifetime_ms\":0,\"timestamp_ms\":0,\"session_reset\":false,\"wire_versions\":[],\"env_versions\":[],\"rich_versions\":[],\"data_versions\":[],\"ctrl_versions\":[],\"svc_versions\":[]}", "nostr_pubkey_absent_is_omitted_not_null": true } } diff --git a/crates/offline-protocol/src/protocol/blocking.rs b/crates/offline-protocol/src/protocol/blocking.rs index 051b3ec9c..a375a9d07 100644 --- a/crates/offline-protocol/src/protocol/blocking.rs +++ b/crates/offline-protocol/src/protocol/blocking.rs @@ -1441,6 +1441,7 @@ mod tests { rich_versions: Vec::new(), data_versions: Vec::new(), ctrl_versions: Vec::new(), + svc_versions: Vec::new(), nostr_pubkey: None, }; let content = serde_json::to_string(&reset_payload).unwrap(); diff --git a/crates/offline-protocol/src/protocol/send.rs b/crates/offline-protocol/src/protocol/send.rs index 7c34678f1..a719de982 100644 --- a/crates/offline-protocol/src/protocol/send.rs +++ b/crates/offline-protocol/src/protocol/send.rs @@ -5825,6 +5825,7 @@ impl OfflineProtocol { // the peer-capability record that closes the downgrade could then // never be written. ctrl_versions: vec![offline_protocol_sealed::CTRL_SIGN_V2], + svc_versions: Vec::new(), // Present only when the Nostr transport is installed. Advertised // regardless of the sealing kill switch, which gates what *we* // publish, not what a peer may seal to us: withholding it would diff --git a/crates/offline-protocol/src/protocol/tests/mod.rs b/crates/offline-protocol/src/protocol/tests/mod.rs index 328a131df..6c986da9b 100644 --- a/crates/offline-protocol/src/protocol/tests/mod.rs +++ b/crates/offline-protocol/src/protocol/tests/mod.rs @@ -1876,6 +1876,7 @@ fn feed_key_package(protocol: &mut OfflineProtocol, sender: &str, wire_versions: rich_versions: Vec::new(), data_versions: Vec::new(), ctrl_versions: Vec::new(), + svc_versions: Vec::new(), nostr_pubkey: None, }; let content = format!( @@ -1956,6 +1957,7 @@ fn feed_key_package_with_env(protocol: &mut OfflineProtocol, sender: &str, env_v rich_versions: Vec::new(), data_versions: Vec::new(), ctrl_versions: Vec::new(), + svc_versions: Vec::new(), nostr_pubkey: None, }; let content = format!( @@ -2152,6 +2154,7 @@ pub(crate) fn feed_key_package_with_capabilities( rich_versions, data_versions, ctrl_versions: Vec::new(), + svc_versions: Vec::new(), nostr_pubkey: None, }; let content = format!( @@ -2271,6 +2274,7 @@ fn feed_key_package_with_caps( rich_versions, data_versions: Vec::new(), ctrl_versions: Vec::new(), + svc_versions: Vec::new(), nostr_pubkey: None, }; let content = format!( @@ -2413,6 +2417,7 @@ fn key_package_message_with_nostr_pubkey( rich_versions: Vec::new(), data_versions: Vec::new(), ctrl_versions: Vec::new(), + svc_versions: Vec::new(), nostr_pubkey: nostr_pubkey.map(str::to_string), }; let content = format!( @@ -2748,6 +2753,7 @@ fn feed_key_package_with_data( rich_versions: Vec::new(), data_versions, ctrl_versions: Vec::new(), + svc_versions: Vec::new(), nostr_pubkey: None, }; let content = format!( @@ -3876,6 +3882,7 @@ fn test_process_internal_message_key_package() { rich_versions: Vec::new(), data_versions: Vec::new(), ctrl_versions: Vec::new(), + svc_versions: Vec::new(), nostr_pubkey: None, }; let content = format!( @@ -15267,6 +15274,7 @@ fn feed_session_reset_key_package( rich_versions: Vec::new(), data_versions: Vec::new(), ctrl_versions: Vec::new(), + svc_versions: Vec::new(), nostr_pubkey: None, }; let content = format!( @@ -20298,6 +20306,7 @@ fn test_lamport_clock_merge_on_internal_message() { rich_versions: Vec::new(), data_versions: Vec::new(), ctrl_versions: Vec::new(), + svc_versions: Vec::new(), nostr_pubkey: None, }; let content = format!( @@ -20509,6 +20518,7 @@ fn test_key_package_remaining_lifetime_ms() { rich_versions: Vec::new(), data_versions: Vec::new(), ctrl_versions: Vec::new(), + svc_versions: Vec::new(), nostr_pubkey: None, }; let content = format!( @@ -20594,6 +20604,7 @@ fn test_peer_key_package_persisted_and_restored_after_restart() { rich_versions: Vec::new(), data_versions: Vec::new(), ctrl_versions: Vec::new(), + svc_versions: Vec::new(), nostr_pubkey: None, }; let content = format!( @@ -20682,6 +20693,7 @@ fn test_pending_key_packages_capped_evicts_soonest_to_expire() { rich_versions: Vec::new(), data_versions: Vec::new(), ctrl_versions: Vec::new(), + svc_versions: Vec::new(), nostr_pubkey: None, }; let content = format!( @@ -20733,6 +20745,7 @@ fn test_received_key_package_lifetime_is_clamped() { rich_versions: Vec::new(), data_versions: Vec::new(), ctrl_versions: Vec::new(), + svc_versions: Vec::new(), nostr_pubkey: None, }; let content = format!( @@ -22283,6 +22296,7 @@ fn test_establish_secure_session_loads_from_storage_after_restart() { rich_versions: Vec::new(), data_versions: Vec::new(), ctrl_versions: Vec::new(), + svc_versions: Vec::new(), nostr_pubkey: None, }; let content = format!( @@ -39874,6 +39888,7 @@ fn key_package_frame_body(sender: &str, session_reset: bool, ctrl_versions: Vec< rich_versions: Vec::new(), data_versions: Vec::new(), ctrl_versions, + svc_versions: Vec::new(), nostr_pubkey: None, }; format!( @@ -41632,6 +41647,7 @@ fn the_ordinary_handshake_starts_a_latency_without_any_session_missing() { rich_versions: Vec::new(), data_versions: Vec::new(), ctrl_versions: Vec::new(), + svc_versions: Vec::new(), nostr_pubkey: None, }; let content = format!( diff --git a/docs/spec/capability-negotiation.md b/docs/spec/capability-negotiation.md index a53a1291b..14f002b8c 100644 --- a/docs/spec/capability-negotiation.md +++ b/docs/spec/capability-negotiation.md @@ -12,6 +12,7 @@ Peers advertise what they can parse in the key package payload, the body of a | `rich_versions` | End to end | Whether we may seal a `__RICH_V1__` body, and the v2 media envelope | Plain text only, extras dropped | | `data_versions` | End to end | Whether we may send `__DATA_V1__` document sync frames, and which document encoding they carry. Entry 1 is 1:1 replication; entry 2 additionally means the peer intercepts these frames inside a *group* ciphertext; entry 3 additionally means the peer speaks the blob-fetch frames and routes a data-purposed media transfer into its document layer; entry 4 additionally means the peer reads the removals a version offer carries; entry 5 additionally means it answers inside the interest an offer declares; entry 6 additionally means it carries attachment bytes inside a group; entry 7 additionally means it parses the custody receipt, so a custodian may answer its deposits | No replication with that peer | | `ctrl_versions` | End to end | Which control-frame signing payload we build for this peer. Entry 2 means the peer verifies `offline-ctrl-v2`, which binds the frame's timestamp | Build `offline-ctrl-v1`, which states no freshness | +| `svc_versions` | End to end | Whether a service request or response may travel sealed inside `__MLS_ENC__` toward this peer. Entry 1 means the peer routes a decrypted `__SVC_REQ__` or `__SVC_RESP__` into its service layer, with the MLS-authenticated sender as the requester | Signed plaintext service frames | | `nostr_pubkey` | End to end | Which key metadata is sealed to on the Nostr path | Seal to the publicly computable key | The key package payload also carries `user_id`, the MLS key package itself, a @@ -55,9 +56,18 @@ Where a capability gates a security-relevant payload, the non-capable path **drops** the payload rather than sending it in a weaker form. Rich extras toward a non-capable recipient are dropped, never sent in cleartext. +`svc_versions` is the one stated exception. Service request and response +bodies were signed plaintext before the capability existed, so that form is +the floor every peer depends on, and dropping a request toward a peer that +cannot parse the sealed form would break interoperability rather than protect +anything. A sender therefore falls back to the signed plaintext form toward a +peer that has not advertised entry 1, and toward a capable peer before their +session is confirmed. See [the exception](#the-exception-svc_versions) for +what that leaves exposed. + ## Persistence -`env_versions`, `rich_versions` and `data_versions` are **end to end**: they +`env_versions`, `rich_versions`, `data_versions` and `svc_versions` are **end to end**: they describe what a recipient parses after an arbitrary number of relay hops. They MUST persist across restarts and be restored before any queued send flushes. Otherwise a restart silently downgrades every established peer until the next @@ -200,6 +210,28 @@ Stripping it remains possible and downgrades to the bootstrap key. That is a privacy downgrade, not a disclosure to the attacker, and one they could equally achieve by dropping the packet. +### The exception: `svc_versions` + +This field decides whether a service body is readable by every hop, so like +`nostr_pubkey` it sits next to a confidentiality decision and is honoured +**only from a signed key package**. It is signed today whenever it reaches +dispatch, for the same reason `nostr_pubkey` is; the receiver checks anyway, so +the posture does not depend on the gate staying that way. + +Three things remain exposed, and an application that cannot accept them sends +its bodies as ordinary sealed messages instead: + +- **Stripping** the field downgrades that peer's service bodies to signed + plaintext until a genuine package arrives. +- **Replaying** an older signed package from before the peer upgraded does the + same, inside the control-frame freshness window. +- **The first request** to a provider this device has no confirmed session with + leaves in signed plaintext, because there is nothing yet to seal it with. + +A peer that sends a **sealed** service frame has proved it routes them, which +no replay can fake, so a receiver MAY treat that frame as an advertisement of +entry 1 for the sender. The reference implementation does, in memory. + ## Relay capabilities The relay advertises its own capability set, which is separate from peer @@ -247,6 +279,7 @@ A minimal leaf advertises little and works fully: | `rich_versions` | Empty | Peers send plain text and drop extras rather than sending them in a weaker form | | `data_versions` | Empty | No document replication with this peer | | `ctrl_versions` | `[2]`, always | Not optional, unlike every other row: a leaf verifies the freshness-bound payload on every control frame except `__MLS_KEY_PKG__`, so a peer that never learned this and kept sending the older one would have its Welcome refused and never complete a pairing | +| `svc_versions` | Empty | A leaf has no service registry; peers send it signed plaintext service frames, which it never receives in practice | | `nostr_pubkey` | Absent unless the device is reachable over that carrier | Peers seal to the publicly computable key | Two consequences follow from the universal rules above, and both are easy to diff --git a/tools/spec-vectors/generate.py b/tools/spec-vectors/generate.py index 409473789..9c51efe10 100644 --- a/tools/spec-vectors/generate.py +++ b/tools/spec-vectors/generate.py @@ -1268,6 +1268,7 @@ def build_key_package_vectors() -> dict: "rich_versions": [], "data_versions": [], "ctrl_versions": [], + "svc_versions": [], "nostr_pubkey": None, }, "note": "Every absent list defaults to empty, and empty selects " @@ -1280,7 +1281,7 @@ def build_key_package_vectors() -> dict: '"remaining_lifetime_ms":2419200000,"timestamp_ms":1700000000000,' '"session_reset":true,"wire_versions":[1],"env_versions":[1],' '"rich_versions":[1],"data_versions":[1,2,3],"ctrl_versions":[2],' - '"nostr_pubkey":"abcd"}', + '"svc_versions":[1],"nostr_pubkey":"abcd"}', "expect": { "user_id": "off1abc", "key_package_data": [], @@ -1292,6 +1293,7 @@ def build_key_package_vectors() -> dict: "rich_versions": [1], "data_versions": [1, 2, 3], "ctrl_versions": [2], + "svc_versions": [1], "nostr_pubkey": "abcd", }, "note": "data_versions entries are read independently, so [1,2,3] " @@ -1312,6 +1314,7 @@ def build_key_package_vectors() -> dict: "rich_versions": [], "data_versions": [], "ctrl_versions": [], + "svc_versions": [], "nostr_pubkey": None, }, "note": "An unknown field is ignored rather than refused. A " @@ -1333,6 +1336,7 @@ def build_key_package_vectors() -> dict: "rich_versions": [], "data_versions": [], "ctrl_versions": [], + "svc_versions": [], "nostr_pubkey": None, }, "note": "Absent and null mean the same thing on the way in. On " @@ -1346,7 +1350,7 @@ def build_key_package_vectors() -> dict: "example": '{"user_id":"off1abc","key_package_data":[],' '"remaining_lifetime_ms":0,"timestamp_ms":0,"session_reset":false,' '"wire_versions":[],"env_versions":[],"rich_versions":[],' - '"data_versions":[],"ctrl_versions":[]}', + '"data_versions":[],"ctrl_versions":[],"svc_versions":[]}', "nostr_pubkey_absent_is_omitted_not_null": True, }, } From 27da15c6f616a94b74ce94994749be0358ee49be Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Fri, 9 Oct 2026 14:42:26 +0530 Subject: [PATCH 2/7] feat(protocol): service bodies are sealed toward peers that route them A service request or response is sealed inside the recipient's 1:1 MLS session when our switch is on, the peer advertised svc_versions entry 1 in a signed key package (or proved it by sending a sealed service frame), and the session is confirmed. Otherwise it leaves as today's signed plaintext, and the encrypt attempt starts the session the way a direct message does, so the next request seals. A decrypted __SVC_ frame from a session slot is routed into the service layer with the MLS-authenticated sender, after the session confirm, so the automatic not_found answer to a sealed request is sealed too. A sealed frame stages re-seal provenance, so a resend after a re-key is sealed again rather than replayed dead. New EncryptionConfig switch encrypt_service_messages, default on. --- crates/offline-protocol/src/config.rs | 47 + .../offline-protocol/src/protocol/blocking.rs | 1 + .../src/protocol/config_accessors.rs | 12 +- .../src/protocol/message_dispatch.rs | 73 +- crates/offline-protocol/src/protocol/mod.rs | 21 + crates/offline-protocol/src/protocol/send.rs | 50 +- .../src/protocol/service_seal.rs | 97 ++ .../offline-protocol/src/protocol/storage.rs | 10 + .../src/protocol/tests/mod.rs | 34 +- .../src/protocol/tests/service_seal.rs | 875 ++++++++++++++++++ crates/offline-protocol/src/protocol/types.rs | 15 + 11 files changed, 1191 insertions(+), 44 deletions(-) create mode 100644 crates/offline-protocol/src/protocol/service_seal.rs create mode 100644 crates/offline-protocol/src/protocol/tests/service_seal.rs diff --git a/crates/offline-protocol/src/config.rs b/crates/offline-protocol/src/config.rs index c7006bb59..02f6df465 100644 --- a/crates/offline-protocol/src/config.rs +++ b/crates/offline-protocol/src/config.rs @@ -217,6 +217,32 @@ pub struct EncryptionConfig { /// is bounded re-key churn on a single pair: delivery delayed, never lost. /// `OfflineProtocol::schedule_session_rekey` carries the full analysis. pub crypto_recovery_enabled: bool, + + /// Whether service request and response bodies (`__SVC_REQ__`, + /// `__SVC_RESP__`) are sealed inside the recipient's 1:1 MLS session + /// instead of crossing every hop as signed plaintext. + /// + /// Defaults to `true`. It takes effect only toward a peer that advertised + /// `svc_versions` entry 1 in a signed key package (or proved it by sending + /// a sealed service frame) and only once the session with that peer is + /// confirmed. Toward everyone else, and before the session confirms, the + /// body still leaves as signed plaintext, because that form is the floor + /// every older peer depends on: the first request to a provider this + /// device has never met is readable by the hops that carry it. Discovery + /// (`__SVC_DISC_Q__` / `__SVC_DISC_R__`) is never sealed, since every hop + /// must read it to match and forward. + /// + /// Gates advertising, recording and emitting. Routing an inbound sealed + /// service frame is unconditional, so switching this off never makes a + /// peer's sealed request surface as chat text. + #[serde(default = "default_encrypt_service_messages")] + pub encrypt_service_messages: bool, +} + +/// The default for [`EncryptionConfig::encrypt_service_messages`], by name so +/// a config serialized before the field existed still parses, and parses on. +fn default_encrypt_service_messages() -> bool { + true } impl Default for EncryptionConfig { @@ -232,6 +258,7 @@ impl Default for EncryptionConfig { compact_envelope_enabled: true, rich_payload_enabled: true, crypto_recovery_enabled: true, + encrypt_service_messages: true, } } } @@ -248,6 +275,7 @@ impl EncryptionConfig { compact_envelope_enabled: false, rich_payload_enabled: false, crypto_recovery_enabled: false, + encrypt_service_messages: false, } } } @@ -1737,6 +1765,7 @@ mod tests { // SEC-M3: encryption is required by default; plaintext is an // explicit opt-out, never a silent fallback. assert!(encryption.require_encryption); + assert!(encryption.encrypt_service_messages); assert_eq!(encryption.pending_queue.max_pending_per_peer, 64); assert_eq!(encryption.pending_queue.max_pending_global, 4096); assert_eq!( @@ -1756,6 +1785,24 @@ mod tests { assert!(!encryption.auto_key_exchange); assert!(!encryption.store_pending); assert!(!encryption.require_encryption); + assert!(!encryption.encrypt_service_messages); + } + + /// A config serialized before `encrypt_service_messages` existed still + /// parses, and parses with the switch on: without the named default a + /// stored config would fail to load the release that added the field. + #[test] + fn an_encryption_config_without_the_service_switch_parses_with_it_on() { + let mut value = + serde_json::to_value(EncryptionConfig::default()).expect("the config serializes"); + value + .as_object_mut() + .expect("an object") + .remove("encrypt_service_messages") + .expect("the field was serialized"); + let parsed: EncryptionConfig = + serde_json::from_value(value).expect("an older config still parses"); + assert!(parsed.encrypt_service_messages); } #[test] diff --git a/crates/offline-protocol/src/protocol/blocking.rs b/crates/offline-protocol/src/protocol/blocking.rs index a375a9d07..825f37b3a 100644 --- a/crates/offline-protocol/src/protocol/blocking.rs +++ b/crates/offline-protocol/src/protocol/blocking.rs @@ -146,6 +146,7 @@ impl OfflineProtocol { self.peer_ctrl_freshness.remove(user_id); self.peer_rich_payload.remove(user_id); self.peer_rich_attested.remove(user_id); + self.peer_svc_sealed.remove(user_id); self.forget_data_sync_peer(user_id); // The Nostr sealing key lives in the transport, not in one of the sets // above, so it needs its own clear — the durable record is deleted by diff --git a/crates/offline-protocol/src/protocol/config_accessors.rs b/crates/offline-protocol/src/protocol/config_accessors.rs index 79c800ec3..9ad55aa4a 100644 --- a/crates/offline-protocol/src/protocol/config_accessors.rs +++ b/crates/offline-protocol/src/protocol/config_accessors.rs @@ -451,7 +451,9 @@ impl OfflineProtocol { body: &str, ) -> Result { // Service requests are internal control messages (not user content), - // so they are exempt from require_encryption. + // so they are exempt from require_encryption: sealed toward a peer + // that routes the sealed form, signed plaintext otherwise (see + // `send_service_frame`). Self::validate_outbound_recipient(provider)?; let result = self @@ -459,7 +461,7 @@ impl OfflineProtocol { .send_service_request(provider, service_id, method, body) .map_err(Error::Service)?; let msg = result.message; - self.send_internal_message(&msg.recipient, msg.content, msg.priority)?; + self.send_service_frame(&msg.recipient, msg.content, msg.priority)?; Ok(result.request_id) } @@ -473,7 +475,9 @@ impl OfflineProtocol { body: &str, ) -> Result { // Service responses are internal control messages (not user content), - // so they are exempt from require_encryption. + // so they are exempt from require_encryption: sealed toward a peer + // that routes the sealed form, signed plaintext otherwise (see + // `send_service_frame`). Self::validate_outbound_recipient(requester)?; let result = self @@ -481,7 +485,7 @@ impl OfflineProtocol { .respond_to_service_request(request_id, requester, service_id, status, body) .map_err(Error::Service)?; let msg = result.message; - let message_id = self.send_internal_message(&msg.recipient, msg.content, msg.priority)?; + let message_id = self.send_service_frame(&msg.recipient, msg.content, msg.priority)?; Ok(message_id) } } diff --git a/crates/offline-protocol/src/protocol/message_dispatch.rs b/crates/offline-protocol/src/protocol/message_dispatch.rs index 9a9d05b63..2b96ca47c 100644 --- a/crates/offline-protocol/src/protocol/message_dispatch.rs +++ b/crates/offline-protocol/src/protocol/message_dispatch.rs @@ -16,7 +16,8 @@ use crate::SessionStateError; use chrono::Utc; use offline_protocol_core::{Message, MessagePriority}; use offline_protocol_mls::{EncryptedMessage, WelcomeMessage}; -use offline_protocol_services::ServiceAction; +use offline_protocol_sealed::SVC_SEALED_V1; +use offline_protocol_services::{ServiceAction, SVC_MESSAGE_PREFIX}; use offline_protocol_transport::TransportType; use tracing::{debug, error, info, warn}; @@ -46,10 +47,11 @@ impl OfflineProtocol { /// Handles an incoming MLS key package message. /// /// `signed` is the security gate's verdict on this specific frame (see - /// [`ControlGateOutcome::Proceed`]). It gates **only** `nostr_pubkey`, - /// which is consumed as a destination key rather than as a feature hint — - /// see the comment at that use for why the distinction earns a separate - /// trust level from the capability lists around it. + /// [`ControlGateOutcome::Proceed`]). It gates **only** `nostr_pubkey` and + /// `svc_versions`: the first is consumed as a destination key rather than + /// as a feature hint, and the second decides whether a service body is + /// readable by every hop. See the comments at those uses for why each + /// earns a separate trust level from the capability lists around them. /// /// `freshness` gates **only** `session_reset`, which is the one field here /// that destroys state: `Some(stamp)` when the frame's signature covers @@ -153,6 +155,21 @@ impl OfflineProtocol { self.peer_ctrl_freshness.remove(sender); } + // Record whether this peer routes a sealed service frame, so a + // request or response addressed to them may travel inside + // `__MLS_ENC__`. Same shape as the lists above, with one + // difference: honoured only from a signed key package, like + // `nostr_pubkey` below, because this list sits beside a + // confidentiality decision rather than a rendering one. The gate + // refuses unsigned key packages today, so the check costs a branch + // and keeps the posture from depending on that staying true. + let advertises_svc_sealed = signed && payload.svc_versions.contains(&SVC_SEALED_V1); + if self.config.encryption.encrypt_service_messages && advertises_svc_sealed { + self.record_svc_sealed_peer(sender); + } else { + self.peer_svc_sealed.remove(sender); + } + // Record whether this peer replicates documents, so the data // layer may push `__DATA_V1__` frames to them. Same shape again: // gated by our own kill switch, removed when a fresh key package @@ -345,6 +362,7 @@ impl OfflineProtocol { &payload.rich_versions, &payload.data_versions, &payload.ctrl_versions, + if signed { &payload.svc_versions } else { &[] }, advertised_nostr_pubkey.as_deref(), ); if caps.is_any() { @@ -1338,7 +1356,17 @@ impl OfflineProtocol { sender_owned.clone(), format!("is_confirm={}", is_session_confirm), )); - let surfaced = if is_session_confirm { + // A service request or response sealed in this 1:1 + // session. Handled only after the confirm below, so a reply + // the service layer sends from inside the handler (the + // automatic `not_found`) finds the session confirmed and is + // sealed too; handled before it, the owner's first decrypt + // from this peer would answer in plaintext. + let mut text = text; + let sealed_service_frame = (group_id.starts_with("session:") + && text.starts_with(SVC_MESSAGE_PREFIX)) + .then(|| std::mem::take(&mut text)); + let surfaced = if is_session_confirm || sealed_service_frame.is_some() { Some(InternalMessageResult::Consumed) } else if let Some(body) = text.strip_prefix(internal_prefixes::DATA_V1) { // A document sync frame. Consumed either way, and @@ -1365,6 +1393,9 @@ impl OfflineProtocol { Some(InternalMessageResult::Decrypted(text)) }; self.confirm_session_from_successful_decrypt(&sender_owned, &group_id); + if let Some(frame) = sealed_service_frame { + self.handle_sealed_service_frame(&sender_owned, &frame, message); + } surfaced } DecryptResult::Empty => { @@ -2196,6 +2227,31 @@ impl OfflineProtocol { } /// Handles service discovery and request/response messages. + /// Routes a service frame that arrived sealed in the 1:1 session with + /// `sender` into the service layer. + /// + /// `sender` is the MLS-authenticated one: the decrypt checked the + /// credential that produced the ciphertext against the wire sender, so the + /// provider's `service_request_received.sender` is proven by the session + /// rather than by a signature. The frame is never re-fed through + /// `process_internal_message_via`, whose gate would refuse an unsigned + /// `__SVC_` frame. Routing is unconditional (parsing is never gated by a + /// kill switch), so a peer's sealed request does not surface as chat text + /// here when our switch is off. A group-sealed service frame is not routed + /// here: the service protocol has no group form. + /// + /// A peer that sealed a service frame has proved it routes them, and no + /// replay of an older key package can fake that, so with the switch on the + /// sender is recorded as able to receive the sealed form. That closes the + /// window in which a provider would otherwise answer a sealed request in + /// plaintext because the requester's fresh key package has not landed yet. + fn handle_sealed_service_frame(&mut self, sender: &str, content: &str, message: &Message) { + if self.config.encryption.encrypt_service_messages { + self.record_svc_sealed_peer(sender); + } + self.handle_service_message(sender, content, message); + } + pub(crate) fn handle_service_message( &mut self, sender: &str, @@ -2222,8 +2278,11 @@ impl OfflineProtocol { messages_to_send, events_to_emit, } => { + // Through the chooser, so the automatic `not_found` answer to a + // sealed request is sealed too. Discovery forwards pass + // through it unchanged: it seals requests and responses only. for msg in messages_to_send { - let _ = self.send_internal_message(&msg.recipient, msg.content, msg.priority); + let _ = self.send_service_frame(&msg.recipient, msg.content, msg.priority); } if let Ok(state) = lock_shared_state(&self.shared_state) { for svc_event in events_to_emit { diff --git a/crates/offline-protocol/src/protocol/mod.rs b/crates/offline-protocol/src/protocol/mod.rs index 50ee18c43..385e0d128 100644 --- a/crates/offline-protocol/src/protocol/mod.rs +++ b/crates/offline-protocol/src/protocol/mod.rs @@ -20,6 +20,7 @@ pub(crate) mod reachability; mod receive; mod security; mod send; +mod service_seal; mod session; pub(crate) mod state_crypto; mod storage; @@ -418,6 +419,23 @@ pub struct OfflineProtocol { /// rich extras — never a cleartext fallback. peer_rich_payload: std::collections::HashSet, + /// Peers that route a sealed service frame ([`SVC_SEALED_V1`] in + /// `svc_versions`), so a service request or response addressed to them + /// may travel inside `__MLS_ENC__` rather than as signed plaintext. + /// + /// Learned two ways. From a **signed** key package, like the sets above + /// (persisted as [`PeerCapabilities`], restored on `initialize_mls`); and + /// from a sealed service frame the peer sent us, which proves it more + /// strongly than any key package can, since an older package can be + /// replayed and a sealed frame from before the upgrade cannot exist. The + /// second is in memory only: the durable record stays what the peer + /// advertised. Bounded like `key_package_sent_to`. Forgetting a peer + /// costs signed plaintext service bodies toward them until either signal + /// arrives again. + /// + /// [`SVC_SEALED_V1`]: offline_protocol_sealed::SVC_SEALED_V1 + pub(crate) peer_svc_sealed: std::collections::HashSet, + /// Peers whose key package advertised replicated-document sync /// ([`DATA_SYNC_V1`] in `data_versions`), so the send path may push /// `__DATA_V1__` frames to them. Same lifecycle as the two capability @@ -1162,6 +1180,7 @@ impl OfflineProtocol { control_freshness_peers: std::collections::HashSet::new(), control_reset_watermark: std::collections::HashMap::new(), peer_rich_payload: std::collections::HashSet::new(), + peer_svc_sealed: std::collections::HashSet::new(), peer_data_sync: std::collections::HashSet::new(), peer_data_group: std::collections::HashSet::new(), peer_data_group_attested: std::collections::HashSet::new(), @@ -1372,6 +1391,7 @@ impl OfflineProtocol { let previous_control_freshness_peers = self.control_freshness_peers.clone(); let previous_control_reset_watermark = self.control_reset_watermark.clone(); let previous_peer_rich_payload = self.peer_rich_payload.clone(); + let previous_peer_svc_sealed = self.peer_svc_sealed.clone(); let previous_peer_data_sync = self.peer_data_sync.clone(); let previous_peer_rich_attested = self.peer_rich_attested.clone(); let previous_peer_data_group = self.peer_data_group.clone(); @@ -1537,6 +1557,7 @@ impl OfflineProtocol { self.control_freshness_peers = previous_control_freshness_peers; self.control_reset_watermark = previous_control_reset_watermark; self.peer_rich_payload = previous_peer_rich_payload; + self.peer_svc_sealed = previous_peer_svc_sealed; self.peer_data_sync = previous_peer_data_sync; self.peer_rich_attested = previous_peer_rich_attested; self.peer_data_group = previous_peer_data_group; diff --git a/crates/offline-protocol/src/protocol/send.rs b/crates/offline-protocol/src/protocol/send.rs index a719de982..419dad4bc 100644 --- a/crates/offline-protocol/src/protocol/send.rs +++ b/crates/offline-protocol/src/protocol/send.rs @@ -533,9 +533,47 @@ impl OfflineProtocol { let mut message = self.create_message(recipient, content, Some(priority), None)?; self.sign_control_message(&mut message)?; + self.dispatch_internal_message(message) + } + + /// Sends an internal frame already sealed for `recipient` (an + /// `__MLS_ENC__` body), unsigned. + /// + /// Not signed because the data plane is not: a 1:1 envelope is + /// authenticated by MLS, and the security gate admits `__MLS_ENC__` + /// without a signature for exactly that reason. `reseal` carries the + /// plaintext so a resend after a desync re-key is sealed again against the + /// new epoch rather than replaying ciphertext nobody can open any more. + pub(super) fn send_sealed_internal_message( + &mut self, + recipient: &str, + sealed: String, + priority: MessagePriority, + reseal: Option, + ) -> Result { + { + let state = lock_shared_state(&self.shared_state)?; + if state.state != ProtocolState::Running { + return Err(Error::NotStarted); + } + } + + let message = self.create_message(recipient, sealed, Some(priority), None)?; + if let Some(reseal) = reseal { + self.stage_outbox_reseal(&message.id, reseal); + } + self.dispatch_internal_message(message) + } + + /// Transmits a built internal frame: dedup, send, the success or failure + /// bookkeeping (outbox, ACK, retry) and the transport-switch event. Shared + /// by the signed and the sealed internal senders so the two cannot drift. + fn dispatch_internal_message(&mut self, message: Message) -> Result { let message_id = message.id.clone(); if self.deduplicator.is_duplicate(&message_id) { + // Staged re-seal provenance must never outlive its message. + self.pending_reseal.remove(&message_id); return Err(crate::Error::Other("Duplicate message".to_string())); } @@ -554,7 +592,7 @@ impl OfflineProtocol { self.handle_send_failure(&message, current_transport.or(previous_transport))?; warn!( message_id = %message.id, - recipient = %recipient, + recipient = %message.recipient, error = %err, "Internal message send failed, message deferred" ); @@ -5825,7 +5863,15 @@ impl OfflineProtocol { // the peer-capability record that closes the downgrade could then // never be written. ctrl_versions: vec![offline_protocol_sealed::CTRL_SIGN_V2], - svc_versions: Vec::new(), + // Advertised under the switch only: a peer told we route the + // sealed form seals toward us, and with the switch off we still + // route it (parsing is unconditional), so this gates only whether + // peers are invited to. + svc_versions: if self.config.encryption.encrypt_service_messages { + vec![offline_protocol_sealed::SVC_SEALED_V1] + } else { + Vec::new() + }, // Present only when the Nostr transport is installed. Advertised // regardless of the sealing kill switch, which gates what *we* // publish, not what a peer may seal to us: withholding it would diff --git a/crates/offline-protocol/src/protocol/service_seal.rs b/crates/offline-protocol/src/protocol/service_seal.rs new file mode 100644 index 000000000..4a08ce2f2 --- /dev/null +++ b/crates/offline-protocol/src/protocol/service_seal.rs @@ -0,0 +1,97 @@ +//! Service request and response bodies sealed inside the 1:1 MLS session. +//! +//! `__SVC_REQ__` and `__SVC_RESP__` were signed plaintext before this existed, +//! readable by every device that carried them. They are now sealed toward a +//! peer that routes the sealed form (it advertised `svc_versions` entry 1 in a +//! signed key package, or sent us a sealed service frame) once the session with +//! that peer is confirmed. Toward everyone else the signed plaintext form is +//! still sent, because it is the floor every older peer depends on. +//! +//! Discovery (`__SVC_DISC_Q__` / `__SVC_DISC_R__`) never comes through here +//! sealed: it is a gossip every hop must read to match and forward, and it has +//! no single recipient whose session could seal it. + +use super::{OfflineProtocol, OutboxReseal, MAX_KEY_PACKAGE_SENT_TO}; +use crate::{Error, Result}; +use offline_protocol_core::{ContentType, MessageId, MessagePriority}; +use offline_protocol_services::{SVC_REQUEST, SVC_RESPONSE}; +use tracing::{debug, warn}; + +impl OfflineProtocol { + /// Sends a service frame to `recipient`, sealed when it can be and signed + /// plaintext otherwise. Every service send goes through here, including + /// the replies the service layer builds on its own (the automatic + /// `not_found`), so a sealed request is never answered in the clear. + /// + /// The fallback toward a capable peer without a confirmed session is a + /// stated exception to the capability rule that a downgrade loses the + /// feature rather than the confidentiality: dropping the request instead + /// would break the service for a window the application cannot see. The + /// encrypt attempt is also what starts that session, exactly as it does + /// for a direct message, so the next request seals. + pub(crate) fn send_service_frame( + &mut self, + recipient: &str, + content: String, + priority: MessagePriority, + ) -> Result { + if !self.should_seal_service_frame(recipient, &content) { + return self.send_internal_message(recipient, content, priority); + } + + match self.encrypt_content_for_recipient(recipient, &content, priority) { + Ok(sealed) => { + let reseal = OutboxReseal { + content, + priority, + reply_to_msg: None, + forwarded_from: None, + content_type: ContentType::Text, + media_metadata: None, + rich: None, + }; + self.send_sealed_internal_message(recipient, sealed, priority, Some(reseal)) + } + Err(Error::SessionNotReady(_)) => { + debug!( + recipient = %recipient, + "Session with a sealed-service peer not confirmed yet; service frame sent signed" + ); + self.send_internal_message(recipient, content, priority) + } + Err(err) => { + // A confirmed session that then fails to encrypt is the stale + // confirmed-cache case. Losing the request is worse than the + // exposure this fallback already accepts for an unconfirmed one. + warn!( + recipient = %recipient, + error = %err, + "Sealing a service frame failed; sent signed instead" + ); + self.send_internal_message(recipient, content, priority) + } + } + } + + /// Whether `content` addressed to `recipient` should be attempted sealed: + /// a request or response (never discovery), our switch on, MLS available, + /// and a peer known to route the sealed form. + fn should_seal_service_frame(&self, recipient: &str, content: &str) -> bool { + (content.starts_with(SVC_REQUEST) || content.starts_with(SVC_RESPONSE)) + && self.config.encryption.encrypt_service_messages + && self.should_auto_encrypt() + && self.peer_svc_sealed.contains(recipient) + } + + /// Records that `peer` routes sealed service frames, bounded like + /// `key_package_sent_to`: the set is keyed by a sender id, and forgetting a + /// peer costs only signed plaintext toward them until the next signal. + pub(super) fn record_svc_sealed_peer(&mut self, peer: &str) { + if !self.peer_svc_sealed.contains(peer) + && self.peer_svc_sealed.len() >= MAX_KEY_PACKAGE_SENT_TO + { + self.peer_svc_sealed.clear(); + } + self.peer_svc_sealed.insert(peer.to_string()); + } +} diff --git a/crates/offline-protocol/src/protocol/storage.rs b/crates/offline-protocol/src/protocol/storage.rs index 8509f5ae3..7a8e39e29 100644 --- a/crates/offline-protocol/src/protocol/storage.rs +++ b/crates/offline-protocol/src/protocol/storage.rs @@ -3199,6 +3199,16 @@ impl OfflineProtocol { { self.peer_rich_payload.insert(peer_id.clone()); } + // Skipping this would send a peer met before the restart its + // service bodies in signed plaintext until its next key package, + // which is a confidentiality loss with no symptom. + if self.config.encryption.encrypt_service_messages + && caps + .svc_versions + .contains(&offline_protocol_sealed::SVC_SEALED_V1) + { + self.peer_svc_sealed.insert(peer_id.clone()); + } // Not gated on a config flag, unlike its neighbours. There is no // kill switch for *which payload we sign*: signing the older one // at a peer that has proved it signs the newer is exactly the diff --git a/crates/offline-protocol/src/protocol/tests/mod.rs b/crates/offline-protocol/src/protocol/tests/mod.rs index 6c986da9b..018330be2 100644 --- a/crates/offline-protocol/src/protocol/tests/mod.rs +++ b/crates/offline-protocol/src/protocol/tests/mod.rs @@ -10,6 +10,7 @@ mod file_stores; mod leaf_pairing; mod mesh_session; mod per_send_app_id; +mod service_seal; use super::*; use crate::constants::{ACK_FOR_KEY, ACK_HOP_COUNT_KEY, ACK_TRANSPORT_KEY}; @@ -3134,6 +3135,7 @@ fn peer_capability_restore_prunes_overflow() { rich_versions: vec![RICH_PAYLOAD_V1], data_versions: Vec::new(), ctrl_versions: Vec::new(), + svc_versions: Vec::new(), nostr_pubkey: None, }; let encoded = serde_json::to_vec(&caps).unwrap(); @@ -3275,6 +3277,7 @@ fn peer_capability_restore_prefers_session_peers() { rich_versions: vec![RICH_PAYLOAD_V1], data_versions: Vec::new(), ctrl_versions: Vec::new(), + svc_versions: Vec::new(), nostr_pubkey: None, }; let encoded = serde_json::to_vec(&caps).unwrap(); @@ -22644,37 +22647,6 @@ fn test_discover_services_no_peers() { .contains_key(&query_id)); } -#[test] -fn test_require_encryption_allows_service_control_messages() { - let mut config = create_test_config(); - config.encryption.enabled = true; - config.encryption.require_encryption = true; - - let mut protocol = OfflineProtocol::new(config).unwrap(); - - let mock_transport = MockTransport::new(TransportType::BLE); - mock_transport.start().unwrap(); - protocol - .transport_manager_mut() - .add_transport(TransportType::BLE, Box::new(mock_transport)); - protocol.start().unwrap(); - - // Service messages are internal protocol messages (not user content), - // so they must work even with require_encryption=true. - // discover_services with no known peers returns Ok with empty broadcast. - let discover_result = protocol.discover_services(None); - assert!(discover_result.is_ok()); - - // Add a known peer so service request has a target - protocol.on_neighbor_discovered("bob"); - let request_result = protocol.send_service_request("bob", "echo.v1", "ping", "{}"); - assert!(request_result.is_ok()); - - let respond_result = - protocol.respond_to_service_request("req-1", "alice", "echo.v1", "ok", "pong"); - assert!(respond_result.is_ok()); -} - #[test] fn test_known_peers_capacity_evicts_least_recently_seen() { let mut protocol = OfflineProtocol::new(create_test_config()).unwrap(); diff --git a/crates/offline-protocol/src/protocol/tests/service_seal.rs b/crates/offline-protocol/src/protocol/tests/service_seal.rs new file mode 100644 index 000000000..b0db150d5 --- /dev/null +++ b/crates/offline-protocol/src/protocol/tests/service_seal.rs @@ -0,0 +1,875 @@ +//! Service request and response bodies sealed inside the 1:1 MLS session +//! (issue #533). +//! +//! What is pinned: between two peers that both advertise `svc_versions` +//! entry 1, neither body is readable on the wire; the provider's +//! `service_request_received.sender` is the MLS-authenticated sender; a peer +//! that does not advertise it still gets signed plaintext; a request before +//! the session confirms falls back and starts the session; the automatic +//! answer to a sealed request is sealed even on the owner's first decrypt; and +//! a sealed request retried after a re-key is sealed again rather than +//! replayed dead. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use offline_protocol_core::{Message, ServiceDescriptor, ServiceId}; +use offline_protocol_services::{ + SVC_DISCOVER_QUERY, SVC_MESSAGE_PREFIX, SVC_REQUEST, SVC_RESPONSE, +}; +use offline_protocol_transport::{MockTransport, Transport, TransportType}; + +use super::{ + create_test_config, create_test_config_for_user, establish_confirmed_session, signed_frame, +}; +use crate::mls::InMemoryStorage; +use crate::protocol::prefixes::internal_prefixes; +use crate::protocol::types::KeyPackagePayload; +use crate::protocol::{OfflineProtocol, TestProtocolStateStorage}; +use crate::test_identity::id; +use crate::{Event, ProtocolConfig}; +use offline_protocol_sealed::SVC_SEALED_V1; + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +/// One device: a protocol on a mock link, with every event it emitted. +struct Node { + protocol: OfflineProtocol, + transport: MockTransport, + address: String, + events: Arc>>, +} + +impl Node { + fn new(label: &str, config: ProtocolConfig) -> Self { + let mut protocol = OfflineProtocol::new(config).expect("protocol"); + protocol + .initialize_mls( + crate::test_identity::seeded_storage(label), + Arc::new(TestProtocolStateStorage { + storage: Arc::new(InMemoryStorage::new()), + }), + ) + .expect("initialize_mls"); + let mock = MockTransport::new(TransportType::BLE); + mock.start().expect("transport start"); + let transport = mock.clone(); + protocol + .transport_manager_mut() + .add_transport(TransportType::BLE, Box::new(mock)); + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = events.clone(); + protocol.on_event(move |event| sink.lock().unwrap().push(event)); + protocol.start().expect("start"); + Self { + protocol, + transport, + address: id(label), + events, + } + } + + fn register(&mut self, service: &str) { + self.protocol + .register_service(ServiceDescriptor { + service_id: ServiceId::new(service).unwrap(), + version: "1.0".to_string(), + capabilities: HashMap::new(), + }) + .unwrap(); + } + + fn requests(&self) -> Vec<(String, String, String)> { + self.events + .lock() + .unwrap() + .iter() + .filter_map(|event| match event { + Event::ServiceRequestReceived { + request_id, + body, + sender, + .. + } => Some((request_id.clone(), body.clone(), sender.clone())), + _ => None, + }) + .collect() + } + + fn responses(&self) -> Vec<(String, String, String)> { + self.events + .lock() + .unwrap() + .iter() + .filter_map(|event| match event { + Event::ServiceResponseReceived { + status, + body, + provider_peer_id, + .. + } => Some((status.clone(), body.clone(), provider_peer_id.clone())), + _ => None, + }) + .collect() + } + + fn chat(&self) -> Vec { + self.events + .lock() + .unwrap() + .iter() + .filter_map(|event| match event { + Event::MessageReceived { content, .. } => Some(content.clone()), + _ => None, + }) + .collect() + } +} + +/// Two devices in range of each other, every frame recorded as it crosses. +struct Pair { + alice: Node, + bob: Node, + /// Every frame that crossed the link, as `(from_alice, frame)`. + carried: Vec<(bool, Message)>, +} + +fn config(label: &str) -> ProtocolConfig { + let mut config = create_test_config_for_user(label); + config.encryption.enabled = true; + config.encryption.auto_key_exchange = true; + config.encryption.store_pending = true; + config +} + +impl Pair { + fn new(alice: ProtocolConfig, bob: ProtocolConfig) -> Self { + let mut pair = Self { + alice: Node::new("alice", alice), + bob: Node::new("bob", bob), + carried: Vec::new(), + }; + let (a, b) = (pair.alice.address.clone(), pair.bob.address.clone()); + pair.alice.transport.add_connected_peer(b.clone(), -55); + pair.bob.transport.add_connected_peer(a.clone(), -55); + pair.alice.protocol.on_neighbor_discovered(&b); + pair.bob.protocol.on_neighbor_discovered(&a); + pair + } + + /// One round: both devices handle what they hold and tick, then whatever + /// each put on the link reaches the other. + fn step(&mut self) -> usize { + for node in [&mut self.alice, &mut self.bob] { + while node.protocol.receive_message().is_some() {} + node.protocol.last_reconciliation_at = None; + node.protocol.process().expect("process"); + } + let from_alice = self.alice.transport.sent_messages(); + self.alice.transport.clear_sent_messages(); + let from_bob = self.bob.transport.sent_messages(); + self.bob.transport.clear_sent_messages(); + let moved = from_alice.len() + from_bob.len(); + for frame in from_alice { + self.carried.push((true, frame.clone())); + self.bob + .transport + .queue_message_from(frame, self.alice.address.clone()); + } + for frame in from_bob { + self.carried.push((false, frame.clone())); + self.alice + .transport + .queue_message_from(frame, self.bob.address.clone()); + } + moved + } + + fn run(&mut self, rounds: usize) { + for _ in 0..rounds { + self.step(); + } + } + + fn sessions_confirmed(&self) -> bool { + self.alice + .protocol + .confirmed_sessions + .contains(&self.bob.address) + && self + .bob + .protocol + .confirmed_sessions + .contains(&self.alice.address) + } + + /// The frames that crossed in one direction since the last `clear`. + fn crossed(&self, from_alice: bool) -> Vec<&Message> { + self.carried + .iter() + .filter(|(direction, _)| *direction == from_alice) + .map(|(_, frame)| frame) + .collect() + } +} + +fn service_frames<'a>(frames: &[&'a Message]) -> Vec<&'a Message> { + frames + .iter() + .copied() + .filter(|frame| frame.content.starts_with(SVC_MESSAGE_PREFIX)) + .collect() +} + +/// A started, MLS-enabled protocol on a mock link, with `tweak` applied to its +/// config first and a sink for every event it emits. +fn encrypted( + label: &str, + tweak: impl FnOnce(&mut ProtocolConfig), +) -> (OfflineProtocol, MockTransport, Arc>>) { + let mut config = create_test_config_for_user(label); + config.encryption.enabled = true; + config.encryption.store_pending = true; + tweak(&mut config); + let mut protocol = OfflineProtocol::new(config).unwrap(); + protocol + .initialize_mls_for_test(Arc::new(InMemoryStorage::new())) + .unwrap(); + let transport = MockTransport::new(TransportType::BLE); + transport.start().unwrap(); + let handle = transport.clone(); + protocol + .transport_manager_mut() + .add_transport(TransportType::BLE, Box::new(transport)); + let events: Arc>> = Arc::new(Mutex::new(Vec::new())); + let sink = events.clone(); + protocol.on_event(move |event| sink.lock().unwrap().push(event)); + protocol.start().unwrap(); + (protocol, handle, events) +} + +/// Hands every frame `from` sent to `to`, as arriving from `sender`, lets `to` +/// handle them, and returns what was handed. +fn deliver( + from: &MockTransport, + sender: &str, + to: &mut OfflineProtocol, + to_transport: &MockTransport, +) -> Vec { + let frames = from.sent_messages(); + from.clear_sent_messages(); + for frame in frames.clone() { + to_transport.queue_message_from(frame, sender.to_string()); + } + while to.receive_message().is_some() {} + frames +} + +/// The frames that carry something, leaving out the delivery acknowledgements +/// (an empty body) a received request is answered with. +fn without_acks(frames: Vec) -> Vec { + frames + .into_iter() + .filter(|frame| !frame.content.is_empty()) + .collect() +} + +fn register(protocol: &mut OfflineProtocol, service: &str) { + protocol + .register_service(ServiceDescriptor { + service_id: ServiceId::new(service).unwrap(), + version: "1.0".to_string(), + capabilities: HashMap::new(), + }) + .unwrap(); +} + +/// A key package body from `sender` advertising `svc_versions`. +fn key_package_body(sender: &str, svc_versions: Vec) -> String { + let payload = KeyPackagePayload { + user_id: sender.to_string(), + key_package_data: vec![1, 2, 3, 4], + remaining_lifetime_ms: 30 * 24 * 60 * 60 * 1000, + timestamp_ms: 0, + session_reset: false, + wire_versions: Vec::new(), + env_versions: Vec::new(), + rich_versions: Vec::new(), + data_versions: Vec::new(), + ctrl_versions: Vec::new(), + svc_versions, + nostr_pubkey: None, + }; + serde_json::to_string(&payload).unwrap() +} + +/// Feeds `protocol` a signed key package from `sender`, as the gate admits it. +fn feed_signed_key_package(protocol: &mut OfflineProtocol, sender: &str, svc_versions: Vec) { + let content = format!( + "{}{}", + internal_prefixes::KEY_PACKAGE, + key_package_body(sender, svc_versions) + ); + let message = signed_frame(sender, &id("user123"), &content); + protocol.process_internal_message(&message); +} + +fn negotiating_protocol(tweak: impl FnOnce(&mut ProtocolConfig)) -> OfflineProtocol { + let mut config = create_test_config(); + config.encryption.enabled = true; + config.encryption.auto_key_exchange = true; + tweak(&mut config); + OfflineProtocol::new(config).unwrap() +} + +// --------------------------------------------------------------------------- +// Negotiation +// --------------------------------------------------------------------------- + +#[test] +fn svc_sealed_negotiation_marks_a_capable_peer() { + let mut protocol = negotiating_protocol(|_| {}); + feed_signed_key_package(&mut protocol, &id("provider"), vec![SVC_SEALED_V1]); + assert!(protocol.peer_svc_sealed.contains(&id("provider"))); +} + +#[test] +fn svc_sealed_negotiation_ignores_a_legacy_peer() { + let mut protocol = negotiating_protocol(|_| {}); + feed_signed_key_package(&mut protocol, &id("provider"), Vec::new()); + assert!(!protocol.peer_svc_sealed.contains(&id("provider"))); +} + +#[test] +fn svc_sealed_negotiation_downgrade_removes_the_capability() { + let mut protocol = negotiating_protocol(|_| {}); + feed_signed_key_package(&mut protocol, &id("provider"), vec![SVC_SEALED_V1]); + assert!(protocol.peer_svc_sealed.contains(&id("provider"))); + feed_signed_key_package(&mut protocol, &id("provider"), Vec::new()); + assert!( + !protocol.peer_svc_sealed.contains(&id("provider")), + "a fresh key package that stops advertising it must stop the sealing" + ); +} + +#[test] +fn svc_sealed_kill_switch_stops_recording_and_advertising() { + let mut protocol = negotiating_protocol(|config| { + config.encryption.encrypt_service_messages = false; + }); + feed_signed_key_package(&mut protocol, &id("provider"), vec![SVC_SEALED_V1]); + assert!(!protocol.peer_svc_sealed.contains(&id("provider"))); + + let mut on = negotiating_protocol(|_| {}); + on.initialize_mls_for_test(Arc::new(InMemoryStorage::new())) + .unwrap(); + let mut off = negotiating_protocol(|config| { + config.encryption.encrypt_service_messages = false; + }); + off.initialize_mls_for_test(Arc::new(InMemoryStorage::new())) + .unwrap(); + let bundle = { + let manager = on.mls_manager.as_ref().unwrap().read().unwrap(); + manager.get_or_create_key_package().unwrap() + }; + assert_eq!( + on.build_key_package_payload(&bundle, false).svc_versions, + vec![SVC_SEALED_V1] + ); + assert!(off + .build_key_package_payload(&bundle, false) + .svc_versions + .is_empty()); +} + +/// The list sits beside a confidentiality decision, so it is honoured only +/// from a signed key package, like `nostr_pubkey`: neither recorded in memory +/// nor persisted for a restart to pick up. +#[test] +fn svc_sealed_is_never_learned_from_an_unsigned_key_package() { + let storage = Arc::new(InMemoryStorage::new()); + let mut protocol = super::protocol_with_mls_storage(storage.clone()); + protocol.handle_key_package_message( + &id("provider"), + &key_package_body(&id("provider"), vec![SVC_SEALED_V1]), + false, + None, + ); + assert!(!protocol.peer_svc_sealed.contains(&id("provider"))); + + let restarted = super::protocol_with_mls_storage(storage); + assert!( + !restarted.peer_svc_sealed.contains(&id("provider")), + "an unsigned advertisement must not reach the durable record either" + ); +} + +#[test] +fn svc_sealed_capability_persists_across_restart_and_respects_the_switch() { + let storage = Arc::new(InMemoryStorage::new()); + let mut protocol = super::protocol_with_mls_storage(storage.clone()); + feed_signed_key_package(&mut protocol, &id("provider"), vec![SVC_SEALED_V1]); + assert!(protocol.peer_svc_sealed.contains(&id("provider"))); + + let restarted = super::protocol_with_mls_storage(storage.clone()); + assert!( + restarted.peer_svc_sealed.contains(&id("provider")), + "a peer met before the restart must keep receiving sealed service bodies" + ); + + let mut config = create_test_config(); + config.encryption.enabled = true; + config.encryption.encrypt_service_messages = false; + let mut gated = OfflineProtocol::new(config).unwrap(); + gated.initialize_mls_for_test(storage.clone()).unwrap(); + assert!(!gated.peer_svc_sealed.contains(&id("provider"))); + + // The record survives the switched-off run, as for every capability. + let again = super::protocol_with_mls_storage(storage); + assert!(again.peer_svc_sealed.contains(&id("provider"))); +} + +// --------------------------------------------------------------------------- +// End to end, over a real handshake +// --------------------------------------------------------------------------- + +/// The headline. Both peers learn each other's `svc_versions` from genuine key +/// packages, and then neither the request body nor the response body is +/// readable on the wire; the provider sees the MLS-authenticated requester. +#[test] +fn service_request_and_response_are_sealed_between_capable_peers() { + let mut pair = Pair::new(config("alice"), config("bob")); + pair.run(40); + assert!(pair.sessions_confirmed(), "the handshake must converge"); + assert!(pair + .alice + .protocol + .peer_svc_sealed + .contains(&pair.bob.address)); + assert!(pair + .bob + .protocol + .peer_svc_sealed + .contains(&pair.alice.address)); + + pair.bob.register("echo"); + pair.carried.clear(); + let bob = pair.bob.address.clone(); + let alice = pair.alice.address.clone(); + let request_id = pair + .alice + .protocol + .send_service_request(&bob, "echo", "ping", "the-request-secret") + .unwrap(); + pair.run(10); + + let requests = pair.bob.requests(); + assert_eq!( + requests, + vec![( + request_id.clone(), + "the-request-secret".to_string(), + alice.clone() + )], + "the provider must receive the request once, from the authenticated requester" + ); + for frame in pair.crossed(true) { + assert!( + !frame.content.contains("the-request-secret"), + "the request body crossed the link readable: {}", + frame.content + ); + } + assert!(service_frames(&pair.crossed(true)).is_empty()); + + pair.carried.clear(); + pair.bob + .protocol + .respond_to_service_request(&request_id, &alice, "echo", "ok", "the-response-secret") + .unwrap(); + pair.run(10); + + assert_eq!( + pair.alice.responses(), + vec![( + "ok".to_string(), + "the-response-secret".to_string(), + bob.clone() + )] + ); + for frame in pair.crossed(false) { + assert!( + !frame.content.contains("the-response-secret"), + "the response body crossed the link readable: {}", + frame.content + ); + } + assert!(service_frames(&pair.crossed(false)).is_empty()); + assert!( + pair.alice.chat().is_empty() && pair.bob.chat().is_empty(), + "a sealed service frame must never surface as a chat message" + ); +} + +/// A peer that does not advertise the sealed form (a release from before it, +/// modelled by the switch off on its side) is sent signed plaintext, and the +/// round trip still works: the plaintext floor is the interoperability +/// requirement. +#[test] +fn service_request_to_a_legacy_peer_stays_signed_plaintext() { + let mut legacy = config("bob"); + legacy.encryption.encrypt_service_messages = false; + let mut pair = Pair::new(config("alice"), legacy); + pair.run(40); + assert!(pair.sessions_confirmed()); + assert!(!pair + .alice + .protocol + .peer_svc_sealed + .contains(&pair.bob.address)); + + pair.bob.register("echo"); + pair.carried.clear(); + let bob = pair.bob.address.clone(); + pair.alice + .protocol + .send_service_request(&bob, "echo", "ping", "plain-body") + .unwrap(); + pair.run(10); + + let sent = service_frames(&pair.crossed(true)); + assert_eq!(sent.len(), 1); + assert!(sent[0].content.starts_with(SVC_REQUEST)); + // The gate refuses unsigned control frames, so the provider handling it + // proves it left signed. + assert_eq!(pair.bob.requests().len(), 1); +} + +/// A request to a capable peer before the session confirms leaves as signed +/// plaintext, and is what starts the session (the encrypt attempt sends the +/// Welcome, exactly as for a direct message); once the session confirms, the +/// next request is sealed. +#[test] +fn a_request_before_the_session_confirms_falls_back_and_starts_the_session() { + // Alice does not exchange key packages on her own, so she holds bob's + // package (and knows he routes the sealed form) without a session. + let mut quiet = config("alice"); + quiet.encryption.auto_key_exchange = false; + let mut pair = Pair::new(quiet, config("bob")); + let bob = pair.bob.address.clone(); + for _ in 0..10 { + pair.step(); + if pair.alice.protocol.peer_svc_sealed.contains(&bob) { + break; + } + } + assert!(pair.alice.protocol.peer_svc_sealed.contains(&bob)); + assert!(!pair.alice.protocol.confirmed_sessions.contains(&bob)); + assert!( + !pair.alice.protocol.has_mls_session(&bob).unwrap(), + "no session yet: the request below is what must start it" + ); + + pair.bob.register("echo"); + pair.alice + .protocol + .send_service_request(&bob, "echo", "ping", "first") + .unwrap(); + let sent = pair.alice.transport.sent_messages(); + let requests: Vec<&Message> = sent + .iter() + .filter(|frame| frame.content.starts_with(SVC_MESSAGE_PREFIX)) + .collect(); + assert_eq!(requests.len(), 1); + assert!( + requests[0].content.starts_with(SVC_REQUEST), + "with no confirmed session the request must leave signed, not be dropped" + ); + assert_eq!( + sent.iter() + .filter(|frame| frame.content.starts_with(internal_prefixes::WELCOME)) + .count(), + 1, + "the encrypt attempt must have started the session" + ); + + pair.run(40); + assert_eq!(pair.bob.requests().len(), 1, "the first request arrived"); + assert!(pair.alice.protocol.confirmed_sessions.contains(&bob)); + + pair.carried.clear(); + pair.alice + .protocol + .send_service_request(&bob, "echo", "ping", "second-secret") + .unwrap(); + pair.run(10); + assert_eq!(pair.bob.requests().len(), 2); + for frame in pair.crossed(true) { + assert!(!frame.content.contains("second-secret")); + } +} + +// --------------------------------------------------------------------------- +// The receive side +// --------------------------------------------------------------------------- + +/// Bob owns the session and has decrypted nothing from alice yet, so it is not +/// confirmed on his side when her sealed request arrives. The automatic +/// `not_found` answer is sent from inside the handler, so it is sealed only +/// because the decrypt confirms the session before the frame is handled. +#[test] +fn the_automatic_reply_to_the_first_sealed_request_is_sealed() { + let (mut alice, alice_h, alice_events) = encrypted("alice", |_| {}); + let (mut bob, bob_h, _) = encrypted("bob", |_| {}); + establish_confirmed_session(&mut alice, &id("alice"), &mut bob, &id("bob")); + bob.confirmed_sessions.remove(&id("alice")); + alice.record_svc_sealed_peer(&id("bob")); + bob.record_svc_sealed_peer(&id("alice")); + + alice + .send_service_request(&id("bob"), "nobody-here", "ping", "x") + .unwrap(); + let request = deliver(&alice_h, &id("alice"), &mut bob, &bob_h); + assert!(request[0].content.starts_with(internal_prefixes::ENCRYPTED)); + + let replies = deliver(&bob_h, &id("bob"), &mut alice, &alice_h); + assert!( + !replies.is_empty(), + "the unknown service must be answered automatically" + ); + for reply in &replies { + assert!( + !reply.content.starts_with(SVC_MESSAGE_PREFIX), + "the automatic answer to a sealed request left in plaintext: {}", + reply.content + ); + } + let statuses: Vec = alice_events + .lock() + .unwrap() + .iter() + .filter_map(|event| match event { + Event::ServiceResponseReceived { status, .. } => Some(status.clone()), + _ => None, + }) + .collect(); + assert_eq!(statuses, vec!["not_found".to_string()]); +} + +/// A sealed service frame proves its sender routes them, which a replayed key +/// package cannot, so bob answers sealed even though he never received a key +/// package from alice that advertised it. +#[test] +fn a_sealed_service_request_marks_its_sender_capable() { + let (mut alice, alice_h, _) = encrypted("alice", |_| {}); + let (mut bob, bob_h, bob_events) = encrypted("bob", |_| {}); + establish_confirmed_session(&mut alice, &id("alice"), &mut bob, &id("bob")); + alice.record_svc_sealed_peer(&id("bob")); + assert!(!bob.peer_svc_sealed.contains(&id("alice"))); + register(&mut bob, "echo"); + + let request_id = alice + .send_service_request(&id("bob"), "echo", "ping", "x") + .unwrap(); + deliver(&alice_h, &id("alice"), &mut bob, &bob_h); + assert!(bob.peer_svc_sealed.contains(&id("alice"))); + assert!(bob_events + .lock() + .unwrap() + .iter() + .any(|event| matches!(event, Event::ServiceRequestReceived { .. }))); + + bob.respond_to_service_request(&request_id, &id("alice"), "echo", "ok", "y") + .unwrap(); + let response = without_acks(bob_h.sent_messages()); + assert_eq!(response.len(), 1); + assert!(response[0] + .content + .starts_with(internal_prefixes::ENCRYPTED)); +} + +/// Routing a sealed service frame is parsing, and parsing is never gated by a +/// kill switch: with the switch off bob still hands alice's sealed request to +/// his service layer (rather than surfacing it as chat text), but he neither +/// records her as capable nor seals his answer. +#[test] +fn a_receiver_with_the_switch_off_still_routes_a_sealed_service_frame() { + let (mut alice, alice_h, _) = encrypted("alice", |_| {}); + let (mut bob, bob_h, bob_events) = encrypted("bob", |config| { + config.encryption.encrypt_service_messages = false; + }); + establish_confirmed_session(&mut alice, &id("alice"), &mut bob, &id("bob")); + alice.record_svc_sealed_peer(&id("bob")); + register(&mut bob, "echo"); + + let request_id = alice + .send_service_request(&id("bob"), "echo", "ping", "x") + .unwrap(); + let request = deliver(&alice_h, &id("alice"), &mut bob, &bob_h); + assert!(request[0].content.starts_with(internal_prefixes::ENCRYPTED)); + + let events = bob_events.lock().unwrap().clone(); + assert!(events + .iter() + .any(|event| matches!(event, Event::ServiceRequestReceived { .. }))); + assert!(!events + .iter() + .any(|event| matches!(event, Event::MessageReceived { .. }))); + assert!(!bob.peer_svc_sealed.contains(&id("alice"))); + + bob.respond_to_service_request(&request_id, &id("alice"), "echo", "ok", "y") + .unwrap(); + let response = without_acks(bob_h.sent_messages()); + assert_eq!(response.len(), 1); + assert!(response[0].content.starts_with(SVC_RESPONSE)); +} + +// --------------------------------------------------------------------------- +// Resend after a re-key +// --------------------------------------------------------------------------- + +/// A sealed request still in the outbox when the session is re-keyed is sealed +/// again against the new epoch on resend, not replayed as ciphertext nobody +/// can open: the frame stages the same re-seal provenance a direct message +/// does. +#[test] +fn a_sealed_service_request_is_resealed_on_resend_after_a_rekey() { + let (mut alice, _alice_h, _) = encrypted("alice", |config| { + // The JSON envelope, for a deterministic decode below. + config.encryption.compact_envelope_enabled = false; + }); + let bob = + crate::test_identity::manager_for("bob", Arc::new(crate::mls::InMemoryStorage::new())); + let join = |alice: &mut OfflineProtocol| { + let package = bob.get_or_create_key_package().unwrap(); + let welcome = { + let manager = alice.mls_manager.as_ref().unwrap().read().unwrap(); + manager + .import_key_package(&id("bob"), &package.key_package_data) + .unwrap(); + manager.create_session(&id("bob")).unwrap() + }; + bob.join_session(&welcome).unwrap(); + alice.confirmed_sessions.insert(id("bob")); + }; + join(&mut alice); + alice.record_svc_sealed_peer(&id("bob")); + + alice + .send_service_request(&id("bob"), "echo", "ping", "retry-me") + .unwrap(); + let entry = alice + .outbox + .values() + .find(|entry| entry.message.recipient.as_str() == id("bob")) + .expect("a sealed service frame must enter the outbox") + .clone(); + assert!(entry + .message + .content + .starts_with(internal_prefixes::ENCRYPTED)); + let reseal = entry + .reseal + .as_ref() + .expect("a sealed service frame must store re-seal provenance"); + assert!(reseal.content.starts_with(SVC_REQUEST)); + + // Re-key exactly as the desync heal does: both sessions torn down and a + // fresh one built at a new epoch. + alice.manual_mls_delete_session(&id("bob")).unwrap(); + bob.delete_session(&id("alice")).unwrap(); + join(&mut alice); + + let mut resend = entry.message.clone(); + alice.reseal_for_resend_in_place(&mut resend); + assert_ne!( + resend.content, entry.message.content, + "the resend must be sealed afresh" + ); + assert_eq!(resend.id, entry.message.id); + let sealed: offline_protocol_mls::EncryptedMessage = serde_json::from_str( + resend + .content + .strip_prefix(internal_prefixes::ENCRYPTED) + .unwrap(), + ) + .unwrap(); + let plaintext = bob + .decrypt_from_user(&sealed, &id("alice")) + .unwrap() + .unwrap(); + let plaintext = String::from_utf8(plaintext).unwrap(); + assert!(plaintext.starts_with(SVC_REQUEST) && plaintext.contains("retry-me")); +} + +// --------------------------------------------------------------------------- +// The require_encryption exemption, per frame class +// --------------------------------------------------------------------------- + +fn strict(label: &str) -> (OfflineProtocol, MockTransport, Arc>>) { + encrypted(label, |config| { + config.encryption.require_encryption = true; + }) +} + +/// Discovery is a gossip every hop reads, so it stays signed plaintext even +/// under `require_encryption`, toward any peer. +#[test] +fn require_encryption_sends_discovery_as_signed_plaintext() { + let (mut alice, alice_h, _) = strict("alice"); + alice.on_neighbor_discovered(&id("bob")); + alice_h.clear_sent_messages(); + alice.discover_services(None).unwrap(); + let sent = alice_h.sent_messages(); + assert!(!sent.is_empty()); + assert!(sent + .iter() + .all(|frame| frame.content.starts_with(SVC_DISCOVER_QUERY))); +} + +/// Toward a peer that has not advertised the sealed form, requests and +/// responses stay signed plaintext under `require_encryption`: the exemption +/// for service frames still holds there. +#[test] +fn require_encryption_sends_service_frames_to_a_legacy_peer_as_signed_plaintext() { + let (mut alice, alice_h, _) = strict("alice"); + alice + .send_service_request(&id("bob"), "echo", "ping", "{}") + .unwrap(); + alice + .respond_to_service_request("req-1", &id("bob"), "echo", "ok", "pong") + .unwrap(); + let sent: Vec = alice_h + .sent_messages() + .into_iter() + .filter(|frame| frame.content.starts_with(SVC_MESSAGE_PREFIX)) + .collect(); + assert_eq!(sent.len(), 2); + assert!(sent[0].content.starts_with(SVC_REQUEST)); + assert!(sent[1].content.starts_with(SVC_RESPONSE)); +} + +/// Toward a capable peer with a confirmed session, requests and responses are +/// sealed under `require_encryption` as everywhere else. +#[test] +fn require_encryption_seals_service_frames_to_a_capable_confirmed_peer() { + let (mut alice, alice_h, _) = strict("alice"); + let (mut bob, _bob_h, _) = strict("bob"); + establish_confirmed_session(&mut alice, &id("alice"), &mut bob, &id("bob")); + alice.record_svc_sealed_peer(&id("bob")); + alice_h.clear_sent_messages(); + + alice + .send_service_request(&id("bob"), "echo", "ping", "{}") + .unwrap(); + alice + .respond_to_service_request("req-1", &id("bob"), "echo", "ok", "pong") + .unwrap(); + let sent = alice_h.sent_messages(); + assert_eq!(sent.len(), 2); + assert!(sent + .iter() + .all(|frame| frame.content.starts_with(internal_prefixes::ENCRYPTED))); +} diff --git a/crates/offline-protocol/src/protocol/types.rs b/crates/offline-protocol/src/protocol/types.rs index bf515e6c8..2ab508c1d 100644 --- a/crates/offline-protocol/src/protocol/types.rs +++ b/crates/offline-protocol/src/protocol/types.rs @@ -1478,6 +1478,14 @@ pub(crate) struct PeerCapabilities { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(crate) ctrl_versions: Vec, + /// Sealed service frame versions the peer advertised, from + /// [`KeyPackagePayload::svc_versions`], recorded only from a signed key + /// package. Persisted for the same reason as the lists above: without it + /// a peer met before a restart would be sent its service bodies in signed + /// plaintext until the next live key-package exchange. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) svc_versions: Vec, + /// The peer's Nostr public key, from [`KeyPackagePayload::nostr_pubkey`]. /// /// Persisted for the same reason as the capability lists: the cached key @@ -1522,6 +1530,7 @@ impl PeerCapabilities { rich_versions: &[u8], data_versions: &[u8], ctrl_versions: &[u8], + svc_versions: &[u8], nostr_pubkey: Option<&str>, ) -> Self { Self { @@ -1545,6 +1554,11 @@ impl PeerCapabilities { .copied() .take(MAX_PERSISTED_CAPABILITY_VERSIONS) .collect(), + svc_versions: svc_versions + .iter() + .copied() + .take(MAX_PERSISTED_CAPABILITY_VERSIONS) + .collect(), attested_rich_versions: Vec::new(), attested_data_versions: Vec::new(), nostr_pubkey: nostr_pubkey.and_then(normalize_nostr_pubkey), @@ -1558,6 +1572,7 @@ impl PeerCapabilities { || !self.rich_versions.is_empty() || !self.data_versions.is_empty() || !self.ctrl_versions.is_empty() + || !self.svc_versions.is_empty() || !self.attested_rich_versions.is_empty() || !self.attested_data_versions.is_empty() || self.nostr_pubkey.is_some() From 7916b4936a8a63f33259c27a45b2e05d2e688284 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Fri, 9 Oct 2026 15:43:35 +0530 Subject: [PATCH 3/7] feat(uniffi,bindings): encrypt_service_messages crosses every binding Appended last to the flat UDL ProtocolConfig (default true), carried by the FFI config, and read by the iOS and Android parsers nested-then-flat in both spellings. The TypeScript wrapper resolves it into both shapes. Swift, Kotlin and Python regenerated together; the local API table regenerated. every_bridge_reads_the_encryption_config_section is the first guard over the encryption section across bridges. --- .../offline_protocol_sdk/local_api/table.py | 5 +- .../offline_protocol_sdk/offline_protocol.py | 10 +- .../offlineprotocol/ProtocolConfigParser.kt | 7 ++ .../offline_protocol/offline_protocol.kt | 7 +- .../ProtocolConfigParserTest.kt | 24 +++++ .../ios/EncryptionConfigReader.swift | 5 + .../ios/Generated/offline_protocol.swift | 8 +- .../ios/OfflineProtocolModule.swift | 3 + .../tests/EncryptionConfigReaderTests.swift | 17 ++++ bindings/react-native/src/index.ts | 4 + bindings/react-native/src/types.ts | 14 +++ crates/offline-protocol-uniffi/src/lib.rs | 96 +++++++++++++++++++ .../src/offline_protocol.udl | 12 +++ 13 files changed, 205 insertions(+), 7 deletions(-) diff --git a/bindings/python/offline_protocol_sdk/local_api/table.py b/bindings/python/offline_protocol_sdk/local_api/table.py index 26a2037d4..a6849653c 100644 --- a/bindings/python/offline_protocol_sdk/local_api/table.py +++ b/bindings/python/offline_protocol_sdk/local_api/table.py @@ -9,7 +9,7 @@ from __future__ import annotations -UDL_SHA256 = "db1022f43254c9843ac4164e4993169e1d4886901be23f1340d6aed5652e43bc" +UDL_SHA256 = "fde449d34b2410e5d6ab608bd964ce0c22691a66689dff84cc9433a012015e05" TABLE = {'callbacks': ('MlsStorageProvider', 'ProtocolStateStorageProvider', @@ -763,7 +763,8 @@ ('mesh_relay', 'MeshRelayConfig?', True), ('custody', 'CustodyConfig?', True), ('data_enabled', 'boolean', True), - ('control_freshness_enforced', 'boolean', True)), + ('control_freshness_enforced', 'boolean', True), + ('encrypt_service_messages', 'boolean', True)), 'ProtocolLockDiagnostics': (('held', 'boolean', False), ('holder_location', 'string', False), ('holder_thread', 'string', False), diff --git a/bindings/python/offline_protocol_sdk/offline_protocol.py b/bindings/python/offline_protocol_sdk/offline_protocol.py index 2ccf26e5e..616966f80 100644 --- a/bindings/python/offline_protocol_sdk/offline_protocol.py +++ b/bindings/python/offline_protocol_sdk/offline_protocol.py @@ -6291,7 +6291,7 @@ def read(cls, buf): @dataclass class ProtocolConfig: - def __init__(self, *, app_id:str, profile:str, ble_enabled:bool, wifi_direct_enabled:bool, internet_enabled:bool, reticulum_enabled:bool, nostr_enabled:bool, prefer_online:bool, initial_ttl:int, encryption_enabled:bool, auto_key_exchange:bool, store_pending:bool, require_encryption:bool = True, max_pending_per_peer:int, max_pending_global:int, pending_ttl_ms:int, overflow_policy:OverflowPolicy, edge_driven_unreachable_dm:bool = False, max_group_members:int = 256, group_relay_enabled:bool = True, group_relay_broadcast_enabled:bool = True, group_enforce_admin_commits:bool = False, require_transport_identity:bool = False, binary_wire_enabled:bool = True, nostr_sealing_enabled:bool = True, nostr_cold_contact_enabled:bool = True, nostr_username_discovery_enabled:bool = False, compact_envelope_enabled:bool = True, rich_payload_enabled:bool = True, crypto_recovery_enabled:bool = True, mesh_relay:typing.Optional[MeshRelayConfig] = _DEFAULT, custody:typing.Optional[CustodyConfig] = _DEFAULT, data_enabled:bool = True, control_freshness_enforced:bool = True): + def __init__(self, *, app_id:str, profile:str, ble_enabled:bool, wifi_direct_enabled:bool, internet_enabled:bool, reticulum_enabled:bool, nostr_enabled:bool, prefer_online:bool, initial_ttl:int, encryption_enabled:bool, auto_key_exchange:bool, store_pending:bool, require_encryption:bool = True, max_pending_per_peer:int, max_pending_global:int, pending_ttl_ms:int, overflow_policy:OverflowPolicy, edge_driven_unreachable_dm:bool = False, max_group_members:int = 256, group_relay_enabled:bool = True, group_relay_broadcast_enabled:bool = True, group_enforce_admin_commits:bool = False, require_transport_identity:bool = False, binary_wire_enabled:bool = True, nostr_sealing_enabled:bool = True, nostr_cold_contact_enabled:bool = True, nostr_username_discovery_enabled:bool = False, compact_envelope_enabled:bool = True, rich_payload_enabled:bool = True, crypto_recovery_enabled:bool = True, mesh_relay:typing.Optional[MeshRelayConfig] = _DEFAULT, custody:typing.Optional[CustodyConfig] = _DEFAULT, data_enabled:bool = True, control_freshness_enforced:bool = True, encrypt_service_messages:bool = True): self.app_id = app_id self.profile = profile self.ble_enabled = ble_enabled @@ -6332,12 +6332,13 @@ def __init__(self, *, app_id:str, profile:str, ble_enabled:bool, wifi_direct_ena self.custody = custody self.data_enabled = data_enabled self.control_freshness_enforced = control_freshness_enforced + self.encrypt_service_messages = encrypt_service_messages def __str__(self): - return "ProtocolConfig(app_id={}, profile={}, ble_enabled={}, wifi_direct_enabled={}, internet_enabled={}, reticulum_enabled={}, nostr_enabled={}, prefer_online={}, initial_ttl={}, encryption_enabled={}, auto_key_exchange={}, store_pending={}, require_encryption={}, max_pending_per_peer={}, max_pending_global={}, pending_ttl_ms={}, overflow_policy={}, edge_driven_unreachable_dm={}, max_group_members={}, group_relay_enabled={}, group_relay_broadcast_enabled={}, group_enforce_admin_commits={}, require_transport_identity={}, binary_wire_enabled={}, nostr_sealing_enabled={}, nostr_cold_contact_enabled={}, nostr_username_discovery_enabled={}, compact_envelope_enabled={}, rich_payload_enabled={}, crypto_recovery_enabled={}, mesh_relay={}, custody={}, data_enabled={}, control_freshness_enforced={})".format(self.app_id, self.profile, self.ble_enabled, self.wifi_direct_enabled, self.internet_enabled, self.reticulum_enabled, self.nostr_enabled, self.prefer_online, self.initial_ttl, self.encryption_enabled, self.auto_key_exchange, self.store_pending, self.require_encryption, self.max_pending_per_peer, self.max_pending_global, self.pending_ttl_ms, self.overflow_policy, self.edge_driven_unreachable_dm, self.max_group_members, self.group_relay_enabled, self.group_relay_broadcast_enabled, self.group_enforce_admin_commits, self.require_transport_identity, self.binary_wire_enabled, self.nostr_sealing_enabled, self.nostr_cold_contact_enabled, self.nostr_username_discovery_enabled, self.compact_envelope_enabled, self.rich_payload_enabled, self.crypto_recovery_enabled, self.mesh_relay, self.custody, self.data_enabled, self.control_freshness_enforced) + return "ProtocolConfig(app_id={}, profile={}, ble_enabled={}, wifi_direct_enabled={}, internet_enabled={}, reticulum_enabled={}, nostr_enabled={}, prefer_online={}, initial_ttl={}, encryption_enabled={}, auto_key_exchange={}, store_pending={}, require_encryption={}, max_pending_per_peer={}, max_pending_global={}, pending_ttl_ms={}, overflow_policy={}, edge_driven_unreachable_dm={}, max_group_members={}, group_relay_enabled={}, group_relay_broadcast_enabled={}, group_enforce_admin_commits={}, require_transport_identity={}, binary_wire_enabled={}, nostr_sealing_enabled={}, nostr_cold_contact_enabled={}, nostr_username_discovery_enabled={}, compact_envelope_enabled={}, rich_payload_enabled={}, crypto_recovery_enabled={}, mesh_relay={}, custody={}, data_enabled={}, control_freshness_enforced={}, encrypt_service_messages={})".format(self.app_id, self.profile, self.ble_enabled, self.wifi_direct_enabled, self.internet_enabled, self.reticulum_enabled, self.nostr_enabled, self.prefer_online, self.initial_ttl, self.encryption_enabled, self.auto_key_exchange, self.store_pending, self.require_encryption, self.max_pending_per_peer, self.max_pending_global, self.pending_ttl_ms, self.overflow_policy, self.edge_driven_unreachable_dm, self.max_group_members, self.group_relay_enabled, self.group_relay_broadcast_enabled, self.group_enforce_admin_commits, self.require_transport_identity, self.binary_wire_enabled, self.nostr_sealing_enabled, self.nostr_cold_contact_enabled, self.nostr_username_discovery_enabled, self.compact_envelope_enabled, self.rich_payload_enabled, self.crypto_recovery_enabled, self.mesh_relay, self.custody, self.data_enabled, self.control_freshness_enforced, self.encrypt_service_messages) def __eq__(self, other): if self.app_id != other.app_id: return False @@ -6407,6 +6408,8 @@ def __eq__(self, other): return False if self.control_freshness_enforced != other.control_freshness_enforced: return False + if self.encrypt_service_messages != other.encrypt_service_messages: + return False return True class _UniffiFfiConverterTypeProtocolConfig(_UniffiConverterRustBuffer): @@ -6447,6 +6450,7 @@ def read(buf): custody=_UniffiFfiConverterOptionalTypeCustodyConfig.read(buf), data_enabled=_UniffiFfiConverterBoolean.read(buf), control_freshness_enforced=_UniffiFfiConverterBoolean.read(buf), + encrypt_service_messages=_UniffiFfiConverterBoolean.read(buf), ) @staticmethod @@ -6485,6 +6489,7 @@ def check_lower(value): _UniffiFfiConverterOptionalTypeCustodyConfig.check_lower(value.custody) _UniffiFfiConverterBoolean.check_lower(value.data_enabled) _UniffiFfiConverterBoolean.check_lower(value.control_freshness_enforced) + _UniffiFfiConverterBoolean.check_lower(value.encrypt_service_messages) @staticmethod def write(value, buf): @@ -6522,6 +6527,7 @@ def write(value, buf): _UniffiFfiConverterOptionalTypeCustodyConfig.write(value.custody, buf) _UniffiFfiConverterBoolean.write(value.data_enabled, buf) _UniffiFfiConverterBoolean.write(value.control_freshness_enforced, buf) + _UniffiFfiConverterBoolean.write(value.encrypt_service_messages, buf) @dataclass class ProtocolLockDiagnostics: diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/ProtocolConfigParser.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/ProtocolConfigParser.kt index e6f3b5bbc..ac9f36de4 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/ProtocolConfigParser.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/ProtocolConfigParser.kt @@ -97,6 +97,12 @@ internal object ProtocolConfigParser { "cryptoRecoveryEnabled", "crypto_recovery_enabled" ) ?: json.optBooleanCompat("cryptoRecoveryEnabled", "crypto_recovery_enabled") ?: true + // Sealed service bodies (default on). Same nested-then-flat shape; + // mirrors EncryptionConfigReader.swift, keep in sync. + val encryptServiceMessages = encryptionJson?.optBooleanCompat( + "encryptServiceMessages", + "encrypt_service_messages" + ) ?: json.optBooleanCompat("encryptServiceMessages", "encrypt_service_messages") ?: true val pendingQueueJson = encryptionJson?.optJSONObject("pendingQueue") ?: encryptionJson?.optJSONObject("pending_queue") val maxPendingPerPeer = pendingQueueJson?.optLongCompat( @@ -277,6 +283,7 @@ internal object ProtocolConfigParser { compactEnvelopeEnabled = compactEnvelopeEnabled, richPayloadEnabled = richPayloadEnabled, cryptoRecoveryEnabled = cryptoRecoveryEnabled, + encryptServiceMessages = encryptServiceMessages, meshRelay = meshRelay, custody = custody ) diff --git a/bindings/react-native/android/src/main/java/uniffi/offline_protocol/offline_protocol.kt b/bindings/react-native/android/src/main/java/uniffi/offline_protocol/offline_protocol.kt index 55c8e05e4..99d6d673e 100644 --- a/bindings/react-native/android/src/main/java/uniffi/offline_protocol/offline_protocol.kt +++ b/bindings/react-native/android/src/main/java/uniffi/offline_protocol/offline_protocol.kt @@ -8978,6 +8978,8 @@ data class ProtocolConfig ( var `dataEnabled`: kotlin.Boolean = true , var `controlFreshnessEnforced`: kotlin.Boolean = true + , + var `encryptServiceMessages`: kotlin.Boolean = true ){ @@ -9026,6 +9028,7 @@ public object FfiConverterTypeProtocolConfig: FfiConverterRustBuffer, @@ -2406,6 +2410,7 @@ impl From for CoreConfig { core_config.encryption.compact_envelope_enabled = config.compact_envelope_enabled; core_config.encryption.rich_payload_enabled = config.rich_payload_enabled; core_config.encryption.crypto_recovery_enabled = config.crypto_recovery_enabled; + core_config.encryption.encrypt_service_messages = config.encrypt_service_messages; core_config.dors.prefer_online = config.prefer_online; core_config.initial_ttl = config.initial_ttl; core_config.encryption.enabled = config.encryption_enabled; @@ -8476,6 +8481,7 @@ mod tests { group_enforce_admin_commits: false, require_transport_identity: false, control_freshness_enforced: true, + encrypt_service_messages: true, } } @@ -8515,6 +8521,7 @@ mod tests { group_enforce_admin_commits: false, require_transport_identity: false, control_freshness_enforced: true, + encrypt_service_messages: true, } } @@ -8831,6 +8838,7 @@ mod tests { group_enforce_admin_commits: false, require_transport_identity: false, control_freshness_enforced: true, + encrypt_service_messages: true, } } @@ -11518,6 +11526,94 @@ mod tests { offline_protocol::telemetry::pipe::testing::stop_capturing_uploads(); } + /// Every encryption switch the flat UDL `ProtocolConfig` declares is read + /// by both native config readers, in both spellings, and carried by the + /// TypeScript wrapper in both the flat and the nested shape. + /// + /// The failure this catches: a switch added to the UDL and the Rust config + /// that one bridge never reads. The generated initializer supplies the + /// UDL default, so everything compiles and every test that does not set + /// the switch passes, while an app that turns it off on that platform is + /// silently ignored. That is a runtime-only failure (C1, C5), and until + /// this guard nothing pinned the encryption section across bridges. + #[test] + fn every_bridge_reads_the_encryption_config_section() { + let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let rn = manifest.join("../../bindings/react-native"); + let read = |rel: std::path::PathBuf| -> String { + std::fs::read_to_string(&rel) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", rel.display())) + }; + let udl = read(manifest.join("src/offline_protocol.udl")); + let swift_reader = read(rn.join("ios/EncryptionConfigReader.swift")); + let swift_module = read(rn.join("ios/OfflineProtocolModule.swift")); + let kotlin_parser = + read(rn.join("android/src/main/java/com/offlineprotocol/ProtocolConfigParser.kt")); + let index = read(rn.join("src/index.ts")); + let types = read(rn.join("src/types.ts")); + + // The encryption switches a bridge reads from the `encryption` + // section, by their UDL (snake_case) name. + let switches = [ + ("auto_key_exchange", "autoKeyExchange"), + ("store_pending", "storePending"), + ("require_encryption", "requireEncryption"), + ("compact_envelope_enabled", "compactEnvelopeEnabled"), + ("rich_payload_enabled", "richPayloadEnabled"), + ("crypto_recovery_enabled", "cryptoRecoveryEnabled"), + ("encrypt_service_messages", "encryptServiceMessages"), + ]; + for (snake, camel) in switches { + assert!( + udl.contains(&format!("boolean {snake} = ")) + || udl.contains(&format!("boolean {snake};")), + "the UDL ProtocolConfig no longer declares {snake}" + ); + for (name, source) in [ + ("EncryptionConfigReader.swift", &swift_reader), + ("ProtocolConfigParser.kt", &kotlin_parser), + ] { + assert!( + source.contains(&format!("\"{camel}\"")), + "{name} does not read {camel}" + ); + assert!( + source.contains(&format!("\"{snake}\"")), + "{name} does not read {snake}" + ); + } + assert!( + swift_module.contains(&format!("encryption.{camel}")), + "OfflineProtocolModule.swift does not pass {camel} to the ProtocolConfig" + ); + assert!( + kotlin_parser.contains(&format!("{camel} = {camel}")), + "ProtocolConfigParser.kt does not pass {camel} to the ProtocolConfig" + ); + assert!( + types.contains(&format!("{camel}?: boolean")), + "types.ts does not declare {camel} on the encryption config" + ); + // The effective value, the flat copy the bridges read, and the + // field on both the flat and the nested native config type: miss + // one and the two shapes diverge. + assert!( + index.contains(&format!("{camel}: encryptionSource?.{camel}")), + "index.ts does not resolve {camel} from the app's encryption config" + ); + assert!( + index.contains(&format!("{camel}: encryption.{camel},")), + "index.ts does not copy {camel} into the flat shape" + ); + assert!( + index.matches(&format!("{camel}: boolean")).count() + + index.matches(&format!("{camel}?: boolean")).count() + >= 2, + "index.ts does not declare {camel} on both the flat and the nested native config" + ); + } + } + /// The telemetry config section must be read by every bridge that /// parses it, field for field, and the two platform fields must be /// filled by the bridge rather than read from the app. diff --git a/crates/offline-protocol-uniffi/src/offline_protocol.udl b/crates/offline-protocol-uniffi/src/offline_protocol.udl index 769b5b91e..d09227efd 100644 --- a/crates/offline-protocol-uniffi/src/offline_protocol.udl +++ b/crates/offline-protocol-uniffi/src/offline_protocol.udl @@ -992,6 +992,18 @@ dictionary ProtocolConfig { // MANY DIFFERENT peers, which is the clock; one peer while others are fine // is that peer. Defaulted so existing callers keep compiling. boolean control_freshness_enforced = true; + // Whether service request and response bodies are sealed inside the + // recipient's 1:1 MLS session instead of crossing every hop as signed + // plaintext (default on). Takes effect only toward a peer that advertised + // support in a signed key package, or proved it by sending a sealed + // service frame, and only once the session with that peer is confirmed. + // Toward everyone else, and before the session confirms, the body still + // leaves as signed plaintext, so the first request to a provider this + // device has never met is readable by the hops that carry it. Discovery + // is never sealed. Routing an inbound sealed service frame does not depend + // on this switch. Appended last because the Swift initializer is + // positional; defaulted so existing callers keep compiling. + boolean encrypt_service_messages = true; }; // ======================================================================== From 1852e66802db4177d4900825ebb8b6996566dcd9 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Fri, 9 Oct 2026 15:46:27 +0530 Subject: [PATCH 4/7] docs: service bodies are sealed end to end R9 now says what is sealed and what still is not: discovery always, and a request or response toward an older peer or before the session confirms, including the first request to a never-met provider. The service chapter, the control-message and envelope specs, the HTTP front's invariant 1 and every config reference name the new switch. UPGRADING gives the measured cost: a sealed frame is about 1.34x the plaintext one with the compact envelope, so bodies above about 52 KB stop crossing Bluetooth LE at the MTU floor and above about 47 KiB stop fitting a Nostr relay. --- CHANGELOG.md | 20 +++++++ .../http_front/envelope.py | 4 +- .../offline_protocol_sdk/http_front/front.py | 5 +- bindings/react-native/README.md | 4 +- docs/UPGRADING.md | 25 ++++++++ docs/android-integration.md | 4 +- docs/api-reference.md | 11 +++- docs/bridges/python.md | 2 +- docs/configuration.md | 1 + docs/ios-integration.md | 4 +- docs/mls-integration.md | 1 + docs/security/threat-model.md | 60 ++++++++++++++----- docs/service-discovery.md | 10 +++- docs/spec/control-messages.md | 19 ++++-- docs/spec/encryption-envelopes.md | 16 +++++ docs/spec/http-front.md | 6 +- 16 files changed, 156 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89fb646ed..37dde471b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,26 @@ archived by series under [docs/changelog/](docs/changelog/); see the ### Added +- **Service request and response bodies are sealed end to end.** A body sent + with `send_service_request` or `respond_to_service_request` used to cross + every hop as signed plaintext, readable by each device that carried it + (threat model R9). It is now sealed inside the recipient's 1:1 MLS session + when the recipient advertises the new `svc_versions` capability in a signed + key package (or has already sent a sealed service frame) and the session + with it is confirmed, and the provider's `service_request_received.sender` + is then the MLS-authenticated requester. Toward an older peer, and before + the session confirms, the body still leaves as signed plaintext, so the + first request to a provider this device has never met is readable by the + hops that carry it; the attempt to seal it starts the session, as a direct + message does, and the next request is sealed. The automatic `not_found` + answer to a sealed request is sealed too, and a sealed request retried + after a session re-key is sealed again rather than replayed as dead + ciphertext. Discovery stays signed plaintext, because every hop reads it to + forward it. A new switch, `encryption.encryptServiceMessages` + (`encrypt_service_messages`, default on), turns sealing off without a + release; routing an inbound sealed frame does not depend on it + ([capability negotiation](docs/spec/capability-negotiation.md#the-exception-svc_versions)). + - **Android peer streams on the Wi-Fi network.** With `wifiDirect: { enabled: true }`, the Android manager advertises and browses DNS-SD `_offlineprotocol._tcp` through `NsdManager` on the Wi-Fi network it diff --git a/bindings/python/offline_protocol_sdk/http_front/envelope.py b/bindings/python/offline_protocol_sdk/http_front/envelope.py index fa81470ec..a5b655f67 100644 --- a/bindings/python/offline_protocol_sdk/http_front/envelope.py +++ b/bindings/python/offline_protocol_sdk/http_front/envelope.py @@ -6,8 +6,8 @@ this module would agree with any edit. The envelope is the content of an ordinary direct message, which the engine -seals with MLS. It is never sent on the service request path, whose bodies -are signed plaintext (threat model R9). +seals with MLS. It is never sent on the service request path, which falls +back to signed plaintext whenever it cannot seal (threat model R9). """ from __future__ import annotations diff --git a/bindings/python/offline_protocol_sdk/http_front/front.py b/bindings/python/offline_protocol_sdk/http_front/front.py index fd30239ff..e45c0eeb5 100644 --- a/bindings/python/offline_protocol_sdk/http_front/front.py +++ b/bindings/python/offline_protocol_sdk/http_front/front.py @@ -10,8 +10,9 @@ * **A body leaves the host only inside a sealed message.** Requests and responses go through ``send_message``, never the service request path, - whose bodies are signed plaintext: on a LAN with no hop encryption that - path shows every body to every forwarder. + which falls back to signed plaintext whenever it cannot seal (an older + peer, or no confirmed session yet): on a LAN with no hop encryption that + fallback shows the body to every forwarder. * **Only a message the engine decrypted is acted on.** The engine refuses a wire sender that differs from the MLS-authenticated one, so for an ``encrypted`` message ``sender`` is the device that sealed it, and that is diff --git a/bindings/react-native/README.md b/bindings/react-native/README.md index f1499eec3..dfea99a1f 100644 --- a/bindings/react-native/README.md +++ b/bindings/react-native/README.md @@ -225,7 +225,7 @@ await protocol.start(); // MLS auto-initialized when encryption.enabled is true // Key packages are exchanged automatically when peers are discovered. ``` -Four runtime kill switches (all default `true`) let you disable a negotiated +Five runtime kill switches (all default `true`) let you disable a negotiated format or the recovery path without an SDK release: | Flag | Disables | @@ -234,6 +234,7 @@ format or the recovery path without an SDK release: | `encryption.compactEnvelopeEnabled` | Compact MLS envelope, back to the JSON floor | | `encryption.richPayloadEnabled` | Sealing rich extras — they drop rather than going cleartext | | `encryption.cryptoRecoveryEnabled` | Epoch-desync healing, back to legacy drop-and-ACK | +| `encryption.encryptServiceMessages` | Sealed service request and response bodies, back to signed plaintext | Each degrades independently, and inbound parsing of every format stays on regardless, so a disabled fleet still interoperates with an enabled one. See @@ -575,6 +576,7 @@ interface EncryptionConfig { compactEnvelopeEnabled?: boolean; // default: true (kill switch) richPayloadEnabled?: boolean; // default: true (kill switch) cryptoRecoveryEnabled?: boolean; // default: true (kill switch) + encryptServiceMessages?: boolean; // default: true (kill switch) pendingQueue?: PendingQueueConfig; } diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 28b843fff..f40e476f8 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -2534,6 +2534,31 @@ and the relays carry traffic meanwhile. LAN, so they meet a phone on this release only inside a Wi-Fi Direct group, as before. +**Service bodies are sealed between peers on this release.** A request or +response to a peer that also runs this release, once your session with it is +confirmed, now crosses the mesh as an ordinary `__MLS_ENC__` message instead +of signed plaintext. Nothing changes toward an older peer, and the first +request to a provider you have never met still leaves as signed plaintext. +Turn it off with `encryption.encryptServiceMessages: false` +(`encrypt_service_messages` in Rust and Python), which stops your device from +advertising, recording and sealing; a peer's sealed frame is still routed to +your service handlers. + +**A sealed service body is about a third larger on the wire.** With the +compact envelope, which every release that seals service bodies advertises +unless it is switched off, the frame is about 1.34 times the plaintext one: a +64 KiB body, the service layer's limit, becomes a frame of about 86 KiB. +Bodies above about 52 KB no longer cross Bluetooth LE at the 185-byte MTU +floor, where every body under the limit did, and bodies above about 47 KiB no +longer fit a Nostr relay's 64 KiB event limit. If a provider answers with +large bodies over those carriers, keep bodies under those sizes or switch the +sealing off. Toward a peer with the compact envelope switched off the frame is +about 3.6 times the plaintext one. + +**`service_request_received.sender` is proven a second way.** For a sealed +request it is the sender MLS authenticated, rather than the sender a control +signature proved. The field and its value are unchanged. + --- ## Appendix A: limits reference diff --git a/docs/android-integration.md b/docs/android-integration.md index fad916372..cc510b4d7 100644 --- a/docs/android-integration.md +++ b/docs/android-integration.md @@ -80,7 +80,7 @@ class MainActivity : AppCompatActivity() { maxPendingGlobal = 1_000.toULong(), pendingTtlMs = 86_400_000.toULong(), // 24 h (the SDK default) overflowPolicy = OverflowPolicy.DROP_OLDEST, - // These 9 use their defaults: requireEncryption (true), + // These 10 use their defaults: requireEncryption (true), // maxGroupMembers (256u), groupRelayEnabled (true), // groupRelayBroadcastEnabled (true — capability-gated, and it // falls back to per-member fan-out against any relay that did not @@ -88,7 +88,7 @@ class MainActivity : AppCompatActivity() { // docs/configuration.md#group-configuration), // requireTransportIdentity (false), binaryWireEnabled (true), // compactEnvelopeEnabled (true), richPayloadEnabled (true), - // cryptoRecoveryEnabled (true). + // cryptoRecoveryEnabled (true), encryptServiceMessages (true). ) protocol = OfflineProtocol(config) diff --git a/docs/api-reference.md b/docs/api-reference.md index 271cc4cb7..db97d104a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -168,6 +168,12 @@ pub struct EncryptionConfig { /// unauthenticated by construction — safe because it is bounded, not /// because it is trusted; see Crypto-Failure Recovery. (default: true) pub crypto_recovery_enabled: bool, + /// Seal service request and response bodies inside the recipient's 1:1 + /// MLS session, toward a peer that advertised svc_versions entry 1 (or + /// sent a sealed service frame) once the session is confirmed. Signed + /// plaintext otherwise, including the first request to a provider this + /// device has never met. Discovery is never sealed. (default: true) + pub encrypt_service_messages: bool, } ``` @@ -187,6 +193,7 @@ interface EncryptionConfig { compactEnvelopeEnabled?: boolean; // Default: true richPayloadEnabled?: boolean; // Default: true cryptoRecoveryEnabled?: boolean; // Default: true + encryptServiceMessages?: boolean; // Default: true } ``` @@ -200,7 +207,9 @@ pre-session queue is separate and has its own bounds; see See [Wire Format Kill Switches](configuration.md#wire-format-kill-switches) for what `compactEnvelopeEnabled` and `richPayloadEnabled` gate, and [Crypto-Failure Recovery](configuration.md#crypto-failure-recovery) for -`cryptoRecoveryEnabled`. +`cryptoRecoveryEnabled`, and +[Service Discovery](service-discovery.md#encryption-interaction) for +`encryptServiceMessages`. ### DorsConfig diff --git a/docs/bridges/python.md b/docs/bridges/python.md index 1459ace1d..f522727c7 100644 --- a/docs/bridges/python.md +++ b/docs/bridges/python.md @@ -372,7 +372,7 @@ other, so every rule of [the local API bridge](local-api.md) holds for it unchanged: it declares one application id, its sends are stamped with it, and the server holds its inbound messages while it is away. It sends every request and response as a direct message, never on the service request path, -whose bodies are plaintext, and it acts only on a `message_received` the +which falls back to signed plaintext whenever it cannot seal, and it acts only on a `message_received` the engine reports as `encrypted`. Discovery is the one thing it takes from the service path. It runs one per service, since every client under one application id receives each request. Everything a handler can wait on diff --git a/docs/configuration.md b/docs/configuration.md index bbf6e47d6..aa74980a7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -288,6 +288,7 @@ Controls automatic MLS end-to-end encryption. See [MLS Integration Guide](./mls- | `compactEnvelopeEnabled` | boolean | true | Emit the compact MLS envelope to recipients that advertise support (kill switch — see [Wire Format Kill Switches](#wire-format-kill-switches)) | | `richPayloadEnabled` | boolean | true | Seal rich extras (reply context, media metadata, forward attribution) inside the MLS ciphertext for capable recipients (kill switch — see [Wire Format Kill Switches](#wire-format-kill-switches)) | | `cryptoRecoveryEnabled` | boolean | true | Recover an undecryptable 1:1 message instead of dropping it and ACKing anyway (kill switch — see [Crypto-Failure Recovery](#crypto-failure-recovery)) | +| `encryptServiceMessages` | boolean | true | Seal service request and response bodies inside the 1:1 MLS session toward peers that advertise support, once the session is confirmed. Otherwise they stay signed plaintext (see [Service Discovery](service-discovery.md#encryption-interaction)) | | `pendingQueue.maxPendingPerPeer` | number | 64 | Max inbound encrypted messages held per peer awaiting session readiness | | `pendingQueue.maxPendingGlobal` | number | 4096 | Max inbound encrypted messages held across all peers | | `pendingQueue.pendingTtlMs` | number | 86400000 | TTL for held encrypted messages (24 hours; the queue is also persisted, see `docs/mls-integration.md`) | diff --git a/docs/ios-integration.md b/docs/ios-integration.md index f1ed6759b..e10f65c2d 100644 --- a/docs/ios-integration.md +++ b/docs/ios-integration.md @@ -119,14 +119,14 @@ final class MeshController { maxPendingGlobal: 1000, pendingTtlMs: 86_400_000, // 24 h (the SDK default) overflowPolicy: .dropOldest - // These 9 use their defaults: requireEncryption (true), + // These 10 use their defaults: requireEncryption (true), // maxGroupMembers (256), groupRelayEnabled (true), // groupRelayBroadcastEnabled (false — group sends fan out per // member so each copy gets the full delivery ladder; see // docs/configuration.md#group-configuration), // requireTransportIdentity (false), binaryWireEnabled (true), // compactEnvelopeEnabled (true), richPayloadEnabled (true), - // cryptoRecoveryEnabled (true). + // cryptoRecoveryEnabled (true), encryptServiceMessages (true). ) do { diff --git a/docs/mls-integration.md b/docs/mls-integration.md index 93514d4b4..4a72c6b65 100644 --- a/docs/mls-integration.md +++ b/docs/mls-integration.md @@ -116,6 +116,7 @@ const protocol = new OfflineProtocol({ | `compactEnvelopeEnabled` | `true` | Emit the compact MLS envelope to recipients that advertise `env_versions` | | `richPayloadEnabled` | `true` | Seal rich extras inside the MLS ciphertext for recipients that advertise `rich_versions` | | `cryptoRecoveryEnabled` | `true` | Recover an undecryptable 1:1 message instead of dropping it and ACKing anyway ([below](#crypto-failure-recovery)) | +| `encryptServiceMessages` | `true` | Seal service request and response bodies toward peers that advertise `svc_versions`, once the session is confirmed; signed plaintext otherwise ([service discovery](service-discovery.md#encryption-interaction)) | The `pendingTtlMs` default is 24 hours (it was 2 minutes, then 30 minutes, in earlier releases): under the deferred-ACK model a message held here is not diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 1d5cd7c00..70b134567 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -419,24 +419,52 @@ timeout and frames are retransmitted before they were ever written. Duplicates are absorbed by deduplication, so this is wasted work rather than loss, but it is a real scaling cliff. -### R9. Service discovery and service bodies are signed, not encrypted +### R9. Service bodies are sealed only toward capable peers; discovery is signed, not encrypted The service prefix family (`__SVC_DISC_Q__`, `__SVC_DISC_R__`, `__SVC_REQ__`, -`__SVC_RESP__`, and the generic service message) is control-plane: every frame -is signature-gated, so its sender is proven, and every frame is **exempt from -the encryption requirement**. Discovery gossip and the application-supplied -request and response bodies therefore travel in cleartext. - -**Impact:** A1 and A3 read service bodies and the full discovery pattern. This -is the one application-supplied payload that boundary 5 does **not** cover, so -"MLS protects application content" does not hold here. - -**Why it stands:** discovery is a broadcast to peers with whom no session -necessarily exists, so there is no established group to encrypt to; encrypting -request and response bodies is not implemented. - -**What application teams must do today:** treat a service body as public, and -encrypt anything sensitive above the SDK before handing it over. +`__SVC_RESP__`, and the generic service message) is control-plane: every +plaintext frame is signature-gated, so its sender is proven, and the family is +**exempt from the encryption requirement**. + +**Discovery** gossip always travels in cleartext. It is a broadcast to peers +with whom no session necessarily exists, and every hop must read `service_id` +to match and forward it, so there is no group to encrypt it to. + +**Request and response bodies** are sealed inside the recipient's 1:1 MLS +session when all of these hold: the sender's `encrypt_service_messages` is on +(the default), the recipient advertised `svc_versions` entry 1 in a signed key +package or proved it by sending a sealed service frame, and the session with +the recipient is confirmed. The provider then sees the MLS-authenticated +requester. A body still travels as signed plaintext: + +- toward a peer on a release from before this capability, or with the switch + off, +- toward a capable peer before the session confirms, which includes **the + first request to a provider this device has never met**: the attempt to + seal it is what starts the session, and the request goes out signed rather + than being held or dropped, +- after an attacker strips `svc_versions` from a key package in transit, or + replays an older signed package from before the peer upgraded, inside the + control-frame freshness window, until a genuine package or a sealed frame + from that peer arrives. + +The plaintext fallback is a stated exception to the capability rule that a +downgrade loses the feature rather than the confidentiality +([capability negotiation](../spec/capability-negotiation.md#the-exception-svc_versions)): +the signed plaintext form is the floor every older peer depends on. + +**Impact:** A1 and A3 read the full discovery pattern, and any service body that +took the fallback. Between two peers running this release with a confirmed +session, they read neither body. + +**What application teams must do today:** treat the first request to a provider +as readable by the hops that carry it, or make sure a session exists first (any +exchanged direct message does it). A body that must never travel in plaintext +goes in an ordinary direct message instead, as the HTTP front does. + +**Follow-up:** a durable record of a peer that has proved it routes sealed +service frames (today it is held in memory only), and a strict mode under +`require_encryption` that refuses the fallback toward such a peer. ### R10. Gateways are unauthenticated for delivery, and see who is in the zone diff --git a/docs/service-discovery.md b/docs/service-discovery.md index 8fa173537..e797e1522 100644 --- a/docs/service-discovery.md +++ b/docs/service-discovery.md @@ -87,9 +87,13 @@ Service discovery broadcasts to **known peers**, not just those with established ### Encryption Interaction -Service discovery, requests, and responses use `send_internal_message()` and are sent as **signed plaintext control messages** (Ed25519 control-message signing, not MLS). They are internal control messages — not user content — so they are **exempt from `require_encryption`**: service APIs work unchanged under the default fail-closed encryption policy (`require_encryption = true`), just like key packages, Welcome messages, and connection requests. +Service frames are internal control messages, not user content, so they are **exempt from `require_encryption`**: service APIs work unchanged under the default fail-closed encryption policy, just like key packages, Welcome messages and connection requests. What is encrypted depends on the frame. -Treat service payloads accordingly: anything passed through service discovery/request/response bodies travels unencrypted on the wire. Do not put sensitive user content in them. +- **Discovery** (`discover_services` and its answers) is always **signed plaintext** (Ed25519 control-message signing, not MLS). Every hop reads it to match the service and forward the query, and there is no single recipient whose session could seal it. Do not put anything sensitive in a service descriptor. +- **Requests and responses** are **sealed inside the recipient's 1:1 MLS session** when `encryption.encryptServiceMessages` is on (the default), the recipient runs a release that advertises support, and the session with the recipient is confirmed. The provider's `service_request_received.sender` is then the MLS-authenticated requester. +- Otherwise a request or response leaves as **signed plaintext**. That covers a peer on an older release, the switch off on either side, and any request sent before the session confirms, which includes **the first request to a provider this device has never met**. The attempt to seal that first request is what starts the session, so the next one is sealed. + +If a body must never travel in plaintext, make sure a session exists first (any exchanged direct message does it), or send it as an ordinary direct message instead of through the service API, as the HTTP front does. Threat model [R9](security/threat-model.md#r9-service-bodies-are-sealed-only-toward-capable-peers-discovery-is-signed-not-encrypted) records what the fallback leaves exposed. ## Rust API @@ -369,7 +373,7 @@ Service discovery is built on top of existing protocol infrastructure: - **Transport**: All service messages route through the DORS multi-transport selector (BLE, WiFi Direct, Internet, Reticulum). The best available transport is chosen automatically. - **Reliability**: ACK/retry mechanisms from the reliability layer are automatically applied to request/response messages. -- **Encryption**: Service messages are signed plaintext control messages, exempt from `require_encryption` (they bootstrap connectivity before sessions exist). Do not put sensitive user content in service payloads. +- **Encryption**: Discovery is signed plaintext. Requests and responses are sealed in the 1:1 MLS session toward peers that advertise support once the session is confirmed, and signed plaintext otherwise; see [Encryption Interaction](#encryption-interaction). All are exempt from `require_encryption`. - **Routing**: Discovery queries use the existing gossip and message forwarding infrastructure with the same deduplication and hop-counting as other mesh messages. - **Events**: Integrated with the existing `EventCallback` system — no separate event subscription needed. diff --git a/docs/spec/control-messages.md b/docs/spec/control-messages.md index 12dc2ab71..69de69851 100644 --- a/docs/spec/control-messages.md +++ b/docs/spec/control-messages.md @@ -149,10 +149,21 @@ treats the registry as a set of mutually non-prefixing frame tags fails on this entry alone; the correct reading is that `__SVC_` bounds a namespace and the four spellings above name the frames currently in it. -They are signature-gated like any control frame, but they are **exempt from the -encryption requirement**, so discovery gossip and the application-supplied -request and response bodies are sent in cleartext. See -[residual risk R9](../security/threat-model.md#r9-service-discovery-and-service-bodies-are-signed-not-encrypted). +They are signature-gated like any control frame, and they are **exempt from the +encryption requirement**. Discovery gossip is always sent in cleartext. + +`__SVC_REQ__` and `__SVC_RESP__` MAY instead travel inside `__MLS_ENC__` +toward a peer that advertised `svc_versions` entry 1 +([capability negotiation](capability-negotiation.md)), once the 1:1 session +with that peer is confirmed. The sealed form is unsigned, as every 1:1 +envelope is: MLS authenticates its sender. A receiver that decrypts a plaintext +beginning `__SVC_` from a 1:1 session slot MUST hand it to its service layer +with the MLS-authenticated sender, and MUST NOT surface it as a message. It +routes the frame whatever its own configuration says, because parsing is never +gated. The signed plaintext form remains the floor. Neither form changes the +exemption classes in this chapter, since the sealed form is `__MLS_ENC__`. See +[residual risk R9](../security/threat-model.md#r9-service-bodies-are-sealed-only-toward-capable-peers-discovery-is-signed-not-encrypted) for what the +fallback leaves exposed. ### Custody diff --git a/docs/spec/encryption-envelopes.md b/docs/spec/encryption-envelopes.md index 6a007c7d6..3e9787f28 100644 --- a/docs/spec/encryption-envelopes.md +++ b/docs/spec/encryption-envelopes.md @@ -263,6 +263,22 @@ queued behind session establishment re-makes the seal decision when it flushes, and a seal-time failure there would re-queue the message forever. Bounding at the boundary means every queued blob is already known to seal. +## Sealed service frames + +A service request or response sealed toward a peer is not a new envelope. The +whole `__SVC_REQ__` or `__SVC_RESP__` frame, prefix included, is the MLS +plaintext of an ordinary 1:1 `__MLS_ENC__` message, exactly as a `__DATA_V1__` +sync frame is. It is negotiated by `svc_versions`, honoured only from a signed +key package, and sent only once the 1:1 session is confirmed. + +A receiver routes it only from a 1:1 session slot (`session:`). The service +protocol has no group form, so a service prefix inside a group ciphertext is +left alone. The receiver confirms the session from the decrypt **before** it +handles the frame. The failure this ordering prevents: a reply the service +layer sends while handling the frame, such as the automatic `not_found`, would +otherwise find the session unconfirmed on the owner's first decrypt and leave +in plaintext. + ## Group sealing gate In a group, the sealed body is used only when **every** other member is known to diff --git a/docs/spec/http-front.md b/docs/spec/http-front.md index 58a218070..691853fed 100644 --- a/docs/spec/http-front.md +++ b/docs/spec/http-front.md @@ -31,8 +31,10 @@ breaks an invariant is wrong. 1. **A body leaves the host only inside a sealed message.** Requests and responses travel as the content of an ordinary direct message, which the engine encrypts end to end with MLS. A front MUST NOT carry a request or - response body on the service request path, whose bodies are signed but - not encrypted ([R9](../security/threat-model.md#r9-service-discovery-and-service-bodies-are-signed-not-encrypted)). + response body on the service request path. That path seals a body only + toward a peer that advertised it and only once their session is + confirmed, and sends it as signed plaintext otherwise, which a front + cannot observe ([R9](../security/threat-model.md#r9-service-bodies-are-sealed-only-toward-capable-peers-discovery-is-signed-not-encrypted)). The failure this prevents: every body readable by every device that forwards it, on a carrier with no hop encryption at all. 2. **A front acts only on a message the engine decrypted.** It processes an From ab014c9d3800677b10e95d0e50421ae0d1c5aaee Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Fri, 9 Oct 2026 20:07:03 +0530 Subject: [PATCH 5/7] fix(protocol,bindings)!: a sealed-service peer is never quietly unsealed Review of #544 found the plaintext fallback reaching further than the design says it does, and nobody able to see when it did. It turns out the proof that a peer routes sealed service frames lived in the same in-memory set the key package rewrites. So one replayed key package from before the peer upgraded removed it, and that peer's bodies went back to signed plaintext. The docs claimed the control frame freshness window bounds that replay. It doesn't: a key package signed under offline-ctrl-v1 is admitted at any age, on purpose, so it can always re-teach capabilities. A ratchet that any replay can clear is not a ratchet. Keep the proof where the control-freshness ratchet already lives, in the peer's encryption-capability record, which no key package touches. Restore it on start, seal on either the advertised or the proved set, and leave it standing through block and unblock. Every writer of that record now builds it from one place, because each write replaces the whole thing and a writer that spells a field out itself will one day spell it false. Second, a seal failure on a *confirmed* session used to send the body as signed plaintext and return Ok. The fallback exists for a peer we cannot seal to yet, not for an MLS or storage fault on one we can. Return the error, as send_message does. A failure while the session is still being set up keeps the documented fallback. Third, the service events now carry `encrypted`, so an application can tell which form arrived. That is the build break: Rust code naming every field of either variant needs the field or a `..`. Events cross the bindings as JSON, so nothing is regenerated. Fixes part of #533. --- CHANGELOG.md | 13 +- bindings/react-native/src/types.ts | 11 + .../offline-protocol-sealed/src/keypackage.rs | 7 +- crates/offline-protocol/src/events.rs | 28 +- .../offline-protocol/src/protocol/blocking.rs | 2 + .../src/protocol/message_dispatch.rs | 46 ++- crates/offline-protocol/src/protocol/mod.rs | 37 ++- .../offline-protocol/src/protocol/security.rs | 89 +++-- .../src/protocol/service_seal.rs | 38 ++- .../src/protocol/tests/mod.rs | 11 + .../src/protocol/tests/service_seal.rs | 304 +++++++++++++++++- crates/offline-protocol/src/protocol/types.rs | 15 + .../offline-protocol/src/telemetry/record.rs | 2 + .../src/telemetry/scrub_event.rs | 2 + docs/UPGRADING.md | 30 +- docs/security/threat-model.md | 30 +- docs/service-discovery.md | 12 +- docs/spec/capability-negotiation.md | 17 +- docs/spec/http-front.md | 4 +- 19 files changed, 608 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37dde471b..bddc70758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,9 +36,16 @@ archived by series under [docs/changelog/](docs/changelog/); see the answer to a sealed request is sealed too, and a sealed request retried after a session re-key is sealed again rather than replayed as dead ciphertext. Discovery stays signed plaintext, because every hop reads it to - forward it. A new switch, `encryption.encryptServiceMessages` - (`encrypt_service_messages`, default on), turns sealing off without a - release; routing an inbound sealed frame does not depend on it + forward it. A peer that has sent a sealed service frame stays sealed + through any later or replayed key package, by a durable ratchet beside the + control-freshness one. A seal failure on a confirmed session is returned as + an error rather than sent in plaintext. `service_request_received` and + `service_response_received` carry a new `encrypted` field, so an + application can tell which form arrived; Rust code naming every field of + either variant needs it or a `..`. A new switch, + `encryption.encryptServiceMessages` (`encrypt_service_messages`, default + on), turns sealing off without a release; routing an inbound sealed frame + does not depend on it ([capability negotiation](docs/spec/capability-negotiation.md#the-exception-svc_versions)). - **Android peer streams on the Wi-Fi network.** With diff --git a/bindings/react-native/src/types.ts b/bindings/react-native/src/types.ts index 32c4c54f5..5e331201f 100644 --- a/bindings/react-native/src/types.ts +++ b/bindings/react-native/src/types.ts @@ -2255,6 +2255,12 @@ export interface ServiceRequestReceivedEvent extends BaseEvent { method: string; body: string; sender: string; + /** + * Whether the request arrived sealed inside the 1:1 MLS session with + * `sender`. `false` means it arrived as signed plaintext that every device + * carrying it could read (an older peer, or no confirmed session yet). + */ + encrypted: boolean; } /** @@ -2267,6 +2273,11 @@ export interface ServiceResponseReceivedEvent extends BaseEvent { status: string; body: string; provider_peer_id: string; + /** + * Whether the response arrived sealed inside the 1:1 MLS session with the + * provider. `false` means it arrived as signed plaintext. + */ + encrypted: boolean; } // ============================================================================ diff --git a/crates/offline-protocol-sealed/src/keypackage.rs b/crates/offline-protocol-sealed/src/keypackage.rs index c94789004..7bf2f210d 100644 --- a/crates/offline-protocol-sealed/src/keypackage.rs +++ b/crates/offline-protocol-sealed/src/keypackage.rs @@ -174,8 +174,11 @@ pub struct KeyPackagePayload { /// whether a body is readable by every hop, so the receiver honours it /// only from a signed key package, as it does `nostr_pubkey`. Stripping it /// downgrades the peer's service bodies to signed plaintext; replaying an - /// older signed package inside the control-frame freshness window does the - /// same until a genuine one arrives. Forging it onto a legacy peer makes + /// older signed package does the same until a genuine one arrives, and no + /// freshness window bounds that replay, since a key package signed under + /// `offline-ctrl-v1` is admitted at any age. Neither reaches a peer that + /// has already sent a sealed service frame: the receiver keeps that proof + /// in a ratchet no key package clears. Forging it onto a legacy peer makes /// us send sealed frames it surfaces as chat text, which only the /// sender's own key could have produced, so it grants an attacker nothing. #[serde(default)] diff --git a/crates/offline-protocol/src/events.rs b/crates/offline-protocol/src/events.rs index db72b9cc5..bb3d8cb4a 100644 --- a/crates/offline-protocol/src/events.rs +++ b/crates/offline-protocol/src/events.rs @@ -1656,6 +1656,12 @@ pub enum Event { body: String, /// Peer user ID of the requester. sender: String, + /// Whether the request arrived sealed inside the 1:1 MLS session with + /// `sender`, so no device that carried it could read the body and the + /// session authenticated `sender`. `false` means it arrived as signed + /// plaintext, readable by every hop (threat model R9). + #[serde(default)] + encrypted: bool, }, /// A response to a service request was received. @@ -1670,6 +1676,11 @@ pub enum Event { body: String, /// Peer user ID of the provider. provider_peer_id: String, + /// Whether the response arrived sealed inside the 1:1 MLS session + /// with the provider. `false` means it arrived as signed plaintext, + /// readable by every hop (threat model R9). + #[serde(default)] + encrypted: bool, }, // --- Presence, typing, and read receipts --- @@ -2749,7 +2760,8 @@ impl Event { } } - /// Creates a ServiceRequestReceived event. + /// Creates a ServiceRequestReceived event for a request that arrived as + /// signed plaintext (`encrypted: false`). pub fn service_request_received( request_id: String, service_id: String, @@ -2763,10 +2775,12 @@ impl Event { method, body, sender, + encrypted: false, } } - /// Creates a ServiceResponseReceived event. + /// Creates a ServiceResponseReceived event for a response that arrived as + /// signed plaintext (`encrypted: false`). pub fn service_response_received( request_id: String, service_id: String, @@ -2780,6 +2794,7 @@ impl Event { status, body, provider_peer_id, + encrypted: false, } } @@ -3006,6 +3021,9 @@ impl From for Event { method, body, sender, + // The service layer cannot know; the engine sets it for a + // frame that arrived sealed (`handle_service_message`). + encrypted: false, }, ServiceEvent::ServiceResponseReceived { request_id, @@ -3019,6 +3037,7 @@ impl From for Event { status, body, provider_peer_id, + encrypted: false, }, } } @@ -3470,6 +3489,7 @@ impl fmt::Debug for Event { method, body, sender: _, + encrypted, } => f .debug_struct("ServiceRequestReceived") .field("request_id", request_id) @@ -3477,6 +3497,7 @@ impl fmt::Debug for Event { .field("method", method) .field("body", &format!("[REDACTED {} bytes]", body.len())) .field("sender", &"[REDACTED]") + .field("encrypted", encrypted) .finish(), Self::ServiceResponseReceived { request_id, @@ -3484,6 +3505,7 @@ impl fmt::Debug for Event { status, body, provider_peer_id: _, + encrypted, } => f .debug_struct("ServiceResponseReceived") .field("request_id", request_id) @@ -3491,6 +3513,7 @@ impl fmt::Debug for Event { .field("status", status) .field("body", &format!("[REDACTED {} bytes]", body.len())) .field("provider_peer_id", &"[REDACTED]") + .field("encrypted", encrypted) .finish(), Self::GroupCreated { group_id, name } => f .debug_struct("GroupCreated") @@ -4378,6 +4401,7 @@ mod tests { status: "ok".to_string(), body: "body".to_string(), provider_peer_id: SENTINEL.to_string(), + encrypted: true, }, Event::PresenceUpdated { peer_id: SENTINEL.to_string(), diff --git a/crates/offline-protocol/src/protocol/blocking.rs b/crates/offline-protocol/src/protocol/blocking.rs index 825f37b3a..03f718078 100644 --- a/crates/offline-protocol/src/protocol/blocking.rs +++ b/crates/offline-protocol/src/protocol/blocking.rs @@ -146,6 +146,8 @@ impl OfflineProtocol { self.peer_ctrl_freshness.remove(user_id); self.peer_rich_payload.remove(user_id); self.peer_rich_attested.remove(user_id); + // Advertised half only, as above: `svc_sealed_proved_peers` is a + // ratchet, and "block, then unblock" must not walk it back. self.peer_svc_sealed.remove(user_id); self.forget_data_sync_peer(user_id); // The Nostr sealing key lives in the transport, not in one of the sets diff --git a/crates/offline-protocol/src/protocol/message_dispatch.rs b/crates/offline-protocol/src/protocol/message_dispatch.rs index 2b96ca47c..37d63f358 100644 --- a/crates/offline-protocol/src/protocol/message_dispatch.rs +++ b/crates/offline-protocol/src/protocol/message_dispatch.rs @@ -163,6 +163,12 @@ impl OfflineProtocol { // confidentiality decision rather than a rendering one. The gate // refuses unsigned key packages today, so the check costs a branch // and keeps the posture from depending on that staying true. + // + // This is the advertised half only, and the removal below is + // what a replayed older key package can reach. A peer that has + // sent us a sealed service frame stays in + // `svc_sealed_proved_peers`, which nothing here touches, and the + // send path seals on either set. let advertises_svc_sealed = signed && payload.svc_versions.contains(&SVC_SEALED_V1); if self.config.encryption.encrypt_service_messages && advertises_svc_sealed { self.record_svc_sealed_peer(sender); @@ -2226,7 +2232,6 @@ impl OfflineProtocol { } } - /// Handles service discovery and request/response messages. /// Routes a service frame that arrived sealed in the 1:1 session with /// `sender` into the service layer. /// @@ -2241,22 +2246,41 @@ impl OfflineProtocol { /// here: the service protocol has no group form. /// /// A peer that sealed a service frame has proved it routes them, and no - /// replay of an older key package can fake that, so with the switch on the - /// sender is recorded as able to receive the sealed form. That closes the - /// window in which a provider would otherwise answer a sealed request in - /// plaintext because the requester's fresh key package has not landed yet. + /// replay of an older key package can fake that, so the sender is + /// recorded in the durable ratchet `svc_sealed_proved_peers`, which no key + /// package can clear. That closes two windows: a provider answering a + /// sealed request in plaintext because the requester's fresh key package + /// has not landed yet, and a replayed older key package removing the peer + /// from the advertised set. Recorded whatever our switch says (knowledge, + /// not policy); the switch gates only whether we seal. fn handle_sealed_service_frame(&mut self, sender: &str, content: &str, message: &Message) { - if self.config.encryption.encrypt_service_messages { + // The frame decrypted in a 1:1 session with `sender`, which is the + // encryption-capability fact itself. Recorded first because the + // ratchet below is a subset of that set and refuses anyone outside it. + self.record_encryption_capable(sender); + self.record_svc_sealed_proved(sender); + if !self.svc_sealed_proved_peers.contains(sender) + && self.config.encryption.encrypt_service_messages + { + // The ratchet refused a peer outside the capped encryption-capable + // set. Keep the proof for this run at least, so the reply is + // sealed. self.record_svc_sealed_peer(sender); } - self.handle_service_message(sender, content, message); + self.handle_service_message(sender, content, message, true); } + /// Handles service discovery and request/response messages. + /// + /// `sealed` says whether the frame arrived inside the 1:1 MLS session + /// with `sender`, and is reported to the application as the `encrypted` + /// field of the request and response events. pub(crate) fn handle_service_message( &mut self, sender: &str, content: &str, message: &Message, + sealed: bool, ) { let peers: Vec = self .known_peers @@ -2286,7 +2310,13 @@ impl OfflineProtocol { } if let Ok(state) = lock_shared_state(&self.shared_state) { for svc_event in events_to_emit { - state.emit_event(Event::from(svc_event)); + let mut event = Event::from(svc_event); + if let Event::ServiceRequestReceived { encrypted, .. } + | Event::ServiceResponseReceived { encrypted, .. } = &mut event + { + *encrypted = sealed; + } + state.emit_event(event); } } } diff --git a/crates/offline-protocol/src/protocol/mod.rs b/crates/offline-protocol/src/protocol/mod.rs index 385e0d128..eac150a80 100644 --- a/crates/offline-protocol/src/protocol/mod.rs +++ b/crates/offline-protocol/src/protocol/mod.rs @@ -423,19 +423,33 @@ pub struct OfflineProtocol { /// `svc_versions`), so a service request or response addressed to them /// may travel inside `__MLS_ENC__` rather than as signed plaintext. /// - /// Learned two ways. From a **signed** key package, like the sets above - /// (persisted as [`PeerCapabilities`], restored on `initialize_mls`); and - /// from a sealed service frame the peer sent us, which proves it more - /// strongly than any key package can, since an older package can be - /// replayed and a sealed frame from before the upgrade cannot exist. The - /// second is in memory only: the durable record stays what the peer - /// advertised. Bounded like `key_package_sent_to`. Forgetting a peer - /// costs signed plaintext service bodies toward them until either signal - /// arrives again. + /// The **advertised** half: learned only from a **signed** key package, + /// like the sets above (persisted as [`PeerCapabilities`], restored on + /// `initialize_mls`), and rewritten by every key package, so a fresh one + /// that stops advertising removes the peer. Bounded like + /// `key_package_sent_to`. The proved half is + /// [`Self::svc_sealed_proved_peers`], and either one is enough to seal. /// /// [`SVC_SEALED_V1`]: offline_protocol_sealed::SVC_SEALED_V1 pub(crate) peer_svc_sealed: std::collections::HashSet, + /// Peers that have sent us a service frame sealed inside the 1:1 session, + /// which proves they route the sealed form. + /// + /// A **ratchet**, unlike [`Self::peer_svc_sealed`]: no key package can + /// remove a peer from it. The failure it prevents is a replayed key + /// package from before the peer upgraded, which would otherwise drop the + /// peer from the advertised set and send its service bodies in signed + /// plaintext again. A key package signed under `offline-ctrl-v1` is + /// admitted at any age, so nothing else bounds that replay. Durable in + /// the peer's [`EncryptionCapableEntry`] beside `ctrl_freshness_proved`, + /// and a strict subset of [`Self::encryption_capable_peers`] by + /// construction, so `MAX_ENCRYPTION_CAPABLE_PEERS` bounds it too. + /// + /// Knowledge, not policy: recorded whatever our own switch says, and the + /// switch gates only whether we seal. + pub(crate) svc_sealed_proved_peers: std::collections::HashSet, + /// Peers whose key package advertised replicated-document sync /// ([`DATA_SYNC_V1`] in `data_versions`), so the send path may push /// `__DATA_V1__` frames to them. Same lifecycle as the two capability @@ -1181,6 +1195,7 @@ impl OfflineProtocol { control_reset_watermark: std::collections::HashMap::new(), peer_rich_payload: std::collections::HashSet::new(), peer_svc_sealed: std::collections::HashSet::new(), + svc_sealed_proved_peers: std::collections::HashSet::new(), peer_data_sync: std::collections::HashSet::new(), peer_data_group: std::collections::HashSet::new(), peer_data_group_attested: std::collections::HashSet::new(), @@ -1392,6 +1407,7 @@ impl OfflineProtocol { let previous_control_reset_watermark = self.control_reset_watermark.clone(); let previous_peer_rich_payload = self.peer_rich_payload.clone(); let previous_peer_svc_sealed = self.peer_svc_sealed.clone(); + let previous_svc_sealed_proved_peers = self.svc_sealed_proved_peers.clone(); let previous_peer_data_sync = self.peer_data_sync.clone(); let previous_peer_rich_attested = self.peer_rich_attested.clone(); let previous_peer_data_group = self.peer_data_group.clone(); @@ -1558,6 +1574,7 @@ impl OfflineProtocol { self.control_reset_watermark = previous_control_reset_watermark; self.peer_rich_payload = previous_peer_rich_payload; self.peer_svc_sealed = previous_peer_svc_sealed; + self.svc_sealed_proved_peers = previous_svc_sealed_proved_peers; self.peer_data_sync = previous_peer_data_sync; self.peer_rich_attested = previous_peer_rich_attested; self.peer_data_group = previous_peer_data_group; @@ -3541,7 +3558,7 @@ impl OfflineProtocol { // --- Service discovery & request/response --- if content.starts_with(offline_protocol_services::SVC_MESSAGE_PREFIX) { - self.handle_service_message(sender, content, message); + self.handle_service_message(sender, content, message, false); return Some(InternalMessageResult::Consumed); } diff --git a/crates/offline-protocol/src/protocol/security.rs b/crates/offline-protocol/src/protocol/security.rs index 835974a8f..e14baa158 100644 --- a/crates/offline-protocol/src/protocol/security.rs +++ b/crates/offline-protocol/src/protocol/security.rs @@ -1413,25 +1413,70 @@ impl OfflineProtocol { let Some(storage) = &self.secure_storage else { return; }; - let entry = EncryptionCapableEntry { + let entry = self.current_capable_entry(peer_id); + match serde_json::to_vec(&entry) { + Ok(data) => { + if let Err(e) = + storage.store(storage_keys::ENCRYPTION_CAPABLE_PEERS, peer_id, &data) + { + warn!(peer_id = %peer_id, error = %e, "Failed to persist control-freshness ratchet"); + } + } + Err(e) => { + warn!(peer_id = %peer_id, error = %e, "Failed to serialize control-freshness ratchet"); + } + } + } + + /// The durable capability record for `peer_id` as this node knows it now: + /// every ratchet and mark read from memory. + /// + /// Every writer of [`EncryptionCapableEntry`] builds it here. Each one + /// rewrites the whole record, so a writer that spelled a ratchet field out + /// itself and got it wrong (a literal `false`, a field added later and + /// missed) would clear a ratchet that is set, and a ratchet any ordinary + /// write can clear is not one. + pub(crate) fn current_capable_entry(&self, peer_id: &str) -> EncryptionCapableEntry { + EncryptionCapableEntry { last_seen_ms: Utc::now().timestamp_millis(), - ctrl_freshness_proved: true, + ctrl_freshness_proved: self.signs_freshness_bound_control(peer_id), last_reset_ms: self .control_reset_watermark .get(peer_id) .copied() .unwrap_or(0), + svc_sealed_proved: self.svc_sealed_proved_peers.contains(peer_id), + } + } + + /// Records that `peer_id` sent us a service frame sealed inside the 1:1 + /// session, so it routes the sealed form, in memory and durably. + /// + /// Same subset rule and same once-per-peer write as + /// [`Self::record_control_freshness_proved`]. Refusing a peer the capped + /// encryption-capable set did not accept costs only the ratchet: its + /// service bodies still seal while its key package advertises the form. + pub(crate) fn record_svc_sealed_proved(&mut self, peer_id: &str) { + if !self.is_encryption_capable(peer_id) { + return; + } + if !self.svc_sealed_proved_peers.insert(peer_id.to_string()) { + return; + } + let Some(storage) = &self.secure_storage else { + return; }; + let entry = self.current_capable_entry(peer_id); match serde_json::to_vec(&entry) { Ok(data) => { if let Err(e) = storage.store(storage_keys::ENCRYPTION_CAPABLE_PEERS, peer_id, &data) { - warn!(peer_id = %peer_id, error = %e, "Failed to persist control-freshness ratchet"); + warn!(peer_id = %peer_id, error = %e, "Failed to persist the sealed-service ratchet"); } } Err(e) => { - warn!(peer_id = %peer_id, error = %e, "Failed to serialize control-freshness ratchet"); + warn!(peer_id = %peer_id, error = %e, "Failed to serialize the sealed-service ratchet"); } } } @@ -1473,11 +1518,7 @@ impl OfflineProtocol { let Some(storage) = &self.secure_storage else { return; }; - let entry = EncryptionCapableEntry { - last_seen_ms: Utc::now().timestamp_millis(), - ctrl_freshness_proved: self.signs_freshness_bound_control(peer_id), - last_reset_ms: signed_ms, - }; + let entry = self.current_capable_entry(peer_id); match serde_json::to_vec(&entry) { Ok(data) => { if let Err(e) = @@ -1508,21 +1549,11 @@ impl OfflineProtocol { let Some(storage) = &self.secure_storage else { return false; }; - let entry = EncryptionCapableEntry { - last_seen_ms: Utc::now().timestamp_millis(), - // Carried forward rather than defaulted. This path writes the - // whole record, so spelling `false` here would clear a ratchet - // that is set — and a ratchet any ordinary frame can clear is not - // one. In practice the elision cache means this rarely rewrites an - // existing record, which is exactly the kind of "cannot happen" - // that stops being true after a refactor. - ctrl_freshness_proved: self.signs_freshness_bound_control(peer_id), - last_reset_ms: self - .control_reset_watermark - .get(peer_id) - .copied() - .unwrap_or(0), - }; + // Carried forward rather than defaulted (see `current_capable_entry`). + // In practice the elision cache means this rarely rewrites an existing + // record, which is exactly the kind of "cannot happen" that stops + // being true after a refactor. + let entry = self.current_capable_entry(peer_id); match serde_json::to_vec(&entry) { Ok(data) => { match storage.store(storage_keys::ENCRYPTION_CAPABLE_PEERS, peer_id, &data) { @@ -1584,7 +1615,7 @@ impl OfflineProtocol { let listed = peer_ids.len(); // Collected before marking: `mark_encryption_capable` takes `&mut self` // while `storage` is borrowed from `self`. - let mut capable: Vec<(String, bool, i64)> = Vec::new(); + let mut capable: Vec<(String, bool, i64, bool)> = Vec::new(); for peer_id in peer_ids.iter().take(MAX_RESTORE_KEYS_PER_CATEGORY) { // Storage keys bypass `UserId::new()`, so a corrupted or // pre-validation-era entry could contain hostile characters. @@ -1603,6 +1634,7 @@ impl OfflineProtocol { peer_id.clone(), entry.ctrl_freshness_proved, entry.last_reset_ms, + entry.svc_sealed_proved, )), Err(e) => { warn!(peer_id = %peer_id, error = %e, "Skipping corrupted capability record"); @@ -1615,7 +1647,7 @@ impl OfflineProtocol { } } let restored = capable.len() as u32; - for (peer_id, ctrl_freshness_proved, last_reset_ms) in capable { + for (peer_id, ctrl_freshness_proved, last_reset_ms, svc_sealed_proved) in capable { // Read back from the category, so the record demonstrably exists: // seeding the elision cache here is what keeps the first verified // frame from a restored peer from rewriting a record that is @@ -1629,6 +1661,11 @@ impl OfflineProtocol { if ctrl_freshness_proved { self.control_freshness_peers.insert(peer_id.clone()); } + // Losing this across a restart would let one replayed key + // package send that peer's service bodies in plaintext again. + if svc_sealed_proved { + self.svc_sealed_proved_peers.insert(peer_id.clone()); + } if last_reset_ms != 0 { // Losing this across a restart would make every reset this // node already acted on spendable again, which is the diff --git a/crates/offline-protocol/src/protocol/service_seal.rs b/crates/offline-protocol/src/protocol/service_seal.rs index 4a08ce2f2..49faba85c 100644 --- a/crates/offline-protocol/src/protocol/service_seal.rs +++ b/crates/offline-protocol/src/protocol/service_seal.rs @@ -7,6 +7,10 @@ //! that peer is confirmed. Toward everyone else the signed plaintext form is //! still sent, because it is the floor every older peer depends on. //! +//! A failure to seal on a session that is confirmed is returned as an error, +//! never sent in plaintext instead: the fallback exists for a peer we cannot +//! seal to yet, not for a fault on one we can. +//! //! Discovery (`__SVC_DISC_Q__` / `__SVC_DISC_R__`) never comes through here //! sealed: it is a gossip every hop must read to match and forward, and it has //! no single recipient whose session could seal it. @@ -29,6 +33,14 @@ impl OfflineProtocol { /// would break the service for a window the application cannot see. The /// encrypt attempt is also what starts that session, exactly as it does /// for a direct message, so the next request seals. + /// + /// The exception covers only that window. When the session is confirmed + /// and sealing still fails (an MLS or storage fault), the error is + /// returned, as `send_message` returns it for a direct message: a body the + /// caller expected sealed must not leave readable by every hop, and the + /// caller can retry a refused send but cannot see a downgraded one. A + /// session that vanished from storage is not this case: the confirmed + /// cache evicts it and reports `SessionNotReady`, which is the window. pub(crate) fn send_service_frame( &mut self, recipient: &str, @@ -59,14 +71,23 @@ impl OfflineProtocol { ); self.send_internal_message(recipient, content, priority) } - Err(err) => { - // A confirmed session that then fails to encrypt is the stale - // confirmed-cache case. Losing the request is worse than the - // exposure this fallback already accepts for an unconfirmed one. + Err(err) if self.confirmed_sessions.contains(recipient) => { warn!( recipient = %recipient, error = %err, - "Sealing a service frame failed; sent signed instead" + "Sealing a service frame failed on a confirmed session; not sent" + ); + Err(err) + } + Err(err) => { + // No confirmed session: the failure came from starting one + // (importing the key package, creating the group, sending the + // Welcome). That is the documented window, so the frame takes + // the floor exactly as it does on `SessionNotReady`. + debug!( + recipient = %recipient, + error = %err, + "Starting a session with a sealed-service peer failed; service frame sent signed" ); self.send_internal_message(recipient, content, priority) } @@ -75,12 +96,15 @@ impl OfflineProtocol { /// Whether `content` addressed to `recipient` should be attempted sealed: /// a request or response (never discovery), our switch on, MLS available, - /// and a peer known to route the sealed form. + /// and a peer known to route the sealed form, either because its key + /// package advertises it or because it has sent us a sealed service frame + /// (the ratchet a replayed key package cannot clear). fn should_seal_service_frame(&self, recipient: &str, content: &str) -> bool { (content.starts_with(SVC_REQUEST) || content.starts_with(SVC_RESPONSE)) && self.config.encryption.encrypt_service_messages && self.should_auto_encrypt() - && self.peer_svc_sealed.contains(recipient) + && (self.peer_svc_sealed.contains(recipient) + || self.svc_sealed_proved_peers.contains(recipient)) } /// Records that `peer` routes sealed service frames, bounded like diff --git a/crates/offline-protocol/src/protocol/tests/mod.rs b/crates/offline-protocol/src/protocol/tests/mod.rs index 018330be2..5949cda3c 100644 --- a/crates/offline-protocol/src/protocol/tests/mod.rs +++ b/crates/offline-protocol/src/protocol/tests/mod.rs @@ -22558,7 +22558,12 @@ fn test_process_svc_request_registered_emits_event() { method, body, sender, + encrypted, } => { + assert!( + !encrypted, + "a signed plaintext request is not reported sealed" + ); assert_eq!(request_id, "req-002"); assert_eq!(service_id, "echo"); assert_eq!(method, "ping"); @@ -22605,7 +22610,12 @@ fn test_process_svc_response_emits_event() { status, body, provider_peer_id, + encrypted, } => { + assert!( + !encrypted, + "a signed plaintext response is not reported sealed" + ); assert_eq!(request_id, "req-003"); assert_eq!(service_id, "echo"); assert_eq!(status, "ok"); @@ -37731,6 +37741,7 @@ fn capability_restore_stops_at_the_category_bound_without_pruning() { last_seen_ms: 1, ctrl_freshness_proved: false, last_reset_ms: 0, + svc_sealed_proved: false, }) .unwrap(); counting diff --git a/crates/offline-protocol/src/protocol/tests/service_seal.rs b/crates/offline-protocol/src/protocol/tests/service_seal.rs index b0db150d5..bfd3d213e 100644 --- a/crates/offline-protocol/src/protocol/tests/service_seal.rs +++ b/crates/offline-protocol/src/protocol/tests/service_seal.rs @@ -6,11 +6,15 @@ //! `service_request_received.sender` is the MLS-authenticated sender; a peer //! that does not advertise it still gets signed plaintext; a request before //! the session confirms falls back and starts the session; the automatic -//! answer to a sealed request is sealed even on the owner's first decrypt; and -//! a sealed request retried after a re-key is sealed again rather than -//! replayed dead. +//! answer to a sealed request is sealed even on the owner's first decrypt; a +//! sealed request retried after a re-key is sealed again rather than +//! replayed dead; a peer that sent a sealed frame stays sealed through a +//! replayed key package and a restart; a seal failure on a confirmed session +//! is an error rather than plaintext; and the service events say which form +//! arrived. use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use offline_protocol_core::{Message, ServiceDescriptor, ServiceId}; @@ -22,10 +26,10 @@ use offline_protocol_transport::{MockTransport, Transport, TransportType}; use super::{ create_test_config, create_test_config_for_user, establish_confirmed_session, signed_frame, }; -use crate::mls::InMemoryStorage; +use crate::mls::{InMemoryStorage, MlsStorage}; use crate::protocol::prefixes::internal_prefixes; use crate::protocol::types::KeyPackagePayload; -use crate::protocol::{OfflineProtocol, TestProtocolStateStorage}; +use crate::protocol::{OfflineProtocol, TestProtocolStateStorage, MAX_KEY_PACKAGE_SENT_TO}; use crate::test_identity::id; use crate::{Event, ProtocolConfig}; use offline_protocol_sealed::SVC_SEALED_V1; @@ -215,6 +219,20 @@ impl Pair { } } +/// The `encrypted` flag of every service request and response event emitted. +fn service_event_flags(events: &Arc>>) -> Vec { + events + .lock() + .unwrap() + .iter() + .filter_map(|event| match event { + Event::ServiceRequestReceived { encrypted, .. } + | Event::ServiceResponseReceived { encrypted, .. } => Some(*encrypted), + _ => None, + }) + .collect() +} + fn service_frames<'a>(frames: &[&'a Message]) -> Vec<&'a Message> { frames .iter() @@ -228,15 +246,22 @@ fn service_frames<'a>(frames: &[&'a Message]) -> Vec<&'a Message> { fn encrypted( label: &str, tweak: impl FnOnce(&mut ProtocolConfig), +) -> (OfflineProtocol, MockTransport, Arc>>) { + encrypted_on(label, Arc::new(InMemoryStorage::new()), tweak) +} + +/// [`encrypted`] over a storage the test supplies. +fn encrypted_on( + label: &str, + storage: Arc, + tweak: impl FnOnce(&mut ProtocolConfig), ) -> (OfflineProtocol, MockTransport, Arc>>) { let mut config = create_test_config_for_user(label); config.encryption.enabled = true; config.encryption.store_pending = true; tweak(&mut config); let mut protocol = OfflineProtocol::new(config).unwrap(); - protocol - .initialize_mls_for_test(Arc::new(InMemoryStorage::new())) - .unwrap(); + protocol.initialize_mls_for_test(storage).unwrap(); let transport = MockTransport::new(TransportType::BLE); transport.start().unwrap(); let handle = transport.clone(); @@ -511,6 +536,16 @@ fn service_request_and_response_are_sealed_between_capable_peers() { pair.alice.chat().is_empty() && pair.bob.chat().is_empty(), "a sealed service frame must never surface as a chat message" ); + assert_eq!( + service_event_flags(&pair.bob.events), + vec![true], + "the provider must be told the request arrived sealed" + ); + assert_eq!( + service_event_flags(&pair.alice.events), + vec![true], + "the requester must be told the response arrived sealed" + ); } /// A peer that does not advertise the sealed form (a release from before it, @@ -545,6 +580,11 @@ fn service_request_to_a_legacy_peer_stays_signed_plaintext() { // The gate refuses unsigned control frames, so the provider handling it // proves it left signed. assert_eq!(pair.bob.requests().len(), 1); + assert_eq!( + service_event_flags(&pair.bob.events), + vec![false], + "a plaintext request must not be reported sealed" + ); } /// A request to a capable peer before the session confirms leaves as signed @@ -659,8 +699,9 @@ fn the_automatic_reply_to_the_first_sealed_request_is_sealed() { } /// A sealed service frame proves its sender routes them, which a replayed key -/// package cannot, so bob answers sealed even though he never received a key -/// package from alice that advertised it. +/// package cannot, so bob records alice in the proved ratchet and answers +/// sealed even though he never received a key package from her that +/// advertised it. #[test] fn a_sealed_service_request_marks_its_sender_capable() { let (mut alice, alice_h, _) = encrypted("alice", |_| {}); @@ -674,7 +715,7 @@ fn a_sealed_service_request_marks_its_sender_capable() { .send_service_request(&id("bob"), "echo", "ping", "x") .unwrap(); deliver(&alice_h, &id("alice"), &mut bob, &bob_h); - assert!(bob.peer_svc_sealed.contains(&id("alice"))); + assert!(bob.svc_sealed_proved_peers.contains(&id("alice"))); assert!(bob_events .lock() .unwrap() @@ -692,8 +733,8 @@ fn a_sealed_service_request_marks_its_sender_capable() { /// Routing a sealed service frame is parsing, and parsing is never gated by a /// kill switch: with the switch off bob still hands alice's sealed request to -/// his service layer (rather than surfacing it as chat text), but he neither -/// records her as capable nor seals his answer. +/// his service layer (rather than surfacing it as chat text). He records the +/// proof, which is knowledge rather than policy, but does not seal his answer. #[test] fn a_receiver_with_the_switch_off_still_routes_a_sealed_service_frame() { let (mut alice, alice_h, _) = encrypted("alice", |_| {}); @@ -718,6 +759,7 @@ fn a_receiver_with_the_switch_off_still_routes_a_sealed_service_frame() { .iter() .any(|event| matches!(event, Event::MessageReceived { .. }))); assert!(!bob.peer_svc_sealed.contains(&id("alice"))); + assert!(bob.svc_sealed_proved_peers.contains(&id("alice"))); bob.respond_to_service_request(&request_id, &id("alice"), "echo", "ok", "y") .unwrap(); @@ -873,3 +915,239 @@ fn require_encryption_seals_service_frames_to_a_capable_confirmed_peer() { .iter() .all(|frame| frame.content.starts_with(internal_prefixes::ENCRYPTED))); } + +// --------------------------------------------------------------------------- +// The proof ratchet +// --------------------------------------------------------------------------- + +/// The review finding this pins: a signed key package that does not advertise +/// the sealed form, replayed after the peer has sent us a sealed service +/// frame, must not send that peer's service bodies in plaintext again. The +/// advertised half follows the key package; the proved half does not, and +/// sealing needs only one of them. +#[test] +fn a_replayed_key_package_cannot_unseal_a_peer_that_sent_a_sealed_frame() { + let (mut alice, alice_h, _) = encrypted("alice", |_| {}); + let (mut bob, bob_h, _) = encrypted("bob", |_| {}); + establish_confirmed_session(&mut alice, &id("alice"), &mut bob, &id("bob")); + alice.record_svc_sealed_peer(&id("bob")); + register(&mut bob, "echo"); + + let request_id = alice + .send_service_request(&id("bob"), "echo", "ping", "x") + .unwrap(); + deliver(&alice_h, &id("alice"), &mut bob, &bob_h); + assert!(bob.svc_sealed_proved_peers.contains(&id("alice"))); + + // A key package from before alice upgraded, signed by her and replayed. + let replay = signed_frame( + &id("alice"), + &id("bob"), + format!( + "{}{}", + internal_prefixes::KEY_PACKAGE, + key_package_body(&id("alice"), Vec::new()) + ), + ); + bob.process_internal_message(&replay); + assert!( + !bob.peer_svc_sealed.contains(&id("alice")), + "the advertised half follows the key package" + ); + assert!(bob.svc_sealed_proved_peers.contains(&id("alice"))); + + bob_h.clear_sent_messages(); + bob.respond_to_service_request(&request_id, &id("alice"), "echo", "ok", "y") + .unwrap(); + let sent = bob_h.sent_messages(); + assert!( + !sent + .iter() + .any(|frame| frame.content.starts_with(SVC_MESSAGE_PREFIX)), + "the replay downgraded the response to plaintext" + ); + assert!(sent + .iter() + .any(|frame| frame.content.starts_with(internal_prefixes::ENCRYPTED))); +} + +/// The proof survives a restart, and survives every other write of the +/// record it lives in: each one rewrites the whole record, so a writer that +/// forgot the field would clear the ratchet silently. +#[test] +fn the_sealed_service_proof_is_durable_and_no_other_write_clears_it() { + let storage = Arc::new(InMemoryStorage::new()); + let peer = id("provider"); + let mut protocol = super::protocol_with_mls_storage(storage.clone()); + protocol.record_encryption_capable(&peer); + protocol.record_svc_sealed_proved(&peer); + protocol.record_reset_spent(&peer, 5); + protocol.record_control_freshness_proved(&peer); + + let mut restarted = super::protocol_with_mls_storage(storage); + assert!( + restarted.svc_sealed_proved_peers.contains(&peer), + "a restart must not cost a proved peer its sealed service bodies" + ); + feed_signed_key_package(&mut restarted, &peer, Vec::new()); + assert!(restarted.svc_sealed_proved_peers.contains(&peer)); +} + +/// Refused outside the capped encryption-capable set, like the +/// control-freshness ratchet it sits beside, so the set the cap bounds also +/// bounds this one. +#[test] +fn the_sealed_service_proof_is_a_subset_of_the_capable_set() { + let mut protocol = super::protocol_with_mls_storage(Arc::new(InMemoryStorage::new())); + protocol.record_svc_sealed_proved(&id("stranger")); + assert!(!protocol.svc_sealed_proved_peers.contains(&id("stranger"))); +} + +/// The advertised set is bounded like `key_package_sent_to`: full, it is +/// cleared rather than grown. +#[test] +fn the_advertised_set_is_bounded() { + let mut protocol = negotiating_protocol(|_| {}); + for i in 0..MAX_KEY_PACKAGE_SENT_TO { + protocol.record_svc_sealed_peer(&format!("peer-{i}")); + } + assert_eq!(protocol.peer_svc_sealed.len(), MAX_KEY_PACKAGE_SENT_TO); + protocol.record_svc_sealed_peer("one-more"); + assert_eq!(protocol.peer_svc_sealed.len(), 1); + assert!(protocol.peer_svc_sealed.contains("one-more")); +} + +// --------------------------------------------------------------------------- +// A seal failure on a confirmed session +// --------------------------------------------------------------------------- + +/// MLS storage that refuses the group-state write encryption makes, on +/// demand, and passes everything else through, so a confirmed session fails +/// to seal while signing a plaintext frame still works. +struct GroupStateWriteFails { + inner: InMemoryStorage, + fail: AtomicBool, +} + +impl MlsStorage for GroupStateWriteFails { + fn store( + &self, + key_type: &str, + key_id: &str, + data: &[u8], + ) -> offline_protocol_mls::storage::StorageResult<()> { + if self.fail.load(Ordering::SeqCst) + && key_type == offline_protocol_mls::types::StorageKeyType::GroupState.as_str() + { + return Err(offline_protocol_mls::StorageError::StoreFailed( + "forced group-state write failure".to_string(), + )); + } + self.inner.store(key_type, key_id, data) + } + + fn load( + &self, + key_type: &str, + key_id: &str, + ) -> offline_protocol_mls::storage::StorageResult>> { + self.inner.load(key_type, key_id) + } + + fn delete( + &self, + key_type: &str, + key_id: &str, + ) -> offline_protocol_mls::storage::StorageResult<()> { + self.inner.delete(key_type, key_id) + } + + fn list_keys( + &self, + key_type: &str, + ) -> offline_protocol_mls::storage::StorageResult> { + self.inner.list_keys(key_type) + } +} + +/// The fallback to signed plaintext is for a peer we cannot seal to yet. On a +/// confirmed session a seal failure is returned to the caller, as it is for a +/// direct message, and nothing readable leaves. +#[test] +fn a_seal_failure_on_a_confirmed_session_is_an_error_not_plaintext() { + let storage = Arc::new(GroupStateWriteFails { + inner: InMemoryStorage::new(), + fail: AtomicBool::new(false), + }); + let (mut alice, alice_h, _) = encrypted_on("alice", storage.clone(), |_| {}); + let (mut bob, _bob_h, _) = encrypted("bob", |_| {}); + establish_confirmed_session(&mut alice, &id("alice"), &mut bob, &id("bob")); + alice.record_svc_sealed_peer(&id("bob")); + alice_h.clear_sent_messages(); + + storage.fail.store(true, Ordering::SeqCst); + let result = alice.send_service_request(&id("bob"), "echo", "ping", "must-not-leak"); + assert!( + result.is_err(), + "a seal failure on a confirmed session must be reported" + ); + for frame in alice_h.sent_messages() { + assert!( + !frame.content.contains("must-not-leak"), + "the body left readable: {}", + frame.content + ); + } +} + +// --------------------------------------------------------------------------- +// A sealed frame that arrives before the session +// --------------------------------------------------------------------------- + +/// A sealed request that reaches the provider before its session does is +/// queued undecrypted, and the drain that runs when the session is ready +/// routes it to the service layer like a live one: never surfaced as chat. +#[test] +fn a_sealed_request_queued_before_the_session_is_routed_when_it_drains() { + let (mut alice, alice_h, _) = encrypted("alice", |_| {}); + let (mut bob, bob_h, bob_events) = encrypted("bob", |_| {}); + register(&mut bob, "echo"); + + // Alice holds a session bob has not joined yet. + let welcome = { + let package = { + let manager = bob.mls_manager.as_ref().unwrap().read().unwrap(); + manager.get_or_create_key_package().unwrap() + }; + let manager = alice.mls_manager.as_ref().unwrap().read().unwrap(); + manager + .import_key_package(&id("bob"), &package.key_package_data) + .unwrap(); + manager.create_session(&id("bob")).unwrap() + }; + alice.confirmed_sessions.insert(id("bob")); + alice.record_svc_sealed_peer(&id("bob")); + + alice + .send_service_request(&id("bob"), "echo", "ping", "early") + .unwrap(); + let request = deliver(&alice_h, &id("alice"), &mut bob, &bob_h); + assert!(request[0].content.starts_with(internal_prefixes::ENCRYPTED)); + assert!( + service_event_flags(&bob_events).is_empty(), + "with no session the frame must wait, not be handled" + ); + + { + let manager = bob.mls_manager.as_ref().unwrap().read().unwrap(); + manager.join_session(&welcome).unwrap(); + } + bob.process_pending_decryption(&id("alice")); + + assert_eq!(service_event_flags(&bob_events), vec![true]); + assert!(!bob_events + .lock() + .unwrap() + .iter() + .any(|event| matches!(event, Event::MessageReceived { .. }))); +} diff --git a/crates/offline-protocol/src/protocol/types.rs b/crates/offline-protocol/src/protocol/types.rs index 2ab508c1d..129fbcc78 100644 --- a/crates/offline-protocol/src/protocol/types.rs +++ b/crates/offline-protocol/src/protocol/types.rs @@ -830,6 +830,21 @@ pub(crate) struct EncryptionCapableEntry { /// fixed. #[serde(default)] pub(crate) last_reset_ms: i64, + /// Whether this peer has ever sent us a service frame sealed inside the + /// 1:1 session, which proves it routes the sealed form. + /// + /// A **ratchet**, here rather than in [`PeerCapabilities`] for the reason + /// `ctrl_freshness_proved` is: that record is what a peer advertised and + /// every key package overwrites it. Kept there, one replayed key package + /// from before the peer upgraded would erase the proof and send that + /// peer's service bodies in signed plaintext again, and a key package + /// signed under `offline-ctrl-v1` is admitted at any age (the key-package + /// escape), so no freshness window bounds that replay. + /// + /// `#[serde(default)]` gives `false` for records written before the field + /// existed, which is correct: nothing was proved. + #[serde(default)] + pub(crate) svc_sealed_proved: bool, } /// Payload for key package exchange, and the compact envelope version it diff --git a/crates/offline-protocol/src/telemetry/record.rs b/crates/offline-protocol/src/telemetry/record.rs index 81ae8e83d..714eecee3 100644 --- a/crates/offline-protocol/src/telemetry/record.rs +++ b/crates/offline-protocol/src/telemetry/record.rs @@ -725,6 +725,7 @@ pub(crate) mod tests { method: String::new(), body: String::new(), sender: String::new(), + encrypted: false, }, Event::ServiceResponseReceived { request_id: String::new(), @@ -732,6 +733,7 @@ pub(crate) mod tests { status: String::new(), body: String::new(), provider_peer_id: String::new(), + encrypted: false, }, Event::PresenceUpdated { peer_id: String::new(), diff --git a/crates/offline-protocol/src/telemetry/scrub_event.rs b/crates/offline-protocol/src/telemetry/scrub_event.rs index 4543c05b7..14cb9687c 100644 --- a/crates/offline-protocol/src/telemetry/scrub_event.rs +++ b/crates/offline-protocol/src/telemetry/scrub_event.rs @@ -719,6 +719,7 @@ fn scrub_in_place(event: &mut Event, scrubber: &Scrubber) { service_id: _, method: _, body: _, + encrypted: _, } => { hash_string(sender, scrubber); } @@ -728,6 +729,7 @@ fn scrub_in_place(event: &mut Event, scrubber: &Scrubber) { service_id: _, status: _, body: _, + encrypted: _, } => { hash_string(provider_peer_id, scrubber); } diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index f40e476f8..794bdf101 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -52,8 +52,10 @@ for the new member. It also breaks one thing at run time: iOS's peer-stream slot moves from MultipeerConnectivity to Network framework, so an iPhone on `v0.28` does not see one on `v0.27` or earlier over that slot ([§26](#26-behaviour-that-changes-without-a-compile-error-v0280)). The -unreleased changes break no build; Android's peer-stream slot joins the Wi-Fi -network it is on +unreleased changes break one build, narrowly: Rust code that names every field +of `Event::ServiceRequestReceived` or `Event::ServiceResponseReceived` needs +the new `encrypted` field or a `..`. Android's peer-stream slot joins the Wi-Fi +network it is on, and service bodies are sealed between peers on this release ([§27](#27-behaviour-that-changes-without-a-compile-error-unreleased)). Otherwise, where a later section documents an @@ -2505,7 +2507,8 @@ default. `ProtocolManager` already passes it; only code that builds an ## 27. Behaviour that changes without a compile error *(unreleased)* -Everything compiles unchanged. Each paragraph says what to check. +Everything compiles unchanged except one narrow case, the first paragraph +under the service bodies below. Each paragraph says what to check. **Android: the peer-stream slot joins the Wi-Fi network.** With `wifiDirect: { enabled: true }`, an Android phone now advertises its address @@ -2559,6 +2562,27 @@ about 3.6 times the plaintext one. request it is the sender MLS authenticated, rather than the sender a control signature proved. The field and its value are unchanged. +**The two service events carry `encrypted`.** `service_request_received` and +`service_response_received` say whether the frame arrived sealed. Check it +if a body must never have crossed the mesh readable. This is the one build +break in this release: Rust code that destructures +`Event::ServiceRequestReceived` or `Event::ServiceResponseReceived` naming +every field, or builds one, needs `encrypted` or a `..`, and a TypeScript +object literal typed as either event needs `encrypted`. Code that reads the +events compiles unchanged, and the JSON only gains a field. + +**A service send can now fail where it used to succeed.** When sealing fails +on a session that is already confirmed (an MLS or storage fault), +`send_service_request` and `respond_to_service_request` return the error, as +`send_message` does. The body used to leave as signed plaintext instead. A +failure while the session is still being set up keeps the old behaviour: the +body leaves signed, because that is the documented first-contact window. + +**A peer that has sent you a sealed service frame stays sealed.** The proof is +durable, kept in the same record as the control-freshness ratchet, and no key +package clears it, including a replayed one that does not advertise support. +Blocking and then unblocking the peer does not clear it either. + --- ## Appendix A: limits reference diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 70b134567..16e59dd63 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -443,10 +443,23 @@ requester. A body still travels as signed plaintext: first request to a provider this device has never met**: the attempt to seal it is what starts the session, and the request goes out signed rather than being held or dropped, -- after an attacker strips `svc_versions` from a key package in transit, or - replays an older signed package from before the peer upgraded, inside the - control-frame freshness window, until a genuine package or a sealed frame - from that peer arrives. +- toward a peer that has **never sent us a sealed service frame**, after an + attacker strips `svc_versions` from its key package in transit or replays + an older signed package from before it upgraded, until a genuine package or + a sealed frame from that peer arrives. No freshness window bounds the + replay: a key package signed under `offline-ctrl-v1` is admitted at any age + (the key-package escape in the control-frame gate). + +A peer that has sent us a sealed service frame is past all three. The frame +proves it routes the sealed form, which no replay can fake, and the receiver +records that in a durable ratchet beside the control-freshness one, which no +key package can clear. From then on its bodies are sealed whatever its key +packages say. + +A failure to seal on a **confirmed** session (an MLS or storage fault) is +returned to the caller as an error, never sent in plaintext instead. The +fallback covers a peer this device cannot seal to yet, not a fault on one it +can. The plaintext fallback is a stated exception to the capability rule that a downgrade loses the feature rather than the confidentiality @@ -455,16 +468,17 @@ the signed plaintext form is the floor every older peer depends on. **Impact:** A1 and A3 read the full discovery pattern, and any service body that took the fallback. Between two peers running this release with a confirmed -session, they read neither body. +session, they read neither body. The receiving application can tell which form +arrived: `service_request_received` and `service_response_received` carry +`encrypted`. **What application teams must do today:** treat the first request to a provider as readable by the hops that carry it, or make sure a session exists first (any exchanged direct message does it). A body that must never travel in plaintext goes in an ordinary direct message instead, as the HTTP front does. -**Follow-up:** a durable record of a peer that has proved it routes sealed -service frames (today it is held in memory only), and a strict mode under -`require_encryption` that refuses the fallback toward such a peer. +**Follow-up:** a strict mode under `require_encryption` that refuses the +fallback toward a peer that advertised or proved the sealed form. ### R10. Gateways are unauthenticated for delivery, and see who is in the zone diff --git a/docs/service-discovery.md b/docs/service-discovery.md index e797e1522..2e55a45b9 100644 --- a/docs/service-discovery.md +++ b/docs/service-discovery.md @@ -92,6 +92,10 @@ Service frames are internal control messages, not user content, so they are **ex - **Discovery** (`discover_services` and its answers) is always **signed plaintext** (Ed25519 control-message signing, not MLS). Every hop reads it to match the service and forward the query, and there is no single recipient whose session could seal it. Do not put anything sensitive in a service descriptor. - **Requests and responses** are **sealed inside the recipient's 1:1 MLS session** when `encryption.encryptServiceMessages` is on (the default), the recipient runs a release that advertises support, and the session with the recipient is confirmed. The provider's `service_request_received.sender` is then the MLS-authenticated requester. - Otherwise a request or response leaves as **signed plaintext**. That covers a peer on an older release, the switch off on either side, and any request sent before the session confirms, which includes **the first request to a provider this device has never met**. The attempt to seal that first request is what starts the session, so the next one is sealed. +- Once a peer has sent this device a sealed request or response, its bodies stay sealed even if a later (or replayed) key package stops advertising support. That proof is durable. +- If sealing fails on a confirmed session, `send_service_request` and `respond_to_service_request` return the error. The body is never sent in plaintext instead. + +Each `service_request_received` and `service_response_received` event carries `encrypted`, so the receiving app can tell which form arrived and refuse a plaintext one if it must. If a body must never travel in plaintext, make sure a session exists first (any exchanged direct message does it), or send it as an ordinary direct message instead of through the service API, as the HTTP front does. Threat model [R9](security/threat-model.md#r9-service-bodies-are-sealed-only-toward-capable-peers-discovery-is-signed-not-encrypted) records what the fallback leaves exposed. @@ -205,6 +209,7 @@ Emitted on the **provider** node when a consumer sends a request to a locally re | `method` | `String` | Application-defined method name or action | | `body` | `String` | Request payload (typically JSON) | | `sender` | `String` | Peer ID of the requester (use as `requester` in response) | +| `encrypted` | `bool` | Whether the request arrived sealed in the 1:1 MLS session. `false` means signed plaintext that every hop could read; see [Encryption Interaction](#encryption-interaction) | ```json { @@ -213,7 +218,8 @@ Emitted on the **provider** node when a consumer sends a request to a locally re "service_id": "weather.v1", "method": "get_forecast", "body": "{\"city\": \"NYC\"}", - "sender": "alice" + "sender": "alice", + "encrypted": true } ``` @@ -228,6 +234,7 @@ Emitted on the **consumer** node when a provider responds to a request. | `status` | `String` | One of `"ok"`, `"not_found"`, `"error"`; a response with any other status is dropped before this event | | `body` | `String` | Response payload | | `provider_peer_id` | `String` | Peer ID of the provider | +| `encrypted` | `bool` | Whether the response arrived sealed in the 1:1 MLS session | ```json { @@ -236,7 +243,8 @@ Emitted on the **consumer** node when a provider responds to a request. "service_id": "weather.v1", "status": "ok", "body": "{\"temp\": 72, \"unit\": \"F\"}", - "provider_peer_id": "bob" + "provider_peer_id": "bob", + "encrypted": true } ``` diff --git a/docs/spec/capability-negotiation.md b/docs/spec/capability-negotiation.md index 14f002b8c..1cf20d7c4 100644 --- a/docs/spec/capability-negotiation.md +++ b/docs/spec/capability-negotiation.md @@ -224,13 +224,22 @@ its bodies as ordinary sealed messages instead: - **Stripping** the field downgrades that peer's service bodies to signed plaintext until a genuine package arrives. - **Replaying** an older signed package from before the peer upgraded does the - same, inside the control-frame freshness window. + same. No freshness window bounds it: a key package signed under + `offline-ctrl-v1` is admitted at any age, so that the key package can always + re-teach capabilities. - **The first request** to a provider this device has no confirmed session with leaves in signed plaintext, because there is nothing yet to seal it with. -A peer that sends a **sealed** service frame has proved it routes them, which -no replay can fake, so a receiver MAY treat that frame as an advertisement of -entry 1 for the sender. The reference implementation does, in memory. +The first two reach only a peer that has never sent us a sealed service frame. +A peer that sends one has proved it routes them, which no replay can fake, so a +receiver SHOULD record that as a **ratchet**: durable, kept apart from the +advertised record every key package overwrites, and never cleared by a key +package. It then seals toward that peer whatever later key packages say. The +reference implementation stores it in the peer's encryption-capability record, +beside the control-freshness ratchet. + +A sender that cannot seal on a session it has confirmed MUST NOT fall back to +plaintext. The fallback is for a peer it cannot seal to yet. ## Relay capabilities diff --git a/docs/spec/http-front.md b/docs/spec/http-front.md index 691853fed..fd121d964 100644 --- a/docs/spec/http-front.md +++ b/docs/spec/http-front.md @@ -33,8 +33,8 @@ breaks an invariant is wrong. engine encrypts end to end with MLS. A front MUST NOT carry a request or response body on the service request path. That path seals a body only toward a peer that advertised it and only once their session is - confirmed, and sends it as signed plaintext otherwise, which a front - cannot observe ([R9](../security/threat-model.md#r9-service-bodies-are-sealed-only-toward-capable-peers-discovery-is-signed-not-encrypted)). + confirmed, and sends it as signed plaintext otherwise, which the sending + side cannot see before it happens ([R9](../security/threat-model.md#r9-service-bodies-are-sealed-only-toward-capable-peers-discovery-is-signed-not-encrypted)). The failure this prevents: every body readable by every device that forwards it, on a carrier with no hop encryption at all. 2. **A front acts only on a message the engine decrypted.** It processes an From c8bae754e89045c15b91734948c9eec034109cb6 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Fri, 9 Oct 2026 23:04:50 +0530 Subject: [PATCH 6/7] fix(protocol): a key package flood cannot unseal service bodies The advertised half of the sealed-service capability copied the bound its neighbours use: at 1000 peers, clear the whole set and start over. That is fine for the compact envelope or rich extras, where forgetting a peer costs a feature. It is *not* fine here, where forgetting a peer sends its service bodies in signed plaintext. So a neighbour mints a thousand identities, one keygen each, sends a thousand signed key packages, and every established peer that has not yet sent us a sealed frame gets its next request or response in the clear. No error, no event. Flood both ends before the first exchange and neither side ever earns the ratchet. This is not great. Make the set a strict subset of the capped encryption-capable set, refused at that cap instead of evicted, the same rule the proof ratchet and the reset watermark already follow. The gate marks a signed sender capable before its key package reaches dispatch, so a genuine peer still gets in, and a flood only costs its own later identities the capability. The "keep it for this run" fallback in the sealed-frame handler existed only to paper over the ratchet refusing a peer; with the subset rule it can never fire, so it goes. While at it, correct R9: a proved peer is not past all three plaintext cases. A request sent while a desync re-key rebuilds the session still takes the not-yet-confirmed fallback. And note the both-create owner that answers its first sealed request in the clear, which is inside that same documented window. --- .../src/protocol/message_dispatch.rs | 14 ++--- crates/offline-protocol/src/protocol/mod.rs | 6 +- .../src/protocol/service_seal.rs | 23 ++++--- .../src/protocol/tests/service_seal.rs | 60 +++++++++++++------ docs/security/threat-model.md | 17 ++++-- 5 files changed, 78 insertions(+), 42 deletions(-) diff --git a/crates/offline-protocol/src/protocol/message_dispatch.rs b/crates/offline-protocol/src/protocol/message_dispatch.rs index 37d63f358..af7ab8dfb 100644 --- a/crates/offline-protocol/src/protocol/message_dispatch.rs +++ b/crates/offline-protocol/src/protocol/message_dispatch.rs @@ -2253,20 +2253,18 @@ impl OfflineProtocol { /// has not landed yet, and a replayed older key package removing the peer /// from the advertised set. Recorded whatever our switch says (knowledge, /// not policy); the switch gates only whether we seal. + /// + /// One case still answers in plaintext: a both-create owner whose decrypt + /// `can_confirm_from_source` refused to confirm on holds an unconfirmed + /// session, so a reply sent from inside the handler (the automatic + /// `not_found`) takes the documented "before the session confirms" + /// fallback even though the proof was just recorded. fn handle_sealed_service_frame(&mut self, sender: &str, content: &str, message: &Message) { // The frame decrypted in a 1:1 session with `sender`, which is the // encryption-capability fact itself. Recorded first because the // ratchet below is a subset of that set and refuses anyone outside it. self.record_encryption_capable(sender); self.record_svc_sealed_proved(sender); - if !self.svc_sealed_proved_peers.contains(sender) - && self.config.encryption.encrypt_service_messages - { - // The ratchet refused a peer outside the capped encryption-capable - // set. Keep the proof for this run at least, so the reply is - // sealed. - self.record_svc_sealed_peer(sender); - } self.handle_service_message(sender, content, message, true); } diff --git a/crates/offline-protocol/src/protocol/mod.rs b/crates/offline-protocol/src/protocol/mod.rs index eac150a80..6d9c002b5 100644 --- a/crates/offline-protocol/src/protocol/mod.rs +++ b/crates/offline-protocol/src/protocol/mod.rs @@ -426,8 +426,10 @@ pub struct OfflineProtocol { /// The **advertised** half: learned only from a **signed** key package, /// like the sets above (persisted as [`PeerCapabilities`], restored on /// `initialize_mls`), and rewritten by every key package, so a fresh one - /// that stops advertising removes the peer. Bounded like - /// `key_package_sent_to`. The proved half is + /// that stops advertising removes the peer. A strict subset of + /// [`Self::encryption_capable_peers`], refused at its cap rather than + /// cleared when full: clearing would send every established peer's + /// service bodies in plaintext on a flood. The proved half is /// [`Self::svc_sealed_proved_peers`], and either one is enough to seal. /// /// [`SVC_SEALED_V1`]: offline_protocol_sealed::SVC_SEALED_V1 diff --git a/crates/offline-protocol/src/protocol/service_seal.rs b/crates/offline-protocol/src/protocol/service_seal.rs index 49faba85c..8b20ea899 100644 --- a/crates/offline-protocol/src/protocol/service_seal.rs +++ b/crates/offline-protocol/src/protocol/service_seal.rs @@ -15,7 +15,7 @@ //! sealed: it is a gossip every hop must read to match and forward, and it has //! no single recipient whose session could seal it. -use super::{OfflineProtocol, OutboxReseal, MAX_KEY_PACKAGE_SENT_TO}; +use super::{OfflineProtocol, OutboxReseal}; use crate::{Error, Result}; use offline_protocol_core::{ContentType, MessageId, MessagePriority}; use offline_protocol_services::{SVC_REQUEST, SVC_RESPONSE}; @@ -107,14 +107,21 @@ impl OfflineProtocol { || self.svc_sealed_proved_peers.contains(recipient)) } - /// Records that `peer` routes sealed service frames, bounded like - /// `key_package_sent_to`: the set is keyed by a sender id, and forgetting a - /// peer costs only signed plaintext toward them until the next signal. + /// Records that `peer` routes sealed service frames. + /// + /// Bounded as a strict subset of the capped encryption-capable set, + /// refused rather than evicted, unlike the feature sets beside it that + /// clear themselves when full. Those can, because forgetting a peer there + /// costs a feature and never confidentiality. Here forgetting a peer sends + /// its service bodies in signed plaintext, so a flood of fresh identities + /// (one keygen each) that cleared the set would downgrade every + /// established peer with no symptom. Refusing at the cap costs a flood's + /// later peers the capability and never costs an earlier peer the sealing + /// it had. The security gate marks a signed sender encryption-capable + /// before any key package reaches dispatch, so a genuine peer passes. pub(super) fn record_svc_sealed_peer(&mut self, peer: &str) { - if !self.peer_svc_sealed.contains(peer) - && self.peer_svc_sealed.len() >= MAX_KEY_PACKAGE_SENT_TO - { - self.peer_svc_sealed.clear(); + if !self.is_encryption_capable(peer) { + return; } self.peer_svc_sealed.insert(peer.to_string()); } diff --git a/crates/offline-protocol/src/protocol/tests/service_seal.rs b/crates/offline-protocol/src/protocol/tests/service_seal.rs index bfd3d213e..f78f7c697 100644 --- a/crates/offline-protocol/src/protocol/tests/service_seal.rs +++ b/crates/offline-protocol/src/protocol/tests/service_seal.rs @@ -29,7 +29,7 @@ use super::{ use crate::mls::{InMemoryStorage, MlsStorage}; use crate::protocol::prefixes::internal_prefixes; use crate::protocol::types::KeyPackagePayload; -use crate::protocol::{OfflineProtocol, TestProtocolStateStorage, MAX_KEY_PACKAGE_SENT_TO}; +use crate::protocol::{OfflineProtocol, TestProtocolStateStorage, MAX_ENCRYPTION_CAPABLE_PEERS}; use crate::test_identity::id; use crate::{Event, ProtocolConfig}; use offline_protocol_sealed::SVC_SEALED_V1; @@ -341,6 +341,14 @@ fn feed_signed_key_package(protocol: &mut OfflineProtocol, sender: &str, svc_ver protocol.process_internal_message(&message); } +/// Records that `peer` advertised the sealed form, as a signed key package +/// that passed the gate would: the gate marks the signed sender +/// encryption-capable first, and the advertised set is a subset of that one. +fn advertise(protocol: &mut OfflineProtocol, peer: &str) { + protocol.record_encryption_capable(peer); + protocol.record_svc_sealed_peer(peer); +} + fn negotiating_protocol(tweak: impl FnOnce(&mut ProtocolConfig)) -> OfflineProtocol { let mut config = create_test_config(); config.encryption.enabled = true; @@ -665,8 +673,8 @@ fn the_automatic_reply_to_the_first_sealed_request_is_sealed() { let (mut bob, bob_h, _) = encrypted("bob", |_| {}); establish_confirmed_session(&mut alice, &id("alice"), &mut bob, &id("bob")); bob.confirmed_sessions.remove(&id("alice")); - alice.record_svc_sealed_peer(&id("bob")); - bob.record_svc_sealed_peer(&id("alice")); + advertise(&mut alice, &id("bob")); + advertise(&mut bob, &id("alice")); alice .send_service_request(&id("bob"), "nobody-here", "ping", "x") @@ -707,7 +715,7 @@ fn a_sealed_service_request_marks_its_sender_capable() { let (mut alice, alice_h, _) = encrypted("alice", |_| {}); let (mut bob, bob_h, bob_events) = encrypted("bob", |_| {}); establish_confirmed_session(&mut alice, &id("alice"), &mut bob, &id("bob")); - alice.record_svc_sealed_peer(&id("bob")); + advertise(&mut alice, &id("bob")); assert!(!bob.peer_svc_sealed.contains(&id("alice"))); register(&mut bob, "echo"); @@ -742,7 +750,7 @@ fn a_receiver_with_the_switch_off_still_routes_a_sealed_service_frame() { config.encryption.encrypt_service_messages = false; }); establish_confirmed_session(&mut alice, &id("alice"), &mut bob, &id("bob")); - alice.record_svc_sealed_peer(&id("bob")); + advertise(&mut alice, &id("bob")); register(&mut bob, "echo"); let request_id = alice @@ -797,7 +805,7 @@ fn a_sealed_service_request_is_resealed_on_resend_after_a_rekey() { alice.confirmed_sessions.insert(id("bob")); }; join(&mut alice); - alice.record_svc_sealed_peer(&id("bob")); + advertise(&mut alice, &id("bob")); alice .send_service_request(&id("bob"), "echo", "ping", "retry-me") @@ -900,7 +908,7 @@ fn require_encryption_seals_service_frames_to_a_capable_confirmed_peer() { let (mut alice, alice_h, _) = strict("alice"); let (mut bob, _bob_h, _) = strict("bob"); establish_confirmed_session(&mut alice, &id("alice"), &mut bob, &id("bob")); - alice.record_svc_sealed_peer(&id("bob")); + advertise(&mut alice, &id("bob")); alice_h.clear_sent_messages(); alice @@ -930,7 +938,7 @@ fn a_replayed_key_package_cannot_unseal_a_peer_that_sent_a_sealed_frame() { let (mut alice, alice_h, _) = encrypted("alice", |_| {}); let (mut bob, bob_h, _) = encrypted("bob", |_| {}); establish_confirmed_session(&mut alice, &id("alice"), &mut bob, &id("bob")); - alice.record_svc_sealed_peer(&id("bob")); + advertise(&mut alice, &id("bob")); register(&mut bob, "echo"); let request_id = alice @@ -1003,18 +1011,32 @@ fn the_sealed_service_proof_is_a_subset_of_the_capable_set() { assert!(!protocol.svc_sealed_proved_peers.contains(&id("stranger"))); } -/// The advertised set is bounded like `key_package_sent_to`: full, it is -/// cleared rather than grown. +/// The advertised set is a subset of the capped encryption-capable set and +/// refuses at that cap rather than evicting. The failure this pins: a set that +/// cleared itself when full let a flood of fresh identities, one keygen each, +/// send every established peer's service bodies in plaintext. #[test] -fn the_advertised_set_is_bounded() { +fn a_flood_of_identities_cannot_unseal_an_established_peer() { let mut protocol = negotiating_protocol(|_| {}); - for i in 0..MAX_KEY_PACKAGE_SENT_TO { - protocol.record_svc_sealed_peer(&format!("peer-{i}")); + advertise(&mut protocol, &id("established")); + for i in 0..MAX_ENCRYPTION_CAPABLE_PEERS + 10 { + advertise(&mut protocol, &format!("flood-{i}")); } - assert_eq!(protocol.peer_svc_sealed.len(), MAX_KEY_PACKAGE_SENT_TO); - protocol.record_svc_sealed_peer("one-more"); - assert_eq!(protocol.peer_svc_sealed.len(), 1); - assert!(protocol.peer_svc_sealed.contains("one-more")); + assert!( + protocol.peer_svc_sealed.contains(&id("established")), + "the flood evicted an established peer from the advertised set" + ); + assert!(protocol.peer_svc_sealed.len() <= MAX_ENCRYPTION_CAPABLE_PEERS); + assert!(!protocol.peer_svc_sealed.contains("flood-8200")); +} + +/// Outside the encryption-capable set the advertisement is refused, so the +/// cap that bounds that set bounds this one. +#[test] +fn the_advertised_set_is_a_subset_of_the_capable_set() { + let mut protocol = negotiating_protocol(|_| {}); + protocol.record_svc_sealed_peer(&id("stranger")); + assert!(!protocol.peer_svc_sealed.contains(&id("stranger"))); } // --------------------------------------------------------------------------- @@ -1082,7 +1104,7 @@ fn a_seal_failure_on_a_confirmed_session_is_an_error_not_plaintext() { let (mut alice, alice_h, _) = encrypted_on("alice", storage.clone(), |_| {}); let (mut bob, _bob_h, _) = encrypted("bob", |_| {}); establish_confirmed_session(&mut alice, &id("alice"), &mut bob, &id("bob")); - alice.record_svc_sealed_peer(&id("bob")); + advertise(&mut alice, &id("bob")); alice_h.clear_sent_messages(); storage.fail.store(true, Ordering::SeqCst); @@ -1126,7 +1148,7 @@ fn a_sealed_request_queued_before_the_session_is_routed_when_it_drains() { manager.create_session(&id("bob")).unwrap() }; alice.confirmed_sessions.insert(id("bob")); - alice.record_svc_sealed_peer(&id("bob")); + advertise(&mut alice, &id("bob")); alice .send_service_request(&id("bob"), "echo", "ping", "early") diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index 16e59dd63..2cab6c796 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -450,11 +450,18 @@ requester. A body still travels as signed plaintext: replay: a key package signed under `offline-ctrl-v1` is admitted at any age (the key-package escape in the control-frame gate). -A peer that has sent us a sealed service frame is past all three. The frame -proves it routes the sealed form, which no replay can fake, and the receiver -records that in a durable ratchet beside the control-freshness one, which no -key package can clear. From then on its bodies are sealed whatever its key -packages say. +A peer that has sent us a sealed service frame is past the first and the +third. The frame proves it routes the sealed form, which no replay can fake, +and the receiver records that in a durable ratchet beside the control-freshness +one, which no key package can clear. From then on its bodies are sealed +whatever its key packages say, except while the session is not confirmed: a +request sent while a desync re-key rebuilds the session takes the second +fallback, toward a proved peer too. + +The advertised capability cannot be flushed by a flood either. The set that +holds it is a subset of the capped encryption-capable set and refuses new +peers at the cap rather than evicting old ones, so a stream of fresh +identities costs only the later identities the capability. A failure to seal on a **confirmed** session (an MLS or storage fault) is returned to the caller as an error, never sent in plaintext instead. The From 152c06a4236c3101e66f6bdc257975c4329fa924 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Fri, 9 Oct 2026 23:04:50 +0530 Subject: [PATCH 7/7] fix(protocol): route only sealed requests and responses The receive side handed *any* decrypted plaintext starting with `__SVC_` to the service layer, while the send side only ever seals requests and responses. Discovery is never sealed, because every hop has to read it to match and forward. So a peer that sealed a discovery query anyway got it routed, and we then forwarded it onward as plaintext gossip. Not a disclosure the peer couldn't have caused by sending it in the clear, but the send rule and the receive rule disagreeing is how the next frame family gets routed before anyone designed it. Match the two frames that are sealed and drop any other service frame found in a 1:1 session. Dropped, not passed through: it must never surface as a chat message either. The spec now says so. --- .../src/protocol/message_dispatch.rs | 27 +++++++++-- .../src/protocol/tests/service_seal.rs | 46 ++++++++++++++++++- docs/spec/control-messages.md | 7 ++- 3 files changed, 73 insertions(+), 7 deletions(-) diff --git a/crates/offline-protocol/src/protocol/message_dispatch.rs b/crates/offline-protocol/src/protocol/message_dispatch.rs index af7ab8dfb..99c63bf31 100644 --- a/crates/offline-protocol/src/protocol/message_dispatch.rs +++ b/crates/offline-protocol/src/protocol/message_dispatch.rs @@ -17,7 +17,7 @@ use chrono::Utc; use offline_protocol_core::{Message, MessagePriority}; use offline_protocol_mls::{EncryptedMessage, WelcomeMessage}; use offline_protocol_sealed::SVC_SEALED_V1; -use offline_protocol_services::{ServiceAction, SVC_MESSAGE_PREFIX}; +use offline_protocol_services::{ServiceAction, SVC_MESSAGE_PREFIX, SVC_REQUEST, SVC_RESPONSE}; use offline_protocol_transport::TransportType; use tracing::{debug, error, info, warn}; @@ -1369,10 +1369,29 @@ impl OfflineProtocol { // sealed too; handled before it, the owner's first decrypt // from this peer would answer in plaintext. let mut text = text; - let sealed_service_frame = (group_id.starts_with("session:") - && text.starts_with(SVC_MESSAGE_PREFIX)) + // + // Request and response only, the two frames a sender + // seals. Discovery is never sealed: routing a sealed query + // would have us forward it onward as plaintext gossip, so + // any other service frame found here is dropped, and like + // every service frame it never surfaces as a message. + let in_session = group_id.starts_with("session:"); + let sealed_service_frame = (in_session + && (text.starts_with(SVC_REQUEST) || text.starts_with(SVC_RESPONSE))) .then(|| std::mem::take(&mut text)); - let surfaced = if is_session_confirm || sealed_service_frame.is_some() { + let unroutable_service_frame = sealed_service_frame.is_none() + && in_session + && text.starts_with(SVC_MESSAGE_PREFIX); + if unroutable_service_frame { + debug!( + sender = %sender_owned, + "Dropping a sealed service frame that is not a request or response" + ); + } + let surfaced = if is_session_confirm + || sealed_service_frame.is_some() + || unroutable_service_frame + { Some(InternalMessageResult::Consumed) } else if let Some(body) = text.strip_prefix(internal_prefixes::DATA_V1) { // A document sync frame. Consumed either way, and diff --git a/crates/offline-protocol/src/protocol/tests/service_seal.rs b/crates/offline-protocol/src/protocol/tests/service_seal.rs index f78f7c697..f4bffa8de 100644 --- a/crates/offline-protocol/src/protocol/tests/service_seal.rs +++ b/crates/offline-protocol/src/protocol/tests/service_seal.rs @@ -17,7 +17,7 @@ use std::collections::HashMap; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; -use offline_protocol_core::{Message, ServiceDescriptor, ServiceId}; +use offline_protocol_core::{Message, MessagePriority, ServiceDescriptor, ServiceId}; use offline_protocol_services::{ SVC_DISCOVER_QUERY, SVC_MESSAGE_PREFIX, SVC_REQUEST, SVC_RESPONSE, }; @@ -706,6 +706,50 @@ fn the_automatic_reply_to_the_first_sealed_request_is_sealed() { assert_eq!(statuses, vec!["not_found".to_string()]); } +/// Discovery is never sealed. A sealed discovery query that arrives anyway is +/// dropped: routing it would have the receiver forward it onward as plaintext +/// gossip, and it must never surface as a message either. +#[test] +fn a_sealed_discovery_query_is_dropped_not_forwarded_or_surfaced() { + let (mut alice, alice_h, _) = encrypted("alice", |_| {}); + let (mut bob, bob_h, bob_events) = encrypted("bob", |_| {}); + establish_confirmed_session(&mut alice, &id("alice"), &mut bob, &id("bob")); + register(&mut bob, "echo"); + // A third peer bob would forward a routed query to. + bob.on_neighbor_discovered(&id("carol")); + alice.on_neighbor_discovered(&id("bob")); + + alice_h.clear_sent_messages(); + alice.discover_services(Some("echo")).unwrap(); + let query = alice_h + .sent_messages() + .into_iter() + .find(|frame| frame.content.starts_with(SVC_DISCOVER_QUERY)) + .expect("discovery sends a query") + .content; + alice_h.clear_sent_messages(); + let sealed = alice + .encrypt_content_for_recipient(&id("bob"), &query, MessagePriority::Medium) + .unwrap(); + alice + .send_sealed_internal_message(&id("bob"), sealed, MessagePriority::Medium, None) + .unwrap(); + + bob_h.clear_sent_messages(); + deliver(&alice_h, &id("alice"), &mut bob, &bob_h); + assert!( + !bob_h + .sent_messages() + .iter() + .any(|frame| frame.content.starts_with(SVC_MESSAGE_PREFIX)), + "a sealed discovery query was routed and forwarded in plaintext" + ); + assert!(!bob_events.lock().unwrap().iter().any(|event| matches!( + event, + Event::MessageReceived { .. } | Event::ServiceDiscovered { .. } + ))); +} + /// A sealed service frame proves its sender routes them, which a replayed key /// package cannot, so bob records alice in the proved ratchet and answers /// sealed even though he never received a key package from her that diff --git a/docs/spec/control-messages.md b/docs/spec/control-messages.md index 69de69851..97384a993 100644 --- a/docs/spec/control-messages.md +++ b/docs/spec/control-messages.md @@ -157,8 +157,11 @@ toward a peer that advertised `svc_versions` entry 1 ([capability negotiation](capability-negotiation.md)), once the 1:1 session with that peer is confirmed. The sealed form is unsigned, as every 1:1 envelope is: MLS authenticates its sender. A receiver that decrypts a plaintext -beginning `__SVC_` from a 1:1 session slot MUST hand it to its service layer -with the MLS-authenticated sender, and MUST NOT surface it as a message. It +beginning `__SVC_REQ__` or `__SVC_RESP__` from a 1:1 session slot MUST hand +it to its service layer with the MLS-authenticated sender. It MUST NOT surface +any decrypted `__SVC_` plaintext as a message, and it drops any other service +frame found there: discovery is never sealed, and a receiver that routed a +sealed query would forward it onward as plaintext gossip. It routes the frame whatever its own configuration says, because parsing is never gated. The signed plaintext form remains the floor. Neither form changes the exemption classes in this chapter, since the sealed form is `__MLS_ENC__`. See