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
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,59 @@ archived by series under [docs/changelog/](docs/changelog/); see the

### Fixed

- **React Native holds an interest declared before `start()` and applies it
before the engine starts.** (#472) The engine's start-up exchange offers
every held space with the interest in force at that moment, and a narrowing
never deletes what a wider exchange pulled in, so interest has to be
declared after storage opens and before the engine starts. React Native's
`start()` does both steps, and the JSDoc said to call `setInterest` before
it, where both native modules rejected it with `DataStore not initialized`.
An app that moved the call after `start()` had its first exchange ask for
everything. `DataStore.setInterest` now holds a call made before `start()`
and resolves it, and `start()` applies what is held after MLS
initialization and before the native start. A pattern refused then rejects
`start()` with an error naming the space and carrying the native code, and
the engine is not started, because starting would replicate the space
whole; when the data layer is off the held call is dropped with a warning
and `start()` proceeds. `destroy()` discards what is held. The behaviour
change for a deployed app: an invalid pattern declared before `start()`
used to reject that call and now rejects `start()`; a call after `start()`
is unchanged. Both native `destroy()` paths now also release the data
store, which holds a strong reference to the engine: left set, every data
call after `destroy()` reached the stopped engine instead of rejecting, and
on Android the engine stayed alive until the collector reached it. Python's
`ProtocolManager` initializes MLS inside its own `start()` too; its first
exchange still runs under the default interest, now stated in
`docs/bridges/python.md`.

- **`DiagnosticEvent.level` declares `debug`, which both native platforms
emit, and Android no longer emits `warn`.** (#471) The iOS and Android
managers emit diagnostics at `debug` at about seventy sites, while the
TypeScript union said `'info' | 'warning' | 'error'`. Android's GATT server
also emitted four diagnostics as `warn`, a spelling nothing declared; they
are `warning` now. The union is `'debug' | 'info' | 'warning' | 'error'`, and
`react_native_diagnostic_levels_match_every_bridge` reads every level
literal the bridges hand to a diagnostic sink and fails when a level is
emitted but not declared, or declared but never emitted. This widens a
public type to match what already arrives at runtime. It is not
automatically source-compatible: a consumer with an exhaustive `switch` or
a `Record` keyed by the level needs a `debug` entry to typecheck, and a
filter that compared against `'warn'` for the four GATT server diagnostics
should compare against `'warning'`.

- **Python holds a pinned copy of the relay-answer prefixes, and decides
attribution in one place.** (#368) The relay's group answers reach the core
only when they carry no transport peer identity, so an answer injected with
a `sender_id` is refused as unsigned while the inject reports success. The
Swift and Kotlin bridges each hold the list and force those answers
unattributed centrally; Python held no copy, and every call site had to get
it right on its own. 0.27.0 already corrected the prefix names and the
attribution at each site (#453). Now `relay_answer_prefixes.py` holds the
list, `_inject_group_frame` drops the actor for any answer whatever the
caller passed, and `test_relay_answer_prefixes.py` pins the six literals,
so the next registry change fails the Python suite rather than a relay
feature in the field. No behaviour change for a correct caller.

- **A key package the application marks synced keeps its record, so its
private key is still destroyed when it expires.**
`mls_mark_key_package_synced` deleted the record and left the init key in
Expand Down
9 changes: 7 additions & 2 deletions bindings/python/offline_protocol_sdk/internet_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from websockets.asyncio.client import ClientConnection

from . import address_declaration
from .relay_answer_prefixes import attributable_actor
from .transport_manager import TransportError, TransportManager, TransportState

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -1098,7 +1099,8 @@ def _handle_group_message(self, msg_type: str, msg: dict[str, Any]) -> None:
is data plane (MLS authenticates it afterwards) and the attribution is
the reachability signal for the relayed sender.

The others are relay answers (the core's ``RELAY_ANSWER_PREFIXES``) and
The others are relay answers (``relay_answer_prefixes``, mirroring the
core's ``RELAY_ANSWER_PREFIXES``) and
must reach the core unattributed: no peer sent them, so nothing can
sign them, and the core's exemption from the control-frame signature
gate only recognizes a frame with no transport peer identity. An
Expand Down Expand Up @@ -1157,8 +1159,11 @@ def _inject_group_frame(

``actor`` is both the FFI ``sender_id`` and the frame's ``sender``;
``None`` selects unattributed ingest, with the "relay" placeholder as
the frame sender because the core rejects an empty one.
the frame sender because the core rejects an empty one. A relay
answer is forced unattributed here, whatever the caller passed: see
``relay_answer_prefixes``, which holds the pinned list.
"""
actor = attributable_actor(prefix, actor)
content = prefix + json.dumps(payload)
data_bytes = json.dumps(
self._build_internal_message(actor or "relay", content)
Expand Down
61 changes: 61 additions & 0 deletions bindings/python/offline_protocol_sdk/relay_answer_prefixes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Which synthesized relay frames must reach the core unattributed.

A port of ``RelayAnswerPrefixes.swift`` and ``RelayAnswerPrefixes.kt``.

These are the prefixes the **relay server** originates. The bridge
synthesizes a frame from a WebSocket answer rather than receiving one from a
peer, so no key exists to sign it. The core exempts exactly these from its
control-frame signature gate (``RELAY_ANSWER_PREFIXES`` in
``crates/offline-protocol/src/protocol/prefixes.rs``), and the four copies
must agree: the core, the Swift bridge, the Kotlin bridge and this module.

Why attribution breaks them: the core's exemption is narrower than the
prefix. It also requires the frame to carry **no transport peer identity**,
which is what a locally synthesized answer looks like. Passing a non-empty
``sender_id`` to ``internet_message_received`` sets that identity, so the
frame stops looking synthesized and is dropped as unsigned. The caller sees a
successful inject and the answer never takes effect. That narrowness is doing
real work and must not be widened to close this: without it, any peer able to
address us through the relay could inject unsigned group state.

``__GROUP_MSG__`` is **not** here. It is a data-plane prefix, never
signature-gated (MLS authenticates it afterwards), so it keeps its
attribution and remains the reachability signal for a relayed sender.

``test_relay_answer_prefixes.py`` pins the set as literals (contract C5 in
``docs/bridges/README.md``). A test that recomputed it from this constant
would agree with any edit, which is the failure it exists to catch.
"""

from __future__ import annotations

RELAY_ANSWER_PREFIXES: frozenset[str] = frozenset(
{
"__GROUP_CREATED__",
"__GROUP_MEMBER_ADDED__",
"__GROUP_MEMBER_REMOVED__",
"__GROUP_INFO__",
"__USER_GROUPS__",
"__GROUP_ERROR__",
}
)


def is_relay_answer(prefix: str) -> bool:
"""Whether ``prefix`` names a relay answer that must be injected unattributed.

Matched whole, not as a prefix of a prefix: whether a content string that
starts with an exempt prefix is admitted is decided in the core, against
the frame's transport and attribution.
"""
return prefix in RELAY_ANSWER_PREFIXES


def attributable_actor(prefix: str, actor: str | None) -> str | None:
"""The actor a synthesized frame may carry: ``None`` for a relay answer.

Enforced here rather than trusted to each call site. The rule comes from
a constant in the Rust core, and a new answer injected with an actor is
dropped as unsigned with no error anywhere.
"""
return None if is_relay_answer(prefix) else actor
16 changes: 16 additions & 0 deletions bindings/python/tests/test_internet_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,22 @@ def test_relay_answers_are_injected_unattributed(
assert frame["recipient"] == self.ADDRESS
assert frame["content"] == prefix + json.dumps(payload)

def test_a_relay_answer_is_unattributed_whatever_the_caller_passes(
self, mock_protocol: MagicMock
) -> None:
# The rule lives in one place, not at each call site: a future arm that
# hands an actor to a relay answer must still reach the core with no
# transport identity, or the gate drops it as unsigned.
mgr = self._manager(mock_protocol)
mgr._inject_group_frame("off1someone", "__USER_GROUPS__", {"groups": []})
sender_id, frame = self._injected(mock_protocol)
assert sender_id == ""
assert frame["sender"] == "relay"

mgr._inject_group_frame("off1someone", "__GROUP_MSG__", {"group_id": "g"})
sender_id, frame = self._injected(mock_protocol)
assert sender_id == "off1someone"

def test_group_delivery_report_stays_off_the_message_plane(
self, mock_protocol: MagicMock
) -> None:
Expand Down
53 changes: 53 additions & 0 deletions bindings/python/tests/test_relay_answer_prefixes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Pins the "a relay answer reaches the core unattributed" rule.

Mirrors ``RelayAnswerPrefixesTests.swift`` and ``RelayAnswerPrefixesTest.kt``
against the core's ``RELAY_ANSWER_PREFIXES``. Python used to hold no copy at
all, and the injection used prefixes the registry did not contain (#368): a
frame under an unregistered prefix is refused as unsigned, invisibly.
"""

from __future__ import annotations

from offline_protocol_sdk.relay_answer_prefixes import (
RELAY_ANSWER_PREFIXES,
attributable_actor,
is_relay_answer,
)


def test_the_set_matches_the_core_constant() -> None:
# Written out, not derived: a prefix here the core does not exempt is
# dropped as unsigned, and one the core exempts that is missing here is
# attributed and dropped the same way.
assert RELAY_ANSWER_PREFIXES == {
"__GROUP_CREATED__",
"__GROUP_MEMBER_ADDED__",
"__GROUP_MEMBER_REMOVED__",
"__GROUP_INFO__",
"__USER_GROUPS__",
"__GROUP_ERROR__",
}


def test_membership_answers_are_never_attributed() -> None:
for prefix in ("__GROUP_MEMBER_ADDED__", "__GROUP_MEMBER_REMOVED__"):
assert attributable_actor(prefix, "alice") is None


def test_every_relay_answer_drops_its_actor() -> None:
for prefix in RELAY_ANSWER_PREFIXES:
assert attributable_actor(prefix, "alice") is None
assert attributable_actor(prefix, None) is None


def test_data_plane_and_peer_prefixes_keep_their_actor() -> None:
# Scoped, not blanket: `__GROUP_MSG__` is data plane and its attribution
# is the reachability signal for a relayed sender.
for prefix in ("__GROUP_MSG__", "__CONN_REQ__", "__MLS_ENC__"):
assert attributable_actor(prefix, "alice") == "alice"


def test_is_relay_answer_discriminates() -> None:
assert is_relay_answer("__GROUP_CREATED__")
assert not is_relay_answer("__GROUP_MSG__")
assert not is_relay_answer("__GROUP_CREATED__extra")
2 changes: 1 addition & 1 deletion bindings/react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1085,7 +1085,7 @@ interface FileReceivedEvent {
```typescript
interface DiagnosticEvent {
type: 'diagnostic';
level: 'info' | 'warning' | 'error';
level: 'debug' | 'info' | 'warning' | 'error';
message: string;
context?: Record<string, unknown>;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2368,6 +2368,18 @@ class OfflineProtocolModule(reactContext: ReactApplicationContext) :
// destroyed handle throws; publishing the null first means the
// widest that race can be is one already-in-flight call, which
// [notifyAppStateQuietly] absorbs.
// The data store holds a strong reference to the engine, so it is
// released first and explicitly: left set, every data call after
// destroy() reached the stopped engine instead of rejecting with
// "DataStore not initialized", and its reference kept the engine
// alive until the collector got to it.
val store = dataStore
dataStore = null
try {
store?.destroy()
} catch (e: Exception) {
android.util.Log.w(NAME, "Releasing the data store handle failed", e)
}
val handle = protocol
protocol = null
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ package com.offlineprotocol
*
* Mirrors `RELAY_ANSWER_PREFIXES` in
* `crates/offline-protocol/src/protocol/prefixes.rs`, and iOS's
* RelayAnswerPrefixes.swift — keep all three in sync. The lists must agree: the
* RelayAnswerPrefixes.swift and Python's relay_answer_prefixes.py. Keep all
* four in sync. The lists must agree: the
* core exempts exactly these from its unconditional control-frame signature
* gate, because no peer sent them so no key exists to sign them.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ class PeripheralGattServer(
if (!isReady) {
Log.w(TAG, "GATT service ready timeout (attempt=$setupAttempts)")
diagnosticEmitter(
"warn",
"warning",
"gatt_service_ready_timeout",
mapOf("attempt" to setupAttempts),
)
Expand Down Expand Up @@ -700,13 +700,13 @@ class PeripheralGattServer(
if (cached != null) {
identityReadSnapshots.remove(address)
diagnosticEmitter(
"warn",
"warning",
"identity_long_read_snapshot_stale",
mapOf("address" to address, "offset" to offset),
)
} else {
diagnosticEmitter(
"warn",
"warning",
"identity_long_read_missing_snapshot",
mapOf("address" to address, "offset" to offset),
)
Expand Down Expand Up @@ -744,7 +744,7 @@ class PeripheralGattServer(
// the payload to the upstream fragment assembler.
if (value.size > MAX_INBOUND_WRITE_BYTES) {
diagnosticEmitter(
"warn",
"warning",
"gatt_inbound_write_oversize",
mapOf(
"address" to device.address,
Expand Down
4 changes: 4 additions & 0 deletions bindings/react-native/ios/OfflineProtocolModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2648,6 +2648,10 @@ class OfflineProtocolModule: RCTEventEmitter {
}
protocolInstance = nil
meshServicesInstance = nil
// The store holds a strong reference to the engine. Left set, every
// data call after destroy() reached the stopped engine instead of
// rejecting with "DataStore not initialized", and kept it alive.
dataStoreInstance = nil
currentConfig = nil
// The app tore the SDK down itself; a message held for this account
// must not reach whatever it constructs next.
Expand Down
3 changes: 2 additions & 1 deletion bindings/react-native/ios/RelayAnswerPrefixes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
// RelayAnswerPrefixes.swift
//
// Which synthesized relay frames must reach the core unattributed.
// Mirrors android's RelayAnswerPrefixes.kt — keep in sync.
// Mirrors android's RelayAnswerPrefixes.kt and Python's
// relay_answer_prefixes.py. Keep all four copies in sync.
//

import Foundation
Expand Down
1 change: 1 addition & 0 deletions bindings/react-native/js-ci-harness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,4 @@ Two traps, both of which produce a test that passes while proving nothing:
| `rich-send-app-id.test.js` | The per-send `appId` on `sendMessage` and `sendMedia` (`src/index.ts`): an appId-only call must take the rich native method, because only it carries `app_id`, and the plain path would send under the configured id without a word; a rich call without one sends `null`. |
| `relay-config.test.js` | The relay and DORS config payloads this layer hands to native: the whole `relay` section crossing at create time (not just `relayPriority`), the legacy `low`/`medium`/`high` spelling mapping to the engine vocabulary, and a runtime update naming only the fields it was given — which is what makes the native-side merge a partial update rather than a full overwrite. Its other half is the Rust guard `react_native_bridges_merge_dors_updates_from_the_live_config`. |
| `key-package-mapping.test.js` | The JS shape of a key package record (`src/index.ts`, `toMlsKeyPackage`): `createdAt`, `expiresAt` and `isSynced` read from the `createdAtMs`, `expiresAtMs` and `synced` both native bridges send. The wrappers read other names, so every caller got `undefined` for both, and neither the typecheck nor a text guard can see a key that is sent but never read. |
| `data-interest-order.test.js` | When an interest declared with `DataStore.setInterest` reaches native (`src/index.ts`, `pendingInterest`): held before `start()`, applied between MLS initialization and the native start so the engine's start-up exchange carries it (#472), straight through afterwards, a refusal rejecting `start()` without starting the engine, a layer that is off dropping it with a warning, and `destroy()` discarding it. Its other half is the Rust guard `react_native_start_applies_held_interest_before_the_engine_starts`. |
Loading
Loading