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

> Discover how BitChat uses prekey bundles for forward-secret courier mail encryption. Learn about one-way Noise X handshakes and unique one-time keys for secure messaging. permissionlesstech/bitchat

- Repository: [permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat)
- Tags: internals
- Published: 2026-07-27

---

**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`](https://github.com/permissionlesstech/bitchat/blob/main/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`](https://github.com/permissionlesstech/bitchat/blob/main/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`](https://github.com/permissionlesstech/bitchat/blob/main/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`](https://github.com/permissionlesstech/bitchat/blob/main/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

```swift
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.