Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/permissionlesstech/bitchat/llms.txt

Use this file to discover all available pages before exploring further.

Bitchat implements the Noise Protocol Framework for end-to-end encryption on the BLE mesh transport. All private payloads on the mesh — messages, delivery acknowledgments, read receipts — travel inside an established Noise session as typed ciphertext. Intermediate relay nodes see only opaque noiseEncrypted packets and cannot read the content. Two Noise patterns are in use: Noise XX for live sessions between connected peers, and Noise X for offline courier envelope seals.

The XX Pattern

Bitchat uses the three-message Noise XX handshake pattern for all live peer-to-peer sessions:
XX:
  -> e
  <- e, ee, s, es
  -> s, se
The XX pattern was chosen because it provides the strongest security properties for an environment where peers may have no prior knowledge of each other’s static keys before connecting:
PropertyProvided by XX
Mutual authentication✅ Both parties prove possession of their static key
Identity hiding✅ Static keys are transmitted only after the initial ephemeral exchange
Key-compromise impersonation resistance✅ Compromising one peer’s static key does not allow impersonating that peer to others
Forward secrecy✅ Ephemeral keys are generated fresh for each handshake
The full Noise protocol name for Bitchat’s BLE sessions is:
Noise_XX_25519_ChaChaPoly_SHA256

Core Components

The Noise implementation is structured around three collaborating classes, as documented in BRING_THE_NOISE.md:
final class NoiseEncryptionService {
    private let staticIdentityKey: Curve25519.KeyAgreement.PrivateKey
    private let sessionManager: NoiseSessionManager
    private let channelEncryption = NoiseChannelEncryption()
}

final class NoiseSession {
    private var handshakeState: NoiseHandshakeState?
    private var sendCipher: NoiseCipherState?
    private var receiveCipher: NoiseCipherState?
    private let remoteStaticKey: Curve25519.KeyAgreement.PublicKey?
}

final class NoiseSessionManager {
    private var sessions: [PeerID: NoiseSession] = [:]
    private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
}
NoiseEncryptionService is the main entry point for all Noise operations. It holds the device’s long-term Curve25519 static identity key (stored in the iOS/macOS Keychain) and delegates per-peer session state to NoiseSessionManager. NoiseSession holds the active handshake state and, once established, the split send/receive cipher states.

Handshake Flow

The three-message XX handshake proceeds as follows: Step 1 — Initiator sends ephemeral key (message 1):
let ephemeralKey = Curve25519.KeyAgreement.PrivateKey()
let message = ephemeralKey.publicKey.rawRepresentation
The initiator generates a fresh 32-byte ephemeral key pair. Only the ephemeral public key is transmitted. At this point neither party’s static identity has been revealed. Step 2 — Responder sends ephemeral key + encrypted static key (message 2):
// Generate ephemeral, perform DH, encrypt static key
let encryptedStatic = encrypt(staticKey, using: sharedSecret)
The responder generates its own ephemeral key, performs two Diffie-Hellman operations (ee and es), and uses the resulting mixed key material to encrypt its static public key. The responder’s identity is now hidden inside ciphertext that only the initiator can decrypt. Step 3 — Initiator sends encrypted static key (message 3):
// Complete handshake, derive session keys
let (sendKey, recvKey) = deriveSessionKeys(transcript)
The initiator decrypts the responder’s static key (authenticating the responder), performs the final DH operations (se), and sends its own encrypted static key. After this message, both parties have mutually authenticated and derived split transport cipher keys from the full handshake transcript. The handshake hash — a running SHA-256 digest of the entire transcript — is captured at the split point and stored for channel binding. This ties the resulting session to the specific handshake that produced it.

Session Management

Sessions are indexed by peer ID and managed with thread-safe concurrent dispatch:
// Session lookup by peer ID
func getSession(for peerID: PeerID) -> NoiseSession?

// Automatic session removal on disconnect
func removeSession(for peerID: PeerID)

// Rekey detection
func getSessionsNeedingRekey() -> [(peerID: PeerID, needsRekey: Bool)]
Rekey thresholds: Sessions are flagged for rekeying after:
  • 24 hours (sessionTimeout: 86400) — sessions older than this trigger renegotiation
  • 1 billion messages (maxMessagesPerSession: 1_000_000_000) — well within the ChaCha20-Poly1305 nonce limit of 2⁶⁴ − 1
Collision handling: When both peers simultaneously attempt to initiate a handshake, NoiseSessionManager detects the collision (crossed message 1 packets) and coordinates a deterministic yield. One side wins the role of initiator; the other cancels its attempt and responds. An unauthenticated inbound message 1 moves the existing transport into a receive-only “quarantine” state while the replacement handshake proves the claimed identity, then restores or discards the quarantined transport on a bounded timer. Rate limiting: The implementation enforces per-peer and global handshake rate limits:
  • Max handshakes per peer: 10 per minute
  • Max global handshakes: 30 per minute
  • Max messages per peer: 100 per second
  • Max global messages: 500 per second

Noise X for Courier Seals

When no transport can deliver a private message promptly, Bitchat seals it into a courier envelope for physical delivery. Courier envelopes use the Noise X pattern — a one-way, single-message construction:
X:
  -> e, es, s, ss
In Noise X, the sender encrypts a message to the recipient’s static public key. The sender’s identity is authenticated inside the ciphertext (the recipient can verify who sent it after decryption), but there is no handshake response — the recipient’s ephemeral key is never exchanged.
Noise X has no forward secrecy. Courier envelopes are sealed to the recipient’s long-term static Noise key. If that key is ever compromised, any sealed-but-undelivered courier envelopes addressed to it can be decrypted retroactively. A prekey-based scheme for courier envelopes is listed as future work. See Security Considerations.
Courier envelopes are capped at 16 KiB and have a maximum 24-hour lifetime. The only addressing information visible to a courier peer is a 16-byte rotating recipient tag — an HMAC of the recipient’s static key and the UTC day — computable only by parties who already know that key. Couriers learn neither the sender’s identity, the recipient’s full identity, nor the content.

Cryptographic Primitives

ComponentAlgorithm
Diffie-HellmanX25519 (Curve25519)
AEAD cipherChaCha20-Poly1305 (ChaChaPoly in CryptoKit)
Hash functionSHA-256
Key derivationHKDF-SHA256
These are the exact algorithms in the Noise specification name Noise_XX_25519_ChaChaPoly_SHA256. Bitchat uses Apple’s CryptoKit for all primitive operations; there are no third-party cryptographic dependencies on the Noise path. Replay protection: NoiseCipherState implements a sliding-window replay protection scheme:
  • Tracks received nonces in a 1,024-message window
  • Rejects any nonce that falls below the window floor (too old) or has already been seen
  • Handles out-of-order delivery within the window
Padding: Only noiseEncrypted and noiseHandshake packet types are padded (toward 256/512/1024/2048-byte buckets, PKCS#7 style). Frames requiring more than 255 bytes of padding to reach a bucket are emitted unpadded. All other packet types travel at their natural length.

Error Types

The Noise layer surfaces error conditions through NoiseError:
enum NoiseError: Error {
    case uninitializedCipher
    case invalidCiphertext
    case handshakeComplete
    case handshakeNotComplete
    case missingLocalStaticKey
    case missingKeys
    case invalidMessage
    case authenticationFailure
    case invalidPublicKey
    case replayDetected
    case nonceExceeded
}
Session-level security policy violations are surfaced separately through NoiseSecurityError:
enum NoiseSecurityError: Error {
    case sessionExpired
    case sessionExhausted
    case messageTooLarge
    case invalidPeerID
    case rateLimitExceeded
}
nonceExceeded fires when a session’s nonce counter approaches the 4-byte limit used by the transport framing. replayDetected is thrown when the sliding-window replay check rejects a duplicate or out-of-window nonce. In NoiseSecurityError, sessionExhausted covers the session approaching its message-count ceiling, and messageTooLarge enforces the 64 KiB Noise specification maximum (maxMessageSize = 65535).

Protocol Version Negotiation

Bitchat implements protocol version negotiation to ensure compatibility between different client versions. Upon connection, peers exchange supported versions before beginning the Noise handshake:
case versionHello = 0x20    // Announce supported versions
case versionAck   = 0x21    // Acknowledge and agree on version
Negotiation flow:
  1. Version Hello — the connecting peer sends a versionHello (0x20) message advertising the versions it supports
  2. Version Ack — the remote peer responds with versionAck (0x21), selecting the highest mutually supported version
  3. Graceful fallback — peers that do not send version negotiation messages are assumed to support protocol v1, preserving backward compatibility
Future protocol versions can be added to ProtocolVersion.supportedVersions. Peers that negotiate incompatible versions receive a rejection message and disconnect gracefully without disrupting sessions with other peers.

Build docs developers (and LLMs) love