How Prekey Bundles Enable Forward-Secret Courier Mail Encryption in BitChat

BitChat uses signed Curve25519 prekey bundles distributed via gossip to establish one-way Noise X handshakes, ensuring each courier message employs a unique one-time key for forward secrecy.

BitChat (permissionlesstech/bitchat) implements asynchronous, forward-secret messaging for its peer-to-peer courier system using prekey bundles. These bundles contain one-time public keys that enable senders to encrypt messages without requiring an active connection to the recipient, as implemented in the BitFoundation package.

Prekey Bundle Structure and Signing

A prekey bundle is a cryptographically signed payload defined in localPackages/BitFoundation/Sources/BitFoundation/PrekeyBundle.swift. The PrekeyBundle struct encodes the data necessary for a sender to initiate a secure session.

Each bundle contains:

  • The owner's static Noise public key (noiseStaticPublicKey) — identifies the bundle's owner for signature verification.
  • An array of one-time prekeys (prekeys) — up to maxPrekeys Curve25519 key pairs, each assigned a unique id and public key.
  • Generation timestamp (generatedAt) — Unix timestamp marking bundle creation.
  • Ed25519 signature (signature) — computed over the entire payload using the signing context "bitchat-prekey-bundle-v1".

The bundle propagates through the network with TLV type MessageType.prekeyBundle (0x24), defined in localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift. This signature scheme prevents malicious actors from injecting forged prekeys into the gossip stream.

Distribution and Retrieval

Bundles circulate via the gossip protocol, allowing any peer to retrieve a recipient's latest public keys. Senders maintain a sender-side cache (PrekeyBundleStore) that ingests verified bundles and guarantees never-reused prekeys per message. When fetching a bundle for encryption, the store selects an unused prekey ID and marks it as consumed immediately, ensuring cryptographic material is never recycled.

The Encryption Workflow

Selecting a Prekey

When Alice sends a message to Bob, her client retrieves Bob's latest verified bundle from PrekeyBundleStore. The store selects a fresh prekey ID that has not been consumed, guaranteeing forward secrecy for this specific message.

Noise X Handshake

BitChat utilizes a one-way Noise X handshake pattern. The sender encrypts the payload using the recipient's selected one-time prekey public key via the sealPrekeyPayload method. This creates a ciphertext that only the holder of the corresponding private prekey can decrypt.

Courier Envelope Construction

The encrypted payload is wrapped in a CourierEnvelope, defined in localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift. The envelope includes:

  • The recipient's static tag
  • Expiry timestamp and copy count
  • The selected prekeyID as an optional TLV (type 0x05)

Upon receipt, the recipient validates the bundle signature and uses openPrekeyPayload with the specified prekeyID to decrypt the message. If the ID does not match an available unspent prekey, decryption fails, as verified in bitchatTests/Prekeys/NoisePrekeyTests.swift.

Forward Secrecy Guarantees

Because each prekey is one-time use only, the PrekeyBundleStore burns (deletes) a prekey immediately after it is used to seal a message. Compromising a user's long-term static key does not reveal the content of past courier messages, as each historical message relied on a unique, ephemeral prekey that no longer exists. Bundles expire after a configurable duration, and the sender-side cache automatically fetches fresh bundles via gossip when stale bundles are detected.

Implementation Code Examples

import CryptoKit
import Foundation

// 1️⃣ Generate a prekey bundle
let ownerStaticKey = CryptoKit.Curve25519.Signing.PrivateKey()
let prekeys = (0..<10).map { _ in
    let agreementKey = CryptoKit.Curve25519.KeyAgreement.PrivateKey()
    return PrekeyBundle.Prekey(
        id: UInt32.random(in: 0...UInt32.max),
        publicKey: agreementKey.publicKey.rawRepresentation
    )
}

var bundle = PrekeyBundle(
    noiseStaticPublicKey: ownerStaticKey.publicKey.rawRepresentation,
    prekeys: prekeys,
    generatedAt: UInt64(Date().timeIntervalSince1970),
    signature: Data()
)
// Sign with Ed25519 using context "bitchat-prekey-bundle-v1"
bundle.signature = try Crypto.sign(
    data: bundle.encode(),
    with: ownerStaticKey
)

// 2️⃣ Distribute via gossip (TLV type 0x24)
gossip.broadcast(message: .prekeyBundle(bundle.encode()))

// 3️⃣ Sender retrieves and consumes a prekey
let bobBundle = try prekeyStore.latestVerifiedBundle(for: bobID)
let freshPrekey = try prekeyStore.consumePrekey(from: bobBundle)

// 4️⃣ Seal payload with Noise X
let payload = Data("Secret message".utf8)
let sealed = try aliceNoiseEngine.sealPrekeyPayload(
    payload,
    recipientPrekey: freshPrekey
)

// 5️⃣ Build envelope with prekeyID (TLV 0x05)
let envelope = CourierEnvelope(
    recipientTag: bobTag,
    expiry: UInt64(Date().addingTimeInterval(300).timeIntervalSince1970),
    ciphertext: sealed,
    copies: 1,
    prekeyID: freshPrekey.id
)

// 6️⃣ Recipient opens using the specific prekey
let plaintext = try bobNoiseEngine.openPrekeyPayload(
    envelope.ciphertext,
    prekeyID: envelope.prekeyID!
)

Summary

  • Prekey bundles are signed collections of one-time Curve25519 public keys that enable asynchronous encryption.
  • MessageType.prekeyBundle (0x24) governs bundle propagation via gossip.
  • PrekeyBundleStore manages sender-side caching and enforces single-use prekey consumption.
  • CourierEnvelope embeds the prekeyID (TLV 0x05) required for decryption.
  • Forward secrecy is achieved by burning prekeys after use, preventing historical message decryption even if long-term keys are compromised.

Frequently Asked Questions

What is a prekey bundle in BitChat?

A prekey bundle is a data structure containing a user's static Noise public key and an array of one-time Curve25519 public keys, signed with the user's long-term Ed25519 signing key. It allows senders to encrypt messages to offline recipients by selecting a fresh ephemeral key for each message.

How does BitChat ensure forward secrecy?

BitChat ensures forward secrecy by using one-time prekeys that are destroyed immediately after use. Each courier message consumes a unique prekey from the recipient's bundle. Since these prekeys are never reused and their private components are deleted after decryption, compromising a user's device in the future does not expose past message content.

What prevents an attacker from injecting malicious prekeys?

Every prekey bundle is signed with the owner's static private key using the context string "bitchat-prekey-bundle-v1". Receivers verify this signature against the owner's well-known static public key before using any prekey from the bundle. This prevents man-in-the-middle attackers from substituting their own keys into the gossip stream.

How does the recipient know which prekey to use for decryption?

The sender includes the specific prekeyID (a UInt32 identifier) inside the CourierEnvelope as an optional TLV field (type 0x05). The recipient's PrekeyBundleStore maps this ID to the corresponding private key material, allowing the openPrekeyPayload function to decrypt the message. If the ID does not match an available unspent prekey, decryption fails.

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 →