diff --git a/WHITEPAPER.md b/WHITEPAPER.md
index 6ad839d7..7716850c 100644
--- a/WHITEPAPER.md
+++ b/WHITEPAPER.md
@@ -1,309 +1,141 @@
-# BitChat Protocol Whitepaper
+# bitchat Protocol Whitepaper
-**Version 1.1**
+**Version 2.0**
-**Date: July 25, 2025**
+**Date: July 6, 2026**
---
## Abstract
-BitChat is a decentralized, peer-to-peer messaging application designed for secure, private, and censorship-resistant communication over ephemeral, ad-hoc networks. This whitepaper details the BitChat Protocol Stack, a layered architecture that combines a modern cryptographic foundation with a flexible application protocol. At its core, BitChat leverages the Noise Protocol Framework (specifically, the `XX` pattern) to establish mutually authenticated, end-to-end encrypted sessions between peers. This document provides a technical specification of the identity management, session lifecycle, message framing, and security considerations that underpin the BitChat network.
+bitchat is a decentralized, peer-to-peer messaging application for secure, private, censorship-resistant communication that works with or without the internet. Nearby devices form an ad-hoc Bluetooth Low Energy (BLE) mesh; distant peers are reached over the Nostr protocol when a connection exists. A layered store-and-forward stack — a persistent sender outbox, opportunistic couriers with a spray-and-wait copy budget, gossip-synced public history, and Nostr relay mailboxes — delivers messages to peers who are out of range at send time. This document describes the protocol and its delivery guarantees as implemented.
---
-## 1. Introduction
+## 1. Design Goals
-In an era of centralized communication platforms, BitChat offers a resilient alternative by operating without central servers. It is designed for scenarios where internet connectivity is unavailable or untrustworthy, such as protests, natural disasters, or remote areas. Communication occurs directly between devices over transports like Bluetooth Low Energy (BLE).
+* **Confidentiality:** all private communication is end-to-end encrypted; intermediate nodes and couriers carry only opaque ciphertext.
+* **Authentication:** peers are identified by cryptographic keys; announcements are signed and verified.
+* **Resilience:** the network functions in lossy, low-bandwidth, partitioned environments with churning membership.
+* **Eventual delivery:** a message to an out-of-range peer should still arrive — relayed by the mesh, carried by a moving person, or resting on an internet relay — within a bounded retention window.
+* **Ephemerality by default:** no plaintext message content is ever written to disk. Everything the store-and-forward stack persists is either sealed ciphertext or already-public broadcast traffic, and all of it dies with the panic wipe.
-The design goals of the BitChat Protocol are:
+## 2. Architecture Overview
-* **Confidentiality:** All communication must be unreadable to third parties.
-* **Authentication:** Users must be able to verify the identity of their correspondents.
-* **Integrity:** Messages cannot be tampered with in transit.
-* **Forward Secrecy:** The compromise of long-term identity keys must not compromise past session keys.
-* **Deniability:** It should be difficult to cryptographically prove that a specific user sent a particular message.
-* **Resilience:** The protocol must function reliably in lossy, low-bandwidth environments.
+Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
-This paper specifies the technical details of the protocol designed to meet these goals.
+* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts.
+* **Nostr** — private messages to mutual favorites travel as NIP-17 gift-wrapped events over public relays (over Tor where enabled), bridging separate meshes through the internet.
----
+The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly.
-## 2. Protocol Stack
+## 3. Identity
-The BitChat Protocol is a four-layer stack. This layered approach separates concerns, allowing for modularity and future extensibility.
+Each device holds two long-term key pairs in the Keychain:
-```mermaid
-graph TD
- A[Application Layer] --> B[Session Layer];
- B --> C[Encryption Layer];
- C --> D[Transport Layer];
+* a **Curve25519 static key** for Noise key agreement — its SHA-256 fingerprint is the peer's stable identity, and
+* an **Ed25519 signing key** for packet signatures.
- subgraph "BitChat Application"
- A
- end
+On the mesh, peers appear under short ephemeral IDs derived per session; favoriting pins the full Noise public key so identity survives across sessions. Mutual favorites also exchange Nostr public keys for the internet path. Optional QR verification binds a nickname to a fingerprint in person.
- subgraph "Message Framing & State"
- B
- end
+## 4. BLE Mesh Layer
- subgraph "Noise Protocol Framework"
- C
- end
+### 4.1 Packet Format
- subgraph "BLE, Wi-Fi Direct, etc."
- D
- end
+A compact binary header (version, type, TTL, timestamp, flags) is followed by an 8-byte sender ID, an optional 8-byte recipient ID, the payload, and an optional Ed25519 signature. Version 2 packets may carry an explicit source route. Signatures exclude the TTL byte so relays can decrement it without invalidating them. Packets other than fragments are padded toward uniform sizes.
- style A fill:#cde4ff
- style B fill:#b5d8ff
- style C fill:#9ac2ff
- style D fill:#7eadff
-```
+### 4.2 Flood Control
-* **Application Layer:** Defines the structure of user-facing messages (`BitchatMessage`), acknowledgments (`DeliveryAck`), and other application-level data.
-* **Session Layer:** Manages the overall communication packet (`BitchatPacket`). This includes routing information (TTL), message typing, fragmentation, and serialization into a compact binary format.
-* **Encryption Layer:** Establishes and manages secure channels using the Noise Protocol Framework. It is responsible for the cryptographic handshake, session management, and transport message encryption/decryption.
-* **Transport Layer:** The underlying physical medium used for data transmission, such as Bluetooth Low Energy (BLE). This layer is abstracted away from the core protocol.
+Relaying is a deterministic controlled flood tuned by local connection degree:
----
+* **TTL:** packets originate with TTL 7. Relays clamp: dense graphs (≥ 6 links) cap broadcast TTL at 5; thin chains (≤ 2 links) relay at full incoming depth.
+* **Deduplication:** an LRU seen-set (1000 entries, 5-minute expiry) keyed by sender, timestamp, type, and a payload digest drops duplicates. A scheduled relay is cancelled when a duplicate arrives first from another relay.
+* **Jitter:** relays wait a random 10–220 ms (wider when dense) so duplicate suppression wins often.
+* **Fanout subsetting:** broadcast messages are re-sent to a deterministic, message-ID-seeded subset of links (~log₂ of degree) rather than all of them; announces, fragments, and sync packets use full fanout. The ingress link is always excluded (split horizon).
+* **Directed traffic** (handshakes, private messages, courier envelopes) relays deterministically with TTL − 1 and tight jitter, and is never subset.
-## 3. Identity and Key Management
+### 4.3 Routing
-A peer's identity in BitChat is defined by two persistent cryptographic key pairs, which are generated on first launch and stored securely in the device's Keychain.
+Announcements carry up to 10 direct-neighbor IDs, giving each node a shallow topology map (60 s freshness). When a bidirectionally-confirmed path exists, packets are source-routed along it; otherwise — and whenever a route fails — delivery falls back to flooding.
-1. **Noise Static Key Pair (`Curve25519`):** This is the long-term identity key used for the Noise Protocol handshake. The public part of this key is shared with peers to establish secure sessions.
-2. **Signing Key Pair (`Ed25519`):** This key is used to sign announcements and other protocol messages where non-repudiation is required, such as binding a public key to a nickname.
+### 4.4 Fragmentation
-### 3.1. Fingerprint
+Packets exceeding the link MTU split into ~469-byte fragments (8-byte fragment ID, index/total header) that relay independently and reassemble at each receiving node (128 concurrent assemblies, 30 s timeout, 1 MiB cap).
-A user's unique, verifiable fingerprint is the **SHA-256 hash** of their **Noise static public key**. This provides a user-friendly and secure way to verify an identity out-of-band (e.g., by reading it aloud or scanning a QR code).
+### 4.5 Presence
-`Fingerprint = SHA256(StaticPublicKey_Curve25519)`
+Signed announcements propagate multi-hop: every 4 s while isolated, backing off to ~15–30 s (jittered) when connected. A verified announce retains a peer as *reachable* for 60 s after last contact. Connection scheduling is RSSI-gated with duty-cycled scanning to bound battery drain.
-### 3.2. Identity Management
+## 5. Encryption
-The `SecureIdentityStateManager` class is responsible for managing all cryptographic identity material and social metadata (petnames, trust levels, etc.). It uses an in-memory cache for performance and persists this cache to the Keychain after encrypting it with a separate AES-GCM key.
+### 5.1 Live Sessions: Noise XX
----
+Connected peers establish sessions with the Noise `XX` pattern (Curve25519 / ChaCha20-Poly1305 / SHA-256), providing mutual authentication and forward secrecy. All private payloads — messages, delivery acks, read receipts — ride inside the session as typed ciphertext. Intermediate relays see only opaque `noiseEncrypted` packets.
-## 4. The Social Trust Layer
+### 5.2 Offline Seals: Noise X
-Beyond cryptographic identity, BitChat incorporates a social trust layer, allowing users to manage their relationships with peers. This functionality is handled by the `SecureIdentityStateManager`.
+Courier envelopes are sealed to the recipient's *static* key with the one-way Noise `X` pattern; the sender's identity is authenticated inside the ciphertext. **This path has no forward secrecy** — compromise of the recipient's static key exposes sealed-but-undelivered mail. A prekey scheme is future work.
-### 4.1. Peer Verification
+### 5.3 Nostr Path
-While the Noise handshake cryptographically authenticates a peer's key, it doesn't confirm the real-world identity of the person holding the device. To solve this, users can perform out-of-band (OOB) verification by comparing fingerprints. Once a user confirms that a peer's fingerprint matches the one they expect, they can mark that peer as "verified". This status is stored locally and displayed in the UI, providing a strong assurance of identity for future conversations.
+Private messages to mutual favorites are wrapped per NIP-17/NIP-59: a rumor (kind 14) sealed (kind 13) and gift-wrapped (kind 1059) under a throwaway ephemeral key, so relays learn neither sender nor content.
-### 4.2. Favorites and Blocking
+## 6. Store and Forward
-To improve the user experience and provide control over interactions, the protocol supports:
-* **Favorites:** Users can mark trusted or frequently contacted peers as "favorites". This is a local designation that can be used by the application to prioritize notifications or display peers more prominently.
-* **Blocking:** Users can block peers. When a peer is blocked, the application will discard any incoming packets from that peer's fingerprint at the earliest possible stage, effectively silencing them without notifying the blocked peer.
+Four mechanisms cover the "recipient is not here right now" problem. All persisted state is wiped by panic mode.
----
+### 6.1 Sender Outbox
-## 5. The Noise Protocol Layer
+Private messages without a prompt route are retained per peer (100 messages/peer, 24 h TTL) and re-sent on reconnect events until a delivery or read ack clears them, or a resend cap (8 attempts) drops them with visible failure. The outbox persists to disk sealed under a ChaChaPoly key held only in the Keychain, so queued mail survives an app kill without ever storing plaintext.
-BitChat implements the Noise Protocol Framework to provide strong, authenticated end-to-end encryption.
+### 6.2 Couriers
-### 5.1. Protocol Name
+When no transport can deliver promptly, the message is sealed (§5.2) into a **courier envelope** and handed to up to 3 connected peers who may physically encounter the recipient:
-The specific Noise protocol implemented is:
+* **Opaque addressing.** The only routing information is a 16-byte rotating recipient tag — an HMAC of the recipient's static key and the UTC day — computable solely by parties who already know that key. Couriers learn neither sender, recipient, nor content, and tags do not correlate across days.
+* **Trust tiers.** Mutual favorites may deposit 5 envelopes each; any peer with a signature-verified announce may deposit 2, into a bounded pool (20 of 40 slots) that can never crowd out favorites' mail. Envelopes are capped at 16 KiB and 24 h; overflow evicts oldest verified-tier mail first.
+* **Deposit retry.** Queued messages are re-deposited whenever a new eligible courier connects, until 3 distinct couriers carry the message or it expires.
+* **Spray and wait.** Envelopes carry a copy budget (initially 4, capped at 8). A courier meeting another eligible courier hands over half its remaining budget, so mail diffuses through a moving crowd instead of riding one person. Budgets, spray history, and carried mail persist across app restarts (iOS file protection).
+* **Handover.** On a verified *direct* announce from the recipient, matching envelopes are delivered over the live link and removed. On a verified *relayed* announce, a copy floods toward the recipient as a directed packet while the carried original stays put, throttled to one attempt per envelope per 10 minutes.
+* Receivers dedup by message ID, so redundant copies and the retained outbox original are harmless. Couriered mail from blocked senders is dropped at decryption time.
-**`Noise_XX_25519_ChaChaPoly_SHA256`**
+### 6.3 Public History (Gossip Sync)
-* **`XX` Pattern:** This handshake pattern provides mutual authentication and forward secrecy. It does not require either party to know the other's static public key before the handshake begins. The keys are exchanged and authenticated during the three-part handshake. This is ideal for a decentralized P2P environment.
-* **`25519`:** The Diffie-Hellman function used is Curve25519.
-* **`ChaChaPoly`:** The AEAD (Authenticated Encryption with Associated Data) cipher is ChaCha20-Poly1305.
-* **`SHA256`:** The hash function used for all cryptographic hashing operations is SHA-256.
+Public broadcast messages are cached (1000 packets) and reconciled between peers every ~15 s using compact GCS filters: each side advertises what it holds, the other returns what is missing. Messages stay sync-able for **6 hours** and the cache persists to disk, so a device that walks between two partitions — or relaunches later — serves the room's recent history to whoever missed it. Fragments and file transfers keep a short 15-minute window.
-### 5.2. The `XX` Handshake
+### 6.4 Nostr Mailboxes
-The `XX` handshake consists of three messages exchanged between an Initiator and a Responder to establish a shared secret and derive transport encryption keys.
+Gift-wrapped messages rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet.
-```mermaid
-sequenceDiagram
- participant I as Initiator
- participant R as Responder
+### 6.5 Delivery Metrics
- Note over I, R: Pre-computation: h = SHA256(protocol_name)
+Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drops — no identities, message IDs, or timestamps) let delivery behavior be measured on-device. They never leave the device and are cleared by the panic wipe.
- I->>R: -> e
- Note right of I: I generates ephemeral key `e_i`.
h = SHA256(h + e_i.pub)
+## 7. Application Layer
- R->>I: <- e, ee, s, es
- Note left of R: R generates ephemeral key `e_r`.
h = SHA256(h + e_r.pub)
MixKey(DH(e_i, e_r))
R sends static key `s_r`, encrypted.
h = SHA256(h + ciphertext)
MixKey(DH(e_i, s_r))
-
- I->>R: -> s, se
- Note right of I: I decrypts and verifies `s_r`.
I sends static key `s_i`, encrypted.
h = SHA256(h + ciphertext)
MixKey(DH(s_i, e_r))
-
- Note over I, R: Handshake complete. Transport keys derived.
-```
-
-**Handshake Flow:**
-
-1. **Initiator -> Responder:** The initiator generates a new ephemeral key pair (`e_i`) and sends the public part to the responder.
-2. **Responder -> Initiator:** The responder receives the initiator's ephemeral public key. It then generates its own ephemeral key pair (`e_r`), performs a DH exchange with the initiator's ephemeral key (`ee`), sends its own static public key (`s_r`) encrypted with the resulting symmetric key, and performs another DH exchange between the initiator's ephemeral key and its own static key (`es`).
-3. **Initiator -> Responder:** The initiator receives the responder's message, decrypts the responder's static key, and authenticates it. The initiator then sends its own static key (`s_i`) encrypted and performs a final DH exchange between its static key and the responder's ephemeral key (`se`).
-
-Upon completion, both parties share a set of symmetric keys for bidirectional transport message encryption. The final handshake hash is used for channel binding.
-
-### 5.3. Session Management
-
-The `NoiseSessionManager` class manages all active Noise sessions. It handles:
-* Creating sessions for new peers.
-* Coordinating the handshake process to prevent race conditions.
-* Storing the resulting transport ciphers (`sendCipher`, `receiveCipher`).
-* Periodically checking if sessions need to be re-keyed for enhanced security.
-
----
-
-## 6. The BitChat Session and Application Protocol
-
-Once a Noise session is established, peers exchange `BitchatPacket` structures, which are encrypted as the payload of Noise transport messages.
-
-### 6.1. Binary Packet Format (`BitchatPacket`)
-
-To minimize bandwidth, `BitchatPacket`s are serialized into a compact binary format. The structure is designed to be fixed-size where possible to resist traffic analysis.
-
-| Field | Size (bytes) | Description |
-|-----------------|--------------|---------------------------------------------------------------------------------------------------------|
-| **Header** | **13** | **Fixed-size header** |
-| Version | 1 | Protocol version (currently `1`). |
-| Type | 1 | Message type (e.g., `message`, `deliveryAck`, `noiseHandshakeInit`). See `MessageType` enum. |
-| TTL | 1 | Time-To-Live for mesh network routing. Decremented at each hop. |
-| Timestamp | 8 | `UInt64` millisecond timestamp of packet creation. |
-| Flags | 1 | Bitmask for optional fields (`hasRecipient`, `hasSignature`, `isCompressed`). |
-| Payload Length | 2 | `UInt16` length of the payload field. |
-| **Variable** | **...** | **Variable-size fields** |
-| Sender ID | 8 | 8-byte truncated peer ID of the sender. |
-| Recipient ID | 8 (optional) | 8-byte truncated peer ID of the recipient. Present if `hasRecipient` flag is set. Broadcast if `0xFF..FF`. |
-| Payload | Variable | The actual content of the packet, as defined by the `Type` field. |
-| Signature | 64 (optional)| `Ed25519` signature of the packet. Present if `hasSignature` flag is set. |
-
-**Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers.
-
-```mermaid
----
-config:
- theme: dark
----
----
-title: "BitchatPacket"
----
-packet
-+8: "Version"
-+8: "Type"
-+8: "TTL"
-+64: "Timestamp"
-+8: "Flags"
-+16: "Payload Length"
-+64: "Sender ID"
-+64: "Recipient ID (optional)"
-+48: "Payload (variable)"
-+64: "Signature (optional)"
-```
-_A representation of the sizes of the fields in `BitchatPacket`_
-
-### 6.2. Application Message Format (`BitchatMessage`)
-
-For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.
-
-| Field | Size (bytes) | Description |
-|---------------------|--------------|--------------------------------------------------------------------------|
-| Flags | 1 | Bitmask for optional fields (`isRelay`, `isPrivate`, `hasOriginalSender`). |
-| Timestamp | 8 | `UInt64` millisecond timestamp of message creation. |
-| ID | 1 + len | `UUID` string for the message. |
-| Sender | 1 + len | Nickname of the sender. |
-| Content | 2 + len | The UTF-8 encoded message content. |
-| Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. |
-| Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. |
-
-```mermaid
----
-config:
- theme: dark
----
----
-title: "BitchatMessage"
----
-packet
-+8: "Flags"
-+64: "Timestamp"
-+24: "ID (variable)"
-+32: "Sender (variable)"
-+32: "Content (variable)"
-+32: "Original Sender (variable) (optional)"
-+32: "Recipient Nickname (variable) (optional)"
-```
-_A representation of the sizes of the fields in `BitchatMessage`_
-
----
-
-## 7. Message Routing and Propagation
-
-BitChat operates as a decentralized mesh network, meaning there are no central servers to route messages. Packets are propagated through the network from peer to peer. The protocol supports several modes of message delivery.
-
-### 7.1. Direct Connection
-
-This is the simplest case. If Peer A and Peer B are directly connected, they can exchange packets after establishing a mutually authenticated Noise session. All packets are encrypted using the transport ciphers derived from the handshake.
-
-### 7.2. Efficient Gossip with Bloom Filters
-
-To send messages to peers that are not directly connected, BitChat employs a "flooding" or "gossip" protocol. When a peer receives a packet that is not destined for it, it acts as a relay. To prevent infinite routing loops and minimize memory usage, the protocol uses an `OptimizedBloomFilter` to track recently seen packet IDs.
-
-The logic is as follows:
-
-1. A peer receives a packet.
-2. It checks the Bloom filter to see if the packet's ID has likely been seen before. If so, the packet is discarded. Bloom filters can have false positives (though they are rare), but they guarantee no false negatives. This means that while some packets may be incorrectly discarded due to false positives, the gossip protocol's redundancy ensures these packets will eventually be received through subsequent exchanges with other peers.
-3. If the packet is new, its ID is added to the Bloom filter.
-4. The peer decrements the packet's Time-To-Live (TTL) field.
-5. If the TTL is greater than zero, the peer re-broadcasts the packet to all of its connected peers, *except* for the peer from which it received the packet.
-
-This mechanism allows packets to "flood" through the network efficiently, maximizing the chance of reaching their destination while using minimal resources to prevent loops.
-
-### 7.3. Time-To-Live (TTL)
-
-Every `BitchatPacket` contains an 8-bit TTL field. This value is set by the originating peer and is decremented by one at each relay hop. If a peer receives a packet and decrements its TTL to 0, it will process the packet (if it is the recipient) but will not relay it further. This is a crucial mechanism to prevent packets from circulating endlessly in the mesh.
-
-### 7.4. Private vs. Broadcast Messages
-
-The routing logic respects the confidentiality of private messages:
-
-* **Private Messages:** A packet with a specific `recipientID` is a private message. Relay nodes forward the entire, encrypted Noise message without being able to access the inner `BitchatPacket` or its payload. Only the final recipient, who shares the correct Noise session keys with the sender, can decrypt the packet.
-* **Broadcast Messages:** A packet with the special broadcast `recipientID` (`0xFFFFFFFFFFFFFFFF`) is intended for all peers. Any peer that receives and decrypts a broadcast message will process its content. It will still be relayed according to the flooding algorithm to ensure it reaches the entire network.
-
-### 7.5. Message Reliability and Lifecycle
-
-To function in unreliable, lossy networks, the protocol includes features to track the lifecycle of a message and ensure its delivery.
-
-* **Delivery Acknowledgments (`DeliveryAck`):** When a private message reaches its final destination, the recipient's device sends a `DeliveryAck` packet back to the original sender. This acknowledgment contains the ID of the original message.
-* **Read Receipts (`ReadReceipt`):** After a message is displayed on the recipient's screen, the application can send a `ReadReceipt`, also containing the original message ID, to inform the sender that the message has been seen.
-* **Message Retry Service:** Senders maintain a `MessageRetryService` which tracks outgoing messages. If a `DeliveryAck` is not received for a message within a certain time window, the service will automatically re-send the message, creating a more resilient user experience.
-
-### 7.6. Fragmentation
-
-Transport layers like BLE have a Maximum Transmission Unit (MTU) that limits the size of a single packet. To handle messages larger than this limit, BitChat implements a fragmentation protocol.
-
-* **`fragmentStart`:** A packet with this type marks the beginning of a fragmented message. It contains metadata about the total size and number of fragments.
-* **`fragmentContinue`:** These packets carry the intermediate chunks of the message data.
-* **`fragmentEnd`:** This packet carries the final chunk of the message and signals the receiver to begin reassembly.
-
-Receiving peers collect all fragments and reassemble them in the correct order before passing the complete message up to the application layer.
-
----
+* **Public chat** — signed broadcast messages within the mesh, backed by the gossip-synced history above.
+* **Private chat** — end-to-end encrypted messages with delivery and read receipts, over mesh, courier, or Nostr.
+* **Location channels** — geohash-scoped public rooms carried over Nostr relays for regional chat beyond radio range.
+* **Favorites** — the mutual-trust relationship that unlocks Nostr delivery and the larger courier quota.
+* **Media** — files and images fragment over the mesh (1 MiB cap, explicit accept before anything touches disk); couriers carry text only.
+* **Panic wipe** — clears identity keys, favorites, carried courier mail, the sealed outbox, archived public history, and metrics.
## 8. Security Considerations
-* **Replay Attacks:** The Noise transport messages include a nonce that is incremented for each message. The `NoiseCipherState` implements a sliding window replay protection mechanism to detect and discard replayed or out-of-order messages.
-* **Denial of Service:** The `NoiseRateLimiter` is implemented to prevent resource exhaustion from rapid, repeated handshake attempts from a single peer.
-* **Key-Compromise Impersonation:** The `XX` pattern authenticates both parties, preventing an attacker from impersonating one party to the other.
-* **Identity Binding:** While the Noise handshake authenticates the cryptographic keys, binding those keys to a human-readable nickname is handled at the application layer. Users must verify fingerprints out-of-band to prevent man-in-the-middle attacks.
-* **Traffic Analysis:** The use of fixed-size padding for all packets helps to obscure the exact nature and content of the communication, making it harder for a network-level adversary to infer information based on message size.
+* **Relay nodes** cannot read private traffic; they forward padded, opaque ciphertext.
+* **Couriers** are quota-bounded mailbags. A malicious courier can drop mail (redundant copies and deposit retry mitigate this) but cannot read it, link it across days, or amplify it — copy budgets are capped and every envelope is validated against size and lifetime policy on deposit.
+* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
+* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
+* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
+* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path.
+
+## 9. Future Work
+
+* Prekey-based forward secrecy for courier envelopes.
+* Couriered media beyond the 16 KiB text cap.
+* Probabilistic relay and edge-of-network TTL boosting for very dense and very sparse graphs.
+* Multi-hop courier routing informed by encounter history.
---
-## 9. Conclusion
-
-The BitChat Protocol provides a robust and secure foundation for decentralized, peer-to-peer communication. By layering a flexible application protocol on top of the well-regarded Noise Protocol Framework, it achieves strong confidentiality, authentication, and forward secrecy. The use of a compact binary format and thoughtful security considerations like rate limiting and traffic analysis resistance make it suitable for use in challenging network environments.
+*This document describes the protocol as implemented in the current release. The implementation is free and unencumbered software released into the public domain.*
diff --git a/bitchat/Services/BLE/BLEPeerRegistry.swift b/bitchat/Services/BLE/BLEPeerRegistry.swift
index 78e9591d..29776748 100644
--- a/bitchat/Services/BLE/BLEPeerRegistry.swift
+++ b/bitchat/Services/BLE/BLEPeerRegistry.swift
@@ -125,7 +125,8 @@ struct BLEPeerRegistry {
nickname: resolvedNames[info.peerID] ?? info.nickname,
isConnected: info.isConnected,
noisePublicKey: info.noisePublicKey,
- lastSeen: info.lastSeen
+ lastSeen: info.lastSeen,
+ isVerified: info.isVerifiedNickname
)
}
}
diff --git a/bitchat/Services/BLE/BLEPublicMessagePolicy.swift b/bitchat/Services/BLE/BLEPublicMessagePolicy.swift
index e81ddec4..9a0bc965 100644
--- a/bitchat/Services/BLE/BLEPublicMessagePolicy.swift
+++ b/bitchat/Services/BLE/BLEPublicMessagePolicy.swift
@@ -27,8 +27,15 @@ enum BLEPublicMessagePolicy {
}
let isBroadcast = BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID)
+ // Acceptance window matches the gossip-sync serving window: a peer
+ // walking between partitions carries hours of public history, so the
+ // receive side must not drop what sync legitimately serves.
if isBroadcast,
- BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) {
+ BLEPacketFreshnessPolicy.isStale(
+ timestampMilliseconds: packet.timestamp,
+ now: now,
+ maxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds
+ ) {
return .reject(.staleBroadcast(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
timestampMilliseconds: packet.timestamp,
now: now
diff --git a/bitchat/Services/BLE/BLEReceivePipeline.swift b/bitchat/Services/BLE/BLEReceivePipeline.swift
index b6deaf1a..71aacbaf 100644
--- a/bitchat/Services/BLE/BLEReceivePipeline.swift
+++ b/bitchat/Services/BLE/BLEReceivePipeline.swift
@@ -48,7 +48,11 @@ struct BLEReceivePipeline {
senderIsSelf: senderID == localPeerID,
recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID,
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
- isDirectedEncrypted: packet.type == MessageType.noiseEncrypted.rawValue && packet.recipientID != nil,
+ // Courier envelopes are directed opaque ciphertext like DMs; a
+ // remote handover toward a relayed announce rides this same
+ // deterministic relay treatment instead of the broadcast clamp.
+ isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
+ || packet.type == MessageType.courierEnvelope.rawValue) && packet.recipientID != nil,
isFragment: packet.type == MessageType.fragment.rawValue,
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
diff --git a/bitchat/Services/BLE/BLEService.swift b/bitchat/Services/BLE/BLEService.swift
index e82d7ff9..865a89be 100644
--- a/bitchat/Services/BLE/BLEService.swift
+++ b/bitchat/Services/BLE/BLEService.swift
@@ -48,12 +48,17 @@ final class BLEService: NSObject {
private let messageDeduplicator = MessageDeduplicator()
// Courier store-and-forward: envelopes this device carries for offline
- // third parties, and the trust gate for accepting deposits. Injectable
- // for tests; main-actor policy because favorites live on the main actor.
+ // third parties, and the trust gate for accepting deposits. The policy
+ // maps (depositor key, announce-verified?) to a quota tier, or nil to
+ // reject. Injectable for tests; main-actor policy because favorites live
+ // on the main actor.
var courierStore: CourierStore = .shared
- var courierDepositPolicy: @MainActor (Data) -> Bool = { depositorNoiseKey in
- FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey)
+ var courierDepositPolicy: @MainActor (Data, Bool) -> CourierDepositTier? = { depositorNoiseKey, isVerifiedPeer in
+ if FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey) { return .favorite }
+ return isVerifiedPeer ? .verified : nil
}
+ // Local-only store-and-forward counters; nil in unit tests.
+ var sfMetrics: StoreAndForwardMetrics?
#if DEBUG
// Test-only tap on the outbound pipeline so multi-node tests can ferry
@@ -275,6 +280,7 @@ final class BLEService: NSObject {
gcsMaxBytes: TransportConfig.syncGCSMaxBytes,
gcsTargetFpr: TransportConfig.syncGCSTargetFpr,
maxMessageAgeSeconds: TransportConfig.syncMaxMessageAgeSeconds,
+ publicMessageMaxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds,
maintenanceIntervalSeconds: TransportConfig.syncMaintenanceIntervalSeconds,
stalePeerCleanupIntervalSeconds: TransportConfig.syncStalePeerCleanupIntervalSeconds,
stalePeerTimeoutSeconds: TransportConfig.syncStalePeerTimeoutSeconds,
@@ -286,8 +292,10 @@ final class BLEService: NSObject {
responseRateLimitMaxResponses: TransportConfig.syncResponseRateLimitMaxResponses,
responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds
)
-
- let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
+
+ // Only real Bluetooth sessions archive to disk; unit tests stay hermetic.
+ let archive = meshBackgroundEnabled ? GossipMessageArchive() : nil
+ let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager, archive: archive)
manager.delegate = self
// Only start the periodic sync timers when real Bluetooth exists. In unit
// tests there is no mesh to sync with, and the periodic sign/broadcast
@@ -2515,7 +2523,8 @@ extension BLEService {
epochDay: CourierEnvelope.epochDay(for: now)
),
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
- ciphertext: sealed
+ ciphertext: sealed,
+ copies: TransportConfig.courierInitialCopies
)
guard let encoded = envelope.encode() else { return false }
payload = encoded
@@ -2535,7 +2544,7 @@ extension BLEService {
}
private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket {
- BitchatPacket(
+ let packet = BitchatPacket(
type: MessageType.courierEnvelope.rawValue,
senderID: myPeerIDData,
recipientID: Data(hexString: peerID.id),
@@ -2544,6 +2553,10 @@ extension BLEService {
signature: nil,
ttl: messageTTL
)
+ // Signed so a courier can authenticate the depositor before carrying
+ // mail under their quota. Handover to the recipient doesn't need the
+ // packet signature — the inner Noise X seal authenticates the sender.
+ return noiseService.signPacket(packet) ?? packet
}
/// Handles both courier roles for an incoming envelope addressed to us:
@@ -2559,7 +2572,7 @@ extension BLEService {
if CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) {
openCourierEnvelope(envelope)
} else {
- acceptCourierDeposit(envelope, from: peerID)
+ acceptCourierDeposit(envelope, from: peerID, packet: packet)
}
}
@@ -2589,6 +2602,7 @@ extension BLEService {
let senderPeerID = isKnownOnMesh ? shortID : PeerID(hexData: senderStaticKey)
let payload = Data(typedPayload.dropFirst())
SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))…", category: .session)
+ sfMetrics?.record(.courierOpened)
notifyUI { [weak self] in
self?.deliverTransportEvent(.noisePayloadReceived(
peerID: senderPeerID,
@@ -2603,20 +2617,38 @@ extension BLEService {
}
}
- private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID) {
- guard let depositorKey = collectionsQueue.sync(execute: { peerRegistry.info(for: peerID)?.noisePublicKey }) else {
+ private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID, packet: BitchatPacket) {
+ // A deposit must come from its depositor over the direct link: the
+ // claimed sender has to be the ingress peer, and the packet signature
+ // has to verify against that peer's announced signing key. Otherwise
+ // an untrusted sender could route an envelope through any trusted
+ // neighbor and have us carry it under the neighbor's quota.
+ guard PeerID(hexData: packet.senderID) == peerID else {
+ SecureLogger.debug("📦 Courier deposit rejected: relayed envelope claims sender \(PeerID(hexData: packet.senderID).id.prefix(8))… but arrived from \(peerID.id.prefix(8))…", category: .security)
+ return
+ }
+ let depositorInfo = collectionsQueue.sync { peerRegistry.info(for: peerID) }
+ guard let depositorKey = depositorInfo?.noisePublicKey else {
SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session)
return
}
+ guard let signingKey = depositorInfo?.signingPublicKey,
+ noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
+ SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (missing/invalid signature)", category: .security)
+ return
+ }
+ let isVerifiedPeer = depositorInfo?.isVerifiedNickname ?? false
let store = courierStore
let policy = courierDepositPolicy
+ let metrics = sfMetrics
Task { @MainActor in
- guard policy(depositorKey) else {
- SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (not a mutual favorite)", category: .session)
+ guard let tier = policy(depositorKey, isVerifiedPeer) else {
+ SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (neither favorite nor verified)", category: .session)
return
}
- if store.deposit(envelope, from: depositorKey) {
- SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))…", category: .session)
+ if store.deposit(envelope, from: depositorKey, tier: tier) {
+ SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))… (\(tier.rawValue))", category: .session)
+ metrics?.record(.courierAccepted)
}
}
}
@@ -2629,6 +2661,51 @@ extension BLEService {
for envelope in envelopes {
guard let payload = envelope.encode() else { continue }
sendPacketDirected(makeCourierPacket(payload, to: peerID), to: peerID)
+ sfMetrics?.record(.courierHandedOver)
+ }
+ }
+
+ /// Speculative handover toward a recipient heard only via a relayed
+ /// announce: the envelope floods the mesh as a directed packet (relays
+ /// treat it like a directed DM). Non-destructive — the carried copy stays
+ /// until a direct handover or expiry, throttled per envelope so repeated
+ /// announces don't re-flood.
+ private func deliverCourierMailRemotely(to peerID: PeerID, noiseKey: Data) {
+ let envelopes = courierStore.envelopesForRemoteHandover(
+ recipientNoiseKey: noiseKey,
+ cooldown: TransportConfig.courierRemoteHandoverCooldownSeconds
+ )
+ guard !envelopes.isEmpty else { return }
+ SecureLogger.debug("📦 Remote handover: flooding \(envelopes.count) envelope(s) toward \(peerID.id.prefix(8))…", category: .session)
+ for envelope in envelopes {
+ guard let payload = envelope.encode() else { continue }
+ broadcastPacket(makeCourierPacket(payload, to: peerID))
+ sfMetrics?.record(.courierRemoteHandover)
+ }
+ }
+
+ /// Spray-and-wait: split copy budgets with another courier we just
+ /// encountered, so carried mail diffuses through a moving crowd instead
+ /// of riding a single carrier. Only favorites and verified peers qualify,
+ /// mirroring the deposit policy they would apply to us.
+ private func sprayCourierMail(to peerID: PeerID, noiseKey: Data, isVerifiedPeer: Bool) {
+ let store = courierStore
+ let metrics = sfMetrics
+ let sendSpray: ([CourierEnvelope]) -> Void = { [weak self] envelopes in
+ guard let self, !envelopes.isEmpty else { return }
+ SecureLogger.debug("📦 Spraying \(envelopes.count) envelope copy(ies) to courier \(peerID.id.prefix(8))…", category: .session)
+ for envelope in envelopes {
+ guard let payload = envelope.encode() else { continue }
+ self.sendPacketDirected(self.makeCourierPacket(payload, to: peerID), to: peerID)
+ metrics?.record(.courierSprayed)
+ }
+ }
+ let policy = courierDepositPolicy
+ Task { @MainActor in
+ // Same trust gate as deposits: don't hand mail to a peer who
+ // would reject it from us.
+ guard policy(noiseKey, isVerifiedPeer) != nil else { return }
+ sendSpray(store.takeSprayCopies(for: noiseKey))
}
}
@@ -2755,6 +2832,9 @@ extension BLEService {
centralManager?.stopScan()
startScanning()
}
+ // Backgrounding may precede a kill; flush the public-history archive
+ // outside its 30s maintenance cadence.
+ gossipSyncManager?.persistNow()
logBluetoothStatus("entered-background")
scheduleBluetoothStatusSample(after: 15.0, context: "background-15s")
// No Local Name; nothing to refresh for advertising policy
@@ -3186,16 +3266,23 @@ extension BLEService {
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
let result = announceHandler.handle(packet, from: peerID)
- // Courier handover: an announce is the moment we learn a peer's Noise
- // static key, so check whether we're carrying mail addressed to them.
- // Direct announces only: envelopes are removed from the store
- // optimistically, so handover must ride an established link rather
- // than a speculative multi-hop send toward a relayed announce.
+ // Courier work: an announce is the moment we learn a peer's Noise
+ // static key, so check whether we're carrying mail addressed to them
+ // (or spray-able mail they could carry). Verified announces only.
guard !courierStore.isEmpty,
let result,
- result.isVerified,
- result.isDirectAnnounce else { return }
- deliverCourierMail(to: result.peerID, noiseKey: result.announcement.noisePublicKey)
+ result.isVerified else { return }
+ let noiseKey = result.announcement.noisePublicKey
+ if result.isDirectAnnounce {
+ // Established link: destructive handover is safe, and the peer is
+ // close enough to become a courier for other carried mail.
+ deliverCourierMail(to: result.peerID, noiseKey: noiseKey)
+ sprayCourierMail(to: result.peerID, noiseKey: noiseKey, isVerifiedPeer: true)
+ } else {
+ // Relayed announce: recipient is multi-hop away. Push a copy
+ // toward them speculatively; the carried copy stays put.
+ deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey)
+ }
}
/// Builds the announce handler environment. All queue hops stay here so
diff --git a/bitchat/Services/Courier/CourierStore.swift b/bitchat/Services/Courier/CourierStore.swift
index e5523257..063c94d5 100644
--- a/bitchat/Services/Courier/CourierStore.swift
+++ b/bitchat/Services/Courier/CourierStore.swift
@@ -11,13 +11,22 @@ import BitLogger
import Combine
import Foundation
+/// Trust level of a courier deposit, decided by the caller's policy.
+/// Favorites get the larger quota and are never evicted to make room for
+/// verified-tier mail; verified (signature-verified announce, not a mutual
+/// favorite) get a small quota so a crowd of strangers can still carry mail.
+enum CourierDepositTier: String, Codable {
+ case favorite
+ case verified
+}
+
/// Holds courier envelopes this device is carrying for offline third parties.
///
-/// Envelopes are opaque ciphertext deposited by mutual favorites; this store
-/// never learns sender, recipient, or content. Strict quotas keep the device
-/// from becoming a public mailbag: bounded count, bounded per-depositor
-/// count, bounded size, and a 24-hour lifetime aligned with the outbox
-/// retention policy. Carried mail is included in the panic wipe.
+/// Envelopes are opaque ciphertext; this store never learns sender,
+/// recipient, or content. Strict quotas keep the device from becoming a
+/// public mailbag: bounded count, bounded per-depositor count by trust tier,
+/// bounded size, and a 24-hour lifetime aligned with the outbox retention
+/// policy. Carried mail is included in the panic wipe.
final class CourierStore {
struct StoredEnvelope: Codable, Equatable {
let recipientTag: Data
@@ -25,15 +34,63 @@ final class CourierStore {
let ciphertext: Data
let depositorNoiseKey: Data
let storedAt: Date
+ var tier: CourierDepositTier
+ /// Remaining spray-and-wait budget (1 = carry-only).
+ var copies: UInt8
+ /// Couriers this envelope was already sprayed to, so a repeat announce
+ /// from the same peer doesn't burn budget on a copy they already hold.
+ var sprayedTo: Set
+ /// Last speculative multi-hop handover toward a relayed announce.
+ var lastRemoteHandoverAt: Date?
var envelope: CourierEnvelope {
- CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext)
+ CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies)
+ }
+
+ init(
+ recipientTag: Data,
+ expiry: UInt64,
+ ciphertext: Data,
+ depositorNoiseKey: Data,
+ storedAt: Date,
+ tier: CourierDepositTier,
+ copies: UInt8,
+ sprayedTo: Set = [],
+ lastRemoteHandoverAt: Date? = nil
+ ) {
+ self.recipientTag = recipientTag
+ self.expiry = expiry
+ self.ciphertext = ciphertext
+ self.depositorNoiseKey = depositorNoiseKey
+ self.storedAt = storedAt
+ self.tier = tier
+ self.copies = copies
+ self.sprayedTo = sprayedTo
+ self.lastRemoteHandoverAt = lastRemoteHandoverAt
+ }
+
+ // Files written before tiers/spray lack the newer fields; treat that
+ // mail as favorite-tier carry-only, which is what it was.
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ recipientTag = try container.decode(Data.self, forKey: .recipientTag)
+ expiry = try container.decode(UInt64.self, forKey: .expiry)
+ ciphertext = try container.decode(Data.self, forKey: .ciphertext)
+ depositorNoiseKey = try container.decode(Data.self, forKey: .depositorNoiseKey)
+ storedAt = try container.decode(Date.self, forKey: .storedAt)
+ tier = try container.decodeIfPresent(CourierDepositTier.self, forKey: .tier) ?? .favorite
+ copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1
+ sprayedTo = try container.decodeIfPresent(Set.self, forKey: .sprayedTo) ?? []
+ lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt)
}
}
enum Limits {
- static let maxEnvelopes = 20
- static let maxPerDepositor = 5
+ static let maxEnvelopes = 40
+ /// Verified-tier mail can never crowd out favorites' share.
+ static let maxVerifiedEnvelopes = 20
+ static let maxPerFavoriteDepositor = 5
+ static let maxPerVerifiedDepositor = 2
/// Slack on top of the 24h lifetime for depositor clock skew.
static let maxExpirySlack: TimeInterval = 60 * 60
}
@@ -65,10 +122,11 @@ final class CourierStore {
// MARK: - Depositing (courier side)
/// Accept an envelope from a depositor. Returns false when quotas or
- /// validity checks reject it. Trust policy (mutual favorite) is the
- /// caller's responsibility; this store only enforces resource bounds.
+ /// validity checks reject it. Trust policy (which tier a depositor gets,
+ /// if any) is the caller's responsibility; this store only enforces
+ /// resource bounds.
@discardableResult
- func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data) -> Bool {
+ func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data, tier: CourierDepositTier = .favorite) -> Bool {
let date = now()
guard envelope.recipientTag.count == CourierEnvelope.tagLength,
!envelope.ciphertext.isEmpty,
@@ -86,18 +144,39 @@ final class CourierStore {
return queue.sync {
pruneExpiredLocked(at: date)
- // Identical ciphertext is the same envelope; accept idempotently.
- if envelopes.contains(where: { $0.ciphertext == envelope.ciphertext }) {
+ // Identical ciphertext is the same envelope; accept idempotently,
+ // keeping the larger spray budget (bounded by maxCopies either way).
+ if let existing = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) {
+ envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies)
+ persistLocked()
return true
}
- guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < Limits.maxPerDepositor else {
- SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached", category: .session)
+
+ let perDepositorLimit = tier == .favorite ? Limits.maxPerFavoriteDepositor : Limits.maxPerVerifiedDepositor
+ guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < perDepositorLimit else {
+ SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached (\(tier.rawValue))", category: .session)
+ return false
+ }
+ if tier == .verified,
+ envelopes.filter({ $0.tier == .verified }).count >= Limits.maxVerifiedEnvelopes {
+ SecureLogger.debug("📦 Courier deposit rejected: verified-tier pool full", category: .session)
return false
}
if envelopes.count >= Limits.maxEnvelopes {
- // Oldest-first eviction, matching outbox overflow behavior.
- let evicted = envelopes.removeFirst()
- SecureLogger.debug("📦 Courier store full - evicted envelope stored at \(evicted.storedAt)", category: .session)
+ // Oldest-first eviction, shedding verified-tier mail before
+ // favorites' so open couriering can't crowd out trusted mail.
+ // A verified deposit never displaces a favorite: when only
+ // favorite mail is stored, it is rejected instead.
+ if let victim = envelopes.firstIndex(where: { $0.tier == .verified }) {
+ let evicted = envelopes.remove(at: victim)
+ SecureLogger.debug("📦 Courier store full - evicted verified envelope stored at \(evicted.storedAt)", category: .session)
+ } else if tier == .favorite {
+ let evicted = envelopes.removeFirst()
+ SecureLogger.debug("📦 Courier store full - evicted favorite envelope stored at \(evicted.storedAt)", category: .session)
+ } else {
+ SecureLogger.debug("📦 Courier deposit rejected: store full of favorite-tier mail", category: .session)
+ return false
+ }
}
envelopes.append(StoredEnvelope(
@@ -105,7 +184,9 @@ final class CourierStore {
expiry: envelope.expiry,
ciphertext: envelope.ciphertext,
depositorNoiseKey: depositorNoiseKey,
- storedAt: date
+ storedAt: date,
+ tier: tier,
+ copies: envelope.copies
))
persistLocked()
return true
@@ -131,6 +212,58 @@ final class CourierStore {
}
}
+ /// Envelopes addressed to a recipient we heard from via a *relayed*
+ /// announce. Non-destructive: a multi-hop send is speculative, so the
+ /// envelope stays carried until a direct handover or expiry. The per-
+ /// envelope cooldown keeps repeated announces from re-flooding the mesh.
+ func envelopesForRemoteHandover(recipientNoiseKey: Data, cooldown: TimeInterval) -> [CourierEnvelope] {
+ let date = now()
+ let candidates = CourierEnvelope.candidateTags(noiseStaticKey: recipientNoiseKey, around: date)
+ return queue.sync {
+ pruneExpiredLocked(at: date)
+ var matched: [CourierEnvelope] = []
+ for index in envelopes.indices where candidates.contains(envelopes[index].recipientTag) {
+ if let last = envelopes[index].lastRemoteHandoverAt,
+ date.timeIntervalSince(last) < cooldown {
+ continue
+ }
+ envelopes[index].lastRemoteHandoverAt = date
+ // The delivered copy carries no spray budget.
+ matched.append(envelopes[index].envelope.withCopies(1))
+ }
+ if !matched.isEmpty { persistLocked() }
+ return matched
+ }
+ }
+
+ // MARK: - Spray-and-wait (on encountering another courier)
+
+ /// Envelopes to re-deposit with a courier we just encountered, each with
+ /// half its remaining budget (binary spray). Skips envelopes the courier
+ /// deposited, envelopes addressed to them (those ride the handover path),
+ /// carry-only envelopes, and couriers already sprayed.
+ func takeSprayCopies(for courierNoiseKey: Data) -> [CourierEnvelope] {
+ let date = now()
+ let courierTags = CourierEnvelope.candidateTags(noiseStaticKey: courierNoiseKey, around: date)
+ return queue.sync {
+ pruneExpiredLocked(at: date)
+ var sprayed: [CourierEnvelope] = []
+ for index in envelopes.indices {
+ let stored = envelopes[index]
+ guard stored.copies > 1,
+ stored.depositorNoiseKey != courierNoiseKey,
+ !stored.sprayedTo.contains(courierNoiseKey),
+ !courierTags.contains(stored.recipientTag) else { continue }
+ let given = stored.copies / 2
+ envelopes[index].copies = stored.copies - given
+ envelopes[index].sprayedTo.insert(courierNoiseKey)
+ sprayed.append(stored.envelope.withCopies(given))
+ }
+ if !sprayed.isEmpty { persistLocked() }
+ return sprayed
+ }
+ }
+
// MARK: - Maintenance
func pruneExpired() {
diff --git a/bitchat/Services/Courier/MessageOutboxStore.swift b/bitchat/Services/Courier/MessageOutboxStore.swift
new file mode 100644
index 00000000..b38616a2
--- /dev/null
+++ b/bitchat/Services/Courier/MessageOutboxStore.swift
@@ -0,0 +1,156 @@
+//
+// MessageOutboxStore.swift
+// bitchat
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import BitFoundation
+import BitLogger
+import CryptoKit
+import Foundation
+import Security
+
+/// Disk persistence for the MessageRouter outbox, so private messages queued
+/// for an offline peer survive an app kill instead of silently evaporating.
+///
+/// Nothing else in the app persists message plaintext, and this store keeps
+/// that property: the outbox is sealed with a ChaChaPoly key that lives only
+/// in the Keychain (after-first-unlock, this device only), on top of iOS file
+/// protection. Wiped on panic alongside the courier store.
+final class MessageOutboxStore {
+ struct QueuedMessage: Codable, Equatable {
+ let content: String
+ let nickname: String
+ let messageID: String
+ let timestamp: Date
+ var sendAttempts: Int
+ /// Noise keys of couriers already carrying this message, so deposit
+ /// retries add couriers instead of re-burning the same ones.
+ var depositedCourierKeys: Set
+
+ init(
+ content: String,
+ nickname: String,
+ messageID: String,
+ timestamp: Date,
+ sendAttempts: Int = 0,
+ depositedCourierKeys: Set = []
+ ) {
+ self.content = content
+ self.nickname = nickname
+ self.messageID = messageID
+ self.timestamp = timestamp
+ self.sendAttempts = sendAttempts
+ self.depositedCourierKeys = depositedCourierKeys
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ content = try container.decode(String.self, forKey: .content)
+ nickname = try container.decode(String.self, forKey: .nickname)
+ messageID = try container.decode(String.self, forKey: .messageID)
+ timestamp = try container.decode(Date.self, forKey: .timestamp)
+ sendAttempts = try container.decodeIfPresent(Int.self, forKey: .sendAttempts) ?? 0
+ depositedCourierKeys = try container.decodeIfPresent(Set.self, forKey: .depositedCourierKeys) ?? []
+ }
+ }
+
+ private static let keychainService = "chat.bitchat.outbox"
+ private static let keychainKey = "outbox-encryption-key"
+
+ private let fileURL: URL?
+ private let keychain: KeychainManagerProtocol
+
+ init(keychain: KeychainManagerProtocol, fileURL: URL? = nil) {
+ self.keychain = keychain
+ self.fileURL = fileURL ?? Self.defaultFileURL()
+ }
+
+ // MARK: - API (call from the router's actor; IO is small and atomic)
+
+ func load() -> [PeerID: [QueuedMessage]] {
+ guard let fileURL,
+ let sealed = try? Data(contentsOf: fileURL),
+ let key = encryptionKey(createIfMissing: false),
+ let box = try? ChaChaPoly.SealedBox(combined: sealed),
+ let plaintext = try? ChaChaPoly.open(box, using: key),
+ let decoded = try? JSONDecoder().decode([String: [QueuedMessage]].self, from: plaintext) else {
+ return [:]
+ }
+ var outbox: [PeerID: [QueuedMessage]] = [:]
+ for (peerID, queue) in decoded where !queue.isEmpty {
+ outbox[PeerID(str: peerID)] = queue
+ }
+ return outbox
+ }
+
+ func save(_ outbox: [PeerID: [QueuedMessage]]) {
+ guard let fileURL else { return }
+ let flattened = outbox.filter { !$0.value.isEmpty }
+ guard !flattened.isEmpty else {
+ try? FileManager.default.removeItem(at: fileURL)
+ return
+ }
+ guard let key = encryptionKey(createIfMissing: true) else {
+ SecureLogger.error("Outbox not persisted: no encryption key available", category: .session)
+ return
+ }
+ do {
+ let keyed = Dictionary(uniqueKeysWithValues: flattened.map { ($0.key.id, $0.value) })
+ let plaintext = try JSONEncoder().encode(keyed)
+ let sealed = try ChaChaPoly.seal(plaintext, using: key).combined
+ try FileManager.default.createDirectory(
+ at: fileURL.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ var options: Data.WritingOptions = [.atomic]
+ #if os(iOS)
+ options.insert(.completeFileProtection)
+ #endif
+ try sealed.write(to: fileURL, options: options)
+ } catch {
+ SecureLogger.error("Failed to persist outbox: \(error)", category: .session)
+ }
+ }
+
+ /// Panic wipe: drop the queued mail and the key that could ever read it.
+ func wipe() {
+ if let fileURL {
+ try? FileManager.default.removeItem(at: fileURL)
+ }
+ keychain.delete(key: Self.keychainKey, service: Self.keychainService)
+ }
+
+ // MARK: - Internals
+
+ private func encryptionKey(createIfMissing: Bool) -> SymmetricKey? {
+ if let data = keychain.load(key: Self.keychainKey, service: Self.keychainService), data.count == 32 {
+ return SymmetricKey(data: data)
+ }
+ guard createIfMissing else { return nil }
+ let key = SymmetricKey(size: .bits256)
+ let data = key.withUnsafeBytes { Data($0) }
+ // After-first-unlock so queued mail can flush from background BLE wakes.
+ keychain.save(
+ key: Self.keychainKey,
+ data: data,
+ service: Self.keychainService,
+ accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
+ )
+ return key
+ }
+
+ private static func defaultFileURL() -> URL? {
+ guard let base = try? FileManager.default.url(
+ for: .applicationSupportDirectory,
+ in: .userDomainMask,
+ appropriateFor: nil,
+ create: true
+ ) else { return nil }
+ return base
+ .appendingPathComponent("courier", isDirectory: true)
+ .appendingPathComponent("outbox.sealed")
+ }
+}
diff --git a/bitchat/Services/Courier/StoreAndForwardMetrics.swift b/bitchat/Services/Courier/StoreAndForwardMetrics.swift
new file mode 100644
index 00000000..5452f357
--- /dev/null
+++ b/bitchat/Services/Courier/StoreAndForwardMetrics.swift
@@ -0,0 +1,74 @@
+//
+// StoreAndForwardMetrics.swift
+// bitchat
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import BitLogger
+import Foundation
+
+/// Privacy-safe local counters for the store-and-forward stack: bare event
+/// tallies with no message IDs, peer identities, or timestamps, so delivery
+/// behavior can be measured on-device without recording who talked to whom.
+/// Log-only surface — nothing here ever leaves the device.
+final class StoreAndForwardMetrics {
+ enum Event: String, CaseIterable {
+ /// A private message entered the outbox (no prompt route available).
+ case outboxQueued = "outbox.queued"
+ /// A retained message was re-sent on a flush.
+ case outboxResent = "outbox.resent"
+ /// A delivery/read ack cleared a retained message.
+ case outboxDelivered = "outbox.delivered"
+ /// A retained message was dropped (attempt cap, TTL, or overflow).
+ case outboxDropped = "outbox.dropped"
+ /// We handed sealed mail to a courier.
+ case courierDeposited = "courier.deposited"
+ /// We accepted sealed mail to carry for a third party.
+ case courierAccepted = "courier.accepted"
+ /// We handed carried mail to its recipient over a direct link.
+ case courierHandedOver = "courier.handedOver"
+ /// We pushed carried mail toward a recipient heard via relay.
+ case courierRemoteHandover = "courier.remoteHandover"
+ /// We split spray copies to another courier.
+ case courierSprayed = "courier.sprayed"
+ /// Couriered mail addressed to us was opened and delivered.
+ case courierOpened = "courier.opened"
+ }
+
+ static let shared = StoreAndForwardMetrics()
+
+ private let lock = NSLock()
+ private var counts: [String: Int]
+ private let defaults: UserDefaults
+ private static let defaultsKey = "chat.bitchat.storeAndForwardMetrics"
+
+ init(defaults: UserDefaults = .standard) {
+ self.defaults = defaults
+ self.counts = defaults.dictionary(forKey: Self.defaultsKey) as? [String: Int] ?? [:]
+ }
+
+ func record(_ event: Event) {
+ lock.lock()
+ let total = (counts[event.rawValue] ?? 0) + 1
+ counts[event.rawValue] = total
+ defaults.set(counts, forKey: Self.defaultsKey)
+ lock.unlock()
+ SecureLogger.debug("📊 S&F \(event.rawValue) → \(total)", category: .session)
+ }
+
+ func snapshot() -> [String: Int] {
+ lock.lock()
+ defer { lock.unlock() }
+ return counts
+ }
+
+ /// Included in the panic wipe alongside the stores it describes.
+ func reset() {
+ lock.lock()
+ counts = [:]
+ defaults.removeObject(forKey: Self.defaultsKey)
+ lock.unlock()
+ }
+}
diff --git a/bitchat/Services/MessageRouter.swift b/bitchat/Services/MessageRouter.swift
index e0417851..976b8eb8 100644
--- a/bitchat/Services/MessageRouter.swift
+++ b/bitchat/Services/MessageRouter.swift
@@ -7,7 +7,9 @@ import Foundation
struct CourierDirectory {
/// Noise static key for a peer we can address while they're offline.
var noiseKey: (PeerID) -> Data?
- /// Whether a peer (by Noise static key) may carry our mail.
+ /// Whether a peer (by Noise static key) is a mutual favorite — the
+ /// preferred courier tier. Verified non-favorites are the fallback tier,
+ /// read off the transport snapshot.
var isTrustedCourier: (Data) -> Bool
@MainActor
@@ -30,9 +32,13 @@ struct CourierDirectory {
/// Routes messages using available transports (Mesh, Nostr, etc.)
@MainActor
final class MessageRouter {
+ typealias QueuedMessage = MessageOutboxStore.QueuedMessage
+
private let transports: [Transport]
private let now: () -> Date
private let courierDirectory: CourierDirectory
+ private let outboxStore: MessageOutboxStore?
+ private let metrics: StoreAndForwardMetrics?
/// Invoked whenever a retained private message is dropped without a
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
@@ -41,20 +47,11 @@ final class MessageRouter {
var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)?
/// Invoked when a message with no reachable transport was handed to at
- /// least one courier (a connected mutual favorite who will physically
- /// carry the sealed envelope). Delivery stays best-effort: the outbox
- /// retains the message until an ack arrives.
+ /// least one courier (a connected peer who will physically carry the
+ /// sealed envelope). Delivery stays best-effort: the outbox retains the
+ /// message until an ack arrives.
var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)?
- // Outbox entry with timestamp for TTL-based eviction
- private struct QueuedMessage {
- let content: String
- let nickname: String
- let messageID: String
- let timestamp: Date
- var sendAttempts: Int = 0
- }
-
private var outbox: [PeerID: [QueuedMessage]] = [:]
// Outbox limits to prevent unbounded memory growth
@@ -69,11 +66,16 @@ final class MessageRouter {
init(
transports: [Transport],
now: @escaping () -> Date = Date.init,
- courierDirectory: CourierDirectory? = nil
+ courierDirectory: CourierDirectory? = nil,
+ outboxStore: MessageOutboxStore? = nil,
+ metrics: StoreAndForwardMetrics? = nil
) {
self.transports = transports
self.now = now
self.courierDirectory = courierDirectory ?? .favoritesBacked()
+ self.outboxStore = outboxStore
+ self.metrics = metrics
+ self.outbox = outboxStore?.load() ?? [:]
// Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver(
@@ -134,50 +136,137 @@ final class MessageRouter {
// may never come. Double delivery is harmless — receivers dedup
// by message ID, and delivered/read acks never downgrade.
if !transport.canDeliverPromptly(to: peerID) {
- attemptCourierDeposit(content: content, messageID: messageID, for: peerID)
+ attemptCourierDeposit(messageID: messageID, for: peerID)
}
} else {
var unsent = message
unsent.sendAttempts = 0
enqueue(unsent, for: peerID)
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
- attemptCourierDeposit(content: content, messageID: messageID, for: peerID)
+ attemptCourierDeposit(messageID: messageID, for: peerID)
}
}
+ // MARK: - Couriers
+
/// Last resort when no transport can deliver promptly — the peer is
/// unreachable, or only reachable through a send queue waiting on
/// internet: seal the message to their known static key and hand it to
- /// connected mutual favorites who may physically encounter them. The
- /// queued copy above stays retained, so direct delivery still wins if
- /// the peer reappears first (receivers dedup by message ID).
- private func attemptCourierDeposit(content: String, messageID: String, for peerID: PeerID) {
- guard let recipientKey = courierDirectory.noiseKey(peerID) else { return }
+ /// connected couriers who may physically encounter them. Mutual favorites
+ /// are preferred; signature-verified strangers fill remaining slots so a
+ /// crowd without favorites can still carry mail (envelopes are opaque
+ /// either way). The queued copy stays retained, so direct delivery still
+ /// wins if the peer reappears first (receivers dedup by message ID).
+ private func attemptCourierDeposit(messageID: String, for peerID: PeerID) {
+ guard let recipientKey = courierDirectory.noiseKey(peerID),
+ let entry = queuedMessage(messageID, for: peerID) else { return }
+ let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count
+ guard remainingSlots > 0 else { return }
+
for transport in transports {
- let couriers = transport.currentPeerSnapshots()
- .filter { snapshot in
- guard snapshot.isConnected,
- let key = snapshot.noisePublicKey,
- key != recipientKey else { return false }
- return courierDirectory.isTrustedCourier(key)
- }
- .prefix(Self.maxCouriersPerMessage)
- .map(\.peerID)
+ let couriers = eligibleCouriers(
+ on: transport,
+ recipientKey: recipientKey,
+ excluding: entry.depositedCourierKeys,
+ limit: remainingSlots
+ )
guard !couriers.isEmpty else { continue }
- if transport.sendCourierMessage(content, messageID: messageID, recipientNoiseKey: recipientKey, via: Array(couriers)) {
+ if transport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) {
SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))…", category: .session)
+ recordCourierDeposit(messageID: messageID, for: peerID, courierKeys: couriers.map(\.noiseKey))
onMessageCarried?(messageID, peerID)
return
}
}
}
+ /// A courier candidate just connected: hand them any queued mail they are
+ /// not already carrying. This is what turns couriering from "a favorite
+ /// happened to be around at send time" into eventual spread — deposits
+ /// retry as eligible peers appear, until each message rides with
+ /// `maxCouriersPerMessage` distinct couriers or expires.
+ func courierBecameAvailable(_ peerID: PeerID) {
+ for transport in transports {
+ guard transport.isPeerConnected(peerID),
+ let snapshot = transport.currentPeerSnapshots().first(where: { $0.peerID == peerID && $0.isConnected }),
+ let courierKey = snapshot.noisePublicKey,
+ courierDirectory.isTrustedCourier(courierKey) || snapshot.isVerified else { continue }
+
+ let currentDate = now()
+ for (recipient, queue) in outbox {
+ // Mail *to* this peer flushes directly on connect.
+ guard recipient != peerID,
+ let recipientKey = courierDirectory.noiseKey(recipient),
+ recipientKey != courierKey else { continue }
+ for message in queue {
+ guard message.depositedCourierKeys.count < Self.maxCouriersPerMessage,
+ !message.depositedCourierKeys.contains(courierKey),
+ currentDate.timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds else { continue }
+ if transport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) {
+ SecureLogger.debug("📦 Deposit retry: PM \(message.messageID.prefix(8))… handed to \(peerID.id.prefix(8))… for \(recipient.id.prefix(8))…", category: .session)
+ recordCourierDeposit(messageID: message.messageID, for: recipient, courierKeys: [courierKey])
+ onMessageCarried?(message.messageID, recipient)
+ }
+ }
+ }
+ return
+ }
+ }
+
+ private struct CourierCandidate {
+ let peerID: PeerID
+ let noiseKey: Data
+ }
+
+ private func eligibleCouriers(
+ on transport: Transport,
+ recipientKey: Data,
+ excluding excludedKeys: Set,
+ limit: Int
+ ) -> [CourierCandidate] {
+ guard limit > 0 else { return [] }
+ let candidates = transport.currentPeerSnapshots().compactMap { snapshot -> (CourierCandidate, isFavorite: Bool)? in
+ guard snapshot.isConnected,
+ let key = snapshot.noisePublicKey,
+ key != recipientKey,
+ !excludedKeys.contains(key) else { return nil }
+ let isFavorite = courierDirectory.isTrustedCourier(key)
+ guard isFavorite || snapshot.isVerified else { return nil }
+ return (CourierCandidate(peerID: snapshot.peerID, noiseKey: key), isFavorite)
+ }
+ return candidates
+ .sorted { $0.isFavorite && !$1.isFavorite }
+ .prefix(limit)
+ .map(\.0)
+ }
+
+ private func queuedMessage(_ messageID: String, for peerID: PeerID) -> QueuedMessage? {
+ outbox[peerID]?.first { $0.messageID == messageID }
+ }
+
+ private func recordCourierDeposit(messageID: String, for peerID: PeerID, courierKeys: [Data]) {
+ metrics?.record(.courierDeposited)
+ guard var queue = outbox[peerID],
+ let index = queue.firstIndex(where: { $0.messageID == messageID }) else { return }
+ queue[index].depositedCourierKeys.formUnion(courierKeys)
+ outbox[peerID] = queue
+ persistOutbox()
+ }
+
+ // MARK: - Outbox Management
+
/// A delivery or read ack confirms receipt; stop retaining the message.
func markDelivered(_ messageID: String) {
+ var cleared = false
for (peerID, queue) in outbox {
let filtered = queue.filter { $0.messageID != messageID }
guard filtered.count != queue.count else { continue }
outbox[peerID] = filtered.isEmpty ? nil : filtered
+ cleared = true
+ }
+ if cleared {
+ metrics?.record(.outboxDelivered)
+ persistOutbox()
}
}
@@ -191,9 +280,26 @@ final class MessageRouter {
if queue.count > Self.maxMessagesPerPeer {
let evicted = queue.removeFirst()
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted.messageID.prefix(8))…", category: .session)
- onMessageDropped?(evicted.messageID, peerID)
+ dropMessage(evicted.messageID, for: peerID)
}
outbox[peerID] = queue
+ metrics?.record(.outboxQueued)
+ persistOutbox()
+ }
+
+ private func dropMessage(_ messageID: String, for peerID: PeerID) {
+ metrics?.record(.outboxDropped)
+ onMessageDropped?(messageID, peerID)
+ }
+
+ private func persistOutbox() {
+ outboxStore?.save(outbox)
+ }
+
+ /// Panic wipe: forget queued mail on disk and in memory.
+ func wipeOutbox() {
+ outbox.removeAll()
+ outboxStore?.wipe()
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
@@ -220,8 +326,6 @@ final class MessageRouter {
}
}
- // MARK: - Outbox Management
-
func flushOutbox(for peerID: PeerID) {
guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
@@ -233,7 +337,7 @@ final class MessageRouter {
// Skip expired messages (TTL exceeded)
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
- onMessageDropped?(message.messageID, peerID)
+ dropMessage(message.messageID, for: peerID)
continue
}
@@ -241,16 +345,18 @@ final class MessageRouter {
// Live link: send and stop retaining.
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
+ metrics?.record(.outboxResent)
} else if let transport = reachableTransport(for: peerID) {
// Weak signal: send but keep retaining until an ack clears it,
// bounded by attempt count for peers that never ack.
guard message.sendAttempts < Self.maxSendAttempts else {
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
- onMessageDropped?(message.messageID, peerID)
+ dropMessage(message.messageID, for: peerID)
continue
}
SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
+ metrics?.record(.outboxResent)
var retained = message
retained.sendAttempts += 1
remaining.append(retained)
@@ -264,6 +370,7 @@ final class MessageRouter {
} else {
outbox[peerID] = remaining
}
+ persistOutbox()
}
func flushAllOutbox() {
@@ -273,6 +380,7 @@ final class MessageRouter {
/// Periodically clean up expired messages from all outboxes
func cleanupExpiredMessages() {
let now = now()
+ var droppedAny = false
for peerID in Array(outbox.keys) {
var expiredMessageIDs: [String] = []
outbox[peerID]?.removeAll { message in
@@ -285,8 +393,12 @@ final class MessageRouter {
}
for messageID in expiredMessageIDs {
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
- onMessageDropped?(messageID, peerID)
+ dropMessage(messageID, for: peerID)
+ droppedAny = true
}
}
+ if droppedAny {
+ persistOutbox()
+ }
}
}
diff --git a/bitchat/Services/Transport.swift b/bitchat/Services/Transport.swift
index 5cd095a6..27c38700 100644
--- a/bitchat/Services/Transport.swift
+++ b/bitchat/Services/Transport.swift
@@ -11,6 +11,24 @@ struct TransportPeerSnapshot: Equatable, Hashable {
let isConnected: Bool
let noisePublicKey: Data?
let lastSeen: Date
+ /// Whether the peer's announce was signature-verified (courier tier gate).
+ let isVerified: Bool
+
+ init(
+ peerID: PeerID,
+ nickname: String,
+ isConnected: Bool,
+ noisePublicKey: Data?,
+ lastSeen: Date,
+ isVerified: Bool = false
+ ) {
+ self.peerID = peerID
+ self.nickname = nickname
+ self.isConnected = isConnected
+ self.noisePublicKey = noisePublicKey
+ self.lastSeen = lastSeen
+ self.isVerified = isVerified
+ }
}
enum TransportEvent: @unchecked Sendable {
diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift
index b5cb00e0..59d15c55 100644
--- a/bitchat/Services/TransportConfig.swift
+++ b/bitchat/Services/TransportConfig.swift
@@ -262,7 +262,14 @@ enum TransportConfig {
static let syncSeenCapacity: Int = 1000
static let syncGCSMaxBytes: Int = 400
static let syncGCSTargetFpr: Double = 0.01
+ // Fragments and file transfers keep the short window; whole public
+ // messages get hours so a phone walking between partitions carries the
+ // room's recent history with it (see syncPublicMessageMaxAgeSeconds).
static let syncMaxMessageAgeSeconds: TimeInterval = 900
+ // How far back public broadcast messages stay sync-able. Must not exceed
+ // the receive-side acceptance window (BLEPublicMessagePolicy uses this
+ // same constant) or served packets would be dropped as stale.
+ static let syncPublicMessageMaxAgeSeconds: TimeInterval = 6 * 60 * 60
static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0
static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0
static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0
@@ -273,4 +280,13 @@ enum TransportConfig {
static let syncMessageIntervalSeconds: TimeInterval = 15.0
static let syncResponseRateLimitMaxResponses: Int = 8
static let syncResponseRateLimitWindowSeconds: TimeInterval = 30.0
+
+ // Courier store-and-forward
+ // Initial spray-and-wait budget per deposited envelope: each courier may
+ // hand half its remaining copies to another courier on encounter, so a
+ // message diffuses through a moving crowd instead of riding one person.
+ static let courierInitialCopies: UInt8 = 4
+ // Cooldown between speculative multi-hop handovers of the same envelope
+ // toward a recipient heard only via relayed announces.
+ static let courierRemoteHandoverCooldownSeconds: TimeInterval = 10 * 60
}
diff --git a/bitchat/Sync/GossipMessageArchive.swift b/bitchat/Sync/GossipMessageArchive.swift
new file mode 100644
index 00000000..dd81983d
--- /dev/null
+++ b/bitchat/Sync/GossipMessageArchive.swift
@@ -0,0 +1,80 @@
+//
+// GossipMessageArchive.swift
+// bitchat
+//
+// This is free and unencumbered software released into the public domain.
+// For more information, see
+//
+
+import BitLogger
+import Foundation
+
+/// Disk persistence for the gossip-sync public message store, so the recent
+/// public history a device carries survives app restarts. This is what lets
+/// a phone act as a town crier: walk between two mesh partitions (or relaunch
+/// hours later) and sync the room's backlog to whoever missed it.
+///
+/// Contents are signed public broadcasts — already visible to anyone in radio
+/// range — so file protection (no additional sealing) is the right at-rest
+/// posture. Wiped on panic.
+final class GossipMessageArchive {
+ private let fileURL: URL?
+
+ init(fileURL: URL? = nil) {
+ self.fileURL = fileURL ?? Self.defaultFileURL()
+ }
+
+ /// Raw binary packets, decoded and freshness-filtered by the caller.
+ func load() -> [Data] {
+ guard let fileURL,
+ let data = try? Data(contentsOf: fileURL),
+ let packets = try? JSONDecoder().decode([Data].self, from: data) else {
+ return []
+ }
+ return packets
+ }
+
+ func save(_ packets: [Data]) {
+ guard let fileURL else { return }
+ guard !packets.isEmpty else {
+ try? FileManager.default.removeItem(at: fileURL)
+ return
+ }
+ do {
+ try FileManager.default.createDirectory(
+ at: fileURL.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ let data = try JSONEncoder().encode(packets)
+ var options: Data.WritingOptions = [.atomic]
+ #if os(iOS)
+ options.insert(.completeFileProtection)
+ #endif
+ try data.write(to: fileURL, options: options)
+ } catch {
+ SecureLogger.error("Failed to persist gossip archive: \(error)", category: .sync)
+ }
+ }
+
+ func wipe() {
+ guard let fileURL else { return }
+ try? FileManager.default.removeItem(at: fileURL)
+ }
+
+ /// Panic-wipe hook for callers that don't hold the live instance.
+ static func wipeDefault() {
+ GossipMessageArchive().wipe()
+ }
+
+ private static func defaultFileURL() -> URL? {
+ guard let base = try? FileManager.default.url(
+ for: .applicationSupportDirectory,
+ in: .userDomainMask,
+ appropriateFor: nil,
+ create: true
+ ) else { return nil }
+ return base
+ .appendingPathComponent("sync", isDirectory: true)
+ .appendingPathComponent("public-messages.json")
+ }
+}
diff --git a/bitchat/Sync/GossipSyncManager.swift b/bitchat/Sync/GossipSyncManager.swift
index ee337baa..c966a7ca 100644
--- a/bitchat/Sync/GossipSyncManager.swift
+++ b/bitchat/Sync/GossipSyncManager.swift
@@ -64,7 +64,10 @@ final class GossipSyncManager {
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
var gcsTargetFpr: Double = 0.01 // 1%
- var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
+ var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - fragments/files/announces
+ // Whole public messages stay sync-able much longer so devices carry
+ // the room's recent history between partitions and across restarts.
+ var publicMessageMaxAgeSeconds: TimeInterval = 900
var maintenanceIntervalSeconds: TimeInterval = 30.0
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
var stalePeerTimeoutSeconds: TimeInterval = 60.0
@@ -80,6 +83,7 @@ final class GossipSyncManager {
private let myPeerID: PeerID
private let config: Config
private let requestSyncManager: RequestSyncManager
+ private let archive: GossipMessageArchive?
weak var delegate: Delegate?
// Storage: broadcast packets by type, and latest announce per sender
@@ -87,6 +91,7 @@ final class GossipSyncManager {
private var fragments = PacketStore()
private var fileTransfers = PacketStore()
private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
+ private var archiveDirty = false
// Timer
private var periodicTimer: DispatchSourceTimer?
@@ -95,10 +100,11 @@ final class GossipSyncManager {
private var syncSchedules: [SyncSchedule] = []
private var responseRateLimiter: SyncResponseRateLimiter
- init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) {
+ init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager, archive: GossipMessageArchive? = nil) {
self.myPeerID = myPeerID
self.config = config
self.requestSyncManager = requestSyncManager
+ self.archive = archive
self.responseRateLimiter = SyncResponseRateLimiter(
maxResponses: config.responseRateLimitMaxResponses,
window: config.responseRateLimitWindowSeconds
@@ -114,6 +120,12 @@ final class GossipSyncManager {
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
}
syncSchedules = schedules
+
+ if archive != nil {
+ queue.async { [weak self] in
+ self?.restoreArchivedMessages()
+ }
+ }
}
func start() {
@@ -153,10 +165,15 @@ final class GossipSyncManager {
}
}
- // Helper to check if a packet is within the age threshold
+ // Helper to check if a packet is within the age threshold. Whole public
+ // messages get the long town-crier window; fragments, file transfers and
+ // announces keep the short one.
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
+ let maxAgeSeconds = packet.type == MessageType.message.rawValue
+ ? config.publicMessageMaxAgeSeconds
+ : config.maxMessageAgeSeconds
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
- let ageThresholdMs = UInt64(config.maxMessageAgeSeconds * 1000)
+ let ageThresholdMs = UInt64(maxAgeSeconds * 1000)
// If current time is less than threshold, accept all (handle clock issues gracefully)
guard nowMs >= ageThresholdMs else { return true }
@@ -197,6 +214,7 @@ final class GossipSyncManager {
guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
+ archiveDirty = true
case .fragment:
guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return }
@@ -417,14 +435,55 @@ final class GossipSyncManager {
isPacketFresh(pair.packet)
}
+ let messageCountBefore = messages.packets.count
messages.removeExpired(isFresh: isPacketFresh)
+ if messages.packets.count != messageCountBefore {
+ archiveDirty = true
+ }
fragments.removeExpired(isFresh: isPacketFresh)
fileTransfers.removeExpired(isFresh: isPacketFresh)
}
+ // MARK: - Archive (public message persistence)
+
+ /// Rebuild the public message store from disk on launch, dropping
+ /// anything that aged out while the app was dead.
+ private func restoreArchivedMessages() {
+ guard let archive else { return }
+ var restored = 0
+ for data in archive.load() {
+ guard let packet = BitchatPacket.from(data),
+ packet.type == MessageType.message.rawValue,
+ isPacketFresh(packet) else { continue }
+ let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
+ messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
+ restored += 1
+ }
+ if restored > 0 {
+ SecureLogger.debug("Restored \(restored) archived public message(s) for gossip sync", category: .sync)
+ archiveDirty = true
+ }
+ }
+
+ private func persistArchiveIfDirty() {
+ guard archiveDirty, let archive else { return }
+ archiveDirty = false
+ let packets = messages.allPackets(isFresh: isPacketFresh)
+ .compactMap { $0.toBinaryData(padding: false) }
+ archive.save(packets)
+ }
+
+ /// Flush the archive outside the maintenance cadence (app backgrounding).
+ func persistNow() {
+ queue.async { [weak self] in
+ self?.persistArchiveIfDirty()
+ }
+ }
+
private func performPeriodicMaintenance(now: Date = Date()) {
cleanupExpiredMessages()
cleanupStaleAnnouncementsIfNeeded(now: now)
+ persistArchiveIfDirty()
requestSyncManager.cleanup() // Cleanup expired sync requests
responseRateLimiter.prune(now: now)
@@ -471,7 +530,11 @@ final class GossipSyncManager {
private func removeState(for peerID: PeerID) {
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
+ let messageCountBefore = messages.packets.count
messages.remove { PeerID(hexData: $0.senderID) == peerID }
+ if messages.packets.count != messageCountBefore {
+ archiveDirty = true
+ }
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
}
diff --git a/bitchat/ViewModels/ChatTransportEventCoordinator.swift b/bitchat/ViewModels/ChatTransportEventCoordinator.swift
index 31c38f7b..75571b2e 100644
--- a/bitchat/ViewModels/ChatTransportEventCoordinator.swift
+++ b/bitchat/ViewModels/ChatTransportEventCoordinator.swift
@@ -57,6 +57,8 @@ protocol ChatTransportEventContext: AnyObject {
// MARK: Routing & acknowledgements
func flushRouterOutbox(for peerID: PeerID)
+ /// Offer queued mail for *other* peers to this newly connected courier.
+ func retryCourierDeposits(via peerID: PeerID)
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID)
// MARK: Delivery status
@@ -103,6 +105,10 @@ extension ChatViewModel: ChatTransportEventContext {
messageRouter.flushOutbox(for: peerID)
}
+ func retryCourierDeposits(via peerID: PeerID) {
+ messageRouter.courierBecameAvailable(peerID)
+ }
+
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) {
meshService.sendDeliveryAck(for: messageID, to: peerID)
}
@@ -208,6 +214,7 @@ final class ChatTransportEventCoordinator {
}
context.flushRouterOutbox(for: peerID)
+ context.retryCourierDeposits(via: peerID)
}
}
diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift
index 803fec7f..578b6509 100644
--- a/bitchat/ViewModels/ChatViewModel.swift
+++ b/bitchat/ViewModels/ChatViewModel.swift
@@ -764,15 +764,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
locationPresenceStore: LocationPresenceStore? = nil,
locationManager: LocationChannelManager = .shared
) {
+ let meshService = BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
+ meshService.sfMetrics = .shared
self.init(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
- transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager),
+ transport: meshService,
conversations: conversations,
peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(),
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
- locationManager: locationManager
+ locationManager: locationManager,
+ outboxStore: MessageOutboxStore(keychain: keychain),
+ sfMetrics: .shared
)
}
@@ -788,7 +792,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil,
locationManager: LocationChannelManager = .shared,
- readReceiptsDefaults: UserDefaults? = nil
+ readReceiptsDefaults: UserDefaults? = nil,
+ outboxStore: MessageOutboxStore? = nil,
+ sfMetrics: StoreAndForwardMetrics? = nil
) {
let conversations = conversations ?? ConversationStore()
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
@@ -797,7 +803,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
- meshService: transport
+ meshService: transport,
+ outboxStore: outboxStore,
+ sfMetrics: sfMetrics
)
self.keychain = keychain
@@ -1189,8 +1197,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Clear persistent favorites from keychain
FavoritesPersistenceService.shared.clearAllFavorites()
- // Drop courier mail carried for third parties (memory and disk)
+ // Drop courier mail carried for third parties (memory and disk),
+ // our own queued outbox, the carried public history, and the
+ // counters describing all of it
CourierStore.shared.wipe()
+ messageRouter.wipeOutbox()
+ GossipMessageArchive.wipeDefault()
+ StoreAndForwardMetrics.shared.reset()
// Identity manager has cleared persisted identity data above
diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift
index 8897d7c8..35bc0ef4 100644
--- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift
+++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift
@@ -17,7 +17,9 @@ struct ChatViewModelServiceBundle {
keychain: KeychainManagerProtocol,
idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol,
- meshService: Transport
+ meshService: Transport,
+ outboxStore: MessageOutboxStore? = nil,
+ sfMetrics: StoreAndForwardMetrics? = nil
) {
let commandProcessor = CommandProcessor(identityManager: identityManager)
let privateChatManager = PrivateChatManager(meshService: meshService)
@@ -28,7 +30,11 @@ struct ChatViewModelServiceBundle {
)
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)
nostrTransport.senderPeerID = meshService.myPeerID
- let messageRouter = MessageRouter(transports: [meshService, nostrTransport])
+ let messageRouter = MessageRouter(
+ transports: [meshService, nostrTransport],
+ outboxStore: outboxStore,
+ metrics: sfMetrics
+ )
self.commandProcessor = commandProcessor
self.messageRouter = messageRouter
diff --git a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift
index 8ffd2933..1c1fc46f 100644
--- a/bitchatTests/ChatTransportEventCoordinatorContextTests.swift
+++ b/bitchatTests/ChatTransportEventCoordinatorContextTests.swift
@@ -121,9 +121,11 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
// Routing & acknowledgements
private(set) var flushedOutboxPeerIDs: [PeerID] = []
+ private(set) var courierRetryPeerIDs: [PeerID] = []
private(set) var meshDeliveryAcks: [(messageID: String, peerID: PeerID)] = []
func flushRouterOutbox(for peerID: PeerID) { flushedOutboxPeerIDs.append(peerID) }
+ func retryCourierDeposits(via peerID: PeerID) { courierRetryPeerIDs.append(peerID) }
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) {
meshDeliveryAcks.append((messageID, peerID))
}
diff --git a/bitchatTests/CourierStoreTests.swift b/bitchatTests/CourierStoreTests.swift
index 9e675649..23829b0e 100644
--- a/bitchatTests/CourierStoreTests.swift
+++ b/bitchatTests/CourierStoreTests.swift
@@ -103,7 +103,7 @@ struct CourierStoreTests {
@Test func perDepositorQuota() {
let store = makeStore()
- for _ in 0.. give 1, keep 1).
+ let courierY = Data(repeating: 0xC2, count: 32)
+ let sprayedToY = store.takeSprayCopies(for: courierY)
+ #expect(sprayedToY.count == 1)
+ #expect(sprayedToY.first?.copies == 1)
+
+ // Budget exhausted (carry-only): nothing left to spray.
+ #expect(store.takeSprayCopies(for: Data(repeating: 0xC3, count: 32)).isEmpty)
+ // The carried original is still deliverable.
+ #expect(store.takeEnvelopes(for: recipientKey).count == 1)
+ }
+
+ @Test func carryOnlyEnvelopesAreNeverSprayed() {
+ let store = makeStore()
+ #expect(store.deposit(makeEnvelope(), from: depositorA))
+ #expect(store.takeSprayCopies(for: Data(repeating: 0xC1, count: 32)).isEmpty)
+ }
+
+ @Test func duplicateDepositKeepsLargerSprayBudget() {
+ let store = makeStore()
+ let recipientKey = Data(repeating: 0xB0, count: 32)
+ let ciphertext = Data(repeating: 0x42, count: 96)
+ let carryOnly = makeEnvelope(recipientKey: recipientKey, ciphertext: ciphertext)
+ #expect(store.deposit(carryOnly, from: depositorA))
+ #expect(store.deposit(carryOnly.withCopies(4), from: depositorB))
+
+ let sprayed = store.takeSprayCopies(for: Data(repeating: 0xC1, count: 32))
+ #expect(sprayed.first?.copies == 2)
+ }
+
+ // MARK: - Remote handover (relayed announces)
+
+ @Test func remoteHandoverIsNonDestructiveAndCooledDown() {
+ let store = makeStore()
+ let recipientKey = Data(repeating: 0xB0, count: 32)
+ let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(4)
+ #expect(store.deposit(envelope, from: depositorA))
+
+ let first = store.envelopesForRemoteHandover(recipientNoiseKey: recipientKey, cooldown: 600)
+ #expect(first.count == 1)
+ // The flooded copy carries no spray budget.
+ #expect(first.first?.copies == 1)
+ // Non-destructive: the envelope is still carried...
+ #expect(!store.isEmpty)
+ // ...and inside the cooldown it is not re-flooded.
+ #expect(store.envelopesForRemoteHandover(recipientNoiseKey: recipientKey, cooldown: 600).isEmpty)
+ // A direct encounter still hands it over destructively.
+ #expect(store.takeEnvelopes(for: recipientKey).count == 1)
+ #expect(store.isEmpty)
+ }
+
+ // MARK: - Legacy persistence
+
+ @Test func legacyPersistedFileLoadsAsFavoriteCarryOnly() throws {
+ let fileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("courier-legacy-\(UUID().uuidString).json")
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+
+ // Envelope persisted by a pre-tier/pre-spray build: no tier, copies,
+ // or spray bookkeeping fields.
+ let recipientKey = Data(repeating: 0xB0, count: 32)
+ let envelope = makeEnvelope(recipientKey: recipientKey)
+ let legacy: [[String: Any]] = [[
+ "recipientTag": envelope.recipientTag.base64EncodedString(),
+ "expiry": envelope.expiry,
+ "ciphertext": envelope.ciphertext.base64EncodedString(),
+ "depositorNoiseKey": depositorA.base64EncodedString(),
+ "storedAt": Self.baseDate.timeIntervalSinceReferenceDate
+ ]]
+ let data = try JSONSerialization.data(withJSONObject: legacy)
+ try data.write(to: fileURL)
+
+ let store = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
+ // Carry-only, so never sprayed...
+ #expect(store.takeSprayCopies(for: Data(repeating: 0xC1, count: 32)).isEmpty)
+ // ...but still delivered on encounter.
+ #expect(store.takeEnvelopes(for: recipientKey).count == 1)
+ }
}
diff --git a/bitchatTests/EndToEnd/CourierEndToEndTests.swift b/bitchatTests/EndToEnd/CourierEndToEndTests.swift
index a8cc3079..22e56c79 100644
--- a/bitchatTests/EndToEnd/CourierEndToEndTests.swift
+++ b/bitchatTests/EndToEnd/CourierEndToEndTests.swift
@@ -104,7 +104,7 @@ struct CourierEndToEndTests {
let bob = makeService()
// Alice and Carol are mutual favorites; trust policy is exercised
// separately in depositFromUntrustedPeerIsRejected.
- carol.courierDepositPolicy = { _ in true }
+ carol.courierDepositPolicy = { _, _ in .favorite }
let bobDelegate = NoiseCaptureDelegate()
bob.delegate = bobDelegate
@@ -134,7 +134,7 @@ struct CourierEndToEndTests {
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
// 2. Ferry the deposit to Carol; she carries it (opaque to her).
- carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
+ carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
@@ -186,7 +186,7 @@ struct CourierEndToEndTests {
let carol = makeService()
let bobIdentity = MockIdentityManager(MockKeychain())
let bob = makeService(identityManager: bobIdentity)
- carol.courierDepositPolicy = { _ in true }
+ carol.courierDepositPolicy = { _, _ in .favorite }
let bobDelegate = NoiseCaptureDelegate()
bob.delegate = bobDelegate
@@ -215,7 +215,7 @@ struct CourierEndToEndTests {
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
- carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
+ carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
@@ -254,7 +254,7 @@ struct CourierEndToEndTests {
let alice = makeService()
let carol = makeService()
let bob = makeService()
- carol.courierDepositPolicy = { _ in true }
+ carol.courierDepositPolicy = { _, _ in .favorite }
let aliceOut = PacketTap()
alice._test_onOutboundPacket = aliceOut.record
@@ -278,7 +278,7 @@ struct CourierEndToEndTests {
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
- carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
+ carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
@@ -312,11 +312,11 @@ struct CourierEndToEndTests {
#expect(carol.courierStore.isEmpty)
}
- @Test func relayedAnnounceDoesNotTriggerCourierHandover() async throws {
+ @Test func relayedAnnounceTriggersNonDestructiveRemoteHandover() async throws {
let alice = makeService()
let carol = makeService()
let bob = makeService()
- carol.courierDepositPolicy = { _ in true }
+ carol.courierDepositPolicy = { _, _ in .favorite }
let aliceOut = PacketTap()
alice._test_onOutboundPacket = aliceOut.record
@@ -340,7 +340,7 @@ struct CourierEndToEndTests {
#expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
- carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
+ carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout
@@ -356,23 +356,24 @@ struct CourierEndToEndTests {
let directAnnounce = try #require(bobOut.first(ofType: .announce))
// A relayed copy has a decremented TTL but a still-valid signature
- // (TTL is excluded from announce signatures). Envelopes are removed
- // from the store optimistically, so handover must wait for a direct
- // encounter instead of chasing a multi-hop path.
+ // (TTL is excluded from announce signatures). The recipient is
+ // multi-hop away, so a copy floods toward them speculatively while
+ // the carried original stays put for a future direct encounter.
var relayedAnnounce = directAnnounce
relayedAnnounce.ttl = directAnnounce.ttl - 1
carol._test_handlePacket(relayedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
- let leakedOnRelayedAnnounce = await TestHelpers.waitUntil(
- { carolOut.count(ofType: .courierEnvelope) > 0 },
- timeout: TestConstants.shortTimeout
+ let remoteHandover = await TestHelpers.waitUntil(
+ { carolOut.count(ofType: .courierEnvelope) == 1 },
+ timeout: TestConstants.defaultTimeout
)
- #expect(!leakedOnRelayedAnnounce)
+ #expect(remoteHandover)
#expect(!carol.courierStore.isEmpty)
- // The relayed copy consumed the original announce's dedup key
- // (sender/timestamp/payload — TTL excluded), so the direct handover
- // needs a fresh announce. Wait out the 1s announce throttle first.
+ // A second relayed announce inside the cooldown must not re-flood
+ // the same envelope. The original announce's dedup key is consumed
+ // (sender/timestamp/payload — TTL excluded), so use a fresh announce;
+ // wait out the 1s announce throttle first.
try await Task.sleep(nanoseconds: 1_100_000_000)
bob.sendBroadcastAnnounce()
let reannounced = await TestHelpers.waitUntil(
@@ -383,10 +384,32 @@ struct CourierEndToEndTests {
let freshAnnounce = try #require(
bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp }
)
- carol._test_handlePacket(freshAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
+ var relayedFreshAnnounce = freshAnnounce
+ relayedFreshAnnounce.ttl = freshAnnounce.ttl - 1
+ carol._test_handlePacket(relayedFreshAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
+
+ let refloodedInCooldown = await TestHelpers.waitUntil(
+ { carolOut.count(ofType: .courierEnvelope) > 1 },
+ timeout: TestConstants.shortTimeout
+ )
+ #expect(!refloodedInCooldown)
+ #expect(!carol.courierStore.isEmpty)
+
+ // A later *direct* announce still performs the destructive handover.
+ try await Task.sleep(nanoseconds: 1_100_000_000)
+ bob.sendBroadcastAnnounce()
+ let announcedAgain = await TestHelpers.waitUntil(
+ { bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp && $0.timestamp != freshAnnounce.timestamp } },
+ timeout: TestConstants.defaultTimeout
+ )
+ #expect(announcedAgain)
+ let directAgain = try #require(
+ bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp && $0.timestamp != freshAnnounce.timestamp }
+ )
+ carol._test_handlePacket(directAgain, fromPeerID: bob.myPeerID, preseedPeer: false)
let handedOver = await TestHelpers.waitUntil(
- { carolOut.count(ofType: .courierEnvelope) == 1 },
+ { carolOut.count(ofType: .courierEnvelope) == 2 },
timeout: TestConstants.defaultTimeout
)
#expect(handedOver)
@@ -417,7 +440,7 @@ struct CourierEndToEndTests {
@Test func depositFromUntrustedPeerIsRejected() async throws {
let carol = makeService()
- carol.courierDepositPolicy = { _ in false } // depositor is not a mutual favorite
+ carol.courierDepositPolicy = { _, _ in nil } // depositor is neither favorite nor verified
let alice = NoiseEncryptionService(keychain: MockKeychain())
let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData()
@@ -433,6 +456,45 @@ struct CourierEndToEndTests {
ciphertext: sealed
)
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
+ let unsigned = BitchatPacket(
+ type: MessageType.courierEnvelope.rawValue,
+ senderID: Data(hexString: alicePeerID.id) ?? Data(),
+ recipientID: Data(hexString: carol.myPeerID.id),
+ timestamp: UInt64(now.timeIntervalSince1970 * 1000),
+ payload: try #require(envelope.encode()),
+ signature: nil,
+ ttl: 1
+ )
+ let packet = try #require(alice.signPacket(unsigned))
+
+ carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData())
+ let stored = await TestHelpers.waitUntil(
+ { !carol.courierStore.isEmpty },
+ timeout: TestConstants.shortTimeout
+ )
+ #expect(!stored)
+ }
+
+ @Test func unsignedDepositIsRejected() async throws {
+ let carol = makeService()
+ carol.courierDepositPolicy = { _, _ in .favorite }
+
+ let alice = NoiseEncryptionService(keychain: MockKeychain())
+ let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData()
+ let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "x", messageID: "m-unsigned"))
+ let sealed = try alice.sealCourierPayload(typedPayload, recipientStaticKey: bobKey)
+ let now = Date()
+ let envelope = CourierEnvelope(
+ recipientTag: CourierEnvelope.recipientTag(
+ noiseStaticKey: bobKey,
+ epochDay: CourierEnvelope.epochDay(for: now)
+ ),
+ expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000),
+ ciphertext: sealed
+ )
+ let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
+ // Correct sender, willing policy — but no packet signature: the
+ // courier cannot authenticate the depositor, so it must not carry.
let packet = BitchatPacket(
type: MessageType.courierEnvelope.rawValue,
senderID: Data(hexString: alicePeerID.id) ?? Data(),
@@ -443,7 +505,7 @@ struct CourierEndToEndTests {
ttl: 1
)
- carol._test_handlePacket(packet, fromPeerID: alicePeerID)
+ carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData())
let stored = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty },
timeout: TestConstants.shortTimeout
@@ -459,8 +521,8 @@ struct CourierEndToEndTests {
preseedConnectedPeer(mallory, in: carol)
let trustedAliceKey = Data(hexString: alice.myPeerID.id) ?? Data()
- carol.courierDepositPolicy = { depositorKey in
- depositorKey == trustedAliceKey
+ carol.courierDepositPolicy = { depositorKey, _ in
+ depositorKey == trustedAliceKey ? .favorite : nil
}
let aliceNoise = NoiseEncryptionService(keychain: MockKeychain())
diff --git a/bitchatTests/GossipSyncManagerTests.swift b/bitchatTests/GossipSyncManagerTests.swift
index b717e2f0..c44de8f6 100644
--- a/bitchatTests/GossipSyncManagerTests.swift
+++ b/bitchatTests/GossipSyncManagerTests.swift
@@ -468,6 +468,79 @@ struct GossipSyncManagerTests {
#expect(sentPackets.count == 1)
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
}
+
+ // MARK: - Archive persistence
+
+ @Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws {
+ let fileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("gossip-archive-\(UUID().uuidString).json")
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+
+ let senderID = try #require(Data(hexString: "1122334455667788"))
+ let packet = BitchatPacket(
+ type: MessageType.message.rawValue,
+ senderID: senderID,
+ recipientID: nil,
+ timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
+ payload: Data([0x01, 0x02]),
+ signature: nil,
+ ttl: 1
+ )
+
+ let first = GossipSyncManager(
+ myPeerID: myPeerID,
+ requestSyncManager: RequestSyncManager(),
+ archive: GossipMessageArchive(fileURL: fileURL)
+ )
+ first.onPublicPacketSeen(packet)
+ // Maintenance persists the dirty store to disk.
+ first._performMaintenanceSynchronously(now: Date())
+ #expect(FileManager.default.fileExists(atPath: fileURL.path))
+
+ // "App restart": a fresh manager over the same archive re-serves it.
+ let second = GossipSyncManager(
+ myPeerID: myPeerID,
+ requestSyncManager: RequestSyncManager(),
+ archive: GossipMessageArchive(fileURL: fileURL)
+ )
+ let restored = await TestHelpers.waitUntil(
+ { second._messageCount(for: PeerID(hexData: senderID)) == 1 },
+ timeout: TestConstants.shortTimeout
+ )
+ #expect(restored)
+ }
+
+ @Test func archiveDropsMessagesOlderThanPublicWindow() throws {
+ let fileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("gossip-archive-\(UUID().uuidString).json")
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+
+ var config = GossipSyncManager.Config()
+ config.publicMessageMaxAgeSeconds = 60
+
+ let senderID = try #require(Data(hexString: "1122334455667788"))
+ let stale = BitchatPacket(
+ type: MessageType.message.rawValue,
+ senderID: senderID,
+ recipientID: nil,
+ timestamp: UInt64((Date().timeIntervalSince1970 - 120) * 1000),
+ payload: Data([0x01]),
+ signature: nil,
+ ttl: 1
+ )
+ let archive = GossipMessageArchive(fileURL: fileURL)
+ archive.save([stale.toBinaryData(padding: false)!])
+
+ let manager = GossipSyncManager(
+ myPeerID: myPeerID,
+ config: config,
+ requestSyncManager: RequestSyncManager(),
+ archive: archive
+ )
+ manager._performMaintenanceSynchronously(now: Date())
+ #expect(manager._messageCount(for: PeerID(hexData: senderID)) == 0)
+ }
+
}
private final class RecordingDelegate: GossipSyncManager.Delegate {
diff --git a/bitchatTests/Mocks/MockTransport.swift b/bitchatTests/Mocks/MockTransport.swift
index 0cdbe7a0..e4cf08de 100644
--- a/bitchatTests/Mocks/MockTransport.swift
+++ b/bitchatTests/Mocks/MockTransport.swift
@@ -42,6 +42,7 @@ final class MockTransport: Transport {
private(set) var cancelledTransfers: [String] = []
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
+ private(set) var sentCourierMessages: [(content: String, messageID: String, recipientNoiseKey: Data, couriers: [PeerID])] = []
private(set) var startServicesCallCount = 0
private(set) var stopServicesCallCount = 0
private(set) var emergencyDisconnectCallCount = 0
@@ -189,6 +190,12 @@ final class MockTransport: Transport {
sentVerifyResponses.append((peerID, noiseKeyHex, nonceA))
}
+ var courierSendResult = true
+ func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
+ sentCourierMessages.append((content, messageID, recipientNoiseKey, couriers))
+ return courierSendResult
+ }
+
// MARK: - Test Helpers
/// Clears all recorded method calls for fresh assertions
diff --git a/bitchatTests/Services/BLEPublicMessageHandlerTests.swift b/bitchatTests/Services/BLEPublicMessageHandlerTests.swift
index 056d3ca9..74a4441d 100644
--- a/bitchatTests/Services/BLEPublicMessageHandlerTests.swift
+++ b/bitchatTests/Services/BLEPublicMessageHandlerTests.swift
@@ -100,11 +100,11 @@ struct BLEPublicMessageHandlerTests {
@Test
func staleBroadcastIsDropped() {
- let now = Date(timeIntervalSince1970: 1_000)
+ let now = Date(timeIntervalSince1970: 1_000_000)
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder, now: now)
- let staleTimestamp = UInt64((now.timeIntervalSince1970 - 901) * 1000)
+ let staleTimestamp = UInt64((now.timeIntervalSince1970 - TransportConfig.syncPublicMessageMaxAgeSeconds - 1) * 1000)
let packet = makeMessagePacket(sender: remotePeerID, content: "old", timestamp: staleTimestamp)
handler.handle(packet, from: remotePeerID)
diff --git a/bitchatTests/Services/BLEPublicMessagePolicyTests.swift b/bitchatTests/Services/BLEPublicMessagePolicyTests.swift
index fc725194..dc6d7271 100644
--- a/bitchatTests/Services/BLEPublicMessagePolicyTests.swift
+++ b/bitchatTests/Services/BLEPublicMessagePolicyTests.swift
@@ -36,11 +36,13 @@ struct BLEPublicMessagePolicyTests {
@Test
func staleBroadcastIsRejectedWithAge() {
- let now = Date(timeIntervalSince1970: 1_000)
+ // The acceptance window matches the gossip public-history window.
+ let staleAge = TransportConfig.syncPublicMessageMaxAgeSeconds + 1
+ let now = Date(timeIntervalSince1970: 1_000_000)
let sender = PeerID(str: "8877665544332211")
let packet = makePacket(
sender: sender,
- timestamp: UInt64((now.timeIntervalSince1970 - 901) * 1000),
+ timestamp: UInt64((now.timeIntervalSince1970 - staleAge) * 1000),
recipientID: nil
)
@@ -51,7 +53,7 @@ struct BLEPublicMessagePolicyTests {
now: now
)
- #expect(decision == .reject(.staleBroadcast(ageSeconds: 901)))
+ #expect(decision == .reject(.staleBroadcast(ageSeconds: staleAge)))
}
@Test
diff --git a/bitchatTests/Services/MessageOutboxStoreTests.swift b/bitchatTests/Services/MessageOutboxStoreTests.swift
new file mode 100644
index 00000000..218f6cf0
--- /dev/null
+++ b/bitchatTests/Services/MessageOutboxStoreTests.swift
@@ -0,0 +1,92 @@
+//
+// MessageOutboxStoreTests.swift
+// bitchatTests
+//
+// Tests for the encrypted-at-rest outbox persistence.
+//
+
+import Testing
+import Foundation
+import BitFoundation
+@testable import bitchat
+
+struct MessageOutboxStoreTests {
+
+ private func makeTempURL() -> URL {
+ FileManager.default.temporaryDirectory
+ .appendingPathComponent("outbox-\(UUID().uuidString).sealed")
+ }
+
+ private func makeMessage(_ id: String, content: String = "hello") -> MessageOutboxStore.QueuedMessage {
+ MessageOutboxStore.QueuedMessage(
+ content: content,
+ nickname: "peer",
+ messageID: id,
+ timestamp: Date(timeIntervalSince1970: 1_750_000_000),
+ sendAttempts: 2,
+ depositedCourierKeys: [Data(repeating: 0xC1, count: 32)]
+ )
+ }
+
+ @Test func roundTripAcrossInstances() {
+ let fileURL = makeTempURL()
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+ let keychain = MockKeychain()
+ let peerID = PeerID(str: "0000000000000001")
+
+ let store = MessageOutboxStore(keychain: keychain, fileURL: fileURL)
+ store.save([peerID: [makeMessage("m1")]])
+
+ // Same keychain (encryption key) reads it back, fields intact.
+ let reloaded = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load()
+ #expect(reloaded[peerID]?.count == 1)
+ #expect(reloaded[peerID]?.first?.messageID == "m1")
+ #expect(reloaded[peerID]?.first?.sendAttempts == 2)
+ #expect(reloaded[peerID]?.first?.depositedCourierKeys.count == 1)
+ }
+
+ @Test func contentIsNotPlaintextOnDisk() throws {
+ let fileURL = makeTempURL()
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+ let store = MessageOutboxStore(keychain: MockKeychain(), fileURL: fileURL)
+ store.save([PeerID(str: "0000000000000001"): [makeMessage("m1", content: "very secret message")]])
+
+ let raw = try Data(contentsOf: fileURL)
+ #expect(!raw.isEmpty)
+ // Sealed bytes must not contain the message plaintext.
+ #expect(raw.range(of: Data("very secret message".utf8)) == nil)
+ }
+
+ @Test func loadWithoutKeyReturnsEmpty() {
+ let fileURL = makeTempURL()
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+ let store = MessageOutboxStore(keychain: MockKeychain(), fileURL: fileURL)
+ store.save([PeerID(str: "0000000000000001"): [makeMessage("m1")]])
+
+ // A different keychain (fresh device / wiped key) cannot read the file.
+ let other = MessageOutboxStore(keychain: MockKeychain(), fileURL: fileURL)
+ #expect(other.load().isEmpty)
+ }
+
+ @Test func wipeRemovesFileAndKey() {
+ let fileURL = makeTempURL()
+ let keychain = MockKeychain()
+ let store = MessageOutboxStore(keychain: keychain, fileURL: fileURL)
+ store.save([PeerID(str: "0000000000000001"): [makeMessage("m1")]])
+ #expect(FileManager.default.fileExists(atPath: fileURL.path))
+
+ store.wipe()
+ #expect(!FileManager.default.fileExists(atPath: fileURL.path))
+ #expect(store.load().isEmpty)
+ }
+
+ @Test func savingEmptyOutboxRemovesFile() {
+ let fileURL = makeTempURL()
+ let keychain = MockKeychain()
+ let store = MessageOutboxStore(keychain: keychain, fileURL: fileURL)
+ let peerID = PeerID(str: "0000000000000001")
+ store.save([peerID: [makeMessage("m1")]])
+ store.save([peerID: []])
+ #expect(!FileManager.default.fileExists(atPath: fileURL.path))
+ }
+}
diff --git a/bitchatTests/Services/MessageRouterTests.swift b/bitchatTests/Services/MessageRouterTests.swift
index 13f020a9..78a9caec 100644
--- a/bitchatTests/Services/MessageRouterTests.swift
+++ b/bitchatTests/Services/MessageRouterTests.swift
@@ -226,6 +226,197 @@ struct MessageRouterTests {
#expect(transport.sentFavoriteNotifications.count == 1)
}
+
+ // MARK: - Courier deposits
+
+ private static func snapshot(_ peerID: PeerID, key: Data, verified: Bool) -> TransportPeerSnapshot {
+ TransportPeerSnapshot(
+ peerID: peerID,
+ nickname: "peer",
+ isConnected: true,
+ noisePublicKey: key,
+ lastSeen: Date(),
+ isVerified: verified
+ )
+ }
+
+ /// Directory that resolves one offline recipient and treats a fixed key
+ /// set as mutual favorites.
+ private static func directory(recipient: PeerID, recipientKey: Data, favoriteKeys: Set = []) -> CourierDirectory {
+ CourierDirectory(
+ noiseKey: { peerID in peerID == recipient ? recipientKey : nil },
+ isTrustedCourier: { favoriteKeys.contains($0) }
+ )
+ }
+
+ @Test @MainActor
+ func sendPrivate_depositsWithVerifiedStrangerWhenNoFavoriteAround() async {
+ let recipient = PeerID(str: "00000000000000aa")
+ let recipientKey = Data(repeating: 0xBB, count: 32)
+ let courier = PeerID(str: "00000000000000cc")
+ let courierKey = Data(repeating: 0xCC, count: 32)
+
+ let transport = MockTransport()
+ transport.connectedPeers.insert(courier)
+ transport.updatePeerSnapshots([Self.snapshot(courier, key: courierKey, verified: true)])
+
+ let router = MessageRouter(
+ transports: [transport],
+ courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey)
+ )
+ router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cv1")
+
+ #expect(transport.sentCourierMessages.count == 1)
+ #expect(transport.sentCourierMessages.first?.couriers == [courier])
+ }
+
+ @Test @MainActor
+ func sendPrivate_neverDepositsWithUnverifiedStranger() async {
+ let recipient = PeerID(str: "00000000000000aa")
+ let courier = PeerID(str: "00000000000000cc")
+
+ let transport = MockTransport()
+ transport.connectedPeers.insert(courier)
+ transport.updatePeerSnapshots([Self.snapshot(courier, key: Data(repeating: 0xCC, count: 32), verified: false)])
+
+ let router = MessageRouter(
+ transports: [transport],
+ courierDirectory: Self.directory(recipient: recipient, recipientKey: Data(repeating: 0xBB, count: 32))
+ )
+ router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cv2")
+
+ #expect(transport.sentCourierMessages.isEmpty)
+ }
+
+ @Test @MainActor
+ func sendPrivate_prefersFavoriteCouriersOverVerifiedOnes() async {
+ let recipient = PeerID(str: "00000000000000aa")
+ let recipientKey = Data(repeating: 0xBB, count: 32)
+ let favorite = PeerID(str: "00000000000000f0")
+ let favoriteKey = Data(repeating: 0xF0, count: 32)
+ var snapshots = [Self.snapshot(favorite, key: favoriteKey, verified: false)]
+ let transport = MockTransport()
+ transport.connectedPeers.insert(favorite)
+ // Three verified strangers compete for the three courier slots.
+ for byte: UInt8 in [0xC1, 0xC2, 0xC3] {
+ let peer = PeerID(str: String(format: "00000000000000%02x", byte))
+ transport.connectedPeers.insert(peer)
+ snapshots.append(Self.snapshot(peer, key: Data(repeating: byte, count: 32), verified: true))
+ }
+ transport.updatePeerSnapshots(snapshots)
+
+ let router = MessageRouter(
+ transports: [transport],
+ courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey, favoriteKeys: [favoriteKey])
+ )
+ router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cv3")
+
+ let couriers = transport.sentCourierMessages.first?.couriers ?? []
+ #expect(couriers.count == 3)
+ #expect(couriers.contains(favorite))
+ }
+
+ @Test @MainActor
+ func courierBecameAvailable_retriesDepositOnceWithoutDoubleBurn() async {
+ let recipient = PeerID(str: "00000000000000aa")
+ let recipientKey = Data(repeating: 0xBB, count: 32)
+ let courier = PeerID(str: "00000000000000cc")
+ let courierKey = Data(repeating: 0xCC, count: 32)
+
+ let transport = MockTransport()
+ let router = MessageRouter(
+ transports: [transport],
+ courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey)
+ )
+ // Nobody around at send time: the message just queues.
+ router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cr1")
+ #expect(transport.sentCourierMessages.isEmpty)
+
+ // A verified courier appears later: the deposit retries.
+ transport.connectedPeers.insert(courier)
+ transport.updatePeerSnapshots([Self.snapshot(courier, key: courierKey, verified: true)])
+ router.courierBecameAvailable(courier)
+ #expect(transport.sentCourierMessages.count == 1)
+ #expect(transport.sentCourierMessages.first?.couriers == [courier])
+
+ // The same courier reconnecting does not receive the same mail twice.
+ router.courierBecameAvailable(courier)
+ #expect(transport.sentCourierMessages.count == 1)
+ }
+
+ @Test @MainActor
+ func courierBecameAvailable_ignoresTheRecipientThemselves() async {
+ let recipient = PeerID(str: "00000000000000aa")
+ let recipientKey = Data(repeating: 0xBB, count: 32)
+
+ let transport = MockTransport()
+ let router = MessageRouter(
+ transports: [transport],
+ courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey)
+ )
+ router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cr2")
+
+ // The recipient connecting is a flush, not a courier opportunity.
+ transport.connectedPeers.insert(recipient)
+ transport.updatePeerSnapshots([Self.snapshot(recipient, key: recipientKey, verified: true)])
+ router.courierBecameAvailable(recipient)
+ #expect(transport.sentCourierMessages.isEmpty)
+ }
+
+ // MARK: - Outbox persistence
+
+ @Test @MainActor
+ func queuedMessagesSurviveRouterRestart() async {
+ let fileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("router-outbox-\(UUID().uuidString).sealed")
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+ let keychain = MockKeychain()
+ let peerID = PeerID(str: "00000000000000dd")
+
+ let transport = MockTransport()
+ let router = MessageRouter(
+ transports: [transport],
+ outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL)
+ )
+ router.sendPrivate("Survive", to: peerID, recipientNickname: "Peer", messageID: "p1")
+ #expect(transport.sentPrivateMessages.isEmpty)
+
+ // "App restart": a fresh router over the same store, peer now around.
+ let transport2 = MockTransport()
+ transport2.reachablePeers.insert(peerID)
+ let router2 = MessageRouter(
+ transports: [transport2],
+ outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL)
+ )
+ router2.flushOutbox(for: peerID)
+ #expect(transport2.sentPrivateMessages.map(\.messageID) == ["p1"])
+ }
+
+ @Test @MainActor
+ func deliveredMessagesDoNotResurrectAfterRestart() async {
+ let fileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("router-outbox-\(UUID().uuidString).sealed")
+ defer { try? FileManager.default.removeItem(at: fileURL) }
+ let keychain = MockKeychain()
+ let peerID = PeerID(str: "00000000000000de")
+
+ let transport = MockTransport()
+ let router = MessageRouter(
+ transports: [transport],
+ outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL)
+ )
+ router.sendPrivate("Once", to: peerID, recipientNickname: "Peer", messageID: "p2")
+ router.markDelivered("p2")
+
+ let transport2 = MockTransport()
+ transport2.reachablePeers.insert(peerID)
+ let router2 = MessageRouter(
+ transports: [transport2],
+ outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL)
+ )
+ router2.flushOutbox(for: peerID)
+ #expect(transport2.sentPrivateMessages.isEmpty)
+ }
}
/// Mutable wall clock injected into `MessageRouter` so TTL expiry is testable
diff --git a/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift b/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift
index 4bb99de3..4dea3873 100644
--- a/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift
+++ b/localPackages/BitFoundation/Sources/BitFoundation/CourierEnvelope.swift
@@ -24,23 +24,37 @@ public struct CourierEnvelope: Equatable {
public let expiry: UInt64
/// Opaque one-way Noise X ciphertext (sender identity rides inside).
public let ciphertext: Data
+ /// Spray-and-wait copy budget: how many redundant copies of this envelope
+ /// the holder may still hand to other couriers (binary split on each
+ /// spray). 1 means carry-only — deliver to the recipient, never re-spray.
+ public let copies: UInt8
public static let tagLength = 16
/// Couriered messages are text-sized; media transfers are out of scope.
public static let maxCiphertextBytes = 16 * 1024
/// Matches the outbox retention policy in MessageRouter.
public static let maxLifetimeSeconds: TimeInterval = 24 * 60 * 60
+ /// Cap on the copy budget a depositor can claim, so a malicious envelope
+ /// cannot turn the courier network into an amplifier.
+ public static let maxCopies: UInt8 = 8
private enum TLVType: UInt8 {
case recipientTag = 0x01
case expiry = 0x02
case ciphertext = 0x03
+ case copies = 0x04
}
- public init(recipientTag: Data, expiry: UInt64, ciphertext: Data) {
+ public init(recipientTag: Data, expiry: UInt64, ciphertext: Data, copies: UInt8 = 1) {
self.recipientTag = recipientTag
self.expiry = expiry
self.ciphertext = ciphertext
+ self.copies = min(max(copies, 1), Self.maxCopies)
+ }
+
+ /// The same envelope with a different remaining copy budget.
+ public func withCopies(_ copies: UInt8) -> CourierEnvelope {
+ CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies)
}
public var isExpired: Bool {
@@ -75,6 +89,14 @@ public struct CourierEnvelope: Equatable {
appendBE(UInt16(ciphertext.count), into: &encoded)
encoded.append(ciphertext)
+ // Omitted when 1 so carry-only envelopes stay byte-identical to the
+ // pre-spray wire format (old clients skip the TLV as unknown anyway).
+ if copies > 1 {
+ encoded.append(TLVType.copies.rawValue)
+ appendBE(UInt16(1), into: &encoded)
+ encoded.append(copies)
+ }
+
return encoded
}
@@ -85,6 +107,7 @@ public struct CourierEnvelope: Equatable {
var recipientTag: Data?
var expiry: UInt64?
var ciphertext: Data?
+ var copies: UInt8 = 1
while cursor < end {
let typeRaw = data[cursor]
@@ -107,6 +130,9 @@ public struct CourierEnvelope: Equatable {
case .ciphertext:
guard length > 0, length <= maxCiphertextBytes else { return nil }
ciphertext = Data(value)
+ case .copies:
+ guard length == 1 else { return nil }
+ copies = value.first ?? 1
case nil:
// Unknown TLV: skip for forward compatibility.
continue
@@ -114,7 +140,7 @@ public struct CourierEnvelope: Equatable {
}
guard let recipientTag, let expiry, let ciphertext else { return nil }
- return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext)
+ return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies)
}
// MARK: - Recipient Tags
diff --git a/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift b/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift
index a64551ab..9631ee81 100644
--- a/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift
+++ b/localPackages/BitFoundation/Tests/BitFoundationTests/CourierEnvelopeTests.swift
@@ -20,6 +20,38 @@ struct CourierEnvelopeTests {
CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext)
}
+ // MARK: - Spray copies
+
+ @Test func copiesRoundTrip() throws {
+ let envelope = makeEnvelope().withCopies(4)
+ let encoded = try #require(envelope.encode())
+ let decoded = try #require(CourierEnvelope.decode(encoded))
+ #expect(decoded.copies == 4)
+ #expect(decoded == envelope)
+ }
+
+ @Test func carryOnlyEnvelopeEncodesIdenticallyToLegacyFormat() throws {
+ // copies == 1 must be byte-identical to the pre-spray wire format so
+ // old and new clients dedup the same envelope the same way.
+ let envelope = makeEnvelope()
+ #expect(envelope.copies == 1)
+ let encoded = try #require(envelope.encode())
+ let withExplicitOne = try #require(envelope.withCopies(1).encode())
+ #expect(encoded == withExplicitOne)
+ #expect(!encoded.contains(0x04) || CourierEnvelope.decode(encoded)?.copies == 1)
+ }
+
+ @Test func decodeWithoutCopiesTLVDefaultsToCarryOnly() throws {
+ let encoded = try #require(makeEnvelope().encode())
+ let decoded = try #require(CourierEnvelope.decode(encoded))
+ #expect(decoded.copies == 1)
+ }
+
+ @Test func copiesAreClampedToPolicyBounds() {
+ #expect(makeEnvelope().withCopies(0).copies == 1)
+ #expect(makeEnvelope().withCopies(200).copies == CourierEnvelope.maxCopies)
+ }
+
// MARK: - Codec
@Test func roundTrip() throws {