How BitChat Implements the Noise Protocol for End‑to‑End Encryption in Its BLE Mesh
BitChat uses the Noise Protocol Framework with the Noise_XX pattern and XChaCha20‑Poly1305 authenticated encryption to create secure, forward‑secret channels over its Bluetooth Low Energy mesh network.
The open‑source BitChat project (permissionlesstech/bitchat) implements end‑to‑end encryption by isolating all cryptographic operations in a dedicated SecureNoiseSession class. This design keeps the BLE transport layer agnostic to encryption details while guaranteeing confidentiality, integrity, and forward secrecy for mesh communications.
Core Architecture: The SecureNoiseSession Class
All Noise Protocol logic resides in bitchat/Noise/SecureNoiseSession.swift. This class wraps the handshake state machine, key derivation, and payload encryption/decryption in a single, reusable abstraction.
Session Initialization with X25519 Keys
When a BLE connection is established, the app creates a SecureNoiseSession instance configured for the Noise_XX pattern. This pattern requires no prior knowledge of peer static keys—ideal for opportunistic device discovery in mesh environments.
Key initialization parameters from the source:
- Local static key pair:
X25519.KeyPairgenerated viaNostrIdentityBridge.swiftutilities - Remote static key:
X25519.PublicKeyreceived through out‑of‑band mesh handshake - Pattern:
.XX(mutual authentication with forward secrecy)
let localKeyPair = X25519.KeyPair() // Generates fresh static key
let remotePublicKey = X25519.PublicKey(data: peerPubKeyBytes)
let noiseSession = SecureNoiseSession(
localStaticKey: localKeyPair,
remoteStaticKey: remotePublicKey,
pattern: .XX
)
The Three‑Message Noise_XX Handshake
The performHandshake() method implements the canonical Noise_XX three‑message exchange defined in SecureNoiseSession.swift (lines 80–150).
| Message | Direction | Payload Content | Cryptographic Purpose |
|---|---|---|---|
| 1 | Initiator → Responder | Empty payload + ephemeral public key | Establishes initial shared secret |
| 2 | Responder → Initiator | Ephemeral key + encrypted static key + MAC | Mutual authentication begins |
| 3 | Initiator → Responder | Encrypted static key payload | Completes mutual authentication |
Each message updates the internal chaining key and derives fresh symmetric keys for subsequent steps. The handshake completes with both parties possessing authenticated static keys and a shared secret.
noiseSession.performHandshake { result in
switch result {
case .success:
print("Handshake completed – secure channel ready")
case .failure(let error):
print("Handshake failed: \(error)")
}
}
Transport Key Derivation with HKDF
After handshake completion, SecureNoiseSession derives two unidirectional symmetric keys using HKDF (SecureNoiseSession.swift, lines 155–170):
txKey: For encrypting outgoing messagesrxKey: For decrypting incoming messages
The derivation uses the final chaining key and the domain separation string "bitchat noise transport" as the HKDF info parameter. This construction follows Noise Protocol specification requirements for key separation.
Payload Encryption: XChaCha20‑Poly1305
BitChat encrypts every BLE mesh packet with XChaCha20‑Poly1305, selected for its large 192‑bit nonce space that eliminates nonce‑reuse concerns in high‑throughput mesh scenarios.
The encryptMessage(_:) method (SecureNoiseSession.swift, lines 180–200) performs:
- Generates random 24‑byte nonce (XChaCha20 extended nonce)
- Encrypts plaintext with
txKey - Appends 16‑byte Poly1305 authentication tag
- Returns
nonce || ciphertext || tagconcatenation
let plaintext = Data("Hello BLE mesh!".utf8)
if let encrypted = try? noiseSession.encryptMessage(plaintext) {
blePeripheral.writeValue(
encrypted,
for: characteristic,
type: .withResponse
)
}
Payload Decryption and Integrity Verification
On reception, decryptMessage(_:) (SecureNoiseSession.swift, lines 205–225):
- Extracts 24‑byte nonce from packet header
- Decrypts using
rxKey - Verifies Poly1305 MAC
- Returns plaintext or throws
NoiseError.decryptionFailed
Failed MAC verification discards the packet and marks the session compromised, preventing oracle attacks.
func peripheral(
_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?
) {
guard let encrypted = characteristic.value else { return }
do {
let decrypted = try noiseSession.decryptMessage(encrypted)
print("Received:", String(data: decrypted, encoding: .utf8)!)
} catch {
print("Decryption failed – packet rejected:", error)
}
}
Key Rotation and Forward Secrecy
BitChat enforces periodic static key rotation via NostrIdentityBridge.swift (lines 70–95). This limits data encrypted under any single key, ensuring previously captured ciphertext remains secure even if current keys are compromised.
The rotation process:
- Generates new
X25519.KeyPair - Propagates new public key through mesh
- Re‑negotiates Noise handshake with all active peers
- Retires old keys after handshake completion
BLE Mesh Integration
The BLE transport layer treats SecureNoiseSession as an opaque encryption abstraction. CBPeripheral writes carry Noise‑encrypted payloads; delegate callbacks receive raw bytes for session decryption. This separation allows the mesh stack in BLEMeshManager to focus on routing and connection management while cryptographic guarantees remain enforced.
Per the project documentation in docs/BLE-ARCHITECTURE-V3.md, the Noise session sits at the transport layer boundary—below the message framing layer but above raw GATT operations.
Summary
- Noise_XX pattern enables mutual authentication without prior key distribution, matching dynamic mesh discovery requirements
- XChaCha20‑Poly1305 provides authenticated encryption with 192‑bit nonces, eliminating reuse risks
- HKDF key derivation produces strictly separated
txKey/rxKeypairs post‑handshake SecureNoiseSession.swiftcentralizes all cryptographic state, isolating the BLE layer from algorithm details- Periodic key rotation in
NostrIdentityBridge.swiftmaintains forward secrecy across long‑running mesh sessions
Frequently Asked Questions
Why does BitChat use Noise_XX instead of Noise_IK or Noise_XXfallback?
Noise_XX requires no pre‑shared or pre‑distributed static keys. In a BLE mesh where devices discover peers opportunistically, the initiator cannot know the responder's static key in advance. Noise_XX's three‑message pattern provides mutual authentication through in‑band static key transmission, fitting the decentralized mesh model where traditional PKI infrastructure is unavailable.
How does BitChat prevent nonce reuse when encrypting high volumes of mesh traffic?
XChaCha20‑Poly1305 uses 192‑bit (24‑byte) nonces randomly generated per message. This provides 2^192 possible nonce values—compared to 96‑bit in standard ChaCha20‑Poly1305—making accidental collision statistically impossible. Random nonce generation in encryptMessage(_:) removes the need for complex counter synchronization across mesh nodes.
What happens when a Noise decryption fails due to tampering or key mismatch?
The decryptMessage(_:) method throws NoiseError.decryptionFailed and the BLE layer discards the packet. The session is marked compromised, triggering rehandshake or connection termination depending on policy. This strict failure mode prevents padding oracle attacks and ensures no plaintext is ever returned from unverified ciphertext.
Where does BitChat generate and store the X25519 static keys used in Noise sessions?
Static key generation occurs in NostrIdentityBridge.swift using the project's Nostr identity infrastructure. Keys derive from the user's Nostr identity seed, enabling deterministic recovery across devices while maintaining cryptographic separation through domain‑specific derivation paths. Private keys remain in the iOS Secure Enclave when hardware support is available.
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 →