Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions bindings/python/offline_protocol_sdk/http_front/envelope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions bindings/python/offline_protocol_sdk/http_front/front.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions bindings/python/offline_protocol_sdk/local_api/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from __future__ import annotations

UDL_SHA256 = "db1022f43254c9843ac4164e4993169e1d4886901be23f1340d6aed5652e43bc"
UDL_SHA256 = "fde449d34b2410e5d6ab608bd964ce0c22691a66689dff84cc9433a012015e05"

TABLE = {'callbacks': ('MlsStorageProvider',
'ProtocolStateStorageProvider',
Expand Down Expand Up @@ -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),
Expand Down
10 changes: 8 additions & 2 deletions bindings/python/offline_protocol_sdk/offline_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion bindings/react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -277,6 +283,7 @@ internal object ProtocolConfigParser {
compactEnvelopeEnabled = compactEnvelopeEnabled,
richPayloadEnabled = richPayloadEnabled,
cryptoRecoveryEnabled = cryptoRecoveryEnabled,
encryptServiceMessages = encryptServiceMessages,
meshRelay = meshRelay,
custody = custody
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8978,6 +8978,8 @@ data class ProtocolConfig (
var `dataEnabled`: kotlin.Boolean = true
,
var `controlFreshnessEnforced`: kotlin.Boolean = true
,
var `encryptServiceMessages`: kotlin.Boolean = true

){

Expand Down Expand Up @@ -9026,6 +9028,7 @@ public object FfiConverterTypeProtocolConfig: FfiConverterRustBuffer<ProtocolCon
FfiConverterOptionalTypeCustodyConfig.read(buf),
FfiConverterBoolean.read(buf),
FfiConverterBoolean.read(buf),
FfiConverterBoolean.read(buf),
)
}

Expand Down Expand Up @@ -9063,7 +9066,8 @@ public object FfiConverterTypeProtocolConfig: FfiConverterRustBuffer<ProtocolCon
FfiConverterOptionalTypeMeshRelayConfig.allocationSize(value.`meshRelay`) +
FfiConverterOptionalTypeCustodyConfig.allocationSize(value.`custody`) +
FfiConverterBoolean.allocationSize(value.`dataEnabled`) +
FfiConverterBoolean.allocationSize(value.`controlFreshnessEnforced`)
FfiConverterBoolean.allocationSize(value.`controlFreshnessEnforced`) +
FfiConverterBoolean.allocationSize(value.`encryptServiceMessages`)
)

override fun write(value: ProtocolConfig, buf: ByteBuffer) {
Expand Down Expand Up @@ -9101,6 +9105,7 @@ public object FfiConverterTypeProtocolConfig: FfiConverterRustBuffer<ProtocolCon
FfiConverterOptionalTypeCustodyConfig.write(value.`custody`, buf)
FfiConverterBoolean.write(value.`dataEnabled`, buf)
FfiConverterBoolean.write(value.`controlFreshnessEnforced`, buf)
FfiConverterBoolean.write(value.`encryptServiceMessages`, buf)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class ProtocolConfigParserTest {
assertTrue(config.compactEnvelopeEnabled)
assertTrue(config.richPayloadEnabled)
assertTrue(config.cryptoRecoveryEnabled)
assertTrue(config.encryptServiceMessages)
}

@Test
Expand Down Expand Up @@ -238,6 +239,29 @@ class ProtocolConfigParserTest {
assertFalse(snake.cryptoRecoveryEnabled)
}

@Test
fun encryptServiceMessagesReadsItsNestedEncryptionHomeThenTopLevel() {
val nested = parse(
"""{"appId":"app","userId":"alice","encryptServiceMessages":true,"encryption":{"encryptServiceMessages":false}}"""
)
assertFalse(nested.encryptServiceMessages)

val flat = parse(
"""{"appId":"app","userId":"alice","encryptServiceMessages":false,"encryption":{"enabled":true}}"""
)
assertFalse(flat.encryptServiceMessages)

val snake = parse(
"""{"appId":"app","userId":"alice","encryption":{"encrypt_service_messages":false}}"""
)
assertFalse(snake.encryptServiceMessages)

val flatSnake = parse(
"""{"appId":"app","userId":"alice","encrypt_service_messages":false}"""
)
assertFalse(flatSnake.encryptServiceMessages)
}

@Test
fun encryptionFlagsDefaultOnWhenOmitted() {
val config = parse("""{"appId":"app","userId":"alice"}""")
Expand Down
5 changes: 5 additions & 0 deletions bindings/react-native/ios/EncryptionConfigReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ struct EncryptionConfigValues: Equatable {
var compactEnvelopeEnabled: Bool
var richPayloadEnabled: Bool
var cryptoRecoveryEnabled: Bool
var encryptServiceMessages: Bool
var maxPendingPerPeer: UInt64
var maxPendingGlobal: UInt64
var pendingTtlMs: UInt64
Expand Down Expand Up @@ -58,6 +59,9 @@ enum EncryptionConfigReader {
let cryptoRecoveryEnabled = bool(nested, "cryptoRecoveryEnabled", "crypto_recovery_enabled")
?? bool(raw, "cryptoRecoveryEnabled", "crypto_recovery_enabled")
?? true
let encryptServiceMessages = bool(nested, "encryptServiceMessages", "encrypt_service_messages")
?? bool(raw, "encryptServiceMessages", "encrypt_service_messages")
?? true

let pendingQueue = (nested["pendingQueue"] as? [String: Any])
?? (nested["pending_queue"] as? [String: Any])
Expand All @@ -83,6 +87,7 @@ enum EncryptionConfigReader {
compactEnvelopeEnabled: compactEnvelopeEnabled,
richPayloadEnabled: richPayloadEnabled,
cryptoRecoveryEnabled: cryptoRecoveryEnabled,
encryptServiceMessages: encryptServiceMessages,
maxPendingPerPeer: maxPendingPerPeer,
maxPendingGlobal: maxPendingGlobal,
pendingTtlMs: pendingTtlMs,
Expand Down
Loading
Loading