October 9, 2026
2 pull requests merged across 1 repo
Offline-Protocol/offline-protocol-sdk
What changes
Service request and response bodies (__SVC_REQ__, __SVC_RESP__) used to cross every hop as signed plaintext. They are now sealed inside the recipient's 1:1 MLS session when all of these hold:
- our
encrypt_service_messagesswitch is on (new, default on), - the recipient advertised
svc_versionsentry 1 in a signed key package, or has already sent us a sealed service frame, - the session with the recipient is confirmed.
The provider's service_request_received.sender is then the MLS-authenticated requester.
Otherwise the body leaves as today's signed plaintext. That covers an older peer, either side's switch off, and any request before the session confirms, including the first request to a never-met provider. The encrypt attempt on that first request starts the session the way a direct message does, so the next request seals. Discovery (__SVC_DISC_Q__ / __SVC_DISC_R__) is unchanged and stays plaintext.
How
- Capability.
KeyPackagePayload::svc_versionsandSVC_SEALED_V1 = 1inoffline-protocol-sealed. It is honoured only from a signed key package, likenostr_pubkey, because it sits beside a confidentiality decision. It is persisted inPeerCapabilitiesand restored oninitialize_mls. The leaf advertises it empty. - Send.
send_service_frame(protocol/service_seal.rs) is the single choke point for both public entry points and for the replies the service layer builds itself, such as the automaticnot_found. Sealed frames go through a newsend_sealed_internal_message, unsigned like every 1:1 envelope, and stageOutboxReseal. A resend after a desync re-key is therefore sealed again rather than replayed as dead ciphertext.send_internal_message's tail is now a shareddispatch_internal_message. - Receive. A decrypted
__SVC_plaintext from asession:slot is routed tohandle_service_messagewith the MLS-verified sender. It is never re-fed through the control gate, which would refuse it unsigned. The session is confirmed before the frame is handled: otherwise the automaticnot_foundsent from inside the handler would find the session unconfirmed on the owner's first decrypt and leave in plaintext. Routing is unconditional (parsing is never gated). With the switch on, a sealed inbound frame also marks its sender capable, in memory. A replayed older key package cannot fake that, and it closes the provider's plaintext-reply window before the requester's fresh key package lands. - Bindings.
encrypt_service_messagesis appended last to the flat UDLProtocolConfig. Swift, Kotlin and Python are regenerated together, along with the Python local API table. The iOS and Android readers take it nested-then-flat in both spellings, and the TypeScript wrapper carries it in both shapes.every_bridge_reads_the_encryption_config_sectionis the first Rust guard over the encryption section across bridges.
Wire and compatibility
- One additive optional field on
__MLS_KEY_PKG__. The vectors are regenerated throughtools/spec-vectors/generate.py. - No new prefix and no registry change. The sealed form is
__MLS_ENC__, already data plane. - An older peer never advertises, so it never receives a sealed service frame. A full revert is safe: a persisted
svc_versionsis ignored by older code via serde default. EncryptionConfiggets a named serde default, so a config serialized before the field existed still parses, with the switch on.
Measured cost (not estimated): a sealed frame is about 1.34x the plaintext one with the compact envelope, so a 64 KiB body becomes a frame of about 86 KiB. Bodies above about 52 KB stop crossing Bluetooth LE at the 185-byte MTU floor, and bodies above about 47 KiB stop fitting a Nostr relay. docs/UPGRADING.md section 27 says so.
Docs
The threat model R9 is retitled and rewritten, and both links to its anchor are updated. Also changed: the capability-negotiation chapter (the stated exception to rule 3), the control-messages and encryption-envelopes specs, HTTP front invariant 1, service-discovery.md, every config reference, the CHANGELOG and UPGRADING.
Verification
| Check | Result |
|---|---|
cargo test --workspace (incl. doctests) | 3294 passed, 0 failed |
New engine tests (protocol/tests/service_seal.rs) | 16, including a real two-device handshake |
| Mutations of the guarded behaviour | 10 of 10 turned a test red |
fmt, both CI clippy steps, cargo doc -D warnings | clean |
| Bare metal (4 targets) and WASI for sealed and leaf | clean |
| Python bindings | 1085 passed, 1 skipped |
| iOS SwiftPM | 462 passed; React-header typecheck of OfflineProtocolModule.swift passes, negative control fails as expected |
Android ProtocolConfigParserTest | 46 passed |
React Native tsc and test:js | pass |
| Regenerating bindings from a clean tree | no drift; 54 generator guards pass |
The new bridge guard was negative-controlled against each bridge site it reads.
Not covered
- No test pins that a
__SVC_plaintext inside a group ciphertext stays unrouted. Thesession:check does it, but it is not pinned. - The native package jobs and the full Android suite run only in CI.
Review fixes (ab014c9d)
- Proof ratchet. A peer that sent us a sealed service frame is recorded durably beside the control-freshness ratchet, where no key package reaches. A replayed key package, which is admitted at any age when signed under
offline-ctrl-v1, can no longer send that peer's bodies back to plaintext. - No plaintext on a fault. A seal failure on a confirmed session returns the error instead of sending signed plaintext.
encryptedon the service events.service_request_receivedandservice_response_receivedsay which form arrived. Rust code naming every field of either variant needs it or.., hence the!.- Docs. The freshness-window claim in R9, the capability chapter and the field doc is corrected.
- Six new tests. Reverting each fix turns at least one test red.
cargo test --workspacegives 3300 passed, 0 failed.
Follow-ups (not in this PR)
- A strict mode under
require_encryptionthat refuses the plaintext fallback toward a peer that advertised or proved the sealed form. - A test pinning that a
__SVC_plaintext inside a group ciphertext stays unrouted.
Why
An Android phone and a Mac running the Python service found and verified each other over Bluetooth LE and never formed a session, so with encryption required no message ever crossed. Found on a lab of two Macs and a Galaxy M36 (Android 16). The chain, each step in the logs:
1. macOS rotates its Bluetooth address. The Android central dialled both addresses, both verified as the same peer, and it kept both links.
2. The Mac's peripheral then saw two centrals. bless drops the CBCentral behind a CoreBluetooth write, so the peripheral attributed every write to the placeholder ble-peer, and it served no Hello characteristic for the phone to announce itself.
3. The core requires a hop-0 control frame's sender to match the link's identity, so it refused the phone's key package (TRANSPORT_IDENTITY_MISMATCH). No session, no message.
4. Two links per Mac filled the phone's four connection slots, so the cap also refused the Macs' own centrals, closing the other route.
Two more faults turned up in the same lab:
- A Mac that may not use Bluetooth wedged the whole service. bless's CoreBluetooth constructor waits, with no deadline, for a power-on that never comes when the process is not authorised (a service started over ssh). It blocked the event loop, so no API socket opened and the 30 s transport start deadline never fired.
- A dead link stayed "connected". CoreBluetooth kept a link to a Mac whose service had restarted for over five minutes, and every frame written into it vanished.
What
Android (CentralGattClient, MeshConnectionRegistry, BleTransportFacade)
- A client link that verifies as a peer already held at another address is closed unannounced, and that address is left undialed for a minute. A server link to the same peer is not a duplicate.
Python peripheral (ble_peripheral.py)
- Builds bless's server with a subclass of its delegate that passes each write's central, offset and value through, and answers with the ATT code the peripheral returns. BlueZ already names the writer through the bus hook.
- Serves Hello and follows the chapter: refuse an offset write (
0x07) or a value outside 96 to 512 bytes (0x0D) before verifying, verify with the core's verifier, bind the central to the derived address, never rebind, announce only when no other link has. That central's fragments enter the core under the proved address. - On macOS, notifies a bound peer's central alone, and retries a notification CoreBluetooth refuses because its queue is full. bless ignored that refusal and dropped fragments of every burst.
- Builds the server off the loop on macOS with a 20 s deadline, and refuses first when macOS reports Bluetooth denied or restricted. BlueZ still builds on the loop thread, where its setup task must be scheduled.
Python central (ble_manager.py)
- Writes its hello to a peer that serves one.
- Closes a second client link to a peer it already holds.
- Reads each link's Device id back every 15 s, and drops a link whose read fails, hangs past 5 s, or names another address.
iOS central (BleManager.swift, new BleHelloPolicy.swift)
- Writes its hello, as Android has since #512: Hello is discovered on every path, and the assertion it serves is written with response once per connection, after the Message subscription and before the handshake reads, so ahead of any Message write. Without it an iPhone could verify a Python box and never start a session through the link it opened. A refused hello is a diagnostic, never a dropped link.
react_native_ios_central_writes_its_hellopins the call site.
Docs: bridge rule P15 states the invariant (a peripheral link carries the address its hello proved, or it cannot start a session). Also the CHANGELOG, and the offline-first demo's known gap.
Verification
| Check | Result |
|---|---|
| Python suite | 1067 passed, 1 skipped |
| FFI crate unit tests and source guards | 208 passed; the new iOS guard fails under each of 6 mutations |
| SwiftPM tests | 461 passed, including 5 new BleHelloPolicyTests; CI's iOS bridge typecheck passes |
New test_ble_hello.py | 34 tests; each of 10 mutations of the fixes turns at least one red, a no-op control stays green |
| Android JVM unit tests | new DuplicateClientLinkTest 5/5; the rest pass except PeerStreamFramingTest, which cannot find its vectors file when run from a copy outside the repo (unrelated to this change) |
| Phone and this Mac, Bluetooth only | session forms; Mac to phone delivered over ble with receipt in 0.185 s; phone to Mac delivered (encrypted, hop 0), phone shows the receipt |
| Phone and the second Mac | session in 1 s; delivered over ble in 0.174 s |
| Mac to Mac, Bluetooth only | each central's hello binds on the other's peripheral; delivery after a restart in 0.11 s |
| Service started over ssh with Bluetooth on | socket up in 21 s, peripheral fails with a reason, other transports run (was: never) |
Not exercised on hardware: the stale-link liveness drop (macOS reported every disconnect promptly in this round; covered by unit tests), BlueZ (no Linux box in the lab), and any iPhone: the iOS hello write is typechecked, unit-tested and pinned, but no iPhone was in the lab.
Not fixed here: four INVALID_CIPHERTEXT decrypt failures seen once between the two Macs, cause unknown.