mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 06:45:18 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b8256bf72 | ||
|
|
2b7fd2002b | ||
|
|
e4b3cff5fa | ||
|
|
688b954fb8 | ||
|
|
7341696280 | ||
|
|
295f855b6f | ||
|
|
3ea4188699 | ||
|
|
a66c591f8e | ||
|
|
75da63c9d7 |
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.5.3
|
||||
MARKETING_VERSION = 1.5.4
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
|
||||
+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
|
||||
|
||||
BitChat is a decentralized, peer-to-peer messaging application designed for secure, private, and censorship-resistant communication over ephemeral, ad-hoc networks. This whitepaper details the BitChat Protocol Stack, a layered architecture that combines a modern cryptographic foundation with a flexible application protocol. At its core, BitChat leverages the Noise Protocol Framework (specifically, the `XX` pattern) to establish mutually authenticated, end-to-end encrypted sessions between peers. This document provides a technical specification of the identity management, session lifecycle, message framing, and security considerations that underpin the BitChat network.
|
||||
bitchat is a decentralized, peer-to-peer messaging application for secure, private, censorship-resistant communication that works with or without the internet. Nearby devices form an ad-hoc Bluetooth Low Energy (BLE) mesh; distant peers are reached over the Nostr protocol when a connection exists. A layered store-and-forward stack — a persistent sender outbox, opportunistic couriers with a spray-and-wait copy budget, gossip-synced public history, and Nostr relay mailboxes — delivers messages to peers who are out of range at send time. This document describes the protocol and its delivery guarantees as implemented.
|
||||
|
||||
---
|
||||
|
||||
## 1. Introduction
|
||||
## 1. Design Goals
|
||||
|
||||
In an era of centralized communication platforms, BitChat offers a resilient alternative by operating without central servers. It is designed for scenarios where internet connectivity is unavailable or untrustworthy, such as protests, natural disasters, or remote areas. Communication occurs directly between devices over transports like Bluetooth Low Energy (BLE).
|
||||
* **Confidentiality:** all private communication is end-to-end encrypted; intermediate nodes and couriers carry only opaque ciphertext.
|
||||
* **Authentication:** peers are identified by cryptographic keys; announcements are signed and verified.
|
||||
* **Resilience:** the network functions in lossy, low-bandwidth, partitioned environments with churning membership.
|
||||
* **Eventual delivery:** a message to an out-of-range peer should still arrive — relayed by the mesh, carried by a moving person, or resting on an internet relay — within a bounded retention window.
|
||||
* **Ephemerality by default:** no plaintext message content is ever written to disk. Everything the store-and-forward stack persists is either sealed ciphertext or already-public broadcast traffic, and all of it dies with the panic wipe.
|
||||
|
||||
The design goals of the BitChat Protocol are:
|
||||
## 2. Architecture Overview
|
||||
|
||||
* **Confidentiality:** All communication must be unreadable to third parties.
|
||||
* **Authentication:** Users must be able to verify the identity of their correspondents.
|
||||
* **Integrity:** Messages cannot be tampered with in transit.
|
||||
* **Forward Secrecy:** The compromise of long-term identity keys must not compromise past session keys.
|
||||
* **Deniability:** It should be difficult to cryptographically prove that a specific user sent a particular message.
|
||||
* **Resilience:** The protocol must function reliably in lossy, low-bandwidth environments.
|
||||
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
|
||||
|
||||
This paper specifies the technical details of the protocol designed to meet these goals.
|
||||
* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts.
|
||||
* **Nostr** — private messages to mutual favorites travel as NIP-17 gift-wrapped events over public relays (over Tor where enabled), bridging separate meshes through the internet.
|
||||
|
||||
---
|
||||
The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly.
|
||||
|
||||
## 2. Protocol Stack
|
||||
## 3. Identity
|
||||
|
||||
The BitChat Protocol is a four-layer stack. This layered approach separates concerns, allowing for modularity and future extensibility.
|
||||
Each device holds two long-term key pairs in the Keychain:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Application Layer] --> B[Session Layer];
|
||||
B --> C[Encryption Layer];
|
||||
C --> D[Transport Layer];
|
||||
* a **Curve25519 static key** for Noise key agreement — its SHA-256 fingerprint is the peer's stable identity, and
|
||||
* an **Ed25519 signing key** for packet signatures.
|
||||
|
||||
subgraph "BitChat Application"
|
||||
A
|
||||
end
|
||||
On the mesh, peers appear under short ephemeral IDs derived per session; favoriting pins the full Noise public key so identity survives across sessions. Mutual favorites also exchange Nostr public keys for the internet path. Optional QR verification binds a nickname to a fingerprint in person.
|
||||
|
||||
subgraph "Message Framing & State"
|
||||
B
|
||||
end
|
||||
## 4. BLE Mesh Layer
|
||||
|
||||
subgraph "Noise Protocol Framework"
|
||||
C
|
||||
end
|
||||
### 4.1 Packet Format
|
||||
|
||||
subgraph "BLE, Wi-Fi Direct, etc."
|
||||
D
|
||||
end
|
||||
A compact binary header (version, type, TTL, timestamp, flags) is followed by an 8-byte sender ID, an optional 8-byte recipient ID, the payload, and an optional Ed25519 signature. Version 2 packets may carry an explicit source route. Signatures exclude the TTL byte so relays can decrement it without invalidating them. Packets other than fragments are padded toward uniform sizes.
|
||||
|
||||
style A fill:#cde4ff
|
||||
style B fill:#b5d8ff
|
||||
style C fill:#9ac2ff
|
||||
style D fill:#7eadff
|
||||
```
|
||||
### 4.2 Flood Control
|
||||
|
||||
* **Application Layer:** Defines the structure of user-facing messages (`BitchatMessage`), acknowledgments (`DeliveryAck`), and other application-level data.
|
||||
* **Session Layer:** Manages the overall communication packet (`BitchatPacket`). This includes routing information (TTL), message typing, fragmentation, and serialization into a compact binary format.
|
||||
* **Encryption Layer:** Establishes and manages secure channels using the Noise Protocol Framework. It is responsible for the cryptographic handshake, session management, and transport message encryption/decryption.
|
||||
* **Transport Layer:** The underlying physical medium used for data transmission, such as Bluetooth Low Energy (BLE). This layer is abstracted away from the core protocol.
|
||||
Relaying is a deterministic controlled flood tuned by local connection degree:
|
||||
|
||||
---
|
||||
* **TTL:** packets originate with TTL 7. Relays clamp: dense graphs (≥ 6 links) cap broadcast TTL at 5; thin chains (≤ 2 links) relay at full incoming depth.
|
||||
* **Deduplication:** an LRU seen-set (1000 entries, 5-minute expiry) keyed by sender, timestamp, type, and a payload digest drops duplicates. A scheduled relay is cancelled when a duplicate arrives first from another relay.
|
||||
* **Jitter:** relays wait a random 10–220 ms (wider when dense) so duplicate suppression wins often.
|
||||
* **Fanout subsetting:** broadcast messages are re-sent to a deterministic, message-ID-seeded subset of links (~log₂ of degree) rather than all of them; announces, fragments, and sync packets use full fanout. The ingress link is always excluded (split horizon).
|
||||
* **Directed traffic** (handshakes, private messages, courier envelopes) relays deterministically with TTL − 1 and tight jitter, and is never subset.
|
||||
|
||||
## 3. Identity and Key Management
|
||||
### 4.3 Routing
|
||||
|
||||
A peer's identity in BitChat is defined by two persistent cryptographic key pairs, which are generated on first launch and stored securely in the device's Keychain.
|
||||
Announcements carry up to 10 direct-neighbor IDs, giving each node a shallow topology map (60 s freshness). When a bidirectionally-confirmed path exists, packets are source-routed along it; otherwise — and whenever a route fails — delivery falls back to flooding.
|
||||
|
||||
1. **Noise Static Key Pair (`Curve25519`):** This is the long-term identity key used for the Noise Protocol handshake. The public part of this key is shared with peers to establish secure sessions.
|
||||
2. **Signing Key Pair (`Ed25519`):** This key is used to sign announcements and other protocol messages where non-repudiation is required, such as binding a public key to a nickname.
|
||||
### 4.4 Fragmentation
|
||||
|
||||
### 3.1. Fingerprint
|
||||
Packets exceeding the link MTU split into ~469-byte fragments (8-byte fragment ID, index/total header) that relay independently and reassemble at each receiving node (128 concurrent assemblies, 30 s timeout, 1 MiB cap).
|
||||
|
||||
A user's unique, verifiable fingerprint is the **SHA-256 hash** of their **Noise static public key**. This provides a user-friendly and secure way to verify an identity out-of-band (e.g., by reading it aloud or scanning a QR code).
|
||||
### 4.5 Presence
|
||||
|
||||
`Fingerprint = SHA256(StaticPublicKey_Curve25519)`
|
||||
Signed announcements propagate multi-hop: every 4 s while isolated, backing off to ~15–30 s (jittered) when connected. A verified announce retains a peer as *reachable* for 60 s after last contact. Connection scheduling is RSSI-gated with duty-cycled scanning to bound battery drain.
|
||||
|
||||
### 3.2. Identity Management
|
||||
## 5. Encryption
|
||||
|
||||
The `SecureIdentityStateManager` class is responsible for managing all cryptographic identity material and social metadata (petnames, trust levels, etc.). It uses an in-memory cache for performance and persists this cache to the Keychain after encrypting it with a separate AES-GCM key.
|
||||
### 5.1 Live Sessions: Noise XX
|
||||
|
||||
---
|
||||
Connected peers establish sessions with the Noise `XX` pattern (Curve25519 / ChaCha20-Poly1305 / SHA-256), providing mutual authentication and forward secrecy. All private payloads — messages, delivery acks, read receipts — ride inside the session as typed ciphertext. Intermediate relays see only opaque `noiseEncrypted` packets.
|
||||
|
||||
## 4. The Social Trust Layer
|
||||
### 5.2 Offline Seals: Noise X
|
||||
|
||||
Beyond cryptographic identity, BitChat incorporates a social trust layer, allowing users to manage their relationships with peers. This functionality is handled by the `SecureIdentityStateManager`.
|
||||
Courier envelopes are sealed to the recipient's *static* key with the one-way Noise `X` pattern; the sender's identity is authenticated inside the ciphertext. **This path has no forward secrecy** — compromise of the recipient's static key exposes sealed-but-undelivered mail. A prekey scheme is future work.
|
||||
|
||||
### 4.1. Peer Verification
|
||||
### 5.3 Nostr Path
|
||||
|
||||
While the Noise handshake cryptographically authenticates a peer's key, it doesn't confirm the real-world identity of the person holding the device. To solve this, users can perform out-of-band (OOB) verification by comparing fingerprints. Once a user confirms that a peer's fingerprint matches the one they expect, they can mark that peer as "verified". This status is stored locally and displayed in the UI, providing a strong assurance of identity for future conversations.
|
||||
Private messages to mutual favorites are wrapped per NIP-17/NIP-59: a rumor (kind 14) sealed (kind 13) and gift-wrapped (kind 1059) under a throwaway ephemeral key, so relays learn neither sender nor content.
|
||||
|
||||
### 4.2. Favorites and Blocking
|
||||
## 6. Store and Forward
|
||||
|
||||
To improve the user experience and provide control over interactions, the protocol supports:
|
||||
* **Favorites:** Users can mark trusted or frequently contacted peers as "favorites". This is a local designation that can be used by the application to prioritize notifications or display peers more prominently.
|
||||
* **Blocking:** Users can block peers. When a peer is blocked, the application will discard any incoming packets from that peer's fingerprint at the earliest possible stage, effectively silencing them without notifying the blocked peer.
|
||||
Four mechanisms cover the "recipient is not here right now" problem. All persisted state is wiped by panic mode.
|
||||
|
||||
---
|
||||
### 6.1 Sender Outbox
|
||||
|
||||
## 5. The Noise Protocol Layer
|
||||
Private messages without a prompt route are retained per peer (100 messages/peer, 24 h TTL) and re-sent on reconnect events until a delivery or read ack clears them, or a resend cap (8 attempts) drops them with visible failure. The outbox persists to disk sealed under a ChaChaPoly key held only in the Keychain, so queued mail survives an app kill without ever storing plaintext.
|
||||
|
||||
BitChat implements the Noise Protocol Framework to provide strong, authenticated end-to-end encryption.
|
||||
### 6.2 Couriers
|
||||
|
||||
### 5.1. Protocol Name
|
||||
When no transport can deliver promptly, the message is sealed (§5.2) into a **courier envelope** and handed to up to 3 connected peers who may physically encounter the recipient:
|
||||
|
||||
The specific Noise protocol implemented is:
|
||||
* **Opaque addressing.** The only routing information is a 16-byte rotating recipient tag — an HMAC of the recipient's static key and the UTC day — computable solely by parties who already know that key. Couriers learn neither sender, recipient, nor content, and tags do not correlate across days.
|
||||
* **Trust tiers.** Mutual favorites may deposit 5 envelopes each; any peer with a signature-verified announce may deposit 2, into a bounded pool (20 of 40 slots) that can never crowd out favorites' mail. Envelopes are capped at 16 KiB and 24 h; overflow evicts oldest verified-tier mail first.
|
||||
* **Deposit retry.** Queued messages are re-deposited whenever a new eligible courier connects, until 3 distinct couriers carry the message or it expires.
|
||||
* **Spray and wait.** Envelopes carry a copy budget (initially 4, capped at 8). A courier meeting another eligible courier hands over half its remaining budget, so mail diffuses through a moving crowd instead of riding one person. Budgets, spray history, and carried mail persist across app restarts (iOS file protection).
|
||||
* **Handover.** On a verified *direct* announce from the recipient, matching envelopes are delivered over the live link and removed. On a verified *relayed* announce, a copy floods toward the recipient as a directed packet while the carried original stays put, throttled to one attempt per envelope per 10 minutes.
|
||||
* Receivers dedup by message ID, so redundant copies and the retained outbox original are harmless. Couriered mail from blocked senders is dropped at decryption time.
|
||||
|
||||
**`Noise_XX_25519_ChaChaPoly_SHA256`**
|
||||
### 6.3 Public History (Gossip Sync)
|
||||
|
||||
* **`XX` Pattern:** This handshake pattern provides mutual authentication and forward secrecy. It does not require either party to know the other's static public key before the handshake begins. The keys are exchanged and authenticated during the three-part handshake. This is ideal for a decentralized P2P environment.
|
||||
* **`25519`:** The Diffie-Hellman function used is Curve25519.
|
||||
* **`ChaChaPoly`:** The AEAD (Authenticated Encryption with Associated Data) cipher is ChaCha20-Poly1305.
|
||||
* **`SHA256`:** The hash function used for all cryptographic hashing operations is SHA-256.
|
||||
Public broadcast messages are cached (1000 packets) and reconciled between peers every ~15 s using compact GCS filters: each side advertises what it holds, the other returns what is missing. Messages stay sync-able for **6 hours** and the cache persists to disk, so a device that walks between two partitions — or relaunches later — serves the room's recent history to whoever missed it. Fragments and file transfers keep a short 15-minute window.
|
||||
|
||||
### 5.2. The `XX` Handshake
|
||||
### 6.4 Nostr Mailboxes
|
||||
|
||||
The `XX` handshake consists of three messages exchanged between an Initiator and a Responder to establish a shared secret and derive transport encryption keys.
|
||||
Gift-wrapped messages rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant I as Initiator
|
||||
participant R as Responder
|
||||
### 6.5 Delivery Metrics
|
||||
|
||||
Note over I, R: Pre-computation: h = SHA256(protocol_name)
|
||||
Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drops — no identities, message IDs, or timestamps) let delivery behavior be measured on-device. They never leave the device and are cleared by the panic wipe.
|
||||
|
||||
I->>R: -> e
|
||||
Note right of I: I generates ephemeral key `e_i`.<br/>h = SHA256(h + e_i.pub)
|
||||
## 7. Application Layer
|
||||
|
||||
R->>I: <- e, ee, s, es
|
||||
Note left of R: R generates ephemeral key `e_r`.<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))
|
||||
|
||||
I->>R: -> s, se
|
||||
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))
|
||||
|
||||
Note over I, R: Handshake complete. Transport keys derived.
|
||||
```
|
||||
|
||||
**Handshake Flow:**
|
||||
|
||||
1. **Initiator -> Responder:** The initiator generates a new ephemeral key pair (`e_i`) and sends the public part to the responder.
|
||||
2. **Responder -> Initiator:** The responder receives the initiator's ephemeral public key. It then generates its own ephemeral key pair (`e_r`), performs a DH exchange with the initiator's ephemeral key (`ee`), sends its own static public key (`s_r`) encrypted with the resulting symmetric key, and performs another DH exchange between the initiator's ephemeral key and its own static key (`es`).
|
||||
3. **Initiator -> Responder:** The initiator receives the responder's message, decrypts the responder's static key, and authenticates it. The initiator then sends its own static key (`s_i`) encrypted and performs a final DH exchange between its static key and the responder's ephemeral key (`se`).
|
||||
|
||||
Upon completion, both parties share a set of symmetric keys for bidirectional transport message encryption. The final handshake hash is used for channel binding.
|
||||
|
||||
### 5.3. Session Management
|
||||
|
||||
The `NoiseSessionManager` class manages all active Noise sessions. It handles:
|
||||
* Creating sessions for new peers.
|
||||
* Coordinating the handshake process to prevent race conditions.
|
||||
* Storing the resulting transport ciphers (`sendCipher`, `receiveCipher`).
|
||||
* Periodically checking if sessions need to be re-keyed for enhanced security.
|
||||
|
||||
---
|
||||
|
||||
## 6. The BitChat Session and Application Protocol
|
||||
|
||||
Once a Noise session is established, peers exchange `BitchatPacket` structures, which are encrypted as the payload of Noise transport messages.
|
||||
|
||||
### 6.1. Binary Packet Format (`BitchatPacket`)
|
||||
|
||||
To minimize bandwidth, `BitchatPacket`s are serialized into a compact binary format. The structure is designed to be fixed-size where possible to resist traffic analysis.
|
||||
|
||||
| Field | Size (bytes) | Description |
|
||||
|-----------------|--------------|---------------------------------------------------------------------------------------------------------|
|
||||
| **Header** | **13** | **Fixed-size header** |
|
||||
| Version | 1 | Protocol version (currently `1`). |
|
||||
| Type | 1 | Message type (e.g., `message`, `deliveryAck`, `noiseHandshakeInit`). See `MessageType` enum. |
|
||||
| TTL | 1 | Time-To-Live for mesh network routing. Decremented at each hop. |
|
||||
| Timestamp | 8 | `UInt64` millisecond timestamp of packet creation. |
|
||||
| Flags | 1 | Bitmask for optional fields (`hasRecipient`, `hasSignature`, `isCompressed`). |
|
||||
| Payload Length | 2 | `UInt16` length of the payload field. |
|
||||
| **Variable** | **...** | **Variable-size fields** |
|
||||
| Sender ID | 8 | 8-byte truncated peer ID of the sender. |
|
||||
| Recipient ID | 8 (optional) | 8-byte truncated peer ID of the recipient. Present if `hasRecipient` flag is set. Broadcast if `0xFF..FF`. |
|
||||
| Payload | Variable | The actual content of the packet, as defined by the `Type` field. |
|
||||
| Signature | 64 (optional)| `Ed25519` signature of the packet. Present if `hasSignature` flag is set. |
|
||||
|
||||
**Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers.
|
||||
|
||||
```mermaid
|
||||
---
|
||||
config:
|
||||
theme: dark
|
||||
---
|
||||
---
|
||||
title: "BitchatPacket"
|
||||
---
|
||||
packet
|
||||
+8: "Version"
|
||||
+8: "Type"
|
||||
+8: "TTL"
|
||||
+64: "Timestamp"
|
||||
+8: "Flags"
|
||||
+16: "Payload Length"
|
||||
+64: "Sender ID"
|
||||
+64: "Recipient ID (optional)"
|
||||
+48: "Payload (variable)"
|
||||
+64: "Signature (optional)"
|
||||
```
|
||||
_A representation of the sizes of the fields in `BitchatPacket`_
|
||||
|
||||
### 6.2. Application Message Format (`BitchatMessage`)
|
||||
|
||||
For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.
|
||||
|
||||
| Field | Size (bytes) | Description |
|
||||
|---------------------|--------------|--------------------------------------------------------------------------|
|
||||
| Flags | 1 | Bitmask for optional fields (`isRelay`, `isPrivate`, `hasOriginalSender`). |
|
||||
| Timestamp | 8 | `UInt64` millisecond timestamp of message creation. |
|
||||
| ID | 1 + len | `UUID` string for the message. |
|
||||
| Sender | 1 + len | Nickname of the sender. |
|
||||
| Content | 2 + len | The UTF-8 encoded message content. |
|
||||
| Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. |
|
||||
| Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. |
|
||||
|
||||
```mermaid
|
||||
---
|
||||
config:
|
||||
theme: dark
|
||||
---
|
||||
---
|
||||
title: "BitchatMessage"
|
||||
---
|
||||
packet
|
||||
+8: "Flags"
|
||||
+64: "Timestamp"
|
||||
+24: "ID (variable)"
|
||||
+32: "Sender (variable)"
|
||||
+32: "Content (variable)"
|
||||
+32: "Original Sender (variable) (optional)"
|
||||
+32: "Recipient Nickname (variable) (optional)"
|
||||
```
|
||||
_A representation of the sizes of the fields in `BitchatMessage`_
|
||||
|
||||
---
|
||||
|
||||
## 7. Message Routing and Propagation
|
||||
|
||||
BitChat operates as a decentralized mesh network, meaning there are no central servers to route messages. Packets are propagated through the network from peer to peer. The protocol supports several modes of message delivery.
|
||||
|
||||
### 7.1. Direct Connection
|
||||
|
||||
This is the simplest case. If Peer A and Peer B are directly connected, they can exchange packets after establishing a mutually authenticated Noise session. All packets are encrypted using the transport ciphers derived from the handshake.
|
||||
|
||||
### 7.2. Efficient Gossip with Bloom Filters
|
||||
|
||||
To send messages to peers that are not directly connected, BitChat employs a "flooding" or "gossip" protocol. When a peer receives a packet that is not destined for it, it acts as a relay. To prevent infinite routing loops and minimize memory usage, the protocol uses an `OptimizedBloomFilter` to track recently seen packet IDs.
|
||||
|
||||
The logic is as follows:
|
||||
|
||||
1. A peer receives a packet.
|
||||
2. It checks the Bloom filter to see if the packet's ID has likely been seen before. If so, the packet is discarded. Bloom filters can have false positives (though they are rare), but they guarantee no false negatives. This means that while some packets may be incorrectly discarded due to false positives, the gossip protocol's redundancy ensures these packets will eventually be received through subsequent exchanges with other peers.
|
||||
3. If the packet is new, its ID is added to the Bloom filter.
|
||||
4. The peer decrements the packet's Time-To-Live (TTL) field.
|
||||
5. If the TTL is greater than zero, the peer re-broadcasts the packet to all of its connected peers, *except* for the peer from which it received the packet.
|
||||
|
||||
This mechanism allows packets to "flood" through the network efficiently, maximizing the chance of reaching their destination while using minimal resources to prevent loops.
|
||||
|
||||
### 7.3. Time-To-Live (TTL)
|
||||
|
||||
Every `BitchatPacket` contains an 8-bit TTL field. This value is set by the originating peer and is decremented by one at each relay hop. If a peer receives a packet and decrements its TTL to 0, it will process the packet (if it is the recipient) but will not relay it further. This is a crucial mechanism to prevent packets from circulating endlessly in the mesh.
|
||||
|
||||
### 7.4. Private vs. Broadcast Messages
|
||||
|
||||
The routing logic respects the confidentiality of private messages:
|
||||
|
||||
* **Private Messages:** A packet with a specific `recipientID` is a private message. Relay nodes forward the entire, encrypted Noise message without being able to access the inner `BitchatPacket` or its payload. Only the final recipient, who shares the correct Noise session keys with the sender, can decrypt the packet.
|
||||
* **Broadcast Messages:** A packet with the special broadcast `recipientID` (`0xFFFFFFFFFFFFFFFF`) is intended for all peers. Any peer that receives and decrypts a broadcast message will process its content. It will still be relayed according to the flooding algorithm to ensure it reaches the entire network.
|
||||
|
||||
### 7.5. Message Reliability and Lifecycle
|
||||
|
||||
To function in unreliable, lossy networks, the protocol includes features to track the lifecycle of a message and ensure its delivery.
|
||||
|
||||
* **Delivery Acknowledgments (`DeliveryAck`):** When a private message reaches its final destination, the recipient's device sends a `DeliveryAck` packet back to the original sender. This acknowledgment contains the ID of the original message.
|
||||
* **Read Receipts (`ReadReceipt`):** After a message is displayed on the recipient's screen, the application can send a `ReadReceipt`, also containing the original message ID, to inform the sender that the message has been seen.
|
||||
* **Message Retry Service:** Senders maintain a `MessageRetryService` which tracks outgoing messages. If a `DeliveryAck` is not received for a message within a certain time window, the service will automatically re-send the message, creating a more resilient user experience.
|
||||
|
||||
### 7.6. Fragmentation
|
||||
|
||||
Transport layers like BLE have a Maximum Transmission Unit (MTU) that limits the size of a single packet. To handle messages larger than this limit, BitChat implements a fragmentation protocol.
|
||||
|
||||
* **`fragmentStart`:** A packet with this type marks the beginning of a fragmented message. It contains metadata about the total size and number of fragments.
|
||||
* **`fragmentContinue`:** These packets carry the intermediate chunks of the message data.
|
||||
* **`fragmentEnd`:** This packet carries the final chunk of the message and signals the receiver to begin reassembly.
|
||||
|
||||
Receiving peers collect all fragments and reassemble them in the correct order before passing the complete message up to the application layer.
|
||||
|
||||
---
|
||||
* **Public chat** — signed broadcast messages within the mesh, backed by the gossip-synced history above.
|
||||
* **Private chat** — end-to-end encrypted messages with delivery and read receipts, over mesh, courier, or Nostr.
|
||||
* **Location channels** — geohash-scoped public rooms carried over Nostr relays for regional chat beyond radio range.
|
||||
* **Favorites** — the mutual-trust relationship that unlocks Nostr delivery and the larger courier quota.
|
||||
* **Media** — files and images fragment over the mesh (1 MiB cap, explicit accept before anything touches disk); couriers carry text only.
|
||||
* **Panic wipe** — clears identity keys, favorites, carried courier mail, the sealed outbox, archived public history, and metrics.
|
||||
|
||||
## 8. Security Considerations
|
||||
|
||||
* **Replay Attacks:** The Noise transport messages include a nonce that is incremented for each message. The `NoiseCipherState` implements a sliding window replay protection mechanism to detect and discard replayed or out-of-order messages.
|
||||
* **Denial of Service:** The `NoiseRateLimiter` is implemented to prevent resource exhaustion from rapid, repeated handshake attempts from a single peer.
|
||||
* **Key-Compromise Impersonation:** The `XX` pattern authenticates both parties, preventing an attacker from impersonating one party to the other.
|
||||
* **Identity Binding:** While the Noise handshake authenticates the cryptographic keys, binding those keys to a human-readable nickname is handled at the application layer. Users must verify fingerprints out-of-band to prevent man-in-the-middle attacks.
|
||||
* **Traffic Analysis:** The use of fixed-size padding for all packets helps to obscure the exact nature and content of the communication, making it harder for a network-level adversary to infer information based on message size.
|
||||
* **Relay nodes** cannot read private traffic; they forward padded, opaque ciphertext.
|
||||
* **Couriers** are quota-bounded mailbags. A malicious courier can drop mail (redundant copies and deposit retry mitigate this) but cannot read it, link it across days, or amplify it — copy budgets are capped and every envelope is validated against size and lifetime policy on deposit.
|
||||
* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
|
||||
* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
|
||||
* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
|
||||
* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path.
|
||||
|
||||
## 9. Future Work
|
||||
|
||||
* Prekey-based forward secrecy for courier envelopes.
|
||||
* Couriered media beyond the 16 KiB text cap.
|
||||
* Probabilistic relay and edge-of-network TTL boosting for very dense and very sparse graphs.
|
||||
* Multi-hop courier routing informed by encounter history.
|
||||
|
||||
---
|
||||
|
||||
## 9. Conclusion
|
||||
|
||||
The BitChat Protocol provides a robust and secure foundation for decentralized, peer-to-peer communication. By layering a flexible application protocol on top of the well-regarded Noise Protocol Framework, it achieves strong confidentiality, authentication, and forward secrecy. The use of a compact binary format and thoughtful security considerations like rate limiting and traffic analysis resistance make it suitable for use in challenging network environments.
|
||||
*This document describes the protocol as implemented in the current release. The implementation is free and unencumbered software released into the public domain.*
|
||||
|
||||
Generated
-8
@@ -528,7 +528,6 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
@@ -561,7 +560,6 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -620,7 +618,6 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -655,7 +652,6 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = 1.5.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -716,7 +712,6 @@
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -749,7 +744,6 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = 1.5.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -816,7 +810,6 @@
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
@@ -846,7 +839,6 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
@@ -13,11 +14,28 @@ enum ImageUtilsError: Error {
|
||||
}
|
||||
|
||||
enum ImageUtils {
|
||||
private static let compressionQuality: CGFloat = 0.82
|
||||
private static let targetImageBytes: Int = 45_000
|
||||
private static let compressionQuality: CGFloat = 0.85
|
||||
// Upper bound for the compressed JPEG. This is only a ceiling: the encoder
|
||||
// keeps whatever a photo naturally weighs at `defaultMaxDimension` and
|
||||
// `compressionQuality`, and only steps quality down when a payload would
|
||||
// exceed this budget. It stays well under `FileTransferLimits.maxImageBytes`
|
||||
// (512 KiB) so the BLE path never overruns its cap.
|
||||
//
|
||||
// Wi-Fi bulk relevance: the old 45 KB / 448 px budget crushed every photo
|
||||
// to ~40 KB — below `TransportConfig.wifiBulkMinPayloadBytes` (64 KiB) — so
|
||||
// `WifiBulkPolicy.shouldOffer` never fired and the AWDL data plane was dead
|
||||
// in production. A genuinely detailed photo at `defaultMaxDimension` now
|
||||
// weighs well over 64 KiB, so it becomes Wi-Fi-bulk eligible to a capable
|
||||
// direct peer while still riding BLE fragmentation for everyone else.
|
||||
private static let targetImageBytes: Int = 200_000
|
||||
private static let maxSourceImageBytes: Int = 10 * 1024 * 1024
|
||||
// Longest-side ceiling for shared photos. 448 px was thumbnail-tier and
|
||||
// (together with the tiny byte budget) forced every photo below the Wi-Fi
|
||||
// bulk threshold. 1024 px keeps a shared photo legible and lets detailed
|
||||
// images clear 64 KiB, without approaching the 512 KiB hard cap.
|
||||
static let defaultMaxDimension: CGFloat = 1024
|
||||
|
||||
static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||
static func processImage(at url: URL, maxDimension: CGFloat = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
|
||||
try validateImageSource(at: url)
|
||||
|
||||
let data = try Data(contentsOf: url)
|
||||
@@ -47,34 +65,33 @@ enum ImageUtils {
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
|
||||
return try autoreleasepool {
|
||||
// Scale the image first
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
|
||||
// Get CGImage from UIImage - this is the key to stripping metadata
|
||||
guard let cgImage = scaled.cgImage else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
|
||||
// Use CGImageDestination to encode without metadata (same as macOS)
|
||||
var quality = compressionQuality
|
||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
|
||||
// Compress to target size
|
||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||
quality -= 0.1
|
||||
autoreleasepool {
|
||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||
jpegData = next
|
||||
}
|
||||
var dimension = maxDimension
|
||||
var jpegData: Data?
|
||||
// Downscale-and-compress until the payload fits the hard image cap.
|
||||
// A normal photo converges on the first pass; this loop only kicks
|
||||
// in for near-incompressible inputs (e.g. full-frame noise) that
|
||||
// would otherwise overrun `maxImageBytes` at the raised dimension.
|
||||
while true {
|
||||
let scaled = scaledImage(image, maxDimension: dimension)
|
||||
// Get CGImage from UIImage - this is the key to stripping metadata
|
||||
guard let cgImage = scaled.cgImage else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
guard let data = compressToBudget(cgImage) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
jpegData = data
|
||||
if data.count <= FileTransferLimits.maxImageBytes || dimension <= minRetryDimension {
|
||||
break
|
||||
}
|
||||
dimension = (dimension * dimensionRetryFactor).rounded(.down)
|
||||
}
|
||||
guard let finalData = jpegData else { throw ImageUtilsError.encodingFailed }
|
||||
|
||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
try finalData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
}
|
||||
}
|
||||
@@ -93,66 +110,49 @@ enum ImageUtils {
|
||||
UIGraphicsEndImageContext()
|
||||
return rendered ?? image
|
||||
}
|
||||
|
||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||
guard let data = CFDataCreateMutable(nil, 0) else {
|
||||
return nil
|
||||
}
|
||||
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
|
||||
return nil
|
||||
}
|
||||
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
|
||||
// By only specifying compression quality and no metadata keys,
|
||||
// we ensure a clean JPEG with no privacy-leaking information
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageDestinationLossyCompressionQuality: quality
|
||||
]
|
||||
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
return nil
|
||||
}
|
||||
return data as Data
|
||||
}
|
||||
#else
|
||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
|
||||
return try autoreleasepool {
|
||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
let width = inputCG.width
|
||||
let height = inputCG.height
|
||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: colorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||
guard let cgImage = context.makeImage() else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
var quality = compressionQuality
|
||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||
quality -= 0.1
|
||||
autoreleasepool {
|
||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||
jpegData = next
|
||||
}
|
||||
var dimension = maxDimension
|
||||
var jpegData: Data?
|
||||
// See the iOS path: normal photos converge immediately; the loop
|
||||
// only shrinks further for near-incompressible inputs so the
|
||||
// output never overruns `maxImageBytes`.
|
||||
while true {
|
||||
let scaled = scaledImage(image, maxDimension: dimension)
|
||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
let width = inputCG.width
|
||||
let height = inputCG.height
|
||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||
guard let context = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: 0,
|
||||
space: colorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
|
||||
guard let cgImage = context.makeImage() else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
guard let data = compressToBudget(cgImage) else {
|
||||
throw ImageUtilsError.encodingFailed
|
||||
}
|
||||
jpegData = data
|
||||
if data.count <= FileTransferLimits.maxImageBytes || dimension <= minRetryDimension {
|
||||
break
|
||||
}
|
||||
dimension = (dimension * dimensionRetryFactor).rounded(.down)
|
||||
}
|
||||
guard let finalData = jpegData else { throw ImageUtilsError.encodingFailed }
|
||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
try finalData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
}
|
||||
}
|
||||
@@ -172,6 +172,31 @@ enum ImageUtils {
|
||||
scaledImage.unlockFocus()
|
||||
return scaledImage
|
||||
}
|
||||
#endif
|
||||
|
||||
// When even the quality floor can't get an image under the byte budget,
|
||||
// shrink the longest side by this factor and re-encode. Bounded below so
|
||||
// the retry loop always terminates.
|
||||
private static let dimensionRetryFactor: CGFloat = 0.75
|
||||
private static let minRetryDimension: CGFloat = 256
|
||||
|
||||
/// Encodes `cgImage` to JPEG, stepping quality down toward
|
||||
/// `targetImageBytes`. Shared by both platforms.
|
||||
private static func compressToBudget(_ cgImage: CGImage) -> Data? {
|
||||
var quality = compressionQuality
|
||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
||||
return nil
|
||||
}
|
||||
while jpegData.count > targetImageBytes && quality > 0.3 {
|
||||
quality -= 0.1
|
||||
autoreleasepool {
|
||||
if let next = encodeJPEG(from: cgImage, quality: quality) {
|
||||
jpegData = next
|
||||
}
|
||||
}
|
||||
}
|
||||
return jpegData
|
||||
}
|
||||
|
||||
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||
@@ -193,7 +218,6 @@ enum ImageUtils {
|
||||
}
|
||||
return data as Data
|
||||
}
|
||||
#endif
|
||||
|
||||
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
|
||||
let formatter = DateFormatter()
|
||||
|
||||
@@ -33,12 +33,18 @@
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_bitchat-bulk._tcp</string>
|
||||
</array>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>bitchat uses peer-to-peer Wi-Fi to transfer large photos and voice notes directly between nearby devices.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
|
||||
@@ -93,6 +93,7 @@ enum NoisePattern {
|
||||
case XX // Most versatile, mutual authentication
|
||||
case IK // Initiator knows responder's static key
|
||||
case NK // Anonymous initiator
|
||||
case X // One-way: single message to a known static key (no response)
|
||||
}
|
||||
|
||||
enum NoiseRole {
|
||||
@@ -601,7 +602,7 @@ final class NoiseHandshakeState {
|
||||
switch pattern {
|
||||
case .XX:
|
||||
break // No pre-message keys
|
||||
case .IK, .NK:
|
||||
case .IK, .NK, .X:
|
||||
if role == .initiator, let remoteStatic = remoteStaticPublic {
|
||||
symmetricState.mixHash(remoteStatic.rawRepresentation)
|
||||
} else if role == .responder, let localStatic = localStaticPublic {
|
||||
@@ -904,6 +905,7 @@ extension NoisePattern {
|
||||
case .XX: return "XX"
|
||||
case .IK: return "IK"
|
||||
case .NK: return "NK"
|
||||
case .X: return "X"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -925,6 +927,10 @@ extension NoisePattern {
|
||||
[.e, .es], // -> e, es
|
||||
[.e, .ee] // <- e, ee
|
||||
]
|
||||
case .X:
|
||||
return [
|
||||
[.e, .es, .s, .ss] // -> e, es, s, ss (single one-way message)
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,10 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
@Published private(set) var relays: [Relay] = []
|
||||
@Published private(set) var isConnected = false
|
||||
/// Whether a relay that carries private messages is connected. DMs
|
||||
/// target the default (gift-wrap-capable) relay set, so a connected
|
||||
/// geohash/custom relay alone must not count — sends would still queue.
|
||||
@Published private(set) var isDMRelayConnected = false
|
||||
|
||||
private let dependencies: NostrRelayManagerDependencies
|
||||
private var allowDefaultRelays: Bool = false
|
||||
@@ -1087,6 +1091,9 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
private func updateConnectionStatus() {
|
||||
isConnected = relays.contains { $0.isConnected }
|
||||
// Relay URLs are normalized before entries are created, so direct
|
||||
// set membership is sound.
|
||||
isDMRelayConnected = relays.contains { $0.isConnected && Self.defaultRelaySet.contains($0.url) }
|
||||
}
|
||||
|
||||
/// A relay that drops before sending EOSE must not stall initial-load
|
||||
|
||||
@@ -28,12 +28,14 @@ struct BitchatFilePacket {
|
||||
|
||||
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
||||
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
||||
func encode() -> Data? {
|
||||
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
|
||||
/// `FileTransferLimits.maxWifiBulkPayloadBytes`.
|
||||
func encode(limit: Int = FileTransferLimits.maxPayloadBytes) -> Data? {
|
||||
let resolvedSize = fileSize ?? UInt64(content.count)
|
||||
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
||||
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
|
||||
guard resolvedSize <= UInt64(limit) else { return nil }
|
||||
guard content.count <= Int(UInt32.max) else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
|
||||
|
||||
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||
var big = value.bigEndian
|
||||
@@ -66,7 +68,9 @@ struct BitchatFilePacket {
|
||||
}
|
||||
|
||||
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
||||
static func decode(_ data: Data) -> BitchatFilePacket? {
|
||||
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
|
||||
/// the (smaller of the) accepted-offer size and the Wi-Fi bulk ceiling.
|
||||
static func decode(_ data: Data, limit: Int = FileTransferLimits.maxPayloadBytes) -> BitchatFilePacket? {
|
||||
var cursor = data.startIndex
|
||||
let end = data.endIndex
|
||||
|
||||
@@ -126,7 +130,7 @@ struct BitchatFilePacket {
|
||||
for byte in value {
|
||||
size = (size << 8) | UInt64(byte)
|
||||
}
|
||||
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
|
||||
if size > UInt64(limit) {
|
||||
return nil
|
||||
}
|
||||
fileSize = size
|
||||
@@ -135,7 +139,7 @@ struct BitchatFilePacket {
|
||||
mimeType = String(data: Data(value), encoding: .utf8)
|
||||
case .content:
|
||||
let proposedSize = content.count + value.count
|
||||
if proposedSize > FileTransferLimits.maxPayloadBytes {
|
||||
if proposedSize > limit {
|
||||
return nil
|
||||
}
|
||||
content.append(contentsOf: value)
|
||||
@@ -145,7 +149,7 @@ struct BitchatFilePacket {
|
||||
}
|
||||
|
||||
guard !content.isEmpty else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
|
||||
return BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
fileSize: fileSize ?? UInt64(content.count),
|
||||
|
||||
@@ -72,15 +72,20 @@ enum NoisePayloadType: UInt8 {
|
||||
case privateMessage = 0x01 // Private chat message
|
||||
case readReceipt = 0x02 // Message was read
|
||||
case delivered = 0x03 // Message was delivered
|
||||
// Wi-Fi bulk transport negotiation (AWDL data plane for large media)
|
||||
case bulkTransferOffer = 0x04 // Offer to move a large file over peer-to-peer Wi-Fi
|
||||
case bulkTransferResponse = 0x05 // Accept/decline reply to a bulk transfer offer
|
||||
// Verification (QR-based OOB binding)
|
||||
case verifyChallenge = 0x10 // Verification challenge
|
||||
case verifyResponse = 0x11 // Verification response
|
||||
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .privateMessage: return "privateMessage"
|
||||
case .readReceipt: return "readReceipt"
|
||||
case .delivered: return "delivered"
|
||||
case .bulkTransferOffer: return "bulkTransferOffer"
|
||||
case .bulkTransferResponse: return "bulkTransferResponse"
|
||||
case .verifyChallenge: return "verifyChallenge"
|
||||
case .verifyResponse: return "verifyResponse"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
// MARK: - Protocol TLV Packets
|
||||
@@ -7,12 +8,28 @@ struct AnnouncementPacket {
|
||||
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
||||
let signingPublicKey: Data // Ed25519 public key for signing
|
||||
let directNeighbors: [Data]? // 8-byte peer IDs
|
||||
let capabilities: PeerCapabilities? // advertised feature bits; nil when absent (old clients)
|
||||
|
||||
init(
|
||||
nickname: String,
|
||||
noisePublicKey: Data,
|
||||
signingPublicKey: Data,
|
||||
directNeighbors: [Data]?,
|
||||
capabilities: PeerCapabilities? = nil
|
||||
) {
|
||||
self.nickname = nickname
|
||||
self.noisePublicKey = noisePublicKey
|
||||
self.signingPublicKey = signingPublicKey
|
||||
self.directNeighbors = directNeighbors
|
||||
self.capabilities = capabilities
|
||||
}
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case nickname = 0x01
|
||||
case noisePublicKey = 0x02
|
||||
case signingPublicKey = 0x03
|
||||
case directNeighbors = 0x04
|
||||
case capabilities = 0x05
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
@@ -48,6 +65,15 @@ struct AnnouncementPacket {
|
||||
}
|
||||
}
|
||||
|
||||
// TLV for capabilities (optional)
|
||||
if let capabilities = capabilities {
|
||||
let capabilityBytes = capabilities.encoded()
|
||||
guard capabilityBytes.count <= 255 else { return nil }
|
||||
data.append(TLVType.capabilities.rawValue)
|
||||
data.append(UInt8(capabilityBytes.count))
|
||||
data.append(capabilityBytes)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -57,6 +83,7 @@ struct AnnouncementPacket {
|
||||
var noisePublicKey: Data?
|
||||
var signingPublicKey: Data?
|
||||
var directNeighbors: [Data]?
|
||||
var capabilities: PeerCapabilities?
|
||||
|
||||
while offset + 2 <= data.count {
|
||||
let typeRaw = data[offset]
|
||||
@@ -87,6 +114,8 @@ struct AnnouncementPacket {
|
||||
}
|
||||
directNeighbors = neighbors
|
||||
}
|
||||
case .capabilities:
|
||||
capabilities = PeerCapabilities(encoded: Data(value))
|
||||
}
|
||||
} else {
|
||||
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||
@@ -99,7 +128,8 @@ struct AnnouncementPacket {
|
||||
nickname: nickname,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey,
|
||||
directNeighbors: directNeighbors
|
||||
directNeighbors: directNeighbors,
|
||||
capabilities: capabilities
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import BitFoundation
|
||||
|
||||
extension PeerCapabilities {
|
||||
/// Capabilities this build advertises in its announce packets.
|
||||
/// Each feature adds its bit here when it ships.
|
||||
static let localSupported: PeerCapabilities = TransportConfig.wifiBulkEnabled ? [.wifiBulk] : []
|
||||
}
|
||||
@@ -59,6 +59,15 @@ struct BLEAnnounceHandlerEnvironment {
|
||||
let scheduleAfterglow: (TimeInterval) -> Void
|
||||
}
|
||||
|
||||
/// Outcome of an accepted announce, surfaced so the service can run
|
||||
/// follow-up work (e.g. courier handover) that keys off the announce.
|
||||
struct BLEAnnounceHandlingResult {
|
||||
let peerID: PeerID
|
||||
let announcement: AnnouncementPacket
|
||||
let isDirectAnnounce: Bool
|
||||
let isVerified: Bool
|
||||
}
|
||||
|
||||
/// Orchestrates inbound announce packets: preflight validation, signature
|
||||
/// trust, registry/topology updates, identity persistence, UI notification,
|
||||
/// gossip tracking, and the reciprocal announce response.
|
||||
@@ -69,7 +78,8 @@ final class BLEAnnounceHandler {
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
@discardableResult
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> BLEAnnounceHandlingResult? {
|
||||
let env = environment
|
||||
let now = env.now()
|
||||
let preflight = BLEAnnouncePreflightPolicy.evaluate(
|
||||
@@ -85,15 +95,15 @@ final class BLEAnnounceHandler {
|
||||
announcement = acceptance.announcement
|
||||
case .reject(.malformed):
|
||||
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))…", category: .session)
|
||||
return
|
||||
return nil
|
||||
case .reject(.senderMismatch(let derivedFromKey)):
|
||||
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security)
|
||||
return
|
||||
return nil
|
||||
case .reject(.selfAnnounce):
|
||||
return
|
||||
return nil
|
||||
case .reject(.stale(let ageSeconds)):
|
||||
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
// Suppress announce logs to reduce noise
|
||||
@@ -210,5 +220,12 @@ final class BLEAnnounceHandler {
|
||||
let delay = Double.random(in: 0.3...0.6)
|
||||
env.scheduleAfterglow(delay)
|
||||
}
|
||||
|
||||
return BLEAnnounceHandlingResult(
|
||||
peerID: peerID,
|
||||
announcement: announcement,
|
||||
isDirectAnnounce: isDirectAnnounce,
|
||||
isVerified: verifiedAnnounce
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,13 +19,35 @@ enum BLEFanoutSelector {
|
||||
packetType: UInt8,
|
||||
messageID: String
|
||||
) -> BLEFanoutSelection {
|
||||
let rawAllowed = allowedLinks(
|
||||
peripheralIDs: peripheralIDs,
|
||||
centralIDs: centralIDs,
|
||||
ingressLink: ingressLink,
|
||||
excludedLinks: excludedLinks
|
||||
)
|
||||
|
||||
if let directedPeerHint,
|
||||
let directedSelection = directLinks(
|
||||
to: directedPeerHint,
|
||||
links: rawAllowed,
|
||||
peripheralPeerBindings: peripheralPeerBindings,
|
||||
centralPeerBindings: centralPeerBindings
|
||||
) {
|
||||
return directedSelection
|
||||
}
|
||||
if let directedPeerHint,
|
||||
hasBoundLink(
|
||||
to: directedPeerHint,
|
||||
peripheralIDs: peripheralIDs,
|
||||
centralIDs: centralIDs,
|
||||
peripheralPeerBindings: peripheralPeerBindings,
|
||||
centralPeerBindings: centralPeerBindings
|
||||
) {
|
||||
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
|
||||
}
|
||||
|
||||
let allowed = collapseDuplicateLinksPerPeer(
|
||||
allowedLinks(
|
||||
peripheralIDs: peripheralIDs,
|
||||
centralIDs: centralIDs,
|
||||
ingressLink: ingressLink,
|
||||
excludedLinks: excludedLinks
|
||||
),
|
||||
rawAllowed,
|
||||
peripheralPeerBindings: peripheralPeerBindings,
|
||||
centralPeerBindings: centralPeerBindings
|
||||
)
|
||||
@@ -71,6 +93,42 @@ enum BLEFanoutSelector {
|
||||
return (allowedPeripheralIDs, allowedCentralIDs)
|
||||
}
|
||||
|
||||
private static func directLinks(
|
||||
to peerID: PeerID,
|
||||
links: (peripheralIDs: [String], centralIDs: [String]),
|
||||
peripheralPeerBindings: [String: PeerID],
|
||||
centralPeerBindings: [String: PeerID]
|
||||
) -> BLEFanoutSelection? {
|
||||
let directLinks = collapseDuplicateLinksPerPeer(
|
||||
(
|
||||
peripheralIDs: links.peripheralIDs.filter { peripheralPeerBindings[$0] == peerID },
|
||||
centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID }
|
||||
),
|
||||
peripheralPeerBindings: peripheralPeerBindings,
|
||||
centralPeerBindings: centralPeerBindings
|
||||
)
|
||||
|
||||
guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return BLEFanoutSelection(
|
||||
peripheralIDs: Set(directLinks.peripheralIDs),
|
||||
centralIDs: Set(directLinks.centralIDs)
|
||||
)
|
||||
}
|
||||
|
||||
private static func hasBoundLink(
|
||||
to peerID: PeerID,
|
||||
peripheralIDs: [String],
|
||||
centralIDs: [String],
|
||||
peripheralPeerBindings: [String: PeerID],
|
||||
centralPeerBindings: [String: PeerID]
|
||||
) -> Bool {
|
||||
peripheralIDs.contains { peripheralPeerBindings[$0] == peerID }
|
||||
|| centralIDs.contains { centralPeerBindings[$0] == peerID }
|
||||
}
|
||||
|
||||
// Dual-role pairs hold two live links (we-as-central writing to their
|
||||
// peripheral, and they-as-central subscribed to ours). Sending the same
|
||||
// packet down both doubles airtime for nothing — the receiver's assembler
|
||||
|
||||
@@ -44,7 +44,9 @@ final class BLEFileTransferHandler {
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
/// `payloadLimit` defaults to the Bluetooth cap; Wi-Fi bulk deliveries
|
||||
/// pass the ceiling that was enforced against the accepted offer.
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID, payloadLimit: Int = FileTransferLimits.maxPayloadBytes) {
|
||||
let env = environment
|
||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
|
||||
|
||||
@@ -69,7 +71,7 @@ final class BLEFileTransferHandler {
|
||||
|
||||
let filePacket: BitchatFilePacket
|
||||
let mime: MimeType
|
||||
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
|
||||
switch BLEIncomingFileValidator.validate(payload: packet.payload, limit: payloadLimit) {
|
||||
case .success(let acceptance):
|
||||
filePacket = acceptance.filePacket
|
||||
mime = acceptance.mime
|
||||
|
||||
@@ -42,12 +42,17 @@ enum BLEIncomingFileRejection: Error, Equatable {
|
||||
}
|
||||
|
||||
enum BLEIncomingFileValidator {
|
||||
static func validate(payload: Data) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
||||
guard let filePacket = BitchatFilePacket.decode(payload) else {
|
||||
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk deliveries
|
||||
/// pass the ceiling enforced against the accepted offer.
|
||||
static func validate(
|
||||
payload: Data,
|
||||
limit: Int = FileTransferLimits.maxPayloadBytes
|
||||
) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
||||
guard let filePacket = BitchatFilePacket.decode(payload, limit: limit) else {
|
||||
return .failure(.malformedPayload)
|
||||
}
|
||||
|
||||
guard FileTransferLimits.isValidPayload(filePacket.content.count) else {
|
||||
guard FileTransferLimits.isValidPayload(filePacket.content.count, limit: limit) else {
|
||||
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,11 @@ struct BLEIngressLinkRegistry {
|
||||
}
|
||||
|
||||
private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
|
||||
packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
|
||||
// REQUEST_SYNC is never relayed, so on a bound link the claimed sender
|
||||
// must be the link peer — it elicits a full store replay, and the
|
||||
// response is addressed to whoever the sender claims to be.
|
||||
if packet.type == MessageType.requestSync.rawValue { return true }
|
||||
return packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
|
||||
}
|
||||
|
||||
private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
|
||||
|
||||
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
|
||||
switch MessageType(rawValue: packetType) {
|
||||
case .noiseEncrypted, .noiseHandshake:
|
||||
return true
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer:
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ struct BLEPeerInfo: Equatable {
|
||||
var signingPublicKey: Data?
|
||||
var isVerifiedNickname: Bool
|
||||
var lastSeen: Date
|
||||
var capabilities: PeerCapabilities = []
|
||||
}
|
||||
|
||||
struct BLEPeerAnnounceUpdate: Equatable {
|
||||
@@ -107,6 +108,10 @@ struct BLEPeerRegistry {
|
||||
peers[peerID]?.noisePublicKey?.sha256Fingerprint()
|
||||
}
|
||||
|
||||
func capabilities(for peerID: PeerID) -> PeerCapabilities {
|
||||
peers[peerID.toShort()]?.capabilities ?? []
|
||||
}
|
||||
|
||||
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
||||
let connected = peers.filter { $0.value.isConnected }
|
||||
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
||||
@@ -125,7 +130,8 @@ struct BLEPeerRegistry {
|
||||
nickname: resolvedNames[info.peerID] ?? info.nickname,
|
||||
isConnected: info.isConnected,
|
||||
noisePublicKey: info.noisePublicKey,
|
||||
lastSeen: info.lastSeen
|
||||
lastSeen: info.lastSeen,
|
||||
isVerified: info.isVerifiedNickname
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -156,7 +162,8 @@ struct BLEPeerRegistry {
|
||||
noisePublicKey: Data,
|
||||
signingPublicKey: Data?,
|
||||
isConnected: Bool,
|
||||
now: Date
|
||||
now: Date,
|
||||
capabilities: PeerCapabilities = []
|
||||
) -> BLEPeerAnnounceUpdate {
|
||||
let existing = peers[peerID]
|
||||
let update = BLEPeerAnnounceUpdate(
|
||||
@@ -172,7 +179,8 @@ struct BLEPeerRegistry {
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: now
|
||||
lastSeen: now,
|
||||
capabilities: capabilities
|
||||
)
|
||||
|
||||
return update
|
||||
|
||||
@@ -27,8 +27,15 @@ enum BLEPublicMessagePolicy {
|
||||
}
|
||||
|
||||
let isBroadcast = BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID)
|
||||
// Acceptance window matches the gossip-sync serving window: a peer
|
||||
// walking between partitions carries hours of public history, so the
|
||||
// receive side must not drop what sync legitimately serves.
|
||||
if isBroadcast,
|
||||
BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) {
|
||||
BLEPacketFreshnessPolicy.isStale(
|
||||
timestampMilliseconds: packet.timestamp,
|
||||
now: now,
|
||||
maxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds
|
||||
) {
|
||||
return .reject(.staleBroadcast(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
|
||||
timestampMilliseconds: packet.timestamp,
|
||||
now: now
|
||||
|
||||
@@ -48,11 +48,16 @@ struct BLEReceivePipeline {
|
||||
senderIsSelf: senderID == localPeerID,
|
||||
recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID,
|
||||
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
isDirectedEncrypted: packet.type == MessageType.noiseEncrypted.rawValue && packet.recipientID != nil,
|
||||
// Courier envelopes are directed opaque ciphertext like DMs; a
|
||||
// remote handover toward a relayed announce rides this same
|
||||
// deterministic relay treatment instead of the broadcast clamp.
|
||||
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|
||||
|| packet.type == MessageType.courierEnvelope.rawValue) && packet.recipientID != nil,
|
||||
isFragment: packet.type == MessageType.fragment.rawValue,
|
||||
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||
isAnnounce: packet.type == MessageType.announce.rawValue,
|
||||
isRequestSync: packet.type == MessageType.requestSync.rawValue,
|
||||
degree: degree,
|
||||
highDegreeThreshold: highDegreeThreshold
|
||||
)
|
||||
|
||||
@@ -35,6 +35,14 @@ struct BLERouteForwardingPolicy {
|
||||
routingPeer: (Data) -> PeerID?,
|
||||
isPeerConnected: (PeerID) -> Bool
|
||||
) -> BLERouteForwardingPlan {
|
||||
// REQUEST_SYNC is link-local: never forward it, on the flood path or
|
||||
// the source-routed path. A crafted request with a route and TTL
|
||||
// headroom must not be able to fan a full-store replay out to the next
|
||||
// hop. Suppressing here also short-circuits the flood relay.
|
||||
if packet.type == MessageType.requestSync.rawValue {
|
||||
return .suppressFloodRelay
|
||||
}
|
||||
|
||||
if PeerID(hexData: packet.recipientID) == localPeerID {
|
||||
return .suppressFloodRelay
|
||||
}
|
||||
|
||||
@@ -46,6 +46,25 @@ final class BLEService: NSObject {
|
||||
|
||||
// 4. Efficient Message Deduplication
|
||||
private let messageDeduplicator = MessageDeduplicator()
|
||||
|
||||
// Courier store-and-forward: envelopes this device carries for offline
|
||||
// third parties, and the trust gate for accepting deposits. The policy
|
||||
// maps (depositor key, announce-verified?) to a quota tier, or nil to
|
||||
// reject. Injectable for tests; main-actor policy because favorites live
|
||||
// on the main actor.
|
||||
var courierStore: CourierStore = .shared
|
||||
var courierDepositPolicy: @MainActor (Data, Bool) -> CourierDepositTier? = { depositorNoiseKey, isVerifiedPeer in
|
||||
if FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey) { return .favorite }
|
||||
return isVerifiedPeer ? .verified : nil
|
||||
}
|
||||
// Local-only store-and-forward counters; nil in unit tests.
|
||||
var sfMetrics: StoreAndForwardMetrics?
|
||||
|
||||
#if DEBUG
|
||||
// Test-only tap on the outbound pipeline so multi-node tests can ferry
|
||||
// packets between in-process service instances.
|
||||
var _test_onOutboundPacket: ((BitchatPacket) -> Void)?
|
||||
#endif
|
||||
private var selfBroadcastTracker = BLESelfBroadcastTracker()
|
||||
private let meshTopology = MeshTopologyTracker()
|
||||
|
||||
@@ -126,6 +145,8 @@ final class BLEService: NSObject {
|
||||
private lazy var fragmentHandler = BLEFragmentHandler(environment: makeFragmentHandlerEnvironment())
|
||||
// File-transfer orchestration (queue hops stay in the environment closures)
|
||||
private lazy var fileTransferHandler = BLEFileTransferHandler(environment: makeFileTransferHandlerEnvironment())
|
||||
// Wi-Fi bulk data plane: BLE/Noise negotiates, AWDL carries large media
|
||||
private lazy var wifiBulkService = WifiBulkTransferService(environment: makeWifiBulkEnvironment())
|
||||
|
||||
// MARK: - Gossip Sync
|
||||
private var gossipSyncManager: GossipSyncManager?
|
||||
@@ -250,6 +271,12 @@ final class BLEService: NSObject {
|
||||
|
||||
// Initialize gossip sync manager
|
||||
restartGossipManager()
|
||||
|
||||
// Force single-threaded instantiation: unlike the packet handlers
|
||||
// (touched only from messageQueue), the Wi-Fi bulk service is reached
|
||||
// from the main actor too (cancelTransfer/stopServices), and lazy
|
||||
// vars are not thread-safe.
|
||||
_ = wifiBulkService
|
||||
}
|
||||
|
||||
private func restartGossipManager() {
|
||||
@@ -261,6 +288,7 @@ final class BLEService: NSObject {
|
||||
gcsMaxBytes: TransportConfig.syncGCSMaxBytes,
|
||||
gcsTargetFpr: TransportConfig.syncGCSTargetFpr,
|
||||
maxMessageAgeSeconds: TransportConfig.syncMaxMessageAgeSeconds,
|
||||
publicMessageMaxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds,
|
||||
maintenanceIntervalSeconds: TransportConfig.syncMaintenanceIntervalSeconds,
|
||||
stalePeerCleanupIntervalSeconds: TransportConfig.syncStalePeerCleanupIntervalSeconds,
|
||||
stalePeerTimeoutSeconds: TransportConfig.syncStalePeerTimeoutSeconds,
|
||||
@@ -268,10 +296,14 @@ final class BLEService: NSObject {
|
||||
fileTransferCapacity: TransportConfig.syncFileTransferCapacity,
|
||||
fragmentSyncIntervalSeconds: TransportConfig.syncFragmentIntervalSeconds,
|
||||
fileTransferSyncIntervalSeconds: TransportConfig.syncFileTransferIntervalSeconds,
|
||||
messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds
|
||||
messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds,
|
||||
responseRateLimitMaxResponses: TransportConfig.syncResponseRateLimitMaxResponses,
|
||||
responseRateLimitWindowSeconds: TransportConfig.syncResponseRateLimitWindowSeconds
|
||||
)
|
||||
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
|
||||
// Only real Bluetooth sessions archive to disk; unit tests stay hermetic.
|
||||
let archive = meshBackgroundEnabled ? GossipMessageArchive() : nil
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager, archive: archive)
|
||||
manager.delegate = self
|
||||
// Only start the periodic sync timers when real Bluetooth exists. In unit
|
||||
// tests there is no mesh to sync with, and the periodic sign/broadcast
|
||||
@@ -480,6 +512,9 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
func stopServices() {
|
||||
// Tear down any Wi-Fi bulk listeners/connections deterministically
|
||||
wifiBulkService.stop()
|
||||
|
||||
// Send leave message synchronously to ensure delivery
|
||||
var leavePacket = BitchatPacket(
|
||||
type: MessageType.leave.rawValue,
|
||||
@@ -595,6 +630,12 @@ final class BLEService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Capabilities the peer advertised in its last verified announce.
|
||||
/// Empty for peers that predate the capabilities TLV.
|
||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities {
|
||||
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
|
||||
}
|
||||
|
||||
func getPeerNicknames() -> [PeerID: String] {
|
||||
return collectionsQueue.sync {
|
||||
peerRegistry.displayNicknames(selfNickname: myNickname)
|
||||
@@ -666,6 +707,9 @@ final class BLEService: NSObject {
|
||||
// MARK: Messaging
|
||||
|
||||
func cancelTransfer(_ transferId: String) {
|
||||
// A transfer may be riding the Wi-Fi bulk channel instead of the BLE
|
||||
// fragment scheduler; cancelling both is safe (each no-ops on miss).
|
||||
wifiBulkService.cancelTransfer(transferId: transferId)
|
||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
@@ -741,10 +785,72 @@ final class BLEService: NSObject {
|
||||
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard let payload = filePacket.encode() else {
|
||||
// Encode with the Wi-Fi bulk ceiling; whether the payload also
|
||||
// fits the BLE caps decides what a fallback may do.
|
||||
guard let payload = filePacket.encode(limit: FileTransferLimits.maxWifiBulkPayloadBytes) else {
|
||||
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
||||
return
|
||||
}
|
||||
let fitsBLECaps = filePacket.encode() != nil
|
||||
|
||||
// Normalize to the short routing ID (SHA256-derived 16-hex) once.
|
||||
// Stable/verified private chats address the peer by its full
|
||||
// 64-hex Noise key, but the Noise session and the negotiation
|
||||
// packet are keyed by the short ID — so the capability/session
|
||||
// eligibility checks (and the Wi-Fi offer) must all use it, or a
|
||||
// 64-hex key makes `hasEstablishedSession` return false and Wi-Fi
|
||||
// is never selected even when the peer advertises `.wifiBulk`.
|
||||
let routingID = peerID.toShort()
|
||||
|
||||
let candidate = self.wifiBulkSendCandidate(payloadBytes: payload.count, to: routingID)
|
||||
if WifiBulkPolicy.shouldOffer(candidate) {
|
||||
self.wifiBulkService.sendFile(payload: payload, to: routingID, transferId: transferId) { [weak self] in
|
||||
self?.sendFilePrivateViaBLE(payload: payload, to: routingID, transferId: transferId, fitsBLECaps: fitsBLECaps)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard fitsBLECaps else {
|
||||
// Wi-Fi-only payload with no eligible Wi-Fi path.
|
||||
SecureLogger.error("❌ File exceeds BLE caps and Wi-Fi bulk is unavailable", category: .session)
|
||||
self.surfaceUndeliverableTransfer(transferId)
|
||||
return
|
||||
}
|
||||
self.sendFilePrivateViaBLE(payload: payload, to: routingID, transferId: transferId, fitsBLECaps: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the Wi-Fi bulk eligibility candidate for a private send.
|
||||
///
|
||||
/// Normalizes `peerID` to its short routing ID first: stable/verified
|
||||
/// private chats address the peer by its full 64-hex Noise key, but the
|
||||
/// Noise session, advertised capabilities, and connection state are all
|
||||
/// keyed by the short (SHA256-derived 16-hex) ID. Resolving them with the
|
||||
/// 64-hex key would miss the session (`hasEstablishedSession` returns
|
||||
/// false), so Wi-Fi would never be offered even to a directly-connected
|
||||
/// `.wifiBulk` peer.
|
||||
private func wifiBulkSendCandidate(payloadBytes: Int, to peerID: PeerID) -> WifiBulkPolicy.SendCandidate {
|
||||
let routingID = peerID.toShort()
|
||||
return WifiBulkPolicy.SendCandidate(
|
||||
payloadBytes: payloadBytes,
|
||||
peerCapabilities: peerCapabilities(routingID),
|
||||
isDirectlyConnected: isPeerConnected(routingID),
|
||||
hasEstablishedNoiseSession: noiseService.hasEstablishedSession(with: routingID)
|
||||
)
|
||||
}
|
||||
|
||||
/// BLE fragmentation path for private file transfers; also the fallback
|
||||
/// target when a Wi-Fi bulk attempt declines, times out, or errors.
|
||||
/// May be invoked from the Wi-Fi bulk queue.
|
||||
private func sendFilePrivateViaBLE(payload: Data, to peerID: PeerID, transferId: String, fitsBLECaps: Bool) {
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
guard fitsBLECaps else {
|
||||
// A >1 MiB payload was negotiated for Wi-Fi and the channel
|
||||
// failed: BLE cannot carry it, surface the normal failure path.
|
||||
SecureLogger.error("❌ Wi-Fi bulk failed and payload exceeds BLE caps (\(payload.count) bytes)", category: .session)
|
||||
self.surfaceUndeliverableTransfer(transferId)
|
||||
return
|
||||
}
|
||||
// Normalize to short form (SHA256-derived 16-hex) for wire protocol compatibility
|
||||
// This ensures 64-hex Noise keys are converted to the canonical routing format
|
||||
let targetID = peerID.toShort()
|
||||
@@ -773,6 +879,13 @@ final class BLEService: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drives the progress bus through started→cancelled so the UI clears a
|
||||
/// transfer that no transport can carry.
|
||||
private func surfaceUndeliverableTransfer(_ transferId: String) {
|
||||
TransferProgressManager.shared.start(id: transferId, totalFragments: 1)
|
||||
TransferProgressManager.shared.cancel(id: transferId)
|
||||
}
|
||||
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
let payload = BLENoisePayloadFactory.readReceipt(originalMessageID: receipt.originalMessageID)
|
||||
@@ -888,6 +1001,10 @@ final class BLEService: NSObject {
|
||||
} else {
|
||||
packetToSend = packet
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
_test_onOutboundPacket?(packetToSend)
|
||||
#endif
|
||||
|
||||
// Encode once using a small per-type padding policy, then delegate by type
|
||||
let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
|
||||
@@ -1047,6 +1164,9 @@ final class BLEService: NSObject {
|
||||
|
||||
// Directed send helper (unicast to a specific peerID) without altering packet contents
|
||||
private func sendPacketDirected(_ packet: BitchatPacket, to peerID: PeerID) {
|
||||
#if DEBUG
|
||||
_test_onOutboundPacket?(packet)
|
||||
#endif
|
||||
guard let data = packet.toBinaryData(padding: false) else { return }
|
||||
sendOnAllLinks(packet: packet, data: data, pad: false, directedOnlyPeer: peerID)
|
||||
}
|
||||
@@ -1151,6 +1271,57 @@ final class BLEService: NSObject {
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds the Wi-Fi bulk service environment. Noise encryption and packet
|
||||
/// dispatch stay on the message queue; incoming payloads re-enter the
|
||||
/// normal file-transfer pipeline as if a fully assembled packet arrived.
|
||||
private func makeWifiBulkEnvironment() -> WifiBulkTransferServiceEnvironment {
|
||||
WifiBulkTransferServiceEnvironment(
|
||||
sendNoisePayload: { [weak self] typedPayload, peerID in
|
||||
guard let self = self, self.noiseService.hasEstablishedSession(with: peerID) else { return false }
|
||||
self.messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
do {
|
||||
self.broadcastPacket(try self.makeEncryptedNoisePacket(typedPayload, to: peerID))
|
||||
} catch {
|
||||
SecureLogger.error("❌ Failed to send Wi-Fi bulk negotiation payload: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
isPeerConnected: { [weak self] peerID in
|
||||
self?.isPeerConnected(peerID) ?? false
|
||||
},
|
||||
deliverReceivedFile: { [weak self] payload, peerID, payloadLimit in
|
||||
self?.messageQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.fileTransfer.rawValue,
|
||||
senderID: Data(hexString: peerID.toShort().id) ?? Data(),
|
||||
recipientID: self.myPeerIDData,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 0,
|
||||
version: 2
|
||||
)
|
||||
self.fileTransferHandler.handle(packet, from: peerID, payloadLimit: payloadLimit)
|
||||
}
|
||||
},
|
||||
progressStart: { transferId, totalChunks in
|
||||
TransferProgressManager.shared.start(id: transferId, totalFragments: totalChunks)
|
||||
},
|
||||
progressChunkSent: { transferId in
|
||||
TransferProgressManager.shared.recordFragmentSent(id: transferId)
|
||||
},
|
||||
progressReset: { transferId in
|
||||
TransferProgressManager.shared.reset(id: transferId)
|
||||
},
|
||||
progressCancel: { transferId in
|
||||
TransferProgressManager.shared.cancel(id: transferId)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||
SecureLogger.debug("🔔 sendFavoriteNotification peer=\(peerID.id.prefix(8))… isFavorite=\(isFavorite)", category: .session)
|
||||
|
||||
@@ -1230,7 +1401,8 @@ final class BLEService: NSObject {
|
||||
nickname: myNickname,
|
||||
noisePublicKey: noisePub,
|
||||
signingPublicKey: signingPub,
|
||||
directNeighbors: connectedPeerIDs
|
||||
directNeighbors: connectedPeerIDs,
|
||||
capabilities: PeerCapabilities.localSupported
|
||||
)
|
||||
|
||||
guard let payload = announcement.encode() else {
|
||||
@@ -1684,6 +1856,34 @@ extension BLEService {
|
||||
cachedServiceUUIDs: cachedServiceUUIDs
|
||||
)
|
||||
}
|
||||
|
||||
/// The internal Noise service, so tests can drive a real handshake and
|
||||
/// establish a session keyed by a chosen (short) routing ID.
|
||||
var _test_noiseService: NoiseEncryptionService { noiseService }
|
||||
|
||||
/// Registers a directly-connected peer with the given advertised
|
||||
/// capabilities, keyed by its short routing ID (as production does).
|
||||
func _test_registerConnectedPeer(_ peerID: PeerID, capabilities: PeerCapabilities) {
|
||||
let shortID = peerID.toShort()
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
peerRegistry.upsert(BLEPeerInfo(
|
||||
peerID: shortID,
|
||||
nickname: "TestPeer_\(shortID.id.prefix(4))",
|
||||
isConnected: true,
|
||||
noisePublicKey: peerID.noiseKey,
|
||||
signingPublicKey: nil,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: Date(),
|
||||
capabilities: capabilities
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the production Wi-Fi bulk eligibility resolution (including peer-ID
|
||||
/// normalization) for the given payload size and recipient.
|
||||
func _test_wifiBulkSendCandidate(payloadBytes: Int, to peerID: PeerID) -> WifiBulkPolicy.SendCandidate {
|
||||
wifiBulkSendCandidate(payloadBytes: payloadBytes, to: peerID)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -2468,7 +2668,216 @@ extension BLEService {
|
||||
ttl: messageTTL
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// MARK: Courier Store-and-Forward
|
||||
|
||||
/// Seal `content` to the recipient's static key (one-way Noise X) and hand
|
||||
/// the envelope to the given couriers for physical delivery. Returns false
|
||||
/// when no courier is connected, the payload cannot be built, or sealing
|
||||
/// fails; link writes are queued asynchronously after the envelope is ready.
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
|
||||
let connected = couriers.filter { isPeerConnected($0) }
|
||||
guard !connected.isEmpty,
|
||||
let typedPayload = BLENoisePayloadFactory.privateMessage(content: content, messageID: messageID) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let payload: Data
|
||||
do {
|
||||
let now = Date()
|
||||
let sealed = try noiseService.sealCourierPayload(typedPayload, recipientStaticKey: recipientNoiseKey)
|
||||
let envelope = CourierEnvelope(
|
||||
recipientTag: CourierEnvelope.recipientTag(
|
||||
noiseStaticKey: recipientNoiseKey,
|
||||
epochDay: CourierEnvelope.epochDay(for: now)
|
||||
),
|
||||
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
|
||||
ciphertext: sealed,
|
||||
copies: TransportConfig.courierInitialCopies
|
||||
)
|
||||
guard let encoded = envelope.encode() else { return false }
|
||||
payload = encoded
|
||||
} catch {
|
||||
SecureLogger.error("Failed to seal courier envelope: \(error)", category: .encryption)
|
||||
return false
|
||||
}
|
||||
|
||||
messageQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
for courier in connected {
|
||||
SecureLogger.debug("📦 Depositing courier envelope with \(courier.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
self.sendPacketDirected(self.makeCourierPacket(payload, to: courier), to: courier)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket {
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.courierEnvelope.rawValue,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: Data(hexString: peerID.id),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: messageTTL
|
||||
)
|
||||
// Signed so a courier can authenticate the depositor before carrying
|
||||
// mail under their quota. Handover to the recipient doesn't need the
|
||||
// packet signature — the inner Noise X seal authenticates the sender.
|
||||
return noiseService.signPacket(packet) ?? packet
|
||||
}
|
||||
|
||||
/// Handles both courier roles for an incoming envelope addressed to us:
|
||||
/// recipient (the rotating tag matches our static key → open and deliver)
|
||||
/// or courier (a trusted peer is depositing mail for someone else → store).
|
||||
private func handleCourierEnvelope(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
// Directed packets only; envelopes addressed elsewhere ride the
|
||||
// generic relay path untouched.
|
||||
guard packet.recipientID == myPeerIDData else { return }
|
||||
guard let envelope = CourierEnvelope.decode(packet.payload), !envelope.isExpired else { return }
|
||||
|
||||
let myKey = noiseService.getStaticPublicKeyData()
|
||||
if CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) {
|
||||
openCourierEnvelope(envelope)
|
||||
} else {
|
||||
acceptCourierDeposit(envelope, from: peerID, packet: packet)
|
||||
}
|
||||
}
|
||||
|
||||
private func openCourierEnvelope(_ envelope: CourierEnvelope) {
|
||||
do {
|
||||
let (typedPayload, senderStaticKey) = try noiseService.openCourierPayload(envelope.ciphertext)
|
||||
guard let typeRaw = typedPayload.first,
|
||||
let payloadType = NoisePayloadType(rawValue: typeRaw),
|
||||
payloadType == .privateMessage else {
|
||||
SecureLogger.warning("⚠️ Courier envelope carried unsupported payload type", category: .session)
|
||||
return
|
||||
}
|
||||
// Couriered mail arrives while the sender is absent, so the UI's
|
||||
// block check can't resolve their fingerprint from a live session.
|
||||
// Gate here, where the full static key is in hand.
|
||||
guard !identityManager.isBlocked(fingerprint: senderStaticKey.sha256Fingerprint()) else {
|
||||
SecureLogger.debug("🚫 Dropping courier envelope from blocked sender", category: .security)
|
||||
return
|
||||
}
|
||||
// A present sender resolves to their live mesh thread via the
|
||||
// derived short ID. An absent sender — the usual courier case —
|
||||
// uses the full noise-key ID so the message lands on the stable
|
||||
// favorite conversation instead of an unresolvable short-ID
|
||||
// thread labeled "Unknown".
|
||||
let shortID = PeerID(publicKey: senderStaticKey)
|
||||
let isKnownOnMesh = collectionsQueue.sync { peerRegistry.info(for: shortID) != nil }
|
||||
let senderPeerID = isKnownOnMesh ? shortID : PeerID(hexData: senderStaticKey)
|
||||
let payload = Data(typedPayload.dropFirst())
|
||||
SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))…", category: .session)
|
||||
sfMetrics?.record(.courierOpened)
|
||||
notifyUI { [weak self] in
|
||||
self?.deliverTransportEvent(.noisePayloadReceived(
|
||||
peerID: senderPeerID,
|
||||
type: payloadType,
|
||||
payload: payload,
|
||||
timestamp: Date()
|
||||
))
|
||||
}
|
||||
} catch {
|
||||
// Tag collision or stale key: not addressed to us after all.
|
||||
SecureLogger.debug("📦 Courier envelope failed to open: \(error)", category: .encryption)
|
||||
}
|
||||
}
|
||||
|
||||
private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID, packet: BitchatPacket) {
|
||||
// A deposit must come from its depositor over the direct link: the
|
||||
// claimed sender has to be the ingress peer, and the packet signature
|
||||
// has to verify against that peer's announced signing key. Otherwise
|
||||
// an untrusted sender could route an envelope through any trusted
|
||||
// neighbor and have us carry it under the neighbor's quota.
|
||||
guard PeerID(hexData: packet.senderID) == peerID else {
|
||||
SecureLogger.debug("📦 Courier deposit rejected: relayed envelope claims sender \(PeerID(hexData: packet.senderID).id.prefix(8))… but arrived from \(peerID.id.prefix(8))…", category: .security)
|
||||
return
|
||||
}
|
||||
let depositorInfo = collectionsQueue.sync { peerRegistry.info(for: peerID) }
|
||||
guard let depositorKey = depositorInfo?.noisePublicKey else {
|
||||
SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session)
|
||||
return
|
||||
}
|
||||
guard let signingKey = depositorInfo?.signingPublicKey,
|
||||
noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
|
||||
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (missing/invalid signature)", category: .security)
|
||||
return
|
||||
}
|
||||
let isVerifiedPeer = depositorInfo?.isVerifiedNickname ?? false
|
||||
let store = courierStore
|
||||
let policy = courierDepositPolicy
|
||||
let metrics = sfMetrics
|
||||
Task { @MainActor in
|
||||
guard let tier = policy(depositorKey, isVerifiedPeer) else {
|
||||
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (neither favorite nor verified)", category: .session)
|
||||
return
|
||||
}
|
||||
if store.deposit(envelope, from: depositorKey, tier: tier) {
|
||||
SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))… (\(tier.rawValue))", category: .session)
|
||||
metrics?.record(.courierAccepted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand over any carried envelopes addressed to a peer we just heard from.
|
||||
private func deliverCourierMail(to peerID: PeerID, noiseKey: Data) {
|
||||
let envelopes = courierStore.takeEnvelopes(for: noiseKey)
|
||||
guard !envelopes.isEmpty else { return }
|
||||
SecureLogger.debug("📦 Handing over \(envelopes.count) courier envelope(s) to \(peerID.id.prefix(8))…", category: .session)
|
||||
for envelope in envelopes {
|
||||
guard let payload = envelope.encode() else { continue }
|
||||
sendPacketDirected(makeCourierPacket(payload, to: peerID), to: peerID)
|
||||
sfMetrics?.record(.courierHandedOver)
|
||||
}
|
||||
}
|
||||
|
||||
/// Speculative handover toward a recipient heard only via a relayed
|
||||
/// announce: the envelope floods the mesh as a directed packet (relays
|
||||
/// treat it like a directed DM). Non-destructive — the carried copy stays
|
||||
/// until a direct handover or expiry, throttled per envelope so repeated
|
||||
/// announces don't re-flood.
|
||||
private func deliverCourierMailRemotely(to peerID: PeerID, noiseKey: Data) {
|
||||
let envelopes = courierStore.envelopesForRemoteHandover(
|
||||
recipientNoiseKey: noiseKey,
|
||||
cooldown: TransportConfig.courierRemoteHandoverCooldownSeconds
|
||||
)
|
||||
guard !envelopes.isEmpty else { return }
|
||||
SecureLogger.debug("📦 Remote handover: flooding \(envelopes.count) envelope(s) toward \(peerID.id.prefix(8))…", category: .session)
|
||||
for envelope in envelopes {
|
||||
guard let payload = envelope.encode() else { continue }
|
||||
broadcastPacket(makeCourierPacket(payload, to: peerID))
|
||||
sfMetrics?.record(.courierRemoteHandover)
|
||||
}
|
||||
}
|
||||
|
||||
/// Spray-and-wait: split copy budgets with another courier we just
|
||||
/// encountered, so carried mail diffuses through a moving crowd instead
|
||||
/// of riding a single carrier. Only favorites and verified peers qualify,
|
||||
/// mirroring the deposit policy they would apply to us.
|
||||
private func sprayCourierMail(to peerID: PeerID, noiseKey: Data, isVerifiedPeer: Bool) {
|
||||
let store = courierStore
|
||||
let metrics = sfMetrics
|
||||
let sendSpray: ([CourierEnvelope]) -> Void = { [weak self] envelopes in
|
||||
guard let self, !envelopes.isEmpty else { return }
|
||||
SecureLogger.debug("📦 Spraying \(envelopes.count) envelope copy(ies) to courier \(peerID.id.prefix(8))…", category: .session)
|
||||
for envelope in envelopes {
|
||||
guard let payload = envelope.encode() else { continue }
|
||||
self.sendPacketDirected(self.makeCourierPacket(payload, to: peerID), to: peerID)
|
||||
metrics?.record(.courierSprayed)
|
||||
}
|
||||
}
|
||||
let policy = courierDepositPolicy
|
||||
Task { @MainActor in
|
||||
// Same trust gate as deposits: don't hand mail to a peer who
|
||||
// would reject it from us.
|
||||
guard policy(noiseKey, isVerifiedPeer) != nil else { return }
|
||||
sendSpray(store.takeSprayCopies(for: noiseKey))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Link capability snapshots (thread-safe via bleQueue)
|
||||
|
||||
private func readLinkState<T>(_ body: (BLELinkStateStore) -> T) -> T {
|
||||
@@ -2592,6 +3001,9 @@ extension BLEService {
|
||||
centralManager?.stopScan()
|
||||
startScanning()
|
||||
}
|
||||
// Backgrounding may precede a kill; flush the public-history archive
|
||||
// outside its 30s maintenance cadence.
|
||||
gossipSyncManager?.persistNow()
|
||||
logBluetoothStatus("entered-background")
|
||||
scheduleBluetoothStatusSample(after: 15.0, context: "background-15s")
|
||||
// No Local Name; nothing to refresh for advertising policy
|
||||
@@ -2601,8 +3013,11 @@ extension BLEService {
|
||||
// MARK: Private Message Handling
|
||||
|
||||
private func sendPrivateMessage(_ content: String, to recipientID: PeerID, messageID: String) {
|
||||
// Sessions and wire recipient IDs are keyed by the short 16-hex form;
|
||||
// callers may pass the full 64-hex noise key (mirrors sendFilePrivate).
|
||||
let recipientID = recipientID.toShort()
|
||||
SecureLogger.debug("📨 Sending PM to \(recipientID.id.prefix(8))… id=\(messageID.prefix(8))… chars=\(content.count) bytes=\(content.utf8.count)", category: .session)
|
||||
|
||||
|
||||
// Check if we have an established Noise session
|
||||
if noiseService.hasEstablishedSession(with: recipientID) {
|
||||
// Encrypt and send
|
||||
@@ -2953,7 +3368,10 @@ extension BLEService {
|
||||
|
||||
case .fileTransfer:
|
||||
handleFileTransfer(packet, from: senderID)
|
||||
|
||||
|
||||
case .courierEnvelope:
|
||||
handleCourierEnvelope(packet, from: peerID)
|
||||
|
||||
case .leave:
|
||||
handleLeave(packet, from: senderID)
|
||||
|
||||
@@ -3015,7 +3433,25 @@ extension BLEService {
|
||||
}
|
||||
|
||||
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
announceHandler.handle(packet, from: peerID)
|
||||
let result = announceHandler.handle(packet, from: peerID)
|
||||
|
||||
// Courier work: an announce is the moment we learn a peer's Noise
|
||||
// static key, so check whether we're carrying mail addressed to them
|
||||
// (or spray-able mail they could carry). Verified announces only.
|
||||
guard !courierStore.isEmpty,
|
||||
let result,
|
||||
result.isVerified else { return }
|
||||
let noiseKey = result.announcement.noisePublicKey
|
||||
if result.isDirectAnnounce {
|
||||
// Established link: destructive handover is safe, and the peer is
|
||||
// close enough to become a courier for other carried mail.
|
||||
deliverCourierMail(to: result.peerID, noiseKey: noiseKey)
|
||||
sprayCourierMail(to: result.peerID, noiseKey: noiseKey, isVerifiedPeer: true)
|
||||
} else {
|
||||
// Relayed announce: recipient is multi-hop away. Push a copy
|
||||
// toward them speculatively; the carried copy stays put.
|
||||
deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the announce handler environment. All queue hops stay here so
|
||||
@@ -3048,7 +3484,8 @@ extension BLEService {
|
||||
noisePublicKey: announcement.noisePublicKey,
|
||||
signingPublicKey: announcement.signingPublicKey,
|
||||
isConnected: isConnected,
|
||||
now: now
|
||||
now: now,
|
||||
capabilities: announcement.capabilities ?? []
|
||||
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||
},
|
||||
shouldEmitReconnectLog: { [weak self] peerID, now in
|
||||
@@ -3109,6 +3546,26 @@ extension BLEService {
|
||||
|
||||
// Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager
|
||||
private func handleRequestSync(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
// REQUEST_SYNC is link-local by design (always sent with ttl 0): a
|
||||
// nonzero TTL means a crafted or relayed request, and answering one
|
||||
// would let a single small packet fan a full store replay out of
|
||||
// every node it reaches.
|
||||
guard packet.ttl == 0 else {
|
||||
if logRateLimiter.shouldLog(key: "sync-ttl:\(peerID.id)") {
|
||||
SecureLogger.warning("🚫 Dropping REQUEST_SYNC with nonzero TTL from \(peerID.id.prefix(8))…", category: .security)
|
||||
}
|
||||
return
|
||||
}
|
||||
// A response can replay the entire gossip store, so require proof the
|
||||
// requester owns the claimed sender ID: the request must verify
|
||||
// against the signing key from that peer's announce.
|
||||
let signingKey = collectionsQueue.sync { peerRegistry.info(for: peerID)?.signingPublicKey }
|
||||
guard let signingKey, noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
|
||||
if logRateLimiter.shouldLog(key: "sync-sig:\(peerID.id)") {
|
||||
SecureLogger.warning("🚫 Dropping REQUEST_SYNC without verifiable signature from \(peerID.id.prefix(8))…", category: .security)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard let req = RequestSyncPacket.decode(from: packet.payload) else {
|
||||
SecureLogger.warning("⚠️ Malformed REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .session)
|
||||
return
|
||||
@@ -3215,8 +3672,21 @@ extension BLEService {
|
||||
self?.noiseService.clearSession(for: peerID)
|
||||
},
|
||||
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
|
||||
guard let self = self else { return }
|
||||
// Wi-Fi bulk negotiation is a transport concern; consume it
|
||||
// here instead of surfacing it to the UI layer.
|
||||
switch type {
|
||||
case .bulkTransferOffer:
|
||||
self.wifiBulkService.handleOfferPayload(payload, from: peerID)
|
||||
return
|
||||
case .bulkTransferResponse:
|
||||
self.wifiBulkService.handleResponsePayload(payload, from: peerID)
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
// Single main-actor hop delivering `.noisePayloadReceived`.
|
||||
self?.notifyUI { [weak self] in
|
||||
self.notifyUI { [weak self] in
|
||||
self?.deliverTransportEvent(.noisePayloadReceived(
|
||||
peerID: peerID,
|
||||
type: type,
|
||||
|
||||
@@ -51,8 +51,9 @@ protocol CommandContextProvider: AnyObject {
|
||||
func addPublicSystemMessage(_ content: String)
|
||||
|
||||
// MARK: - Favorites
|
||||
/// Toggles the favorite via the unified peer flow, which persists by the
|
||||
/// real noise key and notifies the peer over mesh or Nostr.
|
||||
func toggleFavorite(peerID: PeerID)
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||
}
|
||||
|
||||
/// Processes chat commands in a focused, efficient way
|
||||
@@ -335,34 +336,31 @@ final class CommandProcessor {
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
|
||||
}
|
||||
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let noisePublicKey = Data(hexString: peerID.id) else {
|
||||
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
|
||||
return .error(message: "can't find peer: \(nickname)")
|
||||
}
|
||||
|
||||
if add {
|
||||
let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: noisePublicKey,
|
||||
peerNostrPublicKey: existingFavorite?.peerNostrPublicKey,
|
||||
peerNickname: nickname
|
||||
)
|
||||
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true)
|
||||
|
||||
return .success(message: "added \(nickname) to favorites")
|
||||
|
||||
// Resolve current state by the peer's real noise key. The resolved
|
||||
// peerID is either the short 16-hex mesh ID or the full 64-hex
|
||||
// noise-key ID (offline favorite row) — never the noise key itself.
|
||||
let isCurrentlyFavorite: Bool
|
||||
if let noiseKey = peerID.noiseKey {
|
||||
isCurrentlyFavorite = FavoritesPersistenceService.shared.isFavorite(noiseKey)
|
||||
} else {
|
||||
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
||||
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false)
|
||||
|
||||
return .success(message: "removed \(nickname) from favorites")
|
||||
isCurrentlyFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.isFavorite ?? false
|
||||
}
|
||||
|
||||
guard add != isCurrentlyFavorite else {
|
||||
return .success(message: add ? "\(nickname) is already a favorite" : "\(nickname) is not a favorite")
|
||||
}
|
||||
|
||||
// toggleFavorite persists by the real noise key and notifies the peer.
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
|
||||
return .success(message: add ? "added \(nickname) to favorites" : "removed \(nickname) from favorites")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
//
|
||||
// CourierStore.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 Combine
|
||||
import Foundation
|
||||
|
||||
/// Trust level of a courier deposit, decided by the caller's policy.
|
||||
/// Favorites get the larger quota and are never evicted to make room for
|
||||
/// verified-tier mail; verified (signature-verified announce, not a mutual
|
||||
/// favorite) get a small quota so a crowd of strangers can still carry mail.
|
||||
enum CourierDepositTier: String, Codable {
|
||||
case favorite
|
||||
case verified
|
||||
}
|
||||
|
||||
/// Holds courier envelopes this device is carrying for offline third parties.
|
||||
///
|
||||
/// Envelopes are opaque ciphertext; this store never learns sender,
|
||||
/// recipient, or content. Strict quotas keep the device from becoming a
|
||||
/// public mailbag: bounded count, bounded per-depositor count by trust tier,
|
||||
/// bounded size, and a 24-hour lifetime aligned with the outbox retention
|
||||
/// policy. Carried mail is included in the panic wipe.
|
||||
final class CourierStore {
|
||||
struct StoredEnvelope: Codable, Equatable {
|
||||
let recipientTag: Data
|
||||
let expiry: UInt64
|
||||
let ciphertext: Data
|
||||
let depositorNoiseKey: Data
|
||||
let storedAt: Date
|
||||
var tier: CourierDepositTier
|
||||
/// Remaining spray-and-wait budget (1 = carry-only).
|
||||
var copies: UInt8
|
||||
/// Couriers this envelope was already sprayed to, so a repeat announce
|
||||
/// from the same peer doesn't burn budget on a copy they already hold.
|
||||
var sprayedTo: Set<Data>
|
||||
/// Last speculative multi-hop handover toward a relayed announce.
|
||||
var lastRemoteHandoverAt: Date?
|
||||
|
||||
var envelope: CourierEnvelope {
|
||||
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 {
|
||||
static let maxEnvelopes = 40
|
||||
/// Verified-tier mail can never crowd out favorites' share.
|
||||
static let maxVerifiedEnvelopes = 20
|
||||
static let maxPerFavoriteDepositor = 5
|
||||
static let maxPerVerifiedDepositor = 2
|
||||
/// Slack on top of the 24h lifetime for depositor clock skew.
|
||||
static let maxExpirySlack: TimeInterval = 60 * 60
|
||||
}
|
||||
|
||||
static let shared = CourierStore()
|
||||
|
||||
/// Number of envelopes currently carried, published on the main thread
|
||||
/// so the UI can show a "carrying mail" indicator.
|
||||
@Published private(set) var carriedCount: Int = 0
|
||||
|
||||
/// Fast path so hot code (announce handling) can skip tag computation.
|
||||
var isEmpty: Bool {
|
||||
queue.sync { envelopes.isEmpty }
|
||||
}
|
||||
|
||||
private var envelopes: [StoredEnvelope] = []
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.courier.store")
|
||||
private let fileURL: URL?
|
||||
private let now: () -> Date
|
||||
|
||||
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
|
||||
/// when `persistsToDisk` is false.
|
||||
init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) {
|
||||
self.now = now
|
||||
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
// MARK: - Depositing (courier side)
|
||||
|
||||
/// Accept an envelope from a depositor. Returns false when quotas or
|
||||
/// validity checks reject it. Trust policy (which tier a depositor gets,
|
||||
/// if any) is the caller's responsibility; this store only enforces
|
||||
/// resource bounds.
|
||||
@discardableResult
|
||||
func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data, tier: CourierDepositTier = .favorite) -> Bool {
|
||||
let date = now()
|
||||
guard envelope.recipientTag.count == CourierEnvelope.tagLength,
|
||||
!envelope.ciphertext.isEmpty,
|
||||
envelope.ciphertext.count <= CourierEnvelope.maxCiphertextBytes,
|
||||
!envelope.isExpired(at: date) else {
|
||||
return false
|
||||
}
|
||||
// Reject expiries beyond the policy lifetime so depositors can't pin
|
||||
// storage longer than the outbox would retain the message itself.
|
||||
let maxExpiry = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + Limits.maxExpirySlack)
|
||||
guard envelope.expiry <= UInt64(maxExpiry.timeIntervalSince1970 * 1000) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
|
||||
// Identical ciphertext is the same envelope; accept idempotently,
|
||||
// keeping the larger spray budget (bounded by maxCopies either way).
|
||||
if let existing = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) {
|
||||
envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies)
|
||||
persistLocked()
|
||||
return true
|
||||
}
|
||||
|
||||
let perDepositorLimit = tier == .favorite ? Limits.maxPerFavoriteDepositor : Limits.maxPerVerifiedDepositor
|
||||
guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < perDepositorLimit else {
|
||||
SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached (\(tier.rawValue))", category: .session)
|
||||
return false
|
||||
}
|
||||
if tier == .verified,
|
||||
envelopes.filter({ $0.tier == .verified }).count >= Limits.maxVerifiedEnvelopes {
|
||||
SecureLogger.debug("📦 Courier deposit rejected: verified-tier pool full", category: .session)
|
||||
return false
|
||||
}
|
||||
if envelopes.count >= Limits.maxEnvelopes {
|
||||
// Oldest-first eviction, shedding verified-tier mail before
|
||||
// favorites' so open couriering can't crowd out trusted mail.
|
||||
// A verified deposit never displaces a favorite: when only
|
||||
// favorite mail is stored, it is rejected instead.
|
||||
if let victim = envelopes.firstIndex(where: { $0.tier == .verified }) {
|
||||
let evicted = envelopes.remove(at: victim)
|
||||
SecureLogger.debug("📦 Courier store full - evicted verified envelope stored at \(evicted.storedAt)", category: .session)
|
||||
} else if tier == .favorite {
|
||||
let evicted = envelopes.removeFirst()
|
||||
SecureLogger.debug("📦 Courier store full - evicted favorite envelope stored at \(evicted.storedAt)", category: .session)
|
||||
} else {
|
||||
SecureLogger.debug("📦 Courier deposit rejected: store full of favorite-tier mail", category: .session)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
envelopes.append(StoredEnvelope(
|
||||
recipientTag: envelope.recipientTag,
|
||||
expiry: envelope.expiry,
|
||||
ciphertext: envelope.ciphertext,
|
||||
depositorNoiseKey: depositorNoiseKey,
|
||||
storedAt: date,
|
||||
tier: tier,
|
||||
copies: envelope.copies
|
||||
))
|
||||
persistLocked()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handover (on encountering a peer)
|
||||
|
||||
/// Remove and return all envelopes addressed to the given peer, matching
|
||||
/// the rotating recipient tag across adjacent days. Envelopes are removed
|
||||
/// optimistically: handover happens over a live link, and the depositor's
|
||||
/// outbox still retains the original for direct delivery.
|
||||
func takeEnvelopes(for noiseStaticKey: Data) -> [CourierEnvelope] {
|
||||
let date = now()
|
||||
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date)
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
let matched = envelopes.filter { candidates.contains($0.recipientTag) }
|
||||
guard !matched.isEmpty else { return [] }
|
||||
envelopes.removeAll { stored in matched.contains(stored) }
|
||||
persistLocked()
|
||||
return matched.map(\.envelope)
|
||||
}
|
||||
}
|
||||
|
||||
/// Envelopes addressed to a recipient we heard from via a *relayed*
|
||||
/// announce. Non-destructive: a multi-hop send is speculative, so the
|
||||
/// envelope stays carried until a direct handover or expiry. The per-
|
||||
/// envelope cooldown keeps repeated announces from re-flooding the mesh.
|
||||
func envelopesForRemoteHandover(recipientNoiseKey: Data, cooldown: TimeInterval) -> [CourierEnvelope] {
|
||||
let date = now()
|
||||
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: recipientNoiseKey, around: date)
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
var matched: [CourierEnvelope] = []
|
||||
for index in envelopes.indices where candidates.contains(envelopes[index].recipientTag) {
|
||||
if let last = envelopes[index].lastRemoteHandoverAt,
|
||||
date.timeIntervalSince(last) < cooldown {
|
||||
continue
|
||||
}
|
||||
envelopes[index].lastRemoteHandoverAt = date
|
||||
// The delivered copy carries no spray budget.
|
||||
matched.append(envelopes[index].envelope.withCopies(1))
|
||||
}
|
||||
if !matched.isEmpty { persistLocked() }
|
||||
return matched
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Spray-and-wait (on encountering another courier)
|
||||
|
||||
/// Envelopes to re-deposit with a courier we just encountered, each with
|
||||
/// half its remaining budget (binary spray). Skips envelopes the courier
|
||||
/// deposited, envelopes addressed to them (those ride the handover path),
|
||||
/// carry-only envelopes, and couriers already sprayed.
|
||||
func takeSprayCopies(for courierNoiseKey: Data) -> [CourierEnvelope] {
|
||||
let date = now()
|
||||
let courierTags = CourierEnvelope.candidateTags(noiseStaticKey: courierNoiseKey, around: date)
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
var sprayed: [CourierEnvelope] = []
|
||||
for index in envelopes.indices {
|
||||
let stored = envelopes[index]
|
||||
guard stored.copies > 1,
|
||||
stored.depositorNoiseKey != courierNoiseKey,
|
||||
!stored.sprayedTo.contains(courierNoiseKey),
|
||||
!courierTags.contains(stored.recipientTag) else { continue }
|
||||
let given = stored.copies / 2
|
||||
envelopes[index].copies = stored.copies - given
|
||||
envelopes[index].sprayedTo.insert(courierNoiseKey)
|
||||
sprayed.append(stored.envelope.withCopies(given))
|
||||
}
|
||||
if !sprayed.isEmpty { persistLocked() }
|
||||
return sprayed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Maintenance
|
||||
|
||||
func pruneExpired() {
|
||||
let date = now()
|
||||
queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
persistLocked()
|
||||
}
|
||||
}
|
||||
|
||||
/// Panic wipe: drop all carried mail from memory and disk.
|
||||
func wipe() {
|
||||
queue.sync {
|
||||
envelopes.removeAll()
|
||||
if let fileURL {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
publishCountLocked()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals (call only on `queue`)
|
||||
|
||||
private func pruneExpiredLocked(at date: Date) {
|
||||
let before = envelopes.count
|
||||
envelopes.removeAll { $0.envelope.isExpired(at: date) }
|
||||
if envelopes.count != before {
|
||||
SecureLogger.debug("📦 Courier store pruned \(before - envelopes.count) expired envelope(s)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
private func publishCountLocked() {
|
||||
let count = envelopes.count
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.carriedCount = count
|
||||
}
|
||||
}
|
||||
|
||||
private func persistLocked() {
|
||||
publishCountLocked()
|
||||
guard let fileURL else { return }
|
||||
do {
|
||||
if envelopes.isEmpty {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
return
|
||||
}
|
||||
try FileManager.default.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let data = try JSONEncoder().encode(envelopes)
|
||||
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 courier store: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFromDisk() {
|
||||
guard let fileURL else { return }
|
||||
queue.sync {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let stored = try? JSONDecoder().decode([StoredEnvelope].self, from: data) else {
|
||||
return
|
||||
}
|
||||
envelopes = stored
|
||||
pruneExpiredLocked(at: now())
|
||||
publishCountLocked()
|
||||
}
|
||||
}
|
||||
|
||||
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("envelopes.json")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,13 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
peerNostrPublicKey: String? = nil
|
||||
) {
|
||||
let existing = favorites[peerNoisePublicKey]
|
||||
let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown"
|
||||
// Callers that can't resolve the live nickname pass the "Unknown"
|
||||
// placeholder (e.g. a notification arriving before the announce);
|
||||
// never let it clobber a real stored nickname.
|
||||
let incoming = peerNickname.flatMap { name in
|
||||
(name.isEmpty || name == "Unknown") ? nil : name
|
||||
}
|
||||
let displayName = incoming ?? existing?.peerNickname ?? "Unknown"
|
||||
|
||||
SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
|
||||
|
||||
|
||||
@@ -2,11 +2,43 @@ import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Trust and identity lookups the router needs to pick couriers. Backed by
|
||||
/// the favorites store in production; injectable for tests.
|
||||
struct CourierDirectory {
|
||||
/// Noise static key for a peer we can address while they're offline.
|
||||
var noiseKey: (PeerID) -> Data?
|
||||
/// Whether a peer (by Noise static key) is a mutual favorite — the
|
||||
/// preferred courier tier. Verified non-favorites are the fallback tier,
|
||||
/// read off the transport snapshot.
|
||||
var isTrustedCourier: (Data) -> Bool
|
||||
|
||||
@MainActor
|
||||
static func favoritesBacked() -> CourierDirectory {
|
||||
CourierDirectory(
|
||||
noiseKey: { peerID in
|
||||
// Offline favorites are addressed by the full 64-hex
|
||||
// noise-key ID, which carries the key itself; the favorites
|
||||
// lookup only resolves short 16-hex IDs.
|
||||
peerID.noiseKey
|
||||
?? FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNoisePublicKey
|
||||
},
|
||||
isTrustedCourier: { noiseKey in
|
||||
FavoritesPersistenceService.shared.isMutualFavorite(noiseKey)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes messages using available transports (Mesh, Nostr, etc.)
|
||||
@MainActor
|
||||
final class MessageRouter {
|
||||
typealias QueuedMessage = MessageOutboxStore.QueuedMessage
|
||||
|
||||
private let transports: [Transport]
|
||||
private let now: () -> Date
|
||||
private let courierDirectory: CourierDirectory
|
||||
private let outboxStore: MessageOutboxStore?
|
||||
private let metrics: StoreAndForwardMetrics?
|
||||
|
||||
/// Invoked whenever a retained private message is dropped without a
|
||||
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
|
||||
@@ -14,14 +46,11 @@ final class MessageRouter {
|
||||
/// stale "sending/sent" state forever.
|
||||
var onMessageDropped: ((_ 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
|
||||
}
|
||||
/// Invoked when a message with no reachable transport was handed to at
|
||||
/// least one courier (a connected peer who will physically carry the
|
||||
/// sealed envelope). Delivery stays best-effort: the outbox retains the
|
||||
/// message until an ack arrives.
|
||||
var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)?
|
||||
|
||||
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
||||
|
||||
@@ -31,10 +60,22 @@ final class MessageRouter {
|
||||
// Bound resends of messages sent on a weak reachability signal that never
|
||||
// get a delivery ack (e.g. peer on an old client that doesn't ack).
|
||||
private static let maxSendAttempts = 8
|
||||
// Redundant couriers improve delivery odds; receivers dedup by message ID.
|
||||
private static let maxCouriersPerMessage = 3
|
||||
|
||||
init(transports: [Transport], now: @escaping () -> Date = Date.init) {
|
||||
init(
|
||||
transports: [Transport],
|
||||
now: @escaping () -> Date = Date.init,
|
||||
courierDirectory: CourierDirectory? = nil,
|
||||
outboxStore: MessageOutboxStore? = nil,
|
||||
metrics: StoreAndForwardMetrics? = nil
|
||||
) {
|
||||
self.transports = transports
|
||||
self.now = now
|
||||
self.courierDirectory = courierDirectory ?? .favoritesBacked()
|
||||
self.outboxStore = outboxStore
|
||||
self.metrics = metrics
|
||||
self.outbox = outboxStore?.load() ?? [:]
|
||||
|
||||
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
||||
NotificationCenter.default.addObserver(
|
||||
@@ -89,20 +130,143 @@ final class MessageRouter {
|
||||
SecureLogger.debug("Routing PM via \(type(of: transport)) (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
enqueue(message, for: peerID)
|
||||
// "Reachable" without prompt delivery means the send only joined
|
||||
// a queue (Nostr with relays down): also hand a sealed copy to
|
||||
// any connected couriers rather than waiting for internet that
|
||||
// may never come. Double delivery is harmless — receivers dedup
|
||||
// by message ID, and delivered/read acks never downgrade.
|
||||
if !transport.canDeliverPromptly(to: peerID) {
|
||||
attemptCourierDeposit(messageID: messageID, for: peerID)
|
||||
}
|
||||
} else {
|
||||
var unsent = message
|
||||
unsent.sendAttempts = 0
|
||||
enqueue(unsent, for: peerID)
|
||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
|
||||
attemptCourierDeposit(messageID: messageID, for: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Couriers
|
||||
|
||||
/// Last resort when no transport can deliver promptly — the peer is
|
||||
/// unreachable, or only reachable through a send queue waiting on
|
||||
/// internet: seal the message to their known static key and hand it to
|
||||
/// connected couriers who may physically encounter them. Mutual favorites
|
||||
/// are preferred; signature-verified strangers fill remaining slots so a
|
||||
/// crowd without favorites can still carry mail (envelopes are opaque
|
||||
/// either way). The queued copy stays retained, so direct delivery still
|
||||
/// wins if the peer reappears first (receivers dedup by message ID).
|
||||
private func attemptCourierDeposit(messageID: String, for peerID: PeerID) {
|
||||
guard let recipientKey = courierDirectory.noiseKey(peerID),
|
||||
let entry = queuedMessage(messageID, for: peerID) else { return }
|
||||
let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count
|
||||
guard remainingSlots > 0 else { return }
|
||||
|
||||
for transport in transports {
|
||||
let couriers = eligibleCouriers(
|
||||
on: transport,
|
||||
recipientKey: recipientKey,
|
||||
excluding: entry.depositedCourierKeys,
|
||||
limit: remainingSlots
|
||||
)
|
||||
guard !couriers.isEmpty else { continue }
|
||||
if transport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) {
|
||||
SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))…", category: .session)
|
||||
recordCourierDeposit(messageID: messageID, for: peerID, courierKeys: couriers.map(\.noiseKey))
|
||||
onMessageCarried?(messageID, peerID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A courier candidate just connected: hand them any queued mail they are
|
||||
/// not already carrying. This is what turns couriering from "a favorite
|
||||
/// happened to be around at send time" into eventual spread — deposits
|
||||
/// retry as eligible peers appear, until each message rides with
|
||||
/// `maxCouriersPerMessage` distinct couriers or expires.
|
||||
func courierBecameAvailable(_ peerID: PeerID) {
|
||||
for transport in transports {
|
||||
guard transport.isPeerConnected(peerID),
|
||||
let snapshot = transport.currentPeerSnapshots().first(where: { $0.peerID == peerID && $0.isConnected }),
|
||||
let courierKey = snapshot.noisePublicKey,
|
||||
courierDirectory.isTrustedCourier(courierKey) || snapshot.isVerified else { continue }
|
||||
|
||||
let currentDate = now()
|
||||
for (recipient, queue) in outbox {
|
||||
// Mail *to* this peer flushes directly on connect.
|
||||
guard recipient != peerID,
|
||||
let recipientKey = courierDirectory.noiseKey(recipient),
|
||||
recipientKey != courierKey else { continue }
|
||||
for message in queue {
|
||||
guard message.depositedCourierKeys.count < Self.maxCouriersPerMessage,
|
||||
!message.depositedCourierKeys.contains(courierKey),
|
||||
currentDate.timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds else { continue }
|
||||
if transport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) {
|
||||
SecureLogger.debug("📦 Deposit retry: PM \(message.messageID.prefix(8))… handed to \(peerID.id.prefix(8))… for \(recipient.id.prefix(8))…", category: .session)
|
||||
recordCourierDeposit(messageID: message.messageID, for: recipient, courierKeys: [courierKey])
|
||||
onMessageCarried?(message.messageID, recipient)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private struct CourierCandidate {
|
||||
let peerID: PeerID
|
||||
let noiseKey: Data
|
||||
}
|
||||
|
||||
private func eligibleCouriers(
|
||||
on transport: Transport,
|
||||
recipientKey: Data,
|
||||
excluding excludedKeys: Set<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.
|
||||
func markDelivered(_ messageID: String) {
|
||||
var cleared = false
|
||||
for (peerID, queue) in outbox {
|
||||
let filtered = queue.filter { $0.messageID != messageID }
|
||||
guard filtered.count != queue.count else { continue }
|
||||
outbox[peerID] = filtered.isEmpty ? nil : filtered
|
||||
cleared = true
|
||||
}
|
||||
if cleared {
|
||||
metrics?.record(.outboxDelivered)
|
||||
persistOutbox()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,9 +280,26 @@ final class MessageRouter {
|
||||
if queue.count > Self.maxMessagesPerPeer {
|
||||
let evicted = queue.removeFirst()
|
||||
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted.messageID.prefix(8))…", category: .session)
|
||||
onMessageDropped?(evicted.messageID, peerID)
|
||||
dropMessage(evicted.messageID, for: peerID)
|
||||
}
|
||||
outbox[peerID] = queue
|
||||
metrics?.record(.outboxQueued)
|
||||
persistOutbox()
|
||||
}
|
||||
|
||||
private func dropMessage(_ messageID: String, for peerID: PeerID) {
|
||||
metrics?.record(.outboxDropped)
|
||||
onMessageDropped?(messageID, peerID)
|
||||
}
|
||||
|
||||
private func persistOutbox() {
|
||||
outboxStore?.save(outbox)
|
||||
}
|
||||
|
||||
/// Panic wipe: forget queued mail on disk and in memory.
|
||||
func wipeOutbox() {
|
||||
outbox.removeAll()
|
||||
outboxStore?.wipe()
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
@@ -145,8 +326,6 @@ final class MessageRouter {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Outbox Management
|
||||
|
||||
func flushOutbox(for peerID: PeerID) {
|
||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||
@@ -158,7 +337,7 @@ final class MessageRouter {
|
||||
// Skip expired messages (TTL exceeded)
|
||||
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
||||
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
|
||||
onMessageDropped?(message.messageID, peerID)
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -166,16 +345,18 @@ final class MessageRouter {
|
||||
// Live link: send and stop retaining.
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||
metrics?.record(.outboxResent)
|
||||
} else if let transport = reachableTransport(for: peerID) {
|
||||
// Weak signal: send but keep retaining until an ack clears it,
|
||||
// bounded by attempt count for peers that never ack.
|
||||
guard message.sendAttempts < Self.maxSendAttempts else {
|
||||
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
|
||||
onMessageDropped?(message.messageID, peerID)
|
||||
dropMessage(message.messageID, for: peerID)
|
||||
continue
|
||||
}
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||
metrics?.record(.outboxResent)
|
||||
var retained = message
|
||||
retained.sendAttempts += 1
|
||||
remaining.append(retained)
|
||||
@@ -189,6 +370,7 @@ final class MessageRouter {
|
||||
} else {
|
||||
outbox[peerID] = remaining
|
||||
}
|
||||
persistOutbox()
|
||||
}
|
||||
|
||||
func flushAllOutbox() {
|
||||
@@ -198,6 +380,7 @@ final class MessageRouter {
|
||||
/// Periodically clean up expired messages from all outboxes
|
||||
func cleanupExpiredMessages() {
|
||||
let now = now()
|
||||
var droppedAny = false
|
||||
for peerID in Array(outbox.keys) {
|
||||
var expiredMessageIDs: [String] = []
|
||||
outbox[peerID]?.removeAll { message in
|
||||
@@ -210,8 +393,12 @@ final class MessageRouter {
|
||||
}
|
||||
for messageID in expiredMessageIDs {
|
||||
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
onMessageDropped?(messageID, peerID)
|
||||
dropMessage(messageID, for: peerID)
|
||||
droppedAny = true
|
||||
}
|
||||
}
|
||||
if droppedAny {
|
||||
persistOutbox()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,6 +369,49 @@ final class NoiseEncryptionService {
|
||||
func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
|
||||
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
|
||||
}
|
||||
|
||||
// MARK: - Courier Envelopes (one-way Noise X)
|
||||
|
||||
/// Domain separation for courier envelopes so X-pattern transcripts can
|
||||
/// never be confused with interactive XX handshakes.
|
||||
private static let courierPrologue = Data("bitchat-courier-v1".utf8)
|
||||
|
||||
/// Encrypt a payload to a peer's known static key without an interactive
|
||||
/// handshake (Noise X pattern). Used for store-and-forward envelopes
|
||||
/// carried by couriers while the recipient is offline.
|
||||
/// - Warning: One-way messages have no forward secrecy: a later compromise
|
||||
/// of the recipient's static key exposes envelopes captured in transit.
|
||||
/// Use established sessions whenever the peer is reachable.
|
||||
func sealCourierPayload(_ payload: Data, recipientStaticKey: Data) throws -> Data {
|
||||
let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientStaticKey)
|
||||
let handshake = NoiseHandshakeState(
|
||||
role: .initiator,
|
||||
pattern: .X,
|
||||
keychain: keychain,
|
||||
localStaticKey: staticIdentityKey,
|
||||
remoteStaticKey: remoteKey,
|
||||
prologue: Self.courierPrologue
|
||||
)
|
||||
return try handshake.writeMessage(payload: payload)
|
||||
}
|
||||
|
||||
/// Decrypt a courier envelope addressed to our static key. Returns the
|
||||
/// payload and the sender's authenticated static public key (the `ss`
|
||||
/// DH in the X pattern binds the sender's identity to the ciphertext).
|
||||
func openCourierPayload(_ envelopeCiphertext: Data) throws -> (payload: Data, senderStaticKey: Data) {
|
||||
let handshake = NoiseHandshakeState(
|
||||
role: .responder,
|
||||
pattern: .X,
|
||||
keychain: keychain,
|
||||
localStaticKey: staticIdentityKey,
|
||||
prologue: Self.courierPrologue
|
||||
)
|
||||
let payload = try handshake.readMessage(envelopeCiphertext)
|
||||
guard let senderKey = handshake.getRemoteStaticPublicKey() else {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
return (payload: payload, senderStaticKey: senderKey.rawRepresentation)
|
||||
}
|
||||
|
||||
/// Clear persistent identity (for panic mode)
|
||||
func clearPersistentIdentity() {
|
||||
|
||||
@@ -14,7 +14,13 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
let registerPendingGiftWrap: @MainActor (String) -> Void
|
||||
let sendEvent: @MainActor (NostrEvent) -> Void
|
||||
let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
|
||||
/// Emits whether a relay that carries private messages is up
|
||||
/// (fail-closed behind Tor). A connected geohash/custom relay alone
|
||||
/// doesn't count: DM sends target the default relay set and would
|
||||
/// still queue.
|
||||
let relayConnectivity: @MainActor () -> AnyPublisher<Bool, Never>
|
||||
|
||||
@MainActor
|
||||
static func live(idBridge: NostrIdentityBridge) -> Dependencies {
|
||||
Dependencies(
|
||||
notificationCenter: .default,
|
||||
@@ -26,7 +32,8 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
||||
scheduleAfter: { delay, action in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||
}
|
||||
},
|
||||
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -49,6 +56,10 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
|
||||
// Reachability Cache (thread-safe)
|
||||
private var reachablePeers: Set<PeerID> = []
|
||||
// Mirror of the relay manager's connection state, cached here because
|
||||
// canDeliverPromptly is called synchronously off the main actor.
|
||||
private var relaysConnected = false
|
||||
private var relayConnectivityCancellable: AnyCancellable?
|
||||
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
|
||||
|
||||
@MainActor
|
||||
@@ -72,6 +83,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.reachablePeers = Set(reachable)
|
||||
}
|
||||
|
||||
relayConnectivityCancellable = self.dependencies.relayConnectivity()
|
||||
.sink { [weak self] connected in
|
||||
guard let self else { return }
|
||||
self.queue.async(flags: .barrier) { self.relaysConnected = connected }
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -125,16 +142,22 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
||||
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
||||
queue.sync {
|
||||
// Check if exact match
|
||||
// Callers address peers by either the short 16-hex ID or the full
|
||||
// 64-hex noise key (offline favorites), so compare in short form.
|
||||
let short = peerID.toShort()
|
||||
return queue.sync {
|
||||
if reachablePeers.contains(peerID) { return true }
|
||||
// Check for short ID match
|
||||
if peerID.isShort {
|
||||
return reachablePeers.contains(where: { $0.toShort() == peerID })
|
||||
}
|
||||
return false
|
||||
return reachablePeers.contains(where: { $0.toShort() == short })
|
||||
}
|
||||
}
|
||||
|
||||
func canDeliverPromptly(to peerID: PeerID) -> Bool {
|
||||
// A known npub makes a peer "reachable", but with no relay
|
||||
// connection a send only joins the local queue. Answering honestly
|
||||
// here lets the router hand a sealed copy to a courier in parallel
|
||||
// instead of waiting for internet that may never come.
|
||||
isPeerReachable(peerID) && queue.sync { relaysConnected }
|
||||
}
|
||||
|
||||
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||
func getPeerNicknames() -> [PeerID: String] { [:] }
|
||||
|
||||
@@ -209,7 +209,7 @@ final class PrivateChatManager: ObservableObject {
|
||||
case .read, .delivered:
|
||||
externalReceipts.insert(message.id)
|
||||
sentReadReceipts.insert(message.id)
|
||||
case .failed, .partiallyDelivered, .sending, .sent:
|
||||
case .failed, .partiallyDelivered, .sending, .sent, .carried:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,10 +18,17 @@ struct RelayController {
|
||||
isDirectedFragment: Bool,
|
||||
isHandshake: Bool,
|
||||
isAnnounce: Bool,
|
||||
isRequestSync: Bool = false,
|
||||
degree: Int,
|
||||
highDegreeThreshold: Int) -> RelayDecision {
|
||||
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
||||
|
||||
// REQUEST_SYNC is link-local: never relay it, even when a peer crafts
|
||||
// one with TTL headroom to turn every reachable node into a responder.
|
||||
if isRequestSync {
|
||||
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
|
||||
}
|
||||
|
||||
// Suppress obvious non-relays
|
||||
if ttlCap <= 1 || senderIsSelf || recipientIsSelf {
|
||||
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
|
||||
|
||||
@@ -11,6 +11,24 @@ struct TransportPeerSnapshot: Equatable, Hashable {
|
||||
let isConnected: Bool
|
||||
let noisePublicKey: Data?
|
||||
let lastSeen: Date
|
||||
/// Whether the peer's announce was signature-verified (courier tier gate).
|
||||
let isVerified: Bool
|
||||
|
||||
init(
|
||||
peerID: PeerID,
|
||||
nickname: String,
|
||||
isConnected: Bool,
|
||||
noisePublicKey: Data?,
|
||||
lastSeen: Date,
|
||||
isVerified: Bool = false
|
||||
) {
|
||||
self.peerID = peerID
|
||||
self.nickname = nickname
|
||||
self.isConnected = isConnected
|
||||
self.noisePublicKey = noisePublicKey
|
||||
self.lastSeen = lastSeen
|
||||
self.isVerified = isVerified
|
||||
}
|
||||
}
|
||||
|
||||
enum TransportEvent: @unchecked Sendable {
|
||||
@@ -54,6 +72,11 @@ protocol Transport: AnyObject {
|
||||
// Connectivity and peers
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool
|
||||
/// Whether a send to this peer is likely to leave the device promptly.
|
||||
/// Distinct from reachability: Nostr claims any favorite with a known
|
||||
/// npub as reachable even with no relay connection, where a send only
|
||||
/// joins a queue waiting for internet that may never come.
|
||||
func canDeliverPromptly(to peerID: PeerID) -> Bool
|
||||
func peerNickname(peerID: PeerID) -> String?
|
||||
func getPeerNicknames() -> [PeerID: String]
|
||||
|
||||
@@ -95,6 +118,12 @@ protocol Transport: AnyObject {
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
||||
func cancelTransfer(_ transferId: String)
|
||||
|
||||
// Courier store-and-forward (mesh transports only): seal a message to the
|
||||
// recipient's static key and hand it to connected couriers for physical
|
||||
// delivery while the recipient is offline. Returns false when the
|
||||
// transport cannot courier (no connected courier, or unsupported).
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
|
||||
|
||||
// QR verification (optional for transports)
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
@@ -105,6 +134,10 @@ protocol Transport: AnyObject {
|
||||
}
|
||||
|
||||
extension Transport {
|
||||
// Reachability implies prompt delivery for transports that hand packets
|
||||
// straight to the radio; queue-backed transports override this.
|
||||
func canDeliverPromptly(to peerID: PeerID) -> Bool { isPeerReachable(peerID) }
|
||||
|
||||
// Noise identity hooks default to inert for transports that do not carry
|
||||
// Noise sessions (e.g. NostrTransport).
|
||||
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil }
|
||||
@@ -120,6 +153,7 @@ extension Transport {
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||
func cancelTransfer(_ transferId: String) {}
|
||||
|
||||
@@ -262,7 +262,14 @@ enum TransportConfig {
|
||||
static let syncSeenCapacity: Int = 1000
|
||||
static let syncGCSMaxBytes: Int = 400
|
||||
static let syncGCSTargetFpr: Double = 0.01
|
||||
// Fragments and file transfers keep the short window; whole public
|
||||
// messages get hours so a phone walking between partitions carries the
|
||||
// room's recent history with it (see syncPublicMessageMaxAgeSeconds).
|
||||
static let syncMaxMessageAgeSeconds: TimeInterval = 900
|
||||
// How far back public broadcast messages stay sync-able. Must not exceed
|
||||
// the receive-side acceptance window (BLEPublicMessagePolicy uses this
|
||||
// same constant) or served packets would be dropped as stale.
|
||||
static let syncPublicMessageMaxAgeSeconds: TimeInterval = 6 * 60 * 60
|
||||
static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0
|
||||
static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||
static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||
@@ -271,4 +278,30 @@ enum TransportConfig {
|
||||
static let syncFragmentIntervalSeconds: TimeInterval = 30.0
|
||||
static let syncFileTransferIntervalSeconds: TimeInterval = 60.0
|
||||
static let syncMessageIntervalSeconds: TimeInterval = 15.0
|
||||
static let syncResponseRateLimitMaxResponses: Int = 8
|
||||
static let syncResponseRateLimitWindowSeconds: TimeInterval = 30.0
|
||||
|
||||
// Wi-Fi bulk transport (peer-to-peer AWDL data plane for large media).
|
||||
// BLE stays the control plane: offers/responses ride the Noise session,
|
||||
// only the sealed chunk stream moves to TCP over AWDL.
|
||||
static let wifiBulkEnabled: Bool = true
|
||||
// Below this size BLE fragmentation is fast enough that negotiation
|
||||
// overhead isn't worth it.
|
||||
static let wifiBulkMinPayloadBytes: Int = 64 * 1024
|
||||
static let wifiBulkChunkBytes: Int = 64 * 1024
|
||||
// Offer unanswered for this long → fall back to BLE fragmentation.
|
||||
static let wifiBulkOfferTimeoutSeconds: TimeInterval = 10.0
|
||||
// Hard ceiling on how long the Bonjour listener/connection may live.
|
||||
static let wifiBulkTransferWindowSeconds: TimeInterval = 60.0
|
||||
static let wifiBulkServiceType: String = "_bitchat-bulk._tcp"
|
||||
static let wifiBulkMaxConcurrentIncoming: Int = 4
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -86,45 +86,44 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
var enrichedPeers: [BitchatPeer] = []
|
||||
var connected: Set<PeerID> = []
|
||||
var addedPeerIDs: Set<PeerID> = []
|
||||
|
||||
var meshNoiseKeys: Set<Data> = []
|
||||
|
||||
// Phase 1: Add all mesh peers (connected and reachable)
|
||||
for peerInfo in meshPeers {
|
||||
let peerID = peerInfo.peerID
|
||||
guard peerID != meshService.myPeerID else { continue } // Never add self
|
||||
|
||||
|
||||
let peer = buildPeerFromMesh(
|
||||
peerInfo: peerInfo,
|
||||
favorites: favorites,
|
||||
meshAttached: hasAnyConnected
|
||||
)
|
||||
|
||||
|
||||
enrichedPeers.append(peer)
|
||||
if peer.isConnected { connected.insert(peerID) }
|
||||
addedPeerIDs.insert(peerID)
|
||||
|
||||
|
||||
// Update fingerprint cache
|
||||
if let publicKey = peerInfo.noisePublicKey {
|
||||
meshNoiseKeys.insert(publicKey)
|
||||
fingerprintCache[peerID] = publicKey.sha256Fingerprint()
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Add offline favorites that we actively favorite
|
||||
|
||||
// Phase 2: Add offline favorites that we actively favorite.
|
||||
// Mesh rows use the short 16-hex peer ID while favorites are keyed by
|
||||
// the full 32-byte noise key, so dedup must compare noise keys — a
|
||||
// PeerID comparison between the two forms can never match.
|
||||
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
||||
if meshNoiseKeys.contains(favoriteKey) { continue }
|
||||
|
||||
let peerID = PeerID(hexData: favoriteKey)
|
||||
|
||||
// Skip if already added (connected peer)
|
||||
if addedPeerIDs.contains(peerID) { continue }
|
||||
|
||||
// Skip if connected under different ID but same nickname
|
||||
let isConnectedByNickname = enrichedPeers.contains {
|
||||
$0.nickname == favorite.peerNickname && $0.isConnected
|
||||
}
|
||||
if isConnectedByNickname { continue }
|
||||
|
||||
|
||||
let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID)
|
||||
enrichedPeers.append(peer)
|
||||
addedPeerIDs.insert(peerID)
|
||||
|
||||
|
||||
// Update fingerprint cache
|
||||
fingerprintCache[peerID] = favoriteKey.sha256Fingerprint()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
//
|
||||
// WifiBulkChannel.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// Shared frame-stream reading over an `NWConnection`. All callbacks fire on
|
||||
/// the connection's dispatch queue.
|
||||
enum WifiBulkStream {
|
||||
/// Largest sealed frame body on the wire: one plaintext chunk plus AEAD overhead.
|
||||
static func maxFrameBodyBytes(chunkBytes: Int) -> Int {
|
||||
chunkBytes + WifiBulkCrypto.frameOverhead
|
||||
}
|
||||
|
||||
/// Reads frames until `onFrame` returns false (stop) or the stream
|
||||
/// errors/closes. `onFrame` returning true keeps the loop alive.
|
||||
static func readFrames(
|
||||
on connection: NWConnection,
|
||||
buffer: WifiBulkFrameBuffer,
|
||||
maxFrameBodyBytes: Int,
|
||||
onFrame: @escaping (Data) -> Bool,
|
||||
onError: @escaping (String) -> Void
|
||||
) {
|
||||
// Drain any frames already buffered before touching the socket.
|
||||
do {
|
||||
while let body = try buffer.nextFrameBody() {
|
||||
guard onFrame(body) else { return }
|
||||
}
|
||||
} catch {
|
||||
onError("frame decode failed: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
connection.receive(
|
||||
minimumIncompleteLength: 1,
|
||||
maximumLength: maxFrameBodyBytes + WifiBulkCrypto.framePrefixLength
|
||||
) { data, _, isComplete, error in
|
||||
if let data, !data.isEmpty {
|
||||
buffer.append(data)
|
||||
}
|
||||
if let error {
|
||||
onError("receive failed: \(error)")
|
||||
return
|
||||
}
|
||||
if isComplete {
|
||||
// Peer closed: hand over whatever complete frames remain, then
|
||||
// report the close (sessions that already got what they need
|
||||
// will have stopped the loop from inside onFrame).
|
||||
do {
|
||||
while let body = try buffer.nextFrameBody() {
|
||||
guard onFrame(body) else { return }
|
||||
}
|
||||
} catch {
|
||||
onError("frame decode failed: \(error)")
|
||||
return
|
||||
}
|
||||
onError("connection closed by peer")
|
||||
return
|
||||
}
|
||||
readFrames(
|
||||
on: connection,
|
||||
buffer: buffer,
|
||||
maxFrameBodyBytes: maxFrameBodyBytes,
|
||||
onFrame: onFrame,
|
||||
onError: onError
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sender side of the bulk channel: publishes the per-transfer Bonjour
|
||||
/// listener, requires the first inbound frame to prove knowledge of the
|
||||
/// Noise-exchanged channel key, then streams sealed chunks and waits for the
|
||||
/// receiver's verified receipt.
|
||||
///
|
||||
/// The listener starts at offer time (Bonjour registration takes a moment)
|
||||
/// but data can only flow after `activate(key:)` supplies the channel key
|
||||
/// derived from the accepted response.
|
||||
final class WifiBulkSenderSession {
|
||||
private let queue: DispatchQueue
|
||||
private let payload: Data
|
||||
private let transferID: Data
|
||||
private let payloadHash: Data
|
||||
private let chunkBytes: Int
|
||||
private let parameters: NWParameters
|
||||
private let service: NWListener.Service?
|
||||
private let maxCandidateConnections = 4
|
||||
|
||||
private var key: SymmetricKey?
|
||||
private var listener: NWListener?
|
||||
/// Connections that have not yet produced a valid auth frame.
|
||||
private var candidates: [NWConnection] = []
|
||||
private var authenticated: NWConnection?
|
||||
private var finished = false
|
||||
|
||||
let totalChunks: Int
|
||||
|
||||
/// Test hook: fires once the listener is ready, with its bound port.
|
||||
var onListenerReady: ((UInt16) -> Void)?
|
||||
var onChunkSent: ((_ sent: Int, _ total: Int) -> Void)?
|
||||
var onCompleted: (() -> Void)?
|
||||
var onFailed: ((String) -> Void)?
|
||||
|
||||
init(
|
||||
payload: Data,
|
||||
transferID: Data,
|
||||
chunkBytes: Int,
|
||||
parameters: NWParameters,
|
||||
service: NWListener.Service?,
|
||||
queue: DispatchQueue
|
||||
) {
|
||||
self.payload = payload
|
||||
self.transferID = transferID
|
||||
self.payloadHash = Data(SHA256.hash(data: payload))
|
||||
self.chunkBytes = chunkBytes
|
||||
self.parameters = parameters
|
||||
self.service = service
|
||||
self.queue = queue
|
||||
self.totalChunks = (payload.count + chunkBytes - 1) / chunkBytes
|
||||
}
|
||||
|
||||
deinit {
|
||||
cancelNetworkResources()
|
||||
}
|
||||
|
||||
/// Starts the listener. Returns false when the listener cannot be created
|
||||
/// (caller falls back to BLE immediately).
|
||||
func start() -> Bool {
|
||||
let listener: NWListener
|
||||
do {
|
||||
listener = try NWListener(using: parameters)
|
||||
} catch {
|
||||
SecureLogger.error("WifiBulk: listener creation failed: \(error)", category: .session)
|
||||
return false
|
||||
}
|
||||
listener.service = service
|
||||
listener.stateUpdateHandler = { [weak self] state in
|
||||
guard let self else { return }
|
||||
switch state {
|
||||
case .ready:
|
||||
if let port = listener.port?.rawValue {
|
||||
self.onListenerReady?(port)
|
||||
}
|
||||
case .failed(let error):
|
||||
self.fail("listener failed: \(error)")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
listener.newConnectionHandler = { [weak self] connection in
|
||||
self?.acceptCandidate(connection)
|
||||
}
|
||||
self.listener = listener
|
||||
listener.start(queue: queue)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Supplies the channel key once the receiver accepted the offer; begins
|
||||
/// authenticating any connections that raced ahead of the response.
|
||||
func activate(key: SymmetricKey) {
|
||||
guard !finished, self.key == nil else { return }
|
||||
self.key = key
|
||||
for candidate in candidates {
|
||||
beginAuthentication(on: candidate, key: key)
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
finished = true
|
||||
cancelNetworkResources()
|
||||
}
|
||||
|
||||
// MARK: - Connection handling
|
||||
|
||||
private func acceptCandidate(_ connection: NWConnection) {
|
||||
guard !finished, authenticated == nil, candidates.count < maxCandidateConnections else {
|
||||
connection.cancel()
|
||||
return
|
||||
}
|
||||
candidates.append(connection)
|
||||
connection.stateUpdateHandler = { [weak self, weak connection] state in
|
||||
guard let self, let connection else { return }
|
||||
if case .failed = state {
|
||||
self.dropCandidate(connection)
|
||||
}
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
if let key {
|
||||
beginAuthentication(on: connection, key: key)
|
||||
}
|
||||
}
|
||||
|
||||
private func dropCandidate(_ connection: NWConnection) {
|
||||
if let index = candidates.firstIndex(where: { $0 === connection }) {
|
||||
candidates.remove(at: index)
|
||||
connection.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private func beginAuthentication(on connection: NWConnection, key: SymmetricKey) {
|
||||
let buffer = WifiBulkFrameBuffer(maxBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes))
|
||||
WifiBulkStream.readFrames(
|
||||
on: connection,
|
||||
buffer: buffer,
|
||||
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
|
||||
onFrame: { [weak self, weak connection] body in
|
||||
guard let self, let connection, !self.finished, self.authenticated == nil else { return false }
|
||||
guard WifiBulkCrypto.validateClientAuthFrameBody(body, transferID: self.transferID, key: key) else {
|
||||
// Bonjour-level gatecrasher: no channel key, no service.
|
||||
SecureLogger.warning("WifiBulk: disconnecting client with invalid auth frame", category: .security)
|
||||
self.dropCandidate(connection)
|
||||
return false
|
||||
}
|
||||
self.promoteAuthenticated(connection, key: key, residualBuffer: buffer)
|
||||
return false
|
||||
},
|
||||
onError: { [weak self, weak connection] _ in
|
||||
guard let self, let connection, self.authenticated !== connection else { return }
|
||||
self.dropCandidate(connection)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func promoteAuthenticated(_ connection: NWConnection, key: SymmetricKey, residualBuffer: WifiBulkFrameBuffer) {
|
||||
authenticated = connection
|
||||
// One authenticated peer is all a transfer needs: stop advertising and
|
||||
// shed the other candidates.
|
||||
listener?.cancel()
|
||||
listener = nil
|
||||
for candidate in candidates where candidate !== connection {
|
||||
candidate.cancel()
|
||||
}
|
||||
candidates.removeAll()
|
||||
streamChunk(at: 0, over: connection, key: key, receiptBuffer: residualBuffer)
|
||||
}
|
||||
|
||||
// MARK: - Streaming
|
||||
|
||||
private func streamChunk(at index: Int, over connection: NWConnection, key: SymmetricKey, receiptBuffer: WifiBulkFrameBuffer) {
|
||||
guard !finished else { return }
|
||||
guard index < totalChunks else {
|
||||
awaitReceipt(on: connection, key: key, buffer: receiptBuffer)
|
||||
return
|
||||
}
|
||||
|
||||
let start = payload.index(payload.startIndex, offsetBy: index * chunkBytes)
|
||||
let end = payload.index(start, offsetBy: min(chunkBytes, payload.distance(from: start, to: payload.endIndex)))
|
||||
let chunk = Data(payload[start..<end])
|
||||
|
||||
let body: Data
|
||||
do {
|
||||
body = try WifiBulkCrypto.sealFrameBody(chunk, direction: .senderToReceiver, counter: UInt64(index), key: key)
|
||||
} catch {
|
||||
fail("chunk seal failed: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
connection.send(content: WifiBulkCrypto.frameData(body: body), completion: .contentProcessed { [weak self] error in
|
||||
guard let self, !self.finished else { return }
|
||||
if let error {
|
||||
self.fail("send failed: \(error)")
|
||||
return
|
||||
}
|
||||
self.onChunkSent?(index + 1, self.totalChunks)
|
||||
self.streamChunk(at: index + 1, over: connection, key: key, receiptBuffer: receiptBuffer)
|
||||
})
|
||||
}
|
||||
|
||||
private func awaitReceipt(on connection: NWConnection, key: SymmetricKey, buffer: WifiBulkFrameBuffer) {
|
||||
WifiBulkStream.readFrames(
|
||||
on: connection,
|
||||
buffer: buffer,
|
||||
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
|
||||
onFrame: { [weak self] body in
|
||||
guard let self, !self.finished else { return false }
|
||||
guard WifiBulkCrypto.validateReceiptFrameBody(body, payloadHash: self.payloadHash, key: key) else {
|
||||
self.fail("invalid receipt frame")
|
||||
return false
|
||||
}
|
||||
self.finished = true
|
||||
self.cancelNetworkResources()
|
||||
self.onCompleted?()
|
||||
return false
|
||||
},
|
||||
onError: { [weak self] reason in
|
||||
self?.fail("receipt wait failed: \(reason)")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Teardown
|
||||
|
||||
private func fail(_ reason: String) {
|
||||
guard !finished else { return }
|
||||
finished = true
|
||||
cancelNetworkResources()
|
||||
onFailed?(reason)
|
||||
}
|
||||
|
||||
private func cancelNetworkResources() {
|
||||
listener?.cancel()
|
||||
listener = nil
|
||||
authenticated?.cancel()
|
||||
authenticated = nil
|
||||
for candidate in candidates {
|
||||
candidate.cancel()
|
||||
}
|
||||
candidates.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
/// Receiver side of the bulk channel: connects to the sender's per-transfer
|
||||
/// endpoint, proves knowledge of the channel key with the first frame, then
|
||||
/// reassembles sealed chunks, verifies the offer hash, and returns a receipt.
|
||||
final class WifiBulkReceiverSession {
|
||||
private let queue: DispatchQueue
|
||||
private let connection: NWConnection
|
||||
private let key: SymmetricKey
|
||||
private let transferID: Data
|
||||
private let payloadHash: Data
|
||||
private let chunkBytes: Int
|
||||
private let assembler: WifiBulkPayloadAssembler
|
||||
|
||||
private var finished = false
|
||||
|
||||
var onCompleted: ((Data) -> Void)?
|
||||
var onFailed: ((String) -> Void)?
|
||||
|
||||
/// Fails (returns nil) when the offer exceeds `sizeCap` — the receiver
|
||||
/// enforces the cap it advertised, not the sender's word.
|
||||
init?(
|
||||
endpoint: NWEndpoint,
|
||||
parameters: NWParameters,
|
||||
key: SymmetricKey,
|
||||
transferID: Data,
|
||||
expectedSize: UInt64,
|
||||
expectedHash: Data,
|
||||
sizeCap: Int,
|
||||
chunkBytes: Int,
|
||||
queue: DispatchQueue
|
||||
) {
|
||||
guard let assembler = WifiBulkPayloadAssembler(
|
||||
key: key,
|
||||
expectedSize: expectedSize,
|
||||
expectedHash: expectedHash,
|
||||
sizeCap: sizeCap
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
self.assembler = assembler
|
||||
self.connection = NWConnection(to: endpoint, using: parameters)
|
||||
self.key = key
|
||||
self.transferID = transferID
|
||||
self.payloadHash = expectedHash
|
||||
self.chunkBytes = chunkBytes
|
||||
self.queue = queue
|
||||
}
|
||||
|
||||
deinit {
|
||||
connection.cancel()
|
||||
}
|
||||
|
||||
func start() {
|
||||
connection.stateUpdateHandler = { [weak self] state in
|
||||
guard let self else { return }
|
||||
switch state {
|
||||
case .ready:
|
||||
self.sendAuthFrameAndReceive()
|
||||
case .failed(let error):
|
||||
self.fail("connect failed: \(error)")
|
||||
case .waiting(let error):
|
||||
// .waiting can resolve on its own, but a per-transfer channel
|
||||
// has a peer actively listening; treat unreachable as fatal so
|
||||
// the sender's fallback isn't left to the window timeout alone.
|
||||
self.fail("connection waiting: \(error)")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
finished = true
|
||||
connection.cancel()
|
||||
}
|
||||
|
||||
private func sendAuthFrameAndReceive() {
|
||||
guard !finished else { return }
|
||||
let authBody: Data
|
||||
do {
|
||||
authBody = try WifiBulkCrypto.makeClientAuthFrameBody(transferID: transferID, key: key)
|
||||
} catch {
|
||||
fail("auth frame seal failed: \(error)")
|
||||
return
|
||||
}
|
||||
connection.send(content: WifiBulkCrypto.frameData(body: authBody), completion: .contentProcessed { [weak self] error in
|
||||
guard let self, !self.finished else { return }
|
||||
if let error {
|
||||
self.fail("auth frame send failed: \(error)")
|
||||
return
|
||||
}
|
||||
self.receiveChunks()
|
||||
})
|
||||
}
|
||||
|
||||
private func receiveChunks() {
|
||||
let buffer = WifiBulkFrameBuffer(maxBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes))
|
||||
WifiBulkStream.readFrames(
|
||||
on: connection,
|
||||
buffer: buffer,
|
||||
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
|
||||
onFrame: { [weak self] body in
|
||||
guard let self, !self.finished else { return false }
|
||||
do {
|
||||
guard let payload = try self.assembler.consume(frameBody: body) else {
|
||||
return true // keep reading
|
||||
}
|
||||
self.sendReceiptAndComplete(payload)
|
||||
return false
|
||||
} catch {
|
||||
self.fail("chunk rejected: \(error)")
|
||||
return false
|
||||
}
|
||||
},
|
||||
onError: { [weak self] reason in
|
||||
self?.fail(reason)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func sendReceiptAndComplete(_ payload: Data) {
|
||||
let receiptBody: Data
|
||||
do {
|
||||
receiptBody = try WifiBulkCrypto.makeReceiptFrameBody(payloadHash: payloadHash, key: key)
|
||||
} catch {
|
||||
fail("receipt seal failed: \(error)")
|
||||
return
|
||||
}
|
||||
connection.send(content: WifiBulkCrypto.frameData(body: receiptBody), completion: .contentProcessed { [weak self] _ in
|
||||
// Receipt is best-effort from the receiver's perspective: the
|
||||
// payload is already verified. Close the channel either way.
|
||||
guard let self, !self.finished else { return }
|
||||
self.finished = true
|
||||
self.connection.cancel()
|
||||
self.onCompleted?(payload)
|
||||
})
|
||||
}
|
||||
|
||||
private func fail(_ reason: String) {
|
||||
guard !finished else { return }
|
||||
finished = true
|
||||
connection.cancel()
|
||||
onFailed?(reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//
|
||||
// WifiBulkCrypto.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// Channel security for the Wi-Fi bulk data plane.
|
||||
///
|
||||
/// The TCP stream is encrypted and authenticated independently of TLS: both
|
||||
/// endpoints exchanged random 32-byte tokens inside the established Noise
|
||||
/// session, so only they can derive the ChaChaPoly channel key via
|
||||
/// HKDF-SHA256 (domain "bitchat-bulk-v1", transferID as salt). A Bonjour-level
|
||||
/// gatecrasher that connects to the listener cannot produce a single valid
|
||||
/// frame and is disconnected.
|
||||
///
|
||||
/// Stream format: length-prefixed frames, each a ChaChaPoly sealed box in
|
||||
/// combined form (12-byte nonce ‖ ciphertext ‖ 16-byte tag). Nonces are
|
||||
/// structured, never random: [direction byte][3 zero bytes][8-byte BE counter],
|
||||
/// and the reader requires the exact expected nonce for each frame, so frames
|
||||
/// cannot be replayed, reordered, or reflected across directions.
|
||||
enum WifiBulkCryptoError: Error, Equatable {
|
||||
case invalidParameters
|
||||
case frameTooLarge
|
||||
case truncatedFrame
|
||||
case nonceMismatch
|
||||
case authenticationFailed
|
||||
case emptyChunk
|
||||
case payloadOverflow
|
||||
case hashMismatch
|
||||
}
|
||||
|
||||
enum WifiBulkFrameDirection: UInt8 {
|
||||
/// Data chunks: counters 0, 1, 2, …
|
||||
case senderToReceiver = 0x00
|
||||
/// Counter 0 = client auth frame, counter 1 = final receipt.
|
||||
case receiverToSender = 0x01
|
||||
}
|
||||
|
||||
enum WifiBulkCrypto {
|
||||
static let keyDomain = "bitchat-bulk-v1"
|
||||
static let nonceLength = 12
|
||||
static let tagLength = 16
|
||||
/// AEAD overhead per frame body (nonce + tag).
|
||||
static let frameOverhead = nonceLength + tagLength
|
||||
/// 4-byte big-endian length prefix per frame.
|
||||
static let framePrefixLength = 4
|
||||
|
||||
// MARK: Key derivation
|
||||
|
||||
/// Derives the ChaChaPoly channel key from the two Noise-exchanged tokens.
|
||||
/// Deterministic: same tokens + transferID always yield the same key.
|
||||
static func deriveKey(senderToken: Data, receiverToken: Data, transferID: Data) -> SymmetricKey? {
|
||||
guard senderToken.count == WifiBulkWire.tokenLength,
|
||||
receiverToken.count == WifiBulkWire.tokenLength,
|
||||
transferID.count == WifiBulkWire.transferIDLength else {
|
||||
return nil
|
||||
}
|
||||
var inputKeyMaterial = Data()
|
||||
inputKeyMaterial.append(senderToken)
|
||||
inputKeyMaterial.append(receiverToken)
|
||||
return HKDF<SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: inputKeyMaterial),
|
||||
salt: transferID,
|
||||
info: Data(keyDomain.utf8),
|
||||
outputByteCount: 32
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Frame sealing
|
||||
|
||||
static func nonceData(direction: WifiBulkFrameDirection, counter: UInt64) -> Data {
|
||||
var nonce = Data(count: nonceLength)
|
||||
nonce[0] = direction.rawValue
|
||||
var counterBE = counter.bigEndian
|
||||
withUnsafeBytes(of: &counterBE) { nonce.replaceSubrange(4..<nonceLength, with: $0) }
|
||||
return nonce
|
||||
}
|
||||
|
||||
/// Seals one frame body (nonce ‖ ciphertext ‖ tag), without length prefix.
|
||||
static func sealFrameBody(
|
||||
_ plaintext: Data,
|
||||
direction: WifiBulkFrameDirection,
|
||||
counter: UInt64,
|
||||
key: SymmetricKey
|
||||
) throws -> Data {
|
||||
let nonce = try ChaChaPoly.Nonce(data: nonceData(direction: direction, counter: counter))
|
||||
return try ChaChaPoly.seal(plaintext, using: key, nonce: nonce).combined
|
||||
}
|
||||
|
||||
/// Opens one frame body, enforcing the exact expected nonce.
|
||||
static func openFrameBody(
|
||||
_ body: Data,
|
||||
direction: WifiBulkFrameDirection,
|
||||
counter: UInt64,
|
||||
key: SymmetricKey
|
||||
) throws -> Data {
|
||||
guard body.count >= frameOverhead else { throw WifiBulkCryptoError.truncatedFrame }
|
||||
guard body.prefix(nonceLength) == nonceData(direction: direction, counter: counter) else {
|
||||
throw WifiBulkCryptoError.nonceMismatch
|
||||
}
|
||||
do {
|
||||
let box = try ChaChaPoly.SealedBox(combined: body)
|
||||
return try ChaChaPoly.open(box, using: key)
|
||||
} catch {
|
||||
throw WifiBulkCryptoError.authenticationFailed
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefixes a frame body with its 4-byte big-endian length for the wire.
|
||||
static func frameData(body: Data) -> Data {
|
||||
var framed = Data(capacity: framePrefixLength + body.count)
|
||||
var lengthBE = UInt32(body.count).bigEndian
|
||||
withUnsafeBytes(of: &lengthBE) { framed.append(contentsOf: $0) }
|
||||
framed.append(body)
|
||||
return framed
|
||||
}
|
||||
|
||||
// MARK: Control frames
|
||||
|
||||
/// First frame on the wire, receiver → sender: proves the connecting
|
||||
/// client holds the Noise-exchanged secret before any data flows.
|
||||
static func makeClientAuthFrameBody(transferID: Data, key: SymmetricKey) throws -> Data {
|
||||
try sealFrameBody(transferID, direction: .receiverToSender, counter: 0, key: key)
|
||||
}
|
||||
|
||||
static func validateClientAuthFrameBody(_ body: Data, transferID: Data, key: SymmetricKey) -> Bool {
|
||||
(try? openFrameBody(body, direction: .receiverToSender, counter: 0, key: key)) == transferID
|
||||
}
|
||||
|
||||
/// Final frame, receiver → sender: acknowledges the fully verified payload.
|
||||
static func makeReceiptFrameBody(payloadHash: Data, key: SymmetricKey) throws -> Data {
|
||||
try sealFrameBody(payloadHash, direction: .receiverToSender, counter: 1, key: key)
|
||||
}
|
||||
|
||||
static func validateReceiptFrameBody(_ body: Data, payloadHash: Data, key: SymmetricKey) -> Bool {
|
||||
(try? openFrameBody(body, direction: .receiverToSender, counter: 1, key: key)) == payloadHash
|
||||
}
|
||||
}
|
||||
|
||||
/// Incremental length-prefix parser for the frame stream. Bounded: bodies
|
||||
/// larger than `maxBodyBytes` throw instead of buffering unboundedly.
|
||||
final class WifiBulkFrameBuffer {
|
||||
private var buffer = Data()
|
||||
private let maxBodyBytes: Int
|
||||
|
||||
init(maxBodyBytes: Int) {
|
||||
self.maxBodyBytes = maxBodyBytes
|
||||
}
|
||||
|
||||
func append(_ data: Data) {
|
||||
buffer.append(data)
|
||||
}
|
||||
|
||||
/// Extracts the next complete frame body, or nil when more bytes are needed.
|
||||
func nextFrameBody() throws -> Data? {
|
||||
guard buffer.count >= WifiBulkCrypto.framePrefixLength else { return nil }
|
||||
let length = buffer.prefix(WifiBulkCrypto.framePrefixLength).reduce(Int(0)) { ($0 << 8) | Int($1) }
|
||||
guard length <= maxBodyBytes else { throw WifiBulkCryptoError.frameTooLarge }
|
||||
guard buffer.count >= WifiBulkCrypto.framePrefixLength + length else { return nil }
|
||||
let body = Data(buffer.dropFirst(WifiBulkCrypto.framePrefixLength).prefix(length))
|
||||
buffer.removeFirst(WifiBulkCrypto.framePrefixLength + length)
|
||||
return body
|
||||
}
|
||||
}
|
||||
|
||||
/// Receiver-side reassembly: opens sequential data frames, enforces the size
|
||||
/// negotiated in the accepted offer, and verifies the final SHA-256.
|
||||
final class WifiBulkPayloadAssembler {
|
||||
private let key: SymmetricKey
|
||||
private let expectedSize: Int
|
||||
private let expectedHash: Data
|
||||
private var received = Data()
|
||||
private var counter: UInt64 = 0
|
||||
|
||||
/// Fails when the offer exceeds the receiver-enforced cap.
|
||||
init?(key: SymmetricKey, expectedSize: UInt64, expectedHash: Data, sizeCap: Int) {
|
||||
guard expectedSize > 0,
|
||||
expectedSize <= UInt64(sizeCap),
|
||||
expectedHash.count == WifiBulkWire.hashLength else {
|
||||
return nil
|
||||
}
|
||||
self.key = key
|
||||
self.expectedSize = Int(expectedSize)
|
||||
self.expectedHash = expectedHash
|
||||
}
|
||||
|
||||
var isComplete: Bool { received.count == expectedSize }
|
||||
|
||||
/// Consumes one sealed data frame body. Returns the verified payload when
|
||||
/// the final byte arrives; throws on tampering, overflow, or hash mismatch.
|
||||
func consume(frameBody: Data) throws -> Data? {
|
||||
let chunk = try WifiBulkCrypto.openFrameBody(
|
||||
frameBody,
|
||||
direction: .senderToReceiver,
|
||||
counter: counter,
|
||||
key: key
|
||||
)
|
||||
guard !chunk.isEmpty else { throw WifiBulkCryptoError.emptyChunk }
|
||||
counter += 1
|
||||
guard received.count + chunk.count <= expectedSize else {
|
||||
throw WifiBulkCryptoError.payloadOverflow
|
||||
}
|
||||
received.append(chunk)
|
||||
guard isComplete else { return nil }
|
||||
guard Data(SHA256.hash(data: received)) == expectedHash else {
|
||||
throw WifiBulkCryptoError.hashMismatch
|
||||
}
|
||||
return received
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//
|
||||
// WifiBulkMessages.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// TLV payloads for negotiating a Wi-Fi bulk transfer inside an established
|
||||
/// Noise session (`NoisePayloadType.bulkTransferOffer` / `.bulkTransferResponse`).
|
||||
///
|
||||
/// Both messages ride the encrypted Noise channel, so every field — including
|
||||
/// the session tokens and the random Bonjour instance name — is only visible
|
||||
/// to the two endpoints. TLV format matches `BitchatFilePacket`: 1-byte type,
|
||||
/// 2-byte big-endian length, value. Unknown TLVs are skipped for forward
|
||||
/// compatibility.
|
||||
enum WifiBulkWire {
|
||||
static let transferIDLength = 16
|
||||
static let tokenLength = 32
|
||||
static let hashLength = 32
|
||||
/// Bonjour instance names are capped at 63 UTF-8 bytes.
|
||||
static let maxServiceNameBytes = 63
|
||||
|
||||
static func appendTLV(_ type: UInt8, value: Data, into data: inout Data) {
|
||||
data.append(type)
|
||||
var length = UInt16(value.count).bigEndian
|
||||
withUnsafeBytes(of: &length) { data.append(contentsOf: $0) }
|
||||
data.append(value)
|
||||
}
|
||||
|
||||
/// Iterates well-formed TLVs, handing each (type, value) to `visit`.
|
||||
/// Returns false when the buffer is structurally malformed.
|
||||
static func parseTLVs(_ data: Data, visit: (UInt8, Data) -> Void) -> Bool {
|
||||
var cursor = data.startIndex
|
||||
let end = data.endIndex
|
||||
while cursor < end {
|
||||
let type = data[cursor]
|
||||
cursor = data.index(after: cursor)
|
||||
guard data.distance(from: cursor, to: end) >= 2 else { return false }
|
||||
let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)])
|
||||
cursor = data.index(cursor, offsetBy: 2)
|
||||
guard data.distance(from: cursor, to: end) >= length else { return false }
|
||||
let valueEnd = data.index(cursor, offsetBy: length)
|
||||
visit(type, Data(data[cursor..<valueEnd]))
|
||||
cursor = valueEnd
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Sender → receiver: proposal to move an already-encoded file payload over
|
||||
/// a peer-to-peer Wi-Fi (AWDL) TCP channel instead of BLE fragmentation.
|
||||
struct WifiBulkOffer: Equatable {
|
||||
/// Random per-transfer identifier; also the HKDF salt.
|
||||
let transferID: Data
|
||||
/// Exact byte count of the payload that will cross the channel.
|
||||
let fileSize: UInt64
|
||||
/// SHA-256 over the payload bytes as they cross the channel, verified by
|
||||
/// the receiver after reassembly.
|
||||
let payloadHash: Data
|
||||
/// Sender's random half of the channel secret.
|
||||
let token: Data
|
||||
/// Random Bonjour instance name the sender publishes for this transfer.
|
||||
/// Never derived from nickname or peer ID.
|
||||
let serviceName: String
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case transferID = 0x01
|
||||
case fileSize = 0x02
|
||||
case payloadHash = 0x03
|
||||
case token = 0x04
|
||||
case serviceName = 0x05
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
guard transferID.count == WifiBulkWire.transferIDLength,
|
||||
payloadHash.count == WifiBulkWire.hashLength,
|
||||
token.count == WifiBulkWire.tokenLength else { return nil }
|
||||
let nameData = Data(serviceName.utf8)
|
||||
guard !nameData.isEmpty, nameData.count <= WifiBulkWire.maxServiceNameBytes else { return nil }
|
||||
|
||||
var encoded = Data()
|
||||
WifiBulkWire.appendTLV(TLVType.transferID.rawValue, value: transferID, into: &encoded)
|
||||
var sizeBE = fileSize.bigEndian
|
||||
WifiBulkWire.appendTLV(TLVType.fileSize.rawValue, value: withUnsafeBytes(of: &sizeBE) { Data($0) }, into: &encoded)
|
||||
WifiBulkWire.appendTLV(TLVType.payloadHash.rawValue, value: payloadHash, into: &encoded)
|
||||
WifiBulkWire.appendTLV(TLVType.token.rawValue, value: token, into: &encoded)
|
||||
WifiBulkWire.appendTLV(TLVType.serviceName.rawValue, value: nameData, into: &encoded)
|
||||
return encoded
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) -> WifiBulkOffer? {
|
||||
var transferID: Data?
|
||||
var fileSize: UInt64?
|
||||
var payloadHash: Data?
|
||||
var token: Data?
|
||||
var serviceName: String?
|
||||
|
||||
let wellFormed = WifiBulkWire.parseTLVs(data) { type, value in
|
||||
switch TLVType(rawValue: type) {
|
||||
case .transferID where value.count == WifiBulkWire.transferIDLength:
|
||||
transferID = value
|
||||
case .fileSize where value.count == 8:
|
||||
fileSize = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
|
||||
case .payloadHash where value.count == WifiBulkWire.hashLength:
|
||||
payloadHash = value
|
||||
case .token where value.count == WifiBulkWire.tokenLength:
|
||||
token = value
|
||||
case .serviceName where !value.isEmpty && value.count <= WifiBulkWire.maxServiceNameBytes:
|
||||
serviceName = String(data: value, encoding: .utf8)
|
||||
default:
|
||||
break // Unknown or malformed field: ignore; required checks below.
|
||||
}
|
||||
}
|
||||
guard wellFormed,
|
||||
let transferID, let fileSize, let payloadHash, let token, let serviceName else {
|
||||
return nil
|
||||
}
|
||||
return WifiBulkOffer(
|
||||
transferID: transferID,
|
||||
fileSize: fileSize,
|
||||
payloadHash: payloadHash,
|
||||
token: token,
|
||||
serviceName: serviceName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Receiver → sender: accept (with the receiver's token half) or decline.
|
||||
struct WifiBulkResponse: Equatable {
|
||||
let transferID: Data
|
||||
let accepted: Bool
|
||||
/// Receiver's random half of the channel secret; present iff accepted.
|
||||
let token: Data?
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case transferID = 0x01
|
||||
case accepted = 0x02
|
||||
case token = 0x03
|
||||
}
|
||||
|
||||
static func accept(transferID: Data, token: Data) -> WifiBulkResponse {
|
||||
WifiBulkResponse(transferID: transferID, accepted: true, token: token)
|
||||
}
|
||||
|
||||
static func decline(transferID: Data) -> WifiBulkResponse {
|
||||
WifiBulkResponse(transferID: transferID, accepted: false, token: nil)
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
guard transferID.count == WifiBulkWire.transferIDLength else { return nil }
|
||||
if accepted {
|
||||
guard token?.count == WifiBulkWire.tokenLength else { return nil }
|
||||
}
|
||||
|
||||
var encoded = Data()
|
||||
WifiBulkWire.appendTLV(TLVType.transferID.rawValue, value: transferID, into: &encoded)
|
||||
WifiBulkWire.appendTLV(TLVType.accepted.rawValue, value: Data([accepted ? 1 : 0]), into: &encoded)
|
||||
if accepted, let token {
|
||||
WifiBulkWire.appendTLV(TLVType.token.rawValue, value: token, into: &encoded)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) -> WifiBulkResponse? {
|
||||
var transferID: Data?
|
||||
var accepted: Bool?
|
||||
var token: Data?
|
||||
|
||||
let wellFormed = WifiBulkWire.parseTLVs(data) { type, value in
|
||||
switch TLVType(rawValue: type) {
|
||||
case .transferID where value.count == WifiBulkWire.transferIDLength:
|
||||
transferID = value
|
||||
case .accepted where value.count == 1:
|
||||
accepted = value.first == 1
|
||||
case .token where value.count == WifiBulkWire.tokenLength:
|
||||
token = value
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
guard wellFormed, let transferID, let accepted else { return nil }
|
||||
if accepted {
|
||||
guard let token else { return nil }
|
||||
return WifiBulkResponse(transferID: transferID, accepted: true, token: token)
|
||||
}
|
||||
return WifiBulkResponse(transferID: transferID, accepted: false, token: nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//
|
||||
// WifiBulkPolicy.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Pure eligibility decisions for the Wi-Fi bulk data plane. Anything that
|
||||
/// fails these gates rides BLE fragmentation exactly as before — the BLE
|
||||
/// fallback is the common case and must stay bulletproof.
|
||||
enum WifiBulkPolicy {
|
||||
struct SendCandidate {
|
||||
let payloadBytes: Int
|
||||
let peerCapabilities: PeerCapabilities
|
||||
/// Direct BLE link (1 hop). Multi-hop recipients stay on BLE: AWDL
|
||||
/// only reaches direct neighbors, and relays can't proxy the channel.
|
||||
let isDirectlyConnected: Bool
|
||||
/// The offer rides the Noise session, so one must already exist.
|
||||
let hasEstablishedNoiseSession: Bool
|
||||
}
|
||||
|
||||
static func shouldOffer(
|
||||
_ candidate: SendCandidate,
|
||||
enabled: Bool = TransportConfig.wifiBulkEnabled,
|
||||
minPayloadBytes: Int = TransportConfig.wifiBulkMinPayloadBytes,
|
||||
maxPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes
|
||||
) -> Bool {
|
||||
enabled
|
||||
&& candidate.payloadBytes > minPayloadBytes
|
||||
&& candidate.payloadBytes <= maxPayloadBytes
|
||||
&& candidate.peerCapabilities.contains(.wifiBulk)
|
||||
&& candidate.isDirectlyConnected
|
||||
&& candidate.hasEstablishedNoiseSession
|
||||
}
|
||||
|
||||
/// Receiver-side gate. Field lengths were validated at decode; this
|
||||
/// enforces the size cap (from the local ceiling, not the sender's word)
|
||||
/// and local enablement.
|
||||
static func shouldAccept(
|
||||
offer: WifiBulkOffer,
|
||||
senderIsDirectlyConnected: Bool,
|
||||
activeIncomingTransfers: Int,
|
||||
enabled: Bool = TransportConfig.wifiBulkEnabled,
|
||||
maxPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes,
|
||||
maxConcurrentIncoming: Int = TransportConfig.wifiBulkMaxConcurrentIncoming
|
||||
) -> Bool {
|
||||
enabled
|
||||
&& senderIsDirectlyConnected
|
||||
&& activeIncomingTransfers < maxConcurrentIncoming
|
||||
&& offer.fileSize > 0
|
||||
&& offer.fileSize <= UInt64(maxPayloadBytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
//
|
||||
// WifiBulkTransferService.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 Network
|
||||
|
||||
/// Narrow environment for `WifiBulkTransferService`. All BLE-service queue
|
||||
/// hops live inside the closures supplied by `BLEService`, keeping this
|
||||
/// service independently testable.
|
||||
struct WifiBulkTransferServiceEnvironment {
|
||||
/// Sends a typed payload inside the established Noise session with the
|
||||
/// peer. Returns false when no established session exists (the caller
|
||||
/// falls back to BLE).
|
||||
let sendNoisePayload: (_ typedPayload: Data, _ peerID: PeerID) -> Bool
|
||||
/// Whether the peer is on a direct BLE link right now.
|
||||
let isPeerConnected: (PeerID) -> Bool
|
||||
/// Delivers a fully received, hash-verified payload (encoded
|
||||
/// `BitchatFilePacket` TLV) into the normal incoming-file pipeline.
|
||||
let deliverReceivedFile: (_ payload: Data, _ peerID: PeerID, _ payloadLimit: Int) -> Void
|
||||
/// Progress bus hooks mirroring the BLE fragmentation path so the UI is
|
||||
/// unchanged (chunks report as "fragments").
|
||||
let progressStart: (_ transferId: String, _ totalChunks: Int) -> Void
|
||||
let progressChunkSent: (_ transferId: String) -> Void
|
||||
/// Silently forgets progress state ahead of a BLE fallback re-start.
|
||||
let progressReset: (_ transferId: String) -> Void
|
||||
/// Emits the cancelled event for user-cancelled transfers.
|
||||
let progressCancel: (_ transferId: String) -> Void
|
||||
}
|
||||
|
||||
/// Knobs with test overrides; production values come from `TransportConfig`.
|
||||
struct WifiBulkTransferServiceConfig {
|
||||
var serviceType: String = TransportConfig.wifiBulkServiceType
|
||||
var chunkBytes: Int = TransportConfig.wifiBulkChunkBytes
|
||||
var offerTimeout: TimeInterval = TransportConfig.wifiBulkOfferTimeoutSeconds
|
||||
var transferWindow: TimeInterval = TransportConfig.wifiBulkTransferWindowSeconds
|
||||
var maxIncomingPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes
|
||||
var maxConcurrentIncoming: Int = TransportConfig.wifiBulkMaxConcurrentIncoming
|
||||
/// Tests disable peer-to-peer so loopback interfaces stay usable.
|
||||
var usePeerToPeer: Bool = true
|
||||
/// Tests disable Bonjour publication (unit-test hosts may lack mDNS access).
|
||||
var publishBonjourService: Bool = true
|
||||
}
|
||||
|
||||
/// Orchestrates the Wi-Fi bulk data plane: BLE/Noise carries the offer and
|
||||
/// response (control plane), then the payload crosses a per-transfer TCP
|
||||
/// channel over AWDL, sealed with a key both sides derived from the
|
||||
/// Noise-exchanged tokens. Any failure at any stage falls back to BLE
|
||||
/// fragmentation exactly once; the receiver side fails silently and lets the
|
||||
/// sender's timeout drive that fallback.
|
||||
final class WifiBulkTransferService {
|
||||
private let queue = DispatchQueue(label: "com.bitchat.wifi-bulk", qos: .userInitiated)
|
||||
private let environment: WifiBulkTransferServiceEnvironment
|
||||
private let config: WifiBulkTransferServiceConfig
|
||||
|
||||
private final class OutgoingTransfer {
|
||||
let transferID: Data
|
||||
let transferId: String
|
||||
let peerID: PeerID
|
||||
let token: Data
|
||||
let fallback: () -> Void
|
||||
var session: WifiBulkSenderSession?
|
||||
var offerTimeout: DispatchWorkItem?
|
||||
var windowTimeout: DispatchWorkItem?
|
||||
var accepted = false
|
||||
var finished = false
|
||||
|
||||
init(transferID: Data, transferId: String, peerID: PeerID, token: Data, fallback: @escaping () -> Void) {
|
||||
self.transferID = transferID
|
||||
self.transferId = transferId
|
||||
self.peerID = peerID
|
||||
self.token = token
|
||||
self.fallback = fallback
|
||||
}
|
||||
}
|
||||
|
||||
private final class IncomingTransfer {
|
||||
let offer: WifiBulkOffer
|
||||
let peerID: PeerID
|
||||
let key: SymmetricKey
|
||||
var browser: NWBrowser?
|
||||
var session: WifiBulkReceiverSession?
|
||||
var windowTimeout: DispatchWorkItem?
|
||||
|
||||
init(offer: WifiBulkOffer, peerID: PeerID, key: SymmetricKey) {
|
||||
self.offer = offer
|
||||
self.peerID = peerID
|
||||
self.key = key
|
||||
}
|
||||
}
|
||||
|
||||
private var outgoing: [Data: OutgoingTransfer] = [:]
|
||||
private var incoming: [Data: IncomingTransfer] = [:]
|
||||
|
||||
init(
|
||||
environment: WifiBulkTransferServiceEnvironment,
|
||||
config: WifiBulkTransferServiceConfig = WifiBulkTransferServiceConfig()
|
||||
) {
|
||||
self.environment = environment
|
||||
self.config = config
|
||||
}
|
||||
|
||||
// MARK: - Sender
|
||||
|
||||
/// Offers `payload` over the Wi-Fi bulk channel. `fallbackToBLE` runs at
|
||||
/// most once, on decline, timeout, or any mid-transfer error.
|
||||
func sendFile(payload: Data, to peerID: PeerID, transferId: String, fallbackToBLE: @escaping () -> Void) {
|
||||
queue.async { [weak self] in
|
||||
self?.beginOutgoing(payload: payload, peerID: peerID, transferId: transferId, fallbackToBLE: fallbackToBLE)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a decrypted `bulkTransferResponse` Noise payload.
|
||||
func handleResponsePayload(_ payload: Data, from peerID: PeerID) {
|
||||
queue.async { [weak self] in
|
||||
self?.processResponse(payload, from: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
/// User-initiated cancel from the UI (mirrors BLE `cancelTransfer`).
|
||||
func cancelTransfer(transferId: String) {
|
||||
queue.async { [weak self] in
|
||||
guard let self,
|
||||
let transfer = self.outgoing.values.first(where: { $0.transferId == transferId }) else { return }
|
||||
self.finishOutgoing(transfer, outcome: .cancelled, reason: "cancelled by user")
|
||||
}
|
||||
}
|
||||
|
||||
/// Tears down every transfer (service shutdown / emergency disconnect).
|
||||
/// In-flight outgoing transfers do NOT fall back — the transport is going away.
|
||||
func stop() {
|
||||
queue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
for transfer in self.outgoing.values {
|
||||
transfer.finished = true
|
||||
transfer.offerTimeout?.cancel()
|
||||
transfer.windowTimeout?.cancel()
|
||||
transfer.session?.cancel()
|
||||
}
|
||||
self.outgoing.removeAll()
|
||||
for transfer in self.incoming.values {
|
||||
self.tearDownIncomingResources(transfer)
|
||||
}
|
||||
self.incoming.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private enum OutgoingOutcome {
|
||||
case completed
|
||||
case fallback
|
||||
case cancelled
|
||||
}
|
||||
|
||||
private func beginOutgoing(payload: Data, peerID: PeerID, transferId: String, fallbackToBLE: @escaping () -> Void) {
|
||||
let transferID = Self.randomData(WifiBulkWire.transferIDLength)
|
||||
let token = Self.randomData(WifiBulkWire.tokenLength)
|
||||
// Random per-transfer instance name — never the nickname or peer ID.
|
||||
let serviceName = Self.randomData(16).hexEncodedString()
|
||||
|
||||
let offer = WifiBulkOffer(
|
||||
transferID: transferID,
|
||||
fileSize: UInt64(payload.count),
|
||||
payloadHash: Data(SHA256.hash(data: payload)),
|
||||
token: token,
|
||||
serviceName: serviceName
|
||||
)
|
||||
guard let offerData = offer.encode() else {
|
||||
fallbackToBLE()
|
||||
return
|
||||
}
|
||||
|
||||
let transfer = OutgoingTransfer(
|
||||
transferID: transferID,
|
||||
transferId: transferId,
|
||||
peerID: peerID,
|
||||
token: token,
|
||||
fallback: fallbackToBLE
|
||||
)
|
||||
|
||||
let session = WifiBulkSenderSession(
|
||||
payload: payload,
|
||||
transferID: transferID,
|
||||
chunkBytes: config.chunkBytes,
|
||||
parameters: makeParameters(),
|
||||
service: config.publishBonjourService
|
||||
? NWListener.Service(name: serviceName, type: config.serviceType)
|
||||
: nil,
|
||||
queue: queue
|
||||
)
|
||||
if let onListenerReady = _test_onListenerReady {
|
||||
session.onListenerReady = { port in onListenerReady(transferID, port) }
|
||||
}
|
||||
session.onChunkSent = { [weak self, weak transfer] sent, total in
|
||||
guard let self, let transfer, !transfer.finished else { return }
|
||||
// Hold the final tick until the receipt confirms delivery, so the
|
||||
// progress bus only emits .completed for verified transfers.
|
||||
if sent < total {
|
||||
self.environment.progressChunkSent(transfer.transferId)
|
||||
}
|
||||
}
|
||||
session.onCompleted = { [weak self, weak transfer] in
|
||||
guard let self, let transfer, !transfer.finished else { return }
|
||||
self.environment.progressChunkSent(transfer.transferId)
|
||||
self.finishOutgoing(transfer, outcome: .completed, reason: "receipt verified")
|
||||
}
|
||||
session.onFailed = { [weak self, weak transfer] reason in
|
||||
guard let self, let transfer else { return }
|
||||
self.finishOutgoing(transfer, outcome: .fallback, reason: reason)
|
||||
}
|
||||
transfer.session = session
|
||||
outgoing[transferID] = transfer
|
||||
|
||||
guard session.start() else {
|
||||
finishOutgoing(transfer, outcome: .fallback, reason: "listener unavailable")
|
||||
return
|
||||
}
|
||||
guard environment.sendNoisePayload(
|
||||
BLENoisePayloadFactory.typedPayload(.bulkTransferOffer, payload: offerData),
|
||||
peerID
|
||||
) else {
|
||||
finishOutgoing(transfer, outcome: .fallback, reason: "no established noise session")
|
||||
return
|
||||
}
|
||||
|
||||
SecureLogger.debug("WifiBulk: offered \(payload.count) bytes to \(peerID.id.prefix(8))… over \(serviceName.prefix(8))…", category: .session)
|
||||
environment.progressStart(transferId, session.totalChunks)
|
||||
|
||||
let offerTimeout = DispatchWorkItem { [weak self, weak transfer] in
|
||||
guard let self, let transfer, !transfer.accepted else { return }
|
||||
self.finishOutgoing(transfer, outcome: .fallback, reason: "offer timed out")
|
||||
}
|
||||
transfer.offerTimeout = offerTimeout
|
||||
queue.asyncAfter(deadline: .now() + config.offerTimeout, execute: offerTimeout)
|
||||
|
||||
let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in
|
||||
guard let self, let transfer else { return }
|
||||
self.finishOutgoing(transfer, outcome: .fallback, reason: "transfer window expired")
|
||||
}
|
||||
transfer.windowTimeout = windowTimeout
|
||||
queue.asyncAfter(deadline: .now() + config.transferWindow, execute: windowTimeout)
|
||||
}
|
||||
|
||||
private func processResponse(_ payload: Data, from peerID: PeerID) {
|
||||
guard let response = WifiBulkResponse.decode(payload),
|
||||
let transfer = outgoing[response.transferID],
|
||||
transfer.peerID.toShort() == peerID.toShort(),
|
||||
!transfer.accepted, !transfer.finished else {
|
||||
return
|
||||
}
|
||||
|
||||
guard response.accepted, let receiverToken = response.token else {
|
||||
finishOutgoing(transfer, outcome: .fallback, reason: "offer declined")
|
||||
return
|
||||
}
|
||||
guard let key = WifiBulkCrypto.deriveKey(
|
||||
senderToken: transfer.token,
|
||||
receiverToken: receiverToken,
|
||||
transferID: transfer.transferID
|
||||
) else {
|
||||
finishOutgoing(transfer, outcome: .fallback, reason: "key derivation failed")
|
||||
return
|
||||
}
|
||||
|
||||
transfer.accepted = true
|
||||
transfer.offerTimeout?.cancel()
|
||||
transfer.offerTimeout = nil
|
||||
transfer.session?.activate(key: key)
|
||||
}
|
||||
|
||||
private func finishOutgoing(_ transfer: OutgoingTransfer, outcome: OutgoingOutcome, reason: String) {
|
||||
guard !transfer.finished else { return }
|
||||
transfer.finished = true
|
||||
transfer.offerTimeout?.cancel()
|
||||
transfer.windowTimeout?.cancel()
|
||||
transfer.session?.cancel()
|
||||
outgoing.removeValue(forKey: transfer.transferID)
|
||||
|
||||
switch outcome {
|
||||
case .completed:
|
||||
SecureLogger.debug("WifiBulk: transfer \(transfer.transferId.prefix(8))… completed (\(reason))", category: .session)
|
||||
case .fallback:
|
||||
SecureLogger.info("WifiBulk: transfer \(transfer.transferId.prefix(8))… falling back to BLE (\(reason))", category: .session)
|
||||
environment.progressReset(transfer.transferId)
|
||||
transfer.fallback()
|
||||
case .cancelled:
|
||||
SecureLogger.debug("WifiBulk: transfer \(transfer.transferId.prefix(8))… cancelled", category: .session)
|
||||
environment.progressCancel(transfer.transferId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Receiver
|
||||
|
||||
/// Handles a decrypted `bulkTransferOffer` Noise payload.
|
||||
func handleOfferPayload(_ payload: Data, from peerID: PeerID) {
|
||||
queue.async { [weak self] in
|
||||
self?.processOffer(payload, from: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func processOffer(_ payload: Data, from peerID: PeerID) {
|
||||
guard let offer = WifiBulkOffer.decode(payload) else { return }
|
||||
guard incoming[offer.transferID] == nil else { return }
|
||||
|
||||
guard WifiBulkPolicy.shouldAccept(
|
||||
offer: offer,
|
||||
senderIsDirectlyConnected: environment.isPeerConnected(peerID),
|
||||
activeIncomingTransfers: incoming.count,
|
||||
maxPayloadBytes: config.maxIncomingPayloadBytes,
|
||||
maxConcurrentIncoming: config.maxConcurrentIncoming
|
||||
) else {
|
||||
decline(offer: offer, peerID: peerID)
|
||||
return
|
||||
}
|
||||
|
||||
let token = Self.randomData(WifiBulkWire.tokenLength)
|
||||
guard let key = WifiBulkCrypto.deriveKey(
|
||||
senderToken: offer.token,
|
||||
receiverToken: token,
|
||||
transferID: offer.transferID
|
||||
),
|
||||
let responseData = WifiBulkResponse.accept(transferID: offer.transferID, token: token).encode() else {
|
||||
decline(offer: offer, peerID: peerID)
|
||||
return
|
||||
}
|
||||
guard environment.sendNoisePayload(
|
||||
BLENoisePayloadFactory.typedPayload(.bulkTransferResponse, payload: responseData),
|
||||
peerID
|
||||
) else {
|
||||
return // No session to answer on; the sender's timeout handles fallback.
|
||||
}
|
||||
|
||||
let transfer = IncomingTransfer(offer: offer, peerID: peerID, key: key)
|
||||
incoming[offer.transferID] = transfer
|
||||
SecureLogger.debug("WifiBulk: accepted offer of \(offer.fileSize) bytes from \(peerID.id.prefix(8))…", category: .session)
|
||||
|
||||
startBrowsing(for: transfer)
|
||||
|
||||
let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in
|
||||
guard let self, let transfer else { return }
|
||||
SecureLogger.info("WifiBulk: incoming transfer window expired", category: .session)
|
||||
self.tearDownIncoming(transfer)
|
||||
}
|
||||
transfer.windowTimeout = windowTimeout
|
||||
queue.asyncAfter(deadline: .now() + config.transferWindow, execute: windowTimeout)
|
||||
}
|
||||
|
||||
private func decline(offer: WifiBulkOffer, peerID: PeerID) {
|
||||
SecureLogger.debug("WifiBulk: declining offer of \(offer.fileSize) bytes from \(peerID.id.prefix(8))…", category: .session)
|
||||
guard let responseData = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return }
|
||||
_ = environment.sendNoisePayload(
|
||||
BLENoisePayloadFactory.typedPayload(.bulkTransferResponse, payload: responseData),
|
||||
peerID
|
||||
)
|
||||
}
|
||||
|
||||
private func startBrowsing(for transfer: IncomingTransfer) {
|
||||
let browser = NWBrowser(
|
||||
for: .bonjour(type: config.serviceType, domain: nil),
|
||||
using: makeParameters()
|
||||
)
|
||||
transfer.browser = browser
|
||||
browser.browseResultsChangedHandler = { [weak self, weak transfer] results, _ in
|
||||
guard let self, let transfer, transfer.session == nil else { return }
|
||||
let match = results.first { result in
|
||||
if case .service(let name, _, _, _) = result.endpoint {
|
||||
return name == transfer.offer.serviceName
|
||||
}
|
||||
return false
|
||||
}
|
||||
guard let match else { return }
|
||||
self.connect(transfer, to: match.endpoint)
|
||||
}
|
||||
browser.stateUpdateHandler = { [weak self, weak transfer] state in
|
||||
guard let self, let transfer else { return }
|
||||
if case .failed(let error) = state {
|
||||
SecureLogger.warning("WifiBulk: browser failed: \(error)", category: .session)
|
||||
self.tearDownIncoming(transfer)
|
||||
}
|
||||
}
|
||||
browser.start(queue: queue)
|
||||
}
|
||||
|
||||
/// Test hook: connects an accepted incoming transfer straight to an
|
||||
/// endpoint, standing in for Bonjour discovery on hosts without mDNS.
|
||||
func _test_connectIncoming(transferID: Data, to endpoint: NWEndpoint) {
|
||||
queue.async { [weak self] in
|
||||
guard let self, let transfer = self.incoming[transferID], transfer.session == nil else { return }
|
||||
self.connect(transfer, to: endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
private func connect(_ transfer: IncomingTransfer, to endpoint: NWEndpoint) {
|
||||
transfer.browser?.cancel()
|
||||
transfer.browser = nil
|
||||
|
||||
guard let session = WifiBulkReceiverSession(
|
||||
endpoint: endpoint,
|
||||
parameters: makeParameters(),
|
||||
key: transfer.key,
|
||||
transferID: transfer.offer.transferID,
|
||||
expectedSize: transfer.offer.fileSize,
|
||||
expectedHash: transfer.offer.payloadHash,
|
||||
sizeCap: config.maxIncomingPayloadBytes,
|
||||
chunkBytes: config.chunkBytes,
|
||||
queue: queue
|
||||
) else {
|
||||
tearDownIncoming(transfer)
|
||||
return
|
||||
}
|
||||
session.onCompleted = { [weak self, weak transfer] payload in
|
||||
guard let self, let transfer else { return }
|
||||
SecureLogger.debug("WifiBulk: received \(payload.count) bytes from \(transfer.peerID.id.prefix(8))…", category: .session)
|
||||
self.environment.deliverReceivedFile(payload, transfer.peerID, self.config.maxIncomingPayloadBytes)
|
||||
self.tearDownIncoming(transfer)
|
||||
}
|
||||
session.onFailed = { [weak self, weak transfer] reason in
|
||||
guard let self, let transfer else { return }
|
||||
SecureLogger.info("WifiBulk: incoming transfer failed (\(reason)); sender falls back to BLE", category: .session)
|
||||
self.tearDownIncoming(transfer)
|
||||
}
|
||||
transfer.session = session
|
||||
session.start()
|
||||
}
|
||||
|
||||
private func tearDownIncoming(_ transfer: IncomingTransfer) {
|
||||
tearDownIncomingResources(transfer)
|
||||
incoming.removeValue(forKey: transfer.offer.transferID)
|
||||
}
|
||||
|
||||
private func tearDownIncomingResources(_ transfer: IncomingTransfer) {
|
||||
transfer.windowTimeout?.cancel()
|
||||
transfer.windowTimeout = nil
|
||||
transfer.browser?.cancel()
|
||||
transfer.browser = nil
|
||||
transfer.session?.cancel()
|
||||
transfer.session = nil
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makeParameters() -> NWParameters {
|
||||
let parameters = NWParameters.tcp
|
||||
if config.usePeerToPeer {
|
||||
parameters.includePeerToPeer = true
|
||||
// Keep the channel off infrastructure-independent radios we never
|
||||
// want (cellular/wired); AWDL rides on the peer-to-peer flag.
|
||||
parameters.prohibitedInterfaceTypes = [.cellular, .wiredEthernet, .loopback]
|
||||
}
|
||||
return parameters
|
||||
}
|
||||
|
||||
/// Cryptographically secure random bytes (Swift's default RNG is CSPRNG-backed).
|
||||
private static func randomData(_ count: Int) -> Data {
|
||||
Data((0..<count).map { _ in UInt8.random(in: .min ... .max) })
|
||||
}
|
||||
|
||||
// MARK: - Test observability
|
||||
|
||||
/// Test hook: reports each outgoing listener's bound port, standing in
|
||||
/// for Bonjour resolution on hosts without mDNS. Set before `sendFile`.
|
||||
var _test_onListenerReady: ((_ transferID: Data, _ port: UInt16) -> Void)?
|
||||
|
||||
var _test_activeOutgoingCount: Int {
|
||||
queue.sync { outgoing.count }
|
||||
}
|
||||
|
||||
var _test_activeIncomingCount: Int {
|
||||
queue.sync { incoming.count }
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,13 @@ import CryptoKit
|
||||
// - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary (q ones then a zero), then write P-bit remainder r = (x - 1) & ((1<<P)-1).
|
||||
// - Bitstream is MSB-first within each byte.
|
||||
enum GCSFilter {
|
||||
struct Params { let p: Int; let m: UInt32; let data: Data }
|
||||
// `includedCount` is how many of the input `ids` (in input order) the
|
||||
// returned filter actually encodes. It can be below `ids.count` when the
|
||||
// Golomb-Rice encoding overflows the byte budget and the tail is trimmed.
|
||||
// Callers that derive a since-cursor need this: trimming drops from the
|
||||
// input tail, so the first `includedCount` inputs are exactly what the
|
||||
// filter covers.
|
||||
struct Params { let p: Int; let m: UInt32; let data: Data; let includedCount: Int }
|
||||
|
||||
// Highest Golomb-Rice parameter we accept from the wire. P maps to an FPR
|
||||
// of ~1/2^P; beyond 32 the remainder width exceeds any practical filter
|
||||
@@ -35,39 +41,40 @@ enum GCSFilter {
|
||||
static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params {
|
||||
let p = deriveP(targetFpr: targetFpr)
|
||||
guard !ids.isEmpty else {
|
||||
return Params(p: p, m: 1, data: Data())
|
||||
return Params(p: p, m: 1, data: Data(), includedCount: 0)
|
||||
}
|
||||
|
||||
let cap = estimateMaxElements(sizeBytes: maxBytes, p: p)
|
||||
let selected = Array(ids.prefix(cap))
|
||||
let range = max(1, hashRange(count: selected.count, p: p))
|
||||
// Modulus is fixed to the initial candidate count so `m` stays stable
|
||||
// as the tail is trimmed to fit the byte budget below.
|
||||
let range = max(1, hashRange(count: min(ids.count, cap), p: p))
|
||||
let modulo = UInt64(range)
|
||||
|
||||
var mapped = selected
|
||||
.map { h64($0) }
|
||||
.map { mapHash($0, modulo: modulo) }
|
||||
.sorted()
|
||||
mapped = normalizeMappedValues(mapped, modulo: modulo)
|
||||
|
||||
if mapped.isEmpty {
|
||||
return Params(p: p, m: range, data: Data())
|
||||
// Encode the first `count` inputs (input order). The caller passes IDs
|
||||
// newest-first, so trimming from the tail drops the oldest — which is
|
||||
// what lets a since-cursor stay exact: the surviving set is always a
|
||||
// contiguous newest-prefix, never a hash-order-arbitrary subset.
|
||||
func encodeFirst(_ count: Int) -> Data {
|
||||
var mapped = ids.prefix(count)
|
||||
.map { h64($0) }
|
||||
.map { mapHash($0, modulo: modulo) }
|
||||
.sorted()
|
||||
mapped = normalizeMappedValues(mapped, modulo: modulo)
|
||||
return mapped.isEmpty ? Data() : encode(sorted: mapped, p: p)
|
||||
}
|
||||
|
||||
var encoded = encode(sorted: mapped, p: p)
|
||||
var trimmedCount = mapped.count
|
||||
|
||||
while encoded.count > maxBytes && trimmedCount > 0 {
|
||||
if trimmedCount == 1 {
|
||||
mapped.removeAll()
|
||||
encoded = Data()
|
||||
break
|
||||
}
|
||||
trimmedCount = max(1, (trimmedCount * 9) / 10)
|
||||
mapped = Array(mapped.prefix(trimmedCount))
|
||||
encoded = encode(sorted: mapped, p: p)
|
||||
var count = min(ids.count, cap)
|
||||
var encoded = encodeFirst(count)
|
||||
while encoded.count > maxBytes && count > 1 {
|
||||
count = max(1, (count * 9) / 10)
|
||||
encoded = encodeFirst(count)
|
||||
}
|
||||
// A single element that still overflows can't be represented.
|
||||
if encoded.count > maxBytes {
|
||||
return Params(p: p, m: range, data: Data(), includedCount: 0)
|
||||
}
|
||||
|
||||
return Params(p: p, m: range, data: encoded)
|
||||
return Params(p: p, m: range, data: encoded, includedCount: encoded.isEmpty ? 0 : count)
|
||||
}
|
||||
|
||||
static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {
|
||||
|
||||
@@ -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 gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
||||
var gcsTargetFpr: Double = 0.01 // 1%
|
||||
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
|
||||
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - fragments/files/announces
|
||||
// Whole public messages stay sync-able much longer so devices carry
|
||||
// the room's recent history between partitions and across restarts.
|
||||
var publicMessageMaxAgeSeconds: TimeInterval = 900
|
||||
var maintenanceIntervalSeconds: TimeInterval = 30.0
|
||||
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||
@@ -73,11 +76,14 @@ final class GossipSyncManager {
|
||||
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
|
||||
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
|
||||
var messageSyncIntervalSeconds: TimeInterval = 15.0
|
||||
var responseRateLimitMaxResponses: Int = 8
|
||||
var responseRateLimitWindowSeconds: TimeInterval = 30.0
|
||||
}
|
||||
|
||||
private let myPeerID: PeerID
|
||||
private let config: Config
|
||||
private let requestSyncManager: RequestSyncManager
|
||||
private let archive: GossipMessageArchive?
|
||||
weak var delegate: Delegate?
|
||||
|
||||
// Storage: broadcast packets by type, and latest announce per sender
|
||||
@@ -85,17 +91,24 @@ final class GossipSyncManager {
|
||||
private var fragments = PacketStore()
|
||||
private var fileTransfers = PacketStore()
|
||||
private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
|
||||
private var archiveDirty = false
|
||||
|
||||
// Timer
|
||||
private var periodicTimer: DispatchSourceTimer?
|
||||
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
||||
private var lastStalePeerCleanup: Date = .distantPast
|
||||
private var syncSchedules: [SyncSchedule] = []
|
||||
private var responseRateLimiter: SyncResponseRateLimiter
|
||||
|
||||
init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) {
|
||||
init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager, archive: GossipMessageArchive? = nil) {
|
||||
self.myPeerID = myPeerID
|
||||
self.config = config
|
||||
self.requestSyncManager = requestSyncManager
|
||||
self.archive = archive
|
||||
self.responseRateLimiter = SyncResponseRateLimiter(
|
||||
maxResponses: config.responseRateLimitMaxResponses,
|
||||
window: config.responseRateLimitWindowSeconds
|
||||
)
|
||||
var schedules: [SyncSchedule] = []
|
||||
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
|
||||
@@ -107,6 +120,12 @@ final class GossipSyncManager {
|
||||
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
|
||||
}
|
||||
syncSchedules = schedules
|
||||
|
||||
if archive != nil {
|
||||
queue.async { [weak self] in
|
||||
self?.restoreArchivedMessages()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func start() {
|
||||
@@ -146,10 +165,15 @@ final class GossipSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to check if a packet is within the age threshold
|
||||
// Helper to check if a packet is within the age threshold. Whole public
|
||||
// messages get the long town-crier window; fragments, file transfers and
|
||||
// announces keep the short one.
|
||||
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
||||
let maxAgeSeconds = packet.type == MessageType.message.rawValue
|
||||
? config.publicMessageMaxAgeSeconds
|
||||
: config.maxMessageAgeSeconds
|
||||
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let ageThresholdMs = UInt64(config.maxMessageAgeSeconds * 1000)
|
||||
let ageThresholdMs = UInt64(maxAgeSeconds * 1000)
|
||||
|
||||
// If current time is less than threshold, accept all (handle clock issues gracefully)
|
||||
guard nowMs >= ageThresholdMs else { return true }
|
||||
@@ -190,6 +214,7 @@ final class GossipSyncManager {
|
||||
guard isPacketFresh(packet) else { return }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
|
||||
archiveDirty = true
|
||||
case .fragment:
|
||||
guard isBroadcastRecipient else { return }
|
||||
guard isPacketFresh(packet) else { return }
|
||||
@@ -265,7 +290,17 @@ final class GossipSyncManager {
|
||||
}
|
||||
|
||||
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
||||
// A response can replay the whole store, so bound how often one peer
|
||||
// can trigger a diff pass regardless of how fast it asks.
|
||||
guard responseRateLimiter.shouldRespond(to: peerID, now: Date()) else {
|
||||
SecureLogger.warning("Rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .sync)
|
||||
return
|
||||
}
|
||||
let requestedTypes = (request.types ?? .publicMessages)
|
||||
// The requester's filter only covers packets at or after this cursor;
|
||||
// older packets are outside the filter but not missing, and without
|
||||
// the cursor they would be re-sent every round.
|
||||
let since = request.sinceTimestamp
|
||||
// Decode GCS into sorted set and prepare membership checker
|
||||
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
||||
func mightContain(_ id: Data) -> Bool {
|
||||
@@ -273,6 +308,9 @@ final class GossipSyncManager {
|
||||
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
||||
}
|
||||
|
||||
// Announces are exempt from the since-cursor: they carry the signing
|
||||
// keys needed to verify everything else, and there is at most one per
|
||||
// peer, so the resend cost is negligible.
|
||||
if requestedTypes.contains(.announce) {
|
||||
for (_, pair) in latestAnnouncementByPeer {
|
||||
let (idHex, pkt) = pair
|
||||
@@ -290,6 +328,7 @@ final class GossipSyncManager {
|
||||
if requestedTypes.contains(.message) {
|
||||
let toSendMsgs = messages.allPackets(isFresh: isPacketFresh)
|
||||
for pkt in toSendMsgs {
|
||||
if let since, pkt.timestamp < since { continue }
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
@@ -303,6 +342,7 @@ final class GossipSyncManager {
|
||||
if requestedTypes.contains(.fragment) {
|
||||
let frags = fragments.allPackets(isFresh: isPacketFresh)
|
||||
for pkt in frags {
|
||||
if let since, pkt.timestamp < since { continue }
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
@@ -316,6 +356,7 @@ final class GossipSyncManager {
|
||||
if requestedTypes.contains(.fileTransfer) {
|
||||
let files = fileTransfers.allPackets(isFresh: isPacketFresh)
|
||||
for pkt in files {
|
||||
if let since, pkt.timestamp < since { continue }
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
@@ -368,9 +409,22 @@ final class GossipSyncManager {
|
||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
||||
return req.encode()
|
||||
}
|
||||
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
|
||||
let included = Array(candidates.prefix(takeN))
|
||||
let ids: [Data] = included.map { PacketIdUtil.computeId($0) }
|
||||
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
|
||||
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types)
|
||||
// When the filter can't cover every candidate — either the store
|
||||
// exceeds `takeN` or the encoder trimmed the tail to fit the byte
|
||||
// budget — tell the responder how far back the filter actually
|
||||
// reaches. `includedCount` counts inputs in newest-first order, so the
|
||||
// covered set is a contiguous newest-prefix and the oldest included
|
||||
// timestamp is an exact cursor. Packets older than it are outside the
|
||||
// filter but not missing; without the cursor the responder would
|
||||
// re-send that entire tail every round.
|
||||
let covered = params.includedCount
|
||||
let sinceTimestamp: UInt64? = (covered < candidates.count && covered > 0)
|
||||
? included[covered - 1].timestamp
|
||||
: nil
|
||||
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types, sinceTimestamp: sinceTimestamp)
|
||||
return req.encode()
|
||||
}
|
||||
|
||||
@@ -381,28 +435,68 @@ final class GossipSyncManager {
|
||||
isPacketFresh(pair.packet)
|
||||
}
|
||||
|
||||
let messageCountBefore = messages.packets.count
|
||||
messages.removeExpired(isFresh: isPacketFresh)
|
||||
if messages.packets.count != messageCountBefore {
|
||||
archiveDirty = true
|
||||
}
|
||||
fragments.removeExpired(isFresh: isPacketFresh)
|
||||
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
||||
}
|
||||
|
||||
// MARK: - Archive (public message persistence)
|
||||
|
||||
/// Rebuild the public message store from disk on launch, dropping
|
||||
/// anything that aged out while the app was dead.
|
||||
private func restoreArchivedMessages() {
|
||||
guard let archive else { return }
|
||||
var restored = 0
|
||||
for data in archive.load() {
|
||||
guard let packet = BitchatPacket.from(data),
|
||||
packet.type == MessageType.message.rawValue,
|
||||
isPacketFresh(packet) else { continue }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
|
||||
restored += 1
|
||||
}
|
||||
if restored > 0 {
|
||||
SecureLogger.debug("Restored \(restored) archived public message(s) for gossip sync", category: .sync)
|
||||
archiveDirty = true
|
||||
}
|
||||
}
|
||||
|
||||
private func persistArchiveIfDirty() {
|
||||
guard archiveDirty, let archive else { return }
|
||||
archiveDirty = false
|
||||
let packets = messages.allPackets(isFresh: isPacketFresh)
|
||||
.compactMap { $0.toBinaryData(padding: false) }
|
||||
archive.save(packets)
|
||||
}
|
||||
|
||||
/// Flush the archive outside the maintenance cadence (app backgrounding).
|
||||
func persistNow() {
|
||||
queue.async { [weak self] in
|
||||
self?.persistArchiveIfDirty()
|
||||
}
|
||||
}
|
||||
|
||||
private func performPeriodicMaintenance(now: Date = Date()) {
|
||||
cleanupExpiredMessages()
|
||||
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||
persistArchiveIfDirty()
|
||||
requestSyncManager.cleanup() // Cleanup expired sync requests
|
||||
responseRateLimiter.prune(now: now)
|
||||
|
||||
var dueTypes: SyncTypeFlags = []
|
||||
// One request per due schedule rather than a union filter: each type
|
||||
// group gets the full GCS capacity and its own since-cursor, so heavy
|
||||
// fragment traffic can't crowd messages out of the filter.
|
||||
for index in syncSchedules.indices {
|
||||
guard syncSchedules[index].interval > 0 else { continue }
|
||||
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
||||
syncSchedules[index].lastSent = now
|
||||
dueTypes.formUnion(syncSchedules[index].types)
|
||||
sendPeriodicSync(for: syncSchedules[index].types)
|
||||
}
|
||||
}
|
||||
|
||||
if !dueTypes.isEmpty {
|
||||
sendPeriodicSync(for: dueTypes)
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
||||
@@ -436,7 +530,11 @@ final class GossipSyncManager {
|
||||
|
||||
private func removeState(for peerID: PeerID) {
|
||||
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
|
||||
let messageCountBefore = messages.packets.count
|
||||
messages.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
if messages.packets.count != messageCountBefore {
|
||||
archiveDirty = true
|
||||
}
|
||||
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Sliding-window limiter for REQUEST_SYNC responses.
|
||||
///
|
||||
/// A single sync response can replay the entire gossip store, so a peer that
|
||||
/// requests in a tight loop must not be able to drain the airtime and battery
|
||||
/// of everyone in radio range. Legitimate peers send at most a few requests
|
||||
/// per maintenance tick (one per type schedule, plus the initial sync).
|
||||
struct SyncResponseRateLimiter {
|
||||
private let maxResponses: Int
|
||||
private let window: TimeInterval
|
||||
private var history: [PeerID: [Date]] = [:]
|
||||
|
||||
init(maxResponses: Int, window: TimeInterval) {
|
||||
self.maxResponses = max(1, maxResponses)
|
||||
self.window = max(0, window)
|
||||
}
|
||||
|
||||
/// Returns true (and records the response) if the peer is under its
|
||||
/// response budget for the current window.
|
||||
mutating func shouldRespond(to peerID: PeerID, now: Date) -> Bool {
|
||||
let cutoff = now.addingTimeInterval(-window)
|
||||
var recent = (history[peerID] ?? []).filter { $0 >= cutoff }
|
||||
guard recent.count < maxResponses else {
|
||||
history[peerID] = recent
|
||||
return false
|
||||
}
|
||||
recent.append(now)
|
||||
history[peerID] = recent
|
||||
return true
|
||||
}
|
||||
|
||||
/// Drops history outside the window so departed peers don't accumulate.
|
||||
mutating func prune(now: Date) {
|
||||
let cutoff = now.addingTimeInterval(-window)
|
||||
history = history.compactMapValues { dates in
|
||||
let recent = dates.filter { $0 >= cutoff }
|
||||
return recent.isEmpty ? nil : recent
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,9 @@ struct SyncTypeFlags: OptionSet {
|
||||
case .fragment: return 5
|
||||
case .requestSync: return 6
|
||||
case .fileTransfer: return 7
|
||||
// Courier envelopes are directed deposits between trusted peers and
|
||||
// must never spread via gossip sync.
|
||||
case .courierEnvelope: return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -355,9 +355,10 @@ private extension ChatLifecycleCoordinator {
|
||||
case .failed: return 1
|
||||
case .sending: return 2
|
||||
case .sent: return 3
|
||||
case .partiallyDelivered: return 4
|
||||
case .delivered: return 5
|
||||
case .read: return 6
|
||||
case .carried: return 4
|
||||
case .partiallyDelivered: return 5
|
||||
case .delivered: return 6
|
||||
case .read: return 7
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,13 +21,9 @@ protocol ChatNostrContext: GeohashSubscriptionContext, NostrInboundPipelineConte
|
||||
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||
|
||||
// MARK: Favorites & notifications (shared with the other contexts)
|
||||
// MARK: Favorites (shared with the other contexts)
|
||||
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
||||
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
/// Adds (or updates) a favorite in the favorites store.
|
||||
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String)
|
||||
/// Posts a generic local user notification.
|
||||
func postLocalNotification(title: String, body: String, identifier: String)
|
||||
}
|
||||
|
||||
extension ChatViewModel: ChatNostrContext {
|
||||
@@ -98,57 +94,6 @@ final class ChatNostrCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
|
||||
guard let context else { return }
|
||||
guard let senderNoiseKey = inbound.findNoiseKey(for: nostrPubkey) else { return }
|
||||
|
||||
let isFavorite = content.contains("FAVORITE:TRUE")
|
||||
let senderNickname = content.components(separatedBy: "|").last ?? "Unknown"
|
||||
|
||||
if isFavorite {
|
||||
context.addFavorite(
|
||||
noiseKey: senderNoiseKey,
|
||||
nostrPublicKey: nostrPubkey,
|
||||
nickname: senderNickname
|
||||
)
|
||||
}
|
||||
|
||||
var extractedNostrPubkey: String?
|
||||
if let range = content.range(of: "NPUB:") {
|
||||
let suffix = content[range.upperBound...]
|
||||
let parts = suffix.components(separatedBy: "|")
|
||||
if let key = parts.first {
|
||||
extractedNostrPubkey = String(key)
|
||||
}
|
||||
} else if content.contains(":") {
|
||||
let parts = content.components(separatedBy: ":")
|
||||
if parts.count >= 3 {
|
||||
extractedNostrPubkey = String(parts[2])
|
||||
}
|
||||
}
|
||||
|
||||
SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session)
|
||||
|
||||
if isFavorite && extractedNostrPubkey != nil {
|
||||
SecureLogger.info(
|
||||
"💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...",
|
||||
category: .session
|
||||
)
|
||||
context.addFavorite(
|
||||
noiseKey: senderNoiseKey,
|
||||
nostrPublicKey: extractedNostrPubkey,
|
||||
nickname: senderNickname
|
||||
)
|
||||
}
|
||||
|
||||
context.postLocalNotification(
|
||||
title: isFavorite ? "New Favorite" : "Favorite Removed",
|
||||
body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you",
|
||||
identifier: "fav-\(UUID().uuidString)"
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
||||
guard let context else { return }
|
||||
|
||||
@@ -92,7 +92,6 @@ protocol ChatPrivateConversationContext: AnyObject {
|
||||
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String)
|
||||
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||
func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?)
|
||||
|
||||
// MARK: System messages
|
||||
func addSystemMessage(_ content: String)
|
||||
@@ -101,6 +100,9 @@ protocol ChatPrivateConversationContext: AnyObject {
|
||||
// MARK: Favorites & notifications
|
||||
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
||||
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
/// The persisted favorite relationship resolved from a short 16-hex mesh
|
||||
/// peer ID (matched against the IDs derived from stored noise keys).
|
||||
func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||
/// Persists that the peer favorited/unfavorited us (favorites store write).
|
||||
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)
|
||||
/// Posts the incoming-private-message local notification.
|
||||
@@ -197,6 +199,10 @@ extension ChatViewModel: ChatPrivateConversationContext {
|
||||
FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
|
||||
}
|
||||
|
||||
// `favoriteRelationship(forPeerID:)` is shared with
|
||||
// `ChatPeerIdentityContext`; its witness lives in
|
||||
// `ChatPeerIdentityCoordinator.swift`.
|
||||
|
||||
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) {
|
||||
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
@@ -245,10 +251,15 @@ final class ChatPrivateConversationCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
guard let noiseKey = Data(hexString: peerID.id) else { return }
|
||||
// Resolve the favorite behind this conversation. It may be keyed by
|
||||
// the full 64-hex noise-key ID (offline favorite row) or the short
|
||||
// 16-hex mesh ID — the raw hex bytes of a short ID are a routing ID,
|
||||
// never a noise key, so they must not be used as a favorites key.
|
||||
let noiseKey = peerID.noiseKey ?? context.noisePublicKey(for: peerID)
|
||||
let isConnected = context.isPeerConnected(peerID)
|
||||
let isReachable = context.isPeerReachable(peerID)
|
||||
let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey)
|
||||
let favoriteStatus = noiseKey.flatMap { context.favoriteRelationship(forNoiseKey: $0) }
|
||||
?? context.favoriteRelationship(forPeerID: peerID)
|
||||
let isMutualFavorite = favoriteStatus?.isMutual ?? false
|
||||
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
|
||||
|
||||
@@ -405,9 +416,32 @@ final class ChatPrivateConversationCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer the favorite's stored nickname when the sender resolved to a
|
||||
// known noise key; the Nostr display name is a geohash-scoped
|
||||
// fallback (e.g. "anon#678e") that would mislabel favorite-transport
|
||||
// DMs. Geohash conversations (nostr_ keys) keep the geo name.
|
||||
let senderName: String = {
|
||||
if let noiseKey = convKey.noiseKey,
|
||||
let favoriteNickname = context.favoriteRelationship(forNoiseKey: noiseKey)?.peerNickname,
|
||||
!favoriteNickname.isEmpty {
|
||||
return favoriteNickname
|
||||
}
|
||||
return context.displayNameForNostrPubkey(senderPubkey)
|
||||
}()
|
||||
|
||||
// Favorite notifications ride the PM channel over Nostr too; intercept
|
||||
// them so they update the relationship instead of rendering as text.
|
||||
if pm.content.hasPrefix("[FAVORITED]") || pm.content.hasPrefix("[UNFAVORITED]") {
|
||||
handleFavoriteNotification(
|
||||
pm.content,
|
||||
from: convKey,
|
||||
senderNickname: senderName
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if context.privateChatsContainMessage(withID: messageId) { return }
|
||||
|
||||
let senderName = context.displayNameForNostrPubkey(senderPubkey)
|
||||
let message = BitchatMessage(
|
||||
id: messageId,
|
||||
sender: senderName,
|
||||
@@ -486,93 +520,6 @@ final class ChatPrivateConversationCoordinator {
|
||||
context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id)
|
||||
}
|
||||
|
||||
func handlePrivateMessage(
|
||||
_ payload: NoisePayload,
|
||||
actualSenderNoiseKey: Data?,
|
||||
senderNickname: String,
|
||||
targetPeerID: PeerID,
|
||||
messageTimestamp: Date,
|
||||
senderPubkey: String
|
||||
) {
|
||||
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
|
||||
let messageId = pm.messageID
|
||||
let messageContent = pm.content
|
||||
|
||||
if messageContent.hasPrefix("[FAVORITED]") || messageContent.hasPrefix("[UNFAVORITED]") {
|
||||
if let key = actualSenderNoiseKey {
|
||||
handleFavoriteNotificationFromMesh(
|
||||
messageContent,
|
||||
from: PeerID(hexData: key),
|
||||
senderNickname: senderNickname
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if isDuplicateMessage(messageId, targetPeerID: targetPeerID) {
|
||||
return
|
||||
}
|
||||
|
||||
let wasReadBefore = context.sentReadReceipts.contains(messageId)
|
||||
|
||||
var isViewingThisChat = false
|
||||
if context.selectedPrivateChatPeer == targetPeerID {
|
||||
isViewingThisChat = true
|
||||
} else if let selectedPeer = context.selectedPrivateChatPeer,
|
||||
let selectedPeerNoiseKey = context.noisePublicKey(for: selectedPeer),
|
||||
let key = actualSenderNoiseKey,
|
||||
selectedPeerNoiseKey == key {
|
||||
isViewingThisChat = true
|
||||
}
|
||||
|
||||
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
|
||||
let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && isRecentMessage
|
||||
|
||||
let message = BitchatMessage(
|
||||
id: messageId,
|
||||
sender: senderNickname,
|
||||
content: messageContent,
|
||||
timestamp: messageTimestamp,
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: context.nickname,
|
||||
senderPeerID: targetPeerID,
|
||||
deliveryStatus: .delivered(to: context.nickname, at: Date())
|
||||
)
|
||||
|
||||
addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID)
|
||||
mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: actualSenderNoiseKey)
|
||||
|
||||
context.sendDeliveryAckViaNostrEmbedded(
|
||||
message,
|
||||
wasReadBefore: wasReadBefore,
|
||||
senderPubkey: senderPubkey,
|
||||
key: actualSenderNoiseKey
|
||||
)
|
||||
|
||||
if wasReadBefore {
|
||||
// No-op.
|
||||
} else if isViewingThisChat {
|
||||
handleViewingThisChat(
|
||||
message,
|
||||
targetPeerID: targetPeerID,
|
||||
key: actualSenderNoiseKey,
|
||||
senderPubkey: senderPubkey
|
||||
)
|
||||
} else {
|
||||
markAsUnreadIfNeeded(
|
||||
shouldMarkAsUnread: shouldMarkAsUnread,
|
||||
targetPeerID: targetPeerID,
|
||||
key: actualSenderNoiseKey,
|
||||
isRecentMessage: isRecentMessage,
|
||||
senderNickname: senderNickname,
|
||||
messageContent: messageContent
|
||||
)
|
||||
}
|
||||
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
|
||||
func handlePrivateMessage(_ message: BitchatMessage) {
|
||||
SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session)
|
||||
let senderPeerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender)
|
||||
@@ -583,7 +530,7 @@ final class ChatPrivateConversationCoordinator {
|
||||
}
|
||||
|
||||
if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") {
|
||||
handleFavoriteNotificationFromMesh(message.content, from: peerID, senderNickname: message.sender)
|
||||
handleFavoriteNotification(message.content, from: peerID, senderNickname: message.sender)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -706,7 +653,10 @@ final class ChatPrivateConversationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) {
|
||||
/// Applies an inbound `[FAVORITED]`/`[UNFAVORITED]` marker from either
|
||||
/// transport. `peerID` must resolve to a noise key — a full 64-hex ID or
|
||||
/// one the unified peer list knows; otherwise the notification is dropped.
|
||||
func handleFavoriteNotification(_ content: String, from peerID: PeerID, senderNickname: String) {
|
||||
let isFavorite = content.hasPrefix("[FAVORITED]")
|
||||
let parts = content.split(separator: ":")
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ protocol ChatTransportEventContext: AnyObject {
|
||||
|
||||
// MARK: Routing & acknowledgements
|
||||
func flushRouterOutbox(for peerID: PeerID)
|
||||
/// Offer queued mail for *other* peers to this newly connected courier.
|
||||
func retryCourierDeposits(via peerID: PeerID)
|
||||
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID)
|
||||
|
||||
// MARK: Delivery status
|
||||
@@ -103,6 +105,10 @@ extension ChatViewModel: ChatTransportEventContext {
|
||||
messageRouter.flushOutbox(for: peerID)
|
||||
}
|
||||
|
||||
func retryCourierDeposits(via peerID: PeerID) {
|
||||
messageRouter.courierBecameAvailable(peerID)
|
||||
}
|
||||
|
||||
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||
meshService.sendDeliveryAck(for: messageID, to: peerID)
|
||||
}
|
||||
@@ -208,6 +214,7 @@ final class ChatTransportEventCoordinator {
|
||||
}
|
||||
|
||||
context.flushRouterOutbox(for: peerID)
|
||||
context.retryCourierDeposits(via: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +371,11 @@ private extension ChatTransportEventCoordinator {
|
||||
|
||||
case .verifyResponse:
|
||||
context.handleVerifyResponsePayload(from: peerID, payload: payload)
|
||||
|
||||
case .bulkTransferOffer, .bulkTransferResponse:
|
||||
// Wi-Fi bulk negotiation is consumed inside the mesh transport
|
||||
// (BLEService); it never reaches the UI layer.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -764,15 +764,19 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
locationPresenceStore: LocationPresenceStore? = nil,
|
||||
locationManager: LocationChannelManager = .shared
|
||||
) {
|
||||
let meshService = BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
|
||||
meshService.sfMetrics = .shared
|
||||
self.init(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager),
|
||||
transport: meshService,
|
||||
conversations: conversations,
|
||||
peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(),
|
||||
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
|
||||
locationManager: locationManager
|
||||
locationManager: locationManager,
|
||||
outboxStore: MessageOutboxStore(keychain: keychain),
|
||||
sfMetrics: .shared
|
||||
)
|
||||
}
|
||||
|
||||
@@ -788,7 +792,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
peerIdentityStore: PeerIdentityStore? = nil,
|
||||
locationPresenceStore: LocationPresenceStore? = nil,
|
||||
locationManager: LocationChannelManager = .shared,
|
||||
readReceiptsDefaults: UserDefaults? = nil
|
||||
readReceiptsDefaults: UserDefaults? = nil,
|
||||
outboxStore: MessageOutboxStore? = nil,
|
||||
sfMetrics: StoreAndForwardMetrics? = nil
|
||||
) {
|
||||
let conversations = conversations ?? ConversationStore()
|
||||
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
||||
@@ -797,7 +803,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
meshService: transport
|
||||
meshService: transport,
|
||||
outboxStore: outboxStore,
|
||||
sfMetrics: sfMetrics
|
||||
)
|
||||
|
||||
self.keychain = keychain
|
||||
@@ -1189,6 +1197,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
// Clear persistent favorites from keychain
|
||||
FavoritesPersistenceService.shared.clearAllFavorites()
|
||||
|
||||
// Drop courier mail carried for third parties (memory and disk),
|
||||
// our own queued outbox, the carried public history, and the
|
||||
// counters describing all of it
|
||||
CourierStore.shared.wipe()
|
||||
messageRouter.wipeOutbox()
|
||||
GossipMessageArchive.wipeDefault()
|
||||
StoreAndForwardMetrics.shared.reset()
|
||||
|
||||
// Identity manager has cleared persisted identity data above
|
||||
|
||||
// Clear autocomplete state
|
||||
|
||||
@@ -17,7 +17,9 @@ struct ChatViewModelServiceBundle {
|
||||
keychain: KeychainManagerProtocol,
|
||||
idBridge: NostrIdentityBridge,
|
||||
identityManager: SecureIdentityStateManagerProtocol,
|
||||
meshService: Transport
|
||||
meshService: Transport,
|
||||
outboxStore: MessageOutboxStore? = nil,
|
||||
sfMetrics: StoreAndForwardMetrics? = nil
|
||||
) {
|
||||
let commandProcessor = CommandProcessor(identityManager: identityManager)
|
||||
let privateChatManager = PrivateChatManager(meshService: meshService)
|
||||
@@ -28,7 +30,11 @@ struct ChatViewModelServiceBundle {
|
||||
)
|
||||
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||
nostrTransport.senderPeerID = meshService.myPeerID
|
||||
let messageRouter = MessageRouter(transports: [meshService, nostrTransport])
|
||||
let messageRouter = MessageRouter(
|
||||
transports: [meshService, nostrTransport],
|
||||
outboxStore: outboxStore,
|
||||
metrics: sfMetrics
|
||||
)
|
||||
|
||||
self.commandProcessor = commandProcessor
|
||||
self.messageRouter = messageRouter
|
||||
@@ -105,6 +111,23 @@ private extension ChatViewModelBootstrapper {
|
||||
)
|
||||
}
|
||||
}
|
||||
// A message with no reachable transport that was handed to a courier
|
||||
// shows a distinct "carried" state instead of sitting in "sending"
|
||||
// forever. Never downgrade a confirmed receipt: the courier copy can
|
||||
// race direct delivery when the peer reappears.
|
||||
viewModel.messageRouter.onMessageCarried = { [weak viewModel] messageID, peerID in
|
||||
guard let viewModel else { return }
|
||||
switch viewModel.conversations.deliveryStatus(forMessageID: messageID) {
|
||||
case .delivered, .read:
|
||||
break
|
||||
default:
|
||||
SecureLogger.debug(
|
||||
"📦 Message \(messageID.prefix(8))… for \(peerID.id.prefix(8))… handed to courier → marked carried",
|
||||
category: .session
|
||||
)
|
||||
viewModel.conversations.setDeliveryStatus(.carried, forMessageID: messageID)
|
||||
}
|
||||
}
|
||||
viewModel.commandProcessor.contextProvider = viewModel
|
||||
viewModel.commandProcessor.meshService = viewModel.meshService
|
||||
viewModel.participantTracker.configure(context: viewModel)
|
||||
|
||||
@@ -109,11 +109,6 @@ extension ChatViewModel {
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
|
||||
nostrCoordinator.handleFavoriteNotification(content: content, from: nostrPubkey)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
||||
nostrCoordinator.sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: isFavorite)
|
||||
|
||||
@@ -121,25 +121,6 @@ extension ChatViewModel {
|
||||
mediaTransferCoordinator.deleteMediaMessage(messageID: messageID)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handlePrivateMessage(
|
||||
_ payload: NoisePayload,
|
||||
actualSenderNoiseKey: Data?,
|
||||
senderNickname: String,
|
||||
targetPeerID: PeerID,
|
||||
messageTimestamp: Date,
|
||||
senderPubkey: String
|
||||
) {
|
||||
privateConversationCoordinator.handlePrivateMessage(
|
||||
payload,
|
||||
actualSenderNoiseKey: actualSenderNoiseKey,
|
||||
senderNickname: senderNickname,
|
||||
targetPeerID: targetPeerID,
|
||||
messageTimestamp: messageTimestamp,
|
||||
senderPubkey: senderPubkey
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handlePrivateMessage(_ message: BitchatMessage) {
|
||||
privateConversationCoordinator.handlePrivateMessage(message)
|
||||
@@ -190,8 +171,8 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) {
|
||||
privateConversationCoordinator.handleFavoriteNotificationFromMesh(
|
||||
func handleFavoriteNotification(_ content: String, from peerID: PeerID, senderNickname: String) {
|
||||
privateConversationCoordinator.handleFavoriteNotification(
|
||||
content,
|
||||
from: peerID,
|
||||
senderNickname: senderNickname
|
||||
|
||||
@@ -297,7 +297,9 @@ final class NostrInboundPipeline {
|
||||
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .readReceipt:
|
||||
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
case .verifyChallenge, .verifyResponse,
|
||||
.bulkTransferOffer, .bulkTransferResponse:
|
||||
// Wi-Fi bulk negotiation is mesh-proximity only; it never rides Nostr.
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -349,7 +351,9 @@ final class NostrInboundPipeline {
|
||||
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .readReceipt:
|
||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
case .verifyChallenge, .verifyResponse,
|
||||
.bulkTransferOffer, .bulkTransferResponse:
|
||||
// Wi-Fi bulk negotiation is mesh-proximity only; it never rides Nostr.
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -428,7 +432,10 @@ final class NostrInboundPipeline {
|
||||
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .readReceipt:
|
||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
case .verifyChallenge, .verifyResponse,
|
||||
.bulkTransferOffer, .bulkTransferResponse:
|
||||
// Wi-Fi bulk negotiation is mesh-proximity only;
|
||||
// it never rides Nostr.
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -442,8 +449,7 @@ final class NostrInboundPipeline {
|
||||
}
|
||||
|
||||
/// Resolves the Noise static key behind a Nostr pubkey via the favorites
|
||||
/// store. Lives here because the inbound DM path needs it per message;
|
||||
/// the favorites glue in `ChatNostrCoordinator` delegates to it.
|
||||
/// store. Lives here because the inbound DM path needs it per message.
|
||||
@MainActor
|
||||
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
||||
guard let context else { return nil }
|
||||
|
||||
@@ -19,6 +19,8 @@ extension DeliveryStatus {
|
||||
return String(localized: "content.delivery.sending", comment: "Delivery status description while a private message is being sent")
|
||||
case .sent:
|
||||
return String(localized: "content.delivery.sent", comment: "Delivery status description for a sent but not yet confirmed private message")
|
||||
case .carried:
|
||||
return String(localized: "content.delivery.carried", defaultValue: "Carried by a friend who may meet them", comment: "Delivery status description for messages handed to a courier for physical delivery")
|
||||
case .delivered(let nickname, _):
|
||||
return String(
|
||||
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
|
||||
@@ -80,6 +82,11 @@ struct DeliveryStatusView: View {
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
|
||||
case .carried:
|
||||
Image(systemName: "figure.walk")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.8))
|
||||
|
||||
case .delivered:
|
||||
HStack(spacing: -2) {
|
||||
Image(systemName: "checkmark")
|
||||
@@ -120,6 +127,7 @@ struct DeliveryStatusView: View {
|
||||
let statuses: [DeliveryStatus] = [
|
||||
.sending,
|
||||
.sent,
|
||||
.carried,
|
||||
.delivered(to: "John Doe", at: Date()),
|
||||
.read(by: "Jane Doe", at: Date()),
|
||||
.failed(reason: "Offline"),
|
||||
|
||||
@@ -22,6 +22,9 @@ struct ContentHeaderView: View {
|
||||
let headerPeerIconSize: CGFloat
|
||||
let headerPeerCountFontSize: CGFloat
|
||||
|
||||
/// Courier envelopes this device is carrying for offline third parties.
|
||||
@State private var carriedMailCount = 0
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
Text(verbatim: "bitchat/")
|
||||
@@ -88,6 +91,23 @@ struct ContentHeaderView: View {
|
||||
}()
|
||||
|
||||
HStack(spacing: 2) {
|
||||
if carriedMailCount > 0 {
|
||||
Image(systemName: "figure.walk")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(palette.secondary.opacity(0.8))
|
||||
.headerTapTarget()
|
||||
.accessibilityLabel(
|
||||
String(
|
||||
format: String(localized: "content.accessibility.carrying_mail", defaultValue: "Carrying %lld sealed messages for friends", comment: "Accessibility label for the courier mail indicator"),
|
||||
locale: .current,
|
||||
carriedMailCount
|
||||
)
|
||||
)
|
||||
.help(
|
||||
String(localized: "content.header.carrying_mail", defaultValue: "Carrying sealed messages for friends to deliver", comment: "Tooltip for the courier mail indicator")
|
||||
)
|
||||
}
|
||||
|
||||
if appChromeModel.hasUnreadPrivateMessages {
|
||||
Button(action: { appChromeModel.openMostRelevantPrivateChat() }) {
|
||||
Image(systemName: "envelope.fill")
|
||||
@@ -214,6 +234,9 @@ struct ContentHeaderView: View {
|
||||
// Type.
|
||||
.frame(height: headerHeight)
|
||||
.padding(.horizontal, 12)
|
||||
.onReceive(CourierStore.shared.$carriedCount) { count in
|
||||
carriedMailCount = count
|
||||
}
|
||||
.sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) {
|
||||
LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented)
|
||||
.environmentObject(locationChannelsModel)
|
||||
|
||||
@@ -139,7 +139,7 @@ struct MediaMessageView: View {
|
||||
isSending = true
|
||||
progress = Double(reached) / Double(total)
|
||||
}
|
||||
case .sent, .read, .delivered, .failed:
|
||||
case .sent, .carried, .read, .delivered, .failed:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,6 +216,51 @@ struct BLEServiceCoreTests {
|
||||
cachedServiceUUIDs: [BLEService.serviceUUID, otherService]
|
||||
))
|
||||
}
|
||||
|
||||
// Regression: a stable/verified private chat addresses the peer by its
|
||||
// full 64-hex Noise key, but the Noise session (and capabilities/
|
||||
// connection) are keyed by the short routing ID. The Wi-Fi bulk send path
|
||||
// must normalize the ID first, or `hasEstablishedSession` misses and
|
||||
// Wi-Fi is never offered for a large payload to a direct `.wifiBulk` peer.
|
||||
@Test
|
||||
func wifiBulkEligibility_resolvesWhenAddressedBy64HexNoiseKey() throws {
|
||||
let ble = makeService()
|
||||
|
||||
let noiseKey = Data((0..<32).map { UInt8(($0 &* 7) &+ 3) })
|
||||
let fullKey = PeerID(str: noiseKey.hexEncodedString()) // 64-hex Noise key
|
||||
#expect(fullKey.noiseKey != nil)
|
||||
let shortID = fullKey.toShort() // 16-hex routing ID
|
||||
#expect(shortID != fullKey)
|
||||
|
||||
// Establish a real Noise session in the service's noise engine, keyed
|
||||
// by the short routing ID (exactly as a completed handshake would).
|
||||
let peer = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let noise = ble._test_noiseService
|
||||
let m1 = try noise.initiateHandshake(with: shortID)
|
||||
let m2 = try #require(try peer.processHandshakeMessage(from: shortID, message: m1))
|
||||
let m3 = try #require(try noise.processHandshakeMessage(from: shortID, message: m2))
|
||||
_ = try peer.processHandshakeMessage(from: shortID, message: m3)
|
||||
|
||||
#expect(noise.hasEstablishedSession(with: shortID))
|
||||
// The bug in raw form: querying by the 64-hex key misses the session.
|
||||
#expect(!noise.hasEstablishedSession(with: fullKey))
|
||||
|
||||
// Register the peer as a directly-connected `.wifiBulk` neighbor.
|
||||
ble._test_registerConnectedPeer(fullKey, capabilities: [.wifiBulk])
|
||||
|
||||
let bigPayload = FileTransferLimits.maxWifiBulkPayloadBytes
|
||||
|
||||
// The send path normalizes first, so eligibility + offer resolve for
|
||||
// both the short ID and the full 64-hex Noise key.
|
||||
let viaShort = ble._test_wifiBulkSendCandidate(payloadBytes: bigPayload, to: shortID)
|
||||
let viaFull = ble._test_wifiBulkSendCandidate(payloadBytes: bigPayload, to: fullKey)
|
||||
#expect(viaShort.hasEstablishedNoiseSession)
|
||||
#expect(viaFull.hasEstablishedNoiseSession)
|
||||
#expect(viaFull.isDirectlyConnected)
|
||||
#expect(viaFull.peerCapabilities.contains(.wifiBulk))
|
||||
#expect(WifiBulkPolicy.shouldOffer(viaShort, enabled: true))
|
||||
#expect(WifiBulkPolicy.shouldOffer(viaFull, enabled: true))
|
||||
}
|
||||
}
|
||||
|
||||
private func makeService() -> BLEService {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import BitFoundation
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import Testing
|
||||
import UniformTypeIdentifiers
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#else
|
||||
@@ -58,6 +61,25 @@ struct ChatMediaPreparationTests {
|
||||
#expect(prepared.packet.fileSize == UInt64(prepared.packet.content.count))
|
||||
#expect(prepared.packet.encode() != nil)
|
||||
}
|
||||
|
||||
/// A genuinely detailed photo must prepare to more than
|
||||
/// `TransportConfig.wifiBulkMinPayloadBytes` (64 KiB); otherwise the Wi-Fi
|
||||
/// bulk (AWDL) data plane is never offered in production because
|
||||
/// `WifiBulkPolicy.shouldOffer` requires `payloadBytes > 64 KiB`.
|
||||
/// Regression guard for the ~40 KB over-compression gap (PR #1385).
|
||||
@Test
|
||||
func prepareImagePacket_detailedImageExceedsWifiBulkThreshold() throws {
|
||||
let sourceURL = try makeDetailedImageURL(dimension: 1200)
|
||||
defer { try? FileManager.default.removeItem(at: sourceURL) }
|
||||
|
||||
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
|
||||
defer { try? FileManager.default.removeItem(at: prepared.outputURL) }
|
||||
|
||||
// Comfortably above the 64 KiB Wi-Fi bulk offer threshold...
|
||||
#expect(prepared.packet.content.count > TransportConfig.wifiBulkMinPayloadBytes)
|
||||
// ...and still within the hard image cap for the BLE path.
|
||||
#expect(prepared.packet.content.count <= FileTransferLimits.maxImageBytes)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTemporaryImageURL() throws -> URL {
|
||||
@@ -87,6 +109,50 @@ private func makeTemporaryImageURL() throws -> URL {
|
||||
return url
|
||||
}
|
||||
|
||||
/// Builds a random-noise PNG. Noise is incompressible, so the JPEG the prep
|
||||
/// pipeline produces stays large — a faithful stand-in for a detailed photo,
|
||||
/// unlike a flat solid-color image which would compress to a few KB regardless
|
||||
/// of dimension.
|
||||
private func makeDetailedImageURL(dimension: Int) throws -> URL {
|
||||
let width = dimension
|
||||
let height = dimension
|
||||
let bytesPerPixel = 4
|
||||
let bytesPerRow = width * bytesPerPixel
|
||||
|
||||
var pixels = [UInt8](repeating: 0, count: bytesPerRow * height)
|
||||
for index in pixels.indices {
|
||||
pixels[index] = UInt8.random(in: 0...255)
|
||||
}
|
||||
|
||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||
guard let context = CGContext(
|
||||
data: &pixels,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: bytesPerRow,
|
||||
space: colorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
|
||||
), let cgImage = context.makeImage() else {
|
||||
throw ChatMediaPreparationTestError.imageEncodingFailed
|
||||
}
|
||||
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent("detailed-\(UUID().uuidString).png")
|
||||
guard let destination = CGImageDestinationCreateWithURL(
|
||||
url as CFURL,
|
||||
UTType.png.identifier as CFString,
|
||||
1,
|
||||
nil
|
||||
) else {
|
||||
throw ChatMediaPreparationTestError.imageEncodingFailed
|
||||
}
|
||||
CGImageDestinationAddImage(destination, cgImage, nil)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
throw ChatMediaPreparationTestError.imageEncodingFailed
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private enum ChatMediaPreparationTestError: Error {
|
||||
case imageEncodingFailed
|
||||
}
|
||||
|
||||
@@ -608,34 +608,6 @@ struct GeoPresenceTrackerTests {
|
||||
#expect(stamped > stale)
|
||||
#expect(context.appendedGeohashMessages.count == 1)
|
||||
}
|
||||
@Test @MainActor
|
||||
func handleFavoriteNotification_persistsFavoriteAndPostsLocalNotification() async throws {
|
||||
let context = MockChatNostrContext()
|
||||
let coordinator = ChatNostrCoordinator(context: context)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let noiseKey = Data(repeating: 0x42, count: 32)
|
||||
// The favorites store bridges the sender's npub back to a Noise key.
|
||||
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
|
||||
noiseKey: noiseKey,
|
||||
nostrPublicKey: sender.npub
|
||||
)
|
||||
|
||||
coordinator.handleFavoriteNotification(content: "FAVORITE:TRUE|alice", from: sender.publicKeyHex)
|
||||
|
||||
#expect(context.addedFavorites.count == 1)
|
||||
#expect(context.addedFavorites.first?.noiseKey == noiseKey)
|
||||
#expect(context.addedFavorites.first?.nostrPublicKey == sender.publicKeyHex)
|
||||
#expect(context.addedFavorites.first?.nickname == "alice")
|
||||
#expect(context.postedLocalNotifications.count == 1)
|
||||
#expect(context.postedLocalNotifications.first?.title == "New Favorite")
|
||||
#expect(context.postedLocalNotifications.first?.body == "alice favorited you")
|
||||
|
||||
// Unfavorite: no store write, but the removal notification still posts.
|
||||
coordinator.handleFavoriteNotification(content: "FAVORITE:FALSE|alice", from: sender.publicKeyHex)
|
||||
#expect(context.addedFavorites.count == 1)
|
||||
#expect(context.postedLocalNotifications.last?.title == "Favorite Removed")
|
||||
#expect(context.postedLocalNotifications.last?.body == "alice unfavorited you")
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func geoPresence_sampledActivityNotificationRespectsPerGeohashCooldown() async throws {
|
||||
|
||||
@@ -225,6 +225,10 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
|
||||
favoriteRelationshipsByNoiseKey[noiseKey]
|
||||
}
|
||||
|
||||
func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? {
|
||||
favoriteRelationshipsByNoiseKey.first(where: { PeerID(publicKey: $0.key) == peerID })?.value
|
||||
}
|
||||
|
||||
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) {
|
||||
peerFavoritedUsUpdates.append((noiseKey, favorited, nickname, nostrPublicKey))
|
||||
}
|
||||
@@ -549,14 +553,14 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleFavoriteNotificationFromMesh_persistsAndAnnouncesTransitionsOnly() async {
|
||||
func handleFavoriteNotification_persistsAndAnnouncesTransitionsOnly() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||
let noiseKey = Data(repeating: 0xAB, count: 32)
|
||||
let peerID = PeerID(hexData: noiseKey)
|
||||
|
||||
// First [FAVORITED] flips theyFavoritedUs: store write + announcement.
|
||||
coordinator.handleFavoriteNotificationFromMesh("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
|
||||
coordinator.handleFavoriteNotification("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
|
||||
#expect(context.peerFavoritedUsUpdates.count == 1)
|
||||
#expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey)
|
||||
#expect(context.peerFavoritedUsUpdates.first?.favorited == true)
|
||||
@@ -568,16 +572,79 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
noiseKey: noiseKey,
|
||||
theyFavoritedUs: true
|
||||
)
|
||||
coordinator.handleFavoriteNotificationFromMesh("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
|
||||
coordinator.handleFavoriteNotification("[FAVORITED]:npub1alice", from: peerID, senderNickname: "alice")
|
||||
#expect(context.peerFavoritedUsUpdates.count == 2)
|
||||
#expect(context.meshOnlySystemMessages == ["alice favorited you"])
|
||||
|
||||
// [UNFAVORITED] transition announces again.
|
||||
coordinator.handleFavoriteNotificationFromMesh("[UNFAVORITED]", from: peerID, senderNickname: "alice")
|
||||
coordinator.handleFavoriteNotification("[UNFAVORITED]", from: peerID, senderNickname: "alice")
|
||||
#expect(context.peerFavoritedUsUpdates.last?.favorited == false)
|
||||
#expect(context.meshOnlySystemMessages == ["alice favorited you", "alice unfavorited you"])
|
||||
}
|
||||
|
||||
/// A Nostr DM whose sender resolved to a known noise key must be labeled
|
||||
/// with the favorite's nickname, not the geohash-scoped anon fallback.
|
||||
@Test @MainActor
|
||||
func nostrPrivateMessage_noiseKeyedConversationUsesFavoriteNickname() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||
let noiseKey = Data(repeating: 0xDA, count: 32)
|
||||
let convKey = PeerID(hexData: noiseKey)
|
||||
let senderPubkey = "0badc0de00112233"
|
||||
// No displayNamesByPubkey entry: the geo fallback would be "anon".
|
||||
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
|
||||
noiseKey: noiseKey,
|
||||
nostrPublicKey: "npub1bob",
|
||||
nickname: "bob",
|
||||
isFavorite: true,
|
||||
theyFavoritedUs: true
|
||||
)
|
||||
|
||||
let payloadData = PrivateMessagePacket(messageID: "nostr-dm-1", content: "hello from afar").encode()!
|
||||
let payload = NoisePayload(type: .privateMessage, data: payloadData)
|
||||
|
||||
coordinator.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: convKey,
|
||||
id: MockChatPrivateConversationContext.dummyIdentity,
|
||||
messageTimestamp: Date()
|
||||
)
|
||||
|
||||
#expect(context.privateChats[convKey]?.first?.sender == "bob")
|
||||
}
|
||||
|
||||
/// Over Nostr, [FAVORITED] markers arrive as embedded PMs on the convKey
|
||||
/// path; they must update the relationship, not render as chat text.
|
||||
@Test @MainActor
|
||||
func nostrPrivateMessage_favoritedMarkerUpdatesRelationshipInsteadOfAppending() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||
let noiseKey = Data(repeating: 0xEE, count: 32)
|
||||
// The inbound pipeline resolves known favorites to their noise-key ID.
|
||||
let convKey = PeerID(hexData: noiseKey)
|
||||
let senderPubkey = "feedface99887766"
|
||||
context.displayNamesByPubkey[senderPubkey] = "alice#1234"
|
||||
|
||||
let payloadData = PrivateMessagePacket(messageID: "fav-1", content: "[FAVORITED]:npub1alice").encode()!
|
||||
let payload = NoisePayload(type: .privateMessage, data: payloadData)
|
||||
|
||||
coordinator.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: convKey,
|
||||
id: MockChatPrivateConversationContext.dummyIdentity,
|
||||
messageTimestamp: Date()
|
||||
)
|
||||
|
||||
#expect(context.peerFavoritedUsUpdates.count == 1)
|
||||
#expect(context.peerFavoritedUsUpdates.first?.noiseKey == noiseKey)
|
||||
#expect(context.peerFavoritedUsUpdates.first?.favorited == true)
|
||||
#expect(context.peerFavoritedUsUpdates.first?.nostrPublicKey == "npub1alice")
|
||||
#expect(context.privateChats[convKey, default: []].isEmpty)
|
||||
#expect(context.meshOnlySystemMessages == ["alice#1234 favorited you"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivateMessage_routesViaMutualFavoriteNostrWhenPeerOffline() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
@@ -602,6 +669,32 @@ struct ChatPrivateConversationCoordinatorContextTests {
|
||||
#expect(context.systemMessages.isEmpty)
|
||||
}
|
||||
|
||||
/// Same as above, but the conversation is keyed by the SHORT mesh ID —
|
||||
/// the DM window was opened while the peer was on mesh, then they went
|
||||
/// out of range. The favorite must resolve via the derived short ID and
|
||||
/// route over Nostr instead of failing "peer not reachable".
|
||||
@Test @MainActor
|
||||
func sendPrivateMessage_routesViaNostrWhenMeshKeyedPeerGoesOffline() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
let coordinator = ChatPrivateConversationCoordinator(context: context)
|
||||
let noiseKey = Data(repeating: 0xCE, count: 32)
|
||||
let shortID = PeerID(publicKey: noiseKey)
|
||||
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
|
||||
noiseKey: noiseKey,
|
||||
nostrPublicKey: "npub1bob",
|
||||
nickname: "bob",
|
||||
isFavorite: true,
|
||||
theyFavoritedUs: true
|
||||
)
|
||||
|
||||
coordinator.sendPrivateMessage("hello again", to: shortID)
|
||||
|
||||
#expect(context.routedPrivateMessages.map(\.content) == ["hello again"])
|
||||
#expect(context.privateChats[shortID]?.first?.deliveryStatus == .sent)
|
||||
#expect(context.privateChats[shortID]?.first?.recipientNickname == "bob")
|
||||
#expect(context.systemMessages.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func sendPrivateMessage_failsWhenOfflineWithoutMutualFavorite() async {
|
||||
let context = MockChatPrivateConversationContext()
|
||||
|
||||
@@ -121,9 +121,11 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
|
||||
|
||||
// Routing & acknowledgements
|
||||
private(set) var flushedOutboxPeerIDs: [PeerID] = []
|
||||
private(set) var courierRetryPeerIDs: [PeerID] = []
|
||||
private(set) var meshDeliveryAcks: [(messageID: String, peerID: PeerID)] = []
|
||||
|
||||
func flushRouterOutbox(for peerID: PeerID) { flushedOutboxPeerIDs.append(peerID) }
|
||||
func retryCourierDeposits(via peerID: PeerID) { courierRetryPeerIDs.append(peerID) }
|
||||
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||
meshDeliveryAcks.append((messageID, peerID))
|
||||
}
|
||||
|
||||
@@ -428,12 +428,13 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
@Test @MainActor
|
||||
func statusRank_orderingIsCorrect() async {
|
||||
// This tests the implicit ordering used in refreshVisibleMessages
|
||||
// failed < sending < sent < partiallyDelivered < delivered < read
|
||||
// failed < sending < sent < carried < partiallyDelivered < delivered < read
|
||||
|
||||
let statuses: [DeliveryStatus] = [
|
||||
.failed(reason: "test"),
|
||||
.sending,
|
||||
.sent,
|
||||
.carried,
|
||||
.partiallyDelivered(reached: 1, total: 3),
|
||||
.delivered(to: "B", at: Date()),
|
||||
.read(by: "C", at: Date())
|
||||
@@ -446,9 +447,10 @@ struct ChatViewModelDeliveryStatusTests {
|
||||
case .failed: #expect(index == 0)
|
||||
case .sending: #expect(index == 1)
|
||||
case .sent: #expect(index == 2)
|
||||
case .partiallyDelivered: #expect(index == 3)
|
||||
case .delivered: #expect(index == 4)
|
||||
case .read: #expect(index == 5)
|
||||
case .carried: #expect(index == 3)
|
||||
case .partiallyDelivered: #expect(index == 4)
|
||||
case .delivered: #expect(index == 5)
|
||||
case .read: #expect(index == 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -655,8 +655,10 @@ struct ChatViewModelNostrExtensionTests {
|
||||
#expect(viewModel.findNoiseKey(for: nostrHex) == noiseKey)
|
||||
}
|
||||
|
||||
/// An inbound Nostr [FAVORITED] marker must flip theyFavoritedUs and stay
|
||||
/// out of the conversation transcript.
|
||||
@Test @MainActor
|
||||
func handleFavoriteNotification_updatesFavoriteAssociation() async throws {
|
||||
func handlePrivateMessage_nostrFavoritedMarkerUpdatesRelationship() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let identity = try NostrIdentity.generate()
|
||||
let noiseKey = Data((0..<32).map { UInt8(($0 + 144) & 0xFF) })
|
||||
@@ -664,19 +666,33 @@ struct ChatViewModelNostrExtensionTests {
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: identity.npub,
|
||||
peerNickname: "Before"
|
||||
peerNickname: "Alice"
|
||||
)
|
||||
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey) }
|
||||
defer {
|
||||
FavoritesPersistenceService.shared.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false)
|
||||
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey)
|
||||
}
|
||||
|
||||
viewModel.handleFavoriteNotification(
|
||||
content: "FAVORITE:TRUE|NPUB:\(identity.npub)|Alice",
|
||||
from: identity.publicKeyHex
|
||||
// The inbound pipeline resolves a known sender to their noise-key ID.
|
||||
let convKey = PeerID(hexData: noiseKey)
|
||||
let payloadData = try #require(
|
||||
PrivateMessagePacket(messageID: "fav-e2e-1", content: "[FAVORITED]:\(identity.npub)").encode()
|
||||
)
|
||||
let payload = NoisePayload(type: .privateMessage, data: payloadData)
|
||||
|
||||
viewModel.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: identity.publicKeyHex,
|
||||
convKey: convKey,
|
||||
id: identity,
|
||||
messageTimestamp: Date()
|
||||
)
|
||||
|
||||
let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
|
||||
#expect(relationship?.peerNickname == "Alice")
|
||||
#expect(relationship?.theyFavoritedUs == true)
|
||||
#expect(relationship?.isMutual == true)
|
||||
#expect(relationship?.peerNostrPublicKey == identity.npub)
|
||||
#expect(relationship?.isFavorite == true)
|
||||
#expect(viewModel.privateChats[convKey, default: []].isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
|
||||
@@ -303,6 +303,50 @@ struct CommandProcessorTests {
|
||||
#expect(!identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
|
||||
}
|
||||
|
||||
/// /fav must go through toggleFavorite (which persists by the real noise
|
||||
/// key) — not write the hex peer ID into the favorites store, and not
|
||||
/// send a second favorite notification.
|
||||
@MainActor
|
||||
@Test func favoriteCommandTogglesWithoutDirectStoreWrite() async {
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
let context = MockCommandContextProvider()
|
||||
let processor = CommandProcessor(
|
||||
contextProvider: context,
|
||||
meshService: MockTransport(),
|
||||
identityManager: identityManager
|
||||
)
|
||||
let peerID = PeerID(str: "00aa00bb00cc00dd")
|
||||
context.nicknameToPeerID["alice"] = peerID
|
||||
|
||||
let result = await withSelectedChannel(.mesh, context: context) {
|
||||
processor.process("/fav alice")
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success(let message):
|
||||
#expect(message == "added alice to favorites")
|
||||
default:
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
#expect(context.toggledFavorites == [peerID])
|
||||
#expect(context.favoriteNotifications.isEmpty)
|
||||
// The 8-byte routing ID must never be stored as a "noise key".
|
||||
let bogusKey = Data(hexString: peerID.id)!
|
||||
#expect(FavoritesPersistenceService.shared.getFavoriteStatus(for: bogusKey) == nil)
|
||||
|
||||
// Unfavoriting someone who is not a favorite is a no-op.
|
||||
let unfavResult = await withSelectedChannel(.mesh, context: context) {
|
||||
processor.process("/unfav alice")
|
||||
}
|
||||
switch unfavResult {
|
||||
case .success(let message):
|
||||
#expect(message == "alice is not a favorite")
|
||||
default:
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
#expect(context.toggledFavorites == [peerID])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func favoriteCommandIsRejectedOutsideMesh() async {
|
||||
let identityManager = MockIdentityManager(MockKeychain())
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
//
|
||||
// CourierStoreTests.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct CourierStoreTests {
|
||||
|
||||
private static let baseDate = Date(timeIntervalSince1970: 1_750_000_000)
|
||||
|
||||
private func makeStore(now: Date = baseDate) -> CourierStore {
|
||||
CourierStore(persistsToDisk: false, now: { now })
|
||||
}
|
||||
|
||||
/// Store whose clock can be advanced by tests.
|
||||
private final class Clock {
|
||||
var now: Date
|
||||
init(_ now: Date) { self.now = now }
|
||||
}
|
||||
|
||||
private func makeEnvelope(
|
||||
recipientKey: Data = Data(repeating: 0xB0, count: 32),
|
||||
sealedAt: Date = baseDate,
|
||||
lifetime: TimeInterval = 60 * 60,
|
||||
ciphertext: Data = Data((0..<96).map { _ in UInt8.random(in: 0...255) })
|
||||
) -> CourierEnvelope {
|
||||
CourierEnvelope(
|
||||
recipientTag: CourierEnvelope.recipientTag(
|
||||
noiseStaticKey: recipientKey,
|
||||
epochDay: CourierEnvelope.epochDay(for: sealedAt)
|
||||
),
|
||||
expiry: UInt64((sealedAt.timeIntervalSince1970 + lifetime) * 1000),
|
||||
ciphertext: ciphertext
|
||||
)
|
||||
}
|
||||
|
||||
private let depositorA = Data(repeating: 0xA1, count: 32)
|
||||
private let depositorB = Data(repeating: 0xA2, count: 32)
|
||||
|
||||
// MARK: - Deposit and handover
|
||||
|
||||
@Test func depositThenTakeForRecipient() {
|
||||
let store = makeStore()
|
||||
let recipientKey = Data(repeating: 0xB0, count: 32)
|
||||
let envelope = makeEnvelope(recipientKey: recipientKey)
|
||||
|
||||
#expect(store.deposit(envelope, from: depositorA))
|
||||
let taken = store.takeEnvelopes(for: recipientKey)
|
||||
#expect(taken == [envelope])
|
||||
// Handover removes the envelope.
|
||||
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
|
||||
}
|
||||
|
||||
@Test func takeIgnoresOtherRecipients() {
|
||||
let store = makeStore()
|
||||
let envelope = makeEnvelope(recipientKey: Data(repeating: 0xB0, count: 32))
|
||||
store.deposit(envelope, from: depositorA)
|
||||
#expect(store.takeEnvelopes(for: Data(repeating: 0xCC, count: 32)).isEmpty)
|
||||
#expect(store.takeEnvelopes(for: Data(repeating: 0xB0, count: 32)).count == 1)
|
||||
}
|
||||
|
||||
@Test func duplicateDepositIsIdempotent() {
|
||||
let store = makeStore()
|
||||
let recipientKey = Data(repeating: 0xB0, count: 32)
|
||||
let envelope = makeEnvelope(recipientKey: recipientKey)
|
||||
#expect(store.deposit(envelope, from: depositorA))
|
||||
#expect(store.deposit(envelope, from: depositorA))
|
||||
#expect(store.takeEnvelopes(for: recipientKey).count == 1)
|
||||
}
|
||||
|
||||
// MARK: - Validity
|
||||
|
||||
@Test func rejectsExpiredAndOversizedAndMalformed() {
|
||||
let store = makeStore()
|
||||
let expired = makeEnvelope(sealedAt: Self.baseDate.addingTimeInterval(-7200), lifetime: 3600)
|
||||
#expect(!store.deposit(expired, from: depositorA))
|
||||
|
||||
let oversized = makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1))
|
||||
#expect(!store.deposit(oversized, from: depositorA))
|
||||
|
||||
let badTag = CourierEnvelope(
|
||||
recipientTag: Data(repeating: 0, count: 4),
|
||||
expiry: UInt64((Self.baseDate.timeIntervalSince1970 + 3600) * 1000),
|
||||
ciphertext: Data(repeating: 1, count: 16)
|
||||
)
|
||||
#expect(!store.deposit(badTag, from: depositorA))
|
||||
}
|
||||
|
||||
@Test func rejectsExpiryBeyondPolicyLifetime() {
|
||||
let store = makeStore()
|
||||
let pinned = makeEnvelope(lifetime: 7 * 24 * 60 * 60)
|
||||
#expect(!store.deposit(pinned, from: depositorA))
|
||||
}
|
||||
|
||||
// MARK: - Quotas
|
||||
|
||||
@Test func perDepositorQuota() {
|
||||
let store = makeStore()
|
||||
for _ in 0..<CourierStore.Limits.maxPerFavoriteDepositor {
|
||||
#expect(store.deposit(makeEnvelope(), from: depositorA))
|
||||
}
|
||||
#expect(!store.deposit(makeEnvelope(), from: depositorA))
|
||||
// A different depositor still has room.
|
||||
#expect(store.deposit(makeEnvelope(), from: depositorB))
|
||||
}
|
||||
|
||||
@Test func totalQuotaEvictsOldestFirst() {
|
||||
let store = makeStore()
|
||||
let firstRecipient = Data(repeating: 0xD0, count: 32)
|
||||
let first = makeEnvelope(recipientKey: firstRecipient)
|
||||
store.deposit(first, from: depositorA)
|
||||
|
||||
// Fill to the cap using distinct depositors to dodge the per-depositor quota.
|
||||
var deposited = 1
|
||||
var depositorByte: UInt8 = 1
|
||||
while deposited < CourierStore.Limits.maxEnvelopes + 1 {
|
||||
let depositor = Data(repeating: depositorByte, count: 32)
|
||||
for _ in 0..<CourierStore.Limits.maxPerFavoriteDepositor where deposited < CourierStore.Limits.maxEnvelopes + 1 {
|
||||
#expect(store.deposit(makeEnvelope(), from: depositor))
|
||||
deposited += 1
|
||||
}
|
||||
depositorByte += 1
|
||||
}
|
||||
|
||||
// The first envelope was evicted to make room.
|
||||
#expect(store.takeEnvelopes(for: firstRecipient).isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Expiry over time
|
||||
|
||||
@Test func expiredEnvelopesAreNotHandedOver() {
|
||||
let clock = Clock(Self.baseDate)
|
||||
let store = CourierStore(persistsToDisk: false, now: { clock.now })
|
||||
let recipientKey = Data(repeating: 0xB0, count: 32)
|
||||
store.deposit(makeEnvelope(recipientKey: recipientKey, lifetime: 3600), from: depositorA)
|
||||
|
||||
clock.now = Self.baseDate.addingTimeInterval(7200)
|
||||
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Panic wipe
|
||||
|
||||
@Test func wipeDropsEverything() {
|
||||
let store = makeStore()
|
||||
let recipientKey = Data(repeating: 0xB0, count: 32)
|
||||
store.deposit(makeEnvelope(recipientKey: recipientKey), from: depositorA)
|
||||
store.wipe()
|
||||
#expect(store.takeEnvelopes(for: recipientKey).isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
@Test func persistsAndReloadsAcrossInstances() throws {
|
||||
// Isolated on-disk location so the test never touches the real store.
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("courier-store-tests-\(UUID().uuidString)", isDirectory: true)
|
||||
.appendingPathComponent("envelopes.json")
|
||||
defer { try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent()) }
|
||||
|
||||
let first = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
|
||||
|
||||
let recipientKey = Data(repeating: 0xE0, count: 32)
|
||||
let envelope = makeEnvelope(recipientKey: recipientKey)
|
||||
#expect(first.deposit(envelope, from: depositorA))
|
||||
|
||||
let second = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
|
||||
#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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,792 @@
|
||||
//
|
||||
// CourierEndToEndTests.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
import Combine
|
||||
import CoreBluetooth
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
/// Three-node courier flow exercised through real BLEService instances with
|
||||
/// packets ferried in-process: Alice deposits a sealed envelope with Carol
|
||||
/// while Bob is unreachable; Carol hands it over when Bob announces; Bob
|
||||
/// opens it and sees Alice's message in the right DM thread.
|
||||
struct CourierEndToEndTests {
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private final class PacketTap {
|
||||
private let lock = NSLock()
|
||||
private var packets: [BitchatPacket] = []
|
||||
|
||||
func record(_ packet: BitchatPacket) {
|
||||
lock.lock(); packets.append(packet); lock.unlock()
|
||||
}
|
||||
|
||||
func first(ofType type: MessageType) -> BitchatPacket? {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return packets.first { $0.type == type.rawValue }
|
||||
}
|
||||
|
||||
func count(ofType type: MessageType) -> Int {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return packets.filter { $0.type == type.rawValue }.count
|
||||
}
|
||||
|
||||
func all(ofType type: MessageType) -> [BitchatPacket] {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return packets.filter { $0.type == type.rawValue }
|
||||
}
|
||||
}
|
||||
|
||||
private final class NoiseCaptureDelegate: BitchatDelegate {
|
||||
private let lock = NSLock()
|
||||
private var payloads: [(peerID: PeerID, type: NoisePayloadType, payload: Data)] = []
|
||||
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) {
|
||||
lock.lock(); payloads.append((peerID, type, payload)); lock.unlock()
|
||||
}
|
||||
|
||||
func snapshot() -> [(peerID: PeerID, type: NoisePayloadType, payload: Data)] {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return payloads
|
||||
}
|
||||
|
||||
// Unused BitchatDelegate requirements.
|
||||
func didReceiveMessage(_ message: BitchatMessage) {}
|
||||
func didConnectToPeer(_ peerID: PeerID) {}
|
||||
func didDisconnectFromPeer(_ peerID: PeerID) {}
|
||||
func didUpdatePeerList(_ peers: [PeerID]) {}
|
||||
func didUpdateBluetoothState(_ state: CBManagerState) {}
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {}
|
||||
}
|
||||
|
||||
private func makeService(identityManager: MockIdentityManager? = nil) -> BLEService {
|
||||
let keychain = MockKeychain()
|
||||
let identityManager = identityManager ?? MockIdentityManager(keychain)
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let service = BLEService(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: identityManager,
|
||||
initializeBluetoothManagers: false
|
||||
)
|
||||
service.courierStore = CourierStore(persistsToDisk: false)
|
||||
return service
|
||||
}
|
||||
|
||||
/// Handling any packet from a peer preseeds it as a connected,
|
||||
/// verified entry in the receiving service's registry.
|
||||
private func preseedConnectedPeer(_ peer: BLEService, in service: BLEService) {
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: Data(hexString: peer.myPeerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data("ping".utf8),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
service._test_handlePacket(packet, fromPeerID: peer.myPeerID)
|
||||
}
|
||||
|
||||
// MARK: - Tests
|
||||
|
||||
@Test func courierCarriesMessageAcrossDisjointConnectivity() async throws {
|
||||
let alice = makeService()
|
||||
let carol = makeService()
|
||||
let bob = makeService()
|
||||
// Alice and Carol are mutual favorites; trust policy is exercised
|
||||
// separately in depositFromUntrustedPeerIsRejected.
|
||||
carol.courierDepositPolicy = { _, _ in .favorite }
|
||||
|
||||
let bobDelegate = NoiseCaptureDelegate()
|
||||
bob.delegate = bobDelegate
|
||||
|
||||
let aliceOut = PacketTap()
|
||||
alice._test_onOutboundPacket = aliceOut.record
|
||||
let carolOut = PacketTap()
|
||||
carol._test_onOutboundPacket = carolOut.record
|
||||
let bobOut = PacketTap()
|
||||
bob._test_onOutboundPacket = bobOut.record
|
||||
|
||||
// Alice can see Carol; Bob is nowhere on the mesh.
|
||||
preseedConnectedPeer(carol, in: alice)
|
||||
|
||||
// 1. Alice seals to Bob's static key and deposits with Carol.
|
||||
#expect(alice.sendCourierMessage(
|
||||
"the camp moved north",
|
||||
messageID: "courier-msg-1",
|
||||
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
|
||||
via: [carol.myPeerID]
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(deposited)
|
||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||
|
||||
// 2. Ferry the deposit to Carol; she carries it (opaque to her).
|
||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||
let carried = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
// 3. Later, Bob announces near Carol → handover fires.
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(announced)
|
||||
let announcePacket = try #require(bobOut.first(ofType: .announce))
|
||||
carol._test_handlePacket(announcePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
|
||||
let handedOver = await TestHelpers.waitUntil(
|
||||
{ carolOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(handedOver)
|
||||
#expect(carol.courierStore.isEmpty)
|
||||
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
|
||||
#expect(PeerID(hexData: handoverPacket.recipientID) == bob.myPeerID)
|
||||
|
||||
// 4. Ferry the handover to Bob; he opens the envelope.
|
||||
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
|
||||
let received = await TestHelpers.waitUntil(
|
||||
{ !bobDelegate.snapshot().isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(received)
|
||||
|
||||
let delivered = try #require(bobDelegate.snapshot().first)
|
||||
#expect(delivered.type == .privateMessage)
|
||||
// Alice is absent from Bob's mesh, so the sender resolves to her
|
||||
// full noise-key ID — the stable favorite conversation — not the
|
||||
// short mesh ID (which Bob couldn't resolve to a nickname) and not
|
||||
// the courier's identity.
|
||||
#expect(delivered.peerID == PeerID(hexData: alice.noiseStaticPublicKeyData()))
|
||||
#expect(delivered.peerID != carol.myPeerID)
|
||||
let message = try #require(PrivateMessagePacket.decode(from: delivered.payload))
|
||||
#expect(message.messageID == "courier-msg-1")
|
||||
#expect(message.content == "the camp moved north")
|
||||
}
|
||||
|
||||
@Test func courieredMailFromBlockedSenderIsDropped() async throws {
|
||||
let alice = makeService()
|
||||
let carol = makeService()
|
||||
let bobIdentity = MockIdentityManager(MockKeychain())
|
||||
let bob = makeService(identityManager: bobIdentity)
|
||||
carol.courierDepositPolicy = { _, _ in .favorite }
|
||||
|
||||
let bobDelegate = NoiseCaptureDelegate()
|
||||
bob.delegate = bobDelegate
|
||||
let aliceOut = PacketTap()
|
||||
alice._test_onOutboundPacket = aliceOut.record
|
||||
let carolOut = PacketTap()
|
||||
carol._test_onOutboundPacket = carolOut.record
|
||||
let bobOut = PacketTap()
|
||||
bob._test_onOutboundPacket = bobOut.record
|
||||
|
||||
preseedConnectedPeer(carol, in: alice)
|
||||
|
||||
// Bob blocked Alice by her stable Noise identity while she was away.
|
||||
bobIdentity.setBlocked(alice.noiseStaticPublicKeyData().sha256Fingerprint(), isBlocked: true)
|
||||
|
||||
#expect(alice.sendCourierMessage(
|
||||
"you should not see this",
|
||||
messageID: "courier-msg-blocked-sender",
|
||||
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
|
||||
via: [carol.myPeerID]
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(deposited)
|
||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||
|
||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||
let carried = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(announced)
|
||||
let announcePacket = try #require(bobOut.first(ofType: .announce))
|
||||
carol._test_handlePacket(announcePacket, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
|
||||
let handedOver = await TestHelpers.waitUntil(
|
||||
{ carolOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(handedOver)
|
||||
let handoverPacket = try #require(carolOut.first(ofType: .courierEnvelope))
|
||||
|
||||
// Bob opens the envelope — but the sealed sender is blocked, and it
|
||||
// must never reach the UI. The live block check can't cover this: the
|
||||
// sender is absent from Bob's registry, so no fingerprint resolves at
|
||||
// delivery time.
|
||||
bob._test_handlePacket(handoverPacket, fromPeerID: carol.myPeerID)
|
||||
let delivered = await TestHelpers.waitUntil(
|
||||
{ !bobDelegate.snapshot().isEmpty },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!delivered)
|
||||
}
|
||||
|
||||
@Test func unverifiedAnnounceDoesNotTriggerCourierHandover() async throws {
|
||||
let alice = makeService()
|
||||
let carol = makeService()
|
||||
let bob = makeService()
|
||||
carol.courierDepositPolicy = { _, _ in .favorite }
|
||||
|
||||
let aliceOut = PacketTap()
|
||||
alice._test_onOutboundPacket = aliceOut.record
|
||||
let carolOut = PacketTap()
|
||||
carol._test_onOutboundPacket = carolOut.record
|
||||
let bobOut = PacketTap()
|
||||
bob._test_onOutboundPacket = bobOut.record
|
||||
|
||||
preseedConnectedPeer(carol, in: alice)
|
||||
|
||||
#expect(alice.sendCourierMessage(
|
||||
"hold until verified",
|
||||
messageID: "courier-msg-unverified-announce",
|
||||
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
|
||||
via: [carol.myPeerID]
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(deposited)
|
||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||
|
||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||
let carried = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
let forgedAnnounce = try makeUnsignedAnnounce(from: bob)
|
||||
carol._test_handlePacket(forgedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
|
||||
let leakedOnUnverifiedAnnounce = await TestHelpers.waitUntil(
|
||||
{ carolOut.count(ofType: .courierEnvelope) > 0 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!leakedOnUnverifiedAnnounce)
|
||||
#expect(!carol.courierStore.isEmpty)
|
||||
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(announced)
|
||||
let verifiedAnnounce = try #require(bobOut.first(ofType: .announce))
|
||||
carol._test_handlePacket(verifiedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
|
||||
let handedOver = await TestHelpers.waitUntil(
|
||||
{ carolOut.count(ofType: .courierEnvelope) == 1 },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(handedOver)
|
||||
#expect(carol.courierStore.isEmpty)
|
||||
}
|
||||
|
||||
@Test func relayedAnnounceTriggersNonDestructiveRemoteHandover() async throws {
|
||||
let alice = makeService()
|
||||
let carol = makeService()
|
||||
let bob = makeService()
|
||||
carol.courierDepositPolicy = { _, _ in .favorite }
|
||||
|
||||
let aliceOut = PacketTap()
|
||||
alice._test_onOutboundPacket = aliceOut.record
|
||||
let carolOut = PacketTap()
|
||||
carol._test_onOutboundPacket = carolOut.record
|
||||
let bobOut = PacketTap()
|
||||
bob._test_onOutboundPacket = bobOut.record
|
||||
|
||||
preseedConnectedPeer(carol, in: alice)
|
||||
|
||||
#expect(alice.sendCourierMessage(
|
||||
"hold for a direct encounter",
|
||||
messageID: "courier-msg-relayed-announce",
|
||||
recipientNoiseKey: bob.noiseStaticPublicKeyData(),
|
||||
via: [carol.myPeerID]
|
||||
))
|
||||
let deposited = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(deposited)
|
||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||
|
||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||
let carried = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(carried)
|
||||
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announced = await TestHelpers.waitUntil(
|
||||
{ bobOut.first(ofType: .announce) != nil },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(announced)
|
||||
let directAnnounce = try #require(bobOut.first(ofType: .announce))
|
||||
|
||||
// A relayed copy has a decremented TTL but a still-valid signature
|
||||
// (TTL is excluded from announce signatures). The recipient is
|
||||
// multi-hop away, so a copy floods toward them speculatively while
|
||||
// the carried original stays put for a future direct encounter.
|
||||
var relayedAnnounce = directAnnounce
|
||||
relayedAnnounce.ttl = directAnnounce.ttl - 1
|
||||
carol._test_handlePacket(relayedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
|
||||
let remoteHandover = await TestHelpers.waitUntil(
|
||||
{ carolOut.count(ofType: .courierEnvelope) == 1 },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(remoteHandover)
|
||||
#expect(!carol.courierStore.isEmpty)
|
||||
|
||||
// A second relayed announce inside the cooldown must not re-flood
|
||||
// the same envelope. The original announce's dedup key is consumed
|
||||
// (sender/timestamp/payload — TTL excluded), so use a fresh announce;
|
||||
// wait out the 1s announce throttle first.
|
||||
try await Task.sleep(nanoseconds: 1_100_000_000)
|
||||
bob.sendBroadcastAnnounce()
|
||||
let reannounced = await TestHelpers.waitUntil(
|
||||
{ bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp } },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(reannounced)
|
||||
let freshAnnounce = try #require(
|
||||
bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp }
|
||||
)
|
||||
var relayedFreshAnnounce = freshAnnounce
|
||||
relayedFreshAnnounce.ttl = freshAnnounce.ttl - 1
|
||||
carol._test_handlePacket(relayedFreshAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
|
||||
let refloodedInCooldown = await TestHelpers.waitUntil(
|
||||
{ carolOut.count(ofType: .courierEnvelope) > 1 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!refloodedInCooldown)
|
||||
#expect(!carol.courierStore.isEmpty)
|
||||
|
||||
// A later *direct* announce still performs the destructive handover.
|
||||
try await Task.sleep(nanoseconds: 1_100_000_000)
|
||||
bob.sendBroadcastAnnounce()
|
||||
let announcedAgain = await TestHelpers.waitUntil(
|
||||
{ bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp && $0.timestamp != freshAnnounce.timestamp } },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(announcedAgain)
|
||||
let directAgain = try #require(
|
||||
bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp && $0.timestamp != freshAnnounce.timestamp }
|
||||
)
|
||||
carol._test_handlePacket(directAgain, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||
|
||||
let handedOver = await TestHelpers.waitUntil(
|
||||
{ carolOut.count(ofType: .courierEnvelope) == 2 },
|
||||
timeout: TestConstants.defaultTimeout
|
||||
)
|
||||
#expect(handedOver)
|
||||
#expect(carol.courierStore.isEmpty)
|
||||
}
|
||||
|
||||
@Test func sendCourierMessageRejectsInvalidRecipientKeyBeforeQueueing() async throws {
|
||||
let alice = makeService()
|
||||
let carol = makeService()
|
||||
preseedConnectedPeer(carol, in: alice)
|
||||
|
||||
let aliceOut = PacketTap()
|
||||
alice._test_onOutboundPacket = aliceOut.record
|
||||
|
||||
#expect(!alice.sendCourierMessage(
|
||||
"this cannot be sealed",
|
||||
messageID: "courier-msg-invalid-key",
|
||||
recipientNoiseKey: Data(repeating: 0x01, count: 8),
|
||||
via: [carol.myPeerID]
|
||||
))
|
||||
|
||||
let queuedPacket = await TestHelpers.waitUntil(
|
||||
{ aliceOut.first(ofType: .courierEnvelope) != nil },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!queuedPacket)
|
||||
}
|
||||
|
||||
@Test func depositFromUntrustedPeerIsRejected() async throws {
|
||||
let carol = makeService()
|
||||
carol.courierDepositPolicy = { _, _ in nil } // depositor is neither favorite nor verified
|
||||
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData()
|
||||
let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "x", messageID: "m1"))
|
||||
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())
|
||||
let unsigned = BitchatPacket(
|
||||
type: MessageType.courierEnvelope.rawValue,
|
||||
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: carol.myPeerID.id),
|
||||
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
|
||||
payload: try #require(envelope.encode()),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
let packet = try #require(alice.signPacket(unsigned))
|
||||
|
||||
carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData())
|
||||
let stored = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!stored)
|
||||
}
|
||||
|
||||
@Test func unsignedDepositIsRejected() async throws {
|
||||
let carol = makeService()
|
||||
carol.courierDepositPolicy = { _, _ in .favorite }
|
||||
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData()
|
||||
let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "x", messageID: "m-unsigned"))
|
||||
let sealed = try alice.sealCourierPayload(typedPayload, recipientStaticKey: bobKey)
|
||||
let now = Date()
|
||||
let envelope = CourierEnvelope(
|
||||
recipientTag: CourierEnvelope.recipientTag(
|
||||
noiseStaticKey: bobKey,
|
||||
epochDay: CourierEnvelope.epochDay(for: now)
|
||||
),
|
||||
expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000),
|
||||
ciphertext: sealed
|
||||
)
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
// Correct sender, willing policy — but no packet signature: the
|
||||
// courier cannot authenticate the depositor, so it must not carry.
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.courierEnvelope.rawValue,
|
||||
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: carol.myPeerID.id),
|
||||
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
|
||||
payload: try #require(envelope.encode()),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
|
||||
carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData())
|
||||
let stored = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!stored)
|
||||
}
|
||||
|
||||
@Test func courierDepositTrustUsesIngressPeerNotClaimedSender() async throws {
|
||||
let alice = makeService()
|
||||
let carol = makeService()
|
||||
let mallory = makeService()
|
||||
preseedConnectedPeer(alice, in: carol)
|
||||
preseedConnectedPeer(mallory, in: carol)
|
||||
|
||||
let trustedAliceKey = Data(hexString: alice.myPeerID.id) ?? Data()
|
||||
carol.courierDepositPolicy = { depositorKey, _ in
|
||||
depositorKey == trustedAliceKey ? .favorite : nil
|
||||
}
|
||||
|
||||
let aliceNoise = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData()
|
||||
let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "spoofed", messageID: "m-spoof"))
|
||||
let sealed = try aliceNoise.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 packet = BitchatPacket(
|
||||
type: MessageType.courierEnvelope.rawValue,
|
||||
senderID: Data(hexString: alice.myPeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: carol.myPeerID.id),
|
||||
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
|
||||
payload: try #require(envelope.encode()),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
|
||||
carol._test_handlePacket(packet, fromPeerID: mallory.myPeerID, preseedPeer: false)
|
||||
let stored = await TestHelpers.waitUntil(
|
||||
{ !carol.courierStore.isEmpty },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(!stored)
|
||||
}
|
||||
|
||||
private func makeUnsignedAnnounce(from service: BLEService) throws -> BitchatPacket {
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: "Unsigned",
|
||||
noisePublicKey: service.noiseStaticPublicKeyData(),
|
||||
signingPublicKey: service.noiseSigningPublicKeyData(),
|
||||
directNeighbors: nil
|
||||
)
|
||||
let payload = try #require(announcement.encode())
|
||||
|
||||
return BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: Data(hexString: service.myPeerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Router courier selection
|
||||
|
||||
/// Minimal transport stub for exercising MessageRouter's courier deposit
|
||||
/// logic without BLE plumbing.
|
||||
private final class CourierCaptureTransport: Transport {
|
||||
weak var delegate: BitchatDelegate?
|
||||
weak var eventDelegate: TransportEventDelegate?
|
||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||
|
||||
var snapshots: [TransportPeerSnapshot] = []
|
||||
private(set) var courierSends: [(messageID: String, recipientKey: Data, couriers: [PeerID])] = []
|
||||
private(set) var directSends: [String] = []
|
||||
|
||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||
Just(snapshots).eraseToAnyPublisher()
|
||||
}
|
||||
func currentPeerSnapshots() -> [TransportPeerSnapshot] { snapshots }
|
||||
|
||||
var myPeerID = PeerID(str: "00000000000000aa")
|
||||
var myNickname = "stub"
|
||||
func setNickname(_ nickname: String) {}
|
||||
|
||||
func startServices() {}
|
||||
func stopServices() {}
|
||||
func emergencyDisconnectAll() {}
|
||||
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool {
|
||||
snapshots.contains { $0.peerID == peerID && $0.isConnected }
|
||||
}
|
||||
// Nostr-style reachability: claimed for peers with no live link (known
|
||||
// npub), where prompt delivery additionally needs a relay connection.
|
||||
var reachablePeers: Set<PeerID> = []
|
||||
var promptDelivery = true
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
||||
isPeerConnected(peerID) || reachablePeers.contains(peerID)
|
||||
}
|
||||
func canDeliverPromptly(to peerID: PeerID) -> Bool {
|
||||
isPeerReachable(peerID) && promptDelivery
|
||||
}
|
||||
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||
func getPeerNicknames() -> [PeerID: String] { [:] }
|
||||
|
||||
func getFingerprint(for peerID: PeerID) -> String? { nil }
|
||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
||||
func triggerHandshake(with peerID: PeerID) {}
|
||||
|
||||
func sendMessage(_ content: String, mentions: [String]) {}
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
directSends.append(messageID)
|
||||
}
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {}
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {}
|
||||
func sendBroadcastAnnounce() {}
|
||||
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {}
|
||||
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
|
||||
courierSends.append((messageID, recipientNoiseKey, couriers))
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
struct MessageRouterCourierTests {
|
||||
|
||||
@Test @MainActor
|
||||
func unreachablePeerMessageGoesToTrustedCouriersOnly() {
|
||||
let bobKey = Data(repeating: 0xB0, count: 32)
|
||||
let bobID = PeerID(publicKey: bobKey)
|
||||
let carolKey = Data(repeating: 0xC0, count: 32)
|
||||
let carolID = PeerID(publicKey: carolKey)
|
||||
let daveKey = Data(repeating: 0xD0, count: 32)
|
||||
let daveID = PeerID(publicKey: daveKey)
|
||||
|
||||
let transport = CourierCaptureTransport()
|
||||
transport.snapshots = [
|
||||
// Carol: connected mutual favorite → eligible courier.
|
||||
TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date()),
|
||||
// Dave: connected but not trusted → never a courier.
|
||||
TransportPeerSnapshot(peerID: daveID, nickname: "dave", isConnected: true, noisePublicKey: daveKey, lastSeen: Date())
|
||||
]
|
||||
|
||||
let directory = CourierDirectory(
|
||||
noiseKey: { peerID in peerID == bobID ? bobKey : nil },
|
||||
isTrustedCourier: { $0 == carolKey }
|
||||
)
|
||||
let router = MessageRouter(transports: [transport], courierDirectory: directory)
|
||||
var carried: [String] = []
|
||||
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
|
||||
|
||||
router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m1")
|
||||
|
||||
#expect(transport.directSends.isEmpty)
|
||||
#expect(transport.courierSends.count == 1)
|
||||
#expect(transport.courierSends.first?.messageID == "m1")
|
||||
#expect(transport.courierSends.first?.recipientKey == bobKey)
|
||||
#expect(transport.courierSends.first?.couriers == [carolID])
|
||||
#expect(carried == ["m1"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func noCourierDepositWithoutKnownRecipientKey() {
|
||||
let transport = CourierCaptureTransport()
|
||||
transport.snapshots = [
|
||||
TransportPeerSnapshot(peerID: PeerID(str: "00000000000000cc"), nickname: "carol", isConnected: true, noisePublicKey: Data(repeating: 0xC0, count: 32), lastSeen: Date())
|
||||
]
|
||||
let directory = CourierDirectory(noiseKey: { _ in nil }, isTrustedCourier: { _ in true })
|
||||
let router = MessageRouter(transports: [transport], courierDirectory: directory)
|
||||
var carried: [String] = []
|
||||
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
|
||||
|
||||
router.sendPrivate("hi", to: PeerID(str: "00000000000000bb"), recipientNickname: "bob", messageID: "m2")
|
||||
|
||||
#expect(transport.courierSends.isEmpty)
|
||||
#expect(carried.isEmpty)
|
||||
}
|
||||
|
||||
/// The production directory must resolve both ID forms: a 64-hex
|
||||
/// noise-key ID (offline favorite row) carries the key itself, and a
|
||||
/// short 16-hex ID resolves through the favorites store.
|
||||
@Test @MainActor
|
||||
func favoritesBackedDirectoryResolvesBothIDForms() {
|
||||
let directory = CourierDirectory.favoritesBacked()
|
||||
let bobKey = Data(repeating: 0xB7, count: 32)
|
||||
|
||||
#expect(directory.noiseKey(PeerID(hexData: bobKey)) == bobKey)
|
||||
|
||||
FavoritesPersistenceService.shared.addFavorite(peerNoisePublicKey: bobKey, peerNickname: "bob")
|
||||
defer { FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: bobKey) }
|
||||
#expect(directory.noiseKey(PeerID(publicKey: bobKey)) == bobKey)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func reachablePeerSkipsCourier() {
|
||||
let bobKey = Data(repeating: 0xB0, count: 32)
|
||||
let bobID = PeerID(publicKey: bobKey)
|
||||
let transport = CourierCaptureTransport()
|
||||
transport.snapshots = [
|
||||
TransportPeerSnapshot(peerID: bobID, nickname: "bob", isConnected: true, noisePublicKey: bobKey, lastSeen: Date())
|
||||
]
|
||||
let directory = CourierDirectory(noiseKey: { _ in bobKey }, isTrustedCourier: { _ in true })
|
||||
let router = MessageRouter(transports: [transport], courierDirectory: directory)
|
||||
|
||||
router.sendPrivate("hi", to: bobID, recipientNickname: "bob", messageID: "m3")
|
||||
|
||||
#expect(transport.directSends == ["m3"])
|
||||
#expect(transport.courierSends.isEmpty)
|
||||
}
|
||||
|
||||
/// A peer can be "reachable" through a transport that cannot deliver
|
||||
/// promptly (Nostr claims any favorite with a known npub, even with no
|
||||
/// relay connection). The queued send must not shadow the courier: a
|
||||
/// sealed copy goes to connected couriers in parallel, and receivers
|
||||
/// dedup by message ID if both arrive.
|
||||
@Test @MainActor
|
||||
func queuedReachableSendAlsoDepositsWithCourier() {
|
||||
let bobKey = Data(repeating: 0xB0, count: 32)
|
||||
let bobID = PeerID(publicKey: bobKey)
|
||||
let carolKey = Data(repeating: 0xC0, count: 32)
|
||||
let carolID = PeerID(publicKey: carolKey)
|
||||
|
||||
let transport = CourierCaptureTransport()
|
||||
transport.snapshots = [
|
||||
TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date())
|
||||
]
|
||||
transport.reachablePeers = [bobID]
|
||||
transport.promptDelivery = false
|
||||
|
||||
let directory = CourierDirectory(
|
||||
noiseKey: { peerID in peerID == bobID ? bobKey : nil },
|
||||
isTrustedCourier: { $0 == carolKey }
|
||||
)
|
||||
let router = MessageRouter(transports: [transport], courierDirectory: directory)
|
||||
var carried: [String] = []
|
||||
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
|
||||
|
||||
router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m4")
|
||||
|
||||
#expect(transport.directSends == ["m4"])
|
||||
#expect(transport.courierSends.count == 1)
|
||||
#expect(transport.courierSends.first?.messageID == "m4")
|
||||
#expect(transport.courierSends.first?.couriers == [carolID])
|
||||
#expect(carried == ["m4"])
|
||||
}
|
||||
|
||||
/// When the reachable transport can deliver promptly (relays up), the
|
||||
/// send is trusted and no courier quota is spent.
|
||||
@Test @MainActor
|
||||
func promptlyDeliverableReachablePeerSkipsCourier() {
|
||||
let bobKey = Data(repeating: 0xB0, count: 32)
|
||||
let bobID = PeerID(publicKey: bobKey)
|
||||
let carolKey = Data(repeating: 0xC0, count: 32)
|
||||
let carolID = PeerID(publicKey: carolKey)
|
||||
|
||||
let transport = CourierCaptureTransport()
|
||||
transport.snapshots = [
|
||||
TransportPeerSnapshot(peerID: carolID, nickname: "carol", isConnected: true, noisePublicKey: carolKey, lastSeen: Date())
|
||||
]
|
||||
transport.reachablePeers = [bobID]
|
||||
|
||||
let directory = CourierDirectory(
|
||||
noiseKey: { peerID in peerID == bobID ? bobKey : nil },
|
||||
isTrustedCourier: { $0 == carolKey }
|
||||
)
|
||||
let router = MessageRouter(transports: [transport], courierDirectory: directory)
|
||||
var carried: [String] = []
|
||||
router.onMessageCarried = { messageID, _ in carried.append(messageID) }
|
||||
|
||||
router.sendPrivate("hi bob", to: bobID, recipientNickname: "bob", messageID: "m5")
|
||||
|
||||
#expect(transport.directSends == ["m5"])
|
||||
#expect(transport.courierSends.isEmpty)
|
||||
#expect(carried.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,24 @@ struct GCSFilterTests {
|
||||
#expect(truncated.allSatisfy { full.contains($0) })
|
||||
}
|
||||
|
||||
@Test func buildFilterReportsFullCoverageWhenBudgetFits() {
|
||||
let ids = (0..<8).map { i in Data(repeating: UInt8(i), count: 16) }
|
||||
let params = GCSFilter.buildFilter(ids: ids, maxBytes: 1024, targetFpr: 0.01)
|
||||
#expect(params.includedCount == ids.count)
|
||||
}
|
||||
|
||||
@Test func buildFilterTrimsTailWhenBudgetExceeded() {
|
||||
// A tight byte budget can't hold every ID, so the encoder trims from
|
||||
// the input tail and reports how many it actually covered.
|
||||
let ids = (0..<200).map { i in
|
||||
Data((0..<16).map { UInt8((i &* 31 &+ $0) & 0xFF) })
|
||||
}
|
||||
let params = GCSFilter.buildFilter(ids: ids, maxBytes: 32, targetFpr: 0.01)
|
||||
#expect(params.includedCount > 0)
|
||||
#expect(params.includedCount < ids.count)
|
||||
#expect(params.data.count <= 32)
|
||||
}
|
||||
|
||||
@Test func requestSyncPacketDecodeRejectsOversizedP() {
|
||||
let valid = RequestSyncPacket(p: 8, m: 4096, data: Data([0x01, 0x02]))
|
||||
#expect(RequestSyncPacket.decode(from: valid.encode()) != nil)
|
||||
|
||||
@@ -194,15 +194,204 @@ struct GossipSyncManagerTests {
|
||||
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
|
||||
// One request per due schedule so each type group gets the full
|
||||
// filter capacity: publicMessages, fragment, and fileTransfer.
|
||||
let sentPackets = delegate.packets
|
||||
#expect(sentPackets.count == 1)
|
||||
#expect(sentPackets.count == 3)
|
||||
let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) }
|
||||
#expect(decoded.count == 1)
|
||||
let types = try #require(decoded.first?.types)
|
||||
#expect(types.contains(.announce))
|
||||
#expect(types.contains(.message))
|
||||
#expect(types.contains(.fragment))
|
||||
#expect(types.contains(.fileTransfer))
|
||||
#expect(decoded.count == 3)
|
||||
let allTypes = decoded.compactMap(\.types).reduce(SyncTypeFlags(rawValue: 0)) { $0.union($1) }
|
||||
#expect(allTypes.contains(.announce))
|
||||
#expect(allTypes.contains(.message))
|
||||
#expect(allTypes.contains(.fragment))
|
||||
#expect(allTypes.contains(.fileTransfer))
|
||||
#expect(decoded.contains { $0.types == .publicMessages })
|
||||
#expect(decoded.contains { $0.types == .fragment })
|
||||
#expect(decoded.contains { $0.types == .fileTransfer })
|
||||
}
|
||||
|
||||
@Test func truncatedFilterCarriesSinceCursor() throws {
|
||||
var config = GossipSyncManager.Config()
|
||||
config.seenCapacity = 100
|
||||
config.gcsMaxBytes = 32 // caps the filter at 28 IDs (256 bits / 9 bits per element)
|
||||
config.messageSyncIntervalSeconds = 1
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
config.maintenanceIntervalSeconds = 0
|
||||
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
let delegate = RecordingDelegate()
|
||||
manager.delegate = delegate
|
||||
|
||||
let sender = try #require(Data(hexString: "1122334455667788"))
|
||||
let baseTimestamp = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let totalMessages = 40
|
||||
for i in 0..<totalMessages {
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: sender,
|
||||
recipientID: nil,
|
||||
timestamp: baseTimestamp + UInt64(i),
|
||||
payload: Data([UInt8(truncatingIfNeeded: i)]),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
manager.onPublicPacketSeen(packet)
|
||||
}
|
||||
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
|
||||
let packet = try #require(delegate.packets.first)
|
||||
let request = try #require(RequestSyncPacket.decode(from: packet.payload))
|
||||
// The store (40) exceeds what the tiny filter can cover, so a cursor
|
||||
// must be present. It points at the oldest timestamp the filter
|
||||
// actually encodes: the filter covers the newest ~28, and byte-budget
|
||||
// trimming can only shrink that further, so the cursor sits at or
|
||||
// above baseTimestamp + 12 (= 40 - 28) and below the newest message.
|
||||
let since = try #require(request.sinceTimestamp)
|
||||
#expect(since >= baseTimestamp + 12)
|
||||
#expect(since < baseTimestamp + UInt64(totalMessages))
|
||||
}
|
||||
|
||||
@Test func fullCoverageFilterOmitsSinceCursor() throws {
|
||||
var config = GossipSyncManager.Config()
|
||||
config.seenCapacity = 100
|
||||
config.messageSyncIntervalSeconds = 1
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
config.maintenanceIntervalSeconds = 0
|
||||
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
let delegate = RecordingDelegate()
|
||||
manager.delegate = delegate
|
||||
|
||||
let sender = try #require(Data(hexString: "1122334455667788"))
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: sender,
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data([0x01]),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
manager.onPublicPacketSeen(packet)
|
||||
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
|
||||
let sent = try #require(delegate.packets.first)
|
||||
let request = try #require(RequestSyncPacket.decode(from: sent.payload))
|
||||
#expect(request.sinceTimestamp == nil)
|
||||
}
|
||||
|
||||
@Test func handleRequestSyncHonorsSinceCursorButAlwaysSendsAnnounces() async throws {
|
||||
var config = GossipSyncManager.Config()
|
||||
config.seenCapacity = 5
|
||||
config.messageSyncIntervalSeconds = 0
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
let delegate = RecordingDelegate()
|
||||
manager.delegate = delegate
|
||||
|
||||
let sender = try #require(Data(hexString: "aabbccddeeff0011"))
|
||||
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
|
||||
// Announce older than the cursor: must still be sent (identity is
|
||||
// needed to verify everything else).
|
||||
let announcePacket = BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: sender,
|
||||
recipientID: nil,
|
||||
timestamp: nowMs - 50_000,
|
||||
payload: Data(),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
let oldMessage = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: sender,
|
||||
recipientID: nil,
|
||||
timestamp: nowMs - 60_000,
|
||||
payload: Data([0x01]),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
let newMessage = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: sender,
|
||||
recipientID: nil,
|
||||
timestamp: nowMs,
|
||||
payload: Data([0x02]),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
|
||||
manager.onPublicPacketSeen(announcePacket)
|
||||
manager.onPublicPacketSeen(oldMessage)
|
||||
manager.onPublicPacketSeen(newMessage)
|
||||
|
||||
let peer = PeerID(str: "FFFFFFFFFFFFFFFF")
|
||||
let request = RequestSyncPacket(
|
||||
p: 7,
|
||||
m: 1,
|
||||
data: Data(),
|
||||
types: .publicMessages,
|
||||
sinceTimestamp: nowMs - 30_000
|
||||
)
|
||||
manager.handleRequestSync(from: peer, request: request)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count == 2 }, timeout: TestConstants.shortTimeout)
|
||||
// Barrier: flush the sync queue so a late third packet would be visible.
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
let sentPackets = delegate.packets
|
||||
#expect(sentPackets.count == 2)
|
||||
#expect(sentPackets.contains { $0.type == MessageType.announce.rawValue })
|
||||
let sentMessages = sentPackets.filter { $0.type == MessageType.message.rawValue }
|
||||
#expect(sentMessages.count == 1)
|
||||
#expect(sentMessages.first?.payload == Data([0x02]))
|
||||
#expect(sentPackets.allSatisfy { $0.isRSR })
|
||||
}
|
||||
|
||||
@Test func handleRequestSyncIsRateLimitedPerPeer() async throws {
|
||||
var config = GossipSyncManager.Config()
|
||||
config.seenCapacity = 5
|
||||
config.messageSyncIntervalSeconds = 0
|
||||
config.fragmentSyncIntervalSeconds = 0
|
||||
config.fileTransferSyncIntervalSeconds = 0
|
||||
config.responseRateLimitMaxResponses = 1
|
||||
config.responseRateLimitWindowSeconds = 60
|
||||
|
||||
let requestSyncManager = RequestSyncManager()
|
||||
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
|
||||
let delegate = RecordingDelegate()
|
||||
manager.delegate = delegate
|
||||
|
||||
let sender = try #require(Data(hexString: "aabbccddeeff0011"))
|
||||
let messagePacket = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: sender,
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data([0x10]),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
manager.onPublicPacketSeen(messagePacket)
|
||||
|
||||
let peer = PeerID(str: "FFFFFFFFFFFFFFFF")
|
||||
let request = RequestSyncPacket(p: 7, m: 1, data: Data(), types: .message)
|
||||
manager.handleRequestSync(from: peer, request: request)
|
||||
manager.handleRequestSync(from: peer, request: request)
|
||||
|
||||
try await TestHelpers.waitFor({ delegate.packets.count >= 1 }, timeout: TestConstants.shortTimeout)
|
||||
// Barrier: both requests have been processed once this returns.
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
#expect(delegate.packets.count == 1)
|
||||
}
|
||||
|
||||
@Test func initialSyncCoalescesEnabledTypes() async throws {
|
||||
@@ -279,6 +468,79 @@ struct GossipSyncManagerTests {
|
||||
#expect(sentPackets.count == 1)
|
||||
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
|
||||
}
|
||||
|
||||
// MARK: - Archive persistence
|
||||
|
||||
@Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws {
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("gossip-archive-\(UUID().uuidString).json")
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
|
||||
let senderID = try #require(Data(hexString: "1122334455667788"))
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: senderID,
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data([0x01, 0x02]),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
|
||||
let first = GossipSyncManager(
|
||||
myPeerID: myPeerID,
|
||||
requestSyncManager: RequestSyncManager(),
|
||||
archive: GossipMessageArchive(fileURL: fileURL)
|
||||
)
|
||||
first.onPublicPacketSeen(packet)
|
||||
// Maintenance persists the dirty store to disk.
|
||||
first._performMaintenanceSynchronously(now: Date())
|
||||
#expect(FileManager.default.fileExists(atPath: fileURL.path))
|
||||
|
||||
// "App restart": a fresh manager over the same archive re-serves it.
|
||||
let second = GossipSyncManager(
|
||||
myPeerID: myPeerID,
|
||||
requestSyncManager: RequestSyncManager(),
|
||||
archive: GossipMessageArchive(fileURL: fileURL)
|
||||
)
|
||||
let restored = await TestHelpers.waitUntil(
|
||||
{ second._messageCount(for: PeerID(hexData: senderID)) == 1 },
|
||||
timeout: TestConstants.shortTimeout
|
||||
)
|
||||
#expect(restored)
|
||||
}
|
||||
|
||||
@Test func archiveDropsMessagesOlderThanPublicWindow() throws {
|
||||
let fileURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("gossip-archive-\(UUID().uuidString).json")
|
||||
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||
|
||||
var config = GossipSyncManager.Config()
|
||||
config.publicMessageMaxAgeSeconds = 60
|
||||
|
||||
let senderID = try #require(Data(hexString: "1122334455667788"))
|
||||
let stale = BitchatPacket(
|
||||
type: MessageType.message.rawValue,
|
||||
senderID: senderID,
|
||||
recipientID: nil,
|
||||
timestamp: UInt64((Date().timeIntervalSince1970 - 120) * 1000),
|
||||
payload: Data([0x01]),
|
||||
signature: nil,
|
||||
ttl: 1
|
||||
)
|
||||
let archive = GossipMessageArchive(fileURL: fileURL)
|
||||
archive.save([stale.toBinaryData(padding: false)!])
|
||||
|
||||
let manager = GossipSyncManager(
|
||||
myPeerID: myPeerID,
|
||||
config: config,
|
||||
requestSyncManager: RequestSyncManager(),
|
||||
archive: archive
|
||||
)
|
||||
manager._performMaintenanceSynchronously(now: Date())
|
||||
#expect(manager._messageCount(for: PeerID(hexData: senderID)) == 0)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private final class RecordingDelegate: GossipSyncManager.Delegate {
|
||||
|
||||
@@ -42,6 +42,7 @@ final class MockTransport: Transport {
|
||||
private(set) var cancelledTransfers: [String] = []
|
||||
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||
private(set) var sentCourierMessages: [(content: String, messageID: String, recipientNoiseKey: Data, couriers: [PeerID])] = []
|
||||
private(set) var startServicesCallCount = 0
|
||||
private(set) var stopServicesCallCount = 0
|
||||
private(set) var emergencyDisconnectCallCount = 0
|
||||
@@ -189,6 +190,12 @@ final class MockTransport: Transport {
|
||||
sentVerifyResponses.append((peerID, noiseKeyHex, nonceA))
|
||||
}
|
||||
|
||||
var courierSendResult = true
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
|
||||
sentCourierMessages.append((content, messageID, recipientNoiseKey, couriers))
|
||||
return courierSendResult
|
||||
}
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
/// Clears all recorded method calls for fresh assertions
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//
|
||||
// NoiseCourierTests.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
|
||||
/// One-way Noise X envelopes: encryption to a known static key without an
|
||||
/// interactive handshake, used by the courier store-and-forward path.
|
||||
struct NoiseCourierTests {
|
||||
|
||||
@Test func sealAndOpenRoundTrip() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
|
||||
let payload = Data("meet at the north gate".utf8)
|
||||
let sealed = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData())
|
||||
|
||||
let opened = try bob.openCourierPayload(sealed)
|
||||
#expect(opened.payload == payload)
|
||||
// The X pattern authenticates the sender: Bob learns Alice's real static key.
|
||||
#expect(opened.senderStaticKey == alice.getStaticPublicKeyData())
|
||||
}
|
||||
|
||||
@Test func wrongRecipientCannotOpen() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let carol = NoiseEncryptionService(keychain: MockKeychain())
|
||||
|
||||
let sealed = try alice.sealCourierPayload(Data("secret".utf8), recipientStaticKey: bob.getStaticPublicKeyData())
|
||||
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try carol.openCourierPayload(sealed)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func tamperedEnvelopeFailsToOpen() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
|
||||
var sealed = try alice.sealCourierPayload(Data("secret".utf8), recipientStaticKey: bob.getStaticPublicKeyData())
|
||||
sealed[sealed.count - 1] ^= 0x01
|
||||
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try bob.openCourierPayload(sealed)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func senderIdentityCannotBeForged() throws {
|
||||
// The encrypted static key inside the envelope is bound by the ss DH;
|
||||
// splicing one envelope's ephemeral prefix onto another must fail.
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
|
||||
let bobKey = bob.getStaticPublicKeyData()
|
||||
let fromAlice = try alice.sealCourierPayload(Data("hi".utf8), recipientStaticKey: bobKey)
|
||||
let fromMallory = try mallory.sealCourierPayload(Data("hi".utf8), recipientStaticKey: bobKey)
|
||||
|
||||
// e (32 bytes) from Mallory's envelope + rest from Alice's.
|
||||
let spliced = fromMallory.prefix(32) + fromAlice.dropFirst(32)
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try bob.openCourierPayload(Data(spliced))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func sealRejectsInvalidRecipientKey() {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: Data(repeating: 0, count: 32))
|
||||
}
|
||||
#expect(throws: (any Error).self) {
|
||||
_ = try alice.sealCourierPayload(Data("x".utf8), recipientStaticKey: Data(repeating: 1, count: 8))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func emptyAndLargePayloadsRoundTrip() throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bobKey = bob.getStaticPublicKeyData()
|
||||
|
||||
let empty = try alice.sealCourierPayload(Data(), recipientStaticKey: bobKey)
|
||||
#expect(try bob.openCourierPayload(empty).payload.isEmpty)
|
||||
|
||||
let large = Data((0..<8192).map { UInt8($0 % 251) })
|
||||
let sealed = try alice.sealCourierPayload(large, recipientStaticKey: bobKey)
|
||||
#expect(try bob.openCourierPayload(sealed).payload == large)
|
||||
}
|
||||
|
||||
@Test func envelopesAreNotLinkableAcrossSends() throws {
|
||||
// Fresh ephemeral per seal: same payload to the same recipient must
|
||||
// produce entirely different ciphertexts.
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let payload = Data("same message".utf8)
|
||||
|
||||
let a = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData())
|
||||
let b = try alice.sealCourierPayload(payload, recipientStaticKey: bob.getStaticPublicKeyData())
|
||||
#expect(a != b)
|
||||
#expect(a.prefix(32) != b.prefix(32))
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@@ -108,6 +109,42 @@ struct PacketsTests {
|
||||
#expect(decoded.directNeighbors == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func announcementPacketRoundTripsCapabilities() throws {
|
||||
let capabilities: PeerCapabilities = [.prekeys, .board, .meshDiagnostics]
|
||||
let packet = AnnouncementPacket(
|
||||
nickname: "alice",
|
||||
noisePublicKey: Data(repeating: 0x11, count: 32),
|
||||
signingPublicKey: Data(repeating: 0x22, count: 32),
|
||||
directNeighbors: nil,
|
||||
capabilities: capabilities
|
||||
)
|
||||
|
||||
let encoded = try #require(packet.encode())
|
||||
let decoded = try #require(AnnouncementPacket.decode(from: encoded))
|
||||
#expect(decoded.capabilities == capabilities)
|
||||
}
|
||||
|
||||
@Test
|
||||
func announcementPacketWithoutCapabilitiesDecodesNilAndUnknownBitsSurvive() throws {
|
||||
let legacy = try #require(
|
||||
AnnouncementPacket(
|
||||
nickname: "alice",
|
||||
noisePublicKey: Data(repeating: 0x11, count: 32),
|
||||
signingPublicKey: Data(repeating: 0x22, count: 32),
|
||||
directNeighbors: nil
|
||||
).encode()
|
||||
)
|
||||
// The TLV is emitted only when capabilities are set, so legacy peers
|
||||
// (and this packet) decode as nil rather than empty.
|
||||
#expect(try #require(AnnouncementPacket.decode(from: legacy)).capabilities == nil)
|
||||
|
||||
var withFutureBits = legacy
|
||||
withFutureBits.append(makeTLV(type: 0x05, value: Data([0x80, 0x01])))
|
||||
let decoded = try #require(AnnouncementPacket.decode(from: withFutureBits))
|
||||
#expect(decoded.capabilities?.rawValue == 0x0180)
|
||||
}
|
||||
|
||||
@Test
|
||||
func privateMessagePacketRejectsUnknownTypeAndTruncation() {
|
||||
let unknownTLV = Data([0x7F, 0x01, 0x41])
|
||||
|
||||
@@ -98,8 +98,12 @@ struct BLEAnnounceHandlerTests {
|
||||
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
let result = handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(result?.peerID == peerID)
|
||||
#expect(result?.announcement.noisePublicKey == noiseKey)
|
||||
#expect(result?.isDirectAnnounce == true)
|
||||
#expect(result?.isVerified == true)
|
||||
#expect(recorder.verifySignatureCalls.count == 1)
|
||||
#expect(recorder.verifySignatureCalls.first?.signingPublicKey == Data(repeating: 0x99, count: 32))
|
||||
#expect(recorder.barrierCount == 1)
|
||||
@@ -161,8 +165,11 @@ struct BLEAnnounceHandlerTests {
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
let result = handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(result?.peerID == peerID)
|
||||
#expect(result?.announcement.noisePublicKey == noiseKey)
|
||||
#expect(result?.isVerified == false)
|
||||
#expect(recorder.verifySignatureCalls.isEmpty)
|
||||
#expect(recorder.barrierCount == 1)
|
||||
#expect(recorder.upsertCalls.isEmpty)
|
||||
@@ -197,8 +204,9 @@ struct BLEAnnounceHandlerTests {
|
||||
recorder.signatureValid = false
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
let result = handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(result?.isVerified == false)
|
||||
#expect(recorder.verifySignatureCalls.count == 1)
|
||||
#expect(recorder.upsertCalls.isEmpty)
|
||||
#expect(recorder.uiEventDeliveries.count == 1)
|
||||
@@ -222,8 +230,9 @@ struct BLEAnnounceHandlerTests {
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
let result = handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(result == nil)
|
||||
expectNoSideEffects(recorder)
|
||||
}
|
||||
|
||||
@@ -242,8 +251,9 @@ struct BLEAnnounceHandlerTests {
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder, localPeerID: peerID, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
let result = handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(result == nil)
|
||||
expectNoSideEffects(recorder)
|
||||
}
|
||||
|
||||
@@ -263,8 +273,9 @@ struct BLEAnnounceHandlerTests {
|
||||
let recorder = Recorder()
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
let result = handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(result == nil)
|
||||
expectNoSideEffects(recorder)
|
||||
}
|
||||
|
||||
@@ -311,8 +322,10 @@ struct BLEAnnounceHandlerTests {
|
||||
recorder.upsertResult = BLEPeerAnnounceUpdate(isNewPeer: true, wasDisconnected: false, previousNickname: nil)
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
let result = handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(result?.isDirectAnnounce == false)
|
||||
#expect(result?.isVerified == true)
|
||||
#expect(recorder.upsertCalls.count == 1)
|
||||
#expect(recorder.upsertCalls.first?.isConnected == false)
|
||||
#expect(recorder.uiEventDeliveries.count == 1)
|
||||
@@ -384,8 +397,9 @@ struct BLEAnnounceHandlerTests {
|
||||
recorder.existingNoisePublicKey = Data(repeating: 0xAA, count: 32)
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
let result = handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(result?.isVerified == false)
|
||||
#expect(recorder.upsertCalls.isEmpty)
|
||||
#expect(recorder.uiEventDeliveries.count == 1)
|
||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||
|
||||
@@ -19,6 +19,79 @@ struct BLEFanoutSelectorTests {
|
||||
#expect(selection.centralIDs == Set(["c2"]))
|
||||
}
|
||||
|
||||
@Test
|
||||
func directedSendUsesOnlyBoundPeripheralLinkWhenAvailable() {
|
||||
let target = PeerID(str: "1122334455667788")
|
||||
let bystander = PeerID(str: "8877665544332211")
|
||||
let selection = BLEFanoutSelector.selectLinks(
|
||||
peripheralIDs: ["target-p", "bystander-p"],
|
||||
centralIDs: ["target-c", "bystander-c"],
|
||||
ingressLink: nil,
|
||||
peripheralPeerBindings: [
|
||||
"target-p": target,
|
||||
"bystander-p": bystander
|
||||
],
|
||||
centralPeerBindings: [
|
||||
"target-c": target,
|
||||
"bystander-c": bystander
|
||||
],
|
||||
directedPeerHint: target,
|
||||
packetType: MessageType.courierEnvelope.rawValue,
|
||||
messageID: "message-1"
|
||||
)
|
||||
|
||||
#expect(selection.peripheralIDs == Set(["target-p"]))
|
||||
#expect(selection.centralIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func directedSendUsesBoundCentralLinkWhenNoPeripheralLinkExists() {
|
||||
let target = PeerID(str: "1122334455667788")
|
||||
let bystander = PeerID(str: "8877665544332211")
|
||||
let selection = BLEFanoutSelector.selectLinks(
|
||||
peripheralIDs: ["bystander-p"],
|
||||
centralIDs: ["target-c", "bystander-c"],
|
||||
ingressLink: nil,
|
||||
peripheralPeerBindings: [
|
||||
"bystander-p": bystander
|
||||
],
|
||||
centralPeerBindings: [
|
||||
"target-c": target,
|
||||
"bystander-c": bystander
|
||||
],
|
||||
directedPeerHint: target,
|
||||
packetType: MessageType.courierEnvelope.rawValue,
|
||||
messageID: "message-1"
|
||||
)
|
||||
|
||||
#expect(selection.peripheralIDs.isEmpty)
|
||||
#expect(selection.centralIDs == Set(["target-c"]))
|
||||
}
|
||||
|
||||
@Test
|
||||
func directedSendToKnownPeerDoesNotFallBackWhenOnlyDirectLinkIsExcluded() {
|
||||
let target = PeerID(str: "1122334455667788")
|
||||
let bystander = PeerID(str: "8877665544332211")
|
||||
let selection = BLEFanoutSelector.selectLinks(
|
||||
peripheralIDs: ["bystander-p"],
|
||||
centralIDs: ["target-c", "bystander-c"],
|
||||
ingressLink: .central("target-c"),
|
||||
peripheralPeerBindings: [
|
||||
"bystander-p": bystander
|
||||
],
|
||||
centralPeerBindings: [
|
||||
"target-c": target,
|
||||
"bystander-c": bystander
|
||||
],
|
||||
directedPeerHint: target,
|
||||
packetType: MessageType.courierEnvelope.rawValue,
|
||||
messageID: "message-1"
|
||||
)
|
||||
|
||||
#expect(selection.peripheralIDs.isEmpty)
|
||||
#expect(selection.centralIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func directedSendExcludesAllLinksToIngressPeer() {
|
||||
let selection = BLEFanoutSelector.selectLinks(
|
||||
|
||||
@@ -105,6 +105,41 @@ struct BLEIngressLinkRegistryTests {
|
||||
#expect(result == .failure(.directSenderMismatch(boundPeerID: boundPeer, claimedSenderID: claimedPeer)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func packetContextRejectsRequestSyncSenderMismatchOnBoundLink() {
|
||||
let localPeer = PeerID(str: "0011223344556677")
|
||||
let boundPeer = PeerID(str: "1122334455667788")
|
||||
let claimedPeer = PeerID(str: "8899aabbccddeeff")
|
||||
let packet = makeRequestSyncPacket(sender: claimedPeer)
|
||||
|
||||
let result = BLEIngressLinkRegistry.packetContext(
|
||||
for: packet,
|
||||
claimedSenderID: claimedPeer,
|
||||
boundPeerID: boundPeer,
|
||||
localPeerID: localPeer,
|
||||
directAnnounceTTL: 7
|
||||
)
|
||||
|
||||
#expect(result == .failure(.directSenderMismatch(boundPeerID: boundPeer, claimedSenderID: claimedPeer)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func packetContextAllowsRequestSyncFromBoundPeer() throws {
|
||||
let localPeer = PeerID(str: "0011223344556677")
|
||||
let boundPeer = PeerID(str: "1122334455667788")
|
||||
let packet = makeRequestSyncPacket(sender: boundPeer)
|
||||
|
||||
let context = try #require(trySuccess(BLEIngressLinkRegistry.packetContext(
|
||||
for: packet,
|
||||
claimedSenderID: boundPeer,
|
||||
boundPeerID: boundPeer,
|
||||
localPeerID: localPeer,
|
||||
directAnnounceTTL: 7
|
||||
)))
|
||||
|
||||
#expect(context.receivedFromPeerID == boundPeer)
|
||||
}
|
||||
|
||||
@Test
|
||||
func packetContextUsesBoundPeerForRSRValidation() throws {
|
||||
let localPeer = PeerID(str: "0011223344556677")
|
||||
@@ -158,6 +193,18 @@ private func makePacket(sender: PeerID, timestamp: UInt64) -> BitchatPacket {
|
||||
)
|
||||
}
|
||||
|
||||
private func makeRequestSyncPacket(sender: PeerID) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.requestSync.rawValue,
|
||||
senderID: Data(hexString: sender.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: 1,
|
||||
payload: Data(),
|
||||
signature: nil,
|
||||
ttl: 0
|
||||
)
|
||||
}
|
||||
|
||||
private func makeAnnouncePacket(sender: PeerID, ttl: UInt8) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
|
||||
@@ -100,11 +100,11 @@ struct BLEPublicMessageHandlerTests {
|
||||
|
||||
@Test
|
||||
func staleBroadcastIsDropped() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let now = Date(timeIntervalSince1970: 1_000_000)
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
let staleTimestamp = UInt64((now.timeIntervalSince1970 - 901) * 1000)
|
||||
let staleTimestamp = UInt64((now.timeIntervalSince1970 - TransportConfig.syncPublicMessageMaxAgeSeconds - 1) * 1000)
|
||||
let packet = makeMessagePacket(sender: remotePeerID, content: "old", timestamp: staleTimestamp)
|
||||
|
||||
handler.handle(packet, from: remotePeerID)
|
||||
|
||||
@@ -36,11 +36,13 @@ struct BLEPublicMessagePolicyTests {
|
||||
|
||||
@Test
|
||||
func staleBroadcastIsRejectedWithAge() {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
// The acceptance window matches the gossip public-history window.
|
||||
let staleAge = TransportConfig.syncPublicMessageMaxAgeSeconds + 1
|
||||
let now = Date(timeIntervalSince1970: 1_000_000)
|
||||
let sender = PeerID(str: "8877665544332211")
|
||||
let packet = makePacket(
|
||||
sender: sender,
|
||||
timestamp: UInt64((now.timeIntervalSince1970 - 901) * 1000),
|
||||
timestamp: UInt64((now.timeIntervalSince1970 - staleAge) * 1000),
|
||||
recipientID: nil
|
||||
)
|
||||
|
||||
@@ -51,7 +53,7 @@ struct BLEPublicMessagePolicyTests {
|
||||
now: now
|
||||
)
|
||||
|
||||
#expect(decision == .reject(.staleBroadcast(ageSeconds: 901)))
|
||||
#expect(decision == .reject(.staleBroadcast(ageSeconds: staleAge)))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -112,6 +112,36 @@ struct BLERouteForwardingPolicyTests {
|
||||
#expect(plan.nextHop == nil)
|
||||
}
|
||||
|
||||
@Test("REQUEST_SYNC is never route-forwarded even with a route and TTL headroom")
|
||||
func requestSyncNeverRouteForwarded() {
|
||||
let previous = peer("1111111111111111")
|
||||
let local = peer("2222222222222222")
|
||||
let nextHop = peer("3333333333333333")
|
||||
let destination = peer("4444444444444444")
|
||||
var packet = makePacket(
|
||||
sender: previous,
|
||||
recipient: destination,
|
||||
ttl: 7,
|
||||
route: [routeData(local), routeData(nextHop)]
|
||||
)
|
||||
packet = BitchatPacket(
|
||||
type: MessageType.requestSync.rawValue,
|
||||
senderID: packet.senderID,
|
||||
recipientID: packet.recipientID,
|
||||
timestamp: packet.timestamp,
|
||||
payload: packet.payload,
|
||||
signature: nil,
|
||||
ttl: packet.ttl,
|
||||
route: packet.route
|
||||
)
|
||||
|
||||
let plan = forwardingPlan(packet, local: local, connected: [nextHop])
|
||||
|
||||
#expect(plan.shouldSuppressFloodRelay)
|
||||
#expect(plan.forwardPacket == nil)
|
||||
#expect(plan.nextHop == nil)
|
||||
}
|
||||
|
||||
private func forwardingPlan(
|
||||
_ packet: BitchatPacket,
|
||||
local: PeerID,
|
||||
|
||||
@@ -49,6 +49,21 @@ final class FavoritesPersistenceServiceTests: XCTestCase {
|
||||
XCTAssertFalse(service.isMutualFavorite(peerKey))
|
||||
}
|
||||
|
||||
func test_updatePeerFavoritedUs_keepsStoredNicknameOverUnknownPlaceholder() {
|
||||
let service = FavoritesPersistenceService(keychain: MockKeychain())
|
||||
let peerKey = Data((128..<160).map(UInt8.init))
|
||||
|
||||
service.addFavorite(peerNoisePublicKey: peerKey, peerNickname: "Erin")
|
||||
|
||||
// A notification arriving before the peer is known passes "Unknown".
|
||||
service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Unknown")
|
||||
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Erin")
|
||||
|
||||
// A real nickname still updates the stored one.
|
||||
service.updatePeerFavoritedUs(peerNoisePublicKey: peerKey, favorited: true, peerNickname: "Erin2")
|
||||
XCTAssertEqual(service.getFavoriteStatus(for: peerKey)?.peerNickname, "Erin2")
|
||||
}
|
||||
|
||||
func test_getFavoriteStatus_forPeerID_returnsMutualFavorite() {
|
||||
let service = FavoritesPersistenceService(keychain: MockKeychain())
|
||||
let peerKey = Data((96..<128).map(UInt8.init))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@@ -42,7 +43,9 @@ struct NostrTransportTests {
|
||||
)
|
||||
)
|
||||
|
||||
#expect(!transport.isPeerReachable(fullPeerID))
|
||||
// Offline favorites are addressed by the full 64-hex noise key, so
|
||||
// both forms must resolve to the same reachability answer.
|
||||
#expect(transport.isPeerReachable(fullPeerID))
|
||||
#expect(transport.isPeerReachable(shortPeerID))
|
||||
#expect(!transport.isPeerReachable(PeerID(str: "feedfeedfeedfeed")))
|
||||
}
|
||||
@@ -83,6 +86,51 @@ struct NostrTransportTests {
|
||||
#expect(didRefresh)
|
||||
}
|
||||
|
||||
@Test("Prompt delivery requires both a known npub and a relay connection")
|
||||
@MainActor
|
||||
func canDeliverPromptlyTracksRelayConnectivity() async throws {
|
||||
let keychain = MockKeychain()
|
||||
let idBridge = NostrIdentityBridge(keychain: keychain)
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let noiseKey = Data((0..<32).map(UInt8.init))
|
||||
let peerID = PeerID(hexData: noiseKey)
|
||||
let relationship = makeRelationship(
|
||||
peerNoisePublicKey: noiseKey,
|
||||
peerNostrPublicKey: recipient.npub,
|
||||
peerNickname: "Alice"
|
||||
)
|
||||
let connectivity = CurrentValueSubject<Bool, Never>(false)
|
||||
|
||||
let transport = NostrTransport(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
dependencies: makeDependencies(
|
||||
loadFavorites: { [noiseKey: relationship] },
|
||||
relayConnectivity: { connectivity.eraseToAnyPublisher() }
|
||||
)
|
||||
)
|
||||
|
||||
// Reachable (npub known) but relays down: the peer must not be
|
||||
// treated as promptly deliverable, or the router would skip the
|
||||
// courier and let the message rot in the Nostr send queue.
|
||||
#expect(transport.isPeerReachable(peerID))
|
||||
#expect(!transport.canDeliverPromptly(to: peerID))
|
||||
|
||||
connectivity.send(true)
|
||||
let deliverable = await TestHelpers.waitUntil(
|
||||
{ transport.canDeliverPromptly(to: peerID) },
|
||||
timeout: 5.0
|
||||
)
|
||||
#expect(deliverable)
|
||||
|
||||
connectivity.send(false)
|
||||
let undeliverable = await TestHelpers.waitUntil(
|
||||
{ !transport.canDeliverPromptly(to: peerID) },
|
||||
timeout: 5.0
|
||||
)
|
||||
#expect(undeliverable)
|
||||
}
|
||||
|
||||
@Test("Private message resolves short peer ID and emits decryptable packet")
|
||||
@MainActor
|
||||
func sendPrivateMessageResolvesShortPeerID() async throws {
|
||||
@@ -373,7 +421,8 @@ struct NostrTransportTests {
|
||||
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
|
||||
registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in },
|
||||
sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in },
|
||||
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in }
|
||||
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in },
|
||||
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never> = { Just(false).eraseToAnyPublisher() }
|
||||
) -> NostrTransport.Dependencies {
|
||||
NostrTransport.Dependencies(
|
||||
notificationCenter: notificationCenter,
|
||||
@@ -383,7 +432,8 @@ struct NostrTransportTests {
|
||||
currentIdentity: currentIdentity,
|
||||
registerPendingGiftWrap: registerPendingGiftWrap,
|
||||
sendEvent: sendEvent,
|
||||
scheduleAfter: scheduleAfter
|
||||
scheduleAfter: scheduleAfter,
|
||||
relayConnectivity: relayConnectivity
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +134,25 @@ struct RelayControllerTests {
|
||||
#expect(decision.newTTL == TransportConfig.bleFragmentRelayTtlCapDense - 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func requestSync_neverRelaysEvenWithTTLHeadroom() async {
|
||||
let decision = RelayController.decide(
|
||||
ttl: 7,
|
||||
senderIsSelf: false,
|
||||
isEncrypted: false,
|
||||
isDirectedEncrypted: false,
|
||||
isFragment: false,
|
||||
isDirectedFragment: false,
|
||||
isHandshake: false,
|
||||
isAnnounce: false,
|
||||
isRequestSync: true,
|
||||
degree: 3,
|
||||
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
|
||||
)
|
||||
|
||||
#expect(!decision.shouldRelay)
|
||||
}
|
||||
|
||||
@Test
|
||||
func denseGraph_capsTTL() async {
|
||||
let decision = RelayController.decide(
|
||||
|
||||
@@ -66,6 +66,117 @@ struct UnifiedPeerServiceTests {
|
||||
#expect(!service.isBlocked(peerID))
|
||||
}
|
||||
|
||||
// MARK: - Offline-favorite dedup (updatePeers phase 2)
|
||||
|
||||
/// A mutual favorite that is also on the mesh must collapse to a single
|
||||
/// row keyed by the short mesh ID — even when the announced nickname no
|
||||
/// longer matches the one stored with the favorite.
|
||||
@Test @MainActor
|
||||
func updatePeers_mutualFavoriteOnMeshYieldsSingleRow() async {
|
||||
let favoritesService = FavoritesPersistenceService.shared
|
||||
|
||||
let transport = MockTransport()
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager())
|
||||
|
||||
let noiseKey = Data(repeating: 0xAB, count: 32)
|
||||
favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "alice")
|
||||
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true)
|
||||
defer {
|
||||
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false)
|
||||
favoritesService.removeFavorite(peerNoisePublicKey: noiseKey)
|
||||
}
|
||||
|
||||
let meshID = PeerID(publicKey: noiseKey)
|
||||
let snapshots = [TransportPeerSnapshot(
|
||||
peerID: meshID,
|
||||
nickname: "alice-renamed",
|
||||
isConnected: true,
|
||||
noisePublicKey: noiseKey,
|
||||
lastSeen: Date()
|
||||
)]
|
||||
transport.updatePeerSnapshots(snapshots)
|
||||
service.didUpdatePeerSnapshots(snapshots)
|
||||
|
||||
let rows = service.peers.filter { $0.noisePublicKey == noiseKey }
|
||||
#expect(rows.count == 1)
|
||||
#expect(rows.first?.peerID == meshID)
|
||||
#expect(rows.first?.isMutualFavorite == true)
|
||||
#expect(service.favorites.filter { $0.noisePublicKey == noiseKey }.count == 1)
|
||||
}
|
||||
|
||||
/// Same collapse must hold for a reachable-but-not-connected favorite
|
||||
/// (relayed peers linger as "reachable" after their link drops).
|
||||
@Test @MainActor
|
||||
func updatePeers_reachableMutualFavoriteYieldsSingleRow() async {
|
||||
let favoritesService = FavoritesPersistenceService.shared
|
||||
|
||||
let transport = MockTransport()
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager())
|
||||
|
||||
let noiseKey = Data(repeating: 0xCD, count: 32)
|
||||
favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "bob")
|
||||
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true)
|
||||
defer {
|
||||
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false)
|
||||
favoritesService.removeFavorite(peerNoisePublicKey: noiseKey)
|
||||
}
|
||||
|
||||
let otherKey = Data(repeating: 0x11, count: 32)
|
||||
let snapshots = [
|
||||
// A live link is required for anyone to count as reachable.
|
||||
TransportPeerSnapshot(
|
||||
peerID: PeerID(publicKey: otherKey),
|
||||
nickname: "carol",
|
||||
isConnected: true,
|
||||
noisePublicKey: otherKey,
|
||||
lastSeen: Date()
|
||||
),
|
||||
TransportPeerSnapshot(
|
||||
peerID: PeerID(publicKey: noiseKey),
|
||||
nickname: "bob",
|
||||
isConnected: false,
|
||||
noisePublicKey: noiseKey,
|
||||
lastSeen: Date()
|
||||
)
|
||||
]
|
||||
transport.updatePeerSnapshots(snapshots)
|
||||
service.didUpdatePeerSnapshots(snapshots)
|
||||
|
||||
let bobRows = service.peers.filter { $0.noisePublicKey == noiseKey }
|
||||
#expect(bobRows.count == 1)
|
||||
#expect(bobRows.first?.peerID == PeerID(publicKey: noiseKey))
|
||||
#expect(bobRows.first?.isReachable == true)
|
||||
}
|
||||
|
||||
/// A mutual favorite with no mesh presence still gets its offline row,
|
||||
/// keyed by the full noise-key PeerID.
|
||||
@Test @MainActor
|
||||
func updatePeers_offlineMutualFavoriteGetsOfflineRow() async {
|
||||
let favoritesService = FavoritesPersistenceService.shared
|
||||
|
||||
let transport = MockTransport()
|
||||
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
|
||||
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: TestIdentityManager())
|
||||
|
||||
let noiseKey = Data(repeating: 0xEF, count: 32)
|
||||
favoritesService.addFavorite(peerNoisePublicKey: noiseKey, peerNickname: "dave")
|
||||
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: true)
|
||||
defer {
|
||||
favoritesService.updatePeerFavoritedUs(peerNoisePublicKey: noiseKey, favorited: false)
|
||||
favoritesService.removeFavorite(peerNoisePublicKey: noiseKey)
|
||||
}
|
||||
|
||||
transport.updatePeerSnapshots([])
|
||||
service.didUpdatePeerSnapshots([])
|
||||
|
||||
let rows = service.peers.filter { $0.noisePublicKey == noiseKey }
|
||||
#expect(rows.count == 1)
|
||||
#expect(rows.first?.peerID == PeerID(hexData: noiseKey))
|
||||
#expect(rows.first?.isMutualFavorite == true)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func setBlocked_unknownIdentityReturnsNil() async {
|
||||
let transport = MockTransport()
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import BitFoundation
|
||||
@testable import bitchat
|
||||
|
||||
struct SyncResponseRateLimiterTests {
|
||||
|
||||
private let peer = PeerID(str: "1122334455667788")
|
||||
private let otherPeer = PeerID(str: "8899aabbccddeeff")
|
||||
|
||||
@Test func allowsResponsesUpToBudgetThenBlocks() {
|
||||
var limiter = SyncResponseRateLimiter(maxResponses: 2, window: 30)
|
||||
let now = Date()
|
||||
|
||||
let first = limiter.shouldRespond(to: peer, now: now)
|
||||
let second = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(1))
|
||||
let third = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(2))
|
||||
|
||||
#expect(first)
|
||||
#expect(second)
|
||||
#expect(!third)
|
||||
}
|
||||
|
||||
@Test func budgetIsPerPeer() {
|
||||
var limiter = SyncResponseRateLimiter(maxResponses: 1, window: 30)
|
||||
let now = Date()
|
||||
|
||||
let first = limiter.shouldRespond(to: peer, now: now)
|
||||
let repeated = limiter.shouldRespond(to: peer, now: now)
|
||||
let other = limiter.shouldRespond(to: otherPeer, now: now)
|
||||
|
||||
#expect(first)
|
||||
#expect(!repeated)
|
||||
#expect(other)
|
||||
}
|
||||
|
||||
@Test func allowsAgainAfterWindowSlides() {
|
||||
var limiter = SyncResponseRateLimiter(maxResponses: 1, window: 30)
|
||||
let now = Date()
|
||||
|
||||
let first = limiter.shouldRespond(to: peer, now: now)
|
||||
let insideWindow = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(29))
|
||||
let afterWindow = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(31))
|
||||
|
||||
#expect(first)
|
||||
#expect(!insideWindow)
|
||||
#expect(afterWindow)
|
||||
}
|
||||
|
||||
@Test func pruneDropsExpiredHistory() {
|
||||
var limiter = SyncResponseRateLimiter(maxResponses: 1, window: 30)
|
||||
let now = Date()
|
||||
|
||||
let first = limiter.shouldRespond(to: peer, now: now)
|
||||
limiter.prune(now: now.addingTimeInterval(31))
|
||||
let afterPrune = limiter.shouldRespond(to: peer, now: now.addingTimeInterval(32))
|
||||
|
||||
#expect(first)
|
||||
#expect(afterPrune)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//
|
||||
// WifiBulkCryptoTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("WifiBulk channel crypto")
|
||||
struct WifiBulkCryptoTests {
|
||||
private static func keyHex(_ key: SymmetricKey) -> String {
|
||||
key.withUnsafeBytes { Data($0).map { String(format: "%02x", $0) }.joined() }
|
||||
}
|
||||
|
||||
private func makeKey() throws -> SymmetricKey {
|
||||
try #require(WifiBulkCrypto.deriveKey(
|
||||
senderToken: Data(repeating: 0x11, count: 32),
|
||||
receiverToken: Data(repeating: 0x22, count: 32),
|
||||
transferID: Data(repeating: 0x33, count: 16)
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: HKDF derivation
|
||||
|
||||
@Test("HKDF derivation matches fixed vectors")
|
||||
func hkdfVectors() throws {
|
||||
// Vectors computed independently with CryptoKit HKDF<SHA256>,
|
||||
// ikm = senderToken ‖ receiverToken, salt = transferID,
|
||||
// info = "bitchat-bulk-v1", 32 bytes out.
|
||||
let key1 = try makeKey()
|
||||
#expect(Self.keyHex(key1) == "9ee6f4bf7753a8a9564d6760b7064e31657f1a6bcca2b3ff266bb975cc4f66eb")
|
||||
|
||||
let key2 = try #require(WifiBulkCrypto.deriveKey(
|
||||
senderToken: Data((0..<32).map { UInt8($0) }),
|
||||
receiverToken: Data((0..<32).map { UInt8(255 - $0) }),
|
||||
transferID: Data((0..<16).map { UInt8($0 * 3) })
|
||||
))
|
||||
#expect(Self.keyHex(key2) == "432ebb559f2f546d632a91d53b5c25af36f15d1ba53917910a0041329dc0efd4")
|
||||
}
|
||||
|
||||
@Test("HKDF derivation is order- and role-sensitive")
|
||||
func hkdfRoleSensitivity() throws {
|
||||
let a = Data(repeating: 0x11, count: 32)
|
||||
let b = Data(repeating: 0x22, count: 32)
|
||||
let tid = Data(repeating: 0x33, count: 16)
|
||||
let forward = try #require(WifiBulkCrypto.deriveKey(senderToken: a, receiverToken: b, transferID: tid))
|
||||
let reversed = try #require(WifiBulkCrypto.deriveKey(senderToken: b, receiverToken: a, transferID: tid))
|
||||
#expect(Self.keyHex(forward) != Self.keyHex(reversed))
|
||||
}
|
||||
|
||||
@Test("HKDF derivation rejects wrong-length inputs")
|
||||
func hkdfRejectsBadLengths() {
|
||||
let token = Data(repeating: 1, count: 32)
|
||||
let tid = Data(repeating: 2, count: 16)
|
||||
#expect(WifiBulkCrypto.deriveKey(senderToken: Data(count: 31), receiverToken: token, transferID: tid) == nil)
|
||||
#expect(WifiBulkCrypto.deriveKey(senderToken: token, receiverToken: Data(count: 33), transferID: tid) == nil)
|
||||
#expect(WifiBulkCrypto.deriveKey(senderToken: token, receiverToken: token, transferID: Data(count: 15)) == nil)
|
||||
}
|
||||
|
||||
// MARK: Frame sealing
|
||||
|
||||
@Test("Frame seal/open round-trips")
|
||||
func frameRoundTrip() throws {
|
||||
let key = try makeKey()
|
||||
let plaintext = Data((0..<1000).map { UInt8($0 % 251) })
|
||||
let body = try WifiBulkCrypto.sealFrameBody(plaintext, direction: .senderToReceiver, counter: 7, key: key)
|
||||
let opened = try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 7, key: key)
|
||||
#expect(opened == plaintext)
|
||||
}
|
||||
|
||||
@Test("Tampered frames are rejected")
|
||||
func tamperRejection() throws {
|
||||
let key = try makeKey()
|
||||
var body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 0, key: key)
|
||||
body[body.count - 1] ^= 0x01 // flip a tag bit
|
||||
#expect(throws: WifiBulkCryptoError.authenticationFailed) {
|
||||
try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 0, key: key)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Frames cannot be replayed at another counter or reflected across directions")
|
||||
func nonceBinding() throws {
|
||||
let key = try makeKey()
|
||||
let body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 3, key: key)
|
||||
#expect(throws: WifiBulkCryptoError.nonceMismatch) {
|
||||
try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 4, key: key)
|
||||
}
|
||||
#expect(throws: WifiBulkCryptoError.nonceMismatch) {
|
||||
try WifiBulkCrypto.openFrameBody(body, direction: .receiverToSender, counter: 3, key: key)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Frames sealed under a different key are rejected")
|
||||
func wrongKeyRejection() throws {
|
||||
let key = try makeKey()
|
||||
let otherKey = try #require(WifiBulkCrypto.deriveKey(
|
||||
senderToken: Data(repeating: 0x44, count: 32),
|
||||
receiverToken: Data(repeating: 0x22, count: 32),
|
||||
transferID: Data(repeating: 0x33, count: 16)
|
||||
))
|
||||
let body = try WifiBulkCrypto.sealFrameBody(Data(repeating: 9, count: 64), direction: .senderToReceiver, counter: 0, key: otherKey)
|
||||
#expect(throws: WifiBulkCryptoError.authenticationFailed) {
|
||||
try WifiBulkCrypto.openFrameBody(body, direction: .senderToReceiver, counter: 0, key: key)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Auth and receipt control frames validate and reject forgeries")
|
||||
func controlFrames() throws {
|
||||
let key = try makeKey()
|
||||
let transferID = Data(repeating: 0x33, count: 16)
|
||||
let hash = Data(repeating: 0x55, count: 32)
|
||||
|
||||
let auth = try WifiBulkCrypto.makeClientAuthFrameBody(transferID: transferID, key: key)
|
||||
#expect(WifiBulkCrypto.validateClientAuthFrameBody(auth, transferID: transferID, key: key))
|
||||
#expect(!WifiBulkCrypto.validateClientAuthFrameBody(auth, transferID: Data(repeating: 0x34, count: 16), key: key))
|
||||
var forgedAuth = auth
|
||||
forgedAuth[forgedAuth.count - 1] ^= 0x01
|
||||
#expect(!WifiBulkCrypto.validateClientAuthFrameBody(forgedAuth, transferID: transferID, key: key))
|
||||
|
||||
let receipt = try WifiBulkCrypto.makeReceiptFrameBody(payloadHash: hash, key: key)
|
||||
#expect(WifiBulkCrypto.validateReceiptFrameBody(receipt, payloadHash: hash, key: key))
|
||||
#expect(!WifiBulkCrypto.validateReceiptFrameBody(receipt, payloadHash: Data(repeating: 0x56, count: 32), key: key))
|
||||
// An auth frame is not a receipt (distinct counter).
|
||||
#expect(!WifiBulkCrypto.validateReceiptFrameBody(auth, payloadHash: hash, key: key))
|
||||
}
|
||||
|
||||
// MARK: Frame buffer
|
||||
|
||||
@Test("Frame buffer reassembles frames from arbitrary byte boundaries")
|
||||
func frameBufferReassembly() throws {
|
||||
let bodyA = Data(repeating: 0xAA, count: 100)
|
||||
let bodyB = Data(repeating: 0xBB, count: 5)
|
||||
var stream = WifiBulkCrypto.frameData(body: bodyA)
|
||||
stream.append(WifiBulkCrypto.frameData(body: bodyB))
|
||||
|
||||
let buffer = WifiBulkFrameBuffer(maxBodyBytes: 1024)
|
||||
// Drip-feed 3 bytes at a time.
|
||||
var extracted: [Data] = []
|
||||
var index = stream.startIndex
|
||||
while index < stream.endIndex {
|
||||
let next = stream.index(index, offsetBy: 3, limitedBy: stream.endIndex) ?? stream.endIndex
|
||||
buffer.append(Data(stream[index..<next]))
|
||||
while let body = try buffer.nextFrameBody() {
|
||||
extracted.append(body)
|
||||
}
|
||||
index = next
|
||||
}
|
||||
#expect(extracted == [bodyA, bodyB])
|
||||
#expect(try buffer.nextFrameBody() == nil)
|
||||
}
|
||||
|
||||
@Test("Frame buffer rejects oversized frame lengths without buffering them")
|
||||
func frameBufferOversizeRejection() {
|
||||
let buffer = WifiBulkFrameBuffer(maxBodyBytes: 64)
|
||||
buffer.append(WifiBulkCrypto.frameData(body: Data(repeating: 1, count: 65)).prefix(8))
|
||||
#expect(throws: WifiBulkCryptoError.frameTooLarge) {
|
||||
_ = try buffer.nextFrameBody()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Payload assembler
|
||||
|
||||
private func sealedChunks(_ payload: Data, chunkSize: Int, key: SymmetricKey) throws -> [Data] {
|
||||
try stride(from: 0, to: payload.count, by: chunkSize).enumerated().map { index, offset in
|
||||
try WifiBulkCrypto.sealFrameBody(
|
||||
Data(payload[offset..<min(offset + chunkSize, payload.count)]),
|
||||
direction: .senderToReceiver,
|
||||
counter: UInt64(index),
|
||||
key: key
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Assembler reassembles and verifies a chunked payload")
|
||||
func assemblerHappyPath() throws {
|
||||
let key = try makeKey()
|
||||
let payload = Data((0..<200_000).map { UInt8($0 % 253) })
|
||||
let assembler = try #require(WifiBulkPayloadAssembler(
|
||||
key: key,
|
||||
expectedSize: UInt64(payload.count),
|
||||
expectedHash: Data(SHA256.hash(data: payload)),
|
||||
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
|
||||
))
|
||||
|
||||
var result: Data?
|
||||
for chunk in try sealedChunks(payload, chunkSize: 64 * 1024, key: key) {
|
||||
result = try assembler.consume(frameBody: chunk)
|
||||
}
|
||||
#expect(result == payload)
|
||||
}
|
||||
|
||||
@Test("Assembler rejects a payload whose final hash mismatches the offer")
|
||||
func assemblerHashMismatch() throws {
|
||||
let key = try makeKey()
|
||||
let payload = Data(repeating: 0x77, count: 100_000)
|
||||
let assembler = try #require(WifiBulkPayloadAssembler(
|
||||
key: key,
|
||||
expectedSize: UInt64(payload.count),
|
||||
expectedHash: Data(repeating: 0, count: 32), // wrong hash
|
||||
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
|
||||
))
|
||||
|
||||
let chunks = try sealedChunks(payload, chunkSize: 64 * 1024, key: key)
|
||||
_ = try assembler.consume(frameBody: chunks[0])
|
||||
#expect(throws: WifiBulkCryptoError.hashMismatch) {
|
||||
_ = try assembler.consume(frameBody: chunks[1])
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Assembler rejects overflow beyond the offered size")
|
||||
func assemblerOverflow() throws {
|
||||
let key = try makeKey()
|
||||
let payload = Data(repeating: 0x77, count: 1000)
|
||||
let assembler = try #require(WifiBulkPayloadAssembler(
|
||||
key: key,
|
||||
expectedSize: 500, // offer promised less than the sender streams
|
||||
expectedHash: Data(SHA256.hash(data: payload)),
|
||||
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
|
||||
))
|
||||
let chunk = try WifiBulkCrypto.sealFrameBody(payload, direction: .senderToReceiver, counter: 0, key: key)
|
||||
#expect(throws: WifiBulkCryptoError.payloadOverflow) {
|
||||
_ = try assembler.consume(frameBody: chunk)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Assembler enforces the receiver-side size cap at construction")
|
||||
func assemblerSizeCap() throws {
|
||||
let key = try makeKey()
|
||||
#expect(WifiBulkPayloadAssembler(
|
||||
key: key,
|
||||
expectedSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes) + 1,
|
||||
expectedHash: Data(repeating: 0, count: 32),
|
||||
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
|
||||
) == nil)
|
||||
#expect(WifiBulkPayloadAssembler(
|
||||
key: key,
|
||||
expectedSize: 0,
|
||||
expectedHash: Data(repeating: 0, count: 32),
|
||||
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
|
||||
) == nil)
|
||||
}
|
||||
|
||||
@Test("Assembler rejects out-of-order chunks")
|
||||
func assemblerOutOfOrder() throws {
|
||||
let key = try makeKey()
|
||||
let payload = Data(repeating: 0x42, count: 100_000)
|
||||
let assembler = try #require(WifiBulkPayloadAssembler(
|
||||
key: key,
|
||||
expectedSize: UInt64(payload.count),
|
||||
expectedHash: Data(SHA256.hash(data: payload)),
|
||||
sizeCap: FileTransferLimits.maxWifiBulkPayloadBytes
|
||||
))
|
||||
let chunks = try sealedChunks(payload, chunkSize: 64 * 1024, key: key)
|
||||
#expect(throws: WifiBulkCryptoError.nonceMismatch) {
|
||||
_ = try assembler.consume(frameBody: chunks[1]) // skip chunk 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
//
|
||||
// WifiBulkLoopbackTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Network
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
/// End-to-end integration over real Network.framework sockets on localhost:
|
||||
/// two `WifiBulkTransferService` instances negotiate through an in-process
|
||||
/// "Noise" pipe, then move the payload over a genuine TCP connection using
|
||||
/// the production listener/browser-free test hooks (peer-to-peer and Bonjour
|
||||
/// are disabled — unit-test hosts have no AWDL or mDNS access).
|
||||
@Suite("WifiBulk loopback integration", .serialized)
|
||||
struct WifiBulkLoopbackTests {
|
||||
private static let senderPeer = PeerID(str: "00112233aabbccdd")
|
||||
private static let receiverPeer = PeerID(str: "ddccbbaa33221100")
|
||||
|
||||
private func makeConfig() -> WifiBulkTransferServiceConfig {
|
||||
var config = WifiBulkTransferServiceConfig()
|
||||
config.usePeerToPeer = false
|
||||
config.publishBonjourService = false
|
||||
config.offerTimeout = 5.0
|
||||
config.transferWindow = 10.0
|
||||
return config
|
||||
}
|
||||
|
||||
@Test("Payload crosses a real TCP loopback channel and lands verified")
|
||||
func loopbackTransferSucceeds() async throws {
|
||||
let payload = Data((0..<300_000).map { UInt8($0 % 249) })
|
||||
|
||||
let delivered = WifiBulkTestBox<Data>()
|
||||
let progress = WifiBulkTestBox<String>()
|
||||
let fallbacks = WifiBulkTestBox<Bool>()
|
||||
let ports = WifiBulkTestBox<(Data, UInt16)>()
|
||||
let offers = WifiBulkTestBox<Data>()
|
||||
|
||||
var senderService: WifiBulkTransferService?
|
||||
var receiverService: WifiBulkTransferService?
|
||||
|
||||
// Once the receiver has accepted AND the listener port is known,
|
||||
// connect the receiver straight to 127.0.0.1 (Bonjour stand-in).
|
||||
let accepted = WifiBulkTestBox<Data>()
|
||||
let connectIfReady: () -> Void = {
|
||||
guard let (transferID, port) = ports.values.first,
|
||||
accepted.values.contains(transferID),
|
||||
let nwPort = NWEndpoint.Port(rawValue: port) else { return }
|
||||
receiverService?._test_connectIncoming(
|
||||
transferID: transferID,
|
||||
to: NWEndpoint.hostPort(host: "127.0.0.1", port: nwPort)
|
||||
)
|
||||
}
|
||||
|
||||
let senderEnv = WifiBulkTransferServiceEnvironment(
|
||||
sendNoisePayload: { typed, _ in
|
||||
// Sender → receiver control plane (offer).
|
||||
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue else { return true }
|
||||
offers.append(Data(typed.dropFirst()))
|
||||
receiverService?.handleOfferPayload(Data(typed.dropFirst()), from: Self.senderPeer)
|
||||
return true
|
||||
},
|
||||
isPeerConnected: { _ in true },
|
||||
deliverReceivedFile: { _, _, _ in },
|
||||
progressStart: { id, total in progress.append("start:\(id):\(total)") },
|
||||
progressChunkSent: { id in progress.append("chunk:\(id)") },
|
||||
progressReset: { id in progress.append("reset:\(id)") },
|
||||
progressCancel: { id in progress.append("cancel:\(id)") }
|
||||
)
|
||||
let receiverEnv = WifiBulkTransferServiceEnvironment(
|
||||
sendNoisePayload: { typed, _ in
|
||||
// Receiver → sender control plane (response).
|
||||
guard typed.first == NoisePayloadType.bulkTransferResponse.rawValue else { return true }
|
||||
let body = Data(typed.dropFirst())
|
||||
if let response = WifiBulkResponse.decode(body), response.accepted {
|
||||
accepted.append(response.transferID)
|
||||
}
|
||||
senderService?.handleResponsePayload(body, from: Self.receiverPeer)
|
||||
connectIfReady()
|
||||
return true
|
||||
},
|
||||
isPeerConnected: { _ in true },
|
||||
deliverReceivedFile: { data, peer, limit in
|
||||
#expect(peer == Self.senderPeer)
|
||||
#expect(limit == FileTransferLimits.maxWifiBulkPayloadBytes)
|
||||
delivered.append(data)
|
||||
},
|
||||
progressStart: { _, _ in },
|
||||
progressChunkSent: { _ in },
|
||||
progressReset: { _ in },
|
||||
progressCancel: { _ in }
|
||||
)
|
||||
|
||||
let sender = WifiBulkTransferService(environment: senderEnv, config: makeConfig())
|
||||
sender._test_onListenerReady = { transferID, port in
|
||||
ports.append((transferID, port))
|
||||
connectIfReady()
|
||||
}
|
||||
let receiver = WifiBulkTransferService(environment: receiverEnv, config: makeConfig())
|
||||
senderService = sender
|
||||
receiverService = receiver
|
||||
|
||||
sender.sendFile(payload: payload, to: Self.receiverPeer, transferId: "t-loopback") {
|
||||
fallbacks.append(true)
|
||||
}
|
||||
|
||||
#expect(await wifiBulkWait(timeout: 10.0) { delivered.count == 1 })
|
||||
#expect(delivered.values.first == payload)
|
||||
#expect(fallbacks.count == 0)
|
||||
|
||||
// Deterministic teardown: both sides drop all transfer state.
|
||||
#expect(await wifiBulkWait { sender._test_activeOutgoingCount == 0 })
|
||||
#expect(await wifiBulkWait { receiver._test_activeIncomingCount == 0 })
|
||||
|
||||
// Progress mirrored the BLE contract: start with the chunk total,
|
||||
// then exactly `total` chunk ticks (the last one gated on the receipt).
|
||||
let totalChunks = (payload.count + TransportConfig.wifiBulkChunkBytes - 1) / TransportConfig.wifiBulkChunkBytes
|
||||
#expect(await wifiBulkWait { progress.values.filter { $0 == "chunk:t-loopback" }.count == totalChunks })
|
||||
#expect(progress.values.first == "start:t-loopback:\(totalChunks)")
|
||||
#expect(!progress.values.contains("reset:t-loopback"))
|
||||
}
|
||||
|
||||
@Test("A gatecrasher without the channel key is disconnected and the real peer still succeeds")
|
||||
func gatecrasherIsRejected() async throws {
|
||||
let payload = Data((0..<150_000).map { UInt8($0 % 241) })
|
||||
|
||||
let delivered = WifiBulkTestBox<Data>()
|
||||
let fallbacks = WifiBulkTestBox<Bool>()
|
||||
let ports = WifiBulkTestBox<(Data, UInt16)>()
|
||||
let accepted = WifiBulkTestBox<Data>()
|
||||
|
||||
var senderService: WifiBulkTransferService?
|
||||
var receiverService: WifiBulkTransferService?
|
||||
|
||||
let gatecrashed = WifiBulkTestBox<Bool>()
|
||||
let connectIfReady: () -> Void = {
|
||||
guard let (transferID, port) = ports.values.first,
|
||||
accepted.values.contains(transferID),
|
||||
gatecrashed.count > 0,
|
||||
let nwPort = NWEndpoint.Port(rawValue: port) else { return }
|
||||
receiverService?._test_connectIncoming(
|
||||
transferID: transferID,
|
||||
to: NWEndpoint.hostPort(host: "127.0.0.1", port: nwPort)
|
||||
)
|
||||
}
|
||||
|
||||
let senderEnv = WifiBulkTransferServiceEnvironment(
|
||||
sendNoisePayload: { typed, _ in
|
||||
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue else { return true }
|
||||
receiverService?.handleOfferPayload(Data(typed.dropFirst()), from: Self.senderPeer)
|
||||
return true
|
||||
},
|
||||
isPeerConnected: { _ in true },
|
||||
deliverReceivedFile: { _, _, _ in },
|
||||
progressStart: { _, _ in },
|
||||
progressChunkSent: { _ in },
|
||||
progressReset: { _ in },
|
||||
progressCancel: { _ in }
|
||||
)
|
||||
let receiverEnv = WifiBulkTransferServiceEnvironment(
|
||||
sendNoisePayload: { typed, _ in
|
||||
guard typed.first == NoisePayloadType.bulkTransferResponse.rawValue else { return true }
|
||||
let body = Data(typed.dropFirst())
|
||||
if let response = WifiBulkResponse.decode(body), response.accepted {
|
||||
accepted.append(response.transferID)
|
||||
}
|
||||
senderService?.handleResponsePayload(body, from: Self.receiverPeer)
|
||||
connectIfReady()
|
||||
return true
|
||||
},
|
||||
isPeerConnected: { _ in true },
|
||||
deliverReceivedFile: { data, _, _ in delivered.append(data) },
|
||||
progressStart: { _, _ in },
|
||||
progressChunkSent: { _ in },
|
||||
progressReset: { _ in },
|
||||
progressCancel: { _ in }
|
||||
)
|
||||
|
||||
let sender = WifiBulkTransferService(environment: senderEnv, config: makeConfig())
|
||||
let gateQueue = DispatchQueue(label: "test.gatecrasher")
|
||||
sender._test_onListenerReady = { transferID, port in
|
||||
ports.append((transferID, port))
|
||||
guard let nwPort = NWEndpoint.Port(rawValue: port) else { return }
|
||||
// A stranger who saw the Bonjour advertisement connects first and
|
||||
// sends garbage that cannot carry a valid MAC.
|
||||
let crasher = NWConnection(
|
||||
to: NWEndpoint.hostPort(host: "127.0.0.1", port: nwPort),
|
||||
using: .tcp
|
||||
)
|
||||
crasher.stateUpdateHandler = { state in
|
||||
if case .ready = state {
|
||||
let junkBody = Data(repeating: 0xAA, count: 60)
|
||||
crasher.send(
|
||||
content: WifiBulkCrypto.frameData(body: junkBody),
|
||||
completion: .contentProcessed { _ in
|
||||
gatecrashed.append(true)
|
||||
connectIfReady()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
crasher.start(queue: gateQueue)
|
||||
}
|
||||
let receiver = WifiBulkTransferService(environment: receiverEnv, config: makeConfig())
|
||||
senderService = sender
|
||||
receiverService = receiver
|
||||
|
||||
sender.sendFile(payload: payload, to: Self.receiverPeer, transferId: "t-gatecrash") {
|
||||
fallbacks.append(true)
|
||||
}
|
||||
|
||||
#expect(await wifiBulkWait(timeout: 10.0) { delivered.count == 1 })
|
||||
#expect(delivered.values.first == payload)
|
||||
#expect(fallbacks.count == 0)
|
||||
#expect(await wifiBulkWait { sender._test_activeOutgoingCount == 0 })
|
||||
}
|
||||
|
||||
@Test("Receiver vanishing mid-negotiation leaves the sender to time out into BLE")
|
||||
func vanishedReceiverFallsBack() async {
|
||||
var config = makeConfig()
|
||||
config.offerTimeout = 0.3
|
||||
|
||||
let fallbacks = WifiBulkTestBox<Bool>()
|
||||
let environment = WifiBulkTransferServiceEnvironment(
|
||||
sendNoisePayload: { _, _ in true }, // offer sent, receiver never answers
|
||||
isPeerConnected: { _ in true },
|
||||
deliverReceivedFile: { _, _, _ in },
|
||||
progressStart: { _, _ in },
|
||||
progressChunkSent: { _ in },
|
||||
progressReset: { _ in },
|
||||
progressCancel: { _ in }
|
||||
)
|
||||
let sender = WifiBulkTransferService(environment: environment, config: config)
|
||||
sender.sendFile(payload: Data(repeating: 1, count: 100_000), to: Self.receiverPeer, transferId: "t-vanish") {
|
||||
fallbacks.append(true)
|
||||
}
|
||||
#expect(await wifiBulkWait { fallbacks.count == 1 })
|
||||
#expect(sender._test_activeOutgoingCount == 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//
|
||||
// WifiBulkMessagesTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("WifiBulk offer/response TLV")
|
||||
struct WifiBulkMessagesTests {
|
||||
private func makeOffer(
|
||||
fileSize: UInt64 = 300_000,
|
||||
serviceName: String = "a1b2c3d4e5f60718a1b2c3d4e5f60718"
|
||||
) -> WifiBulkOffer {
|
||||
WifiBulkOffer(
|
||||
transferID: Data(repeating: 0xAB, count: WifiBulkWire.transferIDLength),
|
||||
fileSize: fileSize,
|
||||
payloadHash: Data(repeating: 0xCD, count: WifiBulkWire.hashLength),
|
||||
token: Data(repeating: 0xEF, count: WifiBulkWire.tokenLength),
|
||||
serviceName: serviceName
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Offer round-trips through TLV encoding")
|
||||
func offerRoundTrip() throws {
|
||||
let offer = makeOffer()
|
||||
let encoded = try #require(offer.encode())
|
||||
let decoded = try #require(WifiBulkOffer.decode(encoded))
|
||||
#expect(decoded == offer)
|
||||
}
|
||||
|
||||
@Test("Offer encode rejects malformed field lengths")
|
||||
func offerEncodeRejectsBadFields() {
|
||||
#expect(WifiBulkOffer(
|
||||
transferID: Data(repeating: 1, count: 15), // short transferID
|
||||
fileSize: 1,
|
||||
payloadHash: Data(repeating: 2, count: 32),
|
||||
token: Data(repeating: 3, count: 32),
|
||||
serviceName: "x"
|
||||
).encode() == nil)
|
||||
|
||||
#expect(makeOffer(serviceName: "").encode() == nil)
|
||||
#expect(makeOffer(serviceName: String(repeating: "a", count: 64)).encode() == nil)
|
||||
}
|
||||
|
||||
@Test("Offer decode rejects missing or wrong-length fields")
|
||||
func offerDecodeRejectsMalformed() throws {
|
||||
let encoded = try #require(makeOffer().encode())
|
||||
|
||||
// Truncation anywhere breaks a TLV boundary or drops a required field.
|
||||
#expect(WifiBulkOffer.decode(encoded.dropLast(1)) == nil)
|
||||
#expect(WifiBulkOffer.decode(encoded.prefix(3)) == nil)
|
||||
#expect(WifiBulkOffer.decode(Data()) == nil)
|
||||
|
||||
// A wrong-length transferID TLV is ignored, leaving the field missing.
|
||||
var mangled = Data([0x01, 0x00, 0x02, 0xAA, 0xBB]) // transferID of 2 bytes
|
||||
mangled.append(encoded.dropFirst(3 + WifiBulkWire.transferIDLength))
|
||||
#expect(WifiBulkOffer.decode(mangled) == nil)
|
||||
}
|
||||
|
||||
@Test("Offer decode skips unknown TLVs for forward compatibility")
|
||||
func offerDecodeSkipsUnknownTLVs() throws {
|
||||
var encoded = try #require(makeOffer().encode())
|
||||
encoded.append(contentsOf: [0x7F, 0x00, 0x03, 0x01, 0x02, 0x03]) // unknown type 0x7F
|
||||
let decoded = try #require(WifiBulkOffer.decode(encoded))
|
||||
#expect(decoded == makeOffer())
|
||||
}
|
||||
|
||||
@Test("Accept response round-trips with token")
|
||||
func acceptResponseRoundTrip() throws {
|
||||
let response = WifiBulkResponse.accept(
|
||||
transferID: Data(repeating: 0x11, count: WifiBulkWire.transferIDLength),
|
||||
token: Data(repeating: 0x22, count: WifiBulkWire.tokenLength)
|
||||
)
|
||||
let encoded = try #require(response.encode())
|
||||
let decoded = try #require(WifiBulkResponse.decode(encoded))
|
||||
#expect(decoded == response)
|
||||
#expect(decoded.accepted)
|
||||
#expect(decoded.token?.count == WifiBulkWire.tokenLength)
|
||||
}
|
||||
|
||||
@Test("Decline response round-trips without token")
|
||||
func declineResponseRoundTrip() throws {
|
||||
let response = WifiBulkResponse.decline(
|
||||
transferID: Data(repeating: 0x11, count: WifiBulkWire.transferIDLength)
|
||||
)
|
||||
let encoded = try #require(response.encode())
|
||||
let decoded = try #require(WifiBulkResponse.decode(encoded))
|
||||
#expect(decoded == response)
|
||||
#expect(!decoded.accepted)
|
||||
#expect(decoded.token == nil)
|
||||
}
|
||||
|
||||
@Test("Accept response without a token is rejected")
|
||||
func acceptWithoutTokenRejected() throws {
|
||||
// Hand-build: transferID + accepted=1, no token TLV.
|
||||
var data = Data()
|
||||
WifiBulkWire.appendTLV(0x01, value: Data(repeating: 0x11, count: 16), into: &data)
|
||||
WifiBulkWire.appendTLV(0x02, value: Data([1]), into: &data)
|
||||
#expect(WifiBulkResponse.decode(data) == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//
|
||||
// WifiBulkPolicyTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("WifiBulk policy")
|
||||
struct WifiBulkPolicyTests {
|
||||
private func candidate(
|
||||
payloadBytes: Int = 300_000,
|
||||
capabilities: PeerCapabilities = [.wifiBulk],
|
||||
direct: Bool = true,
|
||||
session: Bool = true
|
||||
) -> WifiBulkPolicy.SendCandidate {
|
||||
WifiBulkPolicy.SendCandidate(
|
||||
payloadBytes: payloadBytes,
|
||||
peerCapabilities: capabilities,
|
||||
isDirectlyConnected: direct,
|
||||
hasEstablishedNoiseSession: session
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Eligible large transfer to a direct wifiBulk peer is offered")
|
||||
func eligibleTransferOffered() {
|
||||
#expect(WifiBulkPolicy.shouldOffer(candidate(), enabled: true))
|
||||
}
|
||||
|
||||
@Test("Fallback matrix: every failed gate keeps the transfer on BLE")
|
||||
func fallbackMatrix() {
|
||||
// Feature disabled.
|
||||
#expect(!WifiBulkPolicy.shouldOffer(candidate(), enabled: false))
|
||||
// Small file: negotiation overhead not worth it.
|
||||
#expect(!WifiBulkPolicy.shouldOffer(candidate(payloadBytes: 64 * 1024), enabled: true))
|
||||
// Peer doesn't advertise the capability.
|
||||
#expect(!WifiBulkPolicy.shouldOffer(candidate(capabilities: []), enabled: true))
|
||||
#expect(!WifiBulkPolicy.shouldOffer(candidate(capabilities: [.prekeys, .gateway]), enabled: true))
|
||||
// Multi-hop recipient (reachable but not directly connected).
|
||||
#expect(!WifiBulkPolicy.shouldOffer(candidate(direct: false), enabled: true))
|
||||
// No established Noise session to carry the offer.
|
||||
#expect(!WifiBulkPolicy.shouldOffer(candidate(session: false), enabled: true))
|
||||
// Payload beyond even the Wi-Fi ceiling.
|
||||
#expect(!WifiBulkPolicy.shouldOffer(
|
||||
candidate(payloadBytes: FileTransferLimits.maxWifiBulkPayloadBytes + 1),
|
||||
enabled: true
|
||||
))
|
||||
}
|
||||
|
||||
@Test("Offer threshold is strictly greater than the minimum")
|
||||
func offerThresholdBoundary() {
|
||||
#expect(!WifiBulkPolicy.shouldOffer(
|
||||
candidate(payloadBytes: TransportConfig.wifiBulkMinPayloadBytes),
|
||||
enabled: true
|
||||
))
|
||||
#expect(WifiBulkPolicy.shouldOffer(
|
||||
candidate(payloadBytes: TransportConfig.wifiBulkMinPayloadBytes + 1),
|
||||
enabled: true
|
||||
))
|
||||
}
|
||||
|
||||
private func offer(fileSize: UInt64) -> WifiBulkOffer {
|
||||
WifiBulkOffer(
|
||||
transferID: Data(repeating: 1, count: WifiBulkWire.transferIDLength),
|
||||
fileSize: fileSize,
|
||||
payloadHash: Data(repeating: 2, count: WifiBulkWire.hashLength),
|
||||
token: Data(repeating: 3, count: WifiBulkWire.tokenLength),
|
||||
serviceName: "0011223344556677"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Receiver accepts an in-cap offer from a direct peer")
|
||||
func receiverAccepts() {
|
||||
#expect(WifiBulkPolicy.shouldAccept(
|
||||
offer: offer(fileSize: 1_000_000),
|
||||
senderIsDirectlyConnected: true,
|
||||
activeIncomingTransfers: 0,
|
||||
enabled: true
|
||||
))
|
||||
}
|
||||
|
||||
@Test("Receiver enforces its own size cap, not the sender's word")
|
||||
func receiverSizeCap() {
|
||||
#expect(!WifiBulkPolicy.shouldAccept(
|
||||
offer: offer(fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes) + 1),
|
||||
senderIsDirectlyConnected: true,
|
||||
activeIncomingTransfers: 0,
|
||||
enabled: true
|
||||
))
|
||||
#expect(WifiBulkPolicy.shouldAccept(
|
||||
offer: offer(fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes)),
|
||||
senderIsDirectlyConnected: true,
|
||||
activeIncomingTransfers: 0,
|
||||
enabled: true
|
||||
))
|
||||
#expect(!WifiBulkPolicy.shouldAccept(
|
||||
offer: offer(fileSize: 0),
|
||||
senderIsDirectlyConnected: true,
|
||||
activeIncomingTransfers: 0,
|
||||
enabled: true
|
||||
))
|
||||
}
|
||||
|
||||
@Test("Receiver declines when disabled, indirect, or saturated")
|
||||
func receiverDeclines() {
|
||||
#expect(!WifiBulkPolicy.shouldAccept(
|
||||
offer: offer(fileSize: 1000),
|
||||
senderIsDirectlyConnected: true,
|
||||
activeIncomingTransfers: 0,
|
||||
enabled: false
|
||||
))
|
||||
#expect(!WifiBulkPolicy.shouldAccept(
|
||||
offer: offer(fileSize: 1000),
|
||||
senderIsDirectlyConnected: false,
|
||||
activeIncomingTransfers: 0,
|
||||
enabled: true
|
||||
))
|
||||
#expect(!WifiBulkPolicy.shouldAccept(
|
||||
offer: offer(fileSize: 1000),
|
||||
senderIsDirectlyConnected: true,
|
||||
activeIncomingTransfers: TransportConfig.wifiBulkMaxConcurrentIncoming,
|
||||
enabled: true
|
||||
))
|
||||
}
|
||||
|
||||
@Test("This build advertises the wifiBulk capability when enabled")
|
||||
func localCapabilityAdvertised() {
|
||||
#expect(PeerCapabilities.localSupported.contains(.wifiBulk) == TransportConfig.wifiBulkEnabled)
|
||||
}
|
||||
|
||||
@Test("File packets above the BLE cap encode/decode only with the Wi-Fi limit")
|
||||
func filePacketWifiLimit() throws {
|
||||
let content = Data(repeating: 0x5A, count: 2 * 1024 * 1024) // 2 MiB
|
||||
let packet = BitchatFilePacket(fileName: "big.jpg", fileSize: UInt64(content.count), mimeType: "image/jpeg", content: content)
|
||||
|
||||
// BLE cap unchanged.
|
||||
#expect(packet.encode() == nil)
|
||||
|
||||
let encoded = try #require(packet.encode(limit: FileTransferLimits.maxWifiBulkPayloadBytes))
|
||||
#expect(BitchatFilePacket.decode(encoded) == nil) // BLE-cap decode still rejects
|
||||
let decoded = try #require(BitchatFilePacket.decode(encoded, limit: FileTransferLimits.maxWifiBulkPayloadBytes))
|
||||
#expect(decoded.content == content)
|
||||
#expect(decoded.fileName == "big.jpg")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
//
|
||||
// WifiBulkTransferServiceTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Network
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
/// Thread-safe capture box for closures invoked on service queues.
|
||||
final class WifiBulkTestBox<Value>: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var storage: [Value] = []
|
||||
|
||||
func append(_ value: Value) {
|
||||
lock.lock()
|
||||
storage.append(value)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
var values: [Value] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return storage
|
||||
}
|
||||
|
||||
var count: Int { values.count }
|
||||
}
|
||||
|
||||
func wifiBulkWait(
|
||||
timeout: TimeInterval = 5.0,
|
||||
_ condition: @escaping () -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while Date() < deadline {
|
||||
if condition() { return true }
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
}
|
||||
return condition()
|
||||
}
|
||||
|
||||
/// Negotiation/fallback decisions exercised through the real service with the
|
||||
/// network kept on loopback and Bonjour publication disabled.
|
||||
@Suite("WifiBulk transfer service negotiation", .serialized)
|
||||
struct WifiBulkTransferServiceTests {
|
||||
private static let peer = PeerID(str: "aabbccddeeff0011")
|
||||
|
||||
private func makeConfig() -> WifiBulkTransferServiceConfig {
|
||||
var config = WifiBulkTransferServiceConfig()
|
||||
config.usePeerToPeer = false
|
||||
config.publishBonjourService = false
|
||||
config.offerTimeout = 0.25
|
||||
config.transferWindow = 2.0
|
||||
return config
|
||||
}
|
||||
|
||||
private func makeEnvironment(
|
||||
sendNoisePayload: @escaping (Data, PeerID) -> Bool,
|
||||
deliver: @escaping (Data, PeerID, Int) -> Void = { _, _, _ in },
|
||||
progressEvents: WifiBulkTestBox<String>? = nil
|
||||
) -> WifiBulkTransferServiceEnvironment {
|
||||
WifiBulkTransferServiceEnvironment(
|
||||
sendNoisePayload: sendNoisePayload,
|
||||
isPeerConnected: { _ in true },
|
||||
deliverReceivedFile: deliver,
|
||||
progressStart: { id, total in progressEvents?.append("start:\(id):\(total)") },
|
||||
progressChunkSent: { id in progressEvents?.append("chunk:\(id)") },
|
||||
progressReset: { id in progressEvents?.append("reset:\(id)") },
|
||||
progressCancel: { id in progressEvents?.append("cancel:\(id)") }
|
||||
)
|
||||
}
|
||||
|
||||
private var payload: Data { Data(repeating: 0x42, count: 100_000) }
|
||||
|
||||
@Test("No established Noise session falls straight back to BLE")
|
||||
func noSessionFallsBack() async {
|
||||
let fallbacks = WifiBulkTestBox<Bool>()
|
||||
let service = WifiBulkTransferService(
|
||||
environment: makeEnvironment(sendNoisePayload: { _, _ in false }),
|
||||
config: makeConfig()
|
||||
)
|
||||
service.sendFile(payload: payload, to: Self.peer, transferId: "t-nosession") {
|
||||
fallbacks.append(true)
|
||||
}
|
||||
#expect(await wifiBulkWait { fallbacks.count == 1 })
|
||||
#expect(service._test_activeOutgoingCount == 0)
|
||||
}
|
||||
|
||||
@Test("Unanswered offer times out and falls back exactly once")
|
||||
func offerTimeoutFallsBackOnce() async {
|
||||
let fallbacks = WifiBulkTestBox<Bool>()
|
||||
let progress = WifiBulkTestBox<String>()
|
||||
let service = WifiBulkTransferService(
|
||||
environment: makeEnvironment(sendNoisePayload: { _, _ in true }, progressEvents: progress),
|
||||
config: makeConfig()
|
||||
)
|
||||
service.sendFile(payload: payload, to: Self.peer, transferId: "t-timeout") {
|
||||
fallbacks.append(true)
|
||||
}
|
||||
#expect(await wifiBulkWait { fallbacks.count == 1 })
|
||||
// Wait past the transfer window: the expiry must not double-fire.
|
||||
try? await Task.sleep(nanoseconds: 2_300_000_000)
|
||||
#expect(fallbacks.count == 1)
|
||||
#expect(service._test_activeOutgoingCount == 0)
|
||||
// Progress state was silently reset ahead of the BLE re-start.
|
||||
#expect(progress.values.contains("reset:t-timeout"))
|
||||
#expect(!progress.values.contains("cancel:t-timeout"))
|
||||
}
|
||||
|
||||
@Test("Declined offer falls back exactly once, even on duplicate declines")
|
||||
func declineFallsBackOnce() async {
|
||||
let fallbacks = WifiBulkTestBox<Bool>()
|
||||
let offers = WifiBulkTestBox<Data>()
|
||||
var service: WifiBulkTransferService?
|
||||
let environment = makeEnvironment(sendNoisePayload: { typed, peer in
|
||||
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue,
|
||||
let offer = WifiBulkOffer.decode(typed.dropFirst()) else { return true }
|
||||
offers.append(offer.transferID)
|
||||
guard let decline = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return true }
|
||||
// Deliver the decline twice; the fallback must still fire once.
|
||||
service?.handleResponsePayload(decline, from: peer)
|
||||
service?.handleResponsePayload(decline, from: peer)
|
||||
return true
|
||||
})
|
||||
let sut = WifiBulkTransferService(environment: environment, config: makeConfig())
|
||||
service = sut
|
||||
sut.sendFile(payload: payload, to: Self.peer, transferId: "t-decline") {
|
||||
fallbacks.append(true)
|
||||
}
|
||||
#expect(await wifiBulkWait { fallbacks.count == 1 })
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
#expect(fallbacks.count == 1)
|
||||
#expect(sut._test_activeOutgoingCount == 0)
|
||||
}
|
||||
|
||||
@Test("Responses from the wrong peer are ignored")
|
||||
func wrongPeerResponseIgnored() async {
|
||||
let fallbacks = WifiBulkTestBox<Bool>()
|
||||
var service: WifiBulkTransferService?
|
||||
let environment = makeEnvironment(sendNoisePayload: { typed, _ in
|
||||
guard typed.first == NoisePayloadType.bulkTransferOffer.rawValue,
|
||||
let offer = WifiBulkOffer.decode(typed.dropFirst()),
|
||||
let decline = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return true }
|
||||
// Decline arrives from an unrelated peer: must be ignored, so the
|
||||
// transfer ends via offer timeout instead.
|
||||
service?.handleResponsePayload(decline, from: PeerID(str: "1122334455667788"))
|
||||
return true
|
||||
})
|
||||
let sut = WifiBulkTransferService(environment: environment, config: makeConfig())
|
||||
service = sut
|
||||
sut.sendFile(payload: payload, to: Self.peer, transferId: "t-wrongpeer") {
|
||||
fallbacks.append(true)
|
||||
}
|
||||
// Not fallen back before the offer timeout window…
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
#expect(fallbacks.count == 0)
|
||||
// …but the timeout still cleans up.
|
||||
#expect(await wifiBulkWait { fallbacks.count == 1 })
|
||||
}
|
||||
|
||||
@Test("User cancel tears down without BLE fallback")
|
||||
func userCancelDoesNotFallBack() async {
|
||||
let fallbacks = WifiBulkTestBox<Bool>()
|
||||
let progress = WifiBulkTestBox<String>()
|
||||
let service = WifiBulkTransferService(
|
||||
environment: makeEnvironment(sendNoisePayload: { _, _ in true }, progressEvents: progress),
|
||||
config: makeConfig()
|
||||
)
|
||||
service.sendFile(payload: payload, to: Self.peer, transferId: "t-cancel") {
|
||||
fallbacks.append(true)
|
||||
}
|
||||
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 1 })
|
||||
service.cancelTransfer(transferId: "t-cancel")
|
||||
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 0 })
|
||||
try? await Task.sleep(nanoseconds: 400_000_000) // past the offer timeout
|
||||
#expect(fallbacks.count == 0)
|
||||
#expect(progress.values.contains("cancel:t-cancel"))
|
||||
}
|
||||
|
||||
@Test("Receiver declines an offer that exceeds its size cap")
|
||||
func receiverDeclinesOversizedOffer() async {
|
||||
let responses = WifiBulkTestBox<Data>()
|
||||
let service = WifiBulkTransferService(
|
||||
environment: makeEnvironment(sendNoisePayload: { typed, _ in
|
||||
if typed.first == NoisePayloadType.bulkTransferResponse.rawValue {
|
||||
responses.append(Data(typed.dropFirst()))
|
||||
}
|
||||
return true
|
||||
}),
|
||||
config: makeConfig()
|
||||
)
|
||||
let offer = WifiBulkOffer(
|
||||
transferID: Data(repeating: 7, count: WifiBulkWire.transferIDLength),
|
||||
fileSize: UInt64(FileTransferLimits.maxWifiBulkPayloadBytes) + 1,
|
||||
payloadHash: Data(repeating: 8, count: WifiBulkWire.hashLength),
|
||||
token: Data(repeating: 9, count: WifiBulkWire.tokenLength),
|
||||
serviceName: "0011223344556677"
|
||||
)
|
||||
if let encoded = offer.encode() {
|
||||
service.handleOfferPayload(encoded, from: Self.peer)
|
||||
}
|
||||
#expect(await wifiBulkWait { responses.count == 1 })
|
||||
let response = WifiBulkResponse.decode(responses.values[0])
|
||||
#expect(response?.accepted == false)
|
||||
#expect(response?.transferID == offer.transferID)
|
||||
#expect(service._test_activeIncomingCount == 0)
|
||||
}
|
||||
|
||||
@Test("Malformed offers are dropped without a response")
|
||||
func malformedOfferDropped() async {
|
||||
let responses = WifiBulkTestBox<Data>()
|
||||
let service = WifiBulkTransferService(
|
||||
environment: makeEnvironment(sendNoisePayload: { typed, _ in
|
||||
responses.append(typed)
|
||||
return true
|
||||
}),
|
||||
config: makeConfig()
|
||||
)
|
||||
service.handleOfferPayload(Data([0x01, 0x02, 0x03]), from: Self.peer)
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
#expect(responses.count == 0)
|
||||
#expect(service._test_activeIncomingCount == 0)
|
||||
}
|
||||
|
||||
@Test("stop() tears down active transfers without falling back")
|
||||
func stopTearsDownWithoutFallback() async {
|
||||
let fallbacks = WifiBulkTestBox<Bool>()
|
||||
let service = WifiBulkTransferService(
|
||||
environment: makeEnvironment(sendNoisePayload: { _, _ in true }),
|
||||
config: makeConfig()
|
||||
)
|
||||
service.sendFile(payload: payload, to: Self.peer, transferId: "t-stop") {
|
||||
fallbacks.append(true)
|
||||
}
|
||||
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 1 })
|
||||
service.stop()
|
||||
#expect(await wifiBulkWait { service._test_activeOutgoingCount == 0 })
|
||||
try? await Task.sleep(nanoseconds: 400_000_000)
|
||||
#expect(fallbacks.count == 0)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//
|
||||
// CourierEnvelope.swift
|
||||
// BitFoundation
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
private import CryptoKit
|
||||
|
||||
/// TLV payload for store-and-forward courier envelopes.
|
||||
///
|
||||
/// A courier envelope lets a mutual favorite physically carry an encrypted
|
||||
/// message to a peer who is currently offline. The envelope is opaque to the
|
||||
/// courier: the only routing information is a rotating recipient tag derived
|
||||
/// from the recipient's Noise static public key and the UTC day, so envelopes
|
||||
/// addressed to the same peer on different days do not correlate for
|
||||
/// observers who don't already know that peer's public key.
|
||||
public struct CourierEnvelope: Equatable {
|
||||
/// Rotating recipient hint: HMAC-SHA256(recipient static key, context || epoch day), truncated.
|
||||
public let recipientTag: Data
|
||||
/// Milliseconds since epoch after which the envelope must be discarded.
|
||||
public let expiry: UInt64
|
||||
/// Opaque one-way Noise X ciphertext (sender identity rides inside).
|
||||
public let ciphertext: Data
|
||||
/// Spray-and-wait copy budget: how many redundant copies of this envelope
|
||||
/// the holder may still hand to other couriers (binary split on each
|
||||
/// spray). 1 means carry-only — deliver to the recipient, never re-spray.
|
||||
public let copies: UInt8
|
||||
|
||||
public static let tagLength = 16
|
||||
/// Couriered messages are text-sized; media transfers are out of scope.
|
||||
public static let maxCiphertextBytes = 16 * 1024
|
||||
/// Matches the outbox retention policy in MessageRouter.
|
||||
public static let maxLifetimeSeconds: TimeInterval = 24 * 60 * 60
|
||||
/// Cap on the copy budget a depositor can claim, so a malicious envelope
|
||||
/// cannot turn the courier network into an amplifier.
|
||||
public static let maxCopies: UInt8 = 8
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case recipientTag = 0x01
|
||||
case expiry = 0x02
|
||||
case ciphertext = 0x03
|
||||
case copies = 0x04
|
||||
}
|
||||
|
||||
public init(recipientTag: Data, expiry: UInt64, ciphertext: Data, copies: UInt8 = 1) {
|
||||
self.recipientTag = recipientTag
|
||||
self.expiry = expiry
|
||||
self.ciphertext = ciphertext
|
||||
self.copies = min(max(copies, 1), Self.maxCopies)
|
||||
}
|
||||
|
||||
/// The same envelope with a different remaining copy budget.
|
||||
public func withCopies(_ copies: UInt8) -> CourierEnvelope {
|
||||
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies)
|
||||
}
|
||||
|
||||
public var isExpired: Bool {
|
||||
isExpired(at: Date())
|
||||
}
|
||||
|
||||
public func isExpired(at date: Date) -> Bool {
|
||||
UInt64(max(0, date.timeIntervalSince1970 * 1000)) >= expiry
|
||||
}
|
||||
|
||||
public func encode() -> Data? {
|
||||
guard recipientTag.count == Self.tagLength else { return nil }
|
||||
guard !ciphertext.isEmpty, ciphertext.count <= Self.maxCiphertextBytes else { return nil }
|
||||
|
||||
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||
var big = value.bigEndian
|
||||
withUnsafeBytes(of: &big) { data.append(contentsOf: $0) }
|
||||
}
|
||||
|
||||
var encoded = Data()
|
||||
encoded.reserveCapacity(3 * 3 + Self.tagLength + 8 + ciphertext.count)
|
||||
|
||||
encoded.append(TLVType.recipientTag.rawValue)
|
||||
appendBE(UInt16(recipientTag.count), into: &encoded)
|
||||
encoded.append(recipientTag)
|
||||
|
||||
encoded.append(TLVType.expiry.rawValue)
|
||||
appendBE(UInt16(8), into: &encoded)
|
||||
appendBE(expiry, into: &encoded)
|
||||
|
||||
encoded.append(TLVType.ciphertext.rawValue)
|
||||
appendBE(UInt16(ciphertext.count), into: &encoded)
|
||||
encoded.append(ciphertext)
|
||||
|
||||
// Omitted when 1 so carry-only envelopes stay byte-identical to the
|
||||
// pre-spray wire format (old clients skip the TLV as unknown anyway).
|
||||
if copies > 1 {
|
||||
encoded.append(TLVType.copies.rawValue)
|
||||
appendBE(UInt16(1), into: &encoded)
|
||||
encoded.append(copies)
|
||||
}
|
||||
|
||||
return encoded
|
||||
}
|
||||
|
||||
public static func decode(_ data: Data) -> CourierEnvelope? {
|
||||
var cursor = data.startIndex
|
||||
let end = data.endIndex
|
||||
|
||||
var recipientTag: Data?
|
||||
var expiry: UInt64?
|
||||
var ciphertext: Data?
|
||||
var copies: UInt8 = 1
|
||||
|
||||
while cursor < end {
|
||||
let typeRaw = data[cursor]
|
||||
cursor = data.index(after: cursor)
|
||||
|
||||
guard data.distance(from: cursor, to: end) >= 2 else { return nil }
|
||||
let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)])
|
||||
cursor = data.index(cursor, offsetBy: 2)
|
||||
guard data.distance(from: cursor, to: end) >= length else { return nil }
|
||||
let value = data[cursor..<data.index(cursor, offsetBy: length)]
|
||||
cursor = data.index(cursor, offsetBy: length)
|
||||
|
||||
switch TLVType(rawValue: typeRaw) {
|
||||
case .recipientTag:
|
||||
guard length == tagLength else { return nil }
|
||||
recipientTag = Data(value)
|
||||
case .expiry:
|
||||
guard length == 8 else { return nil }
|
||||
expiry = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
|
||||
case .ciphertext:
|
||||
guard length > 0, length <= maxCiphertextBytes else { return nil }
|
||||
ciphertext = Data(value)
|
||||
case .copies:
|
||||
guard length == 1 else { return nil }
|
||||
copies = value.first ?? 1
|
||||
case nil:
|
||||
// Unknown TLV: skip for forward compatibility.
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
guard let recipientTag, let expiry, let ciphertext else { return nil }
|
||||
return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies)
|
||||
}
|
||||
|
||||
// MARK: - Recipient Tags
|
||||
|
||||
private static let tagContext = Data("bitchat-courier-tag-v1".utf8)
|
||||
|
||||
/// UTC day number used to rotate recipient tags.
|
||||
public static func epochDay(for date: Date) -> UInt32 {
|
||||
UInt32(max(0, date.timeIntervalSince1970) / 86_400)
|
||||
}
|
||||
|
||||
/// Rotating recipient hint for a given day. Computable only by parties
|
||||
/// who already know the recipient's Noise static public key.
|
||||
public static func recipientTag(noiseStaticKey: Data, epochDay: UInt32) -> Data {
|
||||
var message = tagContext
|
||||
withUnsafeBytes(of: epochDay.bigEndian) { message.append(contentsOf: $0) }
|
||||
let mac = HMAC<SHA256>.authenticationCode(for: message, using: SymmetricKey(data: noiseStaticKey))
|
||||
return Data(mac).prefix(tagLength)
|
||||
}
|
||||
|
||||
/// Tags to test when checking whether an envelope is addressed to a peer.
|
||||
/// Covers the adjacent days so envelopes sealed near midnight (or across
|
||||
/// modest clock skew) still match while being carried.
|
||||
public static func candidateTags(noiseStaticKey: Data, around date: Date) -> [Data] {
|
||||
let day = epochDay(for: date)
|
||||
return [day == 0 ? 0 : day - 1, day, day + 1].map {
|
||||
recipientTag(noiseStaticKey: noiseStaticKey, epochDay: $0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,17 +11,20 @@ import struct Foundation.Date
|
||||
public enum DeliveryStatus: Codable, Equatable, Hashable {
|
||||
case sending
|
||||
case sent // Left our device
|
||||
case carried // Sealed envelope handed to a courier; best-effort physical delivery
|
||||
case delivered(to: String, at: Date) // Confirmed by recipient
|
||||
case read(by: String, at: Date) // Seen by recipient
|
||||
case failed(reason: String)
|
||||
case partiallyDelivered(reached: Int, total: Int) // For rooms
|
||||
|
||||
|
||||
public var displayText: String {
|
||||
switch self {
|
||||
case .sending:
|
||||
return "Sending..."
|
||||
case .sent:
|
||||
return "Sent"
|
||||
case .carried:
|
||||
return "Carried by a friend"
|
||||
case .delivered(let nickname, _):
|
||||
return "Delivered to \(nickname)"
|
||||
case .read(let nickname, _):
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
public enum FileTransferLimits {
|
||||
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
||||
public static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
||||
/// Ceiling for transfers negotiated onto the peer-to-peer Wi-Fi bulk
|
||||
/// channel. The receiver enforces it against the size in the accepted
|
||||
/// offer; the Bluetooth path keeps `maxPayloadBytes`.
|
||||
public static let maxWifiBulkPayloadBytes: Int = 8 * 1024 * 1024 // 8 MiB
|
||||
/// Voice notes stay small for low-latency relays.
|
||||
public static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
|
||||
/// Compressed images after downscaling should comfortably fit under this budget.
|
||||
@@ -17,7 +21,7 @@ public enum FileTransferLimits {
|
||||
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
|
||||
}()
|
||||
|
||||
public static func isValidPayload(_ size: Int) -> Bool {
|
||||
size <= maxPayloadBytes
|
||||
public static func isValidPayload(_ size: Int, limit: Int = maxPayloadBytes) -> Bool {
|
||||
size <= limit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
public enum MessageType: UInt8 {
|
||||
// Public messages (unencrypted)
|
||||
case announce = 0x01 // "I'm here" with nickname
|
||||
case message = 0x02 // Public chat message
|
||||
case message = 0x02 // Public chat message
|
||||
case leave = 0x03 // "I'm leaving"
|
||||
case courierEnvelope = 0x04 // Store-and-forward envelope carried by a trusted peer
|
||||
case requestSync = 0x21 // GCS filter-based sync request (local-only)
|
||||
|
||||
// Noise encryption
|
||||
@@ -29,6 +30,7 @@ public enum MessageType: UInt8 {
|
||||
case .announce: return "announce"
|
||||
case .message: return "message"
|
||||
case .leave: return "leave"
|
||||
case .courierEnvelope: return "courierEnvelope"
|
||||
case .requestSync: return "requestSync"
|
||||
case .noiseHandshake: return "noiseHandshake"
|
||||
case .noiseEncrypted: return "noiseEncrypted"
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
|
||||
/// Feature capabilities a peer advertises in its announce packet.
|
||||
///
|
||||
/// Encoded as a little-endian bitfield with trailing zero bytes dropped, so the
|
||||
/// wire form grows only when high bits are assigned. Decoders keep the low 64
|
||||
/// bits and ignore any longer field, and unknown bits are preserved verbatim —
|
||||
/// old clients skip the TLV entirely, new clients degrade per-feature.
|
||||
public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
|
||||
public let rawValue: UInt64
|
||||
|
||||
public init(rawValue: UInt64) {
|
||||
self.rawValue = rawValue
|
||||
}
|
||||
|
||||
public static let prekeys = PeerCapabilities(rawValue: 1 << 0)
|
||||
public static let wifiBulk = PeerCapabilities(rawValue: 1 << 1)
|
||||
public static let gateway = PeerCapabilities(rawValue: 1 << 2)
|
||||
public static let groups = PeerCapabilities(rawValue: 1 << 3)
|
||||
public static let board = PeerCapabilities(rawValue: 1 << 4)
|
||||
public static let vouch = PeerCapabilities(rawValue: 1 << 5)
|
||||
public static let meshDiagnostics = PeerCapabilities(rawValue: 1 << 6)
|
||||
|
||||
/// Minimal little-endian byte encoding; always at least one byte so an
|
||||
/// empty set is distinguishable from an absent TLV.
|
||||
public func encoded() -> Data {
|
||||
var value = rawValue
|
||||
var bytes = Data()
|
||||
repeat {
|
||||
bytes.append(UInt8(truncatingIfNeeded: value))
|
||||
value >>= 8
|
||||
} while value != 0
|
||||
return bytes
|
||||
}
|
||||
|
||||
/// Accepts any length; bytes beyond the low 64 bits are ignored for
|
||||
/// forward compatibility.
|
||||
public init(encoded data: Data) {
|
||||
var value: UInt64 = 0
|
||||
for (index, byte) in data.prefix(8).enumerated() {
|
||||
value |= UInt64(byte) << (8 * index)
|
||||
}
|
||||
self.init(rawValue: value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//
|
||||
// CourierEnvelopeTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import BitFoundation
|
||||
|
||||
struct CourierEnvelopeTests {
|
||||
|
||||
private func makeEnvelope(
|
||||
tag: Data = Data(repeating: 0xAB, count: CourierEnvelope.tagLength),
|
||||
expiry: UInt64 = 1_900_000_000_000,
|
||||
ciphertext: Data = Data(repeating: 0x42, count: 128)
|
||||
) -> CourierEnvelope {
|
||||
CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext)
|
||||
}
|
||||
|
||||
// MARK: - Spray copies
|
||||
|
||||
@Test func copiesRoundTrip() throws {
|
||||
let envelope = makeEnvelope().withCopies(4)
|
||||
let encoded = try #require(envelope.encode())
|
||||
let decoded = try #require(CourierEnvelope.decode(encoded))
|
||||
#expect(decoded.copies == 4)
|
||||
#expect(decoded == envelope)
|
||||
}
|
||||
|
||||
@Test func carryOnlyEnvelopeEncodesIdenticallyToLegacyFormat() throws {
|
||||
// copies == 1 must be byte-identical to the pre-spray wire format so
|
||||
// old and new clients dedup the same envelope the same way.
|
||||
let envelope = makeEnvelope()
|
||||
#expect(envelope.copies == 1)
|
||||
let encoded = try #require(envelope.encode())
|
||||
let withExplicitOne = try #require(envelope.withCopies(1).encode())
|
||||
#expect(encoded == withExplicitOne)
|
||||
#expect(!encoded.contains(0x04) || CourierEnvelope.decode(encoded)?.copies == 1)
|
||||
}
|
||||
|
||||
@Test func decodeWithoutCopiesTLVDefaultsToCarryOnly() throws {
|
||||
let encoded = try #require(makeEnvelope().encode())
|
||||
let decoded = try #require(CourierEnvelope.decode(encoded))
|
||||
#expect(decoded.copies == 1)
|
||||
}
|
||||
|
||||
@Test func copiesAreClampedToPolicyBounds() {
|
||||
#expect(makeEnvelope().withCopies(0).copies == 1)
|
||||
#expect(makeEnvelope().withCopies(200).copies == CourierEnvelope.maxCopies)
|
||||
}
|
||||
|
||||
// MARK: - Codec
|
||||
|
||||
@Test func roundTrip() throws {
|
||||
let envelope = makeEnvelope()
|
||||
let encoded = try #require(envelope.encode())
|
||||
let decoded = try #require(CourierEnvelope.decode(encoded))
|
||||
#expect(decoded == envelope)
|
||||
}
|
||||
|
||||
@Test func roundTripAtMaxCiphertextSize() throws {
|
||||
let envelope = makeEnvelope(ciphertext: Data(repeating: 0x01, count: CourierEnvelope.maxCiphertextBytes))
|
||||
let encoded = try #require(envelope.encode())
|
||||
let decoded = try #require(CourierEnvelope.decode(encoded))
|
||||
#expect(decoded == envelope)
|
||||
}
|
||||
|
||||
@Test func encodeRejectsInvalidFields() {
|
||||
#expect(makeEnvelope(tag: Data(repeating: 0, count: 8)).encode() == nil)
|
||||
#expect(makeEnvelope(ciphertext: Data()).encode() == nil)
|
||||
#expect(makeEnvelope(ciphertext: Data(repeating: 0, count: CourierEnvelope.maxCiphertextBytes + 1)).encode() == nil)
|
||||
}
|
||||
|
||||
@Test func decodeRejectsMissingFields() throws {
|
||||
// Strip the trailing ciphertext TLV: tag(3+16) + expiry(3+8) only.
|
||||
let encoded = try #require(makeEnvelope().encode())
|
||||
let truncated = encoded.prefix(3 + CourierEnvelope.tagLength + 3 + 8)
|
||||
#expect(CourierEnvelope.decode(Data(truncated)) == nil)
|
||||
}
|
||||
|
||||
@Test func decodeRejectsTruncatedValue() throws {
|
||||
let encoded = try #require(makeEnvelope().encode())
|
||||
#expect(CourierEnvelope.decode(encoded.dropLast(1)) == nil)
|
||||
}
|
||||
|
||||
@Test func decodeSkipsUnknownTLVs() throws {
|
||||
var encoded = try #require(makeEnvelope().encode())
|
||||
// Append an unknown TLV (type 0x7F, 2-byte value); decoder must tolerate it.
|
||||
encoded.append(contentsOf: [0x7F, 0x00, 0x02, 0xDE, 0xAD])
|
||||
let decoded = try #require(CourierEnvelope.decode(encoded))
|
||||
#expect(decoded == makeEnvelope())
|
||||
}
|
||||
|
||||
@Test func decodeOffsetSlice() throws {
|
||||
// Decoder must handle slices with non-zero startIndex.
|
||||
let encoded = try #require(makeEnvelope().encode())
|
||||
let padded = Data([0xFF, 0xFF]) + encoded
|
||||
let slice = padded.dropFirst(2)
|
||||
#expect(CourierEnvelope.decode(Data(slice)) == makeEnvelope())
|
||||
#expect(CourierEnvelope.decode(slice) == makeEnvelope())
|
||||
}
|
||||
|
||||
// MARK: - Expiry
|
||||
|
||||
@Test func expiryComparison() {
|
||||
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
#expect(!makeEnvelope(expiry: nowMs + 60_000).isExpired)
|
||||
#expect(makeEnvelope(expiry: nowMs - 60_000).isExpired)
|
||||
#expect(makeEnvelope(expiry: 0).isExpired)
|
||||
}
|
||||
|
||||
// MARK: - Recipient Tags
|
||||
|
||||
@Test func tagIsDeterministicPerKeyAndDay() {
|
||||
let key = Data(repeating: 0x11, count: 32)
|
||||
let tag1 = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: 20_000)
|
||||
let tag2 = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: 20_000)
|
||||
#expect(tag1 == tag2)
|
||||
#expect(tag1.count == CourierEnvelope.tagLength)
|
||||
}
|
||||
|
||||
@Test func tagRotatesAcrossDaysAndKeys() {
|
||||
let key = Data(repeating: 0x11, count: 32)
|
||||
let otherKey = Data(repeating: 0x22, count: 32)
|
||||
let day: UInt32 = 20_000
|
||||
#expect(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day)
|
||||
!= CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day + 1))
|
||||
#expect(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day)
|
||||
!= CourierEnvelope.recipientTag(noiseStaticKey: otherKey, epochDay: day))
|
||||
}
|
||||
|
||||
@Test func candidateTagsCoverAdjacentDays() {
|
||||
let key = Data(repeating: 0x33, count: 32)
|
||||
let date = Date(timeIntervalSince1970: 1_750_000_000)
|
||||
let day = CourierEnvelope.epochDay(for: date)
|
||||
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: key, around: date)
|
||||
#expect(candidates.count == 3)
|
||||
#expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day - 1)))
|
||||
#expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day)))
|
||||
#expect(candidates.contains(CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: day + 1)))
|
||||
}
|
||||
|
||||
@Test func sealedYesterdayMatchesToday() {
|
||||
// An envelope sealed late on day D must still match the recipient on day D+1.
|
||||
let key = Data(repeating: 0x44, count: 32)
|
||||
let sealedAt = Date(timeIntervalSince1970: 1_750_000_000)
|
||||
let deliveredAt = sealedAt.addingTimeInterval(20 * 60 * 60)
|
||||
let tag = CourierEnvelope.recipientTag(noiseStaticKey: key, epochDay: CourierEnvelope.epochDay(for: sealedAt))
|
||||
#expect(CourierEnvelope.candidateTags(noiseStaticKey: key, around: deliveredAt).contains(tag))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// PeerCapabilitiesTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import BitFoundation
|
||||
|
||||
struct PeerCapabilitiesTests {
|
||||
@Test
|
||||
func encodingIsMinimalAndRoundTrips() {
|
||||
#expect(PeerCapabilities([]).encoded() == Data([0x00]))
|
||||
#expect(PeerCapabilities.prekeys.encoded() == Data([0x01]))
|
||||
#expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40]))
|
||||
|
||||
let high = PeerCapabilities(rawValue: 1 << 9)
|
||||
#expect(high.encoded() == Data([0x00, 0x02]))
|
||||
|
||||
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics]
|
||||
#expect(PeerCapabilities(encoded: all.encoded()) == all)
|
||||
#expect(PeerCapabilities(encoded: high.encoded()) == high)
|
||||
#expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])
|
||||
}
|
||||
|
||||
@Test
|
||||
func decodingToleratesUnknownBitsAndOversizedFields() {
|
||||
// Unknown bits survive a round-trip untouched.
|
||||
let unknown = PeerCapabilities(encoded: Data([0xFF, 0xFF]))
|
||||
#expect(unknown.rawValue == 0xFFFF)
|
||||
#expect(unknown.contains(.gateway))
|
||||
|
||||
// Fields longer than 8 bytes keep the low 64 bits and ignore the rest.
|
||||
let oversized = Data([0x01] + [UInt8](repeating: 0x00, count: 7) + [0xAA, 0xBB])
|
||||
#expect(PeerCapabilities(encoded: oversized) == .prekeys)
|
||||
|
||||
// Empty value decodes to no capabilities.
|
||||
#expect(PeerCapabilities(encoded: Data()) == [])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user