How BitChat Implements Offline Message Sealing Using Noise X

BitChat implements offline message sealing by encrypting payloads with a one-way Noise X pattern to the recipient’s static public key, then wrapping the ciphertext in a courier envelope tagged with a rotating HMAC value that allows anonymous routing without revealing the recipient’s identity.

BitChat is an open-source peer-to-peer messaging protocol built for censorship-resistant mesh networks. The permissionlesstech/bitchat repository solves the offline delivery problem—where the recipient is disconnected—by using the Noise X cryptographic pattern to create sealed envelopes that any intermediary node can store and forward without reading the content or identifying the intended recipient.

The Three-Step Sealing Process

Offline message sealing in BitChat follows a strict three-phase pipeline: derive an anonymous recipient tag, encrypt the payload using Noise X, and package the result into a CourierEnvelope for transport.

1. Deriving the Anonymous Recipient Tag

To enable routing without exposing the recipient’s static public key, BitChat generates a rotating recipient tag. This 16-byte identifier is computed as an HMAC-SHA-256 over the recipient’s static Noise public key and the current UTC day, truncated to 16 bytes.

In bitchat/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift, the recipientTag(noiseStaticKey:epochDay:) static method implements this derivation:

public static func recipientTag(noiseStaticKey: Data, epochDay: UInt32) -> Data {
    var message = tagContext
    withUnsafeBytes(of: epochDay.bigEndian) { message.append(contentsOf: $0) }
    let mac = HMAC<SHA256>.authenticationCode(for: message,
                                               using: SymmetricKey(data: noiseStaticKey))
    return Data(mac).prefix(tagLength)            // ← 16-byte tag
}

The tag rotates daily via the epochDay parameter, preventing correlation attacks where an observer might link envelopes across time periods. When the recipient comes online, they regenerate candidate tags for the current day using their own static key to locate envelopes destined for them.

2. Encrypting with the Noise X Pattern

The actual payload encryption uses the one-way Noise X pattern: the sender encrypts directly to the recipient’s long-term static public key without performing a full handshake. This creates a "seal" that only the holder of the corresponding private key can open.

The implementation resides in bitchat/Services/NoiseEncryptionService.swift. The seal(_:to:) method derives a shared secret between the sender’s ephemeral or static key and the recipient’s static key, then encrypts using ChaChaPoly:

func seal(_ plaintext: Data, to recipientStaticKey: Data) throws -> Data {
    // Derive a shared secret from the recipient’s static public key and our
    // long-term static private key, then encrypt with ChaChaPoly.
    let shared = try deriveSharedSecret(with: recipientStaticKey)
    return try ChaChaPoly.seal(plaintext, using: shared).combined
}

When the envelope specifies a pre-key (for forward secrecy), the same routine applies but uses the one-time pre-key as the static key target, ensuring that even key compromise does not expose past messages.

3. Assembling the Courier Envelope

The sealed ciphertext, recipient tag, and metadata are assembled into a CourierEnvelope structure. This TLV-encoded envelope contains the expiry timestamp, copy budget, optional pre-key identifier, and the encrypted payload.

The construction flow in the outbox logic looks like this:

let tag = CourierEnvelope.recipientTag(noiseStaticKey: recipientStaticKey,
                                       epochDay: CourierEnvelope.epochDay(for: Date()))
let sealed = try noiseService.seal(messageData, to: recipientStaticKey)

let envelope = CourierEnvelope(recipientTag: tag,
                              expiry: UInt64(Date().addingTimeInterval(24*60*60).timeIntervalSince1970 * 1000),
                              ciphertext: sealed,
                              copies: 1,               // carry-only budget
                              prekeyID: nil)           // static-key (Noise X) envelope

The envelope is then stored in the MessageOutboxStore and disseminated via available transports—whether Bluetooth Low Energy mesh, Nostr relays, or direct peer connections. Intermediary nodes route based solely on the 16-byte tag, which reveals neither the sender nor the recipient’s identity.

Forward Secrecy Enhancements

While basic Noise X provides confidentiality to the static key, BitChat supports forward-secret offline sealing through pre-keys defined in bitchat/localPackages/BitFoundation/Sources/BitFoundation/PrekeyBundle.swift. When a pre-key is used (indicated by a non-nil prekeyID in the envelope), the seal targets the one-time pre-key rather than the long-term static key. This ensures that even if the recipient’s static key is compromised later, previously sealed offline messages remain secure because the pre-keys have been deleted after use.

Summary

  • Anonymous Routing: The 16-byte recipient tag is derived from HMAC-SHA-256 of the static key and current day, rotating daily to prevent correlation.
  • One-Way Encryption: The Noise X pattern in NoiseEncryptionService seals payloads directly to the recipient’s static public key using ChaChaPoly.
  • Envelope Structure: CourierEnvelope packs the ciphertext, rotating tag, expiry, and copy budget into a format readable only by the intended recipient.
  • Transport Agnostic: Sealed envelopes flow through MessageOutboxStore to any available mesh peer, enabling store-and-forward without trust.
  • Forward Secrecy: Optional pre-key targeting (via PrekeyBundle) upgrades static-key seals to forward-secret seals.

Frequently Asked Questions

What is the Noise X pattern in BitChat?

The Noise X pattern in BitChat is a one-way cryptographic seal where a sender encrypts a message directly to a recipient’s long-term static public key without performing a bidirectional handshake. This allows the sender to encrypt without requiring an online recipient, creating a "sealed envelope" that only the private key holder can open.

How does the recipient tag protect privacy?

The recipient tag protects privacy by serving as a routing identifier that reveals neither the recipient’s public key nor their identity. Because it is an HMAC truncation derived from the static key and the current day’s timestamp, only the intended recipient—who possesses the static key—can compute the correct tag to locate their messages. Daily rotation prevents long-term tracking of envelope flows.

Can offline messages be decrypted by forwarding nodes?

No. Forwarding nodes possess only the CourierEnvelope containing the encrypted ciphertext and the routing tag. The ciphertext is sealed using the Noise X pattern, which requires the recipient’s private static key (or the private pre-key) to derive the ChaChaPoly decryption key. Intermediaries cannot decrypt the payload or identify the sender or recipient beyond the anonymous 16-byte tag.

What provides forward secrecy in BitChat's offline sealing?

Forward secrecy is achieved when the sender targets a one-time pre-key instead of the static key. According to the logic in PrekeyBundle.swift and the envelope construction, if the prekeyID field is set, the seal uses the ephemeral pre-key as the target. Since the recipient deletes pre-keys after use, compromise of the long-term static key cannot decrypt past messages sealed to now-deleted pre-keys.

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 →