How Noise Handshakes Are Initiated and Managed for Secure Peer Sessions in Bitchat Android

Bitchat Android employs the Noise XX handshake pattern (Curve25519 + ChaChaPoly + SHA-256) to establish mutually authenticated encrypted channels, with NoiseSessionManager coordinating initiation and collision handling while NoiseSession executes the low-level cryptographic state machine.

The permissionlesstech/bitchat-android repository implements decentralized peer-to-peer messaging using the Noise Protocol Framework to guarantee confidentiality and identity verification. This article examines how the Android client initiates, manages, and completes Noise handshakes to establish secure sessions between mesh network participants.

Handshake Initiation Architecture

Creating Initiator Sessions in NoiseSessionManager

When an outbound connection is required, the system invokes initiateHandshake() in NoiseSessionManager.kt. This method validates existing session states, then instantiates a new NoiseSession configured as the initiator.

// NoiseSessionManager.kt
fun initiateHandshake(peerID: String, replaceEstablished: Boolean = false): ByteArray? {
    // … (checks for existing session, stale hand‑shake, etc.) …
    val session = NoiseSession(
        peerID = peerID,
        isInitiator = true,
        localStaticPrivateKey = localStaticPrivateKey,
        localStaticPublicKey = localStaticPublicKey
    )
    addSession(peerID, session)
    return session.startHandshake()          // <- first XX message (e)
}

The manager immediately calls session.startHandshake() to generate the first handshake message.

Generating the First Handshake Message

Inside NoiseSession.kt (lines 74–106), the startHandshake() method initializes a HandshakeState with role INITIATOR and writes the first message containing the 32-byte ephemeral public key defined by XX_MESSAGE_1_SIZE.

// NoiseSession.kt
fun startHandshake(): ByteArray {
    val handshakeState = HandshakeState()
    handshakeState.initialize(noiseProtocolName, HandshakeState.INITIATOR)
    val message = ByteArray(XX_MESSAGE_1_SIZE)
    handshakeState.writeMessage(message, 0, ephemeralKey, ephemeralKey.size)
    return message
}

This 32-byte payload represents the ephemeral public key (e) sent to the responder to begin the XX protocol.

Processing Inbound Handshake Traffic

Message Routing and Session Lookup

Incoming handshake bytes are routed to processHandshakeMessage() in NoiseSessionManager.kt. The method delegates to processHandshakeMessageWithResult() to determine session ownership:

// NoiseSessionManager.kt
fun processHandshakeMessage(peerID: String, message: ByteArray): ByteArray? {
    return processHandshakeMessageWithResult(peerID, message).response
}

The manager first checks for an existing responder candidate (used during replacement handshakes). If none exists, it either creates a new responder session or reuses the current one, handling several edge cases:

  • Existing initiator session still handshaking and receives message 1 → Possible collision detected (lines 220–236)
  • Existing established session → triggers replacement handshake logic, storing the candidate in responderCandidates (lines 127–135)
  • Stale handshake detected → removes the stale session before creating a new one (lines 138–144)

Collision Detection and Role Resolution

When both peers simultaneously initiate connections, the implementation resolves the collision deterministically: the peer with the higher localPeerID yields to the responder role. This logic appears in NoiseSessionManager.kt lines 118–122, ensuring that exactly one initiator/responder pair emerges from simultaneous connection attempts.

Handshake State Machine Execution

Advancing the XX Pattern

The processHandshakeMessage() method in NoiseSession.kt (lines 118–176) drives the cryptographic state machine. For responders receiving the first message, it initializes the handshake with role RESPONDER:

// NoiseSession.kt
fun processHandshakeMessage(message: ByteArray): ByteArray? {
    if (!isInitiator && handshakeState == null) {
        initializeNoiseHandshake(HandshakeState.RESPONDER)
    }
    // Feed message to state machine
    handshakeState.readMessage(message, 0, message.size, payloadBuffer, 0)
    // Check state transition...
}

Depending on the internal state (WRITE_MESSAGE, SPLIT, or FAILED), the method produces a response message or completes the handshake.

Session Establishment and Transport Key Derivation

Upon reaching HandshakeState.SPLIT, completeHandshake() (lines 81–129) executes four critical steps:

  1. Verifies the remote static key against the claimed peer ID using NoisePeerIdentity.matchesClaimedPeerID
  2. Derives transport ciphers by calling handshakeState.split() to create sendCipher and receiveCipher pairs
  3. Captures the session token by cloning the 32-byte handshake hash before destroying the state
  4. Resets security counters and initializes sliding-window replay protection, marking the session as Established

Session Replacement and Lifecycle Management

Handling Replacement Handshakes

When an established session receives a fresh handshake initiation (e.g., due to transport-layer changes), the manager treats the new session as a replacement candidate stored in responderCandidates. The candidate must successfully authenticate the remote static key before evicting the existing session (processHandshakeMessageWithResult lines 64–70).

Stale Handshake Cleanup

A scheduled sweeper executes every HANDSHAKE_SWEEP_INTERVAL_MS (2 seconds) to invoke cleanupStaleHandshakes(). This method iterates over both sessions and responderCandidates maps, removing any handshake that has not progressed within HANDSHAKE_TIMEOUT_MS (10 seconds) (lines 15–33).

Post-Handshake Secure Communication

Once a session is Established, callers use NoiseSessionManager.encrypt() and decrypt(), which delegate to the NoiseSession transport ciphers. The payload format follows <nonce><ciphertext> where the nonce is a 4-byte big-endian counter. Replay protection uses a sliding-window implementation via isValidNonce() and markNonceAsSeen().

The handshake hash (32 bytes) returned by session.getHandshakeHash() serves as the session token (SESSION_TOKEN_SIZE). The getAuthenticatedSession() method constructs an AuthenticatedNoiseSession containing the remote static key and hashed token, providing cryptographically-bound identity for higher-level UI and re-key decisions.

Summary

  • Noise XX Pattern: Bitchat Android uses Curve25519, ChaChaPoly, and SHA-256 for mutually authenticated encryption
  • Central Coordination: NoiseSessionManager.kt handles initiation, collision resolution, and lifecycle management
  • Cryptographic Implementation: NoiseSession.kt manages the state machine, key derivation, and transport cipher setup
  • Collision Resolution: Deterministic role assignment based on localPeerID comparison prevents handshake deadlocks
  • Automatic Cleanup: A 2-second interval sweeper removes handshakes stalled longer than 10 seconds
  • Identity Binding: Remote static keys are verified against 16-character mesh IDs using NoisePeerIdentity

Frequently Asked Questions

What Noise pattern does Bitchat Android use?

Bitchat Android implements the Noise XX pattern using Curve25519 for elliptic curve operations, ChaChaPoly for authenticated encryption, and SHA-256 for hashing. This pattern provides mutual authentication where both parties transmit their static public keys, ensuring each peer can verify the other's identity before establishing the transport channel.

How does Bitchat handle simultaneous connection attempts?

When two peers initiate handshakes simultaneously, NoiseSessionManager detects the collision and resolves it deterministically: the peer with the higher localPeerID yields to the responder role while the other continues as initiator. This logic in lines 220–236 of NoiseSessionManager.kt guarantees that exactly one cryptographic session emerges from concurrent connection attempts.

What happens if a handshake message is lost or delayed?

If a handshake fails to complete within HANDSHAKE_TIMEOUT_MS (10 seconds), the scheduled sweeper removes the stale session during its next cleanup cycle (HANDSHAKE_SWEEP_INTERVAL_MS is 2 seconds). The application can then retry connection establishment by calling initiateHandshake() again, which creates a fresh session state.

How is the peer identity verified during the handshake?

During completeHandshake() (lines 81–129 in NoiseSession.kt), the implementation extracts the remote static public key and calls NoisePeerIdentity.matchesClaimedPeerID(). This derives the 16-character mesh ID from the 32-byte static key and confirms it matches the claimed peer identifier, preventing man-in-the-middle attacks where an attacker might present a different ephemeral key.

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 →