Commit Graph
3 Commits
Author SHA1 Message Date
jackandClaude Opus 5 c9f2f418ca Fix two P1 flaws in the recognition tag design (Codex #1487)
Both findings are correct and both were real. This is the argument for
shipping code next to the prose: neither was obvious in the design text.

**Tags were symmetric, which leaked the social graph.** `HMAC(K_AB, epoch)`
produces the same 8 bytes for both parties, so an observer who saw one
value in two different announces would learn those two devices are mutual
favourites, and could link their two rotating IDs to each other — handing
over exactly the graph the design exists to hide, plus a cross-epoch
correlation handle. Tags are now directional: the MAC covers the ordered
sender and recipient static public keys, so A→B and B→A differ. Both
parties can still compute both directions because both hold both keys.

**Tags were replayable under any ID.** A tag depending only on
(pair, epoch) could be lifted from a recorded announce and replayed in a
fresh announce under an attacker-chosen ID; the recipient would match and
treat that ID as the favourite, and since epoch-1 is accepted it would
keep working into the next period. The MAC now covers the announced peer
ID, which reduces this to replaying the victim's own presence.

That residual is unfixable while announces are unsigned, so the spec now
states plainly that recognition is a hint only: presence may be populated,
but routing a DM or showing a verified badge must wait for a handshake
whose static key equals the favourite that produced the match. O4 is
rewritten around that, with the two alternatives named (per-epoch
ephemeral signing key, or a freshness nonce echoed by the recipient).

Tests: two regression cases named for the findings, plus a
wrong-direction-does-not-match case so the directional fix cannot silently
become cosmetic. The vector table now gives both directions, because their
difference is the security property — an implementation that produces one
value for both has reintroduced the flaw. Recomputed independently in
Python from the spec and matched byte for byte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 20:49:10 +02:00
jackandClaude Opus 5 10f1e5c8b7 Implement the peer ID rotation primitives
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>
2026-07-26 19:54:34 +02:00
jackandClaude Opus 5 19a56780d0 docs: specify peer ID rotation for cross-platform review
Draft protocol spec for review by both iOS and Android before any
implementation. Nothing here is implemented; this is the artifact to
agree on, since the change is a wire revision neither platform can ship
alone.

The headline correction, because it is easy to get wrong: rotating the
peer ID alone accomplishes nothing. The announce carries the Noise static
key, the Ed25519 signing key and the nickname in cleartext, so a rotated
ID is re-linked to the same device on its first announce. Rotation and
announce confidentiality have to land together.

The second thing an implementer needs to know up front is that
peerID == SHA-256(noiseStaticKey)[0..8] is not a convention, it is the
mechanism that makes peer IDs unforgeable, enforced in the announce
preflight and again at handshake completion. Making IDs independent of
the key fails both checks for every peer, so a replacement binding has to
ship in the same change. The spec proposes one: an Ed25519 proof over
(context, epoch, rotating ID, static key) carried inside the completed
Noise session via the existing AuthenticatedPeerStatePacket, checked
against a pinned signing key — strictly stronger than today's
self-signed announce.

Design summary: hour-epoch IDs derived from private key material via
HKDF+HMAC so no observer can predict or link them; pairwise recognition
tags from the X25519 shared secret so mutual favourites still recognise
each other with no handshake, padded to fixed slots so the tag count does
not leak how many favourites someone has; strangers discovered by
handshake-first-identify-second over Noise XX, whose static keys are
already encrypted on the wire. Nickname moves inside the session and the
neighbour list is dropped rather than rotated.

Includes a verified impact inventory separating what breaks hard (the
handshake check, the announce preflight, the disk outbox keyed by peer ID,
private-media stable IDs and their deletion tombstones, the initiator
tie-break, fingerprint-prefix lookups) from what degrades gracefully and
what is already safe because it keys on fingerprints or Noise keys.

Rollout uses the two mechanisms already proven in this repo: a
PeerCapabilities bit (11 is next; 10 is burned) with
capabilitiesWereExplicitlyAdvertised to tell an old client from a new one
with the bit off, and observed-version gating as used for source routing.

Two findings surfaced while writing this and are recorded in the spec.
CourierEnvelope.recipientTag is HMAC keyed on the recipient's *public*
static key, and since that key is broadcast in cleartext today, any
observer in radio range can compute a peer's courier tags for any day —
so the whitepaper's "cannot link it across days" does not currently hold,
and the pattern must not be copied. And NoiseEncryptionService's
buildAnnounceSignature/verifyAnnounceSignature/canonicalAnnounceBytes are
present but production-dead, called only from tests; the binding above
deliberately uses a different context string so the two can never be
confused.

Eight open questions are left explicitly unresolved, including the
rotation period, whether unsigned v2 announces are an acceptable posture,
and whether Android's decoder tolerates trailing bytes the way iOS's does
(which decides whether padding coverage can ship ungated).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 19:33:56 +02:00