How the BitChat Courier System Delivers Messages to Offline Peers: Store-and-Forward Architecture Explained
BitChat uses a store-and-forward mechanism that encrypts messages into rotating-tag envelopes and deposits them on Nostr relays or trusted physical couriers, allowing delivery to peers even when they are offline.
The BitChat courier system solves the offline messaging problem for a decentralized mesh network. When direct peer-to-peer delivery fails because the recipient is unreachable, the protocol falls back to a privacy-preserving store-and-forward layer. This architecture uses daily rotating recipient tags, ephemeral Nostr drops, and trusted physical couriers to ensure message reliability without compromising metadata privacy.
The Courier Envelope Structure
At the heart of the offline delivery mechanism lies the CourierEnvelope, an opaque TLV (Type-Length-Value) payload defined in localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift. This structure encapsulates the encrypted message alongside metadata required for routing and privacy.
Each envelope contains four critical fields:
- Recipient Tag: A 16-byte rotating identifier derived from the recipient’s Noise static public key and the current UTC day
- Expiry Timestamp: A Unix millisecond timestamp limiting envelope lifetime (default 24 hours)
- Copy Budget: A counter allowing limited re-transmission by intermediate couriers
- Ciphertext: The Noise-X encrypted message payload
Rotating Recipient Tags for Privacy
The recipient tag provides metadata resistance through daily rotation. The system calculates the tag using CourierEnvelope.recipientTag(noiseStaticKey:epochDay:), combining the peer’s long-term Noise public key with the current epoch day. Because the tag changes every 24 hours, external observers cannot link drops targeting the same recipient across different days without prior knowledge of the recipient’s public key.
The receiver generates candidate tags for the current day and adjacent days using CourierEnvelope.candidateTags, allowing retrieval even if the sender and receiver operate in different time zones or the message crosses a day boundary during transit.
Expiry and Copy Budget
The maxLifetimeSeconds constant (86,400 seconds / 24 hours) ensures that stale messages do not accumulate on relays. The copy budget, typically set to 2, permits one re-spray by an intermediate courier while preventing infinite replication that could flood the network.
Bridge Courier Service: The Internet Path
When local mesh delivery fails, bitchat/Services/Gateway/BridgeCourierService.swift implements the internet bridge path. This service publishes encrypted envelopes as drops on Nostr relays using kind-1401 events.
Publishing Drops to Nostr Relays
The sender’s router calls BridgeCourierService.shared.depositDrop(envelope:), which signs the drop with a fresh throw-away key. This ephemeral signature prevents correlation between the sender’s identity and the envelope. The service publishes the drop to configured relays where it persists for up to 24 hours.
// Publish the envelope as a drop via the bridge service
BridgeCourierService.shared.depositDrop(envelope) { ok in
print("Drop published to relays:", ok)
}
Subscribing to Candidate Tags
On the receiving side, BridgeCourierService maintains a subscription to the three candidate tags (yesterday, today, tomorrow) for the local peer. When a matching drop arrives, the service invokes openEnvelope, which attempts decryption and processes the plaintext through the standard message pipeline.
// Receiver – open matching drops (usually called automatically)
BridgeCourierService.shared.openEnvelope = { env in
// Attempt decryption; return true if envelope handled (delivered or deduped)
return try? MessageRouter.shared.processCourierEnvelope(env) != nil
}
If a verified local mesh peer is watching the same tags, the service can forward the drop directly via deliverToPeer, creating a hybrid internet-to-mesh bridge.
Physical Courier Path: Trusted Peer Forwarding
BitChat also supports physical couriers—online favorite peers who store and forward envelopes for offline contacts. When the sender cannot reach the recipient directly and prefers not to use the public bridge, the router advertises the envelope to its list of trusted couriers.
The courier receives the envelope if the recipient tag matches any peer in its favorite list. Because the courier only handles the encrypted envelope and rotating tag, it never learns the message contents or the ultimate recipient’s identity. When the offline peer reconnects, the courier transmits the envelope directly via the local mesh transport.
// Physical courier – forward a held envelope to a local peer
BridgeCourierService.shared.deliverToPeer = { env, peerID in
// Hand the envelope to the peer’s transport layer.
// Return true only if the transport accepted the packet.
return PeerTransport.shared.send(env, to: peerID)
}
End-to-End Delivery Flow
The complete offline delivery sequence involves three distinct phases:
-
Envelope Creation:
MessageRoutergenerates aCourierEnvelopewith a recipient tag valid for the current epoch day, encrypts the payload using Noise-X, and sets the expiry and copy budget. -
Bridge Deposition: The router calls
depositDrop, publishing a kind-1401 Nostr event signed with a throw-away key. The drop remains available on relays for 24 hours. -
Retrieval and Decryption: The recipient’s
BridgeCourierServicemonitors candidate tags, retrieves matching drops, and invokesopenEnvelope. Upon successful decryption,MessageRouter.processCourierEnvelopeinjects the message into the local conversation stream.
Implementation Example
The following Swift code demonstrates creating a courier envelope for an offline peer and configuring the bridge service handlers:
import BitFoundation
import BitChat
// Create a courier envelope for an offline peer
let recipientStaticKey: Data = /* peer’s Noise static key */
let epochDay = CourierEnvelope.epochDay(for: Date())
let tag = CourierEnvelope.recipientTag(
noiseStaticKey: recipientStaticKey,
epochDay: epochDay)
// Encrypt the message (Noise-X) – omitted for brevity
let ciphertext = /* encrypted payload */
let envelope = CourierEnvelope(
recipientTag: tag,
expiry: UInt64(Date().addingTimeInterval(24*60*60).timeIntervalSince1970 * 1000),
ciphertext: ciphertext,
copies: 2) // allow one re-spray by a courier
// Configure bridge service handlers
BridgeCourierService.shared.depositDrop(envelope) { success in
print("Drop status:", success)
}
BridgeCourierService.shared.openEnvelope = { envelope in
return (try? MessageRouter.shared.processCourierEnvelope(envelope)) != nil
}
Unit tests in bitchatTests/Services/BridgeCourierServiceTests.swift exercise this full flow, simulating envelope creation, drop deposit, and delivery via both bridge and physical courier paths.
Summary
- BitChat’s courier system uses store-and-forward semantics to reach offline peers through two paths: a public Nostr bridge and private physical couriers.
- Rotating recipient tags (16 bytes, daily rotation) protect metadata while allowing recipients to poll for messages using
candidateTags. - The bridge service publishes encrypted drops as kind-1401 Nostr events with ephemeral signatures, storing them for 24 hours maximum.
- Physical couriers (trusted favorite peers) can receive, hold, and forward envelopes without decrypting contents, creating a resilient fallback when internet connectivity is unavailable.
- All envelope logic resides in
CourierEnvelope.swift, while deposition and retrieval implement theBridgeCourierServiceprotocol defined inBridgeCourierService.swift.
Frequently Asked Questions
How does BitChat prevent observers from linking messages to specific users?
BitChat derives the recipient tag from the Noise static public key combined with the current UTC day, producing a value that changes every 24 hours. Because the tag is not the public key itself, an observer who sees a drop on a Nostr relay cannot determine the recipient’s identity or link drops across different days without prior knowledge of the recipient’s key.
What happens if a recipient is offline for more than 24 hours?
The CourierEnvelope specifies a maximum lifetime of 86,400 seconds (24 hours) in the expiry field. If the recipient does not retrieve the drop from relays within this window, the message expires and the drop becomes invalid. Senders must rely on physical couriers or retry delivery once the recipient reconnects to the mesh.
Can physical couriers read the messages they carry?
No. Physical couriers handle only the encrypted CourierEnvelope and match it using the rotating recipient tag. They never possess the decryption keys required to open the Noise-X ciphertext. The courier merely stores opaque bytes and forwards them to the matching peer ID when connectivity becomes available.
How does the receiver know which drops to download from Nostr relays?
The receiver calculates candidate tags for the current day and the adjacent days (yesterday and tomorrow) using CourierEnvelope.candidateTags. The BridgeCourierService subscribes to these three tags on connected relays, ensuring it captures drops sent slightly before or after midnight regardless of time zone differences.
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 →