How Peer ID Rotation in BitChat Works: Identity Management and Privacy Considerations

Peer ID rotation in BitChat replaces static 8‑byte identifiers derived from SHA‑256 hashes of Noise public keys with time‑based rotating IDs computed via HKDF‑SHA256, enabling unlinkability while preserving mutual recognition through pairwise tags and cryptographically signed binding messages.

BitChat’s original Bluetooth Low Energy (BLE) protocol exposes devices to long‑term tracking by binding the peer ID to the static hash of a Noise key pair (peerID == SHA‑256(noiseStaticPublicKey)[0…8]). The permissionlesstech/bitchat repository implements a privacy‑first rotation scheme that cryptographically severs this link, preventing passive observers from correlating broadcasts across time while maintaining seamless discovery for trusted contacts.

What Is Peer ID Rotation in BitChat?

In the legacy protocol, every device broadcasts an immutable 8‑byte identifier for its entire lifetime. This allows adversaries to track protest attendees or users across physical locations by passively sniffing BLE advertisements. Peer ID rotation addresses this by deriving temporary identifiers from the private Noise key using a time‑based epoch system.

The rotation scheme is governed by six design goals codified in docs/PEER-ID-ROTATION.md:

  • G1 – Unlinkability: Prevent passive listeners from correlating observations of the same device across rotation periods.
  • G2 – Confidentiality: Eliminate broadcast leakage of long‑term public keys or nicknames.
  • G3 – Seamless Recognition: Allow mutual favourites to auto‑recognise each other without repeating handshakes.
  • G4 – Discoverability: Preserve the ability for strangers to discover and handshake with devices.
  • G5 – Mixed‑Version Interoperability: Support gradual rollout without flag days.
  • G6 – No Extra Spoofing Risk: Maintain existing security baselines against identity spoofing.

Cryptographic Primitives for Rotating Identities

All cryptographic operations reside in localPackages/BitFoundation/Sources/BitFoundation/PeerIDRotation.swift as pure, deterministic Swift code with no I/O dependencies.

Epoch Calculation and Windowing

Rotating IDs change every rotation period (default 1 hour). The system calculates the current epoch as floor(unixTime / ROTATION_PERIOD) and uses a ±1 window (candidateEpochs(around:)) to absorb clock skew and boundary crossings.

let epoch = PeerIDRotation.epoch(at: Date())
let candidates = PeerIDRotation.candidateEpochs(around: Date()) // [epoch-1, epoch, epoch+1]

Generating the Rotating Peer ID

Rather than hashing the public key, the peer ID derives from the private Noise key using HKDF‑SHA256 with info string "bitchat-peer-rotation-v1". An HMAC‑SHA256 with context "bitchat-peer-id-v2" and the epoch produces the final 8‑byte identifier:

let rotationSecret = PeerIDRotation.rotationSecret(noiseStaticPrivateKey)
let peerID = PeerIDRotation.peerID(
    rotationSecret: rotationSecret,
    epoch: currentEpoch)  // 8 bytes

Pairwise Recognition Tags for Mutual Favourites

Because rotating IDs are unlinkable across epochs, mutual favourites cannot rely on the peer ID alone for recognition. Instead, they compute directional recognition tags using a shared X25519 secret.

The Recognition Key and Tag

For each mutual favourite pair, the system derives a recognitionKey via HKDF‑SHA256 over the X25519 shared secret (info "bitchat-recognition-v1"). The tag binds the current peer ID, epoch, and ordered public keys:

let tag = PeerIDRotation.recognitionTag(
    recognitionKey: PeerIDRotation.recognitionKey(sharedSecret: sharedSecret),
    epoch: epoch,
    senderStaticPublicKey: myNoisePublic,
    recipientStaticPublicKey: friendNoisePublic,
    peerID: myPeerID)   // 8‑byte Data

Tag Block Structure

Announce packets carry up to eight recognition tags in a fixed 64‑byte tag block (TAG_SLOTS = 8). Real tags are shuffled and padded with random bytes so the block size reveals nothing about the number of mutual favourites.

let matches = PeerIDRotation.blockMatches(
    tagBlock,
    recognitionKey: recognitionKey,
    senderStaticPublicKey: senderNoisePub,
    recipientStaticPublicKey: myNoisePub,
    peerID: senderPeerID,
    at: Date())

Binding Messages and Handshake Verification

Since rotating peer IDs are no longer derivable from static Noise keys, legacy pre‑flight checks (derivedPeerID == peerID) fail. The protocol replaces these with an explicit signed binding transmitted during the Noise handshake.

Binding Message Format

The binding message concatenates a version string, epoch, rotating peer ID, and the static Noise public key:


"bitchat-peerid-binding-v1" || uint32be(epoch) || peerID || noiseStaticPublicKey

Verification Flow

Inside NoiseSessionManager.swift (lines 1106‑1122), the peer signs this binding with its Ed25519 identity key after completing the Noise handshake. Verification confirms that the rotating ID presented in the BLE advertisement legitimately belongs to the static Noise key used for the session, preventing spoofing attacks.

Integration Points in the BitChat Codebase

Implementing peer ID rotation requires modifications across the networking and storage layers:

  • BLEAnnounceHandlingPolicy.swift: Replaces the static derivation check at lines 32‑35 with announceV2 = 0x2C parsing and tag‑matching logic.
  • NoiseSessionManager.swift: Defers identity verification until the binding message signature is validated post‑handshake.
  • MessageOutboxStore.swift: Re‑keys queued messages by stable fingerprint rather than rotating peer ID to prevent data loss during rotation events.
  • BLEFragmentAssemblyBuffer.swift: Ensures fragment reassembly completes before rotation or uses consistent IDs for in‑flight packets to avoid stranding.
  • PeerCapabilities.swift: Defines capability bit 14 (peerIDRotation) to signal v2 support.
  • MeshTopologyTracker.swift: Records observed protocol versions and applies version‑gated routing logic in recordObservedVersion(_:for:).

Rollout Strategy and Compatibility

The migration proceeds through three phases to maintain network connectivity:

  1. Parse‑Only Phase: Clients parse announceV2 and advertise the capability bit but continue sending v1 announces.
  2. Dual‑Announce Phase: Devices broadcast both v1 and v2 announces (alternating). New clients prefer v2 handshakes while legacy clients remain compatible.
  3. v1‑Sunset Phase: After sufficient telemetry confirms adoption, v1 announces are suppressed by default. Pure v2 devices become invisible to old clients—a deliberate privacy trade‑off.

Code Examples

Generate the Current Rotating Peer ID

import BitFoundation

let noisePrivKey: Data = // 32‑byte private key from keychain
let now = Date()

let peerID = PeerIDRotation.currentPeerID(
    noiseStaticPrivateKey: noisePrivKey,
    at: now)          // Returns 8‑byte Data

Create a Recognition Tag for a Mutual Favourite

let sharedSecret: Data = // X25519 shared secret
let recognitionKey = PeerIDRotation.recognitionKey(sharedSecret: sharedSecret)

let tag = PeerIDRotation.recognitionTag(
    recognitionKey: recognitionKey,
    epoch: PeerIDRotation.epoch(at: now),
    senderStaticPublicKey: myNoisePublic,
    recipientStaticPublicKey: friendNoisePublic,
    peerID: myPeerID)

Verify an Incoming Tag Block

let tagBlock: Data = // 64‑byte block from BLE advertisement
let isRecognised = PeerIDRotation.blockMatches(
    tagBlock,
    recognitionKey: recognitionKey,
    senderStaticPublicKey: senderNoisePub,
    recipientStaticPublicKey: myNoisePub,
    peerID: senderPeerID,
    at: Date())

Construct the Binding Message for Handshake

let binding = PeerIDRotation.bindingMessage(
    epoch: PeerIDRotation.epoch(at: Date()),
    peerID: myPeerID,
    noiseStaticPublicKey: myNoisePublic)

// Sign `binding` with Ed25519 and transmit in AuthenticatedPeerStatePacket

Summary

  • Peer ID rotation cryptographically decouples device identity from long‑term public keys, requiring replacement of static‑hash verification with explicit signed bindings.
  • Recognition tags enable mutual favourites to identify each other across rotation periods without leaking relationship metadata to passive observers.
  • Protocol version negotiation via capability bit 14 and announceV2 = 0x2C ensures backward compatibility during the phased rollout.
  • Storage and routing layers must migrate from 8‑byte peer ID keys to stable fingerprints to prevent message loss during rotation events.
  • All core functions are implemented in PeerIDRotation.swift with pure Swift cryptography, eliminating clock and I/O dependencies.

Frequently Asked Questions

How does peer ID rotation prevent tracking in BitChat?

By deriving the 8‑byte broadcast identifier from the private Noise key using HKDF‑SHA256 and a time‑based epoch (default 1 hour), the protocol ensures that passive listeners cannot link different advertisements to the same physical device across rotation windows. This eliminates the long‑term correlation surface that existed when the peer ID was a static SHA‑256 hash of the public key.

How do mutual favourites recognise each other after ID rotation?

Mutual favourites pre‑compute directional recognition tags using the X25519 shared secret and the current epoch. These tags are embedded in a shuffled, padded 64‑byte block within the BLE advertisement. When a device receives an advertisement, it tests the tag block against its locally stored recognition keys; a match confirms the sender is a known contact without revealing the relationship to eavesdroppers.

What changes are required to support rotating peer IDs in BitChat?

Implementers must update BLEAnnounceHandlingPolicy.swift to accept announceV2 messages and verify recognition tags instead of static key hashes. NoiseSessionManager.swift must verify the Ed25519 signature on the binding message post‑handshake. Additionally, MessageOutboxStore.swift and BLEFragmentAssemblyBuffer.swift require updates to index by stable fingerprints rather than rotating peer IDs to ensure message delivery persists across epoch boundaries.

How does the phased rollout maintain compatibility with older clients?

The rollout uses three phases: parse‑only (understand v2 but send v1), dual‑announce (alternate v1 and v2 broadcasts), and v1‑sunset (suppress v1 once adoption is sufficient). During the dual‑announce phase, new clients prefer v2 handshakes while legacy clients continue operating via v1, ensuring the mesh remains connected during the transition.

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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →