Expand store-and-forward: open couriers, spray-and-wait, persistent outbox, 6h public history (#1372)

Store-and-forward previously delivered to an out-of-range peer only if a
mutual favorite happened to be connected at send time and later met the
recipient directly, and everything except courier envelopes died with the
app process. This closes those gaps end to end:

- Persist the MessageRouter outbox to disk, sealed with a ChaChaPoly key
  held only in the Keychain (no plaintext at rest); queued private
  messages now survive an app kill and flush on next launch.
- Deposit retry: queued messages are re-deposited whenever a new eligible
  courier connects, tracked per message so the same courier is never
  double-burned, until 3 distinct couriers carry it or it expires.
- Tiered open couriering: signature-verified strangers can now carry mail
  (2 envelopes/depositor into a 20-slot pool) alongside mutual favorites
  (5 each); overflow evicts verified-tier mail before favorites'.
- Spray-and-wait: envelopes carry a copy budget (4, capped 8, new TLV,
  wire-compatible with old clients); couriers split half their remaining
  budget with each newly encountered courier so mail diffuses through a
  moving crowd.
- Remote handover: a verified relayed announce now floods a copy toward
  the multi-hop recipient (directed-relay treatment, 10-min per-envelope
  cooldown) while the carried original stays put for a direct encounter.
- Public history: gossip-sync window for whole public messages widened
  from 15 min to 6 h, matched on the receive-acceptance side, and the
  message store persists to disk so devices bridge partitions and
  restarts ("town crier").
- Privacy-safe local delivery counters (bare tallies, log-only) so the
  store-and-forward stack is measurable on-device.
- Panic wipe now also clears the sealed outbox, gossip archive, and
  counters.
- Rewrite WHITEPAPER.md to describe the app as implemented (Noise XX/X,
  actual flood control, courier system, gossip sync, Nostr path); the old
  document described a bloom filter, three fragment types, and a
  MessageRetryService that don't exist.

1037 macOS tests pass (17 new); iOS builds.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-06 19:33:16 +02:00
committed by GitHub
co-authored by jack Claude Fable 5
parent 295f855b6f
commit 7341696280
27 changed files with 1648 additions and 377 deletions
+82 -250
View File
@@ -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 ## 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. Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
* **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.
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 * a **Curve25519 static key** for Noise key agreement — its SHA-256 fingerprint is the peer's stable identity, and
graph TD * an **Ed25519 signing key** for packet signatures.
A[Application Layer] --> B[Session Layer];
B --> C[Encryption Layer];
C --> D[Transport Layer];
subgraph "BitChat Application" 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.
A
end
subgraph "Message Framing & State" ## 4. BLE Mesh Layer
B
end
subgraph "Noise Protocol Framework" ### 4.1 Packet Format
C
end
subgraph "BLE, Wi-Fi Direct, etc." 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.
D
end
style A fill:#cde4ff ### 4.2 Flood Control
style B fill:#b5d8ff
style C fill:#9ac2ff
style D fill:#7eadff
```
* **Application Layer:** Defines the structure of user-facing messages (`BitchatMessage`), acknowledgments (`DeliveryAck`), and other application-level data. Relaying is a deterministic controlled flood tuned by local connection degree:
* **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.
--- * **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 10220 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. ### 4.4 Fragmentation
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.
### 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 ~1530 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: Four mechanisms cover the "recipient is not here right now" problem. All persisted state is wiped by panic mode.
* **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.
--- ### 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. 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.
* **`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.
### 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 ### 6.5 Delivery Metrics
sequenceDiagram
participant I as Initiator
participant R as Responder
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 ## 7. Application Layer
Note right of I: I generates ephemeral key `e_i`.<br/>h = SHA256(h + e_i.pub)
R->>I: <- e, ee, s, es * **Public chat** — signed broadcast messages within the mesh, backed by the gossip-synced history above.
Note left of R: R generates ephemeral key `e_r`.<br/>h = SHA256(h + e_r.pub)<br/>MixKey(DH(e_i, e_r))<br/>R sends static key `s_r`, encrypted.<br/>h = SHA256(h + ciphertext)<br/>MixKey(DH(e_i, s_r)) * **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.
I->>R: -> s, se * **Favorites** — the mutual-trust relationship that unlocks Nostr delivery and the larger courier quota.
Note right of I: I decrypts and verifies `s_r`.<br/>I sends static key `s_i`, encrypted.<br/>h = SHA256(h + ciphertext)<br/>MixKey(DH(s_i, e_r)) * **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.
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.
---
## 8. Security Considerations ## 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. * **Relay nodes** cannot read private traffic; they forward padded, opaque ciphertext.
* **Denial of Service:** The `NoiseRateLimiter` is implemented to prevent resource exhaustion from rapid, repeated handshake attempts from a single peer. * **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.
* **Key-Compromise Impersonation:** The `XX` pattern authenticates both parties, preventing an attacker from impersonating one party to the other. * **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
* **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. * **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
* **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. * **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 *This document describes the protocol as implemented in the current release. The implementation is free and unencumbered software released into the public domain.*
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.
+2 -1
View File
@@ -125,7 +125,8 @@ struct BLEPeerRegistry {
nickname: resolvedNames[info.peerID] ?? info.nickname, nickname: resolvedNames[info.peerID] ?? info.nickname,
isConnected: info.isConnected, isConnected: info.isConnected,
noisePublicKey: info.noisePublicKey, noisePublicKey: info.noisePublicKey,
lastSeen: info.lastSeen lastSeen: info.lastSeen,
isVerified: info.isVerifiedNickname
) )
} }
} }
@@ -27,8 +27,15 @@ enum BLEPublicMessagePolicy {
} }
let isBroadcast = BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID) 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, 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( return .reject(.staleBroadcast(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
timestampMilliseconds: packet.timestamp, timestampMilliseconds: packet.timestamp,
now: now now: now
@@ -48,7 +48,11 @@ struct BLEReceivePipeline {
senderIsSelf: senderID == localPeerID, senderIsSelf: senderID == localPeerID,
recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID, recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID,
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue, 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, isFragment: packet.type == MessageType.fragment.rawValue,
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil, isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
isHandshake: packet.type == MessageType.noiseHandshake.rawValue, isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
+109 -22
View File
@@ -48,12 +48,17 @@ final class BLEService: NSObject {
private let messageDeduplicator = MessageDeduplicator() private let messageDeduplicator = MessageDeduplicator()
// Courier store-and-forward: envelopes this device carries for offline // Courier store-and-forward: envelopes this device carries for offline
// third parties, and the trust gate for accepting deposits. Injectable // third parties, and the trust gate for accepting deposits. The policy
// for tests; main-actor policy because favorites live on the main actor. // 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 courierStore: CourierStore = .shared
var courierDepositPolicy: @MainActor (Data) -> Bool = { depositorNoiseKey in var courierDepositPolicy: @MainActor (Data, Bool) -> CourierDepositTier? = { depositorNoiseKey, isVerifiedPeer in
FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey) 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 #if DEBUG
// Test-only tap on the outbound pipeline so multi-node tests can ferry // Test-only tap on the outbound pipeline so multi-node tests can ferry
@@ -275,6 +280,7 @@ final class BLEService: NSObject {
gcsMaxBytes: TransportConfig.syncGCSMaxBytes, gcsMaxBytes: TransportConfig.syncGCSMaxBytes,
gcsTargetFpr: TransportConfig.syncGCSTargetFpr, gcsTargetFpr: TransportConfig.syncGCSTargetFpr,
maxMessageAgeSeconds: TransportConfig.syncMaxMessageAgeSeconds, maxMessageAgeSeconds: TransportConfig.syncMaxMessageAgeSeconds,
publicMessageMaxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds,
maintenanceIntervalSeconds: TransportConfig.syncMaintenanceIntervalSeconds, maintenanceIntervalSeconds: TransportConfig.syncMaintenanceIntervalSeconds,
stalePeerCleanupIntervalSeconds: TransportConfig.syncStalePeerCleanupIntervalSeconds, stalePeerCleanupIntervalSeconds: TransportConfig.syncStalePeerCleanupIntervalSeconds,
stalePeerTimeoutSeconds: TransportConfig.syncStalePeerTimeoutSeconds, stalePeerTimeoutSeconds: TransportConfig.syncStalePeerTimeoutSeconds,
@@ -287,7 +293,9 @@ final class BLEService: NSObject {
responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds 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 manager.delegate = self
// Only start the periodic sync timers when real Bluetooth exists. In unit // 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 // tests there is no mesh to sync with, and the periodic sign/broadcast
@@ -2515,7 +2523,8 @@ extension BLEService {
epochDay: CourierEnvelope.epochDay(for: now) epochDay: CourierEnvelope.epochDay(for: now)
), ),
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000), expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
ciphertext: sealed ciphertext: sealed,
copies: TransportConfig.courierInitialCopies
) )
guard let encoded = envelope.encode() else { return false } guard let encoded = envelope.encode() else { return false }
payload = encoded payload = encoded
@@ -2535,7 +2544,7 @@ extension BLEService {
} }
private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket { private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket {
BitchatPacket( let packet = BitchatPacket(
type: MessageType.courierEnvelope.rawValue, type: MessageType.courierEnvelope.rawValue,
senderID: myPeerIDData, senderID: myPeerIDData,
recipientID: Data(hexString: peerID.id), recipientID: Data(hexString: peerID.id),
@@ -2544,6 +2553,10 @@ extension BLEService {
signature: nil, signature: nil,
ttl: messageTTL 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: /// 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) { if CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) {
openCourierEnvelope(envelope) openCourierEnvelope(envelope)
} else { } 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 senderPeerID = isKnownOnMesh ? shortID : PeerID(hexData: senderStaticKey)
let payload = Data(typedPayload.dropFirst()) let payload = Data(typedPayload.dropFirst())
SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))", category: .session) SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))", category: .session)
sfMetrics?.record(.courierOpened)
notifyUI { [weak self] in notifyUI { [weak self] in
self?.deliverTransportEvent(.noisePayloadReceived( self?.deliverTransportEvent(.noisePayloadReceived(
peerID: senderPeerID, peerID: senderPeerID,
@@ -2603,20 +2617,38 @@ extension BLEService {
} }
} }
private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID) { private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID, packet: BitchatPacket) {
guard let depositorKey = collectionsQueue.sync(execute: { peerRegistry.info(for: peerID)?.noisePublicKey }) else { // 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) SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session)
return 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 store = courierStore
let policy = courierDepositPolicy let policy = courierDepositPolicy
let metrics = sfMetrics
Task { @MainActor in Task { @MainActor in
guard policy(depositorKey) else { guard let tier = policy(depositorKey, isVerifiedPeer) else {
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (not a mutual favorite)", category: .session) SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (neither favorite nor verified)", category: .session)
return return
} }
if store.deposit(envelope, from: depositorKey) { if store.deposit(envelope, from: depositorKey, tier: tier) {
SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))", category: .session) 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 { for envelope in envelopes {
guard let payload = envelope.encode() else { continue } guard let payload = envelope.encode() else { continue }
sendPacketDirected(makeCourierPacket(payload, to: peerID), to: peerID) 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() centralManager?.stopScan()
startScanning() startScanning()
} }
// Backgrounding may precede a kill; flush the public-history archive
// outside its 30s maintenance cadence.
gossipSyncManager?.persistNow()
logBluetoothStatus("entered-background") logBluetoothStatus("entered-background")
scheduleBluetoothStatusSample(after: 15.0, context: "background-15s") scheduleBluetoothStatusSample(after: 15.0, context: "background-15s")
// No Local Name; nothing to refresh for advertising policy // No Local Name; nothing to refresh for advertising policy
@@ -3186,16 +3266,23 @@ extension BLEService {
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) { private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
let result = announceHandler.handle(packet, from: peerID) let result = announceHandler.handle(packet, from: peerID)
// Courier handover: an announce is the moment we learn a peer's Noise // 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. // static key, so check whether we're carrying mail addressed to them
// Direct announces only: envelopes are removed from the store // (or spray-able mail they could carry). Verified announces only.
// optimistically, so handover must ride an established link rather
// than a speculative multi-hop send toward a relayed announce.
guard !courierStore.isEmpty, guard !courierStore.isEmpty,
let result, let result,
result.isVerified, result.isVerified else { return }
result.isDirectAnnounce else { return } let noiseKey = result.announcement.noisePublicKey
deliverCourierMail(to: result.peerID, 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 /// Builds the announce handler environment. All queue hops stay here so
+152 -19
View File
@@ -11,13 +11,22 @@ import BitLogger
import Combine import Combine
import Foundation 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. /// Holds courier envelopes this device is carrying for offline third parties.
/// ///
/// Envelopes are opaque ciphertext deposited by mutual favorites; this store /// Envelopes are opaque ciphertext; this store never learns sender,
/// never learns sender, recipient, or content. Strict quotas keep the device /// recipient, or content. Strict quotas keep the device from becoming a
/// from becoming a public mailbag: bounded count, bounded per-depositor /// public mailbag: bounded count, bounded per-depositor count by trust tier,
/// count, bounded size, and a 24-hour lifetime aligned with the outbox /// bounded size, and a 24-hour lifetime aligned with the outbox retention
/// retention policy. Carried mail is included in the panic wipe. /// policy. Carried mail is included in the panic wipe.
final class CourierStore { final class CourierStore {
struct StoredEnvelope: Codable, Equatable { struct StoredEnvelope: Codable, Equatable {
let recipientTag: Data let recipientTag: Data
@@ -25,15 +34,63 @@ final class CourierStore {
let ciphertext: Data let ciphertext: Data
let depositorNoiseKey: Data let depositorNoiseKey: Data
let storedAt: Date 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<Data>
/// Last speculative multi-hop handover toward a relayed announce.
var lastRemoteHandoverAt: Date?
var envelope: CourierEnvelope { 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<Data> = [],
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<Data>.self, forKey: .sprayedTo) ?? []
lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt)
} }
} }
enum Limits { enum Limits {
static let maxEnvelopes = 20 static let maxEnvelopes = 40
static let maxPerDepositor = 5 /// 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. /// Slack on top of the 24h lifetime for depositor clock skew.
static let maxExpirySlack: TimeInterval = 60 * 60 static let maxExpirySlack: TimeInterval = 60 * 60
} }
@@ -65,10 +122,11 @@ final class CourierStore {
// MARK: - Depositing (courier side) // MARK: - Depositing (courier side)
/// Accept an envelope from a depositor. Returns false when quotas or /// Accept an envelope from a depositor. Returns false when quotas or
/// validity checks reject it. Trust policy (mutual favorite) is the /// validity checks reject it. Trust policy (which tier a depositor gets,
/// caller's responsibility; this store only enforces resource bounds. /// if any) is the caller's responsibility; this store only enforces
/// resource bounds.
@discardableResult @discardableResult
func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data) -> Bool { func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data, tier: CourierDepositTier = .favorite) -> Bool {
let date = now() let date = now()
guard envelope.recipientTag.count == CourierEnvelope.tagLength, guard envelope.recipientTag.count == CourierEnvelope.tagLength,
!envelope.ciphertext.isEmpty, !envelope.ciphertext.isEmpty,
@@ -86,18 +144,39 @@ final class CourierStore {
return queue.sync { return queue.sync {
pruneExpiredLocked(at: date) pruneExpiredLocked(at: date)
// Identical ciphertext is the same envelope; accept idempotently. // Identical ciphertext is the same envelope; accept idempotently,
if envelopes.contains(where: { $0.ciphertext == envelope.ciphertext }) { // 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 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 return false
} }
if envelopes.count >= Limits.maxEnvelopes { if envelopes.count >= Limits.maxEnvelopes {
// Oldest-first eviction, matching outbox overflow behavior. // Oldest-first eviction, shedding verified-tier mail before
let evicted = envelopes.removeFirst() // favorites' so open couriering can't crowd out trusted mail.
SecureLogger.debug("📦 Courier store full - evicted envelope stored at \(evicted.storedAt)", category: .session) // 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( envelopes.append(StoredEnvelope(
@@ -105,7 +184,9 @@ final class CourierStore {
expiry: envelope.expiry, expiry: envelope.expiry,
ciphertext: envelope.ciphertext, ciphertext: envelope.ciphertext,
depositorNoiseKey: depositorNoiseKey, depositorNoiseKey: depositorNoiseKey,
storedAt: date storedAt: date,
tier: tier,
copies: envelope.copies
)) ))
persistLocked() persistLocked()
return true 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 // MARK: - Maintenance
func pruneExpired() { func pruneExpired() {
@@ -0,0 +1,156 @@
//
// MessageOutboxStore.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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<Data>
init(
content: String,
nickname: String,
messageID: String,
timestamp: Date,
sendAttempts: Int = 0,
depositedCourierKeys: Set<Data> = []
) {
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<Data>.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")
}
}
@@ -0,0 +1,74 @@
//
// StoreAndForwardMetrics.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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()
}
}
+149 -37
View File
@@ -7,7 +7,9 @@ import Foundation
struct CourierDirectory { struct CourierDirectory {
/// Noise static key for a peer we can address while they're offline. /// Noise static key for a peer we can address while they're offline.
var noiseKey: (PeerID) -> Data? 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 var isTrustedCourier: (Data) -> Bool
@MainActor @MainActor
@@ -30,9 +32,13 @@ struct CourierDirectory {
/// Routes messages using available transports (Mesh, Nostr, etc.) /// Routes messages using available transports (Mesh, Nostr, etc.)
@MainActor @MainActor
final class MessageRouter { final class MessageRouter {
typealias QueuedMessage = MessageOutboxStore.QueuedMessage
private let transports: [Transport] private let transports: [Transport]
private let now: () -> Date private let now: () -> Date
private let courierDirectory: CourierDirectory private let courierDirectory: CourierDirectory
private let outboxStore: MessageOutboxStore?
private let metrics: StoreAndForwardMetrics?
/// Invoked whenever a retained private message is dropped without a /// Invoked whenever a retained private message is dropped without a
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction) /// 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)? var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)?
/// Invoked when a message with no reachable transport was handed to at /// Invoked when a message with no reachable transport was handed to at
/// least one courier (a connected mutual favorite who will physically /// least one courier (a connected peer who will physically carry the
/// carry the sealed envelope). Delivery stays best-effort: the outbox /// sealed envelope). Delivery stays best-effort: the outbox retains the
/// retains the message until an ack arrives. /// message until an ack arrives.
var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)? 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]] = [:] private var outbox: [PeerID: [QueuedMessage]] = [:]
// Outbox limits to prevent unbounded memory growth // Outbox limits to prevent unbounded memory growth
@@ -69,11 +66,16 @@ final class MessageRouter {
init( init(
transports: [Transport], transports: [Transport],
now: @escaping () -> Date = Date.init, now: @escaping () -> Date = Date.init,
courierDirectory: CourierDirectory? = nil courierDirectory: CourierDirectory? = nil,
outboxStore: MessageOutboxStore? = nil,
metrics: StoreAndForwardMetrics? = nil
) { ) {
self.transports = transports self.transports = transports
self.now = now self.now = now
self.courierDirectory = courierDirectory ?? .favoritesBacked() 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 // Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
@@ -134,50 +136,137 @@ final class MessageRouter {
// may never come. Double delivery is harmless receivers dedup // may never come. Double delivery is harmless receivers dedup
// by message ID, and delivered/read acks never downgrade. // by message ID, and delivered/read acks never downgrade.
if !transport.canDeliverPromptly(to: peerID) { if !transport.canDeliverPromptly(to: peerID) {
attemptCourierDeposit(content: content, messageID: messageID, for: peerID) attemptCourierDeposit(messageID: messageID, for: peerID)
} }
} else { } else {
var unsent = message var unsent = message
unsent.sendAttempts = 0 unsent.sendAttempts = 0
enqueue(unsent, for: peerID) 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) 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 /// Last resort when no transport can deliver promptly the peer is
/// unreachable, or only reachable through a send queue waiting on /// unreachable, or only reachable through a send queue waiting on
/// internet: seal the message to their known static key and hand it to /// internet: seal the message to their known static key and hand it to
/// connected mutual favorites who may physically encounter them. The /// connected couriers who may physically encounter them. Mutual favorites
/// queued copy above stays retained, so direct delivery still wins if /// are preferred; signature-verified strangers fill remaining slots so a
/// the peer reappears first (receivers dedup by message ID). /// crowd without favorites can still carry mail (envelopes are opaque
private func attemptCourierDeposit(content: String, messageID: String, for peerID: PeerID) { /// either way). The queued copy stays retained, so direct delivery still
guard let recipientKey = courierDirectory.noiseKey(peerID) else { return } /// 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 { for transport in transports {
let couriers = transport.currentPeerSnapshots() let couriers = eligibleCouriers(
.filter { snapshot in on: transport,
guard snapshot.isConnected, recipientKey: recipientKey,
let key = snapshot.noisePublicKey, excluding: entry.depositedCourierKeys,
key != recipientKey else { return false } limit: remainingSlots
return courierDirectory.isTrustedCourier(key) )
}
.prefix(Self.maxCouriersPerMessage)
.map(\.peerID)
guard !couriers.isEmpty else { continue } 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) 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) onMessageCarried?(messageID, peerID)
return 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<Data>,
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. /// A delivery or read ack confirms receipt; stop retaining the message.
func markDelivered(_ messageID: String) { func markDelivered(_ messageID: String) {
var cleared = false
for (peerID, queue) in outbox { for (peerID, queue) in outbox {
let filtered = queue.filter { $0.messageID != messageID } let filtered = queue.filter { $0.messageID != messageID }
guard filtered.count != queue.count else { continue } guard filtered.count != queue.count else { continue }
outbox[peerID] = filtered.isEmpty ? nil : filtered 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 { if queue.count > Self.maxMessagesPerPeer {
let evicted = queue.removeFirst() let evicted = queue.removeFirst()
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted.messageID.prefix(8))", category: .session) 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 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) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
@@ -220,8 +326,6 @@ final class MessageRouter {
} }
} }
// MARK: - Outbox Management
func flushOutbox(for peerID: PeerID) { func flushOutbox(for peerID: PeerID) {
guard let queued = outbox[peerID], !queued.isEmpty else { return } guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session) 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) // Skip expired messages (TTL exceeded)
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds { 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) 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 continue
} }
@@ -241,16 +345,18 @@ final class MessageRouter {
// Live link: send and stop retaining. // 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) 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) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
metrics?.record(.outboxResent)
} else if let transport = reachableTransport(for: peerID) { } else if let transport = reachableTransport(for: peerID) {
// Weak signal: send but keep retaining until an ack clears it, // Weak signal: send but keep retaining until an ack clears it,
// bounded by attempt count for peers that never ack. // bounded by attempt count for peers that never ack.
guard message.sendAttempts < Self.maxSendAttempts else { 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) 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 continue
} }
SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session) 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) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
metrics?.record(.outboxResent)
var retained = message var retained = message
retained.sendAttempts += 1 retained.sendAttempts += 1
remaining.append(retained) remaining.append(retained)
@@ -264,6 +370,7 @@ final class MessageRouter {
} else { } else {
outbox[peerID] = remaining outbox[peerID] = remaining
} }
persistOutbox()
} }
func flushAllOutbox() { func flushAllOutbox() {
@@ -273,6 +380,7 @@ final class MessageRouter {
/// Periodically clean up expired messages from all outboxes /// Periodically clean up expired messages from all outboxes
func cleanupExpiredMessages() { func cleanupExpiredMessages() {
let now = now() let now = now()
var droppedAny = false
for peerID in Array(outbox.keys) { for peerID in Array(outbox.keys) {
var expiredMessageIDs: [String] = [] var expiredMessageIDs: [String] = []
outbox[peerID]?.removeAll { message in outbox[peerID]?.removeAll { message in
@@ -285,8 +393,12 @@ final class MessageRouter {
} }
for messageID in expiredMessageIDs { for messageID in expiredMessageIDs {
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session) 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()
}
} }
} }
+18
View File
@@ -11,6 +11,24 @@ struct TransportPeerSnapshot: Equatable, Hashable {
let isConnected: Bool let isConnected: Bool
let noisePublicKey: Data? let noisePublicKey: Data?
let lastSeen: Date 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 { enum TransportEvent: @unchecked Sendable {
+16
View File
@@ -262,7 +262,14 @@ enum TransportConfig {
static let syncSeenCapacity: Int = 1000 static let syncSeenCapacity: Int = 1000
static let syncGCSMaxBytes: Int = 400 static let syncGCSMaxBytes: Int = 400
static let syncGCSTargetFpr: Double = 0.01 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 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 syncMaintenanceIntervalSeconds: TimeInterval = 30.0
static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0 static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0
static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0 static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0
@@ -273,4 +280,13 @@ enum TransportConfig {
static let syncMessageIntervalSeconds: TimeInterval = 15.0 static let syncMessageIntervalSeconds: TimeInterval = 15.0
static let syncResponseRateLimitMaxResponses: Int = 8 static let syncResponseRateLimitMaxResponses: Int = 8
static let syncResponseRateLimitWindowSeconds: TimeInterval = 30.0 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
} }
+80
View File
@@ -0,0 +1,80 @@
//
// GossipMessageArchive.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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")
}
}
+67 -4
View File
@@ -64,7 +64,10 @@ final class GossipSyncManager {
var seenCapacity: Int = 1000 // max packets per sync (cap across types) var seenCapacity: Int = 1000 // max packets per sync (cap across types)
var gcsMaxBytes: Int = 400 // filter size budget (128..1024) var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
var gcsTargetFpr: Double = 0.01 // 1% 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 maintenanceIntervalSeconds: TimeInterval = 30.0
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0 var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
var stalePeerTimeoutSeconds: TimeInterval = 60.0 var stalePeerTimeoutSeconds: TimeInterval = 60.0
@@ -80,6 +83,7 @@ final class GossipSyncManager {
private let myPeerID: PeerID private let myPeerID: PeerID
private let config: Config private let config: Config
private let requestSyncManager: RequestSyncManager private let requestSyncManager: RequestSyncManager
private let archive: GossipMessageArchive?
weak var delegate: Delegate? weak var delegate: Delegate?
// Storage: broadcast packets by type, and latest announce per sender // Storage: broadcast packets by type, and latest announce per sender
@@ -87,6 +91,7 @@ final class GossipSyncManager {
private var fragments = PacketStore() private var fragments = PacketStore()
private var fileTransfers = PacketStore() private var fileTransfers = PacketStore()
private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:] private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
private var archiveDirty = false
// Timer // Timer
private var periodicTimer: DispatchSourceTimer? private var periodicTimer: DispatchSourceTimer?
@@ -95,10 +100,11 @@ final class GossipSyncManager {
private var syncSchedules: [SyncSchedule] = [] private var syncSchedules: [SyncSchedule] = []
private var responseRateLimiter: SyncResponseRateLimiter 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.myPeerID = myPeerID
self.config = config self.config = config
self.requestSyncManager = requestSyncManager self.requestSyncManager = requestSyncManager
self.archive = archive
self.responseRateLimiter = SyncResponseRateLimiter( self.responseRateLimiter = SyncResponseRateLimiter(
maxResponses: config.responseRateLimitMaxResponses, maxResponses: config.responseRateLimitMaxResponses,
window: config.responseRateLimitWindowSeconds window: config.responseRateLimitWindowSeconds
@@ -114,6 +120,12 @@ final class GossipSyncManager {
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast)) schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
} }
syncSchedules = schedules syncSchedules = schedules
if archive != nil {
queue.async { [weak self] in
self?.restoreArchivedMessages()
}
}
} }
func start() { 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 { private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
let maxAgeSeconds = packet.type == MessageType.message.rawValue
? config.publicMessageMaxAgeSeconds
: config.maxMessageAgeSeconds
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000) 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) // If current time is less than threshold, accept all (handle clock issues gracefully)
guard nowMs >= ageThresholdMs else { return true } guard nowMs >= ageThresholdMs else { return true }
@@ -197,6 +214,7 @@ final class GossipSyncManager {
guard isPacketFresh(packet) else { return } guard isPacketFresh(packet) else { return }
let idHex = PacketIdUtil.computeId(packet).hexEncodedString() let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity)) messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
archiveDirty = true
case .fragment: case .fragment:
guard isBroadcastRecipient else { return } guard isBroadcastRecipient else { return }
guard isPacketFresh(packet) else { return } guard isPacketFresh(packet) else { return }
@@ -417,14 +435,55 @@ final class GossipSyncManager {
isPacketFresh(pair.packet) isPacketFresh(pair.packet)
} }
let messageCountBefore = messages.packets.count
messages.removeExpired(isFresh: isPacketFresh) messages.removeExpired(isFresh: isPacketFresh)
if messages.packets.count != messageCountBefore {
archiveDirty = true
}
fragments.removeExpired(isFresh: isPacketFresh) fragments.removeExpired(isFresh: isPacketFresh)
fileTransfers.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()) { private func performPeriodicMaintenance(now: Date = Date()) {
cleanupExpiredMessages() cleanupExpiredMessages()
cleanupStaleAnnouncementsIfNeeded(now: now) cleanupStaleAnnouncementsIfNeeded(now: now)
persistArchiveIfDirty()
requestSyncManager.cleanup() // Cleanup expired sync requests requestSyncManager.cleanup() // Cleanup expired sync requests
responseRateLimiter.prune(now: now) responseRateLimiter.prune(now: now)
@@ -471,7 +530,11 @@ final class GossipSyncManager {
private func removeState(for peerID: PeerID) { private func removeState(for peerID: PeerID) {
_ = latestAnnouncementByPeer.removeValue(forKey: peerID) _ = latestAnnouncementByPeer.removeValue(forKey: peerID)
let messageCountBefore = messages.packets.count
messages.remove { PeerID(hexData: $0.senderID) == peerID } messages.remove { PeerID(hexData: $0.senderID) == peerID }
if messages.packets.count != messageCountBefore {
archiveDirty = true
}
fragments.remove { PeerID(hexData: $0.senderID) == peerID } fragments.remove { PeerID(hexData: $0.senderID) == peerID }
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID } fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
} }
@@ -57,6 +57,8 @@ protocol ChatTransportEventContext: AnyObject {
// MARK: Routing & acknowledgements // MARK: Routing & acknowledgements
func flushRouterOutbox(for peerID: PeerID) 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) func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID)
// MARK: Delivery status // MARK: Delivery status
@@ -103,6 +105,10 @@ extension ChatViewModel: ChatTransportEventContext {
messageRouter.flushOutbox(for: peerID) messageRouter.flushOutbox(for: peerID)
} }
func retryCourierDeposits(via peerID: PeerID) {
messageRouter.courierBecameAvailable(peerID)
}
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) { func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) {
meshService.sendDeliveryAck(for: messageID, to: peerID) meshService.sendDeliveryAck(for: messageID, to: peerID)
} }
@@ -208,6 +214,7 @@ final class ChatTransportEventCoordinator {
} }
context.flushRouterOutbox(for: peerID) context.flushRouterOutbox(for: peerID)
context.retryCourierDeposits(via: peerID)
} }
} }
+18 -5
View File
@@ -764,15 +764,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
locationPresenceStore: LocationPresenceStore? = nil, locationPresenceStore: LocationPresenceStore? = nil,
locationManager: LocationChannelManager = .shared locationManager: LocationChannelManager = .shared
) { ) {
let meshService = BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
meshService.sfMetrics = .shared
self.init( self.init(
keychain: keychain, keychain: keychain,
idBridge: idBridge, idBridge: idBridge,
identityManager: identityManager, identityManager: identityManager,
transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager), transport: meshService,
conversations: conversations, conversations: conversations,
peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(), peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(),
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(), 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, peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil, locationPresenceStore: LocationPresenceStore? = nil,
locationManager: LocationChannelManager = .shared, locationManager: LocationChannelManager = .shared,
readReceiptsDefaults: UserDefaults? = nil readReceiptsDefaults: UserDefaults? = nil,
outboxStore: MessageOutboxStore? = nil,
sfMetrics: StoreAndForwardMetrics? = nil
) { ) {
let conversations = conversations ?? ConversationStore() let conversations = conversations ?? ConversationStore()
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore() let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
@@ -797,7 +803,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
keychain: keychain, keychain: keychain,
idBridge: idBridge, idBridge: idBridge,
identityManager: identityManager, identityManager: identityManager,
meshService: transport meshService: transport,
outboxStore: outboxStore,
sfMetrics: sfMetrics
) )
self.keychain = keychain self.keychain = keychain
@@ -1189,8 +1197,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Clear persistent favorites from keychain // Clear persistent favorites from keychain
FavoritesPersistenceService.shared.clearAllFavorites() 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() CourierStore.shared.wipe()
messageRouter.wipeOutbox()
GossipMessageArchive.wipeDefault()
StoreAndForwardMetrics.shared.reset()
// Identity manager has cleared persisted identity data above // Identity manager has cleared persisted identity data above
@@ -17,7 +17,9 @@ struct ChatViewModelServiceBundle {
keychain: KeychainManagerProtocol, keychain: KeychainManagerProtocol,
idBridge: NostrIdentityBridge, idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol, identityManager: SecureIdentityStateManagerProtocol,
meshService: Transport meshService: Transport,
outboxStore: MessageOutboxStore? = nil,
sfMetrics: StoreAndForwardMetrics? = nil
) { ) {
let commandProcessor = CommandProcessor(identityManager: identityManager) let commandProcessor = CommandProcessor(identityManager: identityManager)
let privateChatManager = PrivateChatManager(meshService: meshService) let privateChatManager = PrivateChatManager(meshService: meshService)
@@ -28,7 +30,11 @@ struct ChatViewModelServiceBundle {
) )
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge) let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)
nostrTransport.senderPeerID = meshService.myPeerID nostrTransport.senderPeerID = meshService.myPeerID
let messageRouter = MessageRouter(transports: [meshService, nostrTransport]) let messageRouter = MessageRouter(
transports: [meshService, nostrTransport],
outboxStore: outboxStore,
metrics: sfMetrics
)
self.commandProcessor = commandProcessor self.commandProcessor = commandProcessor
self.messageRouter = messageRouter self.messageRouter = messageRouter
@@ -121,9 +121,11 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
// Routing & acknowledgements // Routing & acknowledgements
private(set) var flushedOutboxPeerIDs: [PeerID] = [] private(set) var flushedOutboxPeerIDs: [PeerID] = []
private(set) var courierRetryPeerIDs: [PeerID] = []
private(set) var meshDeliveryAcks: [(messageID: String, peerID: PeerID)] = [] private(set) var meshDeliveryAcks: [(messageID: String, peerID: PeerID)] = []
func flushRouterOutbox(for peerID: PeerID) { flushedOutboxPeerIDs.append(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) { func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) {
meshDeliveryAcks.append((messageID, peerID)) meshDeliveryAcks.append((messageID, peerID))
} }
+177 -2
View File
@@ -103,7 +103,7 @@ struct CourierStoreTests {
@Test func perDepositorQuota() { @Test func perDepositorQuota() {
let store = makeStore() let store = makeStore()
for _ in 0..<CourierStore.Limits.maxPerDepositor { for _ in 0..<CourierStore.Limits.maxPerFavoriteDepositor {
#expect(store.deposit(makeEnvelope(), from: depositorA)) #expect(store.deposit(makeEnvelope(), from: depositorA))
} }
#expect(!store.deposit(makeEnvelope(), from: depositorA)) #expect(!store.deposit(makeEnvelope(), from: depositorA))
@@ -122,7 +122,7 @@ struct CourierStoreTests {
var depositorByte: UInt8 = 1 var depositorByte: UInt8 = 1
while deposited < CourierStore.Limits.maxEnvelopes + 1 { while deposited < CourierStore.Limits.maxEnvelopes + 1 {
let depositor = Data(repeating: depositorByte, count: 32) let depositor = Data(repeating: depositorByte, count: 32)
for _ in 0..<CourierStore.Limits.maxPerDepositor where deposited < CourierStore.Limits.maxEnvelopes + 1 { for _ in 0..<CourierStore.Limits.maxPerFavoriteDepositor where deposited < CourierStore.Limits.maxEnvelopes + 1 {
#expect(store.deposit(makeEnvelope(), from: depositor)) #expect(store.deposit(makeEnvelope(), from: depositor))
deposited += 1 deposited += 1
} }
@@ -173,4 +173,179 @@ struct CourierStoreTests {
let second = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate }) let second = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
#expect(second.takeEnvelopes(for: recipientKey) == [envelope]) #expect(second.takeEnvelopes(for: recipientKey) == [envelope])
} }
// MARK: - Tiers (open couriering)
@Test func verifiedTierGetsSmallerPerDepositorQuota() {
let store = makeStore()
for _ in 0..<CourierStore.Limits.maxPerVerifiedDepositor {
#expect(store.deposit(makeEnvelope(), from: depositorA, tier: .verified))
}
#expect(!store.deposit(makeEnvelope(), from: depositorA, tier: .verified))
// The same depositor promoted to favorite gets the larger quota.
#expect(store.deposit(makeEnvelope(), from: depositorB, tier: .favorite))
}
@Test func verifiedPoolIsCappedIndependentlyOfFavorites() {
let store = makeStore()
var depositorByte: UInt8 = 1
var accepted = 0
while accepted < CourierStore.Limits.maxVerifiedEnvelopes {
let depositor = Data(repeating: depositorByte, count: 32)
for _ in 0..<CourierStore.Limits.maxPerVerifiedDepositor where accepted < CourierStore.Limits.maxVerifiedEnvelopes {
#expect(store.deposit(makeEnvelope(), from: depositor, tier: .verified))
accepted += 1
}
depositorByte += 1
}
// Verified pool full: another verified deposit is rejected...
#expect(!store.deposit(makeEnvelope(), from: Data(repeating: 0xEE, count: 32), tier: .verified))
// ...but favorites still have their share.
#expect(store.deposit(makeEnvelope(), from: depositorA, tier: .favorite))
}
@Test func overflowEvictsVerifiedTierBeforeFavorites() {
let store = makeStore()
let favoriteRecipient = Data(repeating: 0xD0, count: 32)
let verifiedRecipient = Data(repeating: 0xD1, count: 32)
// Oldest envelope is a favorite deposit; a verified one follows.
#expect(store.deposit(makeEnvelope(recipientKey: favoriteRecipient), from: depositorA, tier: .favorite))
#expect(store.deposit(makeEnvelope(recipientKey: verifiedRecipient), from: depositorB, tier: .verified))
// Fill to the total cap with favorite deposits from distinct depositors.
var depositorByte: UInt8 = 10
var count = 2
while count < CourierStore.Limits.maxEnvelopes {
let depositor = Data(repeating: depositorByte, count: 32)
for _ in 0..<CourierStore.Limits.maxPerFavoriteDepositor where count < CourierStore.Limits.maxEnvelopes {
#expect(store.deposit(makeEnvelope(), from: depositor, tier: .favorite))
count += 1
}
depositorByte += 1
}
// The next favorite deposit evicts the verified envelope, not the
// older favorite one.
#expect(store.deposit(makeEnvelope(), from: Data(repeating: 0xEF, count: 32), tier: .favorite))
#expect(store.takeEnvelopes(for: verifiedRecipient).isEmpty)
#expect(store.takeEnvelopes(for: favoriteRecipient).count == 1)
}
@Test func verifiedDepositIsRejectedWhenStoreIsFullOfFavorites() {
let store = makeStore()
var depositorByte: UInt8 = 10
var count = 0
while count < CourierStore.Limits.maxEnvelopes {
let depositor = Data(repeating: depositorByte, count: 32)
for _ in 0..<CourierStore.Limits.maxPerFavoriteDepositor where count < CourierStore.Limits.maxEnvelopes {
#expect(store.deposit(makeEnvelope(), from: depositor, tier: .favorite))
count += 1
}
depositorByte += 1
}
// A verified deposit must not displace favorite-tier mail.
#expect(!store.deposit(makeEnvelope(), from: Data(repeating: 0xEE, count: 32), tier: .verified))
// A favorite deposit still can (oldest-favorite eviction).
#expect(store.deposit(makeEnvelope(), from: Data(repeating: 0xEF, count: 32), tier: .favorite))
}
// MARK: - Spray-and-wait
@Test func sprayHalvesBudgetAndSkipsIneligibleCouriers() {
let store = makeStore()
let recipientKey = Data(repeating: 0xB0, count: 32)
let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(4)
#expect(store.deposit(envelope, from: depositorA))
// The recipient themselves never gets a spray copy (handover path).
#expect(store.takeSprayCopies(for: recipientKey).isEmpty)
// Neither does the depositor.
#expect(store.takeSprayCopies(for: depositorA).isEmpty)
// A fresh courier gets half the budget.
let courierX = Data(repeating: 0xC1, count: 32)
let sprayedToX = store.takeSprayCopies(for: courierX)
#expect(sprayedToX.count == 1)
#expect(sprayedToX.first?.copies == 2)
// Same courier again: no double spend.
#expect(store.takeSprayCopies(for: courierX).isEmpty)
// Next courier gets half the remainder (2 -> 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)
}
} }
@@ -104,7 +104,7 @@ struct CourierEndToEndTests {
let bob = makeService() let bob = makeService()
// Alice and Carol are mutual favorites; trust policy is exercised // Alice and Carol are mutual favorites; trust policy is exercised
// separately in depositFromUntrustedPeerIsRejected. // separately in depositFromUntrustedPeerIsRejected.
carol.courierDepositPolicy = { _ in true } carol.courierDepositPolicy = { _, _ in .favorite }
let bobDelegate = NoiseCaptureDelegate() let bobDelegate = NoiseCaptureDelegate()
bob.delegate = bobDelegate bob.delegate = bobDelegate
@@ -134,7 +134,7 @@ struct CourierEndToEndTests {
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
// 2. Ferry the deposit to Carol; she carries it (opaque to her). // 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( let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty }, { !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout timeout: TestConstants.defaultTimeout
@@ -186,7 +186,7 @@ struct CourierEndToEndTests {
let carol = makeService() let carol = makeService()
let bobIdentity = MockIdentityManager(MockKeychain()) let bobIdentity = MockIdentityManager(MockKeychain())
let bob = makeService(identityManager: bobIdentity) let bob = makeService(identityManager: bobIdentity)
carol.courierDepositPolicy = { _ in true } carol.courierDepositPolicy = { _, _ in .favorite }
let bobDelegate = NoiseCaptureDelegate() let bobDelegate = NoiseCaptureDelegate()
bob.delegate = bobDelegate bob.delegate = bobDelegate
@@ -215,7 +215,7 @@ struct CourierEndToEndTests {
#expect(deposited) #expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) 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( let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty }, { !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout timeout: TestConstants.defaultTimeout
@@ -254,7 +254,7 @@ struct CourierEndToEndTests {
let alice = makeService() let alice = makeService()
let carol = makeService() let carol = makeService()
let bob = makeService() let bob = makeService()
carol.courierDepositPolicy = { _ in true } carol.courierDepositPolicy = { _, _ in .favorite }
let aliceOut = PacketTap() let aliceOut = PacketTap()
alice._test_onOutboundPacket = aliceOut.record alice._test_onOutboundPacket = aliceOut.record
@@ -278,7 +278,7 @@ struct CourierEndToEndTests {
#expect(deposited) #expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) 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( let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty }, { !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout timeout: TestConstants.defaultTimeout
@@ -312,11 +312,11 @@ struct CourierEndToEndTests {
#expect(carol.courierStore.isEmpty) #expect(carol.courierStore.isEmpty)
} }
@Test func relayedAnnounceDoesNotTriggerCourierHandover() async throws { @Test func relayedAnnounceTriggersNonDestructiveRemoteHandover() async throws {
let alice = makeService() let alice = makeService()
let carol = makeService() let carol = makeService()
let bob = makeService() let bob = makeService()
carol.courierDepositPolicy = { _ in true } carol.courierDepositPolicy = { _, _ in .favorite }
let aliceOut = PacketTap() let aliceOut = PacketTap()
alice._test_onOutboundPacket = aliceOut.record alice._test_onOutboundPacket = aliceOut.record
@@ -340,7 +340,7 @@ struct CourierEndToEndTests {
#expect(deposited) #expect(deposited)
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope)) 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( let carried = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty }, { !carol.courierStore.isEmpty },
timeout: TestConstants.defaultTimeout timeout: TestConstants.defaultTimeout
@@ -356,23 +356,24 @@ struct CourierEndToEndTests {
let directAnnounce = try #require(bobOut.first(ofType: .announce)) let directAnnounce = try #require(bobOut.first(ofType: .announce))
// A relayed copy has a decremented TTL but a still-valid signature // A relayed copy has a decremented TTL but a still-valid signature
// (TTL is excluded from announce signatures). Envelopes are removed // (TTL is excluded from announce signatures). The recipient is
// from the store optimistically, so handover must wait for a direct // multi-hop away, so a copy floods toward them speculatively while
// encounter instead of chasing a multi-hop path. // the carried original stays put for a future direct encounter.
var relayedAnnounce = directAnnounce var relayedAnnounce = directAnnounce
relayedAnnounce.ttl = directAnnounce.ttl - 1 relayedAnnounce.ttl = directAnnounce.ttl - 1
carol._test_handlePacket(relayedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false) carol._test_handlePacket(relayedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
let leakedOnRelayedAnnounce = await TestHelpers.waitUntil( let remoteHandover = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) > 0 }, { carolOut.count(ofType: .courierEnvelope) == 1 },
timeout: TestConstants.shortTimeout timeout: TestConstants.defaultTimeout
) )
#expect(!leakedOnRelayedAnnounce) #expect(remoteHandover)
#expect(!carol.courierStore.isEmpty) #expect(!carol.courierStore.isEmpty)
// The relayed copy consumed the original announce's dedup key // A second relayed announce inside the cooldown must not re-flood
// (sender/timestamp/payload TTL excluded), so the direct handover // the same envelope. The original announce's dedup key is consumed
// needs a fresh announce. Wait out the 1s announce throttle first. // (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) try await Task.sleep(nanoseconds: 1_100_000_000)
bob.sendBroadcastAnnounce() bob.sendBroadcastAnnounce()
let reannounced = await TestHelpers.waitUntil( let reannounced = await TestHelpers.waitUntil(
@@ -383,10 +384,32 @@ struct CourierEndToEndTests {
let freshAnnounce = try #require( let freshAnnounce = try #require(
bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp } 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( let handedOver = await TestHelpers.waitUntil(
{ carolOut.count(ofType: .courierEnvelope) == 1 }, { carolOut.count(ofType: .courierEnvelope) == 2 },
timeout: TestConstants.defaultTimeout timeout: TestConstants.defaultTimeout
) )
#expect(handedOver) #expect(handedOver)
@@ -417,7 +440,7 @@ struct CourierEndToEndTests {
@Test func depositFromUntrustedPeerIsRejected() async throws { @Test func depositFromUntrustedPeerIsRejected() async throws {
let carol = makeService() 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 alice = NoiseEncryptionService(keychain: MockKeychain())
let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData() let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData()
@@ -433,6 +456,45 @@ struct CourierEndToEndTests {
ciphertext: sealed ciphertext: sealed
) )
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData()) 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( let packet = BitchatPacket(
type: MessageType.courierEnvelope.rawValue, type: MessageType.courierEnvelope.rawValue,
senderID: Data(hexString: alicePeerID.id) ?? Data(), senderID: Data(hexString: alicePeerID.id) ?? Data(),
@@ -443,7 +505,7 @@ struct CourierEndToEndTests {
ttl: 1 ttl: 1
) )
carol._test_handlePacket(packet, fromPeerID: alicePeerID) carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData())
let stored = await TestHelpers.waitUntil( let stored = await TestHelpers.waitUntil(
{ !carol.courierStore.isEmpty }, { !carol.courierStore.isEmpty },
timeout: TestConstants.shortTimeout timeout: TestConstants.shortTimeout
@@ -459,8 +521,8 @@ struct CourierEndToEndTests {
preseedConnectedPeer(mallory, in: carol) preseedConnectedPeer(mallory, in: carol)
let trustedAliceKey = Data(hexString: alice.myPeerID.id) ?? Data() let trustedAliceKey = Data(hexString: alice.myPeerID.id) ?? Data()
carol.courierDepositPolicy = { depositorKey in carol.courierDepositPolicy = { depositorKey, _ in
depositorKey == trustedAliceKey depositorKey == trustedAliceKey ? .favorite : nil
} }
let aliceNoise = NoiseEncryptionService(keychain: MockKeychain()) let aliceNoise = NoiseEncryptionService(keychain: MockKeychain())
+73
View File
@@ -468,6 +468,79 @@ struct GossipSyncManagerTests {
#expect(sentPackets.count == 1) #expect(sentPackets.count == 1)
#expect(sentPackets[0].type == MessageType.fragment.rawValue) #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 { private final class RecordingDelegate: GossipSyncManager.Delegate {
+7
View File
@@ -42,6 +42,7 @@ final class MockTransport: Transport {
private(set) var cancelledTransfers: [String] = [] private(set) var cancelledTransfers: [String] = []
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
private(set) var sentVerifyResponses: [(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 startServicesCallCount = 0
private(set) var stopServicesCallCount = 0 private(set) var stopServicesCallCount = 0
private(set) var emergencyDisconnectCallCount = 0 private(set) var emergencyDisconnectCallCount = 0
@@ -189,6 +190,12 @@ final class MockTransport: Transport {
sentVerifyResponses.append((peerID, noiseKeyHex, nonceA)) 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 // MARK: - Test Helpers
/// Clears all recorded method calls for fresh assertions /// Clears all recorded method calls for fresh assertions
@@ -100,11 +100,11 @@ struct BLEPublicMessageHandlerTests {
@Test @Test
func staleBroadcastIsDropped() { func staleBroadcastIsDropped() {
let now = Date(timeIntervalSince1970: 1_000) let now = Date(timeIntervalSince1970: 1_000_000)
let recorder = Recorder() let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)] recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder, now: now) 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) let packet = makeMessagePacket(sender: remotePeerID, content: "old", timestamp: staleTimestamp)
handler.handle(packet, from: remotePeerID) handler.handle(packet, from: remotePeerID)
@@ -36,11 +36,13 @@ struct BLEPublicMessagePolicyTests {
@Test @Test
func staleBroadcastIsRejectedWithAge() { 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 sender = PeerID(str: "8877665544332211")
let packet = makePacket( let packet = makePacket(
sender: sender, sender: sender,
timestamp: UInt64((now.timeIntervalSince1970 - 901) * 1000), timestamp: UInt64((now.timeIntervalSince1970 - staleAge) * 1000),
recipientID: nil recipientID: nil
) )
@@ -51,7 +53,7 @@ struct BLEPublicMessagePolicyTests {
now: now now: now
) )
#expect(decision == .reject(.staleBroadcast(ageSeconds: 901))) #expect(decision == .reject(.staleBroadcast(ageSeconds: staleAge)))
} }
@Test @Test
@@ -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))
}
}
@@ -226,6 +226,197 @@ struct MessageRouterTests {
#expect(transport.sentFavoriteNotifications.count == 1) #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<Data> = []) -> 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 /// Mutable wall clock injected into `MessageRouter` so TTL expiry is testable
@@ -24,23 +24,37 @@ public struct CourierEnvelope: Equatable {
public let expiry: UInt64 public let expiry: UInt64
/// Opaque one-way Noise X ciphertext (sender identity rides inside). /// Opaque one-way Noise X ciphertext (sender identity rides inside).
public let ciphertext: Data 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 public static let tagLength = 16
/// Couriered messages are text-sized; media transfers are out of scope. /// Couriered messages are text-sized; media transfers are out of scope.
public static let maxCiphertextBytes = 16 * 1024 public static let maxCiphertextBytes = 16 * 1024
/// Matches the outbox retention policy in MessageRouter. /// Matches the outbox retention policy in MessageRouter.
public static let maxLifetimeSeconds: TimeInterval = 24 * 60 * 60 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 { private enum TLVType: UInt8 {
case recipientTag = 0x01 case recipientTag = 0x01
case expiry = 0x02 case expiry = 0x02
case ciphertext = 0x03 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.recipientTag = recipientTag
self.expiry = expiry self.expiry = expiry
self.ciphertext = ciphertext 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 { public var isExpired: Bool {
@@ -75,6 +89,14 @@ public struct CourierEnvelope: Equatable {
appendBE(UInt16(ciphertext.count), into: &encoded) appendBE(UInt16(ciphertext.count), into: &encoded)
encoded.append(ciphertext) 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 return encoded
} }
@@ -85,6 +107,7 @@ public struct CourierEnvelope: Equatable {
var recipientTag: Data? var recipientTag: Data?
var expiry: UInt64? var expiry: UInt64?
var ciphertext: Data? var ciphertext: Data?
var copies: UInt8 = 1
while cursor < end { while cursor < end {
let typeRaw = data[cursor] let typeRaw = data[cursor]
@@ -107,6 +130,9 @@ public struct CourierEnvelope: Equatable {
case .ciphertext: case .ciphertext:
guard length > 0, length <= maxCiphertextBytes else { return nil } guard length > 0, length <= maxCiphertextBytes else { return nil }
ciphertext = Data(value) ciphertext = Data(value)
case .copies:
guard length == 1 else { return nil }
copies = value.first ?? 1
case nil: case nil:
// Unknown TLV: skip for forward compatibility. // Unknown TLV: skip for forward compatibility.
continue continue
@@ -114,7 +140,7 @@ public struct CourierEnvelope: Equatable {
} }
guard let recipientTag, let expiry, let ciphertext else { return nil } 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 // MARK: - Recipient Tags
@@ -20,6 +20,38 @@ struct CourierEnvelopeTests {
CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext) 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 // MARK: - Codec
@Test func roundTrip() throws { @Test func roundTrip() throws {