logs.gokuls.in

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_messages switch is on (new, default on),
  • the recipient advertised svc_versions entry 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_versions and SVC_SEALED_V1 = 1 in offline-protocol-sealed. It is honoured only from a signed key package, like nostr_pubkey, because it sits beside a confidentiality decision. It is persisted in PeerCapabilities and restored on initialize_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 automatic not_found. Sealed frames go through a new send_sealed_internal_message, unsigned like every 1:1 envelope, and stage OutboxReseal. 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 shared dispatch_internal_message.
  • Receive. A decrypted __SVC_ plaintext from a session: slot is routed to handle_service_message with 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 automatic not_found sent 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_messages is appended last to the flat UDL ProtocolConfig. 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_section is 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 through tools/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_versions is ignored by older code via serde default.
  • EncryptionConfig gets 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

CheckResult
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 behaviour10 of 10 turned a test red
fmt, both CI clippy steps, cargo doc -D warningsclean
Bare metal (4 targets) and WASI for sealed and leafclean
Python bindings1085 passed, 1 skipped
iOS SwiftPM462 passed; React-header typecheck of OfflineProtocolModule.swift passes, negative control fails as expected
Android ProtocolConfigParserTest46 passed
React Native tsc and test:jspass
Regenerating bindings from a clean treeno 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. The session: 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.
  • encrypted on the service events. service_request_received and service_response_received say 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 --workspace gives 3300 passed, 0 failed.

Follow-ups (not in this PR)

  • A strict mode under require_encryption that 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_hello pins 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

CheckResult
Python suite1067 passed, 1 skipped
FFI crate unit tests and source guards208 passed; the new iOS guard fails under each of 6 mutations
SwiftPM tests461 passed, including 5 new BleHelloPolicyTests; CI's iOS bridge typecheck passes
New test_ble_hello.py34 tests; each of 10 mutations of the fixes turns at least one red, a no-op control stays green
Android JVM unit testsnew 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 onlysession 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 Macsession in 1 s; delivered over ble in 0.174 s
Mac to Mac, Bluetooth onlyeach central's hello binds on the other's peripheral; delivery after a restart in 0.11 s
Service started over ssh with Bluetooth onsocket 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.