Compare commits

..
Author SHA1 Message Date
jackandClaude Opus 5 399cdf95ab Stop broadcasting the adjacency graph and the authorship marker
Two radio-layer metadata leaks that need no cross-platform agreement,
because both only change what this device chooses to emit.

**Announces no longer carry the neighbour list.** The TLV held up to ten
8-byte peer IDs, so a *single* passive receiver could reconstruct the local
adjacency graph — who is standing next to whom — with no need for several
receivers or RSSI trilateration. In a crowd that is the most sensitive
thing the radio layer gives away, and unlike the identity keys it is not
required for the protocol to work.

Backward compatible in both directions: an empty list omits the TLV
entirely rather than emitting a zero-length one, the decoder already treats
its absence as "no topology offered", and lists from other peers are still
parsed so a mixed network behaves sensibly.

The cost is source routing. MeshTopologyTracker builds its adjacency map
from these lists, so with everyone silent there are no routes to compute
and directed traffic floods instead — which is already the documented
fallback whenever a route fails. More airtime for directed sends in dense
meshes; no correctness change. Left as a TransportConfig constant rather
than a user setting because it is a protocol trade-off, not a preference,
and flipping it back is one line.

**Public broadcasts no longer always originate at the maximum TTL.**
`ttl == messageTTLDefault` was a reliable "this device wrote it" marker to
any direct listener, which discloses authorship rather than mere presence.
Origin TTL is now drawn from 5...7: in a dense graph relays already clamp
broadcasts to 5, so an origin emitting 5 is indistinguishable from relayed
traffic, and in a sparse chain a 6 could be an origin or one hop from a 7.

Signature-safe and needs no agreement: TTL is excluded from the signed
bytes (toBinaryDataForSigning zeroes it so relays can decrement), so a peer
on any version just sees a smaller starting TTL and relays it normally. The
floor is not below the dense-graph clamp, since lower would cost reach
without buying ambiguity that clamp does not already provide.

Announces deliberately keep the fixed TTL: three link-binding paths read a
maximum-TTL announce as "direct link", and an announce's sender ID already
identifies the device, so there is nothing to hide and something to break.

**Not done here: padding.** Extending padding beyond Noise frames, and
fixing the gap where a frame needing over 255 bytes of padding is emitted
unpadded, both looked unilateral but are not. `toBinaryDataForSigning`
encodes with padding enabled, so the padding bytes are inside the signed
material for every signed packet — changing the algorithm changes the
signed byte stream and breaks signature verification against any peer that
has not changed it identically. That makes it a coordinated wire change;
recorded in the privacy assessment and in #1487's open questions rather
than attempted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 21:03:35 +02:00
14 changed files with 211 additions and 1410 deletions
+8 -5
View File
@@ -94,6 +94,7 @@
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
bitchatShareExtension.entitlements,
);
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
};
@@ -378,11 +379,6 @@
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
);
};
7E9B64F63F93443FB7BA12DF /* Resources */ = {
isa = PBXResourcesBuildPhase;
files = (
);
};
C5E027A42ECCDFD700BD6012 /* Resources */ = {
isa = PBXResourcesBuildPhase;
files = (
@@ -399,6 +395,13 @@
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,
);
};
7E9B64F63F93443FB7BA12DF /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -0,0 +1,37 @@
//
// BLEOriginTTLPolicy.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Chooses the TTL a locally originated public broadcast leaves with.
///
/// Split out from `BLEService` so the choice is testable without a radio, and so
/// the reasoning lives in one place: see
/// `TransportConfig.broadcastOriginTTLRange` for why originating at a fixed
/// maximum identifies the author to any direct listener.
enum BLEOriginTTLPolicy {
/// Uniform draw from the configured range.
///
/// The randomizer is injectable so tests can pin the value; production uses
/// the system generator. Note that TTL is excluded from the packet signature
/// (`toBinaryDataForSigning` zeroes it so relays can decrement without
/// invalidating), so varying it per message is signature-safe and needs no
/// cross-platform agreement a peer running any version simply sees a
/// smaller starting TTL and relays it normally.
static func originTTL(
range: ClosedRange<UInt8> = TransportConfig.broadcastOriginTTLRange,
randomTTL: (ClosedRange<UInt8>) -> UInt8 = { UInt8.random(in: $0) }
) -> UInt8 {
// A degenerate or inverted range must not trap; fall back to the
// documented default rather than crashing a send path.
guard range.lowerBound <= range.upperBound, range.lowerBound >= 1 else {
return TransportConfig.messageTTLDefault
}
return randomTTL(range)
}
}
@@ -15,15 +15,7 @@ enum BLEOutboundPacketPolicy {
// voiceFrame is deliberately unpadded: padding to the 512 block would
// push every ~490-byte signed voice packet over the MTU into the
// fragment path.
//
// announceV2 is unpadded too, but for a different reason and it is worth
// revisiting: it is ~75 bytes, so the smallest bucket would triple the
// airtime of the most frequently sent packet in the protocol. Its length
// is already near-constant by construction (the tag block is fixed
// width); the residual variation is the capability width and whether a
// bridge geohash is present. Making those fixed-width would be cheaper
// than padding. See docs/PEER-ID-ROTATION.md.
case .none, .announce, .announceV2, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame:
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame:
return false
}
}
+10 -12
View File
@@ -869,7 +869,9 @@ final class BLEService: NSObject {
timestamp: sendTimestampMs,
payload: Data(content.utf8),
signature: nil,
ttl: messageTTL
// Not the fixed maximum: that would mark every message this device
// wrote as originating here, to any direct listener.
ttl: BLEOriginTTLPolicy.originTTL()
)
guard let signedPacket = noiseService.signPacket(basePacket) else {
SecureLogger.error("❌ Failed to sign public message", category: .security)
@@ -2892,7 +2894,12 @@ final class BLEService: NSObject {
let (connectedPeerIDs, advertisedCapabilities, advertisedBridgeCell): ([Data], PeerCapabilities, String?) = collectionsQueue.sync {
(
peerRegistry.connectedRoutingData,
// Publishing the neighbour list hands the local adjacency graph
// to a single passive receiver; see
// TransportConfig.announceIncludesDirectNeighbors.
TransportConfig.announceIncludesDirectNeighbors
? peerRegistry.connectedRoutingData
: [],
PeerCapabilities.localSupported.union(runtimeCapabilities),
runtimeCapabilities.contains(.bridge) ? localBridgeGeohash : nil
)
@@ -7025,16 +7032,7 @@ extension BLEService {
switch context.messageType {
case .announce:
handleAnnounce(packet, from: senderID)
case .announceV2:
// Parsed and ignored on purpose. The wire format and derivations are
// implemented and tested (see PeerIDRotation, AnnounceV2Packet), but
// consuming presence from it needs the replacement identity binding
// and the peer-list policy for unverified presence, both of which are
// still open questions in docs/PEER-ID-ROTATION.md. Accepting it now
// would add unauthenticated entries to the peer list.
break
case .message:
handleMessage(packet, from: senderID)
+44
View File
@@ -6,6 +6,50 @@ enum TransportConfig {
// BLE / Protocol
static let bleDefaultFragmentSize: Int = 469 // ~512 MTU minus protocol overhead
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
/// TTL range a public broadcast is originated with.
///
/// Originating every message at the maximum makes `ttl == messageTTLDefault`
/// a reliable "this device wrote it" marker for any direct listener, which
/// tells a passive observer who *said* a thing rather than merely who is
/// present. Drawing from a range makes the lower values ambiguous between an
/// origin and a relay: in a dense graph relays already clamp broadcasts to
/// 5, so an origin that emits 5 is indistinguishable from relayed traffic,
/// and in a sparse chain a 6 could be an origin or one hop from a 7.
///
/// The cost is reach: a message originated at 5 crosses two fewer hops than
/// one at 7. The upper bound stays at the default so the common case is
/// unchanged, and the floor is deliberately not lower than the dense-graph
/// relay clamp going below it would cost reach without buying ambiguity
/// that clamp does not already provide.
///
/// Announces are deliberately excluded: the link-binding paths treat
/// `ttl == messageTTLDefault` on an announce as "direct link", and an
/// announce's sender ID already identifies the device anyway, so there is
/// nothing to hide and something to break.
static let broadcastOriginTTLRange: ClosedRange<UInt8> = 5...7
/// Whether signed announces advertise this device's direct neighbours.
///
/// The neighbour TLV carries up to ten 8-byte peer IDs, so a *single*
/// passive receiver can reconstruct the local adjacency graph who is
/// standing next to whom without needing several receivers or RSSI
/// trilateration. For a crowd, that is the most sensitive thing the radio
/// layer discloses, and unlike the identity keys it is not needed for the
/// protocol to work.
///
/// Turning it off costs source routing. `MeshTopologyTracker` builds its
/// adjacency map from these lists, and `computeRoute` needs that map, so
/// with every device silent there are no routes to compute and directed
/// traffic falls back to flooding which is the documented fallback and is
/// already what happens whenever a route fails. Expect more airtime for
/// directed sends in dense meshes, and no correctness change.
///
/// Kept as a constant rather than a user setting because it is a protocol
/// trade-off, not a preference: flipping it back is a one-line change, and
/// receiving peers' lists is unaffected either way, so a mixed network
/// behaves sensibly during any transition.
static let announceIncludesDirectNeighbors = false
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
-5
View File
@@ -57,11 +57,6 @@ struct SyncTypeFlags: OptionSet {
// Live voice is only useful now; replaying stale audio frames via
// sync would waste airtime (receivers drop them as stale anyway).
case .voiceFrame: return nil
// Rotating-ID presence is valid only inside its epoch, and gossiping it
// would defeat the point: a synced announce would let a device that was
// never in radio range collect tag blocks, turning a local presence
// beacon into a network-wide one.
case .announceV2: return nil
// Prekey bundles gossip like board posts. The bitfield is a
// wire-tolerant little-endian UInt64 (1-8 bytes, unknown high bits
// ignored by `type(forBit:)`), so bits 8+ need no format change: old
@@ -0,0 +1,105 @@
import BitFoundation
import Foundation
import Testing
@testable import bitchat
/// Two radio-layer metadata leaks that need no cross-platform agreement to
/// close: the neighbour list in announces, and the fixed origin TTL.
struct RadioMetadataTests {
// MARK: - Origin TTL
@Test func originTTLStaysInsideTheConfiguredRange() {
let range = TransportConfig.broadcastOriginTTLRange
for _ in 0..<200 {
let ttl = BLEOriginTTLPolicy.originTTL()
#expect(range.contains(ttl))
}
}
/// The point of the change: a message must not always leave at the maximum,
/// because a direct listener reads `ttl == max` as "this device wrote it".
@Test func originTTLDoesNotAlwaysUseTheMaximum() {
var seen = Set<UInt8>()
for _ in 0..<500 {
seen.insert(BLEOriginTTLPolicy.originTTL())
}
#expect(seen.count > 1)
#expect(seen.contains { $0 < TransportConfig.messageTTLDefault })
}
@Test func originTTLUsesTheInjectedRandomizer() {
let ttl = BLEOriginTTLPolicy.originTTL(range: 5...7, randomTTL: { _ in 6 })
#expect(ttl == 6)
}
/// A send path must never trap on a bad range.
@Test func degenerateRangesFallBackToTheDefault() {
#expect(BLEOriginTTLPolicy.originTTL(range: 0...0) == TransportConfig.messageTTLDefault)
// Single-value range is legitimate and must be honoured.
#expect(BLEOriginTTLPolicy.originTTL(range: 4...4) == 4)
}
/// The floor must not drop below the dense-graph relay clamp: going lower
/// costs reach without buying ambiguity the clamp does not already provide.
@Test func rangeSitsBetweenTheDenseClampAndTheDefault() {
let range = TransportConfig.broadcastOriginTTLRange
#expect(range.upperBound == TransportConfig.messageTTLDefault)
#expect(range.lowerBound >= TransportConfig.bleFragmentRelayTtlCapDense)
#expect(range.lowerBound >= 2, "TTL 1 is dropped by RelayController")
}
// MARK: - Neighbour list
@Test func neighborAdvertisingIsOffByDefault() {
#expect(!TransportConfig.announceIncludesDirectNeighbors)
}
/// The mechanism that makes this backward compatible: an empty list omits
/// the TLV entirely rather than emitting a zero-length one, and the decoder
/// treats its absence as "no topology offered".
@Test func emptyNeighborListOmitsTheTLV() throws {
let announcement = AnnouncementPacket(
nickname: "alice",
noisePublicKey: Data(repeating: 0x11, count: 32),
signingPublicKey: Data(repeating: 0x22, count: 32),
directNeighbors: [],
capabilities: [.bridge]
)
let encoded = try #require(announcement.encode())
// TLV type 0x04 is the neighbour list; it must not appear at all.
var offset = encoded.startIndex
var types: [UInt8] = []
while offset < encoded.endIndex {
guard encoded.distance(from: offset, to: encoded.endIndex) >= 2 else { break }
let type = encoded[offset]
let length = Int(encoded[encoded.index(after: offset)])
types.append(type)
offset = encoded.index(offset, offsetBy: 2 + length)
}
#expect(!types.contains(0x04))
let decoded = try #require(AnnouncementPacket.decode(from: encoded))
#expect(decoded.directNeighbors == nil)
// Everything else still round-trips, so old peers lose nothing but the
// topology hint.
#expect(decoded.nickname == "alice")
#expect(decoded.capabilities == [.bridge])
}
/// Receiving a neighbour list must keep working: peers on older builds still
/// send one, and a mixed mesh has to behave sensibly.
@Test func receivedNeighborListsAreStillParsed() throws {
let neighbors = [Data(repeating: 0xA1, count: 8), Data(repeating: 0xB2, count: 8)]
let announcement = AnnouncementPacket(
nickname: "bob",
noisePublicKey: Data(repeating: 0x11, count: 32),
signingPublicKey: Data(repeating: 0x22, count: 32),
directNeighbors: neighbors
)
let encoded = try #require(announcement.encode())
let decoded = try #require(AnnouncementPacket.decode(from: encoded))
#expect(decoded.directNeighbors == neighbors)
}
}
-335
View File
@@ -1,335 +0,0 @@
# Peer ID Rotation Specification
**Status:** Draft for cross-platform review. The derivations and the wire format **are implemented and tested**; nothing is wired into the shipping mesh.
**Audience:** bitchat iOS and bitchat Android maintainers.
**Requires agreement before going further.** This changes the wire protocol, so neither platform can ship it alone.
**Where the code is:**
| Piece | File |
|---|---|
| Epochs, ID derivation, recognition tags, tag block, binding message | `localPackages/BitFoundation/Sources/BitFoundation/PeerIDRotation.swift` |
| `announceV2 = 0x05` wire format | `localPackages/BitFoundation/Sources/BitFoundation/AnnounceV2Packet.swift` |
| Executable test vectors | `localPackages/BitFoundation/Tests/BitFoundationTests/PeerIDRotationTests.swift` |
| Wire-format tests | `localPackages/BitFoundation/Tests/BitFoundationTests/AnnounceV2PacketTests.swift` |
The code is deliberately an **opinionated working base, not a finished feature**. Every number and context string in it is a concrete proposal you can disagree with by changing one function and watching a test vector move. What is *not* implemented is the part that carries risk: nothing emits a v2 announce, and `BLEService` parses the type and explicitly ignores it, because consuming it needs both the replacement identity binding (§4.5) and a decision on how unverified presence appears in the peer list (O4).
Three policy decisions were forced by the compiler when the new message type was added, and are worth reviewing as part of this:
- **Not gossip-synced** (`SyncTypeFlags`). Syncing presence would defeat the purpose: a device never in radio range could collect tag blocks, turning a local beacon into a network-wide one.
- **Not padded** (`BLEOutboundPacketPolicy`). 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; making the capability and geohash fields fixed-width would be cheaper than padding. Open for argument.
- **Parsed but ignored** on receive (`BLEService`), as above.
---
## 1. The problem
Today a passive listener with a BLE dongle, standing in a crowd, can do the following with no cryptographic attack and no active participation:
1. **Detect that a phone is running bitchat.** The service UUID is a fixed constant.
2. **Assign that phone a permanent identifier.** The 8-byte sender ID in every packet header is `SHA-256(noiseStaticPublicKey)[0..8]`, and the Noise static key is generated once and kept in the keychain. It does not rotate. Same phone, same bytes, next week, next city.
3. **Learn the phone's long-term public keys and its self-chosen nickname.** The announce carries the 32-byte Noise static key, the 32-byte Ed25519 signing key, and the nickname, all in cleartext, re-broadcast every 430 seconds and on demand to anything that connects and subscribes.
4. **Reconstruct who was standing near whom.** The announce also carries up to ten neighbour IDs, so one receiver gets the local adjacency graph without needing several receivers or signal-strength trilateration.
For the people this app is explicitly built for, (2) and (4) are the dangerous ones. A protest attendee's phone announces a stable pseudonym and its social graph to anyone within radio range.
iOS BLE address randomization does not help. It randomizes the link-layer address underneath an application layer that publishes a stable identifier above it.
**The correction that matters most:** rotating the peer ID *alone* accomplishes nothing. As long as the announce carries the static keys in cleartext, a rotated ID is re-linked to the same device on its first announce. Rotation and announce confidentiality have to land together or not at all.
## 2. Goals and non-goals
**Goals**
- **G1.** A passive listener cannot link two observations of the same device across rotation periods.
- **G2.** A passive listener cannot learn a device's long-term identity keys or nickname.
- **G3.** Peers who already know each other (mutual favourites) still recognise each other automatically, without an interactive handshake, so existing UX does not regress.
- **G4.** Strangers can still discover and handshake, so the mesh still forms among people who have never met.
- **G5.** Old and new clients interoperate. A mixed mesh keeps working, in both directions, with no flag day.
- **G6.** Rotation does not make identity spoofing easier than it is today.
**Non-goals, explicitly out of scope here**
- Hiding *that* bitchat is in use. The service UUID is a separate problem; BLE requires something discoverable. Tracked separately.
- Traffic-analysis resistance in general: padding coverage, send-time jitter, TTL randomization, and the neighbour-list leak each need their own change. Rotation does not fix them and they do not fix rotation.
- Resistance to an active attacker who connects and completes a handshake. Anyone you handshake with learns your identity; that is what a handshake is for.
## 3. What currently binds an identity, and why rotation breaks it
This is the part most likely to be underestimated, so it is stated precisely.
`peerID == SHA-256(noiseStaticPublicKey)[0..8]` is not merely a convention. It is **the mechanism that makes peer IDs unforgeable**, and it is enforced in two places:
**Announce preflight**`BLEAnnounceHandlingPolicy.swift:32-35`:
```swift
let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
guard derivedPeerID == peerID else { return .reject(.senderMismatch(derivedPeerID: derivedPeerID)) }
```
**Handshake completion**`NoiseSessionManager.swift:1106-1122`:
```swift
private func authenticatedRemoteKey(_ remoteKey: Curve25519.KeyAgreement.PublicKey,
matches claimedPeerID: PeerID) -> Bool {
let rawKey = remoteKey.rawRepresentation
if claimedPeerID.isShort { return PeerID(publicKey: rawKey) == claimedPeerID }
}
```
Failure throws `NoiseSessionError.peerIdentityMismatch`.
If the peer ID becomes independent of the key, **both checks fail for every peer** and there is nothing left proving that a sender ID belongs to the sender. Any rotation design must therefore ship a *replacement* binding in the same change. This is why the work is a protocol revision and not a patch.
Note also what the existing announce signature does and does not prove. The packet signature covers the sender ID (`BitchatPacket.toBinaryDataForSigning()` zeroes only TTL and the RSR flag), but it is verified against the Ed25519 key carried *inside the same announce* — a self-signature. The code says so plainly (`BLEAnnounceHandlingPolicy.swift:94-103`): an attacker can replay a victim's peer ID and Noise key with their own signing key and a valid self-signature, and only trust-on-first-use pinning of the signing key stops it. So today's binding is "derived ID + TOFU", and a replacement must be at least that strong.
## 4. Design
### 4.1 Epochs
Rotation is on a wall-clock schedule so that two devices that have never met agree on the current period without negotiation.
```
epoch = floor(unixTimeSeconds / ROTATION_PERIOD)
ROTATION_PERIOD = 3600 (1 hour, proposed — see open question O1)
```
`epoch` is a `UInt32`, big-endian wherever it is hashed. Implementations MUST accept `epoch-1`, `epoch`, and `epoch+1` when matching (the ±1 window absorbs clock skew and boundary crossings), following the precedent already set by courier recipient tags (`CourierEnvelope.candidateTags`).
### 4.2 The rotating peer ID
```
K_rot = HKDF-SHA256(ikm: noiseStaticPrivateKey,
salt: "",
info: "bitchat-peer-rotation-v1",
length: 32)
peerID_e = HMAC-SHA256(key: K_rot,
message: "bitchat-peer-id-v2" || uint32be(epoch))[0..8]
```
Properties:
- Derived from the **private** key, so no observer can compute or predict it, and two epochs' IDs are unlinkable.
- Deterministic, so the device recomputes the same ID after a restart within the same epoch.
- Still 8 bytes, so the packet header layout is unchanged.
**It must be derived from private key material.** Deriving from the *public* key would let anyone who has ever seen that key compute every past and future ID, which is worse than doing nothing because it would look like protection. This mistake already exists in the codebase: `CourierEnvelope.recipientTag` is `HMAC(key: recipient's **public** static key, epochDay)`, and since that public key is broadcast in cleartext today, any observer in radio range can compute a peer's courier tags for any day. The whitepaper's claim that couriers "cannot link it across days" does not currently hold. Fixing that is out of scope here but should be tracked; do not copy the pattern.
### 4.3 Recognising peers you already know
With the static keys off the air, mutual favourites need another way to spot each other. Each announce carries a set of **pairwise recognition tags**. For a device A announcing under `peerID_e` to mutual favourite B:
```
S_AB = X25519(A_noiseStaticPrivate, B_noiseStaticPublic) // == X25519(B_priv, A_pub)
K_AB = HKDF-SHA256(ikm: S_AB, salt: "", info: "bitchat-recognition-v1", length: 32)
tag_A→B = HMAC-SHA256(key: K_AB,
message: uint32be(epoch)
|| A_noiseStaticPublic (32)
|| B_noiseStaticPublic (32)
|| peerID_e (8))[0..8]
```
A includes `tag_A→B` in its announce. B computes the same value independently — it holds the same shared secret and both public keys — and matches it against inbound announces. Only A and B can compute it, because it needs one of the two private keys.
Two properties of that MAC input are load-bearing, and an earlier draft of this document got both wrong. They were caught in review of #1487, which is the argument for shipping the code alongside the prose.
**Ordered keys make the tag directional.** The earlier form was `HMAC(K_AB, epoch)`, which is symmetric: A and B would broadcast the *identical* 8 bytes. An observer who saw one value appear in two different announces would learn that those two devices are mutual favourites, and could link their two rotating IDs to each other — handing over precisely the social graph this design exists to hide, and providing a cross-epoch correlation handle. Ordering the keys yields distinct A→B and B→A values, and both parties can still compute both directions because both hold both public keys.
**`peerID_e` binds the tag to the announce carrying it.** Without it a tag depends only on (pair, epoch), so an attacker could lift A's tag out of a recorded announce and replay it in a fresh announce under an ID of their own choosing; B would match and treat that ID as A. Because `epoch-1` is also accepted, the spoof would stay usable into the following period. Binding to the ID reduces this from impersonation-as-any-ID to replaying A's own presence.
**Residual risk, unfixable while announces are unsigned:** an attacker can rebroadcast A's exact announce within the epoch window, making A appear present when absent. Recognition is therefore a **hint only**. A match may populate presence, but anything consequential — routing a DM, showing a verified badge — MUST wait for a completed handshake whose static key equals the favourite that produced the match. See O4.
Rules:
- Tags are **unordered**. Implementations MUST NOT infer anything from position.
- The tag list MUST be padded with uniform random 8-byte values to a fixed count `TAG_SLOTS = 8`, so the number of tags does not disclose how many mutual favourites a device has. Random padding is indistinguishable from a real tag to anyone who cannot compute it.
- With more than `TAG_SLOTS` mutual favourites, a device MUST rotate which favourites occupy the slots across successive announces so all of them eventually see a tag. (Selection strategy is an implementation detail; convergence is not — see O2.)
- A device MUST NOT include a tag for a one-directional favourite, since that would disclose interest to someone who has not reciprocated.
### 4.4 Strangers
Nothing identifying is broadcast for strangers. Discovery still works:
1. A hears an announce from unknown `peerID_e` advertising the rotation capability.
2. A initiates Noise **XX** to that ID.
3. In XX, the responder's static key is sent in message 2 *after* `ee`, and the initiator's in message 3 — both encrypted. A passive observer learns neither.
4. On completion, both sides learn the peer's real static key and fingerprint, exactly as they do today (`handleSessionEstablished`), and the existing `AuthenticatedPeerStatePacket` (Noise payload `0x21`) carries the Ed25519 signing key and capability claims *inside* the session, where they are proven rather than asserted.
So the model becomes **handshake first, identify second**, for anyone who is not already a mutual favourite.
### 4.5 The replacement binding
Inside the completed handshake, each side proves that the rotating ID it was using belongs to its static key:
```
proof = Ed25519-Sign(signingPrivateKey,
"bitchat-peerid-binding-v1"
|| uint32be(epoch)
|| peerID_e (8 bytes)
|| noiseStaticPublicKey (32 bytes))
```
Sent as a new TLV in `AuthenticatedPeerStatePacket`, whose existing structure already carries a version byte, a canonicality-checked capability TLV, and the 32-byte signing key. The receiver verifies:
- the signature against the signing key in the same packet, **and**
- that the signing key matches whatever it has already pinned for this fingerprint, using the existing trust ladder (authenticated key, then TOFU pin), and
- that `peerID_e` equals the ID the session was actually conducted under, and
- that `epoch` is within the ±1 window.
This replaces `authenticatedRemoteKey`'s derivation check with an explicit signed statement. It is strictly stronger than today's self-signed announce, because the signing key is checked against a pin rather than taken from the same message.
Note the canonical-bytes helper for this already half-exists: `NoiseEncryptionService.buildAnnounceSignature` / `verifyAnnounceSignature` / `canonicalAnnounceBytes`, with context `"bitchat-announce-v1"`, are present but unreferenced in production (only tests call them). They sign `context‖peerID(8)‖noiseKey(32)‖ed25519Key(32)‖nickname‖timestampMs`. The binding above is deliberately a **different context string** and a different field set, so the two can never be confused; the dead code should be deleted or repurposed explicitly rather than silently reused.
### 4.6 The announce, before and after
**Today** (`AnnouncementPacket`, TLVs in `Packets.swift:33-40`), all cleartext:
| T | Field | Width |
|---|---|---|
| `0x01` | nickname | var |
| `0x02` | Noise static public key | 32 |
| `0x03` | Ed25519 signing public key | 32 |
| `0x04` | direct neighbours | N × 8, max 10 |
| `0x05` | capabilities | 18 |
| `0x06` | bridge geohash | var |
`0x01`, `0x02`, `0x03` are **required** by the decoder (`Packets.swift:147`).
**Proposed v2 announce.** Because the existing decoder hard-requires the three identity TLVs, a v2 announce cannot simply omit them — that is a parse failure, not a graceful degrade. It therefore needs a distinct message type. Assigned values today are `0x01``0x04`, `0x10``0x11`, and `0x20``0x29`, so **`announceV2 = 0x05`** is proposed: free, and adjacent to `announce = 0x01` so the grouping stays readable (see O3).
TLVs, all cleartext but none identifying:
| T | Field | Width | Notes |
|---|---|---|---|
| `0x01` | epoch | 4 | `uint32be`; lets a receiver match without guessing |
| `0x02` | recognition tags | `TAG_SLOTS` × 8 = 64 | unordered, random-padded |
| `0x03` | capabilities | 18 | same minimal-LE encoding as today |
| `0x04` | bridge geohash | ≤12 | unchanged semantics |
Deliberately absent: nickname, both public keys, neighbour list.
Worth noting because it is counter-intuitive: **the v2 announce is smaller than the v1 announce**, despite carrying 64 bytes of tags. A v1 announce with a 10-byte nickname and a full neighbour list is roughly 165 payload bytes plus a 64-byte signature; a v2 announce is roughly 75 bytes and unsigned. Dropping two 32-byte keys, the neighbour list, and the signature more than pays for the tag block, so this reduces airtime rather than adding to it.
- **Nickname** moves inside the session (`AuthenticatedPeerStatePacket`). A nickname is a self-chosen, often reused human label; broadcasting it in cleartext is a linkage vector on its own.
- **Neighbour list** is dropped entirely. It exists to seed source routing, and its documented fallback is flooding. Publishing the adjacency graph of a crowd is not a reasonable price for routing efficiency. (Dropping it is independently backward compatible — the TLV is optional on decode — and can ship ahead of this spec.)
**The v2 announce is unsigned.** This is a real trade-off and needs review (O4). There is no key to verify a signature against without disclosing one, so a v2 announce asserts nothing except "somebody is here, and here are some tags". Consequences:
- An attacker can emit v2 announces with arbitrary IDs and random tags — cheap peer-list noise. This is bounded by the existing announce rate limiting, per-central subscription limiting, and connection rate limits, but it is weaker than today.
- An attacker **cannot** impersonate a specific known peer, because it cannot compute that peer's recognition tags without one of the two private keys.
- An attacker cannot get a Noise session, so it cannot send messages, only occupy a peer-list slot.
Mitigation for review: treat a v2 announce as *unverified presence* only, and do not surface it in the peer list until either a recognition tag matches or a handshake completes. That preserves today's property that the peer list reflects authenticated peers.
## 5. Compatibility and rollout
The repo already has the two mechanisms this needs, both proven in production.
**Capability bit.** `PeerCapabilities` is a `UInt64` `OptionSet` with minimal little-endian wire encoding, at least one byte, so "no TLV" and "empty set" stay distinguishable. Crucially `BLEPeerRegistry.capabilitiesWereExplicitlyAdvertised(for:)` distinguishes *old client that sent no TLV* from *new client with the bit off*. Add `peerIDRotation` at the next free bit (bit 11; bit 10 is burned and MUST NOT be reused).
**Observed-version gating.** `MeshTopologyTracker.recordObservedVersion(_:for:)` records the highest protocol version seen from each node, and `computeRoute(…, requiringVersion:)` refuses paths through nodes not observed at that version. `docs/SOURCE_ROUTING.md` records this as the shipped pattern for a compatible rollout. The same shape applies here.
**Phased plan.**
| Phase | Behaviour |
|---|---|
| 1 | Both platforms ship the ability to **parse** v2 announces and advertise the capability, while still sending v1. Purely additive; a v2 announce from a test build is understood rather than dropped. |
| 2 | Send v1 **and** v2 announces, alternating. New clients prefer v2 and ignore the v1 from a peer they have recognised via v2; old clients see only the v1. Costs airtime, buys a no-flag-day transition. |
| 3 | Once telemetry-free judgement says adoption is sufficient, a setting (default on) suppresses v1 announces. A device that suppresses v1 becomes invisible to old clients — that is the intended cost of unlinkability, and it must be stated in the UI, not buried. |
During phases 23 a device runs **both** a stable v1 ID and a rotating v2 ID. They must never appear as two peers; a peer recognised by both paths has to collapse to one entry. The repo has the beginnings of this in `MessageRouter.peerIDAliases` and `ChatPeerIdentityCoordinator.migrateChatState`, but they were built for panic-reset rotation, not steady-state rotation.
## 6. Impact inventory
This is what an implementer must handle. Every item below was verified against the iOS source; Android should expect its own equivalents.
### 6.1 Must be fixed or the feature is broken
| Area | Why | iOS reference |
|---|---|---|
| **Handshake identity check** | `authenticatedRemoteKey` re-derives the ID from the static key and fails for every peer once IDs are independent. Replace with §4.5. | `NoiseSessionManager.swift:1106-1122`, enforced `:714-718` |
| **Announce preflight** | Same derivation check rejects any announce whose ID is not the key's hash. | `BLEAnnounceHandlingPolicy.swift:32-35` |
| **Sealed message outbox** | Queued DM plaintext is keyed by peer ID on disk and survives app kill. A recipient's rotation orphans their queue. Needs re-keying by **fingerprint** (stable) with the peer ID as a lookup hint. This is the single worst offender. | `MessageOutboxStore.swift:66`, `:704-707`, `:746` |
| **Private-media durable IDs** | `stableID` hashes sender and recipient short IDs, and the durable receipt ledger keys accept/tombstone records on it. Rotation silently breaks dedup **and user deletion tombstones**, so deleted media could be re-accepted. | `BitchatFilePacket.swift:183-231`, `BLEPrivateMediaReceiptStore.swift` |
| **Initiator tie-break** | Crossed-initiation resolution compares `localPeerID < peerID`. Both sides must reach the same verdict; a rotation mid-negotiation flips it asymmetrically. Needs a rotation-stable comparison key (fingerprint). | `NoiseSessionManager.swift:83`, `:569`, `:582`, `:603` |
| **Fingerprint-prefix lookups** | Several paths recover a peer from `fingerprint.hasPrefix(peerID)`. These silently return empty, and one of them is what lets a public message from a not-yet-registered peer be accepted at all. | `SecureIdentityStateManager.swift:437-444`; `ChatGroupCoordinator.swift:98-102`, `:432`; `FavoritesPersistenceService.swift:188-195`; `BLEService.swift:2552`, `:2823` |
| **`PeerID.routingData`** | Falls back to `toShort()`, i.e. fingerprint-derived routing bytes. | `PeerID.swift:190-202` |
### 6.2 Degrades gracefully but needs handling
| Area | Effect | iOS reference |
|---|---|---|
| **Noise sessions** | A rotation mid-session leaves an established session under the old ID. Rotation should either be deferred while sessions are live or migrate them explicitly. | `NoiseEncryptionService.swift:1010-1019` |
| **Fragment reassembly** | The reassembly key mixes the 8-byte sender ID, so a rotation mid-transfer strands every in-flight assembly until the 30 s timeout. Defer rotation while fragments are in flight. | `BLEFragmentAssemblyBuffer.swift:4-47` |
| **Dedup LRU** | Keys embed the sender ID, so the same packet crossing a rotation boundary can be reprocessed once. Bounded and probably acceptable. | `BLEReceivePipeline.swift:21` |
| **Source routes / topology** | A remote rotation invalidates cached adjacency, and a rotated relay no longer finds itself in an in-flight v2 route, falling back to flooding. Already the documented fallback. | `MeshTopologyTracker.swift`, `BLERouteForwardingPolicy.swift:62` |
| **Gossip archive** | Archived raw packets keep the old sender ID forever, and packet IDs are sender-derived, so attribution and purge-by-peer break for pre-rotation history. | `GossipMessageArchive.swift`, `PacketIdUtil.swift:8-17` |
| **Read receipts** | The wire receipt carries an 8-byte `readerID`; one sent before and matched after a rotation will not correlate. | `ReadReceipt.swift:47-64` |
### 6.3 Already safe — no work needed
Keyed by fingerprint, Noise key, or Ed25519 key rather than peer ID: the identity cache and every map in it (social identities, verified fingerprints, vouches, blocks), favourites (keyed by Noise static key), courier envelopes and recipient tags, prekey bundles, board posts, bridge drop dedup, group rosters, vouch attestations, and all geohash/location state (keyed by Nostr pubkey). Peer registry, link state, and all Noise session maps are in-memory and session-scoped.
## 7. Test vectors
These live as assertions in `PeerIDRotationTests.swift`, so they run on every build rather than rotting in a table.
All three were **cross-checked against an independent implementation written from this document alone** — Python `hmac`/`hashlib`, HKDF as extract-then-expand with an empty salt — and matched byte for byte. That is the property that matters: the spec text is sufficient to reproduce the numbers without reading the Swift.
With `noiseStaticPrivateKey = 0102…20` (bytes 1 through 32):
```
rotationSecret = HKDF-SHA256(ikm: 0102…20, salt: <empty>,
info: "bitchat-peer-rotation-v1", len: 32)
= fb82dfec0c0a2a4677beca44e2f72c80e7c5de773dd5fce6ee47af83d3c25f09
peerID(epoch=100) = HMAC-SHA256(rotationSecret,
"bitchat-peer-id-v2" || uint32be(100))[0..8]
= f7c08c528506a374
```
With a recognition key derived from a shared secret of 32 × `0x42`, sender key
32 × `0x0A`, recipient key 32 × `0x0B`, and announced ID 8 × `0xA1`:
```
recognitionKey = HKDF-SHA256(ikm: 42×32, salt: <empty>,
info: "bitchat-recognition-v1", len: 32)
tag_A→B(epoch=100) = HMAC-SHA256(recognitionKey,
uint32be(100) || 0A×32 || 0B×32 || A1×8)[0..8]
= 4568f61d61d6cbfb
tag_B→A(epoch=100) (same key, keys swapped)
= 5313c7731f629959
```
Both directions are given because their *difference* is the security property: if
an implementation produces the same value for both, it has reintroduced the
symmetric-tag flaw.
Also asserted, and worth reproducing on Android because they are the properties rather than the numbers: both sides of a real X25519 pair derive the identical tag from opposite key halves; consecutive epochs produce unrelated IDs; the ±1 epoch window matches across a boundary but two epochs out does not; the tag block is always 64 bytes regardless of how many tags it carries; a match is found regardless of slot position; and the binding message is fixed-width so a short input cannot shift a later field into an earlier field's position.
Still to be written jointly: a full `announceV2` packet as a hex blob, and the §4.5 signature over a fixed key. Whichever platform writes a vector, the other MUST reproduce it from this document rather than from the first platform's code.
## 8. Open questions for review
- **O1 — Rotation period.** One hour is a guess balancing unlinkability against churn. Shorter means less linkable and more session/route disruption; longer the reverse. Is there a period that is clearly right, or should it be a build constant both platforms pin?
- **O2 — More than `TAG_SLOTS` favourites.** What is the required convergence guarantee — "every mutual favourite sees a tag within N announces"? Should the slot rotation be deterministic from the epoch so it is testable?
- **O3 — New message type vs. announce version byte.** A distinct `MessageType` is cleanest given the decoder's required TLVs, but it consumes a type value and means two announce paths. Would a version TLV inside the existing type, with the identity TLVs made optional on both platforms first, be preferable?
- **O4 — Unsigned v2 announces.** Binding tags to the announced peer ID removes impersonation-as-any-ID, but a recorded announce can still be rebroadcast verbatim within the epoch window, so a peer can be made to look present when absent. Is "presence is a hint; nothing consequential until a handshake whose static key matches the favourite that produced the match" acceptable? The alternatives are an ephemeral per-epoch signing key with a proof-of-continuity, or a freshness nonce echoed by the recipient — both more machinery and more bytes.
- **O5 — Rotation while a session is live.** Defer rotation until sessions are idle, or rotate and migrate? Deferring is simpler and safer, but a long-lived session pins the ID for its lifetime, which weakens G1 for exactly the people who talk most.
- **O6 — Nickname timing.** Moving the nickname into the session means a stranger's name appears only after a handshake. Is that acceptable UX on both platforms, or does the peer list need a "someone nearby" placeholder state?
- **O7 — Padding is a coordinated change, not a local one.** This started as a question about decoder tolerance and turned into something firmer. `BitchatPacket.toBinaryDataForSigning()` encodes with padding enabled, so **the padding bytes are inside the signed material for every signed packet**. Changing the padding algorithm therefore changes the signed byte stream, and signatures stop verifying against any peer that has not made the identical change. Both outstanding padding fixes are affected: extending coverage beyond `noiseEncrypted`/`noiseHandshake`, and closing the gap where a frame needing more than 255 bytes of padding is emitted unpadded (payloads of 241256, 497768 and 10091792 bytes ship at exact length today). Two things to settle: whether Android's decoder also tolerates trailing bytes the way iOS's does (`guard offset <= buf.count`, plus an unpad retry), and whether padding changes ride this protocol revision or get their own capability-gated one.
## 9. Relationship to other work
Rotation is the largest item in the radio-layer metadata cluster but not the only one, and the others are cheaper:
- **Drop the neighbour list** and **randomize origin TTL** — both landed separately, since neither needs agreement: see the radio-metadata PR.
- **Extend padding beyond Noise frames, and fix the length-marker gap** — only `noiseEncrypted` and `noiseHandshake` are padded, and `pad` silently declines when the required padding exceeds the single-byte marker, so frames well below their bucket ship unpadded. **Not unilateral**: padding is inside the signed bytes, so this needs both platforms. See O7.
None of these substitute for rotation, and rotation does not substitute for them: a device with a rotating ID that still publishes its neighbour list, or that still marks its own originated packets by TTL, remains linkable.
+6 -1
View File
@@ -26,13 +26,18 @@ Signed announces can expose:
- Nickname, persistent Noise public key, and Ed25519 signing public key
- Capability flags
- A bounded set of short direct-neighbor identifiers
- A coarse rendezvous geohash when the bridge capability is enabled
Announces no longer advertise this device's direct neighbours. That TLV carried up to ten peer IDs, so a single passive receiver could reconstruct the local adjacency graph — who is standing next to whom — with no need for multiple receivers or signal-strength trilateration. It is off by default (`TransportConfig.announceIncludesDirectNeighbors`). Neighbour lists from other peers are still parsed, so a mixed network behaves sensibly. The cost is source routing: its adjacency map comes from these lists, so directed traffic falls back to flooding, which is already the documented fallback whenever a route fails.
The app does not advertise the device's assigned name. iOS manages BLE address randomization; bitchat does not attempt to create a stable MAC address.
That randomization does not deliver the unlinkability it might suggest, because the application layer publishes stable identifiers above it. The 8-byte peer ID in every packet header is the first 8 bytes of the Noise static key fingerprint, so it does not rotate; announces carry the static keys themselves; and the fixed service UUID makes any bitchat device detectable as such by a passive scanner. A receiver in radio range can therefore recognise a specific device across sessions and locations, and detect that the app is in use at all. RSSI, timing, traffic volume, and radio fingerprints remain observable as well.
Public broadcasts are originated with a TTL drawn from a range rather than always at the maximum. A fixed maximum made `ttl == default` a reliable "this device wrote it" marker to any direct listener — disclosing authorship, not merely presence. Lower draws are ambiguous between an origin and a relay, at the cost of fewer hops for some messages. Announces keep the fixed TTL, because link binding treats a maximum-TTL announce as a direct link and an announce already identifies its sender.
Payload length remains observable for most traffic: only Noise frames are padded, and the padding itself is inside the signed bytes, so widening its coverage or fixing its length-marker gap is a coordinated cross-platform change rather than a local one.
Ingress validates announce structure, sender binding, signatures, payload sizes, and freshness. Current-link Noise authentication is required before destructive courier handoff or strict directed delivery. Floods, queues, fragments, ingress work, and per-peer state are bounded.
## Private Messaging and Courier Delivery
@@ -1,158 +0,0 @@
//
// AnnounceV2Packet.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Identity-free presence announcement for rotating peer IDs.
///
/// The v1 `AnnouncementPacket` broadcasts, in cleartext, every 430 seconds: the
/// nickname, the 32-byte Noise static public key, the 32-byte Ed25519 signing
/// key, and up to ten neighbour IDs. That is a permanent device fingerprint plus
/// the local social graph, free to anyone in radio range. This carries none of
/// it only an epoch, a fixed-size block of pairwise recognition tags, and
/// capability bits.
///
/// Deliberately absent, with reasons:
/// - **Public keys**: they are the linkage. Peers learn them inside the Noise XX
/// handshake, where they are already encrypted on the wire.
/// - **Nickname**: a self-chosen, frequently reused human label. It moves into
/// the session (`AuthenticatedPeerStatePacket`).
/// - **Neighbour list**: it seeds source routing, whose documented fallback is
/// flooding. Publishing a crowd's adjacency graph is not a reasonable price
/// for routing efficiency.
///
/// **Unsigned, on purpose and not without cost.** There is no key to verify a
/// signature against without disclosing one, so this asserts only "somebody is
/// here, and here are some tags". An attacker can therefore emit noise bounded
/// by existing announce and connection rate limits but cannot impersonate a
/// specific peer, because forging a recognition tag needs one of the two private
/// keys, and cannot send anything without completing a handshake. The intended
/// posture is to treat a v2 announce as *unverified presence* and not surface it
/// until a tag matches or a handshake completes. See open question O4 in
/// `docs/PEER-ID-ROTATION.md`.
///
/// Not emitted or consumed by the shipping mesh yet.
// periphery:ignore - intentionally unreferenced by production code; nothing
// emits or consumes this type yet, and BLEService parses it only to ignore it.
// Delete this annotation when the mesh starts using it.
public struct AnnounceV2Packet: Equatable, Sendable {
/// Rotation epoch this announce was built for. Carried explicitly so a
/// receiver matches against a stated epoch instead of guessing.
public let epoch: UInt32
/// Exactly `PeerIDRotation.tagSlots * PeerIDRotation.idLength` bytes.
public let tagBlock: Data
public let capabilities: PeerCapabilities?
/// Coarse rendezvous cell, when bridging. Same semantics as v1.
public let bridgeGeohash: String?
public init(
epoch: UInt32,
tagBlock: Data,
capabilities: PeerCapabilities? = nil,
bridgeGeohash: String? = nil
) {
self.epoch = epoch
self.tagBlock = tagBlock
self.capabilities = capabilities
self.bridgeGeohash = bridgeGeohash
}
private enum TLVType: UInt8 {
case epoch = 0x01
case tagBlock = 0x02
case capabilities = 0x03
case bridgeGeohash = 0x04
}
/// Expected tag-block width. A fixed size is load-bearing: it hides how many
/// mutual favourites a device has.
public static var tagBlockLength: Int {
PeerIDRotation.tagSlots * PeerIDRotation.idLength
}
public func encode() -> Data? {
guard tagBlock.count == Self.tagBlockLength else { return nil }
var data = Data()
data.append(TLVType.epoch.rawValue)
data.append(UInt8(4))
withUnsafeBytes(of: epoch.bigEndian) { data.append(contentsOf: $0) }
data.append(TLVType.tagBlock.rawValue)
data.append(UInt8(tagBlock.count))
data.append(tagBlock)
if let capabilities {
let bytes = capabilities.encoded()
guard bytes.count <= 255 else { return nil }
data.append(TLVType.capabilities.rawValue)
data.append(UInt8(bytes.count))
data.append(bytes)
}
if let bridgeGeohash, !bridgeGeohash.isEmpty {
let bytes = Data(bridgeGeohash.utf8)
guard bytes.count <= 12 else { return nil }
data.append(TLVType.bridgeGeohash.rawValue)
data.append(UInt8(bytes.count))
data.append(bytes)
}
return data
}
public static func decode(from data: Data) -> AnnounceV2Packet? {
var epoch: UInt32?
var tagBlock: Data?
var capabilities: PeerCapabilities?
var bridgeGeohash: String?
var offset = data.startIndex
while offset < data.endIndex {
guard data.distance(from: offset, to: data.endIndex) >= 2 else { return nil }
let rawType = data[offset]
let length = Int(data[data.index(after: offset)])
let valueStart = data.index(offset, offsetBy: 2)
guard data.distance(from: valueStart, to: data.endIndex) >= length else { return nil }
let value = data.subdata(in: valueStart..<data.index(valueStart, offsetBy: length))
switch TLVType(rawValue: rawType) {
case .epoch:
guard length == 4 else { return nil }
epoch = value.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
case .tagBlock:
guard length == tagBlockLength else { return nil }
tagBlock = value
case .capabilities:
let decoded = PeerCapabilities(encoded: value)
// Canonicality check, matching AuthenticatedPeerStatePacket: a
// non-minimal encoding would let the same capability set travel
// as different bytes.
guard decoded.encoded() == value else { return nil }
capabilities = decoded
case .bridgeGeohash:
guard length <= 12, let text = String(data: value, encoding: .utf8) else { return nil }
bridgeGeohash = text
case nil:
// Unknown TLV: skip, for forward compatibility.
break
}
offset = data.index(valueStart, offsetBy: length)
}
guard let epoch, let tagBlock else { return nil }
return AnnounceV2Packet(
epoch: epoch,
tagBlock: tagBlock,
capabilities: capabilities,
bridgeGeohash: bridgeGeohash
)
}
}
@@ -15,14 +15,6 @@ public enum MessageType: UInt8 {
case message = 0x02 // Public chat message
case leave = 0x03 // "I'm leaving"
case courierEnvelope = 0x04 // Store-and-forward envelope carried by a trusted peer
/// Identity-free presence for rotating peer IDs. Carries an epoch, a fixed
/// block of pairwise recognition tags, and capabilities no nickname, no
/// public keys, no neighbour list. A separate type rather than a version of
/// `announce` because that decoder hard-requires the identity TLVs, so
/// omitting them is a parse failure rather than a graceful degrade.
/// Not emitted or consumed by the shipping mesh yet; see
/// `docs/PEER-ID-ROTATION.md`.
case announceV2 = 0x05
case requestSync = 0x21 // GCS filter-based sync request (local-only)
// Noise encryption
@@ -51,7 +43,6 @@ public enum MessageType: UInt8 {
public var description: String {
switch self {
case .announce: return "announce"
case .announceV2: return "announceV2"
case .message: return "message"
case .leave: return "leave"
case .courierEnvelope: return "courierEnvelope"
@@ -1,315 +0,0 @@
//
// PeerIDRotation.swift
// BitFoundation
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
private import CryptoKit
/// Derivations for rotating peer IDs and pairwise recognition tags.
///
/// See `docs/PEER-ID-ROTATION.md` for the design, the threat model, and the
/// open questions. This type is the executable half of that document: it is
/// deliberately pure (no I/O, no clock of its own, no dependency on the BLE
/// stack) so both platforms can agree on the numbers before anyone wires it
/// into a transport.
///
/// Nothing here is used by the shipping mesh yet.
///
/// ## Why the derivations look like this
///
/// The rotating ID comes from **private** key material. Deriving it from the
/// public key would let anyone who has ever seen that key compute every past
/// and future ID, which is worse than not rotating because it would look like
/// protection. The same mistake is live in `CourierEnvelope.recipientTag`,
/// which is keyed on the recipient's *public* static key and since that key
/// is broadcast in cleartext in every announce today, any observer in radio
/// range can compute a peer's courier tags for any day.
///
/// Recognition tags come from the X25519 shared secret between two static
/// keys, so exactly two parties can compute a given tag and an observer can
/// compute none of them.
// periphery:ignore - intentionally unreferenced by production code. These are
// the reviewable primitives for a protocol change that cannot ship until both
// platforms agree on it; wiring them into the transport is the next step, not
// this one. Delete this annotation when the mesh starts using them.
public enum PeerIDRotation {
// MARK: - Parameters
/// Seconds per rotation epoch. One hour is a starting position, not a
/// settled one: shorter is less linkable but churns sessions, routes, and
/// in-flight fragment reassembly more often. See open question O1.
public static let rotationPeriod: TimeInterval = 3600
/// Bytes of an ID or tag placed on the wire. Matches the existing 8-byte
/// header sender ID, so the packet layout is unchanged.
public static let idLength = 8
/// Fixed number of tag slots in an announce. Padding to a constant hides
/// how many mutual favourites a device has, which is itself identifying.
public static let tagSlots = 8
// MARK: - Context strings
//
// Distinct per use so a value derived for one purpose can never be
// substituted for another. `bitchat-announce-v1` is deliberately NOT reused:
// it belongs to the production-dead announce-signature helpers in
// NoiseEncryptionService, and confusing the two would be a real bug.
private static let rotationInfo = Data("bitchat-peer-rotation-v1".utf8)
private static let peerIDContext = Data("bitchat-peer-id-v2".utf8)
private static let recognitionInfo = Data("bitchat-recognition-v1".utf8)
private static let bindingContext = Data("bitchat-peerid-binding-v1".utf8)
// MARK: - Epochs
/// Epoch number for a point in time. Wall-clock derived so two devices that
/// have never met agree on the current epoch without negotiating.
public static func epoch(at date: Date) -> UInt32 {
let seconds = max(0, date.timeIntervalSince1970)
return UInt32(truncatingIfNeeded: Int(seconds / rotationPeriod))
}
/// Epochs to test when matching, oldest first.
///
/// The ±1 window absorbs clock skew and the moment either side crosses a
/// boundary, mirroring `CourierEnvelope.candidateTags`. Without it, two
/// devices a few seconds apart across a boundary would fail to recognise
/// each other for no reason a person could understand.
public static func candidateEpochs(around date: Date) -> [UInt32] {
let current = epoch(at: date)
return current == 0 ? [0, 1] : [current - 1, current, current + 1]
}
// MARK: - Rotating peer ID
/// Long-lived rotation secret for this device. Derived from the Noise
/// static **private** key, so it never leaves the device and no observer
/// can predict any ID it produces.
public static func rotationSecret(noiseStaticPrivateKey: Data) -> Data {
let derived = HKDF<SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: noiseStaticPrivateKey),
info: rotationInfo,
outputByteCount: 32
)
return derived.withUnsafeBytes { Data($0) }
}
/// This device's peer ID for a given epoch.
public static func peerID(rotationSecret: Data, epoch: UInt32) -> Data {
var message = peerIDContext
message.append(bigEndianBytes(epoch))
let mac = HMAC<SHA256>.authenticationCode(
for: message,
using: SymmetricKey(data: rotationSecret)
)
return Data(mac).prefix(idLength)
}
/// Convenience: the ID this device should be using at `date`.
public static func currentPeerID(noiseStaticPrivateKey: Data, at date: Date) -> Data {
peerID(
rotationSecret: rotationSecret(noiseStaticPrivateKey: noiseStaticPrivateKey),
epoch: epoch(at: date)
)
}
// MARK: - Pairwise recognition tags
/// Symmetric recognition key for a pair, from their X25519 shared secret.
///
/// Both sides compute the identical value from opposite key halves, which
/// is the whole point: recognition needs no round trip, and no third party
/// can derive it.
public static func recognitionKey(sharedSecret: Data) -> Data {
let derived = HKDF<SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecret),
info: recognitionInfo,
outputByteCount: 32
)
return derived.withUnsafeBytes { Data($0) }
}
/// The tag `sender` puts in its announce for `recipient` this epoch.
///
/// Three inputs beyond the epoch, each load-bearing:
///
/// - **Ordered keys make the tag directional.** An earlier draft used
/// `HMAC(K_AB, epoch)`, which is symmetric so A and B broadcast the
/// *same* 8 bytes, and an observer who spots one value in two different
/// announces learns those two devices are mutual favourites and can link
/// their rotating IDs to each other. That hands over exactly the social
/// graph this design exists to hide. Ordering the keys gives AB and BA
/// distinct values; both parties can still compute both directions,
/// because both hold both public keys.
/// - **`peerID` binds the tag to the announce carrying it.** Without it the
/// tag depends only on (pair, epoch), so an attacker could lift a tag out
/// of A's announce and replay it under an ID of their choosing; the
/// recipient would match and believe that ID is A. Binding means a lifted
/// tag is only valid alongside A's own ID, which reduces the attack from
/// impersonation-as-any-ID to replaying A's presence.
///
/// Replaying A's own announce within the epoch window remains possible
/// unsigned announces cannot prevent it. Recognition is therefore a hint
/// only, and anything consequential must wait for a completed handshake.
/// See open question O4.
public static func recognitionTag(
recognitionKey: Data,
epoch: UInt32,
senderStaticPublicKey: Data,
recipientStaticPublicKey: Data,
peerID: Data
) -> Data {
var message = bigEndianBytes(epoch)
message.append(fixedWidth(senderStaticPublicKey, 32))
message.append(fixedWidth(recipientStaticPublicKey, 32))
message.append(fixedWidth(peerID, idLength))
let mac = HMAC<SHA256>.authenticationCode(
for: message,
using: SymmetricKey(data: recognitionKey)
)
return Data(mac).prefix(idLength)
}
// MARK: - Tag block
/// Packs tags into the fixed-size announce block, padding with uniform
/// random bytes.
///
/// Random padding is indistinguishable from a real tag to anyone who cannot
/// compute the real ones, so the block discloses neither how many mutual
/// favourites a device has nor which slot belongs to whom. Tags beyond
/// `tagSlots` are dropped here; choosing *which* to carry across successive
/// announces is the caller's problem (open question O2).
public static func tagBlock(
tags: [Data],
randomBytes: (Int) -> Data = Self.secureRandomBytes
) -> Data {
var slots = tags.prefix(tagSlots).map { $0.prefix(idLength) }
// Order must carry no information, so shuffle rather than appending
// real tags at the front.
slots.shuffle()
var block = Data()
for slot in slots {
block.append(slot)
if slot.count < idLength {
block.append(Data(repeating: 0, count: idLength - slot.count))
}
}
let padding = (tagSlots - slots.count) * idLength
if padding > 0 {
block.append(randomBytes(padding))
}
return block
}
/// Splits a received block back into candidate tags.
///
/// Returns nil for a block that is not exactly `tagSlots * idLength`, so a
/// malformed announce is rejected rather than partially interpreted.
public static func tags(fromBlock block: Data) -> [Data]? {
guard block.count == tagSlots * idLength else { return nil }
return stride(from: 0, to: block.count, by: idLength).map {
block.subdata(in: (block.startIndex + $0)..<(block.startIndex + $0 + idLength))
}
}
/// Whether any slot in `block` holds the tag we expect a specific peer to
/// have put there, for an announce carrying `peerID`.
///
/// `senderStaticPublicKey` is the peer we hope sent this (so we compute the
/// direction they would use) and `recipientStaticPublicKey` is our own.
/// Passing them the other way round tests the opposite direction and will
/// not match, which is the point of making tags directional.
///
/// Comparison is constant-time per candidate, and every slot is examined
/// even after a match, so neither the presence of a match nor its slot
/// index is observable through timing.
public static func blockMatches(
_ block: Data,
recognitionKey: Data,
senderStaticPublicKey: Data,
recipientStaticPublicKey: Data,
peerID: Data,
at date: Date
) -> Bool {
guard let slots = tags(fromBlock: block) else { return false }
let expected = candidateEpochs(around: date).map {
recognitionTag(
recognitionKey: recognitionKey,
epoch: $0,
senderStaticPublicKey: senderStaticPublicKey,
recipientStaticPublicKey: recipientStaticPublicKey,
peerID: peerID
)
}
var matched = false
for slot in slots {
for candidate in expected where constantTimeEquals(slot, candidate) {
matched = true
}
}
return matched
}
// MARK: - Identity binding
/// Canonical bytes proving a rotating ID belongs to a static key.
///
/// Signed with the Ed25519 identity key and exchanged **inside** a
/// completed Noise session, this replaces the derivation check that today
/// makes peer IDs unforgeable (`peerID == SHA-256(staticKey)[0..8]`, checked
/// in the announce preflight and again at handshake completion). Once IDs
/// are independent of the key, those checks fail for every peer, so a
/// replacement has to exist before rotation can ship.
///
/// Fixed-width fields throughout: no length prefixes are needed and no two
/// distinct inputs can produce the same bytes.
public static func bindingMessage(
epoch: UInt32,
peerID: Data,
noiseStaticPublicKey: Data
) -> Data {
var out = bindingContext
out.append(bigEndianBytes(epoch))
out.append(fixedWidth(peerID, idLength))
out.append(fixedWidth(noiseStaticPublicKey, 32))
return out
}
// MARK: - Helpers
/// Padding must be indistinguishable from a real tag, so it comes from the
/// system CSPRNG via key generation rather than a general-purpose RNG.
public static func secureRandomBytes(_ count: Int) -> Data {
guard count > 0 else { return Data() }
let key = SymmetricKey(size: SymmetricKeySize(bitCount: count * 8))
return key.withUnsafeBytes { Data($0) }
}
private static func bigEndianBytes(_ value: UInt32) -> Data {
withUnsafeBytes(of: value.bigEndian) { Data($0) }
}
private static func fixedWidth(_ data: Data, _ width: Int) -> Data {
var out = data.prefix(width)
if out.count < width {
out.append(Data(repeating: 0, count: width - out.count))
}
return Data(out)
}
/// Length-independent comparison, so a match cannot be found byte by byte
/// through timing.
private static func constantTimeEquals(_ lhs: Data, _ rhs: Data) -> Bool {
guard lhs.count == rhs.count else { return false }
var difference: UInt8 = 0
for (left, right) in zip(lhs, rhs) {
difference |= left ^ right
}
return difference == 0
}
}
@@ -1,153 +0,0 @@
import Foundation
import Testing
@testable import BitFoundation
/// Wire-format tests for the identity-free announce. These are the second half
/// of the cross-platform contract: Android must encode and decode byte-identical
/// packets, so anything asserted here is a promise, not an implementation detail.
struct AnnounceV2PacketTests {
private var block: Data {
Data(repeating: 0xAB, count: AnnounceV2Packet.tagBlockLength)
}
@Test func typeValueIsStable() {
// Changing this breaks every deployed decoder. 0x05 was free; 0x01-0x04,
// 0x10-0x11 and 0x20-0x29 were already taken.
#expect(MessageType.announceV2.rawValue == 0x05)
#expect(MessageType(rawValue: 0x05) == .announceV2)
#expect(MessageType.announceV2.description == "announceV2")
}
@Test func tagBlockIsSixtyFourBytes() {
#expect(AnnounceV2Packet.tagBlockLength == 64)
}
@Test func roundTripsWithEveryField() throws {
let packet = AnnounceV2Packet(
epoch: 495_555,
tagBlock: block,
capabilities: [.bridge, .prekeys],
bridgeGeohash: "u4pruy"
)
let encoded = try #require(packet.encode())
let decoded = try #require(AnnounceV2Packet.decode(from: encoded))
#expect(decoded == packet)
}
@Test func roundTripsWithOnlyRequiredFields() throws {
let packet = AnnounceV2Packet(epoch: 0, tagBlock: block)
let encoded = try #require(packet.encode())
let decoded = try #require(AnnounceV2Packet.decode(from: encoded))
#expect(decoded == packet)
#expect(decoded.capabilities == nil)
#expect(decoded.bridgeGeohash == nil)
}
@Test func epochIsBigEndianOnTheWire() throws {
let encoded = try #require(AnnounceV2Packet(epoch: 0x0102_0304, tagBlock: block).encode())
// TLV 0x01, length 4, then the epoch most-significant byte first.
#expect(Array(encoded.prefix(6)) == [0x01, 0x04, 0x01, 0x02, 0x03, 0x04])
}
/// The whole point of the format: none of the identifying v1 fields appear.
@Test func encodingCarriesNoIdentity() throws {
let noiseKey = Data(repeating: 0x11, count: 32)
let signingKey = Data(repeating: 0x22, count: 32)
let nickname = Data("alice".utf8)
let encoded = try #require(
AnnounceV2Packet(
epoch: 100,
tagBlock: block,
capabilities: [.bridge],
bridgeGeohash: "u4pruy"
).encode()
)
#expect(!encoded.contains(noiseKey))
#expect(!encoded.contains(signingKey))
#expect(encoded.range(of: nickname) == nil)
}
@Test func encodingIsSmallerThanAV1Announce() throws {
let v2 = try #require(
AnnounceV2Packet(epoch: 100, tagBlock: block, capabilities: [.bridge]).encode()
)
// v1 with a 10-byte nickname and a full neighbour list, before its
// 64-byte signature: nickname 12 + noise 34 + signing 34 + neighbours 82
// + capabilities 3.
let v1PayloadEstimate = 12 + 34 + 34 + 82 + 3
#expect(v2.count < v1PayloadEstimate)
}
// MARK: - Rejection
@Test func encodeRejectsAWrongWidthTagBlock() {
// A short block would disclose the favourite count, so it must never go
// on the wire.
#expect(AnnounceV2Packet(epoch: 1, tagBlock: Data(repeating: 0, count: 63)).encode() == nil)
#expect(AnnounceV2Packet(epoch: 1, tagBlock: Data(repeating: 0, count: 65)).encode() == nil)
#expect(AnnounceV2Packet(epoch: 1, tagBlock: Data()).encode() == nil)
}
@Test func encodeRejectsAnOversizedGeohash() {
#expect(AnnounceV2Packet(
epoch: 1,
tagBlock: block,
bridgeGeohash: String(repeating: "u", count: 13)
).encode() == nil)
}
@Test func decodeRequiresEpochAndTagBlock() throws {
// Capabilities alone is not a valid announce.
var onlyCapabilities = Data([0x03, 0x01])
onlyCapabilities.append(PeerCapabilities([.bridge]).encoded())
#expect(AnnounceV2Packet.decode(from: onlyCapabilities) == nil)
// Epoch without a tag block is not either.
let onlyEpoch = Data([0x01, 0x04, 0x00, 0x00, 0x00, 0x64])
#expect(AnnounceV2Packet.decode(from: onlyEpoch) == nil)
}
@Test func decodeRejectsTruncatedAndMalformedInput() {
#expect(AnnounceV2Packet.decode(from: Data()) == nil)
// Declares 4 bytes, supplies 2.
#expect(AnnounceV2Packet.decode(from: Data([0x01, 0x04, 0x00, 0x00])) == nil)
// Dangling type byte with no length.
#expect(AnnounceV2Packet.decode(from: Data([0x01])) == nil)
// Wrong epoch width.
#expect(AnnounceV2Packet.decode(from: Data([0x01, 0x02, 0x00, 0x64])) == nil)
}
@Test func decodeRejectsAWrongWidthTagBlock() {
var data = Data([0x01, 0x04, 0x00, 0x00, 0x00, 0x64])
data.append(0x02)
data.append(UInt8(63))
data.append(Data(repeating: 0xAB, count: 63))
#expect(AnnounceV2Packet.decode(from: data) == nil)
}
@Test func decodeRejectsNonCanonicalCapabilities() throws {
// Same capability set, non-minimal encoding: it must not be accepted, or
// one set could travel as several distinct byte strings.
var data = Data([0x01, 0x04, 0x00, 0x00, 0x00, 0x64])
data.append(0x02)
data.append(UInt8(AnnounceV2Packet.tagBlockLength))
data.append(block)
data.append(0x03)
data.append(UInt8(3))
data.append(Data([0x80, 0x00, 0x00])) // trailing zero bytes are non-minimal
#expect(AnnounceV2Packet.decode(from: data) == nil)
}
@Test func unknownTLVsAreSkippedForForwardCompatibility() throws {
var data = try #require(AnnounceV2Packet(epoch: 100, tagBlock: block).encode())
data.append(0x7F) // a type this build has never heard of
data.append(UInt8(3))
data.append(Data([0x01, 0x02, 0x03]))
let decoded = try #require(AnnounceV2Packet.decode(from: data))
#expect(decoded.epoch == 100)
#expect(decoded.tagBlock == block)
}
}
@@ -1,408 +0,0 @@
import Foundation
import Testing
import CryptoKit
@testable import BitFoundation
/// Executable test vectors for peer ID rotation.
///
/// These are the numbers the Android implementation must reproduce. Two rules
/// for keeping them useful:
///
/// 1. **Reproduce them from `docs/PEER-ID-ROTATION.md`, not from this code.**
/// Deriving the expected values by reading the other platform's
/// implementation proves only that both share a bug.
/// 2. **If a derivation changes, the hex here changes too, deliberately.** A
/// vector that gets "fixed" to match new behavior has stopped being a vector.
///
/// The three `VECTOR:` values below were cross-checked against an independent
/// HKDF/HMAC implementation written from the specification alone (Python
/// `hmac`/`hashlib`, empty salt, extract-then-expand) and matched byte for byte.
/// So the spec text is sufficient to reproduce them without reading this code
/// which is the property Android needs.
struct PeerIDRotationTests {
// A fixed, obviously-fake private key so the vectors are stable.
private let staticPrivateA = Data((0..<32).map { UInt8($0 + 1) }) // 01..20
private let staticPrivateB = Data((0..<32).map { UInt8(0xA0 &+ $0) }) // a0..bf
private func hex(_ data: Data) -> String {
data.map { String(format: "%02x", $0) }.joined()
}
// MARK: - Epochs
@Test func epochIsWallClockDivision() {
#expect(PeerIDRotation.rotationPeriod == 3600)
#expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 0)) == 0)
#expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 3599)) == 0)
#expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 3600)) == 1)
// 2026-07-26T00:00:00Z
#expect(PeerIDRotation.epoch(at: Date(timeIntervalSince1970: 1_784_000_000)) == 495_555)
}
@Test func candidateEpochsCoverTheBoundaryBothWays() {
// Two devices seconds apart across a boundary must still recognise each
// other, so the window spans the neighbouring epochs.
let date = Date(timeIntervalSince1970: 3600 * 100)
#expect(PeerIDRotation.candidateEpochs(around: date) == [99, 100, 101])
}
@Test func candidateEpochsDoNotUnderflowAtTheOrigin() {
// UInt32 underflow here would produce 4294967295 and break matching.
#expect(PeerIDRotation.candidateEpochs(around: Date(timeIntervalSince1970: 0)) == [0, 1])
}
// MARK: - Rotating peer ID
@Test func rotationSecretIsStableForAKey() {
let first = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA)
let second = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA)
#expect(first == second)
#expect(first.count == 32)
// VECTOR: HKDF-SHA256(ikm: 01..20, salt: empty, info: "bitchat-peer-rotation-v1", 32)
#expect(hex(first) == "fb82dfec0c0a2a4677beca44e2f72c80e7c5de773dd5fce6ee47af83d3c25f09")
}
@Test func peerIDIsEightBytesAndEpochDependent() {
let secret = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA)
let a = PeerIDRotation.peerID(rotationSecret: secret, epoch: 100)
let b = PeerIDRotation.peerID(rotationSecret: secret, epoch: 101)
#expect(a.count == PeerIDRotation.idLength)
#expect(b.count == PeerIDRotation.idLength)
// VECTOR: HMAC-SHA256(rotationSecret, "bitchat-peer-id-v2" || uint32be(100))[0..8]
#expect(hex(a) == "f7c08c528506a374")
// The whole point: consecutive epochs are unrelated to an observer.
#expect(a != b)
// Deterministic within an epoch, so a restart keeps the same ID.
#expect(a == PeerIDRotation.peerID(rotationSecret: secret, epoch: 100))
}
@Test func peerIDDiffersBetweenDevices() {
let secretA = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA)
let secretB = PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateB)
#expect(PeerIDRotation.peerID(rotationSecret: secretA, epoch: 100)
!= PeerIDRotation.peerID(rotationSecret: secretB, epoch: 100))
}
@Test func currentPeerIDMatchesTheExplicitEpochForm() {
let date = Date(timeIntervalSince1970: 3600 * 100 + 17)
let viaConvenience = PeerIDRotation.currentPeerID(
noiseStaticPrivateKey: staticPrivateA,
at: date
)
let viaParts = PeerIDRotation.peerID(
rotationSecret: PeerIDRotation.rotationSecret(noiseStaticPrivateKey: staticPrivateA),
epoch: 100
)
#expect(viaConvenience == viaParts)
}
// MARK: - Recognition tags
private var pubA: Data { Data(repeating: 0x0A, count: 32) }
private var pubB: Data { Data(repeating: 0x0B, count: 32) }
private var idA: Data { Data(repeating: 0xA1, count: 8) }
/// The property that makes handshake-free recognition possible: both sides
/// reach the same tag from opposite halves of the key pair.
@Test func bothSidesDeriveTheSameRecognitionTag() throws {
let privA = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: staticPrivateA)
let privB = try Curve25519.KeyAgreement.PrivateKey(rawRepresentation: staticPrivateB)
let sharedFromA = try privA.sharedSecretFromKeyAgreement(with: privB.publicKey)
let sharedFromB = try privB.sharedSecretFromKeyAgreement(with: privA.publicKey)
let rawA = sharedFromA.withUnsafeBytes { Data($0) }
let rawB = sharedFromB.withUnsafeBytes { Data($0) }
#expect(rawA == rawB)
let keyA = PeerIDRotation.recognitionKey(sharedSecret: rawA)
let keyB = PeerIDRotation.recognitionKey(sharedSecret: rawB)
#expect(keyA == keyB)
// A emits its A->B tag; B computes the same value to look for it.
let emitted = PeerIDRotation.recognitionTag(
recognitionKey: keyA, epoch: 100,
senderStaticPublicKey: privA.publicKey.rawRepresentation,
recipientStaticPublicKey: privB.publicKey.rawRepresentation,
peerID: idA
)
let expected = PeerIDRotation.recognitionTag(
recognitionKey: keyB, epoch: 100,
senderStaticPublicKey: privA.publicKey.rawRepresentation,
recipientStaticPublicKey: privB.publicKey.rawRepresentation,
peerID: idA
)
#expect(emitted == expected)
#expect(emitted.count == PeerIDRotation.idLength)
}
/// Regression, Codex #1487 P1: a symmetric tag means A and B broadcast the
/// identical 8 bytes, so an observer who sees one value in two announces
/// learns those two are mutual favourites and can link their rotating IDs.
/// Tags must therefore differ by direction.
@Test func recognitionTagsAreDirectional() {
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x42, count: 32))
let aToB = PeerIDRotation.recognitionTag(
recognitionKey: key, epoch: 100,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
)
let bToA = PeerIDRotation.recognitionTag(
recognitionKey: key, epoch: 100,
senderStaticPublicKey: pubB, recipientStaticPublicKey: pubA, peerID: idA
)
#expect(aToB != bToA)
}
/// Regression, Codex #1487 P1: without the peer ID in the MAC, a tag lifted
/// from someone's announce could be replayed under an attacker-chosen ID and
/// the recipient would accept that ID as the favourite.
@Test func recognitionTagIsBoundToTheAnnouncedPeerID() {
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x42, count: 32))
let real = PeerIDRotation.recognitionTag(
recognitionKey: key, epoch: 100,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
)
let underAttackerID = PeerIDRotation.recognitionTag(
recognitionKey: key, epoch: 100,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
peerID: Data(repeating: 0xFF, count: 8)
)
#expect(real != underAttackerID)
// And the lifted tag must not verify against the attacker's ID.
let block = PeerIDRotation.tagBlock(tags: [real])
#expect(!PeerIDRotation.blockMatches(
block, recognitionKey: key,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
peerID: Data(repeating: 0xFF, count: 8),
at: Date(timeIntervalSince1970: 3600 * 100)
))
}
@Test func recognitionTagRotatesWithTheEpoch() {
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x42, count: 32))
let now = PeerIDRotation.recognitionTag(
recognitionKey: key, epoch: 100,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
)
let next = PeerIDRotation.recognitionTag(
recognitionKey: key, epoch: 101,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
)
#expect(now != next)
// VECTOR: HMAC-SHA256(HKDF(ikm: 0x42*32, info: "bitchat-recognition-v1"),
// uint32be(100) || 0x0A*32 || 0x0B*32 || 0xA1*8)[0..8]
#expect(hex(now) == "4568f61d61d6cbfb")
}
@Test func aThirdPartyCannotDeriveAPairsTag() {
// An observer holding a *different* shared secret gets a different tag,
// which is what stops it from tracking the pair.
let pair = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x01, count: 32))
let other = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x02, count: 32))
#expect(PeerIDRotation.recognitionTag(
recognitionKey: pair, epoch: 7,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
) != PeerIDRotation.recognitionTag(
recognitionKey: other, epoch: 7,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
))
}
// MARK: - Tag block
@Test func tagBlockIsAlwaysFullWidth() {
let expected = PeerIDRotation.tagSlots * PeerIDRotation.idLength
for count in 0...PeerIDRotation.tagSlots {
let tags = (0..<count).map { Data(repeating: UInt8($0 + 1), count: 8) }
#expect(PeerIDRotation.tagBlock(tags: tags).count == expected)
}
}
/// A device with one favourite and a device with six must be
/// indistinguishable from the block, or the block leaks social-graph size.
@Test func tagBlockHidesHowManyFavouritesThereAre() {
let one = PeerIDRotation.tagBlock(tags: [Data(repeating: 0xAA, count: 8)])
let six = PeerIDRotation.tagBlock(
tags: (1...6).map { Data(repeating: UInt8($0), count: 8) }
)
#expect(one.count == six.count)
}
@Test func tagBlockDropsOverflowRatherThanGrowing() {
let tags = (1...(PeerIDRotation.tagSlots + 5)).map { Data(repeating: UInt8($0), count: 8) }
#expect(PeerIDRotation.tagBlock(tags: tags).count == PeerIDRotation.tagSlots * 8)
}
@Test func padOnlyBlockUsesFreshRandomnessEachTime() {
// Repeated identical padding would make an empty block recognisable.
let first = PeerIDRotation.tagBlock(tags: [])
let second = PeerIDRotation.tagBlock(tags: [])
#expect(first != second)
}
@Test func tagsRoundTripThroughTheBlock() throws {
let real = Data(repeating: 0xC3, count: 8)
let block = PeerIDRotation.tagBlock(
tags: [real],
randomBytes: { Data(repeating: 0x00, count: $0) }
)
let slots = try #require(PeerIDRotation.tags(fromBlock: block))
#expect(slots.count == PeerIDRotation.tagSlots)
#expect(slots.contains(real))
}
@Test func malformedBlockIsRejectedRatherThanPartiallyRead() {
#expect(PeerIDRotation.tags(fromBlock: Data()) == nil)
#expect(PeerIDRotation.tags(fromBlock: Data(repeating: 0, count: 7)) == nil)
#expect(PeerIDRotation.tags(fromBlock: Data(repeating: 0, count: 65)) == nil)
}
// MARK: - Matching
private func matchFixture() -> (key: Data, tag: Data, date: Date) {
let date = Date(timeIntervalSince1970: 3600 * 100)
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x77, count: 32))
let tag = PeerIDRotation.recognitionTag(
recognitionKey: key,
epoch: PeerIDRotation.epoch(at: date),
senderStaticPublicKey: pubA,
recipientStaticPublicKey: pubB,
peerID: idA
)
return (key, tag, date)
}
@Test func blockMatchesRecogniseAPeerAnywhereInTheBlock() {
let (key, tag, date) = matchFixture()
// Slot order must not matter, so assert across many shuffles.
for _ in 0..<20 {
let block = PeerIDRotation.tagBlock(tags: [tag])
#expect(PeerIDRotation.blockMatches(
block, recognitionKey: key,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
peerID: idA, at: date
))
}
}
/// Testing the wrong direction must fail, or the directional fix would be
/// cosmetic.
@Test func blockDoesNotMatchTheOppositeDirection() {
let (key, tag, date) = matchFixture()
let block = PeerIDRotation.tagBlock(tags: [tag])
#expect(!PeerIDRotation.blockMatches(
block, recognitionKey: key,
senderStaticPublicKey: pubB, recipientStaticPublicKey: pubA,
peerID: idA, at: date
))
}
@Test func blockMatchesToleratesTheEpochBoundary() {
let date = Date(timeIntervalSince1970: 3600 * 100)
let key = PeerIDRotation.recognitionKey(sharedSecret: Data(repeating: 0x11, count: 32))
func tag(epoch: UInt32) -> Data {
PeerIDRotation.recognitionTag(
recognitionKey: key, epoch: epoch,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB, peerID: idA
)
}
func matches(_ candidate: Data) -> Bool {
PeerIDRotation.blockMatches(
PeerIDRotation.tagBlock(tags: [candidate]), recognitionKey: key,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
peerID: idA, at: date
)
}
// A peer whose clock has already ticked over still matches.
#expect(matches(tag(epoch: 101)))
// Two epochs out is outside the window and must not.
#expect(!matches(tag(epoch: 98)))
}
@Test func randomBlockDoesNotMatch() {
let (key, _, date) = matchFixture()
#expect(!PeerIDRotation.blockMatches(
PeerIDRotation.tagBlock(tags: []), recognitionKey: key,
senderStaticPublicKey: pubA, recipientStaticPublicKey: pubB,
peerID: idA, at: date
))
}
// MARK: - Identity binding
@Test func bindingMessageIsFixedWidthAndContextSeparated() {
let message = PeerIDRotation.bindingMessage(
epoch: 100,
peerID: Data(repeating: 0xAB, count: 8),
noiseStaticPublicKey: Data(repeating: 0xCD, count: 32)
)
let context = Data("bitchat-peerid-binding-v1".utf8)
#expect(message.count == context.count + 4 + 8 + 32)
#expect(message.starts(with: context))
// Must not collide with the production-dead announce-signature helpers,
// which use "bitchat-announce-v1".
#expect(!message.starts(with: Data("bitchat-announce-v1".utf8)))
}
@Test func bindingMessagePadsShortInputsRatherThanShifting() {
// Fixed-width fields mean a short ID cannot shift the key into the ID's
// position and produce a message that verifies for the wrong pairing.
let short = PeerIDRotation.bindingMessage(
epoch: 1,
peerID: Data([0x01]),
noiseStaticPublicKey: Data([0x02])
)
let padded = PeerIDRotation.bindingMessage(
epoch: 1,
peerID: Data([0x01]) + Data(repeating: 0, count: 7),
noiseStaticPublicKey: Data([0x02]) + Data(repeating: 0, count: 31)
)
#expect(short == padded)
}
@Test func bindingMessageChangesWithEveryField() {
let base = PeerIDRotation.bindingMessage(
epoch: 1,
peerID: Data(repeating: 0x01, count: 8),
noiseStaticPublicKey: Data(repeating: 0x02, count: 32)
)
#expect(base != PeerIDRotation.bindingMessage(
epoch: 2,
peerID: Data(repeating: 0x01, count: 8),
noiseStaticPublicKey: Data(repeating: 0x02, count: 32)
))
#expect(base != PeerIDRotation.bindingMessage(
epoch: 1,
peerID: Data(repeating: 0x03, count: 8),
noiseStaticPublicKey: Data(repeating: 0x02, count: 32)
))
#expect(base != PeerIDRotation.bindingMessage(
epoch: 1,
peerID: Data(repeating: 0x01, count: 8),
noiseStaticPublicKey: Data(repeating: 0x04, count: 32)
))
}
@Test func bindingMessageVerifiesUnderTheIdentityKey() throws {
let signing = Curve25519.Signing.PrivateKey()
let message = PeerIDRotation.bindingMessage(
epoch: 100,
peerID: Data(repeating: 0xAB, count: 8),
noiseStaticPublicKey: Data(repeating: 0xCD, count: 32)
)
let signature = try signing.signature(for: message)
#expect(signing.publicKey.isValidSignature(signature, for: message))
// A different epoch must not verify: replaying a binding into a later
// epoch is exactly what this prevents.
let other = PeerIDRotation.bindingMessage(
epoch: 101,
peerID: Data(repeating: 0xAB, count: 8),
noiseStaticPublicKey: Data(repeating: 0xCD, count: 32)
)
#expect(!signing.publicKey.isValidSignature(signature, for: other))
}
}