Help

PROTOCOL.md — ChannelMessenger wire protocol, normative

WireProtocol v1 · Envelope v1 · Handshake v1 · CryptoSuite v1 · RoutingProtocol v1

Status: draft complete, checkpoint 3 — §0–§9. Test vectors in vectors/. THREAT-WALKTHROUGH.md is the remaining Assignment 01 deliverable.

§6.5 (Handshake v1) was added on 2 September 2026. The header above had named Handshake v1 as a v1 component since the first draft, and §4.2 had described its output, but no section defined the exchange — §4.2's cross-reference pointed at §6.2, which is the encounter sequence and contains no pairing step. THREAT-WALKTHROUGH.md predates §6.5 and does not yet cover packet types 11–13; §6.5.8 carries that analysis in the meantime.

This document is normative. It must be sufficient for an Android engineer and an iOS engineer, working from it alone, to produce byte-compatible implementations. Where it is ambiguous, that is a defect in this document — record it in SPEC.md §6 rather than resolving it locally.

Governing documents, in precedence order: DECISIONS.md, SPEC-REVISION.md, SPEC-ORIGINAL.md.

---

0. Conventions

Requirement levels. MUST / MUST NOT / SHOULD / MAY per RFC 2119.

Constants marked [policy] are tunable and appear in §9's policy table. They are SHOULD, not MUST, and are expected to change empirically (DECISIONS.md D1). An implementation MUST read them from configuration rather than hardcoding them, and MUST NOT reject a peer for holding different values.

Byte order. All multi-byte integers on the wire are big-endian unless stated otherwise.

Byte strings are written as lowercase hex. denotes concatenation. len(x) is the length of x in bytes.

Fixed-width means fixed-width. Fields specified with an exact byte count are never length-prefixed, never variable, and never omitted. Absent optional values are encoded as their defined zero value, not skipped.

Hostile input. Every parser MUST treat received data as hostile (SPEC-ORIGINAL §39). Length bounds are checked before allocation. No force-unwrapping, no try!, no unwrap() on network-derived or cryptographic input. A packet that fails any structural or cryptographic check is discarded immediately and silently — implementations MUST NOT return distinguishing errors to a peer, because doing so builds an oracle.

---

1. Canonical CBOR

The wire format is CBOR (RFC 8949). Determinism matters because two implementations that encode the same data differently will fail to interoperate — and, before DECISIONS.md C1, would have failed authentication with no obvious cause.

### 1.1 Encoding rules — normative

An ChannelMessenger encoder MUST produce, and a decoder MUST accept only, CBOR satisfying RFC 8949 §4.2.1 core deterministic encoding:

  1. Map key ordering: bytewise lexicographic on the encoded key. Keys are sorted by comparing their encoded byte sequences directly.
  2. RFC 7049 canonical ordering is FORBIDDEN. The older standard sorted by encoded length first, then bytewise. It produces a different order and some libraries still default to it. An implementation MUST NOT use length-first ordering. This is named explicitly because "canonical CBOR" is ambiguous between two incompatible standards, and choosing silently is the single most likely source of cross-platform divergence in this protocol.
  3. Shortest-form integers. A value MUST be encoded in the smallest representation that holds it. 5 is 0x05, never 0x18 0x05.
  4. Definite lengths only. Indefinite-length arrays, maps, byte strings and text strings MUST NOT be emitted, and MUST be rejected on decode.
  5. Integer map keys only. Every map key defined by this protocol is an unsigned integer. Text string keys MUST NOT be used. This is both smaller and removes any question of collation.
  6. No floating point. No half, single or double precision values appear anywhere in this protocol. A decoder MUST reject them.
  7. No tags other than those this document defines. Currently: none.
  8. Canonical simple values. false/true/null only; no undefined.

> Measured, not assumed. vectors/03-canonical-cbor.json demonstrates that for canonically > encoded unsigned integer keys, the RFC 8949 and RFC 7049 orderings always coincide — CBOR's > uint encoding is monotonic in both value and encoded length, so bytewise and length-first sorting > agree. Rule 5 therefore does more than save bytes: it removes the 8949-vs-7049 hazard from every > map this protocol defines. Rule 2 stays normative anyway, because it must hold for any future map > that is not integer-keyed, and because an implementation should not be relying on a coincidence it > has not checked.

### 1.2 Unknown fields

Forward compatibility (SPEC-ORIGINAL §35):

### 1.3 What canonical CBOR is not responsible for

Authentication does not depend on any of the above. The AAD is a fixed-layout byte concatenation (§3), constructed without CBOR. A CBOR disagreement between two implementations therefore produces a visible parse failure, not a silent universal authentication failure. See DECISIONS.md C1 for why this matters more than any single rule in §1.1.

Compression, likewise, needs no canonical determinism: it sits inside the encryption boundary, so only the format must be pinned (DECISIONS.md C6).

---

2. Envelope v1

The Envelope is the unit of delivery. It is byte-identical regardless of the path it travels — direct, relayed, courier-carried, or across an internet gateway hop (SPEC-ORIGINAL §2). No transport re-encrypts or rewrites it.

### 2.1 Fields

Encoded as a CBOR map with integer keys, per §1.1.

KeyNameTypeWidthIn AADMutable in transit
----:-------------------:------::------------------:
1protocolVersionuint1 byteyesno
2packetIDbstr16 bytesyesno
3destinationTokenbstr16 bytesyesno
4creationBucketuint4 bytesyesno
5expiresAfteruint1 byteyesno
6transitPolicyuint1 byteyesno
7payloadLengthuint2 bytesyesno
8fragmentIndexuint1 byteyesno
9fragmentCountuint1 byteyesno
10totalLengthuint4 bytesyesno
11noncebstr12 bytesnono
12hopLimituint1 bytenoyes
13ciphertextbstrvariablenono

Nothing else. In particular the Envelope carries no display name, device name, OS, model, build number, account identifier, or any permanent identifier of any kind (SPEC-ORIGINAL §13, §15).

### 2.2 Field definitions

protocolVersion1 for this document. A node receiving a higher major version it does not implement MUST discard the packet and MUST NOT relay it, because it cannot validate the structure it would be forwarding.

packetID — 16 bytes from a cryptographically secure random source.

> Duplicate suppression and inventory are keyed on packetID ‖ fragmentIndex, not on packetID > alone. All fragments of one message share a packet id, so a message-granularity table marks the > whole message seen when its first fragment arrives and then rejects every sibling as a replay — > fragmentation and duplicate suppression cannot both work at message granularity. The relayed and > suppressed unit is a fragment. Reassembly happens only at the destination (§3.2). Found by an > end-to-end test after both mechanisms were separately correct. It MUST NOT be derived from the content, the sender, the recipient, or the time; a derived ID would be a linkable fingerprint. 128 bits makes collision negligible across any plausible mesh size.

Privacy note: packetID is necessarily constant along a packet's whole journey — that is what makes duplicate suppression work. A global observer could therefore trace one packet across the network. This is accepted and is already excluded from the threat model (SPEC-ORIGINAL §32, global radio traffic analysis).

destinationToken — 16 bytes, derived per checkpoint 2. Distinct from presenceToken (DECISIONS.md C3): a relay that heard a presence beacon MUST NOT be able to match it against an envelope it is carrying.

creationBucket — the sender's creation time as floor(unixTime / 600), i.e. in 600-second buckets, matching the token epoch length so it reveals nothing the token does not already.

It exists because expiry alone is insufficient: if lifetime were measured only from each hop's receipt time, a packet could be kept alive indefinitely by repeated relaying. An absolute creation bucket bounds total lifetime regardless of path.

expiresAfter — lifetime in 600-second buckets relative to creationBucket, not an absolute timestamp. Relative encoding avoids publishing the sender's clock. A packet is expired when nowBucket > creationBucket + expiresAfter.

Range 1–255 (up to ~42.5 h). Relays SHOULD reject values above MAX_RELAY_LIFETIME [policy], default 144 buckets (24 h). Note this bounds relay retention only; a sender holds its own undelivered outbound messages indefinitely (DECISIONS.md A4).

transitPolicy — sender-declared routing restriction.

ValueMeaning
------:---------
0Local only. MUST NOT traverse an internet gateway hop. Default.
1Any transport permitted, including gateway.

Binary by design, not a bitfield: every additional bit is another feature relays can sort traffic by (DECISIONS.md D5). Default-restrictive so the bulk of traffic sits in the restrictive class and permitting gateway transit is the deliberate act, rather than the restrictive flag marking out exactly the traffic someone cared about.

This field is advisory. Relays share no key with the sender and cannot be compelled to honour it. It is included in the AAD so that tampering is detectable end-to-end, but a relay that simply ignores it leaves no trace. Implementations MUST NOT present this to users as a guarantee.

payloadLength — length of ciphertext in bytes, including the AEAD tag. MUST equal len(ciphertext); a mismatch is a discard. Because of mandatory bucketed padding (§5.3) this takes one of a small set of values, not an arbitrary one.

fragmentIndex, fragmentCount, totalLength — fragmentation, per SPEC-ORIGINAL §13.

Fragmentation is decided before sealing, and never afterwards. fragmentCount is inside the AAD (§3.2), so a sender must know how many fragments there will be at the moment it encrypts. It follows that a relay MUST NOT re-fragment an envelope to fit a smaller transport MTU: it holds no key and cannot recompute the tag. Fragmentation is the sender's decision for the whole journey. Unfragmented envelopes use fragmentIndex = 0, fragmentCount = 1, totalLength = payloadLength.

Fragmentation is the mechanism most likely to undo the size cap, so (DECISIONS.md C7):

fragmentCount was added to this list after implementation: fragmentIndex is meaningless except relative to it, and a reassembly slot table is sized from it, so disagreement is not survivable.

nonce was added after an external review. The AEAD runs once over the whole plaintext (§3.2), so a message has exactly one nonce — but nothing checked that siblings carried the same one. A receiver would take the nonce from whichever envelope completed the set, so altering it on any single fragment silently destroyed a correctly reassembled message.

nonce — 12 bytes, unique per encryption under a given key (SPEC-ORIGINAL §11). Generated from a CSPRNG. A receiver MUST reject a (key, nonce) pair it has already accepted.

It is excluded from the AAD: it is an input to the AEAD itself, and including it would be circular.

hopLimit — remaining hops. Decremented by one at each relay. A packet with hopLimit == 0 MUST NOT be forwarded.

This is the only field mutable in transit, and it is therefore excluded from the AAD (DECISIONS.md C4) — including it would break the authentication tag at the first hop.

The security consequence must be stated rather than glossed: hopLimit is unauthenticated and adversarially mutable. A hostile relay can decrement it faster than it should (silently killing delivery) or raise it (amplifying network load). No pairwise key can prevent this, because relays share no secret with the sender. The defences are entirely local: each node enforces its own MAX_ACCEPTED_HOP_LIMIT [policy] and MUST clamp or reject anything above it — otherwise one permissive build injecting hopLimit = 200 conscripts every honest node into carrying it.

ciphertext — the AEAD output, ciphertext ‖ 16-byte tag. Opaque to every party except the destination. Its plaintext structure is defined in checkpoint 2.

### 2.3 Size bound

A complete encoded Envelope MUST NOT exceed MAX_ENVELOPE_BYTES [policy], default 32768. A receiver MUST check the framed length against this bound before allocating, and MUST abandon a transfer that exceeds it rather than truncating.

---

3. AAD construction

The AAD is a fixed-layout byte concatenation. It is NOT a CBOR re-encoding of the header.

This is DECISIONS.md C1, and it is the most important structural decision in this document. If AAD were "the canonical CBOR encoding of the header," two implementations disagreeing about map key ordering would compute different tags, and every message between them would fail authentication — surfacing as "authentication failed," which sends a developer to inspect the cryptography, where nothing is wrong. With a fixed layout, a CBOR disagreement produces a visible parse error instead, and the security-critical path contains no ambiguity at all.

### 3.1 Layout — exactly 47 bytes

Constructed in this order, with no separators, no length prefixes, and no CBOR:

`` offset size field ------ ---- --------------------------------------------------- 0 1 protocolVersion uint8 1 16 packetID bstr 17 16 destinationToken bstr 33 4 creationBucket uint32 big-endian 37 1 expiresAfter uint8 38 1 transitPolicy uint8 39 2 payloadLength uint16 big-endian 41 1 fragmentIndex uint8 42 1 fragmentCount uint8 43 4 totalLength uint32 big-endian ------ ---- --------------------------------------------------- 47 bytes total, always ``

Excluded and why:

The AAD length is constant at 47 bytes for every envelope, fragmented or not. There is no length-prefix ambiguity and no canonicalisation step. An implementation that produces 47 bytes in this order is byte-identical to every other conforming implementation, in any language.

### 3.2 Fragmented messages have exactly one AAD

The AEAD is computed once, over the whole plaintext, before fragmentation. So a fragmented message has one AAD, not one per fragment, and it MUST be constructed canonically:

`` fragmentIndex = 0 fragmentCount = the real count, N payloadLength = totalLength (the whole ciphertext, not this fragment's slice) ``

Every other field is as normal. A receiver reassembles first, then builds this AAD, then verifies.

Consequence, and it must be stated rather than discovered. A fragment's own fragmentIndex and its own payloadLength are therefore not authenticated — they are reassembly instructions, not protected content, in the same category as hopLimit (§2.2).

An attacker who can inject fragments can therefore corrupt reassembly and cause the AEAD tag to fail, destroying the message. This is a denial of service, not a break — the content stays confidential and forgery remains impossible. And it grants no new capability: anyone positioned to inject fragments is already positioned to simply drop them, which is equally effective and simpler. Recorded in THREAT-WALKTHROUGH.md §8 rather than mitigated, because per-fragment authentication would cost bytes on every packet to defend against an attacker who has a cheaper attack available.

### 3.3 Construction is independent of parsing

An implementation MUST build the AAD from the decoded field values, not from the received bytes. Two conforming encoders produce identical CBOR, so in practice these agree — but binding to decoded values means a non-canonical encoding from a buggy peer fails cleanly at the parse step rather than producing a valid-looking tag over malformed bytes.

### 3.4 Worked example

Fully specified test vectors are in vectors/. Illustratively, an unfragmented envelope with:

`` protocolVersion = 1 packetID = 0102030405060708090a0b0c0d0e0f10 destinationToken = a1a2a3a4a5a6a7a8a9aaabacadaeafb0 creationBucket = 2934496 (0x002cc6e0) expiresAfter = 144 (0x90) transitPolicy = 0 (local only) payloadLength = 272 (0x0110) fragmentIndex = 0 fragmentCount = 1 totalLength = 272 (0x00000110) ``

produces the AAD:

`` 01 0102030405060708090a0b0c0d0e0f10 a1a2a3a4a5a6a7a8a9aaabacadaeafb0 002cc6e0 90 00 0110 00 01 00000110 ``

= 010102030405060708090a0b0c0d0e0f10a1a2a3a4a5a6a7a8a9aaabacadaeafb0002cc6e090000110000100000110

47 bytes. Note hopLimit appears nowhere in it.

(Both values above were machine-computed, not written by hand — the first draft of this example had an arithmetic error in creationBucket and a dropped byte in the concatenation. Every vector in vectors/ is generated, never transcribed.)

---

---

4. Identity, secrets and token derivation

### 4.1 Primitives — CryptoSuite v1

PurposeAlgorithm
Identity signingEd25519
Key agreementX25519
Key derivationHKDF-SHA256
Token derivationHMAC-SHA256, truncated
AEADChaCha20-Poly1305, 96-bit nonce, 128-bit tag
HashSHA-256

No primitive outside this table appears anywhere in the protocol. Never invent cryptography (SPEC-ORIGINAL §11). A future CryptoSuite v2 replaces this table wholesale; it does not add alternatives to it, because negotiable cipher suites are a downgrade-attack surface.

### 4.2 Pairing output

A completed Handshake v1 (§6.5) leaves both parties holding:

`` sharedSecret 32 bytes X25519(myEphemeralPriv, theirEphemeralPub) pairingSalt 32 bytes SHA-256(idPubLow ‖ idPubHigh ‖ pairingNonce) generation uint32 0 at first pairing, +1 on each re-pair idPubLow 32 bytes the lexicographically smaller of the two Ed25519 identity keys idPubHigh 32 bytes the lexicographically larger ``

idPubLow / idPubHigh give both sides an identical, canonical ordering of the pair without either having to be designated "initiator." This ordering is what makes §4.4's direction byte work, and it is also what lets both sides of §6.5's handshake build a byte-identical signed transcript without first agreeing a role.

> This section used to point at §6.2, which does not define a handshake. §6.2 is the encounter > sequence and contains no pairing step. The exchange that actually produces the four values above is > §6.5, added later; until it existed, both platform apps filled the gap with a placeholder that set > sharedSecret = idPubLow ‖ idPubHigh — the two public keys, which travel in the clear in the pairing > code. A specification that names an output without defining the thing that produces it is a defect in > this document, and this is what one costs.

### 4.3 Routing secret

`` routingSecret = HKDF-SHA256( ikm = sharedSecret, salt = pairingSalt, info = "mesh/routing-v1" ‖ generation (uint32 big-endian) ) → 32 bytes ``

Bumping generation on re-pairing invalidates every previously derived token at once (DECISIONS.md C5), while conversation history — keyed separately — survives. Deleting a contact destroys routingSecret, after which anything still in flight to that contact is permanently undecryptable, including by us.

### 4.4 Presence and destination tokens

Two tokens, derived from the same secret under different labels, so that no party can link a presence beacon to an envelope in flight (DECISIONS.md C3).

``` epoch = floor(unixTime / EPOCH_SECONDS) [policy] default 600

dir(P) = 0x00 if P's identity key is idPubLow 0x01 if P's identity key is idPubHigh

presenceToken(P, epoch) = HMAC-SHA256(routingSecret, "mesh/presence-v1" ‖ dir(P) ‖ epoch)[0..16]

destinationToken(P, epoch) = HMAC-SHA256(routingSecret, "mesh/destination-v1" ‖ dir(P) ‖ epoch)[0..16] ```

epoch is encoded as uint64 big-endian. Both tokens are the first 16 bytes of the HMAC output. 128 bits is ample against guessing and keeps the Envelope small.

The direction byte is not optional. Without it both parties would derive the same presence token from a shared secret, and a relay hearing the same token emitted by two different devices would learn immediately that those two devices are a pair. dir(P) identifies the party the token belongs to: Carol beacons with dir(Carol), and Alice computes Carol's expected token using dir(Carol). An envelope addressed to Carol carries destinationToken(Carol, epoch).

Consecutive tokens are unlinkable. Distinct epochs produce independent HMAC outputs; an observer sees uncorrelated 16-byte values every ten minutes (SPEC-REVISION §1.7).

### 4.5 Message key

Token derivation and content encryption MUST use independent key material. routingSecret is handled by relays' worth of machinery — it is the input to values that appear in the clear on every packet — and it must not be recoverable from, or a step toward, the key that protects plaintext.

`` messageKey = HKDF-SHA256( ikm = sharedSecret, salt = pairingSalt, info = "mesh/message-v1" ‖ generation (uint32 big-endian) ) → 32 bytes ``

Derived from sharedSecret directly, in parallel with routingSecret (§4.3) rather than from it. Compromise of a routing secret therefore discloses routing metadata only, and never content.

generation participates identically, so re-pairing rotates both at once (DECISIONS.md C5).

AEAD binding. ChaCha20-Poly1305(key = messageKey, nonce = envelope.nonce, aad = §3 AAD). The nonce is carried in the Envelope and MUST be unique per key; a receiver MUST reject a (key, nonce) pair it has already accepted.

This is CryptoSuite v1's static composition. A Double Ratchet replaces this derivation wholesale in a later suite (SPEC-ORIGINAL §11) — it is not layered on top, and v1 must not be described as providing forward secrecy, which it does not.

### 4.6 Epoch acceptance and clock skew

A receiver MUST accept tokens for the previous, current and next epoch — a ±1 window. With the default 600-second epoch this tolerates just under 10 minutes of skew in either direction, and gives a token a maximum useful life of 30 minutes.

Implementations MUST NOT depend on network time, which is unavailable by definition in the target environment. Device real-time clocks are the only source. A device whose clock has drifted more than one epoch will silently fail to be found; §7's diagnostics surface this rather than leaving it as a mystery.

Precomputing three epochs for every contact is 3N HMAC operations per epoch — negligible at any plausible contact count.

Replay is possible and accepted. An observer can record and rebroadcast a presence token within its 30-minute window, making peers believe a contact is nearby. The cost is a wasted route query; delivery still requires the real holder to respond. Presence tokens are hints, never authorisation.

### 4.7 Service identifier

`` ChannelMessenger service UUID: FB985D30-F439-4BD2-90F8-6C1BA2A23394 Payload characteristic: F9BF4B77-F212-4B11-B04C-90CDE4B5CA5F ``

Protocol-level, not an application detail. A background BLE scan on iOS cannot use a wildcard — it must name the service UUIDs it is interested in — so every node has to look for this exact value. Changing it partitions the network into two populations that cannot see each other, which is why it is pinned here rather than left to each platform.

It carries no information: it is a fixed random value, identical on every device, and reveals only that a device runs this protocol. That is already disclosed by participating at all (THREAT-WALKTHROUGH.md §2).

### 4.8 The beacon fan-out problem, and why beacons carry no tokens

Presence tokens are pairwise. Carol with fifty contacts has fifty distinct presence tokens per epoch. Broadcasting all of them would be a battery and airtime problem, and the cardinality of the set would itself be a fingerprint even though each value is opaque.

So beacons carry no tokens at all. Discovery splits in two:

This keeps the broadcast channel constant-size regardless of contact count, and confines pairwise material to connections that already happened.

---

5. Payload structure, padding and compression

### 5.1 Plaintext layout

The plaintext encrypted into ciphertext is:

`` plaintext = canonical-CBOR( PayloadMap ) ‖ zeroPadding ``

PayloadMap keys:

KeyNameTypeNotes
----:-------------------
1senderIdentitybstr 32Ed25519 identity public key. Sealed sender — visible only to the destination.
2sequenceuintMonotonic per sender per conversation (SPEC-ORIGINAL §21).
3sentAtuintSender's wall clock, seconds. Safe here; never in the Envelope.
4payloadTypeuint1 text · 2 delivery receipt · 3 read receipt · 4 policy update · 5 fragment continuation · 6 attachment chunk (§5.6) · 7 contact card (§5.7)
5compresseduint0 none · 1 Brotli with CompressionDictionary v1.
6policymapSender's current settings (§5.5).
7bodybstrUTF-8 text, or a receipt structure, per payloadType.
8groupIdbstr 16Optional. Present for group messages (§5.2).
9groupDigestbstr 8Optional. Membership digest, so divergence is detectable (§5.2).

compressed lives inside the encryption boundary, never in the header — otherwise it would be one more bit for relays to sort traffic by (DECISIONS.md C6).

### 5.7 Contact card (payloadType 7)

The sender's own card, for the receiver to show beside their messages (DECISIONS.md J84 §4). body is UTF-8 JSON:

`` { "v": 1, "name": "<display name>", "stock": "<glyph>" | absent, "jpeg": "<base64, ≤ 16 KiB decoded>" | absent } ``

Sent once after a pairing completes and again whenever the card changes; a receiver that already holds the same card (same bytes) ignores it. The picture, when present, is a square JPEG no larger than 128 px and 16 KiB, so the whole payload fits the largest padding bucket (32 KiB, §5.3). A card is never filed as a message and never notified. The receiver keeps its own local name for the contact; the card's name is shown only where the receiver has not chosen one, and the picture is shown unless the receiver picked their own.

### 5.2 Groups — pairwise fan-out

Groups are N separate envelopes, one per recipient, each under its own pairwise key. There is no group key and no group destination token (DECISIONS.md G2).

This is a privacy requirement, not an implementation shortcut. A shared group key implies a shared destination token, so every member would carry the same rotating address — and a relay hearing one token from several devices would learn those people are in a group together. That is precisely the correlation the direction byte prevents for pairs (§4.4), reappearing at group scale. With fan-out a relay sees N unrelated messages carrying N unrelated addresses and cannot tell they are one conversation.

groupId and groupDigest live inside the ciphertext. Neither ever appears in an Envelope; an outer group identifier would undo everything above.

MAX_GROUP_MEMBERS [policy], default 6, MUST be enforced rather than advised. Fan-out costs N× bandwidth and N× relay storage, and courier retention is where that bites — six rather than sixteen nearly halves the worst case exactly where it hurts. The cost is affordable at all only because the pairing model caps group size by construction: every member requires an in-person exchange or a deliberate invite, so large groups cannot form.

Status: fields reserved, implementation deferred past v1 (DECISIONS.md G2). A v1 implementation MUST reject groupId on receipt rather than partially honouring it.

A sender MUST assign sequence per group, independently of its pairwise sequences.

### 5.3 Padding — mandatory

After CBOR encoding, the plaintext is zero-padded up to the smallest bucket that fits it:

`` PADDING_BUCKETS [policy] = 256, 1024, 4096, 16384, 32768 bytes ``

A decryptor decodes the CBOR item and then MUST verify every remaining byte is 0x00, rejecting the message otherwise. Trailing non-zero bytes are a covert channel; there is no legitimate reason for them to exist. No explicit length field is needed, because a CBOR decoder terminates at the end of the item.

Padding is not optional and is not a user setting. payloadLength is visible to every relay, and unpadded length distinguishes "ok" from a paragraph — that is a side channel whether or not compression is in use.

### 5.4 Compression — optional, inside the padding

Compression MAY be applied to body before the map is encoded. It is worth understanding what it does and does not buy:

Decompression is a hostile-input surface. A small ciphertext can expand without bound. An implementation MUST enforce MAX_DECOMPRESSED [policy] (default 32768) during streaming decompression, aborting the moment the bound is crossed rather than checking afterwards, and MUST fail closed. This is SPEC-ORIGINAL §39's "strict length bounds before allocation" applied to compression.

Forward rule. Authored and received content MUST NOT share a compression context. Compressing a quoted reply together with the reply text would reconstruct the CRIME attack properly — an attacker who can influence quoted content learns about the secret content from the compressed length. This matters the day someone adds quoting or forwarding, which is why it is written down now.

Compression requires no canonical determinism: it sits inside the encryption boundary, so only the format is pinned. Two implementations producing different compressed bytes for the same input is harmless, because each decompresses correctly (DECISIONS.md C6).

### 5.5 Policy piggybacking

The policy map carries the sender's current settings on every message, so peers converge without a dedicated packet type and without adding an observable traffic pattern (DECISIONS.md D6).

KeySettingStricter direction
----:-----------------------------
1maxHopLimitlower
2maxRelayLifetimelower
3minPaddingBuckethigher
4allowGatewayTransitfalse
5disappearAfterlower (0 = never, which is the weakest)
6allowCompressionfalse

min() is not the strict operator. For padding, stricter is larger — an implementation that naively minimises every field silently degrades padding to the weakest setting on the conversation. The direction column above is normative and no value may be reconciled by inference.

Reconciliation rules:

Relay mode (Off / Contacts Only / Community Mesh) and push enrollment are not in this table. They govern what a device does for strangers, or its own exposure; they are not properties of a conversation and have no counterpart to reconcile against.

---

6. Packet types

All packets share a two-byte frame header: packetType (uint8) and flags (uint8, reserved, MUST be 0 in v1 and MUST be ignored on receipt). The body is canonical CBOR per §1.

Size bounds marked [policy] are enforced before allocation. Any packet exceeding its bound is discarded and the connection SHOULD be closed — an oversized frame is either a bug or an attack, and neither is worth continuing.

### 6.1 Summary

#TypeDirectionWhen sentBoundWhat a relay learns
--:----------------------------------:---------------------
1HELLObothfirst frame of every encounter64 Bprotocol version, nothing else
2CAPABILITYbothimmediately after HELLO128 Btransport class bits, storage class
3PRESENCEbothafter CAPABILITY2 KB [policy]a set of opaque 16-byte tokens
4ROUTE_QUERYbroadcastsender seeks a destination96 Ba query ID, a token, a hop budget
5ROUTE_RESPONSEreverse pathdestination or cache answers96 Bthat some node answered
6INVENTORYbothafter PRESENCE4 KB [policy]fragment keys and destination tokens held
7WANTbothafter INVENTORY4 KB [policy]which packet IDs a peer lacks
8ENVELOPEbothin response to WANT32 KB [policy]§2's fields only
9ACKbothon accepting an ENVELOPE64 Ba packet ID was accepted
10GOODBYEbothend of encounter32 Bthe encounter ended
11PAIR_OFFERinitiatorfirst flight of a pairing (§6.5)96 Ban X25519 public key and a nonce
12PAIR_ACCEPTresponderanswering a PAIR_OFFER160 Bthe same, plus a signature it cannot attribute
13PAIR_CONFIRMinitiatorcompleting a pairing96 Ba signature it cannot attribute

Types 11–13 are Handshake v1 (§6.5), not part of the encounter sequence. They share this frame space because they cross the same link and a receiver has to demultiplex them from the same byte stream; §6.2 never sends or expects one. What an observer of a pairing can do with them, beyond the table above, is set out in §6.5.8 — including the one disclosure that is real.

No packet at any layer carries a device name, OS, model, build number, account identifier, phone number, or permanent device identifier (SPEC-ORIGINAL §15). Capabilities are protocol bits, never named platform characteristics.

### 6.2 Encounter sequence

`` HELLO ─▶ CAPABILITY ─▶ PRESENCE ─▶ INVENTORY ─▶ WANT ─▶ ENVELOPE… ─▶ ACK… ─▶ GOODBYE ``

This sequence contains no pairing step, and never did. Pairing is Handshake v1, defined in §6.5; two nodes that have not paired have nothing to say to each other beyond HELLO/CAPABILITY. §4.2 pointed here for the definition of that handshake for as long as no such definition existed anywhere.

Encounters are deliberately short (SPEC-ORIGINAL §17). An implementation MUST be able to abandon at any point and resume later without loss — the transport window may be ten seconds in a corridor. Every stage is idempotent and no stage depends on completing the one after it.

### 6.3 Definitions

HELLO{1: protocolVersion}. Sent first, always. A version mismatch ends the encounter immediately with no further disclosure.

CAPABILITY{1: transportClasses (uint bitfield), 2: storageClass (uint), 3: roleFlags (uint)}.

roleFlags bit 0 marks a persistent node (DECISIONS.md A3) — always-on, high storage — which raises its preference in route costing. Bit 1 marks gateway-capable, set only when the operator has opted in. Bit 2 marks a wake beacon (§7.8). Remaining bits reserved.

Suppressed when: the peer's HELLO failed.

PRESENCE{1: [tokens]}, a list of 16-byte presence tokens the sender is currently advertising, for the current epoch only.

> The list MUST always contain exactly PRESENCE_SET_SIZE entries [policy], default 32. > A node with fewer contacts pads with values drawn from a CSPRNG, indistinguishable from real > tokens. A node with more sends a random sample, re-sampled each encounter so every contact is > advertised eventually. > > Without this the list length is the sender's contact count, disclosed to anyone who connects — > the same fingerprint §4.6 avoided on the broadcast channel, reintroduced on the unicast one. Found > while writing THREAT-WALKTHROUGH.md; see its §7.

Sent after CAPABILITY, never broadcast (§4.8). A receiver caches each token as token → peer, heardAt, linkQuality with lifetime PRESENCE_CACHE_TTL [policy] (default 900 s), and MUST NOT retain any association beyond that. A relay caching these does not learn who they belong to; that is the entire point (SPEC-REVISION §1.6).

Nodes MUST NOT build or retain global reachability tables of the form "I can reach X" — that leaks topology (SPEC-REVISION §1.5).

ROUTE_QUERY{1: queryID (8 B), 2: presenceToken (16 B), 3: hopBudget (uint), 4: expiresAt (uint)}.

Carries the presence token, so it can be answered from the caches built by PRESENCE. Relays record reverse-path state queryID → receivedFrom, hop, expires for QUERY_STATE_TTL [policy] (default 10 s) and rebroadcast with hopBudget − 1. A queryID already seen MUST be dropped, not rebroadcast — duplicate suppression, not the hop budget, is what bounds flood cost.

ROUTE_RESPONSE{1: queryID, 2: minLinkQuality (uint8), 3: accumulatedPenalty (uint16), 4: hopCount (uint8), 5: mac (16 B)}.

Travels the reverse path only. It does not contain the path (DECISIONS.md D3). The originator learns next hop, coarse hop count and enough to compute a cost — never the identities or ordering of intermediate nodes.

minLinkQuality and accumulatedPenalty are carried separately so the weak-link term is applied exactly once, at the originator (§8.3). Each forwarding node updates both per §8.4.

mac = HMAC-SHA256(routingSecret, "mesh/route-response-v1" ‖ queryID)[0..16], computed by the destination. It proves that a party holding the pairwise secret answered — relays can neither forge nor verify it.

The MAC covers queryID only. The quality and penalty fields are accumulated hop by hop and are therefore mutable in transit, exactly like hopLimit (§2.2). They are unauthenticated hints: a hostile relay can understate cost to attract traffic or overstate it to repel traffic, and no pairwise key can prevent this. What the MAC does guarantee is that the destination is real — an attacker cannot manufacture a route to a party whose secret it does not hold.

INVENTORY{1: [[packetID, destinationToken], …]}.

Offers what the sender is carrying. Destination tokens are included so a peer can recognise packets addressed to it without fetching everything — safe precisely because §4.4 derives destination tokens separately from presence tokens, so harvesting them cannot be correlated against any beacon.

Entries capped at MAX_INVENTORY_ENTRIES [policy] (default 128). A future version MAY substitute a compact probabilistic set (SPEC-ORIGINAL §17); v1 uses an explicit list for determinism.

WANT{1: [packetID, …]}. The subset the sender lacks and will accept, capped by MAX_PACKETS_PER_ENCOUNTER [policy]. A node MUST NOT request packets it has no room to store.

ENVELOPE — §2, unchanged, byte-identical regardless of transport.

ACK{1: packetID, 2: accepted (bool)}. A transport-level acknowledgement that a relay took custody. It is not a delivery receipt — delivery receipts are end-to-end, encrypted, and travel as payloadType 2 inside an Envelope of their own.

GOODBYE{1: reason (uint)}. Reasons: 1 complete · 2 storage full · 3 rate limited · 4 going out of range. Advisory; a peer disappearing without one is normal and MUST be handled identically.

### 6.4 Abuse limits

Per encounter, enforced locally (SPEC-ORIGINAL §18):

LimitDefault [policy]
MAX_PACKETS_PER_ENCOUNTER64
MAX_BYTES_PER_ENCOUNTER1 MB
MAX_RELAY_STORAGE25–100 MB, user-configurable
MAX_ENVELOPE_BYTES32768
MAX_RELAY_LIFETIME144 buckets (24 h)
MAX_ACCEPTED_HOP_LIMIT8
MAX_DISCOVERY_HOPS3

MAX_ACCEPTED_HOP_LIMIT and MAX_DISCOVERY_HOPS differ deliberately and are not in conflict: discovery is bounded tightly because it floods, while an already-routed or courier-carried envelope may legitimately pass through more nodes over its lifetime.

These bound a relay's exposure, not a sender's own outbox — a sender holds undelivered messages indefinitely (DECISIONS.md A4).

### 6.5 Handshake v1 — the pairing key agreement

§4.2 describes what a completed Handshake v1 leaves you holding. This section defines the exchange that produces it. Until it was written, nothing did: the header claimed Handshake v1 as a shipped component, §4.2 pointed at §6.2, and §6.2 is the encounter sequence, which contains no pairing step at all. Both platform apps consequently shipped a placeholder that set sharedSecret = idPubLow ‖ idPubHighthe two public keys, both of which travel in the clear in the pairing code. Anyone who photographed a QR held every secret that followed from it. This section exists so that no implementer has to guess again.

The construction is signed ephemeral Diffie–Hellman, station-to-station in shape. Nothing in it is novel and nothing may be (SPEC-ORIGINAL §11): X25519 for the agreement, Ed25519 for the authentication, SHA-256 for the salt — CryptoSuite v1 (§4.1) unchanged.

#### 6.5.1 What the handshake does and does not authenticate

Authentication comes from the pairing code, not from this exchange. Before a handshake begins, each party already holds the other's long-term Ed25519 identity public key, carried over a channel the users chose — a QR held up for three seconds, an NFC tap, or a blob split across two messaging channels (DECISIONS.md E1, J15). Handshake v1's entire job is to bind a fresh X25519 agreement to those two already-trusted keys, so that the pairwise secret is not derivable from anything public.

If the identity key was substituted during that out-of-band exchange, this handshake completes perfectly against the attacker. Nothing here can detect that, and nothing here claims to. The defence is the spoken confirmation code (§6.5.8), and the honest limit is DECISIONS.md E1's: two people who have never met have nobody to verify against.

The pairing code carries a public identity key, and Handshake v1 requires nothing else from it. An implementation MUST NOT use an X25519 public key obtained from a pairing code as a party's ephemeral in this exchange. A QR is displayed repeatedly and an invite blob sits in a mailbox and a backup forever, so a key placed in either is reused across every attempt that reads it — precisely the property §6.5.5 forbids an ephemeral from having.

> This conflicts with two governing documents, and the conflict is recorded rather than resolved > here. SPEC-ORIGINAL §10 lists "key agreement public information" and a "pairing nonce" among the > QR payload's contents, and DECISIONS.md E3 says the invite blob "carries public keys and a nonce". > Both predate this section; both describe a pairing code that would supply the very values §6.5.5 > requires to be fresh per attempt. What actually shipped — PairingCode on both platforms — carries > the identity key alone, which is the safe reading and the one this section is written against. > DECISIONS.md takes precedence over this document, so reconciling the two is Don's call, not this > section's: see SPEC.md §6. Nothing in Handshake v1 breaks if a code carries more, as long as no > implementation treats what it carries as an ephemeral.

#### 6.5.2 Roles

One party sends first. Which one is a transport and interface choice, not a cryptographic one:

The role is not derived from §4.2's idPubLow/idPubHigh ordering, even though that ordering is already available to both sides and would settle it with no negotiation. It is not derived from it because the ordering says who is idPubLow and nothing about who is holding the phone that scanned the code, or which end of a remote invite is waiting on the other. Binding the first flight to key order would mean a user who initiates in the real world must sometimes wait for the other person to speak first, for a reason no interface could honestly explain.

Nothing cryptographic rests on the choice: the transcript both parties sign is canonical by identity key order regardless (§6.5.4). Two devices that both believe they are the initiator simply fail to progress — each is waiting for a PAIR_ACCEPT neither will send — and time out. They do not agree on anything, and in particular they never agree on anything weaker.

#### 6.5.3 Message formats

Three frames, packet types 11–13, in §6's frame format: packetType (uint8), flags (uint8, MUST be 0, MUST be ignored on receipt), then a canonical-CBOR body per §1.

#TypeFromBodyFrame sizeBound
--:-----------------------------:------:
11PAIR_OFFERinitiator{1: ephemeralPublic (32 B), 2: nonce (16 B), 3: generation (uint32)}58–62 B96 B
12PAIR_ACCEPTresponder{1: ephemeralPublic (32 B), 2: nonce (16 B), 3: generation (uint32), 4: signature (64 B)}125–129 B160 B
13PAIR_CONFIRMinitiator{1: signature (64 B)}70 B96 B

Every field is required and every width is exact (§0: fixed-width means fixed-width). A decoder MUST reject an unknown key, a missing key, a repeated key, a wrong field count, a wrong byte-string length, and a generation above uint32. The size range in the table is the generation field's shortest-form CBOR encoding varying between one and five bytes; PAIR_CONFIRM, which carries no generation, is a fixed 70 bytes. The bound is what a receiver enforces before parsing, per §6, and is generous by design: nothing legitimate comes near it, so a frame that does is rejected before its body is read.

> Measured, not assumed. The frame sizes above are what mesh-core-rs actually emits, printed > from Packet::encode for generation values spanning each of CBOR's uint widths — 58/59/60/62 for > PAIR_OFFER and 125/126/127/129 for PAIR_ACCEPT. 61 and 128 are absent because CBOR has no > four-byte uint; a range stated by arithmetic rather than by measurement would have included them.

These three types are not part of §6.2's encounter sequence. They share the frame space because they travel over the same link and a receiver must demultiplex them from the same byte stream, but an encounter never sends or expects one. A node that receives a pairing frame inside an encounter MUST ignore it rather than treat it as an error — pairing genuinely can overlap an encounter, since two phones in two pockets may be relaying while their owners are pairing.

PAIR_OFFER carries no signature, deliberately. There is nothing to bind yet: the peer's ephemeral does not exist. An offer is therefore entirely forgeable, and an attacker who fabricates or replays one gets a PAIR_ACCEPT it can never confirm, because confirming requires an Ed25519 signature under an identity key the responder already holds. It costs the responder one X25519 operation and one signature per forged offer, so an implementation SHOULD rate-limit inbound PAIR_OFFERs and MUST only process them at all while the user has a pairing screen open — pairing is a deliberate act, not something a node does in the background.

#### 6.5.4 The transcript

Both parties sign the same 182-byte string, differing in one byte:

`` transcript(role) = "mesh/handshake-v1" 17 bytes domain-separation label ‖ idPubLow 32 bytes §4.2's canonical ordering of the two identity keys ‖ idPubHigh 32 bytes ‖ ephPubLow 32 bytes the ephemeral of the party whose identity is idPubLow ‖ ephPubHigh 32 bytes ‖ nonceLow 16 bytes the nonce half contributed by that same party ‖ nonceHigh 16 bytes ‖ generation 4 bytes uint32, big-endian ‖ signerRole 1 byte 0x00 if the signer's identity is idPubLow, else 0x01 --------- 182 bytes ``

signature = Ed25519(identityPriv, transcript(signerRole)). Verification MUST use strict verification — rejecting small-order public keys and small-order signature components — so that a signature is valid for exactly one key rather than several.

Low and high are by identity-key ordering, never by role. That is what lets both parties build byte-identical transcripts without first agreeing who initiated, and it reuses the ordering §4.2 already defines rather than inventing a second one.

Every field is fixed-width, so the concatenation is unambiguous. No separator, no length prefix, and none is needed: there is exactly one way to parse 182 bytes into these nine fields. A variable-width field here would need length-prefixing to avoid the classic ambiguity where two different field assignments produce the same signed string.

The label carries no product name. tokens.rs records what happened the last time one did: the rename to Channel Messenger silently changed every derived key and every token, caught only because the conformance vectors failed. mesh/ ties the label to the protocol, which is the thing that versions.

signerRole is the reason neither signature can stand in for the other. Without it both parties would sign an identical byte string. The two are still verified under different keys, so a swap is already useless whenever idPubLow ≠ idPubHigh — but that inequality holds only because §6.5.6's first check enforces it, and one byte is a cheap price for not having the property depend on another check being present.

generation is inside the transcript. A signature from one generation therefore does not verify for the next, which is what stops an old recorded exchange being replayed into a re-pair.

#### 6.5.5 The exchange

`` Initiator Responder │ │ │ generate ephemeral keypair + 16-byte nonce half │ ├───── PAIR_OFFER {ephPub, nonce, generation} ──────────────────▶│ │ generate ephemeral + nonce │ │ checks (§6.5.6), then sign │ │◀──── PAIR_ACCEPT {ephPub, nonce, generation, signature} ───────┤ │ checks (§6.5.6), verify, then sign │ ├───── PAIR_CONFIRM {signature} ────────────────────────────────▶│ │ verify │ │ paired paired │ ``

Three flights, and DECISIONS.md E3 already predicted the count: "Two round trips are unavoidable (X25519 needs both public keys): invite → response → paired."

The frames are ordinary byte strings. Nothing in this section requires a radio: the same three frames can be carried by BLE, by Wi-Fi Aware, or by a user pasting them into two different messaging apps, which is how DECISIONS.md E1's tier-2 remote pairing works. What the channel MUST provide is ordering and integrity within one attempt — a frame delivered out of order is an abort (§6.5.6), not something to be reassembled.

Each side generates its ephemeral keypair fresh, per attempt, and MUST NOT reuse one. Not across peers, not across attempts with the same peer, and not on a retry after an abort. An implementation MUST discard all handshake state on any abort and generate new material to try again; there is no resumption, and offering one would hand an attacker unlimited attempts against a single ephemeral.

The ephemeral secret and the nonce half are the only entropy the exchange consumes: 32 bytes of X25519 ephemeral secret and 16 bytes of nonce per side, from the platform CSPRNG. If the CSPRNG read fails, the pairing MUST fail. There is no fallback, because a predictable ephemeral is not a degraded pairing, it is no pairing at all.

The last flight is unacknowledged, and the asymmetry is real. The initiator considers itself paired the moment it emits PAIR_CONFIRM; the responder only when it has verified one. A lost PAIR_CONFIRM therefore leaves the initiator holding a contact the responder does not have. There is no fourth flight to close this, deliberately (§6.5.8), and the protocol cannot close it in general — any acknowledgement has a last message of its own. What closes it in practice is the ceremony: DECISIONS.md E1 has both people compare the confirmation code, and a contact that only one side believes in is visible immediately, on the other person's screen, at the moment it matters. An implementation SHOULD surface a pairing as incomplete until it has seen traffic from the peer, and MUST make re-pairing possible without deleting anything (generation is negotiated for exactly this, §6.5.7).

#### 6.5.6 Checks and abort conditions — normative

Every one of these is an abort. On abort an implementation MUST discard the handshake and all material derived within it, MUST NOT send any indication of why to the peer (§0's oracle rule; the distinctions below are for local diagnostics only), and MUST NOT accept any further frame on the same handshake. Retrying means starting over with fresh entropy.

Discarding means scrubbing, at the moment of the abort. The ephemeral private key an aborted handshake generated is live key material for an exchange that will never complete, and an implementation MUST zeroize it when it aborts rather than leaving it to be scrubbed whenever the handshake object is eventually released. A pairing screen can stay open, and a caller can reasonably keep a failed attempt around to tell the user about it; neither is a reason to keep the key.

*An abort is a pre-completion event, and completion is terminal in the other direction. Once a party has completed — both signatures verified, the agreement contributory — no later frame may take that back. A completed handshake MUST refuse every further frame, and MUST do so without* discarding what it derived: it does not process the frame, does not reply to it, and does not change state. This is the one place where "discard the handshake" would be exactly wrong. Duplicate delivery is ordinary on BLE, a PAIR_CONFIRM is replayable by anyone who heard it, and a link carries unrelated traffic; treating any of those as an abort would let an unauthenticated party destroy a pairing that had already succeeded, which is a worse outcome than every attack this section defends against. Both end states are sticky: a failed handshake stays failed, a completed one stays complete, and neither re-enters the state machine.

#Condition — abort ifApplies to
--:----------------------------------
1The peer's identity key equals our ownboth, before the exchange begins
2The frame does not decode, or violates §6.5.3's widths and field setevery received frame
3The frame type is not the one this stage expectsevery received frame
4The peer's ephemeralPublic equals our ownPAIR_OFFER, PAIR_ACCEPT
5The peer's nonce equals our ownPAIR_OFFER, PAIR_ACCEPT
6PAIR_ACCEPT.generation is below the generation we offeredPAIR_ACCEPT
7The peer's signature does not verify under the identity key from the pairing codePAIR_ACCEPT, PAIR_CONFIRM
8X25519(myEphemeralPriv, theirEphemeralPub) is non-contributory (all-zero)whichever frame carried the peer's ephemeral
9The peer's generation exceeds our own stored view by more than MAX_GENERATION_ADVANCE (1024)PAIR_OFFER, PAIR_ACCEPT

The order matters, and it is not the same on both sides. Each frame is processed exactly as follows, stopping at the first failure:

9 runs before 7 on the initiator's side for the same reason 4 and 5 do: it is an arithmetic comparison that does not depend on an attacker being unable to sign, so there is no benefit to paying for a signature verification first.

Check 7 therefore runs before the agreement on the initiator's side and after it on the responder's, which is a consequence of who signs first and not a difference in strength: neither side completes, and neither side keeps any derived material, unless both signatures verified and the agreement was contributory.

1 — identity misbinding, degenerate case. If the peer presents our own identity key, the idPubLow/idPubHigh ordering collapses, both signatures in the exchange verify under a single key, and signerRole is the only thing left distinguishing them. Refusing outright is simpler than reasoning about that, and it is never a legitimate pairing: pairing a user's own two devices pairs two separate identities (DECISIONS.md J20), never one identity with itself.

3 — no resynchronisation. This is the deliberate opposite of §6.2, where an out-of-sequence packet is ignored because encounters are resumable and repeats are normal. A pairing has exactly one legal successor at every point.

3 — the denial of service it accepts, stated rather than glossed. Because PAIR_OFFER is unauthenticated (§6.5.3) and because there is exactly one legal successor, a forged or replayed PAIR_OFFER that reaches a waiting responder first moves it to awaiting PAIR_CONFIRM; the legitimate offer then arrives out of sequence and aborts the attempt. This is a known and accepted denial of service. It is not closed here, and closing it would mean authenticating a flight that has nothing to authenticate against yet — the peer's ephemeral does not exist until this frame delivers it. What makes it tolerable is the ceremony: pairing happens with both people present and a screen open (§6.5.3 requires exactly that), so the attack costs one retry and is visible as one. Sustaining it costs the attacker unbounded repetition, which is why §6.5.3 also says an implementation SHOULD rate-limit inbound offers.

What an implementation MUST do is make it diagnosable. This abort and a signature failure (7) lead a user to opposite conclusions — "someone interfered, start again" versus "the key in that pairing code is not who you think it is" — so the two MUST be distinguishable in local diagnostics, and a second PAIR_OFFER on one attempt SHOULD be reported distinctly from a generic out-of-sequence frame. None of this may be signalled to the peer (§0's oracle rule); it is for the user standing in front of the device.

4 and 5 — reflection. These are stated honestly, because it is easy to overclaim them.

Echoing our own ephemeral back does not, on its own, hand an attacker the secret: we would compute X25519(a, aG) = a²G, and recovering that from aG is exactly the square Diffie–Hellman problem, which is not easy. What it does do is put us in a state no honest peer ever produces — the probability of an honest peer independently generating our ephemeral is negligible — and derive keys from a value with an unusual algebraic relationship to our own key, about which this protocol wishes to reason not at all. The same applies to the nonce half: a reflected nonceLow makes pairingNonce = nonceLow ‖ nonceLow, which is still fresh so long as we are honest, and still a shape nothing legitimate emits.

What checks 4 and 5 are genuinely for is closing the reflection family alongside check 1. Check 1 stops an attacker collapsing the two roles through the identity keys; 4 and 5 stop it being done through the ephemeral or the nonce instead. Together they mean an implementation never has to answer "what happens if both halves of this exchange are the same value" — the answer is that it aborts. They are also the cheapest checks available, and unlike check 7 they do not rest on the attacker being unable to produce a signature, which is why they run first.

6 — generation rollback, and 9 — generation ceiling. The counter has to be defended at both ends: 6 stops it being dragged backwards onto tokens that may already have been observed, 9 stops it being thrown forwards to a value it can never advance past. See §6.5.7 for both.

8 — small-order points. A peer that sends a low-order point forces an all-zero agreement that it knows in advance. This is not subsumed by check 7: the peer whose signature verifies is exactly the peer who would do it deliberately, so a valid signature is no evidence against it. Implementations MUST test the agreement for contributory behaviour rather than assuming their X25519 library rejects such inputs — the widely used ones return the value rather than erroring.

#### 6.5.7 Outputs — sharedSecret, pairingNonce, generation

`` sharedSecret = X25519(myEphemeralPriv, theirEphemeralPub) 32 bytes pairingNonce = nonceLow ‖ nonceHigh 32 bytes generation = the value carried in PAIR_ACCEPT uint32 ``

sharedSecret is §4.2's, exactly. pairingNonce then feeds §4.2's pairingSalt = SHA-256(idPubLow ‖ idPubHigh ‖ pairingNonce), and from there §4.3's routingSecret and §4.5's messageKey follow unchanged. Nothing downstream of §4.2 changes.

pairingNonce is a contribution from both sides, concatenated — never XORed, and never one party's choice. Each side supplies 16 bytes; they are placed in §4.2's canonical identity-key order, so both sides compute the same 32 bytes without agreeing a role.

Concatenation rather than XOR is the load-bearing detail. Whoever sends second sees the other's half before choosing its own. Under XOR that second party could pick its half so the result equals any value it liked — including one from an earlier pairing, forcing pairingSalt back to a previous value. Under concatenation it cannot: the first party's 16 bytes appear in the output verbatim. *The property this buys, stated exactly: pairingNonce is fresh whenever either party's CSPRNG is sound, regardless of the other's.*

Note what that is and is not worth. pairingSalt reuse alone does not repeat any key, because routingSecret and messageKey take sharedSecret as their HKDF input and the ephemerals make that fresh (§4.3, §4.5). pairingNonce is a backstop against the case where sharedSecret is not fresh — a device whose ephemeral generation is broken, stuck or repeating, a failure an implementation can have without knowing. The XOR variant would let a malicious peer defeat precisely that backstop, which is the only thing the nonce was there for. So: belt-and-braces, and the belt has to be one the other party cannot unbuckle.

generation (§4.2, DECISIONS.md C5) is negotiated, monotonically. Each side stores a generation per contact: 0 at first pairing, stored + 1 on a re-pair. The initiator proposes its value in PAIR_OFFER. The responder MUST answer with max(offered, itsOwn) in PAIR_ACCEPT, and the initiator MUST abort if that value is below what it offered (check 6). Both then use the PAIR_ACCEPT value, which is what the transcript covers.

The negotiation exists because the two sides' counters can legitimately diverge: a user who deletes a contact and re-adds it has lost the count while the peer has not. Rejecting the mismatch would make re-pairing impossible after any deletion. Taking the maximum converges, and refusing a decrease keeps it monotone, so an attacker replaying an old exchange cannot roll two parties back onto a generation whose tokens may already have been observed.

A caller MUST persist the negotiated generation, not the one it proposed. Deriving tokens under a generation the peer is not using produces a contact that looks entirely healthy and receives nothing, forever — an invisible failure, not an error.

*PAIR_OFFER.generation is not covered by any signature, and that is safe for the value itself. It is an unauthenticated hint in the same sense as §6.3's ROUTE_RESPONSE quality fields. An attacker who lowers it changes nothing, because the responder takes the maximum. An attacker who raises it can only push both parties onto a higher* generation that they will then agree on, and everything derived is freshly keyed regardless. The value that is authenticated — the one in PAIR_ACCEPT, inside the transcript — is the one both sides actually use.

That argument is correct and it does not go far enough: "higher" runs out. generation is a uint32. An attacker who forges one PAIR_OFFER carrying u32::MAX makes u32::MAX the negotiated value, and because re-pairing requires the counter to increase, that pair of identities can never re-pair again — not with a new ceremony, not with a new QR, not ever. One forged frame from anyone within range of one pairing, and the relationship is permanently unrecoverable. That is a strictly worse outcome than any of the ones the paragraph above rightly dismisses, and it is why the advance is bounded.

Normative — the advance is bounded. A party MUST abort if the peer's generation exceeds its own stored view by more than MAX_GENERATION_ADVANCE, which is 1024 (§6.5.6, check 9). This applies to PAIR_OFFER.generation on the responder's side and to PAIR_ACCEPT.generation on the initiator's; the accept is signed and so only the peer named by the pairing code can set it, but stating the rule as "no single negotiation advances my counter by more than the bound" is cheaper to reason about than one that holds on one flight and not the other. The bound is computed with saturating arithmetic, so a party legitimately near the ceiling can still negotiate up to it rather than having the comparison wrap and admit exactly the value it excludes.

*Why a relative bound.* An absolute ceiling — reject anything above some N — buys nothing: the attacker picks N - 1 and the counter is pinned just below it instead. Bounding the advance preserves the property that actually matters: after any negotiation this side's counter is at most own + 1024, and since honest re-pairing raises own by exactly one at a time, no sequence of forged offers can walk the counter towards the ceiling faster than the two users could themselves.

Why 1024, and what it costs. The divergence this negotiation exists to absorb is one party having deleted a contact and lost the count while the peer kept it; its size is the number of times those two people have genuinely re-paired, which is a handful over a device's life. 1024 is three orders of magnitude clear of that, and leaves the counter some four million forced advances short of u32::MAX. The cost is real and remote: two peers whose counters have legitimately diverged by more than 1024 can no longer re-pair until one of them resets. That is a recoverable local failure traded against an unrecoverable remote one.

#### 6.5.8 What Handshake v1 provides, and what it does not

Provides:

Does not provide:

---

7. Route discovery — RoutingProtocol v1

Bounded discovery first, courier mode as fallback (SPEC-REVISION §1.4). Flooding is not the primary mechanism.

### 7.1 Ancestry

This is AODV-derived: a query ID, a hop budget, duplicate suppression, short-lived reverse-path state, and a response returned along that reverse path. Substituting opaque rotating tokens for addresses is the privacy change; the mechanics are otherwise the classic algorithm.

Naming the ancestry is deliberate (DECISIONS.md D4, from Don's "quite a bit of routing literature we can borrow from rather than inventing every mechanism ourselves"). It brings known failure modes with it — route reply storms, the local-repair versus full-rediscovery trade, and stale-route loops — which is cheaper than rediscovering them in the field.

Deliberate deviations from AODV:

AODVHereWhy
Destination sequence numbers prevent stale routesShort route lifetimes (15 s) insteadSequence numbers are a stable per-destination identifier — precisely the linkable value this protocol exists to avoid
Nodes keep persistent routing tablesReverse-path state only, 10 sPersistent tables are a topology map (SPEC-REVISION §1.5)
Route replies may be sent by intermediate nodes with a fresh-enough routeOnly the destination may originate a replyAn intermediate node cannot compute the MAC, so it cannot prove the destination exists
Local repair patches a broken linkFull rediscoveryLocal repair requires knowing the path; §7.6

### 7.2 Originator state machine

`` message queued, no held route ┌──────┐ ─────────────────────────────────▶ ┌─────────────┐ │ IDLE │ │ DISCOVERING │ └──────┘ ◀───────────────────────────────── └─────────────┘ ▲ route expired / next hop gone │ │ │ │ │ rings exhausted │ valid ROUTE_RESPONSE │ ▼ │ │ ┌─────────┐ │ ┌────────────┐ │ │ COURIER │ └─────── │ ROUTE_HELD │ ◀───────────────────┘ └─────────┘ └────────────┘ │ │ │ encounter offers └────── send fails ──────────────────┘ a better path ``

IDLE → DISCOVERING. A message is queued and no unexpired route is held for its destination.

DISCOVERING performs an expanding ring search (DECISIONS.md D2): budget 1, then 2, then 3, up to MAX_DISCOVERY_HOPS. Nearby destinations resolve without touching the wider cluster.

> Each ring MUST use a fresh queryID. Reusing one is the classic implementation bug here: > relays that suppressed the narrow query would suppress the wider one identically, and the search > would never actually expand. The originator MUST retain the set of queryIDs belonging to one > logical search so it can attribute a late response to the right destination.

Ring timeout SHOULD scale with the budget — RING_TIMEOUT(r) = RING_BASE × 2 × r [policy], so a one-hop ring is not abandoned before a three-hop round trip could possibly complete.

DISCOVERING → ROUTE_HELD on a ROUTE_RESPONSE whose MAC verifies under the destination's routingSecret. An unverifiable response is discarded silently and does not end the search.

ROUTE_HELD retains nextHop, hopCount and computed cost for ROUTE_STATE_TTL [policy], default 15 s. It holds no path.

DISCOVERING → COURIER when every ring has been tried without a verified response. This is the normal case at low network density, not an error (DECISIONS.md E2), and it MUST be the cheapest, best-tested branch.

### 7.3 Relay behaviour

On ROUTE_QUERY:

  1. If queryID is in the seen-set → drop silently. Do not rebroadcast, do not answer.
  2. If hopBudget == 0 → drop.
  3. If expiresAt has passed → drop.
  4. Record reverse-path state queryID → {receivedFrom, hop, linkQuality, expires = now + QUERY_STATE_TTL}.
  5. Add queryID to the seen-set for SEEN_QUERY_TTL [policy].
  6. If the query's presenceToken matches a cached PRESENCE entry, forward only to that peer. Otherwise rebroadcast with hopBudget − 1.

Step 6 is what the presence cache buys: a query converges toward a known holder rather than flooding (SPEC-REVISION §1.6). A cache miss degrades to the flood, never to a failure.

> Duplicate suppression, not the hop budget, is what bounds flood cost. With the seen-set, total > transmissions for one query are bounded by the number of nodes in the cluster — not by > branching-factor raised to the hop count. The hop budget bounds scope and latency. An > implementation that omits or short-changes the seen-set turns a bounded flood into an exponential > one, so this is not an optimisation.

On ROUTE_RESPONSE: look up queryID. If no reverse-path entry exists (expired, or never seen the query) → drop. Otherwise update the metrics per §8.4 and forward to receivedFrom. A relay MAY then cache presenceToken → {peer, cost, expires = now + ROUTE_STATE_TTL}.

Nodes MUST NOT build persistent tables of the form "I can reach X." Reverse-path and route state are short-lived and keyed by opaque values (SPEC-REVISION §1.5).

### 7.4 Destination behaviour

On ROUTE_QUERY whose presenceToken matches one of its own tokens for the previous, current or next epoch (§4.6): emit exactly one ROUTE_RESPONSE per queryID, initialised per §8.4, MAC'd per §6.3.

A destination MUST suppress further responses for a queryID it has already answered — otherwise a flooded query arriving by several paths produces a reply storm.

A destination MUST NOT respond to a query whose token it does not recognise. Silence is the only correct answer; anything else is an oracle.

### 7.5 Timers

TimerDefault [policy]Purpose
QUERY_STATE_TTL10 sReverse-path entry lifetime (Don's sketch)
ROUTE_STATE_TTL15 sHeld-route lifetime (Don's sketch)
SEEN_QUERY_TTL60 sDuplicate suppression window
RING_BASE700 msExpanding-ring timeout base
PRESENCE_CACHE_TTL900 sCached presence entry lifetime
ADVERT_ROTATE900 sEphemeral advertisement ID rotation

Route state is deliberately shorter than presence state: knowing someone is around stays useful far longer than a specific path stays valid.

### 7.6 Route breakage

SPEC-REVISION §5 step 6 requires proving that routing invalidates or expires rather than retrying a stale path forever. Normative:

No local repair. Patching a broken link mid-path requires knowing the path, and the originator deliberately does not (DECISIONS.md D3). Full rediscovery is the cost of that privacy property, and it is affordable because duplicate suppression makes rediscovery cheap.

### 7.7 Courier mode

An envelope with no route is queued and moves opportunistically by INVENTORY / WANT on each encounter (§6.2). It is subject to relay retention, not sender retention (DECISIONS.md A4) on any node other than its originator.

Courier delivery does not use route discovery, presence tokens, or reverse-path state. It proceeds purely by a peer recognising its own destinationToken in an offered inventory — which is why the two token types must be derived separately (§4.4). A courier carrying an envelope for Carol cannot tell that the person it just walked past was Carol.

Both modes coexist. Live mesh delivers now; courier delivers later. A node MAY hold an envelope in courier mode while discovery is still in flight, and MUST suppress the duplicate on packetID if both succeed.

### 7.8 Persistent nodes and wake beacons

A persistent node (DECISIONS.md A3) is a user-operated desktop machine acting as relay, household mailbox and client. Beyond storage and availability it has one capability a phone does not: it can advertise continuously.

Beacon role. A persistent node SHOULD advertise the ChannelMessenger service UUID continuously, at BEACON_ADVERT_INTERVAL [policy] (default 1000 ms), and set roleFlags bit 2.

This matters because of an OS constraint rather than a protocol one. A suspended or terminated mobile app can be woken by the platform when a peripheral advertising a known service UUID appears (§7.9). A stationary always-on advertiser therefore acts as a wake trigger for every phone in the household: arriving home brings a device into range, the platform wakes the app, and queued messages deliver — with no server, no push, and no internet.

Range is the radio's, so this covers a room or two rather than a building. Deploying several nodes is a legitimate way to extend it.

A beacon MUST NOT behave differently from any other node on the wire. It rotates its ephemeral advertisement ID on the same schedule (§4.8), emits the same packet types, and discloses nothing additional. The role bit affects route-cost preference and nothing else.

Keyless by construction. A persistent node performs the mailbox and relay roles without holding identity keys — it stores ciphertext, relays, and answers inventory queries, none of which require decryption. Only the client role needs keys. An implementation SHOULD run the always-on component as a separate process that never receives key material, so compromising the component that is exposed all day yields envelopes nobody can read.

Privacy note, stated rather than glossed. A stationary node is a fixed installation, and ID rotation does not conceal a device that is always present in one place from an observer who is also in one place. Anyone monitoring a location learns that a node lives there. This is inherent to running fixed infrastructure and is recorded in THREAT-WALKTHROUGH.md §8 rather than claimed away. It is one reason the persistent node is optional.

### 7.9 Platform behaviour — researched 2026-09-01

Previously a list of assumptions marked believed, not measured. Researched against live sources on 2026-09-01 and verified against primary documentation; each item below says how strong the evidence is. Still not measured on our own hardware — that remains the probe's job.

#### MEASURED 2026-09-01 on real hardware: Android does NOT see our backgrounded app

Tested with an iPhone 17 Pro Max (iOS 26) advertising, and a Samsung Galaxy A16 5G (Android 16) scanning via nRF Connect.

ConditionResult
Probe in foreground, Android filtering by our 128-bit service UUIDFOUND. Exactly one device, −38 dBm, 30 ms interval
Probe backgrounded, same UUID filterNOT FOUND. The UUID leaves the standard fields, as documented
Probe backgrounded, Android filtering Apple overflow 4C 00 01Device found — but the bitmask does not contain our service

The overflow area is real and present. The raw advertisement carries 0x01 followed by a 128-bit mask, exactly as the reverse-engineering literature describes:

`` 0100000000080000000000000000000004 ``

But that mask is byte-for-byte identical whether our app is running backgrounded, or killed outright. Those two set bits belong to other apps on the phone. Repeated with a second, unrelated service UUID to rule out a hash collision with an already-set bit: still identical.

So SPEC-REVISION §5's four-device acceptance test has a real hole in it. Cross-platform discovery works while the iOS app is on screen and stops when it is not.

#### MEASURED: a backgrounded iPhone CAN still discover others

The single most important result of the session, and it rescues courier mode.

`` 2026-09-02T00:02:28Z [bg] discovered 6E83C072 rssi -45 → LQ 196 ``

That is the iPhone probe, backgrounded, logging a discovery of the iPad. iOS woke the app for the event and it recorded the sighting with its own app-state tag.

Being findable and being able to find are separate capabilities, and only the first is broken. A backgrounded iPhone cannot be discovered by Android — but it can still scan, wake on a match, and connect outward.

What this means for the design. An iPhone in a pocket remains a useful mesh participant: it can walk up to an advertising Android or desktop node and exchange messages. It simply must always be the party that initiates. Courier mode survives; what dies is the case where a stranger's device discovers a sleeping iPhone.

The mesh therefore needs advertisers that are always discoverable — Android foreground services and desktop nodes — with iPhones as mobile clients that find them. That is a real architectural constraint, not a limitation to apologise for, and it makes the free desktop node (DECISIONS.md F10) considerably more important than it looked.

Evidence: measured directly. The reciprocal test (iPad discovering the backgrounded iPhone) is inconclusive — see the probe defect below.

#### CONCLUSIONS: what the radio actually permits

Four things are measured, one is inferred, and one is still open. Keeping those categories apart matters, because three conclusions were withdrawn tonight after a control contradicted them.

ResultBasis
Foreground iPhone → Androidworksmeasured
Backgrounded iPhone → discovered by Androidfailsmeasured, two UUIDs
Backgrounded iPhone → discovers othersworksmeasured
Android advertising persistentlyworksmeasured (foreground service)
Backgrounded iPhone → discovered by iOSuntestedApple documents it; our test was invalid

The governing asymmetry: being findable and being able to find are separate capabilities, and iOS only breaks the first. Every design consequence below follows from that one sentence.

#### The Android problem, and whether it can be fixed

When our app is backgrounded, iOS removes the 128-bit service UUID from the standard advertising fields. Android's ScanFilter reads only those fields, so a filtered Android scan sees nothing at all.

The published workaround — scan unfiltered, find Apple manufacturer data FF 4C 00 01, test the bit for your UUID in the 128-bit "overflow" bitmask — is widely cited and appears in Google's own CompanionDeviceSupport. It did not reproduce here. The bitmask was byte-identical whether our app was backgrounded or force-killed, with two different service UUIDs. If our UUID were setting a bit, those masks would differ.

Honest limit on that finding: one device, one iOS version. And the result is equally consistent with a different cause — that the backgrounded peripheral stopped advertising altogether. We have never confirmed the backgrounded iPhone is advertising to anything. Distinguishing the two is exactly what the iOS-to-iOS test settles, and that test is now runnable.

The fix is architectural, and both halves are already measured working.

Invert the roles. The iPhone is always the scanner and always initiates; Android and desktop nodes always advertise. The pairing that fails — Android hunting for a sleeping iPhone — then never has to happen. This relies on no undocumented behaviour and no reverse-engineered bitmask.

What this leaves exposed: two backgrounded iPhones with nothing else in range. Neither can find the other unless Apple's iOS-to-iOS overflow discovery works. That is the open test, and it decides whether a cabin full of iPhones with no Android and no desktop is a working mesh or a dead one.

Fallback, if the bitmask really is gone. Android scans unfiltered, connects to each Apple device it sees, runs GATT discovery, disconnects when our service is absent. It works in principle. It is also dozens of connections in a crowded room, it punishes battery on both sides, and iOS MAC randomisation defeats any attempt to cache the negatives. A last resort, not a plan.

Ruled out: changing advertised services while already backgrounded — iOS 14+ forbids it. Our setup happens in the foreground, so this costs us nothing.

Worth investigating separately: Wi-Fi Aware (iOS 26+) implements the Wi-Fi Alliance NAN standard rather than proprietary AWDL, so it should interoperate with Android's Wi-Fi Aware. It needs an entitlement and iPhone 12+. Not verified by us — from secondary research only. It is a possible second radio, not a repair to BLE.

#### The design consequence

The mesh needs always-discoverable advertisers, and iPhones cannot be them. Android foreground services and desktop nodes can.

This paragraph originally concluded that the desktop node was therefore the structural rendezvous point of the network. Don corrected it: that is true in a house and worthless on a trail, which is the scenario the product exists for. The conclusion was drawn from one measurement and over-reached. See DECISIONS.md J1–J3 for the corrected framing — direct phone-to-phone is the product, and multi-hop relay is unproven until measured.

The case that actually matters is better than this section first suggested. The sender is, by definition, in the foreground — they are typing. So the question is never "can two sleeping phones find each other," it is "can a sleeping phone find a phone someone is actively using." That is measured working, iOS to iOS: the iPad returned to the foreground and began advertising at 00:02:28, and the backgrounded iPhone logged the discovery in the same second.

#### KNOWN PROBE DEFECT — scanning is never restarted

BLEProbe calls scanForPeripherals only from centralManagerDidUpdateState, which fires once when Bluetooth powers on. If iOS stops the scan — as it appears to on backgrounding — nothing restarts it, and the app is silently deaf thereafter.

This invalidates any negative discovery result from a device that has been backgrounded and returned, which includes the iPad's failure to see the iPhone. It does not affect positive results, and it does not affect the advertising-side findings, which were measured from Android.

Fix before further discovery testing: restart scanning on willEnterForeground, and after any willRestoreState.

Also measured: state restoration works. After the app was killed outright, iOS relaunched it on its own and handed back both managers — the probe logged RESTORED by iOS from terminated state. TN3115's mechanism is real on this hardware. (A user force-quitting from the app switcher is a different case and still expected to be permanent; not tested.)

Not established, and NOT to be read as negative results:

QuestionStatus
Can a backgrounded iPhone still discover others?Untested. An attempt failed because the Android advertisement was misconfigured — the foreground control also saw nothing, which proves the target was wrong, not the platform
iPhone → iPhone backgroundedUntested. This is the case Apple designed the overflow area for, and the one most likely to work. An iPad is the obvious second device
Android → iPhone, either directionUntested

Discovery has two directions and they are separate capabilities: bluetooth-peripheral (being findable) and bluetooth-central (finding). Only the first was measured. If a backgrounded iPhone can still scan outward, courier mode survives — an iPhone in a pocket could still initiate contact with an advertising Android or desktop node, and would simply always be the one initiating. That single unknown is worth more than anything else outstanding.

One thing this does NOT yet distinguish, and it matters:

An Apple device scanning would separate the two, since CoreBluetooth exposes CBAdvertisementDataOverflowServiceUUIDsKey to Apple platforms. A macOS command-line attempt failed: modern macOS refuses CoreBluetooth to an unbundled binary. The next step is a small bundled Mac app, or the iPad running the probe as a scanner. Until that is done, treat the cause as unknown and the effect as established.

Design consequence, which holds either way. Do not build on backgrounded iOS→Android discovery. Android nodes running a foreground service, desktop nodes, and iOS under an explicit Mesh Active session (SPEC-ORIGINAL §28) are the load-bearing paths. A backgrounded iPhone is, on this evidence, not discoverable by Android at all.

Evidence: measured directly, twice, with two different service UUIDs. The earlier claim in this section — that an unfiltered Android scan can detect our backgrounded service — was drawn from research that verified against its sources but does not survive contact with the hardware.

#### Force-quit is permanent

State restoration relaunches a system-terminated app — jetsam, crash, memory pressure — given CBCentralManagerOptionRestoreIdentifierKey, a willRestoreState: implementation and the bluetooth-central background mode.

It does not survive a user swiping the app away in the app switcher. Apple's TN3115 is explicit. iOS 26 adds a narrow exception for apps using AccessorySetupKit, which does not apply to us.

A user who "closes" the app removes themselves from the mesh until they open it again. The UI must say so plainly rather than letting people discover it.

Evidence: strong — Apple technote TN3115, updated September 2025 for iOS 26.

#### The wake window is about ten seconds, and small

Roughly ten seconds per Bluetooth event, extendable if the peer keeps traffic flowing within it. Realistically a few hundred bytes to a few kilobytes at background connection parameters.

This confirms the design. A 256-byte padded message transfers comfortably; a 32 KB envelope will not complete in a background window. Bulk transfer is not available and text-only is not a temporary limitation.

Evidence: moderate — Apple's long-standing ~10s figure plus developer reports, not a precise published guarantee. The probe should measure it.

#### Local Push Connectivity is unavailable to us

NEAppPushProvider requires the app-push-provider entitlement, which Apple grants case by case — historically for local-network VoIP where APNs is unreachable. It does not require MDM. Assume we will not be granted it.

Evidence: strong — Apple technote TN3134.

#### Wi-Fi Aware: iOS 26+, and cross-platform pairing does NOT currently work

Available on iPhone 12 and later from iOS 26, implementing the Wi-Fi Alliance NAN standard rather than proprietary AWDL, and coexisting with infrastructure Wi-Fi. Requires the com.apple.developer.wifi-aware entitlement — a managed capability needing Apple's approval, not a checkbox.

> This contradicts SPEC-REVISION §1.9, which calls Wi-Fi Aware "the > preferred interoperable transport" because both platforms implement the same > standard. They do. It still does not work between them today. > > Developers report bidirectional failure with current Android flagships: an > Android publisher's discovery packets are dropped by iOS for a missing DCEA > attribute, and with iOS publishing, pairing fails with status 15, > authentication rejected. An Apple engineer's response was to take it up with > the Android vendor. Non-phone hardware has interoperated — an ESP32-C5 pairs > with an iPhone — but it needs mandatory six-digit PIN pairing, DNS-SD service > naming, and WPA3-PSK-equivalent security that is an iOS-specific requirement. > > So BLE with the overflow-area workaround is currently the more reliable > cross-platform path, and Wi-Fi Aware is the higher-bandwidth one to adopt when > it matures. Plan for it; do not depend on it.

Evidence: strong on availability and entitlement (Apple docs); strong on the interop failures (multiple developer reports plus an Apple engineer reply, on Apple's own forums).

---

7.10 Connectivity tiers

Route selection is the same algorithm in all three cases; only which transports exist changes (DECISIONS.md A5).

TierSituationAvailable
1No internet at allLocal radio only — proximity and courier
2Internet present but restrictedLocal transports, plus whatever internet actually works
3Full internetEverything, with local preferred and internet last

Tier 3 is the common case. Earlier drafts of this document treated tier 1 as normal, which is true on an aircraft and false almost everywhere else.

No new mechanism is required: transportPenalty already prices a gateway hop at 120 against 0–40 for local transports (§8.2), so "prefer local, fall back to internet" is an outcome of scoring rather than a special case. Tier 2 must never be inferred from network presence — same SSID does not mean reachable peer, and client isolation is normal on public and captive networks (SPEC-ORIGINAL §7). Reachability is measured, not assumed.

An implementation MUST surface the live state of each transport separately rather than a single "offline" indicator (SPEC-ORIGINAL §29).

---

8. LinkQuality and RouteCost

### 8.1 LinkQuality — the transport contract

LinkQuality is a single unsigned byte, 0 (unusable) to 255 (excellent). Every transport MUST map its own native metric into it. Nothing above the transport layer sees dBm, latency, or any transport-specific unit (SPEC-REVISION §1.8).

BLE — normative mapping from RSSI:

`` LinkQuality = clamp( round( (rssi_dBm + 95) × 255 / 65 ), 0, 255 ) ``

so −95 dBm or worse maps to 0, −30 dBm or better maps to 255, linear between. A transport MUST average over RSSI_SAMPLES [policy] (default 4) rather than using a single instantaneous read.

Other transports MUST document their mapping and SHOULD calibrate so that equal LinkQuality implies comparable delivery probability. Wi-Fi Aware maps from the link metrics its platform exposes; LAN from latency and loss; a gateway hop from latency and reachability.

### 8.2 The cost formula

`` RouteCost = hopPenalty + weakLinkPenalty + transportPenalty + congestionPenalty + batteryPenalty ``

as SPEC-REVISION §1.8, computed as uint16 and saturating at 65535.

TermDefinitionDefault [policy]
hopPenaltyHOP_COST × hopCountHOP_COST = 20
weakLinkPenalty255 − min(LinkQuality) over every hop
transportPenaltysummed per hop by classWi-Fi Aware 0 · LAN 10 · Wi-Fi Direct 20 · BLE 40 · gateway 120
congestionPenalty0–100, from each relay's queue depth0
batteryPenalty0–60, from each relay's own power state0

The gateway penalty is high deliberately: that hop discloses the recipient's network address to a relay (DECISIONS.md D7), so it should lose to any local path that exists.

### 8.3 Weakest link, not strongest first hop

weakLinkPenalty uses the minimum LinkQuality across the whole route. A route is only as good as its worst hop, and selecting on the strongest first hop produces worse routes. This is the single most important property in §8 and vectors/04-route-cost.json exists to enforce it.

The term is applied exactly once, by the originator, from the minLinkQuality carried in the ROUTE_RESPONSE. Applying it per hop would compound the penalty and defeat the purpose.

### 8.4 Accumulation along the reverse path

The destination initialises:

`` minLinkQuality = LinkQuality of the hop the query arrived on accumulatedPenalty = 0 hopCount = 0 ``

Each node forwarding a ROUTE_RESPONSE then:

`` minLinkQuality = min(minLinkQuality, LinkQuality of the link it arrived on) accumulatedPenalty += HOP_COST + transportPenalty(that link) + congestionPenalty(self) + batteryPenalty(self) hopCount += 1 ``

and the originator finally computes:

`` RouteCost = accumulatedPenalty + (255 − minLinkQuality) ``

Lower cost wins. On a tie the originator SHOULD prefer fewer hops, then the earlier response.

These fields are unauthenticated (§6.3). A hostile relay can misreport them to attract or repel traffic. It cannot forge the MAC, so it cannot invent a destination.

---

9. Policy table

Every value here is SHOULD, not MUST. They are expected to change empirically (DECISIONS.md D1). An implementation MUST read them from configuration, MUST NOT hardcode them, and MUST NOT reject a peer for holding different values.

ConstantDefaultNotes
------:---
EPOCH_SECONDS600Token epoch; ±1 accepted, so 30 min useful life
ADVERT_ROTATE900 sEphemeral advertisement ID rotation
PRESENCE_CACHE_TTL900 sCached presence entry lifetime
QUERY_STATE_TTL10 sReverse-path entry
ROUTE_STATE_TTL15 sHeld route
SEEN_QUERY_TTL60 sDuplicate suppression window
RING_BASE700 msExpanding-ring timeout base
MAX_DISCOVERY_HOPS3Discovery scope
MAX_ACCEPTED_HOP_LIMIT8Envelope relay ceiling, enforced locally
MAX_ENVELOPE_BYTES32768Checked before allocation
MAX_FRAGMENTS16Checked before reassembly
MAX_DECOMPRESSED32768Enforced during streaming decompression
PADDING_BUCKETS256 / 1K / 4K / 16K / 32KMandatory
MAX_RELAY_LIFETIME144 buckets (24 h)Relay retention only
Sender retentionindefiniteOwn outbox; not a relay limit
MAX_RELAY_STORAGE25–100 MBUser-configurable
MAX_PACKETS_PER_ENCOUNTER64
MAX_BYTES_PER_ENCOUNTER1 MB
MAX_INVENTORY_ENTRIES128
MAX_GROUP_MEMBERS6Enforced; fan-out cost is N× (§5.2). Deferred past v1
PRESENCE_SET_SIZE32Fixed; padded or sampled so contact count never leaks
BEACON_ADVERT_INTERVAL1000 msPersistent-node continuous advertising (§7.8)
HOP_COST20Route cost
RSSI_SAMPLES4Averaging window

MAX_DISCOVERY_HOPS (3) and MAX_ACCEPTED_HOP_LIMIT (8) differ deliberately and are not in conflict. Discovery is bounded tightly because it floods; an already-routed or courier-carried envelope may legitimately pass through more nodes over a 24-hour life.

On changing these. The wire carries the value; the node carries the policy. A default can move with no version bump and no flag day, and mixed-version nodes interoperate — which is the whole reason for this table. What a node MUST NOT do is trust a peer's value: every relay enforces its own ceilings, or one permissive build sets hopLimit to 200 and conscripts everyone else.

---

PROTOCOL.md v1 draft complete. THREAT-WALKTHROUGH.md follows: per packet type, precisely what a passive relay learns — including the places where the honest answer is "more than we would like."

English is the reference text; any translation is a convenience. Help is stored on this phone and never contacts anyone.