**P1 — the binding proof was replayable onto another session.** The §4.5
verifier checklist omitted the check that the proof's noiseStaticPublicKey
equals the remote static key the Noise session actually established. The
proof is a self-contained signed blob with nothing tying it to the session
it arrives on, so a peer M that had seen A's proof could replay it verbatim
inside M's own session with B; B would verify A's signature, see a
well-formed binding, and on first contact TOFU-pin A's signing key against
M's fingerprint. Added as the first item in the checklist, with the attack
written out, because "signed" and "bound to this conversation" are
different properties and the difference is easy to lose in a bullet list.
**announceV2 is 0x2C, not 0x05.** 0x05 only looks free. It has been
recycled twice — announce, then bulkTransferResponse, then fragmentStart
until #446 — so an old peer could still map it to a fragment header and
misparse presence as a partial message. Values above voiceFrame = 0x29
have only ever been allocated forward, and 0x2A/0x2B belong to the courier
spray-ack work, leaving 0x2C. Confirmed never used anywhere in this
repository's history.
**Outbound priority is now stated, not inherited.** announceV2 fell through
to `default: .high`. High is the right answer — presence is small,
time-bounded to its epoch and useless once stale — but for a type nothing
emits yet, a fall-through means the choice gets made without anyone seeing
it.
**Reverted unexplained pbxproj churn.** Xcode had rewritten resource-phase
ordering and dropped a share-extension entitlements membership exception;
none of it belongs in this PR. The file now matches main byte for byte.
**O7 said "payloads" where the arithmetic is over encoded frames.** The
241-256 / 497-768 / 1009-1792 ranges are what `pad` receives, which is the
whole encoded packet, not the payload alone.
**Added O9: a seized device recomputes every past peer ID.** K_rot is
long-lived, so peerID_e is computable for any epoch by whoever holds it —
someone who seizes a phone, or pulls the static key from a backup, can go
back over historical radio captures and identify which were this device.
Rotation defends against the passive observer, not against later key
compromise. A hash ratchet would give forward secrecy for the ID stream at
the cost of state that must survive restarts, tolerate clock jumps, and
resynchronise after a gap — a real trade rather than an obvious win, so it
is written down as a question rather than silently adopted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Code is a better thing to argue with than prose, so the spec now has a
working, tested base under it. Every number and context string is a
concrete proposal you can reject by changing one function and watching a
test vector move.
What is implemented:
- PeerIDRotation: hour epochs with a ±1 matching window, the rotation
secret from the Noise static *private* key, per-epoch peer IDs, pairwise
recognition keys and tags from an X25519 shared secret, the fixed-width
tag block with CSPRNG padding and constant-time matching, and the
canonical bytes for the identity binding.
- AnnounceV2Packet (announceV2 = 0x05): TLV wire format carrying an epoch,
a 64-byte tag block, capabilities and an optional bridge cell — and
nothing else. No nickname, no public keys, no neighbour list. Rejects a
wrong-width tag block on both encode and decode, since a short block
would disclose how many mutual favourites someone has, and rejects
non-canonical capability encodings the way AuthenticatedPeerStatePacket
does. Unknown TLVs are skipped for forward compatibility.
- 37 tests, three of which are hex vectors cross-checked against an
independent implementation written from the spec alone (Python
hmac/hashlib, HKDF extract-then-expand, empty salt) and matching byte
for byte. That is the property Android needs: the document is sufficient
to reproduce the numbers without reading this code.
What is deliberately NOT implemented: nothing emits a v2 announce, and
BLEService parses the type and explicitly ignores it. Consuming presence
needs both the replacement identity binding and a decision on how
unverified presence appears in the peer list, and accepting it now would
put unauthenticated entries in front of people.
Adding the message type forced three policy decisions, all reviewable:
- Not gossip-synced. Syncing presence would defeat the point — a device
never in radio range could collect tag blocks, turning a local beacon
into a network-wide one.
- Not padded. At ~75 bytes the smallest bucket would triple the airtime of
the most frequent packet in the protocol; the format is already
near-constant width, and fixing the capability and geohash field widths
would be cheaper than padding.
- Parsed but ignored on receive, as above.
Notably the v2 announce is *smaller* than v1 (~75 vs ~229 bytes): dropping
two 32-byte keys, the neighbour list and the signature more than pays for
64 bytes of tags, so unlinkability here costs less airtime rather than
more.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>