logs.gokuls.in

4 pull requests merged across 1 repo

Offline-Protocol/offline-protocol-sdk

What

The transport behind the wifi_direct slot existed and was never constructed, so the slot's five FFI operations had nothing behind them: a body handed to wifi_direct_message_received was dropped before the engine saw it and wifi_direct_get_next_message was always empty. This registers it whenever wifi_direct_enabled is set, rebuilds it against the derived address like the others, and retains its transport callback across that rebuild the way the BLE one is.

docs/spec/stream-framing.md (#456) is the contract. This PR gives it its Rust reference and its first consumer:

  • stream_framing.rs in the transport crate: frame writes the u32 big-endian prefix, and PeerStreamReader turns a stream's bytes into events (the announced address, each message body attributed to it, the close and its reason). Zero and over-ceiling prefixes are refused before a byte of the body, an under-floor preamble is refused on the prefix, and a message in the preamble's position is a refusal. The verifier is injected, because the one implementation lives beside the identity key in the MLS crate.
  • tests/stream_framing_vectors.rs consumes the chapter's vectors under the keys #456 settled (exchange, refusals, carries_body, frames), with the one real verifier behind the reader so the message-before-preamble vector is refused for the right reason, and asserts the exactly-the-ceiling prefix passes.
  • PEER_STREAM_MAX_FRAME_BYTES, PEER_STREAM_LENGTH_PREFIX_LEN, PEER_STREAM_PREAMBLE_FLOOR (the last delegating to the sealed crate's assertion floor; no second spelling of 96). WIFI_DIRECT_MAX_PAYLOAD_SIZE stated a 64 KiB ceiling nothing read and the chapter contradicts; removed.

The two behaviour changes, and why they are what make registration safe

Both bundled mobile managers report the stream layer up (wifiDirectStatusChanged(true)) when they *start*, not when a group forms, and neither announces a peer or exchanges a preamble. So on any app that opted into the slot, it is up with zero links from launch. Two things keep that from regressing a phone:

  • The slot counts as a carrier only while a stream has proved a peer. WifiDirectTransport::status is Available only while the layer is up *and* a link exists, and Connecting while the layer is up with none (layer_status keeps the platform's report). Without this, every consumer that reads "available" as "a carrier that can take a frame for somebody" counted a carrier that refuses everyone: the media path pinned a file for a Bluetooth LE neighbour to the slot once the internet dropped, every chunk was refused and handed to the neighbour through the mesh with no window (a 256 KiB file queued fifteen times the fragments it does with the slot down, and the chunks past the own-send budget retried against the pin until the transfer failed); the welcome lifecycle charged attempts it could not make; and the selector picked the slot first on every send.
  • send refuses a recipient no stream has proved, with the same PeerNotReachable BLE returns. The transport manager treats that as "try the next carrier" and records no failure. This covers a slot linked to somebody else, and the media pin likewise asks for a link to the recipient itself.

With both, a phone behaves as before this PR, and only a host that runs the framing carries traffic on the slot. A forced send onto an unlinked slot is deferred by the engine, never queued, and the test pins that.

Separately, a send whose only carrier refused the recipient now ends as PeerNotReachable instead of SendFailed("All transports failed"), so message_deferred says peer_not_reachable rather than transport_send_failed.

Drive-by: the mobile transport-name mappers

forceTransport and getTransportMetrics on iOS and Android each carried a three-name copy of the transport-name mapping that defaulted anything else to BLE, so forceTransport("nostr") from JavaScript forced the radio. Each bridge now has one mapper that names all five transports and refuses an unknown name, both entry points go through it, and react_native_transport_name_mappers_know_every_transport reads both sources (negative-controlled on each bridge). C5 in the bridge rules counts it as the twelfth pinned set.

Tests

  • FFI: two engines with identities exchange preambles through PeerStreamReader, announce each other's derived address, and a message drained from the slot crosses the framing and is delivered at hop zero with the sender attributed; a message in the preamble's position closes the stream and announces nothing; a send to an address no stream proved falls through to the internet carrier, and forced onto the slot is deferred; the rebuild test drives the peer-stream callback for real.
  • Transport: 11 unit tests on the reader, the send refusal and the link-gated status, 7 vector tests, and the chapter/vector drift check.
  • Engine: a sole carrier that refuses the recipient ends as PeerNotReachable.
  • FFI (review fixes): a file to a Bluetooth LE neighbour queues exactly the fragments it does with the slot down, both with the slot up and unlinked and with it linked to someone else; a slot-only phone defers as transport_not_connected; the selector never picks a linkless slot. Each was negative-controlled against its fix.

Verification

cargo fmt --all -- --check; cargo clippy --workspace -- -D warnings; cargo test --workspace --lib (13 crates green); RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps; ./scripts/generate-bindings.sh produces no diff (the UDL change is comment-only); npx tsc --noEmit in the React Native package.

C9: no mobile manager behaviour changes here (headers and the callback diagnostic only), so no device run. The managers adopt the preamble in a later PR, and the chapter records what each owes when it does.

Follow-ups

  • The Python peer-stream manager (the host implementation of the chapter) is the next PR, stacked on this one.
  • transport_time_ms.wifi_direct in telemetry now covers every peer stream; documented in docs/telemetry.md.

Track T1, PR 2 of 5 of the headless service program, rebased onto main now that #455 has merged.

docs/spec/stream-framing.md specifies the transport behind the wifi_direct slot as what it is to the engine: a stream the platform established to exactly one peer, whether a Wi-Fi Direct group socket, a Multipeer session, or a TCP connection over a LAN or a routed mesh. Invariants first:

  • one peer per stream, proved once by the preamble, and a stream whose preamble did not verify carries nothing;
  • the first frame in each direction is the identity assertion from #455, sent without waiting for the other side;
  • every frame is u32 big-endian length plus body, refused on the prefix at zero or above the 1 MiB message ceiling;
  • the framing is unauthenticated, and link encryption (TLS, a framework's session encryption, a mesh's mTLS) forms no part of the trust argument.

Then the preamble (the four verification steps, with step four applying only where a claim exists), the message frame (one message, one frame, position decides what a body is), sizing (why big-endian, with the failure a little-endian writer produces), what a receiver owes, the DNS-SD hint (_offlineprotocol._tcp, txtvers=1, addr= as a hint the preamble proves), and the slot's place among the transports with the name recorded as debt.

The vectors are generated by tools/spec-vectors/generate.py into the transport crate: a preamble whose body is RFC 8032 test vector 1 (so a verifier already checked against the identity-assertion vectors verifies it), one framed message as a binary v1 frame, the ordered exchange, a prefix of exactly the ceiling that must be accepted, and four refusals. The length refusals carry only the prefix, because a receiver must never have read a body for them. The Rust test that consumes the file lands with the registration in PR 3.

Also: the BLE chapter's "Wi-Fi Direct needs no chapter" paragraph narrowed to what is still true, the transport architecture page's Wi-Fi Direct section rewritten as the peer-stream slot, spec index and conformance rows, and a changelog entry.

Review fixes: the chapter no longer claims iOS uses the prefix (it sends bare Multipeer messages, so adopting the chapter is a framing change there), it records that the Android reader refuses a body of exactly 1 MiB and keeps reading after a refused length, the DNS-SD rule now binds a Multipeer advertiser (offlineprotocol; the shipped offline-proto is debt), the BLE chapter says the verifier runs steps one to three, the threat model gains R16 (the replayable assertion on every carrier), and the iOS and configuration pages stop saying iOS has no slot.

Related issues

Refs #455 (the identity assertion this chapter's preamble is).

Type of change

  • docs — documentation only
  • test — adding or correcting tests (the generated vector file)

Breaking changes

None. Nothing ships in this PR but the contract.

Notes for reviewers

  • The chapter commits to the existing Android byte order (DataOutputStream.writeInt, big-endian) rather than inventing one. PR 5 still owes Android an inclusive ceiling and close-on-refusal, and owes iOS the prefix itself and the service type.
  • The preamble deadline and the number of accepted streams are stated as local policy, not wire format, on purpose; the shipped managers will carry their own values.
  • Step four of verification is the caller's, as in #455: an inbound LAN connection has no claim to compare against, and the derived address is the peer's id.

The identity assertion, the public_key(32) || signature(64) || signed_data value a Bluetooth LE peripheral serves to prove its address, was split three times (Swift, Kotlin, Python) and zero times in Rust, and the Python copy was not a split at all but a JSON document. No phone could ever verify a Python peripheral. This PR gives the layout one home and one verifier, and is the first piece of the peer-stream transport, which sends the same assertion as its first frame in each direction.

  • Codec in offline-protocol-sealed (encode_identity_assertion, parse_identity_assertion, the 96-byte floor), no crypto, dual std/no_std. Pinned by a generated vector file whose three assertions are RFC 8032 section 7.1 test vectors 1 to 3 verbatim (the generator computes no signature, so every field traces to the RFC) plus three refusals under the floor. A sealed guard refuses a second fn parse_identity_assertion anywhere in the workspace.
  • Verifier verify_identity_assertion in the MLS crate beside parse_invite: parse, verify the signature under the key, derive the address, return it or an error. MlsManager::identity_assertion builds this identity's value.
  • FFI: namespace-level verify_identity_assertion(assertion) and instance identity_assertion(signed_data). All three bindings regenerated together.
  • Python: the peripheral serves its off1… address as Device id and the core-built assertion as Identity, and refuses to start before MLS has minted an address. The central reads both characteristics, verifies through the FFI, compares exactly, and announces the derived address or drops the link unannounced. It no longer announces the Bluetooth address before connecting, and a link that never proved a peer is not reported lost.
  • Guard: the Swift and Kotlin SignedIdentityData decoders remain as the last copies of the split (they already verify and derive through the core); react_native_ble_identity_decoders_split_at_the_sealed_offsets pins them to 32/64 and to deriving nothing locally.
  • Docs: the BLE framing chapter names the one home and the vector file; conformance and spec index rows; C5 gains the eleventh set; P8 for the Python roles; ADR 0022 lists the codec; CHANGELOG.

Related issues

Refs the headless service program, track T1 (peer-stream transport), PR 1 of 5.

Type of change

  • feat — new feature
  • fix — bug fix
  • docs — documentation only

Checklist

  • cargo fmt --all -- --check passes
  • cargo clippy --workspace -- -D warnings passes, and cargo clippy --package offline-protocol --no-default-features --locked -- -D warnings
  • cargo test --workspace --lib passes (the full run with doctests is left to CI)
  • cargo-deny is satisfied (no dependency changes)
  • Commits follow Conventional Commits
  • Docs / CHANGELOG.md updated
  • No new unsafe
  • UDL changed: ./scripts/generate-bindings.sh run, all three committed together; a second run produces no diff
  • RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps passes
  • python3 tools/spec-vectors/generate.py --check: all 8 files match
  • Bare metal: cargo clippy -p offline-protocol-sealed --no-default-features --target thumbv8m.main-none-eabihf -- -D warnings, and the same for the leaf with --features bare-metal-rng
  • Python: build-desktop.sh then pytest bindings/python/tests/ against the rebuilt cdylib: 304 passed

Breaking changes

Python only, and only for a role nothing could interoperate with:

  • BlePeripheral(identity_json=...) is gone. It was the divergent JSON form.
  • BlePeripheral.start() raises TransportError before initialize_mls (that is, before ProtocolManager.start()), because it has no address to serve.
  • BleManager no longer announces a peer under its Bluetooth address before connecting, and refuses a peer whose identity does not verify or does not name its Device id. Rust, Swift, Kotlin and TypeScript surfaces are additive.

Notes for reviewers

  • Where the verifier lives. The plan record put it in the engine's security.rs; it sits in the MLS crate instead, because parse_invite is the exact precedent (instance-less, verifies a signature under a key, checks the derivation) and that crate already owns Ed25519 and the delegation to the sealed derivation. No engine change was needed.
  • Step 4 is the caller's. The chapter's verification has four steps; the verifier runs the first three and returns the derived address. Comparing it to the Device id characteristic is done by whoever read that characteristic, because a peer-stream preamble has no such claim to compare against. The Python central does the comparison exactly, and a test pins that a case-only difference is refused.
  • Not covered by any test (C9): a live pairing between the Python peripheral and an iOS or Android central. The Rust and Python suites prove the bytes verify under the one verifier and that both roles do the right thing with the answer; only hardware proves a phone reads them. Before this PR that pairing could not have worked, so nothing regresses, but it is the thing to try.
  • The mobile decoders are left as callers rather than replaced with the new FFI function, as the plan allows; switching them is a behaviour change in bridge code no CI job runs and belongs with PR 5 of this track, which touches the same managers.
  • One pytest RuntimeWarning about an un-awaited _connect_to_peer coroutine is pre-existing: the advertisement tests schedule it into a mocked loop.

Problem

The iOS and Android internet managers rebuild some inbound relay traffic into a full Message and hand it to internet_message_received:

  • relay answers synthesized from the relay's typed notifications: __GROUP_CREATED__, __GROUP_MSG__, __GROUP_MEMBER_ADDED__, __GROUP_MEMBER_REMOVED__, __GROUP_ERROR__, and __GROUP_INFO__ / __USER_GROUPS__ via the snapshot bridge;
  • plain-text messages from pre-SDK relay senders.

Every one was addressed to deviceId, which is the profile, under a comment saying "Will be corrected by protocol". The protocol does not correct it. Since #328 local_id is the derived address once initialize_mls has run, and the receive loop (receive.rs:132) forwards any frame whose recipient is not local_id before doing anything else. So on every MLS-initialized phone all of these frames went to the mesh forwarder and were lost silently.

Consequences:

  • __GROUP_CREATED__ is the only production writer of relay_synced, so phones never took the relay broadcast path. Per-member fan-out still delivered, which masked the loss.
  • A group message another member broadcast through the relay never arrived on a phone. This is the mobile mirror of what #453 fixed for Python.
  • __GROUP_ERROR__ never revoked a registration. Member-change, group-info and user-groups events never fired.

Existing tests passed because the FFI injection tests never call initialize_mls, and neither InternetManager is executed by any CI job.

Fix

LegacyRelayMessage on both platforms now takes localAddress and profile and picks the address when present, the profile before init. The recipientId parameter is gone, so the pre-fix shape no longer compiles. All five call sites pass a live localAddress() per frame, never a captured value, for the same reason as the control-op translator (#336). Pre-init frames are byte-identical. There is no wire, UDL, TypeScript or FFI change.

The alternative, treating the profile as self in the engine, was rejected. It would undo the #328 invariant and widen self-matching onto a user-chosen namespace where "default" is a documented profile.

Tests

CheckResult
LegacyRelayMessageTests.swift (2 new: address when present, profile fallback for nil and empty)swift test: 362 passed
LegacyRelayMessageTest.kt (2 mirrors)gradle harness: 497 passed
test_synthesized_relay_frame_must_name_the_local_address_after_mls_init (MLS init, then profile-addressed frame is not processed and address-addressed frame is)passed
react_native_relay_frames_are_addressed_to_a_live_local_address (every builder call passes a live localAddress(); no recipientId = deviceId shape)passed; fails when one Kotlin site is mutated to pass null
CI's swiftc -typecheck of the excluded bridge filesclean; negative control fires on the edited line
cargo clippy --workspace -- -D warnings, cargo test --workspace --lib, cargo fmt --checkgreen

Not covered by automated tests (C9)

The bridge-to-engine seam on a device. Manual check before release: on an MLS-initialized phone, registering a group with the relay emits groupCreated and groupRelaySyncChanged (registered), and a group message a second member broadcasts through the relay arrives.

Also noted, not touched here: the Python internet manager's docstring still says a capitalized priority drops the frame. The core accepts the alias.