How Peer Identity and Peer ID Rotation Work in BitChat: A Technical Deep Dive

BitChat binds every device to an unforgeable 8-byte peer ID derived from its Noise static public key, requiring fresh cryptographic material and a signed v2 announce to achieve true rotation without privacy leaks.

BitChat's peer identity system centers on cryptographic binding between a short identifier and a device's long-term Noise key. This design, documented in the permissionlesstech/bitchat repository, ensures that peer IDs cannot be spoofed or arbitrarily changed without rotating the underlying static key pair. Understanding this mechanism is essential for developers working with the protocol's handshake, storage, and messaging layers.

Peer ID Derivation and Cryptographic Binding

Every BitChat device is identified by an 8-byte peer ID computed as the first 8 bytes of the SHA-256 hash of its Noise static public key:

let peerID = SHA256(noiseStaticPublicKey)[0..<8]

This derivation appears throughout the codebase and creates an unforgeable link between the identifier and the cryptographic key. Two critical components enforce this binding:

  • BLEPeerRegistry — validates that an advertised peer ID matches the derived value from the provided static key (PEER-ID-ROTATION.md lines 62-68)
  • NoiseSessionManager — throws NoiseSessionError.peerIdentityMismatch when the recomputed ID differs from the expected value (lines 82-84)

Because derivation is deterministic, any party can verify a peer ID's legitimacy given the static public key. This eliminates spoofing attacks where a malicious peer might claim another device's ID.

Why Simple Rotation Fails: The Replacement Binding Requirement

A device cannot achieve privacy-preserving rotation by merely changing its peer ID while retaining the same static keys. If the announce still carries identical Noise and Ed25519 keys, the derived-ID check immediately links the new ID to the previous identity (PEER-ID-ROTATION.md lines 39-45).

True rotation requires simultaneous replacement of:

  • The static Noise key pair (source of peer ID derivation)
  • The Ed25519 signing key
  • The peer ID itself

This replacement binding is advertised through a v2 announce structure that carries both the new static key and the rotated ID, signed to prevent replay attacks.

The v2 Announce and Rotation Mechanics

Step 1: Generate Epoch-Based Rotating ID

The rotating peer ID (peerID_e) derives from a long-lived rotation secret K_rot and the current time epoch:

let epoch: UInt32 = UInt32(Date().timeIntervalSince1970 / 86400)  // days
let msg = Data("bitchat-peer-id-v2".utf8) + epoch.bigEndian

let peerID_e = HMAC<SHA256>.authenticationCode(
    for: msg,
    using: SymmetricKey(data: K_rot)
).prefix(8)

This produces a time-bounded identifier that changes with each epoch without requiring new cryptographic material (PEER-ID-ROTATION.md lines 106-110).

Step 2: Build and Sign the Announce

The v2 announce binds the rotating ID to the new static keys through a canonical signature:

let signature = NoiseEncryptionService.buildAnnounceSignature(
    context: "bitchat-announce-v1",
    peerID: peerID_e,
    noiseKey: newStaticNoiseKey,      // NEW key pair
    ed25519Key: newSigningKey,        // NEW signing key
    nickname: nickname,
    timestampMs: timestamp
)

let announce = BLEAnnounce(
    peerID: peerID_e,
    noiseStaticKey: newStaticNoiseKey,
    signingKey: newSigningKey,
    nickname: nickname,
    timestamp: timestamp,
    signature: signature
)

The signature covers the exact announce contents, preventing adversaries from replaying old announces or substituting values (lines 142-148).

Step 3: Receiver Verification

Upon receipt, the verifying peer:

  1. Recomputes peerID_e from K_rot and the announced epoch
  2. Validates the Ed25519 signature against the advertised static keys
  3. Accepts the new binding, replacing any previous (peerID → static key) mapping

Failure at any step results in NoiseSessionError.peerIdentityMismatch.

Step 4: State Migration

During transition periods, MessageRouter.peerIDAliases and ChatPeerIdentityCoordinator.migrateChatState collapse old and new IDs into unified entries, preventing duplicate peer displays in the UI (lines 249-254).

System-Wide Impact of Peer ID Rotation

Rotation affects multiple subsystems that must migrate from peer-ID-based to fingerprint-based addressing:

Component Required Adaptation
Noise handshake NoiseSessionManager must derive IDs from the new static key; lines 1106-1122 require updates (lines 259-261)
Message outbox Pending DMs keyed by peer ID become orphaned; storage must use stable fingerprint instead (lines 261-263)
Initiator tie-break The comparison localPeerID < peerID for role determination needs a stable value—rotating IDs would flip ordering mid-negotiation (lines 263-264)
Prefix caches SecureIdentityStateManager, ChatGroupCoordinator, and similar caches using peerID prefixes must switch to static key fingerprints (lines 264-266)
Gossip archive Historical packets retain original sender IDs; queries spanning rotation events need migration logic (lines 275-277)

Capability Advertisement

BitChat uses a UInt64 OptionSet for capability discovery. Bit 14 (peerIDRotation) signals support for rotating IDs, allowing legacy clients to detect and appropriately handle v2 announces (lines 237-244). This capability is checked in PeerCapabilities.swift before attempting rotation-specific protocol features.

Complete Verification Example

import CryptoKit

func verifyRotatingAnnounce(
    _ announce: BLEAnnounce,
    rotationSecret: Data
) throws -> VerifiedPeerBinding {
    // Reconstruct expected rotating ID
    let expectedID = HMAC<SHA256>.authenticationCode(
        for: Data("bitchat-peer-id-v2".utf8) + announce.epoch.bigEndian,
        using: SymmetricKey(data: rotationSecret)
    ).prefix(8)
    
    guard expectedID == announce.peerID else {
        throw NoiseSessionError.peerIdentityMismatch
    }
    
    // Verify signature binds ID to new static keys
    try NoiseEncryptionService.verifyAnnounceSignature(
        context: "bitchat-announce-v1",
        peerID: announce.peerID,
        noiseKey: announce.noiseStaticKey,
        ed25519Key: announce.signingKey,
        nickname: announce.nickname,
        timestampMs: announce.timestamp,
        signature: announce.signature
    )
    
    // Derive new stable peer ID from new static key
    let newStableID = SHA256.hash(data: announce.noiseStaticKey).prefix(8)
    
    return VerifiedPeerBinding(
        rotatingID: announce.peerID,
        stableID: Data(newStableID),
        staticKey: announce.noiseStaticKey,
        signingKey: announce.signingKey
    )
}

Key Implementation Files

File Responsibility
docs/PEER-ID-ROTATION.md Full protocol design, threat model, and migration checklist
bitchat/Noise/NoiseSessionManager.swift Identity mismatch detection and handshake enforcement
bitchat/Noise/NoiseEncryptionService.swift buildAnnounceSignature and verifyAnnounceSignature helpers
bitchat/PeerCapabilities.swift peerIDRotation capability bit definition
bitchatTests/Services/BLEAnnounceHandlingPolicyTests.swift Announce parsing and rejection tests
bitchatTests/ChatPeerIdentityCoordinatorContextTests.swift v1-to-v2 ID migration scenario tests

Summary

  • Peer ID derivation: Deterministic 8-byte prefix of SHA-256(static Noise key), unforgeable and universally verifiable
  • Rotation requirement: Privacy-preserving rotation demands both a new rotating ID and new static cryptographic material
  • v2 announce structure: HMAC-based epoch ID + fresh static keys + canonical signature prevents replay and linking attacks
  • System migration: Storage, handshake, and caching layers must adopt stable fingerprints as primary keys to support rotation

Frequently Asked Questions

Can a BitChat peer change its ID without generating new keys?

No. Because the peer ID derives directly from the static Noise public key, any ID change requires rotating that key. Attempting to advertise a different ID with the same static key causes NoiseSessionError.peerIdentityMismatch in NoiseSessionManager. The protocol explicitly rejects this scenario as it provides no privacy benefit—the old and new IDs remain cryptographically linked.

What prevents replay attacks on v2 rotation announces?

The buildAnnounceSignature function creates a binding signature over the canonical announce bytes, including the rotating ID, new static Noise key, Ed25519 key, nickname, and timestamp. This ties each announce to a specific cryptographic state and time window. Receivers verify this signature before accepting any ID replacement, ensuring attackers cannot replay old announces to regress a peer's identity.

How do legacy clients handle peers with rotating IDs?

The peerIDRotation capability bit (bit 14) in the PeerCapabilities OptionSet allows new clients to advertise support. Legacy clients without this bit should ignore v2 announce fields they don't recognize, treating the peer as a standard v1 peer. The protocol maintains backward compatibility through gradual capability negotiation rather than hard protocol version requirements.

Why use HMAC-SHA256 instead of direct SHA-256 for the rotating ID?

The rotating ID peerID_e must change predictably across epochs without revealing the underlying static key. HMAC with a long-lived secret K_rot provides this: the same secret produces deterministic IDs for peers who share it, while the irreversibility of HMAC protects K_rot from extraction. Direct SHA-256 of a public value would be guessable; HMAC requires the shared secret to compute or verify.

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 →