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:

  1. Generates its own ephemeral key pair (e′)
  2. Computes Diffie-Hellman shared secrets: DH(e, e′) and DH(e, s′) where s′ is the responder's static secret key
  3. 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:

  1. Computing DH(e′, s) and DH(e, s) where s is the initiator's static secret key
  2. Encrypting its static public key using these DH results
  3. 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() and decrypt() 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.swift to obscure payload sizes
  • Re-keying: Automatic key rotation triggered by NoiseSessionManager after 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), and NoiseEncryptionService.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:

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 →