diff --git a/CHANGELOG.md b/CHANGELOG.md index 38b82ba8..fff58f15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,14 @@ All notable changes to the Offline Protocol SDK are documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). This changelog covers everything after the **v0.7.1** release. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to +[Semantic Versioning](https://semver.org/spec/v2.0.0.html). This changelog covers +everything after the **v0.7.1** release. + +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] @@ -49,6 +56,42 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Changed +- **Design documentation restructured; `CLAUDE.md` reduced from 61 KB to 7.6 KB.** + The durable knowledge that had accumulated in one working document now lives in + reviewable, versioned documents under `docs/`, and nothing was dropped except + the per-feature test-name lists (the docs name the behaviour that is pinned; + grep finds the tests). New material: + [`docs/spec/`](docs/spec/README.md) is an implementation-independent protocol + specification (identity and addressing, the message model with both encodings + and the frozen binary layout, the reserved prefix registry with the signing + gate and its two exemption classes, the three encryption envelopes, the group + protocol, capability negotiation); + [`docs/security/threat-model.md`](docs/security/threat-model.md) states the + adversary classes, trust boundaries and residual risks plainly; + [`docs/state-machines/`](docs/state-machines/README.md) documents delivery and + acknowledgement, outbox and retries, session lifecycle, group message + lifecycle and transport lifecycle with diagrams; + [`docs/adr/`](docs/adr/README.md) records fifteen decisions that are expensive + to reverse or easy to undo by accident; and + [`docs/bridges/`](docs/bridges/README.md) writes down the Rust-to-Swift, + Kotlin, Python and TypeScript contract, every rule of which fails *silently* + when violated. `CLAUDE.md` is now repository instructions plus a pointer + table. Five Rust doc comments that said "see CLAUDE.md" now name the specific + document, and two more are corrected rather than redirected: `Address`'s + ordering note told readers to compare `Address` values at all four protocol + tiebreakers, which is true of one of them and is the exact change + [ADR 0003](docs/adr/0003-self-certifying-addresses.md) warns breaks + convergence; a DORS comment named a default switch hysteresis of 10 when it is + 15.0. No behavioural change. + +- **`CHANGELOG.md` is archived by release series.** The working file was 2,425 + lines and growing without bound; it now holds unreleased changes plus the + current release, and older releases live in + [`docs/changelog/`](docs/changelog/README.md), one file per minor series with + its own release table. Content is unchanged. **Release-cut procedure gains a + step:** after cutting, move the now-previous release's section into + `docs/changelog/..md` and update both archive tables. + - **BREAKING: `relay_promoted` / `relay_demoted` / `isRelay()` report observed forwarding, not predicted capability.** They previously fired from a threshold on connection count and battery, which meant a device surrounded by @@ -1004,1422 +1047,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), *without* `--document-private-items`, which would have suppressed four of the six rather than surfaced them. -## [0.20.1] — 2026-08-07 - -> **Nothing breaks a build, and one event changes meaning.** A receiver no -> longer acknowledges a frame it failed to decrypt or failed to parse — it -> withholds the ACK so the sender's resend can deliver, where before the sender -> was told "delivered" for a message that was dropped. The visible consequence -> is that **`messageDecryptionFailed` is now advisory and fires once per failed -> *attempt*, not once per message.** If your UI settles a message on it — marks -> it lost, removes it from a list — switch to `messageFailed`, or -> `fileReceiveFailed` for media, which are the terminal signals. Everything here -> stays under the existing `encryption.cryptoRecoveryEnabled` kill switch -> (default on); `false` restores the previous drop-and-ACK. -> -> A media chunk that fails its identity binding is now answered with silence -> like the text path, which is deliberately *not* under that switch — it governs -> what the receiver reveals to whoever injected the frame, not whether anything -> can be recovered. One side effect: a repeated injection of the identical frame -> re-emits `MEDIA_SENDER_GROUP_MISMATCH` on each attempt rather than being -> suppressed by dedup after the first. The rate is the signal. -> -> GitHub releases now attach the compiled binaries — six archives plus -> `SHA256SUMS.txt`, each carrying its own licence and export notices, and all of -> them attested. npm is unchanged and remains the supported route for React -> Native. - -### Changed - -- **A GitHub release now carries the compiled binaries as assets.** Every native artifact the release workflow builds was uploaded with `retention-days: 1` and then deleted; the release itself attached only `THIRD-PARTY-NOTICES.md` and `EXPORT.md`, so the binaries survived in exactly one place — the npm tarball. That serves React Native and nothing else. An iOS project consuming the XCFramework directly, a plain Gradle Android build, or anything on desktop had no download to point at, and a build more than a day old could not be re-fetched at all, including for forensics on a release that had already shipped. - - Six archives are attached from now on: `offline-protocol--ios-xcframework.zip` (the XCFramework **plus** the generated FFI header, modulemap and Swift bindings, without which it does not build), `-android.zip` (all four ABIs plus the Kotlin bindings that call them), and one per desktop target — `-macos-arm64.tar.gz`, `-linux-x86_64.tar.gz`, `-linux-aarch64.tar.gz`, `-windows-x86_64.zip` — with a `SHA256SUMS.txt` over the set. **Each archive carries `LICENSE`, `LICENSE-COMMERCIAL.md`, `THIRD-PARTY-NOTICES.md` and `EXPORT.md` inside it**, because a binary pulled straight off the release page is an AGPL distribution in its own right, and the [15 CFR §742.15(b)](https://www.ecfr.gov/current/title-15/subtitle-B/chapter-VII/subchapter-C/part-742/section-742.15) notice attaches to the cryptography rather than to the npm package that usually delivers it. - - **The rule the iOS archive follows applies to desktop too: a bare library is not a distribution.** Each desktop archive carries the generated Python bindings (`offline_protocol.py`) that call into the library, the C scaffolding header (`offline_protocolFFI.h`) for consumers linking directly, and the library under *both* its build name and the fixed name UniFFI's generated loader resolves — a symlink on macOS and Linux, a real copy on Windows, where extractors will not create one. Without that pair the download is unusable: `_uniffi_load_indirect()` looks for `libuniffi.so` / `libuniffi.dylib` / `uniffi.dll`, never the cargo output name. Every archive also carries a `VERSION` stamp (version plus commit) and, on desktop, a `README.md` naming the layout and the image the library was built on — the version otherwise lived only in the archive filename, which is the wrong place for it once someone has extracted the tree. - - **The assets are attested.** `SHA256SUMS.txt` alone defends against transfer corruption, not tampering — it sits on the same release page as the binaries it covers, so whoever can replace an asset can replace the manifest in the same operation. Releases now also publish a [build provenance attestation](https://docs.github.com/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds) signed by the workflow identity, verifiable with `gh attestation verify --repo Offline-Protocol/offline-protocol-sdk`. The npm tarball gains the equivalent via `npm publish --provenance`. - - The desktop libraries and the Python bindings are now verified alongside the iOS and Android ones *before* anything publishes, so a missing slice fails the job rather than surfacing between the GitHub release and the npm publish. Packaging itself runs on dry runs too, so a break in it is found before a tag is cut rather than during one. npm is unchanged and remains the supported route for React Native. - - The packaging logic lives in `scripts/package-release-assets.sh` rather than inline in the workflow, because `release.yml` runs only on a `v*` tag — inline, its first real execution would be during a release. `scripts/tests/test-package-release-assets.sh` drives it against a fixture tree on every PR, asserting the asset set, each archive's internal layout, the manifest, and the failure paths: every missing input must fail with **no** assets written, since `files: release-assets/*` would publish a partial set verbatim. - - **Platform coverage.** macOS is arm64 only — Intel Macs have no asset ([#316](https://github.com/Offline-Protocol/offline-protocol-sdk/issues/316)). The Linux libraries are built on `ubuntu-latest` and carry that image's glibc floor, which each archive's `README.md` states. - - **Python wheels are deliberately not attached.** `python-package` builds four of them, but its `matrix.plat_name` is referenced nowhere and the step captioned "Re-tag the wheel with the correct platform" only prints filenames — and with no ext modules setuptools emits `offline_protocol_sdk--py3-none-any.whl` for every platform. All four therefore share one filename while carrying different native libraries: attaching them would collide on upload, and any that landed would be a wheel `pip` installs happily on the wrong platform. Correcting the tagging is tracked in [#315](https://github.com/Offline-Protocol/offline-protocol-sdk/issues/315). - -### Fixed - -- **An inbound message that fails to decrypt is no longer acknowledged as delivered.** The receiver dropped it and sent a delivery ACK anyway, so the sender marked it delivered and stopped retrying: silent loss behind an ACK that claimed the opposite. This was already fixed for one failure class — an epoch desync — in the crypto-failure recovery work; every *other* decrypt failure (an AEAD/authentication failure, a discarded past ratchet generation, a malformed frame) kept the old drop-and-ACK, on the three ACK sites that class reaches: an inbound DM, an inbound media chunk, and a queued message that fails when the pending-decryption queue drains. - - The boundary was drawn one class too narrow. The same release added the sender-side re-seal that makes exactly these failures recoverable — every resend of an encrypted DM is re-sealed against the peer's current session, so it carries a live ratchet generation rather than replaying bytes that already failed. Withholding the ACK is what lets that resend happen. A message that genuinely cannot be delivered now settles as a `MessageFailed` once the sender's retry budget lapses, instead of being reported delivered. - - Media has no sender-side re-seal (chunks are re-encoded, not replayed), so an undecryptable chunk recovers the way an interrupted transfer already does: the withheld ACK drives the media outbox to surface `MediaResendRequired`, and the app re-supplies the bytes. - - **What did not change is the re-key.** Only a proven epoch mismatch tears down and rebuilds the session. These failures withhold the ACK but never re-key — turning every malformed frame into a session teardown would be an unbounded churn vector, which is why the two classifications exist separately. Both remain under the existing `encryption.cryptoRecoveryEnabled` kill switch (default on); setting it to `false` restores the legacy drop-and-ACK for both. - - **App teams: `messageDecryptionFailed` has changed meaning.** It is now advisory rather than terminal, and fires once per failed *attempt* rather than once per message — bounded by the sender's ACK retry budget. If your UI settles a message on it (marks it lost, drops it from a list), switch to `messageFailed`, or `fileReceiveFailed` for media. The telemetry-side `decryption_failed` record was already rate-limited per peer and failure kind and is unaffected. - -- **A frame that never reaches MLS is no longer acknowledged as delivered either.** The change above covers frames that reached the MLS layer and failed to decrypt. A frame that failed one layer *earlier* — an `__MLS_ENC__` payload in no recognised envelope form, or a media envelope whose encoding does not decode past its magic byte — never reached MLS at all, and kept the old drop-and-ACK. These were the last such arms of the family, and the case is the same one: in-transit corruption a few bytes earlier in the encoding rather than in the ciphertext, acknowledged as delivered while the sender's resend would have parsed and delivered. - - Both now withhold the ACK, under the same `encryption.cryptoRecoveryEnabled` kill switch (default on; `false` restores the legacy drop-and-ACK). Neither queues the frame — an unparseable frame can never become parseable, so a queued copy could never drain — which makes the sender's resend the whole of the recovery path, exactly as for a spent ratchet generation. - - **What stays acknowledged is everything that fails *after* a successful decrypt**: an empty or non-UTF-8 plaintext, and a media chunk whose decrypted body does not parse. Those are terminal — the ratchet generation is spent and a resend would re-seal the same malformed plaintext, so no retry could ever deliver. - - **App teams:** a sender whose frames can never parse — an encoding bug rather than a transient corruption — now rides the full ACK retry ladder and settles as an honest `messageFailed`, where before it was told "delivered" for a frame the receiver dropped. `messageDecryptionFailed` fires for this class too, with the same advisory, once-per-attempt meaning described above. - -- **A media chunk that fails its identity binding is now answered with silence, matching text.** Two conditions are refusals rather than delivery failures: an envelope naming a session slot that is not the claimed sender's, and an MLS credential authenticating someone other than the wire sender. The text path has never acknowledged either, because an ACK confirms to whoever injected the frame that the target is online and processing their traffic. Media acknowledged both — so an injector who sent the same forged frame down both paths learned from the media ACK precisely what the text silence protects. - - Media now behaves identically: no ACK, and the message id is left unmarked so a replay re-enters the gate rather than hitting the duplicate re-ACK path. This is deliberately **not** gated on `encryption.cryptoRecoveryEnabled` — it concerns what the receiver reveals, not whether a message can be recovered, and the text equivalent has always been unconditional. - - The existing `MEDIA_SENDER_GROUP_MISMATCH` security warning still fires for the slot case. One consequence of leaving the id unmarked: a repeated injection of the *identical* frame now re-emits the warning on each attempt, where dedup previously suppressed it after the first. The rate is the signal, as with `SESSION_REKEY_TRIGGERED`. - -## [0.20.0] — 2026-08-07 - -> **One breaking change, and it is React Native on iOS only.** iOS autolinking -> now works, so a manual `pod 'MeshSdk'` line left in your `Podfile` fails -> `pod install`. It is a one-line deletion — -> [`docs/UPGRADING.md` §12.1](docs/UPGRADING.md#121-react-native-ios-delete-your-manual-pod-meshsdk-line-v0200) -> lists every leftover to remove — and the same change is what makes iOS -> **simulator** builds link at all. -> -> Two further things can surprise without breaking a build. The exported -> `ProtocolState` enum's *values* are now the strings `getState()` has always -> resolved (`"Running"`, not `1`), so a comparison that silently never matched -> now does. And turning the Nostr transport on now publishes MLS key packages -> to relays unprompted — sealed, and opt-out via `nostr.coldContactEnabled` — -> which is the first thing that transport emits without traffic to carry. - -### Added - -- **Nostr supports cold first contact: key packages are published as fetchable relay records, and a peer's is resolved before sealing to them.** Until now, reaching someone over Nostr required having already exchanged key packages over some other transport — key packages are *pushed*, so the very first frame of a new conversation had no per-install key to seal to and fell back to the recipient's publicly computable key. That fallback is bulk-collection resistance and nothing more: anyone who guesses the username holds the matching private half. - - Each install now publishes `NOSTR_KEY_PACKAGE_SLOTS` (5) single-use MLS key packages as NIP-33 addressable events (kind `30443`), tagged `[["d", ], ["p", ]]` and signed by the install's real Nostr key — which is how a fetcher learns the key to seal to. A send to a peer whose key we lack queues a query on their routing tag; the send itself still goes out on the bootstrap leg, because blocking it on a relay round-trip would turn a metadata upgrade into latency and the round-trip may have nothing to return. The answer upgrades the next frame. - - **The published record's content is sealed, and that is not belt-and-braces.** An MLS key package carries its owner's username twice — in the payload field, and unremovably in the leaf credential, since this SDK uses basic credentials holding the raw user id. Published in the clear, a filter naming only the kind, with no tag and no author, would have returned *a directory of every username on the relay* — handing over exactly the preimages the `SHA-256(userId)` routing tag exists to withhold, and giving back more than the gift-wrap work took away. The content is therefore NIP-44-sealed to our own computable key: fetching the record at all requires the routing tag, which requires the username, which is the same knowledge needed to open it, so the intended audience is unaffected while a scraper sees an opaque blob. The computable keypair thus keeps exactly one encryption use — a self-published record whose only reader already knows whose it is — and still backs no authentication decision anywhere. - - **Five slots, because an MLS key package is single-use.** Its init key is consumed by the first peer who uses it, so one replaceable record would mean a stranger who fetches it after it was spent builds a Welcome that can never be processed. Each slot holds its own package under its own stable random `d` tag; the process tick replaces any whose package has been consumed or expired and republishes it under the same slot id (addressable events replace, so republication is idempotent — and publication timestamps are deliberately *not* jittered into the past like a gift wrap's, since a relay drops an addressable event older than the one it holds). The count covers the *sequential* gap between refreshes; it does not absorb concurrent cold contacts, since nothing distributes simultaneous fetchers across slots. Consumption is local — an init key leaves provider storage only when this node processes a Welcome built against it — so a stranger can drive it only by actually establishing sessions, and each burnt slot refills on the next tick. A refill that fails emits the new `NOSTR_KEY_PACKAGE_SLOT_EXHAUSTED` security warning rather than leaving a stale record standing, which is the one failure this design must not absorb quietly — reported once per pass and suppressed for 5 minutes, since the causes (MLS or storage errors) persist and one per slot per refresh would bury the signal in its own repetition. A record that was built but never reached a relay (rejected, timed out, or in flight when the connection dropped) is reported back by the transport so the next tick republishes it, rather than leaving the slot marked published for the life of the process. The first such failure retries promptly, but consecutive ones back off — doubling from the 60s refresh interval up to 30 minutes — so a relay that rejects the kind outright, or rate-limits a fresh pubkey bursting the whole slot set, converges instead of being retried five times a minute forever. **Publication outcomes are deliberately kept out of the transport's delivery metrics**: DORS scores reliability on `success / (success + failure)` over lifetime counters that never decay, and an idle install publishes far more than it sends, so counting them would score the transport on something other than its ability to carry messages — a rejecting relay would drive the ratio toward zero and make DORS deprioritise Nostr for traffic that delivers fine. `MlsManager::get_or_create_key_package` (the push path) now skips reserved packages, so a pushed-to peer and a stranger who fetched a record can never race for the same init key. - - **What it costs, stated plainly.** This is the first thing the transport emits unprompted. A small set of records sits at the install's routing tag and refreshes as slots are consumed, whether or not a message is ever sent — so the *existence* of a record at a tag, and the timing of its refreshes, are visible to every relay published to, a liveness signal the transport did not previously emit. Sealing hides who the record belongs to, not that it is there. `transports.nostr.coldContactEnabled` (RN) / `nostr_cold_contact_enabled` (UniFFI, core `TransportConfig`), default **on**, turns both halves off and keeps the transport silent until it has traffic, at the price of cold contact. Nostr itself remains off by default, so nothing publishes unless an app deliberately enables the transport. - - **What one query will accept is bounded.** The query is broadcast, so every connected relay answers under the same subscription id and the same record arrives once per relay — each one otherwise re-running the key-package handler's two durable secure-storage writes to import a package already held. Records are now taken once per query by event id, and a query stops accepting events at a ceiling of 64 regardless: a relay is free to ignore the REQ's `limit` and stream indefinitely, so the bound has to be ours. Exceeding either only costs the metadata upgrade, which falls back to the bootstrap leg. A resolution refused because the pending-lookup queue was full also no longer burns the 5-minute retry interval for a peer that was never actually looked up. - - **Bridge change:** a new four-call contract drives the query loop — `nostrGetNextQuery()`, `nostrQueryEventReceived(queryId, eventJson)`, `nostrQueryCompleted(queryId)`. Poll it on the same timer that drains outgoing messages, broadcast the REQ to every connected relay using `queryId` verbatim as the subscription id, route matching events to `nostrQueryEventReceived` rather than through the message path (they are not messages — their content is sealed to a different key), and close on the first EOSE. Release every in-flight query when all relays drop: one issued just before a disconnect never sees an EOSE, so without that the bridge holds its subscription id for the life of the process and the transport holds the entry until its own cap evicts something — possibly a live query. Both bundled bridges implement it. A bridge that does not still publishes normally, since records ride the existing `nostrGetNextMessage` path whose `event_json` is opaque; it simply never resolves, and sends fall back to the bootstrap leg as before. - - A peer's key is still taken **only** from the Ed25519-signed `nostr_pubkey` inside the record's payload, never from the event's own `pubkey` field — that field is self-attesting, signed by the very key it names and bound to no user id. A record planted at a queried peer's tag by somebody else therefore registers under *that* signer's identity, not the peer's; the resolution channel additionally accepts nothing but key packages, so a squatter cannot deliver other control frames through a path that skipped the receive loop's dedup and block checks. The event's BIP-340 signature is deliberately not verified: it would authenticate nothing that anything relies on. - - **Two things squatting a tag does buy, both bounded.** Foreign records can crowd real ones out of the query's `limit`, which costs the metadata upgrade and nothing else. And since every published record is openable by anyone who knows the username — that is the design — a squatter can unseal one of a peer's *spent* records, re-seal the untouched, genuinely signed payload under their own author key, and stand it back up with a fresh `created_at`. Nothing detects that: the inner signature is real, and no freshness binding ties a record to the live slot. A resolver then imports a genuine-but-consumed key package and builds a Welcome the peer cannot process — worse than crowding, since it commits to a dead session rather than staying on the working bootstrap leg. It self-heals rather than stranding the pair: importing any key package pushes ours back under `auto_key_exchange`, and the peer establishes from their side against a package that is actually live, so the cost is delivery delayed by one exchange (the same bounded class as the already-accepted `key_package_data` substitution). Closing it outright needs records to carry slot-bound freshness, which is future work. - - Two residuals from the sealed-envelope work close as a side effect. The peer-key map's reset-at-capacity downgrade is now bounded for peers who publish — a forgotten key is re-resolved on the next send rather than persisting until restart or re-exchange — and the config→transport propagation of both Nostr kill switches is now pinned by a test, having previously had one call site and no coverage. A *cached* key that goes stale (a peer wipes storage and their key rotates) is still not re-resolved, since only a missing key triggers a lookup; that path continues to heal through contact over any other transport. - -- **Android can now bring the mesh back after a process kill, by waking JavaScript — opt-in.** (#307) The keep-alive service is `START_STICKY`, so the system hands it back after a kill, but with no host in the new process it stops itself (#294) and the device stays off the mesh until the user next opens the app. Senders hold, retry, park and push for up to seven days, so this was never message loss — but for an always-on app it is a real availability hole for the whole window. - - **Nothing native re-creates the protocol, and the #297 refusal that says it must not is unchanged.** This wakes *JavaScript* instead, which is the whole point: a receiver exists before a protocol does, rather than after. On a sticky restart with no live host, an opted-in app has React Native started through a Headless JS task; the app's own task then runs the ordinary `new OfflineProtocol(config)` → `on(...)` → `start()` with its own config and credentials. Nothing is persisted natively to make this work — no `ProtocolConfig`, and in particular no relay `authToken` — so there is no new storage and nothing for `wipePersistedState` to learn about. - - Opting in takes two halves, both the app's: `` in the manifest, and `registerMeshWakeTask(...)` at module scope in `index.js`. The manifest is what the restarted service can read before any JavaScript exists; the optional `MESH_WAKE_TIMEOUT_SECONDS` (default 60, clamped 10–300) bounds the wake. **The reason it is opt-in rather than a default is the obligation it carries**: the core never persists inbound content and the receive path ACKs before it emits, so a task that does not durably store `message_received` *before* calling `start()` walks into exactly the loss #297 refused to build. The task must also be idempotent — it is allowed to run in the foreground, because the alternative is React Native crashing the process when the user opens the app mid-wake — and must re-issue whatever `start()` does not restore (Wi‑Fi Direct always; the relay when its endpoint arrives via `enableTransport`). - - **A wake that does not land stops the service.** The same stop-callback slot that gates the restart is what a successful `start()` re-registers, so no new state tracks the wake: a watchdog armed alongside it re-reads that slot after the task's budget and brings the keep-alive down if nothing arrived — no task registered, JavaScript failed to boot, the task threw, or the app looked at its own state and declined. Without it the "Mesh Active" notification over a dead protocol that #294 removed would come straight back, since this branch now returns `START_STICKY` on a bet rather than an observation. The wake is also refused outright when the foreground promotion did not take, which fails closed to the previous behaviour: the process is then not foreground, so starting the wake service would be an illegal background service start, and a service kept up without a notification is the empty-process squat #294 removed. - - Scope, stated plainly: it rides the service restart, so it covers kills the system chooses to recover from (memory pressure, the common case) and not a force-stop, an OEM swipe-away treated as one, or a reboot — those still need the app opened, and reconciling with `getState()` on foreground (§6.1) remains the belt-and-braces every integrator should have. Requires React Native **0.76.5+** under the New Architecture; on 0.84/0.85 a core bug fixed in 0.86 can leave the wake service running after the task finishes, which the timeout bounds. One correction to the record while here: #294's rationale said a sticky restart is not an exemption from the API 31+ ban on foreground-service starts from the background. It is — `Service.START_STICKY`'s reference states the restriction "doesn't impact restarts of a sticky foreground service" — so the promotion normally succeeds; the defensive handling stays for the cases that can still fail it, such as a `connectedDevice` promotion after Nearby-Devices permissions were revoked. - -- **`ContentType::Poll` — a structured poll content type.** (#281) Propagated everywhere the other types live: the core enum and its string form (`"poll"`), the binary wire codec (`8`), the UniFFI enum and UDL, and every binding surface (Python, Kotlin, Swift, TypeScript, and both React Native native modules). A poll carries its app-defined body in `content` and is treated like text rather than media — `is_media()` is `false` — and the sealed `__RICH_V1__` content-type hint carries it through encrypted DMs and group messages like any other rendering hint. - -### Changed - -- **An unknown content type no longer fails JSON decode — it degrades to `File`.** `ContentType` had three decode paths with two semantics: `ContentType::parse` and the binary codec's `content_type_from_u8` fall back to `File` on unrecognised values, but the derived serde `Deserialize` failed closed. On a mixed-version fleet that asymmetry would have made every future variant addition a breaking wire change on the JSON paths: the internet relay (always JSON) and pre-binary-wire mesh peers would reject the entire `Message` — silent frame drop, no ACK, the full retry ladder into `MessageFailed` — and a sealed `__RICH_V1__` body naming the new type would fail `parse_sealed` wholesale, surfacing the raw JSON blob as message text. JSON deserialization now routes through `parse`, so all three paths share the `File` fallback and builds from this release forward decode any future content type additively. Non-string values still error — that is malformed data, not additive evolution. `Poll` itself (added in #281, before this change) predates any deployed fleet, so nothing in the field is affected; this closes the class before the first fielded build. - - `Serialize` is now hand-written too, over the same `ContentType::as_wire_str` source of truth. JSON output is byte-identical to the derived `snake_case` impl, but the derived impl emitted the variant *index* in non-self-describing formats (postcard, bincode) — which the new string-based `Deserialize` would have read back as a zero-length string and degraded to `File`, silently turning `Text` into `File` with no error. Nothing in-tree hit that (the binary wire codec maps content types to `u8` by hand), but this type is published, so both directions now speak strings in every serde format. Apps should read `File` as "a file *or* a type this build doesn't know": a degraded value keeps `is_media()` `true` while carrying no `MediaMetadata` and no transfer, so it belongs in a generic unsupported-content placeholder rather than a broken attachment. - -- **React Native: `ProtocolState` was a numeric enum, but `getState()` resolves a string.** Both native modules build the state *name* (`"Stopped"` / `"Running"` / `"Paused"`) and `getState()` passes it through unmapped, while the exported enum declared `Stopped = 0, Running = 1, Paused = 2`. Every way of writing the comparison was wrong in one direction: `state === ProtocolState.Running` compared `"Running"` against `1` and was never true — silently reporting a running protocol as stopped — while `state === 'Running'` behaved correctly at runtime but failed `tsc` with TS2367 (`'ProtocolState' and 'string' have no overlap`). The members are now string-valued, so both spellings agree with the wire, and a drift guard (`react_native_protocol_state_members_match_the_wire`) pins them to the variant names the native modules resolve — the values are not part of the UDL, so a bindings regen would never have caught this. - - **This changes the exported enum's *values*** from `0`/`1`/`2` to `"Stopped"`/`"Running"`/`"Paused"`. No code path ever produced the numbers, so nothing read back from `getState()` is affected and no source change is needed; the one hazard is a consumer that *persisted* a `ProtocolState` it wrote itself (AsyncStorage, redux-persist) and compares it after upgrading — that value comes back as a number and now matches nothing. Treat an unrecognised persisted value as `Stopped`. One knock-on for completeness: TypeScript gives string enums no reverse mapping, so `ProtocolState[0]` no longer resolves to `"Stopped"` — nothing could have depended on it, since the numbers were never produced, but the lookup is now `undefined` rather than wrong. - - iOS's `getState()` also resolved `"Unknown"` on its `@unknown default` branch, which is not a member of the enum at all — so the declared `Promise` was unsound and the two modules disagreed (Android maps the same case to `"Stopped"`). Unreachable with today's three variants, but iOS now falls back to `"Stopped"` like Android, which is also the safe direction for the reconcile pattern the integration guide documents. - -- **Android: a `START_STICKY` restart no longer pretends it might bring the mesh back — `MeshForegroundService.onServiceRestarted` is removed.** (#291) The hook was declared and invoked but assigned by nothing outside tests: the notify-host half of a protocol whose host half was never written. Removing it settles what that path is *for*. A re-delivery into a process with no live mesh host stops the service and returns `START_NOT_STICKY` (#294, which also gave the stop-callback slot its second meaning — registered as mesh comes up, surrendered as it goes down — so the gate reads mesh liveness rather than module liveness); a re-delivery that finds a host only re-promotes the notification. Neither branch has anything left for a callback to do. - - **Rebuilding the protocol there is now refused rather than deferred**, which is the part worth reading if you were waiting on it. The obvious design — persist the `ProtocolConfig` natively and re-create the protocol without JavaScript — is not a cheaper route to the same place; it destroys messages and tells their senders they arrived. The receive path sends the delivery ACK *before* it emits `message_received`; that ACK makes the sender drop its outbox entry and retire the retry ladder; the event is then discarded because `canEmitToJs()` finds no React instance. The core persists outbound and session state and never inbound content, so the message exists only in that dropped event — and the MLS ratchet generation it consumed means a resend cannot reconstruct it either. Staying down keeps the failure recoverable instead: the sender's outbox holds for up to seven days, retries, parks and pushes, and delivers once the device is genuinely running again. Waking JavaScript *first* — React Native's Headless JS, an app-supplied opt-in (the config, including relay credentials, is the app's and must stay there), and a watchdog for the wake that never lands — is the only sound direction, and was deliberately not built here; it landed separately under #307, above, in exactly that shape. - - `onServiceRestarted` was public on the service companion, so this is source-breaking for anything that assigned it; nothing in the SDK, either bundled bridge, or the example apps did. Runtime behaviour is otherwise unchanged. Both integration guides now carry the contract: reconcile mesh state at launch rather than assuming it survived the process, never reuse a `destroy()`ed `OfflineProtocol` (its event subscriptions are created only in the constructor), and — on the native Kotlin path, where you own the `EventCallback` — never `start()` ahead of a consumer that durably keeps `message_received`. The React Native guide also now states what that relaunch does *not* restore: `start()` re-enables only the transports declared in the constructor config, so Wi‑Fi Direct — which it never starts — and any relay whose `serverAddress`/`authToken` arrives through `enableTransport('internet', …)` have to be re-issued by the app. `Running` was never a claim that a given transport is attached; `getActiveTransports()` is the read that answers that. - -### Fixed - -- **React Native on iOS could not be installed from npm by following the documentation, and simulator builds could not link at all.** Two packaging defects that compounded each other, fixed together. **This is a breaking Podfile change — see [`docs/UPGRADING.md` §12.1](docs/UPGRADING.md).** - - **iOS autolinking was disabled**, so `npm install` + `pod install` — the entire published instruction — installed no native module, and the app failed at runtime with the SDK's own linking error, whose remedy read "run `pod install`": the step that had just been run. Every consumer had to discover independently that a manual `pod 'MeshSdk', :path => '../node_modules/@offline-protocol/mesh-sdk/ios'` line was required, and no document mentioned it — one guide affirmatively stated that `use_native_modules!` linked the SDK automatically. The cause was mundane: `MeshSdk.podspec` lived in the package's `ios/` directory, and React Native resolves a dependency's podspec by globbing `*.podspec` in the package **root**, without recursing, so autolinking could never see it. Setting `podspecPath` in `react-native.config.js` does not help — the current CLI ignores that field when deriving iOS dependency config. The podspec therefore moves to the package root, `react-native.config.js` no longer disables iOS, and a plain `pod install` links the SDK. The publish gate now refuses to publish if the root podspec is missing, since its absence degrades silently to "autolinking skipped this dependency". - - **The native binary shipped as two loose static archives** (`liboffline_protocol_uniffi_device.a`, `..._sim.a`) declared via `vendored_libraries = "libs/*.a"`. That glob makes CocoaPods emit an unconditional `-l` for **both** archives into the *app* target's link line, so a simulator build tried to link the device archive and failed on the architecture mismatch. The podspec's own sdk-conditional `OTHER_LDFLAGS` could not prevent it: they sat in `pod_target_xcconfig`, and the flags that decide the final link belong to the app target, which a podspec has no way to reach. Nor could a single fat archive fix it, since device and simulator `arm64` cannot coexist in one `lipo` output — the reason there were two files. The binary now ships as `offline_protocol_uniffi.xcframework` with `ios-arm64` and `ios-arm64_x86_64-simulator` slices, declared through `vendored_frameworks`; CocoaPods copies the slice matching the build SDK and points the search paths at it, and no sdk-conditional linker flag exists anywhere. Both slices deliberately carry the same archive basename, because CocoaPods derives one `-l` for the whole bundle and applies it to whichever slice it copied. **Simulator builds work; anyone who concluded this SDK was device-only was hitting this bug** — both slices have always shipped, and the publish gate has always refused to publish without the simulator one. - - The generated UniFFI header and module map are untouched: they stay in `ios/Generated/` and still reach Swift through `SWIFT_INCLUDE_PATHS`/`HEADER_SEARCH_PATHS`, so the XCFramework carries no headers and the Swift import path is unchanged. `DEFINES_MODULE` moves into the podspec, which removes the last reason a consumer needed `:modular_headers => true` or a `post_install` hook for this pod. No JS/TS API, wire-format, or Android change; the npm package is marginally smaller. Consumers must delete any manual `pod 'MeshSdk'` line and any `post_install` hook that set `OTHER_LDFLAGS`/`LIBRARY_SEARCH_PATHS`/`SWIFT_INCLUDE_PATHS` for it — a surviving line pointing into the package's `ios/` directory fails `pod install` immediately with "no podspec found", which is loud by design rather than a silent misbuild. - -- **The key-package push path handed the same MLS init key to every peer.** (#306) `get_or_create_key_package` returned the first stored package to every caller — the auto key exchange, group-invite backfills, the desync `session_reset` advertisement — and only minted a new one once somebody's Welcome had consumed it. One init key was therefore advertised to every peer a device met until it was spent. That is the LastResort-style reuse RFC 9420 §16.8 permits only as a denial-of-service fallback, and which the Least Authority MDK audit flagged for Marmot as enabling unsolicited joins, cross-group linkage, and resource consumption; the published-record path added in #289 already obeyed single-use, and the push path did not. - - The push path now keeps **one package per peer**. A package is claimed by the first peer it is pushed to and only ever re-handed to that peer, so a repeat push costs no new key material while two peers never share an init key. The assignment lives on the package's own stored record rather than in a separate map, so it survives restarts for free and cannot disagree with the pool it describes; a package already in storage when this ships is unclaimed and gets claimed rather than stranded. Consumption is what rotates a peer's key — a Welcome built against the package removes its init key from provider storage, which the loader already reports, and the next push to that peer mints a fresh one. That matches the RFC's "rotate as soon as possible after being used" without any new bookkeeping. - - **This also fixes a delivery bug, not only a forward-secrecy one.** Two peers pushed the same package would both build a Welcome against it; the first to be processed consumed the init key and the second became permanently unprocessable, surfacing as a `SecureSessionFailed` with nothing to re-drive the exchange while the peer's retransmit ladder retried the same doomed Welcome. - - The pool is bounded at 64 live unconsumed packages. It is normally self-limiting — its size tracks peers with an outstanding unused advertisement — but a device meeting an unbounded stream of peers that never establish sessions would otherwise grow it without limit. At the ceiling the push path shares a package rather than refusing to advertise or evicting one a peer may still be about to use, both of which would cost session establishment; peers that already hold their own package keep it. The ceiling gates only the mint, since that is the only step that grows the pool — a full pool still holding an unclaimed package hands that package out rather than sharing, because claiming relabels a package that already exists and degrading to a shared init key to stay under a bound the claim never approaches would weaken forward secrecy for nothing. `ensure_min_key_packages` is capped at the same ceiling for the same reason: beyond it, it would mint key material the push path can never hand out while holding the pool at capacity. Because that is the one condition under which the old reuse is back, it emits the new `PUSH_KEY_PACKAGE_POOL_EXHAUSTED` security warning, suppressed to once per 5 minutes since the condition persists until packages are consumed or expire. The per-push pool scan is cheap: each package's OpenMLS provider hash reference is cached on its stored record at mint time (and backfilled on first load for records that predate the cache), so checking a package's usability is a storage lookup rather than a TLS parse plus a signature validation per stored package per push. - - **Expiry now destroys key material.** Deleting a key package's bundle record — all this crate did, on expiry and on reclaiming an unpublished publication package — never touched the private init key OpenMLS holds, since the only other thing that removes one is a peer actually using it. Every package a device ever minted therefore kept its init key for the life of the install, which is the property the 30-day lifetime was supposed to bound. Expiry and destruction are now two moments: an expired package stops being advertised immediately, but its init key is kept for a 7-day grace window so a Welcome built against it just before expiry is still processable, and only then is the material genuinely deleted. Legacy raw records — stored as bare key package bytes by builds predating the bundle, and still upgraded in place on load — are purged the same way rather than having their record deleted out from under resident key material. - - `mls_get_or_create_key_package` (the peer-less FFI entry point) is unchanged except that it now skips packages a peer has claimed, so it cannot hand out a key another peer is expected to use. Nothing on the wire, in storage formats, or in the UDL changes. - -- **React Native: two security warning codes were missing from the `SecurityWarningCode` union.** `NOSTR_KEY_PACKAGE_SLOT_EXHAUSTED` (added in #289) had drifted, and apps switching on `reason_code` saw it fall into whatever default their `switch` had. Both it and the new `PUSH_KEY_PACKAGE_POOL_EXHAUSTED` are now declared, and a drift guard pins the union to the Rust enum the way the existing one pins event tags — these codes cross UniFFI as opaque JSON, so nothing else fails when `types.ts` lags. - -- **Android: BLE advertising killed by a terminal `onStartFailure` was never retried, leaving the device permanently undiscoverable.** Documented as a known limit when the scan-side recovery landed (#293), now closed. The failure is narrow to describe and easy to miss in the field: the adapter is on and scanning is healthy, so none of the BLE stack's self-healing paths ever run — the facade's recovery runnable repairs advertising only inside an adapter-off episode, and the advertiser's own `refresh()` only when the published identity changes. `onStartFailure` cleared `isAdvertising` and the in-flight gate and scheduled nothing, so advertising stayed dead until an unrelated peer eviction happened to refresh it, or the app restarted. - - Nothing looked wrong from either side of the SDK. The transport still reported `RUNNING` and the central role kept working — the device discovered peers and connected out normally — so what an integrator sees is not an error but an asymmetry: this device can reach its neighbours while they cannot find it. `ADVERTISE_FAILED_TOO_MANY_ADVERTISERS` reaches it whenever other apps on the device hold every hardware advertising slot, and `ADVERTISE_FAILED_INTERNAL_ERROR` on any stack having a bad moment, so neither is exotic. - - `LeAdvertiser` now arms a capped retry (10s, doubling to 30s) through the same `BleRecoveryScheduler` the scan path uses — but only for failures that can actually clear. `TOO_MANY_ADVERTISERS` and `INTERNAL_ERROR` retry, as do unrecognised codes, on the grounds that an unknown value is more likely a vendor-specific transient than a new permanent class and the ladder's cap bounds the cost of guessing wrong. The three terminal codes do not: `ALREADY_STARTED` means an advertisement is running and would earn itself back forever, `DATA_TOO_LARGE` means the SDK's own fixed payload is malformed and will not differ next time, and `FEATURE_UNSUPPORTED` is hardware truth — the same reading the scan path already applies to its own `SCAN_FAILED_FEATURE_UNSUPPORTED`. Which way each code went is visible to apps as a `willRetry` field on the existing failure diagnostic rather than a new emission. - - A deliberate teardown stays authoritative over an armed retry: the cancel sits in `stop()`, which every path funnels through — `pause`, `shutdown`, `refresh`, the adapter-off repair and the BLE reset — and specifically *above* its early return, since a retry is armed exactly when a failure has already cleared the callback reference that return keys on. Nothing is reported to the transport's availability signal even for the permanent codes, because a device that cannot advertise but can still scan is degraded rather than unusable, and saying otherwise would pull a working transport out of DORS. Android bindings only; no FFI, event, wire, or config surface changes. - -- **iOS: bridge emits were gated on the listener flag alone, with no counterpart to Android's live-instance precondition.** (#298) Android refuses an emit unless `listenerCount > 0 && hasActiveReactInstance()`; iOS checked only the first half, and `RCTEventEmitter.sendEvent(withName:body:)` returns `Void`, so the module could not tell a handed-over event from one dropped downstream. React Native re-checks *both* conditions itself one frame later and, when either fails, drops the event with nothing but a log warning — so a `true` from the listener flag was never evidence of delivery, and nothing reported the failure back. - - The missing read is `callableJSModules`, the handle RN hands the event to and the very thing its own gate tests. It is `weak`, and the object is owned strongly by the per-React-instance module decorator, so tearing an instance down nils it — the same event Android's `hasActiveReactInstance()` observes, reached by the only route iOS offers. It never appeared in a header a subclass author reads, which is why it went unadopted for so long: it is an `@optional` requirement of `RCTBridgeModule` that `RCTEventEmitter` satisfies with a private `@synthesize`, absent from `RCTEventEmitter.h`. `sendEventToJS` now gates on both conditions and returns `Bool`, mirroring Android's `sendEvent`. - - **Two silent ways to write this wrong are pinned by a test rather than left to review**, because nothing in CI compiles the iOS module. Read through the protocol existential, `callableJSModules` comes back double-optional — the outer level meaning "does this class implement the requirement", which is always true for `RCTEventEmitter` — so the natural `!= nil` typechecks, reads correctly, and gates on nothing; the value only surfaces when read directly off `self`. And declaring the property on the subclass, which RN's own header comment recommends for Swift modules and which is right for a direct adopter, shadows the parent's storage and silences *every* emit the module makes. - - **This narrows the window; it does not close it**, and nothing is being re-plumbed to depend on it. The instance can still die between our check and RN's, an invalidated legacy bridge drops below both, and the listener flag still reads stale-true across a JS-side `subscription.remove()` whose native `removeListeners` is still in flight. The one genuinely one-shot event, `internet_session_superseded`, therefore keeps re-deriving itself from the transport latch on foreground (#296) rather than building on the new return value — a distinction that matters precisely because it must survive the drops this precondition cannot see. The delivery contract in `docs/react-native-integration.md` §6.1 is unchanged. - -- **iOS: `internet_session_superseded` could be dropped, leaving the app showing a relay connection that was never coming back.** (#296) The event is the one strictly *one-shot* signal both bridges emit: on relay displacement `InternetManager` latches the transport stopped and every reconnect route refuses — auto-reconnect, `scheduleReconnect`, `connect()`, and `forceReconnect()` including the automatic foreground heal — until an explicit `start()` clears the latch. Nothing restates it. iOS emitted it best-effort behind a listener check and, if that check said no, the report was simply gone. #295 made that check *correct* (it was an unsynchronized `Bool` read from the socket callback) but a correct read is not delivery. - - **The fix is to re-derive the report from state rather than to hold a copy of the emit**, which is where this diverges from the Android half of #295 and does so deliberately. A held copy can only be taken on the branch where the module *notices* the failure, and on iOS that branch turns out to be nearly unreachable for this event — the supersede emitter does not exist until `create()`, which the SDK calls after subscribing in its constructor, so "nothing subscribed yet" cannot contain a supersede, and the remaining shut-gate windows are a `destroy()` in progress and an invalidated instance, where there is nothing left to redeliver to. Meanwhile the losses that *do* happen are ones no buffer can see: React Native re-checks its own listener count and its JS-module handle inside `sendEvent` and drops with only a warning, an invalidated bridge drops silently, and a JS-side `subscription.remove()` deregisters synchronously while the native `removeListeners` is still in flight — so the flag reads true and the emit lands nowhere. `SupersededLatchPolicy` already holds the authoritative fact and now retains the displacement reason alongside it, so the bridge re-emits the same event from that state on every app foreground for as long as the transport stays superseded. Every way the emit can be lost becomes "healed on the next foreground", including the ones the module never learns about. - - **`isInternetSuperseded()` is new on both platforms**, and it is the read that answers a question `isInternetReady()` structurally cannot: a `false` there from an ordinary disconnect — which reconnects itself within seconds — and a `false` from a displacement — which will not reconnect on its own, ever — are indistinguishable. It also covers what no in-memory delivery reaches: an app that subscribed after the fact, a JS reload, a process restart. Reconciling against it on foreground is now the recommended shape, and `docs/react-native-integration.md` §6.1 is rewritten around it. (0.16.2 claimed `getMetrics()` "exposes `is_superseded`" — true of the class, but `getMetrics()` has never had a bridge caller on either platform, so the flag was not in fact reachable from JS until now.) - - **Delivery is therefore at-least-once and the event is state, not an edge** — Android may redeliver a held copy, iOS restates until the transport is re-enabled — so handlers must be idempotent. Setting a "connected elsewhere" flag is fine; pushing a screen per event is not. Repeats stop the moment `enableTransport('internet', …)` clears the latch, on both platforms and with no bookkeeping, because after it there is no longer anything to restate. **No queue hop and no subscribe-time flush on iOS**, both re-derived rather than copied from Android: `sendEvent(withName:body:)` can never deliver inline (it posts to the JS thread through `RCTCallableJSModules` on both the bridge and bridgeless paths), so the RN emit path *is* the hop; and `startObserving` fires only on the 0→1 listener transition, which for this SDK happens once per module lifetime before any `InternetManager` exists. The event tag and payload now have a single definition in `SupersededLatchPolicy` on each platform — a file both CI paths compile and both unit suites pin — instead of a literal in the two bridge modules, neither of which CI compiles; a new `react_native_supersede_restatement_wiring_is_present` drift guard asserts the wiring from `cargo test`, following the `getState` guard added in #295. Bridge-only; no Rust/core wire, schema, or send-path change. - -- **Android: a mesh Stop from the notification could be torn down correctly and then reported to nobody.** `mesh_stopped_by_user` went out through the bridge's ordinary `sendEvent`, which drops the event whenever it believes no JS listener is registered. For almost every event that is harmless — they are periodic, re-derivable, or followed by another carrying the same state. This one is the *terminal* event of the mesh lifecycle: by the time it fires the transports, the scheduler, the keep-alive service and the core are already down, so nothing later restates it. Dropped, the app kept reporting an active mesh against a fully stopped protocol, indefinitely, and the window where the event matters most is the one where JS is least likely to be listening — the user reaches the notification shade precisely when the app is not in front of them. - - Three independent paths lost it, and the obvious fix only closes one. **The listener count was a plain `Int`** written on the thread React Native delivers `addListener` on and read from the `"mesh-user-stop"` thread the teardown runs on, sharing no lock and no happens-before edge — so it could legally read a stale zero while JS was fully subscribed. It is now an `AtomicInteger`, which matters more than it looks: replay-on-subscribe cannot fix that path, because a stale read happens exactly when JS is *already* subscribed and no further `addListener` will ever arrive to trigger a replay. **A detached React instance was assumed to throw**, and on the New Architecture it does not — `getJSModule` returns a proxy forwarding to `ReactHost.callFunctionOnModule`, which reports failure by rejecting a Task, so "did not throw" was never evidence of delivery on bridgeless. Emission is now gated on `hasActiveReactInstance()` as a precondition and `sendEvent` reports whether it got that far. **And the gate can simply be shut** when the event fires — a listener count that has transiently gone to zero (an app tearing down and re-creating the SDK across a logout, say), or a React instance that is not currently live. Not, as first supposed, because backgrounding removes listeners: it does not, since the SDK subscribes in its constructor and unsubscribes only in its own `destroy()`. - - That last window is what the new `StickyEventBuffer` covers: a bounded, last-wins-per-key hold for one-shot events only, redelivered on the next subscribe or foreground. It is defence in depth rather than the main fix — the two changes above carry the common cases, and one window nobody can cover is a process kill, since with no module registered `MeshForegroundService` takes its no-host fallback and the event is **never generated at all**. **The hop onto the JS queue in the flush is load-bearing** — `NativeEventEmitter.addListener` calls the native `addListener` *before* it registers the JS-side listener, so emitting synchronously from there would re-lose the event through a subtler version of the same hole; `runOnJSQueueThread` always posts and never runs inline, so the flush lands after that registration under both the bridge and the New Architecture. Entries are cleared once handed over rather than held until JS confirms, because no confirmation exists — a redelivered event is idempotent for an app that reconciles against actual state, whereas one never cleared would re-fire on every subscribe for the life of the process. An event that goes out directly also drops any copy an earlier failed attempt left held, so stale news cannot redeliver *behind* the event that superseded it — and an emit that throws is treated as a failed attempt rather than a delivery, since the module builds a JNI-backed payload outside its own `catch` and an `Error` escapes it regardless. - - **Redelivering a stale one-shot event would be the same bug inverted**, so held entries are stamped with a session generation, and the two lifecycle transitions are kept apart: `start()` *begins* a session (discard what the last one left, take what follows) while `destroy()` *ends* one (discard, and stop holding at all until the next `start()`). Clearing the buffer on `destroy()` is not sufficient on its own: the teardown that produces `mesh_stopped_by_user` runs on its own thread and emits only once every transport has stopped, so an app calling `destroy()` in that window would have its buffer cleared and then refilled by the emit that was already in flight — handing a terminal mesh event to whichever session subscribed next, telling it the mesh was down while it was coming up, with nothing to restate it. Both write paths (a hold arriving late, and a flush restoring entries it could not deliver) refuse anything from a generation that has ended, and a flush re-checks per entry as it goes, since `drain` removes on read and leaves the buffer nothing to refuse with once delivery is under way. The stamp alone covers only the instant between an emit's generation read and its hold, though — a notification Stop that began *before* the `destroy()` emits *after* it, since the two share the teardown lock — which is why ending a session closes the buffer rather than only bumping the generation. Nothing is lost by refusing in between: both enrolled events require a transport that `start()` is what brings up. A hold also re-triggers the flush, since a subscribe that opened the gate microseconds earlier would have found the buffer still empty and left nothing scheduled to collect it. - - `internet_session_superseded` is enrolled in the same set: `InternetManager` latches the transport stopped on relay displacement and refuses auto- and force-reconnect until an explicit `start()`, so a dropped emit left the app showing a relay connection that was never coming back. That explicit re-enable is also the one way a sticky event goes stale *inside* a live session, which the generation stamp cannot see: `enableTransport('internet')` restarts the manager and clears the supersede latch, so any held copy stops reporting an unrestated session and starts reporting one that has just been replaced — redelivered on the next subscribe or foreground it would tell an app with a live relay socket that it is connected elsewhere. The re-enable therefore drops it. Periodic events are deliberately **not** enrolled — a held `internet_status_changed` replayed minutes later reports a link state that has since changed, which is worse than the drop it replaced. The *hold* is Android-only: `mesh_stopped_by_user` has no iOS counterpart, and mirroring a buffer there would be an abstraction with one caller. The **stale-listener-flag race is fixed on both**, because that half is not Android-specific — iOS gated the same event behind a plain non-atomic `Bool`, written from `startObserving`/`stopObserving` on RCTEventEmitter's queue and read from `InternetManager`'s socket callback with no happens-before edge between them. It is now lock-guarded, mirroring Android's `AtomicInteger`. The integration guide states the remaining asymmetry rather than implying both platforms hold. - - **Known gap, stated plainly:** the buffer is in-memory and per-React-instance, so a JS reload or a process kill loses a held event, and persisting it would not help for the reason above. Apps that must be right across that window reconcile with `getState()` on foreground, which is now documented as the belt-and-braces every integrator should have regardless. Covered by `StickyEventBufferTest` (24 cases, including that a restore cannot resurrect a stale copy over a newer event that landed mid-flush, that a restored entry still redelivers *ahead* of one that landed while the flush was carrying it, that neither a late hold nor a late restore can survive the session that produced it, that a hold is refused between a session ending and the next one beginning, and that concurrent holds and drains lose and duplicate nothing) and `StickyEventDispatcherTest` (19 cases pinning the three orderings above — the generation read, the flush a hold re-runs, and the queue hop — plus both throw paths, that a throw from the retry cannot replace the emit failure that caused it, the stale-copy drop, the drop on an explicit internet re-enable, that two queued flushes deliver one held event exactly once, and a session change landing under an in-flight flush, none of which are reachable through the module, since `react-android` is `compileOnly` in the test harness). The two changes that carry the *common* cases — the `AtomicInteger` and the `hasActiveReactInstance()` precondition — have no automated coverage at all for that same reason: they live on a class that cannot be instantiated without a React runtime, so the well-tested half of this change is the defence-in-depth half. - -- **Android: the mesh keep-alive service could take the whole app down on cold start, and the notification offered no way to stop the mesh.** (#278) Android gives an app five seconds after `startForegroundService()` to reach `startForeground()`. `MeshForegroundService` called it from `onStartCommand`, which on a cold start or a resume sits behind JS-thread initialisation and main-thread work and can miss that deadline — the OS then terminates the process with a fatal `RemoteServiceException`. It was the top crash of its class on at least one common device (Redmi Note 8 Pro, Android 11) and had been shipping in one app as a `patch-package` override for months before reaching this repo. The promotion moves to `onCreate`, where nothing the app does afterwards can delay it; the later `startForeground()` calls in `onStartCommand` are idempotent re-promotes on the same instance. - - The notification also gains a **Stop** action, and what it is wired to is the part worth reading. This service is *only* a keep-alive — the module owns the protocol and the transports — so routing Stop back into the service's own `ACTION_STOP` handler would have cleared the notification and the foreground protection while BLE, Wi-Fi Direct, Nostr and the process scheduler kept running, with nothing told to JS: the user sees "mesh off" while the radios go on draining the battery until the OS gets around to reaping the process. It therefore hands off to a host callback that runs the same teardown as the JS-facing `stop()` and emits `mesh_stopped_by_user`, and the service stays up until that teardown comes back around — clearing the notification while the mesh is still running is the exact lie being fixed here. With no host registered it still drops the keep-alive, because a dead button is its own kind of bug. All three promotion sites now share one guarded helper, since only `onCreate` was wrapped and a `connectedDevice` promotion throws once Nearby-Devices permissions are revoked — so the guarded failure came straight back uncaught a few milliseconds later. - -- **Android: one throwing transport stop skipped every teardown step after it.** (#290) The shared mesh teardown stopped five transports inside a single `try`. BLE goes first, and its stop reaches `stopScan` on an adapter the user may have just switched off, which throws `IllegalStateException` back across `runOnMainSync` — so Internet, Wi-Fi Direct, Reticulum and Nostr kept running while the `finally` tail removed the notification and the Stop path told JS the mesh was off. That is precisely the state the Stop handoff exists to prevent. Every step now runs through a `TeardownSequence`: each runs regardless of the ones before it, failures are collected in order, and the first is rethrown once the rest are down, so `stop()` still rejects with the same cause. `invalidate()` uses it too — it keeps its inline, unsynchronized teardown, because routing it through the shared `@Synchronized` path would be a lock-order inversion against React Native's own teardown lock — and errors, as opposed to exceptions, propagate rather than being collected, since an `Error` means the process is already in trouble. `destroy()` now shares the path instead of carrying a third copy of the sequence, which also brings the keep-alive down: a `destroy()` with no preceding `stop()` used to leave a "Mesh Active" notification standing over a protocol that no longer existed. - - **The stop-callback slot is also cleared by identity now.** It is process-global while hosts are per-`ReactContext`, and a React reload overlaps the two: an outgoing module invalidating *after* its replacement had already registered nulled the **live** host's callback, leaving the notification's Stop button to drop the keep-alive over a running mesh. Registration and clearing go through a synchronized register/clear pair on the service, and a module clears only its own registration. - -- **Android: a `START_STICKY` restart re-posted "Mesh Active" over a protocol nothing could rebuild.** (#294) The null-intent branch re-promoted unconditionally, so a process kill left one of two bad outcomes: a keep-alive notification claiming a live mesh in a process that has none, or — on `targetSdk` 31+ — a swallowed `ForegroundServiceStartNotAllowedException` leaving an empty process squatting with no notification at all. The branch is now gated on the stop-callback slot, which the module registers immediately before starting the service and clears by identity on every teardown path, so a non-null slot means a live host that believes mesh is running. No host: stop and return `START_NOT_STICKY`, so the system does not hand the same restart straight back. What made that gate mean anything is the other half of the change — the slot was previously cleared only when the *module* died (`invalidate()`, `destroy()`), while the JS-facing `stop()` and the notification Stop action tore the mesh down without touching it, so after any ordinary start/stop cycle it went on claiming a mesh that was not there. `stop()` now surrenders the caller's registration by identity, ahead of the nothing-to-stop guard, because a host whose service is already down still has to deregister. `onCreate` still promotes unconditionally: a tap on a stale notification re-creates the service through `startForegroundService()`, and that promotion is the only thing satisfying the five-second deadline — the residual is a silent `IMPORTANCE_LOW` tray blip between `onCreate` and the stop. The wake path built in #307 (above under **Added**) is what now fills the no-host branch, rather than the native re-init that the removal of `onServiceRestarted` (under **Changed**) refuses. - -- **Android: toggling Bluetooth off mid-session crashed the host app.** (#279) `BleTransportFacade.startScanning` and `stopScanning` caught only `SecurityException` around their `BluetoothLeScanner` calls. The framework also throws `IllegalStateException("BT Adapter is not turned ON")` from both when the adapter transitions off between the facade's `isScanning` check and the scanner call — and the scan watchdog's restart path hits that the moment the user flips Bluetooth off. Four users hit it on the pre-patch build of one shipping app before the fix went out as a `patch-package` override. Both sites now handle it next to the existing `SecurityException` handler, `stopScanning` mirrors its post-stop cleanup inside the handler so state cannot hang half-torn-down (a stranded `isScanning = true` blocks the next `startScanning` at its guard), and the diagnostics are emitted at info level so the frequency of the adapter-off race is visible without polluting error dashboards. - -- **Android: BLE scanning never came back after the adapter did.** (#293) Not crashing on the adapter-off race left the second half of it open: nothing re-arms scanning once the exception is swallowed, because the preceding `stopScanning` has already cancelled the watchdog and the connection monitor, and `scheduleScanWatchdog` only runs on a *successful* start. Any Bluetooth-off period longer than the 30s watchdog interval — the common case, not a narrow race — left the mesh deaf while the transport still reported `RUNNING`, recoverable only if the app happened to call `resume()` or restart the transport. - - A deduped 10s recovery retry is now armed from every `startScanning` exit that fails to get a scan going, and cancelled by `stopScanning` so a paused or stopped transport cannot put itself back on air. It re-reads the scanner and advertiser (both null while the adapter is off, and the adapter-reset path re-attaches whatever it reads) before retrying. Restoring the scan is what restarts the whole self-healing chain, since the watchdog, connection monitor, refresh paths and adapter reset all hang off an active scan. **The asynchronous half of "the scan didn't start" is covered too**: `onScanFailed` cleared `isScanning`, cancelled both timers and scheduled nothing — the exact terminal state the retry exists to prevent — and is reachable on any device through internal-error and registration-failed, so it is now routed through `stopScanning` rather than clearing the flags by hand. - - Three sharp edges are filed down in passing. The adapter is checked before `startScan`, so the steady state of a switched-off adapter is a throttled info diagnostic per minute rather than a caught exception every heartbeat. Both scan-site catches are narrowed to the framework call — the wide catch swallowed any `IllegalStateException` from the whole function body and reported it as an adapter-off, including this file's own `assertMainThread`, a `check` that exists to fail loud. And `LeAdvertiser` had the same uncaught throw at both LE entry points, reachable from bare handler posts where nothing catches it, so the sibling crash had survived #279; its start path also lowers the in-flight gate on a throw and bails on a null advertiser, since that gate is otherwise only lowered by `stop()` or a terminal `onStartFailure`, neither of which runs when the call throws. - -- **Android: `BluetoothDevice.connectGatt` returning null crashed with a `NullPointerException`.** (#280) The framework declares it `@Nullable`; Kotlin sees a platform type, and `MeshConnectionRegistry.registerGatt` takes a non-null `BluetoothGatt`, so a null return tripped the compiler-inserted `checkNotNullParameter`. It returns null when the adapter has just turned off, when the underlying hardware handle is stale, or when the device unbonded between scan and connect — the same adapter races that motivate the `SecurityException` handler already at the tail of that function. Three users, three events on the pre-patch build. The return is now guarded, the pending role reserved for the address is released (mirroring the RSSI-skip, connection-cap and `SecurityException` paths, so state does not hang half-set-up), and an info diagnostic makes the rate visible. - -- **A Nostr event with a malformed `pubkey` aborted the process.** `ConversationKey::derive` handed the wire-supplied x-only key straight to the BIP-340 decoder, which takes a fixed-size array internally and **panics** rather than erroring on any length other than 32 bytes. The key is a relay event's `pubkey` field, and anyone may publish an event to a public routing tag — so a single record carrying a short-but-valid-hex pubkey and a plausible payload was a remote crash, reachable on the ordinary sealed-frame receive path (`unseal_event_payload`) since gift-wrap sealing shipped. The length is now checked before the decoder sees it, making a malformed key an ordinary decrypt failure, which every caller already handles. Covered by `derive_rejects_a_peer_key_that_is_not_32_bytes` and `test_malformed_event_pubkey_does_not_abort_the_receive_path`. - -- **Nostr no longer publishes the protocol envelope in cleartext — outgoing frames are sealed into NIP-59 gift wraps.** The transport handed `serialize_message` — the *entire* `Message` JSON — to the relay, base64'd into the event `content`. Only the inner `content` field was MLS ciphertext. Everything wrapping it was readable by every relay, permanently: - - ```json - {"id":"…","sender":"alice_real_username","recipient":"bob_real_username", - "app_id":"example-app","priority":"medium","ttl":8,"hop_count":0, - "timestamp":1785919090277,"lamport_clock":0,"content_type":"text", - "content":"__MLS_ENC__…","metadata":{…},"requires_ack":true} - ``` - - Both usernames, the app id, the app's metadata map, the content type and a millisecond timestamp — a durable social-graph disclosure to third-party-operated, archival infrastructure. The root cause was reusing the mesh wire envelope, a format built on the assumption that its reader is the next hop: a semi-trusted peer that legitimately needs the routing fields. A relay is not a hop. - - Outgoing frames are now [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) gift wraps (kind `1059`) with [NIP-44 v2](https://github.com/nostr-protocol/nips/blob/master/44.md) inner encryption, each signed by a **freshly generated single-use key** and stamped with a `created_at` jittered uniformly up to an hour into the past. A relay now sees only: the kind, an unlinkable per-event pubkey, the recipient's opaque routing tag, a coarse timestamp, and ciphertext. Nothing identifies either party, and no two events this device publishes are linkable to each other. - - Conforming to NIP-59's wrapper rather than inventing a sealed format was the decisive choice: **a custom event kind would have let one relay filter enumerate every Offline Protocol user**, turning a per-user privacy bug into a userbase-enumeration primitive. As kind 1059 an individual event is indistinguishable from an ordinary NIP-17 DM. (Later correction, for anyone testing that claim: it holds for a scrape *by kind*, not for a relay you subscribe on — the client's own `REQ` names its routing tag, so that relay learns the tag is the connected client's without any correlation work. `docs/nostr.md` §"What a relay can see" now enumerates this and the other distinguishers.) (NIP-59's inner kind-13 seal layer is skipped — the inner `Message` already names the sender and MLS authenticates it — which is invisible on the wire, since relays only ever see the wrapper.) - - **The NIP-44 implementation is asserted against the official vectors.** All 35 conversation-key vectors, 32 message-key vectors, both encrypt/decrypt sets, and every invalid-input vector run in-tree, plus the extended-length-prefix vectors from the spec body. The vector file is vendored and pinned by CI to the sha256 the spec publishes, so a green suite cannot be achieved by editing the fixture. Both length-prefix forms are implemented: the 6-byte extended prefix (added to NIP-44 on 2026-06-28, under the *same* version byte, so there is no negotiation and no way to detect support) sits at exactly 65536 bytes — the same boundary as this transport's own event cap. *Upstream inconsistency worth knowing:* `invalid.encrypt_msg_lengths` in the vector file still lists 65536 and above as invalid, because the vectors were not regenerated when the extended prefix landed. The spec body and its own extended-prefix vectors are authoritative; only the zero-length case is asserted from that list. - - **First contact.** Sealing needs the recipient's Nostr public key, which now rides in the signed key package (`nostr_pubkey`, persisted in the peer capability record so it survives restarts — the cached key package is deleted once a session exists, so without that a restart would silently downgrade every known peer). Before that exchange, the frame is sealed to the recipient's *publicly computable* key instead. That is **bulk-collection resistance only** — a relay scraping everything cannot read it, but anyone who guesses the recipient's username holds the matching private half. It was chosen over refusing to send (which makes a stranger unreachable over Nostr) and over falling back to a cleartext kind-4 event (which a one-line relay filter would turn into "show me who is just starting to talk"). On the wire the two cases are identical. One exchange in each direction upgrades the conversation to keys only the two installs hold. - - The computable keypair is used for **nothing else**, and must not be: its private half is public by construction, so it must never back NIP-42 AUTH or any authentication decision. Sender authenticity comes from the protocol-layer Ed25519/TOFU gate and MLS, neither of which consults it. - - **Compatibility — asymmetric, read this before enabling Nostr on a mixed fleet.** *Inbound* is fully compatible: the receive path unseals gift wraps *and* parses legacy unsealed frames unconditionally, and the subscription requests kinds 4 and 1059 permanently, so a peer on an older build can still reach us. *Outbound to an older peer does not work while sealing is on.* A pre-sealing build's REQ filter is `{"kinds":[4]}`, so a relay never delivers our kind-1059 events to it, and it has no NIP-44 layer to unseal one with. This is not silent: nothing is ACKed, so the send fails through the normal retry ladder and DORS demotes the transport, rather than the message vanishing. There are no deployed peers to break today, which is precisely why this ships now — but the first release carrying it is effectively a Nostr flag-day, and reaching a not-yet-upgraded peer over Nostr requires `nostr_sealing_enabled = false` on the sender until they upgrade. - - Sealing is gated by `nostr_sealing_enabled` (core `TransportConfig`, UniFFI, and RN `transports.nostr.sealingEnabled`), default **on**; unlike the negotiated wire and envelope switches it needs no peer capability, so it is safe to flip on a single device *among sealed-capable builds*. Rollback leaves no unreadable state. - - **Bridge change:** `nostrGetPublicKey()` is **no longer usable as a self-event filter.** Sealed events are signed by a throwaway key each, so an inbound `pubkey` never matches it — and by design nothing on a gift wrap identifies its author. The bundled bridges keep the comparison for the legacy form only; self-delivery is prevented by the `#p` filter and, for self-addressed messages, by message-id dedup. Bridges that rely on that comparison for anything else need reviewing. - - **Cost.** NIP-44 pads to a power-of-two bucket, so a payload just past a boundary nearly doubles before the MAC and base64 — considerably more than base64's ~33% alone. The 64 KiB cap is measured on the final sealed event, so a message that fits unsealed may not fit sealed. Separately, NIP-59 concedes that ephemeral outer keys defeat relay pubkey-based anti-spam, so public relays may rate-limit unknown-key events more aggressively; worth measuring against the configured relays before enabling Nostr in production. - - **Not addressed, stated plainly.** Traffic to `SHA-256(userId)` remains observable to an adversary who has already guessed a username — volume and timing, not content or counterparty. Rotating rendezvous tags would close that and are deliberately deferred: tying addressing to MLS epoch state turns a desync from "fails to decrypt" into "peers become mutually unreachable", trading a metadata leak for a silent-delivery-failure class. - - Separately, a **cached peer key can go stale with no feedback**. If a peer wipes their storage their Nostr key rotates, and frames sealed to the key we cached become readable by nobody — the transport has no delivery signal that would reveal this, since an unsealable frame is indistinguishable from one addressed to someone else. On a Nostr-only path that direction stays dark until the peer's new key package arrives by another route. A wipe also destroys the peer's MLS session, so the conversation needs rebuilding regardless, and contact over any other transport (or a peer-initiated Nostr message) heals it; the unblock clean slate now clears the cached key explicitly, reverting to the bootstrap key rather than a dead one. Publishing key packages as fetchable relay events — so a sender resolves the peer's current key instead of trusting a cache — removes the class outright and is the planned follow-up. - -- **The Nostr subscription now bounds replay with a persisted `since` watermark, not just a `limit`.** The previous change capped how much history one reconnect could pull down; it did not stop that history being the *same* history every time. A device that reconnects a hundred times still re-fetched the same slice a hundred times, and the cost of that is not bandwidth — replayed past-epoch MLS ciphertext classifies as `SessionDesync` and drives re-key churn, or, with no session at all, `PendingDecryptionQueue` churn. - - The transport now keeps a **receive watermark**: the newest event `created_at` this install has accepted. Every REQ filter carries `since` derived from it, so a reconnect asks only for what it has not already seen. The mark is persisted as a protocol-state record (`nostr_watermark`, unsealed — it is one coarse timestamp) on the same debounce-and-flush template as the Lamport clock: written once the live mark has advanced 5 minutes past the last write, flushed on `stop()`, `pause()`, and drop, and restored on launch. An un-flushed gap can only *widen* the next window, never skip messages. - - Three rules make the mark safe to act on, and each closes a way it could otherwise fail: - - - **`since` sits an hour and five minutes below the mark** — the gift-wrap jitter window plus a clock-skew margin. `created_at` is written by the sender, so an event published now can carry a timestamp in the recent past (deliberately so, once the sealed envelope lands and jitters it); a `since` sitting exactly at the mark would filter out the very events the query exists to fetch, with no error anywhere. NIP-01's `since` is inclusive, so the boundary event is re-delivered once. - - **A future-dated event cannot advance the mark.** The routing tag is `SHA-256(userId)`, so *anyone* who knows a username can publish an event addressed to it, and `created_at` is whatever the publisher wrote. One event dated far ahead would otherwise pin the mark there and make every subsequent subscription ask for events `since` the far future — receiving nothing, permanently, with nothing raised. Values more than 15 minutes ahead of local time are ignored rather than clamped, and the restore path runs through the same check so a record written by an older build (or edited in the app container) cannot stall a subscription either. - - **Only frames that decode into a protocol message advance it.** The mark means "receive progress has reached here"; a frame that did not parse was never processed. Counting it would let junk addressed to the routing tag drag the window past real messages the relay still owes us. The failure mode of the stricter rule is replaying more, never less. - - With no watermark — a fresh install, a `wipePersistedState` logout, or any subscription built before protocol-state storage has been restored — `since` falls back to 24 hours ago. Never zero: an absent or zero `since` is precisely the unbounded filter this removes, and the no-watermark case is common rather than exotic, since the bridges subscribe on every relay connect and that can precede `initialize_mls`. - - **Bridge change:** inbound events should now go through the new `nostrMessageReceivedAt(senderId, data, createdAt)` entry, passing the event's `created_at` verbatim. Both React Native bridges do. The timestamp-less `nostrMessageReceived` remains and still delivers normally — it simply never advances the watermark, so a bridge that keeps calling it re-fetches a full backfill window on every reconnect. - - **Residuals, stated plainly.** The mark can only be as good as what has been received. A relay that truncates a reconnect's history at `limit` returns its *newest* events, so a device coming back to more than 500 stored events advances past ones it never saw. The hour-plus overlap bounds which are at risk — only those already older than jitter + skew at the moment of truncation. That truncation is also reachable on purpose: the routing tag is public and only *decodability* gates the watermark (parsing a `Message` needs no signature), so a sustained flood of decodable junk can both crowd real events out of a truncated query and advance the mark past them. What keeps that recoverable is that Nostr is not the only path — ACK-gated messages sit in the sender's outbox for 7 days and are retransmitted with a fresh `created_at`, which lands above any watermark — so the cost is delay on a Nostr-only route, not loss. - - The replayed overlap is also **not fully deduplicated**, which is a cost rather than a correctness issue but worth stating: message-id dedup retains ids for an hour by default while `since` reaches back an hour and five minutes, so a reconnect after longer than the retention window (an app reopened the next day) re-processes its overlap instead of absorbing it. A replayed ciphertext whose ratchet generation is spent fails closed and is dropped, a past-epoch one triggers at most one rate-limited re-key, and a replayed group copy TTLs out of the pending buffer. The two constants are pinned against each other by a test so this note cannot silently go stale. - - And the watermark bounds *replay*, not metadata exposure: the envelope is still published in cleartext, which the sealed gift wrap addresses separately. - -- **The Nostr subscription no longer replays a relay's entire retention window on every reconnect, and oversized events are dropped before they reach a relay.** Two independent unboundedness bugs in the Nostr transport, both of which only bite once `nostr.enabled` is turned on (it defaults off). - - The NIP-01 REQ filter was `{"#p": [tag], "kinds": [4]}` — no `since`, no `limit` — and the platform bridges re-send it on every relay (re)connect. A relay is therefore free to return every stored event addressed to us, from the beginning of its retention, each time the socket comes back. Nothing downstream bounds that: the transport dedup window is 1000 ids / 1 hour, there is no wall-clock freshness check on the receive path (`Message.ttl` is a hop count, not a lifetime), and replayed past-epoch MLS ciphertext classifies as `SessionDesync`, which drives re-key churn. The filter now carries `limit: 500` (`NOSTR_INITIAL_QUERY_LIMIT`). NIP-01 scopes `limit` to the initial query — relays must ignore it once streaming live events — so it caps the history each reconnect pulls down without capping ongoing delivery. It is advisory in both directions: `limit` is a SHOULD, and NIP-11 `max_limit` lets a relay clamp it silently, so a short result set is not evidence the relay had nothing more. The persisted `since` watermark that bounds replay rather than merely capping it landed separately — see the entry above. - - `NOSTR_MAX_PAYLOAD_SIZE` (64 KiB) had been defined since the transport landed and referenced nowhere, so outgoing events had no size cap at all — while DORS gives Nostr a `media_bonus` of 30.0, which actively routes media there. `get_next_signed_event` now measures the complete `["EVENT", {...}]` relay message against it, not the protocol message inside it: the relay message is what a relay accepts or rejects, and it is larger by the base64 overhead (and, once the envelope is sealed, the encryption overhead), so capping the inner payload would let events onto the wire to be rejected on arrival. An over-cap event fails with `Error::MessageTooLarge` and is dropped **permanently on the first attempt** rather than entering the three-attempt signing retry — no number of attempts shrinks it, and re-queueing it at the front of the queue head-of-line-blocks every message behind a frame no relay will accept. The drop is now also counted as a transport failure, so DORS sees it; the pre-existing permanent-failure path routed through `report_send_failure`, which is keyed on a pending-confirmation entry the message never had, and was silently a no-op. - - **Media over Nostr fails fast now instead of silently.** `Message::binary_content` carries chunk bytes with no base64 serde adapter, so a chunk serializes as a JSON array of decimal numbers (~3.6×) before the event's own base64 (~1.33×) applies on top. At the engine's 32 KiB `DEFAULT_CHUNK_SIZE` that is ~156 KB on the wire — 2.4× this cap and past the 64–128 KB relays typically accept, so those events were never deliverable; they were simply rejected at the relay with nothing in the SDK noticing. They now fail at the transport with a typed error and a DORS-visible failure, which steers subsequent routing away from Nostr and lets the outbox retry ladder try another transport. A 4 KiB chunk (`CHUNK_SIZE_BLE`) still fits, so this bounds the default chunking for media over Nostr rather than forbidding media over Nostr. Both sizes are pinned by `test_default_size_media_chunk_exceeds_the_relay_cap`. - -### Removed - -- **The native transport managers' `getMetrics()` and the metrics half of the `TransportManager` abstraction are gone from both bridges — dead scaffolding with no caller, ever.** `getMetrics()` was a `TransportManager` protocol/interface requirement implemented by all ten native transport managers across iOS and Android, and nothing had **ever** called it: no Swift, Kotlin, ObjC or TypeScript call site existed at any commit in the repo's history. Its paired delegate callbacks (`didUpdateMetrics` / `onTransportMetricsUpdated`) were likewise never fired by any manager, leaving five handler implementations unreachable. No app behaviour changes, because no app could reach any of it. ([#300](https://github.com/Offline-Protocol/offline-protocol-sdk/issues/300)) - - **This is a road the architecture had already declined twice.** The app-facing metrics API is `getTransportMetrics()`, which resolves through the Rust core and carries an entirely different, typed vocabulary (`packetsSent`, `errorRate`, `avgLatencyMs`) — it never touched these managers. And each time a genuine need arose to read native-manager state from JS, it was met with a narrow, purpose-named accessor instead: `isInternetReady()`, and then `isInternetSuperseded()`, which 0.19.0 added *in preference to* wiring up `getMetrics()`. The 0.16.2 note that the relay-superseded work meant "`getMetrics()` exposes `is_superseded`" was true of the class and empty as an app-facing statement; that correction shipped with `isInternetSuperseded()` and this removes the surface behind it. - - **The counters went with it.** Every byte/message/fragment counter on all ten managers had `getMetrics()` as its sole reader, so each was write-only once it left — roughly forty fields fed by fifty-odd increment sites, several paying real per-message synchronization for a value nobody read: lock-guarded computed-property pairs on iOS Nostr/Reticulum, four `AtomicCounter`s on iOS Internet, `AtomicLong`s across the Android managers, and a cross-queue fold in iOS `BleManager.pumpNotifyOutbound` that dispatched to `fragmentQueue` purely to add them up. iOS WifiDirect's `isAdvertising`/`isBrowsing` and Android WifiDirect's `isDiscovering` were the same story — bookkeeping beside the real MultipeerConnectivity / `WifiP2pManager` calls, read only by `getMetrics()`. - - **Thread-safety comments now name real readers.** Several fields justified their synchronization with "read best-effort off-main (`getMetrics`)" — a reader that did not exist, asserted in exactly the places where such reasoning has to be trusted. Each is corrected to the actual reader: iOS `SupersededLatchPolicy.latched` is genuinely main-confined (both public accessors hop through `runOnMainSync`), while the Kotlin mirror's `@Volatile` **is** load-bearing, because `internetIsSuperseded` reads the latch straight from an RN thread with no main hop. iOS `InternetManager.reconnectAttempts` becomes a plain main-owned var to match its sibling `currentReconnectDelay`: an audit of all five writes and both reads confirms main confinement, and the `stateLock` guard existed only for the deleted reader. - - `TransportState` and `TransportError`/`TransportException` live in the same files and are used throughout — those are untouched, as are the state and diagnostic delegate callbacks. Native symbols are technically visible in the source-shipped npm package, so an out-of-tree native consumer calling `getMetrics()` directly would need to drop the call; nothing in the SDK or its known consumers does. - -### Documentation - -- **The integrator-facing docs for `cryptoRecoveryEnabled` overstated the guarantee, and now state the real threat model.** `docs/mls-integration.md`, `docs/configuration.md`, `docs/api-reference.md`, the `EncryptionConfig::crypto_recovery_enabled` rustdoc, the UDL comment, and the RN `types.ts` doc comment all said genuine decrypt failures — "corrupt/forged ciphertext" — still fail closed, so the SDK "never re-keys on injected garbage", and framed the residual as replay-only: an attacker holding a genuine peer's captured old-epoch ciphertext. That is wrong, and the SDK's own test suite has said so since 0.19.0: `test_forged_frame_reaches_session_desync_without_any_key_material` hand-serializes a frame with no key material, no captured ciphertext and no session, and asserts it reaches the recoverable classification that drives a re-key. An MLS epoch is validated as part of *framing*, before any AEAD, sender-data or signature check; `__MLS_ENC__` is a data-plane prefix exempt from the signed control gate; and a 1:1 slot id is `session::` over two public user ids — so **anyone who can inject a frame can drive a rate-limited re-key**. This is inherent to MLS framing, not an OpenMLS defect, and no sender check can cover it: the credential such a check compares against only exists once decrypt succeeds. - - Nothing about the shipped behaviour changes, and nothing new is exposed — the consequences were already bounded and made observable when the trigger was hardened in 0.19.0 (slot binding, the 30 s per-peer floor, a heal that keeps queued plaintext, and the `SESSION_REKEY_TRIGGERED` warning). That work corrected the in-code SECURITY note and the internal architecture notes but left every public surface carrying the old claim, which is the actual defect being fixed here: an integrator reading the docs would have concluded the trigger was authenticated and treated a sustained `SESSION_REKEY_TRIGGERED` rate as a bug rather than as the injection signal it exists to be. The docs now say plainly that the trigger is unauthenticated by construction, that the mitigation is that acting on it is *harmless* rather than that it is trusted, and that the residual is bounded re-key churn on one pair — delivery delayed, never lost — closable only by a signed epoch-corroboration exchange, which is future work. The 0.16.5 entry below is left as written, as the record of what was believed at the time; this entry supersedes its security note. The two in-crate comments at the classification site (`MlsError::SessionDesync`, `group.rs::process_message`) gain the same correction, since they sat exactly where a future maintainer would look to decide whether the classification is trustworthy. Documentation and comments only; no behaviour, wire, or API change. - -- **`docs/nostr.md` now carries the relay anti-spam caveat that sealing creates.** Raised when the gift-wrap sealing landed (#288) but never written down anywhere an integrator would find it. Every sealed event is signed by a fresh single-use key — that is what makes our events mutually unlinkable — and NIP-59 concedes the direct consequence: ephemeral author keys defeat the pubkey-reputation anti-spam public relays rely on, so a relay may rate-limit, deprioritise, or reject never-before-seen keys more aggressively. Every frame this transport publishes looks new by design, so a relay applying that policy applies it to all of them. The SDK cannot detect this on an app's behalf, since a relay that drops an event without an `["OK"]` is indistinguishable from a slow one and the send simply fails through the pending-confirmation timeout. The guidance is therefore to measure real traffic against the *configured* relays before enabling Nostr in production, and to reach for a relay whose policy is known rather than for the sealing kill switch — unsealed frames publish the whole envelope in cleartext. Troubleshooting gains the matching symptom (intermittent loss that scales with send rate). Documentation only; no behaviour change. - -- **The export notice now names both recipients of the §742.15(b) notification.** [15 CFR §742.15(b)](https://www.ecfr.gov/current/title-15/subtitle-B/chapter-VII/subchapter-C/part-742/section-742.15) requires notifying *two* parties of the Internet location of publicly available encryption source code — BIS at `crypt@bis.doc.gov` **and** the ENC Encryption Request Coordinator at the NSA, at `enc@nsa.gov` — and the notice as shipped in 0.19.0 named only BIS. Both were notified; only the document was incomplete. Since the whole "not subject to the EAR under [§734.7(b)](https://www.ecfr.gov/current/title-15/subtitle-B/chapter-VII/subchapter-C/part-734/section-734.7)" conclusion is conditioned on that notification, a notice that describes it as half-made understates the basis for the conclusion it draws. The paragraph addressed to app teams whose own application is open source — for whom the notification duty on their own source location is theirs — now says the duty runs to both addresses, since a developer following that sentence would otherwise file with one. No code, packaging, or license-surface change: `EXPORT.md` still ships in the npm package, the Python wheel, and the GitHub release assets, and the three copies stay byte-identical under `scripts/check-license-consistency.sh`. - -## [0.19.0] — 2026-08-04 - -> **Two receive-side behaviours tighten on first launch.** Cleartext from a peer -> you hold an MLS session *or* a TOFU pin for is now rejected even when that -> session was never confirmed — this only affects `encryption.enabled = true` -> with `requireEncryption: false`, the mixed-mode opt-out. And key packages -> cached by earlier builds are unsealed, so they are dropped on first launch and -> re-exchanged automatically. Neither fails initialization; both are described in -> [`docs/UPGRADING.md` §1.10 and §1.11](docs/UPGRADING.md#110-the-inbound-plaintext-gate-no-longer-reads-session_states). - -### Changed - -- **The workspace crates can now actually be packaged for crates.io.** The seven internal dependencies in `[workspace.dependencies]` carried a `path` but no `version`, and cargo refuses to package a crate whose dependencies have no version requirement ("all dependencies must have a version requirement specified when packaging") — so only `offline-protocol-core`, the one crate with no internal dependencies, could be published at all. Every internal dependency now declares `version = "0.2.0"` alongside its path; path resolution is unchanged for workspace builds, and the version is what cargo rewrites into the published manifest. - -- **The public error enums are `#[non_exhaustive]`.** `Error` (core, transport, router, reliability, engine), `MlsError`, `StorageError`, `ServiceError`, `IdValidationError`, `ChunkDecodeError`, `ProtocolStateError`, and `SessionStateError` can now gain variants without that being a breaking change for downstream crates. Rust code outside this workspace that matches on them exhaustively must add a wildcard arm. The FFI enums (`ProtocolError`, `MlsStorageError`) are deliberately unchanged — they are decoded positionally by the generated bindings and remain governed by the stricter append-only rule. - - This costs the compile-time exhaustiveness guard on the UniFFI error mapping, which existed so that a new engine error variant could not silently degrade to `Other`. `#[non_exhaustive]` makes cross-crate exhaustiveness impossible on stable, so the guard is replaced with a runtime one: the wildcard arms log a `warn!` naming the unmapped variant, making taxonomy drift visible in logs and telemetry rather than invisible. New engine error variants must still be given an explicit arm. - -- **Each publishable crate carries its own `LICENSE` copy instead of pointing at the workspace root.** Setting both `license` and `license-file` made cargo warn "only one of `license` or `license-file` is necessary" on every invocation, for all eight crates. Dropping `license` instead would have cost the SPDX identifier that crates.io displays and cargo-deny classifies on, so the license text now lives at each crate root, where cargo picks it up automatically. `scripts/check-license-consistency.sh` asserts the copies stay byte-identical to the root `LICENSE`, replacing its previous `license-file` assertion. - -### Fixed - -- **A peer's `session_reset` no longer deletes the messages queued for them.** Receiving a `session_reset` key package discarded the entire outbound pending queue for that peer, on the stated grounds that its entries were "encrypted for the old session". They are not — `PendingMessage.content` is original plaintext, and `flush_pending_messages` seals it through `prepare_outbound_content` at flush time against whatever session is current *then*. The reset is precisely what produces the session those entries would have sealed against, so the drop destroyed messages that were one flush away from being deliverable. It settled nothing either, so the application was never told: it kept ids returned by `send_message` that could no longer resolve in either direction. And because a re-key is triggerable by an injected frame, this deletion was reachable with no key material. The queue is now kept. The sibling `pending_queue.drain_for_peer` on the *inbound* side stays, because those really are ciphertexts sealed to the deleted session. - -- **Dropped pending messages are settled instead of handed back and discarded.** `drop_pending_queue_for_peer` returned its dropped entries for the caller to settle, and all three callers threw them away — so blocking a peer or aborting a pending session deleted messages the app still held `send_message` ids for, with no `message_failed` and no other signal. The two sibling drop paths (the blocked branch of `flush_pending_messages`, and the pending-expiry pass) both settle each id for exactly this reason. Settlement now happens inside the function against a caller-supplied reason, and it returns a count, so there is no longer a value a caller can drop on the floor. The pending-expiry watermark is recomputed too; the old whole-queue removal left it pointing at entries that no longer existed. - -- **The blocked-user restore walk is bounded again, by both of the limits that apply to it.** `restore_blocked_users` was bounded by `MAX_RESTORE_KEYS_PER_CATEGORY` (16384) while `block_user` enforces `MAX_BLOCKED_USERS` (10000) — two different numbers doing two different jobs, and the walk bound let the restored set reach a state the live path cannot produce, after which `block_user` refused every new block with "limit reached" until the user manually unblocked someone. Since `blocked_users` is a category restore never consumes, planted records came back on every launch, so an attacker able to write the app container could permanently disable blocking by adding markers — without unblocking anyone. Stopping at the live cap keeps the blocks already restored, which is the fail-closed direction. The first fix then replaced the walk bound rather than joining it, which was its own bug: `MAX_BLOCKED_USERS` bounds the *set* and the loop only reaches it by inserting, so entries that never enter the set — an invalid user id, a planted self-block — `continue` straight past, and a container stuffed with unparseable keys walked every last one at one `warn!` line each, on every launch. Both bounds now apply, with the tail warning restored. Restore also skips a record naming the local user; `block_user` refuses to block your own id, so no legitimate run writes that record, and restoring it blocked the local user. - -- **The set behind the plaintext gate is capped, by refusal rather than eviction.** `encryption_capable_peers` is fed from the wire by paths that do not require the peer to prove anything: a well-formed `__MLS_WELCOME__` marks its sender even when the join fails (deliberately, so a peer whose handshake is breaking does not get the gate left open for it), and under the default `require_transport_identity: false` that frame need not even be signed; the TOFU path marks *before* its own store-full branch, so `MAX_TOFU_PEERS` never transitively bounded this. Anyone in radio range could grow the set by a heap string per forged sender id. The cap must be a refusal and not an eviction, and that distinction is the whole point — the set is monotone on purpose because session teardown is remotely triggerable, so evicting would hand an attacker exactly what the monotonicity exists to deny: a way to un-mark a chosen victim by flooding. A peer turned away at the cap falls through to the older `is_session_confirmed` check, so a flood costs later peers the improvement and never costs anyone protection they already had. Legitimate peers are first in line regardless, since restore seeds the set from the session list and the TOFU pins during `initialize_mls`, before `start()` admits any traffic. - -- **React Native's `SecurityWarningCode` union covers the new codes.** It is hand-maintained in `types.ts` — events cross UniFFI as JSON, so nothing regenerates it — and would otherwise have drifted silently from the Rust enum, leaving an app that narrows on the union unable to see `SESSION_SENDER_GROUP_MISMATCH` or `SESSION_REKEY_TRIGGERED` at all. - -- **The README license blurbs no longer overstate AGPL section 13.** Section 13 attaches only to *modified* versions that users interact with over a network; source obligations for plain distribution come from sections 4–6. The intro blurbs in the root and React Native READMEs claimed section 13 covers both — wrong on both halves, and shipped on the npm package page. Both now match the Python README's wording, which never attempted the summary; the License sections below them already described section 13 accurately. - -- **The CLA no longer offers a document that does not exist.** Clause 4 told employed contributors that their employer could satisfy it by executing "a separate Corporate CLA with the Project", leaving the first corporate contributor at a dead end. That alternative is removed; permission or waiver from the employer remain the two live paths. Per the amendment convention in `cla.yml`, the bot's path-to-document moves to the new immutable tag `cla-v1.1`. Signatures stay on `version1` — the change removes an unusable option and imposes nothing new on prior signers, so no re-signing is forced. - -- **`cargo audit` is green out of the box.** It reported five vulnerabilities against libcrux crates while CI's cargo-deny run was green, because cargo-audit scans `Cargo.lock` (which records optional dependencies nothing enables) and cargo-deny scans the feature-resolved build graph. Two of the five (`RUSTSEC-2026-0209`, `RUSTSEC-2026-0211`) are AES-GCM bugs in `libcrux-aesgcm` — worth verifying rather than waiving on sight, since the SDK's ciphersuite does use AES-GCM. It is provided by `hpke-rs-rust-crypto`; `cargo tree --workspace --target all -i libcrux-aesgcm` confirms the libcrux backend is absent from the build graph at every target. `.cargo/audit.toml` now waives the lockfile-only and known-unreachable advisories with that reasoning recorded per entry, and documents that it must remain a superset of `deny.toml`. - -- **`cargo doc` no longer fails under `-D warnings`.** Seven intra-doc links in `wire.rs`, `offline-protocol-mls/src/group.rs`, `offline-protocol/src/protocol/send.rs`, and the UniFFI crate pointed at private items, which rustdoc rejects in public documentation — so docs.rs would have rendered the wire-format module, the most security-sensitive front page in the workspace, with broken links. The names are retained as code spans. - -- **CHANGELOG 0.18.0 no longer contradicts itself on `relay_broadcast_enabled`.** One entry said the default flips to `true` and a later entry in the same section said it defaults to off; the second landed first and was superseded within the release cycle once the broadcast gained a settled delivery report. Both entries now state the supersession explicitly. The shipped default is `true`, matching `GroupConfig::default()`. - -- **`docs/UPGRADING.md` no longer presents itself as a `v0.17.0`-only document** while containing later material (`wipePersistedState`, added in `v0.18.2`). It now frames itself as the `v0.16.x` → `v0.19.x` guide, states that nothing since `v0.17.0` breaks a build, and labels post-`0.17.0` additions and behaviour changes with the release that introduced them — which on this release means §1.10 and §1.11. - -- **React Native README links resolve on npmjs.com.** Five `../../docs/...` relative links 404'd there because `docs/` is not part of the published package. They now use absolute GitHub URLs, matching the Python README. - -### Removed - -- **Repository hygiene.** Removed `Cargo.toml.bench` (a stale root fragment superseded by `crates/offline-protocol-bench`, and already out of date — it listed four benches to the real manifest's five), `examples/mesh-wiki/ios/.xcode.env.local` (a per-developer file pinning an absolute Homebrew node path, which React Native's own `.xcode.env` documents as unversioned — now gitignored), and an empty `examples/react-native-app/src/helpers/string.ts` with no importers. Cleared the real Apple Team IDs from all four example Xcode projects, added READMEs to `examples/demo-app` and `examples/mesh-wiki`, replaced two references to a `todo.md` that has never existed in the repository with the actual Kotlin 2.x migration requirement, and corrected the React Native TSDoc that still described WiFi Direct and Internet as "future support". - -- **The React Native example no longer logs its relay auth token.** `HARDCODED_TOKEN` — a misnomer, since it reads `RELAY_AUTH_TOKEN` from the environment — is renamed `RELAY_AUTH_TOKEN`, and the `__DEV__` block that printed the token's *value* now prints only its length. Example code gets copied into real apps, and Metro/device logs get pasted into bug reports. - -### Security - -- **Deleting one file in the app container no longer re-opens the inbound plaintext gate.** `accept_plaintext_content` decided whether unauthenticated cleartext counted as a downgrade by asking `is_session_confirmed`, which reads the `session_states` protocol-state record — and that record lives in the install-scoped app container, not the credential store. An attacker able to write there simply deleted it. Restore then did exactly what it must (`bootstrap_missing_session_state` writes a fresh `Pending`, because one unreadable record cannot be allowed to brick initialization), the peer dropped out of `confirmed_sessions`, and the lookup returned `Ok(false)` cleanly — no error, so the deliberate `.unwrap_or(true)` fail-closed never fired. Cleartext carrying an attacker-chosen `sender` was then delivered to the app as a message from a contact. **Sealing the record does not fix this**: a seal authenticates bytes that are present, and this attack removes them. The signal therefore moves somewhere the app container cannot reach — a new `encryption_capable_peers` set seeded from the MLS session list and the TOFU pin store, both credential-store-resident, taking their union (a session can exist with no pin, since an unsigned control message from a not-yet-pinned peer is accepted, and a pin outlives its session). The question also changes from *confirmation* to *capability*, which is the correct one: a sender only emits plaintext when its own `should_auto_encrypt()` is false, and while merely pending it queues rather than downgrading, so no honest peer sends cleartext while we know it speaks MLS. The group path has gated on existence rather than confirmation since it was written; this brings 1:1 into line. The set is **monotone by design, and that is load-bearing rather than sloppy**: session teardown is reachable from an unauthenticated frame (an injected `__MLS_ENC__` classifies as `SessionDesync`, tears down locally, and advertises a `session_reset`), so a set that shrank on teardown would let one forged packet re-open the gate — turning a bug that needs app-container write access into one that needs a single injected packet. `resetTofuForPeer` is the only way out, and it is an explicit operator action. See `docs/UPGRADING.md` §1.10 for what an app team will notice. - -- **A cached key package is now checked against the peer's TOFU-pinned signature key, everywhere it is used.** `import_key_package` verified the leaf's *credential identity*, which cannot bear that weight: these are MLS **basic** credentials, which [RFC 9420](https://www.rfc-editor.org/rfc/rfc9420.html) defines as a bare assertion of an identity — the content is a string the holder writes themselves, so an attacker who generates their own signature keypair and stamps `bob` on it passes. That mattered because a validated key package is not necessarily a freshly received one: the Ed25519 + TOFU gate runs at receive time only, packages are then cached in the install-scoped protocol-state store and re-read later, and the gate does not travel with them. All three read paths re-checked nothing but expiry, so anyone able to write that container could swap in their own package and have this node build the session around — and encrypt to — their leaf. `add_group_member` was worse: it ran no identity check at all, so a package could join a group under a roster label unrelated to it. The material to detect this was already present and simply never read — the TOFU-pinned control-message key *is* the MLS leaf signature key. `peer_key_packages` also becomes a sealed category. `test_credential_identity_check_alone_does_not_stop_a_substitution` pins the premise so it cannot quietly lapse. Behaviour changes app teams will see: cached packages from earlier builds are dropped on first launch (one automatic re-exchange, not an error), `mlsImportKeyPackage` now errors when a package's leaf signature key is not the pinned key for that peer id, and `inviteToGroup` verifies the invitee's key package identity for the first time. FFI signatures are unchanged, so no bindings regeneration is needed. See `docs/UPGRADING.md` §1.11. - -- **An encrypted 1:1 envelope must now name the session slot shared with its claimed sender.** The `group_id` on an `__MLS_ENC__` DM selected which MLS group to decrypt against with nothing tying it to the wire sender. That is safe only on the *success* path, where the existing SEC-M1 check compares the decrypted MLS credential to the claimed sender — but every failure OpenMLS raises happens before it authenticates anything. It validates the framing header (group id, then epoch) ahead of any AEAD, sender-data decryption or signature check, so `WrongEpoch` — the verdict classified as the recoverable `SessionDesync` that drives a re-key — was reachable with **no key material at all**, on a prefix deliberately exempt from the control-plane signature gate. Unbound, the claimed sender and the targeted group were independent: one session id, derivable from two public user ids, yielded a re-key, a peer-keyed map entry and a key-package send for an arbitrary attacker-chosen identity. The binding is enforced at the MLS chokepoint before the group is loaded, because `decrypt_from_user` is reachable both from the receive path and from the public `manual_mls_decrypt_from_user` — checking in either caller alone leaves the other as a bypass. The media path already had this binding locally; text never did. Rejecting pre-decrypt also stops a forgery from consuming the ratchet generation it names, so the genuine message at that generation now survives. Mismatches surface as the new `SESSION_SENDER_GROUP_MISMATCH` security warning, the text-path counterpart to the existing media check. `test_forged_frame_reaches_session_desync_without_any_key_material` hand-serializes an MLS frame from scratch to document the residual honestly — the pre-existing `test_corrupt_ciphertext_is_not_classified_as_session_desync` only ever proved that *malformed* input is rejected before framing validation, which is a different claim. - -- **The epoch-desync re-key is now bounded and visible.** `rekey_due_at` is keyed by the wire-claimed sender of a frame OpenMLS classified before authenticating anything, and it was the one peer-keyed map with no capacity bound and no eviction — `key_package_sent_to`, `pending_key_packages` and `known_peer_public_keys` all cap for exactly this reason. (With the envelope binding above, this is now defence in depth rather than the primary fix.) The re-key was also entirely silent: it tears down a working session and re-advertises a key package, and nothing reached the application. Because a genuine fork produces these occasionally, **the rate is the signal** — a sustained re-key rate for one peer means injected frames, which an operator had no way to see. Each re-key now emits a `SESSION_REKEY_TRIGGERED` security warning. The SDK's own threat-model notes are corrected alongside: the previous text claimed "an outsider still cannot forge a frame that produces this classification", which is false, and its own premise defeats it — producing the classification needs a structurally valid frame for a known group id, which requires no secrets. The mitigation is that acting on the trigger is *harmless*, not that the trigger is trusted; the residual (bounded re-key churn on a pair, delivery delayed and never lost) and what would close it are now written down. - -- **Deleting an inherited key no longer leaves a copy that a later read puts back.** The built-in secure stores adopt the pre-namespace store by read-through, so `delete` has to remove both copies — a surviving legacy copy is one namespaced miss away from being promoted back into the new store. All three providers swallowed a failure on that second removal and returned success, so the very next `load` of that key handed the caller material it had been told was deleted, and re-filed it in the namespaced store on the way out. The window is an upgraded install with an adopted legacy store, and what gets resurrected is whatever the SDK deletes on purpose: consumed key packages, torn-down session state, the material a `session_reset` is meant to destroy — a forward-secrecy regression rather than a correctness one. Easiest to hit on iOS, where a Keychain removal that fails because the device is locked is transient but was treated as final. **Reporting the failure was not available as a fix.** A thrown delete crosses the FFI unswallowed and OpenMLS treats a storage delete as fatal: Welcome processing deletes its consumed key package with `?`, every commit merge deletes the previous epoch's keypairs with `?` — surfacing as a decryption error, which bypasses the desync heal and forks the group — and `create_session` cleans up only after the MLS group and Welcome already exist. There is no retry anywhere in core to absorb any of it, so failing the delete would trade silent resurrection for silently broken sessions. The failure is now **recorded** instead: the key is tombstoned in the namespaced store, and read-through treats a tombstoned key as absent from then on, which is the guarantee `delete` actually owes its caller — the copy in the legacy store is left inert rather than hidden by pretending it is gone. `listKeys` filters tombstoned ids out of the legacy-index union, so nothing advertises a key it would then withhold, and a later read retries the removal, retiring the tombstone once it lands. A tombstone suppresses read-through only, so re-storing under the same id is readable again. The tombstone read itself is three-way rather than a yes/no, because its two failure answers authorise different things: a read that cannot be completed suppresses read-through — it cannot prove promoting the key is safe — but does *not* authorise the removal retry, since a failed read is no evidence that a tombstone exists and deleting on it would destroy the last copy of a key that was legitimately inheritable, which on a first post-upgrade launch can be the signing identity. Suppression costs a read-through until the store recovers; that deletion could not be walked back. The one case that still fails loudly is a double fault — the legacy copy will not delete *and* the namespaced store will not record the tombstone — where nothing can keep the promise and a store failing both is failing everything else too. Tombstones join the migration claim as reserved entries: never promoted, never listed, never returned as key material. - -- **iOS: a legacy-store claim that is not valid UTF-8 no longer authorises destroying the shared store.** `wipePersistedState` erases the pre-namespace store only when this account owns the claim written in it or nobody does — that store was shared by every account on a pre-split install, so another account's claim makes it off-limits. iOS classified a claim whose bytes it could not decode as *absent*, because `String(bytes:encoding:)` returns nil and nil meant "no claim". Absence is the one classification that permits the delete, so bytes iOS merely failed to interpret could take a different account's MLS identity, sessions, and block list with them — the unrecoverable half of a wipe decision that is otherwise carefully fail-closed. Android and Python were already safe here, but by accident of decoding lossily rather than by design: both substitute U+FFFD, the result matches no real namespace, and the wipe is refused. Both platforms now classify claim *bytes* in the shared policy file with an explicitly lossy decode, so undecodable bytes read as owned — they are still evidence that something claimed the store. Adoption gets the same answer and conflicts rather than inheriting an identity whose owner it could not establish, which is what the other two platforms already did. Devices that adopted before this overwrote the claim with their own namespace on the way through, so only a first encounter with garbage changes behaviour. - -- **iOS: a failed Keychain write no longer destroys the value it was replacing.** `store` deleted any existing item and then added the new one, so a `SecItemAdd` that failed left the key with no value at all while reporting a failed write — the old item already unlinked, the new one never landed. For MLS material that is unrecoverable: a session's ratchet state or the signing identity, gone because a write failed rather than because anything asked for it. Writes now add first and fall back to `SecItemUpdate` on `errSecDuplicateItem`, so a failure leaves the last good value in place — the overwrite semantics Android's `commit()` and Python's `set_password` already had. Both arms pin `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, since a value-only update does not carry an item's accessibility over. - -- **iOS: the protocol-state directory now states its data-protection class instead of inheriting one.** The store pinned `isExcludedFromBackup` and left the security-relevant attribute to whatever the container defaulted to, which is backwards for records that are deliberately unsealed — the peer graph, the outbox, and the seen-message id set are recoverable from the container by design. It now sets `completeUntilFirstUserAuthentication` explicitly, on the directory so new entries inherit it and on each write so no record depends on that inheritance holding. This is the class the container already applied, so nothing changes today; it stops being incidental. Deliberately not `complete`, which would make the outbox unreadable during the background BLE and relay work the SDK does across device locks — the same trade the Keychain side already makes. - -- **An MLS membership change made by a non-admin is no longer reported as legitimate.** Group roles are an application-layer overlay on MLS — [RFC 9420 §3.2](https://www.rfc-editor.org/rfc/rfc9420.html#section-3.2) leaves membership access control to the application, and MLS itself has no admin concept — and the SDK enforced that overlay when *sending* an invite or removal but never when receiving the resulting commit. `set_member_role` and `rename_group` were both checked on receive; the Add/Remove commit, the one that actually changes the roster, was not. So a group member running a modified client could add or remove anyone, and every peer applied the change and emitted `group_member_added` / `group_member_removed` naming them as the legitimate actor. The bound is insider-only — MLS still authenticates the committer as a genuine member, so an outsider cannot forge one — but it defeated the moderation model the SDK advertises, and the unauthorized Add is the worse half: it splices a reader into every subsequent group ciphertext. The existing delta-vs-claim check noticed the mismatch and only wrote a log line before continuing. Unauthorized changes are now surfaced as a new `group_unauthorized_membership_change` event carrying the committer, the added and removed members, and a `reason` (`sender_not_admin`, or `affected_member_mismatch` when an admin's commit framing disagrees with the MLS delta it actually produced), and the `group_member_added` / `group_member_removed` events grow an additive `authorized` field so one handler can render the distinction inline. The field is deliberately tri-state: `true` (the change passed the local admin check), `false` (judged unauthorized), or absent when authorization was **not evaluated** on the emitting path — your own join from a Welcome (there is no prior group state to judge the inviter against) and relay reconciliation frames (no authenticated committer to judge) — so absence is never a claimed verdict. The admin determination also now covers removals; it previously evaluated to "not an admin" for any commit that added nobody, which made it structurally unable to describe a removal at all. Two hardenings bound the signal's noise and its inputs: reports are rate-limited per `(group, committer)` — divergent role metadata (e.g. two members auto-promoting different admins) would otherwise re-fire the event on every commit, though every roster event still carries `authorized: false` — and the membership delta is only derived when both roster reads (pre-commit and post-merge) succeed, since a transient platform-storage failure silently defaulting to an empty roster would have fabricated a full-roster delta and a security report naming an innocent committer. When a read fails, the commit still merges (no fork) and the roster self-heals on the next successful refresh. - - **The change is applied, not rejected, and that is deliberate.** Rejecting a commit means declining the MLS merge, which advances every other member's epoch but not yours — an unrecoverable fork, with no path back short of the application re-inviting you (the epoch-fork resolver says as much). Admin state replicates best-effort: a role change is a mesh notification with no reconciliation, and a joiner receives only a point-in-time snapshot in its Welcome. A member whose role map had merely fallen behind would therefore partition itself out of a perfectly healthy group with no attacker involved, and non-unanimous enforcement does not even stop an unauthorized Add, since the intruder still reads whichever branch accepted it. An unrecoverable partition is a worse failure than an insider membership change, so the SDK keeps the group coherent and reports the anomaly instead. Applications that need a stronger guarantee should treat the new event as a moderation alert and have an admin reverse the change, and should not rely on group membership alone to authorize sensitive actions. `docs/mls-integration.md` gains a "Group authorization model" section stating exactly what is and is not enforced on each side; the security-invariant lists in the iOS, Android and React Native guides, which all claimed receive-side enforcement that never existed, are corrected to match. The docs also state the signal's bounds: authorization is judged against the local, best-effort role replica, so a report can be a false positive and must never trigger automated reversal, and the member removed by an unauthorized Remove receives no event at all — only the remaining members report it. - -### Added - -- **An export-control notice ships with every distributed artifact.** Root `EXPORT.md` states the SDK's ECCN 5D002 classification, the publicly-available treatment under [15 CFR 734.7(b)](https://www.ecfr.gov/current/title-15/subtitle-B/chapter-VII/subchapter-C/part-734/section-734.7) relying on the §742.15(b) notification, and — the part app teams actually need — the export-compliance work that stays with them: App Store Connect encryption declarations, the annual BIS self-classification report, and the French ANSSI regime. It is copied into the npm and PyPI packages like the other license documents, attached to GitHub releases, and covered by the license-consistency drift guard (negative-tested). - -- **A licensing FAQ covers what the AGPL option means for app-store distribution.** `docs/licensing-faq.md` states the combined-work reach of the AGPL, why Apple App Store distribution is not a supported combination with it (Apple's terms operating as further restrictions, per the FSF's GNU Go and VLC position, and iOS code signing against the section 6 Installation Information requirement), that **no section 7 app-store exception is currently granted**, and the Play Store and export questions. The README and all three copies of `LICENSE-COMMERCIAL.md` point at it. It is descriptive: it grants nothing new and changes no existing permission. - -- **A trademark policy.** `TRADEMARKS.md` claims the "Offline Protocol" name and logo as marks of Offline Protocol, Inc. and states that neither software license grants trademark rights, with the usual nominative-use carve-outs and a rename-your-fork rule. The README license section points at it. Deliberately silent on registration status. - -- **CI fails when `THIRD-PARTY-NOTICES.md` goes stale.** The existing License Consistency job catches the three NOTICES copies drifting from *each other*, but a dependency bump leaves all three identical and uniformly stale — regeneration after a `Cargo.lock` change was manual discipline with nothing to catch a miss. A new job reruns cargo-about and fails on any diff, mirroring the regenerate-and-diff pattern the Python bindings already use. The cargo-about version is pinned in `scripts/generate-third-party-notices.sh` and read by CI with `sed`, so the two can never disagree, and the script enforces the pin locally and asserts the lockfile with `--locked`. - -- **A joiner now learns who created the group, making the admin fallback reachable.** `check_is_admin` falls back to the group creator when no admin role is stored — the exact state an incomplete role snapshot leaves a member in — but that fallback was dead code for every joiner. The Welcome payload carried a point-in-time `member_roles` snapshot and nothing else, so a joiner's group metadata was materialized by `set_member_role` via `GroupMetadata::new(None)` and its `created_by` stayed permanently absent. A joiner whose snapshot arrived empty therefore had no admin at all in its replica and judged *every* member unauthorized, including the real admin. The Welcome now carries the inviter's creator of record (additive field; absent from older SDKs, where absence means "no information" rather than "no creator"), and the joiner adopts it. The write is monotone — first-write-wins — so a device that created the group or already adopted a creator keeps what it has: duplicate Welcomes are idempotent, and a later invite from an inviter whose own metadata disagrees cannot rewrite an established admin fallback. It is deliberately not bounded to the joined roster, since the creator of record may already have left. Receive-side commit enforcement never consults it (see below); it feeds only send-side gating and the authorization report. - -- **Group membership commits can now be refused rather than applied-and-reported, behind an opt-in flag.** `group.enforce_admin_commits` (React Native: `group.enforceAdminCommits`), **default `false`**, makes the SDK reject an incoming MLS Add/Remove commit whose committer the local admin overlay does not authorize. The refusal happens *before* the MLS merge, so nothing changes locally, no roster event is emitted, and the commit is never buffered for retry — it can never become authorized. The check lives at the MLS layer's single pre-merge chokepoint rather than in the commit handler, because `merge_staged_commit` is reachable from every group decrypt path: a commit ciphertext reframed as an ordinary group *message* merges through the application channel, which otherwise treats a consumed commit as benign reordering, and an `__MLS_ENC__` envelope naming a group id reaches the same code through the manager's prefix dispatch. Gating only the commit-framed path would have left both as bypasses. Pure key-update commits carry no membership proposals and are never gated (fork-resolution key updates are issued by a deterministic leader who is often not an admin), and 1:1 sessions are exempt structurally as well as by an explicit namespace check. - - **The default stays `false` because this is a partition decision, not a hardening toggle.** Refusing a commit means declining the merge, so the refusing device's epoch stays behind every member that accepted it — MLS cannot heal that, and the application has to re-invite. Enforcement is fork-free only if every member reaches the same verdict, and the admin overlay replicates best-effort. Every *absent* input therefore fails open — no group metadata, no admin role stored, an unreadable roster — so the common "my role map is behind" case still merges; the creator fallback is deliberately **not** consulted here either, since one unauthenticated claim is too thin to fork a group over. What the check cannot detect is *divergent* knowledge: two members holding non-empty but disagreeing admin sets will refuse each other's commits. That residual risk is what keeps it opt-in, suitable only for a closed deployment that controls role distribution and never for part of a fleet. Rejection is also receiver-local — the sender's frame is still acknowledged, so a committer gets no signal that anyone refused. Both outcomes report on one signal: `group_unauthorized_membership_change` grows an additive `enforced` field, `false` when the change was applied and reported (the default) and `true` when it was refused, in which case the event doubles as a partition alarm. The report's rate-limit window is keyed on that field as well as on `(group, committer)`, so the two outcomes never suppress each other — losing a refusal behind an earlier applied report would hide the transition an app most needs to act on, while repeats within one outcome class still collapse as before. What enforcement guarantees is that the device never *merges* an unauthorized commit, not that it never ends up in a group the commit changed: re-inviting it arrives as a Welcome, which is not policy-gated, so the refused change has to be resolved separately rather than treating the re-invite as the whole remedy. - -## [0.18.3] — 2026-08-03 - -### Fixed - -- **iOS: tearing the SDK down no longer kills the app.** Every `destroy()` aborted the process with `SIGABRT` and the runtime message `Cannot form weak reference to instance … of class MeshSdk.InternetManager`. Forming a **new** weak reference to an object whose deallocation has already begun is a hard runtime abort — `objc_initWeak` routes to `storeWeak`, which calls `_objc_fatal` — and not, as the code assumed, a reference that harmlessly reads back nil; "goes nil" only describes a weak reference registered *before* its target died. Because a capture list is evaluated when its closure is **created**, the `[weak self]` on the internet transport's teardown hop aborted whether or not the block ever ran. `destroy()` is the double-stop that exposed it: it calls `stop()` on each transport and then releases it, and releasing runs `deinit`, whose own `stop()` reaches that hop a second time — this time from inside `dealloc`. The internet transport was the one that died because, alone among the transports, its stop path deliberately has no early-return state guard (it must always invalidate its `URLSession`, or every `start()` after a `stop()` leaks one), so the second pass ran the whole teardown instead of returning at the top. `deinit` now takes a path that names `self` in no capture list at all: the cleanup that only touches state dying with the object is skipped there, while the two effects that must still happen — draining parked forced-presence checks so their React Native promises resolve `false` instead of hanging their JS callers forever, and the `internetStatusChanged(false)` that stops the transport selector scoring a socket that is gone — run from a hop capturing locals bound before the closure. Reading a stored property during `deinit` is safe; only the weak reference was ever fatal. Both hops stay on the transport's serial message queue in the same order, so 0.18.2's guarantee that a teardown `false` and a later re-authentication's `true` cannot reorder is unchanged, and a strong capture was deliberately *not* used as the fix — it would resurrect a deinitializing object and defer its dealloc onto that queue, where the teardown's main-thread hop becomes a `sync` that can deadlock. The same latent shape is fixed in the Nostr transport, whose relay-disconnect hop had the identical capture: it never aborted only because its early-return guard happens to short-circuit the second `stop()`, which stops being true the moment a manager is released while still running — module teardown without an explicit `destroy()`. The BLE, Reticulum and Wi-Fi Direct transports were audited and are unaffected: neither reaches an escaping closure capturing `self` on its stop path. Android was never affected, and still runs this cleanup inline under a blocking main-thread hop. The invariant, the reasoning, and the reason CI cannot catch it are now written down in `bindings/react-native/ios/BRIDGE_MAINTENANCE.md`, and CI now type-checks the two bridge files that the SwiftPM test harness excludes, which until now shipped every change with no compile coverage at all. - -## [0.18.2] — 2026-08-01 - -### Fixed - -- **iOS: no internet-transport transition takes the protocol lock on the main thread anymore.** 0.18.1 moved the two `UIApplication` lifecycle handlers off the main thread, but the same defect — a UniFFI call that acquires the global protocol mutex, made synchronously on main inside a window the system's scene-update watchdog measures — survived at every internet status transition. Worst was authentication: the relay's `Authenticated` answer was handled on main, and the `internetStatusChanged(true)` it makes is the call that runs the reconnect outbox flush under the lock, so every reconnect — above all the foreground force-reconnect, firing at the exact moment the resumed process tick and queued sends make the lock slowest — parked the main thread for the flush's duration (`0x8BADF00D`). The socket-close funnel had the mirror image: iOS kills the socket on backgrounding, and the close handler's `internetStatusChanged(false)` took the lock on main inside that transition. The transport's `stop()` did the same on its caller's main hop, and a deferred one-second `bleStatusChanged(true)` backup after start ran on main squarely inside the launch window where state restore makes the lock contended. All four now ride the transport's serial message queue (the BLE backup rides the SDK's processing queue), enqueued from main in the order main observed the transitions — which is what keeps a close's `false` and a re-auth's `true` from ever reordering — with the relay-capability injection still strictly before the status flip and the immediate post-reconnect poll now chained strictly after it on the same queue, so the flush-then-drain ordering is guaranteed rather than incidental. The protocol handle is captured strongly in each hop (the component, never the manager itself), so a teardown racing the hop cannot swallow a status change the Rust side still needs; the drain barrier `destroy` already holds also now covers the BLE backup, closing a cancel race where a backup already mid-flight on the main queue could re-enter the protocol after teardown. The two directions are deliberately asymmetric on teardown: a `false` is unconditional — the Rust side needs it even once this manager is gone, and it takes no flush path — while the `true` is gated on the authentication that enqueued it still being current. Making the flush asynchronous otherwise opened a window the synchronous call never had, since `destroy` barriers the SDK's processing queue but not the transport's: a `true` dequeued after `destroy` ran `stop()` would flush the outbox under the lock on a stopped protocol and re-persist state that a `wipePersistedState()` on logout then races. Every teardown flips the authentication flag on main before enqueueing its own `false`, so the gate can never drop a `true` that is still wanted — a later re-authentication enqueues a fresh one carrying its own connection's capabilities. Otherwise observable behavior is unchanged: the calls, their ordering, and their effects are identical; they simply no longer happen on the main thread. The Android bridge's two counterpart sites (its Wi-Fi Direct broadcast receiver and the blocking teardown hops) are tracked separately: Android has no scene-update watchdog, so the same shape costs an ANR budget there, not a kill. - -## [0.18.1] — 2026-08-01 - -### Fixed - -- **iOS: a forced presence check no longer leaves its React Native promise unresolved when the transport is torn down.** `checkInternetPresence(force: true)` parks the query until the relay socket is authenticated and rate-admits it, and the transport's `stop()` is supposed to resolve every parked entry `false` — dangling a JS promise until the deadline helps nobody, and on an explicit stop no reconnect is coming. That drain ran inside a block hopped onto the transport's internal queue and captured the transport weakly, so it executed only if the transport was still alive when the block ran. On the two paths where the drain is the entire point, it was not: `stop()` is reached from `deinit`, and from `destroy()`, which releases the transport immediately afterwards — in both cases the weak reference is gone by the time the block runs and every parked promise is silently dropped, hanging the JS caller forever with no error and no timeout. The queue holding those promise resolvers is now captured strongly and drained unconditionally, which keeps the guarantee without resurrecting a deinitializing object; the rest of the block, which only clears state that dies with the transport anyway, is unchanged. Worth knowing that this was reachable without `destroy()` at all: a transport that was never started sits in `.unavailable`, which the park policy does not fail fast on, so checks park on it and its only teardown is `deinit`. The Android bridge was never affected — it runs the same cleanup inline under a blocking main-thread hop — and this restores parity with it. - -- **iOS: backgrounding or foregrounding the app no longer takes the protocol lock on the main thread.** The `UIApplication` lifecycle handlers called `wifiDirectStatusChanged` synchronously — iOS tears down MultipeerConnectivity on background, so the core has to be told to stop routing over Wi-Fi Direct — and that call acquires the global protocol mutex, the same one held by the process tick, by MLS work, and by a group fan-out. Blocking the main thread on it happens inside the window the system's scene-update watchdog measures, so a device under exactly the load that makes the lock slow could be terminated (`0x8BADF00D`) during an ordinary app switch. Both handlers now hand the call to the SDK's serial processing queue instead. The queue's ordering is what makes this safe: the `false` from background and the `true` from foreground cannot overtake each other. The background side additionally holds a background-task assertion across the hop, so the status change still lands promptly on a build that keeps running in the background on BLE, rather than being deferred to the next foreground while the transport selector still scores a link the OS has already torn down. Nothing about the observable transport behavior changes; the work simply no longer happens on the main thread. Note this call site has no Android counterpart — `onHostPause` there makes no protocol call — so no paired change was needed. - -## [0.18.0] — 2026-07-31 - -### Changed - -- **The relay group broadcast is back on by default — now gated on the relay's `group_delivery_v2` capability and backed by a settled per-recipient delivery report.** **This supersedes the per-member-fan-out-by-default entry below**, which landed earlier in this same release cycle and set the default to off; both are listed because both shipped as commits, but `0.18.0` releases with `group.relay_broadcast_enabled` defaulting to `true`. That flip is what this entry describes, and the flag alone never selects the broadcast: the path is taken only when the connected relay advertised `group_delivery_v2` in its `Authenticated` answer, which the platform bridge now injects into the core (new UniFFI method `internet_relay_capabilities`, called before `internet_status_changed(true)` so the reconnect flush already sees it; the set is cleared when the transport drops). Against such a relay a group send is one O(1) frame carrying a client-minted logical message id — the bridges stamp it onto the relay `SendGroupMessage`, mirroring the direct-message translator — and the relay answers, once its whole fan-out has settled, with a report naming which members took the message over a live socket and which took a device push. The SDK consumes that report through a new dedicated bridge entry point (`internet_group_report_received` — deliberately not message-plane injection, so it cannot be forged through the notification ciphertext injector): every MLS roster member the report does not account for, both the ones the relay names as missed and the ones it never knew (its registered roster can lag the MLS roster), is automatically re-sent a per-member copy through the ordinary outbox/ACK/park delivery ladder, and the outcome surfaces as the new `group_message_delivery_report` event. A lost report re-sends the broadcast under the same logical id — safe, because the relay echoes the id and both receiver dedup and the relay's push dedup key on it — at most twice on a 60-second timer (above the relay's 45-second fan-out budget), then the whole message downgrades to per-member fan-out, which needs no report to be correct; the same downgrade fires immediately if the Internet transport drops while a report is pending. Receivers honor the logical id end to end: a re-issued copy emits the same app-facing message id every other member saw and is absorbed as a duplicate when the relay's own copy also arrived, including across the deferred-decrypt buffer — and because the id is unauthenticated wire input, it is only ever marked as seen after the ciphertext MLS-decrypts, so a non-member cannot poison an id to suppress a genuine message. Against a relay that does not advertise the capability the gate fails closed and every group send takes per-member fan-out exactly as before; the old fire-and-forget broadcast is never taken regardless of configuration. The React Native config surface grows the previously unreachable `group` section (`group.{maxGroupMembers,relayEnabled,relayBroadcastEnabled}`, nested-wins-over-flat like `encryption`) on both platforms, so an RN app can opt out of the broadcast now that the default is on. One caveat worth knowing before leaving the default on: the tracker awaiting a delivery report lives in memory only, so a process death inside the report window loses that backstop — members the relay could not reach get no per-member re-send and nothing retries on restart. It takes both a missed member and a kill in that window, and it is strictly less exposure than the old backstop-less broadcast, but an app that must not lose a group message to a mid-flight kill should set `relayBroadcastEnabled: false` and pay the O(N) uplink. - -- **Group messages sent over the internet now fan out per member by default, instead of taking the relay's O(1) broadcast.** A new `group.relay_broadcast_enabled` setting gates the broadcast path (`group.relay_enabled`, which registers the group so invite links resolve, is untouched and stays on). **Superseded within this same 0.18.0 cycle:** this change introduced the setting defaulting to **off**, and the delivery-report work described above then flipped it back to **on** once the broadcast gained a settled per-recipient delivery contract. `0.18.0` ships with the default **on**; the reasoning below is why the broadcast was untrustworthy *before* that contract existed. The broadcast saves sender uplink but has no delivery contract at any layer: the relay fans it out fire-and-forget with no per-recipient presence check, no push fallback, and no persistence, and answers "sent" before delivery is known — so a member who is backgrounded, offline, or on a socket that has quietly died misses the message permanently and *undetectably*, because MLS application messages don't advance the group epoch and the receiver never learns one existed. Per-member fan-out sends the same MLS ciphertext as ordinary `SendMessage` frames and thereby inherits the entire direct-message ladder that has been built out over the last several releases: outbox persistence, the ACK and retry ladder, the relay's write-ack and successor retry, offline push carrying the ciphertext, park-on-unreachable with escalating presence probes and flush-on-reconnect, and the receiver's deferred-ACK-after-decrypt. The cost is O(N) frames per send. That does not risk tripping the relay's rate limiter at any group size: the platform bridge already meters every relay-bound frame through a client-side token bucket deliberately tighter than the server's (28 capacity and 9/s refill against the relay's 30 and 10/s) and defers frames it cannot fund to a later poll tick rather than dropping them, so client spend stays strictly under the server's budget and the fan-out self-paces. What large groups cost instead is drain latency — the core enqueues all N frames at once and the bridge writes them at about 9 per second after an initial burst of roughly 28 — and, past roughly 118 members, self-inflicted duplicate sends: the ACK timer starts when a frame is enqueued locally rather than when it reaches the wire, so the tail of a very large fan-out times out and is retransmitted before it was ever written. Those duplicates are absorbed by receiver and push dedup via the stable outbox id, so they cost bandwidth rather than correctness; groups near that size should keep the broadcast on and accept the delivery gap until the relay can report per-recipient delivery back to the sender. Two bugs on the broadcast path are fixed in the same change, so opting in is no longer actively harmful. First, the self-addressed hint frame that carries a broadcast (and the one that carries a group registration) went out requiring an ACK that by construction can never arrive — the platform bridge *replaces* the frame with a relay-native one, so nothing addressed to its id ever comes back — which meant the ordinary retry ladder retransmitted it ten times over roughly thirteen minutes. Each retransmission was a complete additional relay fan-out under a freshly minted relay id, which receiver-side dedup keys on and therefore misses, so members saw the message repeatedly and genuine out-of-order messages could be evicted from the bounded pending buffer; the ladder then terminated in a `message_failed` for an id the application was never told about, and recorded a delivery failure that degraded the transport selector's score for the very transport the frame required. Both frames are now honest one-shots (registration retry already lived, correctly, on its own bounded tracker). Second, these frames were routed by ordinary transport selection, which demotes Internet below every mesh transport — and while BLE fails closed on a message addressed to oneself, Wi-Fi Direct and Reticulum accept it unconditionally and report success, silently swallowing the frame while the sender concluded the broadcast had gone out and skipped the per-member fallback, delivering the group message to nobody. Relay hint frames are now pinned to the Internet transport and fail fast when it is unavailable, and the broadcast path additionally re-checks internet availability so that a stale sync flag (internet dropped, the periodic tick not yet run) can no longer route a broadcast into the mesh. -- **Forward attribution now survives the relay path for group messages.** `forward_info` was carried in the broadcast payload but dropped by both platform translators when they built the relay frame, and had no path back into the receiver's injected frame — so a forwarded group message rendered "forwarded from X" for members who received it over the mesh and lost the attribution entirely for members who received it over the relay. Both halves are now plumbed. Sealed rich payloads were never affected: they carry their own copy of the attribution inside the MLS plaintext. The `epoch` field remains deliberately unforwarded — OpenMLS reads the epoch from the ciphertext header, so the payload copy is informational and the receive path never consults it. -- **The internet bridges now stamp the relay `SendMessage` frame with the core's outbox `message_id`.** Every retry of one un-ACKed logical message used to reach the relay as a brand-new message — the relay minted a fresh UUID per frame — so a recipient who stayed offline collected one push notification per rung of the sender's retry ladder: identical banners for a single message, with nothing anywhere able to tell them apart. With the outbox id on the wire (stable across retries by construction), a relay that understands the field echoes it in `MessageReceived`/`MessageSent`/`DeliveryError` and its push payload, remembers which `(recipient, message_id)` pairs it has already pushed, and answers a retry of an already-pushed message with `DeliveryError` instead of another notification. The bridges already classify any relay `DeliveryError` as `recipient_unreachable`, so that answer parks the DM on the designed offline path — escalating reachability probe plus presence-edge re-drive — rather than letting the retry ladder burn to terminal settlement against a peer that cannot ACK. Older relays ignore the unknown field; nothing changes against them. - -### Added - -- **React Native can now erase one account's persisted state: `wipePersistedState(appId, userId)`.** Signing a user out has never removed anything from disk — `destroy()` releases the in-memory instance and leaves the outbox, pending queue, block list, and the whole MLS identity filed under that account's namespace. Two things follow that applications have been hitting. Signing back in restores the outbox and re-drives it, so a message queued for a user who never comes back is retried on every launch and reconnect for the full outbox lifetime. And on iOS the Keychain outlives the app container, so an uninstall does not take the secure store with it: a reinstall and a sign-in as the same user adopts that material again, including the pre-split store's delivery state. Neither was fixable from the application side — the paths are digest-derived from an internal domain separator, the providers are constructed inside the bridge and never handed out, the protocol persists as it works so an external delete races it, and deleting one store but not another lets read-through or the adoption claim resurrect what was removed. The new method erases, for the named account only: the namespaced secure store (MLS identity and sessions, TOFU pins, the Nostr signing secret, the key every sealed protocol-state record is written under), that account's protocol-state directory, and the pre-namespace store an upgraded install inherited from — the last one only when this account owns the claim or nobody does, since it was shared by every account on a pre-split install. A claim that cannot be *read* also stops it, because unreadable and foreign are indistinguishable and only one of those two mistakes is recoverable. The androidx master key is never touched. Call it **after** `destroy()`: the native side rejects a wipe of the account the current instance is running, since the protocol persists on the send path and a wipe underneath it leaves a partially repopulated container. The identity is an explicit argument because `destroy()` clears the config the namespace would otherwise be derived from — which also lets an app wipe the account it just signed out of while the next one is already running. Secure storage is erased first, so an interrupted wipe leaves the remaining records as ciphertext whose key is already gone rather than as readable state; every store is attempted even if one fails, the first error is reported, and the whole thing is idempotent, so a caller that gets an error should call again. It is irreversible and it rotates the account's MLS **and** Nostr identities: peers holding a session will see a desync on next contact and re-establish from a fresh key package, and peers holding a TOFU pin will treat the returning user as a new peer. Applications that supply their own storage providers must erase their own containers — the SDK only knows how to wipe the built-in ones. There is deliberately no Python equivalent: its state directory could be removed trivially, but its secure store cannot be enumerated (`keyring` has no listing operation, the per-`key_type` index has no index *of* key types, and OpenMLS contributes key types of its own), and a partial wipe that left signing-identity material behind would be worse than none. Two teardown races that the wipe ordering depends on are fixed alongside it: Android's `destroy` now waits (bounded) for an in-flight process tick to finish instead of only refusing new ones, and iOS's deferred one-second `bleStatusChanged` backup call is now cancelled by `destroy` instead of re-entering the protocol after teardown. - -## [0.17.0] — 2026-07-30 - -> **Downgrade is not a safe rollback.** The first launch on this release moves -> pre-split delivery state out of the credential store and into the app -> container, deleting the credential-store copy once the move is durable. An -> older build reads the old location and finds none of it — so a rollback comes -> up with an empty outbox, an empty pending queue, and, most sharply, **an empty -> block list: every previously blocked peer silently unblocked.** Blocking is a -> safety control, so treat a downgrade as a decision to reset it, not as an undo. -> Ship this as an explicitly breaking release and roll forward, not back. - -### Added - -- **`SECURITY.md` now carries a safe-harbor statement, and every publishable crate carries the license text it would ship under.** The policy solicits vulnerability research but offered no legal assurance in return, which for a protocol whose whole value is its cryptography is the wrong trade: it now states that good-faith research conducted under the policy is authorized under the CFAA and the DMCA's anti-circumvention provisions, permits reverse engineering of the shipped binaries for the purpose of finding vulnerabilities, and sets a 90-day default disclosure window — bounded by rules of engagement (test only what you own, no volumetric testing against shared relay infrastructure) and by two honest limits, that the grant binds only Offline Protocol, Inc. and does not displace applicable law. The supported-versions table, stale at `0.13.x`, now names the current line and says explicitly that its numbers are release versions rather than the deliberately decoupled workspace `Cargo.toml` version. Separately, each publishable crate now sets `license-file = "../../LICENSE"`, so `cargo package` copies the AGPL text into the `.crate` archive: crates ship individually and a recipient of one is entitled to the license text under AGPL §4, which no crate carried because the license lived only at the workspace root. The benchmark crate is marked `publish = false` instead, being a harness rather than a distributable, and `scripts/check-license-consistency.sh` — already the CI gate against drift between the triplicated license documents — now also asserts the invariant, because a crate added later inherits nothing from this change and the omission is invisible until someone downloads the published archive. `offline-protocol-services` was also missing `repository`, which is why the generated `THIRD-PARTY-NOTICES.md` linked it to a crates.io page that does not exist — the regenerated file links to the repository like its seven siblings. The npm and PyPI READMEs state the dual license up front rather than only in a closing section, since a package manifest can name `AGPL-3.0-only` and nothing else: SPDX has no identifier for the commercial offer, so the prose is the only place a user learns the alternative exists. - -- **Third-party license attribution now ships with every distributed binary.** A generated `THIRD-PARTY-NOTICES.md` (cargo-about over the `offline-protocol-uniffi` dependency graph — the crates actually compiled into the iOS/Android libraries and the Python wheel's native library) preserves the copyright notices and license texts of all ~190 incorporated crates, and states how to obtain the Source Code Form of the MPL-2.0 components (the `hpke-rs` family and the UniFFI runtime, whose helper code is also inlined into the generated Swift/Kotlin/Python bindings) as MPL-2.0 §3.2 requires. The file is included in the npm package, bundled into Python wheels under `dist-info/licenses/`, and attached to GitHub releases; regenerate with `scripts/generate-third-party-notices.sh` after dependency changes. - -### Changed - -- **Messages waiting for MLS session establishment are persisted one record per message, keyed by message id, instead of one record per recipient holding that peer's whole queue.** The layout now matches the outbox and media descriptors, and the reason is what a lost record can say. A per-recipient record holds every id in the queue, so one that will not open destroys all of them and the only honest report is a per-peer `pending_state_lost` diagnostic naming no message at all. Keyed per id the key *is* the id, so the same loss settles as a `message_failed` for exactly the id the application is holding, and a readable entry queued for the same peer is unaffected rather than going down with its neighbour. Two structural simplifications follow. The per-session freeze that kept an ordinary enqueue from writing its one-message view over a record restore could not read is **removed**, not narrowed: every write and delete now addresses its own message id, so an unwalked or unreadable record cannot be clobbered by an unrelated write — the argument the outbox has always made. And the restore walk no longer has to re-persist a peer's survivors after the capacity caps drop an entry, because the dropped entry is simply its own record. Secondarily, an enqueue now writes one record rather than re-serializing the queue it joins, so filling a peer to `MAX_PENDING_MESSAGE_BYTES_PER_PEER` costs that many bytes rather than roughly `budget × entries / 2`. **That byte win is not the reason for the change and should not be read as a speed-up**: every built-in provider pays two device barriers per store and one per delete regardless of record size, so over a queue's full lifecycle this trades about 25% more barriers for linear instead of quadratic bytes — close to a wash at the queue depths that actually occur, where a handful of short messages wait on a handshake. Restored queues are re-ordered by `(queued_at, message_id)` — the comparator the global oldest-first eviction already used — because records come back in whatever order the store enumerates them, and that order carries no ordering information at all. One consequence of a record per message is that removing a batch is now a delete per entry rather than one whole-queue delete, so pending-lifetime expiry is bounded to `MAX_PENDING_EXPIRIES_PER_PASS` (64) entries per `process()` tick: entries queued together come due together, and what a pass leaves behind is still past its deadline, so the next tick drains another pass rather than one tick issuing thousands of synchronous deletes. Because a bounded pass can leave due entries queued, the flush path independently refuses to dispatch an entry past its deadline — it stays queued for the next expiry pass — so the absolute lifetime holds where delivery is decided, and an expired message can never settle `MessageSent`. - -- **Pre-existing per-recipient pending records are migrated forward on first launch.** The restore walk reads the per-message records first, then reads each legacy per-recipient queue and recovers its entries; once the capacity caps have been applied, each *surviving* entry is written under its own id and the legacy record is dropped. Writing after the caps rather than during the walk is what keeps the upgrade cheap: a pre-cap install can hold far more than the current bounds admit, and writing every entry out only to delete it again moments later is what would turn an ordinary upgrade into thousands of device barriers on the boot path. Writes still precede the delete, so a crash in between leaves a queue present in both layouts rather than neither, and the per-message pass claims those ids — recovered *or* settled as destroyed — so the finishing launch admits each entry once, never re-files an id it already reported failed, and completes the interrupted delete. Migration is **all or nothing per recipient**, because nothing outside this walk writes the legacy category and a record whose delete does not land is therefore there for good: the claim that makes a crash converge holds only while the per-message records exist, and those do not survive the flush this launch is about to perform, so a half-migrated queue would be re-filed and re-*sent* by a later launch long after it was delivered. A recipient whose delete fails, whose per-message writes do not all land (a full disk fails the write and lets the delete through, so the writes are as load-bearing as the delete), or that the launch cannot fund, has its entries taken back out of memory (and back off disk if they had been written) so the queue lives in exactly one place — the legacy record — for a later launch to migrate again, and none of its ids are settled in the meantime, since a settlement is terminal and that record can still deliver them. That withholding extends to an id the per-message pass settled as destroyed when a surviving legacy record also holds a copy: the next launch re-files and delivers it, so the terminal report is deferred to the launch that owns both halves rather than emitted and overturned. The funding checks come before the first write, so abandoning is usually free. Both passes share the walk's entry bound and its delete budget and stop at a record boundary, so a truncated pass resumes on the next launch; the capacity-eviction deletes the walk owes are drawn from the same budget, and the migration's *writes* — the more expensive half, since every built-in provider flushes the record and its directory — are bounded separately by `MAX_MIGRATED_PENDING_WRITES_PER_LAUNCH` (512), so a store far past its caps cannot spend an unbounded number of device barriers on one launch. A legacy queue lost inside a record that will not open still reports per peer — that cannot be improved retroactively, which is the point. Nothing outside this walk reads, writes, or deletes the legacy category (the pre-split adoption sweep does write it, but strictly earlier in the same launch, before the walk runs), which is what makes leaving an unwalked record on disk safe without a freeze. Like the pre-split adoption sweep, this is one-shot upgrade scaffolding and retires on the same trigger: when the oldest supported upgrade path starts at or after the release that introduced the per-message layout, not merely one release later. - -- **Breaking: secure key material and restartable protocol state now use separate storage contracts.** `initialize_mls` / generated `initializeMls` now require both an `MlsStorageProvider` and an app-container-scoped `ProtocolStateStorageProvider`; the one-provider API and production `enable_message_persistence` path are removed. Outbox entries, pending messages, retry/welcome lifecycles, peer snapshots, media descriptors, and Lamport state are routed only to protocol-state storage. MLS/TOFU material and install secrets remain in secure storage. Built-in Python and React Native providers isolate both stores by an opaque namespace derived from `(app_id, user_id)`, so multiple accounts cannot share keys or delivery state. iOS state lives in Application Support with backup disabled and Android state lives in `noBackupFilesDir`. Python has no portable uninstall-scoped container, so `ProtocolManager` now requires an explicit `state_root` (or `OFFLINE_PROTOCOL_STATE_ROOT`) owned and removed by the application installer. Both halves of an existing install **are** carried across, by two different mechanisms described under Fixed: restartable delivery state is swept out of the credential store into the app container on first launch and deleted from the credential store once the move is durable, and the MLS identity is adopted by reading through to the pre-namespace store. - -- **Messages waiting for MLS session establishment now have a configurable absolute lifetime and hard capacity bounds.** `RetryConfig.pending_message_max_lifetime_ms` defaults to seven days, is mirrored through UniFFI and React Native, and removes expired entries from memory and protocol-state storage while emitting `message_failed`. The outbound pre-session queue keeps at most 64 messages per peer and 4096 globally, evicting the oldest with a terminal failure event. `process()` tracks the earliest pending deadline instead of scanning the entire queue every 100 ms. Retry/ACK runtime updates now reject zero delays, zero capacities, invalid lifetime bounds, and other invalid configurations instead of installing a hot-loop-capable configuration. Lifetime cleanup and restore use checked timestamp arithmetic, so even the largest accepted lifetime cannot overflow Chrono and panic the process. - -- **The pre-session queue is now bounded by bytes as well as by entry count, and oversized message content is refused at the send boundary.** An entry count alone bounds neither memory nor protocol-state disk, because message content is application-supplied: 64 entries per peer could still be arbitrarily heavy. `send_message*` and `forward_message` now reject content over 256 KiB with `InvalidArgument` (large payloads belong on `send_media`, which chunks), and the pre-session queue additionally holds at most 2 MiB per peer and 16 MiB globally, evicting oldest-first with the same terminal `message_failed` the count caps emit. Restore applies both budgets, so a record written by an older build cannot re-inflate memory on boot. The boundary cap sits at the boundary rather than at transmit time because a message waiting on session establishment is queued — in memory and on disk — long before it reaches the transport's own 1 MiB check. The group send path is deliberately unchanged: it has no durable pre-session queue and remains transport-bounded. Separately, every protocol-state record is now capped at 4 MiB on **both** sides of storage, so a corrupted or tampered state file is refused before it can be deserialized into an unbounded allocation during startup. - -- **Breaking: `update_dedup_config` is now fallible too, and a zero `max_tracked_messages` or `retention_time_secs` is refused everywhere.** The method returns `Result` in Rust and is declared `[Throws=ProtocolError]` over UniFFI, joining its two siblings; Swift callers need `try` (the React Native bridge already wrote one). It was the last reliability updater to install its configuration unchecked, and once the other two began validating the *whole* candidate configuration — which is the right call, since a hand-rolled copy of `ProtocolConfig::validate` drifts — that gap acquired a second-order cost: `validate` checks the Bloom parameters this method installs, so a dedup configuration nothing had rejected could make a perfectly valid `update_retry_config` fail, complaining about a Bloom filter the caller never mentioned. `Deduplicator::with_config` already failed safe to exact HashMap mode on those values rather than panicking, so the cost was a rejection attributed to the wrong call rather than a crash. **`ProtocolConfig::validate` now also constrains the two dedup fields that are not Bloom-gated**, which is what makes the FFI break bind on anything a binding caller can reach: every Bloom check sits behind `use_bloom_filter`, and the UniFFI `DedupConfig` carries only `max_tracked_messages` and `retention_time_secs`, filling the rest from `Default` with Bloom off — so without these two constraints the new `[Throws]` would have been unreachable over Swift, Kotlin, and Python. Nothing failed safe on them the way the deduplicator does for the Bloom parameters: at `max_tracked_messages == 0` the exact-match tracker evicts on every insert, holding a single id, so duplicate suppression — a replay defence — was effectively off for a configuration the SDK accepted in silence. What this rejects is the *degenerate* value, and that is the whole of the claim: a floor of 1 is not a floor on how well duplicates are suppressed, since `max_tracked_messages == 1` behaves indistinguishably from `0` and a retention shorter than the link's retry backoff suppresses nothing either. Sizing the window for the deployment stays the application's call; the SDK refuses only the values that cannot be a choice, because nothing downstream spells them as a configuration error and the failure they produce — silently re-delivered messages — does not look like a config bug. Raising either floor to a policy minimum would be a wider behavioural break and needs its own decision. Apps passing either as `0` (both bridges read them straight from JSON) now get an `InvalidConfiguration` rejection and keep the previous configuration; the React Native bridge already logs and continues on a refused runtime update. Note the constraint binds at **construction** as well, since that is where `ProtocolConfig::validate` already ran: a Rust caller that builds a `ProtocolConfig` with either field at `0` now fails `OfflineProtocol::new` outright rather than keeping anything. That is Rust-only — the FFI init surface carries no dedup fields (`DedupConfig` is reachable only through `update_dedup_config`), so no binding caller can hit the constructor form. - -- **Breaking: `update_ack_config` and `update_retry_config` are now fallible.** Both return `Result` in Rust and are declared `[Throws=ProtocolError]` over UniFFI, so Swift callers need `try`. They reject a zero ACK timeout, a zero pending-ACK capacity, zero delays, and out-of-range lifetime bounds rather than installing a configuration that hot-loops or overflows. Both validate by building the candidate configuration and running `ProtocolConfig::validate` on it, rather than repeating its checks inline — a hand-rolled copy drifts, and a constraint added for a new field would otherwise be enforced at construction and silently skipped on the runtime-update path. No `ProtocolError` variants were added, so the append-only FFI error taxonomy is unchanged. - -- **Breaking: malformed or unresolved recipient tokens are rejected at every outbound boundary.** All user-targeted send APIs, plus `invite_to_group`, now validate the recipient as a `UserId` before any queue, outbox, clock, or transport side effect, so an app-owned placeholder cannot become indefinitely retried protocol state. This is a behavioural break, not only a hardening: `send_presence_update`, `send_typing_indicator`, `send_read_receipt`, `send_service_request`, and `respond_to_service_request` previously accepted any non-empty string, and `UserId` rejects `:` — so namespaced identifier forms (`unresolved:token`, `did:key:…`, `npub:…`) that used to work now fail with `InvalidArgument`. Apps carrying such tokens must resolve them before calling the SDK. Group *removal* and *role* mutation are deliberately exempt: admission is where a gate belongs, and one on the removal path would turn "a member with a stale-format id is on the roster" into "that member can never be removed or demoted". - -- **The React Native bridges' fallback ACK timeout is now 10 s, matching the SDK default.** Both bridges had been substituting 5 s when `updateAckConfig` was called without `defaultTimeoutMs`, silently halving the timeout relative to `DEFAULT_ACK_TIMEOUT_MS`. Apps that pass the field are unaffected; apps that omit it will see ACK waits — and therefore retry timing — return to the documented default. The drift test that pins the retry fallbacks now covers the ACK ones too, so the two cannot separate again. - -- **The React Native bridges' fallback pending-decryption TTL is now 30 minutes, matching the SDK default.** `DEFAULT_PENDING_TTL_MS` moved 2 min → 30 min when delivery ACKs became deferred; that change was Rust-core and UniFFI only, and all three React Native layers — `index.ts`, `ProtocolConfigParser.kt`, `EncryptionConfigReader.swift` — kept substituting the pre-deferred-ACK `120000`. Because the JS wrapper materializes `pendingQueue.pendingTtlMs` before it crosses the bridge, an RN app that omitted the field got the 2-minute window regardless of what the SDK default said — the same shape of bug as the ACK fallback above, on the queue the deferred-ACK model depends on most. Apps that pass the field are unaffected; apps that omit it now hold a message that arrived before its session was ready for 30 minutes instead of 2, which is the recovery window that model needs, since such a message is not delivery-ACKed and an eviction is a silent non-delivery. Memory is unchanged — the count caps (64 per peer, 4096 global) and the byte caps (4 MiB / 32 MiB, which binding callers get from the core defaults) still bound the queue, so a longer TTL lets entries linger within those caps rather than raising the ceiling. A drift test now pins all three bridge literals to `DEFAULT_PENDING_TTL_MS`, and the iOS and Android parser tests assert the default directly. - -- **Breaking: `ProtocolStateStorageProvider` values are `bytes`, not `sequence`.** A custom provider now receives and returns `ByteArray` on Kotlin, `Data` on Swift, and `bytes` on Python. `MlsStorageProvider` is unchanged: it carries key material a few hundred bytes at a time, where the representation does not matter. Protocol-state records reach megabytes, and `sequence` generates `List` on Kotlin — an inline class whose boxes, unlike `java.lang.Byte`'s, are not cached — so a 2 MiB record cost on the order of two million short-lived objects per call, on the way in *and* again on the way out. That is the pathological pending queue the per-peer byte budget exists to bound, paying the cost on exactly the path the bound covers. Changed now rather than in a follow-up because the interface is new in this release and has no implementations to break yet. - -- **`ProtocolStateStorage` no longer speaks the MLS crate's error type.** The Rust trait now returns `ProtocolStateError` / `ProtocolStateResult`, so the two storage domains are decoupled at the abstraction as well as at the lifecycle. The UniFFI callback surface is unchanged — `ProtocolStateStorageProvider` still throws `MlsStorageError`, and the adapter maps it — so no binding regeneration or native provider change is required. - -### Fixed - -- **The React Native bridges manufactured a phantom peer named `relay`, and then sent it mail.** Relay *answers* — `__GROUP_CREATED__`, `__GROUP_ERROR__`, and the `__GROUP_INFO__` / `__USER_GROUPS__` snapshots — are synthesized locally by the platform `InternetManager` from a relay notification; no peer transmits them. The bridges nevertheless gave them a fabricated identity, passing the literal string `"relay"` as the FFI `sender_id` and stamping the frame `requires_ack: true`. Those are two independent claims about a peer that does not exist, and the core acted on both. `internet_message_received` routes a non-empty `sender_id` into the reachability seam every carrier shares, so `relay` was inserted into `known_peers`, emitted as `NeighborDiscovered`, enrolled in service-discovery fan-out, and — under the default `auto_key_exchange` — sent an unsolicited key-package DM. Separately and far more often, the receive path answers a consumed internal message that asked for one with a delivery ACK addressed to the frame's `sender`, so **every injected frame** produced a second outbound DM to `relay`. Both are undeliverable, both draw a relay `DeliveryError`, and each `DeliveryError` re-arms the bridge's presence watch — which is why the resulting `Presence check for relay: false (last seen: None)` never aged out rather than lapsing at the idle TTL. Synthesized frames are now ingested unattributed (empty `sender_id`, a mode the core has supported and tested since the reachability seam was introduced) and built with `requires_ack: false`. Frames that name a *real* relay-reported actor — a group message's `sender`, an `added_by`, a `removed_by` — are unchanged and still assert reachability, because that is what drives outbox flush and Welcome re-arm for peers reachable only over the relay. The body's `sender` keeps the `"relay"` placeholder, since the Rust `UserId` rejects an empty string, but nothing acts on it any more. Note that the phantom was never a security failure — it could not decrypt anything and was addressable only by us — but it burned a `known_peers` slot, leaked group activity timing to the relay as ACK traffic, and made a real user id unusable had anyone registered as `relay`. - -- **The restore delete budget was spent per *walk*, so a launch could cost several times the bound it advertised.** `MAX_RESTORE_PRUNE_DELETES` exists because a device-barrier storm kills the launch — which is a property of the whole synchronous `initialize_mls` call, not of any one walk in it. Each walk nevertheless constructed a private allowance of that size, and `restore_outbox` constructed none at all, so a launch could issue several times the bound while every walk truthfully reported staying inside its own. That is the same failure the constant exists to prevent, one level up, and the release notes above already described the prunes as *sharing* a budget they did not share. They do now: the five advisory walks (session states, peer key packages, peer capabilities, Welcome lifecycles, media descriptors) draw on one pool created per launch. The two settlement-paired walks keep a pool each — being starved there defers a delivery or a diagnostic rather than a cache eviction, and neither may be held hostage to a key-package flood in an unrelated category. **Sharing a pool is not the same as sharing it fairly, and the advisory walks draw in a fixed order**, so each leaves the ones after it a `MIN_ADVISORY_PRUNE_DELETES` floor and may take the rest. Without that reservation the first walk empties the pool on its own — a key-package store over its cap by more than the budget, which the flood-eviction exemption makes an ordinary state rather than a tampered one, leaves every walk behind it with nothing on every launch. *Starved* is not *deferred*: the "re-walked next launch" property every prune here leans on only holds if the walk draws again, and these prunes are the only thing that ever deletes those records, so it would quietly have meant "never". The floor is deliberately well under an even share — the category that floods is the one that needs to converge fastest, while the ones it would starve hold small counts — so the rest of the pool stays elastic for whichever category is actually large. All three pools are constructed side by side by `initialize_mls` and threaded in, rather than by the walk that spends them: a pool allocated inside the callee is the same defect one level down, since a second call in the same launch would silently double the ceiling. The derived launch ceiling — `3 × MAX_RESTORE_PRUNE_DELETES` — is pinned end to end against a provider that counts deletes, which is the assertion a per-walk regression breaks and a per-pool test cannot see. It covers every durable delete the restore walks issue, including the two `restore_outbox` prunes that run after its read walk (see the entry below). Two things it does not bound: a settlement-paired walk stops *between* records, so the record it is already inside may push a little past its pool; and `adopt_legacy_protocol_state` runs before the walks and deletes from the *secure* store as it moves each record across, bounded by its own truncated-and-resumable pass rather than by this constant — a one-time upgrade sweep on a different provider, so count it separately rather than reading `3 ×` as a launch total. - -- **`restore_outbox` had no delete budget at all, and the argument that exempted it came from the wrong number.** The exemption rested on its walk bound capping it "near 1.5k deletes" — a figure that describes the capacity prune. The outbox is a *sealed* category, so the wrong-length record-key branch of `restore_or_init_state_record_key` makes every entry on the install fail to open at once and each one is dropped by the reader: the full walk bound, in device barriers, on the boot path. That is verbatim the case that put a budget on `restore_pending_messages` one entry above. The other half of the exemption does hold — every delete here is paired with a terminal `message_failed`, so an individual one may never be refused — but pairing forbids refusing a delete, not stopping *between* records. The walk now spends a counting budget and stops at a record boundary. It needs no freeze, unlike the pending walk: outbox records are keyed per message id, so every later write touches a different key and an unwalked record is simply restored and re-driven next launch. **The number the old exemption actually described was never bounded either**, and budgeting only the walk would have left precisely the argued-about case uncovered: the capacity drain and the absolute-lifetime drop run *after* the walk, on entries it already admitted, so a store whose records all open cleanly reaches them with the pool untouched and its working set bounded only by `OUTBOX_RESTORE_KEY_CAP` — up to 1500 device barriers on the boot path in the *ordinary* over-capacity case, not the tampered one. Both are settlement-paired like the walk, so both stop between entries in the same way; an entry the pool cannot fund is dropped from memory and left on disk **unsettled**, which is what keeps stopping safe — nothing has been claimed about it, so a later launch owns both halves. Deferring a delete never defers the in-memory cap itself, or an over-cap store would re-inflate memory on boot. - -- **One unreadable record in `session_states` or `welcome_lifecycles` failed `initialize_mls` outright, on every launch.** Both restore paths propagated any per-record storage error, so `list_keys` succeeding while a single record's read returned `LoadFailed` — an `EIO`, an ACL, a container the OS had locked — rolled initialization back. Nothing deletes such a record, so it failed again on the next launch and the one after; with `require_encryption` on by default that install can send nothing at all, and there is no in-app recovery. Every other category on the restore path already treated a per-record read failure as recoverable and continued. These two were the outliers, and moving both categories out of the credential store and into the app container — where `ENOSPC`, `EIO`, and protection-class failures are ordinary — is what made it matter. The restore-path reader is now infallible and answers three ways, and the `Unavailable` answer is load-bearing rather than cosmetic: a session-state record that is merely unreadable must not be treated as *absent*, because absent means "re-bootstrap and persist `Pending`" — a silent confirmation downgrade written straight over a record that may say `Confirmed`. A listing failure still propagates, since it is indistinguishable from an empty category and has no per-record fallback. **`restore_session_states_from_manager` was also the last restore walk issuing unbudgeted deletes**, exempted on the grounds that it iterates the MLS session list rather than a protocol-state category — but that list carries no restore cap of its own, so "bounded" there meant "bounded by the peer count", and a store that reports every session-state record corrupt turns it into one device barrier per peer on the boot path. It now draws on the shared advisory pool, where refusing costs less than anywhere else: a spared record still reads as absent, so the caller re-bootstraps and persists a fresh `Pending` over it, repairing the record whether or not the delete was funded. - -- **Persistence failures on the restore path no longer block restore.** A Welcome-lifecycle repair and the bootstrap of a missing session state both persisted with `?`, so a transient `StoreFailed` failed `initialize_mls` — a write failure blocking a read path, where every sibling call in the module logs and carries on. Both now log and continue: the repair holds in memory for the run, which is what drives the retry ladder, and a launch that can write re-derives the record. The Welcome repair ladder also persists once at the end rather than up to four times mid-ladder. - -- **The unaddressable-recipient path in `restore_pending_messages` no longer deletes a record that is not there.** A recipient whose queue listed but no longer loaded fell through to the drop below it, charging the delete budget, logging a destruction, and making a provider round trip for a record already gone. Nothing was destroyed, so nothing was owed. - -- **Two restore-path deletes were still outside the per-launch delete budget, and one of them was the common case.** The budget bounds how many durable deletes a restore walk may issue, because each is a synchronous provider round trip that flushes the containing directory — `F_FULLFSYNC` on iOS, a full device barrier. Two paths bypassed it. (1) `restore_peer_key_packages` drops a *expired* cached package inline, and that delete counted nothing — and the two branches are mutually exclusive, since an expired package never enters the in-memory map and so never makes the cap bind. An over-cap key-package store is over-cap because it is old, and the cached lifetime is 30 days, so an all-expired store routed *every* delete past the budget: exactly the store the budget exists for. (2) `restore_media_descriptors` budgets the three deletes it issues itself but not the fourth, which happens inside the reader when a record will not open. Media descriptors are a sealed category, so the wrong-length record-key branch makes every descriptor on the install take that path in a single launch. Both now share the walk's budget, and the reader accepts one so the delete is counted where it actually happens; a spared record is re-walked and dropped on a later launch, the same idempotent-and-resumable property the rest of the prune relies on. `restore_outbox` stays deliberately unbudgeted — every delete there is paired with a terminal settlement that cannot be postponed with it, and its walk bound is eight times tighter. - -- **The widest restore walk of all — `restore_pending_messages` — was outside the delete budget, and it is the one a regenerated record key empties in a single launch.** The budget's own worst-case accounting enumerated the key-package, capability, media-descriptor, and outbox walks and omitted this one, even though its record bound is the same `MAX_RESTORE_KEYS_PER_CATEGORY` (16384) the budgeted cache walks use — eight times looser than the outbox's — and it can issue *more than one* durable delete per record. Pending messages are a sealed category, so the wrong-length record-key branch makes every record on the install unreadable at once; and because a queue lost that way contributes no entries, the entry bound (`MAX_PENDING_RESTORE_ENTRIES`) never binds in exactly that case, leaving only the 16384 record bound between the boot path and 16384 device barriers. The exemption argument that covers `restore_outbox` — a delete paired with a terminal settlement cannot be postponed — is true here as well, but its second half (a walk bound 30× tighter) was not. So the walk now spends a *counting* budget, which charges every delete and refuses none, and stops at the next **record boundary**, freezing the recipients it did not reach for the session exactly as it already freezes the tail of its two other bounds. A frozen record is neither read nor written this session, so nothing is settled for it and nothing overwrites it, and the next launch drains it. `PruneBudget` now has no `Default`: whether a walk may *refuse* a delete or only *count* it is the whole safety question, so it must be answered at each construction site. - -- **Three more reader-internal deletes now charge the budget their walk already carries.** A record the store reports corrupt, or one over the record cap, is dropped inside `read_state_record_detailed` — the correction `restore_media_descriptors` received. `restore_peer_capabilities` had three of its four delete sources budgeted and routed the fourth around it; `restore_peer_key_packages` did the same for its loader's read; and `restore_welcome_lifecycles`, which walks up to 16384 container-listed keys, dropped an unparseable record with nothing counting it at all. All three now pass their budget to the reader. `restore_session_states_from_manager` stays unbudgeted on purpose — it walks the *MLS session list*, so its volume is bounded by sessions this install actually has rather than by whatever the container holds. - -- **The peer-capability prune no longer reports every delete as a capacity overflow.** `budget.spent` also counts corrupt, empty, and unreadable records, so a single bad record logged as "store exceeded the cap on restore" sent whoever read it looking for a key-package flood that never happened. Over-cap prunes and unreadable-record drops are now counted and reported separately, matching how `restore_peer_key_packages` already separates its expiry drops. - -- **The Android protocol-state store now sweeps its own write temporaries, like the iOS and Python ones.** From API 30 `AtomicFile.startWrite()` writes `.new` and `finishWrite()` renames it into place, and `AtomicFile` reclaims a stale `.new` only when something opens that key again. For a crash during an entry's *first* write there is no base file, so enumeration never lists the key, nothing ever loads or deletes it, and the orphan survives for the life of the install — the same leak the iOS sweep was added to close, in the provider that was assumed to get it for free. `.bak` is deliberately left alone: below API 30 `startWrite` renames the base to `.bak` first, so a lone `.bak` is the *good* copy, which is why `readHeader` prefers it. - -- **An initialization rollback no longer leaves the Lamport debounce watermark ahead of the clock.** `restore_lamport_clock` sets both `lamport_clock` and `last_persisted_lamport`; the rollback restored only the first, so `persist_lamport_clock`'s `wrapping_sub` read the difference as an enormous delta and wrote on every tick instead of every interval. - -- **Upgrading an install no longer silently discards its delivery state, its block list, or its Lamport clock.** Everything now in protocol-state storage — outbox, pending messages, session and Welcome lifecycles, peer key packages and capabilities, media descriptors, blocked users, the both-create owner gate, the Lamport clock — was previously persisted through the `MlsStorage` handle. Splitting the domains renamed where all of it lives, so without a sweep the first launch after an upgrade would have come up with an empty outbox, an empty pending queue, and **every previously blocked peer silently unblocked** — while the old records stayed in the credential store forever with nothing ever reading or deleting them, which is the worst possible resting place for `pending_messages` (message plaintext) and `outbox` (cloud-media `encryption_key`/`iv`). `initialize_mls` now performs a one-shot adoption sweep before restoring: each record is written into protocol-state storage (sealed on the way in, for the categories that require it) and only then deleted from secure storage. Unlike the MLS keyspace — open, because OpenMLS contributes its own labels — the protocol-state key types are a closed set the SDK declares itself, which is what makes a bulk move possible at all. The sweep is resumable (a crash leaves the remainder for the next launch), non-destructive (a key already present in protocol-state storage wins and its legacy twin is left alone), and marked complete only when it finished without a storage error, so a transiently unavailable credential store means "retry next launch" rather than "give up". A marker in protocol-state storage makes it one-shot, so state deleted after the upgrade cannot be resurrected by a later launch. - -- **Restore no longer drops messages the app is still waiting on without telling it.** A protocol-state record that is oversized, corrupt, or sealed under a key this install no longer has is dropped on read — but the app is holding the id `send_message*` returned, so a silent drop leaves that id unresolved and the message displayed as "sending" forever. Restore now distinguishes an absent record from a destroyed one and settles the difference: an unrecoverable outbox entry emits `message_failed` (its record key *is* the message id, so no decryption is needed to name it), an unrecoverable pending queue emits one `convergence_diag` with stage `pending_state_lost` for the recipient (the ids are inside the record that would not open), and an outbox entry pruned for capacity on restore now settles like the pending queue's capacity eviction already did. A pending queue dropped because its recipient is not a valid user id now settles with that reason rather than borrowing the capacity-eviction one. All restore settlements are parked until `start()` rather than emitted from `initialize_mls`, which apps routinely call before installing an event callback — matching how restored media descriptors already wait to be announced. - -- **A record that cannot be read *this session* is no longer settled as a terminal failure.** "Destroyed" and "unreadable right now" had been collapsed into one outcome, but only the first is a loss. A record whose per-install seal key could not be loaded, or whose backing store refused one read, is deliberately left on disk — so settling it emitted `message_failed`, and then the next launch restored the very same entry and re-drove delivery. The app would have been told the message failed and then had it delivered, with a hand re-send landing as a second copy (a new id, which dedup cannot collapse). Reads now report three states rather than two, and only a record that was examined and deleted is settled. - -- **A pre-split record too large to migrate is now settled instead of quietly deleted.** The adoption sweep must delete a legacy record over the 4 MiB protocol-state record cap — nothing could ever write or restore it — but it was doing so silently, leaving the app holding ids that never resolve. This is not a hypothetical: the pre-split build had neither a content cap nor a per-peer byte budget, only 64 entries per peer, so the installs these budgets exist for are exactly the ones whose legacy records can exceed the cap. An oversized legacy outbox record now emits `message_failed` (its key is the message id) and an oversized legacy pending queue emits `pending_state_lost` for the recipient, on the same deferred path as every other restore settlement — and, unlike restore's, these survive an initialization rollback, since the record they describe is already gone and nothing can re-derive them. - -- **A protocol-state record the *store* reports as corrupt is now settled and deleted, not left to be re-read forever.** `ProtocolStateError::Corrupted` is documented as an entry that exists and can never be decoded, but nothing acted on it: it fell into the same bucket as a transient backend failure, which the SDK deliberately treats as recoverable. So the record was left in place — re-examined on every single boot, never settled — and the application kept an id that would never resolve. Worse, because a corrupt Welcome-lifecycle record propagated as an error, a single one failed `initialize_mls` outright and rolled it back. Reads now classify `Corrupted` as a permanent loss: the record is dropped and, for the categories the application holds message ids for, settled with the same `message_failed` / `pending_state_lost` an unopenable sealed record already emitted. The built-in iOS, Android, and Python providers now report it — previously they deleted an oversized or malformed record and returned absence, which is indistinguishable from a record that was never written, so the loss reached the application as silence. Custom providers may still return absence; the contract now says plainly what that costs. - -- **The built-in iOS protocol-state provider now makes a write durable before it returns.** `Data.write(options: .atomic)` is rename-atomic but not flushed, so the rename's directory entry could commit ahead of the new file's data blocks and a power loss leave the record present and zero-filled. Android (`AtomicFile`) and Python (fsync of the file *and* its parent directory) already flushed; iOS now does the same with `F_FULLFSYNC`, on both the entry and the directory, and on delete as well — an unflushed unlink could otherwise resurrect an entry the SDK had already settled. The durability obligation is now part of the documented provider contract. - -- **`ProtocolStateError::NotFound` from a provider's `load` — and from its `list_keys` — is now read as absence.** The trait documents it as the variant for backends that cannot express absence any other way, but nothing acted on it: it propagated as an error, which restore then read as a destroyed record. A custom provider honoring the documented contract would have seen a spurious `message_failed` for every outbox entry it held, and an adoption sweep that never ran because probing for its own completion marker "failed". The same reading now applies to `list_keys`, where `NotFound` means an empty category: every restore propagates a listing error, so a backend that spelled emptiness that way failed restore and rolled `initialize_mls` back over a store that held nothing. This also covers UniFFI providers, whose `MlsStorageError.KeyNotFound` maps onto it. - -- **Upgrading an install no longer silently rotates its MLS identity.** Scoping the built-in secure store to `(app_id, user_id)` renamed it, so the first launch after an upgrade would have found an empty store, minted a *new* signing identity, and abandoned every session, group, and TOFU pin the install already had — with peers still holding the old pin then rejecting the new key. The namespaced store now adopts the pre-namespace one instead: a miss reads through to the legacy store and promotes what it finds. Read-through rather than a bulk copy, because the legacy key types are not a closed set (OpenMLS contributes its own labels, and `keyring` cannot enumerate at all), and because it is idempotent and resumable across a launch that dies mid-migration. `delete` removes the legacy copy too, so a deleted key cannot be resurrected by a later read-through. The legacy store was shared by every account on the install, so at most one may inherit it: the first to launch writes a claim, and a second account starts from a fresh identity — correct, since the legacy store never held a separable identity for it — but says so out loud rather than rotating silently, via an `error` diagnostic on React Native and `SecureStorage.legacy_adoption` in Python. - -- **Built-in protocol-state records are addressed by digest, not by an encoding of their key.** Base64url filenames were neither case-safe nor bounded: `AAG` and `AAa` resolve to the same file on a case-insensitive volume (APFS's macOS default and Windows), so one record silently overwrote the other, and a valid 190-byte id produced a 256-character name that overruns the common 255-byte `NAME_MAX` — meaning the built-in stores did not implement core's full valid keyspace. Entries are now named by a fixed-length lowercase SHA-256 digest, and each record carries its own `(key_type, key_id)` in a small self-describing header (`"OPS1"` magic, big-endian `u16` lengths, UTF-8 keys, then the value), so the exact key is recoverable for listing and every read can verify it opened the record it asked for. The format, its limits, and golden vectors are pinned by identical tests on iOS, Android, and Python. Namespaces are now validated on all three platforms before becoming a path component or credential-store suffix. - -- **The record-size ceiling is now enforced inside each provider, before allocation.** `load` returns an owned byte vector, so core could only check a length it had already been handed — a corrupt or tampered multi-gigabyte file was read whole, and copied across UniFFI, before the 4 MiB guard ran. Each built-in provider now stats the entry first and drops anything over `MAX_PROTOCOL_STATE_RECORD_TRANSFER_BYTES` (8 MiB, a deliberate superset of core's cap plus its seal envelope, pinned by a Rust test) rather than reading it; `store` refuses oversized values, and key enumeration reads only record headers and stops at a bound. The obligation is now part of the documented `ProtocolStateStorage` contract for custom providers, since it cannot be enforced anywhere else. - -- **Sensitive protocol state is now sealed before it reaches install-scoped storage, restoring the at-rest protection it had while it lived in credential-backed storage.** Splitting delivery state out of the credential store gave it the app container's *lifecycle*, but would also have given it the app container's confidentiality — and that state is not merely "non-cryptographic": pending session entries carry original message plaintext, and outbox entries and rich extras can carry cloud-media `encryption_key`/`iv` values. Pending messages, outbox entries, and media transfer descriptors are now sealed with ChaCha20-Poly1305 under a per-install key held in secure storage (`protocol_state_record_key`), with each record's associated data binding it to its `(key_type, key_id)` slot so records cannot be moved between peers or categories by anyone with write access to the container. Providers see only ciphertext and must store the bytes they are handed verbatim. Sealing fails **closed**: with the key unavailable those categories are not persisted at all rather than written in the clear (delivery is unaffected; only crash recovery for them is lost), and there is no plaintext-passthrough on read — a record that will not open is dropped rather than trusted. Record keys stay in the clear because the store addresses entries by them. - -- **Re-queueing a pending message no longer renews its absolute lifetime.** `pending_message_max_lifetime_ms` is measured from when a message *first* entered the pre-session queue, but a flush that found the session still unavailable put the message back with a fresh `queued_at` — so repeated reconciliation could keep a message alive indefinitely past a lifetime the config presents as absolute. A re-queue now carries the original timestamp forward (in memory and in the persisted snapshot), so an entry that was already past its window is expired by the very next cleanup. - -- **A pre-split adoption pass that had to truncate no longer marks itself complete.** The sweep bounds how many entries it walks per category, but it was still writing its one-shot completion marker afterwards — so anything past the bound stayed in the credential store forever with nothing ever reading or deleting it, which for `pending_messages` and `outbox` means message plaintext and cloud-media key material parked in the one place the sweep exists to clear. Because adoption deletes each record only once it is durably rewritten, withholding the marker drains the remainder over successive launches at no extra cost. - -- **Restores that walk a whole category are now bounded consistently.** Blocked users, the both-create owner gate, and Welcome lifecycles were reading every key a store listed straight into memory, while the categories touched by this branch stop at a generous multiple of their own caps. The justification for that bound is a tampered store — and this state just moved out of the credential store into the app container, where write access is easier to obtain, so it applies more strongly than before, not less. All three now stop at the same bound and log the ignored tail. - -- **A Python `SecureStorage` built without an account namespace now says it cannot adopt the pre-namespace store.** Adoption records its claim under the namespace, so a provider constructed without one silently lands on the new service name, finds nothing, and mints a fresh MLS identity — abandoning every session, group, and TOFU pin the install had. `ProtocolManager` always supplies the namespace, but the provider is a documented constructor parameter; a caller building their own now gets a warning instead of a silent identity reset. Explicitly passing `adopt_legacy_store=False` stays quiet, since that is a decision rather than an accident. - -- **A blocked-user listing failure now fails initialization instead of coming up unblocked.** A listing error is indistinguishable from an empty store, so swallowing it started the SDK with an empty block list and told no one — every blocked peer silently unblocked, from a transient error, which is the same outcome the downgrade warning above exists for. This state also just moved from the credential store into the app container, where a read can fail for far more ordinary reasons. `restore_blocked_users` now propagates like every other category walk, so `initialize_mls` rolls back and the application finds out rather than running unprotected. - -- **An unaddressable pending queue is no longer destroyed while its ids are unreadable.** A persisted queue whose recipient does not validate is dropped, but the ids the application is holding live *inside* the record — so the drop was reading it first. When that read reported the record as intact-but-unreadable-this-session (a launch that could not load the per-install record key), the queue was deleted anyway and the ids went with it, unsettled: the exact silent loss the three-state read exists to prevent. Both halves are reachable on the same launch — recipient validation is new here, so the queues that fail it are the pre-upgrade ones, and that first post-upgrade launch is also the one most likely to find the credential store locked. The record is now left in place for a later launch to settle properly, and one that was examined and destroyed reports `pending_state_lost` like any other unrecoverable queue. - -- **A pending queue the restore walk never reached is now frozen for the session, not just left on disk.** Both bounds on the pending walk stop early and leave the remainder "for a later launch" — but a pending record holds a recipient's *whole* queue, so leaving it there protected nothing: the next ordinary enqueue for an unwalked recipient persisted the in-memory view, one message, straight over a record holding up to 64, and `block_user` (or an aborted pending session) deleted it outright. Either way the ids inside a record nobody had opened were destroyed and settled to no one — the same silent loss the unreadable-record freeze already prevents through the other door, arriving through the one path into it that had no freeze. Unwalked recipients now join that freeze, so their records are neither written over nor cleared until a launch can read and settle them. Reachable on exactly the installs the entry bound exists for: the pre-split build had no pending-queue caps at all, so an upgrade can legitimately hold more queued entries than one restore may walk. - -- **The built-in Android provider's key enumeration now prefers an `AtomicFile` backup over a torn write.** On API < 30 `startWrite` renames the base entry to `.bak` and then writes the base, so a `.bak` on disk means the base is a partial write — which is why `openRead` discards it. Enumeration did the opposite, parsing the torn base, so a crash mid-`store` could drop the key from `listKeys` even though `load` recovered the record perfectly well. The entry was then listed by nobody, restored by nobody, and deleted by nobody — stranded for the life of the install, in precisely the crash the atomic write exists to survive. - -- **The built-in iOS provider now reports a failed directory enumeration instead of an empty category.** Android and Python already threw; iOS returned no keys, which core reads as "nothing is filed here" — so the records were restored by nobody and settled to nobody. The three built-in providers are meant to be the same implementation in three languages, and this was the one that answered silently. - -- **A pending queue that could not be read this session is now frozen on disk, not just skipped on restore.** Restore correctly left an unreadable queue in place — but the pending queue is persisted as one record per recipient holding the *whole* queue, and nothing remembered that it had been skipped. The next enqueue for that peer wrote a snapshot of the in-memory view (one message) straight over messages the application is still holding ids for, settled to no one; `block_user` and the aborted-session path deleted the record outright. Honoring the three-state read at restore and then clobbering the record at runtime is the same silent loss, one layer down. Such a recipient is now refused both writes and deletes for the rest of the session, behaving exactly as it does when no storage is configured, so a later launch can settle or restore the record properly. Reachable while the record key loads perfectly well: it is a *per-record* provider failure, not a locked credential store — that case already fails closed on write and cannot clobber anything. The outbox needs no equivalent, being keyed per message id. - -- **Adoption's "post-split state wins" probe now gets the same three-way read as everything else.** It went through the loader that reads `Corrupted` as absence, so a destination record the store had just examined and destroyed was treated as never having existed. When the legacy twin was still there the overwrite was a genuine recovery; when it had vanished too, the record was gone and the application was told nothing — the only destruction path in this work that did not settle. The probe now settles that case, and a destination reported as unreadable *this session* defers the record instead of adopting over it, withholding the marker so the sweep retries rather than overwriting a record a later launch can still read. The sweep's dependency on the secure store's legacy read-through — it enumerates the namespaced handle to reach records that live in the un-namespaced one — is now documented rather than silently load-bearing. - -- **The built-in Android provider now flushes the directory entry after a store and a delete.** `AtomicFile.finishWrite` fsyncs a record's contents, but the link it renames into place — and the one `delete` removes — lives in the parent directory and needs its own flush; without it a power loss can lose a store the SDK was told succeeded (sharpest for records sealed under a key it just persisted) or resurrect an entry the SDK has already settled. The iOS and Python providers already did this. Best effort, as on the other two. - -- **A claim on the legacy secure store is now verified before it counts as an adoption.** Only one account may inherit the pre-namespace store, and the claim written into it is what makes that exclusive — but the write was best-effort and its result never checked, so a failure was indistinguishable from success. The store then still looked unclaimed to the *next* account, which adopted it too: both would promote the same MLS signing identity, and with it each other's sessions and group state, which is strictly worse than the conflict the claim exists to produce and reached the application as silence. The claim is now read back, and anything other than our own namespace coming back — a throw, an unreadable claim, someone else's — leaves read-through off and is surfaced through the same `error` diagnostic a conflict already used. The read back also catches a second account claiming the store between our probe and our write, which the probe alone cannot see. The invariant this restores is not "the first account to launch wins" but "at most one account holds a verified claim"; the cost is a fresh identity for a launch whose credential store is failing writes, which is a store that could not have completed the adoption anyway. - -- **The two cache restores are now bounded like every other category walk.** Peer key packages and peer capabilities *prune* their overflow rather than ignoring it — dropping a cached entry only costs a recoverable re-exchange, so shrinking the store to the live cap in one boot is the right policy — but neither stopped walking, so the only ceiling was the provider's own listing bound, four times the one core applies everywhere else. Every pruned entry is a synchronous provider delete, and all three built-in stores fsync the type directory on one, so a tampered container turned tens of thousands of those into boot-path latency. That is the threat model every other walk here is bounded against, and it applies more strongly now that this state lives in the app container rather than the credential store. Both now stop at the shared bound, which still shrinks the store by that bound minus the cap on every launch, so an over-large store drains over a handful of them instead of stranding. - -- **The built-in Python provider now sweeps write temporaries a previous process died before renaming.** `store` writes to a temporary and renames it into place, so a crash in between orphans that file — and enumeration filters on the record prefix, so nothing ever looked at it again. They accumulated for the life of the install in a directory the application cannot reasonably be asked to clean itself. The first store into a category now removes them, bounded and best-effort, which keeps it off the restore path and out of the way of a store that is about to succeed. - -- **The legacy-store conflict diagnostic now names everything a conflicting account loses.** It reported only that the account starts from a fresh MLS identity. Since delivery state moved into the same read-through path, such an account also comes up with an empty outbox, an empty pending queue, and an empty **block list** — the outcome the downgrade warning at the top of this section exists for. Python's no-namespace warning says the same. - -- **Legacy-store adoption is now serialised process-wide, so two accounts starting at once cannot both inherit the same MLS identity.** Reading the claim back closes a write that silently failed, and a second account claiming between our probe and our write — but not two accounts interleaving: `A` probes an unclaimed store, `B` probes it too, `A` writes and reads back its own namespace and adopts, then `B` writes and reads back *its* namespace and adopts as well. Both promote the same signing identity, and each ends up holding the other's sessions and group state, silently — which is the outcome the claim exists to produce a loud conflict for. The invariant is "at most one account holds a verified claim", and an unsynchronised read-modify-write does not provide it however carefully the read-back is done. All three built-in providers now hold a process-wide lock across the whole probe → claim → read-back sequence; a per-instance lock could not have ordered them, since two accounts are two provider objects. There is no cross-process case to close for a single application's credential store. Pinned by a Python test that forces the interleaving with a barrier and fails with both accounts adopting when the lock is removed. - -- **An oversized pre-split outbox record whose key is not a parseable message id is now surfaced rather than deleted in silence.** The adoption sweep settles a record it has to destroy so the application does not keep an id that never resolves, but the outbox settlement was keyed on parsing the record key as a `MessageId` and emitted nothing at all when that failed — the last silent-destruction path left in this work. Such a key should not exist, which is exactly what every other record on this path has in common. It now falls back to the same `pending_state_lost` diagnostic the pending queue uses, carrying the raw key. - -- **The TOFU restore walk is now bounded like every other category walk.** It read whatever `list_keys` returned from start to finish, loading every entry into memory before applying `MAX_TOFU_PEERS` — the one restore with no ceiling, in a release whose other five walks were all bounded against exactly this. Living in the credential store rather than the app container is a weaker threat model, not an absent one, and the bound is about work on the boot path either way. The tail is deliberately *ignored*, never pruned, unlike the two cache restores: a dropped key package costs a re-exchange, whereas deleting a TOFU entry silently re-arms trust-on-first-use for that peer, so the next key it offers is accepted with no mismatch warning. Stranding an over-cap pin is the strictly safer failure. - -- **Terminal settlements parked by restore are now explicitly capped.** The restore caps bound how many can be produced, but they bound it as a sum across every category, and nothing drains the queue until `start()` — which an application that only calls `initialize_mls`, or that retries it against a store that keeps failing, may never reach. The queue now stops at twice the pending queue's own global cap, keeping the oldest (dropping those in favour of later ones would bias the survivors by backend listing order) and reporting the suppressed count when `start()` drains. - -- **A restore settlement is no longer discarded by an initialization rollback, when the record it describes has already been destroyed.** The rollback restores in-memory state; it restores no *storage* state, because restore has none to give back. By the time a later step fails, an earlier one has already deleted an unaddressable pending queue, dropped a record its store reported corrupt, and rewritten a peer's snapshot without the entries it evicted for capacity. Rolling those settlements back left the application holding ids that resolve to nothing — not on that launch, and not on any retry, since the records they would be re-derived from are gone. This was reachable and got wider in this release: `restore_pending_messages` runs first and now propagates through a `restore_blocked_users` that used to swallow its failures. The invariant already documented for the adoption sweep — nothing can re-derive a settlement for a record that no longer exists — turns out to hold for every settlement produced under the transaction, so none are rolled back now. The cost is that a retry re-examining a still-present record can settle the same id twice; a duplicate terminal event is a far smaller lie than silence. Separately, the rollback now also covers the peer key-package cache, parked media descriptors, and the both-create owner gate, which restore populated but the snapshot never captured. - -- **The pending-queue restore walk is bounded by entries, not just by records.** Each pending record holds a whole recipient's queue, so a record-count bound alone admitted `MAX_RESTORE_KEYS_PER_CATEGORY × 64` entries — and holding the global caps across them is not free, because finding the oldest entry to evict scans the entire in-memory queue. That is roughly four billion comparisons on the *synchronous* boot path, before counting one provider load per record. It is reachable without tampering: the pre-split build had no pending-queue caps at all, so an upgraded install can legitimately hold far more than the caps now admit, and the adoption sweep moves all of it into the store this walk reads. Measured on 2000 recipients × 64 entries, in release with an in-memory backend: 1.46 s before, 0.17 s after, with the same global cap enforced. This is the same lesson the built-in providers' `list_keys` bound learned — count the work, not the results. The tail past the bound is *ignored*, never pruned, like the record-count tail above it: those ids live inside records nothing has opened, so deleting them would be an unsettleable loss. Restored entries are also measured only after the per-peer count trim, so the entries that trim drops are no longer re-serialized to compute a size nothing reads. - -- **A protocol-state category whose sensitivity has not been decided now fails closed.** Whether a category is sealed was a `matches!` over `&str`, so adding a new category was a two-place edit whose failure mode was silent: the default answer to "is this sensitive?" was "no", and the consequence of that answer is message plaintext or cloud-media key material written to the app container in the clear. The decision is now an exhaustive `match` over an enum — adding a category is a compile error until someone classifies it — and `write_state_record` refuses a key type the enum does not cover rather than writing it unsealed. - -- **An unrecoverable outbox record whose key is not a parseable message id is now surfaced rather than deleted in silence.** The adoption sweep gained this fallback earlier in the release; restore did not, so it kept the settlement keyed on parsing the record key as a `MessageId` and emitted nothing at all when that failed, while deleting the record regardless. Both paths now share one helper, which falls back to the `pending_state_lost` diagnostic carrying the raw key. - -- **The Python binding now documents what a plaintext `keyring` backend costs.** `SecureStorage` already warned when `keyring` resolved to a null or plaintext backend, but the warning understated it: the credential store also holds `protocol_state_record_key`, the per-install key that seals delivery state before it reaches `AppStateStorage`. On a plaintext backend that key sits in a readable file, so the split gives separation of lifecycle but not of confidentiality. - -- **One undecodable session-state or Welcome-lifecycle record no longer fails `initialize_mls` forever.** Every other restore on this path drops a record whose bytes will not decode and carries on; these two propagated the deserialization error instead, so a single such record failed initialization outright — and, because nothing deleted it, failed it again on every launch after that. With `require_encryption` on by default that install can no longer send anything, and there is no in-app recovery. An earlier fix in this release covered only the case where the *store* reports `Corrupted`; a record the provider frames perfectly well but whose JSON does not parse still hard-failed, and framing is all a provider validates. Both categories are unsealed, so they carry no integrity protection at all, and both just moved from the credential store into the app container — the threat model every restore walk here is bounded against. The restore walk now drops such a record and continues; a session whose confirmation cannot be read is re-bootstrapped as `Pending`, never `Confirmed`. The *send* path deliberately keeps propagating it: `is_session_confirmed` must fail closed rather than read a Confirmed session as Pending because its record would not decode, and the two behaviours are now pinned apart by test. - -- **The built-in Python provider creates its directories owner-only.** iOS and Android get container isolation from the OS; Python's container is whatever directory the application names, created at the process umask and so typically world-listable. Record *files* were already owner-only (`mkstemp` creates them `0600` and `os.replace` preserves that), so contents were never exposed — but a listable directory leaks the entry count and, since the filename digest is unsalted, confirms any guessable peer or message id. Directories are now created `0700` and an existing store is tightened on open, since `mkdir(mode=...)` is both masked by the umask and a no-op when the directory already exists. - -- **The built-in Android provider's key enumeration counts a record once, not twice.** An entry and its `AtomicFile` `.bak` twin are one record — the header read resolves both to the same target — but each name incremented the examined counter, so a directory of twinned records spent two of the bound on every one of them and halved the effective ceiling on exactly the tampered or crash-interrupted directory the bound exists for. - -- **Terminal settlements parked while the protocol is paused are now drained on `resume()`, not only on `start()`.** `settle_restored_message_failure` parks anything it produces while the event pipeline is not live, and `start()` was its only drain. But `update_retry_config` reaches that path at runtime: shortening `pending_message_max_lifetime_ms` expires queued messages and settles them, and an application tuning its reliability config while backgrounded — the whole reason `pause()` exists — left the resulting `message_failed` in the deferred queue until a `start()` that a resumed process never performs. Both edges into `Running` now drain, so a parked settlement has no state to be stranded in. - -- **The built-in iOS provider sweeps write temporaries a previous process died before renaming.** The same gap the Python provider closed earlier in this release, in the one store that still had it: `Data.write(options: .atomic)` writes a temporary in the same directory and renames it into place, so a crash in between orphans that file permanently — enumeration filters on the record prefix, so nothing ever looks at it again — and they accumulate for the life of the install. Android self-heals through `AtomicFile`. The first store into a category now removes them, bounded and best-effort, which keeps it off the restore path. - -- **The React Native and Python READMEs said an upgrade does not carry restartable delivery state across.** Stale from before the adoption sweep, and self-contradictory on its face — the outbox and pending queue *are* the delivery state, and they are swept out of the credential store into the app container on first launch. The CHANGELOG was corrected earlier in this release; these are the two files an application team actually reads to decide what to test on an upgrade. Both now describe the two adoption mechanisms separately, say that an account which loses the legacy-store claim gets neither identity nor delivery state, and repeat that downgrading is not a rollback. - -- **An inviter-attested rich capability no longer overwrites what a peer advertised for itself.** Attestation merges into the peer's existing capability record, but the loader behind that merge folded "there is no record" and "this read failed" into the same answer, so one transient provider failure wrote an attested-only record over the peer's own `env_versions` and `rich_versions` — the authoritative ones. The damage outlived the session: the next launch restored a record with no envelope capability, so encrypted DMs to that peer dropped back to the legacy JSON envelope (~2.7× larger, more fragments) until a live key-package exchange rebuilt it. Reachable only for a peer not already known rich-capable, which is exactly the peer attestation exists for. The loader now gives the same three-way answer every other protocol-state read does, and a record that cannot be read this session skips the write entirely — the in-memory set still opens the group seal gate for the run, and the next Add commit re-attests. - -- **Restore-time pruning is now bounded per launch, so a large store cannot turn initialization into a device-barrier storm.** The restore walks bound how many records they *read*. Nothing bounded how many they *delete*, and those are not the same cost: every built-in provider flushes the containing directory after an unlink, which on iOS is `F_FULLFSYNC` — a full device barrier, not a hint. Pruning a whole over-cap category in one pass therefore issued up to tens of thousands of those on the synchronous `initialize_mls` path, which is not a slow launch but a launch the platform watchdog kills. This was not only the tampered case: a peer's capability record is deliberately spared from the key-package flood eviction when it has an established session, so an install with more sessions than the capability cap accumulates over-cap records legitimately. The peer key-package, peer capability, and media-descriptor prunes now draw on a delete budget and leave the remainder for the next launch, which is safe precisely because pruning is idempotent and resumable — the same property the pre-split adoption sweep already relies on. `restore_outbox` is deliberately excluded: each of its deletes is paired with a terminal `message_failed`, and the two cannot be separated without either settling an id a later launch restores and re-drives, or dropping an entry while the application still holds an id nothing resolves. (Both of those statements were narrowed further later in this release — the budget became a genuinely shared pool, and `restore_outbox` gained one.) - -- **The built-in Android provider no longer flushes a directory it did not modify.** `AtomicFile.delete()` succeeds silently when the entry is already absent, and the directory fsync ran regardless — on paths that delete speculatively (clearing a pending queue for a peer with no record, dropping an already-consumed key package, removing a descriptor a transfer never wrote), so the expensive half of the call ran for calls that changed nothing. iOS and Python both returned before their flush for exactly this; the three providers are meant to be one implementation in three languages. - -- **The built-in iOS and Python protocol-state providers now serialise on a process-wide lock, like the Android one.** Their locks were per-instance, and the stale-temporary sweep's safety argument is that no temporary another writer is using can be visible while the lock is held — which a per-instance lock cannot provide. Two providers over one root are not hypothetical: the React Native bridge constructs a fresh one on every `initializeMls` call. Interleaved, one provider's sweep could unlink the temporary another's atomic write was about to rename into place, turning a store that would have succeeded into a failure. - -- **A failed clear of a persisted pending queue is now logged rather than swallowed.** Every other persistence failure in that module warns. This one did not, so a clear that silently failed left a record the next launch restored and re-flushed: the messages carry their original ids, so receivers dedup them, but the sender re-emits `MessageSent` for traffic it had already delivered and nothing said why. - -- **A protocol-state record key of the wrong length is no longer logged as if it were routine key generation.** Regenerating it is correct — nothing can recover the original, so whatever it sealed is already unrecoverable, and refusing to regenerate would preserve ciphertext nobody can open while permanently disabling persistence for every sensitive category. But the consequence is that the entire outbox and pending queue are settled as terminal failures on the next restore, and the log gave no way to tell that apart from a first-run key being minted. It now names the consequence, and the asymmetry with the recoverable load-failure branch (which leaves the key uninstalled and settles nothing) is written down where the next reader will find it. - -## [0.16.6] — 2026-07-28 - -### Fixed - -- **Internet-only devices no longer stall a DM or an MLS welcome parked on a `recipient_unreachable` verdict — the escalating reachability probe now runs on every carrier, not just when a local mesh carrier happens to be up.** The park introduced in 0.16.x kept a timed probe (15s doubling to a 600s cap) only when BLE / Wi-Fi Direct was available; on an internet-only device — the common configuration — the else branch parked **edge-only**: no pending ACK, no retry entry, nothing on a timer at all. The message had *no* self-recovery, so delivery waited entirely on an external reachability edge (`flush_outbox_all` on reconnect/`start()`, or a presence-online answer), i.e. on the platform's presence-polling cadence — tens of seconds to minutes, and nothing whatsoever for a headless consumer that never polls presence. This is the residual 1–2 minute delivery delay that survived the earlier relay-socket fixes. The carrier guard was never the real bound anyway (DORS could always route a mesh device's probe onto the relay); the escalation ladder is, and probing over the relay is self-limiting in every outcome — a still-offline peer returns a fresh verdict that escalates the interval, an accepted frame becomes an ordinary in-flight send on the ACK ladder, and a peer that is back means the probe *was* the delivery. The park counter is likewise armed unconditionally, which is what keeps probe exhaustion re-parking (`try_repark_exhausted_dm`) instead of settling terminally. On the welcome side the presence-offline rotation no longer downgrades a live probe back to edge-only, while still cancelling a genuine carrier-backed data-plane retry (the two are told apart by the reason code, not by the sticky park counter). Welcome parks now also extend the lifecycle TTL past the retry they schedule: the ladder reaches 480s/600s while the TTL is 300s, and `try_send_welcome` checks expiry *before* running a due retry, so without this the probe would have expired the very welcome it was meant to recover (`welcome_send_expired` + `secure_session_failed`) after roughly eight minutes of peer downtime. Rust-core only; no wire, schema, UDL, or event-shape change. - -- **A welcome probe that the relay accepts no longer expires the welcome it exists to recover.** The relay answers `DeliveryError` only when the recipient is undeliverable *and* its push fallback also failed — so whenever push succeeds the probe earns no verdict at all and resolves at the 10s confirm timeout instead. Scored as an ordinary carrier-backed failure that charges a retry attempt and arms a plain data-plane retry, and with nothing in that ladder pushing `expires_at`, the record walked into terminal `welcome_send_expired` + `secure_session_failed` within one TTL window (~5 minutes at the first rung) for a peer that was merely offline. The only thing that stopped it was a presence-offline answer cancelling the retry — no defense at all for the headless consumer this probe exists to serve, which is a regression against the pre-probe behavior of parking quiet and surviving. A confirm timeout on a record still holding a live unreachable-park counter is now treated as another unreachable verdict: the attempt is refunded, the interval escalates and the TTL is pushed, mirroring the DM path's `try_repark_exhausted_dm`. The platform bridge's wire confirm (socket-write success, issued before the relay can answer) no longer upgrades a live probe to `Sent` either — that would clear the confirm deadline and park the welcome silently in a state outside every retry scan, making the repark unreachable on exactly the internet path it exists for; the probe stays `SendAttempted` so the deadline can resolve it, and genuine delivery still converges through session confirmation. Bounded by `WELCOME_WATCHLIST_MAX_AGE_SECS` (14 days from creation, the same threshold that gives up watching a peer as permanently dead), past which the record ages out normally — the welcome's twin of the DM probe's absolute outbox bound. - -- **Delivering any DM to a parked peer now re-drives that peer's other parked DMs immediately.** The park counter is per-peer while the probes are per-message, so a burst of DMs to an offline peer escalates the shared ladder once per park — message 1 probes at 15s but the seventh is already at the 600s cap. Clearing the counter on delivery was not enough on its own: the siblings sat out their own escalated timers, and on a consumer that never polls presence the delivery ACK is the only edge that will ever say the peer is back. The re-drive prefers the transport the delivered message was last sent over — the sender's own record, which the ACK just proved reaches the peer — falling back to DORS when that carrier is no longer available locally. (Deliberately not the ACK's transport label: that field is peer-supplied and decodes any absent or unknown value to BLE, which would let a peer pin a burst of sibling sends to a carrier of its choosing.) - - **Contract notes for app teams:** `message_undeliverable` now repeats on internet-only devices for as long as the peer stays offline (once per probe verdict, settling at one per 600s per message) where it previously fired once and went silent — it remains a non-terminal, repeatable status signal, and terminal settlement is still only `message_delivered` or outbox-lifetime `message_failed`. `welcome_send_failed` with reason `PEER_UNREACHABLE` now always carries a `next_retry_at`, where it previously carried none on internet-only devices. Terminal `message_failed` for an internet-only parked DM moves out from ~7 days to ~28 days: each probe refreshes the outbox entry's last-send timestamp, so the sliding 7-day lifetime stops binding and settlement falls to the absolute cap (4× the lifetime) instead. For relay capacity planning, note that the "one frame per 600s" steady state describes only the branch where the relay answers with a verdict; when the relay accepts the frame the probe rides the ordinary ACK ladder (up to `max_retries` sends on 1s → 300s backoff) before re-parking. - -- **iOS: the relay write-stall watchdog now fires on time when send completions arrive out of order — it retires the completing write's OWN slot, not the oldest one around.** The watchdog shipped in 0.16.5 keyed each tracked write on its socket generation and, on a send completion, popped the oldest entry of that generation — so a completion did not necessarily retire its own write. `URLSession` makes no promise that send completions fire in send order, and in the mixed regime where some writes complete while an older one hangs, a fast write's completion discarded the still-hung older write's timestamp and re-keyed the stall clock off a younger send, delaying the zombie-socket teardown by the gap between their send times. The outstanding *count* stayed honest either way — each completion retires exactly one slot, so a genuinely hung write could never drain the FIFO to empty and the watchdog always fired — but the *timestamp* it fires on did not, so it fired late. (In the failure mode the watchdog exists for — a socket the OS silently killed, where nothing completes at all — there are no out-of-order pops and the 0.16.5 behaviour was already exact; this only bites the mixed regime.) `arm` now mints an opaque, never-reused `WriteToken` and the write's own completion hands it back to `disarm`, retiring exactly that entry. Token identity **supersedes the socket-generation tag described in the 0.16.5 entry below** and strictly improves on it: a cancelled completion from a torn-down socket names an entry `reset` already dropped, so it still cannot pop a live successor's freshly-armed slot — and because the token counter deliberately survives `reset`, that now holds by construction rather than by which generation happened to be minted. `sendWatched` also widens from arm+send to the whole arm/send/disarm triple, so a future send site cannot drop coverage on either end, and the `task.taskDescription` generation parse is gone from the watchdog path (that stamp now has a single consumer, the close-`4000` bygone-generation decision). iOS-bridge only, no user-facing API change (`swift test`, 14 cases); no Rust/core, wire, schema, UDL, or event change. - -## [0.16.5] — 2026-07-27 - -### Fixed - -- **iOS: a relay socket the OS killed during suspension no longer black-holes all egress (and can no longer freeze the entire data plane) for the ~1min OS TCP timeout — a new write-stall watchdog is the iOS analogue of OkHttp's `writeTimeout`.** `URLSessionWebSocketTask.send` has no write timeout: on a socket the OS silently killed while the app was suspended, the send completion just never fires — no error, no delegate callback — until the ~1min+ OS TCP timeout. On the iOS relay bridge that stalled write both black-holes all outbound traffic and, if it is a control-op primary, pins `inFlightControlPrimaries > 0` so the poll gate freezes the whole data plane (DMs, ACKs, typing, read receipts) for that entire window. Android never sees this because OkHttp's `writeTimeout(10s)` fails a hung write in seconds. The new `WriteStallWatchdog` gives iOS the same bound: every poll-path data/control write is funnelled through a single `sendWatched` that arms the watchdog immediately before `task.send` (so a future send site cannot silently drop coverage) and disarms it from the completion; the poll checks the oldest outstanding write each tick and, once it ages past the 10s timeout (matching the Kotlin bridge's `writeTimeout`), tears the suspected zombie down — the cancel then fires the hung completions promptly and `autoReconnect` + outbox flush re-drive the backlog. Each tracked write is tagged with its socket generation so a late cancelled completion from a torn-down socket can never retire a live successor's freshly-armed write. Auth, ping, presence, and raw-command writes are deliberately not watched (they cannot freeze the data plane). iOS-bridge only, extracted into a unit-tested standalone policy class (`swift test`, 13 cases); no Rust/core, wire, schema, UDL, or event change. - -- **iOS and Android now automatically force a relay reconnect on foreground after a background stay long enough to have killed the socket — apps no longer need to call `forceInternetReconnect()` on foreground.** `forceInternetReconnect()` (0.16.1) was an app-driven primitive: the app had to detect a stale-post-suspend socket and call it, debounced and gated on background duration. Both native bridges now do this themselves — iOS from `applicationWillEnterForeground`, Android via a `LifecycleEventListener`'s `onHostResume` — gated on a shared, unit-tested `ForegroundReconnectPolicy` (a ~4s minimum background stay, measured with monotonic sleep-inclusive time: `mach_continuous_time` on iOS, `SystemClock.elapsedRealtime` on Android; immune to NTP/wall-clock steps, counts device sleep). The policy consumes its background timestamp on read, so a cold launch or a duplicate foreground with no intervening background never spuriously reconnects. This closes a cross-platform gap the iOS write-stall watchdog exposed: previously only iOS was set to heal proactively, and the JS `forceInternetReconnect()` guidance told apps to call it on foreground on both platforms — which, now that the bridges heal automatically, would double-reconnect and drop a healthy socket. The JS doc is updated accordingly: `forceInternetReconnect()` remains for deliberate user-initiated reconnects and for stale sockets detected while already foregrounded, but should no longer be wired to every foreground. **Action for app teams: remove any existing `forceInternetReconnect()` call wired to a foreground/`AppState` `active` transition — the bridges now do this, and keeping the app-side call double-reconnects and drops a healthy socket, forcing a wasted group re-registration round-trip.** Bridge-only (paired iOS SPM + Android JUnit tests for the shared policy); no Rust/core, wire, schema, UDL, or event change. - -- **Encrypted messages that arrive before the receiver's MLS session is ready no longer become silent loss — every queue-path failure is now an ordinary sender retry (the deferred-ACK atom).** Previously an encrypted DM or media chunk received before its session/group was established was queued for delayed decryption *and* immediately delivery-ACKed *and* left dedup-marked. If the session was not confirmed before the 120 s pending-queue TTL (or the entry was evicted under load), the queued copy was dropped — and because the sender had already received an ACK and the id stayed dedup-marked for up to an hour, the message was lost with no possible retransmission (the receive path recorded a message "seen" at receipt but only "delivered" at decrypt, and welded the two together for the not-yet-decryptable case). Five interdependent changes, correct only together, close this: - - **Deferred, not Consumed.** A message queued because the session is not ready now returns a new `Deferred` outcome: the receiver sends **no** delivery ACK and **unmarks** the id, so the sender keeps retrying and the resend re-enters processing instead of being swallowed by the duplicate re-ACK path. (Genuine crypto failures, malformed payloads, and security rejections are unchanged.) - - **Idempotent enqueue.** Resends of the same id no longer stack duplicate pending-queue entries (which would burn the per-peer budget and double-surface on drain). - - **Drain on any successful decrypt.** The pending-decryption queue was drained only on explicit session-confirmation events (Welcome, confirm probe/ack). A session that became usable purely via a live decrypt — the 1:1 session *owner* (both-create) or an in-band `__MLS_ENC__` that decrypted first — left earlier-queued messages stranded until TTL. `confirm_session_from_successful_decrypt` now drains too. - - **Mark-seen and ACK on drain.** When the queue drains and a message is finally surfaced, its id is re-marked (so a later resend is deduped rather than delivered twice or rejected by the MLS ratchet as a replay) **and** the deferred delivery ACK is sent directly on the transport the message originally arrived on (recorded on the pending entry). This closes the ACK-latency window: a sender no longer has to resend to learn its message was delivered, so it will not mark a locally-delivered message undeliverable after exhausting its retry budget. The ACK degrades gracefully — if the arrival transport is unknown or gone, it falls back to DORS and finally to the sender's-next-resend re-ACK path. - - **Pending-queue TTL 2 min → 30 min** (`PendingQueueConfig.pending_ttl_ms`, FFI-mirrored). With the ACK now deferred, this queue is the primary recovery window before the session confirms; memory stays bounded by the unchanged per-peer/global byte caps and the `DropOldest` overflow policy. An evicted encrypted media chunk still surfaces a `MessageDecryptionFailed`/`PendingQueueDropped` signal, but it is now **advisory** — the transfer is *stalled*, not permanently failed, since the un-ACKed sender keeps retransmitting and a resend can still complete it (the terminal media signal remains `FileReceiveFailed`). Rust-core + UniFFI only; no wire, schema, UDL, or event-shape change. - -- **Mesh group messages that arrive before the receiver's group epoch is ready no longer risk the same queue-path silent loss (the deferred-ACK atom, extended to groups).** On the mesh (non-relay) path a group message received out-of-order — before the commit that advances the receiver's epoch — is buffered for delayed decryption. Previously the buffer branch returned `Consumed`, so the receive loop delivery-ACKed it *before* decryption. Because per-member group fan-out arms ACK-gated retransmission (group envelopes require ACK), that premature ACK told the sender "delivered" and it stopped retransmitting; if the buffered copy was then evicted (per-group/global caps under load) or expired (5-min TTL) before a commit drained it, the message was lost even though the group-level dedup release was already in place. The mesh buffer branch now returns `Deferred` (no ACK, sender keeps retransmitting), the drain sends the deferred delivery ACK on the transport the message arrived on (recorded on the buffered entry), and a duplicate of a *still-pending* message defers rather than re-ACKing — while preserving the dedup-mark-before-decrypt replay-amplification defense (the duplicate path returns before any MLS crypto). The group-level dedup table stays marked across the pending lifetime as the authoritative double-delivery guard, so — unlike the DM path — the drain does not re-mark the transport dedup. The relay path is unchanged and unaffected: it sends no delivery ACK and the relay sender is not ACK-gated (`try_relay_broadcast`), so its buffered entries carry no arrival transport and the drain ACK is a correct no-op. Rust-core only; no wire, schema, UDL, or event-shape change. - -- **An established 1:1 MLS session that forks (the two peers disagree on the epoch) now heals with no message loss instead of silently dropping — the crypto-desync recovery, distinct from the not-yet-ready defer above.** Previously an epoch fork produced an opaque decrypt failure that was delivery-ACKed and dropped: silent loss behind a lying ACK. The fix is two tiers, both gated by `EncryptionConfig::crypto_recovery_enabled` (default on, FFI/RN-mirrored; disabled → legacy drop-and-ACK): - - **Tier 1 — honest failure + heal.** The epoch-mismatch case (`WrongEpoch`/`NoPastEpochData` only) is classified as the new recoverable `MlsError::SessionDesync`, kept strictly separate from `Decryption`: AEAD/corrupt/forged and ratchet-generation failures stay `Decryption` and fail closed, so injected garbage can't drive a re-key storm. On a desync the receiver withholds the delivery ACK and `unmark`s the id (so the sender keeps retransmitting) but does **not** enqueue the ciphertext (it is sealed to the dead epoch and can never drain), and fires a rate-limited (one per peer per 30 s) `session_reset` re-key: it tears down its **own** stale session and advertises a fresh key package, so the peer rebuilds and Welcomes it back. Tearing down the local session is what makes convergence symmetric for both user-id orderings. The re-key floor lapses only by the interval elapsing — a successful decrypt on the healed session never resets it early (a genuine re-fork and a replayed old-epoch frame are indistinguishable here, so clearing on heal would let an attacker interleaving one real decrypt between replays force ~one teardown per inbound message). **Security:** the re-key acts on the wire-claimed sender, so a network attacker replaying a genuine peer's captured old-epoch ciphertext can force one rate-limited teardown+re-establishment per window — strictly better than the old silent drop, with the unconditional rate limit as the mitigation. - - **Tier 2 — true no-loss re-delivery.** The sender keeps per-outbox-entry re-seal provenance (`OutboxReseal` — memory-only via `#[serde(skip)]`; holds plaintext, never persisted) so each resend re-seals the message against the peer's *current* session while preserving the `Message.id` for dedup/ACK correlation. Gated on a confirmed session and the `__MLS_ENC__` envelope, so media chunks and plaintext replay verbatim (in-flight media recovers via the descriptor-based resend path instead). Staging is strictly transient — a staged-but-dropped send never strands plaintext. With both tiers an epoch desync recovers end-to-end: the receiver withholds the ACK and re-keys, and the sender's next resend re-seals against the rebuilt session and is delivered. Rust-core + UniFFI/Python bindings; no wire, schema, or event-shape change (UDL adds only the `crypto_recovery_enabled` kill switch). - -## [0.16.4] — 2026-07-25 - -### Fixed - -- **iOS: CocoaPods consumers of 0.16.3 failed to compile with `cannot find 'SocketGenerationTracker' in scope`.** The 0.16.3 latch-race fix added `ios/SocketGenerationTracker.swift` (referenced by `InternetManager.swift`) and registered it in `Package.swift` for SwiftPM, but `ios/MeshSdk.podspec` enumerates its Swift sources explicitly and the new file was never added to `source_files`. SwiftPM builds — including the SDK's own iOS CI (`swift test --package-path bindings/react-native/ios`), which is the only iOS build in CI since the pod can't build standalone outside an app workspace — resolved the type and stayed green, so the omission surfaced only in CocoaPods consumers, which compiled `InternetManager.swift` without the tracker. Added `SocketGenerationTracker.swift` to the podspec `source_files`. Packaging-only fix: no source change — only the podspec listing was missing. **0.16.3 has been unpublished from npm** (npm does not allow a retired version number to be reused), so 0.16.4 supersedes it, carrying the same 0.16.3 latch-race fix plus this packaging correction; there is no 0.16.3 on npm. - -## [0.16.3] — 2026-07-25 - -### Fixed - -- **iOS: a late relay close-`4000` for a bygone socket no longer wedges the transport during a reconnect window.** The relay-superseded latch added in 0.16.2 keyed its "does this close-`4000` displace the transport?" decision on object identity against the current `webSocketTask`. During a reconnect backoff window `webSocketTask` is momentarily `nil`, and identity reads `nil` as "current generation → latch" — so a background-queued `4000` for an already-superseded socket, flushed into that window on foreground (the systematic trigger being `forceReconnect()`'s foreground recovery), would latch `isSuperseded` and stop a transport that had already moved on to a newer socket, refusing auto-/force-reconnect until an explicit `start()`. The decision is now keyed on a monotonic per-socket **generation** instead: `connect()` stamps each socket with the next generation (carried on `task.taskDescription`), and `didCloseWith` treats a close whose generation is strictly older than the newest minted as bygone and refuses to latch it, regardless of whether `webSocketTask` is currently `nil` or a live successor. A `4000` for the current (newest) generation still latches, so a genuine displacement of the live socket is unaffected. Object identity remains a belt-and-suspenders fallback for the (never-in-practice) case where the tag is absent. The generation logic is extracted into a pure, unit-tested `SocketGenerationTracker`. iOS-only: Android's close funnel runs its socket-identity guard before the supersede decision, so it drops a non-current socket's close before it can latch and is immune to this false-latch by construction (no generation tracking needed). Bridge-only; no Rust/core wire, schema, or send-path change. - -## [0.16.2] — 2026-07-24 - -### Added - -- **React Native: relay session-superseded handling — `internet_session_superseded` event + `is_superseded` metric.** The relay displaces a stale connection by closing the WebSocket with code `4000` (optionally preceded by a `SessionSuperseded` notice) when a newer registration for the same identity takes over the relay slot. Neither native bridge reacted to the code before — both blind-auto-reconnected, so against an undamped displacement server a legacy dual-socket device became a self-sustaining ~1–2s eviction loop (presence flapping, routing lottery, JWT-verify churn) across the fleet. Both platforms now plumb the close code into `handleConnectionClosed` and, on close `4000` or a `SessionSuperseded` notice on the current socket, latch an `isSuperseded` flag, cancel any pending reconnect, stop the transport, and refuse auto-/force-reconnect until an explicit `start()` clears the latch. iOS marks superseded keyed on the close code (not task identity) so the decision survives the URLSession terminal-signal funnel race, but gates the pre-guard mark on `webSocketTask == nil || it's still us` so a late stale `4000` (queued on the delegate queue while `start()` already brought up a fresh socket) can't nuke the healthy successor. The displace decision + boolean is extracted into a pure, unit-tested `SupersededLatchPolicy` on each platform (8 paired tests per platform, iOS SPM + Android CI harness), sharing the exact rule while keeping the two bridges' deliberately opposite identity-guard orderings. A new `internet_session_superseded` event lets the app surface "connected elsewhere" and reconnect only on deliberate user action, and `getMetrics()` exposes `is_superseded`. Additive JSON event (bridge-only, allow-listed in the Rust↔TS drift guard); no Rust/core wire, schema, or send-path change. - -## [0.16.1] — 2026-07-24 - -### Added - -- **React Native: `forceInternetReconnect()` for deterministic foreground recovery.** `isInternetReady()` reflects a cached `(_isConnected && _isAuthenticated)` pair, not a liveness probe: an OS suspend that kills TCP before a clean WebSocket close leaves the flags stale-true, so an app's foreground "if not ready, toggle transport" gate no-ops precisely when it should act, and recovery instead waits ~20–30s on zombie-ping detection. A liveness probe can't heal this — the socket is either a zombie (dead, flag stale-true) or alive-but-deregistered by the relay, and a ping/pong reports the latter as "alive"; only a full teardown → reconnect → re-authenticate repairs both, since re-auth re-runs the relay's authenticate/register handshake. The new `forceInternetReconnect(): Promise` cancels pending backoff, resets the delay to initial, and drives the existing `teardownSocket → handleConnectionClosed → scheduleReconnect` funnel (or `connect()` when no socket), landing recovery in ~1s. It is a no-op unless the transport is running, honors `autoReconnect`, and never rejects (a reconnect that can't even build its request stops the transport instead of surfacing as a rejection). The boolean resolves `true` when the request is *accepted* (a reconnect fired, or the transport exists but isn't running), not as a proof of reconnection. Purely additive — reuses the tested close funnel so all `isStale` guards and the `internet_status_changed` emission chokepoint are honored; no Rust/core, wire, schema, or event change. - -## [0.16.0] — 2026-07-24 - -### Changed - -- **Outbox lifetime default raised from 1 hour to 7 days (`outbox_max_lifetime_ms` = 604800000), matching the app-layer presence-flush window.** A recipient offline for more than an hour no longer costs the sender their queued messages: store-and-forward outbox entries (and, via the shared knob, restored media transfer descriptors awaiting `media_resend_required`) now survive up to 7 days before expiring. The knob remains configurable end-to-end (Rust `ProtocolConfig.reliability.retry.outbox_max_lifetime_ms` → UniFFI `update_retry_config` → RN `outboxMaxLifetimeMs`) for apps that want the old bound back. The default retry backoff ceiling (`max_delay_ms`) also rises 30s → 5 min: delivery latency rides on the flush paths (start, transport reconnect, peer rediscovery, session establishment), which bypass backoff timers entirely, so the shorter ceiling only multiplied futile send attempts — and per-failure `message_retrying` events — against a long-offline peer. - -- **Outbox expiry is now terminal, not silent.** A regular message dropped for exceeding the outbox lifetime emits `message_failed` (reason `"Outbox lifetime exceeded"`); an expired connection request additionally settles its pending entry and emits `connection_request_undeliverable` (reason `outbox_lifetime_exceeded`), mirroring the max-retries exhaustion path. Previously text messages aged out with no event at all, leaving apps showing them as pending forever. (Expired media chunks already aborted their transfer with an event.) Capacity eviction at the 500-entry cap gets the same treatment: the evicted entry emits `message_failed` (reason `"Outbox capacity exceeded"`) and, for a connection request, `connection_request_undeliverable` (reason `outbox_capacity_exceeded`). And the restore path's carrier-relative TTL refresh is now bounded: an entry whose total age (from first send) exceeds 4× the outbox lifetime — 28 days at the default — is dropped at restore with a terminal `message_failed` instead of re-granted a fresh window on every restart. `PENDING_CONNECTION_REQUEST_TTL` widens 600s → 1800s so the typed undeliverable event still outlives the new worst-case retry schedule (~910s with the 5-min ceiling). - -- **Unreachable DMs park instead of failing early: the relay's `recipient_unreachable` verdict no longer burns the ACK retry budget.** Previously the verdict emitted `message_undeliverable` but left the ACK timeout machinery running, so a plain DM to an offline peer settled terminally with `message_failed` (`"Max retries exceeded"`) in ~15 minutes — making the 7-day outbox lifetime unreachable on the internet path. The verdict now *parks* the message: the pending ACK and retry-queue entry are dropped while the outbox entry stays put, and every reachability edge re-drives it with a fresh ACK budget (transport reconnect, `start()`, peer discovery, presence-online). With a local mesh carrier (BLE/WiFi-Direct) up, the park keeps a timed reachability probe instead — its interval escalating per consecutive unreachable park, 15s → 600s cap, reset on any edge — since the peer may be a room away while the relay reports it offline. A probe that locally succeeds into the mesh re-enters the ACK machinery but can never earn a relay verdict there, so ACK exhaustion for a recipient still holding a live park counter re-parks the DM (at the escalated interval) instead of settling it — the settlement contract holds on both carriers. `internet_presence_watchlist()` now includes recipients of pending/parked outbox messages alongside welcome-pending peers, so the SDK owns the "watch my DeliveryError recipients" duty end-to-end and platform layers no longer need their own presence queue for offline sends. Media chunks are deliberately not parked (their offline story remains retry exhaustion → transfer abort → `media_resend_required`), and connection requests keep settling immediately via `connection_request_undeliverable`. **Contract change:** apps that relied on the ~15-minute terminal `message_failed` for offline peers should key "recipient offline" UX off `message_undeliverable`; terminal settlement now happens only at delivery or outbox-lifetime expiry. The in-process expiry sweep also gains the restore path's absolute cap (4× lifetime from first send, 28 days at defaults), so a probing entry's carrier-relative window can't slide forever in a long-lived process. - -### Fixed - -- **Parked DMs are no longer held hostage by an in-flight mesh reachability probe when the peer comes back online.** In the internet+mesh dual-carrier topology, a parked DM's reachability probe re-registers a pending ACK, and the reachability-edge flushes (`presence-online`, peer discovery, reconnect/`start()`) skipped any message awaiting an ACK while still resetting the peer's park counter — so a peer the relay just reported online kept waiting out minutes-scale ACK backoff gaps before delivery, and the cleared counter re-exposed the message to the ~15-minute terminal `message_failed` on exhaustion. The flush edges now cancel the unanswerable probe ACK first (scoped to parkable plain DMs while the park counter is live — connection requests, welcomes, and media keep their in-flight ACKs) and re-drive immediately; on the relay presence-online edge specifically, the re-drive is forced over the internet transport, since the reachability proof is relay-scoped and DORS could otherwise route it back into the mesh void, re-stranding it — including through the edge's welcome-rescue branch, whose inner discovery flush picks up any DM whose forced re-drive just failed and must not hand it back to DORS. A canceled ACK whose answer was genuinely in flight is safe: receivers dedupe and re-ACK duplicates. Flush batches past `FLUSH_BATCH_LIMIT` re-enqueue into the retry queue instead of assuming a backoff timer the probe cancel may have just stripped. An edge that re-drives *no* parked DM successfully (every forced send failed, or they all overflowed the batch) restores the peer's park counter instead of clearing it: the surviving retry-queue sends are DORS-routed, and a mesh-local success there must leave ACK exhaustion re-parkable instead of settling terminally. On the carrier-level reconnect/`start()` edge specifically — the one re-drive path that is DORS-routed with no per-peer reachability proof — only an internet-routed send (the one that can earn a relay verdict) counts as a successful re-drive for counter-clearing; a mesh-routed local success also restores the counter, since it proves nothing about the peer actually being there (delivery prunes the counter if its ACK genuinely arrives). - -### Added - -- **Deferred-delivery lifecycle events: `message_retrying`, `message_undeliverable`, and a real `next_retry_at`.** `message_deferred` now populates `next_retry_at` with the retry queue's actual schedule (previously always absent), and two new non-terminal events close the observability gaps around it: `message_retrying { message_id, recipient, retry_count, next_retry_at }` fires each time the retry machinery re-schedules a message after a failed attempt (transport send error or ACK timeout), and `message_undeliverable { message_id, recipient, reason, file_id? }` fires when the internet relay reports the recipient unreachable for an in-flight regular message — a verdict that previously produced no signal at all (connection requests, welcomes, and media aborts already had theirs). `file_id` is set when the affected message is a chunk of an outbound media transfer. Both are additive JSON events (no UniFFI interface change) with TypeScript mirrors, and neither settles a message: `message_delivered` / `message_failed` remain the terminal signals. - -- **Media transfers now survive restarts as resend signals: `media_resend_required`.** The SDK persists a small per-transfer descriptor (file id, recipient, name, size, SHA-256 — never chunk bytes, honoring the long-standing rule that resurrected chunks can't complete) when an outbound media transfer starts, and deletes it when the transfer completes, aborts, or goes stale. A descriptor that survives into a restart means the process died mid-transfer: `start()` emits `media_resend_required { file_id, recipient, file_name, file_size }` for each, and the app re-initiates by calling `send_media` with the same `file_id` — the resend must target the descriptor's original recipient and the re-supplied bytes are checksum-validated against the interrupted transfer (`InvalidArgument` on either mismatch). Restored descriptors are pruned by `outbox_max_lifetime_ms` age and capped at 16 newest. - -- **Rich message model: quoted-reply context and rich media metadata (foundation).** `Message` gains an optional `reply_context` (`ReplyContext { sender, text, timestamp?, reply_media_label?, reply_content_type? }` — an unverified display-level hint, like `ForwardInfo`), surfaced on `message_received` events, and `MediaMetadata` gains nine optional cloud-media/sticker fields (`media_id`, `download_url`, `thumbnail_url`, `encryption_key`, `iv`, `ciphertext_hash`, `sticker_provider`, `sticker_remote_id`, `sticker_kind`), surfaced on `message_received` and `file_received`. On the binary mesh wire, `reply_context` rides the frame's extension section as tag 2 (only the first entry is honored; a malformed payload rejects the frame, matching the JSON path); the frozen `WireMessageV1` layout is unchanged, and a legacy decoder skipping the tag loses only the reply preview — the same degradation a legacy JSON receiver applies by ignoring the unknown field. Purely additive: nothing populates the new fields yet — the end-to-end-sealed rich send surface ships alongside this in the same release. - -- **Enforced confidentiality boundaries for the new fields.** `MediaMetadata.encryption_key`/`iv` are secret material (they grant access to the cloud-stored media itself) and are enforced never to travel hop-visible: the transport wire chokepoint strips them from every outbound cleartext frame on both codecs (covering direct sends, forwards, pending-queue flushes, and relaying), and the telemetry scrubber redacts them from `message_received`/`file_received` events unconditionally — independent of the `scrub_ids` setting. The outer `reply_context` field on an MLS-encrypted message sits outside the AEAD boundary (a relay could inject or rewrite it in transit), so it is dropped on decryption and never surfaces on an `encrypted: true` event; the sealed envelope is the only trusted carrier for reply context on encrypted messages and ships with the rich send surface. - -- **End-to-end-sealed rich send surface (`send_message_with` / `send_message_rich`), negotiated per recipient.** Sending a quoted reply, cloud-media metadata (including its `encryption_key`/`iv` secrets), or forward attribution now travels *inside* the MLS ciphertext: for recipients that advertise `rich_versions` in their key package, the plaintext is wrapped as a `__RICH_V1__` JSON body (`{text, reply_context?, media_metadata?, forward_info?}`) before encryption, and the receiver restores the fields right after the outer-field strip — so the sealed body is the only trusted carrier, and the relay never sees rich cleartext. Toward recipients without the capability the rich extras are silently dropped (the message degrades to plain text with `reply_to_msg` threading intact) — never sent cleartext. Rich extras queued behind session establishment keep their provenance (`PendingMessage.rich`) and re-decide sealing at flush time against the recipient's current capability. Inbound parsing is always on (a body that fails to parse surfaces as raw text with a warning, never a dropped message), `__RICH_V1__` joins the reserved internal prefixes, and `EncryptionConfig::rich_payload_enabled` (default on) is an independent kill switch alongside `compact_envelope_enabled`. Non-rich sends are byte-for-byte unchanged. Core API: `send_message_with(recipient, content, SendMessageOptions)`; UniFFI: `send_message_rich` + `SendMessageOptions` dictionary (all fields defaulted). The boundary validates input: `ContentType::FileChunk` is rejected (an internal transport content type — the receiver would swallow the message into its file-transfer manager after ACKing delivery), as are rich extras exceeding 32 KiB serialized (an oversized quote or thumbnail would inflate the MLS plaintext into heavy transport fragmentation); both surface as `InvalidArgument`. The cap is enforced before queueing, so a pending flush can never fail it and re-queue forever. - -- **React Native: `sendMessage` accepts rich params; `richPayloadEnabled` kill switch exposed.** `SendMessageParams` gains `replyContext`, `mediaMetadata`, `forwardInfo`, and `contentType`; calls carrying any of them route to the new native `sendMessageRich` method on both platforms (the plain path is untouched). **Note for over-the-air JS updates (CodePush-style):** `sendMessageRich` is a new native method — a JS-only update that starts sending rich params against an older native binary fails those calls (method not found); plain `sendMessage` calls are unaffected. `encryption.richPayloadEnabled` (default `true`) flows JS → native → core with the same nested-then-flat, camelCase-or-snake_case parsing as `compactEnvelopeEnabled`, regression-pinned in both platforms' parser tests. - -- **Rich extras on media transfers (`send_media_with` / `send_media_rich`), sealed with chunk 0.** Media parity for the rich model: a caption, reply-to id, quoted-reply context, and forward attribution can now ride an outbound media transfer, sealed inside the chunk-0 MLS ciphertext toward recipients that advertise `rich_versions` — silently dropped otherwise, never cleartext. `file_received` gains the matching additive fields (`caption`, `reply_to_msg`, `reply_context`, `forward_info`) plus a `timestamp` (the sender's chunk-0 send time, for display ordering alongside `message_received`). `MediaSendOptions` also accepts a caller-supplied `file_id` — how an app answers `media_resend_required` after a restart. React Native's `sendMedia` takes the same params and routes to the new native `sendMediaRich` method when any are present (same over-the-air-update caveat as `sendMessageRich`). - -- **Forwards and group messages seal their rich extras.** `forward_message` now seals its attribution and the original message's `media_metadata` toward rich-capable recipients — the only way forwarded cloud media keeps its `encryption_key`/`iv` secrets, since the wire chokepoint strips them from every cleartext frame (the cleartext outer copies remain as the legacy fallback for non-capable recipients, secrets stripped). Groups gain the same machinery: `send_group_message_with(group_id, content, GroupSendOptions)` (core API; not yet over UniFFI) and `forward_message_to_group` (already bound) seal a `__RICH_V1__` body into the group MLS plaintext — including hint-only bodies for non-Text content types, which have no outer carrier on the group payload — parsed on all three inbound paths (mesh, buffered drain, relay). Group sealing is gated on *every* other member being known rich-capable and fails closed: when the gate is closed the text still sends but the extras drop, surfaced by the new `group_rich_extras_dropped { group_id, unknown_members }` event, and the drop path key-packages the unknown members once so their auto-exchange reply reopens the gate. When a group body seals, the hop-visible payload `forward_info` copy is omitted (every member reads the sealed attribution; a payload copy would only expose the original sender to relays). `group_message_received` gains additive `media_metadata` / `content_type` fields; the telemetry scrubber redacts the secrets. - -- **Rich capability propagates through group adds (inviter attestation).** Previously a member added by someone else stayed unknown to the rest of the group — holding the seal gate closed — until a direct key-package exchange happened to occur. The Add commit now carries `affected_member_rich` (the inviter's attestation, to existing members) and the Welcome a `member_rich` map (to the joiner; entries bounded to the joined MLS roster and admin-gated on the commit, like `role`), so members added by someone else stay sealable. Attestation chains across successive adds, a direct key-package exchange always overrides it, and it feeds *only* the group seal gate — never DM sealing or envelope selection. Plain group sends (no rich extras) skip the capability scan entirely. - -- **`group_rich_readiness(group_id)` pre-check, exposed core → UniFFI → React Native (`meshGroupRichReadiness`).** Returns `{ ready, unknown_members }`: whether a rich group send right now would seal its extras, and which members hold the gate closed. Point-in-time and advisory — the send path re-evaluates the gate itself — so apps can warn before sending (e.g. gray out the attachment button) instead of learning from `group_rich_extras_dropped` after the drop. - -- **Relay group registration is now observable: `group_relay_sync_changed` event + `group_relay_sync_state` / `request_group_relay_registration`, exposed core → UniFFI → React Native (with an `ensureGroupRegistered` await helper).** The relay's positive registration acknowledgment — the internal `relay_synced` transition that gates the O(1) relay-broadcast path — previously left no app-visible trace, so an app could not sequence "mesh-create a group → wait until the relay knows it → issue relay-dependent server commands for it" (the invite-link lifecycle over `sendRawServerCommand`) other than by probing and interpreting `GroupError`s. The new event fires `synced: true, reason: "registered"` on the relay's `GroupCreated` ack (emitted on the pending-registration-consumed transition, so the idempotent re-sync ack after a membership change fires it again — apps awaiting a post-invite roster re-sync hear it), and `synced: false` with a reason on every revocation: `error` (group-scoped relay error), `removed` / `left` (membership teardown), `internet_dropped` (per affected group on the 1→0 transport transition), and — new behavior, previously a silent `debug!` — `ack_timeout` when the SDK gives up on a relay that never answered (3 attempts × 30s). `group_relay_sync_state(group_id)` returns the point-in-time `Synced`/`Pending`/`Unsynced` view; `request_group_relay_registration(group_id)` is the supported on-demand (re-)registration path — never raw-send `CreateGroup`. React Native additionally ships `ensureGroupRegistered(groupId, {timeoutMs})`, which subscribes-then-checks (no ack-arrival race), kicks a registration when none is in flight, and settles on the event outcome — the intended gate before `CreateGroupInviteLink` and friends. The event is not emitted for groups the relay was never asked about, and a `GroupError` that revokes nothing tracked stays silent on this channel. - -- **React Native: positive internet readiness signal — `internet_status_changed` event + `isInternetReady()`.** The `(connected, authenticated)` pair the raw server-command gate checks is now published on actual transitions (deduplicated through a single choke point in both platform bridges; `authenticated: true` is the relay's auth acceptance, not the TCP open) and queryable point-in-time. Replaces app-side `relayStatus === 'authenticated'` tracking against a separate app-owned socket, and closes the "gate raw sends by probing `sendRawServerCommand` for `false`" gap — the boolean-probe contract still works, but a ready socket deferring an individual send (client-side rate limiter) remains a retry-after-short-delay `false`. - -### Fixed - -- **React Native bridges no longer silently rewrite retry defaults on partial configs.** Both native `updateRetryConfig` handlers rebuild the full `RetryConfig` from JSON, filling absent fields from hardcoded fallbacks that had drifted from the Rust defaults (`maxRetries` 3 vs 10, and now the outbox lifetime and backoff ceiling). An app setting only e.g. `retry: { maxRetries: 5 }` was silently resetting every other retry field. The fallbacks now mirror `offline-protocol-reliability`'s defaults on both platforms. - -- **Messages queued behind session establishment now keep their id through flush.** The id returned by `send_message*` for a queued message (and persisted with it) was discarded when the session confirmed: the flush minted a fresh id, so the id an app held could never be correlated with the eventual `message_sent`/`message_delivered`/`message_failed`. The flush now dispatches under the original id — across re-queues too — and, with stable ids, the deduplicator protects against a stale restored snapshot double-sending. Latent flush bugs fixed in the same pass: messages whose recipient was blocked after queueing are now dropped with a terminal `message_failed` per id (previously they error-looped forever); a mid-flush re-queue is no longer clobbered when another message in the same flush fails, and the merged queue preserves the original send order; and a mid-flush session abort (terminal Welcome failure) no longer resurrects the messages the abort just settled. Dropped ids always settle: a dedup hit at flush is silent only in exact dedup mode (the id already settled when it first dispatched); in bloom-filter mode — where the hit may be a false positive — the drop emits `message_failed` so the app can resend. - -- **React Native `MessageReceivedEvent` type was missing `reply_to_msg`.** The Rust event has always carried the replied-to message id; the TypeScript mirror now declares it (alongside the new `reply_context`). - -- **React Native: seven runtime events had no TypeScript declaration.** `group_renamed`, `message_decryption_failed`, `ack_evicted`, `fragment_assembly_evicted`, `relay_demoted_battery`, `user_blocked`, and `user_unblocked` have always been emitted at runtime (events cross the FFI as tagged JSON, and the dispatcher applies no filter) but were absent from `types.ts` — outside the `ProtocolEvent` union, `.on('')` didn't type-check and payloads were untyped. All seven interfaces are now declared (plus a `DecryptionFailureCode` string union), and a Rust drift-guard test pins every core `Event` variant to a `types.ts` declaration so an event can no longer ship without its TypeScript mirror. Purely a typing change — no runtime behavior differs. - -### Removed - -- **React Native: `src/types-uniffi.ts` (and its compiled `lib/types-uniffi.*`) removed from the package.** A pre-UniFFI-migration relic that was never exported from the package root and whose declarations contradicted the shipped API (a 3-argument `sendMessage`, a `ProtocolError` class the SDK never throws). Anyone deep-importing it should switch to the package root exports: `EstablishmentState` is exported from the root, and error codes arrive at runtime as plain `err.code` strings on native promise rejections, mirroring the UDL `ProtocolError` variant names — no runtime behavior changes. - -## [0.15.0] — 2026-07-20 - -### Added - -- **`presence_updated` events now carry their source.** A new `source` field distinguishes the internet relay's authoritative answers (`internet`) from peer-sent `__PRESENCE__` self-reports (`peer`), so apps rendering relay-style headers ("Online" / "Last seen …") can filter on `internet` instead of guessing from `last_seen_ms` absence — which relay answers can legitimately share when the relay doesn't know a last-seen. The value is `peer` (not `mesh`) because self-reports are transport-agnostic and may arrive relay-forwarded. Additive: events without the field deserialize as `peer`; no UniFFI interface change (events cross the FFI as JSON). - -- **`checkInternetPresence` accepts `{ force: true }` for chat open/focus.** A non-forced manual check fails fast when the relay socket isn't authenticated or the client-side rate-limit mirror momentarily defers — which is exactly the state an app resuming from background is in when a chat header wants a fresh answer. A forced check parks in the bridge and retries until the transport is authenticated and rate-admitted (bounded at 8 seconds, then resolves `false`), is serviced immediately on the authenticated edge, survives reconnects until its deadline, drains to `false` on an explicit transport stop, and fails fast against a stopped transport (no reconnect is coming). Force never bypasses rate limiting (the client bucket mirrors the relay's server-side budget, where an over-budget frame is dropped *after* a locally-successful write) and forced checks never join the SDK's automatic presence watch set. Parked forced checks are capped at 32 concurrent entries — checks beyond the cap resolve `false` immediately instead of growing the queue without bound. **Note for over-the-air JS updates (CodePush-style):** the native `checkInternetPresence` method gained an `options` argument on both platforms, and React Native matches native methods by argument count — the JS and native halves of this package must ship together. A JS-only update across this version boundary (in either direction) makes `checkInternetPresence` calls fail with an argument-count mismatch. - -- **The manual presence-check contract is now documented and regression-pinned.** The SDK never throttles or dedupes manual presence checks, and every relay answer re-emits `presence_updated` even when the status is unchanged — a Rust regression test now locks the no-dedup behavior, and the React Native README documents the full contract (fire-and-event, subscribe-before-check, blocked/self suppression, `last_seen_ms` availability caveats) for apps replacing their own presence layers. - -## [0.14.0] — 2026-07-16 - -### Added - -- **Compact binary wire codec for messages, negotiated per peer.** Messages can now be serialized with a postcard-based binary encoding (wire v1) instead of JSON, cutting the message envelope roughly 3–4× on small and media messages and correspondingly reducing BLE fragment counts. JSON remains the permanent interoperability floor: receivers auto-detect the codec from the first byte, and binary is only ever sent to a peer that advertises support via `wire_versions` in its signed key package. Controlled by `TransportConfig::binary_wire_enabled` (default on); a mixed fleet stays on JSON automatically, and the internet relay path stays JSON. Decoding of binary frames is always enabled. No persistence, FFI, or relay-protocol changes. - -- **Compact MLS envelope for encrypted DMs, negotiated per recipient.** The `__MLS_ENC__` payload for peers that advertise `env_versions` in their key package is now base64 of the binary `EncryptedMessage` form instead of JSON whose ciphertext rendered as a ~3.6× integer array. Measured on a real OpenMLS ciphertext with realistic identifier lengths, an encrypted text DM drops from 1342 to 808 bytes on the JSON wire (relay included) and to 472 bytes on the binary mesh wire — 2.8× smaller than the released baseline, BLE fragments 10 → 4. Legacy JSON stays the permanent envelope floor, inbound parsing accepts every historical form unconditionally, and a stale or forged capability can at worst produce a `message_decryption_failed` event, never app-visible garbage. Controlled by `EncryptionConfig::compact_envelope_enabled` (default on), independent of the wire-codec kill switch. - -- **React Native wrapper exposes both wire-format kill switches.** `ProtocolConfig.binaryWireEnabled` and `encryption.compactEnvelopeEnabled` (both default `true`) now flow from the JS config through the iOS/Android native modules to the core `ProtocolConfig`, so React Native apps can disable the binary wire codec or the compact MLS envelope at runtime — previously the switches existed in the UniFFI layer but were not reachable from JS. The native bridges accept `compactEnvelopeEnabled` nested under `encryption` or at the top level, and `binaryWireEnabled` at the top level (its home in the flat UniFFI config), each in camelCase or snake_case, matching the existing config-parsing conventions. - -- **Binary wire frames carry base64 content tails raw (ext TLV tag 1).** When a message's `content` ends in a long canonical-base64 run (the compact MLS envelope, Welcome blobs, and similar), wire-v1 frames now carry the decoded bytes in the frame's extension section instead of paying the 4/3 base64 inflation, reconstructing the exact original string on decode. The split is verified byte-for-byte at encode time, so arbitrary content is safe by construction. `WireMessageV1`'s frozen layout is unchanged; tag 1 ships inside wire v1's first release, so advertising v1 implies understanding it. - -- **Connection requests can carry a first message.** `send_connection_request` accepts an optional `initial_message`, delivered verbatim in the recipient's `connection_request_received` event so apps can seed the conversation on accept — exposed through the UniFFI bindings (Swift/Kotlin/Python) and the React Native wrapper (`initialMessage`). Connection requests precede the MLS session, so the text travels in plaintext and should be treated like the sender display name; input above 4 KiB is rejected with `InvalidArgument`. Payloads without the field parse unchanged and an unset field round-trips byte-identical, so the wire format stays compatible with older builds in both directions unless the feature is used. - -- **Typed feedback when a connection request hits an offline peer.** The relay's authoritative "recipient offline" verdict (`DeliveryError`) now surfaces immediately as a `connection_request_undeliverable` event carrying the recipient, the message id returned by `send_connection_request`, and the transport reason — instead of being silently discarded until generic retry exhaustion. Emitted at most once per request, and never for a request already proven delivered (delivery ack, or an accept/reject from the peer) or one older than the correlation window; all other transport failures stay with the normal retry machinery, which may still deliver — the event is a status signal, not proof of permanent failure. - -- **Typed feedback when a connection request exhausts its retries.** Retry exhaustion — the terminal outcome for a request that never got an authoritative offline verdict — now also emits `connection_request_undeliverable` (reason `max_retries_exceeded`) alongside the generic `message_failed`, so every sender-side connection-request failure reaches apps through one typed channel correlated by the message id `send_connection_request` returned. The pending-request correlation window widened from 300s to 600s so it outlives the worst-case default retry schedule. - -- **The identity and connection-request contracts are now documented API.** The React Native README, integration guide, and API reference now state the canonical-identity invariant (`neighbor_discovered.peer_id` is the peer's `ProtocolConfig.userId` on every transport and is used directly as a send/connection-request `recipient`) and the full connection-request lifecycle: which events fire for offline, retry exhaustion, delivery, accept, reject, and cancel, and which identifier correlates each. - -### Changed - -- **Breaking for direct UniFFI consumers: `send_connection_request` gained an `initial_message` parameter.** Swift, Kotlin, and Python callers must pass the new final argument (`nil`/`null`/`None` preserves the old behavior); the change fails at compile time, and mixed library/binding versions fail at load via the UniFFI checksum. The React Native JS API is unchanged — `initialMessage` is a new optional field on `SendConnectionRequestParams`. - -### Removed - -- **Breaking: relay-native connection-op translation removed from the mobile internet bridges.** Connection requests, accepts, rejects, and cancels now travel verbatim as Ed25519-signed `SendMessage` frames (see the signature-downgrade fix below), so the bridges no longer emit `SendConnectionRequest`-family frames or rebuild inbound `ConnectionRequestReceived`/`ConnectionAccepted`/`ConnectionRejected`/`TypingUpdate` relay frames. Consequence: pre-SDK relay clients and older SDK builds can no longer deliver connection requests **to** a new-SDK peer over the relay (new→old keeps working, and the relay server is unchanged); unhandled relay frames still reach apps verbatim as `internet_server_message`. The SDK's internet transport is now the only supported relay client. - -### Fixed - -- **React Native encryption settings now actually reach the protocol core.** The JS wrapper sent `encryption.enabled`, `autoKeyExchange`, `storePending`, and `requireEncryption` as flat top-level keys while the iOS and Android bridges read them only from the nested `encryption` object, so every app-set value was silently discarded and the all-true defaults won. The wrapper now sends the nested shape alongside the flat keys (kept in lockstep), and both bridges accept either shape in camelCase or snake_case, locked by unit tests on both platforms. The sibling flags also now default to the value of `enabled`, so `encryption: { enabled: false }` alone yields the coherent fully-disabled posture (mirroring Rust's `EncryptionConfig::disabled()`) instead of a node whose every send fails with `EncryptFailed`; explicitly combining `enabled: false` with `requireEncryption: true` is rejected loudly at `create()` by the existing core validation. - -- **Connection requests over the internet relay now reach peers you have already talked to.** The mobile bridges used to translate connection ops into relay-native frames, stripping the Ed25519 control signature from the message metadata; the receiving bridge rebuilt them unsigned, and the security gate correctly dropped the rebuild as a signature downgrade once the sender's key was TOFU-pinned — which any prior signed contact (e.g. an MLS key-package exchange) does. Connection requests therefore failed for exactly the peers a session already existed with, while plain messages kept working. Shipping the ops verbatim keeps the signature intact end to end, and offline recipients additionally gain the relay's push-notification fallback, which the relay-native connection frames never had. - -- **The shipped commercial-license copies now name the licensor.** The `LICENSE-COMMERCIAL.md` files packaged in the React Native npm tarball and the Python wheel previously granted rights from "the project" without naming a legal entity; all copies now name Offline Protocol, Inc., matching the root license and the CLA. - -- **Nostr transport pubkeys no longer leak into discovery.** When a Nostr frame failed to deserialize, the ingest fallback surfaced the sender's per-install transport signing pubkey as `neighbor_discovered.peer_id` — a value that is not a protocol identity and breaks everything keyed by user id (self-suppression, blocking, outbox flush). An undecodable frame now surfaces no discovery at all. - -- **Relay group fan-out is no longer dropped for TOFU-pinned senders.** The relay re-emits `__GROUP_MSG__` per member from only `{group_id, sender, content}`, so the rebuilt frame is structurally unsigned and the Ed25519 gate dropped every fan-out frame from a pinned sender before MLS ever saw the ciphertext. `__GROUP_MSG__` is now classified as data plane: authentication happens after the gate instead, where MLS decryption binds the wire-claimed sender to the MLS-authenticated sender (mismatches are rejected as spoofing) and plaintext naming an MLS-secured group is dropped. Groups without any MLS state keep the legacy relay-only plaintext behavior. - -## [0.13.1] — 2026-07-14 - -### Fixed - -- **React Native `GroupInfo` and `UserGroups` relay snapshots are now lossless.** Android and iOS continue emitting the existing typed `group_info` / `user_groups` projections and additionally emit the original frame as `internet_server_message`. Application-owned fields such as descriptions, avatars, profiles, membership details, pending join requests, and unknown future extensions now reach JavaScript verbatim without expanding the SDK's typed group schemas. The two events have no cross-channel ordering guarantee. - -- **Relay authentication now fails closed when no token is configured.** The Android and iOS internet bridges previously fell back to sending `deviceId` as the authentication token, allowing a forgeable identifier to be presented as an authenticated identity. They now refuse to authenticate without a non-empty token and let the existing authentication watchdog close the unauthenticated connection. Clearing a token on an already-authenticated connection does not invalidate the current session. - -- **Invalid Bloom-filter deduplication settings no longer panic.** Top-level protocol configuration now rejects zero-sized filters, zero hash/filter counts, and zero rotation intervals. Direct users of the reliability crate fall back to exact `HashMap` tracking when given invalid Bloom settings. - -- **Local Android builds now install the UniFFI library under the name the generated loader expects.** `build-android.sh` writes `libuniffi_offline_protocol.so`, matching release artifacts and preventing successful local builds from failing when the app loads the native library. - -- **Python dependency metadata is consistent and installable on Windows.** The reproducible requirements now respect the manifest's Bleak range, and the unsupported Bless peripheral backend is excluded on Windows to avoid incompatible WinRT dependency pins. - -### Changed - -- **Breaking for direct Rust users: `MockTransport` now requires the `test-utils` feature.** The production `offline-protocol-transport` API no longer exposes test-only mock machinery by default. Tests and development tools that import `MockTransport` must enable `offline-protocol-transport/test-utils`. - -- **Build and release checks are stricter.** CI now verifies Rust 1.87 compatibility, generated Swift/Kotlin/Python binding freshness, release-library builds on Linux, macOS, and Windows, dependency policy, and unwrap-free library code. Release credentials are scoped to the publishing job, privileged actions are pinned, and tag-derived versions are validated before use. - -## [0.13.0] — 2026-07-13 - -### Added - -- **Persistent store-and-forward outbox — undelivered messages survive app restarts.** - The outbox that holds messages awaiting delivery/ACK was purely in-memory, so a message queued while offline was lost if the app was killed before a carrier appeared. When message persistence is enabled (automatically via `initialize_mls`, or explicitly via `enable_message_persistence`), each main-outbox entry is now persisted through the existing `MlsStorage` key-value interface under a new `"outbox"` key type and restored on startup. Restored entries re-drive delivery on `start()` via the existing outbox-flush path. - - *Carrier-relative TTL*: the outbox lifetime clock is not wall-clock. An entry whose lifetime lapsed while the app was closed is refreshed on restore rather than immediately reaped, so a message that never had a delivery opportunity gets a fresh window once a carrier (or the peer) reappears — mirroring the Welcome-lifecycle restart behavior. - - *Bounded and self-healing*: the restored set is pruned to the in-memory capacity (newest kept), corrupted records are dropped from storage and skipped, and an entry delivered-then-crashed before its delete is resent and de-duplicated by the receiver's re-ACK path (at-least-once, matching the existing contract). - - *Media excluded by design*: the file-chunk (media) outbox is intentionally **not** persisted. File transfers are not durable, so a resurrected chunk could never complete its transfer — media transfers must be re-initiated by the app after a restart. - - No FFI, UDL, binding, or event changes; the storage contract is additive (older SDKs ignore the unknown `"outbox"` key type). - -## [0.12.0] — 2026-07-13 - -First npm release since **v0.11.0** (0.11.0 is the latest published version). This -release folds in the **v0.11.1** 16 KB page-alignment hotfix — which was tagged but -never published to npm — so the Google Play alignment fix (see the [0.11.1] entry -below) reaches npm consumers for the first time here. - -### Breaking Changes - -- **Inbound plaintext text is rejected by the `require_encryption` gate (SEC: inbound plaintext bypass)** - The receive path enforced encryption policy for legacy plaintext *media* (SEC-H1) and authenticated internal *control* messages, but plain text messages had no inbound gate at all: a message with no internal prefix was ACKed and surfaced as `MessageReceived` with the wire-claimed sender — no signature, no session check. Any mesh peer (or anyone in radio range) could inject unauthenticated cleartext attributed to an arbitrary sender into an app configured fail-closed. Inbound plaintext text now passes the same policy gate as legacy plaintext media: - - *With `require_encryption = true` (default)*: all inbound plaintext content — text and legacy media alike — is rejected: not surfaced, not ACKed (a rejection does not confirm to the injector that the target is online; ACK-before-gate for media chunks is unchanged). Rejected message ids are deliberately forgotten by the deduplicator, so replaying a rejected message re-enters the gate and is rejected again without an ACK instead of hitting the duplicate re-ACK path — the same replay hardening now applies to security-gate (`SecurityRejected`) control-message rejections. (In the opt-in bloom-filter dedup mode ids cannot be forgotten; a replayed rejected message is re-ACKed as a duplicate until its filter window rotates out. The default exact-match mode is airtight.) MLS-encrypted messages are unaffected. - - *With `require_encryption = false` (explicit opt-out)*: plaintext text from a peer **without** a confirmed MLS session is still accepted (legacy interop, unchanged). Once a session is confirmed with a peer, plaintext under that peer's name is rejected as a downgrade/forgery attempt — matching the media rule from SEC-H1. - - *Observability*: every rejection logs a warning, and a `SecurityWarning` event with the new reason code **`PLAINTEXT_RECEIVE_REJECTED`** (`SecurityWarningCode::PlaintextReceiveRejected`, TypeScript `'PLAINTEXT_RECEIVE_REJECTED'`) fires at most once per peer. The per-peer tracking set is bounded (sender ids are attacker-controllable): past 1000 distinct peers it resets rather than growing without bound, so after a forged-sender flood a peer may warn again. Legacy plaintext media rejections, previously log-only, now emit the same warning code. - - *Compatibility*: peers on old SDK versions (or opted-out senders) sending plaintext text to a fail-closed receiver are now dropped instead of delivered; they must establish an MLS session (the default flow does this automatically) or the receiver must opt out. Compliant strict-mode senders are unaffected — the send side already never emits plaintext under `require_encryption = true`. `FileChunk` messages are exempt from the new text gate (encrypted media envelopes carry no content prefix); they continue through the existing media gate. - -- **Typed FFI error taxonomy — React Native rejection codes changed for group and send operations** - Six variants are appended to the FFI `ProtocolError` enum: `TransportError`, `SerializationError`, `ServiceError`, `GroupNotFound`, `PermissionDenied`, `InvalidArgument`. Existing variants keep their positional discriminants (the enum is append-only, now pinned by a test), so previously shipped Swift/Kotlin/Python bindings continue to decode. An error's detail text is preserved, but a reclassified error's message now carries its new class prefix — e.g. `Permission denied: Only admins can invite members` (previously no prefix, via the `Other` passthrough), and the MLS-layer group-missing message drops the old `MLS error: ` prefix. Do not string-match messages; branch on the codes — which change as follows: - - Group operations (`createGroup`, `inviteToGroup`, `sendGroupMessage`, `forwardMessageToGroup`, `removeFromGroup`, `leaveGroup`, `listGroups`, `getGroupInfo`, `setMemberRole`, `getMemberRole`, `getGroupRoles`, `renameGroup`) previously rejected with the catch-all `ERROR_MESH_GROUP` code. Classified failures now reject with typed codes — `GroupNotFound`, `PermissionDenied`, `InvalidState` (last-admin constraints, member caps, expired key packages), `InvalidArgument` (empty names, invalid roles), `NoKeyPackage`, `SendFailed`, `MlsNotInitialized`. `ERROR_MESH_GROUP` remains only as the fallback for unclassified errors, with the message preserved. - - `sendGroupMessage`/`forwardMessageToGroup` previously mapped **every** failure to `SendFailed`; `SendFailed` now specifically means a transport-layer failure during a send (e.g. all member fan-out sends failed, peer unreachable). A missing group is `GroupNotFound`; an uninitialized MLS stack is `MlsNotInitialized`. - - Admin-gated operations on a group that doesn't exist locally now report `GroupNotFound` instead of a permissions error ("Only admins can ..."). Conversely, `getMemberRole`/`getGroupRoles` on a group that *does* exist locally but predates role tracking (no stored role metadata) now return the defaults (`member`; no explicit roles) instead of erroring "Group not found". - - Send-shaped operations (`sendMessage`, `forwardMessage`, connection requests, presence/typing/read receipts, media sends, service request/response) surface transport failures as `SendFailed`; non-send paths (inbound data processing) surface them as `TransportError`. Both previously arrived as `Other` or a generic `ERROR_*` code. The React Native `sendPresenceUpdate`/`sendTypingIndicator`/`sendReadReceipt`, `sendFile`/`sendMedia`, and `sendServiceRequest`/`respondToServiceRequest` methods now route rejections through the same typed mapping as `sendMessage` (their legacy `ERROR_*` codes remain the fallback for unclassified errors) — `sendFile`/`sendMedia` in particular can now reject with `MediaTransferLimit`, `NoKeyPackage`, `SessionNotReady`, or `EncryptFailed` instead of a flat `ERROR_SEND_FILE`/`ERROR_SEND_MEDIA`. - - Input validation on the 1:1 send paths now rejects with `InvalidArgument` instead of the generic fallback codes, matching the group-path twins: reserved-internal-prefix content and an unparseable `replyToMsg` id on `sendMessage`/`forwardMessage`, the forward-count cap on `forwardMessage`, and empty `recipient`/`conversationId`/`messageIds` (plus the read-receipt id cap) on `sendPresenceUpdate`/`sendTypingIndicator`/`sendReadReceipt`. - - `processFileChunk` rejections are split by cause: receiver resource limits and failed-transfer tombstones (`too_many_transfers`, `sender_quota_exceeded`, `buffer_budget_exhausted`, `previously_failed`) are `InvalidState` (retry with a fresh transfer is meaningful); malformed or mismatched chunks are `InvalidArgument`. All previously arrived as `Other` (React Native: a flat `ERROR_FILE`, which remains the fallback). - - *Rust API*: new `Error::{GroupNotFound, PermissionDenied, InvalidState, InvalidArgument}` variants; `validate_id_chars` returns the typed `IdValidationError`, `FileChunk::from_bytes` returns `ChunkDecodeError`, and `GroupRole::from_str` returns `ParseGroupRoleError` (all previously `Err(String)`). - -### Added - -- **Relay `TypingUpdate` events are bridged into the SDK event stream.** The relay server's server-mediated typing event (produced by `SetTyping`/`ClearTyping` relay clients) was only logged as a diagnostic by the platform `InternetManager`s; it is now converted into the SDK's internal `__TYPING__` message and fed through `internetMessageReceived`, so apps receive the same `typing_indicator_received` event regardless of whether the sender used the SDK or a JS-layer relay client. (Read receipts need no bridge: the relay server has no server-mediated read event, and SDK-native `__READ_RECEIPT__` messages already flow through the generic `MessageReceived` relay path.) -- **`MessageReceived` events now carry `encrypted: bool`.** `true` when the content arrived MLS-encrypted and was decrypted by this node (including delayed decryption after session establishment), `false` for plaintext accepted under the `require_encryption = false` opt-out. The React Native `MessageReceivedEvent.encrypted?: boolean` field existed but was never populated by the core — apps (including the in-repo demo) reading it always saw `undefined`; it is now live. JSON consumers unaware of the field are unaffected (additive). - -- **Transport trait seam redesign: platform-bridge lifecycle on the trait, `&self` start/stop, lock-free transport handles (CQ-H4, CQ-H5)** - The methods that actually move bytes in the I/O-free bridge model — `on_status_changed`, `on_data_received(_from)`, `on_fragment_received`, `get_next_message` / `get_next_fragment`, `confirm_sent` / `report_send_failure`, `set_peer_mtu` / `clear_peer_mtu`, `set_on_messages_available`, `deserialize_message` — are now on the `Transport` trait instead of being inherent methods reachable only by downcasting through `as_any()`. Callers holding a `dyn Transport` dispatch directly; the ~24 silent-failure downcast arms in the FFI layer are gone. Provided defaults are calibrated per failure mode: polls return `Ok(None)`, ingress methods error (inbound bytes are never silently dropped), confirmation/MTU hooks are logged no-ops. `as_any()` remains for genuinely transport-specific APIs (BLE peer registry and MTU telemetry counters; Nostr signed events, pubkey, subscriptions). - `Transport::start`/`stop` now take `&self` (every implementation was already fully interior-mutable — the `&mut` receivers were vestigial), so the per-transport mutex wrapper is gone: `TransportManager` stores and hands out plain `Arc` handles, and sends are no longer serialized against status/metrics reads by an outer lock. Behavior and wire format unchanged; FFI signatures unchanged. - - **Breaking (Rust API only):** implementors of `Transport` must change `start`/`stop` to `&self` and implement the new required method `on_status_changed`; `TransportManager::get_transport` returns `Option>` (previously `Option>>>`). Concurrency note: engine sends are no longer externally serialized against `stop()` — quiesce the platform bridge (callbacks *and* sends) before stopping a transport, per the trait docs. - -- **MLS authentication hardening: sender attribution, GroupId validation, key-package identity binding (SEC-M1, SEC-M2, SEC-M5)** - Three related gaps where the wire claimed an identity and the SDK believed it without consulting the cryptography: - - **Decrypted messages are attributed to the MLS-authenticated sender (SEC-M1).** Group and 1:1 decrypt previously discarded the credential OpenMLS authenticates on every message and surfaced the attacker-settable wire `sender` — any group member could speak as any other member, and the app-level admin gates keyed off that same string. All decrypt paths now take the claimed sender (the identity the message will be attributed to) and fail with the new `MlsError::SenderIdentityMismatch` when the MLS credential disagrees. The check runs **before** a staged commit is merged, so a spoofed commit cannot advance group state; on the `__MLS_ENC__` and `__GRP_MLS_MSG__` paths a mismatch is security-rejected (no delivery ACK), and the group-commit path treats it as permanent (no retry buffering). Wire 1:1 Welcomes whose payload `inviter_id` disagrees with the transport sender are rejected on the same principle. **Breaking (Rust API only):** `MlsManager::decrypt`, `decrypt_from_user`, and `decrypt_from_group` (and the underlying `SessionManager`/`GroupManager` methods) take a `claimed_sender: &str` parameter. Wire format unchanged; honest peers already send matching identities. - - **`GroupId` is validated at construction and deserialization (SEC-M2).** Group ids flow from the wire into `MlsStorage` as raw storage keys but, unlike `UserId`/`AppId`, were never validated — `"../../etc"` was a legal group id, with path-traversal exposure left to the app's storage backend. A `GroupId` must now be one or more non-empty colon-separated segments (`:` is the namespace separator in `session::` / `group:` ids), each rejecting path-traversal components (`.`, `..`), ASCII control characters, `/`, and `\`, capped at 4096 bytes total. Wire handlers drop messages carrying invalid ids with a warning; `list_groups` skips (rather than resurrects) any invalid id persisted before this fix. The same storage-key policy applies to `WelcomeMessage.inviter_id` (validated before it is used as a delete key in `replace_session_with_welcome`) and to `GroupId::for_session`, which now validates its inputs. **Breaking (Rust API only):** `GroupId::new` and `GroupId::for_session` return `Result`, the inner field is private, and the `From`/`From<&str>` impls are removed; `SessionManager::get_session_id` returns `Result`. - - **Imported key packages are bound to the claimed identity (SEC-M5).** `import_key_package(user_id, data)` previously stored any parseable key package under any caller-supplied name — a key package generated by one user could be filed under another's, and later `create_session` calls would encrypt to the wrong keys. Import now validates the `user_id` charset (it is a raw storage key), runs full cryptographic validation (signature, lifetime — previously deserialize-only), and requires the embedded credential identity to equal `user_id` (`MlsError::CredentialIdentityMismatch` otherwise). `get_contact_key_package` re-verifies the binding at use time, so entries persisted before this fix fail closed. Key-package **TOFU pinning** (rejecting a re-announced key package for an already-known peer) remains future work; transport-level TOFU on the `__MLS_KEY_PKG__` control channel still applies. - -- **Media and file transfers are now MLS end-to-end encrypted (SEC-H1)** - File chunks were previously sent as cleartext `binary_content` across every transport, including public internet relays — only the text path was encrypted. Chunk bytes now travel through the same MLS session machinery text uses, wrapped in a versioned binary envelope (`"ML"` magic + version byte + a compact `EncryptedMessage` encoding). The chunk-0 cleartext leaks are closed too: `MediaMetadata` (file name, preview thumbnail) and the original content type ride **inside** the ciphertext instead of on the wire `Message`. This is a **wire-format break** for media transfer: - - *New sender → old receiver*: encrypted chunks fail to parse and are dropped; the transfer times out (no plaintext is ever exposed). - - *Old sender → new receiver*: legacy plaintext chunks are accepted only when `require_encryption` is `false` **and** no confirmed MLS session exists with the sender. Once a session is confirmed, plaintext media from that peer is rejected as a downgrade/forgery attempt (plaintext chunks carry no sender authentication). With `require_encryption = true`, all plaintext media is rejected. - - *Sending*: with auto-encryption active (`encryption.enabled` and MLS initialized), `send_media` now requires a confirmed session and returns `SessionNotReady` otherwise (kicking establishment when a key package is available — retry after `secure_session_established`). Media is never queued pending establishment and never falls back to plaintext. With `require_encryption = true` and MLS uninitialized, `send_media` fails with `EncryptFailed`. The legacy plaintext format is used only when auto-encryption is inactive: the explicit `EncryptionConfig::disabled()` opt-out, or encryption enabled but MLS never initialized while `require_encryption` is `false` (matching the text path). - -- **Inbound file transfers are bounded against malicious senders (SEC-H2)** - The receive path previously trusted the sender's claimed `file_size` and `total_chunks` wholesale: a single crafted chunk (e.g. `file_size = u64::MAX, total_chunks = 1`) drove `Vec::with_capacity` on the claimed size during reassembly — a guaranteed panic (process abort under the mobile `minisize` profile, which sets `panic = "abort"`) or OOM, with no checksum forgery required — and the assembly map grew without bound across attacker-chosen `file_id`s. `FileTransferManager::process_chunk` now rejects chunks whose claimed `file_size` is zero or exceeds `max_file_size`; whose `total_chunks` exceeds the claimed `file_size` or a cap derived from `max_file_size` assuming a 1 KiB minimum average chunk payload (the SDK's own senders never chunk below 4 KiB); whose data is empty or larger than the claimed file; or whose bytes would push the total received past the claim. Reassembly buffers are sized from the bytes actually received, never the claim, and `finalize_file` additionally rejects a completed transfer whose reassembled size differs from the claimed `file_size`. Rejected chunks are dropped with a warning log and leave no assembly or metadata state behind. The wire format is unchanged. - - **Assemblies are bound to their sender.** The first accepted chunk pins the assembly to `message.sender`; chunks for the same `file_id` from any other sender are rejected (`sender_mismatch`), so a third party can no longer poison an in-progress transfer it can observe. - - **Receive-path memory is hard-bounded.** `FileTransferConfig` gains `max_total_buffered_bytes` (default 128 MiB) — the total bytes buffered across *all* inbound assemblies, where each stored chunk is charged its payload plus a flat 64-byte bookkeeping overhead so floods of tiny chunks cannot pin hashmap/allocator memory the payload sum does not see. A chunk that would exceed the budget is rejected and, because a dropped chunk is never retransmitted (it was already ACKed), the affected transfer's partial buffer is freed immediately instead of squatting the budget until the 300 s stale sweep. Budgets below what a single `max_file_size` transfer charges are clamped up with a warning (any lower value would make `max_file_size` unachievable). Counts are additionally capped by `max_concurrent_assemblies` (default 32) and the new per-sender quota `max_assemblies_per_sender` (default 16), so no single peer can occupy every slot. - - **Dropped inbound transfers are now observable.** A new `FileReceiveFailed` event (`type: "file_receive_failed"`; telemetry name `protocol.file.receive_failed`) fires when a well-formed transfer is dropped by a resource limit (`too_many_transfers`, `sender_quota_exceeded`, `buffer_budget_exhausted`), when a completed transfer fails its size/checksum integrity checks (`integrity_check_failed`), or when the 300 s stale sweep discards an incomplete transfer (`stale_timeout`). No `FileReceived` follows for that `file_id`; the sender must re-send the file. Malformed chunks (attacker-shaped input) are still dropped with only a warning log. - - **`FileReceiveFailed` is terminal and fires at most once per transfer.** A failed transfer's `file_id` is tombstoned (a capped, randomly-keyed hash set — attacker-length ids cannot grow it), so its remaining in-flight chunks — already ACKed and still streaming — are dropped silently (`previously_failed`) instead of re-emitting the event once per chunk or resurrecting a partial assembly that could never complete (which would squat an assembly slot and the byte budget until the stale sweep, then fail a second time as `stale_timeout`). Tombstones expire by the same staleness rule — no chunks for 300 s — after which the id may be reused; retries should use a fresh `file_id` (the SDK's send path already mints one per attempt). Explicit `cancel_transfer` does not tombstone. - - **Breaking (Rust API only):** `FileTransferManager::process_chunk` now takes the sender (`process_chunk(sender, chunk)`) and returns `Result` instead of `Option`; `FileTransferManager::cleanup_stale_transfers` returns `Vec` (file id, name, and sender of each dropped transfer) instead of `Vec`; `FileTransferManager::chunk_file` now fails when the chunk size would split the file into more chunks than the receive-side `total_chunks` cap (previously such a transfer was produced, ACKed, and then silently dropped chunk by chunk on the receiving side); `FileTransferConfig` gains the three fields above (spread `..FileTransferConfig::default()` when constructing it as a struct literal). `OfflineProtocol` currently uses the default `FileTransferConfig` (not yet configurable via `ProtocolConfig`). - - **Behavior change on the mobile FFI (signatures unchanged):** the manual `process_file_chunk` path now returns an error carrying the stable rejection reason when a chunk is refused — it previously reported success unconditionally, so Swift/Kotlin/React Native callers that ignored the result will start seeing throws under the new limits. Manual chunks are bound to an internal `manual:ffi` pseudo-sender (the `:` is invalid in wire user IDs, so no remote peer can claim it) and collectively share that pseudo-sender's concurrency quota (16 concurrent transfers by default) and the global buffer budget. Once a manual transfer fails, resending chunks under the same `file_id` is rejected as `previously_failed` until the id's tombstone expires (no chunks for 300 s); retry with a fresh `file_id` instead. - -- **Encryption is fail-closed by default (SEC-M3)** - `EncryptionConfig::default()` now sets `require_encryption: true`. Previously the flag defaulted to `false`, so a node whose MLS was never initialized (or whose encryption was disabled) silently sent every message — text and media — as plaintext over whatever transport DORS picked, including public internet relays, while the SDK advertised automatic end-to-end encryption. "Forgot to initialize MLS" and "deliberately opted out of encryption" are now distinguishable: the former fails, the latter requires explicit configuration. - - *Sending with MLS uninitialized*: every `send_message` / `send_media` fails with `EncryptFailed` (the error message names the remedy: call `initialize_mls()` or explicitly set `require_encryption = false`). Nothing reaches the transport. - - *Sending with MLS initialized*: behavior is effectively unchanged — messages to peers with a confirmed session encrypt as before; messages to peers without one are queued (`store_pending`, still the default) and flushed encrypted on confirmation. The strict queue path now also **kicks session establishment at queue time** (imports a stored key package, creates the session, sends the Welcome — matching the non-strict and media paths), so first-contact delivery does not depend on the peer initiating or on the arrival-time auto-establish having succeeded. - - *Receiving*: legacy plaintext media chunks are rejected by default (the existing `require_encryption` downgrade gate). Old-version peers must upgrade, or the receiver must opt out, for plaintext media to be accepted. - - *Explicit opt-out*: set `require_encryption = false` (and for full plaintext operation `enabled = false` — config validation intentionally requires the double opt-out, rejecting `enabled = false` alone). Every plaintext user-content send under the opt-out emits the new `SecurityWarning` reason code **`PLAINTEXT_SEND`** (`SecurityWarningCode::PlaintextSend`, TypeScript `'PLAINTEXT_SEND'`), at most once per peer per protocol instance, so cleartext flows stay visible. - - *Exempt (unchanged)*: internal control messages — key packages, Welcomes, connection request/accept/reject, session-confirmation probes/ACKs, and service discovery/request/response — are signed plaintext bootstrap messages and continue to work under strict mode. The service-discovery and API-reference docs previously claimed these APIs error under `require_encryption = true`; that claim was stale and the docs now describe the exemption. - - **Breaking (all bindings):** the UniFFI UDL now declares `require_encryption = true` as the dictionary default (generated Swift/Kotlin/Python constructors default to `true` when the field is omitted), and the React Native layer's own fallbacks (the JS config mapping and the iOS/Android native bridges) flipped from `false` to `true`. Apps that relied on the implicit `false` — sending plaintext without ever initializing MLS — must either call `initialize_mls()` (RN: `initializeMlsWithSecureStorage()`) or pass `require_encryption: false` explicitly. Apps that already set the flag explicitly are unaffected. - -- **Router relay policy is enforced, and the never-wired congestion controller is removed (CQ-H3, CQ-M11, CQ-M2/M3/M7)** - Three cases where the router crate carried policy that didn't govern behavior: - - **`allow_relay: false` now blocks relay promotion in every priority mode (CQ-H3).** The opt-out was only consulted under `RelayPriority::Always`; in the default `Auto` mode a device whose owner disabled relaying could still be promoted to the relay role — the charging shortcut promoted it unconditionally. Message *forwarding* was always gated separately on the same flag, so no unwanted forwarding occurred; the bug was a mislabeled role — dishonest `RelayPromoted` events and `relay_role` telemetry, plus the battery/duty implications of advertising a role the owner opted out of. Promotion is now refused whenever the config forbids relaying (`allow_relay: false` or priority `Never`), and a device holding the relay role under such a config is demoted on the next evaluation tick with the new `RelayDemotionReason::RelayDisallowed`, surfaced as a `RelayDemoted` event with reason `"relaying disabled by configuration"`. Devices mispromoted by the old behavior emit that one-time demotion after upgrading. - - **`RelayManager::select_best_relays` takes the congestion threshold as a parameter (CQ-M11).** Candidates were filtered against a hardcoded `0.7` instead of the configured `PathConfig::max_congestion_level`. The threshold is the caller's path policy, so it is now an explicit `max_congestion_level` argument rather than a second config knob that could drift from `PathConfig`. The engine's own relay filtering (`PathSelector`) always used the configured value; only direct Rust consumers of `RelayManager` are affected. - - **The `congestion` module is removed (CQ-M2/M3/M7).** `CongestionController` was public API wired into nothing: no send path ever consulted its decisions, its "AIMD" rate control was multiplicative in both directions, and its per-sender fair-queuing state grew without bound. It provided no congestion protection while its module docs claimed to "prevent network collapse". The congestion signaling that *is* wired in — `TransportMetrics::congestion`, DORS congestion scoring and escalation, relay congestion filtering — is unaffected. Future rate control should be designed against the real send path. - - **Breaking (Rust API only, router crate):** `select_best_relays` gains a `max_congestion_level: f32` parameter; `RelayDemotionReason` gains the `RelayDisallowed` variant (exhaustive matches need a new arm); the `offline_protocol_router::congestion` module and its re-exports (`CongestionConfig`, `CongestionController`, `DeliveryOutcome`, `SendDecision`) are removed. No wire-format or FFI changes. - -- **Nostr transport signing key is no longer derivable from the device ID (SEC-M4)** - The Nostr transport previously derived its secp256k1/BIP-340 signing key as `SHA-256(device_id)` — and the device ID passed in practice is the public protocol `user_id` — so anyone who knew a user's ID could reconstruct that device's transport private key and sign or replay Nostr events as it. The old derivation served two roles that are now split: - - *Addressing (unchanged on the wire).* The `#p` routing tag peers compute from a device ID (`routing_tag_for_device_id`, byte-identical to the old derivation) remains public and derivable — it is a rendezvous label, like an email address — so old and new SDK versions continue to address each other with no migration. - - *Event signing (now a real secret).* Events are signed with a key derived via domain-separated HKDF-SHA256 from a 32-byte per-install random secret. The secret is generated on first `initialize_mls()` / `enable_message_persistence()` and persisted through the app's `MlsStorage`; before storage is available (or if it never is), the transport signs with an ephemeral random key that rotates per process. Inbound handling is unaffected: the SDK has never authenticated inbound events by their Nostr pubkey (sender authenticity comes from the protocol-layer MLS signatures), which is exactly why the split interoperates. - - *Relay-visible identity rotates once on upgrade* (and whenever app storage is wiped): the `pubkey` on published events changes from the derived value to the per-install one. Relays and external observers see a new identity; peers are unaffected. - - *Platform note:* `nostr_get_public_key()` now returns the install's signing pubkey, which changes when `initialize_mls` installs the persisted secret — read it after MLS initialization instead of caching it across that boundary (platforms use it to filter self-authored events). The bundled React Native `NostrManager`s re-read it on every relay (re)connect, so their self-event filter stays correct regardless of configure/initialize ordering. `nostr_get_subscription_filter()` filters on the routing tag and is stable as before. FFI signatures are unchanged. - - **Breaking (Rust API only):** `NostrKeypair::from_device_id` and `NostrKeypair::pubkey_hex_for_device_id` are removed — use `NostrKeypair::from_install_secret` / `NostrKeypair::generate_ephemeral` and the free `routing_tag_for_device_id` — and `NostrTransport::public_key_hex` now returns `String`. New APIs: `NostrTransport::install_signing_secret`, `NostrTransport::routing_tag`, `NostrKeypair::generate_install_secret`. - -### Changed - -- **MLS sender-ratchet out-of-order tolerance raised from the OpenMLS default (5) to 32** - Windowed media transfers keep up to 8 encrypted chunks in flight, interleaved with text on the same 1:1 session ratchet; with tolerance 5 a delayed chunk could become permanently undecryptable and stall a transfer. Applies to newly created/joined groups and sessions; pre-existing persisted sessions keep the configuration they were created with until re-established. -- **Encrypted media chunks that arrive before the session is ready are queued** through the same pending-decryption queue the text path uses and are re-processed on session confirmation. -- **Encrypted media chunks are bound to the claimed sender.** A chunk's MLS group must be the deterministic 1:1 session group of the wire sender (`session::`); a valid ciphertext from any *other* session delivered under a different sender's name is dropped and surfaced as a `SecurityWarning` with the new `MEDIA_SENDER_GROUP_MISMATCH` reason code. Without this, any peer holding a session with the local user could have media attributed to an arbitrary identity. -- **Pending-queue drops of encrypted media chunks now fail loudly.** A media chunk evicted from the pending-decryption queue (per-peer/global overflow, or the 120 s TTL) is unrecoverable — it was ACKed on receipt, so the sender never retransmits it and its transfer can never complete. Each dropped chunk now emits `MessageDecryptionFailed` with the new `PENDING_QUEUE_DROPPED` code (plus a warning log) instead of stalling the transfer silently. Note the sender still observes per-chunk delivery ACKs and may emit `MediaSent`; receiver-side failure is the authoritative signal. -- **Concurrent outbound media transfers are capped at 2 per recipient when encrypted** (`Error::MediaTransferLimit`). Each transfer keeps up to 8 chunks in flight on the recipient's session ratchet, whose out-of-order tolerance is 32; unbounded concurrent transfers could push a delayed chunk beyond the tolerance and permanently stall it. Retry after an active transfer completes. -- **`send_media` fails fast on oversized chunk-0 metadata.** The encrypted-media plaintext codec now enforces its 256 KB metadata bound (and the content-type length bound) at encode time, so a too-large thumbnail fails the send with a `Serialization` error instead of being silently dropped by the receiver. -- **Legacy plaintext media policy fails closed on storage errors.** If the session-confirmation lookup errors while gating an unencrypted chunk, the chunk is rejected (previously a storage fault would disable the downgrade gate). -- **Outbound media transfers abort loudly instead of wedging.** A chunk that fails to encrypt mid-transfer (e.g. the session was invalidated concurrently) now aborts the whole transfer: all tracking state is removed (freeing its per-peer transfer slot immediately instead of after the 300 s stale sweep) and the new `MediaSendFailed` event is emitted. Terminal chunk failures in the outbox emit the same event; previously both cases stalled silently with only a log line. -- **The pending-decryption queue is byte-bounded.** `PendingQueueConfig` gains `max_pending_bytes_per_peer` (default 4 MB) and `max_pending_bytes_global` (default 32 MB). The queue now holds full-size encrypted media chunks (up to 256 KB each), so the existing count limits alone would have admitted ~1 GB of unauthenticated ciphertext at defaults. Overflow follows the configured policy; dropped media chunks surface via `PENDING_QUEUE_DROPPED` as before. Existing serialized configs without the new fields deserialize with the defaults. -- **Hard decryption failures on media chunks emit `MessageDecryptionFailed`.** A media chunk whose ciphertext cannot be decrypted (e.g. its ratchet key was deleted after 32+ newer messages decrypted first) was already ACKed on receipt, so the loss is permanent; it now surfaces to the app with the mapped failure code instead of only MLS telemetry. -- **`Error::MediaTransferLimit` is a typed FFI error.** Previously it crossed UniFFI as the opaque `Other`; Swift/Kotlin now receive `ProtocolError::MediaTransferLimit` and the React Native bridge maps it to the `MediaTransferLimit` rejection code for programmatic retry. - -### Bug Fixes - -- **Group messages that outrun their Welcome (or a commit) are buffered instead of permanently lost.** The Welcome and the first group message are sent back-to-back and can arrive out of order across transports — but a group application message that failed MLS decryption was dropped on the spot, and because its ID was already recorded for replay protection, every redelivery (outbox retry, second transport, relay replay) was rejected as a duplicate: the first message(s) after a group invite could never be delivered. - - *Buffering.* A decrypt failure caused by lagging local group state (`GroupNotFound` — Welcome not yet processed — or an epoch-behind `Decryption` failure) buffers the ciphertext (16 per group, TTL matched to the 5-minute replay-dedup window so the buffered copy outlives the dedup entry that blocks redelivery). The buffer is drained in arrival order after a successful Welcome join or commit for the group. Permanent failures (bad payloads, sender-identity mismatches) are still dropped immediately, and buffers are cleared on group leave/removal. - - *Commits get the same repair.* `GroupNotFound` was previously classified as a permanent commit failure, so a commit that outran the Welcome was dropped outright — it is now buffered like any other out-of-order commit, and a successful Welcome join drains buffered commits first (advancing the epoch) and then buffered messages, so the invite → membership-change → first-message sequence delivers regardless of arrival order. A commit or proposal that arrives *on the application-message channel* (MLS consumes it and returns no plaintext) also counts as a state advance and triggers the same drains — including mid-drain, where a consumed riding commit re-runs the drain pass so entries buffered ahead of it in arrival order still deliver. - - *Both inbound paths, one classifier.* The mesh (`__GRP_MLS_MSG__`) and relay (`__GROUP_MSG__`) decrypt-and-classify logic is now shared. The relay dispatch previously routed through MLS only for groups already present in the local members cache — which is populated *by* the Welcome, so a relay group message that outran its Welcome was emitted to the app as raw base64 and never buffered. Relay group messages now route through the MLS path whenever MLS is initialized. To keep legacy relay-only groups working, a payload for a group without local MLS state is buffered only if its bytes parse as MLS wire framing (`is_mls_framed`); legacy plaintext that merely happens to be valid base64 is emitted raw as before (and non-MLS garbage on the mesh path is dropped instead of buffered). - - *Global bounds.* Because `GroupNotFound` buffering is by definition pre-authentication, both buffers are also globally bounded across groups (messages: 256 entries / 8 MiB of ciphertext / 32 distinct group IDs; commits: 64 entries / 4 MiB / 16 distinct group IDs) — per-group caps alone left total retention open-ended across attacker-chosen group IDs, and the distinct-group cap also bounds map-key memory (group IDs are wire-supplied, up to 4 KiB each). An entry that alone exceeds the byte budget is dropped outright instead of purging the buffer. - - *Eviction policy.* At entry/byte capacity the oldest entry of the *largest* per-group buffer is evicted, so a flood that concentrates in a few group IDs is evicted before honest welcome-race entries (few per group). Freeing a *group slot* at the distinct-group cap instead evicts the single largest per-group buffer wholesale — emptying some group is the only way to free a slot, and doing it one entry at a time would purge nearly the whole map across level-sized buffers before any group emptied. A spread flood (one entry per fabricated group ID) levels the buffer sizes and degrades eviction to globally-oldest within the group cap. - - *Dropped-undelivered entries release replay protection — both layers.* To keep cap eviction or TTL expiry from causing permanent loss, any buffered entry dropped undelivered releases its replay-protection records so a sender-side redelivery is accepted fresh instead of being rejected as a duplicate of a copy that no longer exists. This releases both the group-level dedup ID *and* the transport-level deduplicator entry: on the mesh path the envelope ID doubles as the group dedup key, and the receive-loop deduplicator (1-hour retention by default) would otherwise swallow — and re-ACK — the redelivery before it ever reached the group handlers. Applies to buffered commits exactly as to buffered messages (each pending commit records its wire message ID for this), at every drop site: cap eviction, drain-time TTL expiry, and the periodic cleanup sweep. -- **`known_peers` no longer locks out new neighbors once full (#140).** Since the reachability seam fix (#136), every inbound message on Internet/WiFi Direct/Reticulum/Nostr inserted its sender into the known-peers set, but only BLE disconnects ever removed entries — a long-running node (e.g. an internet relay) accumulated senders until the 1000-entry cap, after which *no* new peer could be tracked, including a local BLE neighbor. Peers now carry a last-seen timestamp refreshed on every discovery signal: entries not re-seen for 30 minutes are evicted by the periodic cleanup sweep, and an insert at capacity evicts the least-recently-seen entry instead of refusing the new peer. Evictions mirror `on_neighbor_lost` (the key-package marker is cleared too, so a re-appearing peer gets a fresh key package, as on a BLE reconnect — this also stops `key_package_sent_to` growing unboundedly for tracked peers). Behavioral note: `is_known_peer()` can now flip to false for a peer that has gone quiet for the TTL without any `NeighborLost` event, and the relay-role `connection_count` decays accordingly instead of pinning at stale values. -- **WiFi Direct disconnects now clear core discovery tracking.** `wifi_direct_peer_disconnected` emitted the `NeighborLost` platform event but never notified the core protocol, leaving the peer tracked (and its key-package marker set) until the TTL sweep; it now calls `on_neighbor_lost`, matching `ble_peer_lost`. -- **Adaptive TTL no longer collapses at ~25,650 estimated devices (CQ-M1).** The size-based TTL boost computed "extra hundreds of devices" in a `usize` and narrowed it with a bare `as u8` cast, which wraps modulo 256: at an estimated 25,650+ devices the boost silently vanished — TTL fell back to the small-network base instead of the configured maximum, exactly when reach matters most — and oscillated as the network grew further. The count now saturates at the cast boundary; the existing max-TTL clamp bounds the result as before. -- **Periodic cleanup sweeps no longer risk a panic on freshly-booted devices.** The group-message dedup, pending-commit, pending-group-message, and service-discovery dedup sweeps computed their expiry cutoff as `Instant::now() - TTL`, which panics on underflow — and on platforms where the monotonic clock starts at boot (Linux, Android, embedded), that subtraction underflows whenever the process is younger than the TTL, e.g. a messaging service auto-started at boot running its first sweep within 5 minutes. Entry ages are now compared with `saturating_duration_since` instead. - -## [0.11.1] — 2026-07-12 - -Hotfix cut from `v0.11.0` — no API or behavior changes. Consumers only need a version bump. - -### Fixed - -- **Android native libraries are now 16 KB page-size aligned (Google Play requirement).** - `libuniffi_offline_protocol.so` in `0.11.0` and earlier had 4 KB-aligned `PT_LOAD` segments on every ABI, which Google Play now rejects (as a blocking error) for apps targeting Android 15+ on 64-bit devices. All four ABIs (`arm64-v8a`, `armeabi-v7a`, `x86`, `x86_64`) are now linked with `-Wl,-z,max-page-size=16384` via per-target rustflags in `.cargo/config.toml`, and both build paths (release CI and `build-uniffi-android.sh`) gate on a new `check-elf-alignment.py` so a future toolchain change cannot silently regress alignment. Verify with `readelf -l ` — every `LOAD` segment shows `Align 0x4000`. - -### Changed - -- **JNA dependency bumped from 5.13.0 to 5.19.1** in the Android library. JNA 5.13.0's `libjnidispatch.so` is not 16 KB-aligned on `x86_64` and crashes at runtime on 16 KB-page devices (fixed upstream in JNA 5.17.0). No API impact; apps already forcing a newer JNA via Gradle resolution are unaffected. - -## [0.11.0] — 2026-07-01 - -### Licensing - -- **Relicensed from `MIT OR Apache-2.0` to `AGPL-3.0-only` with a parallel commercial license.** - All workspace crates, the React Native binding (`bindings/react-native/package.json`), and the Python binding (`bindings/python/pyproject.toml`) now ship under AGPL-3.0-only. The repo root gains a full `LICENSE` (the GNU AGPL-3.0 text) and a `LICENSE-COMMERCIAL.md` describing the alternative paid license for organizations that cannot comply with AGPL-3.0 section 13 (network-use source disclosure) — for example, closed-source mobile apps, embedded firmware, or SaaS deployments. Consumers may use the SDK under **either** license, not both. Contributions are accepted under AGPL-3.0-only **and** are dual-granted to the maintainers for inclusion in commercially licensed distributions via the CLA in `CLA.md` (signed once via the CLA Assistant bot on a contributor's first PR); see `CONTRIBUTING.md` for the signing flow. Versions of the SDK published before this change remain available under their original `MIT OR Apache-2.0` terms. Workspace crates and the Python binding move from `0.1.0` to `0.2.0`; the React Native binding moves from `0.10.0` to `0.11.0` so the relicense never collides with a previously-published version under the same number. - -### Breaking Changes - -- **`getTransportMetrics` returns real data (or `null`) instead of a zeroed mock** - The UniFFI method `get_transport_metrics(transportType)` (exposed as `getTransportMetrics` in Swift/Kotlin/TypeScript) previously always returned a `TransportMetrics` populated with zeros. It now pulls directly from `Transport::metrics()` and returns `null` when the requested transport is not registered with the `TransportManager`. Callers that relied on the non-null guarantee or zero-valued fields must add a null-check and treat absent transports as "metrics unavailable" rather than "all counters zero". - -- **`mls-observability` Cargo feature retired** ([#92](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/92)) - Compile-time gating is replaced by runtime `TelemetryConfig::mls_verbosity` (`Off` | `Lifecycle` (default) | `Diagnostic`). Workspace Cargo files that pass `--features mls-observability` should drop the flag; behaviour is preserved by the `Lifecycle` default. Opting out at runtime additionally suppresses the legacy `MlsEventEmitter` path. See `docs/telemetry.md`. - -- **`Event::SecurityWarning` gained a `reason_code` field** ([#123](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/123)) - The variant is now `SecurityWarning { peer_id, reason_code, reason }` and the constructor is `Event::security_warning(peer_id, reason_code, reason)` (previously two arguments). This is source-breaking for direct Rust consumers that construct the event or match it exhaustively without `..`. Mobile/RN/Python consumers are **unaffected**: events cross the FFI as serde-tagged JSON, so `reason_code` is an additive field on the wire and existing `reason` handling keeps working. - -### Added - -#### Telemetry epic ([#89](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/89)–[#96](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/96)) - -- **`TelemetrySink` and `TelemetryRecord` taxonomy** ([#91](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/91)) - A single observer trait replaces the previous patchwork of `EventCallback`, `MlsEventEmitter`, and pull-only `TransportMetrics`. `TelemetryRecord` is a `#[non_exhaustive]` enum spanning six categories (`Protocol`, `Mls`, `MetricsSnapshot`, `TransportState`, `Routing`, `Device`); the `Event` payload is boxed so non-`Protocol` records do not pay a 368-byte size tax. `TelemetryConfig` ships privacy-preserving defaults: `scrub_ids=true` (long-lived pseudonymous identifiers are SHA-256 hashed via a per-instance secret before crossing the sink), `mls_verbosity=Lifecycle`, and `metrics_cadence_ms=Some(5000)` (aligned with the DORS stability window). `Scrubber` is `pub(crate)`; `TelemetryConfig`'s `Debug` impl redacts the secret. - -- **Sink wiring for protocol events and MLS lifecycle** ([#92](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/92)) - `OfflineProtocol::install_telemetry_sink(sink, config) -> Result<()>` plumbs every `Event` and MLS lifecycle event through the sink. Long-lived identifiers (peer/user/group/sender/recipient/members) are scrubbed by default; message IDs and content stay raw. A compile-time exhaustiveness ward in `scrub_event.rs` guarantees new `Event` variants cannot ship without explicit scrubbing decisions. The legacy `EventCallback` continues to fire alongside an installed sink. - -- **`MetricsFrame`, `TransportStateEvent`, `RoutingDecision`, `DeviceCapabilitySnapshot` records** ([#93](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/93)) - These four records carry no long-lived identifiers and are emitted from `OfflineProtocol::process()` (metrics cadence, transport-status diff, device-capability diff) and from a new `routing_decision_callback` wired alongside every `Event::Dors*` site. Bench: 2–29 ns per emission against a <5 µs / <25 µs budget. Pure additive — `Event::Dors*` consumers see the same legacy stream. - -- **Unified `TelemetrySink` across UniFFI / iOS / Android / RN / Python** ([#94](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/94), [#97](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/97)) - Apps install a single sink that receives every typed `TelemetryRecord` the SDK emits, plus a forward-compatible `on_extension(name, payloadJson)` fallback for variants added to the Rust enum after the FFI was generated. A bounded (1024-slot, drop-oldest) poll queue backs `pollTelemetry()` for apps that prefer pull over push. `TransportMetrics` gained 12 optional fields mirroring the richer Rust struct and flows unchanged through both the pull and push paths. - -- **`TelemetryConfig.enablePollQueue` (default `true`)** - Push-only integrations can set this to `false` to skip the per-emit `serde_json` envelope construction inside the Rust adapter. With the opt-out in effect, the typed push callbacks still fire but `pollTelemetry()` returns `null` for records emitted under that config. Flag is local to the UniFFI adapter (not forwarded to `CoreTelemetryConfig`). - -- **`uninstallTelemetrySink()`** - Detaches the installed sink in a single call: replaces the core-side sink with a no-op (future emissions are discarded with zero overhead) and drains the pull queue so a subsequent `installTelemetrySink(...)` starts with an empty queue. Idempotent. - -- **`installTelemetrySink(config, listener?)` accepts an optional listener** - Registering the listener synchronously before the native install is dispatched closes the window where records emitted between the bridge resolving and the next JS microtask would fan out to an empty listener set and be dropped on the push channel. - -- **`RoutingPhase.Unknown` / `RoutingReasonCode.Unknown`** - When the Rust core reports a routing variant the FFI build does not recognise (new-core / old-FFI skew), the adapter now maps to an explicit `Unknown` value, gated by a `std::sync::Once` warn so the routing hot path cannot drown the tracing layer. - -- **`AckEvicted` and `FragmentAssemblyEvicted` events** ([#89](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/89)) - Three `Event` variants were defined and reached the FFI but never fired. ACK eviction now returns `Option` directly from `register_pending_ack` (no drain buffer); BLE fragment eviction uses an injected callback that emits outside the `fragment_buffers` lock to avoid deadlocks. Apps gain observability into capacity pressure on the ack tracker and the fragment reassembly cache. - -- **Telemetry-driven diagnostics in the demo app** ([#95](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/95)) - A new Diagnostics tab and persistent status pill expose transport-time distribution, DORS switch drivers, link-stability flap counts, per-transport delivery/error/latency, hop-count histograms, retry queue by priority, partition duration, and battery drain rate. Aggregate-only — no per-peer data, no message content, no per-decision internals. - -#### Telemetry & observability follow-ups - -- **Relay role transition events now fire** ([#110](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/110)) - `RelayManager` knew how to decide relay promotion/demotion (`should_promote` / `should_demote` / `set_role`) but nothing ever called it, so the role sat at `Regular` for the whole process lifetime and the `RelayPromoted` / `RelayDemoted` / `RelayDemotedBattery` events (defined but never emitted since they were introduced) had nothing to fire on — downstream analytics were measuring churn that never happened. A new `RelayManager::evaluate_transition` runs every `process()` tick with live battery and connection state, mutates the role only on an actual flip, and emits the matching event through the normal channel so both the app `EventCallback` and the telemetry sink see it (it runs whether or not a sink is installed — these are app-facing events). `should_demote` also learned about charging and only forces a demotion at a critical level, so a charging phone parked just under the battery minimum can no longer promote/demote on every tick. Evaluation is skipped while the battery level is unknown. - -- **Persistent per-install telemetry scrub secret** ([#112](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/112)) - When no explicit `scrub_secret` is configured, telemetry identifiers were hashed with a fallback secret freshly minted (`uuid::new_v4()`) at construction, so the same physical device produced a different opaque id every session and cross-session distinct-device counting was impossible. The secret is now loaded from (or generated once and written back to) secure storage during `initialize_mls()` / `enable_message_persistence()` and adopted as the fallback. Precedence is unchanged — an explicit config secret still wins, and apps that never provide storage keep the old random behaviour. Every storage failure degrades to the in-memory secret rather than blocking init, and a corrupt/wrong-length blob is regenerated. - -- **`TelemetryConfig::with_mls_sampling_bypass`** ([#113](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/113)) - MLS lifecycle events run through a fixed-window rate limiter (10 per peer+kind per second) before reaching a sink, which clips aggregate counts: a backend counting `mls.decryption_failed` can't tell a genuine burst of 10 from a burst of 10,000. Telemetry-grade sinks that do their own backpressure can now opt in to skip the limiter entirely — the window counter is left untouched, so flipping it back off resumes clean limiting. Default stays `false`. Plumbed through the UniFFI dict and regenerated Swift/Kotlin/Python bindings. - -- **`telemetryInstallId()` across UniFFI / RN** ([#114](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/114)) - Exposes a stable per-install identifier derived as `SHA-256(secret || "telemetry:install-id")` truncated to 32 hex chars, so analytics can distinguish two sessions of one device from two devices without the raw scrub secret ever crossing the bridge. The domain string is frozen (the `:` makes it un-collidable with any valid `UserId`/`AppId`), and the accessor returns `null` until secure storage is wired (before that the underlying secret is a random per-instance value, so a "stable" id would be a lie). Deliberately pinned to the SDK-managed secret rather than an app-supplied `with_scrub_secret` override so the id can't rotate on sink re-install. Documented as a persistent identifier that may fall under app privacy-disclosure requirements. - -#### Security & trust - -- **Machine-readable `SecurityWarningCode` on `Event::SecurityWarning`** ([#123](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/123)) - Security warnings now carry a stable `reason_code` (`TOFU_KEY_MISMATCH`, `TOFU_STORE_FULL`, `TRANSPORT_IDENTITY_MISMATCH`, `SIGNATURE_DOWNGRADE`, `CONTROL_SIGNATURE_INVALID`) alongside the existing human-readable `reason`. Consumers reacting to a peer reinstall / new device (the `TOFU_KEY_MISMATCH` case) can branch on the code instead of substring-matching `reason`, which is for logs/UI and may be reworded between versions. `SecurityWarningEvent`, `TofuResetEvent`, and the `SecurityWarningCode` union are now typed in the React Native bindings (`bindings/react-native/src/types.ts`) rather than untyped raw-string subscriptions. The `reason_code` is additive on the JSON wire; existing `reason` matches keep working. - -- **`resetTofuForPeer` now drops the stale MLS session in the same call** ([#123](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/123)) - `OfflineProtocol::reset_tofu_for_peer` (exposed as `resetTofuForPeer`) previously only un-pinned the TOFU key, leaving the now-dead 1:1 MLS session in place so callers had to remember to delete it or the next `establish_secure_session` would no-op against stale credentials. It now also drops the session, since accepting a peer's new identity makes the old session dead by definition. The drop is **best-effort, not atomic**: the key un-pin is committed first (so the call still returns `true` and emits `TofuReset`), then the session is dropped — no session or uninitialized MLS is a harmless no-op, while a genuine deletion failure is surfaced via a `warn` log and leaves the stale session in place rather than rolling back the un-pin. Behavior is unchanged when no TOFU entry exists (still returns `false`, still idempotent). - -- **OpenMLS stack updated to clear five RUSTSEC advisories** ([#114](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/114)) - Lockfile-only bump within existing semver requirements: `openmls` 0.7.1 → 0.7.4 and `openmls_rust_crypto` 0.4.1 → 0.4.4, pulling patched `hpke-rs` 0.6.1 and `libcrux-sha3` 0.0.8 to resolve RUSTSEC-2026-0069/0070/0071/0072 (hpke-rs) and RUSTSEC-2026-0074 (libcrux-sha3). The remaining RUSTSEC-2026-0173 (`proc-macro-error2` unmaintained — a compile-time proc-macro from libcrux's hax tooling with no fixed release) is ignored in `deny.toml` with justification. Full workspace test suite passes on the updated stack. - -### Deprecated - -- **`updateTransportMetrics(...)` is a documented no-op, removal targeted for v1.0** - This method predates the per-transport tracking the Rust core now performs internally and has never written to any field the SDK reads from. All passed fields — including the 12 newly-added optional extended fields — are discarded. Use `getTransportMetrics(...)` to read live metrics, or install a `TelemetrySink` to observe push `MetricsFrame`s. A `std::sync::Once`-gated `tracing::warn!` fires on first call so misuse surfaces during development. The method is scheduled for removal in the **v1.0** release. - -### Bug Fixes - -- **Isolate `TelemetrySink` panics to prevent `SharedState` poisoning** ([#96](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/96)) - Three families of emit sites invoked `TelemetrySink::emit` while holding the `SharedState` mutex. A panicking sink (realistic for foreign sinks via UniFFI) unwound through the live `MutexGuard`, poisoning the mutex on drop and silently degrading the protocol. A new `telemetry::dispatch::dispatch_record` helper wraps every internal sink dispatch in `catch_unwind(AssertUnwindSafe(...))`. The legacy `EventCallback` fan-out gets the same treatment. Note: `panic = abort` profiles (mobile `minisize`) bypass `catch_unwind`. - -- **Unblock Python install and close UDL-drift gaps** ([#97](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/97)) - `bleak` pin bumped from `>=0.21,<1.0` to `>=1.1.1,<2` so install resolves against `bless 0.3.0` (previous pins were mutually exclusive — `pip install -e .` failed). `nostr_enabled=False` added to four `ProtocolConfig` construction sites that predated the field becoming required. `_make_config` defaults `internet_enabled=True` so tests satisfy the "at least one transport enabled" validation. `ProtocolManager.start()` registers stub `NostrTransportCallback` and `ReticulumTransportCallback` impls (mirroring the WifiDirect stub), gated on `config.nostr_enabled` / `config.reticulum_enabled` so apps that drive the transport themselves are not silently swallowed. `install_telemetry_sink`, `uninstall_telemetry_sink`, and `poll_telemetry_frame` passthroughs are added with strict GC pin lifecycle (pin first, call Rust, unpin on failure; `stop()` only clears pins after teardown succeeds). New CI `python-bindings` job regenerates from UDL on every PR and fails on drift. - -- **Use `InternetMessage.recipient_id` in outbox drain (Python)** ([#98](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/98)) - UniFFI-generated `InternetMessage` exposes `recipient_id`, not `recipient`. Every outgoing WebSocket send raised `AttributeError`, propagated into `_safe_handle_authenticated` → `_handle_connection_closed`, triggering an auth/reconnect flap on any queued message. - -- **Tear down WebSocket and recv loop on disconnect (Python)** ([#99](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/99)) - `_handle_connection_closed` cancelled poll/ping but never closed `self._ws` or cancelled `self._recv_task`. On `AuthError`, the WebSocket stayed open and the recv loop kept iterating it; `_connect` then overwrote `_recv_task` with a fresh loop against a new socket — two live recv tasks, one zombie WS. Added a `_teardown_in_progress` re-entrancy flag and a `_process_tasks` strong-reference set so fire-and-forget tasks cannot be GC'd mid-execution. `_schedule_reconnect` cancels any prior `TimerHandle` before installing a new one. - -- **Guard `BlePeripheral` peer maps with existing lock (Python)** ([#100](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/100)) - `self._lock` previously protected only metrics counters even though the bless delegate thread (`_on_write` → `_resolve_sender`) and the asyncio loop (`_peer_monitor_loop`, `resolve_sender_identity`, `stop`) read and wrote `_connected_centrals`, `_central_to_user_id`, and `_last_known_central` concurrently. Iteration-during-mutation surfaced as `RuntimeError`s in production, and single-peer sender attribution could flip between threads on the same message. Lock now covers all three fields under a strict "snapshot under lock, FFI outside" rule. - -- **Copy `uniffi.dll` on Windows instead of symlinking** ([#101](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/101)) - `ln -sf` on Git Bash / MSYS requires `SeCreateSymbolicLink` (admin or Developer Mode); without it the symlink is either broken or a regular copy depending on `MSYS2 winsymlinks`. macOS / Linux keep the symlink; Windows uses a straight `cp -f`. - -- **Raise on `send_message("*")` with no known BLE peers (Python)** ([#102](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/102), [#104](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/104)) - Previously fell through to `protocol.send_message(recipient="*", ...)` whenever `_get_known_ble_peers()` returned empty; the Rust core's `BleTransport::send()` treats `"*"` as a literal peer-ID lookup and always fails — the caller got a message id back that would never deliver, with no surfaced error. `send_message("*")` is now explicitly documented as a BLE-only wrapper convenience and raises `ValueError` on empty peer sets. `_get_known_ble_peers` is promoted to public `get_known_ble_peers` since it is now part of the documented escape hatch. Internet/Nostr/Reticulum/Wi-Fi Direct broadcasts remain platform-driven. - -- **Saturate `forward_count` to close a `MAX_FORWARD_COUNT` bypass** ([#108](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/108)) - `ForwardInfo::from_message` incremented a peer-controlled `u32` with `existing.forward_count + 1` *before* the `MAX_FORWARD_COUNT` (100) cap check. Under the workspace default Cargo profile that wraps to `0` in release builds (a peer sending `forward_count = u32::MAX` slipped the cap entirely) and panics in debug (DoSing dev/CI). Switched to `saturating_add(1)` so the value clamps to `u32::MAX` and the existing post-increment check rejects it, matching the monotonic-counter convention used elsewhere in the workspace. Covers both the 1:1 and group forward paths. - -- **Ship Android consumer ProGuard rules for the FFI** ([#118](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/118)) - Any consuming app that minified its release build lost the BLE mesh transport entirely: the Rust core is reached from Kotlin via JNA + the uniffi-generated bindings, wired by reflection over class/field/method names (`Native.register`, `@Structure.FieldOrder` vtables, `com.sun.jna.Callback`), and R8 renamed those apparently-unreferenced symbols so the FFI died at runtime. Debug/Metro builds worked, so it only surfaced once shipped (consuming apps were carrying a patch-package workaround). The keep rules now ship from the library via `consumerProguardFiles` (applied to the consuming app's R8 run automatically) and `consumer-rules.pro` is added to the npm `files` list so it physically exists in the published tarball. - -- **Restore offline 1:1 MLS convergence over BLE mesh** ([#120](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/120)) - A broad fix set for 1:1 MLS sessions that stalled at "pending" when established offline over the BLE mesh. Highlights: DORS now demotes the Internet transport below every mesh transport when `prefer_online=false`, so a message to a nearby peer that isn't on the relay falls back to BLE/Wi-Fi Direct instead of being queued on Internet and silently lost; the both-create "owner" only confirms on a real group-aware decrypt (fixing split-brain when both peers create a group at once) and the adopter proactively sends an MLS-encrypted `SESSION_CONFIRM_ENCRYPTED` marker so a passive owner with no traffic still converges; a no-carrier MLS Welcome is parked and re-armed on peer rediscovery instead of burning its retry budget against a vacuum and expiring; the mesh Welcome sender now drives its own `SESSION_CONFIRM_PROBE` rather than relying solely on the receiver's single proactive confirm; the peripheral-notify egress and per-peer MTU are resolved by device identity (not link address) so iOS's distinct per-direction connection handles no longer defeat the notify path; the notify characteristic switched to ATT **indications** for per-fragment flow control (with a dedicated 250 ms watchdog); the both-create owner gate is persisted and cleared on session teardown; and MLS `join_group_replacing` is a non-destructive stage-then-swap so a Welcome retransmit can't re-brick an adopted group. Adds a `ConvergenceDiag` breadcrumb event (scrubbed instrumentation, additive on the wire, wired to the RN types) that reports where a Welcome's convergence broke. - -- **Floor notify egress to the 185-byte cap when the peripheral-link MTU is unobserved** ([#121](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/121)) - Follow-up to #120. The per-peer `min(central, peripheral)` MTU clamp collapses to the central payload whenever the GATT-server `onMtuChanged` never delivers the notify link's MTU (common: the server-role callback is unreliable and iOS centrals negotiate a small/absent MTU on the link they open). A multi-fragment Welcome egressed over NOTIFY was then sized for the larger central link and silently truncated on air, stalling convergence. Notify-subscribed peers with an unknown peripheral MTU now fall back to the 185-byte fragment cap, re-flushed on CCCD subscribe *and* unsubscribe, with the subscription check device-scoped across both link addresses. The floor/min arithmetic was extracted to a pure, unit-tested `computeEffectivePayload()`. Kotlin bridge only — no Rust/UniFFI/`.so` changes. - -- **Restore offline Android↔iOS message delivery** ([#122](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/122)) - When Android and iOS meet over BLE the steady state (iOS backgrounded) is Android-central / iOS-peripheral, and three things broke that path. (1) iOS had **no** GATT-server data path — `BleManager` could only send over a link it opened as central — so iOS→Android stalled; it now retains subscribed `CBCentral`s and pumps fragments through a main-queue `updateValue` with `peripheralManagerIsReadyToUpdateSubscribers` backpressure, reporting `min(write, notify)` MTU. (2) `can_confirm_from_source` rejected a both-create owner whose Welcome had expired even though a successful group decrypt is definitive proof the peer adopted the group — a successful decrypt now confirms unconditionally (and the both-create gate is no longer re-armed on an already-confirmed session). (3) DORS retained the demoted Internet fallback over an available mesh transport because a cold-start fallback set Internet as current and armed the switch cooldown; it now switches the moment a real mesh score outranks the sentinel. Also emits `secure_session_established` for the both-create **owner** (previously only the adopter emitted it, so whoever drew the owner role saw "no contacts with secure sessions available" when creating a group), raises the pre-device-id inbound buffer from 5 s to 15 s, and counts BLE sends that find the recipient absent while other peers are connected. The iOS notify path mirrors the audited Android design but needs on-device validation; the U1≠U2 inbound-attribution case (distinct iOS per-direction handles) is deferred to a follow-up. - -### Refactoring - -- **Extract `CategorySampler` from `MlsEventRateLimiter`** ([#90](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/90)) - The fixed-window rate-limiting logic is generic (a keyed counter with window reset and eviction) but was welded to MLS event types. Extracted into `CategorySampler` under a new `telemetry` module so the new sink categories can reuse it. `MlsEventRateLimiter` becomes a thin wrapper that maps MLS lifecycle events to string keys. - -- **Stable `Error::code()` for transport-error classification** ([#111](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/111)) - Transport errors were being classified downstream by substring-matching their `Display` text — an invisible coupling that silently reclassifies to the `other` bucket the moment a `#[error("...")]` string is reworded. `Error` now exposes a `code()` returning a stable SCREAMING_SNAKE_CASE string per variant, decoupled from the human-facing text. The match is exhaustive (no wildcard arm) so a new variant won't compile without a code, and a test pins every string. Codes stop at the Rust boundary for now — transport errors still stringify into `ProtocolError::Other` at the FFI layer. - -### CI/CD - -- **Run the React Native Android module's unit tests** ([#120](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/120)) - The Kotlin binding tests (fragment buffers, outbound queue, GATT CCCD classifier, `MeshController` eviction) had never run in CI — which is how several tests asserting the *opposite* of the code under them survived. A standalone Gradle harness supplies the AGP + Kotlin classpath and pulls the module in as a subproject without touching the module's own `build.gradle` (so real RN-app consumption is unaffected); Kotlin is pinned to 1.9 until the 2.x migration lands. - -- **Grant the CLA workflow `actions: write`** ([#109](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/109)) - `contributor-assistant/github-action` v2.x needs `actions: write` to update the PR check status; without it the job recorded the signature but died with "Resource not accessible by integration", leaving the CLAAssistant check stuck on failure. - -### Documentation - -- **Telemetry wire-up guide** ([#95](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/95)) - New `docs/telemetry.md` covers push/pull paths from React Native, Rust `TelemetrySink` examples, the UniFFI callback shape, config knobs, and `MetricsFrame` field semantics. - -- **Correct stale API and transport-priority references** ([#117](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/117)) - The React Native guide documented a "relay-server" group API (`groupCreate`, `groupSendMessage`, `groupAddMember`, …) that exists nowhere in the bindings; section 7 was rewritten around the real MLS mesh group API (`meshCreateGroup`, `meshSendGroupMessage`, `meshInviteToGroup`) and the duplicate relay-server reference table dropped. Also fixed accumulated drift: with Nostr added, the lowest DORS tie-break priority is now Nostr (Reticulum is second-lowest); the `api-reference` `Message`/`ProtocolConfig` structs regained missing fields; `telemetry.md` documents `mlsSamplingBypass`; `SECURITY.md` moved from 0.1.x to 0.2.0; and the react-native-app guide uses the nested transport-config shape the JS layer actually accepts (the Kotlin/Swift examples stay flat on purpose — the UniFFI `ProtocolConfig` dictionary really is flat). - -### Chores - -- **Point repo URLs at the actual canonical slug** ([#105](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/105), [#106](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/106)) - `Cargo.toml` had us at `offline-protocol/sdk`, `cliff.toml` at `nickthecook/offline-protocol-sdk`, and CONTRIBUTING/QUICKSTART/iOS-integration each linked to one of those. None of those slugs exist. The actual remote is `Offline-Protocol/offline-protocol-sdk`. Crate metadata, generated changelogs, clone instructions, the SwiftPM dependency URL, and React Native + Python package metadata now point at something a human or `cargo` can fetch. - -- **Add community-health files** ([#116](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/116)) - Contributor Covenant 2.1 code of conduct (enforcement to gm@offlineprotocol.com), YAML issue forms (forcing a version, an affected-crate dropdown mapped to commit scopes, and a platform), a PR template mirroring the actual CI gates (fmt, clippy, test, cargo-deny, regenerated bindings) plus the CLA-signing reminder, and a `CODEOWNERS` pointing at the `@Offline-Protocol/maintainers` team. Caveat worth knowing: a `CODEOWNERS` entry for a team that doesn't exist or lacks write access is *silently* ignored, so the team must be created and granted write access for review enforcement to take effect. - ---- - -## [0.10.0] — 2026-04-13 - -### Breaking Changes - -- **`TransportType` gained `Reticulum` and `Nostr` variants** - `TransportType` is now a five-variant enum (`BLE`, `WiFiDirect`, `Internet`, `Reticulum`, `Nostr`) across Rust, UDL, Swift, Kotlin, and TypeScript. Exhaustive `switch`/`when` statements over `TransportType` must add the two new cases. `ProtocolConfig` gains matching `reticulum_enabled` and `nostr_enabled` fields (default `false`). - -- **`BleTransport::set_mtu` / `mtu` global accessors removed** ([#86](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/86)) - Replaced by per-peer `bleSetPeerMtu(deviceId, maxPayload)` / `bleClearPeerMtu(deviceId)` UniFFI methods. Callers must pass the *header-adjusted* maximum usable payload (Android subtracts the 3-byte ATT overhead from `onMtuChanged`; iOS reads `maximumWriteValueLength(for: .withoutResponse)` directly). The platform managers shipped in this release already do this — only direct UniFFI integrations need updating. There is also a strict ordering invariant: call `bleSetPeerMtu` *before* `blePeerDiscovered` for each peer, otherwise the very first fragment falls back to the 185-byte floor. - -- **`fragment_message` requires a recipient** - `BleTransport::fragment_message(message)` is now `fragment_message(recipient, message)` so per-peer MTU lookup is keyed correctly. Affects the `offline-protocol-bench` crate and any direct callers; the production protocol path is unaffected. - -### Added - -#### New transports - -- **Reticulum mesh transport** ([#75](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/75), [#80](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/80), [#84](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/84)) - A long-range, low-bandwidth mesh transport (LoRa, TCP, UDP, serial, I2P). The Rust side mirrors `InternetTransport` (async confirmation loop with 120 s timeout, reconnect logic, `SharedCallback` for platform notification). DORS profile is reliability-weighted (0.30) and energy-efficient (0.25) at tie-break priority 3. Full UniFFI bridge plus `ReticulumManager` for Android (TCP socket) and iOS (NWConnection) following the `InternetManager` lifecycle. `isAvailable()` is gated behind `configure()` so DORS cannot select an unconfigured transport. The Rust scoring layer was refactored alongside this PR — `TransportScoringProfile` per transport replaces ~10 hardcoded match arms in `calculate_*_score` functions. `bandwidth_max_bps` is `2700` (corrected from an aspirational `4700`). Daemon TCP JSON protocol (`Identify`, `SendMessage`, `MessageReceived`, `StatusUpdate`) is documented in `docs/reticulum.md`. - -- **Nostr relay transport** ([#82](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/82), [#83](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/83)) - A censorship-resistant decentralized relay layer over WebSockets. The Rust side handles BIP-340 Schnorr signing via `k256`; platform managers (`NostrManager` on iOS via `URLSessionWebSocketTask`, on Android via `OkHttp`) just publish pre-signed `["EVENT", {...}]` JSON to N relays simultaneously and subscribe via NIP-01 REQ filters. Confirmation is deferred to the relay's `["OK", event_id, true]` response (rejections trigger `nostrSendFailedWithReason`). Per-message signing-failure retry capped at 3. DORS profile is reliability-weighted (0.35) at tie-break priority 4 — fallback for when the usual transports are censored. Includes a minimal `examples/nostr-example/` app for end-to-end verification. - -- **Adaptive per-peer BLE fragment sizing (MTU negotiation)** ([#86](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/86)) - BLE fragmentation has been nailed to 185 bytes since forever — `set_mtu` was never wired through UniFFI. The Rust side now stores a per-peer map keyed by recipient with clamp `[BLE_MAX_FRAGMENT_SIZE, MAX_REASONABLE_BLE_PAYLOAD]`. Android wires `requestMtu(517)` into the central handshake chain between `onServicesDiscovered` and the device-id read, with a 3 s watchdog so vendor stacks that accept `requestMtu` and never deliver `onMtuChanged` cannot wedge the handshake. iOS reads `maximumWriteValueLength(for: .withoutResponse)` at the moment the device id resolves. Late `onMtuChanged` callbacks that arrive after the watchdog still get forwarded so peers do not stay pinned to the fallback. Two new counters (`ble_fragment_fallback_count`, `ble_undersized_mtu_reports`) surface ordering-invariant regressions and below-floor renegotiations to dashboards. Wire format unchanged. - -#### Bindings & platform - -- **Python desktop bindings for macOS, Linux, and Windows** ([#76](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/76)) - A full Python SDK under `bindings/python/` mirroring the mobile binding architecture: `SecureStorage` via `keyring` (Keychain / Secret Service / Credential Locker), `InternetManager` via `websockets`, `BleManager` via `bleak` (central role), `BlePeripheral` via `bless` (GATT server, macOS/Linux only), and a high-level `ProtocolManager` with 100 ms processing loop and async-context-manager support. Generated `offline_protocol.py` is checked in; CI runs a `python-bindings` job on every PR that regenerates and fails on drift. Three Python deps audited for supply-chain safety; CI gets a `pip-audit` step. 7 new platform jobs (mac arm64+x86_64, linux x86_64+aarch64, windows x86_64). Known limitations are spelled out in `bindings/python/README.md`. - -- **Reticulum and Nostr UniFFI bridges and platform managers** ([#80](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/80), [#83](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/83)) - Wired through `OfflineProtocolModule` (initialize, start, stop, pause, resume, destroy, enableTransport, disableTransport) on iOS and Android with full lifecycle support and DORS failure tracking. iOS uses `NSLock`-backed properties for shared state; Android uses `Atomic*` plus a dedicated `HandlerThread` for I/O so TCP writes never run on the main thread. Both transports follow the same lifecycle: native `initialize()` creates the manager, JS `start()` auto-enables, native `enableTransport` calls `configureAndStart` ([#84](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/84) aligned the lifecycles). - -- **BLE backpressure-aware drain** ([#87](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/87)) - Outbound BLE drain now responds to write completion: a 50 ms delayed re-drain replaces the unconditional immediate re-post when writes stall, and `onCharacteristicWrite` triggers an immediate drain so fragments flow as soon as the remote acknowledges. Drain is gated on `GATT_SUCCESS` so failed writes do not enter a tight retry loop. Demo app gains BLE permissions for all API levels and a deep-link to system Settings for `NEVER_ASK_AGAIN` permission denials. Group conversations track per-message delivery/failure status. - -### Bug Fixes - -- **Resolve BLE peripheral peer identity and broadcast routing** ([#78](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/78)) - Added `resolve_sender_identity()` to map a central UUID to a `user_id` from the first received message, enabling outgoing routing via the BLE peripheral. `send_message("*")` now expands to individual BLE peers (the Rust core treats `"*"` as a literal peer-ID lookup that always fails). Cached the last-known central UUID to prevent sender flipping during fragment reassembly, and clean up resolved `user_id` entries on peer disconnect. - -- **Prevent BLE scanner from stealing peripheral's outgoing fragments** ([#79](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/79)) - Scanner and peripheral share a single `ble_get_next_fragment` queue; on `on_fragments_available`, both drain — but the scanner ran first and popped fragments it could not deliver (no bleak client for peripheral-discovered peers), dropping all Mac→Phone messages. Scanner now drains only when it has connected clients. - -- **Replace reflection with direct typed UniFFI callbacks on Android** ([#81](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/81)) - BLE and WiFi Direct callbacks were wired via `Class.forName` + `Proxy.newProxyInstance` even though the generated bindings already expose `BleTransportCallback` and `WifiDirectTransportCallback` as public typed interfaces. Replaced with direct object expressions, matching iOS and the existing Reticulum path. Compile-time verification that callback interfaces exist; stale bindings still fall back to polling rather than crashing. - -- **Drain session state on BLE status change and WiFi Direct stop** ([#88](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/88)) - `BleTransport::on_status_changed()` was a bare status setter, despite being the *primary* path BLE goes offline in the React Native bindings (user toggles Bluetooth off → `ble_status_changed(false)`). Stale peers, MTUs, fragment buffers, and queues survived. WiFi Direct had the same hole in both `on_status_changed()` and `stop()`. Both transports now drain per-session state when transitioning away from `Available`. Monotonic lifetime counters are intentionally preserved. - -### Refactoring - -- **Android BLE facade split** ([#85](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/85)) - The 3300-line `BleManager.kt` is decomposed into focused classes under `bindings/react-native/android/.../ble/`: `BleTransportFacade` (entry point), `PeripheralGattServer` (GATT server with CCCD descriptor handling and per-central long-read snapshots), `LeAdvertiser` (advertising lifecycle with cooldown/jitter), `CentralGattClient` (per-peer central-role state machine), `OutboundFragmentQueue` and `InboundFragmentBuffer` (main-thread-enforced FIFO buffers with whole-queue drop on overflow — half-message fragments would reassemble into garbage at the receiver). The PR also closes long-standing correctness gaps in BLE: missing CCCD (0x2902) descriptor on the message characteristic (centrals could not subscribe at all), missing `onDescriptorWriteRequest` handler (subscribe attempts silently dropped), missing CCCD write on the central side (notify stream was silent in both directions), `onCharacteristicReadRequest` ignoring the read offset (long reads returned the full value on every call, garbage on iOS↔Android with default 23-byte ATT MTU), binder-thread reads of `characteristic.value` captured by reference (framework reuses the buffer — main-thread handler saw whatever the BLE stack last wrote). All UniFFI calls now run on the main handler. `provideIdentityBytes` is a pure volatile read; identity refresh is scheduled with 500 ms → 10 s exponential backoff capped at 30 attempts. `assertMainThread` runtime guards replace load-bearing comments. - -### Documentation - -- **Reticulum integration guide** ([#75](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/75)) - New `docs/reticulum.md` documents architecture, integration strategies (embedded Python, the emerging `reticulum-rs` crate, HDLC IPC, TCP gateway via `TCPClientInterface`), DORS scoring, the LoRa throughput reference table, daemon setup, RNode hardware, and troubleshooting. Reticulum also added to all 15 existing docs that enumerate transports, scoring weights, config parameters, or platform availability. - ---- - -## [0.9.4] — 2026-03-27 - -### Bug Fixes - -- **Strict nullable returns for Kotlin 2.x compat** ([#74](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/74)) - Kotlin 2.x stopped being lenient about Java nullable annotations: every `JSONObject.optString()` and `ReadableArray.getString()` now returns strict `String?`. Introduced `safeOptString` / `optNullableString` extensions in `JsonExtensions.kt` and migrated ~58 call sites across `OfflineProtocolModule.kt`, `InternetManager.kt`, and `BleManager.kt`. Read-receipt IDs filter null/empty entries via `mapNotNull` instead of coercing to empty strings (which the Rust side silently accepted). - ---- - -## [0.9.3] — 2026-03-26 - -### Bug Fixes - -- **Allow session confirmation when welcome is in `SendAttempted` state** ([#73](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/73)) - `can_confirm_from_source()` only accepted `WelcomeDeliveryState::Sent`, but the welcome lifecycle can stay at `SendAttempted` (DORS falling back from BLE to Internet, or Internet transport awaiting platform confirmation). Encrypted messages silently queued and never flushed. The gate now opens at `SendAttempted`, and `on_transport_send_confirmed()` issues an immediate confirmation probe so Internet sessions don't wait up to 7 s for the next `process()` tick. - ---- - -## [0.9.2] — 2026-03-24 - -### Added - -- **Cancel connection requests** ([#70](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/70)) - Apps can cancel an in-flight connection request before the peer responds. - -### Chores - -- **Stop shipping the Android build cache in the npm package** - Generated Android build artifacts (`.gradle` / build-cache output) were being bundled into the published npm tarball; they are now excluded, shrinking the package. - ---- - -## [0.9.1] — 2026-03-20 - -### Bug Fixes - -- **Add missing `RCT_EXTERN_METHOD` declarations for iOS bridge** ([#69](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/69)) - Seven `@objc func` implementations in `OfflineProtocolModule.swift` had no corresponding `RCT_EXTERN_METHOD` macros in the `.m` bridge file, which meant React Native on iOS could not call them at all (Android worked fine via `@ReactMethod`). The missing methods: `sendPresenceUpdate`, `sendTypingIndicator`, `sendReadReceipt`, `getIdentityPublicKey`, `deriveUserIdFromPublicKey`, `signData`, and `verifySignature` — the entire presence/typing block and all identity crypto methods. A full audit of all 120 methods confirmed these were the only ones missing. - -### CI/CD - -- **Parallelize release and CI workflows** ([#68](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/68)) - Split the Android release build into a matrix of 4 parallel jobs (one per ABI), cutting release time significantly. Cached the `uniffi-bindgen` binary and switched to a pre-built `git-cliff` binary. Moved the release job to `ubuntu-latest` (it only needs Node and git-cliff, not a macOS runner). Split CI check into parallel fmt/clippy/test jobs so PRs get feedback in ~3 min instead of ~6 min. Added concurrency groups to cancel stale PR runs, restricted coverage to main-only pushes, added `workflow_dispatch` with a `dry_run` input for manual pipeline testing, and added artifact verification before publishing. Fixed a script injection vulnerability in version extraction by moving input interpolation into an env binding. - -### Documentation - -- **Wiring guide for Rust-to-React-Native method flow** - Added a step-by-step guide (`bindings/react-native/WIRING_GUIDE.md`) covering the six layers from Rust to TypeScript, common mistakes, parameter type mappings, and a smoke test snippet to prevent missing iOS bridge declarations. - ---- - -## [0.9.0] — 2026-03-20 - -### Breaking Changes - -#### Low-level MLS group API removed from UniFFI bindings - -The following methods exposed raw MLS group operations that bypassed role checks, fan-out, and mesh routing. They have been removed from the UniFFI surface (Swift, Kotlin, React Native). Use the high-level mesh group API instead. - -| Removed method | Replacement | -|---|---| -| `mlsCreateGroup(groupName)` | `meshCreateGroup(groupName)` | -| `mlsAddGroupMember(groupId, keyPackage)` | `meshInviteToGroup(groupId, inviteeUserId)` | -| `mlsRemoveGroupMember(groupId, memberId)` | `meshRemoveFromGroup(groupId, memberId)` | -| `mlsLeaveGroup(groupId)` | `meshLeaveGroup(groupId)` | -| `mlsEncryptForGroup(groupId, plaintext)` | `meshSendGroupMessage(groupId, content)` | -| `mlsDecryptFromGroup(encrypted)` | Handled automatically by the protocol engine | -| `mlsJoinGroup(welcome)` | Handled automatically via Welcome processing | -| `mlsListGroups()` | `meshListGroups()` | -| `mlsGetGroupInfo(groupId)` | `meshGetGroupInfo(groupId)` | - -#### Transport stub methods removed - -`addInternetTransport(serverUrl, port)` and `addWifiDirectTransport()` were no-op stubs that always returned errors. They have been removed from UniFFI, UDL, and all platform bindings (JNI, Kotlin, Swift, TypeScript). - -#### `ProtocolState` enum variants removed - -`Starting` and `Stopping` were never set by the engine and have been removed. The enum is now `Stopped | Running | Paused`. If you have exhaustive `switch`/`when` statements over `ProtocolState`, remove the `Starting` and `Stopping` cases. - -#### Admin-only group operations (behavioral change) - -`meshInviteToGroup()`, `meshRemoveFromGroup()`, `meshSetMemberRole()`, and `meshRenameGroup()` now enforce admin-only access. If a non-admin calls these methods, they will throw with `Error::NotGroupAdmin`. The group creator is automatically assigned the `Admin` role. If your app previously allowed any member to invite/remove, you must either promote them to admin first or adjust your UI to reflect the new permission model. - -#### New group role management APIs - -Three new methods have been added to the high-level mesh group API: - -- **`meshSetMemberRole(groupId, userId, role)`** — Change a member's role (admin only). `role` must be `"admin"` or `"member"`. -- **`meshGetMemberRole(groupId, userId)`** — Get a member's current role. -- **`meshGetGroupRoles(groupId)`** — Get all member roles as a `Record`. - -A new event **`group_role_changed`** is emitted when a role changes: - -```typescript -protocol.on('group_role_changed', (event) => { - console.log(`${event.user_id} is now ${event.new_role} (changed by ${event.changed_by})`); -}); -``` - -If you use exhaustive `switch` statements on `ProtocolEvent['type']`, add a case for `'group_role_changed'`. - -#### Rust-level events added - -The following events are emitted at the Rust/UniFFI layer. If you are consuming the SDK directly through UniFFI (Swift/Kotlin), you should handle these: - -- **`GroupRoleChanged`** — A member's role was changed in a group (also bridged to React Native as `group_role_changed`). -- **`GroupRenamed`** — A group was renamed, includes `group_id`, `new_name`, `old_name`, and `renamed_by`. - -### Features - -- **Group rename API** - `renameGroup(groupId, newName)` lets admins rename a group and broadcasts the change to all members via a `__GRP_RENAME__` internal message. A `GroupRenamed` event is emitted on all peers with `group_id`, `new_name`, `old_name`, and `renamed_by`. Available via UniFFI (Swift/Kotlin) and React Native (`meshRenameGroup`). - -- **`getGroupInfo` on the high-level API** - Replaces the removed low-level `mlsGetGroupInfo`. Returns group metadata including members, epoch, and timestamps. Available via UniFFI as `getGroupInfo(groupId)` and React Native as `meshGetGroupInfo(groupId)`. - -- **Dead code and security bypass cleanup** ([#67](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/67)) - Removed unused struct fields (`GroupManager::user_id`, `SessionManager::storage`, `InternetState::server_url`, UniFFI `OfflineProtocol::user_id`), all annotated with `#[allow(dead_code)]`. Simplified `GroupManager::new` signature (no longer takes a `user_id` parameter). Regenerated all UniFFI bindings (Kotlin, Swift, C header, JNI, TypeScript) to reflect the consolidated API surface. - -- **Group role management and security hardening** ([#66](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/66)) - Added app-layer role tracking for MLS groups with a typed `GroupRole` enum (`Admin` / `Member`). The group creator is automatically assigned the `Admin` role. Admins can invite/remove members and change roles; non-admins are rejected with a typed error. Key security improvements: last-admin invariant prevents orphaned groups (demoting or removing the last admin is blocked), deterministic admin election on leader departure using lexicographic fallback, phantom member cleanup on group join, and removed member notification via a plaintext `__GRP_REMOVED__` control message so kicked members can clean up local state immediately. Key packages are automatically replenished after member removal so subsequent invites don't fail. Includes new `meshSetMemberRole()`, `meshGetMemberRole()`, and `meshGetGroupRoles()` APIs with full UniFFI and React Native bindings, a `GroupRoleChanged` event, and 1200+ lines of new tests. - -### Bug Fixes - -- **Reject empty group names** - `create_group` and `rename_group` now validate the group name: whitespace is trimmed and empty strings are rejected with a descriptive error. Previously, an empty name could be broadcast to all group members. - -- **Wire `resetTofuForPeer` through all platform bindings** - The TOFU reset API (`resetTofuForPeer`) is now available in React Native (TypeScript), iOS (Swift native module), and Android (Kotlin native module). Previously it was only callable from Rust/UniFFI. After calling this, the next message from the peer will establish a new trust pin. - -- **Wire `renameGroup` through all platform bindings** - `meshRenameGroup` is now wired through the iOS Swift native module, iOS Objective-C bridge, Android Kotlin native module, and the UniFFI-generated Kotlin/Swift bindings. The React Native TypeScript wrapper and the Rust/UniFFI layer already had this method. - -- **Notify removed members and replenish key packages** - Removed members now receive a plaintext `__GRP_REMOVED__` notification so they can clean up local state immediately instead of silently losing access. After member removal, key packages are automatically replenished so subsequent invites don't fail with a stale key package error. - -- **Fix phantom members on group join** - Fixed an issue where the local member list could include stale members after joining a group via Welcome. Member lists are now reconciled from the MLS group state on join. - -- **Fix deterministic admin election** - Admin election on leader departure now uses a deterministic lexicographic sort, preventing split-brain scenarios where different nodes elect different admins. Election failures are logged rather than silently swallowed. - -- **Close last-admin loopholes** - Prevented several edge cases where a group could become orphaned (no admin): demoting the last admin, removing the last admin, and the last admin leaving are all now blocked with explicit errors. - ---- - -## [0.8.0] — 2026-03-19 - -### Breaking Changes (React Native Bindings) - -If you are building an app with the React Native bindings, the following changes require updates to your code: - -#### New error variants: `UserBlocked` and `LockPoisoned` - -Two new error variants have been added to `ProtocolError`: - -- **`UserBlocked`** — Thrown when attempting to send a message, control signal, or establish a connection with a blocked user. If your app catches `ProtocolError` exhaustively, you must now handle this case. -- **`LockPoisoned`** — Thrown when an internal mutex is in a poisoned state (indicates a prior panic inside the SDK). This replaces the previous behavior where 129 `lock().unwrap()` calls would panic the process. Apps should treat this as a fatal internal error and consider restarting the protocol engine. - -#### New `ProtocolConfig` fields - -Three new fields have been added to `ProtocolConfig`. They have defaults so existing code will compile, but you should review them: - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `max_group_members` | `u32` | `256` | Maximum members allowed in a single MLS group | -| `group_relay_enabled` | `boolean` | `true` | Whether relay broadcasting is used for group fan-out | -| `require_transport_identity` | `boolean` | `false` | Enables Ed25519 sender identity binding at the transport layer | - -#### New event types in React Native bindings - -The following event types have been added to the React Native TypeScript types: - -- **`service_discovered`** — A peer advertised a mesh service in response to a discovery query. -- **`service_request_received`** — An incoming service request from another peer. -- **`service_response_received`** — A response to a service request you sent. -- **`presence_updated`** — A peer sent a presence update (Online, Away, Offline). -- **`typing_indicator_received`** — A peer started or stopped typing. -- **`read_receipt_received`** — A peer read one or more of your messages. -- **`message_relayed`** — A message was relayed through this node in the mesh. -- **`message_deferred`** — A message was queued for later delivery because no transport was available. - -#### Rust-level events (not yet bridged to React Native) - -The following events are emitted at the Rust/UniFFI layer but are not yet exposed in the React Native TypeScript types. If you are consuming the SDK directly through UniFFI (Swift/Kotlin), you should handle these: - -- **`UserBlocked`** / **`UserUnblocked`** — A user was blocked or unblocked locally. -- **`MessageDecryptionFailed`** — An incoming message could not be decrypted. -- **`GroupEpochForkDetected`** / **`GroupEpochForkResolved`** — MLS epoch fork lifecycle events. -- **`SecurityWarning`** — A control message failed authentication or replay checks. -- **`TofuReset`** — TOFU trust state was reset for a peer. - -#### `ForwardInfo` added to message events - -`MessageReceivedEvent`, `MessageSentEvent`, and `GroupMessageReceivedEvent` now include an optional `forward_info` field. If your app displays messages, you should check for this field to show forwarding attribution: - -```typescript -interface ForwardInfo { - original_sender: string; - original_message_id: string; - original_timestamp: number; - forward_count: number; -} -``` - -#### Native bridge expansion (iOS & Android) - -The native modules (`OfflineProtocolModule.swift` and `OfflineProtocolModule.kt`) have been significantly expanded. If you have custom native module extensions or overrides, you will need to add implementations for the new methods: `blockUser`, `unblockUser`, `getBlockedUsers`, `isUserBlocked`, `forwardMessage`, `meshForwardMessageToGroup`, `sendPresenceUpdate`, `sendTypingIndicator`, `sendReadReceipt`, `resetTofuForPeer`, and the full `MeshServices` API surface (`registerService`, `unregisterService`, `discoverServices`, `sendServiceRequest`, `respondToServiceRequest`). - -### Features - -- **Service discovery and request/response** ([#45](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/45)) - Added a new `MeshServices` subsystem that enables peer-to-peer service discovery and typed request/response over the mesh network. Services are advertised via gossip broadcast and discovered without a central registry. Includes auto `not_found` responses for unknown services, known_peers tracking independent of MLS encryption state, configurable max-hops gossip limit to control broadcast radius, payload size limits and capacity bounds to prevent resource exhaustion, sender-based response routing for multi-hop meshes, and the new `OutboundMessage` struct replacing raw tuples throughout the send path. Full UniFFI bindings are included for iOS and Android. - -- **Transport-agnostic MLS group messaging** ([#46](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/46)) - Wired MLS group encryption into the protocol engine, enabling encrypted group conversations that work seamlessly across BLE, WiFi Direct, and Internet transports. Messages are encrypted once and fan-out to all group members via DORS-selected transports. Adds configurable `max_group_members` (default 256), relay broadcast optimization for large groups, pending MLS commit buffering with TTL-based expiry, classification of MLS errors into permanent (e.g., bad state) vs retriable (e.g., out-of-order) categories, and extraction of `GroupMeshState` for cleaner state management. Includes 82 new tests in a dedicated test module. - -- **Presence, typing indicators, and read receipts** ([#48](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/48)) - Added protocol-level support for three real-time communication signals: presence updates with a `PresenceStatus` enum (Online, Away, Offline), typing indicators with per-conversation granularity, and read receipts supporting batch message IDs. All three are implemented as lightweight internal control messages routed through DORS, meaning they work across any transport without a relay server. Input validation prevents empty recipient/conversation IDs and excessive message ID lists. Full UniFFI and React Native bindings for mobile with 25 tests covering edge cases. - -- **User blocking with silent message filtering** ([#54](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/54)) - Added a complete user blocking system with a locally-persisted block list. Blocked users are filtered in the receive pipeline (after dedup but before ACK, so blocked senders never learn they are blocked), with guards on all outbound paths including send, control messages, and connection establishment. Blocking persists across restarts via `MlsStorage`. Unblocking a user cleans up stale MLS sessions. Includes a typed `Error::UserBlocked` variant, outbound presence leak prevention (so blocked users don't see your status), file transfer cleanup, a `MAX_BLOCKED_USERS` cap, and full UniFFI/React Native bindings. - -- **User-level message forwarding with attribution** ([#61](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/61)) - Introduced first-class message forwarding as a protocol feature. Forwarded messages carry a `ForwardInfo` struct containing the original sender, original message ID, original timestamp, and a forward count. Both 1:1 (`forward_message()`) and group (`forward_message_to_group()`) forwarding are supported. The pending queue preserves forwarding attribution through retries, relay broadcast handles forwarded group messages, and a `MAX_FORWARD_COUNT=100` cap prevents infinite forwarding chains. Content type and media metadata are preserved through the forwarding path. Full React Native bindings included. - -- **Presence, typing, and read receipts wired to React Native** ([#65](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/65)) - Wired `sendPresenceUpdate`, `sendTypingIndicator`, and `sendReadReceipt` through the React Native TypeScript wrapper, iOS Swift native module, iOS Objective-C bridge, and Android Kotlin native module. Also wired into the demo app with UI controls for all three signals. - -- **Demo app** - Added a simple demo app (`examples/demo-app/`) showcasing all SDK features including messaging, groups, presence (via `sendPresenceUpdate`), typing indicators, read receipts, service discovery, blocking, forwarding, and message relay/deferral tracking. Uses the production-recommended reliability config (10 retries, 10s ACK timeout). - -### Performance - -- **Reduce message latency from invitation to delivery** - Overhauled polling and timing across the stack to dramatically reduce the time from MLS invitation to first decryptable message. Replaced the 750ms × 8 fixed-interval MLS establishment polling with a 100ms exponential backoff helper that resolves faster in the common case. Aligned the Android process tick interval with iOS (500ms → 100ms) to eliminate a platform-specific latency gap. Reduced startup delay from 500ms to 100ms, and presence rebroadcast interval from 60s to 15s for faster peer discovery. Tightened reliability config in the example app to match production expectations. - -### Bug Fixes - -- **Harden mesh group robustness** ([#47](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/47)) - Fixed several issues that caused group messaging to degrade under real-world conditions. Stale relay caches now refresh from MLS membership on each fan-out. Added a leave election fallback with staggered re-election timeouts so groups can recover when the elected leader crashes. Implemented epoch fork detection using Lamport clock comparison and automatic resolution via leader-elected key-update commits. Added a circuit breaker on elections to prevent election storms, tuple-keyed leave elections to handle concurrent leaves, and per-attempt cooldown to prevent rapid-fire retries. - -- **Harden control message authentication and sender verification** ([#49](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/49)) - Comprehensive security hardening of the control message path. Added transport-level sender identity binding so peers can verify who sent each control message. Implemented Ed25519 control message signing with TOFU (Trust On First Use) key pinning — the first time you communicate with a peer, their signing key is recorded, and all future control messages are verified against it. Added protections against internal prefix injection (where a malicious peer crafts payloads that look like control messages), LRU TOFU eviction for bounded memory, replay protection via nonce tracking, length-prefixed binary signing payloads with domain separators, and a `SecurityRejected` variant that suppresses ACKs for rejected messages so attackers don't get delivery confirmation. - -- **Harden TOFU transport prefixes** ([#50](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/50)) - Hardened prefix handling in the TOFU transport layer to prevent prefix confusion attacks. - -- **Harden TOFU storage and validate identity strings** ([#51](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/51)) - Added input validation throughout the identity system. `UserId` and `AppId` constructors now reject storage-hostile characters (path separators, null bytes) and all ASCII control characters to prevent key injection and filesystem traversal. TOFU restore keys are validated before use. TOFU peer restore is capped at `MAX_TOFU_PEERS` with a deterministic secondary sort for consistent truncation behavior. - -- **Wire user blocking to native bridges** ([#55](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/55)) - Wired the Rust-level user blocking API through to the Android and iOS native bridge modules and fixed incorrect field mappings in service discovery event payloads. - -- **Address 14 bugs from full codebase audit** ([#56](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/56)) - Fixed 14 bugs found during a systematic codebase audit: ACK piggyback overflow was silently dropped (messages lost), `FileChunk` had unbounded memory allocation (DoS vector), `finalize_file` skipped SHA256 checksum verification (integrity gap), `LamportClock` deserialization bypassed value clamping (could overflow), UniFFI storage error variant was mismapped (wrong errors surfaced to apps), `RetryEntry` had an Eq/Ord contract violation causing duplicate enqueues, routing table had a stale reverse index (phantom routes), DORS produced NaN scores when `ttl==0`, `MockTransport` used LIFO instead of FIFO ordering (tests didn't match real behavior), UniFFI event callback could deadlock under contention, `received_messages` used O(n) removal (degraded with message volume), `RetryQueueStats` was missing the Critical priority level, and added `#![deny(unsafe_code)]` to the MLS crate. - -- **Production readiness improvements** ([#57](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/57)) - Replaced all 129 `lock().unwrap()` calls across the codebase with poison-recovering lock wrappers, so a panic in one thread no longer takes down the entire SDK. Added a CI pipeline with fmt, clippy, test, cargo-deny license/advisory checking, and code coverage. Added a TOFU reset API for apps that need to clear trust state. Added a 1MB max message size guard at the transport layer to prevent oversized payloads from crashing BLE stacks. Added cargo-deny config and SECURITY.md. Fixed `receive_message()` silently dropping messages when serialization failed (now returns a proper error). - -- **Save BLE fragments on missing peripheral** ([#58](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/58)) - BLE fragments are now saved to a buffer when the peripheral connection is temporarily unavailable, instead of being silently dropped. This fixes a data loss issue where BLE messages were lost during brief connection interruptions. - -- **Enforce FIFO ordering for BLE fragment queues** ([#59](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/59)) - Fixed BLE fragment queues to enforce strict FIFO ordering. Previously, fragments could be delivered out of order, causing message reassembly failures on the receiving side. - -- **Mesh networking fixes** ([#60](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/60)) - A collection of mesh networking improvements: added unicast multi-hop relay forwarding with a `MessageRelayed` event so apps can track relay activity, switched dedup storage to LRU eviction for bounded memory, added composite-score route eviction so stale routes are pruned based on quality rather than age alone, enabled multi-hop service discovery responses with an originator field for correct return routing, reduced epoch fork false positives by tightening detection thresholds, adopted RFC 1982 serial number arithmetic for sequence numbers (handles wrapping correctly at 2^32), and extracted relay logic into a dedicated helper module. - -- **Decouple transport retries from ACK retries** ([#62](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/62)) - Fixed a fundamental reliability issue where messages permanently died after just 3 transport send failures, even though the transport was only temporarily unavailable. The root cause was that the `max_retries` limit was applied at enqueue time rather than being purely a scheduling concern. Now, enqueue is always accepted and the retry queue handles scheduling with exponential backoff. Added `drain_all()` and `flush()` methods so messages are sent immediately when a transport becomes available. Fixed ghost re-sends from un-cleaned retry queue entries, double-sends from concurrent flush paths, and zombie entries that never expired. Returns `Ok` with a `MessageDeferred` event when no transport is available (instead of `Err`). Bumped defaults to 10 retries with 10s ACK timeout for better real-world reliability. - -- **Fix message forwarding** - Fixed a bug where forwarded message JSON was never parseable because the serialization format didn't match the deserialization expectation. - -- **Fix group messaging** ([#63](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/63)) - Six targeted fixes for group messaging: added missing dedup check in the relay group handler (duplicate messages were processed twice), prevented duplicate Welcome messages from overwriting valid MLS state (caused decryption failures for all subsequent messages), stopped raw ciphertext from leaking as `GroupMessageReceived` events on decrypt failure (apps received garbage), added retry logic for commit fan-out (commits to some members were silently lost), fixed `GroupManager` incorrectly defaulting the group display name to the internal group ID, and fixed the demo app not cleaning up local state when the current user is kicked from a group. - -- **Add missing dedup to commit, Welcome, and leave handlers** ([#64](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/64)) - Added deduplication to `handle_group_mls_commit`, `handle_group_mls_welcome`, and `handle_group_mls_leave` — the three group control message handlers that had no dedup at all. Without this, duplicate network deliveries caused false epoch fork detection (from double-applied commits), wasted cryptographic operations (from reprocessing Welcomes), and election timer resets (from duplicate leave messages). - -### Refactoring - -- **Split protocol.rs monolith** ([#52](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/52)) - Split the ~3000-line `protocol.rs` file into focused sub-modules (messaging, groups, security, presence, services, etc.) for better maintainability and faster compilation. No behavioral changes. - -- **Extract pending queue module** ([#53](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/53)) - Extracted the pending message queue into its own module with explicit imports, reducing coupling between the queue logic and the main protocol engine. - -### Documentation -- **Clean up documentation** - Removed duplicate documentation files (including `bindings/react-native/MESH.md`), fixed outdated information across READMEs and inline docs, and added a documentation index for easier navigation. If you had external references to `MESH.md`, note that it has been removed — the relevant information is now covered in inline documentation and the main README. +## Archived releases + +Releases before the current one are archived by minor series. Each file carries +its own release table. + +| Series | Releases | +|--------|----------| +| [0.20.x](docs/changelog/0.20.md) | 0.20.1, 0.20.0 | +| [0.19.x](docs/changelog/0.19.md) | 0.19.0 | +| [0.18.x](docs/changelog/0.18.md) | 0.18.3, 0.18.2, 0.18.1, 0.18.0 | +| [0.17.x](docs/changelog/0.17.md) | 0.17.0 | +| [0.16.x](docs/changelog/0.16.md) | 0.16.6, 0.16.5, 0.16.4, 0.16.3, 0.16.2, 0.16.1, 0.16.0 | +| [0.15.x](docs/changelog/0.15.md) | 0.15.0 | +| [0.14.x](docs/changelog/0.14.md) | 0.14.0 | +| [0.13.x](docs/changelog/0.13.md) | 0.13.1, 0.13.0 | +| [0.12.x](docs/changelog/0.12.md) | 0.12.0 | +| [0.11.x](docs/changelog/0.11.md) | 0.11.1, 0.11.0 | +| [0.10.x](docs/changelog/0.10.md) | 0.10.0 | +| [0.9.x](docs/changelog/0.9.md) | 0.9.4, 0.9.3, 0.9.2, 0.9.1, 0.9.0 | +| [0.8.x](docs/changelog/0.8.md) | 0.8.0 | diff --git a/CLAUDE.md b/CLAUDE.md index 89a63967..87cdc415 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,18 +1,40 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Guidance for Claude Code (claude.ai/code) working in this repository. -## Project Overview +## Project overview -Offline Protocol SDK: an offline-first messaging protocol in Rust with multi-transport switching (BLE, WiFi Direct, Internet), mesh networking, and automatic MLS end-to-end encryption (RFC 9420). Exposed to iOS/Android/React Native via UniFFI bindings. +Offline Protocol SDK: an offline-first messaging protocol in Rust with +multi-transport switching (BLE, Wi-Fi Direct, Reticulum, Nostr, internet relay), +mesh networking, and automatic MLS end-to-end encryption (RFC 9420). Exposed to +iOS, Android, React Native and Python via UniFFI bindings. -## Common Commands +## Where the knowledge lives + +This file is repository instructions. The design knowledge lives in documents +that are versioned, reviewable, and readable by people who are not an agent. +**Read the relevant one before changing behaviour in its area.** + +| If you are touching | Read first | +|---------------------|------------| +| Wire encoding, envelopes, control frames, negotiation | [docs/spec/](docs/spec/README.md) | +| Anything security-relevant | [docs/security/threat-model.md](docs/security/threat-model.md) | +| Acknowledgement, retry, session, group or transport behaviour | [docs/state-machines/](docs/state-machines/README.md) | +| A decision that looks odd or over-engineered | [docs/adr/](docs/adr/README.md) | +| Any binding: Swift, Kotlin, Python, TypeScript | [docs/bridges/](docs/bridges/README.md) | + +The ADR index is the fastest route to "why is this like this". If you are about +to simplify something that looks redundant, check there first: several shapes in +this codebase are correct only as a whole, and the ADR names the failure that +partial versions cause. + +## Common commands ```bash -# Verify loop (lint subsumes typecheck; don't run a separate `cargo build` first — -# it only adds a third artifact set, including the expensive uniffi cdylib link) +# Verify loop (lint subsumes typecheck; don't run a separate `cargo build` first, +# it only adds a third artifact set including the expensive uniffi cdylib link) cargo clippy --workspace -- -D warnings -cargo test --workspace --lib # all unit tests; skips the empty per-crate doctest passes +cargo test --workspace --lib # all unit tests; skips empty per-crate doctest passes # Test cargo test --workspace # full run incl. doctests (what CI runs) @@ -20,29 +42,28 @@ cargo test --package offline-protocol-core # single crate cargo test test_message_creation # single test cargo test -- --nocapture # with stdout -# Build (only when you need the compiled artifacts, e.g. the uniffi cdylib) +# Build (only when you need compiled artifacts, e.g. the uniffi cdylib) cargo build --workspace cargo build --workspace --release # Format (must pass before commits; fmt takes --all, not --workspace) cargo fmt --all -cargo fmt --all -- --check # check only +cargo fmt --all -- --check -# Docs (CI gates this under -D warnings; without the flag broken intra-doc -# links only print and still exit 0, so the plain command passes what CI fails) +# Docs (CI gates this under -D warnings; without the flag broken intra-doc links +# only print and still exit 0, so the plain command passes what CI fails) RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps # Benchmarks (Criterion) cargo bench --package offline-protocol-bench ``` -### UniFFI / Mobile Builds +### UniFFI and mobile builds ```bash -# Regenerate every binding after a UDL change — Swift, Kotlin and Python -# together. The three are one artifact set off one UDL, so there is one -# script; npm run generate:bindings and the python/RN build scripts all -# delegate to it. A partial regeneration fails at runtime, not build time. +# Regenerate EVERY binding after a UDL change: Swift, Kotlin and Python together. +# The three are one artifact set from one UDL, so there is one script. A partial +# regeneration fails at runtime, not at build time. ./scripts/generate-bindings.sh cd bindings/react-native @@ -52,72 +73,108 @@ npm run build:uniffi:android # Android only npm run generate:bindings # wrapper for ../../scripts/generate-bindings.sh ``` -Prerequisites: `cargo install uniffi --version 0.30.0 --features cli --locked` (must match the workspace `uniffi = "0.30"` pin), Android NDK (`ANDROID_NDK_HOME`), Xcode. +Prerequisites: `cargo install uniffi --version 0.30.0 --features cli --locked` +(must match the workspace `uniffi = "0.30"` pin), Android NDK +(`ANDROID_NDK_HOME`), Xcode. + +**`cargo test` proves nothing about the bridges.** Each binding has its own +tests; see [docs/bridges/](docs/bridges/README.md#c9-bridge-behaviour-is-not-covered-by-the-rust-test-suite). ## Architecture -### Dependency Graph (bottom-up) +### Dependency graph (bottom up) ``` -offline-protocol-core ← Foundation: Message, UserId, AppId, TTL, HopCount, timestamps - ↓ -offline-protocol-transport ← Transport trait + BLE/WiFi Direct/Internet impls, TransportMetrics -offline-protocol-reliability ← AckManager, RetryQueue (exp backoff), Deduplicator, AckOptimizer -offline-protocol-mls ← MlsManager, MlsStorage trait, session & group encryption (OpenMLS) -offline-protocol-services ← MeshServices: service registry, discovery (gossip), request/response - ↓ -offline-protocol-router ← DORS transport selector, RelayManager, PathSelector, gossip routing - ↓ -offline-protocol ← Main engine: OfflineProtocol, ProtocolConfig, TransportManager, events - ↓ -offline-protocol-uniffi ← UniFFI bindings for Swift/Kotlin (cdylib + staticlib) -offline-protocol-bench ← Criterion benchmarks +offline-protocol-core Message, UserId, Address, AppId, TTL, HopCount, wire codec + | +offline-protocol-transport Transport trait + BLE/WiFi Direct/Internet impls, metrics +offline-protocol-reliability AckManager, RetryQueue, Deduplicator, AckOptimizer +offline-protocol-mls MlsManager, MlsStorage trait, session & group encryption +offline-protocol-services MeshServices: registry, discovery (gossip), request/response + | +offline-protocol-router DORS transport selector, RelayManager, PathSelector, gossip + | +offline-protocol Engine: OfflineProtocol, ProtocolConfig, TransportManager, events + | +offline-protocol-uniffi UniFFI bindings (cdylib + staticlib) +offline-protocol-bench Criterion benchmarks ``` -### Key Architectural Patterns - -- **`Transport` trait** (`crates/offline-protocol-transport/src/traits.rs`): all transports implement this; uses `as_any()` for safe downcasting. `MockTransport` available for tests. -- **`MlsStorage` trait** (`crates/offline-protocol-mls/src/storage.rs`): platform-agnostic secure storage interface — apps implement this for iOS Keychain, Android Keystore, etc. -- **DORS** (`crates/offline-protocol-router/src/dors.rs`): multi-factor scoring (RSSI, congestion, bandwidth, battery, reliability, capacity) with hysteresis, cooldown, and stability window to prevent transport flapping. -- **Wire format**: `Message` serializes as JSON by default (single chokepoint in `crates/offline-protocol-transport/src/common.rs`), with an additive compact binary codec — wire v1, `crates/offline-protocol-core/src/wire.rs` — selected per peer. Receivers auto-detect via the first byte (`0xF5` = binary, `{` = JSON); senders emit binary only to peers that advertise support (`wire_versions` in the signed key package), gated by `TransportConfig::binary_wire_enabled` (default on). JSON stays the permanent floor and the sole format for persistence and the internet relay. The frozen `WireMessageV1` DTO must never change field order/types — additive changes use its `ext` TLV section (tag registry on `EXT_TAG_B64_TAIL` in `wire.rs`; tag 1 carries base64 content tails as raw bytes); a breaking change bumps the magic byte (`0xF6` = v2) and negotiates. -- **MLS envelope (end-to-end, distinct from the hop-local wire codec)**: `__MLS_ENC__` payloads are legacy JSON or, for recipients advertising `env_versions` in their key package, base64 of `EncryptedMessage::to_bytes` (compact, ~2.7× smaller). Sealed per-recipient in `protocol/send.rs::seal_encrypted_content`, sniffed by the byte after the prefix in `protocol/message_dispatch.rs::parse_encrypted_payload` (`{` = JSON); parsing accepts all historical forms unconditionally. Gated by `EncryptionConfig::compact_envelope_enabled` (default on), independent of `binary_wire_enabled`. Size ground truth: `wire_size_and_fragment_report_for_encrypted_dms` test (run with `--nocapture`). -- **Deferred-ACK atom (queue-path silent-loss fix)**: an encrypted DM or media chunk received *before* the receiver's MLS session/group is established is queued for delayed decryption but is **not** delivery-ACKed and **not** left dedup-marked. The `DecryptResult::SessionNotReady` path returns `InternalMessageResult::Deferred` (media: `ChunkOutcome::Deferred` from `handle_incoming_file_chunk_via`, itself driven by `MediaChunkDecrypt::Deferred`), and the receive loop responds by `unmark_seen` + skip-ACK — so the sender keeps retransmitting and each resend re-enters processing instead of hitting the duplicate re-ACK path. Six interdependent pieces, correct only together (see the `InternalMessageResult::Deferred` and `ChunkOutcome` doc comments in `protocol/types.rs`): (1) `Deferred` outcome; (2) idempotent enqueue by message id (`PendingDecryptionQueue::enqueue` — resends don't stack, TTL measured from first receipt); (3) drain on *any* successful decrypt (`confirm_session_from_successful_decrypt` → `process_pending_decryption`, fixing the both-create **owner** path that never receives a Welcome); (4) re-mark the id when the drain surfaces the message; (5) pending TTL 2 min → 30 min (`DEFAULT_PENDING_TTL_MS`, FFI-mirrored); (6) **ACK on drain**: the arrival transport is recorded on the pending entry (`PendingDecryptMessage::received_via`, plumbed via the `_via` variants — `enqueue_pending_decryption_via`, `handle_incoming_file_chunk_via`, `handle_encrypted_message(.., arrival_transport)`), and the drain sends the deferred delivery ACK directly on it (`process_pending_decryption` → `ack_drained_message`). **ACK-latency semantic (important):** the drain surfaces the message *locally* **and** ACKs it on the recorded transport — so the common window is closed. The ACK still degrades gracefully: if `received_via` is `None` (transport-less enqueue, defensive re-enqueue) or that transport is gone, the ACK falls back to DORS and ultimately to the sender's-next-resend re-ACK path, so a late/absent delivery-ACK during the not-yet-confirmed window is **not** loss (the receiver may already hold the message; a sender that exhausts its retry budget before both the session confirms *and* an ACK lands may still mark the message undeliverable though it was delivered locally — strictly better than the old silent drop, and app teams must not read a missing ACK as non-delivery). `ChunkOutcome::Rejected` (no ACK, id unmarked) is the media mirror of the text `SecurityRejected` path and covers **two** shapes: a plaintext chunk refused by the encryption policy, and — since the #318 parity fix — an encrypted chunk that fails its identity binding. The latter is the media half of the two classes the text path has always answered with silence: the envelope naming another pair's session slot (the `expected_group` pre-check in `decrypt_media_chunk`) and `MlsError::SenderIdentityMismatch` (the MLS credential authenticating someone other than the wire sender). Both are intercepted by `is_media_security_rejection` **before** `SessionStateError` classification, for the same reason the text path intercepts them inline: both classify as `Unknown`, which must keep its terminal drop-and-ACK disposition for `CommitNotAuthorized`. Deliberately **not** gated on `crypto_recovery_enabled` — this is about what the receiver reveals (an ACK confirms to an injector that the target is live and processing their frames), not about recovery, and the text equivalent is unconditional. Note the asymmetry in what is testable end to end: the slot check is reachable by anyone who can re-address a captured frame, but `SenderIdentityMismatch` needs a substituted key package built with raw OpenMLS (every in-crate path is blocked by `verify_welcome_slot` / `ReservedSessionNamespace`), so it is pinned at the predicate seam — exactly as the text path pins its credential check at the MLS layer. An evicted encrypted media chunk surfaces `MessageDecryptionFailed`/`PendingQueueDropped`, but that signal is **advisory** (transfer *stalled*, recoverable on resend) — the terminal media signal remains `FileReceiveFailed`. Coverage: `test_deferred_encrypted_dm_defers_ack_then_recovers_without_loss_or_dup`, `test_deferred_dm_is_acked_on_drain_without_a_resend`, `test_evicted_pending_message_recovers_on_resend_after_session_ready`, `test_pending_queue_drains_on_decrypt_confirmation_owner_path`, `test_deferred_media_chunk_defers_ack_then_recovers`, `test_plaintext_media_chunk_rejected_withholds_ack_and_unmarks`, `test_media_chunk_naming_foreign_slot_withholds_ack_and_unmarks`, `test_media_security_rejection_classifies_both_identity_mismatches`. **Mesh-group path (same atom, group-specific shape)**: the mesh group handler `handle_group_mls_msg_via` (arrival transport plumbed from the `process_internal_message_via` dispatch) returns `InternalMessageResult::Deferred` when a message is buffered because local group state lags (`GroupDecryptOutcome::Retriable` → `buffer_pending_group_message`, `PendingGroupMessage::received_via`), reusing the same receive-loop `Deferred` arm (skip-ACK + `unmark_seen`). `drain_pending_group_messages` sends the deferred ACK on the recorded transport via `ack_drained_group_message` → `send_group_delivery_ack`. Two group-specific differences from the DM path: (a) there are **two** dedup layers — the group-level `message_dedup` stays marked across the whole pending lifetime (replay-amplification defense + authoritative double-delivery guard) while the receive loop unmarks only the transport `deduplicator`, so the drain does **not** re-mark the transport dedup; (b) a duplicate of a *still-pending* message returns `Deferred` (checked via `is_group_message_pending`, before decrypt) rather than re-ACKing, and only a duplicate of an already-delivered id returns `Consumed`. The existing `release_replay_protection` (clears both dedup layers on eviction/TTL-drop) is the un-ACKed sender's recovery path. The **relay** path (`handle_relay_group_message_with_mls`) is deliberately exempt: it sends no delivery ACK and the relay sender is not ACK-gated (`try_relay_broadcast`), so its buffered entries carry `received_via: None` and the drain ACK is a correct no-op. Coverage: `test_deferred_group_msg_defers_ack_then_recovers_without_loss_or_dup`, `test_deferred_group_msg_is_acked_on_drain_without_a_resend`, `test_group_dup_while_pending_defers_not_reacks`, `test_group_dup_after_delivery_reacks_and_not_redelivered`, `test_evicted_pending_group_msg_recovers_on_resend_after_state_ready`. -- **Crypto-failure recovery (1:1 desync heal, distinct from the not-ready defer above)**: an *established* 1:1 MLS session that has forked (the two sides disagree on the MLS epoch) yields a `WrongEpoch`/`NoPastEpochData` decrypt failure. This is classified as the dedicated recoverable `MlsError::SessionDesync` (in `group.rs::process_message`, shared by 1:1 and group decrypt) → `SessionStateError::SessionDesync` → `DecryptResult::SessionDesync`, kept strictly separate from `Decryption`: AEAD/corrupt and ratchet-generation failures stay `Decryption`, because **re-keying** on them would be a re-key-storm vector (coverage: `test_corrupt_ciphertext_is_not_classified_as_session_desync` — note this covers *malformed* input only, which never reaches framing validation; a well-formed frame with a forged epoch **does** classify as `SessionDesync`, see the SECURITY note below). **The classification split gates the re-key, not the ACK.** Both classes withhold the delivery ACK: `SessionStateError::{CryptoFailure,TransportFailure}` under `crypto_recovery_enabled` take `DecryptResult::Failed { retriable: true }` → the same `Deferred` arm, with no enqueue (the failed attempt spent the ratchet generation, so a queued copy could never drain) and **no** `schedule_session_rekey`. Drawing the no-ACK boundary at `SessionDesync` alone was the original bug: Tier 2 re-seals *every* resend, so these failures were already recoverable while the receiver was still ACKing them as delivered. `SessionStateError::Unknown` stays terminal drop-and-ACK — it covers refusals that can never become decryptable (`CommitNotAuthorized`, `SessionIdentityMismatch`), so retries would be pure waste. Media mirrors it (`MediaChunkDecrypt::Deferred` from the same guarded arm), recovering via `MediaResendRequired` since chunks have no Tier 2. Third ACK site: a queued frame that hard-fails on drain returns `Deferred` → no ACK, no re-mark, and the queued copy is **dropped rather than re-enqueued** — the drain *removes* the entry before processing, so a re-enqueue misses `enqueue`'s idempotency check and re-stamps `received_at`, restarting the TTL of a frame that can never decrypt on every drain (and re-reporting an advisory failure each time, even after the sender's re-sealed resend delivered the same id). Nothing is lost by dropping it: the un-ACK already makes the sender's resend the recovery path, and the one case that genuinely wants a queued copy — session-not-ready — re-enqueues itself inside `handle_encrypted_message` before returning `Deferred`. **Fourth class — envelope parse failures (the pre-decrypt sibling, #319)**: a frame that fails *before* any MLS involvement takes the same disposition under the same switch. Text: `parse_encrypted_payload` returning `None` in `handle_encrypted_message`'s `else` tail. Media: a `decode_media_envelope` failure in `handle_incoming_file_chunk_via` — reached only when the `ML` magic still matched, so this is corruption *past* the magic byte. Both were the last drop-and-ACK arms of this family: the same in-transit corruption a few bytes earlier in the encoding rather than the ciphertext, ACKed as delivered while the sender's resend would have parsed and delivered. Both rationales already accepted point the same way — an honest sender's corrupted frame is recoverable by the resend (the ACK is what kills it), and an injector learns less from silence than from an ACK. Like the desync and spent-generation arms these **do not enqueue**: an unparseable frame can never become parseable. Neither arm is reachable from the drain (a queued frame parsed at receipt and parsing is deterministic), so there is no drain re-queue hazard. What stays terminal-and-ACKed is everything *after* a successful decrypt — `Empty`, `NonUtf8Plaintext`, and the media `MediaChunkPlaintext::decode` / `FileChunk::from_bytes` failures — because the generation is spent and Tier 2 would re-seal the same malformed plaintext, so no resend could ever deliver. Consequence for app teams: **`MessageDecryptionFailed` is advisory and fires per failed *attempt***, bounded by the sender's ACK retry budget; the terminal signals stay `MessageFailed` / `FileReceiveFailed`. Coverage: `test_hard_decrypt_failure_{withholds_ack_and_does_not_rekey,recovers_on_resealed_resend,disabled_falls_back_to_drop_and_ack}`, `test_drained_message_that_hard_fails_is_not_acked`, `test_drained_media_chunk_that_hard_fails_is_not_acked`, `test_media_chunk_hard_decrypt_failure_{withholds_ack_and_recovers,emits_decryption_failed_event}`, `test_unparseable_mls_envelope_{withholds_ack_and_recovers_on_resend,disabled_falls_back_to_drop_and_ack}`, `test_undecodable_media_envelope_{withholds_ack_and_recovers,disabled_falls_back_to_drop_and_ack}`, `test_invalid_encrypted_payload_emits_app_error_event_and_is_deferred`. **Tier 1 (honest failure + heal):** on the 1:1 receive path (text: `handle_encrypted_message`; media: `receive.rs`) a desync withholds the delivery ACK and `unmark_seen`s the id (reuses the Deferred atom arm) but does **not** enqueue — the ciphertext is sealed to the dead epoch and can never drain — and triggers `schedule_session_rekey`: tear down our own stale session **and** advertise a `session_reset` key package (the peer drops its stale session, rebuilds from our key package, and Welcomes us back, which we join session-less). Deleting the local session is what makes convergence symmetric for both user-id orderings (the returning Welcome is *joined*, not gated by the greater-id-adopts tiebreaker). Rate-limited to one re-key per peer per `REKEY_INTERVAL_SECS` (30s) via `rekey_due_at`, stamped before send. The floor is **never reset early** — a successful decrypt on the healed session does *not* clear it (a genuine re-fork and a replayed old-epoch frame are indistinguishable at this layer, so clearing on heal would let an attacker interleaving one legit decrypt between replays force ~one teardown per inbound message); it lapses only by the interval elapsing, and Tier 1's un-ACK + sender retries keep delivery honest during the wait. **SECURITY — the trigger is unauthenticated and cannot be made otherwise.** `schedule_session_rekey`'s `peer_id` is the *wire-claimed* sender. `__MLS_ENC__` is a data-plane prefix, deliberately exempt from the Ed25519 signature + sender-address derivation control gate (`DATA_PLANE_PREFIXES`); OpenMLS validates the framing header (group id, then epoch) **before** any AEAD/sender-data/signature check; and a 1:1 slot id is `session::` over two public user ids. So **anyone who can inject a frame** can reach this classification — no key material, no captured ciphertext, no session, no replay (`test_forged_frame_reaches_session_desync_without_any_key_material` hand-builds one). This is inherent to MLS framing, not an OpenMLS defect. `SenderIdentityMismatch` structurally cannot help: the MLS credential it compares against only exists once decrypt *succeeds*. The mitigation is therefore that acting on the trigger is **harmless**, not that it is trusted: (a) `SessionManager::decrypt_message` requires the envelope to name the slot shared with the claimed sender, so one derivable session id can't be aimed at arbitrary peers (and `rekey_due_at`, itself bounded by `MAX_REKEY_TRACKED_PEERS`, can't be grown with attacker-chosen keys); (b) the per-peer rate limit bounds churn; (c) the heal destroys nothing — a `session_reset` **keeps** the receiver's outbound pending queue (it holds plaintext, sealed at flush time against the rebuilt session), and Tier 2 re-seals resends; (d) every re-key emits `SecurityWarningCode::SessionRekeyTriggered`, so a sustained rate (injection, not a real fork) is visible. **Residual:** bounded re-key churn on a pair — delivery delayed, never lost. Closing it needs a signed epoch-corroboration exchange before teardown (a liveness-only probe does not work: a healthy peer answers and we tear down anyway). Coverage of the un-resettable floor: `test_desync_rekey_floor_not_reset_by_successful_decrypt`. **Tier 2 (true no-loss):** the sender keeps per-outbox-entry re-seal provenance (`OutboxReseal`, memory-only via `#[serde(skip)]` — holds plaintext, never persisted) so each resend re-seals against the peer's *current* session (`reseal_for_resend_in_place` at the two resend transmit points; `reseal_resend_content` gated on `confirmed_sessions`, preserves `Message.id` for dedup/ACK). Media has no Tier 2 (chunks are re-encoded, not replayed): it recovers via the descriptor-based `MediaResendRequired` path. Staging (`pending_reseal`) is strictly transient — `take_staged_reseal` always removes, and `remove_outbox_entry` clears it belt-and-braces, so a staged-but-dropped send never strands plaintext. Whole thing gated by `EncryptionConfig::crypto_recovery_enabled` (default on, FFI/RN-mirrored); disabled → legacy drop-and-ACK fall-through. Coverage: `test_desync_dm_withholds_ack_and_triggers_rekey`, `test_desync_rekey_is_rate_limited`, `test_desync_dm_heals_end_to_end_when_detector_id_is_{greater,smaller}`, `test_reseal_on_resend_recovers_after_recipient_rekeys_to_new_epoch`, `test_crypto_recovery_disabled_falls_back_to_drop_and_ack`, `test_forged_frame_reaches_session_desync_without_any_key_material`, `test_forged_frame_naming_a_foreign_slot_is_rejected_before_desync`, `test_session_reset_retains_pending_outbound_and_delivers_after_rebuild`, `test_rekey_tracking_map_is_bounded`, `test_rekey_emits_security_warning`. -- **Sealed rich payload (inside the MLS plaintext)**: rich message extras (reply_context, rich media_metadata incl. key/iv secrets, forward_info) only ever travel as a `__RICH_V1__` + JSON body wrapped around the text *before* encryption, negotiated via `rich_versions` in the key package (`RICH_PAYLOAD_V1`). Sealed in `protocol/send.rs::prepare_outbound_content` (the single chokepoint for fresh sends and pending flushes; `PendingMessage.rich` preserves option-borne provenance through the queue), restored in `protocol/receive.rs::apply_decrypted_content` right after the outer-field strip — sealed body is authoritative, outer copies are wiped. The body also carries a sealed copy of the outer `content_type` hint (additive field; absent → outer stands, `FileChunk` refused on restore like at the send boundary); fresh sends with a non-Text hint seal a hint-only body even without extras. Forwards (`forward_message`) seal their attribution + the original `media_metadata` as extras toward capable recipients — the only way forwarded cloud media keeps its `encryption_key`/`iv` — with the cleartext outer copies kept as the legacy fallback (secrets stripped at the wire chokepoint; sealed restore overwrites them wholesale). Non-capable recipients: extras silently dropped, never cleartext. **Groups**: the same `__RICH_V1__` body seals into the group MLS plaintext (`group_mesh.rs::send_group_message_inner`; hint-only non-Text sends seal too — the group payload has no outer content_type carrier, so an unsealed hint would be lost, not just unprotected; parse shared via `RichPayloadV1::parse_sealed`, applied on all three inbound paths — mesh, buffered drain, relay), but gated on *every* other member being known rich-capable: directly in `peer_rich_payload`, or inviter-attested in `peer_rich_attested` — the Add commit carries `affected_member_rich` (to existing members) and the Welcome a `member_rich` map (to the joiner; entries bounded to the joined MLS roster, admin-gated on the commit like `role`), so members added by someone else stay sealable; attestation chains across successive adds, direct exchange always overrides it, and it feeds *only* the group gate (never DM sealing or envelope selection). A genuinely unknown member still fails the gate closed and extras drop, surfaced via `GroupRichExtrasDropped` (now with `unknown_members`), and the drop path backfills by key-packaging the unknown members once (their auto-exchange reply reopens the gate — the heal for pre-attestation groups). Apps can pre-check via `group_rich_readiness(group_id)`. When the body seals, the hop-visible payload `forward_info` copy is omitted (every member reads the sealed attribution; a payload copy would only expose the original sender to relays). Surfaced via additive `media_metadata`/`content_type` fields on `GroupMessageReceived` (telemetry scrubber redacts the secrets); public surface `send_group_message_with`/`GroupSendOptions` + `forward_message_to_group` (core; not yet exposed over UniFFI beyond the pre-existing forward API). Per-peer capability sets (`env_versions`/`rich_versions`) persist across restarts as `PeerCapabilities` (`peer_capabilities` storage key — separate from the key-package cache, which is deleted on session creation) and are restored in `initialize_mls` before `start()` flushes pending sends; `wire_versions` stays in-memory by design (hop-local, re-exchanged on connect). Gated by `EncryptionConfig::rich_payload_enabled` (default on), independent of the other two kill switches; inbound parsing unconditional (parse failure → raw text + warning). Public surface: `send_message_with`/`SendMessageOptions` (core), `send_message_rich` (UniFFI), rich params on RN `sendMessage`. Boundary validation in `send_message_with`: rejects `ContentType::FileChunk` and rich extras >32 KiB serialized (`MAX_RICH_EXTRAS_BYTES`; capped at the boundary, not seal time, so a flush re-seal can never fail the cap and re-queue forever). -- **Group send paths (capability-gated broadcast with delivery report, per-member fan-out as fallback/opt-out)**: `send_group_message_inner` (`group_mesh.rs`) takes the `__GRP_RELAY_BCAST__` broadcast only when **all four** hold: `GroupConfig::relay_broadcast_enabled` (**default true**) ∧ `relay_synced` ∧ `relay_capabilities` contains `group_delivery_v3` (`RELAY_CAP_GROUP_DELIVERY_V3`; injected by the bridge from the relay `Authenticated` answer via `internet_relay_capabilities` **before** `internet_status_changed(true)` so the false→true flush sees it; cleared on internet drop; bounded 64×128B) ∧ a live `is_internet_available()` re-check. Otherwise it fans the MLS ciphertext out as one `__GRP_MLS_MSG__` `SendMessage` per member (`fanout_group_frames_per_member`), which inherits the whole DM ladder (outbox, ACK/retry, relay write-ack, offline push with ciphertext, park/probe/flush, receiver deferred-ACK). **What makes the broadcast default-safe is the delivery-report contract**: `try_relay_broadcast` mints a *logical* message id, carries it in the payload (the bridge stamps it onto the relay `SendGroupMessage`; ≤64B `[A-Za-z0-9._-]` verbatim-or-mint on the relay), and arms `RelayBroadcastPending` keyed by it. **Why v3 and not v2** (the token bumped when addressing landed): v3 is v2's settled-report contract **plus an address-aware relay group path** — members named in the identifier space the roster was registered in (this SDK registers the MLS roster, i.e. `off1…` addresses), fan-out/push resolving those names relay-side, and group sender attribution carrying the sender's declared address. A v2 relay must fail the gate closed, because its username-keyed path and the address identity cannot compose: it cannot route to address-registered members, its report names members in a namespace that never intersects the MLS roster (so the set difference re-issues to *everyone* after every broadcast), and any copy it does deliver arrives attributed by username → fails the SEC-M1 wire-sender/credential match **after** the decrypt already spent the ciphertext's ratchet generation (OpenMLS persists message secrets through the storage provider before the identity check; the skipped `save_group` does not undo it). That burn is unrecoverable client-side, which is why the *gate* — not the receiver-side unmark below — is the actual fix. The relay's **settled** `GroupMessageSent` report (arrives up to ~45s+ later — relay fan-out deadline — hence the 60s `RELAY_BROADCAST_REPORT_TIMEOUT_SECS`; fields `delivered`/`pushed` name lists + `missed` `{username, reason}` with reasons treated as opaque strings) comes back through a **dedicated** UniFFI entry `internet_group_report_received` (not message-plane injection — unforgeable via the notification injector; bridges also still pass the raw frame through as `internet_server_message`). `handle_relay_group_delivery_report` settles the tracker and **re-sends per-member copies to `roster ∖ delivered ∖ pushed ∖ self`** — covering both `missed` members and members the relay never knew (its registered roster can be a strict subset of the MLS roster) — then emits `group_message_delivery_report {delivered, pushed, missed_reissued}`. Lost report: `process_relay_broadcast_report_timeouts` (process tick) re-broadcasts under the **same logical id** (relay echoes it → receiver dedup + push dedup hold across attempts) up to `RELAY_BROADCAST_MAX_ATTEMPTS`=3 total sends while the gate still holds, then downgrades to full per-member fan-out; internet drop downgrades all pending broadcasts immediately (`check_relay_group_sync` 1→0). **Receiver side**: re-issued copies carry `GroupMlsMessagePayload::message_id` (the logical id). `handle_group_mls_msg_via` checks it in the *duplicate* branch (absorbs cross-path dups without an MLS decrypt — a spent-generation decrypt would misclassify `Retriable` and buffer noise) but only ever *marks* it after successful decrypt (a failed decrypt must not poison the id — insider-equivalent surface only), and emits it as the app-facing message id so all members see one id per logical message; `PendingGroupMessage.logical_id` carries it through the deferred buffer (drain emits logical id, ACKs the envelope id). **Double delivery on the drain is prevented by MLS, not bookkeeping**: a decrypt consumes the ratchet generation, so a copy beaten to it fails (`Decryption` → `Retriable`) and reaching the drain's `Plaintext` branch *proves* first delivery — any "already delivered elsewhere?" check there is unreachable when true and a false positive otherwise (the relay path marks its id at *arrival*, pre-decrypt, and `is_group_message_pending` is blind to the batch the drain `mem::take`s, so such a check suppressed the only decryptable copy = silent loss; fixed, pinned by `test_both_buffered_copies_of_one_logical_message_deliver_exactly_once`). The drain's `delivered_here` set exists only to drop a *sibling* buffered copy without burning a doomed decrypt — and, critically, without letting its TTL expiry `release_replay_protection` an id that WAS delivered. `RelayBroadcastPending` is bounded (`MAX_RELAY_BROADCAST_PENDING`=64, oldest downgraded to per-member on overflow). **Known gap**: the tracker is memory-only, so a process kill inside the report window loses the backstop (documented in `GroupConfig::relay_broadcast_enabled` and `docs/message-delivery.md`; persisting it is future work). The v1 fire-and-forget broadcast (no presence check, no push, no persistence, "sent" before delivery known; missed messages undetectable since MLS app messages don't advance the epoch) is **never** taken — old relays fail the capability gate closed. `GroupConfig::relay_enabled` is separate and stays **on** — it gates *registration*, which invite links resolve against. RN: the `group` config section (`group.{maxGroupMembers,relayEnabled,relayBroadcastEnabled}`, nested-wins-over-flat like `encryption`) is plumbed through both bridges since this change. Per-member costs O(N) frames, which does **not** risk the relay's rate limiter at any size: the bridge meters every relay-bound frame through `RelayRateLimiter` (28 cap, 9/s) — tighter than the relay's 30/10 — and *defers* rather than drops on token exhaustion, so client spend `28 + 9t` stays under the server's `30 + 10t` and the fan-out self-paces. Core enqueues all N at once; the bridge drains at ~9/s after a ~28 burst. The real large-group cost is drain latency: frame N hits the wire at ~`(N - 28) / 9` s, and because the ACK timer starts at local *enqueue* (`on_transport_send_confirmed` advances only the welcome lifecycle, never re-stamps it), past ~118 members the fan-out's tail exceeds the 10s ACK timeout and is retransmitted before it was ever written — duplicate frames absorbed by dedup, not loss. Shared traffic on the same bucket lowers that threshold. Coverage: `test_broadcast_report_{all_reached_settles_without_reissue,reissues_missed_and_unnamed_members,unknown_or_mismatched_is_ignored}`, `test_lost_report_rebroadcasts_bounded_then_downgrades_per_member`, `test_internet_drop_downgrades_pending_broadcasts_and_clears_capabilities`, `test_relay_broadcast_{without_capability_uses_per_member_fanout,opt_out_forces_per_member_fanout}`, `test_relay_capabilities_are_bounded`, `test_grp_mls_msg_logical_id_emit_and_cross_path_dedup`, `test_grp_mls_msg_failed_decrypt_does_not_poison_logical_id`, `test_v2_only_capability_keeps_per_member_fanout`, `test_username_space_report_reissues_full_roster` (the suite's first mixed-namespace report fixture — every earlier one wrapped both roster and report in `id(...)`, which is why this bug class was invisible to it). **Relay-path marking discipline (the mirror invariant):** `handle_relay_group_message_with_mls` cannot follow the mesh handler's mark-after-decrypt rule, because the relay-supplied id *is* the logical id and marking it pre-decrypt is the replay-amplification defense (one MLS crypto op per id). It pays for that with the opposite obligation: **every arm that ends with the frame neither delivered, nor buffered, nor consumed by MLS must unmark before returning** — `SecurityRejected`, `Failed`, and the plaintext-spoof drop (whose id is attacker-chosen wire input). Otherwise a *rejected* copy reads as *delivered* to the duplicate check, and the per-member re-issue that is the broadcast's own delivery safety net is absorbed as a cross-path duplicate and re-ACKed: delivered nowhere, sender told delivered. The obligation extends to `drain_pending_group_messages`, and that half is not optional: a relay copy can outrun its Welcome, so it buffers *before* any decrypt and its mis-attribution is judged on the drain instead of at arrival — an ordering a hostile relay picks for free — so the drain's `SecurityRejected | NotMlsCiphertext | Failed` arm calls `release_replay_protection` exactly as the TTL arm beside it does. `PolicyRejected` deliberately does **not** release on either path (permanent refusal; a later copy could only waste work). Unmarking cannot resurrect the burned generation — the honest recovered outcome is `Deferred` (buffered, un-ACKed, custody with the sender), never `Consumed`. Coverage: `test_relay_copy_sender_mismatch_rejected_without_poisoning_logical_id`, `test_relay_copy_rejected_on_drain_does_not_poison_logical_id`, `test_relay_plaintext_spoof_drop_does_not_poison_logical_id`. -- **Relay hint frames (`__GRP_RELAY_REG__`, `__GRP_RELAY_BCAST__`)**: self-addressed frames the *bridge* (not the relay) recognizes via `internet_control_op` and **replaces** with relay-native frames. Because the frame is replaced, no ACK can ever return for it — so both go out via `send_relay_hint_message` (`protocol/send.rs`), which is `requires_ack = false` (no outbox entry, no pending ACK, no retry entry) and **pinned to `TransportType::Internet`**, propagating transport errors to the caller instead of deferring. Both properties are load-bearing: on the ACK ladder an un-ACKable frame is retransmitted 10× over ~800s (each resend a full extra relay fan-out under a fresh relay-minted id that receiver dedup misses), ending in a `MessageFailed` for an id the app never saw plus a DORS-degrading delivery failure; and under ordinary DORS routing (which demotes Internet by `INTERNET_FALLBACK_DEMOTION`) a self-addressed frame is *swallowed* by Wi-Fi Direct or Reticulum, which enqueue unconditionally and return `Ok` — making `try_relay_broadcast` report success and skip the per-member fallback, delivering to nobody. Retry policy lives at the application layer instead: `RelayRegisterPending` (30s × 3) for registration, `RelayBroadcastPending` (60s × 3, report-settled, then per-member downgrade) for broadcast. Coverage: `test_relay_broadcast_frame_is_unacked_and_not_retried`, `test_relay_registration_frame_is_unacked_and_not_retried`, `test_relay_hint_frames_pin_to_internet_not_mesh`, `test_relay_broadcast_falls_back_when_internet_unavailable`, `test_relay_broadcast_without_capability_uses_per_member_fanout`. -- **Group membership authorization (report-by-default, enforce-by-opt-in)**: MLS Add/Remove commits are applied by every receiving member with no admin check *by design* — rejecting a commit means declining the merge, which forks you permanently from everyone who accepted it, and the admin overlay replicates best-effort (roles ride unreconciled mesh notifications; joiners get a point-in-time snapshot), so a merely-lagging member would partition itself with no attacker involved. Unauthorized changes are therefore **reported**: `judge_membership_change` (`group_mesh.rs`) emits `GroupUnauthorizedMembershipChange` (rate-limited per `(group, committer)`, `UNAUTHORIZED_REPORT_SUPPRESS_SECS`=300) and the roster events carry a **tri-state `authorized: Option`** (`Some(true)` = checked, `Some(false)` = judged unauthorized, `None` = *not evaluated* — own Welcome join, relay reconciliation; never emit `Some(true)` from a path that ran no check). The delta is derived **only when both roster reads succeed** (pre-commit `get_group_info` + post-merge `refresh_group_members`, both `Option`); either failing skips all delta-derived work — a silent empty default would fabricate a full-roster delta and a report naming an innocent committer. `members_before` must stay **MLS-derived, never the members cache** (relay reconciliation splices phantoms in). Stage 2 replicates the admin set: `GroupMlsWelcomePayload::created_by` carries the inviter's creator of record and `MlsManager::set_group_creator` adopts it **first-write-wins**, which is what makes `check_is_admin`'s creator fallback (dead code for joiners before) reachable when a role snapshot arrives incomplete. Stage 3 adds **opt-in rejection**: `GroupConfig::enforce_admin_commits` (default **false**, FFI/RN-mirrored as the flat `group_enforce_admin_commits`) → `GroupManager::authorize_membership_commit`, which runs **pre-merge inside `GroupManager::decrypt_message`'s `StagedCommitMessage` branch** — the single chokepoint, because `merge_staged_commit` is also reachable from `decrypt_group_application`'s `Ok(None)`/`NonApplication` branch (a commit reframed as a `__GRP_MLS_MSG__`) and from `MlsManager::decrypt`'s prefix dispatch (an `__MLS_ENC__` envelope naming a `group:` id); gating only the commit handler leaves both as bypasses (pinned by `test_app_channel_commit_is_also_policy_gated`). **Fail-open rule (load-bearing):** no membership proposals (KeyUpdate), a `session:` id, unreadable/absent metadata, or `!has_any_admin()` → merge; reject only when the admin set is *known non-empty* and a principal (committer ∪ every proposal `sender()`) is positively not in it. `created_by` is deliberately **not** consulted here — one unauthenticated claim is too thin to fork over. Rejection surfaces as `MlsError::CommitNotAuthorized` → `CommitOutcome::Rejected` / `GroupDecryptOutcome::PolicyRejected` (permanent, never buffered) and reports through the shared `report_unauthorized_membership_change` with the additive `enforced: true`. Enforcement cannot detect *divergent* admin views, only absent ones — hence opt-in, never part of a fleet. Coverage: `test_enforcement_fails_open_when_admin_set_unknown` (anti-fork), `test_enforcement_disabled_by_default_applies_commit`, `test_enforced_non_admin_{remove,add}_commit_is_rejected_without_merge`, `test_enforced_keyupdate_commit_applies`, `test_unauthorized_commit_still_applies_membership_and_does_not_fork`, `test_failed_roster_read_skips_delta_and_judgment_but_merges_commit`. -- **Leaf identity binding (the Authentication Service, RFC 9420 §5.3.1/§7.3)**: every leaf entering local group state must carry the address its **own signature key** derives to — `verify_leaf_binding` (`group.rs`), which delegates to `MlsManager::derive_address` so there stays exactly one derivation. This is the check RFC 9420 assigns to the application ("the credential's presented identifiers are correctly associated with the `signature_key` field in the member's LeafNode"); OpenMLS does not do it (its own external-commit validation says "This MUST be checked by the application", and `StagedCommit::credentials_to_verify` exists to hand the application the credentials it must judge). **SEC-M1 depends on it**: `decrypt_message` compares the wire sender to the MLS credential, and an MLS *basic* credential is a bare self-asserted string, so without the binding that comparison proves only that the forger typed the name they wanted — which on the ungated `__GROUP_MSG__` data-plane path costs no signature from anyone. Three seams, and they are not redundant: (1) **Welcome** — `verify_staged_welcome_tree` walks the whole ratchet tree in `join_group`/`join_group_replacing`, before `into_group` and beside `verify_staged_group_id`, because the inviter chooses that tree wholesale (all-or-nothing: joining minus the bad leaves would leave us at an epoch computed over the full tree, decrypting nothing); (2) **commit** — `verify_staged_commit_leaves` in the `StagedCommitMessage` arm, pre-merge and **before** `authorize_membership_commit`, whose `added` list and `GroupUnauthorizedMembershipChange` report are built from the credentials this validates; (3) **use** — `verify_sender_leaf` resolves `processed.sender()` → `member_at(idx)` at the SEC-M1 seam, O(1), which is the only one that covers a leaf the entry gates never saw (a direct write to the install-scoped provider store), the same import-time + use-time pairing `get_contact_key_package` documents. `get_group_info` skips unbound leaves — the roster is not cosmetic, it addresses the per-member fan-out, feeds the rich-payload gate, and supplies the address-ordered tiebreakers — and **counts** them on `GroupInfo::unproven_members` (a count, never the claimed identities: handing the attacker-chosen string back through a second field returns it by another door). `refresh_group_members` turns a non-zero count into the fourth emission site; skipping silently was the gap, since this is the *only* seam at which a leaf already seated in local state surfaces and a `warn!` reaches no app. `authorize_membership_commit`'s own roster read applies the same filter, so the two roster reads in the crate cannot disagree about whether an unproven leaf is visible — unreachable today (both its uses need an unbound leaf in a tree whose committer agrees with ours) and filtered anyway, exactly like the Update-proposal loop below. **The commit walk follows the four sources in `credentials_to_verify`, not the OpenMLS book's shorter "add & update proposals"**: update-path leaf, Update proposals, Add proposals, and `GroupContextExtensions`/`ExternalSenders` (refused via `MlsError::UnsupportedSender`, as are all non-`Member` senders — this SDK issues no external commits or proposals). Dropping the update path would leave the *cheapest* attack open: a member renames their own leaf to a peer's address, no new leaf and no invite needed. **Unconditional, unlike `enforce_admin_commits`, and the difference is load-bearing**: the admin verdict depends on the best-effort-replicated admin overlay, so honest members can disagree and partition each other, whereas this verdict is computed from the commit's own bytes, so every honest member reaches the same answer and a refusal forks the *attacker* off a group that stays consistent. Non-address credentials are **refused, never skipped** — "nothing to derive, pass" is the bypass, the same one `verify_address_binding` and `verify_sender_derivation` each name. Safe for honest peers because nothing in this SDK rotates a leaf signature key or credential independently of the identity key: `update_keys` uses `self_update(.., LeafNodeParameters::default())` and `self_update_with_new_signer` is never called (pinned by `test_key_rotation_and_ordinary_traffic_still_pass_the_binding`). Refusals surface as `SecurityWarningCode::GroupLeafIdentityUnproven` (`peer_id` = the peer that *delivered* the forgery, which is signature-proved, not the impersonated address; the `reason` text is deliberately **identifier-free**, because the telemetry scrubber hashes only `peer_id` and ships `reason` verbatim, and the address at stake is the impersonated third party's — the same substitution is required on the sibling `secure_session_failed`, whose `reason` is equally unscrubbed and which fires on the same two session arms; sanitizing one event and interpolating `LeafAddressMismatch` into the other leaks *both* addresses, the impersonated one and the forger's, pinned by `test_forged_session_welcome_is_reported_on_the_adopt_path`). **That substitution is now the rule rather than a per-arm exception (#346)**: scoping it to the identity refusals left the same leak on every *other* arm of the same two session-Welcome sites, and the premise it rested on — "every other join failure is a fault rather than an accusation and names nobody" — was false. `WelcomeIdentityMismatch` (raised by `verify_welcome_slot` *before* the Welcome blob is deserialized, so reachable with no key material, no valid Welcome and no prior contact) and `WelcomeGroupIdMismatch` each render a `session::` slot — two addresses, one of them possibly a third party's — plus a string the sender chose, the embedded id being `from_utf8_lossy` over raw `GroupContext` bytes and so bounded by neither the charset validation nor `GroupId::MAX_LEN` the wire field passes through. The fix inverts the default: `MlsError::privacy_safe_reason` (`error.rs`) maps every variant to a fixed classified `&'static str`, and both properties are load-bearing — the return type makes interpolation unrepresentable, and the match is **exhaustive in the defining crate** (legal despite `#[non_exhaustive]`) so a newly added variant fails to compile *there*, forcing the privacy decision where variants are written; **never add a `_ =>` arm**, which restores the per-site opt-in this replaced. The third feeder of the same event, the persist-confirmation `Err` arm, takes a fixed string for the same reason (its storage error interpolates the record's id). Coverage: `identifier_bearing_variants_classify_without_their_payload` (mls crate, with a premise guard asserting each variant really does render its payload — otherwise the assertion is vacuous), `test_welcome_naming_a_foreign_session_slot_is_refused_without_naming_it`, `test_welcome_embedding_a_foreign_session_slot_is_refused_without_naming_it`. Note `ReservedSessionNamespace` is **not** reachable from these session arms (it is raised only in `join_group`, the group-Welcome path), and the wider audit of verbatim-shipped `reason`/`detail` sinks found further leaks outside this seam — chiefly the unsigned relay-answer-fed `GroupError.reason` (#349) and the control-gate/relay-binding `SecurityWarning.reason` arms (#350) — since fixed, along with the transport/relay send-failure text on `MessageDeferred`/`MessageUndeliverable`/`ConnectionRequestUndeliverable`/`WelcomeSendFailed` (#351). The producer rule those settled on is written down in `telemetry/scrub_event.rs`: **an event field never carries text chosen by a remote party, nor a rendered error that interpolates one** — classify to a fixed local vocabulary, return `&'static str` (and push that type into the `Event` constructor, so a producer cannot hand it a rendering), keep the wording in a device log, and when the dropped prose carried structure add it back as a typed field the scrubber can hash. The classifiers are `MlsError::privacy_safe_reason`, `GroupErrorPayload::classify_reason`, and `protocol::types::{send_failure_token, classify_transport_send_error}`. On the `__GRP_MLS_MSG__` application path a refusal is `GroupDecryptOutcome::SecurityRejected` — no ACK, id unmarked, never buffered. The two *control* frames differ and deliberately so: `__GRP_MLS_WELCOME__` and `__GRP_MLS_COMMIT__` return `Consumed`, so a declined invite and a refused commit **are** delivery-ACKed and stay dedup-marked — both frames are signature-gated, so the ACK tells an unauthenticated injector nothing, and a permanent refusal is precisely what should not be retransmitted. **Four emission sites, and the commit one is load-bearing**: a declined group invite (`handle_group_mls_welcome`), a refused membership commit (`process_commit_core`), a declined session Welcome (`handle_mls_welcome`, unthrottled because staging spends a one-time key package), and a roster read finding a leaf already seated in local state (`refresh_group_members`). The fourth differs in kind: no frame is refused and no peer delivered it, so `peer_id` is **this device's own id** ("who this concerns", not "who to blame") and the remedy it implies is to abandon the group rather than evict a member — the leaf cannot speak, but it holds live group secrets and reads everything, which no later refusal undoes. Its rate limit is load-bearing for a different reason than the others': the finding is *persistent*, so without suppression every commit, send and drain in the group re-reports it. On the session path the forgery test must run **before** the `has_session` duplicate check in the *adopt* branch: `join_group_replacing` refuses non-destructively, so the surviving session makes a forged tree look like a harmless retransmit and it is silently swallowed (pinned by `test_forged_session_welcome_is_reported_on_the_adopt_path`). The two group sites go through `report_unproven_leaf`, rate-limited per `(group, sender, UnprovenLeafSite)` on `UNAUTHORIZED_REPORT_SUPPRESS_SECS` via the shared `claim_report_window` (which also bounds `unauthorized_change_reports`), since a refusal is permanent and costs an insider nothing to repeat; the site is in the key so a declined invite cannot swallow the first refused *commit*. **Testing the limiter needs two genuinely distinct refusals** — a replayed frame fails earlier as a spent ratchet generation and never reaches the report, so a "still one event" assertion after a replay passes with the limiter deleted (`test_repeated_unprovable_invites_from_one_sender_are_rate_limited` uses two Welcomes built against fresh key packages). **Both new variants must also be in `process_commit_core`'s `is_permanent` allowlist**: that function decides retriability from an allowlist, so a refusal missing from it is *buffered*, re-decrypted on every drain, and — because a buffered commit that expires with `retry_count > 0` is read as an epoch fork — turns one forged commit into a group-wide `update_keys` round plus a false `group_epoch_fork_detected`. Pinned by `test_forged_leaf_commit_is_rejected_permanently_and_not_buffered`, which asserts through `handle_group_mls_commit` rather than the MLS layer, because asserting at the MLS layer is exactly what hid this. The 1:1 `__MLS_ENC__` path intercepts both variants too (inline in `handle_encrypted_message`, plus `is_media_security_rejection`): an `__MLS_ENC__` envelope may name a `group:` id and so reach group decrypt without passing the group handler, and both classify as `SessionStateError::Unknown`, whose disposition is drop-**and-ACK**. Corollary: `remove_group_member` removes **every** matching leaf (and `GroupManager::remove_member` therefore takes a slice). Through the wire gates a duplicate is unreachable — MLS requires unique signature keys and the binding ties credential→key, so duplicate credentials are refused twice over (pinned by `test_one_identity_cannot_hold_two_leaves_in_a_group`) — but that argument covers the gates, **not the tree**: a forged leaf written straight into the provider store claims a peer's address while carrying the *attacker's* key, violates no uniqueness rule, and sits beside the victim's real leaf. There first-match would leave the peer in the group holding live keys while every roster read shows them gone, so the loop is doing real work in exactly the case `verify_sender_leaf` and the roster filter exist for. Update proposals (source 2 of the walk) are **unreachable today and kept anyway**, documented in place: inline, MLS attributes the proposal to the committer and forbids committing your own Update (`CommitterIncludedOwnUpdate`); by reference, the *receiver* must hold the proposal, and this SDK drops every received `ProposalMessage` rather than storing it. A propose-only API would make the loop live. Coverage: `test_committed_forged_leaf_is_refused_and_cannot_speak_as_its_victim`, `test_welcome_carrying_a_forged_leaf_is_refused`, `test_session_welcome_carrying_a_forged_leaf_is_refused_non_destructively`, `test_forged_leaf_seated_behind_the_gates_still_cannot_speak`, `test_member_renaming_their_own_leaf_to_a_peers_address_is_refused`, `test_leaf_with_a_nickname_credential_is_refused`, `undecodable_signature_key_is_refused_not_skipped`, `an_oversized_credential_is_truncated_in_the_error`, `test_commit_proposing_external_senders_is_refused`, `test_welcome_whose_group_context_has_external_senders_is_refused`, `test_group_invite_with_an_unprovable_member_is_declined_and_reported`, `test_forged_leaf_commit_is_rejected_permanently_and_not_buffered`. Also `test_external_join_proposal_is_refused_as_an_unsupported_sender` (the non-`Member` sender arm, whose disposition changed from tolerate-and-ignore to refuse — the surviving `ExternalJoinProposalMessage` match arm still reads like the policy and is not), `test_roster_read_counts_the_leaves_it_skips`, and `test_leaf_seated_in_local_state_is_reported_on_the_roster_read`. Mutation-checked against ten sabotages (stubbed binding, waved-through underivable keys, dropped Welcome walk, dropped update path, dropped sender-leaf check, dropped commit `ExternalSenders` arm, dropped Welcome `ExternalSenders` arm, raw error back into the session `error_reason`, disabled roster report, dropped roster count); the adversarial fixture is `MlsManager::seat_forged_leaf_for_testing` behind the mls crate's `test-utils` feature. -- **Key package pool (one init key per peer)**: an MLS init key is single-use — consumed when a Welcome built against it is processed — so the push path assigns **one package per peer** and never shares. `MlsManager::take_push_key_package(peer_id)` (`manager.rs`) is the only entry point `send_key_package_to` may use; it resolves in order: this peer's own live package (repeat pushes cost no key material) → an *unclaimed* package (claimed here, which is what stops an upgrade stranding the pre-existing one) → a fresh mint. The assignment lives on the stored bundle (`KeyPackageBundle::assigned_peer`, additive `#[serde(default)]`, local-only — never on the wire, never in the FFI record), **not** a side map, so it survives restarts and cannot disagree with the pool. Rotation is consumption-driven: `load_stored_key_package` reports a consumed package gone and the next push mints a successor (RFC 9420 §16.8's "rotate as soon as possible after being used"). `get_or_create_key_package` is the peer-less FFI/test escape hatch and now skips *both* reserved and peer-assigned packages. Bounded at `MAX_PUSH_KEY_PACKAGES`=64 live packages; **at the ceiling it shares the newest rather than refusing to advertise or evicting** (either would cost session establishment) and reports `PushKeyPackagePoolExhausted` — 5-min suppressed, and the one condition under which the old reuse shape is back. **The ceiling gates only the mint**, which is the sole step that grows the pool — a claim relabels a package that already exists, so steps 1–2 run ahead of the check and a full pool holding an unclaimed package still hands out its own key (pinned by `test_ceiling_claims_an_unclaimed_package_rather_than_sharing`; gating the claim too would weaken forward secrecy to stay under a bound the claim never approaches). Reaching the shared branch therefore proves every live package belongs to *another* peer, and "newest" makes it the one most likely mid-establishment — if the over-ceiling peer's Welcome lands first, that peer's advertisement goes unprocessable until its next push. `ensure_min_key_packages` is capped at the same ceiling for the same reason: past it, it would mint material the push path can never hand out while pinning the pool at capacity. The per-push scan is O(pool) storage reads, not crypto: `KeyPackageBundle::provider_hash_ref` caches the TLS-serialized OpenMLS hash ref at mint (backfilled on first load for pre-cache records; local-only like `assigned_peer`) so usability checks skip the per-package parse + signature validation — without it a many-peer push loop costs minutes of debug-build CI (`test_rekey_tracking_map_is_bounded` is deliberately built without MLS for the same reason: it pins the map bound, not the pool). **Expiry destroys key material in two stages** and this is load-bearing: deleting the bundle record alone (all this crate used to do) leaves the private init key in the OpenMLS provider forever, since only a peer's Welcome removes one. `is_expired` withdraws the package from every caller; only `expired_past_grace(KEY_PACKAGE_PURGE_GRACE_SECS=7d)` calls `purge_key_package_material`, which deletes the **provider key first** and keeps the record to retry if that fails. The grace window exists so a Welcome built just before expiry still opens. `delete_key_package` purges **legacy raw records too** — it reads an unparseable record as the serialized key package (the same upgrade reading `load_stored_key_package` applies) so the ref is derivable; a record-only delete there is the exact stranding this fix removes, and a record that names nothing still falls through to the plain delete. Coverage: `test_push_path_gives_each_peer_its_own_key_package`, `test_push_path_reuses_one_package_for_repeat_pushes_to_a_peer`, `test_push_path_mints_a_fresh_package_after_the_peer_consumed_one`, `test_two_peers_can_both_establish_sessions_after_being_pushed_to` (the delivery bug the old shape caused: the second peer's Welcome was unprocessable), `test_push_pool_ceiling_shares_a_package_and_reports_it`, `test_ceiling_claims_an_unclaimed_package_rather_than_sharing`, `test_ensure_min_key_packages_is_capped_at_the_pool_ceiling`, `test_expired_package_is_withdrawn_but_its_init_key_survives_the_grace_window`, `test_package_past_the_grace_window_has_its_init_key_destroyed`, `test_delete_key_package_destroys_the_init_key{,_of_a_legacy_record}`, `test_delete_key_package_removes_an_unreadable_record`, `push_path_advertises_a_distinct_key_package_per_peer`, `test_minted_package_carries_its_provider_hash_ref`, `test_record_without_cached_ref_is_backfilled_on_first_load`, `test_purge_destroys_init_key_for_a_record_without_cached_ref`. -- **Protocol control messages**: internal prefix convention (`__MLS_KEY_PKG__`, `__MLS_WELCOME__`, `__MLS_ENC__`, etc.) in `crates/offline-protocol/src/protocol.rs`. Service messages use `__SVC_DISC_Q__`, `__SVC_DISC_R__`, `__SVC_REQ__`, `__SVC_RESP__` prefixes in `crates/offline-protocol-services/src/payloads.rs`. -- **Event-driven**: `OfflineProtocol` emits events (MessageReceived, PeerDiscovered, TransportChanged, etc.) via `EventCallback`. -- **Runtime telemetry**: apps install a `TelemetrySink` via `OfflineProtocol::install_telemetry_sink(sink, config)`; `TelemetryConfig::mls_verbosity` (`Off` | `Lifecycle` (default) | `Diagnostic`) gates MLS lifecycle emission at runtime. Replaces the retired `mls-observability` Cargo feature. Identifier scrubbing is on by default via `TelemetryConfig::scrub_ids`. - -### Safety Rules - -- Core crates enforce `#![deny(unsafe_code)]` — zero unsafe allowed. -- FFI crate (`offline-protocol-uniffi`) allows unsafe for UniFFI scaffolding only. - -### Build Profiles +### Key extension points + +- **`Transport` trait** (`crates/offline-protocol-transport/src/traits.rs`): all + transports implement it; uses `as_any()` for safe downcasting. `MockTransport` + is available for tests. +- **`MlsStorage` trait** (`crates/offline-protocol-mls/src/storage.rs`): + platform-agnostic secure storage. Apps implement it for iOS Keychain, Android + Keystore, and so on. +- **`TelemetrySink`**: installed via + `OfflineProtocol::install_telemetry_sink(sink, config)`. + `TelemetryConfig::mls_verbosity` gates MLS lifecycle emission at runtime; + identifier scrubbing is on by default. +- **`EventCallback`**: the engine emits events (MessageReceived, + NeighborDiscovered, TransportSwitched, and so on). Events cross UniFFI as + opaque JSON. + +### Rules that are enforced by tests, not by the compiler + +These fail silently if broken. Each is documented in full where it is linked. + +- **Regenerate all bindings together** after a UDL change + ([C1](docs/bridges/README.md#c1-regenerate-every-binding-together)). +- **The FFI error enum is append-only**; discriminants are positional + ([C2](docs/bridges/README.md#c2-the-error-enum-is-append-only)). +- **Hand-mirrored constants** (relay-answer prefixes, one-shot event tags, the + mesh wake task key, the protocol-state record ceiling) exist in up to four + places across three languages, pinned either by per-language literal tests or + by a Rust guard that reads the binding sources + ([C5](docs/bridges/README.md#c5-hand-mirrored-constants-must-be-pinned-in-every-language)). +- **Adding a control-message prefix** means adding it to the registry that + drives injection prevention + ([spec](docs/spec/control-messages.md#reserved-prefix-registry)). +- **Never add a catch-all arm to a telemetry reason classifier that matches on + an enum** (a classifier over an open wire string may, if the fallback returns + a fixed token and never the input) + ([ADR 0013](docs/adr/0013-exhaustive-privacy-classifier.md)). + +### Safety rules + +- Core crates enforce `#![deny(unsafe_code)]`. Zero unsafe allowed. +- The FFI crate (`offline-protocol-uniffi`) allows unsafe for UniFFI scaffolding + only. + +### Build profiles - `dev`: debug, no optimization - `release`: opt-level 3, LTO, stripped -- `minisize`: inherits release + opt-level "z", panic abort (for mobile binary size) +- `minisize`: inherits release + opt-level "z", panic abort (mobile binary size) -## Commit Convention +## Commit convention Conventional Commits: `(): ` Types: `feat`, `fix`, `docs`, `test`, `refactor`, `perf`, `chore` -Scopes: `core`, `transport`, `router`, `reliability`, `services`, `protocol`, `uniffi`, `bindings` +Scopes: `core`, `transport`, `router`, `reliability`, `services`, `protocol`, +`uniffi`, `bindings` -## Code Style (Rust) +## Code style (Rust) -- `thiserror` for library errors, `Result` everywhere (no `unwrap()` in library code) +- `thiserror` for library errors, `Result` everywhere, no `unwrap()` in + library code - Prefer zero-copy (`&str` over `&String`, `bytes::Bytes` for byte handling) -- Avoid allocation when possible — no unnecessary `String`/`Vec` creation +- Avoid allocation when possible - `tracing` for structured logging - `serde` for all serialization -- `tokio` for async (though most core logic is synchronous) +- `tokio` for async, though most core logic is synchronous - `pub(crate)` for internal APIs, `pub` only for truly public APIs + +## Documentation style + +- **No em dashes.** Use a comma, a colon, parentheses, or two sentences. +- Documents state invariants before mechanisms. The invariant survives a + refactor; the function name does not. +- When a shape exists to prevent a specific failure, name the failure. Otherwise + someone will simplify it back. +- Release notes go in `CHANGELOG.md` for the current release only; older + releases are archived by series in [docs/changelog/](docs/changelog/README.md). diff --git a/README.md b/README.md index 5480a539..eeae8822 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,20 @@ See the [docs/](docs/) directory for detailed guides: - [Telemetry](docs/telemetry.md) - [iOS Integration](docs/ios-integration.md) / [Android Integration](docs/android-integration.md) +Reference material for anyone implementing against the protocol or changing its +behaviour: + +- [Protocol Specification](docs/spec/README.md), the wire and behaviour contract, + independent of this implementation +- [Threat Model](docs/security/threat-model.md), including the residual risks + stated plainly +- [State Machines](docs/state-machines/README.md) for delivery, retries, + sessions, groups, and transports +- [Architecture Decision Records](docs/adr/README.md), why the non-obvious + choices are what they are +- [Bridge Contracts](docs/bridges/README.md) for Swift, Kotlin, Python, and + TypeScript + ## Development ```bash diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/MeshWakePolicy.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/MeshWakePolicy.kt index b8325d7e..5ebfd03b 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/MeshWakePolicy.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/MeshWakePolicy.kt @@ -116,7 +116,7 @@ object MeshWakePolicy { * A drift here fails silently in the worst way: React Native logs "No task * registered for key" to the device log and resolves nothing, the app sees * an opt-in that does nothing, and both sides still compile. Pinned by - * `react_native_mesh_wake_task_key_matches_native` in the uniffi crate. + * `react_native_mesh_wake_wiring_is_present` in the uniffi crate. */ const val TASK_KEY = "OfflineProtocolMeshWake" diff --git a/bindings/react-native/src/constants.ts b/bindings/react-native/src/constants.ts index 0a9f0108..3ea07f09 100644 --- a/bindings/react-native/src/constants.ts +++ b/bindings/react-native/src/constants.ts @@ -67,7 +67,7 @@ export const ONE_SHOT_EVENT_TYPES = [ * silently and in the worst possible way — React Native logs "No task * registered for key" to the device log, the app sees an opt-in that does * nothing, and both sides still compile — so it is pinned by a Rust guard - * (`react_native_mesh_wake_task_key_matches_native` in + * (`react_native_mesh_wake_wiring_is_present` in * `crates/offline-protocol-uniffi`). */ export const MESH_WAKE_TASK_KEY = 'OfflineProtocolMeshWake'; diff --git a/crates/offline-protocol-core/src/address.rs b/crates/offline-protocol-core/src/address.rs index 0967d701..9bbae7a9 100644 --- a/crates/offline-protocol-core/src/address.rs +++ b/crates/offline-protocol-core/src/address.rs @@ -93,9 +93,18 @@ pub enum AddressError { /// The two orders differ: the bech32 charset (`qpzry9x8gf2tvdw0s3jn54khce6mua7l`) /// is not monotonic in ASCII — value 4 renders as `y` (0x79) and value 5 as /// `9` (0x39) — and a string comparison would also weigh the checksum -/// characters. Protocol tiebreakers (both-create session ownership, leave -/// election, admin auto-promotion, fork leader) must therefore compare -/// `Address` values, never their `Display` output. +/// characters. +/// +/// **Which order a tiebreaker uses is fixed per site, and the sites disagree.** +/// Both-create session ownership compares `Address` values (hash bytes); group +/// leave election, admin auto-promotion and fork leader election sort rendered +/// strings. Each converges on its own, because every peer running that +/// tiebreaker sorts the same way. +/// +/// Do not "harmonize" one site onto the other order. Peers that changed and +/// peers that did not would then elect different winners from identical input, +/// with no way to detect the disagreement locally. Prefer hash-byte order for +/// anything new. See `docs/adr/0003-self-certifying-addresses.md`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct Address { hash: [u8; Address::HASH_LEN], diff --git a/crates/offline-protocol-router/src/dors.rs b/crates/offline-protocol-router/src/dors.rs index ad7e093d..2c10df70 100644 --- a/crates/offline-protocol-router/src/dors.rs +++ b/crates/offline-protocol-router/src/dors.rs @@ -868,7 +868,7 @@ impl TransportSelector { // // The Internet baseline intentionally exceeds the 0–100 range of // offline transports so that the gap comfortably exceeds the default - // switch hysteresis (10). Increasing hysteresis beyond ~20 may + // switch hysteresis (15.0). Increasing hysteresis beyond ~20 may // prevent DORS from switching *to* Internet even when prefer_online // is set. // diff --git a/crates/offline-protocol/src/group_mesh.rs b/crates/offline-protocol/src/group_mesh.rs index 8ea5d0ae..43217638 100644 --- a/crates/offline-protocol/src/group_mesh.rs +++ b/crates/offline-protocol/src/group_mesh.rs @@ -839,7 +839,8 @@ pub(crate) struct PendingGroupMessage { pub(crate) buffered_at: Instant, /// Transport the frame arrived on, recorded so the drain can send the /// deferred delivery ACK directly on it once the message finally decrypts - /// (see the deferred-ACK atom in CLAUDE.md). `None` for the relay path + /// (see the deferred-acknowledgement atom in + /// `docs/state-machines/delivery-and-acks.md`). `None` for the relay path /// (the relay sender is not ACK-gated) and for transport-less test enqueue /// — in both cases the drain ACK is a correct no-op. pub(crate) received_via: Option, diff --git a/crates/offline-protocol/src/protocol/decryption_queue.rs b/crates/offline-protocol/src/protocol/decryption_queue.rs index 500cae40..8506633e 100644 --- a/crates/offline-protocol/src/protocol/decryption_queue.rs +++ b/crates/offline-protocol/src/protocol/decryption_queue.rs @@ -22,8 +22,10 @@ pub(crate) struct PendingDecryptMessage { pub(crate) sequence: u64, pub(crate) message: Message, /// Transport the frame arrived on, when the caller knew it. Recorded so the - /// drain can send the deferred delivery ACK directly (see the deferred-ACK - /// design in CLAUDE.md) instead of waiting for the sender's next resend to + /// drain can send the deferred delivery ACK directly (see the + /// deferred-acknowledgement atom in + /// `docs/state-machines/delivery-and-acks.md`) instead of waiting for the + /// sender's next resend to /// hit the duplicate re-ACK path. `None` when the message was enqueued from /// a context with no transport (tests, or a re-queue by the handler during a /// drain, which re-processes with no live arrival transport) — the drain diff --git a/crates/offline-protocol/src/protocol/pending_queue.rs b/crates/offline-protocol/src/protocol/pending_queue.rs index 153424a4..67b2b507 100644 --- a/crates/offline-protocol/src/protocol/pending_queue.rs +++ b/crates/offline-protocol/src/protocol/pending_queue.rs @@ -27,8 +27,8 @@ impl OfflineProtocol { /// [`Self::enqueue_pending_decryption`] with the transport the frame arrived /// on, when the caller knows it. The transport is recorded on the pending /// entry so the drain can send the deferred delivery ACK directly instead of - /// relying on the sender's next resend (see the deferred-ACK design in - /// CLAUDE.md). + /// relying on the sender's next resend (see the deferred-acknowledgement + /// atom in `docs/state-machines/delivery-and-acks.md`). pub(super) fn enqueue_pending_decryption_via( &mut self, sender: &str, diff --git a/crates/offline-protocol/src/protocol/session.rs b/crates/offline-protocol/src/protocol/session.rs index 0cbec7cb..60b654d7 100644 --- a/crates/offline-protocol/src/protocol/session.rs +++ b/crates/offline-protocol/src/protocol/session.rs @@ -632,7 +632,8 @@ impl OfflineProtocol { /// (delayed delivery, never lost). Closing that needs a signed /// epoch-corroboration exchange before teardown; a liveness-only probe does /// not work, since a healthy peer answers and we would tear down anyway. - /// Also see the CLAUDE.md "Crypto-failure recovery" note. + /// Also see `docs/state-machines/session-lifecycle.md` ("Desync and heal") + /// and `docs/security/threat-model.md` (residual risk R2). pub(super) fn schedule_session_rekey(&mut self, peer_id: &str) { let now = Utc::now(); if let Some(due_at) = self.rekey_due_at.get(peer_id) { diff --git a/crates/offline-protocol/src/protocol/types.rs b/crates/offline-protocol/src/protocol/types.rs index 6a13295c..b64161e6 100644 --- a/crates/offline-protocol/src/protocol/types.rs +++ b/crates/offline-protocol/src/protocol/types.rs @@ -1273,8 +1273,9 @@ pub(crate) enum InternalMessageResult { /// In the latter three, recovery is the sender's *resend* rather than this /// frame: Tier 2 re-seals each resend of an encrypted DM against a live /// generation, and a message that stays undeliverable settles as an honest - /// `MessageFailed` instead of a lying "delivered". See the deferred-ACK and - /// crypto-failure-recovery designs in CLAUDE.md. + /// `MessageFailed` instead of a lying "delivered". See + /// `docs/state-machines/delivery-and-acks.md` for the deferred-acknowledgement + /// atom and the decrypt-failure classification. Deferred, /// Message was decrypted, here's the plaintext. Decrypted(String), diff --git a/docs/README.md b/docs/README.md index 7d24d0d7..b2c197c0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,11 +1,19 @@ # Documentation +Two kinds of document live here, and they answer different questions. + +**Guides** answer "how do I use this". Start with the Quick Start and the +integration guide for your platform. + +**Reference** answers "what is the contract" and "why is it like this". Read it +before changing behaviour, not before using the SDK. + ## Getting Started | Guide | Description | |-------|-------------| | [Quick Start](../QUICKSTART.md) | Get started in 5 minutes (React Native, iOS, Android) | -| [Upgrading](UPGRADING.md) | **Breaking changes and required app-side work for the storage-split release** | +| [Upgrading](UPGRADING.md) | **Breaking changes and required app-side work** | | [React Native Integration](react-native-integration.md) | Full SDK integration guide with complete API reference | | [iOS Integration](ios-integration.md) | Native iOS (Swift) setup and usage | | [Android Integration](android-integration.md) | Native Android (Kotlin) setup and usage | @@ -31,7 +39,69 @@ | [Telemetry](telemetry.md) | Wire up a telemetry sink for metrics, routing decisions, and MLS lifecycle | | [Transport Architecture](transport-architecture.md) | Transport abstraction layer and how to add new transports | | [Reticulum Transport](reticulum.md) | Reticulum mesh transport setup, architecture, and platform integration | -| [Nostr Transport](nostr.md) | Nostr relay transport — censorship-resistant routing over WebSockets | +| [Nostr Transport](nostr.md) | Nostr relay transport, censorship-resistant routing over WebSockets | + +## Protocol specification + +The wire and behaviour contract, independent of this implementation. A second +implementation written against these documents should interoperate. + +| Document | Scope | +|----------|-------| +| [Specification index](spec/README.md) | Layering, conformance language, the two overriding invariants | +| [Identity and addressing](spec/identity.md) | Address derivation, canonical form, session and group identifiers | +| [Message model and wire format](spec/wire-format.md) | The abstract message, the JSON floor, binary v1, the extension TLV registry | +| [Control messages](spec/control-messages.md) | Reserved prefix registry, control-plane signing, the two exemption classes | +| [Encryption envelopes](spec/encryption-envelopes.md) | MLS envelope forms, media chunk envelope, sealed rich payload | +| [Group protocol](spec/group-protocol.md) | Group frames, membership commits, leaf identity binding, relay broadcast | +| [Capability negotiation](spec/capability-negotiation.md) | What peers advertise, what it gates, what absence means | + +## Security + +| Document | Scope | +|----------|-------| +| [Threat model and trust boundaries](security/threat-model.md) | Assets, adversary classes, controls, and the residual risks stated plainly | +| [Security Policy](../SECURITY.md) | Vulnerability reporting and safe harbor | + +## State machines + +| Document | Governs | +|----------|---------| +| [Overview](state-machines/README.md) | The invariant that spans all five | +| [Delivery and acknowledgements](state-machines/delivery-and-acks.md) | What happens to an inbound frame, and when a receiver acknowledges | +| [Outbox and retries](state-machines/outbox-and-retries.md) | An outbound message from send to terminal state | +| [Session lifecycle](state-machines/session-lifecycle.md) | 1:1 MLS establishment, confirmation, desync, and heal | +| [Group message lifecycle](state-machines/group-message-lifecycle.md) | A group message through fan-out, buffering, and drain | +| [Transport lifecycle](state-machines/transport-lifecycle.md) | Transport availability, scoring, switching, escalation | + +## Decisions + +| Document | Scope | +|----------|-------| +| [ADR index](adr/README.md) | Fifteen decisions that are expensive to reverse or easy to undo by accident | + +If something in the codebase looks redundant or over-engineered, check here +before simplifying it. + +## Bridge contracts + +What each language binding owes the core, and what the core owes it. Every rule +in here fails **silently** when violated. + +| Document | Scope | +|----------|-------| +| [Shared contract](bridges/README.md) | The ten rules every binding shares | +| [Swift](bridges/swift.md) | iOS native and the React Native iOS bridge | +| [Kotlin](bridges/kotlin.md) | Android native and the React Native Android bridge | +| [Python](bridges/python.md) | Desktop and tooling | +| [TypeScript](bridges/typescript.md) | The React Native JavaScript surface | + +## Release history + +| Resource | Description | +|----------|-------------| +| [CHANGELOG](../CHANGELOG.md) | Unreleased changes and the current release | +| [Changelog archive](changelog/README.md) | Older releases, one file per minor series | ## Examples @@ -46,11 +116,10 @@ | Resource | Description | |----------|-------------| | [Contributing Guide](../CONTRIBUTING.md) | Development setup, code quality standards, and PR process | -| [Security Policy](../SECURITY.md) | Vulnerability reporting and security design | ## Licensing | Resource | Description | |----------|-------------| -| [Licensing FAQ](licensing-faq.md) | The dual license in practice — app stores, the AGPL's reach, commercial licensing | +| [Licensing FAQ](licensing-faq.md) | The dual license in practice: app stores, the AGPL's reach, commercial licensing | | [Export Control Notice](../EXPORT.md) | Encryption export status of the SDK and what app teams must handle themselves | diff --git a/docs/adr/0001-json-as-permanent-wire-floor.md b/docs/adr/0001-json-as-permanent-wire-floor.md new file mode 100644 index 00000000..293b5928 --- /dev/null +++ b/docs/adr/0001-json-as-permanent-wire-floor.md @@ -0,0 +1,66 @@ +# 0001. JSON is the permanent wire floor; compact encodings are additive + +**Status:** Accepted +**Shipped in:** 0.14.0 (binary wire codec), 0.14.0 (compact MLS envelope) + +## Context + +JSON message framing is expensive on a Bluetooth LE link. A 36-character +hyphenated message identifier, verbose field names, and a ciphertext rendered as +a decimal integer array cost roughly 3 to 4 times what a compact encoding does, +which translates directly into fragment counts and airtime. + +The obvious move is to replace JSON with a compact encoding. Two things make +that wrong here: + +1. The fleet upgrades gradually and mesh peers meet arbitrary strangers. There + is no coordinated flag day. +2. The internet relay and every persisted record are on the same code path. + Changing the encoding changes what old records deserialize as. + +## Decision + +Compact encodings are **additive**. JSON remains a permanent obligation: + +- every receiver decodes JSON, unconditionally and forever, +- a compact encoding is emitted only to a peer that advertised it, +- persistence and the internet relay transport stay JSON unconditionally (the + Nostr transport is relay-mediated but uses the negotiated codec like any other + peer-to-peer path), +- decoding of compact encodings is always on, independent of whether emitting + them is. + +Three layers carry this full shape, each with its own switch and its own +capability, because they are independent: the hop-local wire codec +(`binary_wire_enabled` / `wire_versions`), the end-to-end MLS envelope +(`compact_envelope_enabled` / `env_versions`), and the sealed rich payload +(`rich_payload_enabled` / `rich_versions`). + +The media chunk envelope is **not** a fourth instance and should not be +described as one. It has no JSON form to fall back to and no switch: its payload +is always the compact encoding, and only the choice between its v1 and v2 forms +is negotiated, riding the rich-payload capability rather than one of its own. + +## Consequences + +**Good.** A mixed fleet works with no coordination. Rollback is a configuration +change, not a migration. A peer that mis-advertises costs a delivery failure to +itself, not a fleet-wide outage. + +**Cost.** Two encoders and two decoders per layer, forever. The JSON path can +never be deleted, so it must stay tested. Size wins only materialize once both +ends have upgraded. + +**Cost.** Detection is by first byte, which constrains the magic byte to a range +that cannot begin valid JSON or valid UTF-8. That range holds eleven values, of +which v1 spends one, leaving ten for future versions. That is plenty, but it is +finite. + +## What would undo this + +Making decode of a compact form conditional on local configuration. That turns a +kill switch into a compatibility break: peers that were told we are capable +start sending a form we then refuse. + +Adding a compact form to persistence "since we already have the codec". Stored +records outlive every negotiation. diff --git a/docs/adr/0002-frozen-dto-with-extension-tlv.md b/docs/adr/0002-frozen-dto-with-extension-tlv.md new file mode 100644 index 00000000..97a730f4 --- /dev/null +++ b/docs/adr/0002-frozen-dto-with-extension-tlv.md @@ -0,0 +1,66 @@ +# 0002. The binary encoding uses a frozen positional DTO with an extension TLV + +**Status:** Accepted +**Shipped in:** 0.14.0 + +## Context + +The binary encoding uses postcard, which is positional and non-self-describing. +That is where the size win comes from, and it is also the hazard: reordering, +removing, retyping, or inserting a field silently corrupts decoding on peers +running the previous layout. There is no error, only wrong values. + +Serializing the domain message type directly compounds the problem. That type +carries defaulting rules, skip-when-absent rules, and validation-on-deserialize +behaviour that a non-self-describing format cannot honour field for field. The +skip-when-absent rules in particular mean the field count varies by content, +which a positional format cannot express. + +## Decision + +Encode through a separate flat DTO with a **frozen** field order, and convert +back to the domain type through the **validating** constructors. + +Evolution is constrained to three rules: + +1. Existing fields never change, in order or in type. +2. Additive data goes into a trailing `(tag, bytes)` extension list that old + decoders read and ignore. +3. Anything that cannot be expressed as an extension entry takes a new magic + byte and is negotiated. + +The numeric enum mappings are frozen on the same terms. + +## Consequences + +**Good.** The security checks the JSON path enforces (identifier caps, logical +clock clamps) apply identically on the binary path, because both go through the +same constructors. + +**Good.** Rich, rarely-present structures ride as embedded JSON blobs, so they +keep evolving through their own additive rules without touching the frozen +surface. + +**Cost.** A field added to the domain type does not automatically appear on the +binary wire. Someone must decide, per field, whether it warrants an extension +tag. + +## The constraint the first extension tag imposes + +Extension tag 1 (the base64 content tail) shipped in the **first** release of +wire v1, so advertising the version implies understanding it. A decoder that +ignored it would reconstruct a truncated content field. + +That is only safe because no v1 decoder without tag-1 support ever shipped, and +it fixes the rule for every future tag: **a tag whose absence changes meaning +cannot be added to v1.** It needs a new wire version. Tags may be added to v1 +only when ignoring them costs efficiency or optional context. + +Tag 2 (quoted-reply context) satisfies that test: skipping it delivers the +message without its reply preview, which is exactly what a legacy JSON receiver +does with an unknown field. + +## What would undo this + +Adding a field to the DTO "at the natural place" rather than at the end, or +adding an extension tag whose absence changes what the message means. diff --git a/docs/adr/0003-self-certifying-addresses.md b/docs/adr/0003-self-certifying-addresses.md new file mode 100644 index 00000000..e7b824b7 --- /dev/null +++ b/docs/adr/0003-self-certifying-addresses.md @@ -0,0 +1,103 @@ +# 0003. Identity is a self-certifying address, not a trust-on-first-use pin + +**Status:** Accepted +**Shipped in:** 0.21.0 + +## Context + +Earlier designs identified peers by an application-chosen name and defended +impersonation with a trust-on-first-use store: the first identity key seen for a +name was pinned, and a later mismatch was refused. + +Three problems with that in a mesh: + +1. **First contact is undefended.** The pin protects the second meeting onward. + In a mesh where peers meet strangers constantly, first contact is the common + case, not the edge case. +2. **The pin store is state that must be persisted, migrated, synchronized + across a user's devices, and recovered after a reinstall.** Every one of + those is a place to get it wrong, and getting it wrong fails **open**. +3. **The relay answers are structurally unsignable**, so the pin could never be + applied to them anyway. + +## Decision + +Make the identity name a function of the identity key: + +``` +address = bech32m("off", 0x01 || SHA-256(ed25519_public_key)[0..20]) +``` + +Verification derives an address from the presented public key and compares it to +the claimed sender. No stored state, no first-contact window, and nothing to +migrate. + +The trust-on-first-use store was deleted rather than kept alongside. + +## Consequences + +**Good.** First contact is as defended as the thousandth. Impersonation of a +chosen peer costs a second preimage, roughly 2^160. + +**Good.** No trust state to persist, corrupt, or lose. + +**Cost.** Addresses are not human-readable. Any human-facing name is a directory +concern layered on top, and the mapping from name to address is outside the +protocol's trust model. + +**Cost, and this one is real.** Truncation to 160 bits gives only ~2^80 +collision resistance. An attacker who finds a collision holds two signing keys +indistinguishable at the address layer, which defeats the one-identity-one-leaf +property the group binding otherwise inherits from MLS. The trade was made +because every mesh frame carries two addresses and the Bluetooth LE budget is +binding. Widening the hash is a version bump and a migration. + +**Cost.** Two identifier namespaces now coexist in deployments with a relay that +keys by username. They do not intersect, and assuming they do has already caused +one delivery bug (see [ADR 0011](0011-relay-broadcast-gated-on-delivery-report.md)). + +## Canonicality is a requirement, not a nicety + +A bech32 decoder that accepts a string and returns a payload has **not** proved +that re-encoding the payload yields that string. Two spellings decoding to one +address splits every set, map, and deduplicator keyed by the rendered form. + +Implementations must re-encode and compare, or refuse every non-canonical form +explicitly. Uppercase input is refused even though BIP-173 permits it. + +## Two orderings exist, and a tiebreaker must not mix them + +The bech32 charset is not monotonic in ASCII, and the rendering includes +checksum characters that carry no identity, so **hash-byte order and rendered +string order are different orders**. `Address` implements `Ord` over the hash +bytes for exactly that reason: the identity-bearing comparison is the one on the +bytes. + +The protocol's tiebreakers do not all use it: + +| Tiebreaker | Compares | +|------------|----------| +| Session slot ownership (both-create) | `Address` values, so hash bytes, falling back to string order when either identifier does not parse as an address | +| Group leave election | rendered address strings | +| Admin auto-promotion | rendered address strings | +| Fork leader election | rendered address strings | + +The fallback in the first row is part of the contract, not an implementation +detail: identifiers predate addresses, and a peer still carrying a legacy +identifier has no hash bytes to compare. Both sides apply the same fallback, so +it converges, but a second implementation that omits it diverges on exactly +those pairs. The session slot identifier derives its ordering the same way, with +the same fallback. + +Both orders are deterministic and total, so each of these converges: every peer +running a given tiebreaker sorts the same way and reaches the same winner. + +**The invariant is per tiebreaker, not global.** A second implementation MUST +use, for each tiebreaker, the order named above, and a change MUST NOT +"harmonize" one site onto the other order. That is the move that breaks +convergence, because the peers that changed and the peers that did not now elect +different winners from identical input, and neither side can detect the +disagreement locally. + +Prefer hash-byte order for anything new: it compares identity rather than +encoding. diff --git a/docs/adr/0004-control-plane-signature-gate.md b/docs/adr/0004-control-plane-signature-gate.md new file mode 100644 index 00000000..931a7dc9 --- /dev/null +++ b/docs/adr/0004-control-plane-signature-gate.md @@ -0,0 +1,79 @@ +# 0004. Control frames are signature-gated with two documented exemption classes + +**Status:** Accepted + +## Context + +Control frames carry session establishment, connection lifecycle, group +membership, presence, and relay coordination. A forged control frame is worth +far more to an attacker than a forged application message. + +Not every control frame can be signed, and the reasons differ. + +## Decision + +Require an Ed25519 signature over a domain-separated, length-prefixed canonical +payload on every internal prefix, verified against **the key the claimed +sender's address derives from**. + +Maintain the exceptions as an **exclusion** list, so a newly added prefix is +gated by default. + +Recognize exactly two exemption classes, and keep them separate: + +| Class | Members | What authenticates them instead | +|-------|---------|--------------------------------| +| Data plane | `__MLS_ENC__`, `__GROUP_MSG__` | MLS decryption plus the credential-to-wire-sender comparison | +| Relay answers | 6 relay-originated prefixes | **Nothing in this protocol** | + +## Consequences + +**Good.** Address derivation from the presented key is what makes the gate +meaningful. Signature verification alone proves only that whoever supplied the +public key also supplied a matching signature, which any party can do for any +name. + +**Good.** An exclusion list means forgetting to gate a new prefix is a +compile-time absence rather than a silent hole. + +**Cost.** The relay-answer exemption is a real hole. Anything able to inject on +the relay ingest path can forge group registration, membership answers, group +info, the group list, and error reports. + +## Why the two exemption classes must not be merged + +A data-plane frame is authenticated **later**. A relay answer is **not +authenticated at all**. Listing a prefix in both makes the narrow relay +conditions unreachable for it, because the data-plane exclusion is consulted +first. + +The relay exemption is narrower than the prefix: it applies only to a frame that +arrived on the internet transport carrying no transport peer identity, which is +the shape a locally synthesized answer has. A peer frame on a mesh transport is +still required to be signed. Merging the lists silently discards those +conditions. + +## Why the relay-answer exemption exists at all + +The relay answers over its own channel and the bridge synthesizes a message from +that answer. There is no private key anywhere in that path. Requiring a +signature drops every one of them, taking group registration with it, and with +that the sync gate group broadcast rides on. + +## What would close it + +Moving relay answers off the message plane onto dedicated entry points, the way +the group delivery report already works. See +[ADR 0014](0014-dedicated-ffi-entry-points.md). + +## Maintenance hazard + +The relay-answer list exists in three places no single compiler sees together: +the core and each native bridge. A prefix present in one copy and absent from +another fails **silently**: the bridge injects the answer unattributed, the gate +declines to exempt it, and the frame is dropped as unsigned with no peer at +fault. + +Each copy is pinned against literals in its own language's tests. A test that +recomputes the list from the constant it checks agrees with any edit, which is +exactly the failure mode. diff --git a/docs/adr/0005-defer-instead-of-drop-and-ack.md b/docs/adr/0005-defer-instead-of-drop-and-ack.md new file mode 100644 index 00000000..13298d4a --- /dev/null +++ b/docs/adr/0005-defer-instead-of-drop-and-ack.md @@ -0,0 +1,99 @@ +# 0005. A receiver that cannot deliver withholds the acknowledgement + +**Status:** Accepted +**Shipped in:** 0.20.1 (extended to every recoverable failure class) + +## Context + +An encrypted message arriving before the receiver's session or group state +exists was queued for later decryption **and acknowledged as delivered**. The +sender then dropped its outbox entry and retired the retry ladder. If the queued +copy never drained, the message was gone, with both sides believing it +delivered. + +The same shape appeared in five further places: epoch desync, ordinary crypto +failure, transport failure, envelope parse failure, and a queued frame that hard +failed on drain. + +## Decision + +**Acknowledge only what is delivered or permanently refused.** + +A receiver that cannot deliver a frame **now**, but where some future event +could make it deliverable, withholds the acknowledgement and unmarks the +identifier so the sender's resend re-enters processing rather than hitting the +duplicate re-acknowledge path. + +Terminal failures keep the acknowledgement: **policy** refusals that can never +become decryptable, such as an unauthorized commit, and failures **after** a +successful decrypt where the ratchet generation is spent and a re-seal would +produce the same malformed plaintext. + +**Security refusals are the exception, and they are silent.** A frame refused +because its identity does not bind (sender identity mismatch, session identity +mismatch, leaf address mismatch, unsupported sender) or because it names another +pair's session slot is permanent in exactly the same sense, but it gets **no +acknowledgement and its identifier is unmarked**. + +That exception is load-bearing rather than incidental. Those shapes would +otherwise classify as ordinary unknown-session failures and inherit this +decision's drop-and-acknowledge disposition, which would hand an injector a +confirmation that the target is live and processing. They are therefore +intercepted **before** classification, on both the text and media paths, and the +interception is deliberately not gated on the crypto-recovery switch: it is +about what the receiver reveals, not about recovery. + +## Consequences + +**Good.** Custody stays with the sender until a receiver positively confirms. +Silent loss becomes delayed delivery. + +**Cost, and application teams must know it.** A sender that exhausts its retry +budget before both the session confirms and an acknowledgement lands may mark a +message undeliverable **though it was delivered locally**. That is strictly +better than the old silent drop, but it means **a missing acknowledgement is not +proof of non-delivery.** + +**Cost.** Decryption-failure events now fire per failed **attempt** rather than +per message, bounded by the sender's retry budget. They are advisory; the +terminal signals are the failure events. + +## The six pieces are correct only together + +Implementing a subset produces a system that looks like it works and loses +messages: + +1. A distinct not-ready outcome, so the receive loop has something to branch on. +2. Idempotent enqueue keyed by message identifier, so resends do not stack and + the time-to-live measures from **first** receipt. +3. Drain on **any successful decrypt**, not on a session-established event. In + the both-create race the **owner** keeps its local session and never *adopts* + the Welcome it receives, confirming only once a decrypt succeeds, so a + Welcome-triggered drain silently skips it. +4. Re-mark the identifier when the drain surfaces the message. +5. A time-to-live long enough to cover mesh session establishment (30 minutes, + not 2). +6. Acknowledge on drain, on the transport the frame arrived on. + +## Two rules the drain path adds + +**Do not enqueue a frame that can never become processable.** A desynced +ciphertext is sealed to a dead epoch; a spent generation cannot be re-spent; an +unparseable envelope stays unparseable. Queuing them re-reports failures on +every drain forever. + +**A queued frame that hard-fails on drain is dropped, not re-enqueued.** The +drain removes the entry before processing, so a re-enqueue misses the idempotency +check and re-stamps the receipt time, restarting the time-to-live of a frame that +can never decrypt, on every drain. + +## What would undo this + +Adding an acknowledgement to any deferred arm "so the sender stops retrying". +The retrying is the recovery mechanism. + +Removing the security-refusal interception, on the reasoning that those shapes +are permanent refusals and permanent refusals are acknowledged. They are, and +that is exactly why deleting the interception is silent: the frames keep being +refused, the refusal keeps being correct, and the only thing that changes is +that an injector now gets an answer. diff --git a/docs/adr/0006-desync-classification-gates-rekey.md b/docs/adr/0006-desync-classification-gates-rekey.md new file mode 100644 index 00000000..ec4973c9 --- /dev/null +++ b/docs/adr/0006-desync-classification-gates-rekey.md @@ -0,0 +1,97 @@ +# 0006. The desync classification gates the re-key, not the acknowledgement + +**Status:** Accepted + +## Context + +Two decrypt failures look similar and are not: + +- **Epoch disagreement.** The two sides of an established session disagree about + the MLS epoch. Recoverable, but only by rebuilding the session. +- **AEAD or ratchet failure.** The ciphertext is corrupt, or the generation was + already spent. Not recoverable by rebuilding anything. + +The first was given a dedicated recoverable class, and that class was +**also** used to decide whether to withhold the acknowledgement. That coupling +was the bug. + +## Decision + +Separate the two questions. + +| Question | Answer | +|----------|--------| +| Withhold the acknowledgement? | **Yes for every recoverable class**, desync and ordinary crypto failure alike | +| Schedule a re-key? | **Only for desync** | + +## Consequences + +**Good.** Ordinary crypto failures were already recoverable, because sender-side +re-sealing regenerates the ciphertext on every resend (see +[ADR 0007](0007-reseal-on-resend.md)). Drawing the no-acknowledgement boundary at +desync alone meant the receiver kept acknowledging them as delivered while the +recovery mechanism sat unused. + +**Good.** Keeping the re-key narrow avoids a re-key-storm vector. Re-keying on +AEAD or corruption failures means anyone who can corrupt a frame can force a +session teardown. + +**Cost.** Two separate decisions where a naive reading expects one, and the code +must keep them separate at every site. + +## The classification boundary in detail + +| Failure | Ack | Enqueue | Re-key | +|---------|-----|---------|--------| +| Session not ready | no | **yes** | no | +| Epoch desync | no | no | **yes** | +| AEAD / corrupt / spent generation | no | no | no | +| Transport failure | no | no | no | +| Envelope parse failure | no | no | no | +| Policy refusal that can never become decryptable | **yes** | no | no | +| Security refusal (identity mismatch, foreign session slot) | **no**, identifier unmarked | no | no | +| Failure after a successful decrypt | **yes** | no | no | + +The two refusal rows are opposite on purpose, and the security row is +intercepted before this classification runs so it cannot inherit the policy +row's acknowledgement. See +[ADR 0005](0005-defer-instead-of-drop-and-ack.md). + +**Four rows describe the table with crypto recovery enabled**: epoch desync, +AEAD / corrupt / spent generation, transport failure, and envelope parse +failure. Enabled is the default; it is off only when an application sets it off +explicitly, or when encryption is disabled outright. With it disabled, those +four collapse into drop-and-acknowledge with no re-key: the receiver treats an +undecryptable frame as permanently undeliverable rather than paying for the +heal. + +The other four rows do not move with the switch, and for two of them that is a +rule rather than an accident. The security refusal must never become gated: +turning off a recovery optimization must not turn off an interception whose +purpose is to withhold a liveness signal from an attacker. Session-not-ready +must not either, because it is the deferred atom's own fix rather than a +recovery heuristic, and acknowledging a frame that is known to become +deliverable once the session arrives is exactly the silent loss +[ADR 0005](0005-defer-instead-of-drop-and-ack.md) closes. The remaining two, the +policy refusal and the post-decrypt failure, already acknowledge in both +configurations, so there is nothing for the switch to change. + +## A subtlety worth pinning in a test + +**Malformed** input never reaches MLS framing validation and correctly stays out +of the desync class. + +A **well-formed** frame carrying a forged epoch **does** classify as desync. That +is not a classification bug; it is the unauthenticated trigger described in +[ADR 0004](0004-control-plane-signature-gate.md)'s data-plane exemption and in +the threat model. A test asserting "corrupt ciphertext is not desync" covers only +the malformed case and must not be read as covering the forged one. + +## What would undo this + +Broadening the re-key trigger to cover ordinary decryption failure, on the +reasoning that "a failed decrypt might mean a fork". It might, and acting on that +guess hands an attacker a teardown per corrupted frame. + +Narrowing the withheld acknowledgement back to desync only, on the reasoning +that "the others are not recoverable". They are, by re-sealing. diff --git a/docs/adr/0007-reseal-on-resend.md b/docs/adr/0007-reseal-on-resend.md new file mode 100644 index 00000000..b8993fa7 --- /dev/null +++ b/docs/adr/0007-reseal-on-resend.md @@ -0,0 +1,54 @@ +# 0007. Resends are re-sealed against the current session, never replayed + +**Status:** Accepted + +## Context + +An outbox entry holds a message awaiting acknowledgement. The obvious +implementation stores the serialized frame and retransmits those bytes. + +That is wrong for encrypted traffic. Ciphertext is sealed to an MLS epoch. If +the recipient's session is rebuilt, for any reason including a legitimate heal, +the stored bytes are sealed to a dead epoch and every retransmission fails +identically, forever, until the retry budget is exhausted. + +The receiver-side heal alone therefore makes a fork **detectable**, not +**recoverable**. + +## Decision + +Re-seal each resend against the recipient's **current** session state, +preserving the message identifier so deduplication and acknowledgement matching +still work. + +Re-sealing is gated on the session being confirmed. Re-sealing against an +unconfirmed session produces ciphertext the peer cannot open either. + +## Consequences + +**Good.** A session fork becomes recoverable without message loss. This is the +half that makes the receiver-side heal worth having. + +**Cost, and it constrains the implementation.** Re-seal provenance holds +**plaintext**. Two rules follow: + +1. It is memory-only and never persisted. +2. Staging is strictly transient: taking a staged re-seal always removes it, and + removing an outbox entry clears any staged re-seal, so a staged-but-dropped + send never strands plaintext. + +**Cost.** Media has no equivalent. Chunks are re-encoded rather than replayed, +so media recovers through a descriptor-based resend request instead. That +asymmetry is permanent and is why media and text differ in the recovery tables. + +## What would undo this + +Persisting the re-seal provenance "so resends survive a restart". It holds +plaintext, and that is the whole reason it is memory-only. The cost is real and +should be stated rather than argued away: a restored entry carries no +provenance, so it replays verbatim, and a fork that spans a sender restart +settles as an honest failure instead of being re-sealed. That is the price of +not writing plaintext to disk, not an absence of consequences. + +Storing the sealed bytes in the outbox and reusing them, which is the default +shape of every retry queue and is the thing this ADR exists to prevent. diff --git a/docs/adr/0008-sealed-rich-payload.md b/docs/adr/0008-sealed-rich-payload.md new file mode 100644 index 00000000..6852862c --- /dev/null +++ b/docs/adr/0008-sealed-rich-payload.md @@ -0,0 +1,90 @@ +# 0008. Rich extras travel inside the MLS plaintext or not at all + +**Status:** Accepted +**Shipped in:** 0.16.0 (direct messages and groups) + +## Context + +Rich message extras are quoted-reply previews, media metadata, and forward +attribution. They were carried as outer message fields, which are visible to +every relay and every forwarding hop. + +What that exposed: + +- **Quoted-reply previews quote another message's content**, so the outer field + leaked plaintext from a message that was itself encrypted. +- **Media metadata for cloud media includes the encryption key and the + initialization vector.** A relay holding those plus the ciphertext URL holds + the media. +- **Forward attribution names the original sender**, which is exactly the + relationship the relay should not learn. + +## Decision + +Wrap the extras and the text in a versioned body **before** encryption, so they +travel inside the AEAD boundary: + +``` +__RICH_V1__ + {text, reply_context?, media_metadata?, forward_info?, content_type?} +``` + +Toward a recipient that has not advertised support, the extras are **silently +dropped, never sent in cleartext**. + +On receipt the sealed body is **authoritative** and the outer copies are wiped +wholesale. + +## Consequences + +**Good.** The relay sees ciphertext and nothing else for the fields that matter. + +**Good.** Sealing the content-type hint closes a restamping attack. Without it a +relay can rewrite the rendering hint in transit, and rewriting it to the +file-chunk type routes the decrypted message into the file-transfer manager +where it is dropped. A sealed file-chunk claim is therefore refused on restore, +mirroring the send boundary. + +**Cost.** A downgrade loses the feature. A recipient without the capability gets +plain text with threading intact but no preview, no rich metadata, and no +attribution. That is the correct trade and it is visible to users. + +**Cost.** Forwarded cloud media only keeps its keys toward capable recipients. +The cleartext outer copies remain as the legacy fallback with secrets stripped +at the wire boundary. + +## Two rules that are easy to get backwards + +**Parsing is unconditional; sealing is gated.** A receiver tries to parse +whatever a peer chose to seal, regardless of what it advertised. A parse failure +surfaces the raw text plus a warning rather than dropping an authenticated +message. + +**The size cap is enforced at the API boundary, not at seal time.** A message +queued behind session establishment re-makes the seal decision when it flushes. +A seal-time failure there re-queues the message forever. Bounding at the +boundary means every queued blob is already known to seal. + +## The group gate is stricter, deliberately + +In a group the body is sealed only when **every** other member is known capable. +One unknown member fails the gate closed. + +Capability is established directly, or by **inviter attestation**: the Add commit +carries the added member's capability to existing members and the Welcome carries +a capability map to the joiner, so members added by someone else are still +sealable. + +Attestation feeds **only** the group sealing gate. Never 1:1 sealing, never +envelope selection. It is a second-hand claim, adequate for deciding whether to +include optional context in a group message and adequate for nothing else. + +Groups formed before attestation existed heal through the drop path, which +key-packages the unknown members once; their automatic reply reopens the gate. + +## What would undo this + +Writing the extras to the outer message "as a fallback so nothing is lost". The +fallback is the leak. + +Letting attestation feed the envelope choice, on the reasoning that it is the +same capability. It is the same field and a different trust level. diff --git a/docs/adr/0009-report-membership-changes-by-default.md b/docs/adr/0009-report-membership-changes-by-default.md new file mode 100644 index 00000000..c119a7c3 --- /dev/null +++ b/docs/adr/0009-report-membership-changes-by-default.md @@ -0,0 +1,102 @@ +# 0009. Unauthorized membership changes are reported; rejection is opt-in + +**Status:** Accepted + +## Context + +MLS Add and Remove commits are applied by every receiving member. Nothing in RFC +9420 says only an administrator may issue one; that is an application policy. + +The obvious enforcement is to reject a commit from a non-administrator. In this +protocol that is dangerous, for a reason that has nothing to do with attackers: + +**Rejecting a commit means declining the merge, which forks you permanently from +everyone who accepted it.** And the administrative overlay replicates +best-effort. Roles ride on unreconciled notifications; joiners receive a +point-in-time snapshot. A member whose role snapshot is merely **stale** would +therefore partition itself from the group with no attacker involved. + +## Decision + +**Report by default.** Emit an unauthorized-change event, rate-limited per +group, committer, **and whether enforcement was on**, and apply the commit. The +enforcement flag belongs in the key: without it an earlier report-only event +suppresses the refusal alarm for the same committer, which is the one event that +must always reach the application. + +Carry a **three-valued** authorization field on roster events: + +| Value | Meaning | +|-------|---------| +| checked and authorized | A check ran and passed | +| checked and unauthorized | A check ran and failed | +| **not evaluated** | No check ran: own Welcome join, relay-reconciled **adds** | + +Relay-reconciled **removes** are the asymmetry in that last row: the remove path +does run an administrator check against the authenticated wire sender and +reports a real verdict. Adds do not, so they report "not evaluated". + +Offer rejection as an explicit opt-in, default off, documented as suitable only +for a closed deployment that controls role distribution, never for part of a +fleet. + +## Consequences + +**Good.** An unauthorized change is visible to the application without risking a +partition. + +**Good.** The third authorization state gives the paths that ran no check +something honest to say. Emitting "authorized" from such a path is a lie the +application cannot detect. + +**Cost.** By default an unauthorized change **takes effect**. The protocol +reports; it does not prevent. + +**Cost.** Enforcement acts only on a **present** administrative set that +positively excludes a principal; absent knowledge of that set fails open. It +cannot detect a **divergent** view. Two honest members with different snapshots +each hold a non-empty set, so they reject each other and partition. That is why +it stays opt-in. + +## The fail-open rule is load-bearing + +When enforcement is on, merge anyway when: + +- the commit proposes no membership change, +- the identifier names a 1:1 session, +- group metadata is unreadable or absent, +- **the administrative set is not known to be non-empty.** + +Reject only when the administrative set is known non-empty **and** a principal +is positively not in it. The principals are the committer and the sender of each +**Add or Remove** proposal, since MLS lets a member commit a proposal another +member made. Update and PSK proposal senders are deliberately excluded: an +Update is legitimate self-service that needs no administrator, so rejecting an +admin's Add because it batched a member's key update would fork the group over a +proposal that changes no membership. + +The creator of record is deliberately **not** consulted here. One unauthenticated +claim is too thin a basis to fork over. + +## Three implementation rules + +**Enforcement runs at the decryption chokepoint, pre-merge, not in the commit +handler.** Gating only the commit handler leaves two bypasses: a commit reframed +as an application message, and an encrypted envelope naming a group identifier. + +**If either roster read fails, skip all delta-derived work.** The delta comes +from a pre-commit read and a post-merge read. A silent empty default on a failed +read fabricates a full-roster delta and a report naming an innocent committer. + +**The pre-commit roster must be MLS-derived, never the members cache.** Relay +reconciliation splices entries into that cache that were never in the tree. + +## Contrast with the leaf identity binding + +[ADR 0010](0010-unconditional-leaf-identity-binding.md) is unconditional, and +the difference is the whole reason both exist. + +An administrative verdict depends on best-effort-replicated state, so honest +members can disagree and partition each other. A leaf-binding verdict is computed +from the commit's own bytes, so every honest member reaches the same answer and a +refusal forks the **attacker** off a group that stays consistent. diff --git a/docs/adr/0010-unconditional-leaf-identity-binding.md b/docs/adr/0010-unconditional-leaf-identity-binding.md new file mode 100644 index 00000000..26978ec2 --- /dev/null +++ b/docs/adr/0010-unconditional-leaf-identity-binding.md @@ -0,0 +1,129 @@ +# 0010. Leaf identity binding is unconditional and checked at three seams + +**Status:** Accepted +**Shipped in:** 0.21.0 + +## Context + +RFC 9420 sections 5.3.1 and 7.3 assign the Authentication Service to the +**application**: the credential's presented identifiers must be correctly +associated with the signature key in the member's leaf node. + +MLS libraries do not do this. OpenMLS says so explicitly for external-commit +validation ("This MUST be checked by the application") and exposes the +credentials for the application to judge. + +Without it, an MLS basic credential is a bare self-asserted string. The +wire-sender to credential comparison that authenticates group messages then +proves only that the forger typed the name they wanted, and on the ungated group +data plane that costs a forger no signature from anyone. + +## Decision + +Every leaf entering local group state must carry the address its **own signature +key** derives to, using the single shared derivation function. + +Check it at three seams. They are not redundant: + +| Seam | When | Scope | Covers | +|------|------|-------|--------| +| Welcome | Before joining | The **whole** ratchet tree | The inviter chooses the tree wholesale | +| Commit | Pre-merge | Every credential the commit introduces or changes | New and renamed leaves | +| Use | At the sender check | The sending leaf, by index | A leaf that entered by neither gate | + +Make it **unconditional**, unlike administrative enforcement. + +## Consequences + +**Good.** A refusal forks the **attacker** off a group that stays consistent, +because the verdict is computed from the commit's own bytes and every honest +member reaches the same answer. + +**Good.** Safe for honest peers, because nothing in this protocol rotates a leaf +signature key or credential independently of the identity key. Any change that +adds such a rotation must revisit this ADR first. + +**Cost.** A roster read must filter unbound leaves, so two roster reads in the +same codebase must apply the same filter or they disagree about who is in the +group. + +## Why the Welcome walk is all-or-nothing + +Joining while skipping bad leaves leaves the joiner at an epoch computed over the +**full** tree, decrypting nothing. There is no partial join. + +## The commit walk covers four sources, not two + +An implementation that walks only Add and Update proposals leaves the +**cheapest** attack open. + +1. **The update-path leaf.** A member renames their own leaf to a peer's address. + No new leaf and no invite needed. This is the source most often missed. +2. Update proposals. +3. Add proposals. +4. Group context extensions, specifically external senders. Refused outright, as + are all non-member senders. + +Source 2 is unreachable in this protocol today and is kept deliberately. By +value, MLS attributes the proposal to the committer and forbids committing your +own update; by reference, the receiver must hold the proposal, and this protocol +drops received proposals rather than storing them. A propose-only API makes it +live. + +## Non-address credentials are refused, never skipped + +"Nothing to derive, so pass" is the bypass. It is the same bypass every +derivation check in this protocol has to close explicitly. + +## Two consequences that look unrelated and are not + +**A refused commit must be classified permanently refused.** Retriability is +decided from an allowlist, so a refusal missing from it is buffered, +re-decrypted on every drain, and, because a buffered commit that expires having +been retried reads as an epoch fork, turns one forged commit into a group-wide +key update round plus a false fork report. Assert this through the frame handler, +not the MLS layer; asserting at the MLS layer is what hid it. + +**Removal must remove every matching leaf, not the first.** Through the wire +gates a duplicate is unreachable, because MLS requires unique signature keys and +this binding ties credential to key. That argument covers the gates, not the +tree: a forged leaf written straight into a key store claims a peer's address +while carrying the attacker's key, violates no uniqueness rule, and sits beside +the victim's real leaf. First-match removal leaves the peer holding live keys +while every roster read shows them gone. + +## Reporting + +A refusal reports the peer that **delivered** the forgery, which is +signature-proved, never the impersonated address. + +The report's reason text is identifier-free, because the telemetry scrubber +hashes the peer field and ships free text verbatim, and the address at stake +belongs to the impersonated third party. See +[ADR 0013](0013-exhaustive-privacy-classifier.md). + +A roster read that finds an unbound leaf already seated in local state is a +fourth report site, and it differs in kind: no frame was refused and no peer +delivered it, so it names **this device** as the subject, and the remedy it +implies is to abandon the group rather than to evict a member. The leaf cannot +speak, but it holds live group secrets and reads everything, which no later +refusal undoes. + +The adopt path's ordering carries the same weight as the checks themselves: the +forged-tree test MUST run **before** the duplicate-session check. The adopt is +stage-then-replace and refuses non-destructively (`join_group_replacing`), so an +existing session survives the refusal; in the other order the forgery is +swallowed as a harmless retransmit. It is the opposite of harmless, and reaching +it needs only that a session with the peer already exists, which is either half +of a both-create race or any re-invite. + +## What would undo this + +Deleting an arm that looks redundant. Each of the four commit-walk sources and +each seam was individually proven load-bearing by mutation testing: the binding +is checked against ten named sabotages, each removing one arm and expecting the +suite to fail. The adversarial fixture that seats a forged leaf directly into a +key store, bypassing both entry gates, is +`MlsManager::seat_forged_leaf_for_testing`, behind the mls crate's `test-utils` +feature. Without it the use-time seam cannot be tested at all, because no honest +code path produces the state it defends against. diff --git a/docs/adr/0011-relay-broadcast-gated-on-delivery-report.md b/docs/adr/0011-relay-broadcast-gated-on-delivery-report.md new file mode 100644 index 00000000..775b667d --- /dev/null +++ b/docs/adr/0011-relay-broadcast-gated-on-delivery-report.md @@ -0,0 +1,108 @@ +# 0011. Relay broadcast defaults on, gated on a capability that guarantees a settled report + +**Status:** Accepted +**Shipped in:** 0.21.0 (the v3 capability gate) + +## Context + +Sending a group message as one directed message per member is O(N) frames and +inherits the whole direct-message delivery ladder. Asking the relay to fan out +server-side is one frame. + +The first attempt at server-side fan-out was fire-and-forget: no presence check, +no push, no persistence, and "sent" reported before delivery was known. Because +MLS application messages do not advance the epoch, a missed message was +**undetectable**. It stayed off by default, correctly. + +## Decision + +Default the broadcast **on**, gated on all four of: + +1. broadcast enabled in configuration, +2. the roster registered with the relay, +3. the relay advertising `group_delivery_v3`, +4. a live internet check. + +What makes it safe to default on is the **delivery report contract**, not the +broadcast itself: + +1. The sender mints a **logical message identifier** and carries it in the + frame. The relay stamps it onto its fan-out verbatim. +2. The sender arms a pending tracker keyed by it. +3. The relay returns a **settled** report of delivered, pushed, and missed + members. +4. The sender re-sends per-member copies to + `roster − delivered − pushed − self`. + +Step 4 covers reported misses **and members the relay never knew**, because the +relay's registered roster can be a strict subset of the MLS roster. + +## Consequences + +**Good.** One frame instead of N in the common case, with a backstop that +converges on the same delivery guarantee. + +**Good.** Re-broadcast retries reuse the **same** logical identifier, so the +relay echoes it and both receiver deduplication and push deduplication hold +across attempts. + +**Cost, known gap.** The tracker is memory-only. A process kill inside the report +window loses the backstop for that broadcast. + +**Cost.** Failure handling is three separate paths: lost report (re-broadcast, +bounded to 3 attempts, then downgrade), internet drop (downgrade immediately), +tracker overflow (downgrade the oldest). + +## Why the capability token had to be v3 and not v2 + +This is the part worth remembering, because the surface reason ("a version +bumped") hides the real one. + +v3 is v2's settled-report contract **plus an address-aware relay group path**. A +v2 relay must fail the gate **closed**, because its username-keyed path and +address identity cannot compose: + +- it cannot route to address-registered members, +- its report names members in a namespace that never intersects the MLS roster, + so the set difference re-issues to **everyone** after every broadcast, +- any copy it does deliver arrives attributed by username, which fails the + wire-sender to credential match **after** the decrypt already spent the + ciphertext's ratchet generation. + +That last one is why the **gate** is the fix rather than any receiver-side +cleanup. The generation burn is unrecoverable on the client: MLS implementations +persist message secrets through the storage provider before the identity check +runs, and skipping the group save does not undo it. + +## The receiver-side rules invert between paths + +Handling the logical identifier correctly requires **opposite** rules on the mesh +and relay paths. Getting either backwards causes silent loss. + +**Mesh path: mark only after a successful decrypt**, so a failed decrypt cannot +poison the identifier. + +**Relay path: mark at arrival, pre-decrypt**, because there the relay-supplied +identifier **is** the logical identifier and marking it early is the +replay-amplification defence. + +The relay path pays for that inversion with an obligation: **every arm that ends +with the frame neither delivered, nor buffered, nor consumed by MLS must unmark +before returning.** Otherwise a rejected copy reads as delivered, the per-member +re-issue is absorbed as a duplicate and acknowledged, and the message is +delivered nowhere while the sender is told it arrived. + +The obligation extends to the buffered drain, and that half is not optional: a +relay copy can outrun its Welcome, so it buffers before any decrypt and its +misattribution is judged on the drain instead of at arrival, an ordering a +hostile relay picks for free. + +## What would undo this + +Adding an "already delivered elsewhere?" check to the drain's plaintext branch. +An MLS decrypt consumes the generation, so reaching that branch **proves** first +delivery. The check is unreachable when true and, because the relay path marks +at arrival, suppresses the only decryptable copy when false. + +Accepting the report through generic message-plane injection rather than a +dedicated entry point. See [ADR 0014](0014-dedicated-ffi-entry-points.md). diff --git a/docs/adr/0012-one-key-package-per-peer.md b/docs/adr/0012-one-key-package-per-peer.md new file mode 100644 index 00000000..8b6296dd --- /dev/null +++ b/docs/adr/0012-one-key-package-per-peer.md @@ -0,0 +1,108 @@ +# 0012. The push path assigns one MLS init key per peer + +**Status:** Accepted +**Shipped in:** 0.20.0 + +## Context + +An MLS init key is **single-use**: it is consumed when a Welcome built against +it is processed. + +The push path returned the first stored key package to every caller and minted a +new one only once somebody's Welcome had spent it. One init key was therefore +advertised to every peer a device met until it was consumed. + +The visible bug: two peers handed the same package cannot both establish. The +second peer's Welcome is unprocessable, and the symptom is a session that never +comes up rather than an error. + +The security concern: this is the last-resort reuse RFC 9420 section 16.8 +permits only as a denial-of-service fallback, and which external MLS audits have +flagged as enabling unsolicited joins, cross-group linkage, and resource +consumption. + +## Decision + +Assign **one package per peer**. Resolution order for a push: + +1. This peer's own live package, so repeat pushes cost no key material. +2. An unclaimed package, **claimed here**. Claiming is what stops an upgrade + stranding a pre-existing package. +3. A fresh mint. + +Store the assignment **on the bundle**, not in a side map, so it survives +restarts and cannot disagree with the pool. + +Rotate on consumption: a consumed package is reported gone and the next push +mints a successor. + +## Consequences + +**Good.** Every peer gets an init key only they can spend. Session establishment +stops failing silently in the two-new-peers case. + +**Cost.** A per-push scan over the pool. Made cheap by caching each package's +provider reference at mint time, so usability checks skip a parse and a signature +validation. Without that cache a many-peer push loop costs minutes in a debug +build. + +## The ceiling shares rather than refuses + +At 64 live packages, the pool **shares the newest package** and reports +exhaustion as a suppressed warning. + +Refusing to advertise or evicting would each cost session establishment outright, +which is worse than the reuse being avoided. This is the one condition under +which the old shape is back, and it is reported so it is visible. + +**The ceiling gates only the mint**, which is the only step that grows the pool. +A claim relabels a package that already exists, so steps 1 and 2 run ahead of the +check and a full pool holding an unclaimed package still hands out its own key. +Gating the claim too would weaken forward secrecy to stay under a bound the claim +never approaches. + +Reaching the shared branch therefore proves every live package belongs to another +peer, and "newest" makes it the one most likely mid-establishment: if the +over-ceiling peer's Welcome lands first, that peer's advertisement goes +unprocessable until its next push. + +## Expiry destroys key material in two stages + +Deleting the bundle record alone leaves the private init key in the MLS provider +**forever**, because only a peer's Welcome removes one. This was the pre-existing +leak and it is the part most likely to be reintroduced. + +1. Expiry **withdraws** the package from every caller immediately. +2. Only past a grace window (7 days) is the provider key destroyed. The provider + key is deleted **first**, and the record is kept so a failed deletion can be + retried. + +The grace window exists so a Welcome built just before expiry still opens. + +Deletion also purges legacy records predating the bundle format: an unparseable +record is read as the serialized key package so its provider reference is +derivable. A record-only delete there is the exact stranding this rule removes. + +### The one record-only delete that survives + +`mark_key_package_synced` is an exception, and an unresolved one. It deletes the +record and leaves the provider key in place. No engine path calls it; it exists +only on the FFI surface, for an application that publishes a package itself and +wants it out of the pending list. + +Retaining the provider key there is not obviously wrong. A published package +must stay openable, and only the peer's Welcome consumes the key. But the record +is what carries expiry, so a published-but-never-claimed package's key has no +path to destruction at all, which is the stranding this section otherwise +forbids. + +Treat it as a known gap rather than a pattern to copy: a caller that wants the +package withdrawn should expire it, not mark it synced. + +## What would undo this + +Adding a "get a key package" convenience that does not take a peer and does not +skip assigned packages. The peerless escape hatch exists for FFI and tests, and +it must skip both reserved and peer-assigned packages. + +Deleting a key package record without purging its provider key. diff --git a/docs/adr/0013-exhaustive-privacy-classifier.md b/docs/adr/0013-exhaustive-privacy-classifier.md new file mode 100644 index 00000000..98001a40 --- /dev/null +++ b/docs/adr/0013-exhaustive-privacy-classifier.md @@ -0,0 +1,90 @@ +# 0013. Remote-influenced text is classified to a fixed vocabulary by an exhaustive match + +**Status:** Accepted + +## Context + +Telemetry events carry some free-text fields. The scrubber hashes identifiers it +knows about, but it cannot know that a free-text `reason` contains one. + +A series of leaks followed the same shape: an event field rendered an error, the +error interpolated wire input, and the wire input was an identifier belonging to +a **third party**. + +The worst example: a session-Welcome refusal rendered a session slot, which is +two addresses, one of them possibly a third party's, plus a group identifier the +sender chose, taken from raw bytes and bounded by neither charset validation nor +a length cap. + +The first fix scoped the substitution to the identity refusals, on the premise +that every other join failure was a fault rather than an accusation and named +nobody. **That premise was false**, and sibling arms at the same two sites leaked +the same things. A wider audit found the class again in relay-answer-fed error +reasons, in control-gate warnings, and in transport send-failure text. + +The exceptions kept turning out not to be exceptions. + +## Decision + +Invert the default. Instead of sanitizing sites known to leak, make leaking +unrepresentable. + +**The producer rule:** an event field never carries text chosen by a remote +party, nor a rendered error that interpolates one. Not shortened, not sanitized +in place. **Classified**, to a fixed local vocabulary. + +Two structural habits enforce it: + +1. **The classifier returns a static string type**, so interpolating wire input + is unrepresentable rather than merely discouraged. Push that type into the + event constructor, so a producer cannot hand it a rendering. +2. **The classifier's match is exhaustive in the crate that defines the error + type.** A newly added variant then fails to compile **there**, forcing the + privacy decision to be made where variants are written. + +Keep the remote wording, bounded, in a device log if it is worth keeping. + +When the dropped prose carried real structure, add it back as a **typed field** +the scrubber can hash, not as prose. + +## Consequences + +**Good.** The decision point moves from "the engineer adding an event site +remembers" to "the engineer adding an error variant cannot compile without +deciding". + +**Cost.** Event text is less specific. A support engineer reading telemetry gets +a class, not a detail, and must reach for the device log for the rest. That is +the intended trade. + +**Cost.** A family of classification functions to keep in step, one per error +family, each returning a static string. + +## Never add a catch-all arm over an error enum + +A catch-all restores the per-site opt-in this replaced, and every leak in this +class was a per-site omission. The exhaustiveness **is** the mechanism; without +it the return type alone only stops interpolation, not omission. + +Exhaustive matching on a non-exhaustive type is legal within its defining crate, +which is exactly why the classifier lives there. + +The rule is about matching on an **enum**, where exhaustiveness is available and +therefore obligatory. Some members of the family classify a wire **string** +instead, typically a code that crossed a crate or transport boundary, and there +a final arm is unavoidable because the input set is open. Those are still +conforming, on one condition: the fallback returns a fixed token and never the +input. A fallback that echoes what it did not recognize is the leak this ADR +exists to prevent, wearing a default's clothing. + +## Testing note + +A test asserting "this variant classifies without its payload" is **vacuous** if +the variant never renders its payload in the first place. Pair it with a premise +guard that asserts the variant really does render the identifier, or the +assertion passes with the classifier deleted. + +## What would undo this + +Adding a `reason` string parameter to a new event constructor because it is +convenient. Adding a catch-all to a classifier to make a new variant compile. diff --git a/docs/adr/0014-dedicated-ffi-entry-points.md b/docs/adr/0014-dedicated-ffi-entry-points.md new file mode 100644 index 00000000..f82683da --- /dev/null +++ b/docs/adr/0014-dedicated-ffi-entry-points.md @@ -0,0 +1,70 @@ +# 0014. Security-relevant relay answers arrive through dedicated entry points + +**Status:** Accepted + +## Context + +Relay answers reach the core by the bridge synthesizing a message frame with a +reserved prefix and injecting it into the ordinary receive path. + +That is convenient: one injection function serves every relay answer, and the +core needs no new surface per answer type. + +It is also a trust hole. The generic injector is reachable by anything that can +call the bridge, including push-notification handling. An answer injected that +way is indistinguishable from one the relay actually sent, and the six +relay-answer prefixes are already exempt from the signature gate because they +have no signer. + +The group delivery report made the cost concrete. It drives **re-sending** to +members the relay says it missed. A forged report can suppress the re-issue +entirely, or drive an arbitrary fan-out. + +## Decision + +Anything whose contents drive a security-relevant or delivery-relevant decision +gets a **dedicated entry point**, not message-plane injection. + +The group delivery report is the reference implementation of this: it arrives +through its own function, so the notification injector cannot forge it. Bridges +still pass the raw frame through as an opaque server message for observability, +but that path drives nothing. + +## Consequences + +**Good.** The report is unforgeable by anything that can reach the generic +injector. + +**Good.** The entry point has a typed signature, so the bridge cannot pass a +malformed shape that the core then parses defensively. + +**Cost.** A new function on the FFI surface per such answer, mirrored across four +bindings. That is the real reason the generic injector existed, and it is a cost +worth paying only for answers that drive decisions. + +**Cost.** Two paths now exist for the same relay answer, and the difference +between "drives a decision" and "is observability" has to be maintained +deliberately. + +## The rule for new answers + +Ask: **if an attacker could forge this, what would happen?** + +| Answer | Forged consequence | Path | +|--------|-------------------|------| +| Group delivery report | Suppressed re-issue, or arbitrary fan-out | **Dedicated entry point** | +| Group registration confirmation | Flips the sync gate broadcast rides on, but only inside the outstanding-registration window | Message plane, and this is residual risk R1 | +| Group info, member lists | Corrupts the members cache | Message plane | +| Relay error report | Surfaces a false error | Message plane | + +The second row is the honest answer about where the boundary currently sits. +Registration confirmation is a decision-driving answer on the message plane, and +moving it is the follow-up that would close residual risk R1 in the +[threat model](../security/threat-model.md#r1-relay-answer-forgery). + +## Related constraint + +The relay capability set must be injected **before** the internet-available +transition, so the flush that transition triggers already sees it, and cleared +when internet drops. Ordering constraints of that kind are invisible in the +bridge's own tests and belong in the [bridge contract](../bridges/README.md). diff --git a/docs/adr/0015-relay-hint-frames-unacked-and-pinned.md b/docs/adr/0015-relay-hint-frames-unacked-and-pinned.md new file mode 100644 index 00000000..ef5dd752 --- /dev/null +++ b/docs/adr/0015-relay-hint-frames-unacked-and-pinned.md @@ -0,0 +1,68 @@ +# 0015. Relay hint frames are unacknowledged and transport-pinned + +**Status:** Accepted + +## Context + +Two frames ask the local bridge to do something rather than asking a peer: +register a group roster with the relay, and broadcast a group message through +it. They are addressed to **this device**, recognized by the bridge, and +**replaced** with relay-native frames before anything reaches the wire. + +Treating them as ordinary messages is the obvious implementation and it fails in +two independent ways. + +## Decision + +Send both with acknowledgement disabled, and pin both to the internet transport +rather than routing them through the transport selector. + +Put their retry policy at the application layer instead: bounded, explicit +trackers with their own timeouts and downgrade paths. + +## Consequences + +### Why acknowledgement must be disabled + +The frame is replaced, so no acknowledgement can ever return. + +On the ordinary ladder that means 10 retransmissions over roughly 800 seconds. +Each resend is another **full relay fan-out**, under a fresh relay-minted +identifier that receiver deduplication does not catch. It ends in a delivery +failure for an identifier the application never saw, plus a transport-selector +penalty against a transport that did nothing wrong. + +So: no outbox entry, no pending acknowledgement, no retry entry. + +### Why the transport must be pinned + +The transport selector demotes the internet transport by design, so a +self-addressed frame under ordinary routing goes to a mesh transport. + +Wi-Fi Direct and Reticulum enqueue any recipient and return success. The caller +therefore believes the broadcast succeeded, skips its per-member fallback, and +delivers to **nobody**. BLE refuses a recipient that is not a connected peer, +but that only helps on a BLE-only device: the transport manager treats a +synchronous refusal as a fallback trigger and hands the frame to Wi-Fi Direct or +Reticulum anyway. Pinning, not BLE's strictness, is what closes this. + +Pinning also means transport errors propagate to the caller rather than being +deferred, which is what lets the fallback trigger correctly. + +### Cost + +Two frames that do not behave like any other frame, and a retry policy that has +to be written twice by hand rather than inherited. + +## Application-layer retry policy + +| Frame | Policy | +|-------|--------| +| Registration | 30 s timeout, 3 attempts | +| Broadcast | 60 s timeout, 3 attempts, report-settled, then downgrade to per-member fan-out | + +## What would undo this + +Routing them through the selector "for consistency". Enabling acknowledgement +"so we know it worked". Neither can work: nothing acknowledges a frame that was +replaced, and any mesh transport will happily swallow it. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 00000000..a7128344 --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,36 @@ +# Architecture decision records + +Short records of decisions that are expensive to reverse or easy to undo by +accident. Each states the forces, the decision, and what it costs. + +An ADR is written when a decision is **non-obvious**: when a reasonable engineer +would pick differently without knowing the context, or when a later change could +silently undo it. Decisions that follow from the obvious default do not need one. + +## Index + +| # | Decision | Status | +|---|----------|--------| +| [0001](0001-json-as-permanent-wire-floor.md) | JSON is the permanent wire floor; compact encodings are additive | Accepted | +| [0002](0002-frozen-dto-with-extension-tlv.md) | The binary encoding uses a frozen positional DTO with an extension TLV | Accepted | +| [0003](0003-self-certifying-addresses.md) | Identity is a self-certifying address, not a trust-on-first-use pin | Accepted | +| [0004](0004-control-plane-signature-gate.md) | Control frames are signature-gated with two documented exemption classes | Accepted | +| [0005](0005-defer-instead-of-drop-and-ack.md) | A receiver that cannot deliver withholds the acknowledgement | Accepted | +| [0006](0006-desync-classification-gates-rekey.md) | The desync classification gates the re-key, not the acknowledgement | Accepted | +| [0007](0007-reseal-on-resend.md) | Resends are re-sealed against the current session, never replayed | Accepted | +| [0008](0008-sealed-rich-payload.md) | Rich extras travel inside the MLS plaintext or not at all | Accepted | +| [0009](0009-report-membership-changes-by-default.md) | Unauthorized membership changes are reported; rejection is opt-in | Accepted | +| [0010](0010-unconditional-leaf-identity-binding.md) | Leaf identity binding is unconditional and checked at three seams | Accepted | +| [0011](0011-relay-broadcast-gated-on-delivery-report.md) | Relay broadcast defaults on, gated on a capability that guarantees a settled report | Accepted | +| [0012](0012-one-key-package-per-peer.md) | The push path assigns one MLS init key per peer | Accepted | +| [0013](0013-exhaustive-privacy-classifier.md) | Remote-influenced text is classified to a fixed vocabulary by an exhaustive match | Accepted | +| [0014](0014-dedicated-ffi-entry-points.md) | Security-relevant relay answers arrive through dedicated entry points | Accepted | +| [0015](0015-relay-hint-frames-unacked-and-pinned.md) | Relay hint frames are unacknowledged and transport-pinned | Accepted | + +## Format + +Keep them short. Context, decision, consequences, and where relevant an +explicit "what would undo this" note, because that is the part a future change +needs to see. + +Status values: Proposed, Accepted, Superseded by NNNN, Deprecated. diff --git a/docs/bridges/README.md b/docs/bridges/README.md new file mode 100644 index 00000000..be32da59 --- /dev/null +++ b/docs/bridges/README.md @@ -0,0 +1,205 @@ +# Bridge contracts + +The Rust core is exposed to Swift, Kotlin, Python, and TypeScript. This +directory states what each binding owes the core and what the core owes each +binding. + +| Document | Scope | +|----------|-------| +| This file | The contract every binding shares | +| [Swift](swift.md) | iOS native and the React Native iOS bridge | +| [Kotlin](kotlin.md) | Android native and the React Native Android bridge | +| [Python](python.md) | Desktop and tooling | +| [TypeScript](typescript.md) | The React Native JavaScript surface | + +Integration **guides** live elsewhere: [iOS](../ios-integration.md), +[Android](../android-integration.md), +[React Native](../react-native-integration.md). This directory is the contract, +not the tutorial. It is what you read before changing the boundary, not before +using it. + +## Why this needs writing down + +Every rule here shares one property: **violating it fails silently.** + +Nothing in the toolchain catches a partial binding regeneration, a config field +dropped in a bridge parser, an error variant inserted rather than appended, or a +constant list that disagrees across three languages. A green `cargo clippy` and a +green `tsc` say nothing about any of them. + +## C1. Regenerate every binding together + +The Swift, Kotlin, and Python bindings are **one artifact set**, not three +independent ones. They come from one bindgen run over one interface definition +and carry the FFI checksums of the library they were generated against. + +```bash +./scripts/generate-bindings.sh +``` + +Regenerating a subset leaves the others describing a different ABI. **That fails +no build.** It fails the app, at the first call, with a checksum mismatch. + +Every path that generates bindings delegates to that one script: the React +Native wrapper, both platform build scripts, the Python desktop build, and both +CI workflows. + +The release workflow matters most and is the least obvious: the Kotlin it +generates is downloaded **over** the committed Android bindings in the publish +job, so it, not the committed file, is what ships. Anything that generates there +must come through the same script, or the released Kotlin and the released Swift +were built by different bindgens. + +## C2. The error enum is append-only + +Generated bindings decode the error enum by **positional discriminant**. +Inserting or removing a variant, or reordering, breaks every committed binding +in every language, silently, by shifting every variant after the edit. + +New variants are appended. Never inserted, never removed, never reordered. + +The core maps its internal errors to this enum by classifying every variant +explicitly. That match is **no longer compiler-enforced**: the engine error +types are `#[non_exhaustive]`, which forces a wildcard arm, so a newly added +internal variant compiles cleanly and reaches the boundary as `Other`, logging a +warning at runtime. + +Treat the wildcard as a tripwire, not a destination. After adding an internal +error variant, classify it here as well, and watch for that warning: the compile +error that used to catch the omission is gone. + +## C3. Events cross as opaque JSON + +Events are serialized to JSON in the core and cross the boundary as a single +string. Nothing about an event's field set appears in the interface definition. + +Consequences, in both directions: + +**Adding or changing an event field needs no interface regeneration.** This is +deliberate. Events change far more often than the API surface, and coupling them +would mean a full four-language regeneration for every field. + +**The bindings' event types are therefore unchecked by the compiler.** A +TypeScript event interface that has drifted from what the core emits compiles +perfectly and fails at runtime. The core carries tests that pin the event tag +strings against the TypeScript definitions for exactly this reason, and those +tests are the only mechanism holding the two in step. + +**A field added to an event is invisible to a binding until someone adds it +there.** There is no warning. + +## C4. Security-relevant answers get dedicated entry points + +Anything whose contents drive a delivery or security decision arrives through +its own function, never by injecting a synthesized message frame into the +generic receive path. + +The group delivery report is the reference case. Bridges may still pass the raw +frame through as an opaque server message for observability, but that path +drives nothing. + +See [ADR 0014](../adr/0014-dedicated-ffi-entry-points.md). + +## C5. Hand-mirrored constants must be pinned in every language + +Some constants exist in several places no single compiler sees together. Four +sets do today, and they are pinned by **two different** mechanisms, so knowing +which one you are touching matters. + +**The relay-answer prefix exemption list** is the canonical example: the core, +the Swift bridge, and the Kotlin bridge each hold a copy. A prefix present in one +copy and absent from another **fails silently**: the bridge injects the answer +unattributed, the core's gate declines to exempt it, and the frame is dropped as +unsigned with no peer at fault. The visible symptom is a relay feature quietly +not working. + +Each copy of that list is pinned against **literals** in its own language's test +suite. A test that recomputes the list from the constant it is checking agrees +with any edit, which is precisely the failure mode. + +**The protocol-state record ceiling** is the second set, and it is wider: three +binding sites across three languages, Python included +(`ProtocolStateStorage.swift`, `ProtocolStateStorage.kt`, `state_storage.py`), +plus the Rust constant they mirror. It is pinned the other way round, by a +single **Rust** guard that reads all three binding sources and asserts the +literal `8 * 1024 * 1024` in each. There is no per-language test for it, so a +binding edited alone fails the Rust suite rather than its own. See +[S6](swift.md#s6-secure-storage). + +The one-shot event tag list and the mesh wake task key are pinned the same way, +by Rust guards that read the binding sources. + +## C6. Config parsers must not default to literals + +A bridge parsing a config object must distinguish "the caller did not supply this +field" from "the caller supplied the default value". + +A parser that reads each field with a literal fallback turns every **partial** +config update into a silent reset of every field the caller did not mention. +This has shipped as a bug more than once. + +Where a binding accepts both a nested section and flat keys for the same setting, +**nested wins over flat**, and both spellings must be pinned in the bridge's own +parser tests. + +## C7. Ordering constraints across the boundary + +Some sequences are constrained and the constraint is invisible on either side +alone. + +| Constraint | Why | +|------------|-----| +| Relay capabilities injected **before** the internet-available transition | The flush that transition triggers must already see them | +| Relay capabilities cleared **on** internet drop | Otherwise a stale capability keeps the broadcast gate open | +| Per-peer end-to-end capabilities restored **before** queued sends flush | Otherwise the startup flush emits downgraded envelopes to every established peer | + +## C8. The identifier the bridge reports must match the namespace it is asked for + +A bridge holds identity in more than one namespace: the protocol address, and +whatever the relay or directory keys by. + +Handing back the wrong one is a silent failure, because both are non-empty +strings that look plausible. Every bridge function that returns or compares an +identity must state which namespace it is in. + +## C9. Bridge behaviour is not covered by the Rust test suite + +`cargo test` proves nothing about the bridges. Each binding needs its own tests, +and they are fast enough that there is no excuse: + +| Binding | Test entry point | Rough cost | +|---------|-----------------|------------| +| Swift | `swift test` in the React Native iOS package | seconds | +| Kotlin | Gradle unit tests, Robolectric where a platform type is needed | tens of seconds | +| Python | pytest against the built desktop library | seconds | +| TypeScript | `tsc` plus the JS harness | seconds | + +**After a rename, grep for the old identifier across every binding.** The +compiler will not find it in the three languages that do not share a type system +with the one you changed. + +## C10. Lifecycle rules for event emission + +An event delivered to a binding that has no live host instance is **lost**. The +core does not retain it, and the core cannot know the host is gone. + +Two shapes solve this and they are not interchangeable: + +- **Restatement** for state that has a current value: derive the current state + from a latch and re-emit it on subscribe. Correct for presence, connection + status, transport state. +- **A held one-shot** for events that fire once and matter: hold the event until + a subscriber acknowledges it, then clear. Correct for one-time results. + +Which one applies depends on **where the event fires** relative to subscription, +not on what the event means. A replay-on-subscribe mechanism alone does not fix +a one-shot that fires before any subscriber exists. + +## What each binding owes + +| Binding | Owes | +|---------|------| +| Swift | The manual Objective-C bridge kept in step with every `@objc` method; secure storage backed by Keychain; a live-instance check before emitting | +| Kotlin | Secure storage backed by Keystore; no blocking work on the main looper; awareness that platform callbacks arrive on binder threads | +| Python | Nothing platform-specific; it is the thinnest binding and therefore the best place to smoke-test an ABI change | +| TypeScript | Config normalization, event typing kept in step with the core, and no assumption that a native method exists in an older binary | diff --git a/docs/bridges/kotlin.md b/docs/bridges/kotlin.md new file mode 100644 index 00000000..8443d4f4 --- /dev/null +++ b/docs/bridges/kotlin.md @@ -0,0 +1,164 @@ +# Kotlin bridge contract + +Covers the generated Kotlin bindings, the native Android surface, and the React +Native Android bridge. + +Read [the shared contract](README.md) first. This document covers what is +specific to Kotlin. + +## The generated layer + +UniFFI produces Kotlin from the interface definition. It is one third of the +artifact set described in [C1](README.md#c1-regenerate-every-binding-together) +and is never regenerated alone. + +**The release workflow regenerates the Kotlin and downloads it over the committed +file in the publish job.** The generated artifact, not the committed one, is what +ships. Any generation step in CI must therefore go through the same script, or +the released Kotlin and the released Swift were built by different bindgens. + +## K1. Platform callbacks arrive on binder threads + +Bluetooth GATT callbacks and most Android system callbacks are delivered on +binder threads, not on the main looper and not on your own executor. + +Anything they touch is concurrent. Fields read across such a boundary need +explicit visibility guarantees; a `@Volatile` on a status field is load-bearing, +not decorative. + +## K2. Nothing blocking on the main looper + +FFI calls and socket work must not run on the main looper. A blocking call there +is an application-not-responding report, and the SDK gets the blame for the +freeze regardless of which layer blocked. + +A scheduled executor with a fixed-rate schedule runs **gapless on overrun**: if +one tick takes longer than the period, the next fires immediately. For work whose +duration varies, prefer a fixed delay. + +## K3. Foreground service lifecycle + +A sticky foreground service restart is exempt from the API 31 and later +restriction on starting a foreground service from the background. That exemption +is what makes mesh wake work. + +The wake service is referenced by fully-qualified class name string, so a rename +or a package move is not caught by the compiler. Grep for the string. + +Invalidation of the service handle is deliberately unsynchronized; the design +tolerates a lost race rather than holding a lock across a platform call. + +## K4. Secure storage + +The same split as Swift applies, with the same two rules. `MlsSecureStorage.kt` +implements secure storage against Keystore, where adoption of an existing store +is read-through plus claim, never a copy. `ProtocolStateStorage.kt` is the +file-backed protocol-state provider, and its `MAX_VALUE_BYTES` must spell +`8 * 1024 * 1024` exactly, matching +`MAX_PROTOCOL_STATE_RECORD_TRANSFER_BYTES` rather than merely exceeding +anything. See [S6](swift.md#s6-secure-storage) for why that distinction matters +and which Rust guard reads this file. + +A logout wipe must clear every namespace the SDK wrote, which is a +bindings-level concern because only the binding knows the platform store layout. + +## K5. Config parsing + +The React Native Android bridge parses the config object handed down from +JavaScript. It must: + +- distinguish an absent field from a field set to the default value, or every + partial update silently resets what it did not mention (see + [C6](README.md#c6-config-parsers-must-not-default-to-literals)), +- accept both a nested section and flat keys, with **nested winning**, +- accept both camelCase and snake_case spellings where the surface historically + did. + +The last two are pinned in `ProtocolConfigParserTest.kt`. Add a case there for +every new field rather than trusting the parser's shape. + +The first is pinned elsewhere, and the split is not arbitrary. Preserving an +absent field is a property of the **update** path, which reads the live config +and merges, so it cannot be exercised against the parser alone: the module that +performs the merge cannot be instantiated in a plain unit test (see +[Running them locally](#running-them-locally)). The Rust guard +`react_native_bridges_merge_dors_updates_from_the_live_config` pins it instead, +by reading the bridge source. Note the consequence: `ProtocolConfigParserTest.kt` +pins literal defaults at **initial** parse, which is the opposite mechanism, so a +green run there says nothing about partial updates. + +## K6. Sticky and one-shot events + +Events that fire before JavaScript subscribes are lost. The Android bridge +carries a sticky buffer and a dispatcher for this, covered by +`StickyEventBufferTest.kt` and `StickyEventDispatcherTest.kt`. + +Which mechanism applies depends on where the event fires relative to +subscription, not on what it means. See +[C10](README.md#c10-lifecycle-rules-for-event-emission). + +## K7. Pinned constant lists + +`RelayAnswerPrefixes.kt` holds one of the three copies of the relay-answer +exemption list, pinned in `RelayAnswerPrefixesTest.kt`. + +See [C5](README.md#c5-hand-mirrored-constants-must-be-pinned-in-every-language). + +## Testing + +Unit tests live in +`bindings/react-native/android/src/test/java/com/offlineprotocol/`. + +Robolectric is used where a platform type is unavoidable. Each suite that needs +it carries its own `@Config(sdk = [...])` annotation; there is no +`robolectric.properties` and no Robolectric block in `build.gradle`. Without the +annotation Robolectric picks a level the project does not target and fails for +unrelated reasons. + +### Running them locally + +CI runs them through the standalone harness in +`bindings/react-native/android-ci-harness`, and following that harness's README +on a development machine works with the currently pinned React Native. + +It is worth knowing why, because the mechanism is one dependency bump away from +biting again. `android/build.gradle` chooses how to depend on React Native by +testing for `node_modules/react-native/**android**`, a legacy local Maven +repository: + +| `node_modules/react-native/android` | Dependency | Result | +|-------------------------------------|-----------|--------| +| Absent (CI, and local dev on the currently pinned React Native) | `compileOnly` on a pinned version from Maven Central | Resolves | +| Present (React Native versions that still shipped that directory) | `implementation` with **no version**, from that local Maven directory | Resolves to an empty version and fails | + +The directory test is deliberately on `android`, not on the package directory. +Newer React Native keeps its sources under `ReactAndroid/` and publishes no +`android/` Maven repo, so testing only for the package would produce an +unversioned `react-android` dependency and fail in the harness for a reason that +looks unrelated to whatever you changed. + +If a future bump reintroduces that layout, or you need to reproduce CI exactly, +copy the module somewhere with no sibling `node_modules` and point a copy of the +harness at it: + +```bash +rsync -a --exclude build/ --exclude .gradle/ bindings/react-native/android /tmp/rn-android-ci/ +rsync -a bindings/react-native/android-ci-harness/ /tmp/rn-android-ci/harness/ +cd /tmp/rn-android-ci/harness +ANDROID_HOME=~/Library/Android/sdk gradle :offlineprotocol:testDebugUnitTest +``` + +Needs JDK 17 and the Android SDK. The console prints only `BUILD SUCCESSFUL`; +for counts, read the `tests=` and `failures=` attributes in +`android/build/test-results/testDebugUnitTest/*.xml`. + +Two consequences of the `compileOnly` path that shape what you can test: + +- Anything React Native pulls in **transitively** is absent at test runtime. + Production code compiles, then the test dies with `NoClassDefFoundError`. Add + the specific androidx artifact as a test dependency when a new test reaches + one. +- `OfflineProtocolModule` extends a React base class, so it cannot be + instantiated at unit-test runtime **at all**. That is a design constraint, not + only a testing one: put an invariant that needs coverage in a collaborator, + not in the module. diff --git a/docs/bridges/python.md b/docs/bridges/python.md new file mode 100644 index 00000000..8e0bfa57 --- /dev/null +++ b/docs/bridges/python.md @@ -0,0 +1,76 @@ +# Python bridge contract + +Covers the generated Python bindings and the desktop package. + +Read [the shared contract](README.md) first. This document covers what is +specific to Python. + +## The generated layer + +UniFFI produces Python from the interface definition. It is one third of the +artifact set described in [C1](README.md#c1-regenerate-every-binding-together) +and is never regenerated alone. + +The desktop build script delegates to the same generation script, and CI builds +the desktop package through it. + +## P1. This is the thinnest binding, and that makes it the ABI canary + +Python has no platform integration to speak of: no Keychain, no Keystore, no +Bluetooth stack, no React Native lifecycle. It is close to a direct view of the +interface definition. + +That makes it the cheapest place to detect an ABI break. If a change makes the +Python package fail to import or a call fail on a checksum, the Swift and Kotlin +bindings have the same problem and will surface it later, on a device, in a +harder-to-diagnose form. + +Run the Python tests before the mobile ones when changing the interface. + +## P2. Nothing here is a reference implementation of platform concerns + +The Python package does not implement secure storage against a platform keystore. +Do not copy its storage handling into a mobile binding, and do not treat its +behaviour as the contract for one. + +It does carry one shared constant, which is easy to miss precisely because the +rest of the binding is platform-free: `state_storage.py` holds one of the four +copies of the protocol-state record ceiling, and it must spell +`8 * 1024 * 1024` exactly. A Rust guard reads this file, so editing the ceiling +in the mobile bindings and not here fails the **Rust** suite, not `pytest`. See +[C5](README.md#c5-hand-mirrored-constants-must-be-pinned-in-every-language). + +## P3. Packaging + +The package is versioned in lockstep with the workspace. A release cut touches +the Python project metadata along with the Cargo manifests, the lockfile, and the +third-party notices. + +## P4. The error enum is positional here too + +The same append-only rule applies. See +[C2](README.md#c2-the-error-enum-is-append-only). + +## P5. Events + +Events arrive as JSON strings, exactly as in the other bindings. Python's +dynamism makes it tempting to consume them ad hoc, and that is fine for tooling, +but it means the Python surface offers no drift protection at all. It will not +catch a renamed event field for you. + +## Testing + +```bash +cd bindings/python +# build the desktop library first, then +pytest +``` + +Tests live in `bindings/python/tests/`. The build script under +`bindings/python/scripts/` produces the library the tests load. + +## Note on packaging tests + +Guard tests that assert on repository layout **panic in a packaged tarball**, +because the layout is not there. Keep such assertions out of the packaged test +set. diff --git a/docs/bridges/swift.md b/docs/bridges/swift.md new file mode 100644 index 00000000..06f55abd --- /dev/null +++ b/docs/bridges/swift.md @@ -0,0 +1,172 @@ +# Swift bridge contract + +Covers the generated Swift bindings, the native iOS surface, and the React +Native iOS bridge. + +Read [the shared contract](README.md) first. This document covers what is +specific to Swift. + +## The generated layer + +UniFFI produces Swift from the interface definition. It is one third of the +artifact set described in [C1](README.md#c1-regenerate-every-binding-together) +and is never regenerated alone. + +The generated Swift lives under `ios/Generated/` and is committed. It carries +FFI checksums that must match the compiled library shipped alongside it. + +## S1. The Objective-C bridge is hand-written and must be kept in step + +React Native reaches Swift through an Objective-C bridge file declaring each +method with `RCT_EXTERN_METHOD`. **UniFFI does not generate it and React Native +does not generate it.** + +Update it whenever you add an `@objc` method, change a parameter list, or change +a return type. + +Type mapping: + +| Swift | Objective-C | +|-------|-------------| +| `String` | `NSString *` | +| `String?` | `NSString *` (nullable) | +| `Int` | `nonnull NSNumber *` | +| `Bool` | `BOOL` | +| Promise | `RCTPromiseResolveBlock` / `RCTPromiseRejectBlock` | + +A method present in Swift and absent from the bridge is simply not callable from +JavaScript. There is no error at build time. + +## S2. Five registration points per new Swift file + +A new Swift source file in the React Native iOS package must be registered in +five places, and missing any one produces a different, unhelpful symptom: + +1. The podspec's source file list, which enumerates top-level files one by one + (`ios/ble/**`, `ios/mesh/**` and `ios/Generated/*.h` are the only globs), so + a new top-level file is invisible to CocoaPods until it is added. This one is + not silent: the Rust guard + `react_native_podspec_ships_every_hand_written_ios_source` fails `cargo test` + on an unlisted top-level source. A file added under `ble/` or `mesh/` needs + no podspec edit at all. +2. The Swift package manifest's target sources, for the typecheck harness. +3. Any exclusion list it must **not** be in. +4. The test target, if it has tests. +5. **The `.github/workflows/ci.yml` "iOS bridge typecheck" file list**, if the + file is one the package manifest excludes. This is the one most often + forgotten, because the local recipe globs the directory while CI enumerates + explicitly: the local harness stays green and CI fails with `cannot find + 'YourNewType' in scope`. + +The typecheck harness is only meaningful if the file is actually in it. Verify by +negative control: break the file deliberately and confirm the harness fails. + +## S3. Emit requires a live instance, and the check is subtler than it looks + +An event emitted when no React instance exists is lost. The core cannot know +that, so the bridge must check. + +**An optional `@objc` protocol member accessed through an existential is a double +optional.** Testing it against nil is therefore always true, and the guard +silently passes. This has shipped as a bug. Unwrap both levels explicitly. + +The precondition is pinned by the Rust guard +`react_native_ios_emit_gate_has_live_instance_precondition`, which reads the +bridge source, so removing the check fails `cargo test` rather than only failing +on a device. + +## S4. Never hold a strong reference during deallocation + +Taking a weak reference to an object that is already deallocating is a **hard +abort**, not a nil. Teardown paths must not capture self weakly and then +resurrect it. + +A device reproduction of this class needs the app to fully initialize first; a +harness that tears down immediately after construction does not reach the state +where it fires. + +## S5. Threading + +- Status-plane FFI calls run off the main thread, on the module's own queue. +- Blocking socket I/O must **not** share a thread that a stop or teardown path + waits on. Mixing latency classes on one confinement thread turns a slow socket + into a hang. +- CoreBluetooth resolves cached versus dynamic characteristic behaviour at + service registration time, not at read time. A characteristic whose value + changes must be declared accordingly when the service is added. + +## S6. Secure storage + +There are two storage surfaces and they have different rules. + +**Secure storage** (`MlsSecureStorage.swift`, backed by Keychain) holds MLS key +material. Adoption of an existing store is read-through plus claim, not copy. A +copy leaves two sources of truth. + +**Protocol-state storage** (`ProtocolStateStorage.swift`, file-backed) holds +restartable protocol state and carries a per-record ceiling of `8 * 1024 * 1024` +that it must spell **exactly**. That value is not a local choice: it mirrors +`MAX_PROTOCOL_STATE_RECORD_TRANSFER_BYTES`, and +`built_in_providers_mirror_the_transfer_ceiling` reads this source and asserts +the literal, so drift in **either** direction fails the Rust suite. + +The relationship is easy to state backwards. The ceiling is a deliberate +superset of the core's own record cap plus its seal envelope, so a provider +enforcing it never rejects a record the SDK legitimately wrote. That +superset relation is what "above" refers to, and it is pinned separately by +`bounded_load_ceiling_is_a_superset_of_the_record_cap`. The provider's job is +not to stay above anything, it is to match the ceiling exactly. + +This ceiling is a hand-mirrored constant across **four** sites in three binding +languages, Python included. See +[C5](README.md#c5-hand-mirrored-constants-must-be-pinned-in-every-language). + +## S7. Pinned constant lists + +`RelayAnswerPrefixes.swift` holds one of the three copies of the relay-answer +exemption list. It is pinned against literals in `RelayAnswerPrefixesTests.swift`. + +See [C5](README.md#c5-hand-mirrored-constants-must-be-pinned-in-every-language). + +## Testing + +```bash +cd bindings/react-native/ios +swift test +``` + +Roughly one second. There is no reason to skip it. + +The test target covers the policy and translation types that carry real logic, +21 suites at the time of writing, including config reading, relay control-op +translation, fragment buffering, rate limiting, presence policy, identity +binding, address declaration, legacy store adoption, the write-stall watchdog, +the superseded latch, and the pinned prefix list. Read `Package.swift` for the +current set rather than trusting this sentence. + +**Error mapping is not in that list.** `ProtocolErrorBridge` depends on the +generated UniFFI module, so both it and its test suite are excluded from the +package manifest; they ride the app build only. The same holds for the mesh +controller and the Bluetooth discovery bootstrap policy: suites exist, `swift +test` does not run them. + +**Excluded from `swift test` does not mean unchecked.** A separate CI step, +"iOS bridge typecheck (files excluded from the SwiftPM harness)", runs `swiftc +-typecheck` over the sources the package manifest leaves out, including +`BleManager.swift`, the Wi-Fi Direct and Reticulum managers, and their `mesh/` +and `ble/` collaborators. They are typechecked on every run, just not +unit-tested. + +**Two files are covered by neither**, and both ride the app build alone: + +- `OfflineProtocolModule.swift`, which needs real React headers. The + symlink-farm harness in `ios/BRIDGE_MAINTENANCE.md` exists for this one. If + you touch it, run the harness, and negative-control it: a shell slip produces + a clean exit that proves nothing. +- `ProtocolErrorBridge.swift`, which depends on the generated UniFFI module. It + is on the package manifest's exclusion list and absent from the CI typecheck + list, and its suite is excluded too, so nothing in CI compiles it. + +Note that the comment above the CI step claims `OfflineProtocolModule.swift` is +the only uncovered file. That comment is stale; the exclusion list in +`Package.swift` is the source of truth. diff --git a/docs/bridges/typescript.md b/docs/bridges/typescript.md new file mode 100644 index 00000000..bcc3727c --- /dev/null +++ b/docs/bridges/typescript.md @@ -0,0 +1,134 @@ +# TypeScript bridge contract + +Covers the React Native JavaScript surface. + +Read [the shared contract](README.md) first. This document covers what is +specific to TypeScript. + +## T1. TypeScript is not generated, and the compiler proves less than it appears to + +UniFFI does not produce TypeScript. The JavaScript surface is hand-written over +the two native modules. + +`tsc` passing means the TypeScript is internally consistent. It says nothing +about whether: + +- the native method being called exists, +- the event shape being destructured is what the core emits, +- a config key being passed is one the bridge parser reads. + +All three are runtime failures that compile cleanly. + +## T2. Event types are pinned from the Rust side + +Events cross as opaque JSON ([C3](README.md#c3-events-cross-as-opaque-json)), so +the TypeScript event interfaces have no compile-time link to the core. + +The core holds guard tests that read `bindings/react-native/src/types.ts` and +pin the event **tag** strings, the security-warning codes, and the protocol-state +member names against the declarations there. Those tests, not the TypeScript +compiler, are what keep the two vocabularies in step. + +Know what they do not cover. They check that every event variant has a +declaration carrying the right `type` discriminant; they do **not** check the +field set inside it. The tests that pin event field shapes assert against Rust +literals and never read `types.ts`. So a renamed or removed event **field** +passes every guard in the repository and arrives mistyped at runtime. + +Adding an event field means updating `types.ts` in the same change, and nothing +will remind you. When `types.ts` lags, nothing fails loudly: the event simply +arrives untyped. + +## T3. Cross-language constant sets are pinned by Rust guards + +Two constant sets in `constants.ts` are mirrored by hand into other languages, +up to four definitions across up to three languages, and drift fails silently +while everything still compiles: + +| Constant | Definitions | Also defined in | Guard | +|----------|-------------|-----------------|-------| +| `ONE_SHOT_EVENT_TYPES` | 4, across 3 languages | Kotlin module event constant, Kotlin and Swift superseded-latch policy | `react_native_one_shot_event_set_matches_native` | +| `MESH_WAKE_TASK_KEY` | 2, across 2 languages | Kotlin mesh wake policy | `react_native_mesh_wake_wiring_is_present` | + +The wake task key is the worse of the two to break: React Native logs "No task +registered for key" to the device log, the app sees an opt-in that does nothing, +and both sides compile. + +## T4. One-shot membership is a decision, not a filter + +An event belongs in the one-shot set only when **redelivering it late is better +than losing it**. + +A held periodic event replayed after the fact reports a state that has since +changed, which is worse than the drop it replaced. That is why the set is two +entries and not twenty. + +Both current members are events emitted after the thing that would restate them +is already down, so there is no later event carrying the same news. + +## T5. The two gaps are covered by different mechanisms + +| Gap | Mechanism | Lives in | +|-----|-----------|----------| +| native to JavaScript | Sticky buffer | Kotlin | +| JavaScript to app listener | Held one-shot | TypeScript | + +They are not interchangeable and neither covers the other's gap. + +The JS-side hold has two operations and they have **opposite** timing rules. +Getting them the wrong way round reintroduces the bug the other one fixes: + +| Operation | Timing | Why | +|-----------|--------|-----| +| Replay: removing an entry from the hold | **synchronously**, when the replay is scheduled | Several `on(...)` calls in the same tick would otherwise each schedule a replay of the same entry, and the app would see it once per registration. This is safe only because replay goes back through the emitter, which re-holds the event if the listeners vanished in the interim | +| Replay: delivering the entry | behind a **microtask yield** | So a listener never fires before the `on(...)` that registered it returns, and every same-tick registration is served | +| Teardown: clearing the hold in `destroy()` | behind a **microtask yield** | The re-hold above defeats a synchronous clear: an `on(...)` plus `destroy()` in the same tick leaves a replay microtask that runs after `destroy()`'s synchronous body, finds the listener map empty, and re-holds behind the clear. Instances are reusable, so the next session's first listener would receive the previous session's event | + +The yield in the teardown row must be unconditional. Guarding it behind an +"is this instance created" check makes the method fully synchronous for an +uncreated instance, which is exactly the case that skips the only other await. + +**Never assert inside a listener** in a test for this. An assertion that throws +inside a listener is swallowed by the emitter and the test passes. + +## T6. Config normalization + +The JavaScript surface accepts both a nested config section and flat keys. +**Nested wins.** It forwards both spellings down to the native parsers, which +apply the same precedence (see [C6](README.md#c6-config-parsers-must-not-default-to-literals)). + +A config section added on the JS side that no native parser reads is silently +inert. Add the parser case and its test in the same change. + +## T7. Over-the-air JavaScript updates can outrun the native binary + +A JavaScript-only update that starts calling a **new** native method against an +older native binary fails those calls with a method-not-found error. Existing +methods are unaffected. + +Any new native method reachable from JavaScript is therefore a native-version +dependency, and a JS-only deployment channel needs to guard it. + +## T8. The published package's build output is not the local one + +The `lib/` directory is gitignored and goes stale. Never read it to determine +what shipped. Check the published tarball, or do a clean rebuild. + +A gitignore rule for a bare `lib/` matches at any depth, which has already eaten +a `scripts/lib` directory once. Note that the rule here is **still** unanchored: +that collision was resolved by renaming the victim to +`bindings/react-native/scripts/shared`, not by +fixing the pattern. Anchor it as `/lib/` before adding any nested `lib/` +directory, or expect the same silent disappearance. + +## Testing + +```bash +cd bindings/react-native +npx tsc --noEmit +``` + +Plus the JavaScript harness under `js-ci-harness/`. + +The example app under `examples/react-native-app/` is **not** typechecked by any +CI job. Changes there are unverified unless you check them by hand. diff --git a/docs/changelog/0.10.md b/docs/changelog/0.10.md new file mode 100644 index 00000000..5325332f --- /dev/null +++ b/docs/changelog/0.10.md @@ -0,0 +1,72 @@ +# Changelog: 0.10.x + +Archived release notes. The working changelog, covering the current release +and unreleased changes, is [CHANGELOG.md](../../CHANGELOG.md). + +| Release | Date | +|---------|------| +| 0.10.0 | 2026-04-13 | + +## [0.10.0] — 2026-04-13 + +### Breaking Changes + +- **`TransportType` gained `Reticulum` and `Nostr` variants** + `TransportType` is now a five-variant enum (`BLE`, `WiFiDirect`, `Internet`, `Reticulum`, `Nostr`) across Rust, UDL, Swift, Kotlin, and TypeScript. Exhaustive `switch`/`when` statements over `TransportType` must add the two new cases. `ProtocolConfig` gains matching `reticulum_enabled` and `nostr_enabled` fields (default `false`). + +- **`BleTransport::set_mtu` / `mtu` global accessors removed** ([#86](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/86)) + Replaced by per-peer `bleSetPeerMtu(deviceId, maxPayload)` / `bleClearPeerMtu(deviceId)` UniFFI methods. Callers must pass the *header-adjusted* maximum usable payload (Android subtracts the 3-byte ATT overhead from `onMtuChanged`; iOS reads `maximumWriteValueLength(for: .withoutResponse)` directly). The platform managers shipped in this release already do this — only direct UniFFI integrations need updating. There is also a strict ordering invariant: call `bleSetPeerMtu` *before* `blePeerDiscovered` for each peer, otherwise the very first fragment falls back to the 185-byte floor. + +- **`fragment_message` requires a recipient** + `BleTransport::fragment_message(message)` is now `fragment_message(recipient, message)` so per-peer MTU lookup is keyed correctly. Affects the `offline-protocol-bench` crate and any direct callers; the production protocol path is unaffected. + +### Added + +#### New transports + +- **Reticulum mesh transport** ([#75](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/75), [#80](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/80), [#84](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/84)) + A long-range, low-bandwidth mesh transport (LoRa, TCP, UDP, serial, I2P). The Rust side mirrors `InternetTransport` (async confirmation loop with 120 s timeout, reconnect logic, `SharedCallback` for platform notification). DORS profile is reliability-weighted (0.30) and energy-efficient (0.25) at tie-break priority 3. Full UniFFI bridge plus `ReticulumManager` for Android (TCP socket) and iOS (NWConnection) following the `InternetManager` lifecycle. `isAvailable()` is gated behind `configure()` so DORS cannot select an unconfigured transport. The Rust scoring layer was refactored alongside this PR — `TransportScoringProfile` per transport replaces ~10 hardcoded match arms in `calculate_*_score` functions. `bandwidth_max_bps` is `2700` (corrected from an aspirational `4700`). Daemon TCP JSON protocol (`Identify`, `SendMessage`, `MessageReceived`, `StatusUpdate`) is documented in `docs/reticulum.md`. + +- **Nostr relay transport** ([#82](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/82), [#83](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/83)) + A censorship-resistant decentralized relay layer over WebSockets. The Rust side handles BIP-340 Schnorr signing via `k256`; platform managers (`NostrManager` on iOS via `URLSessionWebSocketTask`, on Android via `OkHttp`) just publish pre-signed `["EVENT", {...}]` JSON to N relays simultaneously and subscribe via NIP-01 REQ filters. Confirmation is deferred to the relay's `["OK", event_id, true]` response (rejections trigger `nostrSendFailedWithReason`). Per-message signing-failure retry capped at 3. DORS profile is reliability-weighted (0.35) at tie-break priority 4 — fallback for when the usual transports are censored. Includes a minimal `examples/nostr-example/` app for end-to-end verification. + +- **Adaptive per-peer BLE fragment sizing (MTU negotiation)** ([#86](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/86)) + BLE fragmentation has been nailed to 185 bytes since forever — `set_mtu` was never wired through UniFFI. The Rust side now stores a per-peer map keyed by recipient with clamp `[BLE_MAX_FRAGMENT_SIZE, MAX_REASONABLE_BLE_PAYLOAD]`. Android wires `requestMtu(517)` into the central handshake chain between `onServicesDiscovered` and the device-id read, with a 3 s watchdog so vendor stacks that accept `requestMtu` and never deliver `onMtuChanged` cannot wedge the handshake. iOS reads `maximumWriteValueLength(for: .withoutResponse)` at the moment the device id resolves. Late `onMtuChanged` callbacks that arrive after the watchdog still get forwarded so peers do not stay pinned to the fallback. Two new counters (`ble_fragment_fallback_count`, `ble_undersized_mtu_reports`) surface ordering-invariant regressions and below-floor renegotiations to dashboards. Wire format unchanged. + +#### Bindings & platform + +- **Python desktop bindings for macOS, Linux, and Windows** ([#76](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/76)) + A full Python SDK under `bindings/python/` mirroring the mobile binding architecture: `SecureStorage` via `keyring` (Keychain / Secret Service / Credential Locker), `InternetManager` via `websockets`, `BleManager` via `bleak` (central role), `BlePeripheral` via `bless` (GATT server, macOS/Linux only), and a high-level `ProtocolManager` with 100 ms processing loop and async-context-manager support. Generated `offline_protocol.py` is checked in; CI runs a `python-bindings` job on every PR that regenerates and fails on drift. Three Python deps audited for supply-chain safety; CI gets a `pip-audit` step. 7 new platform jobs (mac arm64+x86_64, linux x86_64+aarch64, windows x86_64). Known limitations are spelled out in `bindings/python/README.md`. + +- **Reticulum and Nostr UniFFI bridges and platform managers** ([#80](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/80), [#83](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/83)) + Wired through `OfflineProtocolModule` (initialize, start, stop, pause, resume, destroy, enableTransport, disableTransport) on iOS and Android with full lifecycle support and DORS failure tracking. iOS uses `NSLock`-backed properties for shared state; Android uses `Atomic*` plus a dedicated `HandlerThread` for I/O so TCP writes never run on the main thread. Both transports follow the same lifecycle: native `initialize()` creates the manager, JS `start()` auto-enables, native `enableTransport` calls `configureAndStart` ([#84](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/84) aligned the lifecycles). + +- **BLE backpressure-aware drain** ([#87](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/87)) + Outbound BLE drain now responds to write completion: a 50 ms delayed re-drain replaces the unconditional immediate re-post when writes stall, and `onCharacteristicWrite` triggers an immediate drain so fragments flow as soon as the remote acknowledges. Drain is gated on `GATT_SUCCESS` so failed writes do not enter a tight retry loop. Demo app gains BLE permissions for all API levels and a deep-link to system Settings for `NEVER_ASK_AGAIN` permission denials. Group conversations track per-message delivery/failure status. + +### Bug Fixes + +- **Resolve BLE peripheral peer identity and broadcast routing** ([#78](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/78)) + Added `resolve_sender_identity()` to map a central UUID to a `user_id` from the first received message, enabling outgoing routing via the BLE peripheral. `send_message("*")` now expands to individual BLE peers (the Rust core treats `"*"` as a literal peer-ID lookup that always fails). Cached the last-known central UUID to prevent sender flipping during fragment reassembly, and clean up resolved `user_id` entries on peer disconnect. + +- **Prevent BLE scanner from stealing peripheral's outgoing fragments** ([#79](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/79)) + Scanner and peripheral share a single `ble_get_next_fragment` queue; on `on_fragments_available`, both drain — but the scanner ran first and popped fragments it could not deliver (no bleak client for peripheral-discovered peers), dropping all Mac→Phone messages. Scanner now drains only when it has connected clients. + +- **Replace reflection with direct typed UniFFI callbacks on Android** ([#81](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/81)) + BLE and WiFi Direct callbacks were wired via `Class.forName` + `Proxy.newProxyInstance` even though the generated bindings already expose `BleTransportCallback` and `WifiDirectTransportCallback` as public typed interfaces. Replaced with direct object expressions, matching iOS and the existing Reticulum path. Compile-time verification that callback interfaces exist; stale bindings still fall back to polling rather than crashing. + +- **Drain session state on BLE status change and WiFi Direct stop** ([#88](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/88)) + `BleTransport::on_status_changed()` was a bare status setter, despite being the *primary* path BLE goes offline in the React Native bindings (user toggles Bluetooth off → `ble_status_changed(false)`). Stale peers, MTUs, fragment buffers, and queues survived. WiFi Direct had the same hole in both `on_status_changed()` and `stop()`. Both transports now drain per-session state when transitioning away from `Available`. Monotonic lifetime counters are intentionally preserved. + +### Refactoring + +- **Android BLE facade split** ([#85](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/85)) + The 3300-line `BleManager.kt` is decomposed into focused classes under `bindings/react-native/android/.../ble/`: `BleTransportFacade` (entry point), `PeripheralGattServer` (GATT server with CCCD descriptor handling and per-central long-read snapshots), `LeAdvertiser` (advertising lifecycle with cooldown/jitter), `CentralGattClient` (per-peer central-role state machine), `OutboundFragmentQueue` and `InboundFragmentBuffer` (main-thread-enforced FIFO buffers with whole-queue drop on overflow — half-message fragments would reassemble into garbage at the receiver). The PR also closes long-standing correctness gaps in BLE: missing CCCD (0x2902) descriptor on the message characteristic (centrals could not subscribe at all), missing `onDescriptorWriteRequest` handler (subscribe attempts silently dropped), missing CCCD write on the central side (notify stream was silent in both directions), `onCharacteristicReadRequest` ignoring the read offset (long reads returned the full value on every call, garbage on iOS↔Android with default 23-byte ATT MTU), binder-thread reads of `characteristic.value` captured by reference (framework reuses the buffer — main-thread handler saw whatever the BLE stack last wrote). All UniFFI calls now run on the main handler. `provideIdentityBytes` is a pure volatile read; identity refresh is scheduled with 500 ms → 10 s exponential backoff capped at 30 attempts. `assertMainThread` runtime guards replace load-bearing comments. + +### Documentation + +- **Reticulum integration guide** ([#75](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/75)) + New `docs/reticulum.md` documents architecture, integration strategies (embedded Python, the emerging `reticulum-rs` crate, HDLC IPC, TCP gateway via `TCPClientInterface`), DORS scoring, the LoRa throughput reference table, daemon setup, RNode hardware, and troubleshooting. Reticulum also added to all 15 existing docs that enumerate transports, scoring weights, config parameters, or platform availability. + +--- + diff --git a/docs/changelog/0.11.md b/docs/changelog/0.11.md new file mode 100644 index 00000000..7d22c7fd --- /dev/null +++ b/docs/changelog/0.11.md @@ -0,0 +1,177 @@ +# Changelog: 0.11.x + +Archived release notes. The working changelog, covering the current release +and unreleased changes, is [CHANGELOG.md](../../CHANGELOG.md). + +| Release | Date | +|---------|------| +| 0.11.1 | 2026-07-12 | +| 0.11.0 | 2026-07-01 | + +## [0.11.1] — 2026-07-12 + +Hotfix cut from `v0.11.0` — no API or behavior changes. Consumers only need a version bump. + +### Fixed + +- **Android native libraries are now 16 KB page-size aligned (Google Play requirement).** + `libuniffi_offline_protocol.so` in `0.11.0` and earlier had 4 KB-aligned `PT_LOAD` segments on every ABI, which Google Play now rejects (as a blocking error) for apps targeting Android 15+ on 64-bit devices. All four ABIs (`arm64-v8a`, `armeabi-v7a`, `x86`, `x86_64`) are now linked with `-Wl,-z,max-page-size=16384` via per-target rustflags in `.cargo/config.toml`, and both build paths (release CI and `build-uniffi-android.sh`) gate on a new `check-elf-alignment.py` so a future toolchain change cannot silently regress alignment. Verify with `readelf -l ` — every `LOAD` segment shows `Align 0x4000`. + +### Changed + +- **JNA dependency bumped from 5.13.0 to 5.19.1** in the Android library. JNA 5.13.0's `libjnidispatch.so` is not 16 KB-aligned on `x86_64` and crashes at runtime on 16 KB-page devices (fixed upstream in JNA 5.17.0). No API impact; apps already forcing a newer JNA via Gradle resolution are unaffected. + +## [0.11.0] — 2026-07-01 + +### Licensing + +- **Relicensed from `MIT OR Apache-2.0` to `AGPL-3.0-only` with a parallel commercial license.** + All workspace crates, the React Native binding (`bindings/react-native/package.json`), and the Python binding (`bindings/python/pyproject.toml`) now ship under AGPL-3.0-only. The repo root gains a full `LICENSE` (the GNU AGPL-3.0 text) and a `LICENSE-COMMERCIAL.md` describing the alternative paid license for organizations that cannot comply with AGPL-3.0 section 13 (network-use source disclosure) — for example, closed-source mobile apps, embedded firmware, or SaaS deployments. Consumers may use the SDK under **either** license, not both. Contributions are accepted under AGPL-3.0-only **and** are dual-granted to the maintainers for inclusion in commercially licensed distributions via the CLA in `CLA.md` (signed once via the CLA Assistant bot on a contributor's first PR); see `CONTRIBUTING.md` for the signing flow. Versions of the SDK published before this change remain available under their original `MIT OR Apache-2.0` terms. Workspace crates and the Python binding move from `0.1.0` to `0.2.0`; the React Native binding moves from `0.10.0` to `0.11.0` so the relicense never collides with a previously-published version under the same number. + +### Breaking Changes + +- **`getTransportMetrics` returns real data (or `null`) instead of a zeroed mock** + The UniFFI method `get_transport_metrics(transportType)` (exposed as `getTransportMetrics` in Swift/Kotlin/TypeScript) previously always returned a `TransportMetrics` populated with zeros. It now pulls directly from `Transport::metrics()` and returns `null` when the requested transport is not registered with the `TransportManager`. Callers that relied on the non-null guarantee or zero-valued fields must add a null-check and treat absent transports as "metrics unavailable" rather than "all counters zero". + +- **`mls-observability` Cargo feature retired** ([#92](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/92)) + Compile-time gating is replaced by runtime `TelemetryConfig::mls_verbosity` (`Off` | `Lifecycle` (default) | `Diagnostic`). Workspace Cargo files that pass `--features mls-observability` should drop the flag; behaviour is preserved by the `Lifecycle` default. Opting out at runtime additionally suppresses the legacy `MlsEventEmitter` path. See `docs/telemetry.md`. + +- **`Event::SecurityWarning` gained a `reason_code` field** ([#123](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/123)) + The variant is now `SecurityWarning { peer_id, reason_code, reason }` and the constructor is `Event::security_warning(peer_id, reason_code, reason)` (previously two arguments). This is source-breaking for direct Rust consumers that construct the event or match it exhaustively without `..`. Mobile/RN/Python consumers are **unaffected**: events cross the FFI as serde-tagged JSON, so `reason_code` is an additive field on the wire and existing `reason` handling keeps working. + +### Added + +#### Telemetry epic ([#89](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/89)–[#96](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/96)) + +- **`TelemetrySink` and `TelemetryRecord` taxonomy** ([#91](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/91)) + A single observer trait replaces the previous patchwork of `EventCallback`, `MlsEventEmitter`, and pull-only `TransportMetrics`. `TelemetryRecord` is a `#[non_exhaustive]` enum spanning six categories (`Protocol`, `Mls`, `MetricsSnapshot`, `TransportState`, `Routing`, `Device`); the `Event` payload is boxed so non-`Protocol` records do not pay a 368-byte size tax. `TelemetryConfig` ships privacy-preserving defaults: `scrub_ids=true` (long-lived pseudonymous identifiers are SHA-256 hashed via a per-instance secret before crossing the sink), `mls_verbosity=Lifecycle`, and `metrics_cadence_ms=Some(5000)` (aligned with the DORS stability window). `Scrubber` is `pub(crate)`; `TelemetryConfig`'s `Debug` impl redacts the secret. + +- **Sink wiring for protocol events and MLS lifecycle** ([#92](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/92)) + `OfflineProtocol::install_telemetry_sink(sink, config) -> Result<()>` plumbs every `Event` and MLS lifecycle event through the sink. Long-lived identifiers (peer/user/group/sender/recipient/members) are scrubbed by default; message IDs and content stay raw. A compile-time exhaustiveness ward in `scrub_event.rs` guarantees new `Event` variants cannot ship without explicit scrubbing decisions. The legacy `EventCallback` continues to fire alongside an installed sink. + +- **`MetricsFrame`, `TransportStateEvent`, `RoutingDecision`, `DeviceCapabilitySnapshot` records** ([#93](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/93)) + These four records carry no long-lived identifiers and are emitted from `OfflineProtocol::process()` (metrics cadence, transport-status diff, device-capability diff) and from a new `routing_decision_callback` wired alongside every `Event::Dors*` site. Bench: 2–29 ns per emission against a <5 µs / <25 µs budget. Pure additive — `Event::Dors*` consumers see the same legacy stream. + +- **Unified `TelemetrySink` across UniFFI / iOS / Android / RN / Python** ([#94](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/94), [#97](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/97)) + Apps install a single sink that receives every typed `TelemetryRecord` the SDK emits, plus a forward-compatible `on_extension(name, payloadJson)` fallback for variants added to the Rust enum after the FFI was generated. A bounded (1024-slot, drop-oldest) poll queue backs `pollTelemetry()` for apps that prefer pull over push. `TransportMetrics` gained 12 optional fields mirroring the richer Rust struct and flows unchanged through both the pull and push paths. + +- **`TelemetryConfig.enablePollQueue` (default `true`)** + Push-only integrations can set this to `false` to skip the per-emit `serde_json` envelope construction inside the Rust adapter. With the opt-out in effect, the typed push callbacks still fire but `pollTelemetry()` returns `null` for records emitted under that config. Flag is local to the UniFFI adapter (not forwarded to `CoreTelemetryConfig`). + +- **`uninstallTelemetrySink()`** + Detaches the installed sink in a single call: replaces the core-side sink with a no-op (future emissions are discarded with zero overhead) and drains the pull queue so a subsequent `installTelemetrySink(...)` starts with an empty queue. Idempotent. + +- **`installTelemetrySink(config, listener?)` accepts an optional listener** + Registering the listener synchronously before the native install is dispatched closes the window where records emitted between the bridge resolving and the next JS microtask would fan out to an empty listener set and be dropped on the push channel. + +- **`RoutingPhase.Unknown` / `RoutingReasonCode.Unknown`** + When the Rust core reports a routing variant the FFI build does not recognise (new-core / old-FFI skew), the adapter now maps to an explicit `Unknown` value, gated by a `std::sync::Once` warn so the routing hot path cannot drown the tracing layer. + +- **`AckEvicted` and `FragmentAssemblyEvicted` events** ([#89](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/89)) + Three `Event` variants were defined and reached the FFI but never fired. ACK eviction now returns `Option` directly from `register_pending_ack` (no drain buffer); BLE fragment eviction uses an injected callback that emits outside the `fragment_buffers` lock to avoid deadlocks. Apps gain observability into capacity pressure on the ack tracker and the fragment reassembly cache. + +- **Telemetry-driven diagnostics in the demo app** ([#95](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/95)) + A new Diagnostics tab and persistent status pill expose transport-time distribution, DORS switch drivers, link-stability flap counts, per-transport delivery/error/latency, hop-count histograms, retry queue by priority, partition duration, and battery drain rate. Aggregate-only — no per-peer data, no message content, no per-decision internals. + +#### Telemetry & observability follow-ups + +- **Relay role transition events now fire** ([#110](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/110)) + `RelayManager` knew how to decide relay promotion/demotion (`should_promote` / `should_demote` / `set_role`) but nothing ever called it, so the role sat at `Regular` for the whole process lifetime and the `RelayPromoted` / `RelayDemoted` / `RelayDemotedBattery` events (defined but never emitted since they were introduced) had nothing to fire on — downstream analytics were measuring churn that never happened. A new `RelayManager::evaluate_transition` runs every `process()` tick with live battery and connection state, mutates the role only on an actual flip, and emits the matching event through the normal channel so both the app `EventCallback` and the telemetry sink see it (it runs whether or not a sink is installed — these are app-facing events). `should_demote` also learned about charging and only forces a demotion at a critical level, so a charging phone parked just under the battery minimum can no longer promote/demote on every tick. Evaluation is skipped while the battery level is unknown. + +- **Persistent per-install telemetry scrub secret** ([#112](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/112)) + When no explicit `scrub_secret` is configured, telemetry identifiers were hashed with a fallback secret freshly minted (`uuid::new_v4()`) at construction, so the same physical device produced a different opaque id every session and cross-session distinct-device counting was impossible. The secret is now loaded from (or generated once and written back to) secure storage during `initialize_mls()` / `enable_message_persistence()` and adopted as the fallback. Precedence is unchanged — an explicit config secret still wins, and apps that never provide storage keep the old random behaviour. Every storage failure degrades to the in-memory secret rather than blocking init, and a corrupt/wrong-length blob is regenerated. + +- **`TelemetryConfig::with_mls_sampling_bypass`** ([#113](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/113)) + MLS lifecycle events run through a fixed-window rate limiter (10 per peer+kind per second) before reaching a sink, which clips aggregate counts: a backend counting `mls.decryption_failed` can't tell a genuine burst of 10 from a burst of 10,000. Telemetry-grade sinks that do their own backpressure can now opt in to skip the limiter entirely — the window counter is left untouched, so flipping it back off resumes clean limiting. Default stays `false`. Plumbed through the UniFFI dict and regenerated Swift/Kotlin/Python bindings. + +- **`telemetryInstallId()` across UniFFI / RN** ([#114](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/114)) + Exposes a stable per-install identifier derived as `SHA-256(secret || "telemetry:install-id")` truncated to 32 hex chars, so analytics can distinguish two sessions of one device from two devices without the raw scrub secret ever crossing the bridge. The domain string is frozen (the `:` makes it un-collidable with any valid `UserId`/`AppId`), and the accessor returns `null` until secure storage is wired (before that the underlying secret is a random per-instance value, so a "stable" id would be a lie). Deliberately pinned to the SDK-managed secret rather than an app-supplied `with_scrub_secret` override so the id can't rotate on sink re-install. Documented as a persistent identifier that may fall under app privacy-disclosure requirements. + +#### Security & trust + +- **Machine-readable `SecurityWarningCode` on `Event::SecurityWarning`** ([#123](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/123)) + Security warnings now carry a stable `reason_code` (`TOFU_KEY_MISMATCH`, `TOFU_STORE_FULL`, `TRANSPORT_IDENTITY_MISMATCH`, `SIGNATURE_DOWNGRADE`, `CONTROL_SIGNATURE_INVALID`) alongside the existing human-readable `reason`. Consumers reacting to a peer reinstall / new device (the `TOFU_KEY_MISMATCH` case) can branch on the code instead of substring-matching `reason`, which is for logs/UI and may be reworded between versions. `SecurityWarningEvent`, `TofuResetEvent`, and the `SecurityWarningCode` union are now typed in the React Native bindings (`bindings/react-native/src/types.ts`) rather than untyped raw-string subscriptions. The `reason_code` is additive on the JSON wire; existing `reason` matches keep working. + +- **`resetTofuForPeer` now drops the stale MLS session in the same call** ([#123](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/123)) + `OfflineProtocol::reset_tofu_for_peer` (exposed as `resetTofuForPeer`) previously only un-pinned the TOFU key, leaving the now-dead 1:1 MLS session in place so callers had to remember to delete it or the next `establish_secure_session` would no-op against stale credentials. It now also drops the session, since accepting a peer's new identity makes the old session dead by definition. The drop is **best-effort, not atomic**: the key un-pin is committed first (so the call still returns `true` and emits `TofuReset`), then the session is dropped — no session or uninitialized MLS is a harmless no-op, while a genuine deletion failure is surfaced via a `warn` log and leaves the stale session in place rather than rolling back the un-pin. Behavior is unchanged when no TOFU entry exists (still returns `false`, still idempotent). + +- **OpenMLS stack updated to clear five RUSTSEC advisories** ([#114](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/114)) + Lockfile-only bump within existing semver requirements: `openmls` 0.7.1 → 0.7.4 and `openmls_rust_crypto` 0.4.1 → 0.4.4, pulling patched `hpke-rs` 0.6.1 and `libcrux-sha3` 0.0.8 to resolve RUSTSEC-2026-0069/0070/0071/0072 (hpke-rs) and RUSTSEC-2026-0074 (libcrux-sha3). The remaining RUSTSEC-2026-0173 (`proc-macro-error2` unmaintained — a compile-time proc-macro from libcrux's hax tooling with no fixed release) is ignored in `deny.toml` with justification. Full workspace test suite passes on the updated stack. + +### Deprecated + +- **`updateTransportMetrics(...)` is a documented no-op, removal targeted for v1.0** + This method predates the per-transport tracking the Rust core now performs internally and has never written to any field the SDK reads from. All passed fields — including the 12 newly-added optional extended fields — are discarded. Use `getTransportMetrics(...)` to read live metrics, or install a `TelemetrySink` to observe push `MetricsFrame`s. A `std::sync::Once`-gated `tracing::warn!` fires on first call so misuse surfaces during development. The method is scheduled for removal in the **v1.0** release. + +### Bug Fixes + +- **Isolate `TelemetrySink` panics to prevent `SharedState` poisoning** ([#96](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/96)) + Three families of emit sites invoked `TelemetrySink::emit` while holding the `SharedState` mutex. A panicking sink (realistic for foreign sinks via UniFFI) unwound through the live `MutexGuard`, poisoning the mutex on drop and silently degrading the protocol. A new `telemetry::dispatch::dispatch_record` helper wraps every internal sink dispatch in `catch_unwind(AssertUnwindSafe(...))`. The legacy `EventCallback` fan-out gets the same treatment. Note: `panic = abort` profiles (mobile `minisize`) bypass `catch_unwind`. + +- **Unblock Python install and close UDL-drift gaps** ([#97](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/97)) + `bleak` pin bumped from `>=0.21,<1.0` to `>=1.1.1,<2` so install resolves against `bless 0.3.0` (previous pins were mutually exclusive — `pip install -e .` failed). `nostr_enabled=False` added to four `ProtocolConfig` construction sites that predated the field becoming required. `_make_config` defaults `internet_enabled=True` so tests satisfy the "at least one transport enabled" validation. `ProtocolManager.start()` registers stub `NostrTransportCallback` and `ReticulumTransportCallback` impls (mirroring the WifiDirect stub), gated on `config.nostr_enabled` / `config.reticulum_enabled` so apps that drive the transport themselves are not silently swallowed. `install_telemetry_sink`, `uninstall_telemetry_sink`, and `poll_telemetry_frame` passthroughs are added with strict GC pin lifecycle (pin first, call Rust, unpin on failure; `stop()` only clears pins after teardown succeeds). New CI `python-bindings` job regenerates from UDL on every PR and fails on drift. + +- **Use `InternetMessage.recipient_id` in outbox drain (Python)** ([#98](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/98)) + UniFFI-generated `InternetMessage` exposes `recipient_id`, not `recipient`. Every outgoing WebSocket send raised `AttributeError`, propagated into `_safe_handle_authenticated` → `_handle_connection_closed`, triggering an auth/reconnect flap on any queued message. + +- **Tear down WebSocket and recv loop on disconnect (Python)** ([#99](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/99)) + `_handle_connection_closed` cancelled poll/ping but never closed `self._ws` or cancelled `self._recv_task`. On `AuthError`, the WebSocket stayed open and the recv loop kept iterating it; `_connect` then overwrote `_recv_task` with a fresh loop against a new socket — two live recv tasks, one zombie WS. Added a `_teardown_in_progress` re-entrancy flag and a `_process_tasks` strong-reference set so fire-and-forget tasks cannot be GC'd mid-execution. `_schedule_reconnect` cancels any prior `TimerHandle` before installing a new one. + +- **Guard `BlePeripheral` peer maps with existing lock (Python)** ([#100](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/100)) + `self._lock` previously protected only metrics counters even though the bless delegate thread (`_on_write` → `_resolve_sender`) and the asyncio loop (`_peer_monitor_loop`, `resolve_sender_identity`, `stop`) read and wrote `_connected_centrals`, `_central_to_user_id`, and `_last_known_central` concurrently. Iteration-during-mutation surfaced as `RuntimeError`s in production, and single-peer sender attribution could flip between threads on the same message. Lock now covers all three fields under a strict "snapshot under lock, FFI outside" rule. + +- **Copy `uniffi.dll` on Windows instead of symlinking** ([#101](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/101)) + `ln -sf` on Git Bash / MSYS requires `SeCreateSymbolicLink` (admin or Developer Mode); without it the symlink is either broken or a regular copy depending on `MSYS2 winsymlinks`. macOS / Linux keep the symlink; Windows uses a straight `cp -f`. + +- **Raise on `send_message("*")` with no known BLE peers (Python)** ([#102](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/102), [#104](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/104)) + Previously fell through to `protocol.send_message(recipient="*", ...)` whenever `_get_known_ble_peers()` returned empty; the Rust core's `BleTransport::send()` treats `"*"` as a literal peer-ID lookup and always fails — the caller got a message id back that would never deliver, with no surfaced error. `send_message("*")` is now explicitly documented as a BLE-only wrapper convenience and raises `ValueError` on empty peer sets. `_get_known_ble_peers` is promoted to public `get_known_ble_peers` since it is now part of the documented escape hatch. Internet/Nostr/Reticulum/Wi-Fi Direct broadcasts remain platform-driven. + +- **Saturate `forward_count` to close a `MAX_FORWARD_COUNT` bypass** ([#108](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/108)) + `ForwardInfo::from_message` incremented a peer-controlled `u32` with `existing.forward_count + 1` *before* the `MAX_FORWARD_COUNT` (100) cap check. Under the workspace default Cargo profile that wraps to `0` in release builds (a peer sending `forward_count = u32::MAX` slipped the cap entirely) and panics in debug (DoSing dev/CI). Switched to `saturating_add(1)` so the value clamps to `u32::MAX` and the existing post-increment check rejects it, matching the monotonic-counter convention used elsewhere in the workspace. Covers both the 1:1 and group forward paths. + +- **Ship Android consumer ProGuard rules for the FFI** ([#118](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/118)) + Any consuming app that minified its release build lost the BLE mesh transport entirely: the Rust core is reached from Kotlin via JNA + the uniffi-generated bindings, wired by reflection over class/field/method names (`Native.register`, `@Structure.FieldOrder` vtables, `com.sun.jna.Callback`), and R8 renamed those apparently-unreferenced symbols so the FFI died at runtime. Debug/Metro builds worked, so it only surfaced once shipped (consuming apps were carrying a patch-package workaround). The keep rules now ship from the library via `consumerProguardFiles` (applied to the consuming app's R8 run automatically) and `consumer-rules.pro` is added to the npm `files` list so it physically exists in the published tarball. + +- **Restore offline 1:1 MLS convergence over BLE mesh** ([#120](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/120)) + A broad fix set for 1:1 MLS sessions that stalled at "pending" when established offline over the BLE mesh. Highlights: DORS now demotes the Internet transport below every mesh transport when `prefer_online=false`, so a message to a nearby peer that isn't on the relay falls back to BLE/Wi-Fi Direct instead of being queued on Internet and silently lost; the both-create "owner" only confirms on a real group-aware decrypt (fixing split-brain when both peers create a group at once) and the adopter proactively sends an MLS-encrypted `SESSION_CONFIRM_ENCRYPTED` marker so a passive owner with no traffic still converges; a no-carrier MLS Welcome is parked and re-armed on peer rediscovery instead of burning its retry budget against a vacuum and expiring; the mesh Welcome sender now drives its own `SESSION_CONFIRM_PROBE` rather than relying solely on the receiver's single proactive confirm; the peripheral-notify egress and per-peer MTU are resolved by device identity (not link address) so iOS's distinct per-direction connection handles no longer defeat the notify path; the notify characteristic switched to ATT **indications** for per-fragment flow control (with a dedicated 250 ms watchdog); the both-create owner gate is persisted and cleared on session teardown; and MLS `join_group_replacing` is a non-destructive stage-then-swap so a Welcome retransmit can't re-brick an adopted group. Adds a `ConvergenceDiag` breadcrumb event (scrubbed instrumentation, additive on the wire, wired to the RN types) that reports where a Welcome's convergence broke. + +- **Floor notify egress to the 185-byte cap when the peripheral-link MTU is unobserved** ([#121](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/121)) + Follow-up to #120. The per-peer `min(central, peripheral)` MTU clamp collapses to the central payload whenever the GATT-server `onMtuChanged` never delivers the notify link's MTU (common: the server-role callback is unreliable and iOS centrals negotiate a small/absent MTU on the link they open). A multi-fragment Welcome egressed over NOTIFY was then sized for the larger central link and silently truncated on air, stalling convergence. Notify-subscribed peers with an unknown peripheral MTU now fall back to the 185-byte fragment cap, re-flushed on CCCD subscribe *and* unsubscribe, with the subscription check device-scoped across both link addresses. The floor/min arithmetic was extracted to a pure, unit-tested `computeEffectivePayload()`. Kotlin bridge only — no Rust/UniFFI/`.so` changes. + +- **Restore offline Android↔iOS message delivery** ([#122](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/122)) + When Android and iOS meet over BLE the steady state (iOS backgrounded) is Android-central / iOS-peripheral, and three things broke that path. (1) iOS had **no** GATT-server data path — `BleManager` could only send over a link it opened as central — so iOS→Android stalled; it now retains subscribed `CBCentral`s and pumps fragments through a main-queue `updateValue` with `peripheralManagerIsReadyToUpdateSubscribers` backpressure, reporting `min(write, notify)` MTU. (2) `can_confirm_from_source` rejected a both-create owner whose Welcome had expired even though a successful group decrypt is definitive proof the peer adopted the group — a successful decrypt now confirms unconditionally (and the both-create gate is no longer re-armed on an already-confirmed session). (3) DORS retained the demoted Internet fallback over an available mesh transport because a cold-start fallback set Internet as current and armed the switch cooldown; it now switches the moment a real mesh score outranks the sentinel. Also emits `secure_session_established` for the both-create **owner** (previously only the adopter emitted it, so whoever drew the owner role saw "no contacts with secure sessions available" when creating a group), raises the pre-device-id inbound buffer from 5 s to 15 s, and counts BLE sends that find the recipient absent while other peers are connected. The iOS notify path mirrors the audited Android design but needs on-device validation; the U1≠U2 inbound-attribution case (distinct iOS per-direction handles) is deferred to a follow-up. + +### Refactoring + +- **Extract `CategorySampler` from `MlsEventRateLimiter`** ([#90](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/90)) + The fixed-window rate-limiting logic is generic (a keyed counter with window reset and eviction) but was welded to MLS event types. Extracted into `CategorySampler` under a new `telemetry` module so the new sink categories can reuse it. `MlsEventRateLimiter` becomes a thin wrapper that maps MLS lifecycle events to string keys. + +- **Stable `Error::code()` for transport-error classification** ([#111](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/111)) + Transport errors were being classified downstream by substring-matching their `Display` text — an invisible coupling that silently reclassifies to the `other` bucket the moment a `#[error("...")]` string is reworded. `Error` now exposes a `code()` returning a stable SCREAMING_SNAKE_CASE string per variant, decoupled from the human-facing text. The match is exhaustive (no wildcard arm) so a new variant won't compile without a code, and a test pins every string. Codes stop at the Rust boundary for now — transport errors still stringify into `ProtocolError::Other` at the FFI layer. + +### CI/CD + +- **Run the React Native Android module's unit tests** ([#120](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/120)) + The Kotlin binding tests (fragment buffers, outbound queue, GATT CCCD classifier, `MeshController` eviction) had never run in CI — which is how several tests asserting the *opposite* of the code under them survived. A standalone Gradle harness supplies the AGP + Kotlin classpath and pulls the module in as a subproject without touching the module's own `build.gradle` (so real RN-app consumption is unaffected); Kotlin is pinned to 1.9 until the 2.x migration lands. + +- **Grant the CLA workflow `actions: write`** ([#109](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/109)) + `contributor-assistant/github-action` v2.x needs `actions: write` to update the PR check status; without it the job recorded the signature but died with "Resource not accessible by integration", leaving the CLAAssistant check stuck on failure. + +### Documentation + +- **Telemetry wire-up guide** ([#95](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/95)) + New `docs/telemetry.md` covers push/pull paths from React Native, Rust `TelemetrySink` examples, the UniFFI callback shape, config knobs, and `MetricsFrame` field semantics. + +- **Correct stale API and transport-priority references** ([#117](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/117)) + The React Native guide documented a "relay-server" group API (`groupCreate`, `groupSendMessage`, `groupAddMember`, …) that exists nowhere in the bindings; section 7 was rewritten around the real MLS mesh group API (`meshCreateGroup`, `meshSendGroupMessage`, `meshInviteToGroup`) and the duplicate relay-server reference table dropped. Also fixed accumulated drift: with Nostr added, the lowest DORS tie-break priority is now Nostr (Reticulum is second-lowest); the `api-reference` `Message`/`ProtocolConfig` structs regained missing fields; `telemetry.md` documents `mlsSamplingBypass`; `SECURITY.md` moved from 0.1.x to 0.2.0; and the react-native-app guide uses the nested transport-config shape the JS layer actually accepts (the Kotlin/Swift examples stay flat on purpose — the UniFFI `ProtocolConfig` dictionary really is flat). + +### Chores + +- **Point repo URLs at the actual canonical slug** ([#105](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/105), [#106](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/106)) + `Cargo.toml` had us at `offline-protocol/sdk`, `cliff.toml` at `nickthecook/offline-protocol-sdk`, and CONTRIBUTING/QUICKSTART/iOS-integration each linked to one of those. None of those slugs exist. The actual remote is `Offline-Protocol/offline-protocol-sdk`. Crate metadata, generated changelogs, clone instructions, the SwiftPM dependency URL, and React Native + Python package metadata now point at something a human or `cargo` can fetch. + +- **Add community-health files** ([#116](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/116)) + Contributor Covenant 2.1 code of conduct (enforcement to gm@offlineprotocol.com), YAML issue forms (forcing a version, an affected-crate dropdown mapped to commit scopes, and a platform), a PR template mirroring the actual CI gates (fmt, clippy, test, cargo-deny, regenerated bindings) plus the CLA-signing reminder, and a `CODEOWNERS` pointing at the `@Offline-Protocol/maintainers` team. Caveat worth knowing: a `CODEOWNERS` entry for a team that doesn't exist or lacks write access is *silently* ignored, so the team must be created and granted write access for review enforcement to take effect. + +--- + diff --git a/docs/changelog/0.12.md b/docs/changelog/0.12.md new file mode 100644 index 00000000..a405ce4c --- /dev/null +++ b/docs/changelog/0.12.md @@ -0,0 +1,119 @@ +# Changelog: 0.12.x + +Archived release notes. The working changelog, covering the current release +and unreleased changes, is [CHANGELOG.md](../../CHANGELOG.md). + +| Release | Date | +|---------|------| +| 0.12.0 | 2026-07-13 | + +## [0.12.0] — 2026-07-13 + +First npm release since **v0.11.0** (0.11.0 is the latest published version). This +release folds in the **v0.11.1** 16 KB page-alignment hotfix — which was tagged but +never published to npm — so the Google Play alignment fix (see the [0.11.1] entry +below) reaches npm consumers for the first time here. + +### Breaking Changes + +- **Inbound plaintext text is rejected by the `require_encryption` gate (SEC: inbound plaintext bypass)** + The receive path enforced encryption policy for legacy plaintext *media* (SEC-H1) and authenticated internal *control* messages, but plain text messages had no inbound gate at all: a message with no internal prefix was ACKed and surfaced as `MessageReceived` with the wire-claimed sender — no signature, no session check. Any mesh peer (or anyone in radio range) could inject unauthenticated cleartext attributed to an arbitrary sender into an app configured fail-closed. Inbound plaintext text now passes the same policy gate as legacy plaintext media: + - *With `require_encryption = true` (default)*: all inbound plaintext content — text and legacy media alike — is rejected: not surfaced, not ACKed (a rejection does not confirm to the injector that the target is online; ACK-before-gate for media chunks is unchanged). Rejected message ids are deliberately forgotten by the deduplicator, so replaying a rejected message re-enters the gate and is rejected again without an ACK instead of hitting the duplicate re-ACK path — the same replay hardening now applies to security-gate (`SecurityRejected`) control-message rejections. (In the opt-in bloom-filter dedup mode ids cannot be forgotten; a replayed rejected message is re-ACKed as a duplicate until its filter window rotates out. The default exact-match mode is airtight.) MLS-encrypted messages are unaffected. + - *With `require_encryption = false` (explicit opt-out)*: plaintext text from a peer **without** a confirmed MLS session is still accepted (legacy interop, unchanged). Once a session is confirmed with a peer, plaintext under that peer's name is rejected as a downgrade/forgery attempt — matching the media rule from SEC-H1. + - *Observability*: every rejection logs a warning, and a `SecurityWarning` event with the new reason code **`PLAINTEXT_RECEIVE_REJECTED`** (`SecurityWarningCode::PlaintextReceiveRejected`, TypeScript `'PLAINTEXT_RECEIVE_REJECTED'`) fires at most once per peer. The per-peer tracking set is bounded (sender ids are attacker-controllable): past 1000 distinct peers it resets rather than growing without bound, so after a forged-sender flood a peer may warn again. Legacy plaintext media rejections, previously log-only, now emit the same warning code. + - *Compatibility*: peers on old SDK versions (or opted-out senders) sending plaintext text to a fail-closed receiver are now dropped instead of delivered; they must establish an MLS session (the default flow does this automatically) or the receiver must opt out. Compliant strict-mode senders are unaffected — the send side already never emits plaintext under `require_encryption = true`. `FileChunk` messages are exempt from the new text gate (encrypted media envelopes carry no content prefix); they continue through the existing media gate. + +- **Typed FFI error taxonomy — React Native rejection codes changed for group and send operations** + Six variants are appended to the FFI `ProtocolError` enum: `TransportError`, `SerializationError`, `ServiceError`, `GroupNotFound`, `PermissionDenied`, `InvalidArgument`. Existing variants keep their positional discriminants (the enum is append-only, now pinned by a test), so previously shipped Swift/Kotlin/Python bindings continue to decode. An error's detail text is preserved, but a reclassified error's message now carries its new class prefix — e.g. `Permission denied: Only admins can invite members` (previously no prefix, via the `Other` passthrough), and the MLS-layer group-missing message drops the old `MLS error: ` prefix. Do not string-match messages; branch on the codes — which change as follows: + - Group operations (`createGroup`, `inviteToGroup`, `sendGroupMessage`, `forwardMessageToGroup`, `removeFromGroup`, `leaveGroup`, `listGroups`, `getGroupInfo`, `setMemberRole`, `getMemberRole`, `getGroupRoles`, `renameGroup`) previously rejected with the catch-all `ERROR_MESH_GROUP` code. Classified failures now reject with typed codes — `GroupNotFound`, `PermissionDenied`, `InvalidState` (last-admin constraints, member caps, expired key packages), `InvalidArgument` (empty names, invalid roles), `NoKeyPackage`, `SendFailed`, `MlsNotInitialized`. `ERROR_MESH_GROUP` remains only as the fallback for unclassified errors, with the message preserved. + - `sendGroupMessage`/`forwardMessageToGroup` previously mapped **every** failure to `SendFailed`; `SendFailed` now specifically means a transport-layer failure during a send (e.g. all member fan-out sends failed, peer unreachable). A missing group is `GroupNotFound`; an uninitialized MLS stack is `MlsNotInitialized`. + - Admin-gated operations on a group that doesn't exist locally now report `GroupNotFound` instead of a permissions error ("Only admins can ..."). Conversely, `getMemberRole`/`getGroupRoles` on a group that *does* exist locally but predates role tracking (no stored role metadata) now return the defaults (`member`; no explicit roles) instead of erroring "Group not found". + - Send-shaped operations (`sendMessage`, `forwardMessage`, connection requests, presence/typing/read receipts, media sends, service request/response) surface transport failures as `SendFailed`; non-send paths (inbound data processing) surface them as `TransportError`. Both previously arrived as `Other` or a generic `ERROR_*` code. The React Native `sendPresenceUpdate`/`sendTypingIndicator`/`sendReadReceipt`, `sendFile`/`sendMedia`, and `sendServiceRequest`/`respondToServiceRequest` methods now route rejections through the same typed mapping as `sendMessage` (their legacy `ERROR_*` codes remain the fallback for unclassified errors) — `sendFile`/`sendMedia` in particular can now reject with `MediaTransferLimit`, `NoKeyPackage`, `SessionNotReady`, or `EncryptFailed` instead of a flat `ERROR_SEND_FILE`/`ERROR_SEND_MEDIA`. + - Input validation on the 1:1 send paths now rejects with `InvalidArgument` instead of the generic fallback codes, matching the group-path twins: reserved-internal-prefix content and an unparseable `replyToMsg` id on `sendMessage`/`forwardMessage`, the forward-count cap on `forwardMessage`, and empty `recipient`/`conversationId`/`messageIds` (plus the read-receipt id cap) on `sendPresenceUpdate`/`sendTypingIndicator`/`sendReadReceipt`. + - `processFileChunk` rejections are split by cause: receiver resource limits and failed-transfer tombstones (`too_many_transfers`, `sender_quota_exceeded`, `buffer_budget_exhausted`, `previously_failed`) are `InvalidState` (retry with a fresh transfer is meaningful); malformed or mismatched chunks are `InvalidArgument`. All previously arrived as `Other` (React Native: a flat `ERROR_FILE`, which remains the fallback). + - *Rust API*: new `Error::{GroupNotFound, PermissionDenied, InvalidState, InvalidArgument}` variants; `validate_id_chars` returns the typed `IdValidationError`, `FileChunk::from_bytes` returns `ChunkDecodeError`, and `GroupRole::from_str` returns `ParseGroupRoleError` (all previously `Err(String)`). + +### Added + +- **Relay `TypingUpdate` events are bridged into the SDK event stream.** The relay server's server-mediated typing event (produced by `SetTyping`/`ClearTyping` relay clients) was only logged as a diagnostic by the platform `InternetManager`s; it is now converted into the SDK's internal `__TYPING__` message and fed through `internetMessageReceived`, so apps receive the same `typing_indicator_received` event regardless of whether the sender used the SDK or a JS-layer relay client. (Read receipts need no bridge: the relay server has no server-mediated read event, and SDK-native `__READ_RECEIPT__` messages already flow through the generic `MessageReceived` relay path.) +- **`MessageReceived` events now carry `encrypted: bool`.** `true` when the content arrived MLS-encrypted and was decrypted by this node (including delayed decryption after session establishment), `false` for plaintext accepted under the `require_encryption = false` opt-out. The React Native `MessageReceivedEvent.encrypted?: boolean` field existed but was never populated by the core — apps (including the in-repo demo) reading it always saw `undefined`; it is now live. JSON consumers unaware of the field are unaffected (additive). + +- **Transport trait seam redesign: platform-bridge lifecycle on the trait, `&self` start/stop, lock-free transport handles (CQ-H4, CQ-H5)** + The methods that actually move bytes in the I/O-free bridge model — `on_status_changed`, `on_data_received(_from)`, `on_fragment_received`, `get_next_message` / `get_next_fragment`, `confirm_sent` / `report_send_failure`, `set_peer_mtu` / `clear_peer_mtu`, `set_on_messages_available`, `deserialize_message` — are now on the `Transport` trait instead of being inherent methods reachable only by downcasting through `as_any()`. Callers holding a `dyn Transport` dispatch directly; the ~24 silent-failure downcast arms in the FFI layer are gone. Provided defaults are calibrated per failure mode: polls return `Ok(None)`, ingress methods error (inbound bytes are never silently dropped), confirmation/MTU hooks are logged no-ops. `as_any()` remains for genuinely transport-specific APIs (BLE peer registry and MTU telemetry counters; Nostr signed events, pubkey, subscriptions). + `Transport::start`/`stop` now take `&self` (every implementation was already fully interior-mutable — the `&mut` receivers were vestigial), so the per-transport mutex wrapper is gone: `TransportManager` stores and hands out plain `Arc` handles, and sends are no longer serialized against status/metrics reads by an outer lock. Behavior and wire format unchanged; FFI signatures unchanged. + - **Breaking (Rust API only):** implementors of `Transport` must change `start`/`stop` to `&self` and implement the new required method `on_status_changed`; `TransportManager::get_transport` returns `Option>` (previously `Option>>>`). Concurrency note: engine sends are no longer externally serialized against `stop()` — quiesce the platform bridge (callbacks *and* sends) before stopping a transport, per the trait docs. + +- **MLS authentication hardening: sender attribution, GroupId validation, key-package identity binding (SEC-M1, SEC-M2, SEC-M5)** + Three related gaps where the wire claimed an identity and the SDK believed it without consulting the cryptography: + - **Decrypted messages are attributed to the MLS-authenticated sender (SEC-M1).** Group and 1:1 decrypt previously discarded the credential OpenMLS authenticates on every message and surfaced the attacker-settable wire `sender` — any group member could speak as any other member, and the app-level admin gates keyed off that same string. All decrypt paths now take the claimed sender (the identity the message will be attributed to) and fail with the new `MlsError::SenderIdentityMismatch` when the MLS credential disagrees. The check runs **before** a staged commit is merged, so a spoofed commit cannot advance group state; on the `__MLS_ENC__` and `__GRP_MLS_MSG__` paths a mismatch is security-rejected (no delivery ACK), and the group-commit path treats it as permanent (no retry buffering). Wire 1:1 Welcomes whose payload `inviter_id` disagrees with the transport sender are rejected on the same principle. **Breaking (Rust API only):** `MlsManager::decrypt`, `decrypt_from_user`, and `decrypt_from_group` (and the underlying `SessionManager`/`GroupManager` methods) take a `claimed_sender: &str` parameter. Wire format unchanged; honest peers already send matching identities. + - **`GroupId` is validated at construction and deserialization (SEC-M2).** Group ids flow from the wire into `MlsStorage` as raw storage keys but, unlike `UserId`/`AppId`, were never validated — `"../../etc"` was a legal group id, with path-traversal exposure left to the app's storage backend. A `GroupId` must now be one or more non-empty colon-separated segments (`:` is the namespace separator in `session::` / `group:` ids), each rejecting path-traversal components (`.`, `..`), ASCII control characters, `/`, and `\`, capped at 4096 bytes total. Wire handlers drop messages carrying invalid ids with a warning; `list_groups` skips (rather than resurrects) any invalid id persisted before this fix. The same storage-key policy applies to `WelcomeMessage.inviter_id` (validated before it is used as a delete key in `replace_session_with_welcome`) and to `GroupId::for_session`, which now validates its inputs. **Breaking (Rust API only):** `GroupId::new` and `GroupId::for_session` return `Result`, the inner field is private, and the `From`/`From<&str>` impls are removed; `SessionManager::get_session_id` returns `Result`. + - **Imported key packages are bound to the claimed identity (SEC-M5).** `import_key_package(user_id, data)` previously stored any parseable key package under any caller-supplied name — a key package generated by one user could be filed under another's, and later `create_session` calls would encrypt to the wrong keys. Import now validates the `user_id` charset (it is a raw storage key), runs full cryptographic validation (signature, lifetime — previously deserialize-only), and requires the embedded credential identity to equal `user_id` (`MlsError::CredentialIdentityMismatch` otherwise). `get_contact_key_package` re-verifies the binding at use time, so entries persisted before this fix fail closed. Key-package **TOFU pinning** (rejecting a re-announced key package for an already-known peer) remains future work; transport-level TOFU on the `__MLS_KEY_PKG__` control channel still applies. + +- **Media and file transfers are now MLS end-to-end encrypted (SEC-H1)** + File chunks were previously sent as cleartext `binary_content` across every transport, including public internet relays — only the text path was encrypted. Chunk bytes now travel through the same MLS session machinery text uses, wrapped in a versioned binary envelope (`"ML"` magic + version byte + a compact `EncryptedMessage` encoding). The chunk-0 cleartext leaks are closed too: `MediaMetadata` (file name, preview thumbnail) and the original content type ride **inside** the ciphertext instead of on the wire `Message`. This is a **wire-format break** for media transfer: + - *New sender → old receiver*: encrypted chunks fail to parse and are dropped; the transfer times out (no plaintext is ever exposed). + - *Old sender → new receiver*: legacy plaintext chunks are accepted only when `require_encryption` is `false` **and** no confirmed MLS session exists with the sender. Once a session is confirmed, plaintext media from that peer is rejected as a downgrade/forgery attempt (plaintext chunks carry no sender authentication). With `require_encryption = true`, all plaintext media is rejected. + - *Sending*: with auto-encryption active (`encryption.enabled` and MLS initialized), `send_media` now requires a confirmed session and returns `SessionNotReady` otherwise (kicking establishment when a key package is available — retry after `secure_session_established`). Media is never queued pending establishment and never falls back to plaintext. With `require_encryption = true` and MLS uninitialized, `send_media` fails with `EncryptFailed`. The legacy plaintext format is used only when auto-encryption is inactive: the explicit `EncryptionConfig::disabled()` opt-out, or encryption enabled but MLS never initialized while `require_encryption` is `false` (matching the text path). + +- **Inbound file transfers are bounded against malicious senders (SEC-H2)** + The receive path previously trusted the sender's claimed `file_size` and `total_chunks` wholesale: a single crafted chunk (e.g. `file_size = u64::MAX, total_chunks = 1`) drove `Vec::with_capacity` on the claimed size during reassembly — a guaranteed panic (process abort under the mobile `minisize` profile, which sets `panic = "abort"`) or OOM, with no checksum forgery required — and the assembly map grew without bound across attacker-chosen `file_id`s. `FileTransferManager::process_chunk` now rejects chunks whose claimed `file_size` is zero or exceeds `max_file_size`; whose `total_chunks` exceeds the claimed `file_size` or a cap derived from `max_file_size` assuming a 1 KiB minimum average chunk payload (the SDK's own senders never chunk below 4 KiB); whose data is empty or larger than the claimed file; or whose bytes would push the total received past the claim. Reassembly buffers are sized from the bytes actually received, never the claim, and `finalize_file` additionally rejects a completed transfer whose reassembled size differs from the claimed `file_size`. Rejected chunks are dropped with a warning log and leave no assembly or metadata state behind. The wire format is unchanged. + - **Assemblies are bound to their sender.** The first accepted chunk pins the assembly to `message.sender`; chunks for the same `file_id` from any other sender are rejected (`sender_mismatch`), so a third party can no longer poison an in-progress transfer it can observe. + - **Receive-path memory is hard-bounded.** `FileTransferConfig` gains `max_total_buffered_bytes` (default 128 MiB) — the total bytes buffered across *all* inbound assemblies, where each stored chunk is charged its payload plus a flat 64-byte bookkeeping overhead so floods of tiny chunks cannot pin hashmap/allocator memory the payload sum does not see. A chunk that would exceed the budget is rejected and, because a dropped chunk is never retransmitted (it was already ACKed), the affected transfer's partial buffer is freed immediately instead of squatting the budget until the 300 s stale sweep. Budgets below what a single `max_file_size` transfer charges are clamped up with a warning (any lower value would make `max_file_size` unachievable). Counts are additionally capped by `max_concurrent_assemblies` (default 32) and the new per-sender quota `max_assemblies_per_sender` (default 16), so no single peer can occupy every slot. + - **Dropped inbound transfers are now observable.** A new `FileReceiveFailed` event (`type: "file_receive_failed"`; telemetry name `protocol.file.receive_failed`) fires when a well-formed transfer is dropped by a resource limit (`too_many_transfers`, `sender_quota_exceeded`, `buffer_budget_exhausted`), when a completed transfer fails its size/checksum integrity checks (`integrity_check_failed`), or when the 300 s stale sweep discards an incomplete transfer (`stale_timeout`). No `FileReceived` follows for that `file_id`; the sender must re-send the file. Malformed chunks (attacker-shaped input) are still dropped with only a warning log. + - **`FileReceiveFailed` is terminal and fires at most once per transfer.** A failed transfer's `file_id` is tombstoned (a capped, randomly-keyed hash set — attacker-length ids cannot grow it), so its remaining in-flight chunks — already ACKed and still streaming — are dropped silently (`previously_failed`) instead of re-emitting the event once per chunk or resurrecting a partial assembly that could never complete (which would squat an assembly slot and the byte budget until the stale sweep, then fail a second time as `stale_timeout`). Tombstones expire by the same staleness rule — no chunks for 300 s — after which the id may be reused; retries should use a fresh `file_id` (the SDK's send path already mints one per attempt). Explicit `cancel_transfer` does not tombstone. + - **Breaking (Rust API only):** `FileTransferManager::process_chunk` now takes the sender (`process_chunk(sender, chunk)`) and returns `Result` instead of `Option`; `FileTransferManager::cleanup_stale_transfers` returns `Vec` (file id, name, and sender of each dropped transfer) instead of `Vec`; `FileTransferManager::chunk_file` now fails when the chunk size would split the file into more chunks than the receive-side `total_chunks` cap (previously such a transfer was produced, ACKed, and then silently dropped chunk by chunk on the receiving side); `FileTransferConfig` gains the three fields above (spread `..FileTransferConfig::default()` when constructing it as a struct literal). `OfflineProtocol` currently uses the default `FileTransferConfig` (not yet configurable via `ProtocolConfig`). + - **Behavior change on the mobile FFI (signatures unchanged):** the manual `process_file_chunk` path now returns an error carrying the stable rejection reason when a chunk is refused — it previously reported success unconditionally, so Swift/Kotlin/React Native callers that ignored the result will start seeing throws under the new limits. Manual chunks are bound to an internal `manual:ffi` pseudo-sender (the `:` is invalid in wire user IDs, so no remote peer can claim it) and collectively share that pseudo-sender's concurrency quota (16 concurrent transfers by default) and the global buffer budget. Once a manual transfer fails, resending chunks under the same `file_id` is rejected as `previously_failed` until the id's tombstone expires (no chunks for 300 s); retry with a fresh `file_id` instead. + +- **Encryption is fail-closed by default (SEC-M3)** + `EncryptionConfig::default()` now sets `require_encryption: true`. Previously the flag defaulted to `false`, so a node whose MLS was never initialized (or whose encryption was disabled) silently sent every message — text and media — as plaintext over whatever transport DORS picked, including public internet relays, while the SDK advertised automatic end-to-end encryption. "Forgot to initialize MLS" and "deliberately opted out of encryption" are now distinguishable: the former fails, the latter requires explicit configuration. + - *Sending with MLS uninitialized*: every `send_message` / `send_media` fails with `EncryptFailed` (the error message names the remedy: call `initialize_mls()` or explicitly set `require_encryption = false`). Nothing reaches the transport. + - *Sending with MLS initialized*: behavior is effectively unchanged — messages to peers with a confirmed session encrypt as before; messages to peers without one are queued (`store_pending`, still the default) and flushed encrypted on confirmation. The strict queue path now also **kicks session establishment at queue time** (imports a stored key package, creates the session, sends the Welcome — matching the non-strict and media paths), so first-contact delivery does not depend on the peer initiating or on the arrival-time auto-establish having succeeded. + - *Receiving*: legacy plaintext media chunks are rejected by default (the existing `require_encryption` downgrade gate). Old-version peers must upgrade, or the receiver must opt out, for plaintext media to be accepted. + - *Explicit opt-out*: set `require_encryption = false` (and for full plaintext operation `enabled = false` — config validation intentionally requires the double opt-out, rejecting `enabled = false` alone). Every plaintext user-content send under the opt-out emits the new `SecurityWarning` reason code **`PLAINTEXT_SEND`** (`SecurityWarningCode::PlaintextSend`, TypeScript `'PLAINTEXT_SEND'`), at most once per peer per protocol instance, so cleartext flows stay visible. + - *Exempt (unchanged)*: internal control messages — key packages, Welcomes, connection request/accept/reject, session-confirmation probes/ACKs, and service discovery/request/response — are signed plaintext bootstrap messages and continue to work under strict mode. The service-discovery and API-reference docs previously claimed these APIs error under `require_encryption = true`; that claim was stale and the docs now describe the exemption. + - **Breaking (all bindings):** the UniFFI UDL now declares `require_encryption = true` as the dictionary default (generated Swift/Kotlin/Python constructors default to `true` when the field is omitted), and the React Native layer's own fallbacks (the JS config mapping and the iOS/Android native bridges) flipped from `false` to `true`. Apps that relied on the implicit `false` — sending plaintext without ever initializing MLS — must either call `initialize_mls()` (RN: `initializeMlsWithSecureStorage()`) or pass `require_encryption: false` explicitly. Apps that already set the flag explicitly are unaffected. + +- **Router relay policy is enforced, and the never-wired congestion controller is removed (CQ-H3, CQ-M11, CQ-M2/M3/M7)** + Three cases where the router crate carried policy that didn't govern behavior: + - **`allow_relay: false` now blocks relay promotion in every priority mode (CQ-H3).** The opt-out was only consulted under `RelayPriority::Always`; in the default `Auto` mode a device whose owner disabled relaying could still be promoted to the relay role — the charging shortcut promoted it unconditionally. Message *forwarding* was always gated separately on the same flag, so no unwanted forwarding occurred; the bug was a mislabeled role — dishonest `RelayPromoted` events and `relay_role` telemetry, plus the battery/duty implications of advertising a role the owner opted out of. Promotion is now refused whenever the config forbids relaying (`allow_relay: false` or priority `Never`), and a device holding the relay role under such a config is demoted on the next evaluation tick with the new `RelayDemotionReason::RelayDisallowed`, surfaced as a `RelayDemoted` event with reason `"relaying disabled by configuration"`. Devices mispromoted by the old behavior emit that one-time demotion after upgrading. + - **`RelayManager::select_best_relays` takes the congestion threshold as a parameter (CQ-M11).** Candidates were filtered against a hardcoded `0.7` instead of the configured `PathConfig::max_congestion_level`. The threshold is the caller's path policy, so it is now an explicit `max_congestion_level` argument rather than a second config knob that could drift from `PathConfig`. The engine's own relay filtering (`PathSelector`) always used the configured value; only direct Rust consumers of `RelayManager` are affected. + - **The `congestion` module is removed (CQ-M2/M3/M7).** `CongestionController` was public API wired into nothing: no send path ever consulted its decisions, its "AIMD" rate control was multiplicative in both directions, and its per-sender fair-queuing state grew without bound. It provided no congestion protection while its module docs claimed to "prevent network collapse". The congestion signaling that *is* wired in — `TransportMetrics::congestion`, DORS congestion scoring and escalation, relay congestion filtering — is unaffected. Future rate control should be designed against the real send path. + - **Breaking (Rust API only, router crate):** `select_best_relays` gains a `max_congestion_level: f32` parameter; `RelayDemotionReason` gains the `RelayDisallowed` variant (exhaustive matches need a new arm); the `offline_protocol_router::congestion` module and its re-exports (`CongestionConfig`, `CongestionController`, `DeliveryOutcome`, `SendDecision`) are removed. No wire-format or FFI changes. + +- **Nostr transport signing key is no longer derivable from the device ID (SEC-M4)** + The Nostr transport previously derived its secp256k1/BIP-340 signing key as `SHA-256(device_id)` — and the device ID passed in practice is the public protocol `user_id` — so anyone who knew a user's ID could reconstruct that device's transport private key and sign or replay Nostr events as it. The old derivation served two roles that are now split: + - *Addressing (unchanged on the wire).* The `#p` routing tag peers compute from a device ID (`routing_tag_for_device_id`, byte-identical to the old derivation) remains public and derivable — it is a rendezvous label, like an email address — so old and new SDK versions continue to address each other with no migration. + - *Event signing (now a real secret).* Events are signed with a key derived via domain-separated HKDF-SHA256 from a 32-byte per-install random secret. The secret is generated on first `initialize_mls()` / `enable_message_persistence()` and persisted through the app's `MlsStorage`; before storage is available (or if it never is), the transport signs with an ephemeral random key that rotates per process. Inbound handling is unaffected: the SDK has never authenticated inbound events by their Nostr pubkey (sender authenticity comes from the protocol-layer MLS signatures), which is exactly why the split interoperates. + - *Relay-visible identity rotates once on upgrade* (and whenever app storage is wiped): the `pubkey` on published events changes from the derived value to the per-install one. Relays and external observers see a new identity; peers are unaffected. + - *Platform note:* `nostr_get_public_key()` now returns the install's signing pubkey, which changes when `initialize_mls` installs the persisted secret — read it after MLS initialization instead of caching it across that boundary (platforms use it to filter self-authored events). The bundled React Native `NostrManager`s re-read it on every relay (re)connect, so their self-event filter stays correct regardless of configure/initialize ordering. `nostr_get_subscription_filter()` filters on the routing tag and is stable as before. FFI signatures are unchanged. + - **Breaking (Rust API only):** `NostrKeypair::from_device_id` and `NostrKeypair::pubkey_hex_for_device_id` are removed — use `NostrKeypair::from_install_secret` / `NostrKeypair::generate_ephemeral` and the free `routing_tag_for_device_id` — and `NostrTransport::public_key_hex` now returns `String`. New APIs: `NostrTransport::install_signing_secret`, `NostrTransport::routing_tag`, `NostrKeypair::generate_install_secret`. + +### Changed + +- **MLS sender-ratchet out-of-order tolerance raised from the OpenMLS default (5) to 32** + Windowed media transfers keep up to 8 encrypted chunks in flight, interleaved with text on the same 1:1 session ratchet; with tolerance 5 a delayed chunk could become permanently undecryptable and stall a transfer. Applies to newly created/joined groups and sessions; pre-existing persisted sessions keep the configuration they were created with until re-established. +- **Encrypted media chunks that arrive before the session is ready are queued** through the same pending-decryption queue the text path uses and are re-processed on session confirmation. +- **Encrypted media chunks are bound to the claimed sender.** A chunk's MLS group must be the deterministic 1:1 session group of the wire sender (`session::`); a valid ciphertext from any *other* session delivered under a different sender's name is dropped and surfaced as a `SecurityWarning` with the new `MEDIA_SENDER_GROUP_MISMATCH` reason code. Without this, any peer holding a session with the local user could have media attributed to an arbitrary identity. +- **Pending-queue drops of encrypted media chunks now fail loudly.** A media chunk evicted from the pending-decryption queue (per-peer/global overflow, or the 120 s TTL) is unrecoverable — it was ACKed on receipt, so the sender never retransmits it and its transfer can never complete. Each dropped chunk now emits `MessageDecryptionFailed` with the new `PENDING_QUEUE_DROPPED` code (plus a warning log) instead of stalling the transfer silently. Note the sender still observes per-chunk delivery ACKs and may emit `MediaSent`; receiver-side failure is the authoritative signal. +- **Concurrent outbound media transfers are capped at 2 per recipient when encrypted** (`Error::MediaTransferLimit`). Each transfer keeps up to 8 chunks in flight on the recipient's session ratchet, whose out-of-order tolerance is 32; unbounded concurrent transfers could push a delayed chunk beyond the tolerance and permanently stall it. Retry after an active transfer completes. +- **`send_media` fails fast on oversized chunk-0 metadata.** The encrypted-media plaintext codec now enforces its 256 KB metadata bound (and the content-type length bound) at encode time, so a too-large thumbnail fails the send with a `Serialization` error instead of being silently dropped by the receiver. +- **Legacy plaintext media policy fails closed on storage errors.** If the session-confirmation lookup errors while gating an unencrypted chunk, the chunk is rejected (previously a storage fault would disable the downgrade gate). +- **Outbound media transfers abort loudly instead of wedging.** A chunk that fails to encrypt mid-transfer (e.g. the session was invalidated concurrently) now aborts the whole transfer: all tracking state is removed (freeing its per-peer transfer slot immediately instead of after the 300 s stale sweep) and the new `MediaSendFailed` event is emitted. Terminal chunk failures in the outbox emit the same event; previously both cases stalled silently with only a log line. +- **The pending-decryption queue is byte-bounded.** `PendingQueueConfig` gains `max_pending_bytes_per_peer` (default 4 MB) and `max_pending_bytes_global` (default 32 MB). The queue now holds full-size encrypted media chunks (up to 256 KB each), so the existing count limits alone would have admitted ~1 GB of unauthenticated ciphertext at defaults. Overflow follows the configured policy; dropped media chunks surface via `PENDING_QUEUE_DROPPED` as before. Existing serialized configs without the new fields deserialize with the defaults. +- **Hard decryption failures on media chunks emit `MessageDecryptionFailed`.** A media chunk whose ciphertext cannot be decrypted (e.g. its ratchet key was deleted after 32+ newer messages decrypted first) was already ACKed on receipt, so the loss is permanent; it now surfaces to the app with the mapped failure code instead of only MLS telemetry. +- **`Error::MediaTransferLimit` is a typed FFI error.** Previously it crossed UniFFI as the opaque `Other`; Swift/Kotlin now receive `ProtocolError::MediaTransferLimit` and the React Native bridge maps it to the `MediaTransferLimit` rejection code for programmatic retry. + +### Bug Fixes + +- **Group messages that outrun their Welcome (or a commit) are buffered instead of permanently lost.** The Welcome and the first group message are sent back-to-back and can arrive out of order across transports — but a group application message that failed MLS decryption was dropped on the spot, and because its ID was already recorded for replay protection, every redelivery (outbox retry, second transport, relay replay) was rejected as a duplicate: the first message(s) after a group invite could never be delivered. + - *Buffering.* A decrypt failure caused by lagging local group state (`GroupNotFound` — Welcome not yet processed — or an epoch-behind `Decryption` failure) buffers the ciphertext (16 per group, TTL matched to the 5-minute replay-dedup window so the buffered copy outlives the dedup entry that blocks redelivery). The buffer is drained in arrival order after a successful Welcome join or commit for the group. Permanent failures (bad payloads, sender-identity mismatches) are still dropped immediately, and buffers are cleared on group leave/removal. + - *Commits get the same repair.* `GroupNotFound` was previously classified as a permanent commit failure, so a commit that outran the Welcome was dropped outright — it is now buffered like any other out-of-order commit, and a successful Welcome join drains buffered commits first (advancing the epoch) and then buffered messages, so the invite → membership-change → first-message sequence delivers regardless of arrival order. A commit or proposal that arrives *on the application-message channel* (MLS consumes it and returns no plaintext) also counts as a state advance and triggers the same drains — including mid-drain, where a consumed riding commit re-runs the drain pass so entries buffered ahead of it in arrival order still deliver. + - *Both inbound paths, one classifier.* The mesh (`__GRP_MLS_MSG__`) and relay (`__GROUP_MSG__`) decrypt-and-classify logic is now shared. The relay dispatch previously routed through MLS only for groups already present in the local members cache — which is populated *by* the Welcome, so a relay group message that outran its Welcome was emitted to the app as raw base64 and never buffered. Relay group messages now route through the MLS path whenever MLS is initialized. To keep legacy relay-only groups working, a payload for a group without local MLS state is buffered only if its bytes parse as MLS wire framing (`is_mls_framed`); legacy plaintext that merely happens to be valid base64 is emitted raw as before (and non-MLS garbage on the mesh path is dropped instead of buffered). + - *Global bounds.* Because `GroupNotFound` buffering is by definition pre-authentication, both buffers are also globally bounded across groups (messages: 256 entries / 8 MiB of ciphertext / 32 distinct group IDs; commits: 64 entries / 4 MiB / 16 distinct group IDs) — per-group caps alone left total retention open-ended across attacker-chosen group IDs, and the distinct-group cap also bounds map-key memory (group IDs are wire-supplied, up to 4 KiB each). An entry that alone exceeds the byte budget is dropped outright instead of purging the buffer. + - *Eviction policy.* At entry/byte capacity the oldest entry of the *largest* per-group buffer is evicted, so a flood that concentrates in a few group IDs is evicted before honest welcome-race entries (few per group). Freeing a *group slot* at the distinct-group cap instead evicts the single largest per-group buffer wholesale — emptying some group is the only way to free a slot, and doing it one entry at a time would purge nearly the whole map across level-sized buffers before any group emptied. A spread flood (one entry per fabricated group ID) levels the buffer sizes and degrades eviction to globally-oldest within the group cap. + - *Dropped-undelivered entries release replay protection — both layers.* To keep cap eviction or TTL expiry from causing permanent loss, any buffered entry dropped undelivered releases its replay-protection records so a sender-side redelivery is accepted fresh instead of being rejected as a duplicate of a copy that no longer exists. This releases both the group-level dedup ID *and* the transport-level deduplicator entry: on the mesh path the envelope ID doubles as the group dedup key, and the receive-loop deduplicator (1-hour retention by default) would otherwise swallow — and re-ACK — the redelivery before it ever reached the group handlers. Applies to buffered commits exactly as to buffered messages (each pending commit records its wire message ID for this), at every drop site: cap eviction, drain-time TTL expiry, and the periodic cleanup sweep. +- **`known_peers` no longer locks out new neighbors once full (#140).** Since the reachability seam fix (#136), every inbound message on Internet/WiFi Direct/Reticulum/Nostr inserted its sender into the known-peers set, but only BLE disconnects ever removed entries — a long-running node (e.g. an internet relay) accumulated senders until the 1000-entry cap, after which *no* new peer could be tracked, including a local BLE neighbor. Peers now carry a last-seen timestamp refreshed on every discovery signal: entries not re-seen for 30 minutes are evicted by the periodic cleanup sweep, and an insert at capacity evicts the least-recently-seen entry instead of refusing the new peer. Evictions mirror `on_neighbor_lost` (the key-package marker is cleared too, so a re-appearing peer gets a fresh key package, as on a BLE reconnect — this also stops `key_package_sent_to` growing unboundedly for tracked peers). Behavioral note: `is_known_peer()` can now flip to false for a peer that has gone quiet for the TTL without any `NeighborLost` event, and the relay-role `connection_count` decays accordingly instead of pinning at stale values. +- **WiFi Direct disconnects now clear core discovery tracking.** `wifi_direct_peer_disconnected` emitted the `NeighborLost` platform event but never notified the core protocol, leaving the peer tracked (and its key-package marker set) until the TTL sweep; it now calls `on_neighbor_lost`, matching `ble_peer_lost`. +- **Adaptive TTL no longer collapses at ~25,650 estimated devices (CQ-M1).** The size-based TTL boost computed "extra hundreds of devices" in a `usize` and narrowed it with a bare `as u8` cast, which wraps modulo 256: at an estimated 25,650+ devices the boost silently vanished — TTL fell back to the small-network base instead of the configured maximum, exactly when reach matters most — and oscillated as the network grew further. The count now saturates at the cast boundary; the existing max-TTL clamp bounds the result as before. +- **Periodic cleanup sweeps no longer risk a panic on freshly-booted devices.** The group-message dedup, pending-commit, pending-group-message, and service-discovery dedup sweeps computed their expiry cutoff as `Instant::now() - TTL`, which panics on underflow — and on platforms where the monotonic clock starts at boot (Linux, Android, embedded), that subtraction underflows whenever the process is younger than the TTL, e.g. a messaging service auto-started at boot running its first sweep within 5 minutes. Entry ages are now compared with `saturating_duration_since` instead. + diff --git a/docs/changelog/0.13.md b/docs/changelog/0.13.md new file mode 100644 index 00000000..5992ef2a --- /dev/null +++ b/docs/changelog/0.13.md @@ -0,0 +1,41 @@ +# Changelog: 0.13.x + +Archived release notes. The working changelog, covering the current release +and unreleased changes, is [CHANGELOG.md](../../CHANGELOG.md). + +| Release | Date | +|---------|------| +| 0.13.1 | 2026-07-14 | +| 0.13.0 | 2026-07-13 | + +## [0.13.1] — 2026-07-14 + +### Fixed + +- **React Native `GroupInfo` and `UserGroups` relay snapshots are now lossless.** Android and iOS continue emitting the existing typed `group_info` / `user_groups` projections and additionally emit the original frame as `internet_server_message`. Application-owned fields such as descriptions, avatars, profiles, membership details, pending join requests, and unknown future extensions now reach JavaScript verbatim without expanding the SDK's typed group schemas. The two events have no cross-channel ordering guarantee. + +- **Relay authentication now fails closed when no token is configured.** The Android and iOS internet bridges previously fell back to sending `deviceId` as the authentication token, allowing a forgeable identifier to be presented as an authenticated identity. They now refuse to authenticate without a non-empty token and let the existing authentication watchdog close the unauthenticated connection. Clearing a token on an already-authenticated connection does not invalidate the current session. + +- **Invalid Bloom-filter deduplication settings no longer panic.** Top-level protocol configuration now rejects zero-sized filters, zero hash/filter counts, and zero rotation intervals. Direct users of the reliability crate fall back to exact `HashMap` tracking when given invalid Bloom settings. + +- **Local Android builds now install the UniFFI library under the name the generated loader expects.** `build-android.sh` writes `libuniffi_offline_protocol.so`, matching release artifacts and preventing successful local builds from failing when the app loads the native library. + +- **Python dependency metadata is consistent and installable on Windows.** The reproducible requirements now respect the manifest's Bleak range, and the unsupported Bless peripheral backend is excluded on Windows to avoid incompatible WinRT dependency pins. + +### Changed + +- **Breaking for direct Rust users: `MockTransport` now requires the `test-utils` feature.** The production `offline-protocol-transport` API no longer exposes test-only mock machinery by default. Tests and development tools that import `MockTransport` must enable `offline-protocol-transport/test-utils`. + +- **Build and release checks are stricter.** CI now verifies Rust 1.87 compatibility, generated Swift/Kotlin/Python binding freshness, release-library builds on Linux, macOS, and Windows, dependency policy, and unwrap-free library code. Release credentials are scoped to the publishing job, privileged actions are pinned, and tag-derived versions are validated before use. + +## [0.13.0] — 2026-07-13 + +### Added + +- **Persistent store-and-forward outbox — undelivered messages survive app restarts.** + The outbox that holds messages awaiting delivery/ACK was purely in-memory, so a message queued while offline was lost if the app was killed before a carrier appeared. When message persistence is enabled (automatically via `initialize_mls`, or explicitly via `enable_message_persistence`), each main-outbox entry is now persisted through the existing `MlsStorage` key-value interface under a new `"outbox"` key type and restored on startup. Restored entries re-drive delivery on `start()` via the existing outbox-flush path. + - *Carrier-relative TTL*: the outbox lifetime clock is not wall-clock. An entry whose lifetime lapsed while the app was closed is refreshed on restore rather than immediately reaped, so a message that never had a delivery opportunity gets a fresh window once a carrier (or the peer) reappears — mirroring the Welcome-lifecycle restart behavior. + - *Bounded and self-healing*: the restored set is pruned to the in-memory capacity (newest kept), corrupted records are dropped from storage and skipped, and an entry delivered-then-crashed before its delete is resent and de-duplicated by the receiver's re-ACK path (at-least-once, matching the existing contract). + - *Media excluded by design*: the file-chunk (media) outbox is intentionally **not** persisted. File transfers are not durable, so a resurrected chunk could never complete its transfer — media transfers must be re-initiated by the app after a restart. + - No FFI, UDL, binding, or event changes; the storage contract is additive (older SDKs ignore the unknown `"outbox"` key type). + diff --git a/docs/changelog/0.14.md b/docs/changelog/0.14.md new file mode 100644 index 00000000..10810228 --- /dev/null +++ b/docs/changelog/0.14.md @@ -0,0 +1,49 @@ +# Changelog: 0.14.x + +Archived release notes. The working changelog, covering the current release +and unreleased changes, is [CHANGELOG.md](../../CHANGELOG.md). + +| Release | Date | +|---------|------| +| 0.14.0 | 2026-07-16 | + +## [0.14.0] — 2026-07-16 + +### Added + +- **Compact binary wire codec for messages, negotiated per peer.** Messages can now be serialized with a postcard-based binary encoding (wire v1) instead of JSON, cutting the message envelope roughly 3–4× on small and media messages and correspondingly reducing BLE fragment counts. JSON remains the permanent interoperability floor: receivers auto-detect the codec from the first byte, and binary is only ever sent to a peer that advertises support via `wire_versions` in its signed key package. Controlled by `TransportConfig::binary_wire_enabled` (default on); a mixed fleet stays on JSON automatically, and the internet relay path stays JSON. Decoding of binary frames is always enabled. No persistence, FFI, or relay-protocol changes. + +- **Compact MLS envelope for encrypted DMs, negotiated per recipient.** The `__MLS_ENC__` payload for peers that advertise `env_versions` in their key package is now base64 of the binary `EncryptedMessage` form instead of JSON whose ciphertext rendered as a ~3.6× integer array. Measured on a real OpenMLS ciphertext with realistic identifier lengths, an encrypted text DM drops from 1342 to 808 bytes on the JSON wire (relay included) and to 472 bytes on the binary mesh wire — 2.8× smaller than the released baseline, BLE fragments 10 → 4. Legacy JSON stays the permanent envelope floor, inbound parsing accepts every historical form unconditionally, and a stale or forged capability can at worst produce a `message_decryption_failed` event, never app-visible garbage. Controlled by `EncryptionConfig::compact_envelope_enabled` (default on), independent of the wire-codec kill switch. + +- **React Native wrapper exposes both wire-format kill switches.** `ProtocolConfig.binaryWireEnabled` and `encryption.compactEnvelopeEnabled` (both default `true`) now flow from the JS config through the iOS/Android native modules to the core `ProtocolConfig`, so React Native apps can disable the binary wire codec or the compact MLS envelope at runtime — previously the switches existed in the UniFFI layer but were not reachable from JS. The native bridges accept `compactEnvelopeEnabled` nested under `encryption` or at the top level, and `binaryWireEnabled` at the top level (its home in the flat UniFFI config), each in camelCase or snake_case, matching the existing config-parsing conventions. + +- **Binary wire frames carry base64 content tails raw (ext TLV tag 1).** When a message's `content` ends in a long canonical-base64 run (the compact MLS envelope, Welcome blobs, and similar), wire-v1 frames now carry the decoded bytes in the frame's extension section instead of paying the 4/3 base64 inflation, reconstructing the exact original string on decode. The split is verified byte-for-byte at encode time, so arbitrary content is safe by construction. `WireMessageV1`'s frozen layout is unchanged; tag 1 ships inside wire v1's first release, so advertising v1 implies understanding it. + +- **Connection requests can carry a first message.** `send_connection_request` accepts an optional `initial_message`, delivered verbatim in the recipient's `connection_request_received` event so apps can seed the conversation on accept — exposed through the UniFFI bindings (Swift/Kotlin/Python) and the React Native wrapper (`initialMessage`). Connection requests precede the MLS session, so the text travels in plaintext and should be treated like the sender display name; input above 4 KiB is rejected with `InvalidArgument`. Payloads without the field parse unchanged and an unset field round-trips byte-identical, so the wire format stays compatible with older builds in both directions unless the feature is used. + +- **Typed feedback when a connection request hits an offline peer.** The relay's authoritative "recipient offline" verdict (`DeliveryError`) now surfaces immediately as a `connection_request_undeliverable` event carrying the recipient, the message id returned by `send_connection_request`, and the transport reason — instead of being silently discarded until generic retry exhaustion. Emitted at most once per request, and never for a request already proven delivered (delivery ack, or an accept/reject from the peer) or one older than the correlation window; all other transport failures stay with the normal retry machinery, which may still deliver — the event is a status signal, not proof of permanent failure. + +- **Typed feedback when a connection request exhausts its retries.** Retry exhaustion — the terminal outcome for a request that never got an authoritative offline verdict — now also emits `connection_request_undeliverable` (reason `max_retries_exceeded`) alongside the generic `message_failed`, so every sender-side connection-request failure reaches apps through one typed channel correlated by the message id `send_connection_request` returned. The pending-request correlation window widened from 300s to 600s so it outlives the worst-case default retry schedule. + +- **The identity and connection-request contracts are now documented API.** The React Native README, integration guide, and API reference now state the canonical-identity invariant (`neighbor_discovered.peer_id` is the peer's `ProtocolConfig.userId` on every transport and is used directly as a send/connection-request `recipient`) and the full connection-request lifecycle: which events fire for offline, retry exhaustion, delivery, accept, reject, and cancel, and which identifier correlates each. + +### Changed + +- **Breaking for direct UniFFI consumers: `send_connection_request` gained an `initial_message` parameter.** Swift, Kotlin, and Python callers must pass the new final argument (`nil`/`null`/`None` preserves the old behavior); the change fails at compile time, and mixed library/binding versions fail at load via the UniFFI checksum. The React Native JS API is unchanged — `initialMessage` is a new optional field on `SendConnectionRequestParams`. + +### Removed + +- **Breaking: relay-native connection-op translation removed from the mobile internet bridges.** Connection requests, accepts, rejects, and cancels now travel verbatim as Ed25519-signed `SendMessage` frames (see the signature-downgrade fix below), so the bridges no longer emit `SendConnectionRequest`-family frames or rebuild inbound `ConnectionRequestReceived`/`ConnectionAccepted`/`ConnectionRejected`/`TypingUpdate` relay frames. Consequence: pre-SDK relay clients and older SDK builds can no longer deliver connection requests **to** a new-SDK peer over the relay (new→old keeps working, and the relay server is unchanged); unhandled relay frames still reach apps verbatim as `internet_server_message`. The SDK's internet transport is now the only supported relay client. + +### Fixed + +- **React Native encryption settings now actually reach the protocol core.** The JS wrapper sent `encryption.enabled`, `autoKeyExchange`, `storePending`, and `requireEncryption` as flat top-level keys while the iOS and Android bridges read them only from the nested `encryption` object, so every app-set value was silently discarded and the all-true defaults won. The wrapper now sends the nested shape alongside the flat keys (kept in lockstep), and both bridges accept either shape in camelCase or snake_case, locked by unit tests on both platforms. The sibling flags also now default to the value of `enabled`, so `encryption: { enabled: false }` alone yields the coherent fully-disabled posture (mirroring Rust's `EncryptionConfig::disabled()`) instead of a node whose every send fails with `EncryptFailed`; explicitly combining `enabled: false` with `requireEncryption: true` is rejected loudly at `create()` by the existing core validation. + +- **Connection requests over the internet relay now reach peers you have already talked to.** The mobile bridges used to translate connection ops into relay-native frames, stripping the Ed25519 control signature from the message metadata; the receiving bridge rebuilt them unsigned, and the security gate correctly dropped the rebuild as a signature downgrade once the sender's key was TOFU-pinned — which any prior signed contact (e.g. an MLS key-package exchange) does. Connection requests therefore failed for exactly the peers a session already existed with, while plain messages kept working. Shipping the ops verbatim keeps the signature intact end to end, and offline recipients additionally gain the relay's push-notification fallback, which the relay-native connection frames never had. + +- **The shipped commercial-license copies now name the licensor.** The `LICENSE-COMMERCIAL.md` files packaged in the React Native npm tarball and the Python wheel previously granted rights from "the project" without naming a legal entity; all copies now name Offline Protocol, Inc., matching the root license and the CLA. + +- **Nostr transport pubkeys no longer leak into discovery.** When a Nostr frame failed to deserialize, the ingest fallback surfaced the sender's per-install transport signing pubkey as `neighbor_discovered.peer_id` — a value that is not a protocol identity and breaks everything keyed by user id (self-suppression, blocking, outbox flush). An undecodable frame now surfaces no discovery at all. + +- **Relay group fan-out is no longer dropped for TOFU-pinned senders.** The relay re-emits `__GROUP_MSG__` per member from only `{group_id, sender, content}`, so the rebuilt frame is structurally unsigned and the Ed25519 gate dropped every fan-out frame from a pinned sender before MLS ever saw the ciphertext. `__GROUP_MSG__` is now classified as data plane: authentication happens after the gate instead, where MLS decryption binds the wire-claimed sender to the MLS-authenticated sender (mismatches are rejected as spoofing) and plaintext naming an MLS-secured group is dropped. Groups without any MLS state keep the legacy relay-only plaintext behavior. + diff --git a/docs/changelog/0.15.md b/docs/changelog/0.15.md new file mode 100644 index 00000000..cc3f547f --- /dev/null +++ b/docs/changelog/0.15.md @@ -0,0 +1,19 @@ +# Changelog: 0.15.x + +Archived release notes. The working changelog, covering the current release +and unreleased changes, is [CHANGELOG.md](../../CHANGELOG.md). + +| Release | Date | +|---------|------| +| 0.15.0 | 2026-07-20 | + +## [0.15.0] — 2026-07-20 + +### Added + +- **`presence_updated` events now carry their source.** A new `source` field distinguishes the internet relay's authoritative answers (`internet`) from peer-sent `__PRESENCE__` self-reports (`peer`), so apps rendering relay-style headers ("Online" / "Last seen …") can filter on `internet` instead of guessing from `last_seen_ms` absence — which relay answers can legitimately share when the relay doesn't know a last-seen. The value is `peer` (not `mesh`) because self-reports are transport-agnostic and may arrive relay-forwarded. Additive: events without the field deserialize as `peer`; no UniFFI interface change (events cross the FFI as JSON). + +- **`checkInternetPresence` accepts `{ force: true }` for chat open/focus.** A non-forced manual check fails fast when the relay socket isn't authenticated or the client-side rate-limit mirror momentarily defers — which is exactly the state an app resuming from background is in when a chat header wants a fresh answer. A forced check parks in the bridge and retries until the transport is authenticated and rate-admitted (bounded at 8 seconds, then resolves `false`), is serviced immediately on the authenticated edge, survives reconnects until its deadline, drains to `false` on an explicit transport stop, and fails fast against a stopped transport (no reconnect is coming). Force never bypasses rate limiting (the client bucket mirrors the relay's server-side budget, where an over-budget frame is dropped *after* a locally-successful write) and forced checks never join the SDK's automatic presence watch set. Parked forced checks are capped at 32 concurrent entries — checks beyond the cap resolve `false` immediately instead of growing the queue without bound. **Note for over-the-air JS updates (CodePush-style):** the native `checkInternetPresence` method gained an `options` argument on both platforms, and React Native matches native methods by argument count — the JS and native halves of this package must ship together. A JS-only update across this version boundary (in either direction) makes `checkInternetPresence` calls fail with an argument-count mismatch. + +- **The manual presence-check contract is now documented and regression-pinned.** The SDK never throttles or dedupes manual presence checks, and every relay answer re-emits `presence_updated` even when the status is unchanged — a Rust regression test now locks the no-dedup behavior, and the React Native README documents the full contract (fire-and-event, subscribe-before-check, blocked/self suppression, `last_seen_ms` availability caveats) for apps replacing their own presence layers. + diff --git a/docs/changelog/0.16.md b/docs/changelog/0.16.md new file mode 100644 index 00000000..9a555aaf --- /dev/null +++ b/docs/changelog/0.16.md @@ -0,0 +1,128 @@ +# Changelog: 0.16.x + +Archived release notes. The working changelog, covering the current release +and unreleased changes, is [CHANGELOG.md](../../CHANGELOG.md). + +| Release | Date | +|---------|------| +| 0.16.6 | 2026-07-28 | +| 0.16.5 | 2026-07-27 | +| 0.16.4 | 2026-07-25 | +| 0.16.3 | 2026-07-25 | +| 0.16.2 | 2026-07-24 | +| 0.16.1 | 2026-07-24 | +| 0.16.0 | 2026-07-24 | + +## [0.16.6] — 2026-07-28 + +### Fixed + +- **Internet-only devices no longer stall a DM or an MLS welcome parked on a `recipient_unreachable` verdict — the escalating reachability probe now runs on every carrier, not just when a local mesh carrier happens to be up.** The park introduced in 0.16.x kept a timed probe (15s doubling to a 600s cap) only when BLE / Wi-Fi Direct was available; on an internet-only device — the common configuration — the else branch parked **edge-only**: no pending ACK, no retry entry, nothing on a timer at all. The message had *no* self-recovery, so delivery waited entirely on an external reachability edge (`flush_outbox_all` on reconnect/`start()`, or a presence-online answer), i.e. on the platform's presence-polling cadence — tens of seconds to minutes, and nothing whatsoever for a headless consumer that never polls presence. This is the residual 1–2 minute delivery delay that survived the earlier relay-socket fixes. The carrier guard was never the real bound anyway (DORS could always route a mesh device's probe onto the relay); the escalation ladder is, and probing over the relay is self-limiting in every outcome — a still-offline peer returns a fresh verdict that escalates the interval, an accepted frame becomes an ordinary in-flight send on the ACK ladder, and a peer that is back means the probe *was* the delivery. The park counter is likewise armed unconditionally, which is what keeps probe exhaustion re-parking (`try_repark_exhausted_dm`) instead of settling terminally. On the welcome side the presence-offline rotation no longer downgrades a live probe back to edge-only, while still cancelling a genuine carrier-backed data-plane retry (the two are told apart by the reason code, not by the sticky park counter). Welcome parks now also extend the lifecycle TTL past the retry they schedule: the ladder reaches 480s/600s while the TTL is 300s, and `try_send_welcome` checks expiry *before* running a due retry, so without this the probe would have expired the very welcome it was meant to recover (`welcome_send_expired` + `secure_session_failed`) after roughly eight minutes of peer downtime. Rust-core only; no wire, schema, UDL, or event-shape change. + +- **A welcome probe that the relay accepts no longer expires the welcome it exists to recover.** The relay answers `DeliveryError` only when the recipient is undeliverable *and* its push fallback also failed — so whenever push succeeds the probe earns no verdict at all and resolves at the 10s confirm timeout instead. Scored as an ordinary carrier-backed failure that charges a retry attempt and arms a plain data-plane retry, and with nothing in that ladder pushing `expires_at`, the record walked into terminal `welcome_send_expired` + `secure_session_failed` within one TTL window (~5 minutes at the first rung) for a peer that was merely offline. The only thing that stopped it was a presence-offline answer cancelling the retry — no defense at all for the headless consumer this probe exists to serve, which is a regression against the pre-probe behavior of parking quiet and surviving. A confirm timeout on a record still holding a live unreachable-park counter is now treated as another unreachable verdict: the attempt is refunded, the interval escalates and the TTL is pushed, mirroring the DM path's `try_repark_exhausted_dm`. The platform bridge's wire confirm (socket-write success, issued before the relay can answer) no longer upgrades a live probe to `Sent` either — that would clear the confirm deadline and park the welcome silently in a state outside every retry scan, making the repark unreachable on exactly the internet path it exists for; the probe stays `SendAttempted` so the deadline can resolve it, and genuine delivery still converges through session confirmation. Bounded by `WELCOME_WATCHLIST_MAX_AGE_SECS` (14 days from creation, the same threshold that gives up watching a peer as permanently dead), past which the record ages out normally — the welcome's twin of the DM probe's absolute outbox bound. + +- **Delivering any DM to a parked peer now re-drives that peer's other parked DMs immediately.** The park counter is per-peer while the probes are per-message, so a burst of DMs to an offline peer escalates the shared ladder once per park — message 1 probes at 15s but the seventh is already at the 600s cap. Clearing the counter on delivery was not enough on its own: the siblings sat out their own escalated timers, and on a consumer that never polls presence the delivery ACK is the only edge that will ever say the peer is back. The re-drive prefers the transport the delivered message was last sent over — the sender's own record, which the ACK just proved reaches the peer — falling back to DORS when that carrier is no longer available locally. (Deliberately not the ACK's transport label: that field is peer-supplied and decodes any absent or unknown value to BLE, which would let a peer pin a burst of sibling sends to a carrier of its choosing.) + + **Contract notes for app teams:** `message_undeliverable` now repeats on internet-only devices for as long as the peer stays offline (once per probe verdict, settling at one per 600s per message) where it previously fired once and went silent — it remains a non-terminal, repeatable status signal, and terminal settlement is still only `message_delivered` or outbox-lifetime `message_failed`. `welcome_send_failed` with reason `PEER_UNREACHABLE` now always carries a `next_retry_at`, where it previously carried none on internet-only devices. Terminal `message_failed` for an internet-only parked DM moves out from ~7 days to ~28 days: each probe refreshes the outbox entry's last-send timestamp, so the sliding 7-day lifetime stops binding and settlement falls to the absolute cap (4× the lifetime) instead. For relay capacity planning, note that the "one frame per 600s" steady state describes only the branch where the relay answers with a verdict; when the relay accepts the frame the probe rides the ordinary ACK ladder (up to `max_retries` sends on 1s → 300s backoff) before re-parking. + +- **iOS: the relay write-stall watchdog now fires on time when send completions arrive out of order — it retires the completing write's OWN slot, not the oldest one around.** The watchdog shipped in 0.16.5 keyed each tracked write on its socket generation and, on a send completion, popped the oldest entry of that generation — so a completion did not necessarily retire its own write. `URLSession` makes no promise that send completions fire in send order, and in the mixed regime where some writes complete while an older one hangs, a fast write's completion discarded the still-hung older write's timestamp and re-keyed the stall clock off a younger send, delaying the zombie-socket teardown by the gap between their send times. The outstanding *count* stayed honest either way — each completion retires exactly one slot, so a genuinely hung write could never drain the FIFO to empty and the watchdog always fired — but the *timestamp* it fires on did not, so it fired late. (In the failure mode the watchdog exists for — a socket the OS silently killed, where nothing completes at all — there are no out-of-order pops and the 0.16.5 behaviour was already exact; this only bites the mixed regime.) `arm` now mints an opaque, never-reused `WriteToken` and the write's own completion hands it back to `disarm`, retiring exactly that entry. Token identity **supersedes the socket-generation tag described in the 0.16.5 entry below** and strictly improves on it: a cancelled completion from a torn-down socket names an entry `reset` already dropped, so it still cannot pop a live successor's freshly-armed slot — and because the token counter deliberately survives `reset`, that now holds by construction rather than by which generation happened to be minted. `sendWatched` also widens from arm+send to the whole arm/send/disarm triple, so a future send site cannot drop coverage on either end, and the `task.taskDescription` generation parse is gone from the watchdog path (that stamp now has a single consumer, the close-`4000` bygone-generation decision). iOS-bridge only, no user-facing API change (`swift test`, 14 cases); no Rust/core, wire, schema, UDL, or event change. + +## [0.16.5] — 2026-07-27 + +### Fixed + +- **iOS: a relay socket the OS killed during suspension no longer black-holes all egress (and can no longer freeze the entire data plane) for the ~1min OS TCP timeout — a new write-stall watchdog is the iOS analogue of OkHttp's `writeTimeout`.** `URLSessionWebSocketTask.send` has no write timeout: on a socket the OS silently killed while the app was suspended, the send completion just never fires — no error, no delegate callback — until the ~1min+ OS TCP timeout. On the iOS relay bridge that stalled write both black-holes all outbound traffic and, if it is a control-op primary, pins `inFlightControlPrimaries > 0` so the poll gate freezes the whole data plane (DMs, ACKs, typing, read receipts) for that entire window. Android never sees this because OkHttp's `writeTimeout(10s)` fails a hung write in seconds. The new `WriteStallWatchdog` gives iOS the same bound: every poll-path data/control write is funnelled through a single `sendWatched` that arms the watchdog immediately before `task.send` (so a future send site cannot silently drop coverage) and disarms it from the completion; the poll checks the oldest outstanding write each tick and, once it ages past the 10s timeout (matching the Kotlin bridge's `writeTimeout`), tears the suspected zombie down — the cancel then fires the hung completions promptly and `autoReconnect` + outbox flush re-drive the backlog. Each tracked write is tagged with its socket generation so a late cancelled completion from a torn-down socket can never retire a live successor's freshly-armed write. Auth, ping, presence, and raw-command writes are deliberately not watched (they cannot freeze the data plane). iOS-bridge only, extracted into a unit-tested standalone policy class (`swift test`, 13 cases); no Rust/core, wire, schema, UDL, or event change. + +- **iOS and Android now automatically force a relay reconnect on foreground after a background stay long enough to have killed the socket — apps no longer need to call `forceInternetReconnect()` on foreground.** `forceInternetReconnect()` (0.16.1) was an app-driven primitive: the app had to detect a stale-post-suspend socket and call it, debounced and gated on background duration. Both native bridges now do this themselves — iOS from `applicationWillEnterForeground`, Android via a `LifecycleEventListener`'s `onHostResume` — gated on a shared, unit-tested `ForegroundReconnectPolicy` (a ~4s minimum background stay, measured with monotonic sleep-inclusive time: `mach_continuous_time` on iOS, `SystemClock.elapsedRealtime` on Android; immune to NTP/wall-clock steps, counts device sleep). The policy consumes its background timestamp on read, so a cold launch or a duplicate foreground with no intervening background never spuriously reconnects. This closes a cross-platform gap the iOS write-stall watchdog exposed: previously only iOS was set to heal proactively, and the JS `forceInternetReconnect()` guidance told apps to call it on foreground on both platforms — which, now that the bridges heal automatically, would double-reconnect and drop a healthy socket. The JS doc is updated accordingly: `forceInternetReconnect()` remains for deliberate user-initiated reconnects and for stale sockets detected while already foregrounded, but should no longer be wired to every foreground. **Action for app teams: remove any existing `forceInternetReconnect()` call wired to a foreground/`AppState` `active` transition — the bridges now do this, and keeping the app-side call double-reconnects and drops a healthy socket, forcing a wasted group re-registration round-trip.** Bridge-only (paired iOS SPM + Android JUnit tests for the shared policy); no Rust/core, wire, schema, UDL, or event change. + +- **Encrypted messages that arrive before the receiver's MLS session is ready no longer become silent loss — every queue-path failure is now an ordinary sender retry (the deferred-ACK atom).** Previously an encrypted DM or media chunk received before its session/group was established was queued for delayed decryption *and* immediately delivery-ACKed *and* left dedup-marked. If the session was not confirmed before the 120 s pending-queue TTL (or the entry was evicted under load), the queued copy was dropped — and because the sender had already received an ACK and the id stayed dedup-marked for up to an hour, the message was lost with no possible retransmission (the receive path recorded a message "seen" at receipt but only "delivered" at decrypt, and welded the two together for the not-yet-decryptable case). Five interdependent changes, correct only together, close this: + - **Deferred, not Consumed.** A message queued because the session is not ready now returns a new `Deferred` outcome: the receiver sends **no** delivery ACK and **unmarks** the id, so the sender keeps retrying and the resend re-enters processing instead of being swallowed by the duplicate re-ACK path. (Genuine crypto failures, malformed payloads, and security rejections are unchanged.) + - **Idempotent enqueue.** Resends of the same id no longer stack duplicate pending-queue entries (which would burn the per-peer budget and double-surface on drain). + - **Drain on any successful decrypt.** The pending-decryption queue was drained only on explicit session-confirmation events (Welcome, confirm probe/ack). A session that became usable purely via a live decrypt — the 1:1 session *owner* (both-create) or an in-band `__MLS_ENC__` that decrypted first — left earlier-queued messages stranded until TTL. `confirm_session_from_successful_decrypt` now drains too. + - **Mark-seen and ACK on drain.** When the queue drains and a message is finally surfaced, its id is re-marked (so a later resend is deduped rather than delivered twice or rejected by the MLS ratchet as a replay) **and** the deferred delivery ACK is sent directly on the transport the message originally arrived on (recorded on the pending entry). This closes the ACK-latency window: a sender no longer has to resend to learn its message was delivered, so it will not mark a locally-delivered message undeliverable after exhausting its retry budget. The ACK degrades gracefully — if the arrival transport is unknown or gone, it falls back to DORS and finally to the sender's-next-resend re-ACK path. + - **Pending-queue TTL 2 min → 30 min** (`PendingQueueConfig.pending_ttl_ms`, FFI-mirrored). With the ACK now deferred, this queue is the primary recovery window before the session confirms; memory stays bounded by the unchanged per-peer/global byte caps and the `DropOldest` overflow policy. An evicted encrypted media chunk still surfaces a `MessageDecryptionFailed`/`PendingQueueDropped` signal, but it is now **advisory** — the transfer is *stalled*, not permanently failed, since the un-ACKed sender keeps retransmitting and a resend can still complete it (the terminal media signal remains `FileReceiveFailed`). Rust-core + UniFFI only; no wire, schema, UDL, or event-shape change. + +- **Mesh group messages that arrive before the receiver's group epoch is ready no longer risk the same queue-path silent loss (the deferred-ACK atom, extended to groups).** On the mesh (non-relay) path a group message received out-of-order — before the commit that advances the receiver's epoch — is buffered for delayed decryption. Previously the buffer branch returned `Consumed`, so the receive loop delivery-ACKed it *before* decryption. Because per-member group fan-out arms ACK-gated retransmission (group envelopes require ACK), that premature ACK told the sender "delivered" and it stopped retransmitting; if the buffered copy was then evicted (per-group/global caps under load) or expired (5-min TTL) before a commit drained it, the message was lost even though the group-level dedup release was already in place. The mesh buffer branch now returns `Deferred` (no ACK, sender keeps retransmitting), the drain sends the deferred delivery ACK on the transport the message arrived on (recorded on the buffered entry), and a duplicate of a *still-pending* message defers rather than re-ACKing — while preserving the dedup-mark-before-decrypt replay-amplification defense (the duplicate path returns before any MLS crypto). The group-level dedup table stays marked across the pending lifetime as the authoritative double-delivery guard, so — unlike the DM path — the drain does not re-mark the transport dedup. The relay path is unchanged and unaffected: it sends no delivery ACK and the relay sender is not ACK-gated (`try_relay_broadcast`), so its buffered entries carry no arrival transport and the drain ACK is a correct no-op. Rust-core only; no wire, schema, UDL, or event-shape change. + +- **An established 1:1 MLS session that forks (the two peers disagree on the epoch) now heals with no message loss instead of silently dropping — the crypto-desync recovery, distinct from the not-yet-ready defer above.** Previously an epoch fork produced an opaque decrypt failure that was delivery-ACKed and dropped: silent loss behind a lying ACK. The fix is two tiers, both gated by `EncryptionConfig::crypto_recovery_enabled` (default on, FFI/RN-mirrored; disabled → legacy drop-and-ACK): + - **Tier 1 — honest failure + heal.** The epoch-mismatch case (`WrongEpoch`/`NoPastEpochData` only) is classified as the new recoverable `MlsError::SessionDesync`, kept strictly separate from `Decryption`: AEAD/corrupt/forged and ratchet-generation failures stay `Decryption` and fail closed, so injected garbage can't drive a re-key storm. On a desync the receiver withholds the delivery ACK and `unmark`s the id (so the sender keeps retransmitting) but does **not** enqueue the ciphertext (it is sealed to the dead epoch and can never drain), and fires a rate-limited (one per peer per 30 s) `session_reset` re-key: it tears down its **own** stale session and advertises a fresh key package, so the peer rebuilds and Welcomes it back. Tearing down the local session is what makes convergence symmetric for both user-id orderings. The re-key floor lapses only by the interval elapsing — a successful decrypt on the healed session never resets it early (a genuine re-fork and a replayed old-epoch frame are indistinguishable here, so clearing on heal would let an attacker interleaving one real decrypt between replays force ~one teardown per inbound message). **Security:** the re-key acts on the wire-claimed sender, so a network attacker replaying a genuine peer's captured old-epoch ciphertext can force one rate-limited teardown+re-establishment per window — strictly better than the old silent drop, with the unconditional rate limit as the mitigation. + - **Tier 2 — true no-loss re-delivery.** The sender keeps per-outbox-entry re-seal provenance (`OutboxReseal` — memory-only via `#[serde(skip)]`; holds plaintext, never persisted) so each resend re-seals the message against the peer's *current* session while preserving the `Message.id` for dedup/ACK correlation. Gated on a confirmed session and the `__MLS_ENC__` envelope, so media chunks and plaintext replay verbatim (in-flight media recovers via the descriptor-based resend path instead). Staging is strictly transient — a staged-but-dropped send never strands plaintext. With both tiers an epoch desync recovers end-to-end: the receiver withholds the ACK and re-keys, and the sender's next resend re-seals against the rebuilt session and is delivered. Rust-core + UniFFI/Python bindings; no wire, schema, or event-shape change (UDL adds only the `crypto_recovery_enabled` kill switch). + +## [0.16.4] — 2026-07-25 + +### Fixed + +- **iOS: CocoaPods consumers of 0.16.3 failed to compile with `cannot find 'SocketGenerationTracker' in scope`.** The 0.16.3 latch-race fix added `ios/SocketGenerationTracker.swift` (referenced by `InternetManager.swift`) and registered it in `Package.swift` for SwiftPM, but `ios/MeshSdk.podspec` enumerates its Swift sources explicitly and the new file was never added to `source_files`. SwiftPM builds — including the SDK's own iOS CI (`swift test --package-path bindings/react-native/ios`), which is the only iOS build in CI since the pod can't build standalone outside an app workspace — resolved the type and stayed green, so the omission surfaced only in CocoaPods consumers, which compiled `InternetManager.swift` without the tracker. Added `SocketGenerationTracker.swift` to the podspec `source_files`. Packaging-only fix: no source change — only the podspec listing was missing. **0.16.3 has been unpublished from npm** (npm does not allow a retired version number to be reused), so 0.16.4 supersedes it, carrying the same 0.16.3 latch-race fix plus this packaging correction; there is no 0.16.3 on npm. + +## [0.16.3] — 2026-07-25 + +### Fixed + +- **iOS: a late relay close-`4000` for a bygone socket no longer wedges the transport during a reconnect window.** The relay-superseded latch added in 0.16.2 keyed its "does this close-`4000` displace the transport?" decision on object identity against the current `webSocketTask`. During a reconnect backoff window `webSocketTask` is momentarily `nil`, and identity reads `nil` as "current generation → latch" — so a background-queued `4000` for an already-superseded socket, flushed into that window on foreground (the systematic trigger being `forceReconnect()`'s foreground recovery), would latch `isSuperseded` and stop a transport that had already moved on to a newer socket, refusing auto-/force-reconnect until an explicit `start()`. The decision is now keyed on a monotonic per-socket **generation** instead: `connect()` stamps each socket with the next generation (carried on `task.taskDescription`), and `didCloseWith` treats a close whose generation is strictly older than the newest minted as bygone and refuses to latch it, regardless of whether `webSocketTask` is currently `nil` or a live successor. A `4000` for the current (newest) generation still latches, so a genuine displacement of the live socket is unaffected. Object identity remains a belt-and-suspenders fallback for the (never-in-practice) case where the tag is absent. The generation logic is extracted into a pure, unit-tested `SocketGenerationTracker`. iOS-only: Android's close funnel runs its socket-identity guard before the supersede decision, so it drops a non-current socket's close before it can latch and is immune to this false-latch by construction (no generation tracking needed). Bridge-only; no Rust/core wire, schema, or send-path change. + +## [0.16.2] — 2026-07-24 + +### Added + +- **React Native: relay session-superseded handling — `internet_session_superseded` event + `is_superseded` metric.** The relay displaces a stale connection by closing the WebSocket with code `4000` (optionally preceded by a `SessionSuperseded` notice) when a newer registration for the same identity takes over the relay slot. Neither native bridge reacted to the code before — both blind-auto-reconnected, so against an undamped displacement server a legacy dual-socket device became a self-sustaining ~1–2s eviction loop (presence flapping, routing lottery, JWT-verify churn) across the fleet. Both platforms now plumb the close code into `handleConnectionClosed` and, on close `4000` or a `SessionSuperseded` notice on the current socket, latch an `isSuperseded` flag, cancel any pending reconnect, stop the transport, and refuse auto-/force-reconnect until an explicit `start()` clears the latch. iOS marks superseded keyed on the close code (not task identity) so the decision survives the URLSession terminal-signal funnel race, but gates the pre-guard mark on `webSocketTask == nil || it's still us` so a late stale `4000` (queued on the delegate queue while `start()` already brought up a fresh socket) can't nuke the healthy successor. The displace decision + boolean is extracted into a pure, unit-tested `SupersededLatchPolicy` on each platform (8 paired tests per platform, iOS SPM + Android CI harness), sharing the exact rule while keeping the two bridges' deliberately opposite identity-guard orderings. A new `internet_session_superseded` event lets the app surface "connected elsewhere" and reconnect only on deliberate user action, and `getMetrics()` exposes `is_superseded`. Additive JSON event (bridge-only, allow-listed in the Rust↔TS drift guard); no Rust/core wire, schema, or send-path change. + +## [0.16.1] — 2026-07-24 + +### Added + +- **React Native: `forceInternetReconnect()` for deterministic foreground recovery.** `isInternetReady()` reflects a cached `(_isConnected && _isAuthenticated)` pair, not a liveness probe: an OS suspend that kills TCP before a clean WebSocket close leaves the flags stale-true, so an app's foreground "if not ready, toggle transport" gate no-ops precisely when it should act, and recovery instead waits ~20–30s on zombie-ping detection. A liveness probe can't heal this — the socket is either a zombie (dead, flag stale-true) or alive-but-deregistered by the relay, and a ping/pong reports the latter as "alive"; only a full teardown → reconnect → re-authenticate repairs both, since re-auth re-runs the relay's authenticate/register handshake. The new `forceInternetReconnect(): Promise` cancels pending backoff, resets the delay to initial, and drives the existing `teardownSocket → handleConnectionClosed → scheduleReconnect` funnel (or `connect()` when no socket), landing recovery in ~1s. It is a no-op unless the transport is running, honors `autoReconnect`, and never rejects (a reconnect that can't even build its request stops the transport instead of surfacing as a rejection). The boolean resolves `true` when the request is *accepted* (a reconnect fired, or the transport exists but isn't running), not as a proof of reconnection. Purely additive — reuses the tested close funnel so all `isStale` guards and the `internet_status_changed` emission chokepoint are honored; no Rust/core, wire, schema, or event change. + +## [0.16.0] — 2026-07-24 + +### Changed + +- **Outbox lifetime default raised from 1 hour to 7 days (`outbox_max_lifetime_ms` = 604800000), matching the app-layer presence-flush window.** A recipient offline for more than an hour no longer costs the sender their queued messages: store-and-forward outbox entries (and, via the shared knob, restored media transfer descriptors awaiting `media_resend_required`) now survive up to 7 days before expiring. The knob remains configurable end-to-end (Rust `ProtocolConfig.reliability.retry.outbox_max_lifetime_ms` → UniFFI `update_retry_config` → RN `outboxMaxLifetimeMs`) for apps that want the old bound back. The default retry backoff ceiling (`max_delay_ms`) also rises 30s → 5 min: delivery latency rides on the flush paths (start, transport reconnect, peer rediscovery, session establishment), which bypass backoff timers entirely, so the shorter ceiling only multiplied futile send attempts — and per-failure `message_retrying` events — against a long-offline peer. + +- **Outbox expiry is now terminal, not silent.** A regular message dropped for exceeding the outbox lifetime emits `message_failed` (reason `"Outbox lifetime exceeded"`); an expired connection request additionally settles its pending entry and emits `connection_request_undeliverable` (reason `outbox_lifetime_exceeded`), mirroring the max-retries exhaustion path. Previously text messages aged out with no event at all, leaving apps showing them as pending forever. (Expired media chunks already aborted their transfer with an event.) Capacity eviction at the 500-entry cap gets the same treatment: the evicted entry emits `message_failed` (reason `"Outbox capacity exceeded"`) and, for a connection request, `connection_request_undeliverable` (reason `outbox_capacity_exceeded`). And the restore path's carrier-relative TTL refresh is now bounded: an entry whose total age (from first send) exceeds 4× the outbox lifetime — 28 days at the default — is dropped at restore with a terminal `message_failed` instead of re-granted a fresh window on every restart. `PENDING_CONNECTION_REQUEST_TTL` widens 600s → 1800s so the typed undeliverable event still outlives the new worst-case retry schedule (~910s with the 5-min ceiling). + +- **Unreachable DMs park instead of failing early: the relay's `recipient_unreachable` verdict no longer burns the ACK retry budget.** Previously the verdict emitted `message_undeliverable` but left the ACK timeout machinery running, so a plain DM to an offline peer settled terminally with `message_failed` (`"Max retries exceeded"`) in ~15 minutes — making the 7-day outbox lifetime unreachable on the internet path. The verdict now *parks* the message: the pending ACK and retry-queue entry are dropped while the outbox entry stays put, and every reachability edge re-drives it with a fresh ACK budget (transport reconnect, `start()`, peer discovery, presence-online). With a local mesh carrier (BLE/WiFi-Direct) up, the park keeps a timed reachability probe instead — its interval escalating per consecutive unreachable park, 15s → 600s cap, reset on any edge — since the peer may be a room away while the relay reports it offline. A probe that locally succeeds into the mesh re-enters the ACK machinery but can never earn a relay verdict there, so ACK exhaustion for a recipient still holding a live park counter re-parks the DM (at the escalated interval) instead of settling it — the settlement contract holds on both carriers. `internet_presence_watchlist()` now includes recipients of pending/parked outbox messages alongside welcome-pending peers, so the SDK owns the "watch my DeliveryError recipients" duty end-to-end and platform layers no longer need their own presence queue for offline sends. Media chunks are deliberately not parked (their offline story remains retry exhaustion → transfer abort → `media_resend_required`), and connection requests keep settling immediately via `connection_request_undeliverable`. **Contract change:** apps that relied on the ~15-minute terminal `message_failed` for offline peers should key "recipient offline" UX off `message_undeliverable`; terminal settlement now happens only at delivery or outbox-lifetime expiry. The in-process expiry sweep also gains the restore path's absolute cap (4× lifetime from first send, 28 days at defaults), so a probing entry's carrier-relative window can't slide forever in a long-lived process. + +### Fixed + +- **Parked DMs are no longer held hostage by an in-flight mesh reachability probe when the peer comes back online.** In the internet+mesh dual-carrier topology, a parked DM's reachability probe re-registers a pending ACK, and the reachability-edge flushes (`presence-online`, peer discovery, reconnect/`start()`) skipped any message awaiting an ACK while still resetting the peer's park counter — so a peer the relay just reported online kept waiting out minutes-scale ACK backoff gaps before delivery, and the cleared counter re-exposed the message to the ~15-minute terminal `message_failed` on exhaustion. The flush edges now cancel the unanswerable probe ACK first (scoped to parkable plain DMs while the park counter is live — connection requests, welcomes, and media keep their in-flight ACKs) and re-drive immediately; on the relay presence-online edge specifically, the re-drive is forced over the internet transport, since the reachability proof is relay-scoped and DORS could otherwise route it back into the mesh void, re-stranding it — including through the edge's welcome-rescue branch, whose inner discovery flush picks up any DM whose forced re-drive just failed and must not hand it back to DORS. A canceled ACK whose answer was genuinely in flight is safe: receivers dedupe and re-ACK duplicates. Flush batches past `FLUSH_BATCH_LIMIT` re-enqueue into the retry queue instead of assuming a backoff timer the probe cancel may have just stripped. An edge that re-drives *no* parked DM successfully (every forced send failed, or they all overflowed the batch) restores the peer's park counter instead of clearing it: the surviving retry-queue sends are DORS-routed, and a mesh-local success there must leave ACK exhaustion re-parkable instead of settling terminally. On the carrier-level reconnect/`start()` edge specifically — the one re-drive path that is DORS-routed with no per-peer reachability proof — only an internet-routed send (the one that can earn a relay verdict) counts as a successful re-drive for counter-clearing; a mesh-routed local success also restores the counter, since it proves nothing about the peer actually being there (delivery prunes the counter if its ACK genuinely arrives). + +### Added + +- **Deferred-delivery lifecycle events: `message_retrying`, `message_undeliverable`, and a real `next_retry_at`.** `message_deferred` now populates `next_retry_at` with the retry queue's actual schedule (previously always absent), and two new non-terminal events close the observability gaps around it: `message_retrying { message_id, recipient, retry_count, next_retry_at }` fires each time the retry machinery re-schedules a message after a failed attempt (transport send error or ACK timeout), and `message_undeliverable { message_id, recipient, reason, file_id? }` fires when the internet relay reports the recipient unreachable for an in-flight regular message — a verdict that previously produced no signal at all (connection requests, welcomes, and media aborts already had theirs). `file_id` is set when the affected message is a chunk of an outbound media transfer. Both are additive JSON events (no UniFFI interface change) with TypeScript mirrors, and neither settles a message: `message_delivered` / `message_failed` remain the terminal signals. + +- **Media transfers now survive restarts as resend signals: `media_resend_required`.** The SDK persists a small per-transfer descriptor (file id, recipient, name, size, SHA-256 — never chunk bytes, honoring the long-standing rule that resurrected chunks can't complete) when an outbound media transfer starts, and deletes it when the transfer completes, aborts, or goes stale. A descriptor that survives into a restart means the process died mid-transfer: `start()` emits `media_resend_required { file_id, recipient, file_name, file_size }` for each, and the app re-initiates by calling `send_media` with the same `file_id` — the resend must target the descriptor's original recipient and the re-supplied bytes are checksum-validated against the interrupted transfer (`InvalidArgument` on either mismatch). Restored descriptors are pruned by `outbox_max_lifetime_ms` age and capped at 16 newest. + +- **Rich message model: quoted-reply context and rich media metadata (foundation).** `Message` gains an optional `reply_context` (`ReplyContext { sender, text, timestamp?, reply_media_label?, reply_content_type? }` — an unverified display-level hint, like `ForwardInfo`), surfaced on `message_received` events, and `MediaMetadata` gains nine optional cloud-media/sticker fields (`media_id`, `download_url`, `thumbnail_url`, `encryption_key`, `iv`, `ciphertext_hash`, `sticker_provider`, `sticker_remote_id`, `sticker_kind`), surfaced on `message_received` and `file_received`. On the binary mesh wire, `reply_context` rides the frame's extension section as tag 2 (only the first entry is honored; a malformed payload rejects the frame, matching the JSON path); the frozen `WireMessageV1` layout is unchanged, and a legacy decoder skipping the tag loses only the reply preview — the same degradation a legacy JSON receiver applies by ignoring the unknown field. Purely additive: nothing populates the new fields yet — the end-to-end-sealed rich send surface ships alongside this in the same release. + +- **Enforced confidentiality boundaries for the new fields.** `MediaMetadata.encryption_key`/`iv` are secret material (they grant access to the cloud-stored media itself) and are enforced never to travel hop-visible: the transport wire chokepoint strips them from every outbound cleartext frame on both codecs (covering direct sends, forwards, pending-queue flushes, and relaying), and the telemetry scrubber redacts them from `message_received`/`file_received` events unconditionally — independent of the `scrub_ids` setting. The outer `reply_context` field on an MLS-encrypted message sits outside the AEAD boundary (a relay could inject or rewrite it in transit), so it is dropped on decryption and never surfaces on an `encrypted: true` event; the sealed envelope is the only trusted carrier for reply context on encrypted messages and ships with the rich send surface. + +- **End-to-end-sealed rich send surface (`send_message_with` / `send_message_rich`), negotiated per recipient.** Sending a quoted reply, cloud-media metadata (including its `encryption_key`/`iv` secrets), or forward attribution now travels *inside* the MLS ciphertext: for recipients that advertise `rich_versions` in their key package, the plaintext is wrapped as a `__RICH_V1__` JSON body (`{text, reply_context?, media_metadata?, forward_info?}`) before encryption, and the receiver restores the fields right after the outer-field strip — so the sealed body is the only trusted carrier, and the relay never sees rich cleartext. Toward recipients without the capability the rich extras are silently dropped (the message degrades to plain text with `reply_to_msg` threading intact) — never sent cleartext. Rich extras queued behind session establishment keep their provenance (`PendingMessage.rich`) and re-decide sealing at flush time against the recipient's current capability. Inbound parsing is always on (a body that fails to parse surfaces as raw text with a warning, never a dropped message), `__RICH_V1__` joins the reserved internal prefixes, and `EncryptionConfig::rich_payload_enabled` (default on) is an independent kill switch alongside `compact_envelope_enabled`. Non-rich sends are byte-for-byte unchanged. Core API: `send_message_with(recipient, content, SendMessageOptions)`; UniFFI: `send_message_rich` + `SendMessageOptions` dictionary (all fields defaulted). The boundary validates input: `ContentType::FileChunk` is rejected (an internal transport content type — the receiver would swallow the message into its file-transfer manager after ACKing delivery), as are rich extras exceeding 32 KiB serialized (an oversized quote or thumbnail would inflate the MLS plaintext into heavy transport fragmentation); both surface as `InvalidArgument`. The cap is enforced before queueing, so a pending flush can never fail it and re-queue forever. + +- **React Native: `sendMessage` accepts rich params; `richPayloadEnabled` kill switch exposed.** `SendMessageParams` gains `replyContext`, `mediaMetadata`, `forwardInfo`, and `contentType`; calls carrying any of them route to the new native `sendMessageRich` method on both platforms (the plain path is untouched). **Note for over-the-air JS updates (CodePush-style):** `sendMessageRich` is a new native method — a JS-only update that starts sending rich params against an older native binary fails those calls (method not found); plain `sendMessage` calls are unaffected. `encryption.richPayloadEnabled` (default `true`) flows JS → native → core with the same nested-then-flat, camelCase-or-snake_case parsing as `compactEnvelopeEnabled`, regression-pinned in both platforms' parser tests. + +- **Rich extras on media transfers (`send_media_with` / `send_media_rich`), sealed with chunk 0.** Media parity for the rich model: a caption, reply-to id, quoted-reply context, and forward attribution can now ride an outbound media transfer, sealed inside the chunk-0 MLS ciphertext toward recipients that advertise `rich_versions` — silently dropped otherwise, never cleartext. `file_received` gains the matching additive fields (`caption`, `reply_to_msg`, `reply_context`, `forward_info`) plus a `timestamp` (the sender's chunk-0 send time, for display ordering alongside `message_received`). `MediaSendOptions` also accepts a caller-supplied `file_id` — how an app answers `media_resend_required` after a restart. React Native's `sendMedia` takes the same params and routes to the new native `sendMediaRich` method when any are present (same over-the-air-update caveat as `sendMessageRich`). + +- **Forwards and group messages seal their rich extras.** `forward_message` now seals its attribution and the original message's `media_metadata` toward rich-capable recipients — the only way forwarded cloud media keeps its `encryption_key`/`iv` secrets, since the wire chokepoint strips them from every cleartext frame (the cleartext outer copies remain as the legacy fallback for non-capable recipients, secrets stripped). Groups gain the same machinery: `send_group_message_with(group_id, content, GroupSendOptions)` (core API; not yet over UniFFI) and `forward_message_to_group` (already bound) seal a `__RICH_V1__` body into the group MLS plaintext — including hint-only bodies for non-Text content types, which have no outer carrier on the group payload — parsed on all three inbound paths (mesh, buffered drain, relay). Group sealing is gated on *every* other member being known rich-capable and fails closed: when the gate is closed the text still sends but the extras drop, surfaced by the new `group_rich_extras_dropped { group_id, unknown_members }` event, and the drop path key-packages the unknown members once so their auto-exchange reply reopens the gate. When a group body seals, the hop-visible payload `forward_info` copy is omitted (every member reads the sealed attribution; a payload copy would only expose the original sender to relays). `group_message_received` gains additive `media_metadata` / `content_type` fields; the telemetry scrubber redacts the secrets. + +- **Rich capability propagates through group adds (inviter attestation).** Previously a member added by someone else stayed unknown to the rest of the group — holding the seal gate closed — until a direct key-package exchange happened to occur. The Add commit now carries `affected_member_rich` (the inviter's attestation, to existing members) and the Welcome a `member_rich` map (to the joiner; entries bounded to the joined MLS roster and admin-gated on the commit, like `role`), so members added by someone else stay sealable. Attestation chains across successive adds, a direct key-package exchange always overrides it, and it feeds *only* the group seal gate — never DM sealing or envelope selection. Plain group sends (no rich extras) skip the capability scan entirely. + +- **`group_rich_readiness(group_id)` pre-check, exposed core → UniFFI → React Native (`meshGroupRichReadiness`).** Returns `{ ready, unknown_members }`: whether a rich group send right now would seal its extras, and which members hold the gate closed. Point-in-time and advisory — the send path re-evaluates the gate itself — so apps can warn before sending (e.g. gray out the attachment button) instead of learning from `group_rich_extras_dropped` after the drop. + +- **Relay group registration is now observable: `group_relay_sync_changed` event + `group_relay_sync_state` / `request_group_relay_registration`, exposed core → UniFFI → React Native (with an `ensureGroupRegistered` await helper).** The relay's positive registration acknowledgment — the internal `relay_synced` transition that gates the O(1) relay-broadcast path — previously left no app-visible trace, so an app could not sequence "mesh-create a group → wait until the relay knows it → issue relay-dependent server commands for it" (the invite-link lifecycle over `sendRawServerCommand`) other than by probing and interpreting `GroupError`s. The new event fires `synced: true, reason: "registered"` on the relay's `GroupCreated` ack (emitted on the pending-registration-consumed transition, so the idempotent re-sync ack after a membership change fires it again — apps awaiting a post-invite roster re-sync hear it), and `synced: false` with a reason on every revocation: `error` (group-scoped relay error), `removed` / `left` (membership teardown), `internet_dropped` (per affected group on the 1→0 transport transition), and — new behavior, previously a silent `debug!` — `ack_timeout` when the SDK gives up on a relay that never answered (3 attempts × 30s). `group_relay_sync_state(group_id)` returns the point-in-time `Synced`/`Pending`/`Unsynced` view; `request_group_relay_registration(group_id)` is the supported on-demand (re-)registration path — never raw-send `CreateGroup`. React Native additionally ships `ensureGroupRegistered(groupId, {timeoutMs})`, which subscribes-then-checks (no ack-arrival race), kicks a registration when none is in flight, and settles on the event outcome — the intended gate before `CreateGroupInviteLink` and friends. The event is not emitted for groups the relay was never asked about, and a `GroupError` that revokes nothing tracked stays silent on this channel. + +- **React Native: positive internet readiness signal — `internet_status_changed` event + `isInternetReady()`.** The `(connected, authenticated)` pair the raw server-command gate checks is now published on actual transitions (deduplicated through a single choke point in both platform bridges; `authenticated: true` is the relay's auth acceptance, not the TCP open) and queryable point-in-time. Replaces app-side `relayStatus === 'authenticated'` tracking against a separate app-owned socket, and closes the "gate raw sends by probing `sendRawServerCommand` for `false`" gap — the boolean-probe contract still works, but a ready socket deferring an individual send (client-side rate limiter) remains a retry-after-short-delay `false`. + +### Fixed + +- **React Native bridges no longer silently rewrite retry defaults on partial configs.** Both native `updateRetryConfig` handlers rebuild the full `RetryConfig` from JSON, filling absent fields from hardcoded fallbacks that had drifted from the Rust defaults (`maxRetries` 3 vs 10, and now the outbox lifetime and backoff ceiling). An app setting only e.g. `retry: { maxRetries: 5 }` was silently resetting every other retry field. The fallbacks now mirror `offline-protocol-reliability`'s defaults on both platforms. + +- **Messages queued behind session establishment now keep their id through flush.** The id returned by `send_message*` for a queued message (and persisted with it) was discarded when the session confirmed: the flush minted a fresh id, so the id an app held could never be correlated with the eventual `message_sent`/`message_delivered`/`message_failed`. The flush now dispatches under the original id — across re-queues too — and, with stable ids, the deduplicator protects against a stale restored snapshot double-sending. Latent flush bugs fixed in the same pass: messages whose recipient was blocked after queueing are now dropped with a terminal `message_failed` per id (previously they error-looped forever); a mid-flush re-queue is no longer clobbered when another message in the same flush fails, and the merged queue preserves the original send order; and a mid-flush session abort (terminal Welcome failure) no longer resurrects the messages the abort just settled. Dropped ids always settle: a dedup hit at flush is silent only in exact dedup mode (the id already settled when it first dispatched); in bloom-filter mode — where the hit may be a false positive — the drop emits `message_failed` so the app can resend. + +- **React Native `MessageReceivedEvent` type was missing `reply_to_msg`.** The Rust event has always carried the replied-to message id; the TypeScript mirror now declares it (alongside the new `reply_context`). + +- **React Native: seven runtime events had no TypeScript declaration.** `group_renamed`, `message_decryption_failed`, `ack_evicted`, `fragment_assembly_evicted`, `relay_demoted_battery`, `user_blocked`, and `user_unblocked` have always been emitted at runtime (events cross the FFI as tagged JSON, and the dispatcher applies no filter) but were absent from `types.ts` — outside the `ProtocolEvent` union, `.on('')` didn't type-check and payloads were untyped. All seven interfaces are now declared (plus a `DecryptionFailureCode` string union), and a Rust drift-guard test pins every core `Event` variant to a `types.ts` declaration so an event can no longer ship without its TypeScript mirror. Purely a typing change — no runtime behavior differs. + +### Removed + +- **React Native: `src/types-uniffi.ts` (and its compiled `lib/types-uniffi.*`) removed from the package.** A pre-UniFFI-migration relic that was never exported from the package root and whose declarations contradicted the shipped API (a 3-argument `sendMessage`, a `ProtocolError` class the SDK never throws). Anyone deep-importing it should switch to the package root exports: `EstablishmentState` is exported from the root, and error codes arrive at runtime as plain `err.code` strings on native promise rejections, mirroring the UDL `ProtocolError` variant names — no runtime behavior changes. + diff --git a/docs/changelog/0.17.md b/docs/changelog/0.17.md new file mode 100644 index 00000000..7e399fc7 --- /dev/null +++ b/docs/changelog/0.17.md @@ -0,0 +1,174 @@ +# Changelog: 0.17.x + +Archived release notes. The working changelog, covering the current release +and unreleased changes, is [CHANGELOG.md](../../CHANGELOG.md). + +| Release | Date | +|---------|------| +| 0.17.0 | 2026-07-30 | + +## [0.17.0] — 2026-07-30 + +> **Downgrade is not a safe rollback.** The first launch on this release moves +> pre-split delivery state out of the credential store and into the app +> container, deleting the credential-store copy once the move is durable. An +> older build reads the old location and finds none of it — so a rollback comes +> up with an empty outbox, an empty pending queue, and, most sharply, **an empty +> block list: every previously blocked peer silently unblocked.** Blocking is a +> safety control, so treat a downgrade as a decision to reset it, not as an undo. +> Ship this as an explicitly breaking release and roll forward, not back. + +### Added + +- **`SECURITY.md` now carries a safe-harbor statement, and every publishable crate carries the license text it would ship under.** The policy solicits vulnerability research but offered no legal assurance in return, which for a protocol whose whole value is its cryptography is the wrong trade: it now states that good-faith research conducted under the policy is authorized under the CFAA and the DMCA's anti-circumvention provisions, permits reverse engineering of the shipped binaries for the purpose of finding vulnerabilities, and sets a 90-day default disclosure window — bounded by rules of engagement (test only what you own, no volumetric testing against shared relay infrastructure) and by two honest limits, that the grant binds only Offline Protocol, Inc. and does not displace applicable law. The supported-versions table, stale at `0.13.x`, now names the current line and says explicitly that its numbers are release versions rather than the deliberately decoupled workspace `Cargo.toml` version. Separately, each publishable crate now sets `license-file = "../../LICENSE"`, so `cargo package` copies the AGPL text into the `.crate` archive: crates ship individually and a recipient of one is entitled to the license text under AGPL §4, which no crate carried because the license lived only at the workspace root. The benchmark crate is marked `publish = false` instead, being a harness rather than a distributable, and `scripts/check-license-consistency.sh` — already the CI gate against drift between the triplicated license documents — now also asserts the invariant, because a crate added later inherits nothing from this change and the omission is invisible until someone downloads the published archive. `offline-protocol-services` was also missing `repository`, which is why the generated `THIRD-PARTY-NOTICES.md` linked it to a crates.io page that does not exist — the regenerated file links to the repository like its seven siblings. The npm and PyPI READMEs state the dual license up front rather than only in a closing section, since a package manifest can name `AGPL-3.0-only` and nothing else: SPDX has no identifier for the commercial offer, so the prose is the only place a user learns the alternative exists. + +- **Third-party license attribution now ships with every distributed binary.** A generated `THIRD-PARTY-NOTICES.md` (cargo-about over the `offline-protocol-uniffi` dependency graph — the crates actually compiled into the iOS/Android libraries and the Python wheel's native library) preserves the copyright notices and license texts of all ~190 incorporated crates, and states how to obtain the Source Code Form of the MPL-2.0 components (the `hpke-rs` family and the UniFFI runtime, whose helper code is also inlined into the generated Swift/Kotlin/Python bindings) as MPL-2.0 §3.2 requires. The file is included in the npm package, bundled into Python wheels under `dist-info/licenses/`, and attached to GitHub releases; regenerate with `scripts/generate-third-party-notices.sh` after dependency changes. + +### Changed + +- **Messages waiting for MLS session establishment are persisted one record per message, keyed by message id, instead of one record per recipient holding that peer's whole queue.** The layout now matches the outbox and media descriptors, and the reason is what a lost record can say. A per-recipient record holds every id in the queue, so one that will not open destroys all of them and the only honest report is a per-peer `pending_state_lost` diagnostic naming no message at all. Keyed per id the key *is* the id, so the same loss settles as a `message_failed` for exactly the id the application is holding, and a readable entry queued for the same peer is unaffected rather than going down with its neighbour. Two structural simplifications follow. The per-session freeze that kept an ordinary enqueue from writing its one-message view over a record restore could not read is **removed**, not narrowed: every write and delete now addresses its own message id, so an unwalked or unreadable record cannot be clobbered by an unrelated write — the argument the outbox has always made. And the restore walk no longer has to re-persist a peer's survivors after the capacity caps drop an entry, because the dropped entry is simply its own record. Secondarily, an enqueue now writes one record rather than re-serializing the queue it joins, so filling a peer to `MAX_PENDING_MESSAGE_BYTES_PER_PEER` costs that many bytes rather than roughly `budget × entries / 2`. **That byte win is not the reason for the change and should not be read as a speed-up**: every built-in provider pays two device barriers per store and one per delete regardless of record size, so over a queue's full lifecycle this trades about 25% more barriers for linear instead of quadratic bytes — close to a wash at the queue depths that actually occur, where a handful of short messages wait on a handshake. Restored queues are re-ordered by `(queued_at, message_id)` — the comparator the global oldest-first eviction already used — because records come back in whatever order the store enumerates them, and that order carries no ordering information at all. One consequence of a record per message is that removing a batch is now a delete per entry rather than one whole-queue delete, so pending-lifetime expiry is bounded to `MAX_PENDING_EXPIRIES_PER_PASS` (64) entries per `process()` tick: entries queued together come due together, and what a pass leaves behind is still past its deadline, so the next tick drains another pass rather than one tick issuing thousands of synchronous deletes. Because a bounded pass can leave due entries queued, the flush path independently refuses to dispatch an entry past its deadline — it stays queued for the next expiry pass — so the absolute lifetime holds where delivery is decided, and an expired message can never settle `MessageSent`. + +- **Pre-existing per-recipient pending records are migrated forward on first launch.** The restore walk reads the per-message records first, then reads each legacy per-recipient queue and recovers its entries; once the capacity caps have been applied, each *surviving* entry is written under its own id and the legacy record is dropped. Writing after the caps rather than during the walk is what keeps the upgrade cheap: a pre-cap install can hold far more than the current bounds admit, and writing every entry out only to delete it again moments later is what would turn an ordinary upgrade into thousands of device barriers on the boot path. Writes still precede the delete, so a crash in between leaves a queue present in both layouts rather than neither, and the per-message pass claims those ids — recovered *or* settled as destroyed — so the finishing launch admits each entry once, never re-files an id it already reported failed, and completes the interrupted delete. Migration is **all or nothing per recipient**, because nothing outside this walk writes the legacy category and a record whose delete does not land is therefore there for good: the claim that makes a crash converge holds only while the per-message records exist, and those do not survive the flush this launch is about to perform, so a half-migrated queue would be re-filed and re-*sent* by a later launch long after it was delivered. A recipient whose delete fails, whose per-message writes do not all land (a full disk fails the write and lets the delete through, so the writes are as load-bearing as the delete), or that the launch cannot fund, has its entries taken back out of memory (and back off disk if they had been written) so the queue lives in exactly one place — the legacy record — for a later launch to migrate again, and none of its ids are settled in the meantime, since a settlement is terminal and that record can still deliver them. That withholding extends to an id the per-message pass settled as destroyed when a surviving legacy record also holds a copy: the next launch re-files and delivers it, so the terminal report is deferred to the launch that owns both halves rather than emitted and overturned. The funding checks come before the first write, so abandoning is usually free. Both passes share the walk's entry bound and its delete budget and stop at a record boundary, so a truncated pass resumes on the next launch; the capacity-eviction deletes the walk owes are drawn from the same budget, and the migration's *writes* — the more expensive half, since every built-in provider flushes the record and its directory — are bounded separately by `MAX_MIGRATED_PENDING_WRITES_PER_LAUNCH` (512), so a store far past its caps cannot spend an unbounded number of device barriers on one launch. A legacy queue lost inside a record that will not open still reports per peer — that cannot be improved retroactively, which is the point. Nothing outside this walk reads, writes, or deletes the legacy category (the pre-split adoption sweep does write it, but strictly earlier in the same launch, before the walk runs), which is what makes leaving an unwalked record on disk safe without a freeze. Like the pre-split adoption sweep, this is one-shot upgrade scaffolding and retires on the same trigger: when the oldest supported upgrade path starts at or after the release that introduced the per-message layout, not merely one release later. + +- **Breaking: secure key material and restartable protocol state now use separate storage contracts.** `initialize_mls` / generated `initializeMls` now require both an `MlsStorageProvider` and an app-container-scoped `ProtocolStateStorageProvider`; the one-provider API and production `enable_message_persistence` path are removed. Outbox entries, pending messages, retry/welcome lifecycles, peer snapshots, media descriptors, and Lamport state are routed only to protocol-state storage. MLS/TOFU material and install secrets remain in secure storage. Built-in Python and React Native providers isolate both stores by an opaque namespace derived from `(app_id, user_id)`, so multiple accounts cannot share keys or delivery state. iOS state lives in Application Support with backup disabled and Android state lives in `noBackupFilesDir`. Python has no portable uninstall-scoped container, so `ProtocolManager` now requires an explicit `state_root` (or `OFFLINE_PROTOCOL_STATE_ROOT`) owned and removed by the application installer. Both halves of an existing install **are** carried across, by two different mechanisms described under Fixed: restartable delivery state is swept out of the credential store into the app container on first launch and deleted from the credential store once the move is durable, and the MLS identity is adopted by reading through to the pre-namespace store. + +- **Messages waiting for MLS session establishment now have a configurable absolute lifetime and hard capacity bounds.** `RetryConfig.pending_message_max_lifetime_ms` defaults to seven days, is mirrored through UniFFI and React Native, and removes expired entries from memory and protocol-state storage while emitting `message_failed`. The outbound pre-session queue keeps at most 64 messages per peer and 4096 globally, evicting the oldest with a terminal failure event. `process()` tracks the earliest pending deadline instead of scanning the entire queue every 100 ms. Retry/ACK runtime updates now reject zero delays, zero capacities, invalid lifetime bounds, and other invalid configurations instead of installing a hot-loop-capable configuration. Lifetime cleanup and restore use checked timestamp arithmetic, so even the largest accepted lifetime cannot overflow Chrono and panic the process. + +- **The pre-session queue is now bounded by bytes as well as by entry count, and oversized message content is refused at the send boundary.** An entry count alone bounds neither memory nor protocol-state disk, because message content is application-supplied: 64 entries per peer could still be arbitrarily heavy. `send_message*` and `forward_message` now reject content over 256 KiB with `InvalidArgument` (large payloads belong on `send_media`, which chunks), and the pre-session queue additionally holds at most 2 MiB per peer and 16 MiB globally, evicting oldest-first with the same terminal `message_failed` the count caps emit. Restore applies both budgets, so a record written by an older build cannot re-inflate memory on boot. The boundary cap sits at the boundary rather than at transmit time because a message waiting on session establishment is queued — in memory and on disk — long before it reaches the transport's own 1 MiB check. The group send path is deliberately unchanged: it has no durable pre-session queue and remains transport-bounded. Separately, every protocol-state record is now capped at 4 MiB on **both** sides of storage, so a corrupted or tampered state file is refused before it can be deserialized into an unbounded allocation during startup. + +- **Breaking: `update_dedup_config` is now fallible too, and a zero `max_tracked_messages` or `retention_time_secs` is refused everywhere.** The method returns `Result` in Rust and is declared `[Throws=ProtocolError]` over UniFFI, joining its two siblings; Swift callers need `try` (the React Native bridge already wrote one). It was the last reliability updater to install its configuration unchecked, and once the other two began validating the *whole* candidate configuration — which is the right call, since a hand-rolled copy of `ProtocolConfig::validate` drifts — that gap acquired a second-order cost: `validate` checks the Bloom parameters this method installs, so a dedup configuration nothing had rejected could make a perfectly valid `update_retry_config` fail, complaining about a Bloom filter the caller never mentioned. `Deduplicator::with_config` already failed safe to exact HashMap mode on those values rather than panicking, so the cost was a rejection attributed to the wrong call rather than a crash. **`ProtocolConfig::validate` now also constrains the two dedup fields that are not Bloom-gated**, which is what makes the FFI break bind on anything a binding caller can reach: every Bloom check sits behind `use_bloom_filter`, and the UniFFI `DedupConfig` carries only `max_tracked_messages` and `retention_time_secs`, filling the rest from `Default` with Bloom off — so without these two constraints the new `[Throws]` would have been unreachable over Swift, Kotlin, and Python. Nothing failed safe on them the way the deduplicator does for the Bloom parameters: at `max_tracked_messages == 0` the exact-match tracker evicts on every insert, holding a single id, so duplicate suppression — a replay defence — was effectively off for a configuration the SDK accepted in silence. What this rejects is the *degenerate* value, and that is the whole of the claim: a floor of 1 is not a floor on how well duplicates are suppressed, since `max_tracked_messages == 1` behaves indistinguishably from `0` and a retention shorter than the link's retry backoff suppresses nothing either. Sizing the window for the deployment stays the application's call; the SDK refuses only the values that cannot be a choice, because nothing downstream spells them as a configuration error and the failure they produce — silently re-delivered messages — does not look like a config bug. Raising either floor to a policy minimum would be a wider behavioural break and needs its own decision. Apps passing either as `0` (both bridges read them straight from JSON) now get an `InvalidConfiguration` rejection and keep the previous configuration; the React Native bridge already logs and continues on a refused runtime update. Note the constraint binds at **construction** as well, since that is where `ProtocolConfig::validate` already ran: a Rust caller that builds a `ProtocolConfig` with either field at `0` now fails `OfflineProtocol::new` outright rather than keeping anything. That is Rust-only — the FFI init surface carries no dedup fields (`DedupConfig` is reachable only through `update_dedup_config`), so no binding caller can hit the constructor form. + +- **Breaking: `update_ack_config` and `update_retry_config` are now fallible.** Both return `Result` in Rust and are declared `[Throws=ProtocolError]` over UniFFI, so Swift callers need `try`. They reject a zero ACK timeout, a zero pending-ACK capacity, zero delays, and out-of-range lifetime bounds rather than installing a configuration that hot-loops or overflows. Both validate by building the candidate configuration and running `ProtocolConfig::validate` on it, rather than repeating its checks inline — a hand-rolled copy drifts, and a constraint added for a new field would otherwise be enforced at construction and silently skipped on the runtime-update path. No `ProtocolError` variants were added, so the append-only FFI error taxonomy is unchanged. + +- **Breaking: malformed or unresolved recipient tokens are rejected at every outbound boundary.** All user-targeted send APIs, plus `invite_to_group`, now validate the recipient as a `UserId` before any queue, outbox, clock, or transport side effect, so an app-owned placeholder cannot become indefinitely retried protocol state. This is a behavioural break, not only a hardening: `send_presence_update`, `send_typing_indicator`, `send_read_receipt`, `send_service_request`, and `respond_to_service_request` previously accepted any non-empty string, and `UserId` rejects `:` — so namespaced identifier forms (`unresolved:token`, `did:key:…`, `npub:…`) that used to work now fail with `InvalidArgument`. Apps carrying such tokens must resolve them before calling the SDK. Group *removal* and *role* mutation are deliberately exempt: admission is where a gate belongs, and one on the removal path would turn "a member with a stale-format id is on the roster" into "that member can never be removed or demoted". + +- **The React Native bridges' fallback ACK timeout is now 10 s, matching the SDK default.** Both bridges had been substituting 5 s when `updateAckConfig` was called without `defaultTimeoutMs`, silently halving the timeout relative to `DEFAULT_ACK_TIMEOUT_MS`. Apps that pass the field are unaffected; apps that omit it will see ACK waits — and therefore retry timing — return to the documented default. The drift test that pins the retry fallbacks now covers the ACK ones too, so the two cannot separate again. + +- **The React Native bridges' fallback pending-decryption TTL is now 30 minutes, matching the SDK default.** `DEFAULT_PENDING_TTL_MS` moved 2 min → 30 min when delivery ACKs became deferred; that change was Rust-core and UniFFI only, and all three React Native layers — `index.ts`, `ProtocolConfigParser.kt`, `EncryptionConfigReader.swift` — kept substituting the pre-deferred-ACK `120000`. Because the JS wrapper materializes `pendingQueue.pendingTtlMs` before it crosses the bridge, an RN app that omitted the field got the 2-minute window regardless of what the SDK default said — the same shape of bug as the ACK fallback above, on the queue the deferred-ACK model depends on most. Apps that pass the field are unaffected; apps that omit it now hold a message that arrived before its session was ready for 30 minutes instead of 2, which is the recovery window that model needs, since such a message is not delivery-ACKed and an eviction is a silent non-delivery. Memory is unchanged — the count caps (64 per peer, 4096 global) and the byte caps (4 MiB / 32 MiB, which binding callers get from the core defaults) still bound the queue, so a longer TTL lets entries linger within those caps rather than raising the ceiling. A drift test now pins all three bridge literals to `DEFAULT_PENDING_TTL_MS`, and the iOS and Android parser tests assert the default directly. + +- **Breaking: `ProtocolStateStorageProvider` values are `bytes`, not `sequence`.** A custom provider now receives and returns `ByteArray` on Kotlin, `Data` on Swift, and `bytes` on Python. `MlsStorageProvider` is unchanged: it carries key material a few hundred bytes at a time, where the representation does not matter. Protocol-state records reach megabytes, and `sequence` generates `List` on Kotlin — an inline class whose boxes, unlike `java.lang.Byte`'s, are not cached — so a 2 MiB record cost on the order of two million short-lived objects per call, on the way in *and* again on the way out. That is the pathological pending queue the per-peer byte budget exists to bound, paying the cost on exactly the path the bound covers. Changed now rather than in a follow-up because the interface is new in this release and has no implementations to break yet. + +- **`ProtocolStateStorage` no longer speaks the MLS crate's error type.** The Rust trait now returns `ProtocolStateError` / `ProtocolStateResult`, so the two storage domains are decoupled at the abstraction as well as at the lifecycle. The UniFFI callback surface is unchanged — `ProtocolStateStorageProvider` still throws `MlsStorageError`, and the adapter maps it — so no binding regeneration or native provider change is required. + +### Fixed + +- **The React Native bridges manufactured a phantom peer named `relay`, and then sent it mail.** Relay *answers* — `__GROUP_CREATED__`, `__GROUP_ERROR__`, and the `__GROUP_INFO__` / `__USER_GROUPS__` snapshots — are synthesized locally by the platform `InternetManager` from a relay notification; no peer transmits them. The bridges nevertheless gave them a fabricated identity, passing the literal string `"relay"` as the FFI `sender_id` and stamping the frame `requires_ack: true`. Those are two independent claims about a peer that does not exist, and the core acted on both. `internet_message_received` routes a non-empty `sender_id` into the reachability seam every carrier shares, so `relay` was inserted into `known_peers`, emitted as `NeighborDiscovered`, enrolled in service-discovery fan-out, and — under the default `auto_key_exchange` — sent an unsolicited key-package DM. Separately and far more often, the receive path answers a consumed internal message that asked for one with a delivery ACK addressed to the frame's `sender`, so **every injected frame** produced a second outbound DM to `relay`. Both are undeliverable, both draw a relay `DeliveryError`, and each `DeliveryError` re-arms the bridge's presence watch — which is why the resulting `Presence check for relay: false (last seen: None)` never aged out rather than lapsing at the idle TTL. Synthesized frames are now ingested unattributed (empty `sender_id`, a mode the core has supported and tested since the reachability seam was introduced) and built with `requires_ack: false`. Frames that name a *real* relay-reported actor — a group message's `sender`, an `added_by`, a `removed_by` — are unchanged and still assert reachability, because that is what drives outbox flush and Welcome re-arm for peers reachable only over the relay. The body's `sender` keeps the `"relay"` placeholder, since the Rust `UserId` rejects an empty string, but nothing acts on it any more. Note that the phantom was never a security failure — it could not decrypt anything and was addressable only by us — but it burned a `known_peers` slot, leaked group activity timing to the relay as ACK traffic, and made a real user id unusable had anyone registered as `relay`. + +- **The restore delete budget was spent per *walk*, so a launch could cost several times the bound it advertised.** `MAX_RESTORE_PRUNE_DELETES` exists because a device-barrier storm kills the launch — which is a property of the whole synchronous `initialize_mls` call, not of any one walk in it. Each walk nevertheless constructed a private allowance of that size, and `restore_outbox` constructed none at all, so a launch could issue several times the bound while every walk truthfully reported staying inside its own. That is the same failure the constant exists to prevent, one level up, and the release notes above already described the prunes as *sharing* a budget they did not share. They do now: the five advisory walks (session states, peer key packages, peer capabilities, Welcome lifecycles, media descriptors) draw on one pool created per launch. The two settlement-paired walks keep a pool each — being starved there defers a delivery or a diagnostic rather than a cache eviction, and neither may be held hostage to a key-package flood in an unrelated category. **Sharing a pool is not the same as sharing it fairly, and the advisory walks draw in a fixed order**, so each leaves the ones after it a `MIN_ADVISORY_PRUNE_DELETES` floor and may take the rest. Without that reservation the first walk empties the pool on its own — a key-package store over its cap by more than the budget, which the flood-eviction exemption makes an ordinary state rather than a tampered one, leaves every walk behind it with nothing on every launch. *Starved* is not *deferred*: the "re-walked next launch" property every prune here leans on only holds if the walk draws again, and these prunes are the only thing that ever deletes those records, so it would quietly have meant "never". The floor is deliberately well under an even share — the category that floods is the one that needs to converge fastest, while the ones it would starve hold small counts — so the rest of the pool stays elastic for whichever category is actually large. All three pools are constructed side by side by `initialize_mls` and threaded in, rather than by the walk that spends them: a pool allocated inside the callee is the same defect one level down, since a second call in the same launch would silently double the ceiling. The derived launch ceiling — `3 × MAX_RESTORE_PRUNE_DELETES` — is pinned end to end against a provider that counts deletes, which is the assertion a per-walk regression breaks and a per-pool test cannot see. It covers every durable delete the restore walks issue, including the two `restore_outbox` prunes that run after its read walk (see the entry below). Two things it does not bound: a settlement-paired walk stops *between* records, so the record it is already inside may push a little past its pool; and `adopt_legacy_protocol_state` runs before the walks and deletes from the *secure* store as it moves each record across, bounded by its own truncated-and-resumable pass rather than by this constant — a one-time upgrade sweep on a different provider, so count it separately rather than reading `3 ×` as a launch total. + +- **`restore_outbox` had no delete budget at all, and the argument that exempted it came from the wrong number.** The exemption rested on its walk bound capping it "near 1.5k deletes" — a figure that describes the capacity prune. The outbox is a *sealed* category, so the wrong-length record-key branch of `restore_or_init_state_record_key` makes every entry on the install fail to open at once and each one is dropped by the reader: the full walk bound, in device barriers, on the boot path. That is verbatim the case that put a budget on `restore_pending_messages` one entry above. The other half of the exemption does hold — every delete here is paired with a terminal `message_failed`, so an individual one may never be refused — but pairing forbids refusing a delete, not stopping *between* records. The walk now spends a counting budget and stops at a record boundary. It needs no freeze, unlike the pending walk: outbox records are keyed per message id, so every later write touches a different key and an unwalked record is simply restored and re-driven next launch. **The number the old exemption actually described was never bounded either**, and budgeting only the walk would have left precisely the argued-about case uncovered: the capacity drain and the absolute-lifetime drop run *after* the walk, on entries it already admitted, so a store whose records all open cleanly reaches them with the pool untouched and its working set bounded only by `OUTBOX_RESTORE_KEY_CAP` — up to 1500 device barriers on the boot path in the *ordinary* over-capacity case, not the tampered one. Both are settlement-paired like the walk, so both stop between entries in the same way; an entry the pool cannot fund is dropped from memory and left on disk **unsettled**, which is what keeps stopping safe — nothing has been claimed about it, so a later launch owns both halves. Deferring a delete never defers the in-memory cap itself, or an over-cap store would re-inflate memory on boot. + +- **One unreadable record in `session_states` or `welcome_lifecycles` failed `initialize_mls` outright, on every launch.** Both restore paths propagated any per-record storage error, so `list_keys` succeeding while a single record's read returned `LoadFailed` — an `EIO`, an ACL, a container the OS had locked — rolled initialization back. Nothing deletes such a record, so it failed again on the next launch and the one after; with `require_encryption` on by default that install can send nothing at all, and there is no in-app recovery. Every other category on the restore path already treated a per-record read failure as recoverable and continued. These two were the outliers, and moving both categories out of the credential store and into the app container — where `ENOSPC`, `EIO`, and protection-class failures are ordinary — is what made it matter. The restore-path reader is now infallible and answers three ways, and the `Unavailable` answer is load-bearing rather than cosmetic: a session-state record that is merely unreadable must not be treated as *absent*, because absent means "re-bootstrap and persist `Pending`" — a silent confirmation downgrade written straight over a record that may say `Confirmed`. A listing failure still propagates, since it is indistinguishable from an empty category and has no per-record fallback. **`restore_session_states_from_manager` was also the last restore walk issuing unbudgeted deletes**, exempted on the grounds that it iterates the MLS session list rather than a protocol-state category — but that list carries no restore cap of its own, so "bounded" there meant "bounded by the peer count", and a store that reports every session-state record corrupt turns it into one device barrier per peer on the boot path. It now draws on the shared advisory pool, where refusing costs less than anywhere else: a spared record still reads as absent, so the caller re-bootstraps and persists a fresh `Pending` over it, repairing the record whether or not the delete was funded. + +- **Persistence failures on the restore path no longer block restore.** A Welcome-lifecycle repair and the bootstrap of a missing session state both persisted with `?`, so a transient `StoreFailed` failed `initialize_mls` — a write failure blocking a read path, where every sibling call in the module logs and carries on. Both now log and continue: the repair holds in memory for the run, which is what drives the retry ladder, and a launch that can write re-derives the record. The Welcome repair ladder also persists once at the end rather than up to four times mid-ladder. + +- **The unaddressable-recipient path in `restore_pending_messages` no longer deletes a record that is not there.** A recipient whose queue listed but no longer loaded fell through to the drop below it, charging the delete budget, logging a destruction, and making a provider round trip for a record already gone. Nothing was destroyed, so nothing was owed. + +- **Two restore-path deletes were still outside the per-launch delete budget, and one of them was the common case.** The budget bounds how many durable deletes a restore walk may issue, because each is a synchronous provider round trip that flushes the containing directory — `F_FULLFSYNC` on iOS, a full device barrier. Two paths bypassed it. (1) `restore_peer_key_packages` drops a *expired* cached package inline, and that delete counted nothing — and the two branches are mutually exclusive, since an expired package never enters the in-memory map and so never makes the cap bind. An over-cap key-package store is over-cap because it is old, and the cached lifetime is 30 days, so an all-expired store routed *every* delete past the budget: exactly the store the budget exists for. (2) `restore_media_descriptors` budgets the three deletes it issues itself but not the fourth, which happens inside the reader when a record will not open. Media descriptors are a sealed category, so the wrong-length record-key branch makes every descriptor on the install take that path in a single launch. Both now share the walk's budget, and the reader accepts one so the delete is counted where it actually happens; a spared record is re-walked and dropped on a later launch, the same idempotent-and-resumable property the rest of the prune relies on. `restore_outbox` stays deliberately unbudgeted — every delete there is paired with a terminal settlement that cannot be postponed with it, and its walk bound is eight times tighter. + +- **The widest restore walk of all — `restore_pending_messages` — was outside the delete budget, and it is the one a regenerated record key empties in a single launch.** The budget's own worst-case accounting enumerated the key-package, capability, media-descriptor, and outbox walks and omitted this one, even though its record bound is the same `MAX_RESTORE_KEYS_PER_CATEGORY` (16384) the budgeted cache walks use — eight times looser than the outbox's — and it can issue *more than one* durable delete per record. Pending messages are a sealed category, so the wrong-length record-key branch makes every record on the install unreadable at once; and because a queue lost that way contributes no entries, the entry bound (`MAX_PENDING_RESTORE_ENTRIES`) never binds in exactly that case, leaving only the 16384 record bound between the boot path and 16384 device barriers. The exemption argument that covers `restore_outbox` — a delete paired with a terminal settlement cannot be postponed — is true here as well, but its second half (a walk bound 30× tighter) was not. So the walk now spends a *counting* budget, which charges every delete and refuses none, and stops at the next **record boundary**, freezing the recipients it did not reach for the session exactly as it already freezes the tail of its two other bounds. A frozen record is neither read nor written this session, so nothing is settled for it and nothing overwrites it, and the next launch drains it. `PruneBudget` now has no `Default`: whether a walk may *refuse* a delete or only *count* it is the whole safety question, so it must be answered at each construction site. + +- **Three more reader-internal deletes now charge the budget their walk already carries.** A record the store reports corrupt, or one over the record cap, is dropped inside `read_state_record_detailed` — the correction `restore_media_descriptors` received. `restore_peer_capabilities` had three of its four delete sources budgeted and routed the fourth around it; `restore_peer_key_packages` did the same for its loader's read; and `restore_welcome_lifecycles`, which walks up to 16384 container-listed keys, dropped an unparseable record with nothing counting it at all. All three now pass their budget to the reader. `restore_session_states_from_manager` stays unbudgeted on purpose — it walks the *MLS session list*, so its volume is bounded by sessions this install actually has rather than by whatever the container holds. + +- **The peer-capability prune no longer reports every delete as a capacity overflow.** `budget.spent` also counts corrupt, empty, and unreadable records, so a single bad record logged as "store exceeded the cap on restore" sent whoever read it looking for a key-package flood that never happened. Over-cap prunes and unreadable-record drops are now counted and reported separately, matching how `restore_peer_key_packages` already separates its expiry drops. + +- **The Android protocol-state store now sweeps its own write temporaries, like the iOS and Python ones.** From API 30 `AtomicFile.startWrite()` writes `.new` and `finishWrite()` renames it into place, and `AtomicFile` reclaims a stale `.new` only when something opens that key again. For a crash during an entry's *first* write there is no base file, so enumeration never lists the key, nothing ever loads or deletes it, and the orphan survives for the life of the install — the same leak the iOS sweep was added to close, in the provider that was assumed to get it for free. `.bak` is deliberately left alone: below API 30 `startWrite` renames the base to `.bak` first, so a lone `.bak` is the *good* copy, which is why `readHeader` prefers it. + +- **An initialization rollback no longer leaves the Lamport debounce watermark ahead of the clock.** `restore_lamport_clock` sets both `lamport_clock` and `last_persisted_lamport`; the rollback restored only the first, so `persist_lamport_clock`'s `wrapping_sub` read the difference as an enormous delta and wrote on every tick instead of every interval. + +- **Upgrading an install no longer silently discards its delivery state, its block list, or its Lamport clock.** Everything now in protocol-state storage — outbox, pending messages, session and Welcome lifecycles, peer key packages and capabilities, media descriptors, blocked users, the both-create owner gate, the Lamport clock — was previously persisted through the `MlsStorage` handle. Splitting the domains renamed where all of it lives, so without a sweep the first launch after an upgrade would have come up with an empty outbox, an empty pending queue, and **every previously blocked peer silently unblocked** — while the old records stayed in the credential store forever with nothing ever reading or deleting them, which is the worst possible resting place for `pending_messages` (message plaintext) and `outbox` (cloud-media `encryption_key`/`iv`). `initialize_mls` now performs a one-shot adoption sweep before restoring: each record is written into protocol-state storage (sealed on the way in, for the categories that require it) and only then deleted from secure storage. Unlike the MLS keyspace — open, because OpenMLS contributes its own labels — the protocol-state key types are a closed set the SDK declares itself, which is what makes a bulk move possible at all. The sweep is resumable (a crash leaves the remainder for the next launch), non-destructive (a key already present in protocol-state storage wins and its legacy twin is left alone), and marked complete only when it finished without a storage error, so a transiently unavailable credential store means "retry next launch" rather than "give up". A marker in protocol-state storage makes it one-shot, so state deleted after the upgrade cannot be resurrected by a later launch. + +- **Restore no longer drops messages the app is still waiting on without telling it.** A protocol-state record that is oversized, corrupt, or sealed under a key this install no longer has is dropped on read — but the app is holding the id `send_message*` returned, so a silent drop leaves that id unresolved and the message displayed as "sending" forever. Restore now distinguishes an absent record from a destroyed one and settles the difference: an unrecoverable outbox entry emits `message_failed` (its record key *is* the message id, so no decryption is needed to name it), an unrecoverable pending queue emits one `convergence_diag` with stage `pending_state_lost` for the recipient (the ids are inside the record that would not open), and an outbox entry pruned for capacity on restore now settles like the pending queue's capacity eviction already did. A pending queue dropped because its recipient is not a valid user id now settles with that reason rather than borrowing the capacity-eviction one. All restore settlements are parked until `start()` rather than emitted from `initialize_mls`, which apps routinely call before installing an event callback — matching how restored media descriptors already wait to be announced. + +- **A record that cannot be read *this session* is no longer settled as a terminal failure.** "Destroyed" and "unreadable right now" had been collapsed into one outcome, but only the first is a loss. A record whose per-install seal key could not be loaded, or whose backing store refused one read, is deliberately left on disk — so settling it emitted `message_failed`, and then the next launch restored the very same entry and re-drove delivery. The app would have been told the message failed and then had it delivered, with a hand re-send landing as a second copy (a new id, which dedup cannot collapse). Reads now report three states rather than two, and only a record that was examined and deleted is settled. + +- **A pre-split record too large to migrate is now settled instead of quietly deleted.** The adoption sweep must delete a legacy record over the 4 MiB protocol-state record cap — nothing could ever write or restore it — but it was doing so silently, leaving the app holding ids that never resolve. This is not a hypothetical: the pre-split build had neither a content cap nor a per-peer byte budget, only 64 entries per peer, so the installs these budgets exist for are exactly the ones whose legacy records can exceed the cap. An oversized legacy outbox record now emits `message_failed` (its key is the message id) and an oversized legacy pending queue emits `pending_state_lost` for the recipient, on the same deferred path as every other restore settlement — and, unlike restore's, these survive an initialization rollback, since the record they describe is already gone and nothing can re-derive them. + +- **A protocol-state record the *store* reports as corrupt is now settled and deleted, not left to be re-read forever.** `ProtocolStateError::Corrupted` is documented as an entry that exists and can never be decoded, but nothing acted on it: it fell into the same bucket as a transient backend failure, which the SDK deliberately treats as recoverable. So the record was left in place — re-examined on every single boot, never settled — and the application kept an id that would never resolve. Worse, because a corrupt Welcome-lifecycle record propagated as an error, a single one failed `initialize_mls` outright and rolled it back. Reads now classify `Corrupted` as a permanent loss: the record is dropped and, for the categories the application holds message ids for, settled with the same `message_failed` / `pending_state_lost` an unopenable sealed record already emitted. The built-in iOS, Android, and Python providers now report it — previously they deleted an oversized or malformed record and returned absence, which is indistinguishable from a record that was never written, so the loss reached the application as silence. Custom providers may still return absence; the contract now says plainly what that costs. + +- **The built-in iOS protocol-state provider now makes a write durable before it returns.** `Data.write(options: .atomic)` is rename-atomic but not flushed, so the rename's directory entry could commit ahead of the new file's data blocks and a power loss leave the record present and zero-filled. Android (`AtomicFile`) and Python (fsync of the file *and* its parent directory) already flushed; iOS now does the same with `F_FULLFSYNC`, on both the entry and the directory, and on delete as well — an unflushed unlink could otherwise resurrect an entry the SDK had already settled. The durability obligation is now part of the documented provider contract. + +- **`ProtocolStateError::NotFound` from a provider's `load` — and from its `list_keys` — is now read as absence.** The trait documents it as the variant for backends that cannot express absence any other way, but nothing acted on it: it propagated as an error, which restore then read as a destroyed record. A custom provider honoring the documented contract would have seen a spurious `message_failed` for every outbox entry it held, and an adoption sweep that never ran because probing for its own completion marker "failed". The same reading now applies to `list_keys`, where `NotFound` means an empty category: every restore propagates a listing error, so a backend that spelled emptiness that way failed restore and rolled `initialize_mls` back over a store that held nothing. This also covers UniFFI providers, whose `MlsStorageError.KeyNotFound` maps onto it. + +- **Upgrading an install no longer silently rotates its MLS identity.** Scoping the built-in secure store to `(app_id, user_id)` renamed it, so the first launch after an upgrade would have found an empty store, minted a *new* signing identity, and abandoned every session, group, and TOFU pin the install already had — with peers still holding the old pin then rejecting the new key. The namespaced store now adopts the pre-namespace one instead: a miss reads through to the legacy store and promotes what it finds. Read-through rather than a bulk copy, because the legacy key types are not a closed set (OpenMLS contributes its own labels, and `keyring` cannot enumerate at all), and because it is idempotent and resumable across a launch that dies mid-migration. `delete` removes the legacy copy too, so a deleted key cannot be resurrected by a later read-through. The legacy store was shared by every account on the install, so at most one may inherit it: the first to launch writes a claim, and a second account starts from a fresh identity — correct, since the legacy store never held a separable identity for it — but says so out loud rather than rotating silently, via an `error` diagnostic on React Native and `SecureStorage.legacy_adoption` in Python. + +- **Built-in protocol-state records are addressed by digest, not by an encoding of their key.** Base64url filenames were neither case-safe nor bounded: `AAG` and `AAa` resolve to the same file on a case-insensitive volume (APFS's macOS default and Windows), so one record silently overwrote the other, and a valid 190-byte id produced a 256-character name that overruns the common 255-byte `NAME_MAX` — meaning the built-in stores did not implement core's full valid keyspace. Entries are now named by a fixed-length lowercase SHA-256 digest, and each record carries its own `(key_type, key_id)` in a small self-describing header (`"OPS1"` magic, big-endian `u16` lengths, UTF-8 keys, then the value), so the exact key is recoverable for listing and every read can verify it opened the record it asked for. The format, its limits, and golden vectors are pinned by identical tests on iOS, Android, and Python. Namespaces are now validated on all three platforms before becoming a path component or credential-store suffix. + +- **The record-size ceiling is now enforced inside each provider, before allocation.** `load` returns an owned byte vector, so core could only check a length it had already been handed — a corrupt or tampered multi-gigabyte file was read whole, and copied across UniFFI, before the 4 MiB guard ran. Each built-in provider now stats the entry first and drops anything over `MAX_PROTOCOL_STATE_RECORD_TRANSFER_BYTES` (8 MiB, a deliberate superset of core's cap plus its seal envelope, pinned by a Rust test) rather than reading it; `store` refuses oversized values, and key enumeration reads only record headers and stops at a bound. The obligation is now part of the documented `ProtocolStateStorage` contract for custom providers, since it cannot be enforced anywhere else. + +- **Sensitive protocol state is now sealed before it reaches install-scoped storage, restoring the at-rest protection it had while it lived in credential-backed storage.** Splitting delivery state out of the credential store gave it the app container's *lifecycle*, but would also have given it the app container's confidentiality — and that state is not merely "non-cryptographic": pending session entries carry original message plaintext, and outbox entries and rich extras can carry cloud-media `encryption_key`/`iv` values. Pending messages, outbox entries, and media transfer descriptors are now sealed with ChaCha20-Poly1305 under a per-install key held in secure storage (`protocol_state_record_key`), with each record's associated data binding it to its `(key_type, key_id)` slot so records cannot be moved between peers or categories by anyone with write access to the container. Providers see only ciphertext and must store the bytes they are handed verbatim. Sealing fails **closed**: with the key unavailable those categories are not persisted at all rather than written in the clear (delivery is unaffected; only crash recovery for them is lost), and there is no plaintext-passthrough on read — a record that will not open is dropped rather than trusted. Record keys stay in the clear because the store addresses entries by them. + +- **Re-queueing a pending message no longer renews its absolute lifetime.** `pending_message_max_lifetime_ms` is measured from when a message *first* entered the pre-session queue, but a flush that found the session still unavailable put the message back with a fresh `queued_at` — so repeated reconciliation could keep a message alive indefinitely past a lifetime the config presents as absolute. A re-queue now carries the original timestamp forward (in memory and in the persisted snapshot), so an entry that was already past its window is expired by the very next cleanup. + +- **A pre-split adoption pass that had to truncate no longer marks itself complete.** The sweep bounds how many entries it walks per category, but it was still writing its one-shot completion marker afterwards — so anything past the bound stayed in the credential store forever with nothing ever reading or deleting it, which for `pending_messages` and `outbox` means message plaintext and cloud-media key material parked in the one place the sweep exists to clear. Because adoption deletes each record only once it is durably rewritten, withholding the marker drains the remainder over successive launches at no extra cost. + +- **Restores that walk a whole category are now bounded consistently.** Blocked users, the both-create owner gate, and Welcome lifecycles were reading every key a store listed straight into memory, while the categories touched by this branch stop at a generous multiple of their own caps. The justification for that bound is a tampered store — and this state just moved out of the credential store into the app container, where write access is easier to obtain, so it applies more strongly than before, not less. All three now stop at the same bound and log the ignored tail. + +- **A Python `SecureStorage` built without an account namespace now says it cannot adopt the pre-namespace store.** Adoption records its claim under the namespace, so a provider constructed without one silently lands on the new service name, finds nothing, and mints a fresh MLS identity — abandoning every session, group, and TOFU pin the install had. `ProtocolManager` always supplies the namespace, but the provider is a documented constructor parameter; a caller building their own now gets a warning instead of a silent identity reset. Explicitly passing `adopt_legacy_store=False` stays quiet, since that is a decision rather than an accident. + +- **A blocked-user listing failure now fails initialization instead of coming up unblocked.** A listing error is indistinguishable from an empty store, so swallowing it started the SDK with an empty block list and told no one — every blocked peer silently unblocked, from a transient error, which is the same outcome the downgrade warning above exists for. This state also just moved from the credential store into the app container, where a read can fail for far more ordinary reasons. `restore_blocked_users` now propagates like every other category walk, so `initialize_mls` rolls back and the application finds out rather than running unprotected. + +- **An unaddressable pending queue is no longer destroyed while its ids are unreadable.** A persisted queue whose recipient does not validate is dropped, but the ids the application is holding live *inside* the record — so the drop was reading it first. When that read reported the record as intact-but-unreadable-this-session (a launch that could not load the per-install record key), the queue was deleted anyway and the ids went with it, unsettled: the exact silent loss the three-state read exists to prevent. Both halves are reachable on the same launch — recipient validation is new here, so the queues that fail it are the pre-upgrade ones, and that first post-upgrade launch is also the one most likely to find the credential store locked. The record is now left in place for a later launch to settle properly, and one that was examined and destroyed reports `pending_state_lost` like any other unrecoverable queue. + +- **A pending queue the restore walk never reached is now frozen for the session, not just left on disk.** Both bounds on the pending walk stop early and leave the remainder "for a later launch" — but a pending record holds a recipient's *whole* queue, so leaving it there protected nothing: the next ordinary enqueue for an unwalked recipient persisted the in-memory view, one message, straight over a record holding up to 64, and `block_user` (or an aborted pending session) deleted it outright. Either way the ids inside a record nobody had opened were destroyed and settled to no one — the same silent loss the unreadable-record freeze already prevents through the other door, arriving through the one path into it that had no freeze. Unwalked recipients now join that freeze, so their records are neither written over nor cleared until a launch can read and settle them. Reachable on exactly the installs the entry bound exists for: the pre-split build had no pending-queue caps at all, so an upgrade can legitimately hold more queued entries than one restore may walk. + +- **The built-in Android provider's key enumeration now prefers an `AtomicFile` backup over a torn write.** On API < 30 `startWrite` renames the base entry to `.bak` and then writes the base, so a `.bak` on disk means the base is a partial write — which is why `openRead` discards it. Enumeration did the opposite, parsing the torn base, so a crash mid-`store` could drop the key from `listKeys` even though `load` recovered the record perfectly well. The entry was then listed by nobody, restored by nobody, and deleted by nobody — stranded for the life of the install, in precisely the crash the atomic write exists to survive. + +- **The built-in iOS provider now reports a failed directory enumeration instead of an empty category.** Android and Python already threw; iOS returned no keys, which core reads as "nothing is filed here" — so the records were restored by nobody and settled to nobody. The three built-in providers are meant to be the same implementation in three languages, and this was the one that answered silently. + +- **A pending queue that could not be read this session is now frozen on disk, not just skipped on restore.** Restore correctly left an unreadable queue in place — but the pending queue is persisted as one record per recipient holding the *whole* queue, and nothing remembered that it had been skipped. The next enqueue for that peer wrote a snapshot of the in-memory view (one message) straight over messages the application is still holding ids for, settled to no one; `block_user` and the aborted-session path deleted the record outright. Honoring the three-state read at restore and then clobbering the record at runtime is the same silent loss, one layer down. Such a recipient is now refused both writes and deletes for the rest of the session, behaving exactly as it does when no storage is configured, so a later launch can settle or restore the record properly. Reachable while the record key loads perfectly well: it is a *per-record* provider failure, not a locked credential store — that case already fails closed on write and cannot clobber anything. The outbox needs no equivalent, being keyed per message id. + +- **Adoption's "post-split state wins" probe now gets the same three-way read as everything else.** It went through the loader that reads `Corrupted` as absence, so a destination record the store had just examined and destroyed was treated as never having existed. When the legacy twin was still there the overwrite was a genuine recovery; when it had vanished too, the record was gone and the application was told nothing — the only destruction path in this work that did not settle. The probe now settles that case, and a destination reported as unreadable *this session* defers the record instead of adopting over it, withholding the marker so the sweep retries rather than overwriting a record a later launch can still read. The sweep's dependency on the secure store's legacy read-through — it enumerates the namespaced handle to reach records that live in the un-namespaced one — is now documented rather than silently load-bearing. + +- **The built-in Android provider now flushes the directory entry after a store and a delete.** `AtomicFile.finishWrite` fsyncs a record's contents, but the link it renames into place — and the one `delete` removes — lives in the parent directory and needs its own flush; without it a power loss can lose a store the SDK was told succeeded (sharpest for records sealed under a key it just persisted) or resurrect an entry the SDK has already settled. The iOS and Python providers already did this. Best effort, as on the other two. + +- **A claim on the legacy secure store is now verified before it counts as an adoption.** Only one account may inherit the pre-namespace store, and the claim written into it is what makes that exclusive — but the write was best-effort and its result never checked, so a failure was indistinguishable from success. The store then still looked unclaimed to the *next* account, which adopted it too: both would promote the same MLS signing identity, and with it each other's sessions and group state, which is strictly worse than the conflict the claim exists to produce and reached the application as silence. The claim is now read back, and anything other than our own namespace coming back — a throw, an unreadable claim, someone else's — leaves read-through off and is surfaced through the same `error` diagnostic a conflict already used. The read back also catches a second account claiming the store between our probe and our write, which the probe alone cannot see. The invariant this restores is not "the first account to launch wins" but "at most one account holds a verified claim"; the cost is a fresh identity for a launch whose credential store is failing writes, which is a store that could not have completed the adoption anyway. + +- **The two cache restores are now bounded like every other category walk.** Peer key packages and peer capabilities *prune* their overflow rather than ignoring it — dropping a cached entry only costs a recoverable re-exchange, so shrinking the store to the live cap in one boot is the right policy — but neither stopped walking, so the only ceiling was the provider's own listing bound, four times the one core applies everywhere else. Every pruned entry is a synchronous provider delete, and all three built-in stores fsync the type directory on one, so a tampered container turned tens of thousands of those into boot-path latency. That is the threat model every other walk here is bounded against, and it applies more strongly now that this state lives in the app container rather than the credential store. Both now stop at the shared bound, which still shrinks the store by that bound minus the cap on every launch, so an over-large store drains over a handful of them instead of stranding. + +- **The built-in Python provider now sweeps write temporaries a previous process died before renaming.** `store` writes to a temporary and renames it into place, so a crash in between orphans that file — and enumeration filters on the record prefix, so nothing ever looked at it again. They accumulated for the life of the install in a directory the application cannot reasonably be asked to clean itself. The first store into a category now removes them, bounded and best-effort, which keeps it off the restore path and out of the way of a store that is about to succeed. + +- **The legacy-store conflict diagnostic now names everything a conflicting account loses.** It reported only that the account starts from a fresh MLS identity. Since delivery state moved into the same read-through path, such an account also comes up with an empty outbox, an empty pending queue, and an empty **block list** — the outcome the downgrade warning at the top of this section exists for. Python's no-namespace warning says the same. + +- **Legacy-store adoption is now serialised process-wide, so two accounts starting at once cannot both inherit the same MLS identity.** Reading the claim back closes a write that silently failed, and a second account claiming between our probe and our write — but not two accounts interleaving: `A` probes an unclaimed store, `B` probes it too, `A` writes and reads back its own namespace and adopts, then `B` writes and reads back *its* namespace and adopts as well. Both promote the same signing identity, and each ends up holding the other's sessions and group state, silently — which is the outcome the claim exists to produce a loud conflict for. The invariant is "at most one account holds a verified claim", and an unsynchronised read-modify-write does not provide it however carefully the read-back is done. All three built-in providers now hold a process-wide lock across the whole probe → claim → read-back sequence; a per-instance lock could not have ordered them, since two accounts are two provider objects. There is no cross-process case to close for a single application's credential store. Pinned by a Python test that forces the interleaving with a barrier and fails with both accounts adopting when the lock is removed. + +- **An oversized pre-split outbox record whose key is not a parseable message id is now surfaced rather than deleted in silence.** The adoption sweep settles a record it has to destroy so the application does not keep an id that never resolves, but the outbox settlement was keyed on parsing the record key as a `MessageId` and emitted nothing at all when that failed — the last silent-destruction path left in this work. Such a key should not exist, which is exactly what every other record on this path has in common. It now falls back to the same `pending_state_lost` diagnostic the pending queue uses, carrying the raw key. + +- **The TOFU restore walk is now bounded like every other category walk.** It read whatever `list_keys` returned from start to finish, loading every entry into memory before applying `MAX_TOFU_PEERS` — the one restore with no ceiling, in a release whose other five walks were all bounded against exactly this. Living in the credential store rather than the app container is a weaker threat model, not an absent one, and the bound is about work on the boot path either way. The tail is deliberately *ignored*, never pruned, unlike the two cache restores: a dropped key package costs a re-exchange, whereas deleting a TOFU entry silently re-arms trust-on-first-use for that peer, so the next key it offers is accepted with no mismatch warning. Stranding an over-cap pin is the strictly safer failure. + +- **Terminal settlements parked by restore are now explicitly capped.** The restore caps bound how many can be produced, but they bound it as a sum across every category, and nothing drains the queue until `start()` — which an application that only calls `initialize_mls`, or that retries it against a store that keeps failing, may never reach. The queue now stops at twice the pending queue's own global cap, keeping the oldest (dropping those in favour of later ones would bias the survivors by backend listing order) and reporting the suppressed count when `start()` drains. + +- **A restore settlement is no longer discarded by an initialization rollback, when the record it describes has already been destroyed.** The rollback restores in-memory state; it restores no *storage* state, because restore has none to give back. By the time a later step fails, an earlier one has already deleted an unaddressable pending queue, dropped a record its store reported corrupt, and rewritten a peer's snapshot without the entries it evicted for capacity. Rolling those settlements back left the application holding ids that resolve to nothing — not on that launch, and not on any retry, since the records they would be re-derived from are gone. This was reachable and got wider in this release: `restore_pending_messages` runs first and now propagates through a `restore_blocked_users` that used to swallow its failures. The invariant already documented for the adoption sweep — nothing can re-derive a settlement for a record that no longer exists — turns out to hold for every settlement produced under the transaction, so none are rolled back now. The cost is that a retry re-examining a still-present record can settle the same id twice; a duplicate terminal event is a far smaller lie than silence. Separately, the rollback now also covers the peer key-package cache, parked media descriptors, and the both-create owner gate, which restore populated but the snapshot never captured. + +- **The pending-queue restore walk is bounded by entries, not just by records.** Each pending record holds a whole recipient's queue, so a record-count bound alone admitted `MAX_RESTORE_KEYS_PER_CATEGORY × 64` entries — and holding the global caps across them is not free, because finding the oldest entry to evict scans the entire in-memory queue. That is roughly four billion comparisons on the *synchronous* boot path, before counting one provider load per record. It is reachable without tampering: the pre-split build had no pending-queue caps at all, so an upgraded install can legitimately hold far more than the caps now admit, and the adoption sweep moves all of it into the store this walk reads. Measured on 2000 recipients × 64 entries, in release with an in-memory backend: 1.46 s before, 0.17 s after, with the same global cap enforced. This is the same lesson the built-in providers' `list_keys` bound learned — count the work, not the results. The tail past the bound is *ignored*, never pruned, like the record-count tail above it: those ids live inside records nothing has opened, so deleting them would be an unsettleable loss. Restored entries are also measured only after the per-peer count trim, so the entries that trim drops are no longer re-serialized to compute a size nothing reads. + +- **A protocol-state category whose sensitivity has not been decided now fails closed.** Whether a category is sealed was a `matches!` over `&str`, so adding a new category was a two-place edit whose failure mode was silent: the default answer to "is this sensitive?" was "no", and the consequence of that answer is message plaintext or cloud-media key material written to the app container in the clear. The decision is now an exhaustive `match` over an enum — adding a category is a compile error until someone classifies it — and `write_state_record` refuses a key type the enum does not cover rather than writing it unsealed. + +- **An unrecoverable outbox record whose key is not a parseable message id is now surfaced rather than deleted in silence.** The adoption sweep gained this fallback earlier in the release; restore did not, so it kept the settlement keyed on parsing the record key as a `MessageId` and emitted nothing at all when that failed, while deleting the record regardless. Both paths now share one helper, which falls back to the `pending_state_lost` diagnostic carrying the raw key. + +- **The Python binding now documents what a plaintext `keyring` backend costs.** `SecureStorage` already warned when `keyring` resolved to a null or plaintext backend, but the warning understated it: the credential store also holds `protocol_state_record_key`, the per-install key that seals delivery state before it reaches `AppStateStorage`. On a plaintext backend that key sits in a readable file, so the split gives separation of lifecycle but not of confidentiality. + +- **One undecodable session-state or Welcome-lifecycle record no longer fails `initialize_mls` forever.** Every other restore on this path drops a record whose bytes will not decode and carries on; these two propagated the deserialization error instead, so a single such record failed initialization outright — and, because nothing deleted it, failed it again on every launch after that. With `require_encryption` on by default that install can no longer send anything, and there is no in-app recovery. An earlier fix in this release covered only the case where the *store* reports `Corrupted`; a record the provider frames perfectly well but whose JSON does not parse still hard-failed, and framing is all a provider validates. Both categories are unsealed, so they carry no integrity protection at all, and both just moved from the credential store into the app container — the threat model every restore walk here is bounded against. The restore walk now drops such a record and continues; a session whose confirmation cannot be read is re-bootstrapped as `Pending`, never `Confirmed`. The *send* path deliberately keeps propagating it: `is_session_confirmed` must fail closed rather than read a Confirmed session as Pending because its record would not decode, and the two behaviours are now pinned apart by test. + +- **The built-in Python provider creates its directories owner-only.** iOS and Android get container isolation from the OS; Python's container is whatever directory the application names, created at the process umask and so typically world-listable. Record *files* were already owner-only (`mkstemp` creates them `0600` and `os.replace` preserves that), so contents were never exposed — but a listable directory leaks the entry count and, since the filename digest is unsalted, confirms any guessable peer or message id. Directories are now created `0700` and an existing store is tightened on open, since `mkdir(mode=...)` is both masked by the umask and a no-op when the directory already exists. + +- **The built-in Android provider's key enumeration counts a record once, not twice.** An entry and its `AtomicFile` `.bak` twin are one record — the header read resolves both to the same target — but each name incremented the examined counter, so a directory of twinned records spent two of the bound on every one of them and halved the effective ceiling on exactly the tampered or crash-interrupted directory the bound exists for. + +- **Terminal settlements parked while the protocol is paused are now drained on `resume()`, not only on `start()`.** `settle_restored_message_failure` parks anything it produces while the event pipeline is not live, and `start()` was its only drain. But `update_retry_config` reaches that path at runtime: shortening `pending_message_max_lifetime_ms` expires queued messages and settles them, and an application tuning its reliability config while backgrounded — the whole reason `pause()` exists — left the resulting `message_failed` in the deferred queue until a `start()` that a resumed process never performs. Both edges into `Running` now drain, so a parked settlement has no state to be stranded in. + +- **The built-in iOS provider sweeps write temporaries a previous process died before renaming.** The same gap the Python provider closed earlier in this release, in the one store that still had it: `Data.write(options: .atomic)` writes a temporary in the same directory and renames it into place, so a crash in between orphans that file permanently — enumeration filters on the record prefix, so nothing ever looks at it again — and they accumulate for the life of the install. Android self-heals through `AtomicFile`. The first store into a category now removes them, bounded and best-effort, which keeps it off the restore path. + +- **The React Native and Python READMEs said an upgrade does not carry restartable delivery state across.** Stale from before the adoption sweep, and self-contradictory on its face — the outbox and pending queue *are* the delivery state, and they are swept out of the credential store into the app container on first launch. The CHANGELOG was corrected earlier in this release; these are the two files an application team actually reads to decide what to test on an upgrade. Both now describe the two adoption mechanisms separately, say that an account which loses the legacy-store claim gets neither identity nor delivery state, and repeat that downgrading is not a rollback. + +- **An inviter-attested rich capability no longer overwrites what a peer advertised for itself.** Attestation merges into the peer's existing capability record, but the loader behind that merge folded "there is no record" and "this read failed" into the same answer, so one transient provider failure wrote an attested-only record over the peer's own `env_versions` and `rich_versions` — the authoritative ones. The damage outlived the session: the next launch restored a record with no envelope capability, so encrypted DMs to that peer dropped back to the legacy JSON envelope (~2.7× larger, more fragments) until a live key-package exchange rebuilt it. Reachable only for a peer not already known rich-capable, which is exactly the peer attestation exists for. The loader now gives the same three-way answer every other protocol-state read does, and a record that cannot be read this session skips the write entirely — the in-memory set still opens the group seal gate for the run, and the next Add commit re-attests. + +- **Restore-time pruning is now bounded per launch, so a large store cannot turn initialization into a device-barrier storm.** The restore walks bound how many records they *read*. Nothing bounded how many they *delete*, and those are not the same cost: every built-in provider flushes the containing directory after an unlink, which on iOS is `F_FULLFSYNC` — a full device barrier, not a hint. Pruning a whole over-cap category in one pass therefore issued up to tens of thousands of those on the synchronous `initialize_mls` path, which is not a slow launch but a launch the platform watchdog kills. This was not only the tampered case: a peer's capability record is deliberately spared from the key-package flood eviction when it has an established session, so an install with more sessions than the capability cap accumulates over-cap records legitimately. The peer key-package, peer capability, and media-descriptor prunes now draw on a delete budget and leave the remainder for the next launch, which is safe precisely because pruning is idempotent and resumable — the same property the pre-split adoption sweep already relies on. `restore_outbox` is deliberately excluded: each of its deletes is paired with a terminal `message_failed`, and the two cannot be separated without either settling an id a later launch restores and re-drives, or dropping an entry while the application still holds an id nothing resolves. (Both of those statements were narrowed further later in this release — the budget became a genuinely shared pool, and `restore_outbox` gained one.) + +- **The built-in Android provider no longer flushes a directory it did not modify.** `AtomicFile.delete()` succeeds silently when the entry is already absent, and the directory fsync ran regardless — on paths that delete speculatively (clearing a pending queue for a peer with no record, dropping an already-consumed key package, removing a descriptor a transfer never wrote), so the expensive half of the call ran for calls that changed nothing. iOS and Python both returned before their flush for exactly this; the three providers are meant to be one implementation in three languages. + +- **The built-in iOS and Python protocol-state providers now serialise on a process-wide lock, like the Android one.** Their locks were per-instance, and the stale-temporary sweep's safety argument is that no temporary another writer is using can be visible while the lock is held — which a per-instance lock cannot provide. Two providers over one root are not hypothetical: the React Native bridge constructs a fresh one on every `initializeMls` call. Interleaved, one provider's sweep could unlink the temporary another's atomic write was about to rename into place, turning a store that would have succeeded into a failure. + +- **A failed clear of a persisted pending queue is now logged rather than swallowed.** Every other persistence failure in that module warns. This one did not, so a clear that silently failed left a record the next launch restored and re-flushed: the messages carry their original ids, so receivers dedup them, but the sender re-emits `MessageSent` for traffic it had already delivered and nothing said why. + +- **A protocol-state record key of the wrong length is no longer logged as if it were routine key generation.** Regenerating it is correct — nothing can recover the original, so whatever it sealed is already unrecoverable, and refusing to regenerate would preserve ciphertext nobody can open while permanently disabling persistence for every sensitive category. But the consequence is that the entire outbox and pending queue are settled as terminal failures on the next restore, and the log gave no way to tell that apart from a first-run key being minted. It now names the consequence, and the asymmetry with the recoverable load-failure branch (which leaves the key uninstalled and settles nothing) is written down where the next reader will find it. + diff --git a/docs/changelog/0.18.md b/docs/changelog/0.18.md new file mode 100644 index 00000000..7d8d6976 --- /dev/null +++ b/docs/changelog/0.18.md @@ -0,0 +1,46 @@ +# Changelog: 0.18.x + +Archived release notes. The working changelog, covering the current release +and unreleased changes, is [CHANGELOG.md](../../CHANGELOG.md). + +| Release | Date | +|---------|------| +| 0.18.3 | 2026-08-03 | +| 0.18.2 | 2026-08-01 | +| 0.18.1 | 2026-08-01 | +| 0.18.0 | 2026-07-31 | + +## [0.18.3] — 2026-08-03 + +### Fixed + +- **iOS: tearing the SDK down no longer kills the app.** Every `destroy()` aborted the process with `SIGABRT` and the runtime message `Cannot form weak reference to instance … of class MeshSdk.InternetManager`. Forming a **new** weak reference to an object whose deallocation has already begun is a hard runtime abort — `objc_initWeak` routes to `storeWeak`, which calls `_objc_fatal` — and not, as the code assumed, a reference that harmlessly reads back nil; "goes nil" only describes a weak reference registered *before* its target died. Because a capture list is evaluated when its closure is **created**, the `[weak self]` on the internet transport's teardown hop aborted whether or not the block ever ran. `destroy()` is the double-stop that exposed it: it calls `stop()` on each transport and then releases it, and releasing runs `deinit`, whose own `stop()` reaches that hop a second time — this time from inside `dealloc`. The internet transport was the one that died because, alone among the transports, its stop path deliberately has no early-return state guard (it must always invalidate its `URLSession`, or every `start()` after a `stop()` leaks one), so the second pass ran the whole teardown instead of returning at the top. `deinit` now takes a path that names `self` in no capture list at all: the cleanup that only touches state dying with the object is skipped there, while the two effects that must still happen — draining parked forced-presence checks so their React Native promises resolve `false` instead of hanging their JS callers forever, and the `internetStatusChanged(false)` that stops the transport selector scoring a socket that is gone — run from a hop capturing locals bound before the closure. Reading a stored property during `deinit` is safe; only the weak reference was ever fatal. Both hops stay on the transport's serial message queue in the same order, so 0.18.2's guarantee that a teardown `false` and a later re-authentication's `true` cannot reorder is unchanged, and a strong capture was deliberately *not* used as the fix — it would resurrect a deinitializing object and defer its dealloc onto that queue, where the teardown's main-thread hop becomes a `sync` that can deadlock. The same latent shape is fixed in the Nostr transport, whose relay-disconnect hop had the identical capture: it never aborted only because its early-return guard happens to short-circuit the second `stop()`, which stops being true the moment a manager is released while still running — module teardown without an explicit `destroy()`. The BLE, Reticulum and Wi-Fi Direct transports were audited and are unaffected: neither reaches an escaping closure capturing `self` on its stop path. Android was never affected, and still runs this cleanup inline under a blocking main-thread hop. The invariant, the reasoning, and the reason CI cannot catch it are now written down in `bindings/react-native/ios/BRIDGE_MAINTENANCE.md`, and CI now type-checks the two bridge files that the SwiftPM test harness excludes, which until now shipped every change with no compile coverage at all. + +## [0.18.2] — 2026-08-01 + +### Fixed + +- **iOS: no internet-transport transition takes the protocol lock on the main thread anymore.** 0.18.1 moved the two `UIApplication` lifecycle handlers off the main thread, but the same defect — a UniFFI call that acquires the global protocol mutex, made synchronously on main inside a window the system's scene-update watchdog measures — survived at every internet status transition. Worst was authentication: the relay's `Authenticated` answer was handled on main, and the `internetStatusChanged(true)` it makes is the call that runs the reconnect outbox flush under the lock, so every reconnect — above all the foreground force-reconnect, firing at the exact moment the resumed process tick and queued sends make the lock slowest — parked the main thread for the flush's duration (`0x8BADF00D`). The socket-close funnel had the mirror image: iOS kills the socket on backgrounding, and the close handler's `internetStatusChanged(false)` took the lock on main inside that transition. The transport's `stop()` did the same on its caller's main hop, and a deferred one-second `bleStatusChanged(true)` backup after start ran on main squarely inside the launch window where state restore makes the lock contended. All four now ride the transport's serial message queue (the BLE backup rides the SDK's processing queue), enqueued from main in the order main observed the transitions — which is what keeps a close's `false` and a re-auth's `true` from ever reordering — with the relay-capability injection still strictly before the status flip and the immediate post-reconnect poll now chained strictly after it on the same queue, so the flush-then-drain ordering is guaranteed rather than incidental. The protocol handle is captured strongly in each hop (the component, never the manager itself), so a teardown racing the hop cannot swallow a status change the Rust side still needs; the drain barrier `destroy` already holds also now covers the BLE backup, closing a cancel race where a backup already mid-flight on the main queue could re-enter the protocol after teardown. The two directions are deliberately asymmetric on teardown: a `false` is unconditional — the Rust side needs it even once this manager is gone, and it takes no flush path — while the `true` is gated on the authentication that enqueued it still being current. Making the flush asynchronous otherwise opened a window the synchronous call never had, since `destroy` barriers the SDK's processing queue but not the transport's: a `true` dequeued after `destroy` ran `stop()` would flush the outbox under the lock on a stopped protocol and re-persist state that a `wipePersistedState()` on logout then races. Every teardown flips the authentication flag on main before enqueueing its own `false`, so the gate can never drop a `true` that is still wanted — a later re-authentication enqueues a fresh one carrying its own connection's capabilities. Otherwise observable behavior is unchanged: the calls, their ordering, and their effects are identical; they simply no longer happen on the main thread. The Android bridge's two counterpart sites (its Wi-Fi Direct broadcast receiver and the blocking teardown hops) are tracked separately: Android has no scene-update watchdog, so the same shape costs an ANR budget there, not a kill. + +## [0.18.1] — 2026-08-01 + +### Fixed + +- **iOS: a forced presence check no longer leaves its React Native promise unresolved when the transport is torn down.** `checkInternetPresence(force: true)` parks the query until the relay socket is authenticated and rate-admits it, and the transport's `stop()` is supposed to resolve every parked entry `false` — dangling a JS promise until the deadline helps nobody, and on an explicit stop no reconnect is coming. That drain ran inside a block hopped onto the transport's internal queue and captured the transport weakly, so it executed only if the transport was still alive when the block ran. On the two paths where the drain is the entire point, it was not: `stop()` is reached from `deinit`, and from `destroy()`, which releases the transport immediately afterwards — in both cases the weak reference is gone by the time the block runs and every parked promise is silently dropped, hanging the JS caller forever with no error and no timeout. The queue holding those promise resolvers is now captured strongly and drained unconditionally, which keeps the guarantee without resurrecting a deinitializing object; the rest of the block, which only clears state that dies with the transport anyway, is unchanged. Worth knowing that this was reachable without `destroy()` at all: a transport that was never started sits in `.unavailable`, which the park policy does not fail fast on, so checks park on it and its only teardown is `deinit`. The Android bridge was never affected — it runs the same cleanup inline under a blocking main-thread hop — and this restores parity with it. + +- **iOS: backgrounding or foregrounding the app no longer takes the protocol lock on the main thread.** The `UIApplication` lifecycle handlers called `wifiDirectStatusChanged` synchronously — iOS tears down MultipeerConnectivity on background, so the core has to be told to stop routing over Wi-Fi Direct — and that call acquires the global protocol mutex, the same one held by the process tick, by MLS work, and by a group fan-out. Blocking the main thread on it happens inside the window the system's scene-update watchdog measures, so a device under exactly the load that makes the lock slow could be terminated (`0x8BADF00D`) during an ordinary app switch. Both handlers now hand the call to the SDK's serial processing queue instead. The queue's ordering is what makes this safe: the `false` from background and the `true` from foreground cannot overtake each other. The background side additionally holds a background-task assertion across the hop, so the status change still lands promptly on a build that keeps running in the background on BLE, rather than being deferred to the next foreground while the transport selector still scores a link the OS has already torn down. Nothing about the observable transport behavior changes; the work simply no longer happens on the main thread. Note this call site has no Android counterpart — `onHostPause` there makes no protocol call — so no paired change was needed. + +## [0.18.0] — 2026-07-31 + +### Changed + +- **The relay group broadcast is back on by default — now gated on the relay's `group_delivery_v2` capability and backed by a settled per-recipient delivery report.** **This supersedes the per-member-fan-out-by-default entry below**, which landed earlier in this same release cycle and set the default to off; both are listed because both shipped as commits, but `0.18.0` releases with `group.relay_broadcast_enabled` defaulting to `true`. That flip is what this entry describes, and the flag alone never selects the broadcast: the path is taken only when the connected relay advertised `group_delivery_v2` in its `Authenticated` answer, which the platform bridge now injects into the core (new UniFFI method `internet_relay_capabilities`, called before `internet_status_changed(true)` so the reconnect flush already sees it; the set is cleared when the transport drops). Against such a relay a group send is one O(1) frame carrying a client-minted logical message id — the bridges stamp it onto the relay `SendGroupMessage`, mirroring the direct-message translator — and the relay answers, once its whole fan-out has settled, with a report naming which members took the message over a live socket and which took a device push. The SDK consumes that report through a new dedicated bridge entry point (`internet_group_report_received` — deliberately not message-plane injection, so it cannot be forged through the notification ciphertext injector): every MLS roster member the report does not account for, both the ones the relay names as missed and the ones it never knew (its registered roster can lag the MLS roster), is automatically re-sent a per-member copy through the ordinary outbox/ACK/park delivery ladder, and the outcome surfaces as the new `group_message_delivery_report` event. A lost report re-sends the broadcast under the same logical id — safe, because the relay echoes the id and both receiver dedup and the relay's push dedup key on it — at most twice on a 60-second timer (above the relay's 45-second fan-out budget), then the whole message downgrades to per-member fan-out, which needs no report to be correct; the same downgrade fires immediately if the Internet transport drops while a report is pending. Receivers honor the logical id end to end: a re-issued copy emits the same app-facing message id every other member saw and is absorbed as a duplicate when the relay's own copy also arrived, including across the deferred-decrypt buffer — and because the id is unauthenticated wire input, it is only ever marked as seen after the ciphertext MLS-decrypts, so a non-member cannot poison an id to suppress a genuine message. Against a relay that does not advertise the capability the gate fails closed and every group send takes per-member fan-out exactly as before; the old fire-and-forget broadcast is never taken regardless of configuration. The React Native config surface grows the previously unreachable `group` section (`group.{maxGroupMembers,relayEnabled,relayBroadcastEnabled}`, nested-wins-over-flat like `encryption`) on both platforms, so an RN app can opt out of the broadcast now that the default is on. One caveat worth knowing before leaving the default on: the tracker awaiting a delivery report lives in memory only, so a process death inside the report window loses that backstop — members the relay could not reach get no per-member re-send and nothing retries on restart. It takes both a missed member and a kill in that window, and it is strictly less exposure than the old backstop-less broadcast, but an app that must not lose a group message to a mid-flight kill should set `relayBroadcastEnabled: false` and pay the O(N) uplink. + +- **Group messages sent over the internet now fan out per member by default, instead of taking the relay's O(1) broadcast.** A new `group.relay_broadcast_enabled` setting gates the broadcast path (`group.relay_enabled`, which registers the group so invite links resolve, is untouched and stays on). **Superseded within this same 0.18.0 cycle:** this change introduced the setting defaulting to **off**, and the delivery-report work described above then flipped it back to **on** once the broadcast gained a settled per-recipient delivery contract. `0.18.0` ships with the default **on**; the reasoning below is why the broadcast was untrustworthy *before* that contract existed. The broadcast saves sender uplink but has no delivery contract at any layer: the relay fans it out fire-and-forget with no per-recipient presence check, no push fallback, and no persistence, and answers "sent" before delivery is known — so a member who is backgrounded, offline, or on a socket that has quietly died misses the message permanently and *undetectably*, because MLS application messages don't advance the group epoch and the receiver never learns one existed. Per-member fan-out sends the same MLS ciphertext as ordinary `SendMessage` frames and thereby inherits the entire direct-message ladder that has been built out over the last several releases: outbox persistence, the ACK and retry ladder, the relay's write-ack and successor retry, offline push carrying the ciphertext, park-on-unreachable with escalating presence probes and flush-on-reconnect, and the receiver's deferred-ACK-after-decrypt. The cost is O(N) frames per send. That does not risk tripping the relay's rate limiter at any group size: the platform bridge already meters every relay-bound frame through a client-side token bucket deliberately tighter than the server's (28 capacity and 9/s refill against the relay's 30 and 10/s) and defers frames it cannot fund to a later poll tick rather than dropping them, so client spend stays strictly under the server's budget and the fan-out self-paces. What large groups cost instead is drain latency — the core enqueues all N frames at once and the bridge writes them at about 9 per second after an initial burst of roughly 28 — and, past roughly 118 members, self-inflicted duplicate sends: the ACK timer starts when a frame is enqueued locally rather than when it reaches the wire, so the tail of a very large fan-out times out and is retransmitted before it was ever written. Those duplicates are absorbed by receiver and push dedup via the stable outbox id, so they cost bandwidth rather than correctness; groups near that size should keep the broadcast on and accept the delivery gap until the relay can report per-recipient delivery back to the sender. Two bugs on the broadcast path are fixed in the same change, so opting in is no longer actively harmful. First, the self-addressed hint frame that carries a broadcast (and the one that carries a group registration) went out requiring an ACK that by construction can never arrive — the platform bridge *replaces* the frame with a relay-native one, so nothing addressed to its id ever comes back — which meant the ordinary retry ladder retransmitted it ten times over roughly thirteen minutes. Each retransmission was a complete additional relay fan-out under a freshly minted relay id, which receiver-side dedup keys on and therefore misses, so members saw the message repeatedly and genuine out-of-order messages could be evicted from the bounded pending buffer; the ladder then terminated in a `message_failed` for an id the application was never told about, and recorded a delivery failure that degraded the transport selector's score for the very transport the frame required. Both frames are now honest one-shots (registration retry already lived, correctly, on its own bounded tracker). Second, these frames were routed by ordinary transport selection, which demotes Internet below every mesh transport — and while BLE fails closed on a message addressed to oneself, Wi-Fi Direct and Reticulum accept it unconditionally and report success, silently swallowing the frame while the sender concluded the broadcast had gone out and skipped the per-member fallback, delivering the group message to nobody. Relay hint frames are now pinned to the Internet transport and fail fast when it is unavailable, and the broadcast path additionally re-checks internet availability so that a stale sync flag (internet dropped, the periodic tick not yet run) can no longer route a broadcast into the mesh. +- **Forward attribution now survives the relay path for group messages.** `forward_info` was carried in the broadcast payload but dropped by both platform translators when they built the relay frame, and had no path back into the receiver's injected frame — so a forwarded group message rendered "forwarded from X" for members who received it over the mesh and lost the attribution entirely for members who received it over the relay. Both halves are now plumbed. Sealed rich payloads were never affected: they carry their own copy of the attribution inside the MLS plaintext. The `epoch` field remains deliberately unforwarded — OpenMLS reads the epoch from the ciphertext header, so the payload copy is informational and the receive path never consults it. +- **The internet bridges now stamp the relay `SendMessage` frame with the core's outbox `message_id`.** Every retry of one un-ACKed logical message used to reach the relay as a brand-new message — the relay minted a fresh UUID per frame — so a recipient who stayed offline collected one push notification per rung of the sender's retry ladder: identical banners for a single message, with nothing anywhere able to tell them apart. With the outbox id on the wire (stable across retries by construction), a relay that understands the field echoes it in `MessageReceived`/`MessageSent`/`DeliveryError` and its push payload, remembers which `(recipient, message_id)` pairs it has already pushed, and answers a retry of an already-pushed message with `DeliveryError` instead of another notification. The bridges already classify any relay `DeliveryError` as `recipient_unreachable`, so that answer parks the DM on the designed offline path — escalating reachability probe plus presence-edge re-drive — rather than letting the retry ladder burn to terminal settlement against a peer that cannot ACK. Older relays ignore the unknown field; nothing changes against them. + +### Added + +- **React Native can now erase one account's persisted state: `wipePersistedState(appId, userId)`.** Signing a user out has never removed anything from disk — `destroy()` releases the in-memory instance and leaves the outbox, pending queue, block list, and the whole MLS identity filed under that account's namespace. Two things follow that applications have been hitting. Signing back in restores the outbox and re-drives it, so a message queued for a user who never comes back is retried on every launch and reconnect for the full outbox lifetime. And on iOS the Keychain outlives the app container, so an uninstall does not take the secure store with it: a reinstall and a sign-in as the same user adopts that material again, including the pre-split store's delivery state. Neither was fixable from the application side — the paths are digest-derived from an internal domain separator, the providers are constructed inside the bridge and never handed out, the protocol persists as it works so an external delete races it, and deleting one store but not another lets read-through or the adoption claim resurrect what was removed. The new method erases, for the named account only: the namespaced secure store (MLS identity and sessions, TOFU pins, the Nostr signing secret, the key every sealed protocol-state record is written under), that account's protocol-state directory, and the pre-namespace store an upgraded install inherited from — the last one only when this account owns the claim or nobody does, since it was shared by every account on a pre-split install. A claim that cannot be *read* also stops it, because unreadable and foreign are indistinguishable and only one of those two mistakes is recoverable. The androidx master key is never touched. Call it **after** `destroy()`: the native side rejects a wipe of the account the current instance is running, since the protocol persists on the send path and a wipe underneath it leaves a partially repopulated container. The identity is an explicit argument because `destroy()` clears the config the namespace would otherwise be derived from — which also lets an app wipe the account it just signed out of while the next one is already running. Secure storage is erased first, so an interrupted wipe leaves the remaining records as ciphertext whose key is already gone rather than as readable state; every store is attempted even if one fails, the first error is reported, and the whole thing is idempotent, so a caller that gets an error should call again. It is irreversible and it rotates the account's MLS **and** Nostr identities: peers holding a session will see a desync on next contact and re-establish from a fresh key package, and peers holding a TOFU pin will treat the returning user as a new peer. Applications that supply their own storage providers must erase their own containers — the SDK only knows how to wipe the built-in ones. There is deliberately no Python equivalent: its state directory could be removed trivially, but its secure store cannot be enumerated (`keyring` has no listing operation, the per-`key_type` index has no index *of* key types, and OpenMLS contributes key types of its own), and a partial wipe that left signing-identity material behind would be worse than none. Two teardown races that the wipe ordering depends on are fixed alongside it: Android's `destroy` now waits (bounded) for an in-flight process tick to finish instead of only refusing new ones, and iOS's deferred one-second `bleStatusChanged` backup call is now cancelled by `destroy` instead of re-entering the protocol after teardown. + diff --git a/docs/changelog/0.19.md b/docs/changelog/0.19.md new file mode 100644 index 00000000..50978bc8 --- /dev/null +++ b/docs/changelog/0.19.md @@ -0,0 +1,99 @@ +# Changelog: 0.19.x + +Archived release notes. The working changelog, covering the current release +and unreleased changes, is [CHANGELOG.md](../../CHANGELOG.md). + +| Release | Date | +|---------|------| +| 0.19.0 | 2026-08-04 | + +## [0.19.0] — 2026-08-04 + +> **Two receive-side behaviours tighten on first launch.** Cleartext from a peer +> you hold an MLS session *or* a TOFU pin for is now rejected even when that +> session was never confirmed — this only affects `encryption.enabled = true` +> with `requireEncryption: false`, the mixed-mode opt-out. And key packages +> cached by earlier builds are unsealed, so they are dropped on first launch and +> re-exchanged automatically. Neither fails initialization; both are described in +> [`docs/UPGRADING.md` §1.10 and §1.11](../UPGRADING.md#110-the-inbound-plaintext-gate-no-longer-reads-session_states). + +### Changed + +- **The workspace crates can now actually be packaged for crates.io.** The seven internal dependencies in `[workspace.dependencies]` carried a `path` but no `version`, and cargo refuses to package a crate whose dependencies have no version requirement ("all dependencies must have a version requirement specified when packaging") — so only `offline-protocol-core`, the one crate with no internal dependencies, could be published at all. Every internal dependency now declares `version = "0.2.0"` alongside its path; path resolution is unchanged for workspace builds, and the version is what cargo rewrites into the published manifest. + +- **The public error enums are `#[non_exhaustive]`.** `Error` (core, transport, router, reliability, engine), `MlsError`, `StorageError`, `ServiceError`, `IdValidationError`, `ChunkDecodeError`, `ProtocolStateError`, and `SessionStateError` can now gain variants without that being a breaking change for downstream crates. Rust code outside this workspace that matches on them exhaustively must add a wildcard arm. The FFI enums (`ProtocolError`, `MlsStorageError`) are deliberately unchanged — they are decoded positionally by the generated bindings and remain governed by the stricter append-only rule. + + This costs the compile-time exhaustiveness guard on the UniFFI error mapping, which existed so that a new engine error variant could not silently degrade to `Other`. `#[non_exhaustive]` makes cross-crate exhaustiveness impossible on stable, so the guard is replaced with a runtime one: the wildcard arms log a `warn!` naming the unmapped variant, making taxonomy drift visible in logs and telemetry rather than invisible. New engine error variants must still be given an explicit arm. + +- **Each publishable crate carries its own `LICENSE` copy instead of pointing at the workspace root.** Setting both `license` and `license-file` made cargo warn "only one of `license` or `license-file` is necessary" on every invocation, for all eight crates. Dropping `license` instead would have cost the SPDX identifier that crates.io displays and cargo-deny classifies on, so the license text now lives at each crate root, where cargo picks it up automatically. `scripts/check-license-consistency.sh` asserts the copies stay byte-identical to the root `LICENSE`, replacing its previous `license-file` assertion. + +### Fixed + +- **A peer's `session_reset` no longer deletes the messages queued for them.** Receiving a `session_reset` key package discarded the entire outbound pending queue for that peer, on the stated grounds that its entries were "encrypted for the old session". They are not — `PendingMessage.content` is original plaintext, and `flush_pending_messages` seals it through `prepare_outbound_content` at flush time against whatever session is current *then*. The reset is precisely what produces the session those entries would have sealed against, so the drop destroyed messages that were one flush away from being deliverable. It settled nothing either, so the application was never told: it kept ids returned by `send_message` that could no longer resolve in either direction. And because a re-key is triggerable by an injected frame, this deletion was reachable with no key material. The queue is now kept. The sibling `pending_queue.drain_for_peer` on the *inbound* side stays, because those really are ciphertexts sealed to the deleted session. + +- **Dropped pending messages are settled instead of handed back and discarded.** `drop_pending_queue_for_peer` returned its dropped entries for the caller to settle, and all three callers threw them away — so blocking a peer or aborting a pending session deleted messages the app still held `send_message` ids for, with no `message_failed` and no other signal. The two sibling drop paths (the blocked branch of `flush_pending_messages`, and the pending-expiry pass) both settle each id for exactly this reason. Settlement now happens inside the function against a caller-supplied reason, and it returns a count, so there is no longer a value a caller can drop on the floor. The pending-expiry watermark is recomputed too; the old whole-queue removal left it pointing at entries that no longer existed. + +- **The blocked-user restore walk is bounded again, by both of the limits that apply to it.** `restore_blocked_users` was bounded by `MAX_RESTORE_KEYS_PER_CATEGORY` (16384) while `block_user` enforces `MAX_BLOCKED_USERS` (10000) — two different numbers doing two different jobs, and the walk bound let the restored set reach a state the live path cannot produce, after which `block_user` refused every new block with "limit reached" until the user manually unblocked someone. Since `blocked_users` is a category restore never consumes, planted records came back on every launch, so an attacker able to write the app container could permanently disable blocking by adding markers — without unblocking anyone. Stopping at the live cap keeps the blocks already restored, which is the fail-closed direction. The first fix then replaced the walk bound rather than joining it, which was its own bug: `MAX_BLOCKED_USERS` bounds the *set* and the loop only reaches it by inserting, so entries that never enter the set — an invalid user id, a planted self-block — `continue` straight past, and a container stuffed with unparseable keys walked every last one at one `warn!` line each, on every launch. Both bounds now apply, with the tail warning restored. Restore also skips a record naming the local user; `block_user` refuses to block your own id, so no legitimate run writes that record, and restoring it blocked the local user. + +- **The set behind the plaintext gate is capped, by refusal rather than eviction.** `encryption_capable_peers` is fed from the wire by paths that do not require the peer to prove anything: a well-formed `__MLS_WELCOME__` marks its sender even when the join fails (deliberately, so a peer whose handshake is breaking does not get the gate left open for it), and under the default `require_transport_identity: false` that frame need not even be signed; the TOFU path marks *before* its own store-full branch, so `MAX_TOFU_PEERS` never transitively bounded this. Anyone in radio range could grow the set by a heap string per forged sender id. The cap must be a refusal and not an eviction, and that distinction is the whole point — the set is monotone on purpose because session teardown is remotely triggerable, so evicting would hand an attacker exactly what the monotonicity exists to deny: a way to un-mark a chosen victim by flooding. A peer turned away at the cap falls through to the older `is_session_confirmed` check, so a flood costs later peers the improvement and never costs anyone protection they already had. Legitimate peers are first in line regardless, since restore seeds the set from the session list and the TOFU pins during `initialize_mls`, before `start()` admits any traffic. + +- **React Native's `SecurityWarningCode` union covers the new codes.** It is hand-maintained in `types.ts` — events cross UniFFI as JSON, so nothing regenerates it — and would otherwise have drifted silently from the Rust enum, leaving an app that narrows on the union unable to see `SESSION_SENDER_GROUP_MISMATCH` or `SESSION_REKEY_TRIGGERED` at all. + +- **The README license blurbs no longer overstate AGPL section 13.** Section 13 attaches only to *modified* versions that users interact with over a network; source obligations for plain distribution come from sections 4–6. The intro blurbs in the root and React Native READMEs claimed section 13 covers both — wrong on both halves, and shipped on the npm package page. Both now match the Python README's wording, which never attempted the summary; the License sections below them already described section 13 accurately. + +- **The CLA no longer offers a document that does not exist.** Clause 4 told employed contributors that their employer could satisfy it by executing "a separate Corporate CLA with the Project", leaving the first corporate contributor at a dead end. That alternative is removed; permission or waiver from the employer remain the two live paths. Per the amendment convention in `cla.yml`, the bot's path-to-document moves to the new immutable tag `cla-v1.1`. Signatures stay on `version1` — the change removes an unusable option and imposes nothing new on prior signers, so no re-signing is forced. + +- **`cargo audit` is green out of the box.** It reported five vulnerabilities against libcrux crates while CI's cargo-deny run was green, because cargo-audit scans `Cargo.lock` (which records optional dependencies nothing enables) and cargo-deny scans the feature-resolved build graph. Two of the five (`RUSTSEC-2026-0209`, `RUSTSEC-2026-0211`) are AES-GCM bugs in `libcrux-aesgcm` — worth verifying rather than waiving on sight, since the SDK's ciphersuite does use AES-GCM. It is provided by `hpke-rs-rust-crypto`; `cargo tree --workspace --target all -i libcrux-aesgcm` confirms the libcrux backend is absent from the build graph at every target. `.cargo/audit.toml` now waives the lockfile-only and known-unreachable advisories with that reasoning recorded per entry, and documents that it must remain a superset of `deny.toml`. + +- **`cargo doc` no longer fails under `-D warnings`.** Seven intra-doc links in `wire.rs`, `offline-protocol-mls/src/group.rs`, `offline-protocol/src/protocol/send.rs`, and the UniFFI crate pointed at private items, which rustdoc rejects in public documentation — so docs.rs would have rendered the wire-format module, the most security-sensitive front page in the workspace, with broken links. The names are retained as code spans. + +- **CHANGELOG 0.18.0 no longer contradicts itself on `relay_broadcast_enabled`.** One entry said the default flips to `true` and a later entry in the same section said it defaults to off; the second landed first and was superseded within the release cycle once the broadcast gained a settled delivery report. Both entries now state the supersession explicitly. The shipped default is `true`, matching `GroupConfig::default()`. + +- **`docs/UPGRADING.md` no longer presents itself as a `v0.17.0`-only document** while containing later material (`wipePersistedState`, added in `v0.18.2`). It now frames itself as the `v0.16.x` → `v0.19.x` guide, states that nothing since `v0.17.0` breaks a build, and labels post-`0.17.0` additions and behaviour changes with the release that introduced them — which on this release means §1.10 and §1.11. + +- **React Native README links resolve on npmjs.com.** Five `../../docs/...` relative links 404'd there because `docs/` is not part of the published package. They now use absolute GitHub URLs, matching the Python README. + +### Removed + +- **Repository hygiene.** Removed `Cargo.toml.bench` (a stale root fragment superseded by `crates/offline-protocol-bench`, and already out of date — it listed four benches to the real manifest's five), `examples/mesh-wiki/ios/.xcode.env.local` (a per-developer file pinning an absolute Homebrew node path, which React Native's own `.xcode.env` documents as unversioned — now gitignored), and an empty `examples/react-native-app/src/helpers/string.ts` with no importers. Cleared the real Apple Team IDs from all four example Xcode projects, added READMEs to `examples/demo-app` and `examples/mesh-wiki`, replaced two references to a `todo.md` that has never existed in the repository with the actual Kotlin 2.x migration requirement, and corrected the React Native TSDoc that still described WiFi Direct and Internet as "future support". + +- **The React Native example no longer logs its relay auth token.** `HARDCODED_TOKEN` — a misnomer, since it reads `RELAY_AUTH_TOKEN` from the environment — is renamed `RELAY_AUTH_TOKEN`, and the `__DEV__` block that printed the token's *value* now prints only its length. Example code gets copied into real apps, and Metro/device logs get pasted into bug reports. + +### Security + +- **Deleting one file in the app container no longer re-opens the inbound plaintext gate.** `accept_plaintext_content` decided whether unauthenticated cleartext counted as a downgrade by asking `is_session_confirmed`, which reads the `session_states` protocol-state record — and that record lives in the install-scoped app container, not the credential store. An attacker able to write there simply deleted it. Restore then did exactly what it must (`bootstrap_missing_session_state` writes a fresh `Pending`, because one unreadable record cannot be allowed to brick initialization), the peer dropped out of `confirmed_sessions`, and the lookup returned `Ok(false)` cleanly — no error, so the deliberate `.unwrap_or(true)` fail-closed never fired. Cleartext carrying an attacker-chosen `sender` was then delivered to the app as a message from a contact. **Sealing the record does not fix this**: a seal authenticates bytes that are present, and this attack removes them. The signal therefore moves somewhere the app container cannot reach — a new `encryption_capable_peers` set seeded from the MLS session list and the TOFU pin store, both credential-store-resident, taking their union (a session can exist with no pin, since an unsigned control message from a not-yet-pinned peer is accepted, and a pin outlives its session). The question also changes from *confirmation* to *capability*, which is the correct one: a sender only emits plaintext when its own `should_auto_encrypt()` is false, and while merely pending it queues rather than downgrading, so no honest peer sends cleartext while we know it speaks MLS. The group path has gated on existence rather than confirmation since it was written; this brings 1:1 into line. The set is **monotone by design, and that is load-bearing rather than sloppy**: session teardown is reachable from an unauthenticated frame (an injected `__MLS_ENC__` classifies as `SessionDesync`, tears down locally, and advertises a `session_reset`), so a set that shrank on teardown would let one forged packet re-open the gate — turning a bug that needs app-container write access into one that needs a single injected packet. `resetTofuForPeer` is the only way out, and it is an explicit operator action. See `docs/UPGRADING.md` §1.10 for what an app team will notice. + +- **A cached key package is now checked against the peer's TOFU-pinned signature key, everywhere it is used.** `import_key_package` verified the leaf's *credential identity*, which cannot bear that weight: these are MLS **basic** credentials, which [RFC 9420](https://www.rfc-editor.org/rfc/rfc9420.html) defines as a bare assertion of an identity — the content is a string the holder writes themselves, so an attacker who generates their own signature keypair and stamps `bob` on it passes. That mattered because a validated key package is not necessarily a freshly received one: the Ed25519 + TOFU gate runs at receive time only, packages are then cached in the install-scoped protocol-state store and re-read later, and the gate does not travel with them. All three read paths re-checked nothing but expiry, so anyone able to write that container could swap in their own package and have this node build the session around — and encrypt to — their leaf. `add_group_member` was worse: it ran no identity check at all, so a package could join a group under a roster label unrelated to it. The material to detect this was already present and simply never read — the TOFU-pinned control-message key *is* the MLS leaf signature key. `peer_key_packages` also becomes a sealed category. `test_credential_identity_check_alone_does_not_stop_a_substitution` pins the premise so it cannot quietly lapse. Behaviour changes app teams will see: cached packages from earlier builds are dropped on first launch (one automatic re-exchange, not an error), `mlsImportKeyPackage` now errors when a package's leaf signature key is not the pinned key for that peer id, and `inviteToGroup` verifies the invitee's key package identity for the first time. FFI signatures are unchanged, so no bindings regeneration is needed. See `docs/UPGRADING.md` §1.11. + +- **An encrypted 1:1 envelope must now name the session slot shared with its claimed sender.** The `group_id` on an `__MLS_ENC__` DM selected which MLS group to decrypt against with nothing tying it to the wire sender. That is safe only on the *success* path, where the existing SEC-M1 check compares the decrypted MLS credential to the claimed sender — but every failure OpenMLS raises happens before it authenticates anything. It validates the framing header (group id, then epoch) ahead of any AEAD, sender-data decryption or signature check, so `WrongEpoch` — the verdict classified as the recoverable `SessionDesync` that drives a re-key — was reachable with **no key material at all**, on a prefix deliberately exempt from the control-plane signature gate. Unbound, the claimed sender and the targeted group were independent: one session id, derivable from two public user ids, yielded a re-key, a peer-keyed map entry and a key-package send for an arbitrary attacker-chosen identity. The binding is enforced at the MLS chokepoint before the group is loaded, because `decrypt_from_user` is reachable both from the receive path and from the public `manual_mls_decrypt_from_user` — checking in either caller alone leaves the other as a bypass. The media path already had this binding locally; text never did. Rejecting pre-decrypt also stops a forgery from consuming the ratchet generation it names, so the genuine message at that generation now survives. Mismatches surface as the new `SESSION_SENDER_GROUP_MISMATCH` security warning, the text-path counterpart to the existing media check. `test_forged_frame_reaches_session_desync_without_any_key_material` hand-serializes an MLS frame from scratch to document the residual honestly — the pre-existing `test_corrupt_ciphertext_is_not_classified_as_session_desync` only ever proved that *malformed* input is rejected before framing validation, which is a different claim. + +- **The epoch-desync re-key is now bounded and visible.** `rekey_due_at` is keyed by the wire-claimed sender of a frame OpenMLS classified before authenticating anything, and it was the one peer-keyed map with no capacity bound and no eviction — `key_package_sent_to`, `pending_key_packages` and `known_peer_public_keys` all cap for exactly this reason. (With the envelope binding above, this is now defence in depth rather than the primary fix.) The re-key was also entirely silent: it tears down a working session and re-advertises a key package, and nothing reached the application. Because a genuine fork produces these occasionally, **the rate is the signal** — a sustained re-key rate for one peer means injected frames, which an operator had no way to see. Each re-key now emits a `SESSION_REKEY_TRIGGERED` security warning. The SDK's own threat-model notes are corrected alongside: the previous text claimed "an outsider still cannot forge a frame that produces this classification", which is false, and its own premise defeats it — producing the classification needs a structurally valid frame for a known group id, which requires no secrets. The mitigation is that acting on the trigger is *harmless*, not that the trigger is trusted; the residual (bounded re-key churn on a pair, delivery delayed and never lost) and what would close it are now written down. + +- **Deleting an inherited key no longer leaves a copy that a later read puts back.** The built-in secure stores adopt the pre-namespace store by read-through, so `delete` has to remove both copies — a surviving legacy copy is one namespaced miss away from being promoted back into the new store. All three providers swallowed a failure on that second removal and returned success, so the very next `load` of that key handed the caller material it had been told was deleted, and re-filed it in the namespaced store on the way out. The window is an upgraded install with an adopted legacy store, and what gets resurrected is whatever the SDK deletes on purpose: consumed key packages, torn-down session state, the material a `session_reset` is meant to destroy — a forward-secrecy regression rather than a correctness one. Easiest to hit on iOS, where a Keychain removal that fails because the device is locked is transient but was treated as final. **Reporting the failure was not available as a fix.** A thrown delete crosses the FFI unswallowed and OpenMLS treats a storage delete as fatal: Welcome processing deletes its consumed key package with `?`, every commit merge deletes the previous epoch's keypairs with `?` — surfacing as a decryption error, which bypasses the desync heal and forks the group — and `create_session` cleans up only after the MLS group and Welcome already exist. There is no retry anywhere in core to absorb any of it, so failing the delete would trade silent resurrection for silently broken sessions. The failure is now **recorded** instead: the key is tombstoned in the namespaced store, and read-through treats a tombstoned key as absent from then on, which is the guarantee `delete` actually owes its caller — the copy in the legacy store is left inert rather than hidden by pretending it is gone. `listKeys` filters tombstoned ids out of the legacy-index union, so nothing advertises a key it would then withhold, and a later read retries the removal, retiring the tombstone once it lands. A tombstone suppresses read-through only, so re-storing under the same id is readable again. The tombstone read itself is three-way rather than a yes/no, because its two failure answers authorise different things: a read that cannot be completed suppresses read-through — it cannot prove promoting the key is safe — but does *not* authorise the removal retry, since a failed read is no evidence that a tombstone exists and deleting on it would destroy the last copy of a key that was legitimately inheritable, which on a first post-upgrade launch can be the signing identity. Suppression costs a read-through until the store recovers; that deletion could not be walked back. The one case that still fails loudly is a double fault — the legacy copy will not delete *and* the namespaced store will not record the tombstone — where nothing can keep the promise and a store failing both is failing everything else too. Tombstones join the migration claim as reserved entries: never promoted, never listed, never returned as key material. + +- **iOS: a legacy-store claim that is not valid UTF-8 no longer authorises destroying the shared store.** `wipePersistedState` erases the pre-namespace store only when this account owns the claim written in it or nobody does — that store was shared by every account on a pre-split install, so another account's claim makes it off-limits. iOS classified a claim whose bytes it could not decode as *absent*, because `String(bytes:encoding:)` returns nil and nil meant "no claim". Absence is the one classification that permits the delete, so bytes iOS merely failed to interpret could take a different account's MLS identity, sessions, and block list with them — the unrecoverable half of a wipe decision that is otherwise carefully fail-closed. Android and Python were already safe here, but by accident of decoding lossily rather than by design: both substitute U+FFFD, the result matches no real namespace, and the wipe is refused. Both platforms now classify claim *bytes* in the shared policy file with an explicitly lossy decode, so undecodable bytes read as owned — they are still evidence that something claimed the store. Adoption gets the same answer and conflicts rather than inheriting an identity whose owner it could not establish, which is what the other two platforms already did. Devices that adopted before this overwrote the claim with their own namespace on the way through, so only a first encounter with garbage changes behaviour. + +- **iOS: a failed Keychain write no longer destroys the value it was replacing.** `store` deleted any existing item and then added the new one, so a `SecItemAdd` that failed left the key with no value at all while reporting a failed write — the old item already unlinked, the new one never landed. For MLS material that is unrecoverable: a session's ratchet state or the signing identity, gone because a write failed rather than because anything asked for it. Writes now add first and fall back to `SecItemUpdate` on `errSecDuplicateItem`, so a failure leaves the last good value in place — the overwrite semantics Android's `commit()` and Python's `set_password` already had. Both arms pin `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, since a value-only update does not carry an item's accessibility over. + +- **iOS: the protocol-state directory now states its data-protection class instead of inheriting one.** The store pinned `isExcludedFromBackup` and left the security-relevant attribute to whatever the container defaulted to, which is backwards for records that are deliberately unsealed — the peer graph, the outbox, and the seen-message id set are recoverable from the container by design. It now sets `completeUntilFirstUserAuthentication` explicitly, on the directory so new entries inherit it and on each write so no record depends on that inheritance holding. This is the class the container already applied, so nothing changes today; it stops being incidental. Deliberately not `complete`, which would make the outbox unreadable during the background BLE and relay work the SDK does across device locks — the same trade the Keychain side already makes. + +- **An MLS membership change made by a non-admin is no longer reported as legitimate.** Group roles are an application-layer overlay on MLS — [RFC 9420 §3.2](https://www.rfc-editor.org/rfc/rfc9420.html#section-3.2) leaves membership access control to the application, and MLS itself has no admin concept — and the SDK enforced that overlay when *sending* an invite or removal but never when receiving the resulting commit. `set_member_role` and `rename_group` were both checked on receive; the Add/Remove commit, the one that actually changes the roster, was not. So a group member running a modified client could add or remove anyone, and every peer applied the change and emitted `group_member_added` / `group_member_removed` naming them as the legitimate actor. The bound is insider-only — MLS still authenticates the committer as a genuine member, so an outsider cannot forge one — but it defeated the moderation model the SDK advertises, and the unauthorized Add is the worse half: it splices a reader into every subsequent group ciphertext. The existing delta-vs-claim check noticed the mismatch and only wrote a log line before continuing. Unauthorized changes are now surfaced as a new `group_unauthorized_membership_change` event carrying the committer, the added and removed members, and a `reason` (`sender_not_admin`, or `affected_member_mismatch` when an admin's commit framing disagrees with the MLS delta it actually produced), and the `group_member_added` / `group_member_removed` events grow an additive `authorized` field so one handler can render the distinction inline. The field is deliberately tri-state: `true` (the change passed the local admin check), `false` (judged unauthorized), or absent when authorization was **not evaluated** on the emitting path — your own join from a Welcome (there is no prior group state to judge the inviter against) and relay reconciliation frames (no authenticated committer to judge) — so absence is never a claimed verdict. The admin determination also now covers removals; it previously evaluated to "not an admin" for any commit that added nobody, which made it structurally unable to describe a removal at all. Two hardenings bound the signal's noise and its inputs: reports are rate-limited per `(group, committer)` — divergent role metadata (e.g. two members auto-promoting different admins) would otherwise re-fire the event on every commit, though every roster event still carries `authorized: false` — and the membership delta is only derived when both roster reads (pre-commit and post-merge) succeed, since a transient platform-storage failure silently defaulting to an empty roster would have fabricated a full-roster delta and a security report naming an innocent committer. When a read fails, the commit still merges (no fork) and the roster self-heals on the next successful refresh. + + **The change is applied, not rejected, and that is deliberate.** Rejecting a commit means declining the MLS merge, which advances every other member's epoch but not yours — an unrecoverable fork, with no path back short of the application re-inviting you (the epoch-fork resolver says as much). Admin state replicates best-effort: a role change is a mesh notification with no reconciliation, and a joiner receives only a point-in-time snapshot in its Welcome. A member whose role map had merely fallen behind would therefore partition itself out of a perfectly healthy group with no attacker involved, and non-unanimous enforcement does not even stop an unauthorized Add, since the intruder still reads whichever branch accepted it. An unrecoverable partition is a worse failure than an insider membership change, so the SDK keeps the group coherent and reports the anomaly instead. Applications that need a stronger guarantee should treat the new event as a moderation alert and have an admin reverse the change, and should not rely on group membership alone to authorize sensitive actions. `docs/mls-integration.md` gains a "Group authorization model" section stating exactly what is and is not enforced on each side; the security-invariant lists in the iOS, Android and React Native guides, which all claimed receive-side enforcement that never existed, are corrected to match. The docs also state the signal's bounds: authorization is judged against the local, best-effort role replica, so a report can be a false positive and must never trigger automated reversal, and the member removed by an unauthorized Remove receives no event at all — only the remaining members report it. + +### Added + +- **An export-control notice ships with every distributed artifact.** Root `EXPORT.md` states the SDK's ECCN 5D002 classification, the publicly-available treatment under [15 CFR 734.7(b)](https://www.ecfr.gov/current/title-15/subtitle-B/chapter-VII/subchapter-C/part-734/section-734.7) relying on the §742.15(b) notification, and — the part app teams actually need — the export-compliance work that stays with them: App Store Connect encryption declarations, the annual BIS self-classification report, and the French ANSSI regime. It is copied into the npm and PyPI packages like the other license documents, attached to GitHub releases, and covered by the license-consistency drift guard (negative-tested). + +- **A licensing FAQ covers what the AGPL option means for app-store distribution.** `docs/licensing-faq.md` states the combined-work reach of the AGPL, why Apple App Store distribution is not a supported combination with it (Apple's terms operating as further restrictions, per the FSF's GNU Go and VLC position, and iOS code signing against the section 6 Installation Information requirement), that **no section 7 app-store exception is currently granted**, and the Play Store and export questions. The README and all three copies of `LICENSE-COMMERCIAL.md` point at it. It is descriptive: it grants nothing new and changes no existing permission. + +- **A trademark policy.** `TRADEMARKS.md` claims the "Offline Protocol" name and logo as marks of Offline Protocol, Inc. and states that neither software license grants trademark rights, with the usual nominative-use carve-outs and a rename-your-fork rule. The README license section points at it. Deliberately silent on registration status. + +- **CI fails when `THIRD-PARTY-NOTICES.md` goes stale.** The existing License Consistency job catches the three NOTICES copies drifting from *each other*, but a dependency bump leaves all three identical and uniformly stale — regeneration after a `Cargo.lock` change was manual discipline with nothing to catch a miss. A new job reruns cargo-about and fails on any diff, mirroring the regenerate-and-diff pattern the Python bindings already use. The cargo-about version is pinned in `scripts/generate-third-party-notices.sh` and read by CI with `sed`, so the two can never disagree, and the script enforces the pin locally and asserts the lockfile with `--locked`. + +- **A joiner now learns who created the group, making the admin fallback reachable.** `check_is_admin` falls back to the group creator when no admin role is stored — the exact state an incomplete role snapshot leaves a member in — but that fallback was dead code for every joiner. The Welcome payload carried a point-in-time `member_roles` snapshot and nothing else, so a joiner's group metadata was materialized by `set_member_role` via `GroupMetadata::new(None)` and its `created_by` stayed permanently absent. A joiner whose snapshot arrived empty therefore had no admin at all in its replica and judged *every* member unauthorized, including the real admin. The Welcome now carries the inviter's creator of record (additive field; absent from older SDKs, where absence means "no information" rather than "no creator"), and the joiner adopts it. The write is monotone — first-write-wins — so a device that created the group or already adopted a creator keeps what it has: duplicate Welcomes are idempotent, and a later invite from an inviter whose own metadata disagrees cannot rewrite an established admin fallback. It is deliberately not bounded to the joined roster, since the creator of record may already have left. Receive-side commit enforcement never consults it (see below); it feeds only send-side gating and the authorization report. + +- **Group membership commits can now be refused rather than applied-and-reported, behind an opt-in flag.** `group.enforce_admin_commits` (React Native: `group.enforceAdminCommits`), **default `false`**, makes the SDK reject an incoming MLS Add/Remove commit whose committer the local admin overlay does not authorize. The refusal happens *before* the MLS merge, so nothing changes locally, no roster event is emitted, and the commit is never buffered for retry — it can never become authorized. The check lives at the MLS layer's single pre-merge chokepoint rather than in the commit handler, because `merge_staged_commit` is reachable from every group decrypt path: a commit ciphertext reframed as an ordinary group *message* merges through the application channel, which otherwise treats a consumed commit as benign reordering, and an `__MLS_ENC__` envelope naming a group id reaches the same code through the manager's prefix dispatch. Gating only the commit-framed path would have left both as bypasses. Pure key-update commits carry no membership proposals and are never gated (fork-resolution key updates are issued by a deterministic leader who is often not an admin), and 1:1 sessions are exempt structurally as well as by an explicit namespace check. + + **The default stays `false` because this is a partition decision, not a hardening toggle.** Refusing a commit means declining the merge, so the refusing device's epoch stays behind every member that accepted it — MLS cannot heal that, and the application has to re-invite. Enforcement is fork-free only if every member reaches the same verdict, and the admin overlay replicates best-effort. Every *absent* input therefore fails open — no group metadata, no admin role stored, an unreadable roster — so the common "my role map is behind" case still merges; the creator fallback is deliberately **not** consulted here either, since one unauthenticated claim is too thin to fork a group over. What the check cannot detect is *divergent* knowledge: two members holding non-empty but disagreeing admin sets will refuse each other's commits. That residual risk is what keeps it opt-in, suitable only for a closed deployment that controls role distribution and never for part of a fleet. Rejection is also receiver-local — the sender's frame is still acknowledged, so a committer gets no signal that anyone refused. Both outcomes report on one signal: `group_unauthorized_membership_change` grows an additive `enforced` field, `false` when the change was applied and reported (the default) and `true` when it was refused, in which case the event doubles as a partition alarm. The report's rate-limit window is keyed on that field as well as on `(group, committer)`, so the two outcomes never suppress each other — losing a refusal behind an earlier applied report would hide the transition an app most needs to act on, while repeats within one outcome class still collapse as before. What enforcement guarantees is that the device never *merges* an unauthorized commit, not that it never ends up in a group the commit changed: re-inviting it arrives as a Welcome, which is not policy-gated, so the refused change has to be resolved separately rather than treating the re-invite as the whole remedy. + diff --git a/docs/changelog/0.20.md b/docs/changelog/0.20.md new file mode 100644 index 00000000..ca221749 --- /dev/null +++ b/docs/changelog/0.20.md @@ -0,0 +1,312 @@ +# Changelog: 0.20.x + +Archived release notes. The working changelog, covering the current release +and unreleased changes, is [CHANGELOG.md](../../CHANGELOG.md). + +| Release | Date | +|---------|------| +| 0.20.1 | 2026-08-07 | +| 0.20.0 | 2026-08-07 | + +## [0.20.1] — 2026-08-07 + +> **Nothing breaks a build, and one event changes meaning.** A receiver no +> longer acknowledges a frame it failed to decrypt or failed to parse — it +> withholds the ACK so the sender's resend can deliver, where before the sender +> was told "delivered" for a message that was dropped. The visible consequence +> is that **`messageDecryptionFailed` is now advisory and fires once per failed +> *attempt*, not once per message.** If your UI settles a message on it — marks +> it lost, removes it from a list — switch to `messageFailed`, or +> `fileReceiveFailed` for media, which are the terminal signals. Everything here +> stays under the existing `encryption.cryptoRecoveryEnabled` kill switch +> (default on); `false` restores the previous drop-and-ACK. +> +> A media chunk that fails its identity binding is now answered with silence +> like the text path, which is deliberately *not* under that switch — it governs +> what the receiver reveals to whoever injected the frame, not whether anything +> can be recovered. One side effect: a repeated injection of the identical frame +> re-emits `MEDIA_SENDER_GROUP_MISMATCH` on each attempt rather than being +> suppressed by dedup after the first. The rate is the signal. +> +> GitHub releases now attach the compiled binaries — six archives plus +> `SHA256SUMS.txt`, each carrying its own licence and export notices, and all of +> them attested. npm is unchanged and remains the supported route for React +> Native. + +### Changed + +- **A GitHub release now carries the compiled binaries as assets.** Every native artifact the release workflow builds was uploaded with `retention-days: 1` and then deleted; the release itself attached only `THIRD-PARTY-NOTICES.md` and `EXPORT.md`, so the binaries survived in exactly one place — the npm tarball. That serves React Native and nothing else. An iOS project consuming the XCFramework directly, a plain Gradle Android build, or anything on desktop had no download to point at, and a build more than a day old could not be re-fetched at all, including for forensics on a release that had already shipped. + + Six archives are attached from now on: `offline-protocol--ios-xcframework.zip` (the XCFramework **plus** the generated FFI header, modulemap and Swift bindings, without which it does not build), `-android.zip` (all four ABIs plus the Kotlin bindings that call them), and one per desktop target — `-macos-arm64.tar.gz`, `-linux-x86_64.tar.gz`, `-linux-aarch64.tar.gz`, `-windows-x86_64.zip` — with a `SHA256SUMS.txt` over the set. **Each archive carries `LICENSE`, `LICENSE-COMMERCIAL.md`, `THIRD-PARTY-NOTICES.md` and `EXPORT.md` inside it**, because a binary pulled straight off the release page is an AGPL distribution in its own right, and the [15 CFR §742.15(b)](https://www.ecfr.gov/current/title-15/subtitle-B/chapter-VII/subchapter-C/part-742/section-742.15) notice attaches to the cryptography rather than to the npm package that usually delivers it. + + **The rule the iOS archive follows applies to desktop too: a bare library is not a distribution.** Each desktop archive carries the generated Python bindings (`offline_protocol.py`) that call into the library, the C scaffolding header (`offline_protocolFFI.h`) for consumers linking directly, and the library under *both* its build name and the fixed name UniFFI's generated loader resolves — a symlink on macOS and Linux, a real copy on Windows, where extractors will not create one. Without that pair the download is unusable: `_uniffi_load_indirect()` looks for `libuniffi.so` / `libuniffi.dylib` / `uniffi.dll`, never the cargo output name. Every archive also carries a `VERSION` stamp (version plus commit) and, on desktop, a `README.md` naming the layout and the image the library was built on — the version otherwise lived only in the archive filename, which is the wrong place for it once someone has extracted the tree. + + **The assets are attested.** `SHA256SUMS.txt` alone defends against transfer corruption, not tampering — it sits on the same release page as the binaries it covers, so whoever can replace an asset can replace the manifest in the same operation. Releases now also publish a [build provenance attestation](https://docs.github.com/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds) signed by the workflow identity, verifiable with `gh attestation verify --repo Offline-Protocol/offline-protocol-sdk`. The npm tarball gains the equivalent via `npm publish --provenance`. + + The desktop libraries and the Python bindings are now verified alongside the iOS and Android ones *before* anything publishes, so a missing slice fails the job rather than surfacing between the GitHub release and the npm publish. Packaging itself runs on dry runs too, so a break in it is found before a tag is cut rather than during one. npm is unchanged and remains the supported route for React Native. + + The packaging logic lives in `scripts/package-release-assets.sh` rather than inline in the workflow, because `release.yml` runs only on a `v*` tag — inline, its first real execution would be during a release. `scripts/tests/test-package-release-assets.sh` drives it against a fixture tree on every PR, asserting the asset set, each archive's internal layout, the manifest, and the failure paths: every missing input must fail with **no** assets written, since `files: release-assets/*` would publish a partial set verbatim. + + **Platform coverage.** macOS is arm64 only — Intel Macs have no asset ([#316](https://github.com/Offline-Protocol/offline-protocol-sdk/issues/316)). The Linux libraries are built on `ubuntu-latest` and carry that image's glibc floor, which each archive's `README.md` states. + + **Python wheels are deliberately not attached.** `python-package` builds four of them, but its `matrix.plat_name` is referenced nowhere and the step captioned "Re-tag the wheel with the correct platform" only prints filenames — and with no ext modules setuptools emits `offline_protocol_sdk--py3-none-any.whl` for every platform. All four therefore share one filename while carrying different native libraries: attaching them would collide on upload, and any that landed would be a wheel `pip` installs happily on the wrong platform. Correcting the tagging is tracked in [#315](https://github.com/Offline-Protocol/offline-protocol-sdk/issues/315). + +### Fixed + +- **An inbound message that fails to decrypt is no longer acknowledged as delivered.** The receiver dropped it and sent a delivery ACK anyway, so the sender marked it delivered and stopped retrying: silent loss behind an ACK that claimed the opposite. This was already fixed for one failure class — an epoch desync — in the crypto-failure recovery work; every *other* decrypt failure (an AEAD/authentication failure, a discarded past ratchet generation, a malformed frame) kept the old drop-and-ACK, on the three ACK sites that class reaches: an inbound DM, an inbound media chunk, and a queued message that fails when the pending-decryption queue drains. + + The boundary was drawn one class too narrow. The same release added the sender-side re-seal that makes exactly these failures recoverable — every resend of an encrypted DM is re-sealed against the peer's current session, so it carries a live ratchet generation rather than replaying bytes that already failed. Withholding the ACK is what lets that resend happen. A message that genuinely cannot be delivered now settles as a `MessageFailed` once the sender's retry budget lapses, instead of being reported delivered. + + Media has no sender-side re-seal (chunks are re-encoded, not replayed), so an undecryptable chunk recovers the way an interrupted transfer already does: the withheld ACK drives the media outbox to surface `MediaResendRequired`, and the app re-supplies the bytes. + + **What did not change is the re-key.** Only a proven epoch mismatch tears down and rebuilds the session. These failures withhold the ACK but never re-key — turning every malformed frame into a session teardown would be an unbounded churn vector, which is why the two classifications exist separately. Both remain under the existing `encryption.cryptoRecoveryEnabled` kill switch (default on); setting it to `false` restores the legacy drop-and-ACK for both. + + **App teams: `messageDecryptionFailed` has changed meaning.** It is now advisory rather than terminal, and fires once per failed *attempt* rather than once per message — bounded by the sender's ACK retry budget. If your UI settles a message on it (marks it lost, drops it from a list), switch to `messageFailed`, or `fileReceiveFailed` for media. The telemetry-side `decryption_failed` record was already rate-limited per peer and failure kind and is unaffected. + +- **A frame that never reaches MLS is no longer acknowledged as delivered either.** The change above covers frames that reached the MLS layer and failed to decrypt. A frame that failed one layer *earlier* — an `__MLS_ENC__` payload in no recognised envelope form, or a media envelope whose encoding does not decode past its magic byte — never reached MLS at all, and kept the old drop-and-ACK. These were the last such arms of the family, and the case is the same one: in-transit corruption a few bytes earlier in the encoding rather than in the ciphertext, acknowledged as delivered while the sender's resend would have parsed and delivered. + + Both now withhold the ACK, under the same `encryption.cryptoRecoveryEnabled` kill switch (default on; `false` restores the legacy drop-and-ACK). Neither queues the frame — an unparseable frame can never become parseable, so a queued copy could never drain — which makes the sender's resend the whole of the recovery path, exactly as for a spent ratchet generation. + + **What stays acknowledged is everything that fails *after* a successful decrypt**: an empty or non-UTF-8 plaintext, and a media chunk whose decrypted body does not parse. Those are terminal — the ratchet generation is spent and a resend would re-seal the same malformed plaintext, so no retry could ever deliver. + + **App teams:** a sender whose frames can never parse — an encoding bug rather than a transient corruption — now rides the full ACK retry ladder and settles as an honest `messageFailed`, where before it was told "delivered" for a frame the receiver dropped. `messageDecryptionFailed` fires for this class too, with the same advisory, once-per-attempt meaning described above. + +- **A media chunk that fails its identity binding is now answered with silence, matching text.** Two conditions are refusals rather than delivery failures: an envelope naming a session slot that is not the claimed sender's, and an MLS credential authenticating someone other than the wire sender. The text path has never acknowledged either, because an ACK confirms to whoever injected the frame that the target is online and processing their traffic. Media acknowledged both — so an injector who sent the same forged frame down both paths learned from the media ACK precisely what the text silence protects. + + Media now behaves identically: no ACK, and the message id is left unmarked so a replay re-enters the gate rather than hitting the duplicate re-ACK path. This is deliberately **not** gated on `encryption.cryptoRecoveryEnabled` — it concerns what the receiver reveals, not whether a message can be recovered, and the text equivalent has always been unconditional. + + The existing `MEDIA_SENDER_GROUP_MISMATCH` security warning still fires for the slot case. One consequence of leaving the id unmarked: a repeated injection of the *identical* frame now re-emits the warning on each attempt, where dedup previously suppressed it after the first. The rate is the signal, as with `SESSION_REKEY_TRIGGERED`. + +## [0.20.0] — 2026-08-07 + +> **One breaking change, and it is React Native on iOS only.** iOS autolinking +> now works, so a manual `pod 'MeshSdk'` line left in your `Podfile` fails +> `pod install`. It is a one-line deletion — +> [`docs/UPGRADING.md` §12.1](../UPGRADING.md#121-react-native-ios-delete-your-manual-pod-meshsdk-line-v0200) +> lists every leftover to remove — and the same change is what makes iOS +> **simulator** builds link at all. +> +> Two further things can surprise without breaking a build. The exported +> `ProtocolState` enum's *values* are now the strings `getState()` has always +> resolved (`"Running"`, not `1`), so a comparison that silently never matched +> now does. And turning the Nostr transport on now publishes MLS key packages +> to relays unprompted — sealed, and opt-out via `nostr.coldContactEnabled` — +> which is the first thing that transport emits without traffic to carry. + +### Added + +- **Nostr supports cold first contact: key packages are published as fetchable relay records, and a peer's is resolved before sealing to them.** Until now, reaching someone over Nostr required having already exchanged key packages over some other transport — key packages are *pushed*, so the very first frame of a new conversation had no per-install key to seal to and fell back to the recipient's publicly computable key. That fallback is bulk-collection resistance and nothing more: anyone who guesses the username holds the matching private half. + + Each install now publishes `NOSTR_KEY_PACKAGE_SLOTS` (5) single-use MLS key packages as NIP-33 addressable events (kind `30443`), tagged `[["d", ], ["p", ]]` and signed by the install's real Nostr key — which is how a fetcher learns the key to seal to. A send to a peer whose key we lack queues a query on their routing tag; the send itself still goes out on the bootstrap leg, because blocking it on a relay round-trip would turn a metadata upgrade into latency and the round-trip may have nothing to return. The answer upgrades the next frame. + + **The published record's content is sealed, and that is not belt-and-braces.** An MLS key package carries its owner's username twice — in the payload field, and unremovably in the leaf credential, since this SDK uses basic credentials holding the raw user id. Published in the clear, a filter naming only the kind, with no tag and no author, would have returned *a directory of every username on the relay* — handing over exactly the preimages the `SHA-256(userId)` routing tag exists to withhold, and giving back more than the gift-wrap work took away. The content is therefore NIP-44-sealed to our own computable key: fetching the record at all requires the routing tag, which requires the username, which is the same knowledge needed to open it, so the intended audience is unaffected while a scraper sees an opaque blob. The computable keypair thus keeps exactly one encryption use — a self-published record whose only reader already knows whose it is — and still backs no authentication decision anywhere. + + **Five slots, because an MLS key package is single-use.** Its init key is consumed by the first peer who uses it, so one replaceable record would mean a stranger who fetches it after it was spent builds a Welcome that can never be processed. Each slot holds its own package under its own stable random `d` tag; the process tick replaces any whose package has been consumed or expired and republishes it under the same slot id (addressable events replace, so republication is idempotent — and publication timestamps are deliberately *not* jittered into the past like a gift wrap's, since a relay drops an addressable event older than the one it holds). The count covers the *sequential* gap between refreshes; it does not absorb concurrent cold contacts, since nothing distributes simultaneous fetchers across slots. Consumption is local — an init key leaves provider storage only when this node processes a Welcome built against it — so a stranger can drive it only by actually establishing sessions, and each burnt slot refills on the next tick. A refill that fails emits the new `NOSTR_KEY_PACKAGE_SLOT_EXHAUSTED` security warning rather than leaving a stale record standing, which is the one failure this design must not absorb quietly — reported once per pass and suppressed for 5 minutes, since the causes (MLS or storage errors) persist and one per slot per refresh would bury the signal in its own repetition. A record that was built but never reached a relay (rejected, timed out, or in flight when the connection dropped) is reported back by the transport so the next tick republishes it, rather than leaving the slot marked published for the life of the process. The first such failure retries promptly, but consecutive ones back off — doubling from the 60s refresh interval up to 30 minutes — so a relay that rejects the kind outright, or rate-limits a fresh pubkey bursting the whole slot set, converges instead of being retried five times a minute forever. **Publication outcomes are deliberately kept out of the transport's delivery metrics**: DORS scores reliability on `success / (success + failure)` over lifetime counters that never decay, and an idle install publishes far more than it sends, so counting them would score the transport on something other than its ability to carry messages — a rejecting relay would drive the ratio toward zero and make DORS deprioritise Nostr for traffic that delivers fine. `MlsManager::get_or_create_key_package` (the push path) now skips reserved packages, so a pushed-to peer and a stranger who fetched a record can never race for the same init key. + + **What it costs, stated plainly.** This is the first thing the transport emits unprompted. A small set of records sits at the install's routing tag and refreshes as slots are consumed, whether or not a message is ever sent — so the *existence* of a record at a tag, and the timing of its refreshes, are visible to every relay published to, a liveness signal the transport did not previously emit. Sealing hides who the record belongs to, not that it is there. `transports.nostr.coldContactEnabled` (RN) / `nostr_cold_contact_enabled` (UniFFI, core `TransportConfig`), default **on**, turns both halves off and keeps the transport silent until it has traffic, at the price of cold contact. Nostr itself remains off by default, so nothing publishes unless an app deliberately enables the transport. + + **What one query will accept is bounded.** The query is broadcast, so every connected relay answers under the same subscription id and the same record arrives once per relay — each one otherwise re-running the key-package handler's two durable secure-storage writes to import a package already held. Records are now taken once per query by event id, and a query stops accepting events at a ceiling of 64 regardless: a relay is free to ignore the REQ's `limit` and stream indefinitely, so the bound has to be ours. Exceeding either only costs the metadata upgrade, which falls back to the bootstrap leg. A resolution refused because the pending-lookup queue was full also no longer burns the 5-minute retry interval for a peer that was never actually looked up. + + **Bridge change:** a new four-call contract drives the query loop — `nostrGetNextQuery()`, `nostrQueryEventReceived(queryId, eventJson)`, `nostrQueryCompleted(queryId)`. Poll it on the same timer that drains outgoing messages, broadcast the REQ to every connected relay using `queryId` verbatim as the subscription id, route matching events to `nostrQueryEventReceived` rather than through the message path (they are not messages — their content is sealed to a different key), and close on the first EOSE. Release every in-flight query when all relays drop: one issued just before a disconnect never sees an EOSE, so without that the bridge holds its subscription id for the life of the process and the transport holds the entry until its own cap evicts something — possibly a live query. Both bundled bridges implement it. A bridge that does not still publishes normally, since records ride the existing `nostrGetNextMessage` path whose `event_json` is opaque; it simply never resolves, and sends fall back to the bootstrap leg as before. + + A peer's key is still taken **only** from the Ed25519-signed `nostr_pubkey` inside the record's payload, never from the event's own `pubkey` field — that field is self-attesting, signed by the very key it names and bound to no user id. A record planted at a queried peer's tag by somebody else therefore registers under *that* signer's identity, not the peer's; the resolution channel additionally accepts nothing but key packages, so a squatter cannot deliver other control frames through a path that skipped the receive loop's dedup and block checks. The event's BIP-340 signature is deliberately not verified: it would authenticate nothing that anything relies on. + + **Two things squatting a tag does buy, both bounded.** Foreign records can crowd real ones out of the query's `limit`, which costs the metadata upgrade and nothing else. And since every published record is openable by anyone who knows the username — that is the design — a squatter can unseal one of a peer's *spent* records, re-seal the untouched, genuinely signed payload under their own author key, and stand it back up with a fresh `created_at`. Nothing detects that: the inner signature is real, and no freshness binding ties a record to the live slot. A resolver then imports a genuine-but-consumed key package and builds a Welcome the peer cannot process — worse than crowding, since it commits to a dead session rather than staying on the working bootstrap leg. It self-heals rather than stranding the pair: importing any key package pushes ours back under `auto_key_exchange`, and the peer establishes from their side against a package that is actually live, so the cost is delivery delayed by one exchange (the same bounded class as the already-accepted `key_package_data` substitution). Closing it outright needs records to carry slot-bound freshness, which is future work. + + Two residuals from the sealed-envelope work close as a side effect. The peer-key map's reset-at-capacity downgrade is now bounded for peers who publish — a forgotten key is re-resolved on the next send rather than persisting until restart or re-exchange — and the config→transport propagation of both Nostr kill switches is now pinned by a test, having previously had one call site and no coverage. A *cached* key that goes stale (a peer wipes storage and their key rotates) is still not re-resolved, since only a missing key triggers a lookup; that path continues to heal through contact over any other transport. + +- **Android can now bring the mesh back after a process kill, by waking JavaScript — opt-in.** (#307) The keep-alive service is `START_STICKY`, so the system hands it back after a kill, but with no host in the new process it stops itself (#294) and the device stays off the mesh until the user next opens the app. Senders hold, retry, park and push for up to seven days, so this was never message loss — but for an always-on app it is a real availability hole for the whole window. + + **Nothing native re-creates the protocol, and the #297 refusal that says it must not is unchanged.** This wakes *JavaScript* instead, which is the whole point: a receiver exists before a protocol does, rather than after. On a sticky restart with no live host, an opted-in app has React Native started through a Headless JS task; the app's own task then runs the ordinary `new OfflineProtocol(config)` → `on(...)` → `start()` with its own config and credentials. Nothing is persisted natively to make this work — no `ProtocolConfig`, and in particular no relay `authToken` — so there is no new storage and nothing for `wipePersistedState` to learn about. + + Opting in takes two halves, both the app's: `` in the manifest, and `registerMeshWakeTask(...)` at module scope in `index.js`. The manifest is what the restarted service can read before any JavaScript exists; the optional `MESH_WAKE_TIMEOUT_SECONDS` (default 60, clamped 10–300) bounds the wake. **The reason it is opt-in rather than a default is the obligation it carries**: the core never persists inbound content and the receive path ACKs before it emits, so a task that does not durably store `message_received` *before* calling `start()` walks into exactly the loss #297 refused to build. The task must also be idempotent — it is allowed to run in the foreground, because the alternative is React Native crashing the process when the user opens the app mid-wake — and must re-issue whatever `start()` does not restore (Wi‑Fi Direct always; the relay when its endpoint arrives via `enableTransport`). + + **A wake that does not land stops the service.** The same stop-callback slot that gates the restart is what a successful `start()` re-registers, so no new state tracks the wake: a watchdog armed alongside it re-reads that slot after the task's budget and brings the keep-alive down if nothing arrived — no task registered, JavaScript failed to boot, the task threw, or the app looked at its own state and declined. Without it the "Mesh Active" notification over a dead protocol that #294 removed would come straight back, since this branch now returns `START_STICKY` on a bet rather than an observation. The wake is also refused outright when the foreground promotion did not take, which fails closed to the previous behaviour: the process is then not foreground, so starting the wake service would be an illegal background service start, and a service kept up without a notification is the empty-process squat #294 removed. + + Scope, stated plainly: it rides the service restart, so it covers kills the system chooses to recover from (memory pressure, the common case) and not a force-stop, an OEM swipe-away treated as one, or a reboot — those still need the app opened, and reconciling with `getState()` on foreground (§6.1) remains the belt-and-braces every integrator should have. Requires React Native **0.76.5+** under the New Architecture; on 0.84/0.85 a core bug fixed in 0.86 can leave the wake service running after the task finishes, which the timeout bounds. One correction to the record while here: #294's rationale said a sticky restart is not an exemption from the API 31+ ban on foreground-service starts from the background. It is — `Service.START_STICKY`'s reference states the restriction "doesn't impact restarts of a sticky foreground service" — so the promotion normally succeeds; the defensive handling stays for the cases that can still fail it, such as a `connectedDevice` promotion after Nearby-Devices permissions were revoked. + +- **`ContentType::Poll` — a structured poll content type.** (#281) Propagated everywhere the other types live: the core enum and its string form (`"poll"`), the binary wire codec (`8`), the UniFFI enum and UDL, and every binding surface (Python, Kotlin, Swift, TypeScript, and both React Native native modules). A poll carries its app-defined body in `content` and is treated like text rather than media — `is_media()` is `false` — and the sealed `__RICH_V1__` content-type hint carries it through encrypted DMs and group messages like any other rendering hint. + +### Changed + +- **An unknown content type no longer fails JSON decode — it degrades to `File`.** `ContentType` had three decode paths with two semantics: `ContentType::parse` and the binary codec's `content_type_from_u8` fall back to `File` on unrecognised values, but the derived serde `Deserialize` failed closed. On a mixed-version fleet that asymmetry would have made every future variant addition a breaking wire change on the JSON paths: the internet relay (always JSON) and pre-binary-wire mesh peers would reject the entire `Message` — silent frame drop, no ACK, the full retry ladder into `MessageFailed` — and a sealed `__RICH_V1__` body naming the new type would fail `parse_sealed` wholesale, surfacing the raw JSON blob as message text. JSON deserialization now routes through `parse`, so all three paths share the `File` fallback and builds from this release forward decode any future content type additively. Non-string values still error — that is malformed data, not additive evolution. `Poll` itself (added in #281, before this change) predates any deployed fleet, so nothing in the field is affected; this closes the class before the first fielded build. + + `Serialize` is now hand-written too, over the same `ContentType::as_wire_str` source of truth. JSON output is byte-identical to the derived `snake_case` impl, but the derived impl emitted the variant *index* in non-self-describing formats (postcard, bincode) — which the new string-based `Deserialize` would have read back as a zero-length string and degraded to `File`, silently turning `Text` into `File` with no error. Nothing in-tree hit that (the binary wire codec maps content types to `u8` by hand), but this type is published, so both directions now speak strings in every serde format. Apps should read `File` as "a file *or* a type this build doesn't know": a degraded value keeps `is_media()` `true` while carrying no `MediaMetadata` and no transfer, so it belongs in a generic unsupported-content placeholder rather than a broken attachment. + +- **React Native: `ProtocolState` was a numeric enum, but `getState()` resolves a string.** Both native modules build the state *name* (`"Stopped"` / `"Running"` / `"Paused"`) and `getState()` passes it through unmapped, while the exported enum declared `Stopped = 0, Running = 1, Paused = 2`. Every way of writing the comparison was wrong in one direction: `state === ProtocolState.Running` compared `"Running"` against `1` and was never true — silently reporting a running protocol as stopped — while `state === 'Running'` behaved correctly at runtime but failed `tsc` with TS2367 (`'ProtocolState' and 'string' have no overlap`). The members are now string-valued, so both spellings agree with the wire, and a drift guard (`react_native_protocol_state_members_match_the_wire`) pins them to the variant names the native modules resolve — the values are not part of the UDL, so a bindings regen would never have caught this. + + **This changes the exported enum's *values*** from `0`/`1`/`2` to `"Stopped"`/`"Running"`/`"Paused"`. No code path ever produced the numbers, so nothing read back from `getState()` is affected and no source change is needed; the one hazard is a consumer that *persisted* a `ProtocolState` it wrote itself (AsyncStorage, redux-persist) and compares it after upgrading — that value comes back as a number and now matches nothing. Treat an unrecognised persisted value as `Stopped`. One knock-on for completeness: TypeScript gives string enums no reverse mapping, so `ProtocolState[0]` no longer resolves to `"Stopped"` — nothing could have depended on it, since the numbers were never produced, but the lookup is now `undefined` rather than wrong. + + iOS's `getState()` also resolved `"Unknown"` on its `@unknown default` branch, which is not a member of the enum at all — so the declared `Promise` was unsound and the two modules disagreed (Android maps the same case to `"Stopped"`). Unreachable with today's three variants, but iOS now falls back to `"Stopped"` like Android, which is also the safe direction for the reconcile pattern the integration guide documents. + +- **Android: a `START_STICKY` restart no longer pretends it might bring the mesh back — `MeshForegroundService.onServiceRestarted` is removed.** (#291) The hook was declared and invoked but assigned by nothing outside tests: the notify-host half of a protocol whose host half was never written. Removing it settles what that path is *for*. A re-delivery into a process with no live mesh host stops the service and returns `START_NOT_STICKY` (#294, which also gave the stop-callback slot its second meaning — registered as mesh comes up, surrendered as it goes down — so the gate reads mesh liveness rather than module liveness); a re-delivery that finds a host only re-promotes the notification. Neither branch has anything left for a callback to do. + + **Rebuilding the protocol there is now refused rather than deferred**, which is the part worth reading if you were waiting on it. The obvious design — persist the `ProtocolConfig` natively and re-create the protocol without JavaScript — is not a cheaper route to the same place; it destroys messages and tells their senders they arrived. The receive path sends the delivery ACK *before* it emits `message_received`; that ACK makes the sender drop its outbox entry and retire the retry ladder; the event is then discarded because `canEmitToJs()` finds no React instance. The core persists outbound and session state and never inbound content, so the message exists only in that dropped event — and the MLS ratchet generation it consumed means a resend cannot reconstruct it either. Staying down keeps the failure recoverable instead: the sender's outbox holds for up to seven days, retries, parks and pushes, and delivers once the device is genuinely running again. Waking JavaScript *first* — React Native's Headless JS, an app-supplied opt-in (the config, including relay credentials, is the app's and must stay there), and a watchdog for the wake that never lands — is the only sound direction, and was deliberately not built here; it landed separately under #307, above, in exactly that shape. + + `onServiceRestarted` was public on the service companion, so this is source-breaking for anything that assigned it; nothing in the SDK, either bundled bridge, or the example apps did. Runtime behaviour is otherwise unchanged. Both integration guides now carry the contract: reconcile mesh state at launch rather than assuming it survived the process, never reuse a `destroy()`ed `OfflineProtocol` (its event subscriptions are created only in the constructor), and — on the native Kotlin path, where you own the `EventCallback` — never `start()` ahead of a consumer that durably keeps `message_received`. The React Native guide also now states what that relaunch does *not* restore: `start()` re-enables only the transports declared in the constructor config, so Wi‑Fi Direct — which it never starts — and any relay whose `serverAddress`/`authToken` arrives through `enableTransport('internet', …)` have to be re-issued by the app. `Running` was never a claim that a given transport is attached; `getActiveTransports()` is the read that answers that. + +### Fixed + +- **React Native on iOS could not be installed from npm by following the documentation, and simulator builds could not link at all.** Two packaging defects that compounded each other, fixed together. **This is a breaking Podfile change — see [`docs/UPGRADING.md` §12.1](../UPGRADING.md).** + + **iOS autolinking was disabled**, so `npm install` + `pod install` — the entire published instruction — installed no native module, and the app failed at runtime with the SDK's own linking error, whose remedy read "run `pod install`": the step that had just been run. Every consumer had to discover independently that a manual `pod 'MeshSdk', :path => '../node_modules/@offline-protocol/mesh-sdk/ios'` line was required, and no document mentioned it — one guide affirmatively stated that `use_native_modules!` linked the SDK automatically. The cause was mundane: `MeshSdk.podspec` lived in the package's `ios/` directory, and React Native resolves a dependency's podspec by globbing `*.podspec` in the package **root**, without recursing, so autolinking could never see it. Setting `podspecPath` in `react-native.config.js` does not help — the current CLI ignores that field when deriving iOS dependency config. The podspec therefore moves to the package root, `react-native.config.js` no longer disables iOS, and a plain `pod install` links the SDK. The publish gate now refuses to publish if the root podspec is missing, since its absence degrades silently to "autolinking skipped this dependency". + + **The native binary shipped as two loose static archives** (`liboffline_protocol_uniffi_device.a`, `..._sim.a`) declared via `vendored_libraries = "libs/*.a"`. That glob makes CocoaPods emit an unconditional `-l` for **both** archives into the *app* target's link line, so a simulator build tried to link the device archive and failed on the architecture mismatch. The podspec's own sdk-conditional `OTHER_LDFLAGS` could not prevent it: they sat in `pod_target_xcconfig`, and the flags that decide the final link belong to the app target, which a podspec has no way to reach. Nor could a single fat archive fix it, since device and simulator `arm64` cannot coexist in one `lipo` output — the reason there were two files. The binary now ships as `offline_protocol_uniffi.xcframework` with `ios-arm64` and `ios-arm64_x86_64-simulator` slices, declared through `vendored_frameworks`; CocoaPods copies the slice matching the build SDK and points the search paths at it, and no sdk-conditional linker flag exists anywhere. Both slices deliberately carry the same archive basename, because CocoaPods derives one `-l` for the whole bundle and applies it to whichever slice it copied. **Simulator builds work; anyone who concluded this SDK was device-only was hitting this bug** — both slices have always shipped, and the publish gate has always refused to publish without the simulator one. + + The generated UniFFI header and module map are untouched: they stay in `ios/Generated/` and still reach Swift through `SWIFT_INCLUDE_PATHS`/`HEADER_SEARCH_PATHS`, so the XCFramework carries no headers and the Swift import path is unchanged. `DEFINES_MODULE` moves into the podspec, which removes the last reason a consumer needed `:modular_headers => true` or a `post_install` hook for this pod. No JS/TS API, wire-format, or Android change; the npm package is marginally smaller. Consumers must delete any manual `pod 'MeshSdk'` line and any `post_install` hook that set `OTHER_LDFLAGS`/`LIBRARY_SEARCH_PATHS`/`SWIFT_INCLUDE_PATHS` for it — a surviving line pointing into the package's `ios/` directory fails `pod install` immediately with "no podspec found", which is loud by design rather than a silent misbuild. + +- **The key-package push path handed the same MLS init key to every peer.** (#306) `get_or_create_key_package` returned the first stored package to every caller — the auto key exchange, group-invite backfills, the desync `session_reset` advertisement — and only minted a new one once somebody's Welcome had consumed it. One init key was therefore advertised to every peer a device met until it was spent. That is the LastResort-style reuse RFC 9420 §16.8 permits only as a denial-of-service fallback, and which the Least Authority MDK audit flagged for Marmot as enabling unsolicited joins, cross-group linkage, and resource consumption; the published-record path added in #289 already obeyed single-use, and the push path did not. + + The push path now keeps **one package per peer**. A package is claimed by the first peer it is pushed to and only ever re-handed to that peer, so a repeat push costs no new key material while two peers never share an init key. The assignment lives on the package's own stored record rather than in a separate map, so it survives restarts for free and cannot disagree with the pool it describes; a package already in storage when this ships is unclaimed and gets claimed rather than stranded. Consumption is what rotates a peer's key — a Welcome built against the package removes its init key from provider storage, which the loader already reports, and the next push to that peer mints a fresh one. That matches the RFC's "rotate as soon as possible after being used" without any new bookkeeping. + + **This also fixes a delivery bug, not only a forward-secrecy one.** Two peers pushed the same package would both build a Welcome against it; the first to be processed consumed the init key and the second became permanently unprocessable, surfacing as a `SecureSessionFailed` with nothing to re-drive the exchange while the peer's retransmit ladder retried the same doomed Welcome. + + The pool is bounded at 64 live unconsumed packages. It is normally self-limiting — its size tracks peers with an outstanding unused advertisement — but a device meeting an unbounded stream of peers that never establish sessions would otherwise grow it without limit. At the ceiling the push path shares a package rather than refusing to advertise or evicting one a peer may still be about to use, both of which would cost session establishment; peers that already hold their own package keep it. The ceiling gates only the mint, since that is the only step that grows the pool — a full pool still holding an unclaimed package hands that package out rather than sharing, because claiming relabels a package that already exists and degrading to a shared init key to stay under a bound the claim never approaches would weaken forward secrecy for nothing. `ensure_min_key_packages` is capped at the same ceiling for the same reason: beyond it, it would mint key material the push path can never hand out while holding the pool at capacity. Because that is the one condition under which the old reuse is back, it emits the new `PUSH_KEY_PACKAGE_POOL_EXHAUSTED` security warning, suppressed to once per 5 minutes since the condition persists until packages are consumed or expire. The per-push pool scan is cheap: each package's OpenMLS provider hash reference is cached on its stored record at mint time (and backfilled on first load for records that predate the cache), so checking a package's usability is a storage lookup rather than a TLS parse plus a signature validation per stored package per push. + + **Expiry now destroys key material.** Deleting a key package's bundle record — all this crate did, on expiry and on reclaiming an unpublished publication package — never touched the private init key OpenMLS holds, since the only other thing that removes one is a peer actually using it. Every package a device ever minted therefore kept its init key for the life of the install, which is the property the 30-day lifetime was supposed to bound. Expiry and destruction are now two moments: an expired package stops being advertised immediately, but its init key is kept for a 7-day grace window so a Welcome built against it just before expiry is still processable, and only then is the material genuinely deleted. Legacy raw records — stored as bare key package bytes by builds predating the bundle, and still upgraded in place on load — are purged the same way rather than having their record deleted out from under resident key material. + + `mls_get_or_create_key_package` (the peer-less FFI entry point) is unchanged except that it now skips packages a peer has claimed, so it cannot hand out a key another peer is expected to use. Nothing on the wire, in storage formats, or in the UDL changes. + +- **React Native: two security warning codes were missing from the `SecurityWarningCode` union.** `NOSTR_KEY_PACKAGE_SLOT_EXHAUSTED` (added in #289) had drifted, and apps switching on `reason_code` saw it fall into whatever default their `switch` had. Both it and the new `PUSH_KEY_PACKAGE_POOL_EXHAUSTED` are now declared, and a drift guard pins the union to the Rust enum the way the existing one pins event tags — these codes cross UniFFI as opaque JSON, so nothing else fails when `types.ts` lags. + +- **Android: BLE advertising killed by a terminal `onStartFailure` was never retried, leaving the device permanently undiscoverable.** Documented as a known limit when the scan-side recovery landed (#293), now closed. The failure is narrow to describe and easy to miss in the field: the adapter is on and scanning is healthy, so none of the BLE stack's self-healing paths ever run — the facade's recovery runnable repairs advertising only inside an adapter-off episode, and the advertiser's own `refresh()` only when the published identity changes. `onStartFailure` cleared `isAdvertising` and the in-flight gate and scheduled nothing, so advertising stayed dead until an unrelated peer eviction happened to refresh it, or the app restarted. + + Nothing looked wrong from either side of the SDK. The transport still reported `RUNNING` and the central role kept working — the device discovered peers and connected out normally — so what an integrator sees is not an error but an asymmetry: this device can reach its neighbours while they cannot find it. `ADVERTISE_FAILED_TOO_MANY_ADVERTISERS` reaches it whenever other apps on the device hold every hardware advertising slot, and `ADVERTISE_FAILED_INTERNAL_ERROR` on any stack having a bad moment, so neither is exotic. + + `LeAdvertiser` now arms a capped retry (10s, doubling to 30s) through the same `BleRecoveryScheduler` the scan path uses — but only for failures that can actually clear. `TOO_MANY_ADVERTISERS` and `INTERNAL_ERROR` retry, as do unrecognised codes, on the grounds that an unknown value is more likely a vendor-specific transient than a new permanent class and the ladder's cap bounds the cost of guessing wrong. The three terminal codes do not: `ALREADY_STARTED` means an advertisement is running and would earn itself back forever, `DATA_TOO_LARGE` means the SDK's own fixed payload is malformed and will not differ next time, and `FEATURE_UNSUPPORTED` is hardware truth — the same reading the scan path already applies to its own `SCAN_FAILED_FEATURE_UNSUPPORTED`. Which way each code went is visible to apps as a `willRetry` field on the existing failure diagnostic rather than a new emission. + + A deliberate teardown stays authoritative over an armed retry: the cancel sits in `stop()`, which every path funnels through — `pause`, `shutdown`, `refresh`, the adapter-off repair and the BLE reset — and specifically *above* its early return, since a retry is armed exactly when a failure has already cleared the callback reference that return keys on. Nothing is reported to the transport's availability signal even for the permanent codes, because a device that cannot advertise but can still scan is degraded rather than unusable, and saying otherwise would pull a working transport out of DORS. Android bindings only; no FFI, event, wire, or config surface changes. + +- **iOS: bridge emits were gated on the listener flag alone, with no counterpart to Android's live-instance precondition.** (#298) Android refuses an emit unless `listenerCount > 0 && hasActiveReactInstance()`; iOS checked only the first half, and `RCTEventEmitter.sendEvent(withName:body:)` returns `Void`, so the module could not tell a handed-over event from one dropped downstream. React Native re-checks *both* conditions itself one frame later and, when either fails, drops the event with nothing but a log warning — so a `true` from the listener flag was never evidence of delivery, and nothing reported the failure back. + + The missing read is `callableJSModules`, the handle RN hands the event to and the very thing its own gate tests. It is `weak`, and the object is owned strongly by the per-React-instance module decorator, so tearing an instance down nils it — the same event Android's `hasActiveReactInstance()` observes, reached by the only route iOS offers. It never appeared in a header a subclass author reads, which is why it went unadopted for so long: it is an `@optional` requirement of `RCTBridgeModule` that `RCTEventEmitter` satisfies with a private `@synthesize`, absent from `RCTEventEmitter.h`. `sendEventToJS` now gates on both conditions and returns `Bool`, mirroring Android's `sendEvent`. + + **Two silent ways to write this wrong are pinned by a test rather than left to review**, because nothing in CI compiles the iOS module. Read through the protocol existential, `callableJSModules` comes back double-optional — the outer level meaning "does this class implement the requirement", which is always true for `RCTEventEmitter` — so the natural `!= nil` typechecks, reads correctly, and gates on nothing; the value only surfaces when read directly off `self`. And declaring the property on the subclass, which RN's own header comment recommends for Swift modules and which is right for a direct adopter, shadows the parent's storage and silences *every* emit the module makes. + + **This narrows the window; it does not close it**, and nothing is being re-plumbed to depend on it. The instance can still die between our check and RN's, an invalidated legacy bridge drops below both, and the listener flag still reads stale-true across a JS-side `subscription.remove()` whose native `removeListeners` is still in flight. The one genuinely one-shot event, `internet_session_superseded`, therefore keeps re-deriving itself from the transport latch on foreground (#296) rather than building on the new return value — a distinction that matters precisely because it must survive the drops this precondition cannot see. The delivery contract in `docs/react-native-integration.md` §6.1 is unchanged. + +- **iOS: `internet_session_superseded` could be dropped, leaving the app showing a relay connection that was never coming back.** (#296) The event is the one strictly *one-shot* signal both bridges emit: on relay displacement `InternetManager` latches the transport stopped and every reconnect route refuses — auto-reconnect, `scheduleReconnect`, `connect()`, and `forceReconnect()` including the automatic foreground heal — until an explicit `start()` clears the latch. Nothing restates it. iOS emitted it best-effort behind a listener check and, if that check said no, the report was simply gone. #295 made that check *correct* (it was an unsynchronized `Bool` read from the socket callback) but a correct read is not delivery. + + **The fix is to re-derive the report from state rather than to hold a copy of the emit**, which is where this diverges from the Android half of #295 and does so deliberately. A held copy can only be taken on the branch where the module *notices* the failure, and on iOS that branch turns out to be nearly unreachable for this event — the supersede emitter does not exist until `create()`, which the SDK calls after subscribing in its constructor, so "nothing subscribed yet" cannot contain a supersede, and the remaining shut-gate windows are a `destroy()` in progress and an invalidated instance, where there is nothing left to redeliver to. Meanwhile the losses that *do* happen are ones no buffer can see: React Native re-checks its own listener count and its JS-module handle inside `sendEvent` and drops with only a warning, an invalidated bridge drops silently, and a JS-side `subscription.remove()` deregisters synchronously while the native `removeListeners` is still in flight — so the flag reads true and the emit lands nowhere. `SupersededLatchPolicy` already holds the authoritative fact and now retains the displacement reason alongside it, so the bridge re-emits the same event from that state on every app foreground for as long as the transport stays superseded. Every way the emit can be lost becomes "healed on the next foreground", including the ones the module never learns about. + + **`isInternetSuperseded()` is new on both platforms**, and it is the read that answers a question `isInternetReady()` structurally cannot: a `false` there from an ordinary disconnect — which reconnects itself within seconds — and a `false` from a displacement — which will not reconnect on its own, ever — are indistinguishable. It also covers what no in-memory delivery reaches: an app that subscribed after the fact, a JS reload, a process restart. Reconciling against it on foreground is now the recommended shape, and `docs/react-native-integration.md` §6.1 is rewritten around it. (0.16.2 claimed `getMetrics()` "exposes `is_superseded`" — true of the class, but `getMetrics()` has never had a bridge caller on either platform, so the flag was not in fact reachable from JS until now.) + + **Delivery is therefore at-least-once and the event is state, not an edge** — Android may redeliver a held copy, iOS restates until the transport is re-enabled — so handlers must be idempotent. Setting a "connected elsewhere" flag is fine; pushing a screen per event is not. Repeats stop the moment `enableTransport('internet', …)` clears the latch, on both platforms and with no bookkeeping, because after it there is no longer anything to restate. **No queue hop and no subscribe-time flush on iOS**, both re-derived rather than copied from Android: `sendEvent(withName:body:)` can never deliver inline (it posts to the JS thread through `RCTCallableJSModules` on both the bridge and bridgeless paths), so the RN emit path *is* the hop; and `startObserving` fires only on the 0→1 listener transition, which for this SDK happens once per module lifetime before any `InternetManager` exists. The event tag and payload now have a single definition in `SupersededLatchPolicy` on each platform — a file both CI paths compile and both unit suites pin — instead of a literal in the two bridge modules, neither of which CI compiles; a new `react_native_supersede_restatement_wiring_is_present` drift guard asserts the wiring from `cargo test`, following the `getState` guard added in #295. Bridge-only; no Rust/core wire, schema, or send-path change. + +- **Android: a mesh Stop from the notification could be torn down correctly and then reported to nobody.** `mesh_stopped_by_user` went out through the bridge's ordinary `sendEvent`, which drops the event whenever it believes no JS listener is registered. For almost every event that is harmless — they are periodic, re-derivable, or followed by another carrying the same state. This one is the *terminal* event of the mesh lifecycle: by the time it fires the transports, the scheduler, the keep-alive service and the core are already down, so nothing later restates it. Dropped, the app kept reporting an active mesh against a fully stopped protocol, indefinitely, and the window where the event matters most is the one where JS is least likely to be listening — the user reaches the notification shade precisely when the app is not in front of them. + + Three independent paths lost it, and the obvious fix only closes one. **The listener count was a plain `Int`** written on the thread React Native delivers `addListener` on and read from the `"mesh-user-stop"` thread the teardown runs on, sharing no lock and no happens-before edge — so it could legally read a stale zero while JS was fully subscribed. It is now an `AtomicInteger`, which matters more than it looks: replay-on-subscribe cannot fix that path, because a stale read happens exactly when JS is *already* subscribed and no further `addListener` will ever arrive to trigger a replay. **A detached React instance was assumed to throw**, and on the New Architecture it does not — `getJSModule` returns a proxy forwarding to `ReactHost.callFunctionOnModule`, which reports failure by rejecting a Task, so "did not throw" was never evidence of delivery on bridgeless. Emission is now gated on `hasActiveReactInstance()` as a precondition and `sendEvent` reports whether it got that far. **And the gate can simply be shut** when the event fires — a listener count that has transiently gone to zero (an app tearing down and re-creating the SDK across a logout, say), or a React instance that is not currently live. Not, as first supposed, because backgrounding removes listeners: it does not, since the SDK subscribes in its constructor and unsubscribes only in its own `destroy()`. + + That last window is what the new `StickyEventBuffer` covers: a bounded, last-wins-per-key hold for one-shot events only, redelivered on the next subscribe or foreground. It is defence in depth rather than the main fix — the two changes above carry the common cases, and one window nobody can cover is a process kill, since with no module registered `MeshForegroundService` takes its no-host fallback and the event is **never generated at all**. **The hop onto the JS queue in the flush is load-bearing** — `NativeEventEmitter.addListener` calls the native `addListener` *before* it registers the JS-side listener, so emitting synchronously from there would re-lose the event through a subtler version of the same hole; `runOnJSQueueThread` always posts and never runs inline, so the flush lands after that registration under both the bridge and the New Architecture. Entries are cleared once handed over rather than held until JS confirms, because no confirmation exists — a redelivered event is idempotent for an app that reconciles against actual state, whereas one never cleared would re-fire on every subscribe for the life of the process. An event that goes out directly also drops any copy an earlier failed attempt left held, so stale news cannot redeliver *behind* the event that superseded it — and an emit that throws is treated as a failed attempt rather than a delivery, since the module builds a JNI-backed payload outside its own `catch` and an `Error` escapes it regardless. + + **Redelivering a stale one-shot event would be the same bug inverted**, so held entries are stamped with a session generation, and the two lifecycle transitions are kept apart: `start()` *begins* a session (discard what the last one left, take what follows) while `destroy()` *ends* one (discard, and stop holding at all until the next `start()`). Clearing the buffer on `destroy()` is not sufficient on its own: the teardown that produces `mesh_stopped_by_user` runs on its own thread and emits only once every transport has stopped, so an app calling `destroy()` in that window would have its buffer cleared and then refilled by the emit that was already in flight — handing a terminal mesh event to whichever session subscribed next, telling it the mesh was down while it was coming up, with nothing to restate it. Both write paths (a hold arriving late, and a flush restoring entries it could not deliver) refuse anything from a generation that has ended, and a flush re-checks per entry as it goes, since `drain` removes on read and leaves the buffer nothing to refuse with once delivery is under way. The stamp alone covers only the instant between an emit's generation read and its hold, though — a notification Stop that began *before* the `destroy()` emits *after* it, since the two share the teardown lock — which is why ending a session closes the buffer rather than only bumping the generation. Nothing is lost by refusing in between: both enrolled events require a transport that `start()` is what brings up. A hold also re-triggers the flush, since a subscribe that opened the gate microseconds earlier would have found the buffer still empty and left nothing scheduled to collect it. + + `internet_session_superseded` is enrolled in the same set: `InternetManager` latches the transport stopped on relay displacement and refuses auto- and force-reconnect until an explicit `start()`, so a dropped emit left the app showing a relay connection that was never coming back. That explicit re-enable is also the one way a sticky event goes stale *inside* a live session, which the generation stamp cannot see: `enableTransport('internet')` restarts the manager and clears the supersede latch, so any held copy stops reporting an unrestated session and starts reporting one that has just been replaced — redelivered on the next subscribe or foreground it would tell an app with a live relay socket that it is connected elsewhere. The re-enable therefore drops it. Periodic events are deliberately **not** enrolled — a held `internet_status_changed` replayed minutes later reports a link state that has since changed, which is worse than the drop it replaced. The *hold* is Android-only: `mesh_stopped_by_user` has no iOS counterpart, and mirroring a buffer there would be an abstraction with one caller. The **stale-listener-flag race is fixed on both**, because that half is not Android-specific — iOS gated the same event behind a plain non-atomic `Bool`, written from `startObserving`/`stopObserving` on RCTEventEmitter's queue and read from `InternetManager`'s socket callback with no happens-before edge between them. It is now lock-guarded, mirroring Android's `AtomicInteger`. The integration guide states the remaining asymmetry rather than implying both platforms hold. + + **Known gap, stated plainly:** the buffer is in-memory and per-React-instance, so a JS reload or a process kill loses a held event, and persisting it would not help for the reason above. Apps that must be right across that window reconcile with `getState()` on foreground, which is now documented as the belt-and-braces every integrator should have regardless. Covered by `StickyEventBufferTest` (24 cases, including that a restore cannot resurrect a stale copy over a newer event that landed mid-flush, that a restored entry still redelivers *ahead* of one that landed while the flush was carrying it, that neither a late hold nor a late restore can survive the session that produced it, that a hold is refused between a session ending and the next one beginning, and that concurrent holds and drains lose and duplicate nothing) and `StickyEventDispatcherTest` (19 cases pinning the three orderings above — the generation read, the flush a hold re-runs, and the queue hop — plus both throw paths, that a throw from the retry cannot replace the emit failure that caused it, the stale-copy drop, the drop on an explicit internet re-enable, that two queued flushes deliver one held event exactly once, and a session change landing under an in-flight flush, none of which are reachable through the module, since `react-android` is `compileOnly` in the test harness). The two changes that carry the *common* cases — the `AtomicInteger` and the `hasActiveReactInstance()` precondition — have no automated coverage at all for that same reason: they live on a class that cannot be instantiated without a React runtime, so the well-tested half of this change is the defence-in-depth half. + +- **Android: the mesh keep-alive service could take the whole app down on cold start, and the notification offered no way to stop the mesh.** (#278) Android gives an app five seconds after `startForegroundService()` to reach `startForeground()`. `MeshForegroundService` called it from `onStartCommand`, which on a cold start or a resume sits behind JS-thread initialisation and main-thread work and can miss that deadline — the OS then terminates the process with a fatal `RemoteServiceException`. It was the top crash of its class on at least one common device (Redmi Note 8 Pro, Android 11) and had been shipping in one app as a `patch-package` override for months before reaching this repo. The promotion moves to `onCreate`, where nothing the app does afterwards can delay it; the later `startForeground()` calls in `onStartCommand` are idempotent re-promotes on the same instance. + + The notification also gains a **Stop** action, and what it is wired to is the part worth reading. This service is *only* a keep-alive — the module owns the protocol and the transports — so routing Stop back into the service's own `ACTION_STOP` handler would have cleared the notification and the foreground protection while BLE, Wi-Fi Direct, Nostr and the process scheduler kept running, with nothing told to JS: the user sees "mesh off" while the radios go on draining the battery until the OS gets around to reaping the process. It therefore hands off to a host callback that runs the same teardown as the JS-facing `stop()` and emits `mesh_stopped_by_user`, and the service stays up until that teardown comes back around — clearing the notification while the mesh is still running is the exact lie being fixed here. With no host registered it still drops the keep-alive, because a dead button is its own kind of bug. All three promotion sites now share one guarded helper, since only `onCreate` was wrapped and a `connectedDevice` promotion throws once Nearby-Devices permissions are revoked — so the guarded failure came straight back uncaught a few milliseconds later. + +- **Android: one throwing transport stop skipped every teardown step after it.** (#290) The shared mesh teardown stopped five transports inside a single `try`. BLE goes first, and its stop reaches `stopScan` on an adapter the user may have just switched off, which throws `IllegalStateException` back across `runOnMainSync` — so Internet, Wi-Fi Direct, Reticulum and Nostr kept running while the `finally` tail removed the notification and the Stop path told JS the mesh was off. That is precisely the state the Stop handoff exists to prevent. Every step now runs through a `TeardownSequence`: each runs regardless of the ones before it, failures are collected in order, and the first is rethrown once the rest are down, so `stop()` still rejects with the same cause. `invalidate()` uses it too — it keeps its inline, unsynchronized teardown, because routing it through the shared `@Synchronized` path would be a lock-order inversion against React Native's own teardown lock — and errors, as opposed to exceptions, propagate rather than being collected, since an `Error` means the process is already in trouble. `destroy()` now shares the path instead of carrying a third copy of the sequence, which also brings the keep-alive down: a `destroy()` with no preceding `stop()` used to leave a "Mesh Active" notification standing over a protocol that no longer existed. + + **The stop-callback slot is also cleared by identity now.** It is process-global while hosts are per-`ReactContext`, and a React reload overlaps the two: an outgoing module invalidating *after* its replacement had already registered nulled the **live** host's callback, leaving the notification's Stop button to drop the keep-alive over a running mesh. Registration and clearing go through a synchronized register/clear pair on the service, and a module clears only its own registration. + +- **Android: a `START_STICKY` restart re-posted "Mesh Active" over a protocol nothing could rebuild.** (#294) The null-intent branch re-promoted unconditionally, so a process kill left one of two bad outcomes: a keep-alive notification claiming a live mesh in a process that has none, or — on `targetSdk` 31+ — a swallowed `ForegroundServiceStartNotAllowedException` leaving an empty process squatting with no notification at all. The branch is now gated on the stop-callback slot, which the module registers immediately before starting the service and clears by identity on every teardown path, so a non-null slot means a live host that believes mesh is running. No host: stop and return `START_NOT_STICKY`, so the system does not hand the same restart straight back. What made that gate mean anything is the other half of the change — the slot was previously cleared only when the *module* died (`invalidate()`, `destroy()`), while the JS-facing `stop()` and the notification Stop action tore the mesh down without touching it, so after any ordinary start/stop cycle it went on claiming a mesh that was not there. `stop()` now surrenders the caller's registration by identity, ahead of the nothing-to-stop guard, because a host whose service is already down still has to deregister. `onCreate` still promotes unconditionally: a tap on a stale notification re-creates the service through `startForegroundService()`, and that promotion is the only thing satisfying the five-second deadline — the residual is a silent `IMPORTANCE_LOW` tray blip between `onCreate` and the stop. The wake path built in #307 (above under **Added**) is what now fills the no-host branch, rather than the native re-init that the removal of `onServiceRestarted` (under **Changed**) refuses. + +- **Android: toggling Bluetooth off mid-session crashed the host app.** (#279) `BleTransportFacade.startScanning` and `stopScanning` caught only `SecurityException` around their `BluetoothLeScanner` calls. The framework also throws `IllegalStateException("BT Adapter is not turned ON")` from both when the adapter transitions off between the facade's `isScanning` check and the scanner call — and the scan watchdog's restart path hits that the moment the user flips Bluetooth off. Four users hit it on the pre-patch build of one shipping app before the fix went out as a `patch-package` override. Both sites now handle it next to the existing `SecurityException` handler, `stopScanning` mirrors its post-stop cleanup inside the handler so state cannot hang half-torn-down (a stranded `isScanning = true` blocks the next `startScanning` at its guard), and the diagnostics are emitted at info level so the frequency of the adapter-off race is visible without polluting error dashboards. + +- **Android: BLE scanning never came back after the adapter did.** (#293) Not crashing on the adapter-off race left the second half of it open: nothing re-arms scanning once the exception is swallowed, because the preceding `stopScanning` has already cancelled the watchdog and the connection monitor, and `scheduleScanWatchdog` only runs on a *successful* start. Any Bluetooth-off period longer than the 30s watchdog interval — the common case, not a narrow race — left the mesh deaf while the transport still reported `RUNNING`, recoverable only if the app happened to call `resume()` or restart the transport. + + A deduped 10s recovery retry is now armed from every `startScanning` exit that fails to get a scan going, and cancelled by `stopScanning` so a paused or stopped transport cannot put itself back on air. It re-reads the scanner and advertiser (both null while the adapter is off, and the adapter-reset path re-attaches whatever it reads) before retrying. Restoring the scan is what restarts the whole self-healing chain, since the watchdog, connection monitor, refresh paths and adapter reset all hang off an active scan. **The asynchronous half of "the scan didn't start" is covered too**: `onScanFailed` cleared `isScanning`, cancelled both timers and scheduled nothing — the exact terminal state the retry exists to prevent — and is reachable on any device through internal-error and registration-failed, so it is now routed through `stopScanning` rather than clearing the flags by hand. + + Three sharp edges are filed down in passing. The adapter is checked before `startScan`, so the steady state of a switched-off adapter is a throttled info diagnostic per minute rather than a caught exception every heartbeat. Both scan-site catches are narrowed to the framework call — the wide catch swallowed any `IllegalStateException` from the whole function body and reported it as an adapter-off, including this file's own `assertMainThread`, a `check` that exists to fail loud. And `LeAdvertiser` had the same uncaught throw at both LE entry points, reachable from bare handler posts where nothing catches it, so the sibling crash had survived #279; its start path also lowers the in-flight gate on a throw and bails on a null advertiser, since that gate is otherwise only lowered by `stop()` or a terminal `onStartFailure`, neither of which runs when the call throws. + +- **Android: `BluetoothDevice.connectGatt` returning null crashed with a `NullPointerException`.** (#280) The framework declares it `@Nullable`; Kotlin sees a platform type, and `MeshConnectionRegistry.registerGatt` takes a non-null `BluetoothGatt`, so a null return tripped the compiler-inserted `checkNotNullParameter`. It returns null when the adapter has just turned off, when the underlying hardware handle is stale, or when the device unbonded between scan and connect — the same adapter races that motivate the `SecurityException` handler already at the tail of that function. Three users, three events on the pre-patch build. The return is now guarded, the pending role reserved for the address is released (mirroring the RSSI-skip, connection-cap and `SecurityException` paths, so state does not hang half-set-up), and an info diagnostic makes the rate visible. + +- **A Nostr event with a malformed `pubkey` aborted the process.** `ConversationKey::derive` handed the wire-supplied x-only key straight to the BIP-340 decoder, which takes a fixed-size array internally and **panics** rather than erroring on any length other than 32 bytes. The key is a relay event's `pubkey` field, and anyone may publish an event to a public routing tag — so a single record carrying a short-but-valid-hex pubkey and a plausible payload was a remote crash, reachable on the ordinary sealed-frame receive path (`unseal_event_payload`) since gift-wrap sealing shipped. The length is now checked before the decoder sees it, making a malformed key an ordinary decrypt failure, which every caller already handles. Covered by `derive_rejects_a_peer_key_that_is_not_32_bytes` and `test_malformed_event_pubkey_does_not_abort_the_receive_path`. + +- **Nostr no longer publishes the protocol envelope in cleartext — outgoing frames are sealed into NIP-59 gift wraps.** The transport handed `serialize_message` — the *entire* `Message` JSON — to the relay, base64'd into the event `content`. Only the inner `content` field was MLS ciphertext. Everything wrapping it was readable by every relay, permanently: + + ```json + {"id":"…","sender":"alice_real_username","recipient":"bob_real_username", + "app_id":"example-app","priority":"medium","ttl":8,"hop_count":0, + "timestamp":1785919090277,"lamport_clock":0,"content_type":"text", + "content":"__MLS_ENC__…","metadata":{…},"requires_ack":true} + ``` + + Both usernames, the app id, the app's metadata map, the content type and a millisecond timestamp — a durable social-graph disclosure to third-party-operated, archival infrastructure. The root cause was reusing the mesh wire envelope, a format built on the assumption that its reader is the next hop: a semi-trusted peer that legitimately needs the routing fields. A relay is not a hop. + + Outgoing frames are now [NIP-59](https://github.com/nostr-protocol/nips/blob/master/59.md) gift wraps (kind `1059`) with [NIP-44 v2](https://github.com/nostr-protocol/nips/blob/master/44.md) inner encryption, each signed by a **freshly generated single-use key** and stamped with a `created_at` jittered uniformly up to an hour into the past. A relay now sees only: the kind, an unlinkable per-event pubkey, the recipient's opaque routing tag, a coarse timestamp, and ciphertext. Nothing identifies either party, and no two events this device publishes are linkable to each other. + + Conforming to NIP-59's wrapper rather than inventing a sealed format was the decisive choice: **a custom event kind would have let one relay filter enumerate every Offline Protocol user**, turning a per-user privacy bug into a userbase-enumeration primitive. As kind 1059 an individual event is indistinguishable from an ordinary NIP-17 DM. (Later correction, for anyone testing that claim: it holds for a scrape *by kind*, not for a relay you subscribe on — the client's own `REQ` names its routing tag, so that relay learns the tag is the connected client's without any correlation work. `docs/nostr.md` §"What a relay can see" now enumerates this and the other distinguishers.) (NIP-59's inner kind-13 seal layer is skipped — the inner `Message` already names the sender and MLS authenticates it — which is invisible on the wire, since relays only ever see the wrapper.) + + **The NIP-44 implementation is asserted against the official vectors.** All 35 conversation-key vectors, 32 message-key vectors, both encrypt/decrypt sets, and every invalid-input vector run in-tree, plus the extended-length-prefix vectors from the spec body. The vector file is vendored and pinned by CI to the sha256 the spec publishes, so a green suite cannot be achieved by editing the fixture. Both length-prefix forms are implemented: the 6-byte extended prefix (added to NIP-44 on 2026-06-28, under the *same* version byte, so there is no negotiation and no way to detect support) sits at exactly 65536 bytes — the same boundary as this transport's own event cap. *Upstream inconsistency worth knowing:* `invalid.encrypt_msg_lengths` in the vector file still lists 65536 and above as invalid, because the vectors were not regenerated when the extended prefix landed. The spec body and its own extended-prefix vectors are authoritative; only the zero-length case is asserted from that list. + + **First contact.** Sealing needs the recipient's Nostr public key, which now rides in the signed key package (`nostr_pubkey`, persisted in the peer capability record so it survives restarts — the cached key package is deleted once a session exists, so without that a restart would silently downgrade every known peer). Before that exchange, the frame is sealed to the recipient's *publicly computable* key instead. That is **bulk-collection resistance only** — a relay scraping everything cannot read it, but anyone who guesses the recipient's username holds the matching private half. It was chosen over refusing to send (which makes a stranger unreachable over Nostr) and over falling back to a cleartext kind-4 event (which a one-line relay filter would turn into "show me who is just starting to talk"). On the wire the two cases are identical. One exchange in each direction upgrades the conversation to keys only the two installs hold. + + The computable keypair is used for **nothing else**, and must not be: its private half is public by construction, so it must never back NIP-42 AUTH or any authentication decision. Sender authenticity comes from the protocol-layer Ed25519/TOFU gate and MLS, neither of which consults it. + + **Compatibility — asymmetric, read this before enabling Nostr on a mixed fleet.** *Inbound* is fully compatible: the receive path unseals gift wraps *and* parses legacy unsealed frames unconditionally, and the subscription requests kinds 4 and 1059 permanently, so a peer on an older build can still reach us. *Outbound to an older peer does not work while sealing is on.* A pre-sealing build's REQ filter is `{"kinds":[4]}`, so a relay never delivers our kind-1059 events to it, and it has no NIP-44 layer to unseal one with. This is not silent: nothing is ACKed, so the send fails through the normal retry ladder and DORS demotes the transport, rather than the message vanishing. There are no deployed peers to break today, which is precisely why this ships now — but the first release carrying it is effectively a Nostr flag-day, and reaching a not-yet-upgraded peer over Nostr requires `nostr_sealing_enabled = false` on the sender until they upgrade. + + Sealing is gated by `nostr_sealing_enabled` (core `TransportConfig`, UniFFI, and RN `transports.nostr.sealingEnabled`), default **on**; unlike the negotiated wire and envelope switches it needs no peer capability, so it is safe to flip on a single device *among sealed-capable builds*. Rollback leaves no unreadable state. + + **Bridge change:** `nostrGetPublicKey()` is **no longer usable as a self-event filter.** Sealed events are signed by a throwaway key each, so an inbound `pubkey` never matches it — and by design nothing on a gift wrap identifies its author. The bundled bridges keep the comparison for the legacy form only; self-delivery is prevented by the `#p` filter and, for self-addressed messages, by message-id dedup. Bridges that rely on that comparison for anything else need reviewing. + + **Cost.** NIP-44 pads to a power-of-two bucket, so a payload just past a boundary nearly doubles before the MAC and base64 — considerably more than base64's ~33% alone. The 64 KiB cap is measured on the final sealed event, so a message that fits unsealed may not fit sealed. Separately, NIP-59 concedes that ephemeral outer keys defeat relay pubkey-based anti-spam, so public relays may rate-limit unknown-key events more aggressively; worth measuring against the configured relays before enabling Nostr in production. + + **Not addressed, stated plainly.** Traffic to `SHA-256(userId)` remains observable to an adversary who has already guessed a username — volume and timing, not content or counterparty. Rotating rendezvous tags would close that and are deliberately deferred: tying addressing to MLS epoch state turns a desync from "fails to decrypt" into "peers become mutually unreachable", trading a metadata leak for a silent-delivery-failure class. + + Separately, a **cached peer key can go stale with no feedback**. If a peer wipes their storage their Nostr key rotates, and frames sealed to the key we cached become readable by nobody — the transport has no delivery signal that would reveal this, since an unsealable frame is indistinguishable from one addressed to someone else. On a Nostr-only path that direction stays dark until the peer's new key package arrives by another route. A wipe also destroys the peer's MLS session, so the conversation needs rebuilding regardless, and contact over any other transport (or a peer-initiated Nostr message) heals it; the unblock clean slate now clears the cached key explicitly, reverting to the bootstrap key rather than a dead one. Publishing key packages as fetchable relay events — so a sender resolves the peer's current key instead of trusting a cache — removes the class outright and is the planned follow-up. + +- **The Nostr subscription now bounds replay with a persisted `since` watermark, not just a `limit`.** The previous change capped how much history one reconnect could pull down; it did not stop that history being the *same* history every time. A device that reconnects a hundred times still re-fetched the same slice a hundred times, and the cost of that is not bandwidth — replayed past-epoch MLS ciphertext classifies as `SessionDesync` and drives re-key churn, or, with no session at all, `PendingDecryptionQueue` churn. + + The transport now keeps a **receive watermark**: the newest event `created_at` this install has accepted. Every REQ filter carries `since` derived from it, so a reconnect asks only for what it has not already seen. The mark is persisted as a protocol-state record (`nostr_watermark`, unsealed — it is one coarse timestamp) on the same debounce-and-flush template as the Lamport clock: written once the live mark has advanced 5 minutes past the last write, flushed on `stop()`, `pause()`, and drop, and restored on launch. An un-flushed gap can only *widen* the next window, never skip messages. + + Three rules make the mark safe to act on, and each closes a way it could otherwise fail: + + - **`since` sits an hour and five minutes below the mark** — the gift-wrap jitter window plus a clock-skew margin. `created_at` is written by the sender, so an event published now can carry a timestamp in the recent past (deliberately so, once the sealed envelope lands and jitters it); a `since` sitting exactly at the mark would filter out the very events the query exists to fetch, with no error anywhere. NIP-01's `since` is inclusive, so the boundary event is re-delivered once. + - **A future-dated event cannot advance the mark.** The routing tag is `SHA-256(userId)`, so *anyone* who knows a username can publish an event addressed to it, and `created_at` is whatever the publisher wrote. One event dated far ahead would otherwise pin the mark there and make every subsequent subscription ask for events `since` the far future — receiving nothing, permanently, with nothing raised. Values more than 15 minutes ahead of local time are ignored rather than clamped, and the restore path runs through the same check so a record written by an older build (or edited in the app container) cannot stall a subscription either. + - **Only frames that decode into a protocol message advance it.** The mark means "receive progress has reached here"; a frame that did not parse was never processed. Counting it would let junk addressed to the routing tag drag the window past real messages the relay still owes us. The failure mode of the stricter rule is replaying more, never less. + + With no watermark — a fresh install, a `wipePersistedState` logout, or any subscription built before protocol-state storage has been restored — `since` falls back to 24 hours ago. Never zero: an absent or zero `since` is precisely the unbounded filter this removes, and the no-watermark case is common rather than exotic, since the bridges subscribe on every relay connect and that can precede `initialize_mls`. + + **Bridge change:** inbound events should now go through the new `nostrMessageReceivedAt(senderId, data, createdAt)` entry, passing the event's `created_at` verbatim. Both React Native bridges do. The timestamp-less `nostrMessageReceived` remains and still delivers normally — it simply never advances the watermark, so a bridge that keeps calling it re-fetches a full backfill window on every reconnect. + + **Residuals, stated plainly.** The mark can only be as good as what has been received. A relay that truncates a reconnect's history at `limit` returns its *newest* events, so a device coming back to more than 500 stored events advances past ones it never saw. The hour-plus overlap bounds which are at risk — only those already older than jitter + skew at the moment of truncation. That truncation is also reachable on purpose: the routing tag is public and only *decodability* gates the watermark (parsing a `Message` needs no signature), so a sustained flood of decodable junk can both crowd real events out of a truncated query and advance the mark past them. What keeps that recoverable is that Nostr is not the only path — ACK-gated messages sit in the sender's outbox for 7 days and are retransmitted with a fresh `created_at`, which lands above any watermark — so the cost is delay on a Nostr-only route, not loss. + + The replayed overlap is also **not fully deduplicated**, which is a cost rather than a correctness issue but worth stating: message-id dedup retains ids for an hour by default while `since` reaches back an hour and five minutes, so a reconnect after longer than the retention window (an app reopened the next day) re-processes its overlap instead of absorbing it. A replayed ciphertext whose ratchet generation is spent fails closed and is dropped, a past-epoch one triggers at most one rate-limited re-key, and a replayed group copy TTLs out of the pending buffer. The two constants are pinned against each other by a test so this note cannot silently go stale. + + And the watermark bounds *replay*, not metadata exposure: the envelope is still published in cleartext, which the sealed gift wrap addresses separately. + +- **The Nostr subscription no longer replays a relay's entire retention window on every reconnect, and oversized events are dropped before they reach a relay.** Two independent unboundedness bugs in the Nostr transport, both of which only bite once `nostr.enabled` is turned on (it defaults off). + + The NIP-01 REQ filter was `{"#p": [tag], "kinds": [4]}` — no `since`, no `limit` — and the platform bridges re-send it on every relay (re)connect. A relay is therefore free to return every stored event addressed to us, from the beginning of its retention, each time the socket comes back. Nothing downstream bounds that: the transport dedup window is 1000 ids / 1 hour, there is no wall-clock freshness check on the receive path (`Message.ttl` is a hop count, not a lifetime), and replayed past-epoch MLS ciphertext classifies as `SessionDesync`, which drives re-key churn. The filter now carries `limit: 500` (`NOSTR_INITIAL_QUERY_LIMIT`). NIP-01 scopes `limit` to the initial query — relays must ignore it once streaming live events — so it caps the history each reconnect pulls down without capping ongoing delivery. It is advisory in both directions: `limit` is a SHOULD, and NIP-11 `max_limit` lets a relay clamp it silently, so a short result set is not evidence the relay had nothing more. The persisted `since` watermark that bounds replay rather than merely capping it landed separately — see the entry above. + + `NOSTR_MAX_PAYLOAD_SIZE` (64 KiB) had been defined since the transport landed and referenced nowhere, so outgoing events had no size cap at all — while DORS gives Nostr a `media_bonus` of 30.0, which actively routes media there. `get_next_signed_event` now measures the complete `["EVENT", {...}]` relay message against it, not the protocol message inside it: the relay message is what a relay accepts or rejects, and it is larger by the base64 overhead (and, once the envelope is sealed, the encryption overhead), so capping the inner payload would let events onto the wire to be rejected on arrival. An over-cap event fails with `Error::MessageTooLarge` and is dropped **permanently on the first attempt** rather than entering the three-attempt signing retry — no number of attempts shrinks it, and re-queueing it at the front of the queue head-of-line-blocks every message behind a frame no relay will accept. The drop is now also counted as a transport failure, so DORS sees it; the pre-existing permanent-failure path routed through `report_send_failure`, which is keyed on a pending-confirmation entry the message never had, and was silently a no-op. + + **Media over Nostr fails fast now instead of silently.** `Message::binary_content` carries chunk bytes with no base64 serde adapter, so a chunk serializes as a JSON array of decimal numbers (~3.6×) before the event's own base64 (~1.33×) applies on top. At the engine's 32 KiB `DEFAULT_CHUNK_SIZE` that is ~156 KB on the wire — 2.4× this cap and past the 64–128 KB relays typically accept, so those events were never deliverable; they were simply rejected at the relay with nothing in the SDK noticing. They now fail at the transport with a typed error and a DORS-visible failure, which steers subsequent routing away from Nostr and lets the outbox retry ladder try another transport. A 4 KiB chunk (`CHUNK_SIZE_BLE`) still fits, so this bounds the default chunking for media over Nostr rather than forbidding media over Nostr. Both sizes are pinned by `test_default_size_media_chunk_exceeds_the_relay_cap`. + +### Removed + +- **The native transport managers' `getMetrics()` and the metrics half of the `TransportManager` abstraction are gone from both bridges — dead scaffolding with no caller, ever.** `getMetrics()` was a `TransportManager` protocol/interface requirement implemented by all ten native transport managers across iOS and Android, and nothing had **ever** called it: no Swift, Kotlin, ObjC or TypeScript call site existed at any commit in the repo's history. Its paired delegate callbacks (`didUpdateMetrics` / `onTransportMetricsUpdated`) were likewise never fired by any manager, leaving five handler implementations unreachable. No app behaviour changes, because no app could reach any of it. ([#300](https://github.com/Offline-Protocol/offline-protocol-sdk/issues/300)) + + **This is a road the architecture had already declined twice.** The app-facing metrics API is `getTransportMetrics()`, which resolves through the Rust core and carries an entirely different, typed vocabulary (`packetsSent`, `errorRate`, `avgLatencyMs`) — it never touched these managers. And each time a genuine need arose to read native-manager state from JS, it was met with a narrow, purpose-named accessor instead: `isInternetReady()`, and then `isInternetSuperseded()`, which 0.19.0 added *in preference to* wiring up `getMetrics()`. The 0.16.2 note that the relay-superseded work meant "`getMetrics()` exposes `is_superseded`" was true of the class and empty as an app-facing statement; that correction shipped with `isInternetSuperseded()` and this removes the surface behind it. + + **The counters went with it.** Every byte/message/fragment counter on all ten managers had `getMetrics()` as its sole reader, so each was write-only once it left — roughly forty fields fed by fifty-odd increment sites, several paying real per-message synchronization for a value nobody read: lock-guarded computed-property pairs on iOS Nostr/Reticulum, four `AtomicCounter`s on iOS Internet, `AtomicLong`s across the Android managers, and a cross-queue fold in iOS `BleManager.pumpNotifyOutbound` that dispatched to `fragmentQueue` purely to add them up. iOS WifiDirect's `isAdvertising`/`isBrowsing` and Android WifiDirect's `isDiscovering` were the same story — bookkeeping beside the real MultipeerConnectivity / `WifiP2pManager` calls, read only by `getMetrics()`. + + **Thread-safety comments now name real readers.** Several fields justified their synchronization with "read best-effort off-main (`getMetrics`)" — a reader that did not exist, asserted in exactly the places where such reasoning has to be trusted. Each is corrected to the actual reader: iOS `SupersededLatchPolicy.latched` is genuinely main-confined (both public accessors hop through `runOnMainSync`), while the Kotlin mirror's `@Volatile` **is** load-bearing, because `internetIsSuperseded` reads the latch straight from an RN thread with no main hop. iOS `InternetManager.reconnectAttempts` becomes a plain main-owned var to match its sibling `currentReconnectDelay`: an audit of all five writes and both reads confirms main confinement, and the `stateLock` guard existed only for the deleted reader. + + `TransportState` and `TransportError`/`TransportException` live in the same files and are used throughout — those are untouched, as are the state and diagnostic delegate callbacks. Native symbols are technically visible in the source-shipped npm package, so an out-of-tree native consumer calling `getMetrics()` directly would need to drop the call; nothing in the SDK or its known consumers does. + +### Documentation + +- **The integrator-facing docs for `cryptoRecoveryEnabled` overstated the guarantee, and now state the real threat model.** `docs/mls-integration.md`, `docs/configuration.md`, `docs/api-reference.md`, the `EncryptionConfig::crypto_recovery_enabled` rustdoc, the UDL comment, and the RN `types.ts` doc comment all said genuine decrypt failures — "corrupt/forged ciphertext" — still fail closed, so the SDK "never re-keys on injected garbage", and framed the residual as replay-only: an attacker holding a genuine peer's captured old-epoch ciphertext. That is wrong, and the SDK's own test suite has said so since 0.19.0: `test_forged_frame_reaches_session_desync_without_any_key_material` hand-serializes a frame with no key material, no captured ciphertext and no session, and asserts it reaches the recoverable classification that drives a re-key. An MLS epoch is validated as part of *framing*, before any AEAD, sender-data or signature check; `__MLS_ENC__` is a data-plane prefix exempt from the signed control gate; and a 1:1 slot id is `session::` over two public user ids — so **anyone who can inject a frame can drive a rate-limited re-key**. This is inherent to MLS framing, not an OpenMLS defect, and no sender check can cover it: the credential such a check compares against only exists once decrypt succeeds. + + Nothing about the shipped behaviour changes, and nothing new is exposed — the consequences were already bounded and made observable when the trigger was hardened in 0.19.0 (slot binding, the 30 s per-peer floor, a heal that keeps queued plaintext, and the `SESSION_REKEY_TRIGGERED` warning). That work corrected the in-code SECURITY note and the internal architecture notes but left every public surface carrying the old claim, which is the actual defect being fixed here: an integrator reading the docs would have concluded the trigger was authenticated and treated a sustained `SESSION_REKEY_TRIGGERED` rate as a bug rather than as the injection signal it exists to be. The docs now say plainly that the trigger is unauthenticated by construction, that the mitigation is that acting on it is *harmless* rather than that it is trusted, and that the residual is bounded re-key churn on one pair — delivery delayed, never lost — closable only by a signed epoch-corroboration exchange, which is future work. The 0.16.5 entry below is left as written, as the record of what was believed at the time; this entry supersedes its security note. The two in-crate comments at the classification site (`MlsError::SessionDesync`, `group.rs::process_message`) gain the same correction, since they sat exactly where a future maintainer would look to decide whether the classification is trustworthy. Documentation and comments only; no behaviour, wire, or API change. + +- **`docs/nostr.md` now carries the relay anti-spam caveat that sealing creates.** Raised when the gift-wrap sealing landed (#288) but never written down anywhere an integrator would find it. Every sealed event is signed by a fresh single-use key — that is what makes our events mutually unlinkable — and NIP-59 concedes the direct consequence: ephemeral author keys defeat the pubkey-reputation anti-spam public relays rely on, so a relay may rate-limit, deprioritise, or reject never-before-seen keys more aggressively. Every frame this transport publishes looks new by design, so a relay applying that policy applies it to all of them. The SDK cannot detect this on an app's behalf, since a relay that drops an event without an `["OK"]` is indistinguishable from a slow one and the send simply fails through the pending-confirmation timeout. The guidance is therefore to measure real traffic against the *configured* relays before enabling Nostr in production, and to reach for a relay whose policy is known rather than for the sealing kill switch — unsealed frames publish the whole envelope in cleartext. Troubleshooting gains the matching symptom (intermittent loss that scales with send rate). Documentation only; no behaviour change. + +- **The export notice now names both recipients of the §742.15(b) notification.** [15 CFR §742.15(b)](https://www.ecfr.gov/current/title-15/subtitle-B/chapter-VII/subchapter-C/part-742/section-742.15) requires notifying *two* parties of the Internet location of publicly available encryption source code — BIS at `crypt@bis.doc.gov` **and** the ENC Encryption Request Coordinator at the NSA, at `enc@nsa.gov` — and the notice as shipped in 0.19.0 named only BIS. Both were notified; only the document was incomplete. Since the whole "not subject to the EAR under [§734.7(b)](https://www.ecfr.gov/current/title-15/subtitle-B/chapter-VII/subchapter-C/part-734/section-734.7)" conclusion is conditioned on that notification, a notice that describes it as half-made understates the basis for the conclusion it draws. The paragraph addressed to app teams whose own application is open source — for whom the notification duty on their own source location is theirs — now says the duty runs to both addresses, since a developer following that sentence would otherwise file with one. No code, packaging, or license-surface change: `EXPORT.md` still ships in the npm package, the Python wheel, and the GitHub release assets, and the three copies stay byte-identical under `scripts/check-license-consistency.sh`. + diff --git a/docs/changelog/0.8.md b/docs/changelog/0.8.md new file mode 100644 index 00000000..e3f9e73d --- /dev/null +++ b/docs/changelog/0.8.md @@ -0,0 +1,156 @@ +# Changelog: 0.8.x + +Archived release notes. The working changelog, covering the current release +and unreleased changes, is [CHANGELOG.md](../../CHANGELOG.md). + +| Release | Date | +|---------|------| +| 0.8.0 | 2026-03-19 | + +## [0.8.0] — 2026-03-19 + +### Breaking Changes (React Native Bindings) + +If you are building an app with the React Native bindings, the following changes require updates to your code: + +#### New error variants: `UserBlocked` and `LockPoisoned` + +Two new error variants have been added to `ProtocolError`: + +- **`UserBlocked`** — Thrown when attempting to send a message, control signal, or establish a connection with a blocked user. If your app catches `ProtocolError` exhaustively, you must now handle this case. +- **`LockPoisoned`** — Thrown when an internal mutex is in a poisoned state (indicates a prior panic inside the SDK). This replaces the previous behavior where 129 `lock().unwrap()` calls would panic the process. Apps should treat this as a fatal internal error and consider restarting the protocol engine. + +#### New `ProtocolConfig` fields + +Three new fields have been added to `ProtocolConfig`. They have defaults so existing code will compile, but you should review them: + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `max_group_members` | `u32` | `256` | Maximum members allowed in a single MLS group | +| `group_relay_enabled` | `boolean` | `true` | Whether relay broadcasting is used for group fan-out | +| `require_transport_identity` | `boolean` | `false` | Enables Ed25519 sender identity binding at the transport layer | + +#### New event types in React Native bindings + +The following event types have been added to the React Native TypeScript types: + +- **`service_discovered`** — A peer advertised a mesh service in response to a discovery query. +- **`service_request_received`** — An incoming service request from another peer. +- **`service_response_received`** — A response to a service request you sent. +- **`presence_updated`** — A peer sent a presence update (Online, Away, Offline). +- **`typing_indicator_received`** — A peer started or stopped typing. +- **`read_receipt_received`** — A peer read one or more of your messages. +- **`message_relayed`** — A message was relayed through this node in the mesh. +- **`message_deferred`** — A message was queued for later delivery because no transport was available. + +#### Rust-level events (not yet bridged to React Native) + +The following events are emitted at the Rust/UniFFI layer but are not yet exposed in the React Native TypeScript types. If you are consuming the SDK directly through UniFFI (Swift/Kotlin), you should handle these: + +- **`UserBlocked`** / **`UserUnblocked`** — A user was blocked or unblocked locally. +- **`MessageDecryptionFailed`** — An incoming message could not be decrypted. +- **`GroupEpochForkDetected`** / **`GroupEpochForkResolved`** — MLS epoch fork lifecycle events. +- **`SecurityWarning`** — A control message failed authentication or replay checks. +- **`TofuReset`** — TOFU trust state was reset for a peer. + +#### `ForwardInfo` added to message events + +`MessageReceivedEvent`, `MessageSentEvent`, and `GroupMessageReceivedEvent` now include an optional `forward_info` field. If your app displays messages, you should check for this field to show forwarding attribution: + +```typescript +interface ForwardInfo { + original_sender: string; + original_message_id: string; + original_timestamp: number; + forward_count: number; +} +``` + +#### Native bridge expansion (iOS & Android) + +The native modules (`OfflineProtocolModule.swift` and `OfflineProtocolModule.kt`) have been significantly expanded. If you have custom native module extensions or overrides, you will need to add implementations for the new methods: `blockUser`, `unblockUser`, `getBlockedUsers`, `isUserBlocked`, `forwardMessage`, `meshForwardMessageToGroup`, `sendPresenceUpdate`, `sendTypingIndicator`, `sendReadReceipt`, `resetTofuForPeer`, and the full `MeshServices` API surface (`registerService`, `unregisterService`, `discoverServices`, `sendServiceRequest`, `respondToServiceRequest`). + +### Features + +- **Service discovery and request/response** ([#45](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/45)) + Added a new `MeshServices` subsystem that enables peer-to-peer service discovery and typed request/response over the mesh network. Services are advertised via gossip broadcast and discovered without a central registry. Includes auto `not_found` responses for unknown services, known_peers tracking independent of MLS encryption state, configurable max-hops gossip limit to control broadcast radius, payload size limits and capacity bounds to prevent resource exhaustion, sender-based response routing for multi-hop meshes, and the new `OutboundMessage` struct replacing raw tuples throughout the send path. Full UniFFI bindings are included for iOS and Android. + +- **Transport-agnostic MLS group messaging** ([#46](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/46)) + Wired MLS group encryption into the protocol engine, enabling encrypted group conversations that work seamlessly across BLE, WiFi Direct, and Internet transports. Messages are encrypted once and fan-out to all group members via DORS-selected transports. Adds configurable `max_group_members` (default 256), relay broadcast optimization for large groups, pending MLS commit buffering with TTL-based expiry, classification of MLS errors into permanent (e.g., bad state) vs retriable (e.g., out-of-order) categories, and extraction of `GroupMeshState` for cleaner state management. Includes 82 new tests in a dedicated test module. + +- **Presence, typing indicators, and read receipts** ([#48](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/48)) + Added protocol-level support for three real-time communication signals: presence updates with a `PresenceStatus` enum (Online, Away, Offline), typing indicators with per-conversation granularity, and read receipts supporting batch message IDs. All three are implemented as lightweight internal control messages routed through DORS, meaning they work across any transport without a relay server. Input validation prevents empty recipient/conversation IDs and excessive message ID lists. Full UniFFI and React Native bindings for mobile with 25 tests covering edge cases. + +- **User blocking with silent message filtering** ([#54](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/54)) + Added a complete user blocking system with a locally-persisted block list. Blocked users are filtered in the receive pipeline (after dedup but before ACK, so blocked senders never learn they are blocked), with guards on all outbound paths including send, control messages, and connection establishment. Blocking persists across restarts via `MlsStorage`. Unblocking a user cleans up stale MLS sessions. Includes a typed `Error::UserBlocked` variant, outbound presence leak prevention (so blocked users don't see your status), file transfer cleanup, a `MAX_BLOCKED_USERS` cap, and full UniFFI/React Native bindings. + +- **User-level message forwarding with attribution** ([#61](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/61)) + Introduced first-class message forwarding as a protocol feature. Forwarded messages carry a `ForwardInfo` struct containing the original sender, original message ID, original timestamp, and a forward count. Both 1:1 (`forward_message()`) and group (`forward_message_to_group()`) forwarding are supported. The pending queue preserves forwarding attribution through retries, relay broadcast handles forwarded group messages, and a `MAX_FORWARD_COUNT=100` cap prevents infinite forwarding chains. Content type and media metadata are preserved through the forwarding path. Full React Native bindings included. + +- **Presence, typing, and read receipts wired to React Native** ([#65](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/65)) + Wired `sendPresenceUpdate`, `sendTypingIndicator`, and `sendReadReceipt` through the React Native TypeScript wrapper, iOS Swift native module, iOS Objective-C bridge, and Android Kotlin native module. Also wired into the demo app with UI controls for all three signals. + +- **Demo app** + Added a simple demo app (`examples/demo-app/`) showcasing all SDK features including messaging, groups, presence (via `sendPresenceUpdate`), typing indicators, read receipts, service discovery, blocking, forwarding, and message relay/deferral tracking. Uses the production-recommended reliability config (10 retries, 10s ACK timeout). + +### Performance + +- **Reduce message latency from invitation to delivery** + Overhauled polling and timing across the stack to dramatically reduce the time from MLS invitation to first decryptable message. Replaced the 750ms × 8 fixed-interval MLS establishment polling with a 100ms exponential backoff helper that resolves faster in the common case. Aligned the Android process tick interval with iOS (500ms → 100ms) to eliminate a platform-specific latency gap. Reduced startup delay from 500ms to 100ms, and presence rebroadcast interval from 60s to 15s for faster peer discovery. Tightened reliability config in the example app to match production expectations. + +### Bug Fixes + +- **Harden mesh group robustness** ([#47](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/47)) + Fixed several issues that caused group messaging to degrade under real-world conditions. Stale relay caches now refresh from MLS membership on each fan-out. Added a leave election fallback with staggered re-election timeouts so groups can recover when the elected leader crashes. Implemented epoch fork detection using Lamport clock comparison and automatic resolution via leader-elected key-update commits. Added a circuit breaker on elections to prevent election storms, tuple-keyed leave elections to handle concurrent leaves, and per-attempt cooldown to prevent rapid-fire retries. + +- **Harden control message authentication and sender verification** ([#49](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/49)) + Comprehensive security hardening of the control message path. Added transport-level sender identity binding so peers can verify who sent each control message. Implemented Ed25519 control message signing with TOFU (Trust On First Use) key pinning — the first time you communicate with a peer, their signing key is recorded, and all future control messages are verified against it. Added protections against internal prefix injection (where a malicious peer crafts payloads that look like control messages), LRU TOFU eviction for bounded memory, replay protection via nonce tracking, length-prefixed binary signing payloads with domain separators, and a `SecurityRejected` variant that suppresses ACKs for rejected messages so attackers don't get delivery confirmation. + +- **Harden TOFU transport prefixes** ([#50](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/50)) + Hardened prefix handling in the TOFU transport layer to prevent prefix confusion attacks. + +- **Harden TOFU storage and validate identity strings** ([#51](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/51)) + Added input validation throughout the identity system. `UserId` and `AppId` constructors now reject storage-hostile characters (path separators, null bytes) and all ASCII control characters to prevent key injection and filesystem traversal. TOFU restore keys are validated before use. TOFU peer restore is capped at `MAX_TOFU_PEERS` with a deterministic secondary sort for consistent truncation behavior. + +- **Wire user blocking to native bridges** ([#55](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/55)) + Wired the Rust-level user blocking API through to the Android and iOS native bridge modules and fixed incorrect field mappings in service discovery event payloads. + +- **Address 14 bugs from full codebase audit** ([#56](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/56)) + Fixed 14 bugs found during a systematic codebase audit: ACK piggyback overflow was silently dropped (messages lost), `FileChunk` had unbounded memory allocation (DoS vector), `finalize_file` skipped SHA256 checksum verification (integrity gap), `LamportClock` deserialization bypassed value clamping (could overflow), UniFFI storage error variant was mismapped (wrong errors surfaced to apps), `RetryEntry` had an Eq/Ord contract violation causing duplicate enqueues, routing table had a stale reverse index (phantom routes), DORS produced NaN scores when `ttl==0`, `MockTransport` used LIFO instead of FIFO ordering (tests didn't match real behavior), UniFFI event callback could deadlock under contention, `received_messages` used O(n) removal (degraded with message volume), `RetryQueueStats` was missing the Critical priority level, and added `#![deny(unsafe_code)]` to the MLS crate. + +- **Production readiness improvements** ([#57](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/57)) + Replaced all 129 `lock().unwrap()` calls across the codebase with poison-recovering lock wrappers, so a panic in one thread no longer takes down the entire SDK. Added a CI pipeline with fmt, clippy, test, cargo-deny license/advisory checking, and code coverage. Added a TOFU reset API for apps that need to clear trust state. Added a 1MB max message size guard at the transport layer to prevent oversized payloads from crashing BLE stacks. Added cargo-deny config and SECURITY.md. Fixed `receive_message()` silently dropping messages when serialization failed (now returns a proper error). + +- **Save BLE fragments on missing peripheral** ([#58](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/58)) + BLE fragments are now saved to a buffer when the peripheral connection is temporarily unavailable, instead of being silently dropped. This fixes a data loss issue where BLE messages were lost during brief connection interruptions. + +- **Enforce FIFO ordering for BLE fragment queues** ([#59](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/59)) + Fixed BLE fragment queues to enforce strict FIFO ordering. Previously, fragments could be delivered out of order, causing message reassembly failures on the receiving side. + +- **Mesh networking fixes** ([#60](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/60)) + A collection of mesh networking improvements: added unicast multi-hop relay forwarding with a `MessageRelayed` event so apps can track relay activity, switched dedup storage to LRU eviction for bounded memory, added composite-score route eviction so stale routes are pruned based on quality rather than age alone, enabled multi-hop service discovery responses with an originator field for correct return routing, reduced epoch fork false positives by tightening detection thresholds, adopted RFC 1982 serial number arithmetic for sequence numbers (handles wrapping correctly at 2^32), and extracted relay logic into a dedicated helper module. + +- **Decouple transport retries from ACK retries** ([#62](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/62)) + Fixed a fundamental reliability issue where messages permanently died after just 3 transport send failures, even though the transport was only temporarily unavailable. The root cause was that the `max_retries` limit was applied at enqueue time rather than being purely a scheduling concern. Now, enqueue is always accepted and the retry queue handles scheduling with exponential backoff. Added `drain_all()` and `flush()` methods so messages are sent immediately when a transport becomes available. Fixed ghost re-sends from un-cleaned retry queue entries, double-sends from concurrent flush paths, and zombie entries that never expired. Returns `Ok` with a `MessageDeferred` event when no transport is available (instead of `Err`). Bumped defaults to 10 retries with 10s ACK timeout for better real-world reliability. + +- **Fix message forwarding** + Fixed a bug where forwarded message JSON was never parseable because the serialization format didn't match the deserialization expectation. + +- **Fix group messaging** ([#63](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/63)) + Six targeted fixes for group messaging: added missing dedup check in the relay group handler (duplicate messages were processed twice), prevented duplicate Welcome messages from overwriting valid MLS state (caused decryption failures for all subsequent messages), stopped raw ciphertext from leaking as `GroupMessageReceived` events on decrypt failure (apps received garbage), added retry logic for commit fan-out (commits to some members were silently lost), fixed `GroupManager` incorrectly defaulting the group display name to the internal group ID, and fixed the demo app not cleaning up local state when the current user is kicked from a group. + +- **Add missing dedup to commit, Welcome, and leave handlers** ([#64](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/64)) + Added deduplication to `handle_group_mls_commit`, `handle_group_mls_welcome`, and `handle_group_mls_leave` — the three group control message handlers that had no dedup at all. Without this, duplicate network deliveries caused false epoch fork detection (from double-applied commits), wasted cryptographic operations (from reprocessing Welcomes), and election timer resets (from duplicate leave messages). + +### Refactoring + +- **Split protocol.rs monolith** ([#52](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/52)) + Split the ~3000-line `protocol.rs` file into focused sub-modules (messaging, groups, security, presence, services, etc.) for better maintainability and faster compilation. No behavioral changes. + +- **Extract pending queue module** ([#53](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/53)) + Extracted the pending message queue into its own module with explicit imports, reducing coupling between the queue logic and the main protocol engine. + +### Documentation + +- **Clean up documentation** + Removed duplicate documentation files (including `bindings/react-native/MESH.md`), fixed outdated information across READMEs and inline docs, and added a documentation index for easier navigation. If you had external references to `MESH.md`, note that it has been removed — the relevant information is now covered in inline documentation and the main README. diff --git a/docs/changelog/0.9.md b/docs/changelog/0.9.md new file mode 100644 index 00000000..82ec4fe7 --- /dev/null +++ b/docs/changelog/0.9.md @@ -0,0 +1,160 @@ +# Changelog: 0.9.x + +Archived release notes. The working changelog, covering the current release +and unreleased changes, is [CHANGELOG.md](../../CHANGELOG.md). + +| Release | Date | +|---------|------| +| 0.9.4 | 2026-03-27 | +| 0.9.3 | 2026-03-26 | +| 0.9.2 | 2026-03-24 | +| 0.9.1 | 2026-03-20 | +| 0.9.0 | 2026-03-20 | + +## [0.9.4] — 2026-03-27 + +### Bug Fixes + +- **Strict nullable returns for Kotlin 2.x compat** ([#74](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/74)) + Kotlin 2.x stopped being lenient about Java nullable annotations: every `JSONObject.optString()` and `ReadableArray.getString()` now returns strict `String?`. Introduced `safeOptString` / `optNullableString` extensions in `JsonExtensions.kt` and migrated ~58 call sites across `OfflineProtocolModule.kt`, `InternetManager.kt`, and `BleManager.kt`. Read-receipt IDs filter null/empty entries via `mapNotNull` instead of coercing to empty strings (which the Rust side silently accepted). + +--- + +## [0.9.3] — 2026-03-26 + +### Bug Fixes + +- **Allow session confirmation when welcome is in `SendAttempted` state** ([#73](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/73)) + `can_confirm_from_source()` only accepted `WelcomeDeliveryState::Sent`, but the welcome lifecycle can stay at `SendAttempted` (DORS falling back from BLE to Internet, or Internet transport awaiting platform confirmation). Encrypted messages silently queued and never flushed. The gate now opens at `SendAttempted`, and `on_transport_send_confirmed()` issues an immediate confirmation probe so Internet sessions don't wait up to 7 s for the next `process()` tick. + +--- + +## [0.9.2] — 2026-03-24 + +### Added + +- **Cancel connection requests** ([#70](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/70)) + Apps can cancel an in-flight connection request before the peer responds. + +### Chores + +- **Stop shipping the Android build cache in the npm package** + Generated Android build artifacts (`.gradle` / build-cache output) were being bundled into the published npm tarball; they are now excluded, shrinking the package. + +--- + +## [0.9.1] — 2026-03-20 + +### Bug Fixes + +- **Add missing `RCT_EXTERN_METHOD` declarations for iOS bridge** ([#69](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/69)) + Seven `@objc func` implementations in `OfflineProtocolModule.swift` had no corresponding `RCT_EXTERN_METHOD` macros in the `.m` bridge file, which meant React Native on iOS could not call them at all (Android worked fine via `@ReactMethod`). The missing methods: `sendPresenceUpdate`, `sendTypingIndicator`, `sendReadReceipt`, `getIdentityPublicKey`, `deriveUserIdFromPublicKey`, `signData`, and `verifySignature` — the entire presence/typing block and all identity crypto methods. A full audit of all 120 methods confirmed these were the only ones missing. + +### CI/CD + +- **Parallelize release and CI workflows** ([#68](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/68)) + Split the Android release build into a matrix of 4 parallel jobs (one per ABI), cutting release time significantly. Cached the `uniffi-bindgen` binary and switched to a pre-built `git-cliff` binary. Moved the release job to `ubuntu-latest` (it only needs Node and git-cliff, not a macOS runner). Split CI check into parallel fmt/clippy/test jobs so PRs get feedback in ~3 min instead of ~6 min. Added concurrency groups to cancel stale PR runs, restricted coverage to main-only pushes, added `workflow_dispatch` with a `dry_run` input for manual pipeline testing, and added artifact verification before publishing. Fixed a script injection vulnerability in version extraction by moving input interpolation into an env binding. + +### Documentation + +- **Wiring guide for Rust-to-React-Native method flow** + Added a step-by-step guide (`bindings/react-native/WIRING_GUIDE.md`) covering the six layers from Rust to TypeScript, common mistakes, parameter type mappings, and a smoke test snippet to prevent missing iOS bridge declarations. + +--- + +## [0.9.0] — 2026-03-20 + +### Breaking Changes + +#### Low-level MLS group API removed from UniFFI bindings + +The following methods exposed raw MLS group operations that bypassed role checks, fan-out, and mesh routing. They have been removed from the UniFFI surface (Swift, Kotlin, React Native). Use the high-level mesh group API instead. + +| Removed method | Replacement | +|---|---| +| `mlsCreateGroup(groupName)` | `meshCreateGroup(groupName)` | +| `mlsAddGroupMember(groupId, keyPackage)` | `meshInviteToGroup(groupId, inviteeUserId)` | +| `mlsRemoveGroupMember(groupId, memberId)` | `meshRemoveFromGroup(groupId, memberId)` | +| `mlsLeaveGroup(groupId)` | `meshLeaveGroup(groupId)` | +| `mlsEncryptForGroup(groupId, plaintext)` | `meshSendGroupMessage(groupId, content)` | +| `mlsDecryptFromGroup(encrypted)` | Handled automatically by the protocol engine | +| `mlsJoinGroup(welcome)` | Handled automatically via Welcome processing | +| `mlsListGroups()` | `meshListGroups()` | +| `mlsGetGroupInfo(groupId)` | `meshGetGroupInfo(groupId)` | + +#### Transport stub methods removed + +`addInternetTransport(serverUrl, port)` and `addWifiDirectTransport()` were no-op stubs that always returned errors. They have been removed from UniFFI, UDL, and all platform bindings (JNI, Kotlin, Swift, TypeScript). + +#### `ProtocolState` enum variants removed + +`Starting` and `Stopping` were never set by the engine and have been removed. The enum is now `Stopped | Running | Paused`. If you have exhaustive `switch`/`when` statements over `ProtocolState`, remove the `Starting` and `Stopping` cases. + +#### Admin-only group operations (behavioral change) + +`meshInviteToGroup()`, `meshRemoveFromGroup()`, `meshSetMemberRole()`, and `meshRenameGroup()` now enforce admin-only access. If a non-admin calls these methods, they will throw with `Error::NotGroupAdmin`. The group creator is automatically assigned the `Admin` role. If your app previously allowed any member to invite/remove, you must either promote them to admin first or adjust your UI to reflect the new permission model. + +#### New group role management APIs + +Three new methods have been added to the high-level mesh group API: + +- **`meshSetMemberRole(groupId, userId, role)`** — Change a member's role (admin only). `role` must be `"admin"` or `"member"`. +- **`meshGetMemberRole(groupId, userId)`** — Get a member's current role. +- **`meshGetGroupRoles(groupId)`** — Get all member roles as a `Record`. + +A new event **`group_role_changed`** is emitted when a role changes: + +```typescript +protocol.on('group_role_changed', (event) => { + console.log(`${event.user_id} is now ${event.new_role} (changed by ${event.changed_by})`); +}); +``` + +If you use exhaustive `switch` statements on `ProtocolEvent['type']`, add a case for `'group_role_changed'`. + +#### Rust-level events added + +The following events are emitted at the Rust/UniFFI layer. If you are consuming the SDK directly through UniFFI (Swift/Kotlin), you should handle these: + +- **`GroupRoleChanged`** — A member's role was changed in a group (also bridged to React Native as `group_role_changed`). +- **`GroupRenamed`** — A group was renamed, includes `group_id`, `new_name`, `old_name`, and `renamed_by`. + +### Features + +- **Group rename API** + `renameGroup(groupId, newName)` lets admins rename a group and broadcasts the change to all members via a `__GRP_RENAME__` internal message. A `GroupRenamed` event is emitted on all peers with `group_id`, `new_name`, `old_name`, and `renamed_by`. Available via UniFFI (Swift/Kotlin) and React Native (`meshRenameGroup`). + +- **`getGroupInfo` on the high-level API** + Replaces the removed low-level `mlsGetGroupInfo`. Returns group metadata including members, epoch, and timestamps. Available via UniFFI as `getGroupInfo(groupId)` and React Native as `meshGetGroupInfo(groupId)`. + +- **Dead code and security bypass cleanup** ([#67](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/67)) + Removed unused struct fields (`GroupManager::user_id`, `SessionManager::storage`, `InternetState::server_url`, UniFFI `OfflineProtocol::user_id`), all annotated with `#[allow(dead_code)]`. Simplified `GroupManager::new` signature (no longer takes a `user_id` parameter). Regenerated all UniFFI bindings (Kotlin, Swift, C header, JNI, TypeScript) to reflect the consolidated API surface. + +- **Group role management and security hardening** ([#66](https://github.com/Offline-Protocol/offline-protocol-sdk/pull/66)) + Added app-layer role tracking for MLS groups with a typed `GroupRole` enum (`Admin` / `Member`). The group creator is automatically assigned the `Admin` role. Admins can invite/remove members and change roles; non-admins are rejected with a typed error. Key security improvements: last-admin invariant prevents orphaned groups (demoting or removing the last admin is blocked), deterministic admin election on leader departure using lexicographic fallback, phantom member cleanup on group join, and removed member notification via a plaintext `__GRP_REMOVED__` control message so kicked members can clean up local state immediately. Key packages are automatically replenished after member removal so subsequent invites don't fail. Includes new `meshSetMemberRole()`, `meshGetMemberRole()`, and `meshGetGroupRoles()` APIs with full UniFFI and React Native bindings, a `GroupRoleChanged` event, and 1200+ lines of new tests. + +### Bug Fixes + +- **Reject empty group names** + `create_group` and `rename_group` now validate the group name: whitespace is trimmed and empty strings are rejected with a descriptive error. Previously, an empty name could be broadcast to all group members. + +- **Wire `resetTofuForPeer` through all platform bindings** + The TOFU reset API (`resetTofuForPeer`) is now available in React Native (TypeScript), iOS (Swift native module), and Android (Kotlin native module). Previously it was only callable from Rust/UniFFI. After calling this, the next message from the peer will establish a new trust pin. + +- **Wire `renameGroup` through all platform bindings** + `meshRenameGroup` is now wired through the iOS Swift native module, iOS Objective-C bridge, Android Kotlin native module, and the UniFFI-generated Kotlin/Swift bindings. The React Native TypeScript wrapper and the Rust/UniFFI layer already had this method. + +- **Notify removed members and replenish key packages** + Removed members now receive a plaintext `__GRP_REMOVED__` notification so they can clean up local state immediately instead of silently losing access. After member removal, key packages are automatically replenished so subsequent invites don't fail with a stale key package error. + +- **Fix phantom members on group join** + Fixed an issue where the local member list could include stale members after joining a group via Welcome. Member lists are now reconciled from the MLS group state on join. + +- **Fix deterministic admin election** + Admin election on leader departure now uses a deterministic lexicographic sort, preventing split-brain scenarios where different nodes elect different admins. Election failures are logged rather than silently swallowed. + +- **Close last-admin loopholes** + Prevented several edge cases where a group could become orphaned (no admin): demoting the last admin, removing the last admin, and the last admin leaving are all now blocked with explicit errors. + +--- + diff --git a/docs/changelog/README.md b/docs/changelog/README.md new file mode 100644 index 00000000..721be705 --- /dev/null +++ b/docs/changelog/README.md @@ -0,0 +1,36 @@ +# Changelog archive + +Release notes for versions before the current release, one file per minor +series. The working changelog, covering unreleased changes and the current +release, is [CHANGELOG.md](../../CHANGELOG.md). + +| Series | Releases | First | Last | +|--------|----------|-------|------| +| [0.20.x](0.20.md) | 0.20.1, 0.20.0 | 2026-08-07 | 2026-08-07 | +| [0.19.x](0.19.md) | 0.19.0 | 2026-08-04 | 2026-08-04 | +| [0.18.x](0.18.md) | 0.18.3, 0.18.2, 0.18.1, 0.18.0 | 2026-07-31 | 2026-08-03 | +| [0.17.x](0.17.md) | 0.17.0 | 2026-07-30 | 2026-07-30 | +| [0.16.x](0.16.md) | 0.16.6 through 0.16.0 | 2026-07-24 | 2026-07-28 | +| [0.15.x](0.15.md) | 0.15.0 | 2026-07-20 | 2026-07-20 | +| [0.14.x](0.14.md) | 0.14.0 | 2026-07-16 | 2026-07-16 | +| [0.13.x](0.13.md) | 0.13.1, 0.13.0 | 2026-07-13 | 2026-07-14 | +| [0.12.x](0.12.md) | 0.12.0 | 2026-07-13 | 2026-07-13 | +| [0.11.x](0.11.md) | 0.11.1, 0.11.0 | 2026-07-01 | 2026-07-12 | +| [0.10.x](0.10.md) | 0.10.0 | 2026-04-13 | 2026-04-13 | +| [0.9.x](0.9.md) | 0.9.4 through 0.9.0 | 2026-03-20 | 2026-03-27 | +| [0.8.x](0.8.md) | 0.8.0 | 2026-03-19 | 2026-03-19 | + +Releases before v0.7.1 are not covered by this changelog. + +## Archiving procedure + +When cutting a release, the previous release moves out of the working file: + +1. Cut the release as usual in `CHANGELOG.md`. +2. Move the now-previous release's section into + `docs/changelog/..md`, creating the file with its title and + release table if the series is new. +3. Update the archive table above and the one at the foot of `CHANGELOG.md`. + +The working file should hold unreleased changes plus one release. Anything more +and it grows without bound again. diff --git a/docs/message-delivery.md b/docs/message-delivery.md index 1e209585..702aa5a2 100644 --- a/docs/message-delivery.md +++ b/docs/message-delivery.md @@ -58,7 +58,7 @@ The system has two distinct retry mechanisms that serve different purposes: **Transport Retries** (retry queue): - Fires when no transport can deliver the message right now - The retry queue is a pure scheduling mechanism with no attempt limit -- Uses exponential backoff: 1s → 2s → 4s → 8s → ... → 30s (capped) +- Uses exponential backoff: 1s → 2s → 4s → 8s → ... → 300s (capped) - Messages stay in the queue indefinitely as long as the process runs - Processed in batches of 20 during each `process()` tick @@ -177,7 +177,7 @@ the bridges hand both answers to the SDK, which checks them and reports through |---|---|---| | `AddressDeclared` | echoed address == `local_address()` | none — this is the expected outcome | | `AddressDeclared` | echoed address is anything else | `RELAY_ADDRESS_BINDING_MISMATCH`, `peer_id` = the address the relay bound | -| `AddressError` | — | `RELAY_ADDRESS_DECLARATION_REFUSED`, `peer_id` = this device, `reason` = the relay's text | +| `AddressError` | — | `RELAY_ADDRESS_DECLARATION_REFUSED`, `peer_id` = this device, `reason` = a fixed local classification (the relay's own wording never travels onto the event) | Both are reported and neither is acted on. A mismatch has no benign reading — the relay verifies that the declared address derives from the key that signed diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md new file mode 100644 index 00000000..6239ad1f --- /dev/null +++ b/docs/security/threat-model.md @@ -0,0 +1,394 @@ +# Threat model and trust boundaries + +This document states what the Offline Protocol defends, against whom, and what +it does not defend. It is the reference for judging whether a proposed change +weakens a security property. + +For vulnerability reporting and safe harbor, see [SECURITY.md](../../SECURITY.md). + +## Assets + +Ranked by what an attacker gains from compromising them. + +| Asset | Where it lives | Loss means | +|-------|----------------|------------| +| Identity private key | Platform secure storage | Total impersonation of the identity, retroactive and prospective | +| MLS group secrets | Platform secure storage, via the MLS provider | Reading all traffic in that group for as long as the epoch stands | +| Message plaintext | In memory, and in the outbox until acknowledged | Disclosure of conversation content | +| Cloud media keys | Inside the sealed rich payload | Decryption of media stored on a third-party host | +| Social graph | Inferable from mesh frames and relay routing | Who talks to whom, when, and from where | +| Delivery metadata | Acknowledgements, presence, receipts | Liveness and behaviour patterns of a target | + +The social graph is deliberately listed as an asset. In an offline-first mesh +the traffic pattern is visible to every device in radio range, and the protocol +reduces but does not eliminate what that reveals. + +## Adversary classes + +| Class | Position | Capabilities assumed | +|-------|----------|---------------------| +| **A1. Passive radio observer** | In range of a mesh transport | Reads every frame sent nearby | +| **A2. Active network attacker** | On any path, including the pre-session bootstrap | Injects, drops, reorders, replays, and re-addresses frames | +| **A3. Hostile relay** | Operates or has compromised the internet relay | Everything A2 has, plus chooses delivery order, plus originates relay answers | +| **A4. Group insider** | Holds a legitimate leaf in a group | Everything a member can do, plus can craft frames as a member | +| **A5. Malicious application** | Runs above the SDK on the same device | Full access to the SDK's public API | +| **A6. Device compromise** | Root or equivalent on the device | Everything | + +A6 is out of scope. The protocol assumes platform secure storage holds. A5 is +partly in scope: the SDK refuses control-frame injection through public send +surfaces and refuses to hand back attacker-chosen identifiers, but it does not +defend an application against itself. + +## Trust boundaries + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Application process │ +│ │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ Application code (A5) │ │ +│ └───────────────────────────────────────────────────────┘ │ +│ │ boundary 1: public API + reserved prefix refusal │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ Language binding / bridge │ │ +│ └───────────────────────────────────────────────────────┘ │ +│ │ boundary 2: FFI contract, event JSON, entry points │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ Protocol core (Rust) │ │ +│ │ ┌─────────────────────────────────────────────────┐ │ │ +│ │ │ MLS state, identity key │ │ │ +│ │ └─────────────────────────────────────────────────┘ │ │ +│ │ │ boundary 3: platform secure storage │ │ +│ └───────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ boundary 4: control-plane signature gate +┌──────┴──────────────────┐ ┌──────────────────────────────┐ +│ Mesh peer (A1,A2)│ │ Internet relay (A3) │ +└─────────────────────────┘ └──────────────────────────────┘ + │ boundary 5: MLS AEAD (end to end, survives both) +``` + +### Boundary 1: application to SDK + +**Enforced by:** reserved prefix refusal on every public send surface; size +caps at the API boundary rather than deeper; refusal to accept a transport peer +identity from the caller. + +**Assumption:** the application is not the adversary, but is not trusted to be +careful. + +### Boundary 2: bridge to core + +**Enforced by:** a fixed FFI surface, opaque event payloads, and, for anything +security-relevant, a **dedicated entry point** rather than message-plane +injection. + +The group delivery report is the worked example. It arrives through its own +entry point, which makes it unforgeable by anything that can reach the generic +notification injector. Anything with equivalent authority MUST follow it. + +See [Bridge contracts](../bridges/README.md). + +### Boundary 3: process to secure storage + +**Enforced by:** the platform (Keychain, Keystore, equivalent), reached through +a storage interface the application implements. + +**Assumption:** the platform store is confidential and integral. Sealing is not +integrity: an implementation that seals records but permits deletion of a state +key must consider what deleting that key reopens. + +### Boundary 4: this device to any peer or relay + +**Enforced by:** the control-plane signature gate. See +[Control messages](../spec/control-messages.md#the-control-plane-signature-gate). + +The essential property is that verification derives an address from the +presented public key and compares it to the claimed sender. Without that step, +signature verification proves nothing about identity, and the protocol would +need a trust-on-first-use store, which is what this replaced. + +Two exemption classes cross this boundary and are documented in full in the spec. +Both are stated here because they are the largest deliberate holes in the +design: + +| Exemption | What it means | +|-----------|---------------| +| Data plane (`__MLS_ENC__`, `__GROUP_MSG__`) | Authenticated **later**, by MLS decryption plus the credential comparison | +| Relay answers (six prefixes) | **Not authenticated by this protocol at all** | + +### Boundary 5: end to end + +**Enforced by:** MLS (RFC 9420), plus the application-side leaf identity binding +that RFC 9420 assigns to the application and that MLS implementations do not +perform. + +This is the only boundary that survives a hostile relay. + +## Controls, and the attack each answers + +| Control | Answers | +|---------|---------| +| Self-certifying addresses | Impersonation by name claim (A2, A3) | +| Canonical signing payload with domain separation and length prefixes | Signature replay across purposes, delimiter-shift forgery (A2) | +| Address derivation from the presented key | Signature-by-any-key forgery (A2, A3) | +| Envelope slot binding | One derivable session identifier aimed at arbitrary peers (A2) | +| Leaf identity binding, three seams | A member speaking as another member (A4) | +| Wire-sender to credential comparison | Relay re-attribution of group messages (A3) | +| Plaintext-naming-an-MLS-group drop | Downgrade of a secured group to plaintext (A2, A3) | +| Sealed rich payload | Relay-visible reply previews, media keys, forward attribution (A3) | +| Withholding acknowledgements on refusal | Liveness confirmation to an injector (A2) | +| Report rate limits | Report flooding by an insider (A4) | +| Bounded tracking maps | Memory growth driven by attacker-chosen keys (A2) | +| Telemetry classification to fixed vocabularies | Third-party identifier disclosure through unscrubbed event text (A2, A3) | + +## The acknowledgement channel is a side channel + +An acknowledgement confirms to whoever sent a frame that the target is live and +processing. That makes the acknowledgement decision a security decision, not +only a reliability one. + +The rule the protocol settled on: + +- A frame refused on **security** grounds gets **no acknowledgement**, and its + identifier is unmarked. That covers the signature gate, inbound plaintext + refused by encryption policy, and all four identity bindings (sender identity, + session slot, leaf address, unsupported sender), which are intercepted + *before* classification precisely so they cannot inherit the policy + disposition below. The session slot binding runs before any AEAD, since every + failure the MLS library raises below it happens before it authenticates + anything; the credential comparisons run once decryption has succeeded. + Acknowledging would confirm to an attacker that this device is online and + processing, and unmarking is what stops an exact replay from reaching the + duplicate re-acknowledgement path and leaking the same fact anyway. +- A frame refused on **policy** grounds keeps its acknowledgement **when the + refusal happens on the arrival path**. A membership commit refused by opt-in + enforcement is the case in point: the refusal is permanent, so a resend could + only waste work. A frame already deferred into the group buffer that later + resolves as a policy refusal is never acknowledged, because the arrival path + withheld the acknowledgement already. +- A frame that failed for a **recoverable** reason gets no acknowledgement, so + the sender's resend is the recovery path. + +Both refusals are permanent, so what separates them is neither authentication +nor recoverability: a **policy** refusal is a statement about a *frame*, while a +**security** refusal is a statement about an *attacker*, and answering the second +at all is the leak. Being authenticated does not move a frame into the policy +row. A group message is signature-gated, so its wire sender is proven, and a +leaf credential that fails to bind still refuses it silently. + +Consequences for application teams are in +[Delivery and ACKs](../state-machines/delivery-and-acks.md). The headline: **a +missing acknowledgement is not proof of non-delivery.** + +## Known residual risks + +Stated plainly, because a threat model that lists only what it defeats is +marketing. + +### R1. Relay answer forgery + +Anything able to inject on the relay ingest path can forge the six relay-answer +prefixes: group registration, membership add and remove, group info, the user's +group list, and error reports. + +**Impact:** a forged registration flips the sync gate that group broadcast rides +on, but only inside an armed window: the flag is set only for a frame that +arrives on the internet transport, names a group this device already tracks, and +correlates with a registration this device has outstanding. Forged membership +answers corrupt the members cache, which is **not** the MLS roster and is never +read by roster-derived logic, but is read verbatim as the group fan-out send +cache: an accepted forgery therefore makes this device address every subsequent +group ciphertext to an attacker-chosen identifier, or stop addressing a real +member. The same list feeds the sealed rich payload gate, which requires every +non-self member to be known rich-capable, so an unknown spliced identifier +closes it: reply context and forward attribution move from inside the MLS AEAD +to hop-visible cleartext and media secrets are dropped, until the next commit +refreshes the cache. That defeats a control this document lists by name, reached +by the adversary that control names. Adds are gated on internet arrival and +removes on an administrator check, which is what bounds all of this. + +**Why it stands:** these frames have no signer. Closing it means moving relay +answers onto dedicated entry points. + +**Mitigations in place:** the bridge restricts these prefixes to the relay +channel, and the exemption applies only to internet-transport frames carrying no +carrier identity. + +### R2. Unauthenticated session desync trigger + +Anyone who can inject a frame can drive a 1:1 session to a desync +classification, with no key material, no captured ciphertext, no session, and no +replay. + +**Why it stands, and why it cannot be fixed at this layer:** the encrypted +prefix is data-plane and deliberately signature-exempt; MLS validates the +framing header (group identifier, then epoch) **before** any AEAD, sender-data, +or signature check; and a 1:1 slot identifier is a public function of two public +addresses. The MLS credential that would authenticate the sender exists only +once decryption **succeeds**. + +**Why it is tolerable:** the mitigation is that acting on the trigger is +**harmless**, not that the trigger is trusted. + +- Slot binding means one derivable identifier cannot be aimed at arbitrary + peers, and the tracking map cannot be grown with attacker-chosen keys. +- A per-peer rate limit bounds the churn. +- The heal destroys nothing: the **desync re-key** reset keeps the outbound + pending queue, which holds plaintext and is sealed against the rebuilt session + at flush time. (The post-unblock reset is the other kind and deliberately + drops that queue, failing each entry terminally. See + [Session lifecycle](../state-machines/session-lifecycle.md).) +- Every re-key emits a security warning, so a sustained rate is visible. +- Each resend of an encrypted direct message is re-sealed against a live + generation, **for entries whose re-seal provenance survives in memory**. That + provenance is deliberately not persisted, so a restart drops it. + +**Residual:** bounded re-key churn on a pair. Delivery delayed, never *silently* +lost: a fork that spans a sender restart settles as an honest failure rather +than a false delivery. + +**What would close it:** a signed epoch-corroboration exchange before teardown. +A liveness-only probe does **not** work, because a healthy peer answers and the +teardown happens anyway. + +### R3. Address collision margin + +The 160-bit address hash gives ~2^80 collision resistance. An attacker who finds +a collision holds two signing keys indistinguishable at the address layer, which +is enough to equivocate and enough to defeat the one-identity-one-leaf property +the group binding otherwise inherits from MLS signature-key uniqueness. + +Second-preimage resistance, which is what impersonation of an existing peer +requires, remains ~2^160. + +**Why it stands:** every mesh frame carries two addresses and the Bluetooth LE +budget is the binding constraint. Widening is a version bump and a migration. + +### R4. Divergent administrative views + +Opt-in commit enforcement acts only on a **present** administrative set that +positively excludes a principal; absent knowledge of that set fails open. It +cannot detect a **divergent** view: two honest members with different role +snapshots each hold a non-empty set, so they reject each other's commits and +partition. + +**Why it stands:** the administrative overlay replicates best-effort by design, +and rejecting a commit forks you permanently from everyone who accepted it. + +**Mitigation:** enforcement is opt-in, and is documented as suitable only for a +closed deployment that controls role distribution, never for part of a fleet: +a member with it off applies the commit a member with it on refuses. + +### R5. Unauthenticated plaintext in MLS-free groups + +A group with no MLS state accepts unauthenticated plaintext on the relay group +fan-out prefix. + +**Why it stands:** identical to pre-gate behaviour, and unreachable in +deployments where every group is MLS-secured. + +### R6. Broadcast tracker is memory-only + +A process kill inside the delivery-report window loses the re-issue backstop for +that broadcast. + +**Impact:** members the relay missed are not re-issued to. The sender's outbox +does not cover it, because the broadcast was one frame. + +### R7. Capability negotiation is unsigned + +The three capability lists are not bound to the sender's signature. Forging one +onto a legacy peer is a targeted delivery denial of service. It grants nothing +else, and an attacker in that position can already drop packets. + +`nostr_pubkey` is the exception and is honoured only from a signed key package, +because it is consumed as a destination key rather than a feature hint. + +### R8. Fan-out timing at large group sizes + +Past roughly 118 members, per-member fan-out's tail exceeds the acknowledgement +timeout and frames are retransmitted before they were ever written. Duplicates +are absorbed by deduplication, so this is wasted work rather than loss, but it +is a real scaling cliff. + +### R9. Service discovery and service bodies are signed, not encrypted + +The service prefix family (`__SVC_DISC_Q__`, `__SVC_DISC_R__`, `__SVC_REQ__`, +`__SVC_RESP__`, and the generic service message) is control-plane: every frame +is signature-gated, so its sender is proven, and every frame is **exempt from +the encryption requirement**. Discovery gossip and the application-supplied +request and response bodies therefore travel in cleartext. + +**Impact:** A1 and A3 read service bodies and the full discovery pattern. This +is the one application-supplied payload that boundary 5 does **not** cover, so +"MLS protects application content" does not hold here. + +**Why it stands:** discovery is a broadcast to peers with whom no session +necessarily exists, so there is no established group to encrypt to; encrypting +request and response bodies is not implemented. + +**What application teams must do today:** treat a service body as public, and +encrypt anything sensitive above the SDK before handing it over. + +## The telemetry producer rule + +Telemetry ships some string fields verbatim by design. The scrubber hashes +identifiers it knows about, but it cannot know that a free-text field contains +one. + +The burden therefore sits entirely on producers, and the rule is absolute: + +> **An event field never carries text chosen by a remote party, nor a rendered +> error that interpolates one.** + +Not shortened, not sanitized in place. **Classified**, to a fixed local +vocabulary, with the remote wording kept bounded in a device log if it is worth +keeping at all. + +Two habits follow, and both are structural rather than advisory: + +1. **Return a static string type from the classifier**, so interpolating wire + input is unrepresentable rather than merely discouraged. Push that type all + the way into the event constructor, so a producer cannot hand it a rendering. +2. **Make the classifier's match exhaustive in the crate that defines the error + type.** A newly added variant then fails to compile *there*, forcing the + privacy decision to be made where variants are written. + +**Never add a catch-all arm to a classifier that matches on an enum.** A +catch-all restores the per-site opt-in that the exhaustive match replaced, and +the leaks this rule exists to stop were all per-site omissions. + +A classifier over an open wire **string** is the one exception, because the +input set is unbounded and a final arm is unavoidable. It conforms on one +condition: **the fallback returns a fixed token and never the input.** A +fallback that echoes what it did not recognize is this leak wearing a default's +clothing. See +[ADR 0013](../adr/0013-exhaustive-privacy-classifier.md). + +When the dropped prose carried real structure, add it back as a **typed field** +the scrubber can hash, not as prose. + +### Why this became a rule rather than a set of fixes + +The first fix scoped the substitution to the identity refusals, on the premise +that every other join failure was a fault rather than an accusation and named +nobody. That premise was false. Sibling arms at the same sites rendered a +session slot, which is two addresses, one of them possibly a third party's, plus +a sender-chosen string bounded by neither charset validation nor a length cap. + +A wider audit then found the same class in relay-answer-fed error reasons, in +control-gate warnings, and in transport send-failure text. The rule generalized +because the exceptions kept turning out not to be exceptions. + +## Non-goals + +- **Anonymity.** Sender and recipient addresses are visible on every mesh frame. + The protocol protects content, not the fact of communication. +- **Traffic analysis resistance.** No padding, no cover traffic, no timing + defence. +- **Defending an application against itself.** See A5. +- **Surviving device compromise.** See A6. +- **Byzantine group consensus.** Membership is MLS's, applied by every member. + The protocol reports unauthorized changes; it does not achieve agreement on + who may make them. diff --git a/docs/spec/README.md b/docs/spec/README.md new file mode 100644 index 00000000..9a0a305f --- /dev/null +++ b/docs/spec/README.md @@ -0,0 +1,61 @@ +# Offline Protocol Specification + +This directory specifies the Offline Protocol as a wire and behaviour contract, +independent of the Rust implementation in this repository. A second +implementation written against these documents, in any language, should +interoperate with this one. + +The Rust crates are the reference implementation, not the definition. Where a +document here and the code disagree, that is a bug in one of them, and the +document says which reading is normative for the wire. + +## Documents + +| Document | Scope | +|----------|-------| +| [Identity and addressing](identity.md) | Address derivation, canonical form, self-certification, session and group identifiers | +| [Message model and wire format](wire-format.md) | The abstract message, the JSON encoding floor, the binary v1 encoding, the extension TLV registry | +| [Control messages](control-messages.md) | The reserved prefix registry, control-plane signing, and the two exemption classes | +| [Encryption envelopes](encryption-envelopes.md) | The `__MLS_ENC__` envelope forms, the media chunk envelope, and the sealed rich payload | +| [Group protocol](group-protocol.md) | Group frames, membership commits, leaf identity binding, relay broadcast and the delivery report | +| [Capability negotiation](capability-negotiation.md) | What peers advertise, what each capability gates, and what happens on absence | + +## Conformance language + +The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are used as defined in +RFC 2119 and RFC 8174, and appear in capitals only when used in that sense. + +## Layering + +The protocol has four independent layers. Each has its own versioning and its +own failure mode, and a reader should keep them apart: + +1. **Transport framing.** How bytes reach the next hop. Bluetooth LE, Wi-Fi + Direct, Reticulum, Nostr, or an internet relay. Out of scope here except + where a transport constrains a payload size. +2. **Hop-local encoding.** How a `Message` becomes bytes for one hop. Either + the JSON floor or the binary v1 codec. Negotiated per peer via + `wire_versions`, and re-negotiated on every connection. +3. **End-to-end envelope.** How ciphertext and its addressing survive an + arbitrary number of relay hops. The `__MLS_ENC__` and media envelopes, + negotiated via `env_versions`. +4. **Sealed payload.** What travels inside the MLS AEAD boundary. Plain text, + or a `__RICH_V1__` body, negotiated via `rich_versions`. + +A change at one layer does not imply a change at another. The compact MLS +envelope and the binary wire codec ship independently and are gated by separate +switches, because a message can take the binary encoding on one hop and JSON on +the next while its envelope stays byte-identical end to end. + +## Two invariants that outrank everything else + +**JSON is the permanent floor.** Every conforming receiver MUST accept the JSON +encoding of a `Message`. A sender MAY emit a compact encoding only to a peer +that advertised it. Persistence and the internet relay path use JSON +unconditionally. No negotiation, capability, or configuration removes the +obligation to decode JSON. + +**Frozen encodings never change in place.** The binary v1 DTO field order and +the numeric enum mappings are a wire contract. Additive data goes in the +extension TLV section. A change that cannot be expressed additively takes a new +version identifier and is negotiated, never assumed. diff --git a/docs/spec/capability-negotiation.md b/docs/spec/capability-negotiation.md new file mode 100644 index 00000000..8e0cbd21 --- /dev/null +++ b/docs/spec/capability-negotiation.md @@ -0,0 +1,137 @@ +# Capability negotiation + +## Where capabilities are advertised + +Peers advertise what they can parse in the key package payload, the body of a +`__MLS_KEY_PKG__` control frame. + +| Field | Layer | Gates | Absent means | +|-------|-------|-------|--------------| +| `wire_versions` | Hop-local | Which frame encodings we may emit to this peer | JSON only | +| `env_versions` | End to end | Which `__MLS_ENC__` payload forms we may emit | Legacy JSON envelope only | +| `rich_versions` | End to end | Whether we may seal a `__RICH_V1__` body, and the v2 media envelope | Plain text only, extras dropped | +| `nostr_pubkey` | End to end | Which key metadata is sealed to on the Nostr path | Seal to the publicly computable key | + +The key package payload also carries `user_id`, the MLS key package itself, a +**relative** remaining lifetime in milliseconds, and a `session_reset` flag. + +The lifetime is relative rather than absolute so the receiver applies it to +their own clock and clock skew does not expire a valid package. + +## The universal rules + +Three rules apply to every capability in this protocol. They are what make +mixed-fleet deployments safe. + +### 1. Parsing is unconditional; emission is gated + +A receiver accepts every historical form of everything, regardless of what it +advertised. Capability governs only what a sender **emits**. + +An implementation that gates parsing on its own advertisement drops messages +from peers that legitimately believed it capable, for instance after a partial +upgrade, or after a capability was restored from persistence on one side and not +the other. + +### 2. Absence means the floor, never an error + +An absent capability list decodes to empty, and empty selects the permanent +floor. A legacy peer that has never heard of a field is served the floor form +automatically. + +### 3. Downgrade loses the feature, never the confidentiality + +Where a capability gates a security-relevant payload, the non-capable path +**drops** the payload rather than sending it in a weaker form. Rich extras +toward a non-capable recipient are dropped, never sent in cleartext. + +## Persistence + +`env_versions` and `rich_versions` are **end to end**: they describe what a +recipient parses after an arbitrary number of relay hops. They MUST persist +across restarts and be restored before any queued send flushes. Otherwise a +restart silently downgrades every established peer until the next key package +exchange, and the queued sends that flush at startup take the downgrade. + +`wire_versions` is **hop-local** and deliberately in-memory only. It describes +what the next hop decodes, and it is re-exchanged on connect. + +Per-peer capability state is stored separately from the key package cache. The +key package cache is deleted when a session is created; the capabilities must +outlive it. + +## Trust boundary + +**The three capability lists are not cryptographically bound to the sender.** + +They ride in the plaintext key package envelope *alongside* the signed MLS key +package data, not *inside* the signature. A network attacker positioned on the +pre-session bootstrap can: + +- **strip** a list, which downgrades to the floor and is harmless, +- **forge** a list onto a legacy peer, which makes us emit frames that peer + cannot parse, which is a targeted delivery denial of service. + +Neither grants a new capability. Such an attacker already controls key package +delivery and could deny service outright by dropping the packet. + +**These are performance and feature negotiations. They are never security +controls.** An implementation MUST NOT derive a security decision from them. + +### The exception: `nostr_pubkey` + +This field rides in the same plaintext envelope but is consumed as a +**destination key**, not as a feature hint. The distinction matters: a wrong +capability costs a fallback, whereas a wrong key here means envelope metadata is +sealed **to whoever supplied it** and is then readable off a public relay, +passively, for as long as the value stands. + +It is therefore honoured **only from a signed key package**. The canonical +signing payload covers the whole key package body, and the gate verifies it +against the key the sender's address derives from, so on this prefix an unsigned +frame does not reach dispatch at all. + +Stripping it remains possible and downgrades to the bootstrap key. That is a +privacy downgrade, not a disclosure to the attacker, and one they could equally +achieve by dropping the packet. + +## Relay capabilities + +The relay advertises its own capability set, which is separate from peer +capabilities and arrives in the relay's authentication answer. + +Two ordering requirements: + +1. The capability set MUST be injected **before** the internet-available + transition, so the flush that transition triggers already sees it. +2. It MUST be cleared when internet drops. + +The set is bounded (64 entries of 128 bytes in the reference implementation). + +Relay capability tokens are opaque strings. The one this protocol defines is +`group_delivery_v3`; see [Group protocol](group-protocol.md) for why the version +in the token is load-bearing. + +## Group capability attestation + +Rich payload capability in a group is established directly, or by inviter +attestation when a member was added by someone else. See +[Encryption envelopes](encryption-envelopes.md#group-sealing-gate). + +Attestation feeds **only** the group sealing gate. It MUST NOT feed 1:1 sealing +and MUST NOT feed envelope selection. It is a second-hand claim, adequate for +deciding whether to include optional context in a group message, not adequate +for anything else. + +## Adding a capability + +1. Decide the layer. Hop-local capabilities are in-memory and re-exchanged; + end-to-end capabilities persist. +2. Add the field with a default of empty, so legacy peers decode cleanly. +3. Make the receiving path accept the new form **unconditionally**, and ship + that release first. +4. Only in a later release, start emitting the new form to peers that advertise + it. + +Step 3 before step 4 is not optional. Reversing them means the first peer to +upgrade emits a form no deployed receiver understands. diff --git a/docs/spec/control-messages.md b/docs/spec/control-messages.md new file mode 100644 index 00000000..04b04b53 --- /dev/null +++ b/docs/spec/control-messages.md @@ -0,0 +1,256 @@ +# Control messages + +## The prefix convention + +Protocol-internal messages are ordinary messages whose `content` begins with a +reserved prefix. The prefix names the frame type; the rest of `content` is the +frame body, usually JSON. + +**Where base64 appears in a frame body.** It carries MLS bytes, and within the +body only in these places (message metadata is separate; see the reserved keys +in [Message model and wire format](wire-format.md#reserved-metadata-keys)): + +| Carrier | Shape | +|---------|-------| +| `__MLS_ENC__` body | base64 of the compact binary envelope, **only** when the compact envelope is negotiated for that recipient; the JSON floor carries `ciphertext` as a JSON byte array instead | +| `__GRP_MLS_MSG__` body | JSON payload whose `ciphertext` field is base64 | +| `__GRP_MLS_COMMIT__` body | JSON payload whose `ciphertext` field is base64 | +| `__GRP_RELAY_BCAST__` body | JSON payload whose `ciphertext` field is base64 | +| `__GRP_MLS_WELCOME__` body | JSON payload whose `welcome_data` field is base64 | +| `__GROUP_MSG__` body | base64 of the MLS ciphertext directly, with no JSON wrapper; a decode failure is treated as legacy plaintext, which is then refused for any group the receiver secures with MLS | + +Two consequences are worth stating, because both have been read the wrong way: + +- The 1:1 `__MLS_WELCOME__` body is **not** base64. It is a JSON + `WelcomeMessage` whose MLS bytes are a JSON byte array. +- `__MLS_ENC__` is the only prefix whose body shape depends on negotiation. + Receivers sniff the byte after the prefix (`{` means the JSON floor), so no + per-message signalling is needed. + +A frame body outside this table is not base64. + +This is a deliberately low-tech multiplexing scheme. It costs prefix bytes on +every control frame, and it buys the ability to carry control traffic over any +transport that can carry a message, with no separate channel, no separate +framing, and no separate delivery machinery. + +## Reserved prefix registry + +Every prefix in this table is reserved. An implementation MUST reject an +application `send` whose content begins with any of them, on every public send +surface. Without that check, application text is a control-frame injection +vector. + +### Session establishment + +| Prefix | Direction | Body | +|--------|-----------|------| +| `__MLS_KEY_PKG__` | peer to peer | Key package payload, JSON | +| `__MLS_WELCOME__` | peer to peer | JSON `WelcomeMessage`; the MLS Welcome bytes are a JSON byte array inside it, not base64 | +| `__MLS_ENC__` | peer to peer | Encrypted envelope, see [Encryption envelopes](encryption-envelopes.md) | +| `__MLS_CONFIRM_PROBE__` | peer to peer | Session confirmation probe | +| `__MLS_CONFIRM_ACK__` | peer to peer | Session confirmation acknowledgement | + +### Prefixes that never appear on the wire + +Two prefixes are reserved but travel only **inside** an encrypted envelope. They +are listed in the registry so application content can never impersonate them. + +| Prefix | Where it lives | Purpose | +|--------|----------------|---------| +| `__MLS_ENC_CONFIRM__` | Inside an `__MLS_ENC__` envelope | A group-aware decrypt that lets the both-create session owner converge. Consumed on receipt, never surfaced to the application | +| `__RICH_V1__` | Inside the decrypted MLS plaintext | The sealed rich payload body | + +### Connection lifecycle + +| Prefix | Meaning | +|--------|---------| +| `__CONN_REQ__` | Connection request | +| `__CONN_ACC__` | Connection accepted | +| `__CONN_REJ__` | Connection rejected | +| `__CONN_CAN__` | Connection cancelled | + +### Group frames originated by peers + +| Prefix | Meaning | +|--------|---------| +| `__GRP_MLS_MSG__` | MLS-encrypted group application message | +| `__GRP_MLS_WELCOME__` | Group invite carrying an MLS Welcome | +| `__GRP_MLS_COMMIT__` | Membership change commit | +| `__GRP_MLS_LEAVE__` | Leave notification | +| `__GRP_ROLE_CHG__` | Role change notification | +| `__GRP_RENAME__` | Group rename notification | + +### Group frames originated by the relay + +| Prefix | Meaning | +|--------|---------| +| `__GROUP_CREATED__` | Group registration confirmed | +| `__GROUP_MSG__` | Relay group fan-out | +| `__GROUP_MEMBER_ADDED__` | Relay-side membership add | +| `__GROUP_MEMBER_REMOVED__` | Relay-side membership remove | +| `__GROUP_INFO__` | Group metadata answer | +| `__USER_GROUPS__` | The user's group list | +| `__GROUP_ERROR__` | Relay-side error report | + +### Relay hint frames + +| Prefix | Meaning | +|--------|---------| +| `__GRP_RELAY_REG__` | Register the group roster with the relay | +| `__GRP_RELAY_BCAST__` | Ask the relay to fan a group message out | + +These two are **self-addressed** frames that the local bridge intercepts and +replaces with relay-native frames. They never reach a peer. Their handling has +two mandatory properties, and both are load-bearing: + +1. They MUST be sent with acknowledgement disabled. Because the frame is + replaced rather than transmitted, no acknowledgement can ever come back. On + the ordinary acknowledgement ladder an unacknowledgeable frame is + retransmitted for the full retry budget, each resend costing another full + relay fan-out, ending in a delivery failure for an identifier the + application never saw plus a transport-selector penalty for a transport that + did nothing wrong. +2. They MUST be pinned to the internet transport rather than routed by the + transport selector. The selector demotes the internet transport by design, + and some mesh transports swallow a self-addressed frame: Wi-Fi Direct and + Reticulum enqueue it unconditionally and report success. Bluetooth LE fails + closed, because self is never a connected peer, but that only helps on a + BLE-only device: a synchronous refusal is a fallback trigger, so the frame + reaches one of the others anyway. The caller then believes the broadcast + succeeded and skips its per-member fallback, delivering to nobody. One such + transport is enough to lose the frame, which is why the rule is a pin rather + than a preference. + +Retry policy for these frames lives at the application layer instead, with +explicit trackers and bounded attempts. + +### Presence and indicators + +| Prefix | Meaning | +|--------|---------| +| `__PRESENCE__` | Presence update | +| `__TYPING__` | Typing indicator | +| `__READ_RECEIPT__` | Read receipt | + +### Service discovery + +Service messages use their own prefix family: `__SVC_DISC_Q__`, +`__SVC_DISC_R__`, `__SVC_REQ__`, `__SVC_RESP__`, plus a generic service message +prefix. They are reserved on the same terms. + +They are signature-gated like any control frame, but they are **exempt from the +encryption requirement**, so discovery gossip and the application-supplied +request and response bodies are sent in cleartext. See +[residual risk R9](../security/threat-model.md#r9-service-discovery-and-service-bodies-are-signed-not-encrypted). + +## The control-plane signature gate + +Control frames are authenticated with an Ed25519 signature over a canonical +payload, verified against the key the claimed sender's address derives from. + +### Canonical payload + +``` +CTRL_SIGN_DOMAIN || + u32be(len(sender)) || sender || + u32be(len(id)) || id || + u32be(len(recipient)) || recipient || + u32be(len(content)) || content +``` + +Three properties matter: + +- **Domain separation.** The payload opens with a fixed domain constant, so a + signature produced for this purpose cannot be replayed as a signature for any + other purpose that shares the identity key. +- **Length prefixing.** Every field is length-prefixed, so no two distinct field + tuples produce the same byte string. Concatenation without length prefixes is + forgeable by shifting a delimiter. +- **Big-endian lengths.** Fixed so implementations agree. + +The signature and the signer's public key ride in the message metadata. + +### Verification + +A verifier MUST: + +1. Recompute the canonical payload from the received frame. +2. Verify the signature against the public key carried in the metadata. +3. **Derive an address from that public key and check it equals the claimed + `sender`.** + +Step 3 is the step that makes the gate meaningful. Steps 1 and 2 alone prove +only that whoever supplied the public key also supplied a matching signature, +which any party can do for any name. Deriving the address from the presented key +is what binds the signature to the claimed identity, and it is why this protocol +needs no trust-on-first-use store. + +### Exemption class 1: the data plane + +Two prefixes are exempt because they are authenticated later, by MLS, rather +than by an Ed25519 signature: + +| Prefix | Why exempt | What authenticates it instead | +|--------|-----------|-------------------------------| +| `__MLS_ENC__` | 1:1 envelopes are sent through the ordinary send path and never signed outbound | MLS decryption, plus the credential-to-wire-sender comparison | +| `__GROUP_MSG__` | The relay re-emits it per member from only `{group_id, sender, content}`, so the rebuilt frame is structurally unsigned | MLS decryption, plus the credential-to-wire-sender comparison; plaintext naming an MLS-secured group is dropped as spoofing | + +The list is maintained as an **exclusion** list rather than an inclusion list, so +a newly added prefix is security-gated by default. An implementation MUST NOT +invert that: an inclusion list means every forgotten prefix is silently +ungated. + +Residual, stated plainly: a group with no MLS state accepts unauthenticated +plaintext on `__GROUP_MSG__`. That is identical to the pre-gate behaviour and +unreachable in deployments where every group is MLS-secured. + +### Exemption class 2: relay answers + +Six prefixes are exempt for a different reason, and the two reasons MUST NOT be +conflated. A data-plane frame is authenticated later. A relay answer is not +authenticated by this protocol at all. + +`__GROUP_CREATED__`, `__GROUP_MEMBER_ADDED__`, `__GROUP_MEMBER_REMOVED__`, +`__GROUP_INFO__`, `__USER_GROUPS__`, `__GROUP_ERROR__`. + +These are not frames any peer transmitted. The relay answers over its own +channel and the local bridge synthesizes a message from that answer. There is no +private key anywhere in that path, so requiring a signature would drop every one +of them, taking group registration with it, and with that the sync gate that +group broadcast depends on. + +Two things protect them, neither of them a signature: + +1. The bridge restricts these prefixes to the relay channel, so a mesh peer + cannot deliver a crafted relay answer through the ordinary message path. +2. The exemption is **narrower than the prefix**: it applies only to a frame + that arrived on the internet transport carrying no transport peer identity, + which is the shape a locally synthesized answer has. A peer frame on a mesh + transport, or one carrying a carrier identity, is still required to be + signed. + +**Residual, stated plainly:** anything able to inject on the relay ingest path +can forge these frames. That is the pre-existing relay-trust surface. Closing it +means moving relay answers off the message plane onto dedicated entry points, the +way the group delivery report already works. See +[Threat model](../security/threat-model.md). + +### The two exemption lists must stay disjoint + +A prefix in both lists would make the narrow relay conditions unreachable for +it, because the data-plane exclusion is consulted first. Implementations SHOULD +assert disjointness in a test. + +### Hand-mirrored lists + +The relay-answer exemption list exists in three places that no single compiler +sees together: the protocol core and each native bridge. A prefix present in one +copy and absent from another fails **silently**: the bridge injects the answer +unattributed, the gate declines to exempt it, and the frame is dropped as +unsigned with no peer at fault. + +Each copy MUST be pinned against literals in its own language's test suite. A +test that recomputes the list from the constant it is checking agrees with any +edit, which is precisely the failure mode. See +[Bridge contracts](../bridges/README.md). diff --git a/docs/spec/encryption-envelopes.md b/docs/spec/encryption-envelopes.md new file mode 100644 index 00000000..db2a1347 --- /dev/null +++ b/docs/spec/encryption-envelopes.md @@ -0,0 +1,243 @@ +# Encryption envelopes + +Three envelope formats carry ciphertext, at three different layers. A message +may take any combination of them. + +| Envelope | Layer | Carried in | Negotiated by | +|----------|-------|-----------|---------------| +| `__MLS_ENC__` | End to end, 1:1 and group | `content` | `env_versions` | +| Media chunk envelope | End to end, media | `binary_content` | `rich_versions` for the v2 form | +| `__RICH_V1__` sealed body | Inside the MLS plaintext | The decrypted plaintext | `rich_versions` | + +They are not independently negotiated. Only `__MLS_ENC__` has a capability of +its own; the media envelope's v2 form and the sealed rich body **share +`rich_versions`**, so advertising it enables both, and a change to what that +token means moves two envelopes at once. + +## The MLS encrypted message + +All three envelope forms ultimately carry the same structure: an MLS ciphertext +plus the routing information a receiver needs before it can decrypt. + +| Field | Purpose | +|-------|---------| +| `group_id` | Which MLS group or 1:1 session slot this belongs to | +| `sender_id` | Claimed sender | +| `message_type` | Application message, commit, welcome, proposal | +| `epoch` | MLS epoch the ciphertext was sealed at | +| `timestamp_ms` | Wall clock, display only | +| `ciphertext` | The MLS message | + +### Compact binary encoding + +``` +u32le len(group_id) || group_id (UTF-8) +u32le len(sender_id) || sender_id (UTF-8) +u8 message_type +u64le epoch +u64le timestamp_ms +u32le len(ciphertext) || ciphertext +``` + +All integers are little-endian. Decoders MUST bounds-check every length against +the remaining buffer before reading, and MUST NOT trust a length prefix to be +consistent with the buffer size. + +## The `__MLS_ENC__` envelope + +The prefix is followed by one of three forms. A receiver distinguishes the first +by the **byte immediately after the prefix**, and the other two by attempting +them in order: + +| First byte after prefix | Form | +|-------------------------|------| +| `{` | Legacy JSON, parsed directly | +| anything else | Base64. Decode, then try the compact binary encoding, and fall back to JSON inside the base64 | + +Base64 output never begins with `{`, so separating the first row is total. The +two base64 forms are separated by trying the compact encoding first, which is +safe rather than merely conventional: base64-wrapped JSON decodes to bytes +starting `{"`, which read as a group identifier length far above the compact +decoder's 4 KB cap, so it is rejected deterministically and falls through. + +A conforming receiver MUST accept all three. Emitting the base64-wrapped JSON +form is not required. + +**Parsing is unconditional.** A receiver accepts every historical form +regardless of what it advertised. Capability negotiation governs only what a +sender emits. An implementation that gates parsing on its own advertisement will +drop messages from peers that legitimately believed it capable. + +The compact form is roughly 2.7 times smaller than the JSON form, because the +JSON form renders the ciphertext as a decimal integer array at about 3.6 bytes +per byte. + +### Envelope slot binding + +Before attempting decryption of a 1:1 envelope, a receiver MUST check that the +envelope's `group_id` is the session slot it shares with the **claimed sender**, +computed as described in [Identity](identity.md). + +An envelope naming any other slot is refused without decryption. Without this +check, one derivable session identifier could be aimed at arbitrary peers. + +## The media chunk envelope + +File chunks ride in `binary_content`. Encrypted chunks are wrapped so a receiver +can tell them from legacy plaintext chunks and apply policy. + +``` +[magic: 2 bytes = "ML"][version: 1 byte][compact EncryptedMessage bytes] +``` + +| Version | Meaning | +|---------|---------| +| `0x01` | Payload is an MLS encrypted message; plaintext carries metadata and original content type | +| `0x02` | Same payload; the plaintext may additionally carry rich extras | + +### Disambiguation from legacy plaintext chunks + +Legacy plaintext chunks are raw serialized file chunks, which begin with a file +identifier length as a little-endian `u32` capped at 4096. Their second byte is +therefore the high byte of that length, at most `0x10`, which can never equal +`0x4C` (`L`). The two formats are unambiguous without a version negotiation. + +### The sealed plaintext layout + +``` +[flags: 1 byte] +[if flags & 0x01: u32le meta_len][media metadata JSON] +[if flags & 0x02: u8 oct_len][original content type string] +[if flags & 0x04: u32le rich_len][media rich extras JSON] +[chunk bytes: remainder] +``` + +Flag bits are **not additively safe on their own.** A decoder that ignored an +unknown bit would slurp that field's bytes as chunk data and silently corrupt +the file. Therefore: + +- Any chunk carrying a flag beyond a receiver's known set MUST ship under a + bumped envelope version, so an old decoder rejects it cleanly at the version + check instead of misparsing. +- A decoder MUST also reject unknown flag bits outright, as a backstop. + +Rich extras (caption, reply threading, quoted-reply context, forward +attribution) ship only on chunk 0, only under envelope v2, and only toward +recipients that advertised rich payload support. Additive fields go inside the +rich extras structure itself, which is self-describing JSON and needs no new +flag bit or envelope version. + +### What the envelope moved inside the AEAD boundary + +Before this envelope existed, the chunk-0 message leaked media metadata in +cleartext, including the file name and the preview thumbnail, plus the original +content type. Those now travel in the sealed plaintext. An implementation that +also writes them to the outer message reopens the leak. + +## The sealed rich payload + +Rich message extras travel inside the MLS AEAD boundary, wrapped around the text +before encryption: + +``` +__RICH_V1__ + JSON of: + { + text: string, + reply_context: optional, + media_metadata: optional, + forward_info: optional, + content_type: optional + } +``` + +### Why it exists + +The outer message fields are visible to every relay and every forwarding hop. +The rich fields include quoted-reply previews (which quote another message's +content), media metadata (which for cloud media includes the **encryption key +and initialization vector**), and forward attribution (which names the original +sender). None of that belongs on a relay-visible field. + +### Restore rules + +On receipt, a sealed body is **authoritative** over the outer copies. +Specifically: + +1. Strip the outer reply context unconditionally, sealed body or not. A + relay-visible quoted-reply preview is never trusted and never rendered. +2. If the plaintext carries a `__RICH_V1__` body, parse it and replace the rich + fields **wholesale**, absent values included, so a sealed body that omits a + field clears the outer copy rather than letting it show through. +3. Absent a sealed body, the remaining outer fields (media metadata, forward + attribution) **survive**. That is the deliberate fallback for senders that + predate sealing, and it is why rule 2 is scoped to the sealed branch rather + than applied as an unconditional wipe. +4. The sealed `content_type` hint, when present, overrides the outer value. + +Rule 4 closes a specific attack: without it a relay could restamp the rendering +hint in transit, and restamping it to `FileChunk` routes the decrypted message +into the file-transfer manager where it is dropped. A sealed `FileChunk` claim +is therefore **refused** on restore, mirroring the send boundary which refuses +it too. + +The `content_type` field is additive. Bodies sealed by older senders omit it, in +which case the outer value stands. + +### Parsing is unconditional, sealing is gated + +As with the envelope, a receiver tries to parse whatever a peer chose to seal. A +parse failure surfaces the raw text plus a warning rather than dropping an +authenticated message. + +Sealing is gated on the recipient having advertised rich payload support. Toward +a non-capable recipient the extras are **silently dropped, never sent in +cleartext**. That is the whole point: a downgrade must lose the feature, not the +confidentiality. + +### Hint-only bodies + +A fresh send with a non-`Text` content type seals a body carrying only the hint, +even with no extras present. In groups this is mandatory rather than +belt-and-braces: the group payload has no outer content-type carrier, so an +unsealed hint is lost entirely, not merely unprotected. + +### Forwards + +Forwarding seals the attribution and the original media metadata as extras +toward capable recipients. That is the only way a forwarded cloud media message +keeps its encryption key and initialization vector. + +Cleartext outer copies are kept as the legacy fallback for non-capable +recipients, with the secrets stripped at the wire boundary. A sealed restore +overwrites them wholesale. + +### Size bound + +Serialized rich extras are capped (32 KiB in the reference implementation). + +The cap MUST be enforced at the **API boundary**, not at seal time. A message +queued behind session establishment re-makes the seal decision when it flushes, +and a seal-time failure there would re-queue the message forever. Bounding at +the boundary means every queued blob is already known to seal. + +## Group sealing gate + +In a group, the sealed body is used only when **every** other member is known to +support it. A single unknown member fails the gate closed and the extras drop. + +Capability is established two ways: + +1. **Directly**, from a member's own advertised capability. +2. **By inviter attestation.** The Add commit carries the affected member's + capability to existing members, and the Welcome carries a capability map to + the joiner. Attestation chains across successive adds; direct exchange always + overrides it. + +Attestation entries are bounded to the joined MLS roster and admin-gated on the +commit. Attestation feeds **only** the group sealing gate, never 1:1 sealing and +never envelope selection. + +When the gate fails, the implementation reports which members were unknown and +backfills by pushing a key package to them once. Their automatic reply reopens +the gate, which is the recovery path for groups formed before attestation +existed. diff --git a/docs/spec/group-protocol.md b/docs/spec/group-protocol.md new file mode 100644 index 00000000..7ffac298 --- /dev/null +++ b/docs/spec/group-protocol.md @@ -0,0 +1,410 @@ +# Group protocol + +Groups are MLS groups (RFC 9420). This document specifies what this protocol +adds on top: how frames are addressed, how membership changes are judged, how +identities in the ratchet tree are bound to addresses, and how a message reaches +members who are not currently reachable over the mesh. + +## Frames + +| Prefix | Content | +|--------|---------| +| `__GRP_MLS_WELCOME__` | An MLS Welcome plus the invite metadata below | +| `__GRP_MLS_COMMIT__` | An MLS Commit for a membership change | +| `__GRP_MLS_MSG__` | An MLS application message | +| `__GRP_MLS_LEAVE__` | A leave notification | +| `__GRP_ROLE_CHG__` | An administrative role change | +| `__GRP_RENAME__` | A group rename | + +### Welcome metadata + +Beyond the MLS Welcome itself, the invite carries: + +- **`created_by`**: the inviter's creator of record for the group. Adopted + **first-write-wins** by the joiner. This is what makes the administrative + creator fallback reachable for joiners when a role snapshot arrives + incomplete. +- **`member_rich`**: a capability map for the existing roster, so a joiner can + seal rich payloads to members it has never exchanged key packages with. + Bounded to the joined MLS roster. + +### Commit metadata + +- **`affected_member_rich`**: the added member's capability, delivered to + existing members. Admin-gated on the commit, like the role field. + +## Leaf identity binding + +This is the application-side Authentication Service that RFC 9420 sections +5.3.1 and 7.3 assign to the application. **MLS does not do it for you.** + +### The rule + +Every leaf entering local group state MUST carry the address that its **own +signature key** derives to, using the derivation in [Identity](identity.md). + +### Why it is mandatory + +An MLS basic credential is a bare self-asserted string. The wire-sender to +credential comparison that authenticates group application messages therefore +proves only that the forger typed the name they wanted, unless something binds +the credential to the key. On the ungated group data plane that costs a forger +no signature from anyone. + +### Three seams, and they are not redundant + +| Seam | When | Scope | Covers | +|------|------|-------|--------| +| Welcome | Before joining | The **whole** ratchet tree | The inviter chooses the tree wholesale | +| Commit | Pre-merge | Every credential the commit introduces or changes | New and renamed leaves | +| Use | At the sender check | The sending leaf, resolved by index | A leaf that entered by neither gate | + +The Welcome walk is all-or-nothing by necessity. Joining while skipping bad +leaves would leave the joiner at an epoch computed over the **full** tree, +decrypting nothing. + +The use-time check is the only one that covers a leaf written directly into an +implementation's own key store, bypassing both entry gates. Import-time +validation plus use-time validation is the same pairing key package handling +uses. + +### The commit walk covers four sources + +Not two. An implementation that walks only Add and Update proposals leaves the +**cheapest** attack open. + +1. **The update path leaf.** A member renames their own leaf to a peer's + address. No new leaf, no invite needed. Dropping this source is the gap. +2. **Update proposals.** +3. **Add proposals.** +4. **Group context extensions, specifically external senders.** Refused + outright, as are all non-member senders. This protocol issues no external + commits and no external proposals. + +Source 2 is unreachable in this protocol today, and is kept deliberately: by +value, MLS attributes the proposal to the committer and forbids committing your +own update; by reference, the receiver must be holding the proposal, and this +protocol drops received proposal messages rather than storing them. A +propose-only API would make the loop live. + +### Non-address credentials are refused, never skipped + +"Nothing to derive, so pass" is the bypass. A credential that is not an address +is refused. + +### Unconditional, unlike administrative enforcement + +The verdict is computed from the commit's own bytes, so every honest member +reaches the same answer and a refusal forks the **attacker** off a group that +stays consistent. This is the property that administrative enforcement lacks, +and it is why this check is unconditional while that one is opt-in. + +### Refusal dispositions + +| Frame | Disposition | Why | +|-------|-------------|-----| +| `__GRP_MLS_MSG__` | No acknowledgement, never buffered, identifier unmarked | The refusal is an **attribution failure**: the wire sender and the MLS-authenticated author disagree, or a leaf does not prove the identity it claims. There is no party the acknowledgement is owed to, and sending one hands a liveness oracle to whoever forged the attribution | +| `__GRP_MLS_WELCOME__` | Consumed, so acknowledged and dedup-marked | The refusal is a **verdict on an attributable frame**, and it is permanent. Acknowledging stops a sender retransmitting something that can never be accepted; withholding would buy nothing, because the frame is refused on every copy | +| `__GRP_MLS_COMMIT__` | Consumed, as above | Same | + +**Signature gating is not what separates these rows.** All three prefixes are +control-plane frames and all three pass the signature gate before any of this +runs. The data-plane exemption covers only `__MLS_ENC__` and `__GROUP_MSG__`, +and the other exemption class, relay answers, covers frames the relay +originates rather than any of these (see +[Control messages](control-messages.md#exemption-class-1-the-data-plane)). The +row 1 refusals happen at the MLS layer, *after* the outer sender was proved, +which is exactly what makes them attribution failures: the proved outer sender +is contradicting the authenticated inner author. + +The unmark obligation in row 1 spans **both** deduplication layers, and the two +group paths do not currently discharge it alike. The relay path removes its +group-level entry on a security refusal. The mesh path does not: it marks the +envelope identifier before decrypting (to bound replay amplification to one +crypto operation per identifier) and never releases it on refusal, so a verbatim +replay is absorbed by the duplicate branch, which treats a marked-but-not-pending +identifier as already delivered and **acknowledges** it. + +The mesh behaviour is a defect against this specification, not the specification +describing itself. A conforming implementation MUST release the identifier on +the refusal path, in the mesh handler as well as the relay handler; the +replay-amplification bound survives, at a cost of one crypto operation per +replayed copy, which is the same trade the relay path already accepts. + +A refused commit MUST be classified **permanently** refused. An implementation +that treats it as retriable buffers it, re-decrypts it on every drain, and, +because a buffered commit that expires having been retried reads as an epoch +fork, turns one forged commit into a group-wide key update round plus a false +fork report. + +### Roster reads + +A roster read skips unbound leaves and **counts** them. The count is reported; +the claimed identities are not, because handing an attacker-chosen string back +through a second field returns it by another door. + +The roster is not cosmetic. It addresses per-member fan-out, feeds the rich +payload gate, and supplies the membership lists the group tiebreakers sort. +Those tiebreakers sort **rendered** identifiers rather than `Address` byte +order; see [Ordering](identity.md#ordering) for why the two orders differ and +must not be harmonized. A non-zero unbound count is reported as a security +warning, because this is the only seam at which a leaf already seated in local +state surfaces, and a log line reaches no application. + +That report differs in kind from the others: no frame was refused and no peer +delivered it, so it names **this device** as the subject rather than a +blameworthy peer, and the remedy it implies is to abandon the group rather than +to evict a member. The leaf cannot speak, but it holds live group secrets and +reads everything, which no later refusal undoes. + +### Removal removes every matching leaf + +Removing a member removes **all** leaves matching that address, not the first. + +Through the wire gates a duplicate is unreachable, because MLS requires unique +signature keys and the binding ties credential to key. That argument covers the +gates, not the tree: a forged leaf written straight into a key store claims a +peer's address while carrying the attacker's key, violates no uniqueness rule, +and sits beside the victim's real leaf. First-match removal would leave the peer +in the group holding live keys while every roster read shows them gone. + +## Membership authorization + +### Report by default + +MLS Add and Remove commits are applied by every receiving member with no +administrative check **by design**. + +Rejecting a commit means declining the merge, which forks you permanently from +everyone who accepted it. The administrative overlay replicates best-effort: +roles ride on unreconciled notifications, and joiners get a point-in-time +snapshot. A merely lagging member would therefore partition itself with no +attacker involved. + +Unauthorized changes are **reported** instead. Reports are rate-limited per +group, committer, **and enforcement mode**. The third component is load-bearing, +not incidental: a report emitted while enforcement was off says the change was +merged anyway, and a report emitted while it was on says the change was +declined. Keying the limit on only the first two collapses those into one +window, so whichever arrives first suppresses the other and the application +never hears about the outcome it was told to act on. + +### The tri-state authorization field + +Roster change events carry a three-valued authorization field: + +| Value | Meaning | +|-------|---------| +| checked and authorized | A check ran and passed | +| checked and unauthorized | A check ran and failed | +| not evaluated | No check ran: own Welcome join, relay-reconciled **adds** | + +An implementation MUST NOT emit "authorized" from a path that ran no check. The +third state exists precisely so that path has something honest to say. + +**Removes** are the asymmetry in that last row. The remove handler MUST check +the authenticated wire sender against the administrative set, drop the frame +when that sender is not an administrator, and only then report a real verdict. +Adds cannot do this, because the frame authenticates the *path* rather than the +actor, so `added_by` may be the relay itself. Omitting the remove-side check +lets any peer evict members from the fan-out send cache and silently deny them +group traffic. + +Note what that check implies for genuinely relay-originated removes: the relay's +answer is injected unattributed, so its sender is a placeholder that can never +be an administrator and the frame is always dropped. **Relay-native remove +reconciliation is therefore inert**, and the path that actually removes a member +is the removing administrator's own signed notification. This is a consequence +of the rule, not a gap in it: restoring relay-native reconciliation means moving +relay answers onto a dedicated entry point rather than weakening the check. + +### The delta is derived only when both roster reads succeed + +The membership delta comes from a pre-commit roster read and a post-merge roster +read. If **either** fails, all delta-derived work is skipped. + +A silent empty default on a failed read fabricates a full-roster delta and a +report naming an innocent committer. + +The pre-commit roster MUST be MLS-derived, never taken from a members cache. +Relay reconciliation splices entries into that cache that were never in the +tree. + +### Opt-in rejection + +Rejection is available as an explicit opt-in, default off. + +It runs **pre-merge at the single decryption chokepoint**, not in the commit +handler. Gating only the commit handler leaves two bypasses: a commit reframed +as an application message, and an `__MLS_ENC__` envelope naming a group +identifier. + +**The fail-open rule is load-bearing.** Merge anyway when: + +- the commit proposes no membership change, +- the identifier names a 1:1 session, +- group metadata is unreadable or absent, +- **the administrative set is not known to be non-empty.** + +Reject only when the administrative set is known non-empty and a principal is +positively not in it. The principals are the committer and the sender of each +**Add or Remove** proposal, since MLS lets a member commit a proposal another +member made. Update and PSK proposal senders are deliberately excluded: an +Update is legitimate self-service that needs no administrator, so rejecting an +admin's Add because it batched a member's key update would fork the group over +a proposal that changes no membership. + +The creator of record is deliberately **not** consulted here. One +unauthenticated claim is too thin a basis to fork over. + +Enforcement acts only on a **present** administrative set that positively +excludes a principal; absent knowledge of that set fails open. It therefore cannot detect +a **divergent** view, where two members each hold a non-empty administrative set +and disagree. That is why it is opt-in, and why it belongs only in a closed +deployment that controls role distribution, never on part of a fleet: a member +with it off applies the commit a member with it on refuses. + +## Group message delivery + +Two paths. The choice is made per send. + +### Per-member fan-out + +The MLS ciphertext is sent as one ordinary directed message per member. + +This inherits the entire direct-message delivery ladder: the outbox, the +acknowledgement and retry machinery, relay write acknowledgement, offline push +carrying ciphertext, parking, probing, flushing, and the receiver's deferred +acknowledgement handling. + +It costs O(N) frames. That does not risk a relay rate limiter at any group size +if the client meters relay-bound frames with a bucket strictly tighter than the +server's and **defers** rather than drops on exhaustion, so the fan-out +self-paces. + +The real cost is drain latency. With a client bucket of 28 tokens refilling at +9 per second, frame N reaches the wire at roughly `(N - 28) / 9` seconds. Since +the acknowledgement timer starts at local enqueue, past roughly 118 members the +tail exceeds a 10 second acknowledgement timeout and is retransmitted before it +was ever written. Those duplicates are absorbed by deduplication, so this is +wasted work, not loss. Shared traffic on the same bucket lowers the threshold. + +### Relay broadcast + +One frame to the relay, which fans out server-side. + +Taken only when **all four** hold: + +1. broadcast is enabled in configuration, +2. the group roster is registered with the relay, +3. the relay advertised the `group_delivery_v3` capability, +4. a live check confirms internet availability. + +#### Why the capability token is v3 and not v2 + +v3 is v2's settled-report contract **plus an address-aware relay group path**. +A v2 relay MUST fail the gate closed, because its username-keyed path and +address identity cannot compose: + +- it cannot route to address-registered members, +- its report names members in a namespace that never intersects the MLS roster, + so the set difference re-issues to **everyone** after every broadcast, +- any copy it does deliver arrives attributed by username, which fails the + wire-sender to credential match **after** the decrypt already spent the + ciphertext's ratchet generation. + +That last one is the reason the **gate** is the fix rather than any +receiver-side cleanup. The generation burn is unrecoverable on the client: +MLS implementations persist message secrets through the storage provider before +the identity check runs, and skipping the group save does not undo it. + +#### The delivery report contract + +What makes a server-side broadcast safe to default on is that it is not +fire-and-forget. + +1. The sender mints a **logical message identifier** and carries it in the + broadcast frame. The relay stamps it onto its fan-out verbatim, or mints one + if absent. +2. The sender arms a pending tracker keyed by that identifier. +3. The relay returns a **settled** report naming delivered members, pushed + members, and missed members with opaque reasons. It can arrive up to roughly + 45 seconds later, so the tracker timeout is 60 seconds. +4. On receipt, the sender re-sends per-member copies to + `roster − delivered − pushed − self`. + +Step 4 covers both the reported misses **and members the relay never knew**, +because the relay's registered roster can be a strict subset of the MLS roster. + +The report MUST arrive through a **dedicated entry point**, not by injecting a +message-plane frame. Message-plane injection makes the report forgeable by +anything that can reach the injector. + +#### Failure handling + +| Failure | Response | +|---------|----------| +| Report never arrives | Re-broadcast under the **same** logical identifier, up to 3 total sends, then downgrade to full per-member fan-out | +| Internet drops | Downgrade all pending broadcasts immediately | +| Tracker overflows (64 pending) | Downgrade the oldest to per-member | + +Re-broadcasting under the same identifier is what makes retries safe: the relay +echoes it, so receiver deduplication and push deduplication both hold across +attempts. + +**Known gap:** the tracker is memory-only, so a process kill inside the report +window loses the backstop. + +#### Receiver-side identifier discipline + +Re-issued copies carry the logical identifier. Handling it correctly requires +two **opposite** rules on the two paths, and getting either backwards causes +silent loss. + +**Mesh path: mark only after a successful decrypt.** + +The logical identifier is checked in the duplicate branch, which absorbs +cross-path duplicates without an MLS decrypt. A spent-generation decrypt would +misclassify as retriable and buffer noise. But the identifier is marked only +after a decrypt succeeds, because a failed decrypt must not poison it. + +**Relay path: mark at arrival, before decrypt.** + +Here the relay-supplied identifier **is** the logical identifier, and marking it +pre-decrypt is the replay-amplification defence: one MLS operation per +identifier. + +That inversion carries an obligation. **Every arm that ends with the frame +neither delivered, nor buffered, nor consumed by MLS MUST unmark before +returning.** Security rejections, hard failures, and the plaintext-spoof drop +whose identifier is attacker-chosen wire input. + +Otherwise a *rejected* copy reads as *delivered* to the duplicate check, and the +per-member re-issue that is the broadcast's own safety net is absorbed as a +cross-path duplicate and acknowledged: delivered nowhere, sender told delivered. + +The obligation extends to the buffered-message drain, and that half is not +optional. A relay copy can outrun its Welcome, so it buffers **before** any +decrypt and its misattribution is judged on the drain rather than at arrival, an +ordering a hostile relay picks for free. + +Unmarking cannot resurrect a burned generation. The honest recovered outcome is +"buffered and unacknowledged, custody with the sender", never "consumed". + +Permanent policy refusals deliberately do **not** unmark on either path: a later +copy could only waste work. + +#### Double delivery is prevented by MLS, not by bookkeeping + +An MLS decrypt consumes the ratchet generation. A copy beaten to it fails. +Reaching the plaintext branch of a drain therefore **proves** first delivery. + +An "already delivered elsewhere?" check in that branch is unreachable when true +and a false positive otherwise. The false positive is fatal: the relay path +marks its identifier at arrival, pre-decrypt, so such a check suppresses the +only decryptable copy. That is silent loss. + +The drain may keep a set of identifiers delivered **in this drain batch**, but +only to drop a sibling copy without burning a doomed decrypt, and to stop that +sibling's expiry from releasing replay protection for an identifier that was +delivered. diff --git a/docs/spec/identity.md b/docs/spec/identity.md new file mode 100644 index 00000000..f542cc8d --- /dev/null +++ b/docs/spec/identity.md @@ -0,0 +1,165 @@ +# Identity and addressing + +## The identity key + +Every install holds one long-term Ed25519 keypair. It is the root of every +identity claim the protocol makes: + +- it signs control-plane frames, +- its public key derives the install's address, +- it is the signature key inside the install's MLS credentials. + +Nothing in this protocol rotates the leaf signature key or the MLS credential +independently of the identity key. An implementation that adds such a rotation +breaks the leaf identity binding described in +[Group protocol](group-protocol.md) and MUST re-derive the binding rules first. + +## Address derivation + +An address is a self-certifying name: it is a function of the identity public +key, so a peer can check a claimed address against a presented key with no +directory, no registry, and no trust-on-first-use store. + +``` +payload = 0x01 || SHA-256(ed25519_public_key)[0..20] +address = bech32m(hrp = "off", payload) +``` + +- The version byte is `0x01`. It is the only version this specification + defines. +- The hash is truncated to 20 bytes (160 bits). +- The encoding is bech32m (BIP-350), not the original bech32 constant. +- The canonical rendering is lowercase and exactly 44 characters: + `off` (3) + separator (1) + data (34) + checksum (6). + +Example rendering: `off1…` where the elided part is the 34-character data +section plus checksum. + +### Parsing rules + +A conforming parser MUST reject: + +- uppercase input, even though BIP-173 permits an all-uppercase spelling, +- a string whose checksum validates under the original bech32 constant rather + than bech32m, +- a human-readable part other than `off`, +- a payload whose version byte is not `0x01`, +- a payload whose length is not 21 bytes. + +Canonicality is not a property the bech32 libraries hand you. A decoder that +accepts a string and returns a payload has not proved that re-encoding the +payload yields the same string. Implementations MUST verify canonicality by +re-encoding the decoded payload and comparing, or by refusing every +non-canonical form explicitly. Two distinct strings decoding to one address is a +security bug, not a cosmetic one: it splits any set, map, or dedup keyed by the +rendered form. + +### Security margins + +Truncation to 160 bits buys two different things, and only the first is what the +impersonation resistance rests on: + +| Attack | Cost | What it buys the attacker | +|--------|------|---------------------------| +| Second preimage: produce a key deriving to a *specific existing* address | ~2^160 | Impersonation of a chosen peer | +| Collision: produce *two* keys sharing one address, neither fixed in advance | ~2^80 | One entity holding two signing keys indistinguishable at the address layer | + +The ~2^80 collision margin is below the ~2^128 a greenfield design would target. +It is a deliberate trade: every mesh frame carries a sender and a recipient +address, and the Bluetooth LE budget is the binding constraint. Widening the +hash is a version bump and a migration, not a patch. + +The consequence of the collision margin is worth stating plainly, because it is +the one place the address layer is weaker than the MLS layer above it: an +attacker who finds a collision can equivocate, holding two signing keys that +present as one address, which defeats the "one identity cannot hold two leaves" +property that the group leaf binding otherwise inherits from MLS signature-key +uniqueness. + +## Ordering + +**Two orderings exist and they are different orders.** The bech32 charset +`qpzry9x8gf2tvdw0s3jn54khce6mua7l` is not monotonic in ASCII: value 4 renders as +`y` (0x79) and value 5 as `9` (0x39). A string comparison would also weigh the +checksum characters, which carry no identity information at all. Hash-byte order +is the identity-bearing comparison; rendered-string order is a comparison of an +encoding. + +**Which order applies is fixed per tiebreaker, and the tiebreakers disagree.** + +| Tiebreaker | Compares | +|------------|----------| +| Session slot ownership (both-create) | hash bytes, falling back to string order when either identifier does not parse as an address | +| Group leave election | rendered address strings | +| Admin auto-promotion | rendered address strings | +| Fork leader election | rendered address strings | + +Both orders are deterministic and total, so each of these converges on its own: +every peer running a given tiebreaker sorts the same way and reaches the same +winner. An implementation MUST use, for each tiebreaker, the order named in this +table. + +A change MUST NOT "harmonize" one site onto the other order. That is the move +that breaks convergence: peers that changed and peers that did not would elect +different winners from identical input, with no way to detect the disagreement +locally. Prefer hash-byte order for anything new, because it compares identity +rather than encoding. See +[ADR 0003](../adr/0003-self-certifying-addresses.md#two-orderings-exist-and-a-tiebreaker-must-not-mix-them). + +## Session identifiers + +A 1:1 MLS session between two parties is named by a deterministic slot +identifier derived from the two addresses: + +``` +session_id = "session:" || lower || ":" || higher +``` + +where `lower` and `higher` are the two canonical address renderings ordered by +hash bytes as described above. + +Three properties follow, and all three are load-bearing: + +1. **Symmetric.** Both parties compute the same identifier without exchanging + it, so a session can be addressed before either side has state for it. +2. **Public.** The identifier is a function of two public addresses. Anyone can + compute the slot identifier for any pair. It is therefore not a secret and + MUST NOT be treated as one. +3. **Bindable.** A receiver can check that an inbound envelope names the slot it + shares with the claimed sender. An envelope naming any other slot is + refused before decryption is attempted. + +Property 2 is the reason the desync recovery trigger described in +[Session lifecycle](../state-machines/session-lifecycle.md) is unauthenticated, +and the reason acting on it must be harmless rather than trusted. + +Implementations that carry non-address identifiers for legacy reasons fall back +to string ordering for those. That fallback exists for compatibility and is not +part of the specification for new deployments. + +## Group identifiers + +A group is named by an opaque identifier carried in the MLS group context. It is +chosen by the group creator. + +The `session:` prefix is a **reserved namespace**. A group Welcome naming a +`session:`-prefixed identifier MUST be refused, and a session Welcome naming a +group identifier MUST be refused. Without that reservation, a group invite could +be aimed at a 1:1 session slot and displace it. + +## What an address does not tell you + +An address is a name for an identity key. It is not: + +- a device identifier (one identity may run on several installs only if the + identity key is shared, which this protocol neither prevents nor supports as a + designed feature), +- a username (the mapping from a human-readable name to an address is a + directory concern, handled outside this specification), +- a routing hint (the mesh discovers routes; the address carries no topology). + +The relay and directory layers may key their own state by username. When they +do, an implementation MUST NOT assume the relay's identifier space intersects +the protocol's address space. The group delivery report in +[Group protocol](group-protocol.md) documents the concrete failure that +assumption caused. diff --git a/docs/spec/wire-format.md b/docs/spec/wire-format.md new file mode 100644 index 00000000..d0b9aa17 --- /dev/null +++ b/docs/spec/wire-format.md @@ -0,0 +1,256 @@ +# Message model and wire format + +## The abstract message + +Every frame the protocol puts on a transport is one message. The abstract +message is the same regardless of which encoding carries it. + +| Field | Type | Notes | +|-------|------|-------| +| `id` | 128-bit UUID | Identity for deduplication, acknowledgement, and outbox tracking | +| `sender` | user identifier | Canonically an address; see [Identity](identity.md) | +| `recipient` | user identifier | Canonically an address | +| `app_id` | string | Namespaces traffic between applications sharing a mesh | +| `priority` | enum | `Low`, `Medium`, `High`, `Critical` | +| `ttl` | unsigned | Hops remaining; a message with 1 or fewer is not forwarded | +| `hop_count` | unsigned | Hops traversed so far | +| `timestamp` | signed integer, milliseconds | Wall clock, for display only, never for ordering decisions | +| `lamport_clock` | unsigned | Logical clock for causal ordering across devices | +| `content_type` | enum | Rendering hint; see the mapping table below | +| `content` | string | Text, a control prefix plus its body, or an envelope | +| `binary_content` | optional bytes | File chunk payloads and media envelopes | +| `media_metadata` | optional structure | Present for non-text content | +| `metadata` | string map | Application use plus a small set of reserved keys | +| `requires_ack` | boolean | Default true; see [Delivery and ACKs](../state-machines/delivery-and-acks.md) | +| `reply_to_msg` | optional UUID | Threading | +| `forwarded_from` | optional structure | Forwarding attribution | +| `reply_context` | optional structure | Quoted-reply preview | + +Two further fields exist in the reference implementation and are deliberately +**not** part of the wire model: + +- **transport peer identity.** Stamped by the transport layer on receipt with + the physically verified peer that delivered the frame. It exists in process + only, is never serialized, and is what the control-plane gate compares the + claimed `sender` against. An implementation MUST NOT accept this value from + the wire, because doing so hands the spoofing check to the spoofer. +- **wire codec selection.** Which encoding to use on the next hop. Stamped from + per-peer capability just before send. + +### Content type mapping + +The numeric mapping is a frozen wire contract: + +| Value | Type | +|-------|------| +| 0 | Text | +| 1 | Image | +| 2 | Video | +| 3 | Audio | +| 4 | VoiceNote | +| 5 | VideoNote | +| 6 | File | +| 7 | FileChunk | +| 8 | Poll | + +Adding a variant is an additive change. Every decode path MUST degrade an +unrecognised value to `File` rather than rejecting the containing message. That +applies to the string form, the binary numeric form, and the JSON form alike. A +JSON decoder built from a derived enum deserializer typically rejects unknown +variants and therefore does not conform; the fallback has to be written by hand. + +### Priority mapping + +| Value | Priority | +|-------|----------| +| 0 | Low | +| 1 | Medium | +| 2 | High | +| 3 | Critical | + +An unrecognised **numeric** value degrades to `Medium`. Unlike content type, +that tolerance does not extend to the JSON encoding: priority is carried there +as a string against a closed set, with no fallback and no default, so an unknown +or absent value rejects the whole message. A new priority is therefore a +breaking change on the JSON floor, which is where a future addition has to be +designed around. + +On the wire the value is always **lowercase**, so a wire decoder built to the +four lowercase names alone is conforming. The reference implementation's decoder +additionally accepts the **capitalized** spelling of each name (`Low`, `Medium`, +`High`, `Critical`), but that tolerance exists for its **FFI boundary** rather +than for the wire: the UniFFI `receive_message` JSON renders priority +Debug-cased, and that JSON has to round-trip back in through `forward_message`. +Anything re-parsing SDK-emitted FFI JSON needs the capitalized spellings; a peer +implementation does not. + +## Encodings + +A receiver distinguishes the two encodings by the **first byte alone**: + +| First byte | Encoding | +|------------|----------| +| `0x7B` (`{`) | JSON | +| `0xF5` | Binary wire v1 | + +The magic byte is drawn from `0xF5..=0xFF`. Those are invalid UTF-8 leading +bytes and cannot begin a JSON document, so detection needs no negotiation and no +out-of-band state. Future breaking revisions take the next value (`0xF6` = v2), +leaving room for eleven wire versions before the range is exhausted. + +Detection is unconditional. Negotiation governs only what a sender **emits**. + +### JSON encoding + +The permanent floor. Every conforming receiver MUST accept it. + +It is the sole encoding used for: + +- persistence (outbox, pending queues, stored state), +- the internet relay path. + +Field names are the abstract field names above. Optional fields are omitted +rather than encoded as null where the field is absent, with one exception: +`reply_to_msg` is always present and is `null` when there is no reply. A +receiver MUST accept both spellings for every optional field, since the +distinction carries no meaning. + +Validation on decode is not optional. Identifier length caps and the Lamport +clock clamp are security checks, not conveniences, and the binary path is +required to enforce the identical set. + +### Binary wire v1 + +A compact positional encoding for hop-local use. It is emitted only to a peer +that advertised `wire_versions` containing `1`. + +The frame is `0xF5` followed by a positionally encoded structure with this +**frozen** field order: + +``` +id 16 raw bytes +sender string +recipient string +app_id string +priority u8 +ttl u8 +hop_count u8 +timestamp i64 +lamport_clock u64 +content_type u8 +content string +binary_content optional bytes +media_metadata_json optional bytes (media metadata serialized as JSON) +metadata list of (string, string), sorted by key +requires_ack bool +reply_to_msg optional 16 raw bytes +forwarded_from_json optional bytes (forward attribution serialized as JSON) +ext list of (u16 tag, bytes) +``` + +Three deliberate choices in that layout: + +- **The id is 16 raw bytes, not a 36-character hyphenated string.** This is the + single largest saving on short messages. +- **Rich structures ride as opaque JSON blobs.** Media metadata and forward + attribution are rare and structurally complex. Carrying them as embedded JSON + keeps the frozen surface small and lets those structures keep evolving through + their own additive rules without touching the wire contract. +- **The metadata map is sorted by key.** A hash map iterates + nondeterministically, which would make the encoding non-reproducible. + +#### Why a separate DTO + +The abstract message carries defaulting rules, skip-if-absent rules, and +validation-on-deserialize behaviour that a non-self-describing positional format +cannot honour field for field. Conforming implementations therefore encode +through a flat fixed-order intermediate structure and convert back through the +**validating** constructors, so the checks the JSON path enforces stay intact on +the binary path. + +#### Evolution contract + +A positional format silently corrupts decoding on peers running the previous +layout if a field is reordered, removed, retyped, or inserted. Therefore: + +1. Existing fields MUST NOT change, in order or in type. +2. Additive, backward-compatible data goes into `ext`, a trailing tagged list + that old decoders read and ignore. +3. A change that cannot be expressed as an `ext` entry requires a new magic + byte and out-of-band version negotiation. + +### The `ext` TLV registry + +| Tag | Meaning | Absence-safe? | +|-----|---------|---------------| +| 1 | Trailing base64 run of `content`, carried decoded | No, see below | +| 2 | Quoted-reply context serialized as JSON | Yes | + +**Tag 1** exists because envelopes are base64 and base64 inflates by 4/3. When +`content` ends in a long canonical base64 run, the wire `content` keeps only the +head and the decoded tail rides in the TLV; the decoder re-encodes and appends, +reconstructing the original string byte for byte. + +The split is taken only when the encoder has verified that reconstruction +property by re-encoding and comparing. That makes it correct by construction for +arbitrary input: non-canonical padding, foreign alphabets, and lookalike text +simply fail the comparison and the content rides as plain text. The minimum +tail length before the split is worth taking is 64 base64 characters (48 raw +bytes). + +Tag 1 constrains the registry. It shipped in wire v1's **first** release, so +advertising `wire_versions` containing `1` implies understanding it. A decoder +that ignored tag 1 would reconstruct a truncated `content`, which is only safe +because no v1 decoder without tag-1 support ever shipped. + +The rule that follows, and it is the important one: **a future tag whose absence +changes meaning cannot piggyback on v1.** It needs a new wire version. Tags may +only be added to v1 when ignoring them costs efficiency or optional context, +never correctness. + +**Tag 2** is a correct additive tag under that rule. A decoder that skips it +delivers the message without its reply preview, which is exactly the degradation +a legacy JSON receiver applies by ignoring an unknown field. Decoders honour +only the first tag-2 entry and reject a frame whose payload is not valid reply +context, matching the JSON path where a malformed value rejects the message. + +## Size and fragmentation + +Chunk sizes are transport-dependent, because the constraint is the transport MTU +and duty cycle, not the protocol: + +| Transport class | Chunk size | In-flight window | +|-----------------|-----------|------------------| +| Bluetooth LE | 4 KiB | 2 | +| Default | 32 KiB | 4 | +| Internet | 256 KiB | 8 | + +The encoding choice interacts with this directly. Measured on one encrypted +direct message, the JSON floor with the legacy envelope takes 1342 bytes; the +compact envelope alone brings that to 808 (the envelope is responsible for +roughly a 2.7 times reduction on the payload it replaces); adding the binary +codec brings it to 472, **about 2.8 times smaller** end to end. At the 185-byte +Bluetooth LE fragment size that is the difference between ten fragments and +four. + +## Reserved metadata keys + +The metadata map is application space with a small reserved set. Reserved keys +observed on the wire: + +| Key | Meaning | +|-----|---------| +| `ack_for` | This message acknowledges the named message id | +| `ack_hop_count` | Hop count observed by the acknowledging party | +| `ack_transport` | Transport the acknowledged message arrived on | +| `transport_preference` | Requested transport for this message | +| `original_content_type` | Pre-chunking content type of a file transfer | +| `__ctrl_sig` | Base64 Ed25519 signature over the control-message canonical payload | +| `__ctrl_pk` | Base64 Ed25519 public key of the signer, 32 raw bytes | + +Applications MUST NOT write these keys. + +The last two are the control-plane signature and its verification key, described +in [Control messages](control-messages.md#the-control-plane-signature-gate). They are +security-relevant rather than merely reserved: an implementation that lets +application input reach them lets an application forge the control plane. diff --git a/docs/state-machines/README.md b/docs/state-machines/README.md new file mode 100644 index 00000000..6980ac3f --- /dev/null +++ b/docs/state-machines/README.md @@ -0,0 +1,37 @@ +# State machines + +Five state machines govern the protocol's runtime behaviour. Each is documented +with its states, its transitions, the invariants that hold across them, and the +failure modes that the current shape exists to prevent. + +| Document | Governs | +|----------|---------| +| [Delivery and acknowledgements](delivery-and-acks.md) | What happens to an inbound frame, and when a receiver acknowledges | +| [Outbox and retries](outbox-and-retries.md) | What happens to an outbound message from send to terminal state | +| [Session lifecycle](session-lifecycle.md) | 1:1 MLS session establishment, confirmation, desync, and heal | +| [Group message lifecycle](group-message-lifecycle.md) | A group message from send through fan-out, buffering, and drain | +| [Transport lifecycle](transport-lifecycle.md) | Transport availability, scoring, switching, and escalation | + +## How to read these + +Each document states its invariants first. The invariants are the part that +survives refactoring; the state names are not. + +Where a transition exists to prevent a specific failure, the failure is named. +Several of these shapes look over-engineered until you know which bug they close, +and the surrounding prose is there so a future change does not undo one by +accident. + +## The one invariant that spans all five + +**Custody of an undelivered message stays with the sender until a receiver +positively confirms it.** + +Every acknowledgement decision, every buffering decision, and every deduplication +decision in these documents is downstream of that. When a receiver cannot yet +deliver a frame, the correct move is to withhold the acknowledgement and let the +sender keep custody, not to acknowledge and hope. + +The corollary matters to application teams: because acknowledgements are +withheld on recoverable failures and re-sent on recovery, **a missing +acknowledgement is not proof of non-delivery.** diff --git a/docs/state-machines/delivery-and-acks.md b/docs/state-machines/delivery-and-acks.md new file mode 100644 index 00000000..4e16d3d5 --- /dev/null +++ b/docs/state-machines/delivery-and-acks.md @@ -0,0 +1,344 @@ +# Delivery and acknowledgements + +This is the receive-side state machine. It decides three things for every +inbound frame, together and consistently: + +1. Is the frame delivered to the application? +2. Is the sender acknowledged? +3. Does the frame's identifier stay marked in the deduplicator? + +Getting any one of those out of step with the other two is how messages are +silently lost. Most of this document exists to explain why particular +combinations are the only correct ones. + +## Invariants + +**I1. Acknowledge only what is delivered or permanently refused.** +An acknowledgement means "custody transferred". It does not mean "received". + +**I2. Withholding an acknowledgement requires unmarking the identifier.** +Otherwise the sender's resend arrives, is seen as a duplicate, and is +re-acknowledged without ever being processed. That is the silent loss. + +**I3. Never enqueue a frame that can never become processable.** +A queued copy that can never drain re-reports failures on every drain and +restarts its own time-to-live. + +**I4. An acknowledgement is a side channel.** It confirms to whoever sent a +frame that this device is live and processing. Refusals on security grounds +therefore stay silent. + +## The four outcomes + +Every inbound frame resolves to exactly one: + +| Outcome | Delivered | Acknowledged | Identifier stays marked | Queued | +|---------|-----------|--------------|------------------------|--------| +| **Consumed** | yes, **or permanently refused** | yes | yes | no | +| **Deferred** | not yet | **no** | **no** | sometimes, see below | +| **SecurityRejected** | no | **no** | **no** | no | +| **Duplicate** | no | usually, see below | yes | no | + +```mermaid +stateDiagram-v2 + [*] --> Received + Received --> Duplicate: identifier already seen + Duplicate --> [*]: re-ACK if requested
and sender not blocked, no delivery + Duplicate --> Deferred: original still pending + + Received --> Gate: new identifier, marked + Gate --> SecurityRejected: signature / identity refusal + SecurityRejected --> [*]: unmark, NO ack + + Gate --> Decrypt: gate passed + Decrypt --> Consumed: plaintext recovered + Decrypt --> Deferred: session not ready + Decrypt --> Deferred: recoverable crypto failure + Decrypt --> Deferred: envelope parse failure + Decrypt --> Consumed: terminal post-decrypt failure + Decrypt --> Consumed: commit refused by enforcement + + Deferred --> [*]: unmark, NO ack, sender retains custody + Consumed --> [*]: ack +``` + +**A refused commit is not a fifth outcome.** A commit refused by membership +enforcement resolves to `Consumed`, which is what the "or permanently refused" +column entry means: no delivery, but an acknowledgement and a mark that stays. +That is deliberate, and it is the half of the refusal rule that is easy to lose. +Its opposite, the **security** refusal, is a separate outcome precisely because +it must not be acknowledged. Collapsing the two, in either direction, is the +failure both this document and +[ADR 0005](../adr/0005-defer-instead-of-drop-and-ack.md) exist to prevent. + +Read "policy refusal" carefully in this codebase, because it names two +dispositions that are opposites. The commit-enforcement refusal above is +acknowledged. An inbound **plaintext** message refused by encryption policy is +not: it withholds the acknowledgement and unmarks the identifier, exactly like a +security refusal, because an attacker choosing to send plaintext must not learn +anything from the answer. See the media section below, where the same pairing +appears. + +`Duplicate` is a receive-loop disposition rather than a decrypt outcome, and its +acknowledgement is conditional: a duplicate is re-acknowledged only when the +frame asked for an acknowledgement and the sender is not blocked. A duplicate of +a group message that is **still pending** in the buffer resolves to `Deferred` +instead, because the original has not been delivered either. + +These four are the observable dispositions, not a mirror of any one type. The +implementation's decrypt-result type also has four cases, but they are not the +same four: it splits a delivered message out as its own case and does not model +duplicates at all, because the duplicate check runs before it. + +## The deferred-acknowledgement atom + +The `Deferred` outcome is not a single change. It is six interdependent pieces +that are correct only together. Implementing a subset produces a system that +looks like it works and loses messages. + +### The bug it closes + +An encrypted message arriving **before** the receiver's MLS session or group +state exists used to be queued for later decryption **and acknowledged**. The +sender then stopped retransmitting. If the queued copy never drained, the +message was gone, with both sides believing it delivered. + +### The six pieces + +**1. A distinct outcome.** Not-ready must be distinguishable from delivered and +from failed. Without a third outcome the receive loop has nothing to branch on. + +**2. Idempotent enqueue, keyed by message identifier.** Resends must not stack. +The time-to-live is measured from **first** receipt, so a peer resending every +few seconds cannot hold an entry alive indefinitely. + +**3. A successful decrypt is a session-confirmation source, and confirmation +drains.** Not only an explicit session establishment event. + +This is what fixes the both-create case. When two peers create a session +simultaneously, the **owner** side never *adopts* a Welcome (it receives the +peer's, but the tiebreaker keeps the local session), so a Welcome-triggered +drain never fires there. A successful decrypt is the general proof that the +session works. + +Precisely, the drain hangs off the **confirmation**, and a successful decrypt is +one of the things that can confirm. Confirmation is a state transition, so a +decrypt on a session that is already confirmed does not re-run the drain. + +**4. Re-mark the identifier when the drain surfaces the message.** The receive +loop unmarked it. Once the message is genuinely delivered, the deduplicator must +know, or a later resend delivers it twice. + +**5. A time-to-live long enough to be useful.** 2 minutes is too short for +session establishment across a mesh. 30 minutes is the value this protocol uses. + +**6. Acknowledge on drain, on the transport the frame arrived on.** + +The arrival transport is recorded on the queued entry and the deferred +acknowledgement is sent on it when the drain succeeds. + +Without piece 6, the drain closes the loss but leaves a long window in which the +message is delivered locally and the sender is still retransmitting. + +### The acknowledgement-latency semantics + +Piece 6 degrades gracefully, and application teams need to know how: + +- If the arrival transport was recorded but is **gone**, the acknowledgement + falls back to ordinary routing: the mesh, then transport selection. +- If the arrival transport was **not recorded at all**, no acknowledgement is + attempted on the drain. There is nothing to route it against and no reason to + guess. +- In both cases, if nothing lands, the sender's next resend triggers the + duplicate re-acknowledge path. + +So a late or absent acknowledgement during the not-yet-confirmed window is +**not** loss. The receiver may already hold the message. + +**A sender that exhausts its retry budget before both the session confirms and +an acknowledgement lands may still mark the message undeliverable though it was +delivered locally.** That is strictly better than the old silent drop, and +application teams MUST NOT read a missing acknowledgement as non-delivery. + +## Classifying decrypt failures + +Not every decrypt failure is the same, and the classification decides both the +acknowledgement and whether a re-key fires. Getting the boundary wrong in either +direction is a real bug: too narrow and messages are lost; too wide and the +re-key becomes a denial-of-service amplifier. + +```mermaid +flowchart TD + F[Decrypt failed] --> S{Identity or slot
refusal?} + S -->|yes| SR[SecurityRejected: no ack,
unmark, drop. Intercepted
before classification] + S -->|no| P{Failed before
any MLS involvement?} + P -->|envelope unparseable| D1[Deferred: no ack, no enqueue] + P -->|no| E{Epoch disagreement?
WrongEpoch / NoPastEpochData} + E -->|yes| SD[SessionDesync] + SD --> D2[Deferred: no ack, no enqueue,
+ schedule re-key] + E -->|no| C{Session established
but decrypt failed?} + C -->|AEAD / corrupt / ratchet| D3[Deferred: no ack, no enqueue,
NO re-key] + C -->|session not ready| D4[Deferred: no ack, ENQUEUE] + C -->|policy refusal that can never
become decryptable| K[Consumed: ack, drop] +``` + +### The classes + +| Class | Acknowledged | Enqueued | Re-key | Why | +|-------|--------------|----------|--------|-----| +| Session not ready | no | **yes** | no | It will become decryptable when the session arrives | +| Session desync (epoch fork) | no | no | **yes** | Ciphertext is sealed to a dead epoch and can never drain | +| Crypto failure (AEAD, corrupt, ratchet generation) | no | no | **no** | The attempt spent the generation; a queued copy could never drain | +| Transport failure | no | no | no | Recoverable by resend | +| Envelope parse failure | no | no | no | Unparseable now is unparseable forever; the resend is the fix | +| Policy refusal (commit not authorized) | **yes** | no | no | Can never become decryptable, so retries are pure waste | +| Security refusal (identity mismatch, foreign session slot) | **no**, identifier unmarked | no | no | An acknowledgement confirms to an injector that the target is live | +| Post-decrypt failure (empty, non-UTF-8, malformed plaintext) | **yes** | no | no | The generation is spent and a re-seal would produce the same malformed plaintext | + +The policy-refusal and security-refusal rows are the two halves of "can never +become decryptable", and they are deliberately opposite. Both refusals are +permanent, but a policy refusal is a statement about a **frame** while a +security refusal is a statement about an **attacker**, and answering the second +one at all is the leak. + +The security shapes (sender identity mismatch, session identity mismatch, leaf +address mismatch, unsupported sender) are therefore intercepted **before** +classification. Without that interception they would classify as ordinary +unknown-session failures and inherit the policy row's drop-and-acknowledge +disposition, which is the bug the interception exists to prevent. See +[ADR 0005](../adr/0005-defer-instead-of-drop-and-ack.md). + +### Why the desync split gates the re-key, not the acknowledgement + +**The classification split exists to gate the re-key. Both classes withhold the +acknowledgement.** + +Drawing the no-acknowledgement boundary at desync alone was the original bug. +Sender-side re-sealing means every resend is re-sealed against the peer's +current session, so ordinary crypto failures were **already** recoverable while +the receiver was still acknowledging them as delivered. + +The separation of desync from ordinary decryption failure is still essential, +but for the other reason: re-keying on AEAD or corruption failures would be a +re-key-storm vector. + +Note the subtlety in what "corrupt" covers. **Malformed** input never reaches +framing validation and correctly stays out of the desync class. A **well-formed** +frame carrying a forged epoch **does** classify as desync, which is exactly the +unauthenticated trigger documented as residual risk R2 in the +[threat model](../security/threat-model.md#r2-unauthenticated-session-desync-trigger). + +### Why the parse-failure class joined late + +Envelope parse failures were the last drop-and-acknowledge arm of this family. +They are the same in-transit corruption as a bad ciphertext, a few bytes earlier +in the encoding, and they were being acknowledged as delivered while the +sender's resend would have parsed and delivered. + +Both rationales already accepted point the same way: an honest sender's +corrupted frame is recoverable by the resend, and the acknowledgement is what +kills it; and an injector learns less from silence than from an acknowledgement. + +### What stays terminal, and why + +Everything **after** a successful decrypt: empty plaintext, non-UTF-8 plaintext, +malformed decoded chunk structures. + +The generation is spent, and a sender-side re-seal would re-seal the **same** +malformed plaintext. No resend could ever deliver. Withholding the +acknowledgement there would burn the sender's whole retry budget to no purpose. + +## The drain has its own rules + +A queued frame that hard-fails when the drain retries it is `Deferred`: no +acknowledgement, no re-mark, and **the queued copy is dropped rather than +re-enqueued**. + +That last part is not an optimization. The drain **removes** the entry before +processing it, so a re-enqueue misses the idempotency check and re-stamps the +receipt time, restarting the time-to-live of a frame that can never decrypt, on +every drain, re-reporting an advisory failure each time, even after the sender's +re-sealed resend has already delivered the same identifier. + +Nothing is lost by dropping it. The withheld acknowledgement already makes the +sender's resend the recovery path. The one case that genuinely wants a queued +copy, session-not-ready, re-enqueues itself before returning `Deferred`. + +The **parse-failure** arm is not reachable from the drain: a queued frame parsed +at receipt, and parsing is deterministic. + +The **desync** arm is, and routinely. A queued frame whose session forks while +it waits classifies as desync on the drain attempt and schedules the same +rate-limited re-key it would have from the receive loop. An audit of what can +trigger a re-key must include the drain. + +## Media mirrors this, with two differences + +Media chunks follow the same outcome set, with media-specific names. + +**Difference 1: no sender-side re-seal.** Chunks are re-encoded, not replayed. +Media recovers through a descriptor-based resend request instead. + +**Difference 2: the two shapes of security rejection are expressed in one +place.** A media security rejection covers both a plaintext chunk refused by +encryption policy **and** an encrypted chunk that fails its identity binding, +and both are reported through the chunk outcome. + +The disposition itself is not media-specific. The text path answers an inbound +plaintext message refused by encryption policy exactly the same way, withholding +the acknowledgement and unmarking the identifier, but it does so inline in the +receive loop rather than through an outcome value. What differs is where the +rule lives, which matters only because a reader auditing one path will not find +the other by following types. + +The identity-binding shape covers **four** classes, the same four the text path +intercepts: sender identity mismatch, session identity mismatch, leaf address +mismatch, and unsupported sender. + +Both MUST be intercepted **before** the ordinary session-state classification, +for the same reason the text path intercepts them inline: both otherwise +classify as `Unknown`, whose terminal drop-and-acknowledge disposition must be +preserved for genuine unauthorized-commit refusals. + +This interception is deliberately **not** gated on the crypto-recovery +configuration switch. It is about what the receiver reveals, not about recovery, +and the text equivalent is unconditional. + +### Media signal semantics + +An evicted encrypted media chunk surfaces a decryption-failure event, but that +signal is **advisory**: the transfer stalled and is recoverable on resend. The +terminal media signal is the receive-failure event. + +## What application teams must take from this + +1. **A missing acknowledgement is not proof of non-delivery.** See the latency + semantics above. +2. **Decryption-failure events are advisory and fire per failed attempt.** They + are bounded by the sender's retry budget, not by the number of messages. +3. **The terminal signals are the failure events**, not the absence of a + success event. + +## Configuration + +A crypto-recovery switch, default on, gates **part** of the recoverable-failure +family. Disabled, the classes it covers fall back to legacy +drop-and-acknowledge. It exists as an escape hatch, not as a supported operating +mode. + +It does not cover all of them, and the boundary matters to anyone auditing what +the switch can turn off: + +| Class | Gated by the switch | +|-------|---------------------| +| Session desync (epoch fork) | yes | +| Crypto failure (AEAD, corrupt, ratchet generation) | yes | +| Transport failure | yes | +| Envelope parse failure | yes | +| Session not ready | **no**, always defers and enqueues | +| Security refusal | **no**, see above | + +Session-not-ready deferral is unconditional because it is the atom's own bug +fix, not a recovery heuristic: the frame is known to be deliverable once the +session arrives, so acknowledging it would reintroduce exactly the silent loss +described at the top of this document. diff --git a/docs/state-machines/group-message-lifecycle.md b/docs/state-machines/group-message-lifecycle.md new file mode 100644 index 00000000..eff66f1f --- /dev/null +++ b/docs/state-machines/group-message-lifecycle.md @@ -0,0 +1,244 @@ +# Group message lifecycle + +A group message crosses more paths than a direct message, and the paths have +**different** correctness rules. This document is organized around those +differences, because treating the paths uniformly is how the known bugs in this +area were introduced. + +## Invariants + +**G1. An MLS decrypt consumes a ratchet generation.** Reaching a plaintext +branch therefore proves first delivery. Bookkeeping that tries to prove the same +thing is at best redundant and at worst suppresses the only decryptable copy. + +**G2. Two deduplication layers exist and serve different purposes.** The +group-level layer is the authoritative double-delivery guard and the +replay-amplification defence. The transport layer is ordinary frame +deduplication. + +**G3. The mesh path marks a logical identifier only after a successful decrypt. +The relay path marks it at arrival.** Both are correct for their path, and each +carries an obligation the other does not. + +**G4. Every relay-path arm that ends with the frame neither delivered, nor +buffered, nor consumed by MLS must unmark before returning.** + +## Send + +```mermaid +flowchart TD + S[send_group_message] --> G{All four hold?
enabled AND registered
AND relay advertises v3
AND internet live} + G -->|no| F[Per-member fan-out:
one directed message per member] + G -->|yes| B[Relay broadcast:
one frame, logical id minted] + B --> T[Arm pending tracker, 60s] + T --> R{Settled report?} + R -->|yes| RI[Re-issue per-member to
roster − delivered − pushed − self] + R -->|timeout| RB{Attempts < 3
and gate still holds?} + RB -->|yes| RS[Re-send the same frame:
same logical id, attempts + 1] + RS --> T + RB -->|no| F + R -->|internet dropped| F + F --> L[Inherits the full DM ladder] +``` + +Per-member fan-out inherits the entire direct-message delivery ladder: outbox, +acknowledgement and retry, relay write acknowledgement, offline push carrying +ciphertext, parking, probing, flushing, and the receiver's deferred +acknowledgement handling. + +Relay broadcast inherits none of it, which is why it needs the delivery report +to be safe to default on. Details and the capability-token reasoning are in +[Group protocol](../spec/group-protocol.md#relay-broadcast). + +## Receive: the three inbound paths + +| Path | Frame | Marks logical id | Releases it on refusal | +|------|-------|------------------|------------------------| +| Mesh | `__GRP_MLS_MSG__` | After successful decrypt | Envelope id: **no**, see below | +| Buffered drain | replayed from the buffer | After successful decrypt | Yes | +| Relay | `__GROUP_MSG__` | **At arrival, pre-decrypt** | Yes | + +Both live paths buffer the same way, so buffering is not what distinguishes +them: each attempts the decrypt, and each buffers only when the attempt comes +back retriable (a copy that outran its Welcome, most often). The inversion that +does distinguish them is **when the logical identifier is marked**, and its +consequence is **who has to release it**. + +The mesh path's non-release on a security refusal is a known defect rather than +a design choice; see +[Group protocol](../spec/group-protocol.md#refusal-dispositions). + +### Mesh path + +```mermaid +stateDiagram-v2 + [*] --> Arrived + Arrived --> DupCheck: logical id present? + DupCheck --> Deferred: duplicate of a STILL-PENDING message + DupCheck --> Consumed: duplicate of an already-DELIVERED message + DupCheck --> Decrypt: not a duplicate + + Decrypt --> Delivered: plaintext, mark logical id + Decrypt --> Buffered: local group state lags (Retriable) + Decrypt --> SecurityRejected: identity binding refusal + Decrypt --> PolicyRejected: permanent policy refusal + + Buffered --> Deferred: no ack, unmark transport dedup + Deferred --> [*] + SecurityRejected --> [*]: no ack, unmark transport dedup only + PolicyRejected --> [*]: ack, stays marked + Delivered --> [*]: ack +``` + +Two group-specific differences from the direct-message deferred atom: + +**Difference 1: only the transport deduplication layer is unmarked.** The +group-level layer stays marked for the whole pending lifetime. It is the +replay-amplification defence and the authoritative double-delivery guard, so the +drain does **not** re-mark the transport layer either. + +**Difference 2: a duplicate of a still-pending message returns `Deferred`, not a +re-acknowledgement.** It is checked before decrypt. Only a duplicate of an +already-delivered identifier is `Consumed`. + +The unacknowledged sender's recovery path when a buffered entry is evicted or +expires is an explicit release of replay protection, which clears both +deduplication layers. + +**The logical identifier is marked only after a successful decrypt**, so a +failed decrypt cannot poison it. Failing to hold that line turns a rejected copy +into an apparent delivery. + +### Relay path + +The relay-supplied identifier **is** the logical identifier, and marking it +pre-decrypt is the replay-amplification defence: one MLS operation per +identifier, regardless of how many copies arrive. + +That inversion is why G4 exists. Without it: + +1. A relay copy is rejected on security grounds, leaving the identifier marked. +2. The per-member re-issue that is the broadcast's own safety net arrives. +3. The duplicate check absorbs it and re-acknowledges. +4. The message is delivered nowhere and the sender is told it was delivered. + +The arms that MUST unmark: security rejection, hard failure, and the +plaintext-spoof drop (whose identifier is attacker-chosen wire input). + +Permanent policy refusals deliberately do **not** unmark, on either path. A +later copy could only waste work. + +`SecurityRejected` and `PolicyRejected` in the diagrams above are group-decrypt +verdicts, one layer below the receive loop. At the boundary the first stays +`SecurityRejected` and the second becomes `Consumed`, which is why it is +acknowledged. See +[the four outcomes](delivery-and-acks.md#the-four-outcomes). + +**The obligation extends to the drain, and that half is not optional.** A relay +copy can outrun its Welcome, so it buffers **before** any decrypt and its +misattribution is judged on the drain rather than at arrival. That is an +ordering a hostile relay picks for free. The drain's rejection arm therefore +releases replay protection exactly as its expiry arm does. + +Unmarking cannot resurrect a burned generation. The honest recovered outcome is +`Deferred`, custody with the sender, never `Consumed`. + +The relay path is also exempt from the deferred acknowledgement, deliberately: +it sends no delivery acknowledgement and its sender is not acknowledgement-gated, +so buffered relay entries carry no arrival transport and the drain's +acknowledgement is a correct no-op. + +### The drain + +The drain fires when the **group's** state advances: a Welcome joins the group, a +commit merges and moves the epoch, or a commit or proposal arrives on the message +channel. It does not fire on a successful application decrypt, because decrypting +a message changes nothing about whether the rest of the batch can decrypt. That +differs from the 1:1 machine, where any successful decrypt drains the pending +queue, and the difference is worth holding on to: here the unblocking event is +always an epoch change. + +```mermaid +flowchart TD + D[Drain fires: group state advanced] --> B[Take the whole buffered batch] + B --> E{Entry expired?} + E -->|yes| X[Release replay protection, drop] + E -->|no| S{Sibling of one already
delivered in THIS batch?} + S -->|yes| Y[Drop without decrypting] + S -->|no| C[Decrypt] + C -->|security rejected / not MLS / failed| R[Release replay protection, no ack] + C -->|plaintext| DL[Deliver, emit logical id, ack envelope id] + C -->|policy refused| P[Drop, KEEP replay protection, no ack
the arrival path already withheld it] + C -->|retriable| RB[Re-buffer, wait for the next drain or expiry] + C -->|commit or other non-application| NA[Consume the commit, drain
pending commits, run another pass] +``` + +The last two arms are what make the drain converge rather than stall. A +retriable entry stays buffered until it decrypts or expires, and a +non-application frame is consumed as a commit and triggers a further pass, +because applying it may be exactly what makes the rest of the batch +decryptable. + +**Never add an "already delivered elsewhere?" check to the plaintext branch.** + +By G1, reaching that branch proves first delivery, so such a check is +unreachable when true. When false it is fatal: the relay path marks its +identifier at arrival, pre-decrypt, and the pending check is blind to the batch +the drain has already taken, so the check suppresses the only decryptable copy. +That is silent loss. + +The sibling set in the diagram is a different thing and is legitimate. It exists +only to drop a sibling copy without burning a doomed decrypt, and, critically, +to stop that sibling's expiry from releasing replay protection for an identifier +that **was** delivered. + +The drain emits the **logical** identifier to the application and acknowledges +the **envelope** identifier to the sender. + +## Membership changes + +```mermaid +flowchart TD + C[Commit received] --> LB[Leaf identity binding:
4 sources, pre-merge] + LB -->|refused| RJ[Permanent refusal.
MUST be in the permanent allowlist] + LB -->|passed| AU{Enforcement enabled?} + AU -->|no, default| RD[Read roster before] + AU -->|yes| AZ{Admin set known non-empty
AND principal not in it?} + AZ -->|yes| RJ2[Reject pre-merge] + AZ -->|no, fail open| RD + RD --> M[Merge] + M --> RA[Read roster after] + RA --> DT{Both reads succeeded?} + DT -->|no| SK[Skip all delta-derived work.
Merge still stands] + DT -->|yes| JD[Derive delta, judge, report] +``` + +Three rules that are easy to get wrong: + +1. **A refused commit must be classified permanently refused.** Retriability is + decided from an allowlist, so a refusal missing from the allowlist is + buffered, re-decrypted on every drain, and, because a buffered commit that + expires having been retried reads as an epoch fork, turns one forged commit + into a group-wide key update round plus a false fork report. +2. **If either roster read fails, skip all delta-derived work.** A silent empty + default fabricates a full-roster delta and a report naming an innocent + committer. +3. **The pre-commit roster must be MLS-derived, never the members cache.** Relay + reconciliation splices entries into that cache that were never in the tree. + +The full reasoning for report-by-default and the fail-open enforcement rule is in +[Group protocol](../spec/group-protocol.md#membership-authorization). + +## Bounds + +| Bound | Value | +|-------|-------| +| Pending broadcast trackers | 64, oldest downgraded to per-member on overflow | +| Broadcast attempts under one logical identifier | 3 | +| Broadcast report timeout | 60 s | +| Unauthorized-change report suppression | 300 s per (group, committer, enforced) | +| Unproven-leaf report suppression | 300 s per (group, sender, site) | + +The `enforced` component of the first key is load-bearing. Dropping it lets an +earlier report-only event suppress the refusal alarm for the same committer, +which is the one report that must always reach the application. diff --git a/docs/state-machines/outbox-and-retries.md b/docs/state-machines/outbox-and-retries.md new file mode 100644 index 00000000..b3562dd7 --- /dev/null +++ b/docs/state-machines/outbox-and-retries.md @@ -0,0 +1,220 @@ +# Outbox and retries + +This is the send-side state machine. It governs a message from the moment the +application hands it over until it reaches a terminal state. + +## Invariants + +**S1. Every message with acknowledgement enabled reaches exactly one terminal +state**: delivered, or failed. There is no third outcome and no indefinite +pending state. + +**S2. Expiry is terminal, including across restarts.** A restart may refresh a +relative delivery window, but an absolute cap bounds the total lifetime, or an +application used briefly once per window would re-grant a fresh window forever. + +**S3. A resend within a process lifetime is re-sealed, never replayed.** The +ciphertext is regenerated against the recipient's current session; the message +identifier is preserved so deduplication and acknowledgement still match. The +qualifier is load-bearing: re-seal provenance holds plaintext, so it is never +persisted, and an entry restored after a restart replays verbatim. See +[Provenance handling](#provenance-handling). + +**S4. Frames that cannot be acknowledged never enter the ladder.** See relay +hint frames below. + +## States + +```mermaid +stateDiagram-v2 + [*] --> Queued: send() + Queued --> Pending: transport accepted + Queued --> Queued: no transport, deferred and retried + + Pending --> Delivered: ACK received + Pending --> Pending: retry (backoff), re-sealed + Pending --> Parked: relay reports recipient unreachable + Pending --> Failed: retry budget exhausted, no live park counter + Pending --> Parked: probe budget exhausted, park counter live + + Queued --> Failed: lifetime expired + Parked --> Delivered: ACK arrives, carried back over the mesh + Parked --> Queued: probe succeeds / peer returns + Parked --> Failed: lifetime expired + + Delivered --> [*] + Failed --> [*] +``` + +**Lifetime expiry cannot settle an entry that has a live pending +acknowledgement.** The sweep skips those, so while a message is genuinely +in flight its bound is the retry ladder, not the 7-day lifetime. Expiry reaches +an entry once it is no longer waiting on an acknowledgement, which is the normal +condition of a `Queued` or `Parked` entry. + +The gate is on the acknowledgement, not on the state name, and the two can come +apart: an entry that never asked for an acknowledgement is never skipped, and a +pending acknowledgement can leave the tracker on its own through capacity +eviction or the timed-out-entry prune. So the `Pending --> Failed: lifetime +expired` edge is absent from the diagram because it is not the designed path, +not because it is unreachable in every accounting. + +Two edges that are **not** there are worth naming, because both look like they +should be: + +- **No transport is not terminal.** A send with nowhere to go persists to the + outbox, offers the frame to the mesh, schedules a retry, and emits a + non-terminal deferral event. Terminal failure comes only from retry-budget + exhaustion or expiry. +- **Parking is entered from `Pending`, not from `Queued`.** The unreachable + verdict is an asynchronous relay report about a message the transport already + accepted, which is why there is a pending acknowledgement for the park to + remove. + +## Timing + +| Parameter | Default | Notes | +|-----------|---------|-------| +| Acknowledgement timeout | 10 s | Starts at **local enqueue**, not at wire write | +| Maximum retries | 10 | | +| Backoff multiplier | 2.0 | Exponential | +| Maximum backoff delay | 300 s | Caps the exponential | +| Maximum pending acknowledgements | 1000 | | +| Outbox capacity | 500 entries | | +| Outbox lifetime | 7 days | Per entry, carrier-relative | +| Absolute lifetime cap | 4 × lifetime (28 days) | Measured from first send, bounds restart refreshes | + +### The timer starts at enqueue + +The acknowledgement timer starts when the message is enqueued locally, not when +it reaches the wire. Transport send confirmation advances the Welcome lifecycle +but does **not** re-stamp the acknowledgement timer. + +This is the mechanism behind the group fan-out scaling cliff. When a rate +limiter defers frames, a frame late in a large fan-out can time out and be +retransmitted before it was ever written. Deduplication absorbs the duplicates, +so the cost is wasted work rather than loss, but the effect is real past roughly +118 members. + +## Retry re-sealing + +A retry does not replay stored bytes, as long as the re-seal provenance staged +at send time is still in memory. Before each retransmission the payload is +re-sealed against the recipient's **current** session state, preserving the +message identifier. + +### Why + +An MLS session that has forked leaves stored ciphertext sealed to a dead epoch. +Replaying it fails forever. Re-sealing means the resend that follows a +receiver-side heal actually delivers. + +This is the sender-side half of the desync recovery documented in +[Session lifecycle](session-lifecycle.md). Together with the receiver-side +withheld acknowledgement, it is what makes a session fork recoverable without +message loss, for entries whose provenance is still in memory. + +### Provenance handling + +Re-seal provenance holds **plaintext**, so it is memory-only and never +persisted. Three rules follow: + +1. Staging is strictly transient. Taking a staged re-seal always removes it, and + removing an outbox entry clears any staged re-seal as well, so a + staged-but-dropped send never strands plaintext. +2. Re-sealing is gated on the session being confirmed. Re-sealing against an + unconfirmed session would produce ciphertext the peer cannot open either. +3. **An outbox entry restored after a restart has no provenance, so it replays + verbatim.** This is the deliberate cost of not writing plaintext to disk, and + it bounds the no-loss claim above: a fork that begins before a restart is not + recovered by the resends that follow it, and those messages settle as an + honest failure rather than delivering late. Persisting the plaintext would + close the gap and is rejected for that reason. + +Media has no equivalent. Chunks are re-encoded rather than replayed, and media +recovers through a descriptor-based resend request. + +## Parking + +When the relay reports a recipient unreachable, a direct message is **parked** +rather than retried into a void. + +A parked message is probed periodically with a backoff that widens from 15 +seconds toward 10 minutes. When the peer returns, parked messages re-enter the +queue. + +**Parking removes the pending acknowledgement**, which is what makes it +different from a long retry: nothing is counting down against the message any +more. + +Removing it is only half the change. The park also **offers the frame to the +mesh**, because the relay has just supplied a fact no local transport status +can, that this specific peer is not on the relay, and a neighbour may still be +able to carry it. An offered copy can therefore arrive and be acknowledged while +no pending acknowledgement exists to match it against, so parked messages are +**settleable without one**: an arriving acknowledgement settles a parked message +`Delivered` directly, emits the delivery event, and flushes the rest of that +peer's parked traffic. + +**The offer and the settle arm are one change; neither is correct alone.** An +offer without the settle arm delivers messages the sender never learns about, +which is worse than leaving the message parked. Any future path that hands a +parked message to another carrier inherits this obligation. + +The probe itself re-enters the acknowledgement machinery but may never earn a +relay verdict, since a mesh carrier cannot produce one. A probe that exhausts +its budget with a live park counter therefore **re-parks** rather than settling +terminally; settlement is reserved for delivery or outbox expiry. + +## Frames that never enter the ladder + +Relay hint frames (`__GRP_RELAY_REG__`, `__GRP_RELAY_BCAST__`) are self-addressed +and replaced by the local bridge before transmission. No acknowledgement can +ever return for them. + +They MUST be sent with acknowledgement disabled: no outbox entry, no pending +acknowledgement, no retry entry. On the ordinary ladder such a frame is +retransmitted 10 times over roughly 800 seconds, each resend costing another +full relay fan-out under a fresh relay-minted identifier that receiver +deduplication does not catch, ending in a delivery failure for an identifier the +application never saw plus a transport-selector penalty for a transport that did +nothing wrong. + +They MUST also be pinned to the internet transport rather than routed by the +selector, for the reason given in +[Control messages](../spec/control-messages.md#relay-hint-frames). + +Their retry policy lives at the application layer: bounded, explicit trackers +with their own timeouts and their own downgrade paths. + +## Offline push + +When the recipient is not reachable and a push channel exists, the ciphertext +travels in the push payload. The message stays in the outbox: a push is a wake +signal plus an opportunistic delivery, not an acknowledgement. + +## Restart behaviour + +On restore: + +1. Lifetime drops an entry, with a terminal failure event, only when it is past + **both** windows: the carrier-relative lifetime *and* the absolute cap. + Capacity overflow and unreadable records drop entries too, on their own + rules. +2. An entry that survives and is past its carrier-relative window gets that + window refreshed, because a restart means a fresh delivery opportunity. + Entries still inside their window keep their original stamp. +3. Restoration of per-peer end-to-end capabilities MUST complete **before** the + queued sends flush, or the flush emits downgraded envelopes to every + established peer. + +Rules 1 and 2 read as one rule and are not. The restore drop requires both +windows to have lapsed, while the in-process sweep drops on **either**. The +difference is deliberate and it is visible: an entry past the absolute cap whose +carrier-relative window was refreshed by a park probe survives restore, and is +then dropped by the first in-process sweep instead. An implementation that +harmonizes the two operators loses the refresh in one direction, or the cap in +the other. + +Ordering rule 3 is easy to get wrong because both steps happen during startup +and neither obviously depends on the other. It is a real ordering constraint. diff --git a/docs/state-machines/session-lifecycle.md b/docs/state-machines/session-lifecycle.md new file mode 100644 index 00000000..14dc6e77 --- /dev/null +++ b/docs/state-machines/session-lifecycle.md @@ -0,0 +1,235 @@ +# Session lifecycle (1:1) + +A 1:1 session is an MLS group of two, named by the deterministic slot identifier +in [Identity](../spec/identity.md#session-identifiers). + +## Invariants + +**E1. The slot identifier is symmetric and public.** Both sides compute it +without exchanging it, and so can anyone else. + +**E2. An envelope must name the slot shared with its claimed sender.** Checked +before decryption is attempted. + +**E3. A desync re-key destroys session state, never queued plaintext.** The +outbound pending queue holds plaintext and is sealed against the rebuilt session +at flush time. This is a property of the re-key, not of every reset: the +post-unblock reset deliberately drops the queue. See +[Session reset semantics](#session-reset-semantics). + +**E4. One init key per peer.** An MLS init key is single-use. + +## Establishment + +```mermaid +stateDiagram-v2 + [*] --> None + None --> KeyPackageSent: push our key package + None --> Establishing: peer's key package received + KeyPackageSent --> Joined: peer built a Welcome against it + + Establishing --> Owner: we created the group + Establishing --> Joined: we processed a Welcome + + Owner --> Confirmed: successful decrypt + Joined --> Confirmed: Welcome processed, probe/ack, or decrypt + + Confirmed --> Desynced: WrongEpoch / NoPastEpochData + Desynced --> None: session_reset, tear down + advertise + None --> Joined: peer's Welcome rebuilds us back in + + Confirmed --> [*] +``` + +### Both-create convergence + +Both peers can create a session simultaneously. The tiebreaker orders the two +addresses by **hash bytes** (see [Identity](../spec/identity.md#ordering)) and +one side adopts the other's Welcome. + +The **owner** side, whose session survives, never *adopts* a Welcome. It may +well receive the peer's Welcome, but the tiebreaker keeps the local session and +the Welcome is not processed into one, so any mechanism that keys off Welcome +adoption silently skips the owner. This is why: + +- session confirmation also triggers on **any successful decrypt**, and +- the pending-decryption drain hangs off that confirmation, so a decrypt reaches + it too. + +"Also" is the operative word. Confirmation is not decrypt-only: the joiner +confirms while processing the Welcome itself, and a plaintext confirmation probe +or its acknowledgement confirms too. Successful decrypt is the trigger added on +top so that the one side no other trigger can reach, the both-create owner, is +still covered. Only that owner, waiting on the adopt path, depends on decrypt +alone. + +The drain is downstream of the confirmation **transition**, not of decryption as +such: a decrypt on a session that is already confirmed does not re-run it. + +An encrypted confirmation frame travels inside the envelope on the adopt path +precisely so the owner gets a group-aware decrypt to converge on. It is consumed +on receipt and never surfaced to the application. + +## Key package pool + +An MLS init key is consumed when a Welcome built against it is processed. Two +peers handed the **same** key package therefore cannot both establish: the +second Welcome is unprocessable. + +The pool assigns **one package per peer**. Resolution order for a push: + +1. This peer's own live package, so repeat pushes cost no key material. +2. An unclaimed package, claimed here. Claiming is what stops an upgrade + stranding a pre-existing package. +3. A fresh mint. + +The assignment is stored **on the bundle**, not in a side map, so it survives +restarts and cannot disagree with the pool. + +Rotation is consumption-driven: a consumed package is reported gone and the next +push mints a successor, which is RFC 9420's guidance to rotate as soon as +possible after use. + +### The ceiling + +The pool is bounded (64 live packages). **At the ceiling it shares the newest +package rather than refusing to advertise or evicting**, because either +alternative costs session establishment outright, and it reports pool exhaustion +as a suppressed warning. + +The ceiling gates **only the mint**, which is the only step that grows the pool. +A claim relabels a package that already exists, so steps 1 and 2 run ahead of the +check and a full pool holding an unclaimed package still hands out its own key. +Gating the claim too would weaken forward secrecy to stay under a bound the claim +never approaches. + +Reaching the shared branch therefore proves every live package belongs to another +peer. "Newest" makes it the one most likely to be mid-establishment: if the +over-ceiling peer's Welcome lands first, that peer's advertisement goes +unprocessable until its next push. + +### Expiry destroys key material in two stages + +Deleting the bundle record alone leaves the private init key in the MLS provider +**forever**, because only a peer's Welcome removes one. + +1. Expiry **withdraws** the package from every caller immediately. +2. Only past a grace window (7 days) is the provider key destroyed. The provider + key is deleted **first**, and the record is kept so a failed deletion can be + retried. + +The grace window exists so a Welcome built just before expiry still opens. + +Deletion also purges legacy records whose format predates the bundle: an +unparseable record is read as the serialized key package so its provider +reference is derivable. A record-only delete there is the exact stranding this +rule removes. + +## Desync and heal + +An **established** session whose two sides disagree on the MLS epoch yields an +epoch-disagreement decrypt failure. This is a dedicated recoverable class, kept +strictly separate from ordinary decryption failure. See +[Delivery and acknowledgements](delivery-and-acks.md#classifying-decrypt-failures). + +### Tier 1: honest failure and heal (receiver side) + +On a desync the receiver: + +1. **Withholds** the delivery acknowledgement and unmarks the identifier. +2. Does **not** enqueue. The ciphertext is sealed to a dead epoch and can never + drain. +3. Schedules a re-key. + +The re-key tears down **our own** stale session and advertises a key package +flagged as a session reset. The peer drops its stale session, rebuilds from our +key package, and Welcomes us back, which we join session-less. + +**Deleting the local session is what makes convergence symmetric for both +address orderings.** The returning Welcome is *joined* rather than gated by the +greater-address-adopts tiebreaker. + +### The rate-limit floor is never reset early + +One re-key per peer per interval (30 seconds), stamped before the send. + +A successful decrypt on the healed session does **not** clear the floor. A +genuine re-fork and a replayed old-epoch frame are indistinguishable at this +layer, so clearing on heal would let an attacker interleaving one legitimate +decrypt between replays force roughly one teardown per inbound message. + +The floor lapses only by the interval elapsing. Tier 1's withheld +acknowledgement plus sender retries keep delivery honest during the wait. + +### Tier 2: true no-loss (sender side) + +The sender re-seals each resend against the peer's current session. See +[Outbox and retries](outbox-and-retries.md#retry-re-sealing). + +Tier 1 alone makes the fork **detectable and healable**. Tier 2 is what makes it +**lossless**. + +### The trigger is unauthenticated and cannot be made otherwise + +This is residual risk R2 in the +[threat model](../security/threat-model.md#r2-unauthenticated-session-desync-trigger), +and it is repeated here because it constrains any change to this state machine. + +The peer identifier passed to the re-key scheduler is the **wire-claimed** +sender. The encrypted prefix is data-plane and deliberately exempt from the +signature gate; MLS validates the framing header (group identifier, then epoch) +**before** any AEAD, sender-data, or signature check; and a slot identifier is a +public function of two public addresses. + +So anyone who can inject a frame reaches this classification, with no key +material, no captured ciphertext, no session, and no replay. + +This is inherent to MLS framing, not an implementation defect. A sender-identity +check structurally cannot help: the MLS credential it would compare against +exists only once decryption **succeeds**. + +**The mitigation is that acting on the trigger is harmless, not that it is +trusted:** + +| Property | Effect | +|----------|--------| +| Slot binding (E2) | One derivable identifier cannot be aimed at arbitrary peers, and the re-key tracking map cannot be grown with attacker-chosen keys | +| Bounded tracking map | Memory is bounded regardless | +| Per-peer rate limit | Churn is bounded | +| The re-key preserves the pending queue (E3) | Nothing is destroyed | +| Sender-side re-sealing | Resends deliver after the heal, for entries whose re-seal provenance survives in memory | +| A security warning per re-key | A sustained rate, meaning injection rather than a real fork, is visible | + +**Residual:** bounded re-key churn on a pair. Delivery delayed, never silently +lost. A fork that spans a sender restart is the one case where the resend cannot +recover, because the re-seal provenance did not survive +([outbox S3](outbox-and-retries.md#invariants)); those messages settle as an +honest failure rather than a false delivery. + +**What would close it:** a signed epoch-corroboration exchange before teardown. A +liveness-only probe does not work, because a healthy peer answers and the +teardown happens anyway. + +## Session reset semantics + +Two paths advertise a key package with the reset flag set, and they differ in +exactly one respect that matters: what happens to queued outbound plaintext. + +**The desync re-key**, described above, is a repair. It: + +- deletes local session state, +- **keeps** the outbound pending queue, which holds plaintext and is sealed + against the rebuilt session at flush, +- advertises a fresh key package with the reset flag set. + +**The post-unblock reset** is a clean slate, not a repair. When one side unblocks +the other, it deletes its session and advertises a reset so both sides converge +on a single fresh group rather than one orphaned session each. It additionally +**drops** the peer's pending queue, failing each queued message terminally, and +discards the held key package and learned capabilities. + +The difference is deliberate. A re-key repairs a session the user still wants, +so destroying their unsent messages would be the bug. A block severed the +relationship, and delivering messages queued before the block, on the strength of +an unblock, is not what the user asked for. A reader reasoning about queued +plaintext must therefore ask which reset they are looking at. diff --git a/docs/state-machines/transport-lifecycle.md b/docs/state-machines/transport-lifecycle.md new file mode 100644 index 00000000..3dea05f1 --- /dev/null +++ b/docs/state-machines/transport-lifecycle.md @@ -0,0 +1,183 @@ +# Transport lifecycle + +Transports come and go. This state machine governs which one a message takes, +when the selection changes, and why the change is deliberately reluctant. + +The selection engine is called DORS. For the scoring model and tuning +parameters see [DORS deep dive](../dors.md) and +[DORS configuration](../dors-configuration.md); this document covers the state +machine and the invariants a change must not break. + +## Invariants + +**T1. Flapping is worse than a suboptimal choice.** A transport switch costs a +reconnection and often loses in-flight frames. Hysteresis, cooldown, and a +stability window all exist to make switching reluctant. + +**T2. A demoted fallback must never pin out an available real transport.** This +is the one case where reluctance is wrong, and it is an explicit bypass. + +**T3. Transport availability is observed, not assumed.** A transport that +accepts a frame and returns success has not delivered it. + +**T4. Escalation signals are held, not sampled.** A signal that fires on one bad +sample and clears on the next produces exactly the flapping T1 forbids. + +## Transport states + +Two vocabularies overlap here and are worth separating before the diagram. + +A transport reports its own **status**, one of five values: `Available`, +`Unavailable`, `Connecting`, `Disconnected`, `Error`. Stopping a transport +leaves it `Disconnected`, not `Unavailable`. + +**Current** is not one of them. It is a selection-level overlay owned by DORS, +and the diagram below is drawn at that level: it collapses every non-available +status into one node to show when selection changes. + +```mermaid +stateDiagram-v2 + [*] --> NotAvailable + NotAvailable --> Available: transport reports up + Available --> Current: selected by DORS + Current --> Available: another transport selected + Available --> NotAvailable: reports down, stopped, or errored + Current --> NotAvailable: reports down, stopped, or errored + NotAvailable --> [*] + + note right of Current + One transport is Current per + device, not per peer. + end note +``` + +**Current is device-global, not per-peer.** The selector holds a single current +transport for the whole device. Nothing keyed by peer exists, so a change to +make selection peer-specific is a new data structure, not a tweak. + +## Selection + +```mermaid +flowchart TD + E[Send, or a metric changed] --> A{Is the current
transport still available?} + A -->|no| P[Pick the best available. No gating.] + A -->|yes| D{Current score < 0
AND best score >= 0?} + D -->|yes| B[Demoted-fallback bypass:
switch immediately, ignore
cooldown/hysteresis/stability] + D -->|no| H{Passes hysteresis
AND cooldown
AND stability window?} + H -->|yes| S[Switch] + H -->|no| K[Keep current] +``` + +### The demoted-fallback bypass + +The internet transport is scored with a deliberate demotion so the mesh is +preferred, when `prefer_online` is off and the message does not ask for the +internet. That demotion interacts badly with the ordinary gating, and the +bypass is the fix. + +The sequence that makes it necessary: + +1. A send finds the mesh peer unreachable and escalates to the internet + transport. +2. That escalation makes the internet transport current **and re-arms the switch + cooldown**. +3. The mesh peer comes back. + +Without the bypass, the next send rides the relay for the entire cooldown and a +nearby off-relay peer is silently skipped. The bypass fires the instant a real +transport's non-negative score outranks the demoted sentinel. + +## Escalation + +Escalation is separate from scoring. It answers "this transport is failing, try +a stronger one" rather than "this transport scores lower". + +| Trigger | Threshold | Hold | +|---------|-----------|------| +| Consecutive retry failures | 2 | n/a | +| Low success rate | below 0.30 | requires at least 5 samples first | +| Poor signal | RSSI below threshold | must persist 10 s | +| Queue congestion | queue depth over threshold | must persist a configured duration; recovery uses a ratio of the threshold, not the threshold itself | +| TTL near exhaustion | 2 hops remaining | signal held for a configured duration after detection | + +Three details in that table are load-bearing and easy to lose in a refactor: + +- **The success-rate trigger requires a minimum sample count.** Without it a + single early failure on a fresh link reads as a 0% success rate and escalates + immediately. +- **Congestion recovery uses a ratio of the threshold, not the threshold.** + Recovering at the same value that triggered guarantees oscillation around it. +- **Signals are held after detection.** A TTL signal that cleared on the next + message would escalate and de-escalate alternately. + +## Default gating parameters + +| Parameter | Default | +|-----------|---------| +| Switch hysteresis | 15.0 score points | +| Switch cooldown | 20 s | +| Stability window | 8 s | +| Poor-signal duration | 10 s | +| Retry failures before escalation | 2 | +| Minimum success rate before escalation | 0.30 | +| Minimum samples before that check | 5 | +| TTL escalation threshold | 2 hops | +| Prefer online | false | + +## Metrics feeding the score + +Scoring is multi-factor, over seven factors: signal strength, proximity, +bandwidth, congestion, energy, reliability, and load. + +Two observations about the feed that are worth writing down because they were +learned the hard way: + +1. **A metrics map with no production writer scores nothing.** A metric that + only tests populate silently contributes a constant. Any factor added to the + score needs a live producer, and the absence of one is invisible in unit + tests. +2. **Configuration read once at construction does not respond to updates.** If + the selector snapshots its configuration when it is built, a runtime + configuration update changes a struct nobody reads. A configuration getter + that round-trips through the same snapshot will confirm the update happened + and prove nothing. + +## Relay role + +Relay promotion is derived from **observed forwarding behaviour**, not from a +declared role or a battery feed. That matters because it means the role +resolves on devices that report no battery telemetry at all. + +Where a handicap is applied to discourage a role, it must be a **fixed +constant**, not a multiple of a measured span. A multiple of a span shrinks as +the span shrinks, so the handicap disappears exactly when the measurements +converge and it is needed most. + +## Per-transport constraints + +| Transport class | Chunk size | In-flight window | +|-----------------|-----------|------------------| +| Bluetooth LE | 4 KiB | 2 | +| Default | 32 KiB | 4 | +| Internet | 256 KiB | 8 | + +Concurrent media transfers are capped per peer (2), so one large transfer cannot +starve the link. + +## What "sent" means at each layer + +This is a recurring source of confusion for application teams: + +| Layer | "Success" means | +|-------|-----------------| +| Transport `send` returns Ok | The frame was accepted for transmission. Some transports enqueue unconditionally | +| Transport send confirmation | The frame reached the wire | +| Relay write acknowledgement | The relay accepted the frame | +| Delivery acknowledgement | The **recipient** processed it | + +Only the last one is delivery. Unconditional-enqueue transports are the reason +relay hint frames must be pinned rather than routed: such a transport swallows a +self-addressed frame and reports success. Wi-Fi Direct and Reticulum behave this +way. Bluetooth LE fails closed, because self is never a connected peer, but that +only helps on a BLE-only device: the transport manager treats a synchronous +refusal as a fallback trigger and hands the frame to one of the others anyway.