mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
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:
co-authored by
jack
Claude Fable 5
parent
295f855b6f
commit
7341696280
+82
-250
@@ -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 10–220 ms (wider when dense) so duplicate suppression wins often.
|
||||||
|
* **Fanout subsetting:** broadcast messages are re-sent to a deterministic, message-ID-seeded subset of links (~log₂ of degree) rather than all of them; announces, fragments, and sync packets use full fanout. The ingress link is always excluded (split horizon).
|
||||||
|
* **Directed traffic** (handshakes, private messages, courier envelopes) relays deterministically with TTL − 1 and tight jitter, and is never subset.
|
||||||
|
|
||||||
## 3. Identity and Key Management
|
### 4.3 Routing
|
||||||
|
|
||||||
A peer's identity in BitChat is defined by two persistent cryptographic key pairs, which are generated on first launch and stored securely in the device's Keychain.
|
Announcements carry up to 10 direct-neighbor IDs, giving each node a shallow topology map (60 s freshness). When a bidirectionally-confirmed path exists, packets are source-routed along it; otherwise — and whenever a route fails — delivery falls back to flooding.
|
||||||
|
|
||||||
1. **Noise Static Key Pair (`Curve25519`):** This is the long-term identity key used for the Noise Protocol handshake. The public part of this key is shared with peers to establish secure sessions.
|
### 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 ~15–30 s (jittered) when connected. A verified announce retains a peer as *reachable* for 60 s after last contact. Connection scheduling is RSSI-gated with duty-cycled scanning to bound battery drain.
|
||||||
|
|
||||||
### 3.2. Identity Management
|
## 5. Encryption
|
||||||
|
|
||||||
The `SecureIdentityStateManager` class is responsible for managing all cryptographic identity material and social metadata (petnames, trust levels, etc.). It uses an in-memory cache for performance and persists this cache to the Keychain after encrypting it with a separate AES-GCM key.
|
### 5.1 Live Sessions: Noise XX
|
||||||
|
|
||||||
---
|
Connected peers establish sessions with the Noise `XX` pattern (Curve25519 / ChaCha20-Poly1305 / SHA-256), providing mutual authentication and forward secrecy. All private payloads — messages, delivery acks, read receipts — ride inside the session as typed ciphertext. Intermediate relays see only opaque `noiseEncrypted` packets.
|
||||||
|
|
||||||
## 4. The Social Trust Layer
|
### 5.2 Offline Seals: Noise X
|
||||||
|
|
||||||
Beyond cryptographic identity, BitChat incorporates a social trust layer, allowing users to manage their relationships with peers. This functionality is handled by the `SecureIdentityStateManager`.
|
Courier envelopes are sealed to the recipient's *static* key with the one-way Noise `X` pattern; the sender's identity is authenticated inside the ciphertext. **This path has no forward secrecy** — compromise of the recipient's static key exposes sealed-but-undelivered mail. A prekey scheme is future work.
|
||||||
|
|
||||||
### 4.1. Peer Verification
|
### 5.3 Nostr Path
|
||||||
|
|
||||||
While the Noise handshake cryptographically authenticates a peer's key, it doesn't confirm the real-world identity of the person holding the device. To solve this, users can perform out-of-band (OOB) verification by comparing fingerprints. Once a user confirms that a peer's fingerprint matches the one they expect, they can mark that peer as "verified". This status is stored locally and displayed in the UI, providing a strong assurance of identity for future conversations.
|
Private messages to mutual favorites are wrapped per NIP-17/NIP-59: a rumor (kind 14) sealed (kind 13) and gift-wrapped (kind 1059) under a throwaway ephemeral key, so relays learn neither sender nor content.
|
||||||
|
|
||||||
### 4.2. Favorites and Blocking
|
## 6. Store and Forward
|
||||||
|
|
||||||
To improve the user experience and provide control over interactions, the protocol supports:
|
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.
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user