How BitChat's Store-and-Forward Courier System Enables Offline Messaging
BitChat delivers messages to offline or out-of-range recipients through a multi-layer store-and-forward courier stack that combines local BLE mesh transport with Nostr bridge fallback.
The permissionlesstech/bitchat repository implements a robust store-and-forward courier system designed for mesh networks where peers are frequently disconnected. Rather than requiring always-on connectivity, BitChat lets messages physically travel through intermediate devices or bridge through the Internet when necessary.
Core Components of the Courier Stack
Three abstractions work together to move sealed messages across intermittent networks:
| Component | Purpose |
|---|---|
CourierEnvelope |
The atomic transport unit containing ciphertext, expiry, recipient tag, and copy budget |
MessageRouter with CourierDirectory |
Local outbox persistence and matching of envelopes to nearby couriers |
BridgeCourierService |
Internet fallback that publishes encrypted envelopes as Nostr events |
CourierEnvelope: The Encrypted Payload
Every message travels as a CourierEnvelope — a compact TLV structure defined in [localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift](https://github.com/permissionlesstech/bitchat/blob/main/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift). The envelope contains:
- Recipient tag: A deterministic identifier derived from the recipient's static Noise key and current epoch day, computed via
CourierEnvelope.recipientTag(noiseStaticKey:epochDay:) - Ciphertext: The message encrypted to the recipient's static Noise key
- Expiry timestamp: Maximum lifetime (default ~6 hours via
CourierEnvelope.maxLifetimeSeconds) - Copy budget (
copies): How many redundant couriers may carry this envelope (defaultCourierEnvelope.maxCopies)
The envelope reveals no routing metadata — only the recipient can decrypt, and the tag alone cannot identify the recipient without the corresponding key.
MessageRouter: Local Store-and-Forward
When direct delivery fails, [bitchat/Services/MessageRouter.swift](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Services/MessageRouter.swift) handles persistence and courier discovery:
- Outbox storage: Envelopes are written to the Message Outbox Store (verified in [
MessageOutboxStoreTests.swift](https://github.com/permissionlesstech/bitchat/blob/main/bitchatTests/Services/MessageOutboxStoreTests.swift)) - Courier directory: Nearby devices broadcast favorite courier keys — essentially public static Noise keys they are willing to transport messages for
- Deposit matching: The router compares the envelope's
recipientTagagainst advertised courier keys; on match, it marks the outbox entry as deposited and hands the envelope to the carrier
The directory construction logic is exercised in [MessageRouterTests.swift](https://github.com/permissionlesstech/bitchat/blob/main/bitchatTests/Services/MessageRouterTests.swift#L458-L461) around lines 458-461.
BridgeCourierService: Internet Fallback
When local couriers remain unavailable, [bitchat/Services/BridgeCourierService.swift](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Services/BridgeCourierService.swift) provides the bridge path:
- Creates drop events (
NostrEventstructs) containing one or moreCourierEnvelopeinstances - Signs the event and publishes to a Nostr relay
- Recipients periodically fetch drops, verify the envelope tag matches their key, and open the ciphertext
The bridge path is documented in the WHITEPAPER.md store-and-forward section. Expiration logic is validated in [BridgeCourierServiceTests.swift](https://github.com/permissionlesstech/bitchat/blob/main/bitchatTests/Services/BridgeCourierServiceTests.swift#L319-L321) at lines 319-321.
Store-and-Forward Workflow Step by Step
1. Sealing and Outbox Persistence
// Create recipient tag from static Noise key and current epoch day
let recipientKey = bobNoiseStaticKey // 32-byte X25519 public key
let ciphertext = try encrypt(message, with: recipientKey)
let envelope = CourierEnvelope(
recipientTag: CourierEnvelope.recipientTag(
noiseStaticKey: recipientKey,
epochDay: CourierEnvelope.epochDay(for: Date())
),
expiry: Date().addingTimeInterval(CourierEnvelope.maxLifetimeSeconds),
ciphertext: ciphertext,
copies: 3, // spray-and-wait redundancy
prekeyID: nil // optional for future extensions
)
// Persist to outbox and attempt delivery
await messageRouter.sendPrivate(
messageID: "msg-123",
recipient: bobPeerID,
envelope: envelope
)
The sendPrivate(messageID:recipient:envelope:) method in MessageRouter.swift either transmits immediately via an active Noise session or queues for courier pickup.
2. Courier Discovery and Deposit
Nearby peers advertising compatible courier keys trigger the deposit mechanism. The router:
- Scans the
CourierDirectoryforrecipientTagmatches - Transfers the envelope to the courier device
- Decrements the copy budget and marks outbox status
Physical transport metrics are recorded via [StoreAndForwardMetrics.swift](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Services/Courier/StoreAndForwardMetrics.swift), tracking drops sent and copies delivered without revealing content or participants.
3. Bridge Drop Creation (Fallback)
// If no courier appears before deadline, bridge to Nostr
let bridgeService = BridgeCourierService(
now: Date.now,
dedupStore: DedupStore()
)
let dropEvent = try bridgeService.makeDropEvent(for: envelope)
try await nostrClient.publish(event: dropEvent)
The makeDropEvent(for:) method batches envelopes, signs the Nostr event, and sends to configured relays. Recipients use standard Nostr filters to fetch drops matching their tags.
4. Delivery and Cleanup
Upon successful decryption:
- The recipient's device opens the envelope using its static Noise private key
- The sender's outbox deletes the corresponding entry
- Held copies on couriers expire silently after
maxLifetimeSeconds
Privacy and Reliability Guarantees
BitChat's store-and-forward courier system provides three key properties:
- Spray-and-wait reliability: Multiple redundant copies (
copies> 1) ensure at least one carrier succeeds before expiry - Sealed privacy: Envelopes are opaque to couriers; only the recipient's static key can decrypt
- Bounded ephemerality: Hard expiration prevents indefinite storage and metadata accumulation
Summary
- CourierEnvelope is the encrypted, tagged, expiring unit of transport in [
CourierEnvelope.swift](https://github.com/permissionlesstech/bitchat/blob/main/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift) - MessageRouter coordinates outbox persistence and local courier matching in [
MessageRouter.swift](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Services/MessageRouter.swift) - BridgeCourierService provides Internet fallback via Nostr in [
BridgeCourierService.swift](https://github.com/permissionlesstech/bitchat/blob/main/bitchat/Services/BridgeCourierService.swift) - The complete workflow involves sealing, outbox storage, courier discovery, physical transport, optional bridge fallback, and expiration-based cleanup
Frequently Asked Questions
What happens if no courier is available before the envelope expires?
The MessageRouter automatically triggers the bridge path. BridgeCourierService creates a signed Nostr event containing the envelope and publishes it to relays. The recipient can then fetch the drop using standard Nostr subscription filters. This ensures delivery even in partitioned mesh networks.
How does BitChat prevent couriers from reading message contents?
Envelopes are sealed with the recipient's static Noise key before ever entering the courier system. The CourierEnvelope only exposes a truncated recipient tag (derived from the key and epoch day) and opaque ciphertext. Couriers match tags to advertised keys without possessing the private key required for decryption. This design provides end-to-end encryption independent of transport path.
Why does the recipient tag include an epoch day component?
The epoch day (days since Unix epoch) creates time-bounded identifiers that rotate daily. This prevents long-term correlation of recipient tags across days, limiting the metadata exposure that couriers or Nostr relays could accumulate. A recipient's tag today differs from yesterday's, yet remains deterministic for legitimate senders who know the static key and current date.
What limits how many copies a single envelope can generate?
The copies field in CourierEnvelope enforces a copy budget — typically defaulting to CourierEnvelope.maxCopies. Each handoff to a courier decrements this counter. When copies reaches zero, no further deposits occur. This spray-and-wait approach bounds network overhead while providing statistical reliability through redundant paths.
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 →