diff --git a/CHANGELOG.md b/CHANGELOG.md index f4531122e..af7944b88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,40 @@ This file holds unreleased changes and the current release. Older releases are archived by series under [docs/changelog/](docs/changelog/); see the [archive index](docs/changelog/README.md). -## [Unreleased] +## [0.28.0] — 2026-10-06 + +> **The peer-stream slot carries traffic on phones.** The mobile managers +> exchange the identity preamble and announce a peer only under the address +> it proved. iOS runs the slot on Network framework, over the local network +> or AWDL, and finds a Python host on the same LAN. Android forms its Wi-Fi +> Direct group itself with `autoAccept: true`, with no system dialog, and +> `wifiDirect: { enabled: true }` now starts the transport from `start()` on +> both platforms. +> +> **Bluetooth LE holds up where it used to give out.** Two phones in a room +> full of other Bluetooth devices find each other, an iPhone's Bluetooth +> power-cycle no longer strands either end, and Android notices Bluetooth +> switched off or a stack crash and relinks. `neighbor_lost` fires once, and +> only when nothing nearby reaches the peer. +> +> **The SDK ships outside React Native.** A release now builds, tests and +> publishes a Swift package, an Android library +> (`com.offlineprotocol:offline-protocol-sdk`) and Python wheels tagged for +> their platform. A host with no platform keystore can use the built-in file +> stores, sealed under a store key, and a Python host can front one engine +> for several local applications through the local API. +> +> **Sessions form where they used to stall.** A lost first key package is +> pushed again, and a key package this device's clock refuses is reported as +> `KEY_PACKAGE_OUTSIDE_VALIDITY_WINDOW` instead of leaving messages queued +> with nothing to say why. +> +> **Breaking, narrowly:** Python's `InternetManager` requires `app_id=`, and +> an exhaustive TypeScript `switch` over `SecurityWarningCode` needs the new +> member. At run time, an iPhone on 0.28 does not see one on 0.27 or earlier +> over the peer-stream slot. +> [§26](docs/UPGRADING.md#26-behaviour-that-changes-without-a-compile-error-v0280) +> lists these and the other changes that compile fine and behave differently. ### Added @@ -886,470 +919,6 @@ archived by series under [docs/changelog/](docs/changelog/); see the reference backbone. [ADR 0026](docs/adr/0026-the-backbone-is-a-gateway-property.md) records the decision. -## [0.27.0] — 2026-09-25 - -> **Replicated documents can be removed, narrowed, and carry their bytes -> inside a group.** `removeDoc` and `removeSpace` remove a document from every -> replica instead of having the next exchange refill it, `setInterest` lets a -> device replicate part of a space, and `fetchAttachmentFrom` fetches the bytes -> behind an attachment reference from one group member under the group key. -> Each is negotiated as an appended `data_versions` entry, so a peer on an -> older build keeps working and refuses what it does not understand. -> [§22](docs/UPGRADING.md#22-documents-can-be-removed-from-every-replica-v0270) -> through [§24](docs/UPGRADING.md#24-attachment-bytes-inside-a-group-v0270) -> cover them. -> -> **The peer-stream transport is registered, and a Python host can use it.** -> The `wifi_direct` slot now has a transport behind it, specified in -> `docs/spec/stream-framing.md`, and Python ships a manager that carries it -> over a LAN or a routed mesh. The bundled iOS and Android managers do not -> speak the framing yet, so on a phone the slot announces no peer, refuses -> every recipient, and nothing changes. -> -> **Relay group traffic reaches phones again.** On iOS and Android with -> encryption on, the relay's answers about groups were addressed to the -> profile name and forwarded away unprocessed. They now arrive, so a group -> registered with the relay sends one frame instead of a copy per member, -> group messages other members broadcast through the relay are received, and -> the group events built on those answers fire for the first time. -> [§25](docs/UPGRADING.md#25-behaviour-that-changes-without-a-compile-error-v0270) -> lists this and the other changes that compile fine and still behave -> differently. -> -> **Breaking, narrowly:** the `offline-protocol-transport` crate drops -> `WIFI_DIRECT_MAX_PAYLOAD_SIZE`, and Python's `BlePeripheral` drops its -> `identity_json` argument. No UDL, Swift, Kotlin or TypeScript signature is -> removed. - -### Added - -- **The identity assertion has one implementation.** The - `public_key(32) || signature(64) || signed_data` value a Bluetooth LE - peripheral serves to prove its address was split three times, once per - bridge, and the Python copy was a JSON document no phone could verify. The - codec now lives in `offline-protocol-sealed`, pinned by a generated vector - file whose signatures are RFC 8032's own, and the one verifier is the - namespace-level `verify_identity_assertion(assertion)`: it parses, verifies - the signature under the key, derives the address, and returns it, or throws. - A peripheral builds its value with `identity_assertion(signedData)` on the - instance, so no bridge assembles the layout. The Swift and Kotlin decoders - still split the bytes (they already called the core to verify and derive) - and are now pinned to the sealed offsets by a source guard. This is the - first piece of the peer-stream transport, which sends the same assertion as - its first frame. - -- **The peer-stream transport is registered.** 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. It is now registered - whenever `wifi_direct_enabled` is set, rebuilt against the derived address - like the others, and its transport callback is retained across that rebuild - as the BLE one is. `docs/spec/stream-framing.md` is the contract, and the - transport crate gains its Rust reference: `PeerStreamReader` reads a - stream's bytes into events (the announced address, each message body, the - close and why) for a host that owns its own sockets, and - `stream_framing_vectors.rs` is the consumer that checks it against the - chapter's vectors with the one real verifier behind it. - - Registering the slot changed two behaviours, deliberately. The slot counts - as an available carrier only while a stream has proved a peer: with the - platform's stream layer up and nothing proved it reports `connecting`, so - the selector does not score it, a media transfer is not pinned to it, and - the welcome lifecycle does not count it as a carrier. And the transport - queues a message only toward an address a stream has proved, refusing any - other recipient as not reachable on this carrier, which the selector treats - as "try the next one". The bundled mobile managers report the layer up when - they start and exchange no preamble yet, so they announce no peer. Without - the narrower status, a file sent to a Bluetooth LE neighbour after the - internet dropped was pinned to the slot, every chunk was refused, and the - refused chunks reached the neighbour through the mesh with no window; - without the refusal, the slot would win the selection on bandwidth and hold - direct messages in a queue those managers cannot deliver from. With both, a - phone with the slot enabled behaves as before this entry, and only a host - that runs the framing carries traffic on it. A media transfer is pinned to - the slot only when a stream has proved the recipient itself. The five React Native - operations are no longer marked deprecated; their contract is the chapter's, - and only a verifier's result may be announced. - -- **A Python host can reach another over a LAN or a routed mesh.** - `PeerStreamManager` is the peer-stream transport for a host that owns its - sockets: a TCP listener, outbound connections to configured `host:port` - peers (or `off1…@host:port`, which makes the derived address have to match - the claim exactly) and to hosts found over DNS-SD, and the preamble exchange - the chapter specifies. Each side sends its identity assertion as the first - frame without waiting for the other's, verifies what it receives through - `verify_identity_assertion`, and announces the peer under the derived - address only; a stream that fails any step is closed and was never - announced. `ProtocolManager` builds it when `wifi_direct_enabled` is set - and stops it with the rest; the application starts it after - `ProtocolManager.start()`, since a host with no address to prove is refused - by every peer. DNS-SD advertises `_offlineprotocol._tcp` with `txtvers=1` - and `addr=` through the optional `lan` extra - (`pip install 'offline-protocol-sdk[lan]'`), and the `addr` in a record is - a hint the preamble proves, never a name to announce. Two hosts that list - each other keep one stream, the one the lower address opened, so a - simultaneous open cannot loop; the lower address reconnecting while its - old stream is still half-open supersedes it. The higher address cannot, so - every stream carries TCP keepalive and an idle dead peer's stream ends - within about half a minute. Each stream writes from its own queue, - bounded at 4 MiB while a frame is in flight (a body over it goes back to - the retry path), and a peer that stops taking bytes once the buffers - between the two hosts are full has its stream aborted and reported lost - after thirty seconds without progress (`write_timeout`), without holding - any other peer's traffic; keepalive alone would not end it, since a - stream with data in flight is not idle. Until those buffers fill, on the - order of a megabyte on a default host, a peer that is alive but not - reading stays announced, and the core's acknowledgements are what notice - it. A slow link that is moving is never cut. The preamble deadline covers - the whole first frame, - one remote host holds at most eight inbound streams, and a peer that never - proves an address is retried on a doubling ladder. The listener binds - every interface by default; pass `listen_host` to narrow it. - -- **Attachment bytes move inside a group.** `DataStore.fetchAttachmentFrom(space, peer, hash)` - asks one member for the bytes behind a reference, and they come back as - frames sealed under the group key. Before this, references replicated to - every member and the bytes reached nobody: fetching them needed a confirmed - pairwise session, and two members of a group need not have one with each - other. - - The fetch names the member for two reasons. A reference says what the bytes - are and nothing about who holds them, so somebody has to choose; and a - directed frame is promoted to a roster-wide delivery when the sender's - ratchet budget runs out, which means a request arrives at every member - whether or not it was addressed to them. Without the name in the frame, - every member's application would be asked for bytes the requester can - accept from only one of them. The SDK does not try members in turn, because - each miss costs the whole silence timeout. - - **A group attachment is at most 1 MiB**, 32 frames of 32 KiB. That bound is - what keeps the request a single question with a single answer: the whole - answer leaves at once, so nothing has to ask for the next window and there - is no chain of questions to terminate. `provideAttachment` refuses a larger - blob at the call, while the application still has the file in hand, and - says to use a 1:1 session instead. Nothing about the 1:1 path changed; it - still carries up to the transfer layer's own limit. - - A chunk is admitted only against an outstanding request and only from the - member that request was put to, and the assembled bytes are still checked - against the hash that asked for them. The first two bound what a member can - spend of another's memory and stop one member answering or refusing a - question put to somebody else; only the hash says what the bytes are. - - Negotiated as `data_versions` entry 6, appended, and the first entry since - the second to have an attested sibling: a group inviter now attests every - group-relevant entry it knows about a member, rather than only entry 2. A - member without entry 6 refuses the request it understands and drops the - chunk frames it does not, so nothing is shown to anybody wrongly; what the - entry buys is refusing a fetch at the call instead of waiting out the - timeout. - - A fetch is refused at the call for a member this device does not know - receives replication frames inside a group, because one that does not - would be shown the question as text, and for this device itself. Asking a - second member is a fallback rather than a repeat: the question put to the - first is reported as `evicted`, naming that member, and the new one goes - out at once. Every report of a fetch that ended without bytes now names - the member it was put to rather than the space. - -- **A space can replicate in part.** `DataStore.setInterest(space, patterns)` - names the documents this device wants from its peers: document names, each - optionally ending in `*` to match a prefix, at most 32 per space. `["*"]` - is everything and is the default, so a space nobody narrows behaves exactly - as before and its frames are byte-identical to the ones it sent last - release. - - It does two things that fail differently, and the difference is the whole - design. Toward a peer it is a **request**: every version offer carries it, - so the peer answers only with what was asked for. That is the bandwidth - saving, and it needs the peer to read the field. Locally it is a - **refusal**: a document outside the patterns is never created from an offer - and never imported however it arrives. That half depends on nothing, which - is why a narrowed space is narrow even against a peer that ignores the - request, including every build that predates it. - - Three rules keep it from losing documents. The answer is scoped by the - asker's declared interest and never by the names their frame happened to - carry, because the second drops a document they have never seen with no - symptom on either device. A counter-offer still carries the complete list: - the saving there is one name per unwanted document, which is not worth - narrowing a reply sweep for. And removals are never scoped, because a peer - that narrowed still holds what it held before, so a removal for a document - it no longer wants is exactly the one it still needs. - - Interest is not persisted: it is application policy for this launch rather - than a fact about the store, so declare it before `start()`. `wipeAll()` - clears it along with the documents it scoped, because nothing may - distinguish a wiped space from one this device has never seen. Narrowing - does not delete what is already held, because a policy change should not - destroy data nobody asked to lose; `deleteDoc` is how a document leaves. - Widening asks, since the newly wanted documents are absent from this - device's next offer and inside its declared interest. - - Negotiated as `data_versions` entry 5, appended. On the wire an absent - `want` means everything and an empty one means nothing; the two are - deliberately not collapsed, because collapsing them would turn the - narrowest request into the widest. - -- **Replicated documents can be removed from every replica.** - `DataStore.removeDoc(space, doc)` records the version the document stood at - and tells the space; a replica whose copy that version covers deletes it too - and reports the new `data_doc_removed` event with `by: "peer"`. - `removeSpace(space)` does the same for every document a space holds. A - removed name refuses reads and writes with `InvalidState` until `createDoc` - brings it back. Before this, a document deleted on one device was absent - from its next offer and still present on the peer's, and the rules that - exist to carry a document a peer has never seen recreated and refilled it: - a delete was undone by the next exchange, with no error and no event. - - The removal is a version rather than a flag, which is what lets every - replica reach the same answer without knowing what happened first. Content - at or below it is what the removal decided about; anything beyond it is an - edit made concurrently with the removal, and **an edit wins**. It brings the - whole document back, not the edit alone, because the edit was made on the - old contents and its history is those contents. That arrives as an ordinary - `data_changed` on a document the application removed. A product that needs a - removal which cannot be undone that way should model it as a field it - writes rather than as a document that disappears. For the same reason a - removed name is not fenced off when it is re-used: a replica that kept the - old contents past the removal merges them into the new document, and a - replica on an older build merges the new contents into its old copy. Give - new content a fresh name. - - A removal never expires, so a device away for a year still learns about it, - and a removed name keeps a small record for the life of the space. Those - records count against the existing 1024-document ceiling a peer can fill, - because a floor outlives its document and counting only what is held would - let a peer name a fresh thousand after every removal. The record is written - before the records it removes: the reverse order leaves, after a crash in - between, records whose presence votes the document back into existence at - the next open. - - `deleteDoc` is unchanged and is eviction: it drops this device's copy and - records nothing, so a replica that still holds the document refills it. - `wipeAll()` is unchanged too and records no removals, because a logout has - to leave a custom backend empty. The four documents that described the old - behaviour say so. - - Negotiated as `data_versions` entry 4, appended. A peer without it ignores - the new field, keeps its copy, and offers it back on every exchange; the - removing side refuses each offer and each blob against its own record, so - the removal holds there regardless. Nothing loops and nothing is surfaced - wrongly. New storage category `data_doc_meta`, sealed like every other, and - no new error code. `listSpaces()` includes a space whose documents were all - removed, because it still has removals to report. - -### Changed - -- **`WIFI_DIRECT_MAX_PAYLOAD_SIZE` is gone from the transport crate.** It - stated a 64 KiB ceiling nothing read, and the peer-stream chapter's ceiling - is the 1 MiB message ceiling; the framing constants that replace it are - `PEER_STREAM_MAX_FRAME_BYTES`, `PEER_STREAM_LENGTH_PREFIX_LEN` and - `PEER_STREAM_PREAMBLE_FLOOR`. - -- **The telemetry pipe caps `protocol.message.failed` rows per reason.** Each - reason sends up to 500 rows at once, enough for a full outbox failing in one - sweep, and then one row every ten seconds. Every failure is still counted - into the minute rollup's `sends_failed_sum`, so the totals are exact; only - the per-row `reason` and `retry_count` are thinned, and a held-back row is - counted in `telemetryStats().dropped`, so sent plus dropped still accounts - for every event. A row cannot carry a message id, so the ingest has no way - to tell one failure reported many times from many failures and meters every - row. Without the cap, a device reporting the same failure in a loop is billed - for the loop, which is what an outbox bug fixed in this release did at about - fifteen rows a second per device. The check runs on the borrowed event before - anything is copied, so a held-back row allocates nothing. There is no - setting. -- **A frame the relay's mailbox holds is parked without a reachability probe.** - A relay advertising `mailbox_v1` keeps a copy of a frame it could not deliver - and re-sends it on the recipient's next connection, and says so with - `stored: true` beside the `DeliveryError` or the pushed `MessageSent`. The - React Native bridges and the Python relay client now read that flag and - report those two answers to the core as the new `relay_stored` and - `relay_pushed_stored` tokens. The park they produce is the existing one in - every respect that matters - the pending acknowledgement is dropped so no - budget burns against an offline peer, the outbox entry stays, the frame is - still offered to the mesh, and the recipient is still presence-watched - and - drops only the escalating 15s→600s probe. The probe existed because nothing - else would re-deliver the frame; now something does, and each rung would only - rewrite the row the relay already holds, per parked message, for as long as - the peer stays away. - - Recovery is the relay's own redelivery, and the reachability edge behind it. A - returning peer already re-drives every parked frame the outbox holds, so a - held copy the mailbox evicted or expired is covered by the same edge that has - always covered an unheld one - which means what the token saves is the probe - rungs while the peer is away, not the write on its return. Past the mailbox's - own retention the guarantee is that edge and the outbox lifetime: the trade - `edge_driven_unreachable_dm` opts into for every parked frame, taken here for - held frames alone, where something else is already committed to delivering - them. - - Everything that is not a plain direct message is deliberately untouched: both - tokens normalize back onto the existing vocabulary before any typed branch - reads them, so a connection request still fast-fails with - `ConnectionRequestUndeliverable` and a Welcome still parks on its own - lifecycle. `MessageUndeliverable` is still emitted on the `DeliveryError` - path, because it is documented as a repeatable status signal and "the - recipient is offline right now" is true whether or not a copy was kept. A - missing `stored` key - an older relay, a relay whose mailbox is off, a store - that failed - reads as "not held" and keeps the probe. - - `stored` is a statement about one message, not about the recipient. The - `DeliveryError` path fails every frame in flight to that recipient, so only - the id the relay echoed takes the new token and the rest keep the - `recipient_unreachable` prefix they have always had. Each held frame takes it - from its own verdict: the relay answers every submission separately, and the - first answer for an offline recipient resolves that recipient's whole - in-flight set, so a client reports the id its verdict named even when that set - is already drained. Without that, only the first frame of a burst would park - probe-free and the rest would each pay one probe before the relay said - `stored` again. A Welcome reported that way has usually been failed already - by the drain, and is not failed a second time: one send climbs one rung of its - retry ladder, and the app hears `welcome_send_failed` once. - -### Fixed - -- **The mobile bridges map every transport name.** `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 forcing - `"nostr"` or `"reticulum"` from JavaScript forced the radio, and a metrics - read for either answered for it. Each bridge now has one mapper that names - all five transports and refuses an unknown name, both entry points go - through it, and a Rust guard reads both sources to keep it so. - -- **A send that every carrier refused as not reachable says so.** When the - selected carrier refused the recipient and there was nothing left to fall - back to, the send ended as `SendFailed("All transports failed")`, and the - `message_deferred` reason read `transport_send_failed`, as if a carrier had - broken mid-send. It now ends as the refusal, and the reason is - `peer_not_reachable`. The welcome lifecycle classifies both the same way, - so only the deferral reason changes. - -- **A Python Bluetooth LE peripheral can be verified by a phone.** It served - the profile label as its Device id and a JSON document as its Identity, so - every conforming central refused it before any cryptography ran; no phone - has ever paired with a Python node over Bluetooth LE. The peripheral now - serves its `off1...` address and the core-built identity assertion, and - `start()` refuses to advertise until MLS has minted an address rather than - advertise something unverifiable. The `identity_json` constructor argument - is gone. Any Python peripheral already deployed changes behaviour, but no - working pairing regresses, because none could have worked. - -- **The Python Bluetooth LE central verifies a peer before announcing it.** It - announced whatever string the Device id characteristic carried, and before - connecting it announced the Bluetooth address itself, so the core's routing - table held labels nothing had proved. It now reads the Identity - characteristic, hands it to `verify_identity_assertion`, compares the result - to the Device id exactly, and announces the derived address or drops the - link unannounced. A link that never proved a peer is no longer reported lost - either, since nothing was ever announced for it. - -- **Relay group notifications reach an iOS or Android member.** The React - Native bridges rebuild some relay traffic into a full `Message` before - handing it to the core: the relay's answers about groups (`GroupCreated`, a - relay-broadcast `GroupMessageReceived`, member added and removed, group - errors, group info and the user's group list) and plain-text messages from - pre-SDK relay senders. They addressed every one of those frames to the - profile name. Since the release that derived the wire identity from the - identity key, a device's id is its address once `initialize_mls` has run, - and the core forwards rather than processes a frame addressed to anyone - else. So on every phone with encryption on, all of them were handed to the - mesh forwarder and lost silently. - - The loss was hidden rather than harmless. `GroupCreated` is the relay's - registration acknowledgement and the only thing that lets a group use the - relay's one-frame broadcast, so phones never took that path and fell back to - sending one copy per member, which still arrived. A group message another - member broadcast through the relay never arrived on a phone, and a relay - group error never revoked the registration. A phone with encryption off was - unaffected. - - The frame builder on both platforms now takes the device's address and its - profile and addresses the frame to the address, falling back to the profile - only before initialization, which keeps those frames byte-identical. No wire - format changed. -- **A group message broadcast through the relay reaches a Python member.** A - phone whose group is registered with the relay sends one frame, the relay - fans it out to every member as `GroupMessageReceived`, and counts the socket - write as delivered, so the sender never re-sends a per-member copy. The - Python bridge re-injected that frame under a prefix the core does not - recognize (`__GRP_MSG__`), addressed to the profile name rather than the - device's address, and without the `id` and `timestamp` the deserializer - requires. The core treated it as plaintext for someone else and the message - was lost silently. The bridge now injects it as the iOS and Android bridges - do: `__GROUP_MSG__`, addressed to `local_address()`, in the full `Message` - shape. `GroupCreated`, `GroupMemberAdded`, `GroupMemberRemoved` and - `GroupError` are injected unattributed under the core's relay-answer - prefixes, and `GroupMessageSent` goes to `internet_group_report_received` - instead of the message plane. The same frame builder now backs the - plain-text `MessageReceived` fallback, which was dropped for the same - reason. -- **A message evicted from a full outbox stays failed.** Eviction removed the - outbox entry and reported `message_failed` (`"Outbox capacity exceeded"`), - but left the message queued for retry. The next retry, or the next reconnect - flush, re-created its outbox entry with fresh timestamps and evicted a - different message to make room, whose own retry did the same. A device - holding more than 500 undeliverable messages therefore reported a terminal - `message_failed` on every retry, indefinitely, for ids it had already - reported. Nothing ever ended the cycle, because the outbox is the only thing - that expires a message and each resurrection reset its clocks. With the - telemetry pipe on, every one of those reports was an uploaded, metered - event. The media outbox leaked retry entries the same way, silently. - - Every path that gives a message up (capacity eviction, lifetime expiry, retry - exhaustion, an acknowledgement timeout that finds no outbox entry) now takes - it out of the retry queue, the acknowledgement tracker and the outbox - together. An application that counted `message_failed` events saw that count - inflated and will see it drop to one per failed message. -- **A message evicted while awaiting its acknowledgement is settled once.** Its - acknowledgement stayed tracked, so the timeout reported it failed a second - time (`"Message missing from outbox (cannot retry)"`), and an acknowledgement - arriving first reported it delivered after it had been reported failed. -- **Bluetooth pairing works on a phone running several apps built on this - SDK.** Every such app serves the same BLE service, so a phone running two of - them presents two instances of it behind one connection. The React Native - bridges assumed one. On iOS the handshake read both instances into one slot - and joined whichever reads landed first, so the peer was refused or announced - under the other app's identity at random; on Android it always bound to the - first app to register. Two copies of one app could stop finding each other - as soon as a second SDK app was installed on either phone. - - Each app now serves an eight-byte tag derived from its `appId` in a new, - optional GATT characteristic (`6E400005-…`), and a central that finds several - instances reads their tags and binds to its own app's, then handshakes, - subscribes and writes on that instance only. A phone running only other SDK - apps is still bound (to the first instance, as before) and still relays. - A peer running one SDK app is handled exactly as before and its tag is never - read. No API or configuration changes; the tag comes from the `appId` the app - already passes. An app built on an earlier SDK serves no tag, and one such - build among tagged apps is still found; it cannot find its own counterpart on - a multi-app phone until it upgrades. See - [BLE framing](docs/spec/ble-framing.md#several-instances-behind-one-link). - -### Documentation - -- **The peer-stream framing chapter.** `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 one peer, whether a Wi-Fi - Direct group socket, a Multipeer session, or a TCP connection over a LAN or - a routed mesh. The first frame in each direction is the identity assertion, - every frame is a `u32` big-endian length plus body under the 1 MiB message - ceiling, and a peer is announced only under the address its preamble - proved. It also names the DNS-SD service type and TXT record a LAN - advertiser uses, with the address there a hint the preamble proves. The - vectors are generated: a preamble, a framed message, the ordered exchange, - a prefix of exactly the ceiling that must be accepted, and four refusals. - The chapter also records what the bundled managers still owe: iOS sends - bare messages over its Multipeer session and advertises another service - type, and the Android reader refuses a body of exactly 1 MiB and keeps - reading after a refused length. A receiver holds one announced stream per - address, so a second stream proving the same address never makes the first - one's close read as the peer's loss. The threat model gains R16, the - replayable identity assertion on every carrier. Nothing ships in this entry but the - contract; the registration and the managers follow it. - ## Archived releases Releases before the current one are archived by minor series. Each file carries @@ -1357,6 +926,7 @@ its own release table. | Series | Releases | |--------|----------| +| [0.27.x](docs/changelog/0.27.md) | 0.27.0 | | [0.26.x](docs/changelog/0.26.md) | 0.26.0 | | [0.25.x](docs/changelog/0.25.md) | 0.25.0 | | [0.24.x](docs/changelog/0.24.md) | 0.24.1, 0.24.0 | diff --git a/Cargo.lock b/Cargo.lock index f397ccacf..b3e4d2a9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2293,7 +2293,7 @@ dependencies = [ [[package]] name = "offline-protocol" -version = "0.27.0" +version = "0.28.0" dependencies = [ "base64 0.22.1", "chacha20poly1305", @@ -2330,7 +2330,7 @@ dependencies = [ [[package]] name = "offline-protocol-bench" -version = "0.27.0" +version = "0.28.0" dependencies = [ "criterion", "offline-protocol", @@ -2343,7 +2343,7 @@ dependencies = [ [[package]] name = "offline-protocol-core" -version = "0.27.0" +version = "0.28.0" dependencies = [ "base64 0.22.1", "bech32", @@ -2358,7 +2358,7 @@ dependencies = [ [[package]] name = "offline-protocol-data" -version = "0.27.0" +version = "0.28.0" dependencies = [ "loro", "serde", @@ -2369,7 +2369,7 @@ dependencies = [ [[package]] name = "offline-protocol-leaf" -version = "0.27.0" +version = "0.28.0" dependencies = [ "base64 0.22.1", "getrandom 0.2.16", @@ -2388,7 +2388,7 @@ dependencies = [ [[package]] name = "offline-protocol-mls" -version = "0.27.0" +version = "0.28.0" dependencies = [ "base64 0.22.1", "chrono", @@ -2411,7 +2411,7 @@ dependencies = [ [[package]] name = "offline-protocol-reliability" -version = "0.27.0" +version = "0.28.0" dependencies = [ "chrono", "offline-protocol-core", @@ -2421,7 +2421,7 @@ dependencies = [ [[package]] name = "offline-protocol-router" -version = "0.27.0" +version = "0.28.0" dependencies = [ "chrono", "offline-protocol-core", @@ -2433,7 +2433,7 @@ dependencies = [ [[package]] name = "offline-protocol-sealed" -version = "0.27.0" +version = "0.28.0" dependencies = [ "base64 0.22.1", "offline-protocol-core", @@ -2445,7 +2445,7 @@ dependencies = [ [[package]] name = "offline-protocol-services" -version = "0.27.0" +version = "0.28.0" dependencies = [ "offline-protocol-core", "serde", @@ -2457,7 +2457,7 @@ dependencies = [ [[package]] name = "offline-protocol-telemetry-wire" -version = "0.27.0" +version = "0.28.0" dependencies = [ "serde", "serde_json", @@ -2466,7 +2466,7 @@ dependencies = [ [[package]] name = "offline-protocol-transport" -version = "0.27.0" +version = "0.28.0" dependencies = [ "base64 0.22.1", "chacha20", @@ -2489,7 +2489,7 @@ dependencies = [ [[package]] name = "offline-protocol-uniffi" -version = "0.27.0" +version = "0.28.0" dependencies = [ "android_logger", "log", diff --git a/Cargo.toml b/Cargo.toml index 5b35bb3a2..39af93051 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,7 +36,7 @@ exclude = ["tools/embedded-footprint"] # publish a tag whose number does not match this one — crates.io versions are # immutable, so discovering the mismatch afterwards means the number is wrong # forever. -version = "0.27.0" +version = "0.28.0" edition = "2021" authors = ["Offline Protocol, Inc."] # Dual-licensed: AGPL-3.0-only for open-source use, with a separate commercial @@ -139,17 +139,17 @@ chacha20poly1305 = "0.10" # Keep these in lockstep with `workspace.package.version` above — a crate can # only be published once its dependencies exist on the registry at the named # version, so all eleven move together. -offline-protocol-core = { path = "crates/offline-protocol-core", version = "0.27.0" } -offline-protocol-sealed = { path = "crates/offline-protocol-sealed", version = "0.27.0" } -offline-protocol-leaf = { path = "crates/offline-protocol-leaf", version = "0.27.0" } -offline-protocol-transport = { path = "crates/offline-protocol-transport", version = "0.27.0" } -offline-protocol-router = { path = "crates/offline-protocol-router", version = "0.27.0" } -offline-protocol-reliability = { path = "crates/offline-protocol-reliability", version = "0.27.0" } -offline-protocol-mls = { path = "crates/offline-protocol-mls", version = "0.27.0" } -offline-protocol-services = { path = "crates/offline-protocol-services", version = "0.27.0" } -offline-protocol-data = { path = "crates/offline-protocol-data", version = "0.27.0" } -offline-protocol-telemetry-wire = { path = "crates/offline-protocol-telemetry-wire", version = "0.27.0" } -offline-protocol = { path = "crates/offline-protocol", version = "0.27.0" } +offline-protocol-core = { path = "crates/offline-protocol-core", version = "0.28.0" } +offline-protocol-sealed = { path = "crates/offline-protocol-sealed", version = "0.28.0" } +offline-protocol-leaf = { path = "crates/offline-protocol-leaf", version = "0.28.0" } +offline-protocol-transport = { path = "crates/offline-protocol-transport", version = "0.28.0" } +offline-protocol-router = { path = "crates/offline-protocol-router", version = "0.28.0" } +offline-protocol-reliability = { path = "crates/offline-protocol-reliability", version = "0.28.0" } +offline-protocol-mls = { path = "crates/offline-protocol-mls", version = "0.28.0" } +offline-protocol-services = { path = "crates/offline-protocol-services", version = "0.28.0" } +offline-protocol-data = { path = "crates/offline-protocol-data", version = "0.28.0" } +offline-protocol-telemetry-wire = { path = "crates/offline-protocol-telemetry-wire", version = "0.28.0" } +offline-protocol = { path = "crates/offline-protocol", version = "0.28.0" } [workspace.lints.clippy] unwrap_used = "deny" diff --git a/SECURITY.md b/SECURITY.md index 4fbec85f2..d4fb3a154 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,8 +7,8 @@ backports to earlier lines. If you are on an older line, upgrading is the fix. | Version | Supported | | -------------------------------- | ------------------ | -| Current line (`0.27.x`) | :white_check_mark: | -| Any earlier line (`≤ 0.26.x`) | :x: | +| Current line (`0.28.x`) | :white_check_mark: | +| Any earlier line (`≤ 0.27.x`) | :x: | Report against the latest published release ([releases](https://github.com/Offline-Protocol/offline-protocol-sdk/releases), diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 8dda4a8c1..81ce36337 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -41,17 +41,17 @@ upstream source on each crate's page) — the exact versions are listed below. Used by: -- [offline-protocol 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-core 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-data 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-mls 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-reliability 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-router 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-sealed 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-services 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-telemetry-wire 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-transport 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-uniffi 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-core 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-data 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-mls 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-reliability 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-router 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-sealed 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-services 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-telemetry-wire 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-transport 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-uniffi 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) ``` GNU AFFERO GENERAL PUBLIC LICENSE diff --git a/bindings/python/THIRD-PARTY-NOTICES.md b/bindings/python/THIRD-PARTY-NOTICES.md index 8dda4a8c1..81ce36337 100644 --- a/bindings/python/THIRD-PARTY-NOTICES.md +++ b/bindings/python/THIRD-PARTY-NOTICES.md @@ -41,17 +41,17 @@ upstream source on each crate's page) — the exact versions are listed below. Used by: -- [offline-protocol 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-core 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-data 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-mls 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-reliability 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-router 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-sealed 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-services 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-telemetry-wire 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-transport 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-uniffi 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-core 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-data 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-mls 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-reliability 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-router 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-sealed 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-services 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-telemetry-wire 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-transport 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-uniffi 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) ``` GNU AFFERO GENERAL PUBLIC LICENSE diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml index a86a0cdf2..400e0a4b4 100644 --- a/bindings/python/pyproject.toml +++ b/bindings/python/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "offline-protocol-sdk" -version = "0.27.0" +version = "0.28.0" description = "Python bindings for the Offline Protocol SDK — offline-first mesh networking with MLS encryption" readme = "README.md" license = "AGPL-3.0-only" diff --git a/bindings/react-native/THIRD-PARTY-NOTICES.md b/bindings/react-native/THIRD-PARTY-NOTICES.md index 8dda4a8c1..81ce36337 100644 --- a/bindings/react-native/THIRD-PARTY-NOTICES.md +++ b/bindings/react-native/THIRD-PARTY-NOTICES.md @@ -41,17 +41,17 @@ upstream source on each crate's page) — the exact versions are listed below. Used by: -- [offline-protocol 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-core 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-data 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-mls 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-reliability 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-router 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-sealed 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-services 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-telemetry-wire 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-transport 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) -- [offline-protocol-uniffi 0.27.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-core 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-data 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-mls 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-reliability 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-router 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-sealed 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-services 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-telemetry-wire 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-transport 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) +- [offline-protocol-uniffi 0.28.0](https://github.com/Offline-Protocol/offline-protocol-sdk) ``` GNU AFFERO GENERAL PUBLIC LICENSE diff --git a/bindings/react-native/package-lock.json b/bindings/react-native/package-lock.json index 9074251ca..862bb6935 100644 --- a/bindings/react-native/package-lock.json +++ b/bindings/react-native/package-lock.json @@ -1,12 +1,12 @@ { "name": "@offline-protocol/mesh-sdk", - "version": "0.27.0", + "version": "0.28.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@offline-protocol/mesh-sdk", - "version": "0.27.0", + "version": "0.28.0", "license": "AGPL-3.0-only", "devDependencies": { "@types/react-native": "^0.72.8", diff --git a/bindings/react-native/package.json b/bindings/react-native/package.json index d37bdfb37..bc1e9cb0b 100644 --- a/bindings/react-native/package.json +++ b/bindings/react-native/package.json @@ -1,6 +1,6 @@ { "name": "@offline-protocol/mesh-sdk", - "version": "0.27.0", + "version": "0.28.0", "description": "Offline-first mesh networking SDK with intelligent transport switching for React Native", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/crates/offline-protocol-leaf/Cargo.toml b/crates/offline-protocol-leaf/Cargo.toml index f41a21ac8..6aa2c4b59 100644 --- a/crates/offline-protocol-leaf/Cargo.toml +++ b/crates/offline-protocol-leaf/Cargo.toml @@ -54,8 +54,8 @@ bare-metal-rng = ["dep:getrandom", "getrandom/custom"] # in this crate is the counterweight: dropping inheritance means a # workspace-wide version bump would otherwise stop applying here silently. [dependencies] -offline-protocol-core = { path = "../offline-protocol-core", version = "0.27.0", default-features = false } -offline-protocol-sealed = { path = "../offline-protocol-sealed", version = "0.27.0", default-features = false } +offline-protocol-core = { path = "../offline-protocol-core", version = "0.28.0", default-features = false } +offline-protocol-sealed = { path = "../offline-protocol-sealed", version = "0.28.0", default-features = false } # Pinned with `=` deliberately. An interop result is a claim about two exact # versions (ADR 0021), `tools/mls-interop` pins the same two, and this @@ -120,7 +120,7 @@ serde_json = { version = "1.0", default-features = false, features = ["std"] } # building at all. The interop test that needs the phone gates itself off the # same targets; nothing else here reads it. [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] -offline-protocol-mls = { path = "../offline-protocol-mls", version = "0.27.0" } +offline-protocol-mls = { path = "../offline-protocol-mls", version = "0.28.0" } # The host shim for a WebAssembly runtime (`examples/wasi_host_shim.rs`). It # needs `std` for its standard streams, so `required-features` makes the diff --git a/crates/offline-protocol-sealed/Cargo.toml b/crates/offline-protocol-sealed/Cargo.toml index ffb4b3b7e..112398622 100644 --- a/crates/offline-protocol-sealed/Cargo.toml +++ b/crates/offline-protocol-sealed/Cargo.toml @@ -58,7 +58,7 @@ sha2 = { version = "0.10", default-features = false } # Local for the same reason: the workspace entry carries default features, and # a leaf links this crate without `std`. -offline-protocol-core = { path = "../offline-protocol-core", version = "0.27.0", default-features = false } +offline-protocol-core = { path = "../offline-protocol-core", version = "0.28.0", default-features = false } [dev-dependencies] diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index f122bdac0..88bde4ac0 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -1,14 +1,14 @@ # Upgrading Everything an application team has to change to move off `v0.16.x` and onto the -current `v0.27.x` line. +current `v0.28.x` line. The breaking changes all landed in the **storage-split release**, `v0.17.0` — `initialize_mls` changes shape, three config updaters become fallible, and several previously-accepted inputs are now rejected at the boundary. Sections 1–12 below cover that release and are the ones that can stop your build. -Since `v0.17.0` six releases can break a build. `v0.20.0` enables iOS +Since `v0.17.0` seven releases can break a build. `v0.20.0` enables iOS autolinking, so a manual `pod 'MeshSdk'` line left in your `Podfile` now fails `pod install` — React Native on iOS only; it is a one-line deletion, and [§12.1](#121-react-native-ios-delete-your-manual-pod-meshsdk-line-v0200) has the @@ -45,7 +45,13 @@ and it is short: the replacement is one call with two strings. `v0.27.0` breaks two things, both narrow: the `offline-protocol-transport` crate drops `WIFI_DIRECT_MAX_PAYLOAD_SIZE`, and Python's `BlePeripheral` drops its `identity_json` argument -([§25](#25-behaviour-that-changes-without-a-compile-error-v0270)). +([§25](#25-behaviour-that-changes-without-a-compile-error-v0270)). `v0.28.0` +breaks two builds, both narrow: Python's `InternetManager` requires `app_id=`, +and an exhaustive TypeScript `switch` over `SecurityWarningCode` needs a case +for the new member. It also breaks one thing at run time: iOS's peer-stream +slot moves from MultipeerConnectivity to Network framework, so an iPhone on +`v0.28` does not see one on `v0.27` or earlier over that slot +([§26](#26-behaviour-that-changes-without-a-compile-error-v0280)). Otherwise, where a later section documents an addition or a behaviour change, it is labelled inline with the release that @@ -156,6 +162,14 @@ with the relay sends one frame rather than a copy per member. carrier instead of Bluetooth LE, and a send every carrier refused as not reachable fails as that refusal. [§25](#25-behaviour-that-changes-without-a-compile-error-v0270) has the list. +`v0.28.0` changes defaults and transports more than signatures: the dedup +window grows to 5000 ids over seven days, `wifiDirect: { enabled: true }` now +starts that transport in `start()` (on iOS that is the first Local Network +prompt), the Android module declares the Wi-Fi Direct permissions and stops +asking for location on Android 13 and later, an Android app that already sets +`autoAccept: true` starts forming Wi-Fi Direct groups itself, and a key package +refused by this device's clock is reported as a security warning. +[§26](#26-behaviour-that-changes-without-a-compile-error-v0280) has the list. Work through it in order. [§0](#0-before-you-ship-downgrade-is-not-a-rollback) is a release-engineering decision, not a code change, and it is the one that @@ -2392,6 +2406,93 @@ ceiling is the 1 MiB message ceiling, and the framing constants are --- +## 26. Behaviour that changes without a compile error *(v0.28.0)* + +Everything here compiles against `v0.27.x` unchanged except the Python +`InternetManager` call and an exhaustive TypeScript `switch` over +`SecurityWarningCode`. Each paragraph says what to check. + +**iOS: the peer-stream slot runs on Network framework.** The `wifiDirect` +transport on iOS opens TCP streams over the local network or AWDL instead of +using MultipeerConnectivity. An iPhone on this release does not find one on +`v0.27` or earlier over this slot, so upgrade a fleet together or expect the +two to meet over Bluetooth LE or the relay only. In `Info.plist`, +`NSBonjourServices` needs `_offlineprotocol._tcp` (the `_udp` entry can go) +and `NSLocalNetworkUsageDescription` is still required. A denied local-network +permission is now an `error` diagnostic rather than silence. + +**`wifiDirect: { enabled: true }` starts the transport.** `start()` now enables +it, as it already did internet, Nostr and Reticulum. Before, the setting did +nothing unless the application also called `enableTransport('wifiDirect')`. On +iOS this makes the first `start()` show the Local Network prompt; on Android it +starts Wi-Fi Direct. Remove the setting if you did not mean to run the +transport, and drop any `enableTransport('wifiDirect')` you added as a +workaround (calling it again restarts the transport). + +**Android: Wi-Fi Direct permissions come from the SDK.** The module now +declares `ACCESS_WIFI_STATE`, `CHANGE_WIFI_STATE`, `INTERNET` and +`NEARBY_WIFI_DEVICES` (with `neverForLocation`), and on Android 13 and later it +no longer requires a location grant for Wi-Fi Direct. Request +`NEARBY_WIFI_DEVICES` at runtime on 13+ and `ACCESS_FINE_LOCATION` on 12 and +lower. If your manifest declares `NEARBY_WIFI_DEVICES` without +`neverForLocation`, yours wins the merge and location stays required. + +**Android can form the Wi-Fi Direct group itself.** With +`wifiDirect: { enabled: true, autoAccept: true }` on Android 10 and later, +devices of the same app find each other and join one group with no system +dialog. It is off by default; without it, a group is still formed in the +system's Wi-Fi Direct settings. An app that already sets `autoAccept: true` +(the integration guide's example did, while the option did nothing) starts +forming groups on upgrade: set it to `false` to keep the old behaviour. The +group's passphrase is derived from the app id and is not a secret; it keeps +apps apart, and the identity preamble and end-to-end encryption still protect +the traffic. `stop()` removes an app-named group this device owns. +`groupOwnerIntent` is not used and is deprecated. + +**A key package refused by this device's clock raises a security warning.** +`security_warning` with `KEY_PACKAGE_OUTSIDE_VALIDITY_WINDOW`, once per peer, +when a peer's key package is not valid at this device's time, whether it was +refused on arrival or on the first send after a restart. Phones that have +never been online often have the wrong date, and past a 30-day gap no session +forms between them. A package whose window has already closed is discarded +like any expired package; one whose window has not started yet is kept. +Show the user a prompt to check the date and time. The TypeScript +`SecurityWarningCode` union gains the member. + +**`neighbor_lost` means nothing nearby reaches the peer.** It fires once, when +the last Bluetooth LE or Wi-Fi Direct link to the peer ends. Before, one +Bluetooth departure could fire it twice, and a peer that left one carrier while +still linked over the other was reported lost. An app that removed a peer on +`neighbor_lost` now keeps a reachable one; one that counted the events now +sees one per departure. Bluetooth switched off, on this phone or by a stack +restart, ends every Bluetooth link at once, so it then fires for each peer +nothing else reaches; Android used to report none of them. + +**`transport_switched` to Wi-Fi Direct follows a peer, not the layer.** It +fires when the first Wi-Fi Direct link proves and, to `None`, when the last +one ends, rather than when the transport starts. To show what DORS routes +over, follow `dors_transport_selected` / `dors_transport_switched`. + +**Dedup keeps 5000 ids for seven days.** `maxTrackedMessages` goes from 2000 +to 5000 and `retentionTimeSecs` from 86400 to 604800. A config that sets the +old values keeps them, and with them the re-sends a shorter window lets +through. + +**iOS: an overflowing outbound fragment queue is discarded whole.** Both +outbound queues now drop the whole per-peer queue when full, as Android does, +rather than their oldest fragments, which split messages. The acknowledgement +retry and data-sync anti-entropy re-send what is lost. + +**Documents replicate only when flushed.** Unchanged behaviour, now stated: an +edit is stored and sent only when `flush()` or `flushAll()` runs. Flush on a +short throttle while the user edits, and once more when editing stops. + +**Python: `InternetManager` requires `app_id`.** It is keyword-only with no +default. `ProtocolManager` already passes it; only code that builds an +`InternetManager` directly needs `app_id=`. + +--- + ## Appendix A: limits reference | Limit | Value | Where enforced | diff --git a/docs/changelog/0.27.md b/docs/changelog/0.27.md new file mode 100644 index 000000000..a263f3263 --- /dev/null +++ b/docs/changelog/0.27.md @@ -0,0 +1,472 @@ +# Changelog: 0.27.x + +Archived release notes. The working changelog, covering the current release +and unreleased changes, is [CHANGELOG.md](../../CHANGELOG.md). + +| Release | Date | +|---------|------| +| 0.27.0 | 2026-09-25 | + +## [0.27.0] — 2026-09-25 + +> **Replicated documents can be removed, narrowed, and carry their bytes +> inside a group.** `removeDoc` and `removeSpace` remove a document from every +> replica instead of having the next exchange refill it, `setInterest` lets a +> device replicate part of a space, and `fetchAttachmentFrom` fetches the bytes +> behind an attachment reference from one group member under the group key. +> Each is negotiated as an appended `data_versions` entry, so a peer on an +> older build keeps working and refuses what it does not understand. +> [§22](../UPGRADING.md#22-documents-can-be-removed-from-every-replica-v0270) +> through [§24](../UPGRADING.md#24-attachment-bytes-inside-a-group-v0270) +> cover them. +> +> **The peer-stream transport is registered, and a Python host can use it.** +> The `wifi_direct` slot now has a transport behind it, specified in +> `docs/spec/stream-framing.md`, and Python ships a manager that carries it +> over a LAN or a routed mesh. The bundled iOS and Android managers do not +> speak the framing yet, so on a phone the slot announces no peer, refuses +> every recipient, and nothing changes. +> +> **Relay group traffic reaches phones again.** On iOS and Android with +> encryption on, the relay's answers about groups were addressed to the +> profile name and forwarded away unprocessed. They now arrive, so a group +> registered with the relay sends one frame instead of a copy per member, +> group messages other members broadcast through the relay are received, and +> the group events built on those answers fire for the first time. +> [§25](../UPGRADING.md#25-behaviour-that-changes-without-a-compile-error-v0270) +> lists this and the other changes that compile fine and still behave +> differently. +> +> **Breaking, narrowly:** the `offline-protocol-transport` crate drops +> `WIFI_DIRECT_MAX_PAYLOAD_SIZE`, and Python's `BlePeripheral` drops its +> `identity_json` argument. No UDL, Swift, Kotlin or TypeScript signature is +> removed. + +### Added + +- **The identity assertion has one implementation.** The + `public_key(32) || signature(64) || signed_data` value a Bluetooth LE + peripheral serves to prove its address was split three times, once per + bridge, and the Python copy was a JSON document no phone could verify. The + codec now lives in `offline-protocol-sealed`, pinned by a generated vector + file whose signatures are RFC 8032's own, and the one verifier is the + namespace-level `verify_identity_assertion(assertion)`: it parses, verifies + the signature under the key, derives the address, and returns it, or throws. + A peripheral builds its value with `identity_assertion(signedData)` on the + instance, so no bridge assembles the layout. The Swift and Kotlin decoders + still split the bytes (they already called the core to verify and derive) + and are now pinned to the sealed offsets by a source guard. This is the + first piece of the peer-stream transport, which sends the same assertion as + its first frame. + +- **The peer-stream transport is registered.** 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. It is now registered + whenever `wifi_direct_enabled` is set, rebuilt against the derived address + like the others, and its transport callback is retained across that rebuild + as the BLE one is. `docs/spec/stream-framing.md` is the contract, and the + transport crate gains its Rust reference: `PeerStreamReader` reads a + stream's bytes into events (the announced address, each message body, the + close and why) for a host that owns its own sockets, and + `stream_framing_vectors.rs` is the consumer that checks it against the + chapter's vectors with the one real verifier behind it. + + Registering the slot changed two behaviours, deliberately. The slot counts + as an available carrier only while a stream has proved a peer: with the + platform's stream layer up and nothing proved it reports `connecting`, so + the selector does not score it, a media transfer is not pinned to it, and + the welcome lifecycle does not count it as a carrier. And the transport + queues a message only toward an address a stream has proved, refusing any + other recipient as not reachable on this carrier, which the selector treats + as "try the next one". The bundled mobile managers report the layer up when + they start and exchange no preamble yet, so they announce no peer. Without + the narrower status, a file sent to a Bluetooth LE neighbour after the + internet dropped was pinned to the slot, every chunk was refused, and the + refused chunks reached the neighbour through the mesh with no window; + without the refusal, the slot would win the selection on bandwidth and hold + direct messages in a queue those managers cannot deliver from. With both, a + phone with the slot enabled behaves as before this entry, and only a host + that runs the framing carries traffic on it. A media transfer is pinned to + the slot only when a stream has proved the recipient itself. The five React Native + operations are no longer marked deprecated; their contract is the chapter's, + and only a verifier's result may be announced. + +- **A Python host can reach another over a LAN or a routed mesh.** + `PeerStreamManager` is the peer-stream transport for a host that owns its + sockets: a TCP listener, outbound connections to configured `host:port` + peers (or `off1…@host:port`, which makes the derived address have to match + the claim exactly) and to hosts found over DNS-SD, and the preamble exchange + the chapter specifies. Each side sends its identity assertion as the first + frame without waiting for the other's, verifies what it receives through + `verify_identity_assertion`, and announces the peer under the derived + address only; a stream that fails any step is closed and was never + announced. `ProtocolManager` builds it when `wifi_direct_enabled` is set + and stops it with the rest; the application starts it after + `ProtocolManager.start()`, since a host with no address to prove is refused + by every peer. DNS-SD advertises `_offlineprotocol._tcp` with `txtvers=1` + and `addr=` through the optional `lan` extra + (`pip install 'offline-protocol-sdk[lan]'`), and the `addr` in a record is + a hint the preamble proves, never a name to announce. Two hosts that list + each other keep one stream, the one the lower address opened, so a + simultaneous open cannot loop; the lower address reconnecting while its + old stream is still half-open supersedes it. The higher address cannot, so + every stream carries TCP keepalive and an idle dead peer's stream ends + within about half a minute. Each stream writes from its own queue, + bounded at 4 MiB while a frame is in flight (a body over it goes back to + the retry path), and a peer that stops taking bytes once the buffers + between the two hosts are full has its stream aborted and reported lost + after thirty seconds without progress (`write_timeout`), without holding + any other peer's traffic; keepalive alone would not end it, since a + stream with data in flight is not idle. Until those buffers fill, on the + order of a megabyte on a default host, a peer that is alive but not + reading stays announced, and the core's acknowledgements are what notice + it. A slow link that is moving is never cut. The preamble deadline covers + the whole first frame, + one remote host holds at most eight inbound streams, and a peer that never + proves an address is retried on a doubling ladder. The listener binds + every interface by default; pass `listen_host` to narrow it. + +- **Attachment bytes move inside a group.** `DataStore.fetchAttachmentFrom(space, peer, hash)` + asks one member for the bytes behind a reference, and they come back as + frames sealed under the group key. Before this, references replicated to + every member and the bytes reached nobody: fetching them needed a confirmed + pairwise session, and two members of a group need not have one with each + other. + + The fetch names the member for two reasons. A reference says what the bytes + are and nothing about who holds them, so somebody has to choose; and a + directed frame is promoted to a roster-wide delivery when the sender's + ratchet budget runs out, which means a request arrives at every member + whether or not it was addressed to them. Without the name in the frame, + every member's application would be asked for bytes the requester can + accept from only one of them. The SDK does not try members in turn, because + each miss costs the whole silence timeout. + + **A group attachment is at most 1 MiB**, 32 frames of 32 KiB. That bound is + what keeps the request a single question with a single answer: the whole + answer leaves at once, so nothing has to ask for the next window and there + is no chain of questions to terminate. `provideAttachment` refuses a larger + blob at the call, while the application still has the file in hand, and + says to use a 1:1 session instead. Nothing about the 1:1 path changed; it + still carries up to the transfer layer's own limit. + + A chunk is admitted only against an outstanding request and only from the + member that request was put to, and the assembled bytes are still checked + against the hash that asked for them. The first two bound what a member can + spend of another's memory and stop one member answering or refusing a + question put to somebody else; only the hash says what the bytes are. + + Negotiated as `data_versions` entry 6, appended, and the first entry since + the second to have an attested sibling: a group inviter now attests every + group-relevant entry it knows about a member, rather than only entry 2. A + member without entry 6 refuses the request it understands and drops the + chunk frames it does not, so nothing is shown to anybody wrongly; what the + entry buys is refusing a fetch at the call instead of waiting out the + timeout. + + A fetch is refused at the call for a member this device does not know + receives replication frames inside a group, because one that does not + would be shown the question as text, and for this device itself. Asking a + second member is a fallback rather than a repeat: the question put to the + first is reported as `evicted`, naming that member, and the new one goes + out at once. Every report of a fetch that ended without bytes now names + the member it was put to rather than the space. + +- **A space can replicate in part.** `DataStore.setInterest(space, patterns)` + names the documents this device wants from its peers: document names, each + optionally ending in `*` to match a prefix, at most 32 per space. `["*"]` + is everything and is the default, so a space nobody narrows behaves exactly + as before and its frames are byte-identical to the ones it sent last + release. + + It does two things that fail differently, and the difference is the whole + design. Toward a peer it is a **request**: every version offer carries it, + so the peer answers only with what was asked for. That is the bandwidth + saving, and it needs the peer to read the field. Locally it is a + **refusal**: a document outside the patterns is never created from an offer + and never imported however it arrives. That half depends on nothing, which + is why a narrowed space is narrow even against a peer that ignores the + request, including every build that predates it. + + Three rules keep it from losing documents. The answer is scoped by the + asker's declared interest and never by the names their frame happened to + carry, because the second drops a document they have never seen with no + symptom on either device. A counter-offer still carries the complete list: + the saving there is one name per unwanted document, which is not worth + narrowing a reply sweep for. And removals are never scoped, because a peer + that narrowed still holds what it held before, so a removal for a document + it no longer wants is exactly the one it still needs. + + Interest is not persisted: it is application policy for this launch rather + than a fact about the store, so declare it before `start()`. `wipeAll()` + clears it along with the documents it scoped, because nothing may + distinguish a wiped space from one this device has never seen. Narrowing + does not delete what is already held, because a policy change should not + destroy data nobody asked to lose; `deleteDoc` is how a document leaves. + Widening asks, since the newly wanted documents are absent from this + device's next offer and inside its declared interest. + + Negotiated as `data_versions` entry 5, appended. On the wire an absent + `want` means everything and an empty one means nothing; the two are + deliberately not collapsed, because collapsing them would turn the + narrowest request into the widest. + +- **Replicated documents can be removed from every replica.** + `DataStore.removeDoc(space, doc)` records the version the document stood at + and tells the space; a replica whose copy that version covers deletes it too + and reports the new `data_doc_removed` event with `by: "peer"`. + `removeSpace(space)` does the same for every document a space holds. A + removed name refuses reads and writes with `InvalidState` until `createDoc` + brings it back. Before this, a document deleted on one device was absent + from its next offer and still present on the peer's, and the rules that + exist to carry a document a peer has never seen recreated and refilled it: + a delete was undone by the next exchange, with no error and no event. + + The removal is a version rather than a flag, which is what lets every + replica reach the same answer without knowing what happened first. Content + at or below it is what the removal decided about; anything beyond it is an + edit made concurrently with the removal, and **an edit wins**. It brings the + whole document back, not the edit alone, because the edit was made on the + old contents and its history is those contents. That arrives as an ordinary + `data_changed` on a document the application removed. A product that needs a + removal which cannot be undone that way should model it as a field it + writes rather than as a document that disappears. For the same reason a + removed name is not fenced off when it is re-used: a replica that kept the + old contents past the removal merges them into the new document, and a + replica on an older build merges the new contents into its old copy. Give + new content a fresh name. + + A removal never expires, so a device away for a year still learns about it, + and a removed name keeps a small record for the life of the space. Those + records count against the existing 1024-document ceiling a peer can fill, + because a floor outlives its document and counting only what is held would + let a peer name a fresh thousand after every removal. The record is written + before the records it removes: the reverse order leaves, after a crash in + between, records whose presence votes the document back into existence at + the next open. + + `deleteDoc` is unchanged and is eviction: it drops this device's copy and + records nothing, so a replica that still holds the document refills it. + `wipeAll()` is unchanged too and records no removals, because a logout has + to leave a custom backend empty. The four documents that described the old + behaviour say so. + + Negotiated as `data_versions` entry 4, appended. A peer without it ignores + the new field, keeps its copy, and offers it back on every exchange; the + removing side refuses each offer and each blob against its own record, so + the removal holds there regardless. Nothing loops and nothing is surfaced + wrongly. New storage category `data_doc_meta`, sealed like every other, and + no new error code. `listSpaces()` includes a space whose documents were all + removed, because it still has removals to report. + +### Changed + +- **`WIFI_DIRECT_MAX_PAYLOAD_SIZE` is gone from the transport crate.** It + stated a 64 KiB ceiling nothing read, and the peer-stream chapter's ceiling + is the 1 MiB message ceiling; the framing constants that replace it are + `PEER_STREAM_MAX_FRAME_BYTES`, `PEER_STREAM_LENGTH_PREFIX_LEN` and + `PEER_STREAM_PREAMBLE_FLOOR`. + +- **The telemetry pipe caps `protocol.message.failed` rows per reason.** Each + reason sends up to 500 rows at once, enough for a full outbox failing in one + sweep, and then one row every ten seconds. Every failure is still counted + into the minute rollup's `sends_failed_sum`, so the totals are exact; only + the per-row `reason` and `retry_count` are thinned, and a held-back row is + counted in `telemetryStats().dropped`, so sent plus dropped still accounts + for every event. A row cannot carry a message id, so the ingest has no way + to tell one failure reported many times from many failures and meters every + row. Without the cap, a device reporting the same failure in a loop is billed + for the loop, which is what an outbox bug fixed in this release did at about + fifteen rows a second per device. The check runs on the borrowed event before + anything is copied, so a held-back row allocates nothing. There is no + setting. +- **A frame the relay's mailbox holds is parked without a reachability probe.** + A relay advertising `mailbox_v1` keeps a copy of a frame it could not deliver + and re-sends it on the recipient's next connection, and says so with + `stored: true` beside the `DeliveryError` or the pushed `MessageSent`. The + React Native bridges and the Python relay client now read that flag and + report those two answers to the core as the new `relay_stored` and + `relay_pushed_stored` tokens. The park they produce is the existing one in + every respect that matters - the pending acknowledgement is dropped so no + budget burns against an offline peer, the outbox entry stays, the frame is + still offered to the mesh, and the recipient is still presence-watched - and + drops only the escalating 15s→600s probe. The probe existed because nothing + else would re-deliver the frame; now something does, and each rung would only + rewrite the row the relay already holds, per parked message, for as long as + the peer stays away. + + Recovery is the relay's own redelivery, and the reachability edge behind it. A + returning peer already re-drives every parked frame the outbox holds, so a + held copy the mailbox evicted or expired is covered by the same edge that has + always covered an unheld one - which means what the token saves is the probe + rungs while the peer is away, not the write on its return. Past the mailbox's + own retention the guarantee is that edge and the outbox lifetime: the trade + `edge_driven_unreachable_dm` opts into for every parked frame, taken here for + held frames alone, where something else is already committed to delivering + them. + + Everything that is not a plain direct message is deliberately untouched: both + tokens normalize back onto the existing vocabulary before any typed branch + reads them, so a connection request still fast-fails with + `ConnectionRequestUndeliverable` and a Welcome still parks on its own + lifecycle. `MessageUndeliverable` is still emitted on the `DeliveryError` + path, because it is documented as a repeatable status signal and "the + recipient is offline right now" is true whether or not a copy was kept. A + missing `stored` key - an older relay, a relay whose mailbox is off, a store + that failed - reads as "not held" and keeps the probe. + + `stored` is a statement about one message, not about the recipient. The + `DeliveryError` path fails every frame in flight to that recipient, so only + the id the relay echoed takes the new token and the rest keep the + `recipient_unreachable` prefix they have always had. Each held frame takes it + from its own verdict: the relay answers every submission separately, and the + first answer for an offline recipient resolves that recipient's whole + in-flight set, so a client reports the id its verdict named even when that set + is already drained. Without that, only the first frame of a burst would park + probe-free and the rest would each pay one probe before the relay said + `stored` again. A Welcome reported that way has usually been failed already + by the drain, and is not failed a second time: one send climbs one rung of its + retry ladder, and the app hears `welcome_send_failed` once. + +### Fixed + +- **The mobile bridges map every transport name.** `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 forcing + `"nostr"` or `"reticulum"` from JavaScript forced the radio, and a metrics + read for either answered for it. Each bridge now has one mapper that names + all five transports and refuses an unknown name, both entry points go + through it, and a Rust guard reads both sources to keep it so. + +- **A send that every carrier refused as not reachable says so.** When the + selected carrier refused the recipient and there was nothing left to fall + back to, the send ended as `SendFailed("All transports failed")`, and the + `message_deferred` reason read `transport_send_failed`, as if a carrier had + broken mid-send. It now ends as the refusal, and the reason is + `peer_not_reachable`. The welcome lifecycle classifies both the same way, + so only the deferral reason changes. + +- **A Python Bluetooth LE peripheral can be verified by a phone.** It served + the profile label as its Device id and a JSON document as its Identity, so + every conforming central refused it before any cryptography ran; no phone + has ever paired with a Python node over Bluetooth LE. The peripheral now + serves its `off1...` address and the core-built identity assertion, and + `start()` refuses to advertise until MLS has minted an address rather than + advertise something unverifiable. The `identity_json` constructor argument + is gone. Any Python peripheral already deployed changes behaviour, but no + working pairing regresses, because none could have worked. + +- **The Python Bluetooth LE central verifies a peer before announcing it.** It + announced whatever string the Device id characteristic carried, and before + connecting it announced the Bluetooth address itself, so the core's routing + table held labels nothing had proved. It now reads the Identity + characteristic, hands it to `verify_identity_assertion`, compares the result + to the Device id exactly, and announces the derived address or drops the + link unannounced. A link that never proved a peer is no longer reported lost + either, since nothing was ever announced for it. + +- **Relay group notifications reach an iOS or Android member.** The React + Native bridges rebuild some relay traffic into a full `Message` before + handing it to the core: the relay's answers about groups (`GroupCreated`, a + relay-broadcast `GroupMessageReceived`, member added and removed, group + errors, group info and the user's group list) and plain-text messages from + pre-SDK relay senders. They addressed every one of those frames to the + profile name. Since the release that derived the wire identity from the + identity key, a device's id is its address once `initialize_mls` has run, + and the core forwards rather than processes a frame addressed to anyone + else. So on every phone with encryption on, all of them were handed to the + mesh forwarder and lost silently. + + The loss was hidden rather than harmless. `GroupCreated` is the relay's + registration acknowledgement and the only thing that lets a group use the + relay's one-frame broadcast, so phones never took that path and fell back to + sending one copy per member, which still arrived. A group message another + member broadcast through the relay never arrived on a phone, and a relay + group error never revoked the registration. A phone with encryption off was + unaffected. + + The frame builder on both platforms now takes the device's address and its + profile and addresses the frame to the address, falling back to the profile + only before initialization, which keeps those frames byte-identical. No wire + format changed. +- **A group message broadcast through the relay reaches a Python member.** A + phone whose group is registered with the relay sends one frame, the relay + fans it out to every member as `GroupMessageReceived`, and counts the socket + write as delivered, so the sender never re-sends a per-member copy. The + Python bridge re-injected that frame under a prefix the core does not + recognize (`__GRP_MSG__`), addressed to the profile name rather than the + device's address, and without the `id` and `timestamp` the deserializer + requires. The core treated it as plaintext for someone else and the message + was lost silently. The bridge now injects it as the iOS and Android bridges + do: `__GROUP_MSG__`, addressed to `local_address()`, in the full `Message` + shape. `GroupCreated`, `GroupMemberAdded`, `GroupMemberRemoved` and + `GroupError` are injected unattributed under the core's relay-answer + prefixes, and `GroupMessageSent` goes to `internet_group_report_received` + instead of the message plane. The same frame builder now backs the + plain-text `MessageReceived` fallback, which was dropped for the same + reason. +- **A message evicted from a full outbox stays failed.** Eviction removed the + outbox entry and reported `message_failed` (`"Outbox capacity exceeded"`), + but left the message queued for retry. The next retry, or the next reconnect + flush, re-created its outbox entry with fresh timestamps and evicted a + different message to make room, whose own retry did the same. A device + holding more than 500 undeliverable messages therefore reported a terminal + `message_failed` on every retry, indefinitely, for ids it had already + reported. Nothing ever ended the cycle, because the outbox is the only thing + that expires a message and each resurrection reset its clocks. With the + telemetry pipe on, every one of those reports was an uploaded, metered + event. The media outbox leaked retry entries the same way, silently. + + Every path that gives a message up (capacity eviction, lifetime expiry, retry + exhaustion, an acknowledgement timeout that finds no outbox entry) now takes + it out of the retry queue, the acknowledgement tracker and the outbox + together. An application that counted `message_failed` events saw that count + inflated and will see it drop to one per failed message. +- **A message evicted while awaiting its acknowledgement is settled once.** Its + acknowledgement stayed tracked, so the timeout reported it failed a second + time (`"Message missing from outbox (cannot retry)"`), and an acknowledgement + arriving first reported it delivered after it had been reported failed. +- **Bluetooth pairing works on a phone running several apps built on this + SDK.** Every such app serves the same BLE service, so a phone running two of + them presents two instances of it behind one connection. The React Native + bridges assumed one. On iOS the handshake read both instances into one slot + and joined whichever reads landed first, so the peer was refused or announced + under the other app's identity at random; on Android it always bound to the + first app to register. Two copies of one app could stop finding each other + as soon as a second SDK app was installed on either phone. + + Each app now serves an eight-byte tag derived from its `appId` in a new, + optional GATT characteristic (`6E400005-…`), and a central that finds several + instances reads their tags and binds to its own app's, then handshakes, + subscribes and writes on that instance only. A phone running only other SDK + apps is still bound (to the first instance, as before) and still relays. + A peer running one SDK app is handled exactly as before and its tag is never + read. No API or configuration changes; the tag comes from the `appId` the app + already passes. An app built on an earlier SDK serves no tag, and one such + build among tagged apps is still found; it cannot find its own counterpart on + a multi-app phone until it upgrades. See + [BLE framing](../spec/ble-framing.md#several-instances-behind-one-link). + +### Documentation + +- **The peer-stream framing chapter.** `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 one peer, whether a Wi-Fi + Direct group socket, a Multipeer session, or a TCP connection over a LAN or + a routed mesh. The first frame in each direction is the identity assertion, + every frame is a `u32` big-endian length plus body under the 1 MiB message + ceiling, and a peer is announced only under the address its preamble + proved. It also names the DNS-SD service type and TXT record a LAN + advertiser uses, with the address there a hint the preamble proves. The + vectors are generated: a preamble, a framed message, the ordered exchange, + a prefix of exactly the ceiling that must be accepted, and four refusals. + The chapter also records what the bundled managers still owe: iOS sends + bare messages over its Multipeer session and advertises another service + type, and the Android reader refuses a body of exactly 1 MiB and keeps + reading after a refused length. A receiver holds one announced stream per + address, so a second stream proving the same address never makes the first + one's close read as the peer's loss. The threat model gains R16, the + replayable identity assertion on every carrier. Nothing ships in this entry but the + contract; the registration and the managers follow it. diff --git a/docs/changelog/README.md b/docs/changelog/README.md index 13fc54e4f..76e9ba313 100644 --- a/docs/changelog/README.md +++ b/docs/changelog/README.md @@ -6,6 +6,7 @@ release, is [CHANGELOG.md](../../CHANGELOG.md). | Series | Releases | First | Last | |--------|----------|-------|------| +| [0.27.x](0.27.md) | 0.27.0 | 2026-09-25 | 2026-09-25 | | [0.26.x](0.26.md) | 0.26.0 | 2026-09-12 | 2026-09-12 | | [0.25.x](0.25.md) | 0.25.0 | 2026-09-08 | 2026-09-08 | | [0.24.x](0.24.md) | 0.24.1, 0.24.0 | 2026-08-24 | 2026-08-26 | diff --git a/tools/embedded-footprint/Cargo.lock b/tools/embedded-footprint/Cargo.lock index bf77ee706..db06f2204 100644 --- a/tools/embedded-footprint/Cargo.lock +++ b/tools/embedded-footprint/Cargo.lock @@ -746,7 +746,7 @@ dependencies = [ [[package]] name = "offline-protocol-core" -version = "0.27.0" +version = "0.28.0" dependencies = [ "base64", "bech32", @@ -760,7 +760,7 @@ dependencies = [ [[package]] name = "offline-protocol-leaf" -version = "0.27.0" +version = "0.28.0" dependencies = [ "base64", "mls-rs", @@ -777,7 +777,7 @@ dependencies = [ [[package]] name = "offline-protocol-sealed" -version = "0.27.0" +version = "0.28.0" dependencies = [ "base64", "offline-protocol-core", diff --git a/tools/mls-interop/Cargo.lock b/tools/mls-interop/Cargo.lock index 32bfc07f1..46b416259 100644 --- a/tools/mls-interop/Cargo.lock +++ b/tools/mls-interop/Cargo.lock @@ -1185,7 +1185,7 @@ dependencies = [ [[package]] name = "offline-protocol-core" -version = "0.27.0" +version = "0.28.0" dependencies = [ "base64", "bech32", @@ -1200,7 +1200,7 @@ dependencies = [ [[package]] name = "offline-protocol-sealed" -version = "0.27.0" +version = "0.28.0" dependencies = [ "base64", "offline-protocol-core",