How Noise Session Establishment (XX Handshake) Works in BitChat
BitChat uses the Noise XX handshake pattern to establish mutually authenticated, end-to-end encrypted sessions between peers without requiring prior knowledge of each other's static public keys.
The Noise XX pattern provides one of the most versatile handshake configurations in the Noise Protocol Framework, offering mutual authentication while allowing either party to initiate communication. In BitChat, this pattern is implemented as Noise_XX_25519_ChaChaPoly_SHA256 across the bitchat/Noise package, with the static key pair managed by NoiseEncryptionService and session state orchestrated through NoiseSession and NoiseSessionManager.
The Three-Message XX Handshake Flow
The Noise XX pattern exchanges exactly three messages to establish a secure transport channel. Unlike simpler patterns, neither peer needs the other's static public key before starting the handshake.
Step 1: Initiator Sends Ephemeral Public Key
The initiator begins by generating a fresh ephemeral Curve25519 key pair and transmitting only the public key e.
// NoiseEncryptionService.swift - initiator starts outbound session
let noise = NoiseEncryptionService(keychain: myKeychain)
let session = try noise.createOutboundSession(peerPublicKey: peerNoiseKey)
// First handshake message contains ephemeral public key only
let msg1 = try session.writeMessage(data: Data()) // 32-byte `e`
In bitchat/Noise/NoiseSession.swift, the session records this as the initiator's first handshake record and marks itself as not a responder (meaning it will not send the first message in subsequent exchanges).
Step 2: Responder Replies with Ephemeral and Encrypted Static Key
Upon receiving e, the responder performs three critical operations:
- Generates its own ephemeral key pair (
e′) - Computes Diffie-Hellman shared secrets:
DH(e, e′)andDH(e, s′)wheres′is the responder's static secret key - Encrypts its static public key using these secrets and transmits
[e′ || enc(s′)]
This step proves possession of the responder's long-term static key without revealing it in plaintext. The NoiseSession validates the payload structure and stores the responder's static public key for later authentication.
Step 3: Initiator Sends Encrypted Static Key
The initiator now possesses e′ and the authenticated s′. It completes mutual authentication by:
- Computing
DH(e′, s)andDH(e, s)wheresis the initiator's static secret key - Encrypting its static public key using these DH results
- Sending
enc(s)to the responder
After processing this message, both parties have completed mutual static key authentication and derive identical transport encryption and MAC keys from the full chain of DH results.
| Message | Sender | Contents | Cryptographic Purpose |
|---|---|---|---|
| 1 | Initiator | e (32 bytes) |
Esthemeral-only, no static key commitment |
| 2 | Responder | e′ || enc(s′) |
Proves responder identity via encrypted static key |
| 3 | Initiator | enc(s) |
Proves initiator identity, completes key agreement |
Key Implementation Files and Responsibilities
NoiseProtocol.swift
bitchat/Noise/NoiseProtocol.swift defines the XX pattern constants and protocol properties. It declares the pattern as "most versatile, provides mutual authentication" and establishes the Noise_XX_25519_ChaChaPoly_SHA256 configuration used throughout BitChat.
NoiseSession.swift
bitchat/Noise/NoiseSession.swift contains the core state machine driving the three-message exchange. Critical implementation details include:
- Tracking whether the local party is a responder (determines who sends message 1)
- The explicit comment: "For XX pattern, we don't need remote static key upfront"—highlighting the pattern's flexibility
- Managing the transition from handshake mode to transport mode upon completion
NoiseSessionManager.swift
bitchat/Noise/NoiseSessionManager.swift handles session lifecycle and security boundaries:
- Secure token hand-off between handshake and transport phases
- Preventing premature leakage of prepared message-1
- Monitoring inbound responder state while awaiting the third message
- Coordinating automatic re-keying after successful handshake completion
NoiseEncryptionService.swift
bitchat/Services/NoiseEncryptionService.swift provides the application-facing API:
- Stores the local static Curve25519 key pair in the system keychain
- Generates ephemeral keys for each new session
- Performs all Diffie-Hellman operations
- Guarantees transport messages cannot be confused with handshake transcripts
- Exposes
encrypt()anddecrypt()methods after handshake completion
Complete Session Establishment Example
import Foundation
// MARK: - Full XX Handshake Demonstration
// Initialize service with persistent static keypair
let noiseService = NoiseEncryptionService(keychain: keychain)
// Create outbound session as initiator
var initiatorSession = try noiseService.createOutboundSession(
peerPublicKey: responderStaticPubKey
)
// === MESSAGE 1: Initiator → Responder ===
let message1 = try initiatorSession.writeMessage(data: Data())
// Transmits 32-byte ephemeral public key `e`
// (Network transmission to responder...)
// === Responder processes message 1 and creates reply ===
var responderSession = try noiseService.createInboundSession(
receivedData: message1
)
let message2 = try responderSession.writeMessage(data: Data())
// Transmits `e′ || enc(s′)` — 32 bytes ephemeral + encrypted static
// (Network transmission to initiator...)
// === MESSAGE 3: Initiator processes responder, sends final authentication ===
_ = try initiatorSession.readMessage(message2)
let message3 = try initiatorSession.writeMessage(data: Data())
// Transmits `enc(s)` — encrypted initiator static key
// (Network transmission to responder...)
// === Handshake complete for responder ===
_ = try responderSession.readMessage(message3)
// Both sessions now in TRANSPORT mode — mutually authenticated
assert(initiatorSession.isHandshakeComplete)
assert(responderSession.isHandshakeComplete)
// Transport encryption active
let plaintext = "Hello, BitChat!".data(using: .utf8)!
let encrypted = try initiatorSession.encrypt(payload: plaintext)
let decrypted = try responderSession.decrypt(packet: encrypted)
// decrypted == plaintext
Transport Mode and Message Protection
Once the XX handshake completes, NoiseEncryptionService transitions both sessions to transport mode. All subsequent communications—voice calls, media transfers, and chat messages—use the derived cipher keys:
- Encryption: ChaCha20-Poly1305 authenticated encryption with 16-byte Noise tags
- Padding: Optional message padding via
MessagePadding.swiftto obscure payload sizes - Re-keying: Automatic key rotation triggered by
NoiseSessionManagerafter handshake completion
The transport layer guarantees confidentiality (only the authenticated peer can decrypt), integrity (any modification is detected), and identity binding (both parties have cryptographically proven static key ownership).
Summary
- Noise XX handshake in BitChat uses three messages to achieve mutual authentication without pre-shared static keys.
- Message 1 (
e): Initiator sends ephemeral public key only—no static key commitment required. - Message 2 (
e′ || enc(s′)): Responder proves identity by encrypting its static key using combined DH secrets. - Message 3 (
enc(s)): Initiator proves identity, completing the handshake and enabling transport key derivation. - Core files:
NoiseProtocol.swift(constants),NoiseSession.swift(state machine),NoiseSessionManager.swift(lifecycle), andNoiseEncryptionService.swift(application API).
Frequently Asked Questions
What makes the XX pattern different from simpler Noise patterns like NK or IK?
The XX pattern provides mutual authentication without requiring either party to know the other's static public key beforehand. Patterns like NK (sender anonymous, recipient known) or IK (immediate static key transmission) sacrifice this flexibility—XX enables truly permissionless connection establishment where either peer can initiate and both authenticate dynamically.
How does BitChat prevent handshake message confusion with transport messages?
NoiseEncryptionService maintains strict state separation enforced by NoiseSession. Handshake messages propagate through writeMessage() and readMessage() methods, while transport messages use encrypt() and decrypt()—these APIs are mutually exclusive based on isHandshakeComplete state. The underlying CipherState objects are only initialized after successful handshake finalization.
Where is the static private key stored in BitChat's implementation?
The static Curve25519 key pair is persisted in the system keychain by NoiseEncryptionService, not in application memory or UserDefaults. The private key is accessed only through the service's internal methods for DH operations, never exposed to higher-level application code. This aligns with iOS security best practices and protects against extraction attacks.
Can the XX handshake be used for forward secrecy?
Yes—ephemeral keys (e and e′) are generated fresh for each session and discarded after use. Even if the static private keys are compromised later, recorded handshake transcripts cannot be decrypted because the ephemeral secrets are unrecoverable. This provides future secrecy for past sessions combined with mutual authentication of long-term identities.
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 →