> Source file last changed: 2026-11-03. Dates inside the document record when each part was written, checked or submitted.

# THREAT-WALKTHROUGH.md — what a relay actually learns

Assignment 01, deliverable 3. Per packet type, precisely what a passive relay observes.

**The instruction this document is written under**, from `ASSIGNMENT-01.md`:

> Show that a relay observing ROUTE_QUERY + ROUTE_RESPONSE + ENVELOPE cannot identify sender,
> recipient, or conversation. **If it *can* learn something, say so plainly rather than asserting
> privacy.**

It can. §5 and §6 say so plainly. Assertions of privacy that do not survive an adversary who is
paying attention are worse than useless, because people rely on them.

---

## 1. Adversary positions

These are genuinely different, and conflating them is how privacy claims become false.

| Position | Description |
|---|---|
| **P1 · First hop** | The relay a sender hands a packet to directly |
| **P2 · Middle relay** | A relay with another relay on both sides |
| **P3 · Final hop** | The relay that hands a packet to its destination |
| **P4 · Passive listener** | In radio range, relaying nothing |
| **P5 · Global observer** | Sees many locations at once — **out of scope** (`SPEC-ORIGINAL` §32) |

**With `MAX_DISCOVERY_HOPS = 3`, P2 frequently does not exist.** On a two-hop route A→B→C there is
exactly one relay, and it is simultaneously P1 and P3. §6 is about what that means.

---

## 2. What every relay learns unconditionally

Regardless of packet type, from the fact of the encounter itself:

- A device running this protocol is physically nearby, now.
- Its ephemeral advertisement ID — rotating every 15 minutes, derived from nothing.
- Approximate distance, from radio signal strength.
- Packet sizes, to bucket granularity, and arrival times.
- Whatever the radio layer discloses beneath us — MAC-layer behaviour, chipset fingerprints, timing
  characteristics. **We do not control this and do not claim to** (`SPEC-ORIGINAL` §3).

---

## 3. Per packet type

| Packet | What a relay learns | What it does **not** |
|---|---|---|
| `HELLO` | A protocol version | Anything about the device, user or contacts |
| `CAPABILITY` | Transport-class bits, storage class, persistent/gateway role | Platform, model, OS, build. Capabilities are protocol bits, never `deviceType = iPhone17` (`SPEC-ORIGINAL` §15) |
| `PRESENCE` | Exactly 32 opaque 16-byte values | Who they belong to; **and not the contact count**, since the set is fixed-size and padded (§7) |
| `ROUTE_QUERY` | A random query ID, one opaque token, a hop budget ≤ 3 | Who is asking, who is sought, or whether either is a contact of the relay |
| `ROUTE_RESPONSE` | That *someone* holding the right secret answered; a link-quality figure; a hop count | The path, the responder's identity, or any node between |
| `INVENTORY` | Which packet IDs a peer holds, and their destination tokens | Who those packets are for. Destination tokens are derived under a different label from presence tokens, so they cannot be matched against any beacon (`PROTOCOL.md` §4.4) |
| `WANT` | Which packet IDs a peer lacks | Why, or for whom |
| `ENVELOPE` | Only §2's fields — see §4 below | Content, sender, recipient, conversation, or any permanent identifier |
| `ACK` | That a relay took custody of a packet ID | That anyone received or read anything. Delivery receipts are end-to-end encrypted envelopes of their own |
| `GOODBYE` | The encounter ended, and loosely why | Anything else |

---

## 4. The Envelope, field by field

| Field | Visible to a relay | Assessment |
|---|---|---|
| `protocolVersion` | Yes | Version only |
| `packetID` | Yes | Random, not derived. **Constant along the journey** — that is what makes duplicate suppression work, and it means P5 could trace one packet across the network. Accepted; P5 is out of scope |
| `destinationToken` | Yes | Opaque, rotates every 10 minutes, unlinkable across epochs, and cryptographically unrelated to any presence beacon |
| `creationBucket` | Yes | Age of the packet, to 10-minute granularity |
| `expiresAfter` | Yes | Intended lifetime. **A minor leak we accept:** an unusually short lifetime may signal something about the sender's intent |
| `transitPolicy` | Yes | One bit. Partitions traffic into two classes — mitigated by defaulting to restrictive, so the large population carries the restrictive value (`DECISIONS.md` D5) |
| `payloadLength` | Yes | One of five buckets. Mandatory padding means it does not reveal actual length |
| `fragment*`, `totalLength` | Yes | That a message exceeded a bucket, and by roughly how much |
| `nonce` | Yes | Random |
| `hopLimit` | Yes | Remaining allowance, so roughly how far it has already travelled |
| `ciphertext` | Yes, as bytes | Nothing without the key |

No display name, device name, OS, model, build, phone number, account identifier, or permanent device
identifier appears anywhere in it (`SPEC-ORIGINAL` §13, §15).

---

## 5. The combined observation — the assignment's actual question

A relay sees, in sequence:

```
1.  ROUTE_QUERY    from peer P    { queryID Q, presenceToken X,  budget 3 }
2.  ROUTE_RESPONSE toward peer P  { queryID Q, quality, hops 2 }
3.  ENVELOPE       from peer P    { packetID N, destinationToken Y }
```

**Cryptographically, X and Y are unlinkable.** They are HMACs under different labels, and no party
without `routingSecret` can connect them. The design does what it was meant to do at the level of
values.

**Temporally, they link trivially.** All three arrive from the same immediate peer within seconds.
A relay does not need to break any cryptography to conclude: *the envelope P just handed me is for
whoever P was looking for.*

**So state it plainly. A first-hop relay learns:**

- that its peer P is trying to reach the holder of token X;
- that P then handed it a message for that same party;
- that a **communication relationship exists between two pseudonyms**, right now.

**What it still does not learn:**

- Who P is. P is an ephemeral radio identifier that rotates every 15 minutes and is derived from
  nothing — no identity key, no account, no device identifier.
- Who the recipient is. The token rotates every 10 minutes with no cryptographic relationship between
  consecutive values.
- The content, the conversation, or whether these two have ever spoken before.
- **Anything that survives the epoch.** Both the pseudonym and the token roll independently. Linking
  today's pair to tomorrow's is not possible from this observation alone.

**The honest summary:** the protocol conceals *identity*; within a single epoch, adjacency to a
sender still reveals *the existence of a correspondence*. Sealed sender protects who, not that.

---

## 6. Two-hop routes: the single relay sees both ends

On `A → B → C`, B is P1 and P3 at once. So B learns:

- the sender's radio pseudonym,
- the recipient's radio pseudonym — because B must physically hand the packet to C,
- that these two are corresponding, within this epoch.

**This is not fixable within this architecture and we do not claim otherwise.** Someone must deliver
the packet to the destination, and that someone necessarily knows which radio peer received it. Onion
routing would address it, at the cost of becoming an anonymity network rather than a messenger —
explicitly out of scope for v1 (`DECISIONS.md` D7).

**Context that matters for the flagship scenario:** on an aircraft, B is a passenger seated within a
few metres of A and C and can simply *look at them*. Radio-layer correlation is not the binding
privacy constraint in the environment this was designed for. It matters much more in a dense public
space among strangers, and that is the case to be honest about.

---

## 7. A leak found while writing this, and fixed

Checkpoint 2 moved presence tokens off the broadcast channel into a post-connection `PRESENCE` packet,
precisely so the *number* of tokens could not fingerprint a user (`PROTOCOL.md` §4.6).

**The leak came straight back on the unicast channel.** Presence tokens are pairwise, so a `PRESENCE`
list of length N discloses that the sender has N contacts — to anyone who connects, including a
stranger in a queue.

`PROTOCOL.md` §6.3 now requires the list to be **exactly `PRESENCE_SET_SIZE` (32) entries always**,
padded with CSPRNG values indistinguishable from real tokens, or randomly sampled and re-sampled each
encounter when a node has more. Contact count is no longer observable.

Recorded rather than quietly patched, because it is a useful demonstration that the fingerprint moved
when the channel moved, and the same mistake is available anywhere else a list length depends on a
user's contacts.

---

## 8. Leaks we accept, and why

| Leak | Why accepted |
|---|---|
| `packetID` traceable end to end | Required for duplicate suppression. Only exploitable by P5, which is out of scope |
| Correspondence visible to an adjacent relay (§5) | Inherent to relaying; mitigated by rotating pseudonyms and 10-minute tokens |
| Both endpoints visible on a two-hop route (§6) | Inherent; onion routing is the only fix and it is out of scope |
| Message size to bucket granularity | Padding buckets are a deliberate trade against bandwidth |
| **Fragment injection destroys a message** | A fragmented message has one AAD (`PROTOCOL.md` §3.2), so per-fragment index and length are unauthenticated. Corrupting reassembly makes the tag fail. It is a denial of service, never a break — and it grants nothing, since anyone able to inject fragments can already just drop them, which is simpler and equally effective |
| Hop count to the sender | Needed for route selection; discloses coarse proximity of a contact to that contact only (`DECISIONS.md` D3) |
| Packet age and intended lifetime | Needed for expiry enforcement by nodes that cannot read the packet |
| Radio-layer fingerprinting | Outside application control entirely (`SPEC-ORIGINAL` §3) |
| **A persistent node discloses that a node lives at that location** | Inherent to fixed infrastructure. Rotating the advertisement ID does not conceal a device that is always present in one place from an observer who is also in one place. One reason the persistent node is optional, and a reason not to run one somewhere the household's presence is itself sensitive |
| Install base, if broadcast push is ever enabled | Push is off by default and targeted push is a permanent non-goal (`DECISIONS.md` B1) |

---

## 8b. Discovery: the surface this walkthrough did not cover

Sections 1-8
threat-model the envelope, the relay and the route. Discovery — how a phone number or an email
address becomes a key you can seal to — was left out, and it is where the most sensitive linkage in
the product lives. `LEGAL-RISK` is largely about it. It belongs here.

### What the parts are

A value (`+1555…`, `a@b.com`) is hashed **on the client** into a `slot`. The server never sees the
value. Against a slot a phone may publish a 129-byte prekey bundle, and anyone may leave an
invitation. Publishing requires a **capability** minted by `verify.mts` after a code is proved;
collecting requires one too (`J105`). Reading a published bundle requires nothing, which is the whole
point of publishing it.

### What the server can derive, and what it cannot

- **It cannot reverse a slot.** The hash is over a value it never receives. What it *can* do is
  **test a guess**: hash a candidate number and ask whether that slot exists. The phone-number space
  is small enough to enumerate, so **a slot is not a secret, it is an index**. `verify.mts`'s own
  header says this about the operator, and it is true of anyone who obtains the key material.
- **A copy of the store is not an index (`J1800c`, 30 September 2026).** The relay never stores
  anything under a slot. Every slot-derived record -- the published bundle (`prekeys`), the value
  claim (`valueowners`), the stranger-ring log (`stranger-rings`) and the invitations
  (`invites`, `<name>/<id>`) -- is filed under `HMAC-SHA256(DISCOVERY_STORE_SECRET,
  "channel/discover-store-v1" | slot)`, a secret held in the relay's environment and not in the
  store (`server/netlify/lib/_discovery.mts`). So a dump of Blobs alone matches no list of numbers
  (its values still list every published identity key and wake id, tied to no number).
  **What it does not change:** whoever holds the store AND the secret -- the operator, or anyone who
  obtains both -- can still hash every number and match; and the relay sees the raw slot in each
  request while it answers it. The bullet above stays true of them.
- **It does not know who owns a slot.** There is no row saying *this slot belongs to identity X*, and
  that absence is deliberate. *Corrected 30 September 2026: `J1036` added one -- `valueowners` holds
  an HMAC of the publishing identity (`ownerTag`), never the key, so that two devices cannot both
  publish at one value -- and a different identity waits out a takeover delay instead of replacing
  the bundle at once. The section below predates it; read its "unconditional overwrite" with that
  delay in mind.*
- **It therefore cannot tell a re-publish from a takeover**, which is the next section.

### The overwrite, and why it is not a bug

`prekey.mts` accepts a `POST` with a valid capability and does `store.set(storeKey(slot), body)` — an
**unconditional overwrite**. Anyone who can currently prove control of the value can replace the
bundle at it.

That is correct for the case it exists for: a person who changes phone, rotates keys, or reinstalls
must be able to republish. It also means a **SIM swap or a port-out gives the attacker the slot**, and
from that moment first messages addressed to that number are sealed to their key. `DECISIONS.md`
names SIM swap as a known residual and ranks it correctly below the in-person tiers; this is where
that residual actually lands.

**The interesting part is why we cannot detect it.** Refusing an overwrite from a different party
would require the server to know which party published last — that is, to store a binding between a
slot and an identity. **That binding is exactly the linkage `LEGAL-RISK` exists to prevent**, and it
is the thing a subpoena would ask for. So the inability to notice a takeover is not an oversight; it
is the price of not holding the record that would make it noticeable, and it is a price this design
pays deliberately.

**What bounds it instead:**

- **A published bundle expires after 90 days** (`PREKEY_TTL_MS`). That is the mechanism
  `DECISIONS.md` asks for when it says to *"re-prove a published value periodically so a recycled
  number expires rather than being trusted forever"*. It bounds **recycling**. It does not bound an
  **active swap**, which republishes immediately.
- **A capability lives 30 days** (`CAP_TTL_MS`), so a stolen one ages out without being revocable —
  there is no revocation list, because a revocation list is another record of who held what.
- **The pairwise channel, once established, does not depend on discovery at all.** Discovery decides
  who your *first* message reaches. Every message after it is sealed to a key both devices already
  hold, so a takeover cannot retrospectively open a conversation, and cannot intercept an existing
  one. **The blast radius is first contact.**

### What a person can actually do about it

Nothing in the protocol, and that should be said plainly rather than engineered around: the honest
mitigation is the one the product already offers — **pair in person by QR code**, which involves no
slot, no server and no number. The rendezvous path is *"exactly as good as [the competition's] only
path"* (`DECISIONS.md`), and the QR path is better than anything they offer. This section exists so
that claim is made against a written threat model rather than asserted.

### What is NOT claimed about discovery

- Not protection against an adversary who controls the telephone number.
- Not concealment of *whether* a given value has ever been verified, from anyone holding
  `VERIFY_SECRET` — see `LEGAL-RISK` §9z, and note that the key must therefore be treated as
  server-side key material rather than as configuration.
- Not detection of a slot takeover, for the reason above.
- Not anonymity of the *act* of publishing: the server sees an IP, a slot and a timestamp, the same
  as it does for a mailbox deposit (§2).

---

## 9. Structural properties that do hold

- **No content, ever.** Relays hold ciphertext and no key material.
- **No mailbox role requires decryption.** A persistent node stores envelopes it cannot read
  (`DECISIONS.md` A3).
- **No originator attribution is possible.** No field carries it and no party is positioned to derive
  it. A traceability mandate cannot be satisfied by a system that never had the data — stated here so
  it is on record as a design property rather than an accident.
- **No topology tables.** Only reverse-path state (10 s) and route state (15 s), keyed by opaque
  values.
- **No key escrow, and nowhere to add one.** No server, no server-delivered key material, no lever.
- **Deleting a contact destroys the pairwise secret**, after which anything still in flight to them is
  permanently undecryptable — including by us.
- **Tampering fails closed.** Any structural or cryptographic failure is a silent discard;
  implementations MUST NOT return distinguishing errors, which would build an oracle.
- **Forward secrecy between current builds** (`PROTOCOL.md` §4.5a, `DECISIONS.md` J1559). Messages
  are sealed through a Double Ratchet inside the envelope, so a phone's state stolen today does not
  open the messages it has already read, and the sessions in a state stolen once stop working for
  a thief who only reads after one round trip. The stolen state includes the pairing key, so that
  thief still reads the opener's first chain of every later session, until the opener's first DH
  step on it, for as long as the pairing lasts. Large attachments carry a per-attachment key inside the ratchet (§4.5b).

---

## 10. What we do not claim

Carried from `SPEC-ORIGINAL` §32 and `SPEC-REVISION` §3, plus what this walkthrough added:

- Not protected against a **compromised destination device**, malware in decrypted memory, OS
  compromise, or physical access to an unlocked device.
- Not protected against a **global observer** correlating radio traffic across locations.
- **Not** perfect sender/recipient anonymity — §5 and §6 are the specific limits.
- **Not** concealment that a transmitting device is an iPhone. Radio fingerprinting and the
  underlying peer-to-peer protocols may reveal platform. This is application-layer minimisation.
- **Not** protection against a **targeted app-store build**. No protocol design prevents it. The
  partial answer is detection, and it is half built: the shared core's build is reproducible
  (`scripts/reproduce-core.sh`; a clean checkout of a commit gives the same SHA-256, measured), and an
  Android phone's support report shows the commit and the SHA-256 of the core it loaded, beside Play
  code transparency. Nobody outside the project can repeat that check until the core's source is
  published (`J1885`, the owner's call). The app around the core is not reproducible, and on iPhone
  the App Store encrypts the binary, so an iPhone user cannot verify what they received at all.
- **Not** enforcement of no-forward or no-screenshot. Those are stated intent, not controls
  (`DECISIONS.md` E5).
- **Not** a guarantee that `transitPolicy` is honoured. Relays share no key with the sender.
- **Not** forward secrecy for everything. A session's first chain, before the other phone
  replies, is only as secret as the pairing (§4.5a). A contact whose card does not say it reads
  the ratchet (an older build) is sent the static composition. And the local-network handshake
  proof is sealed statically on purpose (`J1571`); it is a bearer token, not a challenge-response
  (`J1572`), and proves only that the sender is *some* contact.

**What we do claim**, and what `SPEC-REVISION` §3 permits: the protocol discloses **no device model,
operating system, telephone number, account identifier, or permanent device identifier** — and no
message content, to anyone but its destination.
