diff --git a/CHANGELOG.md b/CHANGELOG.md index 89fb646ed..bddc70758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,33 @@ 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 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 `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/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/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/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, 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..7bf2f210d 100644 --- a/crates/offline-protocol-sealed/src/keypackage.rs +++ b/crates/offline-protocol-sealed/src/keypackage.rs @@ -160,6 +160,30 @@ 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 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)] + 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 +242,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 +329,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 +367,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 +403,7 @@ mod spec_vectors { "rich_versions", "data_versions", "ctrl_versions", + "svc_versions", "nostr_pubkey", "session_reset", ] { @@ -384,5 +417,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-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index 4038e6a7a..c34504046 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -2017,6 +2017,10 @@ pub struct ProtocolConfig { /// Kill switch for 1:1 MLS crypto-failure recovery (default on). See the UDL /// dictionary and `EncryptionConfig::crypto_recovery_enabled` for semantics. pub crypto_recovery_enabled: bool, + /// Whether service request and response bodies are sealed toward peers + /// that route the sealed form (default on). See the UDL dictionary and + /// `EncryptionConfig::encrypt_service_messages` for semantics. + pub encrypt_service_messages: bool, /// Mesh forwarding tunables. `None` (and any `None` field inside it) /// leaves the core default alone — see [`MeshRelayConfig`]. pub mesh_relay: Option, @@ -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; }; // ======================================================================== 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/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 051b3ec9c..03f718078 100644 --- a/crates/offline-protocol/src/protocol/blocking.rs +++ b/crates/offline-protocol/src/protocol/blocking.rs @@ -146,6 +146,9 @@ 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 // above, so it needs its own clear — the durable record is deleted by @@ -1441,6 +1444,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/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..99c63bf31 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, SVC_REQUEST, SVC_RESPONSE}; 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,27 @@ 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. + // + // 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); + } 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 +368,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 +1362,36 @@ 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; + // + // 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 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 @@ -1365,6 +1418,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 => { @@ -2195,12 +2251,53 @@ impl OfflineProtocol { } } + /// 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 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. + /// + /// 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); + 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 @@ -2222,12 +2319,21 @@ 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 { - 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 50ee18c43..6d9c002b5 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,39 @@ 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. + /// + /// 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. 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 + 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 @@ -1162,6 +1196,8 @@ 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(), + 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(), @@ -1372,6 +1408,8 @@ 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_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(); @@ -1537,6 +1575,8 @@ 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.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; @@ -3520,7 +3560,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/send.rs b/crates/offline-protocol/src/protocol/send.rs index 7c34678f1..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,6 +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], + // 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..8b20ea899 --- /dev/null +++ b/crates/offline-protocol/src/protocol/service_seal.rs @@ -0,0 +1,128 @@ +//! 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. +//! +//! 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. + +use super::{OfflineProtocol, OutboxReseal}; +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. + /// + /// 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, + 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) if self.confirmed_sessions.contains(recipient) => { + warn!( + recipient = %recipient, + error = %err, + "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) + } + } + } + + /// 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, 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.svc_sealed_proved_peers.contains(recipient)) + } + + /// 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.is_encryption_capable(peer) { + return; + } + 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 328a131df..5949cda3c 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}; @@ -1876,6 +1877,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 +1958,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 +2155,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 +2275,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 +2418,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 +2754,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!( @@ -3128,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(); @@ -3269,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(); @@ -3876,6 +3885,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 +15277,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 +20309,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 +20521,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 +20607,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 +20696,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 +20748,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 +22299,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!( @@ -22541,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"); @@ -22588,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"); @@ -22630,37 +22657,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(); @@ -37745,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 @@ -39874,6 +39871,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 +41630,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/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..f4bffa8de --- /dev/null +++ b/crates/offline-protocol/src/protocol/tests/service_seal.rs @@ -0,0 +1,1219 @@ +//! 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; 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, MessagePriority, 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, MlsStorage}; +use crate::protocol::prefixes::internal_prefixes; +use crate::protocol::types::KeyPackagePayload; +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; + +// --------------------------------------------------------------------------- +// 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() + } +} + +/// 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() + .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>>) { + 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(storage).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); +} + +/// 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; + 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" + ); + 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, +/// 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); + 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 +/// 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")); + advertise(&mut alice, &id("bob")); + advertise(&mut bob, &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()]); +} + +/// 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 +/// 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")); + advertise(&mut alice, &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.svc_sealed_proved_peers.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). 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", |_| {}); + 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")); + advertise(&mut alice, &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"))); + assert!(bob.svc_sealed_proved_peers.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); + advertise(&mut alice, &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")); + advertise(&mut alice, &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))); +} + +// --------------------------------------------------------------------------- +// 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")); + advertise(&mut alice, &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 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 a_flood_of_identities_cannot_unseal_an_established_peer() { + let mut protocol = negotiating_protocol(|_| {}); + advertise(&mut protocol, &id("established")); + for i in 0..MAX_ENCRYPTION_CAPABLE_PEERS + 10 { + advertise(&mut protocol, &format!("flood-{i}")); + } + 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"))); +} + +// --------------------------------------------------------------------------- +// 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")); + advertise(&mut alice, &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")); + advertise(&mut alice, &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 bf515e6c8..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 @@ -1478,6 +1493,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 +1545,7 @@ impl PeerCapabilities { rich_versions: &[u8], data_versions: &[u8], ctrl_versions: &[u8], + svc_versions: &[u8], nostr_pubkey: Option<&str>, ) -> Self { Self { @@ -1545,6 +1569,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 +1587,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() 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 28b843fff..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 @@ -2534,6 +2537,52 @@ 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. + +**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/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..2cab6c796 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -419,24 +419,73 @@ 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, +- 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 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 +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 +([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. 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 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 8fa173537..2e55a45b9 100644 --- a/docs/service-discovery.md +++ b/docs/service-discovery.md @@ -87,9 +87,17 @@ 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. +- 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. ## Rust API @@ -201,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 { @@ -209,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 } ``` @@ -224,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 { @@ -232,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 } ``` @@ -369,7 +381,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/capability-negotiation.md b/docs/spec/capability-negotiation.md index a53a1291b..1cf20d7c4 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,37 @@ 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. 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. + +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 The relay advertises its own capability set, which is separate from peer @@ -247,6 +288,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/docs/spec/control-messages.md b/docs/spec/control-messages.md index 12dc2ab71..97384a993 100644 --- a/docs/spec/control-messages.md +++ b/docs/spec/control-messages.md @@ -149,10 +149,24 @@ 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_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 +[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..fd121d964 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 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 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, }, }