How Prekey Bundles Enable Asynchronous Encrypted Messaging in BitChat
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/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.
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/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/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
PrekeyBundleStore.shared.ingest(bundle)
Once verified, bundles enter the assignment pool for message sealing.
Prekey Assignment: Guaranteeing Forward Secrecy
The assignPrekey method implements careful key selection to prevent reuse attacks:
- Re-deposit detection: If a
messageIDwas previously assigned, return the same prekey (prevents double-consumption during retries) - 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.
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/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:
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 |
| Cache | Sender verifies and stores bundle | PrekeyBundleStore.swift |
| Assign | Sender reserves unique prekey for message | assignPrekey() |
| Seal | Noise-X encryption with selected prekey | CourierEnvelope.swift |
| Transmit | Envelope routed via courier pipeline | 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
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 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.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →