# How Prekey Bundles Enable Asynchronous Encrypted Messaging in BitChat

> Learn how BitChat uses prekey bundles to enable asynchronous encrypted messaging. Send messages securely even when recipients are offline, without real-time key exchange.

- Repository: [permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat)
- Tags: deep-dive
- Published: 2026-08-04

---

**Prekey bundles in BitChat allow senders to encrypt messages using one-time Curve25519 public keys published by offline recipients, enabling true asynchronous end-to-end encryption without real-time key exchange.**

BitChat's messaging protocol solves the fundamental challenge of encrypted chat: how to send a secure message when the recipient isn't online to perform a handshake. The answer lies in **prekey bundles**—cryptographically signed batches of disposable public keys that recipients publish to the gossip network ahead of time. This article examines how the `permissionlesstech/bitchat` repository implements this pattern across its Swift codebase.

---

## What Is a Prekey Bundle?

A `PrekeyBundle` is a self-contained structure that packages a user's long-term identity with ephemeral encryption keys. According to the source code in [[`PrekeyBundle.swift`](https://github.com/permissionlesstech/bitchat/blob/main/PrekeyBundle.swift)](https://github.com/permissionlesstech/bitchat/blob/main/localPackages/BitFoundation/Sources/BitFoundation/PrekeyBundle.swift#L11-L20), each bundle contains:

- The owner's **static Noise public key** (persistent identity)
- Up to **8 one-time prekeys** (fresh Curve25519 key pairs)
- A **generation timestamp** for freshness validation
- An **Ed25519 signature** binding the entire payload to the owner's verified signing key

The signature is computed over deterministic bytes returned by `signableBytes()`, enabling **offline verification** by any peer without contacting the bundle owner.

```swift
let bundle = PrekeyBundle(
    noiseStaticPublicKey: noiseKey,
    prekeys: prekeys,
    generatedAt: UInt64(Date().timeIntervalSince1970 * 1000),
    signature: signingKey.sign(data: bundleTemplate.signableBytes())
)

```

---

## Gossip Distribution and Message Type 0x24

BitChat distributes prekey bundles through its **gossip synchronization layer**. Bundles are wrapped in `BitchatPacket` structures with `MessageType.prekeyBundle` (hex value `0x24`), as defined in [[`MessageType.swift`](https://github.com/permissionlesstech/bitchat/blob/main/MessageType.swift)](https://github.com/permissionlesstech/bitchat/blob/main/localPackages/BitFoundation/Sources/BitFoundation/MessageType.swift#L28-L30).

This design choice is critical for asynchronous encrypted messaging: because bundles are **independently verifiable**, they can propagate through the network, survive node restarts, and remain valid for days without requiring direct peer contact.

---

## PrekeyBundleStore: Caching and Freshness Enforcement

On the sender side, [[`PrekeyBundleStore.swift`](https://github.com/permissionlesstech/bitchat/blob/main/PrekeyBundleStore.swift)](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Services/Prekeys/PrekeyBundleStore.swift#L13-L22) manages the lifecycle of received bundles. The store implements several safeguards:

- **Deduplication by identity**: Tracks the latest bundle per `noiseStaticPublicKey`
- **Age threshold**: Enforces `maxBundleAgeForSealingSeconds` (~7 days) to prevent use of stale keys
- **Memory bounding**: Caps cached peers to prevent unbounded growth
- **Signature verification**: Rejects bundles with invalid cryptographic proofs

```swift
PrekeyBundleStore.shared.ingest(bundle)

```

Once verified, bundles enter the assignment pool for message sealing.

---

## Prekey Assignment: Guaranteeing Forward Secrecy

The [`assignPrekey`](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Services/Prekeys/PrekeyBundleStore.swift#L16-L23) method implements careful key selection to prevent reuse attacks:

1. **Re-deposit detection**: If a `messageID` was previously assigned, return the same prekey (prevents double-consumption during retries)
2. **Fresh selection**: Otherwise, pick the lowest unused prekey ID, mark it consumed, and record the assignment

This ensures **no prekey is ever reused across distinct messages**, preserving **forward secrecy**—compromising one message key reveals nothing about past or future communications.

```swift
guard let prekey = PrekeyBundleStore.shared.assignPrekey(
    messageID: messageID,
    recipientNoiseKey: noiseKey
) else {
    // Fallback to static sealing if bundle exhausted
}

```

---

## Sealing Messages with CourierEnvelope

Selected prekeys integrate into the encryption pipeline through [[`CourierEnvelope.swift`](https://github.com/permissionlesstech/bitchat/blob/main/CourierEnvelope.swift)](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Services/CourierEnvelope.swift#L33-L38). The envelope carries the prekey identifier in TLV field `0x05`, enabling the recipient to locate the matching private key during decryption.

The actual encryption uses **Noise X handshake** with the one-time prekey as the recipient's static key:

```swift
let envelope = try CourierEnvelope(
    recipientTag: recipientTag,
    expiry: UInt64(Date().timeIntervalSince1970 * 1000 + 60_000),
    ciphertext: payload,
    copies: 1,
    prekeyID: prekey.id  // TLV 0x05
).seal(using: prekey.publicKey)  // Noise-X one-way encryption

```

Because the prekey public value is publicly known, the sender completes encryption **unilaterally**—no online recipient required.

---

## Asynchronous Delivery and Decryption

The complete flow demonstrates how prekey bundles enable asynchronous encrypted messaging:

| Phase | Action | Location |
|-------|--------|----------|
| Publish | Owner generates bundle, signs, and gossips | [`PrekeyBundle.swift`](https://github.com/permissionlesstech/bitchat/blob/main/PrekeyBundle.swift) |
| Cache | Sender verifies and stores bundle | [`PrekeyBundleStore.swift`](https://github.com/permissionlesstech/bitchat/blob/main/PrekeyBundleStore.swift) |
| Assign | Sender reserves unique prekey for message | `assignPrekey()` |
| Seal | Noise-X encryption with selected prekey | [`CourierEnvelope.swift`](https://github.com/permissionlesstech/bitchat/blob/main/CourierEnvelope.swift) |
| Transmit | Envelope routed via courier pipeline | [`GossipSyncManager.swift`](https://github.com/permissionlesstech/bitchat/blob/main/GossipSyncManager.swift) |
| Receive | Recipient fetches envelope when online | Courier delivery |
| Decrypt | Matching private prekey unlocks payload; key discarded | Recipient's key store |

The recipient's private prekey is **immediately destroyed after decryption**, ensuring forward secrecy for all subsequent messages.

---

## Complete Implementation Example

```swift
import BitFoundation
import bitchat

// Owner: Create and publish prekey bundle
let noiseKey = Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
let prekeys = (0..<8).map { i in
    PrekeyBundle.Prekey(
        id: UInt32(i),
        publicKey: Curve25519.KeyAgreement.PrivateKey().publicKey.rawRepresentation
    )
}
let bundle = PrekeyBundle(
    noiseStaticPublicKey: noiseKey,
    prekeys: prekeys,
    generatedAt: UInt64(Date().timeIntervalSince1970 * 1000),
    signature: mySigningKey.sign(data: PrekeyBundle(
        noiseStaticPublicKey: noiseKey,
        prekeys: prekeys,
        generatedAt: 0,
        signature: Data()
    ).signableBytes())
)

// Sender: Ingest, assign, and seal
PrekeyBundleStore.shared.ingest(bundle)

let messageID = "msg-\(UUID().uuidString)"
guard let prekey = PrekeyBundleStore.shared.assignPrekey(
    messageID: messageID,
    recipientNoiseKey: noiseKey
) else { fatalError("No usable prekey available") }

let payload = Data("Asynchronous secure delivery".utf8)
let envelope = try CourierEnvelope(
    recipientTag: recipientTag,
    expiry: UInt64(Date().timeIntervalSince1970 * 1000 + 86_400_000),
    ciphertext: payload,
    copies: 1,
    prekeyID: prekey.id
).seal(using: prekey.publicKey)

courier.send(envelope)

```

---

## Summary

- **Prekey bundles** package signed, timestamped batches of one-time Curve25519 keys for offline use
- **Offline verification** via Ed25519 signatures allows gossip-wide distribution without trust assumptions
- **PrekeyBundleStore** enforces freshness, bounded memory, and strict no-reuse policies for forward secrecy
- **Noise X handshake** with assigned prekeys enables unilateral encryption without recipient presence
- **Automatic key destruction** after decryption ensures compromise recovery and future message protection

---

## Frequently Asked Questions

### How does BitChat prevent prekey reuse attacks?

The `assignPrekey` method in [`PrekeyBundleStore.swift`](https://github.com/permissionlesstech/bitchat/blob/main/PrekeyBundleStore.swift) maintains an assignment table mapping `messageID` to consumed prekey IDs. Each prekey is marked used upon first assignment and never reassigned to a different message. Re-deposits of the same message ID receive the identical prekey to prevent double-consumption, but distinct messages always receive unique keys.

### What happens when a prekey bundle expires or empties?

Bundles exceeding `maxBundleAgeForSealingSeconds` (~7 days) are rejected by `PrekeyBundleStore`. If all prekeys in a bundle are consumed, `assignPrekey` returns nil, forcing fallback to **static sealing**—encrypting directly to the recipient's long-term Noise key with reduced forward secrecy guarantees until a fresh bundle arrives.

### Why does BitChat limit bundles to 8 prekeys?

The 8-prekey cap balances **availability** (sufficient for moderate message volumes) with **storage efficiency** and **identity rotation agility**. More prekeys would increase bundle size for gossip transmission and delay the natural key refresh cycle that occurs when owners publish updated bundles.

### Can prekey bundles be used for group messaging?

Current `CourierEnvelope` and `PrekeyBundleStore` implementations target **one-to-one asynchronous encrypted messaging**. Each recipient requires their own bundle ingestion and individual envelope sealing. Group messaging would require parallel envelope generation or a derived group key mechanism not present in the analyzed source files.