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.
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.
| 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 |
| A7. Hostile gateway | Operates or has compromised a gateway a device attached to | Everything A3 has, at a zone's bridge to the wider network: originates verdicts and presence answers, and observes attach sessions |
| A8. Provisioning-time adversary | Handles a leaf node, or its labelling, before its owner does | Chooses which key the device's pairing artifact names, and what the device pairs with first |
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.
A8 arrives with leaf nodes and has no cryptographic answer, because every cryptographic check passes: the key on the swapped label does derive to the address on that label. Its anchor is the one an invite already relies on, the out-of-band human context in which the artifact was obtained, and what the protocol adds is that the substitution is detectable afterwards, because an address is stable and self-certifying. See Leaf node provisioning.
┌─────────────────────────────────────────────────────────────┐
│ 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)
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.
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.
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.
Enforced by: the control-plane signature gate. See Control messages.
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 |
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.
| 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) |
| Freshness inside the signed payload, with a bounded window | A captured control frame verifying forever (A2) |
| Refusing the older signing payload from a peer that has proved the newer | Side-stepping freshness with a capture made before that peer upgraded (A2) |
| A session reset admitted only above a per-peer high-water mark | Repeatable teardown of a live session from one recording (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) |
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. The headline: a missing acknowledgement is not proof of non-delivery.
Stated plainly, because a threat model that lists only what it defeats is marketing.
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.
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.)
- 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.
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.
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.
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.
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.
The 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 one exception and is honoured only from a signed key package,
because it is consumed as a destination key rather than a feature hint.
ctrl_versions is the other, and it is answered by a different mechanism rather
than by a signature. Stripping it makes this node sign the payload that states
no freshness. What bounds that is the rule that never reads the field: a
verifier that has once verified a freshness-bound signature from a peer refuses
that peer's older-payload frames from then on, durably. So the strip works until
the first genuine such frame arrives and never afterwards, and the residual is
the window before a peer's first one.
A frame signed under the freshness-bound payload can still be replayed inside
the window its verifier allows: 30 days on an install running the engine, two
days on a leaf. Only session_reset is closed outright, by the per-peer
high-water mark, because it is the directive that destroys state.
Why the rest are left bounded: a mark per directive is a durable per-peer record per directive class, and the frames in question re-state something that is already true: a replayed presence update, typing indicator or capability advertisement asserts what the peer asserted when it sent the original. The cost of closing them is paid on every peer forever; the harm is a stale restatement inside a bounded window.
What stands in front of it: the receive deduplicator refuses an exact repeat for an hour, so a replay must wait out that window to land at all.
Two exposures that are not in the window at all. A peer that has never
presented a freshness-bound signature is where it always was, since holding it
to a payload it has not shown it can produce would stop its resets working and
with them its ability to heal a forked session. And a published Nostr key
package is signed under the older payload deliberately, because a record left
on a relay for strangers has no known verifier and signing it the other way
makes cold contact unverifiable to anyone who has not upgraded. That record
carries no reset, and its replay is bounded instead by the key package's own
validity window, which the importer now caps at 90 days
(MAX_ACCEPTED_KEY_PACKAGE_LIFETIME, applied on import and on every read of
the contact cache). Before
issue 396
that window had no bound at all: OpenMLS validates only that now falls inside
it, so a package claiming a century was admitted and stayed usable for one.
Both the window and the high-water mark are judged against the verifying device's own clock, which no peer can attest to. A device whose clock is wrong does not fail quietly in one direction: set far enough back, it admits frames it should have refused; set forward or unset, it refuses every honest peer and takes its own control plane down, key package exchange included.
Mitigation: the refusal is reported under its own STALE_CONTROL_FRAME code
so that a run of them across many peers reads as a local fault rather than an
attack, and security.control_freshness_enforced turns enforcement off without
a new binary. A leaf node is required to have a time source at pairing for this
among other reasons; see
leaf provisioning.
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.
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.
A gateway bridges a zone to the internet or to a
wide-area backbone. Attaching to one binds the session to the device's address
with a domain-separated proof under offline-gateway-addr-v1, which stops a
gateway from attaching a session under an address the device does not control,
and stops a proof harvested by one gateway being replayed against the relay. It
does not make the gateway trusted for delivery, and nothing about the design
tries to.
Impact: an A7 gateway can do four things.
Blackhole. Accept frames and forward none. Costs latency and battery.
Lie "unreachable". Trigger parking for a recipient who is reachable. Retries quiet down and the sender's probes stretch out, so delivery is delayed. Mesh offers and probes continue, and the outbox still holds the message, so it remains deliverable by every other path.
Lie "reachable". Attract traffic to a path that goes nowhere, bounded exactly as the blackhole is.
Observe. Attach sessions and presence queries reveal which addresses are in the zone, and when they are active, to the gateway operator. This is the same exposure relay attach already gives the relay operator, at zone granularity.
Why it stands: verdicts are not authenticated and cannot be. They are claims about a third party's connectivity, and no signature makes a claim true. The design answers this at the policy layer instead: a verdict MAY open a path and economise retries, and MUST NOT close one; a "reachable" claim never suppresses the acknowledgement ladder. Delivery settles only on the recipient's end-to-end acknowledgement or terminal outbox expiry, so no gateway answer settles anything.
Mitigations in place today: the settlement invariant, verdicts-never-close-a-path, MLS end to end so a gateway sees ciphertext plus routing metadata, and the recipient-aware decay that reverts every gateway claim to "no opinion" on a TTL (ten minutes for a verdict, five for a presence answer) rather than letting it stand indefinitely. These hold against a hostile relay right now, which is why an A7 gateway inherits a bounded blast radius rather than a new one.
Address-bound attach is now among them. A device attaching over the daemon
contract signs
offline-gateway-addr-v1 over
its own address and the gateway's per-connection challenge, and checks the
address the gateway echoes back against its own. That is what stops a gateway
attaching a session under an address the device does not control, and the
domain separation is what stops a proof harvested here being replayed against
the relay. It does not make any verdict trustworthy, which is the
enumeration above and is unchanged.
Mitigations specified but not enforceable from here: the per-device and per-peer token-bucket budgets against backbone exhaustion are the gateway's own to apply, so a device cannot verify that its gateway applies them. This is listed apart from the others on purpose: a threat model that reads as protection when the protection is somebody else's to implement is the failure this document exists to prevent.
What would close it: nothing closes the lying-gateway case, because the lie is about someone else's state. Provisioning is the real control: gateways are installed by a person (ADR 0016), so "which gateways can my device attach to" is an operational decision rather than an emergent one. Multiple gateways per zone reduce the blast radius of any single hostile or broken one. Zone-membership exposure would need a design that does not name recipients to the bridge at all, which no addressing scheme here provides; it is the same open problem as relay-side metadata.
Replicated documents merge changes that arrive from peers, and merging means
handing bytes to a CRDT engine. The engine has open defects where a malformed
or causally impossible change panics rather than returning an error, and one of
them poisons the document's lock so the retry panics too. The mobile artifact
ships with panic = "abort", so there is no unwinding to catch.
MLS does not help here, and the reason is worth being precise about: authentication establishes who sent the bytes, and the question the engine is about to ask is what shape they are. A peer can be exactly who they claim and still send a blob that ends the process.
Impact: a member of a space can abort the application on a device it replicates with. Not silent, not remote-code-execution, and not available to anyone outside the space: it requires someone the user has already accepted into a shared document, and it is attributable to them.
Group spaces widen who this is, not what it does. A 1:1 space has exactly one other member; a group space has as many as the roster, and every one of them can reach this path. The population is still people the user accepted into the group, and a frame is still attributable to the member whose leaf signed it, so the class does not change. What changes is that "someone the user accepted" now includes members somebody else added, which is a membership decision the group's admins make and the containment below does not depend on.
The quarantine that bounds this is per space, so one crafted blob is refused for the whole group rather than per sender. A second member sending the same blob is refused by the same record.
Why it stands: the defects are upstream and the engine is not ours to fix on our own schedule. Re-implementing its decoder to predict what it will accept would mean maintaining a second parser that has to agree with the first one forever.
Mitigations in place today: blobs are judged before the engine sees them, which refuses every shape we have been able to reproduce, whether it arrives as a run of changes or as a whole document; frames are bounded at 32 KiB before decoding; a space accepts at most 1024 documents on a peer's say-so, so an offer cannot spend unbounded storage; and the digest of a blob about to be imported is written to disk first, so a blob that does end the process is refused when the sender retries it. That last one is what bounds the damage to a single abort rather than a crash loop driven by the delivery ladder faithfully doing its job. See ADR 0019.
A smaller cost at the same boundary: every version offer a peer sends makes this device read the version of each document in that space, and a peer may send offers as fast as the link carries them. The work is bounded by the space's document ceiling and attributable to a member the user has already accepted, which is why it is recorded here rather than raised as a residual of its own.
This shrinks when the engine's import hardening reaches its Rust release.
Boundary 3 assumes platform secure storage. A phone has an OS-backed keystore behind that assumption; a microcontroller has whatever the part provides, and an attacker holding the device has physical access rather than the remote access A6 describes.
Why it stands: it is an integration property, not a protocol one. A leaf SHOULD hold its identity key in the part's secure key storage, reachable through the same storage seam the SDK already uses for key material, and a device that keeps the key in general flash yields it to anyone who holds the device.
What bounds it: one device, one key. A fleet provisioned with a shared identity key turns one extraction into every unit's identity, and the address that names one device names all of them. See Leaf node provisioning.
The telemetry pipe authenticates with a bearer key that lives in the app binary. Anyone who extracts it can post accepted events against it, and the application is billed for them.
Why it stands: the key is the only credential a device can hold, and a device is not a trusted party (A6). Signing each batch with the identity key would name the device to the ingest, which the telemetry design deliberately avoids.
What bounds it: the ingest rate-limits per key and a key can be revoked from the portal, which is the response to a leaked one. Rotation is a portal feature; the SDK carries whatever key the application passes.
The pipe trusts the Mozilla root set compiled into the SDK release, never the
device's trust store. If the ingest's certificate chain ever moves to a root
absent from a shipped SDK, that SDK's telemetry stops: a network error,
backoff, and last_error in the stats.
Why it stands: consulting the device store would make an interception certificate installed on the device a trusted one, and redirection by a compromised device is exactly what the fixed endpoint and bundled roots exist to close.
What bounds it: the ingest stays on a mainstream authority, a change of authority is treated as an SDK release event, and the failure is loud in the stats rather than silent.
Until 0.26 the Rust crates opened no socket: every byte that left a device went through a platform bridge the application could see. The telemetry pipe is the one exception, and its egress is bounded as follows.
- One endpoint, fixed at build time. There is no runtime endpoint setting,
no host callback that could repoint the stream, and no redirect the uploader
follows. A build for a different ingest says so in its environment, and only
https://is accepted. - TLS 1.2 or 1.3 through rustls, with the Mozilla root set compiled in. The device's trust store is not consulted (R14).
- An HTTP CONNECT proxy, when the environment names one. The telemetry uploader can use an HTTP CONNECT proxy selected through the HTTP client's supported environment configuration, read when telemetry is enabled. TLS remains between the SDK and the destination server and is validated against the bundled Mozilla root certificates. Installing an interception certificate only in the device trust store does not make that certificate trusted by the SDK. Such an interception attempt fails certificate validation; queued telemetry remains subject to retry, capacity, and expiry limits.
- What crosses is the inventory in docs/telemetry.md: typed projections with no identifier field, the engine's own fixed reason tokens, and two aggregates computed on the device. Message content, addresses, group ids, usernames and message ids have no field to land in; the golden fixture pins the bytes.
- When it crosses is a bounded set of wakes on one background thread, never the caller's, and never while the engine holds a lock.
- Explicit enablement and shutdown semantics. The hosted telemetry client
performs no collection, buffering, persistence, or uploads before explicit
enablement.
set_telemetry_enabled(false)stops new collection while previously queued records continue to drain.disable_telemetry()stops new collection and initiates a final flush, waiting up to three seconds for shutdown. An in-flight request may complete afterward. Remaining persisted batches are retained for a later enablement, subject to queue limits and expiry, except after the ingest answerstelemetry_disabled(the application's toggle is off in the developer portal). That discards the queue and whatever is collected until the next enablement, so a toggled-off stretch is never uploaded later.
The key that authenticates the stream is R13.
Enabling the pipe also installs the only writer the Rust core has to the
platform log. That is a local disclosure rather than an egress one, and it is
bounded the same way: only records under the pipe's own tracing target are
passed through, so the engine's internal logging, which names groups, senders
and peers, never reaches logcat or the unified log whatever its level. A
device log is readable by anything on the device that can run logcat, which
is why the filter is on the target and not on a level. See
the debug tap.
The bound is on the core, not on the whole SDK. The React Native bridge
sources log through android.util.Log and print on their own account, and
some of those lines name a profile or a peer address. That is pre-existing
behaviour outside this filter, and it is the reason a device log is not
treated as a trust boundary anywhere in this model.
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:
- 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.
- 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.
When the dropped prose carried real structure, add it back as a typed field the scrubber can hash, not as prose.
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.
- 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.