How BitChat Manages Peer ID Rotation to Enhance User Privacy
BitChat prevents long-term tracking by rotating an 8-byte peer identifier every hour, cryptographically derived from a static Noise private key, with known contacts finding each other through pairwise recognition tags.
BitChat is a privacy-focused, peer-to-peer messaging application that uses Bluetooth Low Energy (BLE) for local communication. Because BLE packets are broadcast in the open, a static identifier would allow adversaries to track users over time. The permissionlesstech/bitchat repository implements a sophisticated peer ID rotation mechanism that breaks linkability while preserving discoverability for mutual contacts.
Epoch-Based Rotation Schedule
The rotation follows a time-based epoch system defined in docs/PEER-ID-ROTATION.md. Each device independently calculates the current epoch without network synchronization:
epoch = ⌊unixTime / ROTATION_PERIOD⌋
The ROTATION_PERIOD defaults to one hour but is configurable. To handle clock skew and boundary crossings, BitChat accepts a ±1 epoch window (candidateEpochs), allowing devices with slightly divergent clocks to recognize each other.
This design eliminates coordination overhead—no central server or peer negotiation is required. Every device arrives at the same epoch value deterministically.
Deriving the Rotation Secret
In localPackages/BitFoundation/Sources/BitFoundation/PeerIDRotation.swift, the rotation system anchors to the device's long-term Noise static private key. From this key, BitChat derives a permanent rotation secret K_rot using HKDF-SHA256:
let rotationSecret = HKDF.derive(
inputKeyingMaterial: noiseStaticPrivateKey,
salt: nil,
info: "bitchat-peer-rotation-v1".data(using: .utf8)
)
This secret never leaves the device and never rotates. Compromise of a single epoch's peer ID does not expose past or future identifiers, as each requires knowledge of K_rot to compute.
Generating the Rotating Peer ID
For each epoch, BitChat generates a new 8-byte peer ID through HMAC-SHA256:
// Compute the current rotating peer ID
let privateKey = ... // Noise static private key (Data)
let peerID = PeerIDRotation.currentPeerID(
noiseStaticPrivateKey: privateKey,
at: Date()
)
The HMAC operates over the concatenation of "bitchat-peer-id-v2" and the epoch integer, keyed with K_rot. Only the first 8 bytes are retained. Because K_rot is private, observers cannot:
- Predict future peer IDs
- Link past peer IDs to the same device
- Forge valid peer IDs for any epoch
Binding the Rotating ID to the Static Key
The original BitChat protocol bound identity directly to the static Noise key (peerID == SHA-256(staticKey)[0..8]). Rotation breaks this invariant. To solve this, PeerIDRotation.swift implements a cryptographic binding message signed with the device's Ed25519 signing key:
The signed payload contains:
- The current epoch
- The rotating peer ID
- The static Noise public key
This binding proves ownership of the peer ID without exposing the static keys in BLE broadcasts. The verification occurs inside a completed Noise session, ensuring the proof is only readable by the intended recipient.
Recognition Tags for Mutual Favourites
Peers that have previously established trust need a way to recognize each other across rotations. BitChat solves this with pairwise recognition tags:
// Derive a pairwise recognition tag for a mutual favourite
let sharedSecret = ... // X25519(shared secret) between the two devices
let recognitionKey = PeerIDRotation.recognitionKey(sharedSecret: sharedSecret)
let tag = PeerIDRotation.recognitionTag(
recognitionKey: recognitionKey,
epoch: PeerIDRotation.epoch(at: Date()),
senderStaticPublicKey: myPublicKey,
recipientStaticPublicKey: favouritePublicKey,
peerID: peerID
)
The tag computation uses HMAC-SHA256 keyed with K_AB, the X25519 shared secret between two static keys. Inputs include the epoch, both public keys, and the rotating peer ID. This makes tags directional and epoch-bound—only the two parties can generate or verify a specific tag.
Tag Block Padding for Metadata Protection
The announceV2 packet carries recognition tags in a fixed-size block defined in PeerIDRotation.swift:
- 8 slots × 8 bytes = 64 bytes total
- Real tags placed in random positions
- Empty slots filled with uniform random bytes
This constant-size structure prevents metadata leakage about how many mutual favourites a user has. An observer cannot distinguish between "no contacts nearby" and "eight contacts nearby" from packet inspection alone.
The tagBlock(tags:) function implements this padding automatically:
// Build the padded tag block for the announceV2 packet
let tagBlock = PeerIDRotation.tagBlock(tags: [tag])
Compatibility and Packet Evolution
BitChat introduces the new packet type through AnnounceV2Packet.swift:
| Packet Type | Value | Purpose |
|---|---|---|
announce |
0x01 |
Legacy, static peer ID (deprecated) |
announceV2 |
0x02 |
Rotating peer ID with tag block |
During rollout, devices broadcast both packet types to maintain interoperability with older clients. The BLEAnnounceHandlingPolicy.swift file implements validation logic that accepts both formats, with NoiseSessionManager.swift handling the upgraded handshake that uses signed binding messages.
Implementation Files and Key Functions
| File | Key Functions | Responsibility |
|---|---|---|
PeerIDRotation.swift |
currentPeerID(noiseStaticPrivateKey:at:), recognitionTag(recognitionKey:epoch:senderStaticPublicKey:recipientStaticPublicKey:peerID:), tagBlock(tags:) |
Core rotation logic, tag generation, block padding |
AnnounceV2Packet.swift |
Packet encoding/decoding for 0x02 type |
BLE advertisement structure |
BLEAnnounceHandlingPolicy.swift |
Pre-flight validation | Incoming packet filtering |
NoiseSessionManager.swift |
Handshake with signed binding | Session establishment |
PEER-ID-ROTATION.md |
Protocol specification | Design documentation and threat model |
Summary
- Time-based epochs enable synchronized rotation without coordination
- HKDF-derived secrets anchor rotation to long-term keys without exposing them
- HMAC-based peer IDs ensure unlinkability and unforgeability across epochs
- Ed25519 binding messages prove ID ownership without static key exposure
- Pairwise recognition tags let trusted contacts reconnect despite rotation
- Fixed-size tag blocks prevent metadata leakage about contact counts
- Dual packet broadcast ensures backward compatibility during deployment
Frequently Asked Questions
How often does BitChat rotate peer IDs?
BitChat rotates peer IDs every hour by default, based on unix time divided by a configurable ROTATION_PERIOD. The system accepts a ±1 epoch window to accommodate clock skew between devices, so effective recognition spans approximately 2–3 hours depending on boundary alignment.
Can an observer link multiple peer IDs to the same device?
No. Peer IDs are cryptographically unlinkable to passive observers. Each epoch's ID requires knowledge of K_rot, which is derived from the private Noise key via HKDF-SHA256. Without this secret, computing past or future IDs is computationally infeasible, and the HMAC construction provides no algebraic structure to exploit.
How do mutual contacts find each other after rotation?
Mutual contacts use pairwise recognition tags derived from their shared X25519 secret. Each tag is epoch-specific and directional, computed with HMAC-SHA256 over the epoch, both public keys, and the current peer ID. Only the two parties can generate matching tags, allowing recognition without revealing identity to third parties.
What prevents metadata leakage about a user's number of contacts?
The tag block in announceV2 packets is fixed at 64 bytes (8 slots × 8 bytes) regardless of how many recognition tags are present. Real tags occupy random positions, and empty slots contain indistinguishable random padding. This constant-size design prevents observers from inferring contact count from packet inspection.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →