From 9ce087fd90d41ac271912b02858f9bd259eb59b3 Mon Sep 17 00:00:00 2001 From: kivtxs <131401183+kivtxs@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:39:48 -0400 Subject: [PATCH 1/3] chore(release): 0.28.0 --- CHANGELOG.md | 467 +----------------- Cargo.lock | 26 +- Cargo.toml | 24 +- SECURITY.md | 4 +- THIRD-PARTY-NOTICES.md | 22 +- bindings/python/THIRD-PARTY-NOTICES.md | 22 +- bindings/python/pyproject.toml | 2 +- bindings/react-native/THIRD-PARTY-NOTICES.md | 22 +- bindings/react-native/package-lock.json | 4 +- bindings/react-native/package.json | 2 +- crates/offline-protocol-leaf/Cargo.toml | 6 +- crates/offline-protocol-sealed/Cargo.toml | 2 +- docs/UPGRADING.md | 96 +++- docs/changelog/0.27.md | 472 +++++++++++++++++++ docs/changelog/README.md | 1 + tools/embedded-footprint/Cargo.lock | 6 +- tools/mls-interop/Cargo.lock | 4 +- 17 files changed, 641 insertions(+), 541 deletions(-) create mode 100644 docs/changelog/0.27.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f4531122e..937ecd23b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ 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 ### Added @@ -886,470 +886,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 +893,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..a0088cd71 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,12 @@ 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 things, both narrow: 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, and Python's `InternetManager` +requires `app_id=` +([§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 +161,13 @@ 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, 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 +2404,84 @@ 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. `groupOwnerIntent` is not used. + +**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. Phones that have +never been online often have the wrong date, and past a 30-day gap no session +forms between them. 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", From 61998c446601098087e48b50f3001fe6cea88783 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Tue, 6 Oct 2026 15:47:10 +0530 Subject: [PATCH 2/3] docs(release): describe the merged autoAccept and key package behaviour in the 0.28 upgrade notes #507 and #506 changed after this branch stacked them: an app already setting autoAccept: true starts forming Wi-Fi Direct groups on upgrade, the group passphrase is not a secret, groupOwnerIntent is deprecated, and a key package whose window has closed is discarded rather than kept. The changelog carried each; section 26 and its summary line did not. --- docs/UPGRADING.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index a0088cd71..3468ca58f 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -165,8 +165,9 @@ reachable fails as that refusal. 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, and a key package refused by -this device's clock is reported as a security warning. +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) @@ -2439,13 +2440,21 @@ lower. If your manifest declares `NEARBY_WIFI_DEVICES` without `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. `groupOwnerIntent` is not used. +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. Phones that have +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. Show the user a prompt to check the date and time. The +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 From a212cd40d8105d08542e2b8382efa9ea15bd0b55 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Tue, 6 Oct 2026 16:03:23 +0530 Subject: [PATCH 3/3] docs(release): add the 0.28.0 summary and name the right build breaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every release since 0.21.0 opens with a summary blockquote, and it is the only part of the changelog most app teams read. The 0.28.0 section went straight from the heading into eight hundred lines of entries. The GitHub release body is generated from commits, so the summary exists nowhere else. Add it. While at it, fix the upgrade guide's "can break a build" sentence. It listed the iOS move to Network framework as a build break, which it is not: an unchanged app compiles fine and just stops seeing 0.27 iPhones over that slot. It also left out the exhaustive TypeScript switch over SecurityWarningCode, which §26 itself names as a compile break. The two paragraphs now agree. And rewrap one 130-column line in §26. --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ docs/UPGRADING.md | 14 ++++++++------ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 937ecd23b..af7944b88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,39 @@ archived by series under [docs/changelog/](docs/changelog/); see the ## [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 - **Android forms its Wi-Fi Direct group itself.** With diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 3468ca58f..88bde4ac0 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -46,10 +46,11 @@ 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)). `v0.28.0` -breaks two things, both narrow: 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, and Python's `InternetManager` -requires `app_id=` +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 @@ -2454,8 +2455,9 @@ 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. +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