How BitChat Implements Noise Protocol Encryption in Its Mesh Network
BitChat implements Noise Protocol encryption using the Noise XX handshake pattern with Curve25519 key exchange, ChaCha20-Poly1305 authenticated encryption, and SHA-256 fingerprints, orchestrated through the NoiseEncryptionService that sits between the BLE transport layer and chat logic.
The permissionlesstech/bitchat repository provides a peer-to-peer mesh messaging application that secures all communications using the Noise Protocol framework. Understanding how Noise Protocol encryption is implemented within this Swift codebase reveals a sophisticated combination of static identity keys, per-peer session management, and periodic rekeying to maintain forward secrecy. The implementation centers on the NoiseEncryptionService class, which abstracts cryptographic operations away from the underlying Bluetooth Low Energy transport.
Cryptographic Identity and Key Management
BitChat establishes persistent identities using two distinct key pairs that survive application restarts and form the root of trust for all mesh communications.
Static Noise Keys
Each device maintains a static Curve25519 key pair (noiseStaticKey) that serves as its long-term identity within the mesh. In bitchat/Services/NoiseEncryptionService.swift (lines 45-66), the initializer loads this key from the iOS Keychain or generates a new pair if none exists. This static key is essential for the Noise XX handshake pattern, allowing peers to authenticate each other across session restarts.
Ed25519 Signing Keys
Complementing the Noise key, the service maintains an Ed25519 signing key (ed25519SigningKey) for cryptographic binding (lines 58-61). This key signs announcements and binds the static Noise key to the user's identity, preventing man-in-the-middle attacks during the initial discovery phase. Both keys are persisted via the KeychainManagerProtocol and their public components are advertised in AnnounceV2Packet structures during BLE discovery.
Session Lifecycle and the XX Handshake
The NoiseEncryptionService delegates per-peer state management to a NoiseSessionManager, which handles the cryptographic negotiation between devices.
Lazy Session Creation
Sessions are instantiated on-demand rather than pre-allocated. When a peer first attempts to send a protected message, the onHandshakeRequired callback triggers (lines 86-92), signaling the transport layer to initiate the handshake process. This lazy approach conserves resources in large mesh networks where not all discovered peers require immediate encrypted channels.
Handshake Execution and Rekeying
The Noise XX pattern executes a Diffie-Hellman exchange using X25519 to derive a shared secret, then uses HKDF to generate symmetric encryption keys. The implementation leverages CryptoKit's ChaCha20Poly1305 primitive for authenticated encryption.
Forward secrecy is enforced through aggressive rekeying. A rekeyTimer fires every 60 seconds (lines 80-82), triggering new handshakes that replace existing session keys. This ensures that compromising a device does not expose historical traffic.
Message Encryption and Decryption Flow
Once a session is established, the service provides transparent encryption APIs that wrap payloads for BLE transmission.
Sending Encrypted Messages
When transmitting data, the service checks for an active session. If none exists, it triggers the handshake callback. The encryption process follows this sequence:
func sendSecureMessage(to peerID: PeerID, payload: Data) throws {
// Trigger handshake if needed
noiseService.onHandshakeRequired?(peerID)
// Encrypt using the established session
let encrypted = try noiseService.encrypt(payload,
for: peerID,
payloadType: .privateFile) // NoisePayloadType.privateFile = 0x20
bleService.send(encrypted, to: peerID)
}
The encrypt(_:for:payloadType:) method applies ChaCha20-Poly1305, producing a ciphertext format compliant with the Noise specification: [payload][type-byte][16-byte tag]. As documented in PUSH-TO-TALK-DESIGN.md (lines 30-38), padding is applied to keep frames within a 256-byte bucket, ensuring compatibility with BLE MTU limitations.
Receiving and Decrypting Messages
Inbound packets undergo reverse processing. The receiver looks up the session associated with the sender's peer ID and verifies the 16-byte authentication tag:
func handleIncoming(_ packet: BitchatPacket) {
guard let decrypted = try? noiseService.decrypt(packet.payload,
from: packet.senderPeerID) else {
// handle decryption failure – possibly a stale session
return
}
// Process the clear-text payload
processMessage(decrypted)
}
Decryption failures typically indicate stale sessions or replay attempts, prompting the transport layer to request a new handshake.
Handling Session Events
The service exposes callbacks for transport layer coordination:
noiseService.sessionManager.onSessionEstablished = { [weak self] peerID, remoteKey, generation in
self?.log("Noise session established with \(peerID)")
// Optionally notify UI that the peer is now encrypted
self?.updateEncryptionStatus(for: peerID, .noiseSecured)
}
These hooks allow the UI to display encryption status and enable message sending only after mutual authentication completes (lines 44-53).
Courier Envelopes for Store-and-Forward
For offline messaging scenarios, BitChat implements one-way Noise X encryption via CourierEnvelope (defined in localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift, lines 17-33). Unlike bidirectional XX sessions, these envelopes encrypt data using only the recipient's static public key, allowing any mesh node to store and forward the message without holding session state. The envelope includes a discriminator byte to distinguish protocol versions (v1 vs v2).
Peer Identification and Fingerprints
BitChat derives stable identifiers from cryptographic material to prevent spoofing. The 8-byte peer ID that appears in every packet header represents the first 8 bytes of the SHA-256 hash of the peer's static Noise public key (docs/PEER-ID-ROTATION.md, lines 31-33).
The NoiseEncryptionService caches these fingerprints in a peerFingerprints dictionary (lines 65-68) for O(1) lookup during packet processing. This design allows the transport layer to identify senders before completing the Noise handshake, routing packets efficiently while deferring authentication to the cryptographic layer.
Summary
BitChat's Noise Protocol implementation delivers end-to-end encryption for mesh networks through:
- Static Curve25519 keys persisted in the iOS Keychain for persistent identity
- Noise XX handshakes providing mutual authentication and forward secrecy via 60-second rekeying
- ChaCha20-Poly1305 authenticated encryption via CryptoKit for all payload protection
- Lazy session instantiation triggered by
onHandshakeRequiredcallbacks to conserve mesh resources - SHA-256 fingerprints for compact peer identification without revealing public keys
- CourierEnvelope structures enabling encrypted store-and-forward messaging using one-way Noise X
Frequently Asked Questions
What Noise handshake pattern does BitChat use for peer-to-peer sessions?
BitChat uses the Noise XX pattern for bidirectional peer-to-peer sessions. This pattern provides mutual authentication by combining ephemeral Diffie-Hellman exchanges with static public keys, allowing both parties to verify each other's identities through the NoiseSessionManager before transmitting application data.
How does BitChat maintain forward secrecy in its mesh network?
Forward secrecy is enforced through a 60-second rekey timer (rekeyTimer) that triggers periodic re-handshakes. As implemented in NoiseEncryptionService.swift (lines 80-82), this replaces symmetric session keys regularly, ensuring that compromising a device does not expose historical communications.
What is the purpose of CourierEnvelope in the BitChat architecture?
The CourierEnvelope provides one-way Noise X encryption for store-and-forward scenarios. Defined in localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift, it allows any node to encrypt messages for a recipient using only that recipient's static public key, enabling offline message delivery without maintaining bidirectional session state.
How are BitChat peer IDs derived from Noise keys?
Peer IDs are derived as the first 8 bytes of the SHA-256 hash of a device's static Noise public key (docs/PEER-ID-ROTATION.md, lines 31-33). These fingerprints are cached in peerFingerprints (lines 65-68) and included in BLE announcements via AnnounceV2Packet, allowing nodes to identify peers before establishing encrypted sessions.
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 →