mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 20:45:19 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b8256bf72 | ||
|
|
2b7fd2002b | ||
|
|
e4b3cff5fa | ||
|
|
688b954fb8 | ||
|
|
7341696280 | ||
|
|
295f855b6f | ||
|
|
3ea4188699 |
+82
-250
@@ -1,309 +1,141 @@
|
|||||||
# BitChat Protocol Whitepaper
|
# bitchat Protocol Whitepaper
|
||||||
|
|
||||||
**Version 1.1**
|
**Version 2.0**
|
||||||
|
|
||||||
**Date: July 25, 2025**
|
**Date: July 6, 2026**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Abstract
|
## Abstract
|
||||||
|
|
||||||
BitChat is a decentralized, peer-to-peer messaging application designed for secure, private, and censorship-resistant communication over ephemeral, ad-hoc networks. This whitepaper details the BitChat Protocol Stack, a layered architecture that combines a modern cryptographic foundation with a flexible application protocol. At its core, BitChat leverages the Noise Protocol Framework (specifically, the `XX` pattern) to establish mutually authenticated, end-to-end encrypted sessions between peers. This document provides a technical specification of the identity management, session lifecycle, message framing, and security considerations that underpin the BitChat network.
|
bitchat is a decentralized, peer-to-peer messaging application for secure, private, censorship-resistant communication that works with or without the internet. Nearby devices form an ad-hoc Bluetooth Low Energy (BLE) mesh; distant peers are reached over the Nostr protocol when a connection exists. A layered store-and-forward stack — a persistent sender outbox, opportunistic couriers with a spray-and-wait copy budget, gossip-synced public history, and Nostr relay mailboxes — delivers messages to peers who are out of range at send time. This document describes the protocol and its delivery guarantees as implemented.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Introduction
|
## 1. Design Goals
|
||||||
|
|
||||||
In an era of centralized communication platforms, BitChat offers a resilient alternative by operating without central servers. It is designed for scenarios where internet connectivity is unavailable or untrustworthy, such as protests, natural disasters, or remote areas. Communication occurs directly between devices over transports like Bluetooth Low Energy (BLE).
|
* **Confidentiality:** all private communication is end-to-end encrypted; intermediate nodes and couriers carry only opaque ciphertext.
|
||||||
|
* **Authentication:** peers are identified by cryptographic keys; announcements are signed and verified.
|
||||||
|
* **Resilience:** the network functions in lossy, low-bandwidth, partitioned environments with churning membership.
|
||||||
|
* **Eventual delivery:** a message to an out-of-range peer should still arrive — relayed by the mesh, carried by a moving person, or resting on an internet relay — within a bounded retention window.
|
||||||
|
* **Ephemerality by default:** no plaintext message content is ever written to disk. Everything the store-and-forward stack persists is either sealed ciphertext or already-public broadcast traffic, and all of it dies with the panic wipe.
|
||||||
|
|
||||||
The design goals of the BitChat Protocol are:
|
## 2. Architecture Overview
|
||||||
|
|
||||||
* **Confidentiality:** All communication must be unreadable to third parties.
|
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
|
||||||
* **Authentication:** Users must be able to verify the identity of their correspondents.
|
|
||||||
* **Integrity:** Messages cannot be tampered with in transit.
|
|
||||||
* **Forward Secrecy:** The compromise of long-term identity keys must not compromise past session keys.
|
|
||||||
* **Deniability:** It should be difficult to cryptographically prove that a specific user sent a particular message.
|
|
||||||
* **Resilience:** The protocol must function reliably in lossy, low-bandwidth environments.
|
|
||||||
|
|
||||||
This paper specifies the technical details of the protocol designed to meet these goals.
|
* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts.
|
||||||
|
* **Nostr** — private messages to mutual favorites travel as NIP-17 gift-wrapped events over public relays (over Tor where enabled), bridging separate meshes through the internet.
|
||||||
|
|
||||||
---
|
The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly.
|
||||||
|
|
||||||
## 2. Protocol Stack
|
## 3. Identity
|
||||||
|
|
||||||
The BitChat Protocol is a four-layer stack. This layered approach separates concerns, allowing for modularity and future extensibility.
|
Each device holds two long-term key pairs in the Keychain:
|
||||||
|
|
||||||
```mermaid
|
* a **Curve25519 static key** for Noise key agreement — its SHA-256 fingerprint is the peer's stable identity, and
|
||||||
graph TD
|
* an **Ed25519 signing key** for packet signatures.
|
||||||
A[Application Layer] --> B[Session Layer];
|
|
||||||
B --> C[Encryption Layer];
|
|
||||||
C --> D[Transport Layer];
|
|
||||||
|
|
||||||
subgraph "BitChat Application"
|
On the mesh, peers appear under short ephemeral IDs derived per session; favoriting pins the full Noise public key so identity survives across sessions. Mutual favorites also exchange Nostr public keys for the internet path. Optional QR verification binds a nickname to a fingerprint in person.
|
||||||
A
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Message Framing & State"
|
## 4. BLE Mesh Layer
|
||||||
B
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Noise Protocol Framework"
|
### 4.1 Packet Format
|
||||||
C
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "BLE, Wi-Fi Direct, etc."
|
A compact binary header (version, type, TTL, timestamp, flags) is followed by an 8-byte sender ID, an optional 8-byte recipient ID, the payload, and an optional Ed25519 signature. Version 2 packets may carry an explicit source route. Signatures exclude the TTL byte so relays can decrement it without invalidating them. Packets other than fragments are padded toward uniform sizes.
|
||||||
D
|
|
||||||
end
|
|
||||||
|
|
||||||
style A fill:#cde4ff
|
### 4.2 Flood Control
|
||||||
style B fill:#b5d8ff
|
|
||||||
style C fill:#9ac2ff
|
|
||||||
style D fill:#7eadff
|
|
||||||
```
|
|
||||||
|
|
||||||
* **Application Layer:** Defines the structure of user-facing messages (`BitchatMessage`), acknowledgments (`DeliveryAck`), and other application-level data.
|
Relaying is a deterministic controlled flood tuned by local connection degree:
|
||||||
* **Session Layer:** Manages the overall communication packet (`BitchatPacket`). This includes routing information (TTL), message typing, fragmentation, and serialization into a compact binary format.
|
|
||||||
* **Encryption Layer:** Establishes and manages secure channels using the Noise Protocol Framework. It is responsible for the cryptographic handshake, session management, and transport message encryption/decryption.
|
|
||||||
* **Transport Layer:** The underlying physical medium used for data transmission, such as Bluetooth Low Energy (BLE). This layer is abstracted away from the core protocol.
|
|
||||||
|
|
||||||
---
|
* **TTL:** packets originate with TTL 7. Relays clamp: dense graphs (≥ 6 links) cap broadcast TTL at 5; thin chains (≤ 2 links) relay at full incoming depth.
|
||||||
|
* **Deduplication:** an LRU seen-set (1000 entries, 5-minute expiry) keyed by sender, timestamp, type, and a payload digest drops duplicates. A scheduled relay is cancelled when a duplicate arrives first from another relay.
|
||||||
|
* **Jitter:** relays wait a random 10–220 ms (wider when dense) so duplicate suppression wins often.
|
||||||
|
* **Fanout subsetting:** broadcast messages are re-sent to a deterministic, message-ID-seeded subset of links (~log₂ of degree) rather than all of them; announces, fragments, and sync packets use full fanout. The ingress link is always excluded (split horizon).
|
||||||
|
* **Directed traffic** (handshakes, private messages, courier envelopes) relays deterministically with TTL − 1 and tight jitter, and is never subset.
|
||||||
|
|
||||||
## 3. Identity and Key Management
|
### 4.3 Routing
|
||||||
|
|
||||||
A peer's identity in BitChat is defined by two persistent cryptographic key pairs, which are generated on first launch and stored securely in the device's Keychain.
|
Announcements carry up to 10 direct-neighbor IDs, giving each node a shallow topology map (60 s freshness). When a bidirectionally-confirmed path exists, packets are source-routed along it; otherwise — and whenever a route fails — delivery falls back to flooding.
|
||||||
|
|
||||||
1. **Noise Static Key Pair (`Curve25519`):** This is the long-term identity key used for the Noise Protocol handshake. The public part of this key is shared with peers to establish secure sessions.
|
### 4.4 Fragmentation
|
||||||
2. **Signing Key Pair (`Ed25519`):** This key is used to sign announcements and other protocol messages where non-repudiation is required, such as binding a public key to a nickname.
|
|
||||||
|
|
||||||
### 3.1. Fingerprint
|
Packets exceeding the link MTU split into ~469-byte fragments (8-byte fragment ID, index/total header) that relay independently and reassemble at each receiving node (128 concurrent assemblies, 30 s timeout, 1 MiB cap).
|
||||||
|
|
||||||
A user's unique, verifiable fingerprint is the **SHA-256 hash** of their **Noise static public key**. This provides a user-friendly and secure way to verify an identity out-of-band (e.g., by reading it aloud or scanning a QR code).
|
### 4.5 Presence
|
||||||
|
|
||||||
`Fingerprint = SHA256(StaticPublicKey_Curve25519)`
|
Signed announcements propagate multi-hop: every 4 s while isolated, backing off to ~15–30 s (jittered) when connected. A verified announce retains a peer as *reachable* for 60 s after last contact. Connection scheduling is RSSI-gated with duty-cycled scanning to bound battery drain.
|
||||||
|
|
||||||
### 3.2. Identity Management
|
## 5. Encryption
|
||||||
|
|
||||||
The `SecureIdentityStateManager` class is responsible for managing all cryptographic identity material and social metadata (petnames, trust levels, etc.). It uses an in-memory cache for performance and persists this cache to the Keychain after encrypting it with a separate AES-GCM key.
|
### 5.1 Live Sessions: Noise XX
|
||||||
|
|
||||||
---
|
Connected peers establish sessions with the Noise `XX` pattern (Curve25519 / ChaCha20-Poly1305 / SHA-256), providing mutual authentication and forward secrecy. All private payloads — messages, delivery acks, read receipts — ride inside the session as typed ciphertext. Intermediate relays see only opaque `noiseEncrypted` packets.
|
||||||
|
|
||||||
## 4. The Social Trust Layer
|
### 5.2 Offline Seals: Noise X
|
||||||
|
|
||||||
Beyond cryptographic identity, BitChat incorporates a social trust layer, allowing users to manage their relationships with peers. This functionality is handled by the `SecureIdentityStateManager`.
|
Courier envelopes are sealed to the recipient's *static* key with the one-way Noise `X` pattern; the sender's identity is authenticated inside the ciphertext. **This path has no forward secrecy** — compromise of the recipient's static key exposes sealed-but-undelivered mail. A prekey scheme is future work.
|
||||||
|
|
||||||
### 4.1. Peer Verification
|
### 5.3 Nostr Path
|
||||||
|
|
||||||
While the Noise handshake cryptographically authenticates a peer's key, it doesn't confirm the real-world identity of the person holding the device. To solve this, users can perform out-of-band (OOB) verification by comparing fingerprints. Once a user confirms that a peer's fingerprint matches the one they expect, they can mark that peer as "verified". This status is stored locally and displayed in the UI, providing a strong assurance of identity for future conversations.
|
Private messages to mutual favorites are wrapped per NIP-17/NIP-59: a rumor (kind 14) sealed (kind 13) and gift-wrapped (kind 1059) under a throwaway ephemeral key, so relays learn neither sender nor content.
|
||||||
|
|
||||||
### 4.2. Favorites and Blocking
|
## 6. Store and Forward
|
||||||
|
|
||||||
To improve the user experience and provide control over interactions, the protocol supports:
|
Four mechanisms cover the "recipient is not here right now" problem. All persisted state is wiped by panic mode.
|
||||||
* **Favorites:** Users can mark trusted or frequently contacted peers as "favorites". This is a local designation that can be used by the application to prioritize notifications or display peers more prominently.
|
|
||||||
* **Blocking:** Users can block peers. When a peer is blocked, the application will discard any incoming packets from that peer's fingerprint at the earliest possible stage, effectively silencing them without notifying the blocked peer.
|
|
||||||
|
|
||||||
---
|
### 6.1 Sender Outbox
|
||||||
|
|
||||||
## 5. The Noise Protocol Layer
|
Private messages without a prompt route are retained per peer (100 messages/peer, 24 h TTL) and re-sent on reconnect events until a delivery or read ack clears them, or a resend cap (8 attempts) drops them with visible failure. The outbox persists to disk sealed under a ChaChaPoly key held only in the Keychain, so queued mail survives an app kill without ever storing plaintext.
|
||||||
|
|
||||||
BitChat implements the Noise Protocol Framework to provide strong, authenticated end-to-end encryption.
|
### 6.2 Couriers
|
||||||
|
|
||||||
### 5.1. Protocol Name
|
When no transport can deliver promptly, the message is sealed (§5.2) into a **courier envelope** and handed to up to 3 connected peers who may physically encounter the recipient:
|
||||||
|
|
||||||
The specific Noise protocol implemented is:
|
* **Opaque addressing.** The only routing information is a 16-byte rotating recipient tag — an HMAC of the recipient's static key and the UTC day — computable solely by parties who already know that key. Couriers learn neither sender, recipient, nor content, and tags do not correlate across days.
|
||||||
|
* **Trust tiers.** Mutual favorites may deposit 5 envelopes each; any peer with a signature-verified announce may deposit 2, into a bounded pool (20 of 40 slots) that can never crowd out favorites' mail. Envelopes are capped at 16 KiB and 24 h; overflow evicts oldest verified-tier mail first.
|
||||||
|
* **Deposit retry.** Queued messages are re-deposited whenever a new eligible courier connects, until 3 distinct couriers carry the message or it expires.
|
||||||
|
* **Spray and wait.** Envelopes carry a copy budget (initially 4, capped at 8). A courier meeting another eligible courier hands over half its remaining budget, so mail diffuses through a moving crowd instead of riding one person. Budgets, spray history, and carried mail persist across app restarts (iOS file protection).
|
||||||
|
* **Handover.** On a verified *direct* announce from the recipient, matching envelopes are delivered over the live link and removed. On a verified *relayed* announce, a copy floods toward the recipient as a directed packet while the carried original stays put, throttled to one attempt per envelope per 10 minutes.
|
||||||
|
* Receivers dedup by message ID, so redundant copies and the retained outbox original are harmless. Couriered mail from blocked senders is dropped at decryption time.
|
||||||
|
|
||||||
**`Noise_XX_25519_ChaChaPoly_SHA256`**
|
### 6.3 Public History (Gossip Sync)
|
||||||
|
|
||||||
* **`XX` Pattern:** This handshake pattern provides mutual authentication and forward secrecy. It does not require either party to know the other's static public key before the handshake begins. The keys are exchanged and authenticated during the three-part handshake. This is ideal for a decentralized P2P environment.
|
Public broadcast messages are cached (1000 packets) and reconciled between peers every ~15 s using compact GCS filters: each side advertises what it holds, the other returns what is missing. Messages stay sync-able for **6 hours** and the cache persists to disk, so a device that walks between two partitions — or relaunches later — serves the room's recent history to whoever missed it. Fragments and file transfers keep a short 15-minute window.
|
||||||
* **`25519`:** The Diffie-Hellman function used is Curve25519.
|
|
||||||
* **`ChaChaPoly`:** The AEAD (Authenticated Encryption with Associated Data) cipher is ChaCha20-Poly1305.
|
|
||||||
* **`SHA256`:** The hash function used for all cryptographic hashing operations is SHA-256.
|
|
||||||
|
|
||||||
### 5.2. The `XX` Handshake
|
### 6.4 Nostr Mailboxes
|
||||||
|
|
||||||
The `XX` handshake consists of three messages exchanged between an Initiator and a Responder to establish a shared secret and derive transport encryption keys.
|
Gift-wrapped messages rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet.
|
||||||
|
|
||||||
```mermaid
|
### 6.5 Delivery Metrics
|
||||||
sequenceDiagram
|
|
||||||
participant I as Initiator
|
|
||||||
participant R as Responder
|
|
||||||
|
|
||||||
Note over I, R: Pre-computation: h = SHA256(protocol_name)
|
Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drops — no identities, message IDs, or timestamps) let delivery behavior be measured on-device. They never leave the device and are cleared by the panic wipe.
|
||||||
|
|
||||||
I->>R: -> e
|
## 7. Application Layer
|
||||||
Note right of I: I generates ephemeral key `e_i`.<br/>h = SHA256(h + e_i.pub)
|
|
||||||
|
|
||||||
R->>I: <- e, ee, s, es
|
* **Public chat** — signed broadcast messages within the mesh, backed by the gossip-synced history above.
|
||||||
Note left of R: R generates ephemeral key `e_r`.<br/>h = SHA256(h + e_r.pub)<br/>MixKey(DH(e_i, e_r))<br/>R sends static key `s_r`, encrypted.<br/>h = SHA256(h + ciphertext)<br/>MixKey(DH(e_i, s_r))
|
* **Private chat** — end-to-end encrypted messages with delivery and read receipts, over mesh, courier, or Nostr.
|
||||||
|
* **Location channels** — geohash-scoped public rooms carried over Nostr relays for regional chat beyond radio range.
|
||||||
I->>R: -> s, se
|
* **Favorites** — the mutual-trust relationship that unlocks Nostr delivery and the larger courier quota.
|
||||||
Note right of I: I decrypts and verifies `s_r`.<br/>I sends static key `s_i`, encrypted.<br/>h = SHA256(h + ciphertext)<br/>MixKey(DH(s_i, e_r))
|
* **Media** — files and images fragment over the mesh (1 MiB cap, explicit accept before anything touches disk); couriers carry text only.
|
||||||
|
* **Panic wipe** — clears identity keys, favorites, carried courier mail, the sealed outbox, archived public history, and metrics.
|
||||||
Note over I, R: Handshake complete. Transport keys derived.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Handshake Flow:**
|
|
||||||
|
|
||||||
1. **Initiator -> Responder:** The initiator generates a new ephemeral key pair (`e_i`) and sends the public part to the responder.
|
|
||||||
2. **Responder -> Initiator:** The responder receives the initiator's ephemeral public key. It then generates its own ephemeral key pair (`e_r`), performs a DH exchange with the initiator's ephemeral key (`ee`), sends its own static public key (`s_r`) encrypted with the resulting symmetric key, and performs another DH exchange between the initiator's ephemeral key and its own static key (`es`).
|
|
||||||
3. **Initiator -> Responder:** The initiator receives the responder's message, decrypts the responder's static key, and authenticates it. The initiator then sends its own static key (`s_i`) encrypted and performs a final DH exchange between its static key and the responder's ephemeral key (`se`).
|
|
||||||
|
|
||||||
Upon completion, both parties share a set of symmetric keys for bidirectional transport message encryption. The final handshake hash is used for channel binding.
|
|
||||||
|
|
||||||
### 5.3. Session Management
|
|
||||||
|
|
||||||
The `NoiseSessionManager` class manages all active Noise sessions. It handles:
|
|
||||||
* Creating sessions for new peers.
|
|
||||||
* Coordinating the handshake process to prevent race conditions.
|
|
||||||
* Storing the resulting transport ciphers (`sendCipher`, `receiveCipher`).
|
|
||||||
* Periodically checking if sessions need to be re-keyed for enhanced security.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. The BitChat Session and Application Protocol
|
|
||||||
|
|
||||||
Once a Noise session is established, peers exchange `BitchatPacket` structures, which are encrypted as the payload of Noise transport messages.
|
|
||||||
|
|
||||||
### 6.1. Binary Packet Format (`BitchatPacket`)
|
|
||||||
|
|
||||||
To minimize bandwidth, `BitchatPacket`s are serialized into a compact binary format. The structure is designed to be fixed-size where possible to resist traffic analysis.
|
|
||||||
|
|
||||||
| Field | Size (bytes) | Description |
|
|
||||||
|-----------------|--------------|---------------------------------------------------------------------------------------------------------|
|
|
||||||
| **Header** | **13** | **Fixed-size header** |
|
|
||||||
| Version | 1 | Protocol version (currently `1`). |
|
|
||||||
| Type | 1 | Message type (e.g., `message`, `deliveryAck`, `noiseHandshakeInit`). See `MessageType` enum. |
|
|
||||||
| TTL | 1 | Time-To-Live for mesh network routing. Decremented at each hop. |
|
|
||||||
| Timestamp | 8 | `UInt64` millisecond timestamp of packet creation. |
|
|
||||||
| Flags | 1 | Bitmask for optional fields (`hasRecipient`, `hasSignature`, `isCompressed`). |
|
|
||||||
| Payload Length | 2 | `UInt16` length of the payload field. |
|
|
||||||
| **Variable** | **...** | **Variable-size fields** |
|
|
||||||
| Sender ID | 8 | 8-byte truncated peer ID of the sender. |
|
|
||||||
| Recipient ID | 8 (optional) | 8-byte truncated peer ID of the recipient. Present if `hasRecipient` flag is set. Broadcast if `0xFF..FF`. |
|
|
||||||
| Payload | Variable | The actual content of the packet, as defined by the `Type` field. |
|
|
||||||
| Signature | 64 (optional)| `Ed25519` signature of the packet. Present if `hasSignature` flag is set. |
|
|
||||||
|
|
||||||
**Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
---
|
|
||||||
config:
|
|
||||||
theme: dark
|
|
||||||
---
|
|
||||||
---
|
|
||||||
title: "BitchatPacket"
|
|
||||||
---
|
|
||||||
packet
|
|
||||||
+8: "Version"
|
|
||||||
+8: "Type"
|
|
||||||
+8: "TTL"
|
|
||||||
+64: "Timestamp"
|
|
||||||
+8: "Flags"
|
|
||||||
+16: "Payload Length"
|
|
||||||
+64: "Sender ID"
|
|
||||||
+64: "Recipient ID (optional)"
|
|
||||||
+48: "Payload (variable)"
|
|
||||||
+64: "Signature (optional)"
|
|
||||||
```
|
|
||||||
_A representation of the sizes of the fields in `BitchatPacket`_
|
|
||||||
|
|
||||||
### 6.2. Application Message Format (`BitchatMessage`)
|
|
||||||
|
|
||||||
For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.
|
|
||||||
|
|
||||||
| Field | Size (bytes) | Description |
|
|
||||||
|---------------------|--------------|--------------------------------------------------------------------------|
|
|
||||||
| Flags | 1 | Bitmask for optional fields (`isRelay`, `isPrivate`, `hasOriginalSender`). |
|
|
||||||
| Timestamp | 8 | `UInt64` millisecond timestamp of message creation. |
|
|
||||||
| ID | 1 + len | `UUID` string for the message. |
|
|
||||||
| Sender | 1 + len | Nickname of the sender. |
|
|
||||||
| Content | 2 + len | The UTF-8 encoded message content. |
|
|
||||||
| Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. |
|
|
||||||
| Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. |
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
---
|
|
||||||
config:
|
|
||||||
theme: dark
|
|
||||||
---
|
|
||||||
---
|
|
||||||
title: "BitchatMessage"
|
|
||||||
---
|
|
||||||
packet
|
|
||||||
+8: "Flags"
|
|
||||||
+64: "Timestamp"
|
|
||||||
+24: "ID (variable)"
|
|
||||||
+32: "Sender (variable)"
|
|
||||||
+32: "Content (variable)"
|
|
||||||
+32: "Original Sender (variable) (optional)"
|
|
||||||
+32: "Recipient Nickname (variable) (optional)"
|
|
||||||
```
|
|
||||||
_A representation of the sizes of the fields in `BitchatMessage`_
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Message Routing and Propagation
|
|
||||||
|
|
||||||
BitChat operates as a decentralized mesh network, meaning there are no central servers to route messages. Packets are propagated through the network from peer to peer. The protocol supports several modes of message delivery.
|
|
||||||
|
|
||||||
### 7.1. Direct Connection
|
|
||||||
|
|
||||||
This is the simplest case. If Peer A and Peer B are directly connected, they can exchange packets after establishing a mutually authenticated Noise session. All packets are encrypted using the transport ciphers derived from the handshake.
|
|
||||||
|
|
||||||
### 7.2. Efficient Gossip with Bloom Filters
|
|
||||||
|
|
||||||
To send messages to peers that are not directly connected, BitChat employs a "flooding" or "gossip" protocol. When a peer receives a packet that is not destined for it, it acts as a relay. To prevent infinite routing loops and minimize memory usage, the protocol uses an `OptimizedBloomFilter` to track recently seen packet IDs.
|
|
||||||
|
|
||||||
The logic is as follows:
|
|
||||||
|
|
||||||
1. A peer receives a packet.
|
|
||||||
2. It checks the Bloom filter to see if the packet's ID has likely been seen before. If so, the packet is discarded. Bloom filters can have false positives (though they are rare), but they guarantee no false negatives. This means that while some packets may be incorrectly discarded due to false positives, the gossip protocol's redundancy ensures these packets will eventually be received through subsequent exchanges with other peers.
|
|
||||||
3. If the packet is new, its ID is added to the Bloom filter.
|
|
||||||
4. The peer decrements the packet's Time-To-Live (TTL) field.
|
|
||||||
5. If the TTL is greater than zero, the peer re-broadcasts the packet to all of its connected peers, *except* for the peer from which it received the packet.
|
|
||||||
|
|
||||||
This mechanism allows packets to "flood" through the network efficiently, maximizing the chance of reaching their destination while using minimal resources to prevent loops.
|
|
||||||
|
|
||||||
### 7.3. Time-To-Live (TTL)
|
|
||||||
|
|
||||||
Every `BitchatPacket` contains an 8-bit TTL field. This value is set by the originating peer and is decremented by one at each relay hop. If a peer receives a packet and decrements its TTL to 0, it will process the packet (if it is the recipient) but will not relay it further. This is a crucial mechanism to prevent packets from circulating endlessly in the mesh.
|
|
||||||
|
|
||||||
### 7.4. Private vs. Broadcast Messages
|
|
||||||
|
|
||||||
The routing logic respects the confidentiality of private messages:
|
|
||||||
|
|
||||||
* **Private Messages:** A packet with a specific `recipientID` is a private message. Relay nodes forward the entire, encrypted Noise message without being able to access the inner `BitchatPacket` or its payload. Only the final recipient, who shares the correct Noise session keys with the sender, can decrypt the packet.
|
|
||||||
* **Broadcast Messages:** A packet with the special broadcast `recipientID` (`0xFFFFFFFFFFFFFFFF`) is intended for all peers. Any peer that receives and decrypts a broadcast message will process its content. It will still be relayed according to the flooding algorithm to ensure it reaches the entire network.
|
|
||||||
|
|
||||||
### 7.5. Message Reliability and Lifecycle
|
|
||||||
|
|
||||||
To function in unreliable, lossy networks, the protocol includes features to track the lifecycle of a message and ensure its delivery.
|
|
||||||
|
|
||||||
* **Delivery Acknowledgments (`DeliveryAck`):** When a private message reaches its final destination, the recipient's device sends a `DeliveryAck` packet back to the original sender. This acknowledgment contains the ID of the original message.
|
|
||||||
* **Read Receipts (`ReadReceipt`):** After a message is displayed on the recipient's screen, the application can send a `ReadReceipt`, also containing the original message ID, to inform the sender that the message has been seen.
|
|
||||||
* **Message Retry Service:** Senders maintain a `MessageRetryService` which tracks outgoing messages. If a `DeliveryAck` is not received for a message within a certain time window, the service will automatically re-send the message, creating a more resilient user experience.
|
|
||||||
|
|
||||||
### 7.6. Fragmentation
|
|
||||||
|
|
||||||
Transport layers like BLE have a Maximum Transmission Unit (MTU) that limits the size of a single packet. To handle messages larger than this limit, BitChat implements a fragmentation protocol.
|
|
||||||
|
|
||||||
* **`fragmentStart`:** A packet with this type marks the beginning of a fragmented message. It contains metadata about the total size and number of fragments.
|
|
||||||
* **`fragmentContinue`:** These packets carry the intermediate chunks of the message data.
|
|
||||||
* **`fragmentEnd`:** This packet carries the final chunk of the message and signals the receiver to begin reassembly.
|
|
||||||
|
|
||||||
Receiving peers collect all fragments and reassemble them in the correct order before passing the complete message up to the application layer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Security Considerations
|
## 8. Security Considerations
|
||||||
|
|
||||||
* **Replay Attacks:** The Noise transport messages include a nonce that is incremented for each message. The `NoiseCipherState` implements a sliding window replay protection mechanism to detect and discard replayed or out-of-order messages.
|
* **Relay nodes** cannot read private traffic; they forward padded, opaque ciphertext.
|
||||||
* **Denial of Service:** The `NoiseRateLimiter` is implemented to prevent resource exhaustion from rapid, repeated handshake attempts from a single peer.
|
* **Couriers** are quota-bounded mailbags. A malicious courier can drop mail (redundant copies and deposit retry mitigate this) but cannot read it, link it across days, or amplify it — copy budgets are capped and every envelope is validated against size and lifetime policy on deposit.
|
||||||
* **Key-Compromise Impersonation:** The `XX` pattern authenticates both parties, preventing an attacker from impersonating one party to the other.
|
* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
|
||||||
* **Identity Binding:** While the Noise handshake authenticates the cryptographic keys, binding those keys to a human-readable nickname is handled at the application layer. Users must verify fingerprints out-of-band to prevent man-in-the-middle attacks.
|
* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
|
||||||
* **Traffic Analysis:** The use of fixed-size padding for all packets helps to obscure the exact nature and content of the communication, making it harder for a network-level adversary to infer information based on message size.
|
* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
|
||||||
|
* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path.
|
||||||
|
|
||||||
|
## 9. Future Work
|
||||||
|
|
||||||
|
* Prekey-based forward secrecy for courier envelopes.
|
||||||
|
* Couriered media beyond the 16 KiB text cap.
|
||||||
|
* Probabilistic relay and edge-of-network TTL boosting for very dense and very sparse graphs.
|
||||||
|
* Multi-hop courier routing informed by encounter history.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Conclusion
|
*This document describes the protocol as implemented in the current release. The implementation is free and unencumbered software released into the public domain.*
|
||||||
|
|
||||||
The BitChat Protocol provides a robust and secure foundation for decentralized, peer-to-peer communication. By layering a flexible application protocol on top of the well-regarded Noise Protocol Framework, it achieves strong confidentiality, authentication, and forward secrecy. The use of a compact binary format and thoughtful security considerations like rate limiting and traffic analysis resistance make it suitable for use in challenging network environments.
|
|
||||||
|
|||||||
Generated
-8
@@ -528,7 +528,6 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@@ -561,7 +560,6 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.4;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -620,7 +618,6 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.4;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -655,7 +652,6 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = 1.5.4;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -716,7 +712,6 @@
|
|||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -749,7 +744,6 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = 1.5.4;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -816,7 +810,6 @@
|
|||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
@@ -846,7 +839,6 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import ImageIO
|
import ImageIO
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
@@ -13,11 +14,28 @@ enum ImageUtilsError: Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum ImageUtils {
|
enum ImageUtils {
|
||||||
private static let compressionQuality: CGFloat = 0.82
|
private static let compressionQuality: CGFloat = 0.85
|
||||||
private static let targetImageBytes: Int = 45_000
|
// 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
|
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)
|
try validateImageSource(at: url)
|
||||||
|
|
||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
@@ -47,34 +65,33 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#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 {
|
return try autoreleasepool {
|
||||||
// Scale the image first
|
var dimension = maxDimension
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
var jpegData: Data?
|
||||||
|
// Downscale-and-compress until the payload fits the hard image cap.
|
||||||
// Get CGImage from UIImage - this is the key to stripping metadata
|
// A normal photo converges on the first pass; this loop only kicks
|
||||||
guard let cgImage = scaled.cgImage else {
|
// in for near-incompressible inputs (e.g. full-frame noise) that
|
||||||
throw ImageUtilsError.encodingFailed
|
// would otherwise overrun `maxImageBytes` at the raised dimension.
|
||||||
}
|
while true {
|
||||||
|
let scaled = scaledImage(image, maxDimension: dimension)
|
||||||
// Use CGImageDestination to encode without metadata (same as macOS)
|
// Get CGImage from UIImage - this is the key to stripping metadata
|
||||||
var quality = compressionQuality
|
guard let cgImage = scaled.cgImage else {
|
||||||
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
|
throw ImageUtilsError.encodingFailed
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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)
|
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
try finalData.write(to: outputURL, options: .atomic)
|
||||||
return outputURL
|
return outputURL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,66 +110,49 @@ enum ImageUtils {
|
|||||||
UIGraphicsEndImageContext()
|
UIGraphicsEndImageContext()
|
||||||
return rendered ?? image
|
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
|
#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 {
|
return try autoreleasepool {
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
var dimension = maxDimension
|
||||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
var jpegData: Data?
|
||||||
throw ImageUtilsError.encodingFailed
|
// See the iOS path: normal photos converge immediately; the loop
|
||||||
}
|
// only shrinks further for near-incompressible inputs so the
|
||||||
let width = inputCG.width
|
// output never overruns `maxImageBytes`.
|
||||||
let height = inputCG.height
|
while true {
|
||||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
let scaled = scaledImage(image, maxDimension: dimension)
|
||||||
guard let context = CGContext(
|
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||||
data: nil,
|
throw ImageUtilsError.encodingFailed
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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)
|
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
try finalData.write(to: outputURL, options: .atomic)
|
||||||
return outputURL
|
return outputURL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,6 +172,31 @@ enum ImageUtils {
|
|||||||
scaledImage.unlockFocus()
|
scaledImage.unlockFocus()
|
||||||
return scaledImage
|
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
|
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
|
||||||
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
|
||||||
@@ -193,7 +218,6 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
return data as Data
|
return data as Data
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
|
|
||||||
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
|
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
|
|||||||
@@ -33,12 +33,18 @@
|
|||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||||
|
<key>NSBonjourServices</key>
|
||||||
|
<array>
|
||||||
|
<string>_bitchat-bulk._tcp</string>
|
||||||
|
</array>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||||
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
||||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
<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>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
|||||||
@@ -28,12 +28,14 @@ struct BitchatFilePacket {
|
|||||||
|
|
||||||
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
/// 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).
|
/// 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)
|
let resolvedSize = fileSize ?? UInt64(content.count)
|
||||||
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
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 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) {
|
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||||
var big = value.bigEndian
|
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.
|
/// 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
|
var cursor = data.startIndex
|
||||||
let end = data.endIndex
|
let end = data.endIndex
|
||||||
|
|
||||||
@@ -126,7 +130,7 @@ struct BitchatFilePacket {
|
|||||||
for byte in value {
|
for byte in value {
|
||||||
size = (size << 8) | UInt64(byte)
|
size = (size << 8) | UInt64(byte)
|
||||||
}
|
}
|
||||||
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
|
if size > UInt64(limit) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fileSize = size
|
fileSize = size
|
||||||
@@ -135,7 +139,7 @@ struct BitchatFilePacket {
|
|||||||
mimeType = String(data: Data(value), encoding: .utf8)
|
mimeType = String(data: Data(value), encoding: .utf8)
|
||||||
case .content:
|
case .content:
|
||||||
let proposedSize = content.count + value.count
|
let proposedSize = content.count + value.count
|
||||||
if proposedSize > FileTransferLimits.maxPayloadBytes {
|
if proposedSize > limit {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
content.append(contentsOf: value)
|
content.append(contentsOf: value)
|
||||||
@@ -145,7 +149,7 @@ struct BitchatFilePacket {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard !content.isEmpty else { return nil }
|
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(
|
return BitchatFilePacket(
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
fileSize: fileSize ?? UInt64(content.count),
|
fileSize: fileSize ?? UInt64(content.count),
|
||||||
|
|||||||
@@ -72,15 +72,20 @@ enum NoisePayloadType: UInt8 {
|
|||||||
case privateMessage = 0x01 // Private chat message
|
case privateMessage = 0x01 // Private chat message
|
||||||
case readReceipt = 0x02 // Message was read
|
case readReceipt = 0x02 // Message was read
|
||||||
case delivered = 0x03 // Message was delivered
|
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)
|
// Verification (QR-based OOB binding)
|
||||||
case verifyChallenge = 0x10 // Verification challenge
|
case verifyChallenge = 0x10 // Verification challenge
|
||||||
case verifyResponse = 0x11 // Verification response
|
case verifyResponse = 0x11 // Verification response
|
||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .privateMessage: return "privateMessage"
|
case .privateMessage: return "privateMessage"
|
||||||
case .readReceipt: return "readReceipt"
|
case .readReceipt: return "readReceipt"
|
||||||
case .delivered: return "delivered"
|
case .delivered: return "delivered"
|
||||||
|
case .bulkTransferOffer: return "bulkTransferOffer"
|
||||||
|
case .bulkTransferResponse: return "bulkTransferResponse"
|
||||||
case .verifyChallenge: return "verifyChallenge"
|
case .verifyChallenge: return "verifyChallenge"
|
||||||
case .verifyResponse: return "verifyResponse"
|
case .verifyResponse: return "verifyResponse"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// MARK: - Protocol TLV Packets
|
// MARK: - Protocol TLV Packets
|
||||||
@@ -7,12 +8,28 @@ struct AnnouncementPacket {
|
|||||||
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
||||||
let signingPublicKey: Data // Ed25519 public key for signing
|
let signingPublicKey: Data // Ed25519 public key for signing
|
||||||
let directNeighbors: [Data]? // 8-byte peer IDs
|
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 {
|
private enum TLVType: UInt8 {
|
||||||
case nickname = 0x01
|
case nickname = 0x01
|
||||||
case noisePublicKey = 0x02
|
case noisePublicKey = 0x02
|
||||||
case signingPublicKey = 0x03
|
case signingPublicKey = 0x03
|
||||||
case directNeighbors = 0x04
|
case directNeighbors = 0x04
|
||||||
|
case capabilities = 0x05
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode() -> Data? {
|
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
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +83,7 @@ struct AnnouncementPacket {
|
|||||||
var noisePublicKey: Data?
|
var noisePublicKey: Data?
|
||||||
var signingPublicKey: Data?
|
var signingPublicKey: Data?
|
||||||
var directNeighbors: [Data]?
|
var directNeighbors: [Data]?
|
||||||
|
var capabilities: PeerCapabilities?
|
||||||
|
|
||||||
while offset + 2 <= data.count {
|
while offset + 2 <= data.count {
|
||||||
let typeRaw = data[offset]
|
let typeRaw = data[offset]
|
||||||
@@ -87,6 +114,8 @@ struct AnnouncementPacket {
|
|||||||
}
|
}
|
||||||
directNeighbors = neighbors
|
directNeighbors = neighbors
|
||||||
}
|
}
|
||||||
|
case .capabilities:
|
||||||
|
capabilities = PeerCapabilities(encoded: Data(value))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||||
@@ -99,7 +128,8 @@ struct AnnouncementPacket {
|
|||||||
nickname: nickname,
|
nickname: nickname,
|
||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey,
|
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] : []
|
||||||
|
}
|
||||||
@@ -44,7 +44,9 @@ final class BLEFileTransferHandler {
|
|||||||
self.environment = environment
|
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
|
let env = environment
|
||||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
|
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
|
||||||
|
|
||||||
@@ -69,7 +71,7 @@ final class BLEFileTransferHandler {
|
|||||||
|
|
||||||
let filePacket: BitchatFilePacket
|
let filePacket: BitchatFilePacket
|
||||||
let mime: MimeType
|
let mime: MimeType
|
||||||
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
|
switch BLEIncomingFileValidator.validate(payload: packet.payload, limit: payloadLimit) {
|
||||||
case .success(let acceptance):
|
case .success(let acceptance):
|
||||||
filePacket = acceptance.filePacket
|
filePacket = acceptance.filePacket
|
||||||
mime = acceptance.mime
|
mime = acceptance.mime
|
||||||
|
|||||||
@@ -42,12 +42,17 @@ enum BLEIncomingFileRejection: Error, Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum BLEIncomingFileValidator {
|
enum BLEIncomingFileValidator {
|
||||||
static func validate(payload: Data) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk deliveries
|
||||||
guard let filePacket = BitchatFilePacket.decode(payload) else {
|
/// 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)
|
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))
|
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,11 @@ struct BLEIngressLinkRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
|
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 {
|
private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ struct BLEPeerInfo: Equatable {
|
|||||||
var signingPublicKey: Data?
|
var signingPublicKey: Data?
|
||||||
var isVerifiedNickname: Bool
|
var isVerifiedNickname: Bool
|
||||||
var lastSeen: Date
|
var lastSeen: Date
|
||||||
|
var capabilities: PeerCapabilities = []
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BLEPeerAnnounceUpdate: Equatable {
|
struct BLEPeerAnnounceUpdate: Equatable {
|
||||||
@@ -107,6 +108,10 @@ struct BLEPeerRegistry {
|
|||||||
peers[peerID]?.noisePublicKey?.sha256Fingerprint()
|
peers[peerID]?.noisePublicKey?.sha256Fingerprint()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func capabilities(for peerID: PeerID) -> PeerCapabilities {
|
||||||
|
peers[peerID.toShort()]?.capabilities ?? []
|
||||||
|
}
|
||||||
|
|
||||||
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
||||||
let connected = peers.filter { $0.value.isConnected }
|
let connected = peers.filter { $0.value.isConnected }
|
||||||
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
||||||
@@ -125,7 +130,8 @@ struct BLEPeerRegistry {
|
|||||||
nickname: resolvedNames[info.peerID] ?? info.nickname,
|
nickname: resolvedNames[info.peerID] ?? info.nickname,
|
||||||
isConnected: info.isConnected,
|
isConnected: info.isConnected,
|
||||||
noisePublicKey: info.noisePublicKey,
|
noisePublicKey: info.noisePublicKey,
|
||||||
lastSeen: info.lastSeen
|
lastSeen: info.lastSeen,
|
||||||
|
isVerified: info.isVerifiedNickname
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,7 +162,8 @@ struct BLEPeerRegistry {
|
|||||||
noisePublicKey: Data,
|
noisePublicKey: Data,
|
||||||
signingPublicKey: Data?,
|
signingPublicKey: Data?,
|
||||||
isConnected: Bool,
|
isConnected: Bool,
|
||||||
now: Date
|
now: Date,
|
||||||
|
capabilities: PeerCapabilities = []
|
||||||
) -> BLEPeerAnnounceUpdate {
|
) -> BLEPeerAnnounceUpdate {
|
||||||
let existing = peers[peerID]
|
let existing = peers[peerID]
|
||||||
let update = BLEPeerAnnounceUpdate(
|
let update = BLEPeerAnnounceUpdate(
|
||||||
@@ -172,7 +179,8 @@ struct BLEPeerRegistry {
|
|||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey,
|
||||||
isVerifiedNickname: true,
|
isVerifiedNickname: true,
|
||||||
lastSeen: now
|
lastSeen: now,
|
||||||
|
capabilities: capabilities
|
||||||
)
|
)
|
||||||
|
|
||||||
return update
|
return update
|
||||||
|
|||||||
@@ -27,8 +27,15 @@ enum BLEPublicMessagePolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let isBroadcast = BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID)
|
let isBroadcast = BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID)
|
||||||
|
// Acceptance window matches the gossip-sync serving window: a peer
|
||||||
|
// walking between partitions carries hours of public history, so the
|
||||||
|
// receive side must not drop what sync legitimately serves.
|
||||||
if isBroadcast,
|
if isBroadcast,
|
||||||
BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) {
|
BLEPacketFreshnessPolicy.isStale(
|
||||||
|
timestampMilliseconds: packet.timestamp,
|
||||||
|
now: now,
|
||||||
|
maxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds
|
||||||
|
) {
|
||||||
return .reject(.staleBroadcast(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
|
return .reject(.staleBroadcast(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
|
||||||
timestampMilliseconds: packet.timestamp,
|
timestampMilliseconds: packet.timestamp,
|
||||||
now: now
|
now: now
|
||||||
|
|||||||
@@ -48,11 +48,16 @@ struct BLEReceivePipeline {
|
|||||||
senderIsSelf: senderID == localPeerID,
|
senderIsSelf: senderID == localPeerID,
|
||||||
recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID,
|
recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID,
|
||||||
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
|
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
|
||||||
isDirectedEncrypted: packet.type == MessageType.noiseEncrypted.rawValue && packet.recipientID != nil,
|
// Courier envelopes are directed opaque ciphertext like DMs; a
|
||||||
|
// remote handover toward a relayed announce rides this same
|
||||||
|
// deterministic relay treatment instead of the broadcast clamp.
|
||||||
|
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|
||||||
|
|| packet.type == MessageType.courierEnvelope.rawValue) && packet.recipientID != nil,
|
||||||
isFragment: packet.type == MessageType.fragment.rawValue,
|
isFragment: packet.type == MessageType.fragment.rawValue,
|
||||||
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||||
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||||
isAnnounce: packet.type == MessageType.announce.rawValue,
|
isAnnounce: packet.type == MessageType.announce.rawValue,
|
||||||
|
isRequestSync: packet.type == MessageType.requestSync.rawValue,
|
||||||
degree: degree,
|
degree: degree,
|
||||||
highDegreeThreshold: highDegreeThreshold
|
highDegreeThreshold: highDegreeThreshold
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ struct BLERouteForwardingPolicy {
|
|||||||
routingPeer: (Data) -> PeerID?,
|
routingPeer: (Data) -> PeerID?,
|
||||||
isPeerConnected: (PeerID) -> Bool
|
isPeerConnected: (PeerID) -> Bool
|
||||||
) -> BLERouteForwardingPlan {
|
) -> 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 {
|
if PeerID(hexData: packet.recipientID) == localPeerID {
|
||||||
return .suppressFloodRelay
|
return .suppressFloodRelay
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,12 +48,17 @@ final class BLEService: NSObject {
|
|||||||
private let messageDeduplicator = MessageDeduplicator()
|
private let messageDeduplicator = MessageDeduplicator()
|
||||||
|
|
||||||
// Courier store-and-forward: envelopes this device carries for offline
|
// Courier store-and-forward: envelopes this device carries for offline
|
||||||
// third parties, and the trust gate for accepting deposits. Injectable
|
// third parties, and the trust gate for accepting deposits. The policy
|
||||||
// for tests; main-actor policy because favorites live on the main actor.
|
// maps (depositor key, announce-verified?) to a quota tier, or nil to
|
||||||
|
// reject. Injectable for tests; main-actor policy because favorites live
|
||||||
|
// on the main actor.
|
||||||
var courierStore: CourierStore = .shared
|
var courierStore: CourierStore = .shared
|
||||||
var courierDepositPolicy: @MainActor (Data) -> Bool = { depositorNoiseKey in
|
var courierDepositPolicy: @MainActor (Data, Bool) -> CourierDepositTier? = { depositorNoiseKey, isVerifiedPeer in
|
||||||
FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey)
|
if FavoritesPersistenceService.shared.isMutualFavorite(depositorNoiseKey) { return .favorite }
|
||||||
|
return isVerifiedPeer ? .verified : nil
|
||||||
}
|
}
|
||||||
|
// Local-only store-and-forward counters; nil in unit tests.
|
||||||
|
var sfMetrics: StoreAndForwardMetrics?
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// Test-only tap on the outbound pipeline so multi-node tests can ferry
|
// Test-only tap on the outbound pipeline so multi-node tests can ferry
|
||||||
@@ -140,6 +145,8 @@ final class BLEService: NSObject {
|
|||||||
private lazy var fragmentHandler = BLEFragmentHandler(environment: makeFragmentHandlerEnvironment())
|
private lazy var fragmentHandler = BLEFragmentHandler(environment: makeFragmentHandlerEnvironment())
|
||||||
// File-transfer orchestration (queue hops stay in the environment closures)
|
// File-transfer orchestration (queue hops stay in the environment closures)
|
||||||
private lazy var fileTransferHandler = BLEFileTransferHandler(environment: makeFileTransferHandlerEnvironment())
|
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
|
// MARK: - Gossip Sync
|
||||||
private var gossipSyncManager: GossipSyncManager?
|
private var gossipSyncManager: GossipSyncManager?
|
||||||
@@ -264,6 +271,12 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// Initialize gossip sync manager
|
// Initialize gossip sync manager
|
||||||
restartGossipManager()
|
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() {
|
private func restartGossipManager() {
|
||||||
@@ -275,6 +288,7 @@ final class BLEService: NSObject {
|
|||||||
gcsMaxBytes: TransportConfig.syncGCSMaxBytes,
|
gcsMaxBytes: TransportConfig.syncGCSMaxBytes,
|
||||||
gcsTargetFpr: TransportConfig.syncGCSTargetFpr,
|
gcsTargetFpr: TransportConfig.syncGCSTargetFpr,
|
||||||
maxMessageAgeSeconds: TransportConfig.syncMaxMessageAgeSeconds,
|
maxMessageAgeSeconds: TransportConfig.syncMaxMessageAgeSeconds,
|
||||||
|
publicMessageMaxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds,
|
||||||
maintenanceIntervalSeconds: TransportConfig.syncMaintenanceIntervalSeconds,
|
maintenanceIntervalSeconds: TransportConfig.syncMaintenanceIntervalSeconds,
|
||||||
stalePeerCleanupIntervalSeconds: TransportConfig.syncStalePeerCleanupIntervalSeconds,
|
stalePeerCleanupIntervalSeconds: TransportConfig.syncStalePeerCleanupIntervalSeconds,
|
||||||
stalePeerTimeoutSeconds: TransportConfig.syncStalePeerTimeoutSeconds,
|
stalePeerTimeoutSeconds: TransportConfig.syncStalePeerTimeoutSeconds,
|
||||||
@@ -282,10 +296,14 @@ final class BLEService: NSObject {
|
|||||||
fileTransferCapacity: TransportConfig.syncFileTransferCapacity,
|
fileTransferCapacity: TransportConfig.syncFileTransferCapacity,
|
||||||
fragmentSyncIntervalSeconds: TransportConfig.syncFragmentIntervalSeconds,
|
fragmentSyncIntervalSeconds: TransportConfig.syncFragmentIntervalSeconds,
|
||||||
fileTransferSyncIntervalSeconds: TransportConfig.syncFileTransferIntervalSeconds,
|
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
|
manager.delegate = self
|
||||||
// Only start the periodic sync timers when real Bluetooth exists. In unit
|
// Only start the periodic sync timers when real Bluetooth exists. In unit
|
||||||
// tests there is no mesh to sync with, and the periodic sign/broadcast
|
// tests there is no mesh to sync with, and the periodic sign/broadcast
|
||||||
@@ -494,6 +512,9 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stopServices() {
|
func stopServices() {
|
||||||
|
// Tear down any Wi-Fi bulk listeners/connections deterministically
|
||||||
|
wifiBulkService.stop()
|
||||||
|
|
||||||
// Send leave message synchronously to ensure delivery
|
// Send leave message synchronously to ensure delivery
|
||||||
var leavePacket = BitchatPacket(
|
var leavePacket = BitchatPacket(
|
||||||
type: MessageType.leave.rawValue,
|
type: MessageType.leave.rawValue,
|
||||||
@@ -609,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] {
|
func getPeerNicknames() -> [PeerID: String] {
|
||||||
return collectionsQueue.sync {
|
return collectionsQueue.sync {
|
||||||
peerRegistry.displayNicknames(selfNickname: myNickname)
|
peerRegistry.displayNicknames(selfNickname: myNickname)
|
||||||
@@ -680,6 +707,9 @@ final class BLEService: NSObject {
|
|||||||
// MARK: Messaging
|
// MARK: Messaging
|
||||||
|
|
||||||
func cancelTransfer(_ transferId: String) {
|
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
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
||||||
@@ -755,10 +785,72 @@ final class BLEService: NSObject {
|
|||||||
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||||
messageQueue.async { [weak self] in
|
messageQueue.async { [weak self] in
|
||||||
guard let self = self else { return }
|
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)
|
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
||||||
return
|
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
|
// 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
|
// This ensures 64-hex Noise keys are converted to the canonical routing format
|
||||||
let targetID = peerID.toShort()
|
let targetID = peerID.toShort()
|
||||||
@@ -787,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) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
let payload = BLENoisePayloadFactory.readReceipt(originalMessageID: receipt.originalMessageID)
|
let payload = BLENoisePayloadFactory.readReceipt(originalMessageID: receipt.originalMessageID)
|
||||||
@@ -1172,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) {
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
SecureLogger.debug("🔔 sendFavoriteNotification peer=\(peerID.id.prefix(8))… isFavorite=\(isFavorite)", category: .session)
|
SecureLogger.debug("🔔 sendFavoriteNotification peer=\(peerID.id.prefix(8))… isFavorite=\(isFavorite)", category: .session)
|
||||||
|
|
||||||
@@ -1251,7 +1401,8 @@ final class BLEService: NSObject {
|
|||||||
nickname: myNickname,
|
nickname: myNickname,
|
||||||
noisePublicKey: noisePub,
|
noisePublicKey: noisePub,
|
||||||
signingPublicKey: signingPub,
|
signingPublicKey: signingPub,
|
||||||
directNeighbors: connectedPeerIDs
|
directNeighbors: connectedPeerIDs,
|
||||||
|
capabilities: PeerCapabilities.localSupported
|
||||||
)
|
)
|
||||||
|
|
||||||
guard let payload = announcement.encode() else {
|
guard let payload = announcement.encode() else {
|
||||||
@@ -1705,6 +1856,34 @@ extension BLEService {
|
|||||||
cachedServiceUUIDs: cachedServiceUUIDs
|
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
|
#endif
|
||||||
|
|
||||||
@@ -2513,7 +2692,8 @@ extension BLEService {
|
|||||||
epochDay: CourierEnvelope.epochDay(for: now)
|
epochDay: CourierEnvelope.epochDay(for: now)
|
||||||
),
|
),
|
||||||
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
|
expiry: UInt64((now.timeIntervalSince1970 + CourierEnvelope.maxLifetimeSeconds) * 1000),
|
||||||
ciphertext: sealed
|
ciphertext: sealed,
|
||||||
|
copies: TransportConfig.courierInitialCopies
|
||||||
)
|
)
|
||||||
guard let encoded = envelope.encode() else { return false }
|
guard let encoded = envelope.encode() else { return false }
|
||||||
payload = encoded
|
payload = encoded
|
||||||
@@ -2533,7 +2713,7 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket {
|
private func makeCourierPacket(_ payload: Data, to peerID: PeerID) -> BitchatPacket {
|
||||||
BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.courierEnvelope.rawValue,
|
type: MessageType.courierEnvelope.rawValue,
|
||||||
senderID: myPeerIDData,
|
senderID: myPeerIDData,
|
||||||
recipientID: Data(hexString: peerID.id),
|
recipientID: Data(hexString: peerID.id),
|
||||||
@@ -2542,6 +2722,10 @@ extension BLEService {
|
|||||||
signature: nil,
|
signature: nil,
|
||||||
ttl: messageTTL
|
ttl: messageTTL
|
||||||
)
|
)
|
||||||
|
// Signed so a courier can authenticate the depositor before carrying
|
||||||
|
// mail under their quota. Handover to the recipient doesn't need the
|
||||||
|
// packet signature — the inner Noise X seal authenticates the sender.
|
||||||
|
return noiseService.signPacket(packet) ?? packet
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles both courier roles for an incoming envelope addressed to us:
|
/// Handles both courier roles for an incoming envelope addressed to us:
|
||||||
@@ -2557,7 +2741,7 @@ extension BLEService {
|
|||||||
if CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) {
|
if CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: Date()).contains(envelope.recipientTag) {
|
||||||
openCourierEnvelope(envelope)
|
openCourierEnvelope(envelope)
|
||||||
} else {
|
} else {
|
||||||
acceptCourierDeposit(envelope, from: peerID)
|
acceptCourierDeposit(envelope, from: peerID, packet: packet)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2587,6 +2771,7 @@ extension BLEService {
|
|||||||
let senderPeerID = isKnownOnMesh ? shortID : PeerID(hexData: senderStaticKey)
|
let senderPeerID = isKnownOnMesh ? shortID : PeerID(hexData: senderStaticKey)
|
||||||
let payload = Data(typedPayload.dropFirst())
|
let payload = Data(typedPayload.dropFirst())
|
||||||
SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))…", category: .session)
|
SecureLogger.debug("📦 Opened courier envelope from \(senderPeerID.id.prefix(8))…", category: .session)
|
||||||
|
sfMetrics?.record(.courierOpened)
|
||||||
notifyUI { [weak self] in
|
notifyUI { [weak self] in
|
||||||
self?.deliverTransportEvent(.noisePayloadReceived(
|
self?.deliverTransportEvent(.noisePayloadReceived(
|
||||||
peerID: senderPeerID,
|
peerID: senderPeerID,
|
||||||
@@ -2601,20 +2786,38 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID) {
|
private func acceptCourierDeposit(_ envelope: CourierEnvelope, from peerID: PeerID, packet: BitchatPacket) {
|
||||||
guard let depositorKey = collectionsQueue.sync(execute: { peerRegistry.info(for: peerID)?.noisePublicKey }) else {
|
// A deposit must come from its depositor over the direct link: the
|
||||||
|
// claimed sender has to be the ingress peer, and the packet signature
|
||||||
|
// has to verify against that peer's announced signing key. Otherwise
|
||||||
|
// an untrusted sender could route an envelope through any trusted
|
||||||
|
// neighbor and have us carry it under the neighbor's quota.
|
||||||
|
guard PeerID(hexData: packet.senderID) == peerID else {
|
||||||
|
SecureLogger.debug("📦 Courier deposit rejected: relayed envelope claims sender \(PeerID(hexData: packet.senderID).id.prefix(8))… but arrived from \(peerID.id.prefix(8))…", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let depositorInfo = collectionsQueue.sync { peerRegistry.info(for: peerID) }
|
||||||
|
guard let depositorKey = depositorInfo?.noisePublicKey else {
|
||||||
SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session)
|
SecureLogger.debug("📦 Courier deposit from unknown peer \(peerID.id.prefix(8))… rejected", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
guard let signingKey = depositorInfo?.signingPublicKey,
|
||||||
|
noiseService.verifyPacketSignature(packet, publicKey: signingKey) else {
|
||||||
|
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (missing/invalid signature)", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let isVerifiedPeer = depositorInfo?.isVerifiedNickname ?? false
|
||||||
let store = courierStore
|
let store = courierStore
|
||||||
let policy = courierDepositPolicy
|
let policy = courierDepositPolicy
|
||||||
|
let metrics = sfMetrics
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
guard policy(depositorKey) else {
|
guard let tier = policy(depositorKey, isVerifiedPeer) else {
|
||||||
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (not a mutual favorite)", category: .session)
|
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (neither favorite nor verified)", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if store.deposit(envelope, from: depositorKey) {
|
if store.deposit(envelope, from: depositorKey, tier: tier) {
|
||||||
SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))…", category: .session)
|
SecureLogger.debug("📦 Carrying courier envelope deposited by \(peerID.id.prefix(8))… (\(tier.rawValue))", category: .session)
|
||||||
|
metrics?.record(.courierAccepted)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2627,6 +2830,51 @@ extension BLEService {
|
|||||||
for envelope in envelopes {
|
for envelope in envelopes {
|
||||||
guard let payload = envelope.encode() else { continue }
|
guard let payload = envelope.encode() else { continue }
|
||||||
sendPacketDirected(makeCourierPacket(payload, to: peerID), to: peerID)
|
sendPacketDirected(makeCourierPacket(payload, to: peerID), to: peerID)
|
||||||
|
sfMetrics?.record(.courierHandedOver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Speculative handover toward a recipient heard only via a relayed
|
||||||
|
/// announce: the envelope floods the mesh as a directed packet (relays
|
||||||
|
/// treat it like a directed DM). Non-destructive — the carried copy stays
|
||||||
|
/// until a direct handover or expiry, throttled per envelope so repeated
|
||||||
|
/// announces don't re-flood.
|
||||||
|
private func deliverCourierMailRemotely(to peerID: PeerID, noiseKey: Data) {
|
||||||
|
let envelopes = courierStore.envelopesForRemoteHandover(
|
||||||
|
recipientNoiseKey: noiseKey,
|
||||||
|
cooldown: TransportConfig.courierRemoteHandoverCooldownSeconds
|
||||||
|
)
|
||||||
|
guard !envelopes.isEmpty else { return }
|
||||||
|
SecureLogger.debug("📦 Remote handover: flooding \(envelopes.count) envelope(s) toward \(peerID.id.prefix(8))…", category: .session)
|
||||||
|
for envelope in envelopes {
|
||||||
|
guard let payload = envelope.encode() else { continue }
|
||||||
|
broadcastPacket(makeCourierPacket(payload, to: peerID))
|
||||||
|
sfMetrics?.record(.courierRemoteHandover)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spray-and-wait: split copy budgets with another courier we just
|
||||||
|
/// encountered, so carried mail diffuses through a moving crowd instead
|
||||||
|
/// of riding a single carrier. Only favorites and verified peers qualify,
|
||||||
|
/// mirroring the deposit policy they would apply to us.
|
||||||
|
private func sprayCourierMail(to peerID: PeerID, noiseKey: Data, isVerifiedPeer: Bool) {
|
||||||
|
let store = courierStore
|
||||||
|
let metrics = sfMetrics
|
||||||
|
let sendSpray: ([CourierEnvelope]) -> Void = { [weak self] envelopes in
|
||||||
|
guard let self, !envelopes.isEmpty else { return }
|
||||||
|
SecureLogger.debug("📦 Spraying \(envelopes.count) envelope copy(ies) to courier \(peerID.id.prefix(8))…", category: .session)
|
||||||
|
for envelope in envelopes {
|
||||||
|
guard let payload = envelope.encode() else { continue }
|
||||||
|
self.sendPacketDirected(self.makeCourierPacket(payload, to: peerID), to: peerID)
|
||||||
|
metrics?.record(.courierSprayed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let policy = courierDepositPolicy
|
||||||
|
Task { @MainActor in
|
||||||
|
// Same trust gate as deposits: don't hand mail to a peer who
|
||||||
|
// would reject it from us.
|
||||||
|
guard policy(noiseKey, isVerifiedPeer) != nil else { return }
|
||||||
|
sendSpray(store.takeSprayCopies(for: noiseKey))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2753,6 +3001,9 @@ extension BLEService {
|
|||||||
centralManager?.stopScan()
|
centralManager?.stopScan()
|
||||||
startScanning()
|
startScanning()
|
||||||
}
|
}
|
||||||
|
// Backgrounding may precede a kill; flush the public-history archive
|
||||||
|
// outside its 30s maintenance cadence.
|
||||||
|
gossipSyncManager?.persistNow()
|
||||||
logBluetoothStatus("entered-background")
|
logBluetoothStatus("entered-background")
|
||||||
scheduleBluetoothStatusSample(after: 15.0, context: "background-15s")
|
scheduleBluetoothStatusSample(after: 15.0, context: "background-15s")
|
||||||
// No Local Name; nothing to refresh for advertising policy
|
// No Local Name; nothing to refresh for advertising policy
|
||||||
@@ -3184,16 +3435,23 @@ extension BLEService {
|
|||||||
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleAnnounce(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
let result = announceHandler.handle(packet, from: peerID)
|
let result = announceHandler.handle(packet, from: peerID)
|
||||||
|
|
||||||
// Courier handover: an announce is the moment we learn a peer's Noise
|
// Courier work: an announce is the moment we learn a peer's Noise
|
||||||
// static key, so check whether we're carrying mail addressed to them.
|
// static key, so check whether we're carrying mail addressed to them
|
||||||
// Direct announces only: envelopes are removed from the store
|
// (or spray-able mail they could carry). Verified announces only.
|
||||||
// optimistically, so handover must ride an established link rather
|
|
||||||
// than a speculative multi-hop send toward a relayed announce.
|
|
||||||
guard !courierStore.isEmpty,
|
guard !courierStore.isEmpty,
|
||||||
let result,
|
let result,
|
||||||
result.isVerified,
|
result.isVerified else { return }
|
||||||
result.isDirectAnnounce else { return }
|
let noiseKey = result.announcement.noisePublicKey
|
||||||
deliverCourierMail(to: result.peerID, noiseKey: result.announcement.noisePublicKey)
|
if result.isDirectAnnounce {
|
||||||
|
// Established link: destructive handover is safe, and the peer is
|
||||||
|
// close enough to become a courier for other carried mail.
|
||||||
|
deliverCourierMail(to: result.peerID, noiseKey: noiseKey)
|
||||||
|
sprayCourierMail(to: result.peerID, noiseKey: noiseKey, isVerifiedPeer: true)
|
||||||
|
} else {
|
||||||
|
// Relayed announce: recipient is multi-hop away. Push a copy
|
||||||
|
// toward them speculatively; the carried copy stays put.
|
||||||
|
deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the announce handler environment. All queue hops stay here so
|
/// Builds the announce handler environment. All queue hops stay here so
|
||||||
@@ -3226,7 +3484,8 @@ extension BLEService {
|
|||||||
noisePublicKey: announcement.noisePublicKey,
|
noisePublicKey: announcement.noisePublicKey,
|
||||||
signingPublicKey: announcement.signingPublicKey,
|
signingPublicKey: announcement.signingPublicKey,
|
||||||
isConnected: isConnected,
|
isConnected: isConnected,
|
||||||
now: now
|
now: now,
|
||||||
|
capabilities: announcement.capabilities ?? []
|
||||||
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||||
},
|
},
|
||||||
shouldEmitReconnectLog: { [weak self] peerID, now in
|
shouldEmitReconnectLog: { [weak self] peerID, now in
|
||||||
@@ -3287,6 +3546,26 @@ extension BLEService {
|
|||||||
|
|
||||||
// Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager
|
// Handle REQUEST_SYNC: decode payload and respond with missing packets via sync manager
|
||||||
private func handleRequestSync(_ packet: BitchatPacket, from peerID: PeerID) {
|
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 {
|
guard let req = RequestSyncPacket.decode(from: packet.payload) else {
|
||||||
SecureLogger.warning("⚠️ Malformed REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .session)
|
SecureLogger.warning("⚠️ Malformed REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .session)
|
||||||
return
|
return
|
||||||
@@ -3393,8 +3672,21 @@ extension BLEService {
|
|||||||
self?.noiseService.clearSession(for: peerID)
|
self?.noiseService.clearSession(for: peerID)
|
||||||
},
|
},
|
||||||
deliverNoisePayload: { [weak self] peerID, type, payload, timestamp in
|
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`.
|
// Single main-actor hop delivering `.noisePayloadReceived`.
|
||||||
self?.notifyUI { [weak self] in
|
self.notifyUI { [weak self] in
|
||||||
self?.deliverTransportEvent(.noisePayloadReceived(
|
self?.deliverTransportEvent(.noisePayloadReceived(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
type: type,
|
type: type,
|
||||||
|
|||||||
@@ -11,13 +11,22 @@ import BitLogger
|
|||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
/// Trust level of a courier deposit, decided by the caller's policy.
|
||||||
|
/// Favorites get the larger quota and are never evicted to make room for
|
||||||
|
/// verified-tier mail; verified (signature-verified announce, not a mutual
|
||||||
|
/// favorite) get a small quota so a crowd of strangers can still carry mail.
|
||||||
|
enum CourierDepositTier: String, Codable {
|
||||||
|
case favorite
|
||||||
|
case verified
|
||||||
|
}
|
||||||
|
|
||||||
/// Holds courier envelopes this device is carrying for offline third parties.
|
/// Holds courier envelopes this device is carrying for offline third parties.
|
||||||
///
|
///
|
||||||
/// Envelopes are opaque ciphertext deposited by mutual favorites; this store
|
/// Envelopes are opaque ciphertext; this store never learns sender,
|
||||||
/// never learns sender, recipient, or content. Strict quotas keep the device
|
/// recipient, or content. Strict quotas keep the device from becoming a
|
||||||
/// from becoming a public mailbag: bounded count, bounded per-depositor
|
/// public mailbag: bounded count, bounded per-depositor count by trust tier,
|
||||||
/// count, bounded size, and a 24-hour lifetime aligned with the outbox
|
/// bounded size, and a 24-hour lifetime aligned with the outbox retention
|
||||||
/// retention policy. Carried mail is included in the panic wipe.
|
/// policy. Carried mail is included in the panic wipe.
|
||||||
final class CourierStore {
|
final class CourierStore {
|
||||||
struct StoredEnvelope: Codable, Equatable {
|
struct StoredEnvelope: Codable, Equatable {
|
||||||
let recipientTag: Data
|
let recipientTag: Data
|
||||||
@@ -25,15 +34,63 @@ final class CourierStore {
|
|||||||
let ciphertext: Data
|
let ciphertext: Data
|
||||||
let depositorNoiseKey: Data
|
let depositorNoiseKey: Data
|
||||||
let storedAt: Date
|
let storedAt: Date
|
||||||
|
var tier: CourierDepositTier
|
||||||
|
/// Remaining spray-and-wait budget (1 = carry-only).
|
||||||
|
var copies: UInt8
|
||||||
|
/// Couriers this envelope was already sprayed to, so a repeat announce
|
||||||
|
/// from the same peer doesn't burn budget on a copy they already hold.
|
||||||
|
var sprayedTo: Set<Data>
|
||||||
|
/// Last speculative multi-hop handover toward a relayed announce.
|
||||||
|
var lastRemoteHandoverAt: Date?
|
||||||
|
|
||||||
var envelope: CourierEnvelope {
|
var envelope: CourierEnvelope {
|
||||||
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext)
|
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
recipientTag: Data,
|
||||||
|
expiry: UInt64,
|
||||||
|
ciphertext: Data,
|
||||||
|
depositorNoiseKey: Data,
|
||||||
|
storedAt: Date,
|
||||||
|
tier: CourierDepositTier,
|
||||||
|
copies: UInt8,
|
||||||
|
sprayedTo: Set<Data> = [],
|
||||||
|
lastRemoteHandoverAt: Date? = nil
|
||||||
|
) {
|
||||||
|
self.recipientTag = recipientTag
|
||||||
|
self.expiry = expiry
|
||||||
|
self.ciphertext = ciphertext
|
||||||
|
self.depositorNoiseKey = depositorNoiseKey
|
||||||
|
self.storedAt = storedAt
|
||||||
|
self.tier = tier
|
||||||
|
self.copies = copies
|
||||||
|
self.sprayedTo = sprayedTo
|
||||||
|
self.lastRemoteHandoverAt = lastRemoteHandoverAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Files written before tiers/spray lack the newer fields; treat that
|
||||||
|
// mail as favorite-tier carry-only, which is what it was.
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
recipientTag = try container.decode(Data.self, forKey: .recipientTag)
|
||||||
|
expiry = try container.decode(UInt64.self, forKey: .expiry)
|
||||||
|
ciphertext = try container.decode(Data.self, forKey: .ciphertext)
|
||||||
|
depositorNoiseKey = try container.decode(Data.self, forKey: .depositorNoiseKey)
|
||||||
|
storedAt = try container.decode(Date.self, forKey: .storedAt)
|
||||||
|
tier = try container.decodeIfPresent(CourierDepositTier.self, forKey: .tier) ?? .favorite
|
||||||
|
copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1
|
||||||
|
sprayedTo = try container.decodeIfPresent(Set<Data>.self, forKey: .sprayedTo) ?? []
|
||||||
|
lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Limits {
|
enum Limits {
|
||||||
static let maxEnvelopes = 20
|
static let maxEnvelopes = 40
|
||||||
static let maxPerDepositor = 5
|
/// Verified-tier mail can never crowd out favorites' share.
|
||||||
|
static let maxVerifiedEnvelopes = 20
|
||||||
|
static let maxPerFavoriteDepositor = 5
|
||||||
|
static let maxPerVerifiedDepositor = 2
|
||||||
/// Slack on top of the 24h lifetime for depositor clock skew.
|
/// Slack on top of the 24h lifetime for depositor clock skew.
|
||||||
static let maxExpirySlack: TimeInterval = 60 * 60
|
static let maxExpirySlack: TimeInterval = 60 * 60
|
||||||
}
|
}
|
||||||
@@ -65,10 +122,11 @@ final class CourierStore {
|
|||||||
// MARK: - Depositing (courier side)
|
// MARK: - Depositing (courier side)
|
||||||
|
|
||||||
/// Accept an envelope from a depositor. Returns false when quotas or
|
/// Accept an envelope from a depositor. Returns false when quotas or
|
||||||
/// validity checks reject it. Trust policy (mutual favorite) is the
|
/// validity checks reject it. Trust policy (which tier a depositor gets,
|
||||||
/// caller's responsibility; this store only enforces resource bounds.
|
/// if any) is the caller's responsibility; this store only enforces
|
||||||
|
/// resource bounds.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data) -> Bool {
|
func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data, tier: CourierDepositTier = .favorite) -> Bool {
|
||||||
let date = now()
|
let date = now()
|
||||||
guard envelope.recipientTag.count == CourierEnvelope.tagLength,
|
guard envelope.recipientTag.count == CourierEnvelope.tagLength,
|
||||||
!envelope.ciphertext.isEmpty,
|
!envelope.ciphertext.isEmpty,
|
||||||
@@ -86,18 +144,39 @@ final class CourierStore {
|
|||||||
return queue.sync {
|
return queue.sync {
|
||||||
pruneExpiredLocked(at: date)
|
pruneExpiredLocked(at: date)
|
||||||
|
|
||||||
// Identical ciphertext is the same envelope; accept idempotently.
|
// Identical ciphertext is the same envelope; accept idempotently,
|
||||||
if envelopes.contains(where: { $0.ciphertext == envelope.ciphertext }) {
|
// keeping the larger spray budget (bounded by maxCopies either way).
|
||||||
|
if let existing = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) {
|
||||||
|
envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies)
|
||||||
|
persistLocked()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < Limits.maxPerDepositor else {
|
|
||||||
SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached", category: .session)
|
let perDepositorLimit = tier == .favorite ? Limits.maxPerFavoriteDepositor : Limits.maxPerVerifiedDepositor
|
||||||
|
guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < perDepositorLimit else {
|
||||||
|
SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached (\(tier.rawValue))", category: .session)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if tier == .verified,
|
||||||
|
envelopes.filter({ $0.tier == .verified }).count >= Limits.maxVerifiedEnvelopes {
|
||||||
|
SecureLogger.debug("📦 Courier deposit rejected: verified-tier pool full", category: .session)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if envelopes.count >= Limits.maxEnvelopes {
|
if envelopes.count >= Limits.maxEnvelopes {
|
||||||
// Oldest-first eviction, matching outbox overflow behavior.
|
// Oldest-first eviction, shedding verified-tier mail before
|
||||||
let evicted = envelopes.removeFirst()
|
// favorites' so open couriering can't crowd out trusted mail.
|
||||||
SecureLogger.debug("📦 Courier store full - evicted envelope stored at \(evicted.storedAt)", category: .session)
|
// A verified deposit never displaces a favorite: when only
|
||||||
|
// favorite mail is stored, it is rejected instead.
|
||||||
|
if let victim = envelopes.firstIndex(where: { $0.tier == .verified }) {
|
||||||
|
let evicted = envelopes.remove(at: victim)
|
||||||
|
SecureLogger.debug("📦 Courier store full - evicted verified envelope stored at \(evicted.storedAt)", category: .session)
|
||||||
|
} else if tier == .favorite {
|
||||||
|
let evicted = envelopes.removeFirst()
|
||||||
|
SecureLogger.debug("📦 Courier store full - evicted favorite envelope stored at \(evicted.storedAt)", category: .session)
|
||||||
|
} else {
|
||||||
|
SecureLogger.debug("📦 Courier deposit rejected: store full of favorite-tier mail", category: .session)
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
envelopes.append(StoredEnvelope(
|
envelopes.append(StoredEnvelope(
|
||||||
@@ -105,7 +184,9 @@ final class CourierStore {
|
|||||||
expiry: envelope.expiry,
|
expiry: envelope.expiry,
|
||||||
ciphertext: envelope.ciphertext,
|
ciphertext: envelope.ciphertext,
|
||||||
depositorNoiseKey: depositorNoiseKey,
|
depositorNoiseKey: depositorNoiseKey,
|
||||||
storedAt: date
|
storedAt: date,
|
||||||
|
tier: tier,
|
||||||
|
copies: envelope.copies
|
||||||
))
|
))
|
||||||
persistLocked()
|
persistLocked()
|
||||||
return true
|
return true
|
||||||
@@ -131,6 +212,58 @@ final class CourierStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Envelopes addressed to a recipient we heard from via a *relayed*
|
||||||
|
/// announce. Non-destructive: a multi-hop send is speculative, so the
|
||||||
|
/// envelope stays carried until a direct handover or expiry. The per-
|
||||||
|
/// envelope cooldown keeps repeated announces from re-flooding the mesh.
|
||||||
|
func envelopesForRemoteHandover(recipientNoiseKey: Data, cooldown: TimeInterval) -> [CourierEnvelope] {
|
||||||
|
let date = now()
|
||||||
|
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: recipientNoiseKey, around: date)
|
||||||
|
return queue.sync {
|
||||||
|
pruneExpiredLocked(at: date)
|
||||||
|
var matched: [CourierEnvelope] = []
|
||||||
|
for index in envelopes.indices where candidates.contains(envelopes[index].recipientTag) {
|
||||||
|
if let last = envelopes[index].lastRemoteHandoverAt,
|
||||||
|
date.timeIntervalSince(last) < cooldown {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
envelopes[index].lastRemoteHandoverAt = date
|
||||||
|
// The delivered copy carries no spray budget.
|
||||||
|
matched.append(envelopes[index].envelope.withCopies(1))
|
||||||
|
}
|
||||||
|
if !matched.isEmpty { persistLocked() }
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Spray-and-wait (on encountering another courier)
|
||||||
|
|
||||||
|
/// Envelopes to re-deposit with a courier we just encountered, each with
|
||||||
|
/// half its remaining budget (binary spray). Skips envelopes the courier
|
||||||
|
/// deposited, envelopes addressed to them (those ride the handover path),
|
||||||
|
/// carry-only envelopes, and couriers already sprayed.
|
||||||
|
func takeSprayCopies(for courierNoiseKey: Data) -> [CourierEnvelope] {
|
||||||
|
let date = now()
|
||||||
|
let courierTags = CourierEnvelope.candidateTags(noiseStaticKey: courierNoiseKey, around: date)
|
||||||
|
return queue.sync {
|
||||||
|
pruneExpiredLocked(at: date)
|
||||||
|
var sprayed: [CourierEnvelope] = []
|
||||||
|
for index in envelopes.indices {
|
||||||
|
let stored = envelopes[index]
|
||||||
|
guard stored.copies > 1,
|
||||||
|
stored.depositorNoiseKey != courierNoiseKey,
|
||||||
|
!stored.sprayedTo.contains(courierNoiseKey),
|
||||||
|
!courierTags.contains(stored.recipientTag) else { continue }
|
||||||
|
let given = stored.copies / 2
|
||||||
|
envelopes[index].copies = stored.copies - given
|
||||||
|
envelopes[index].sprayedTo.insert(courierNoiseKey)
|
||||||
|
sprayed.append(stored.envelope.withCopies(given))
|
||||||
|
}
|
||||||
|
if !sprayed.isEmpty { persistLocked() }
|
||||||
|
return sprayed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Maintenance
|
// MARK: - Maintenance
|
||||||
|
|
||||||
func pruneExpired() {
|
func pruneExpired() {
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
//
|
||||||
|
// MessageOutboxStore.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitFoundation
|
||||||
|
import BitLogger
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
/// Disk persistence for the MessageRouter outbox, so private messages queued
|
||||||
|
/// for an offline peer survive an app kill instead of silently evaporating.
|
||||||
|
///
|
||||||
|
/// Nothing else in the app persists message plaintext, and this store keeps
|
||||||
|
/// that property: the outbox is sealed with a ChaChaPoly key that lives only
|
||||||
|
/// in the Keychain (after-first-unlock, this device only), on top of iOS file
|
||||||
|
/// protection. Wiped on panic alongside the courier store.
|
||||||
|
final class MessageOutboxStore {
|
||||||
|
struct QueuedMessage: Codable, Equatable {
|
||||||
|
let content: String
|
||||||
|
let nickname: String
|
||||||
|
let messageID: String
|
||||||
|
let timestamp: Date
|
||||||
|
var sendAttempts: Int
|
||||||
|
/// Noise keys of couriers already carrying this message, so deposit
|
||||||
|
/// retries add couriers instead of re-burning the same ones.
|
||||||
|
var depositedCourierKeys: Set<Data>
|
||||||
|
|
||||||
|
init(
|
||||||
|
content: String,
|
||||||
|
nickname: String,
|
||||||
|
messageID: String,
|
||||||
|
timestamp: Date,
|
||||||
|
sendAttempts: Int = 0,
|
||||||
|
depositedCourierKeys: Set<Data> = []
|
||||||
|
) {
|
||||||
|
self.content = content
|
||||||
|
self.nickname = nickname
|
||||||
|
self.messageID = messageID
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.sendAttempts = sendAttempts
|
||||||
|
self.depositedCourierKeys = depositedCourierKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
content = try container.decode(String.self, forKey: .content)
|
||||||
|
nickname = try container.decode(String.self, forKey: .nickname)
|
||||||
|
messageID = try container.decode(String.self, forKey: .messageID)
|
||||||
|
timestamp = try container.decode(Date.self, forKey: .timestamp)
|
||||||
|
sendAttempts = try container.decodeIfPresent(Int.self, forKey: .sendAttempts) ?? 0
|
||||||
|
depositedCourierKeys = try container.decodeIfPresent(Set<Data>.self, forKey: .depositedCourierKeys) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let keychainService = "chat.bitchat.outbox"
|
||||||
|
private static let keychainKey = "outbox-encryption-key"
|
||||||
|
|
||||||
|
private let fileURL: URL?
|
||||||
|
private let keychain: KeychainManagerProtocol
|
||||||
|
|
||||||
|
init(keychain: KeychainManagerProtocol, fileURL: URL? = nil) {
|
||||||
|
self.keychain = keychain
|
||||||
|
self.fileURL = fileURL ?? Self.defaultFileURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - API (call from the router's actor; IO is small and atomic)
|
||||||
|
|
||||||
|
func load() -> [PeerID: [QueuedMessage]] {
|
||||||
|
guard let fileURL,
|
||||||
|
let sealed = try? Data(contentsOf: fileURL),
|
||||||
|
let key = encryptionKey(createIfMissing: false),
|
||||||
|
let box = try? ChaChaPoly.SealedBox(combined: sealed),
|
||||||
|
let plaintext = try? ChaChaPoly.open(box, using: key),
|
||||||
|
let decoded = try? JSONDecoder().decode([String: [QueuedMessage]].self, from: plaintext) else {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
var outbox: [PeerID: [QueuedMessage]] = [:]
|
||||||
|
for (peerID, queue) in decoded where !queue.isEmpty {
|
||||||
|
outbox[PeerID(str: peerID)] = queue
|
||||||
|
}
|
||||||
|
return outbox
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(_ outbox: [PeerID: [QueuedMessage]]) {
|
||||||
|
guard let fileURL else { return }
|
||||||
|
let flattened = outbox.filter { !$0.value.isEmpty }
|
||||||
|
guard !flattened.isEmpty else {
|
||||||
|
try? FileManager.default.removeItem(at: fileURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let key = encryptionKey(createIfMissing: true) else {
|
||||||
|
SecureLogger.error("Outbox not persisted: no encryption key available", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let keyed = Dictionary(uniqueKeysWithValues: flattened.map { ($0.key.id, $0.value) })
|
||||||
|
let plaintext = try JSONEncoder().encode(keyed)
|
||||||
|
let sealed = try ChaChaPoly.seal(plaintext, using: key).combined
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: fileURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
var options: Data.WritingOptions = [.atomic]
|
||||||
|
#if os(iOS)
|
||||||
|
options.insert(.completeFileProtection)
|
||||||
|
#endif
|
||||||
|
try sealed.write(to: fileURL, options: options)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Failed to persist outbox: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Panic wipe: drop the queued mail and the key that could ever read it.
|
||||||
|
func wipe() {
|
||||||
|
if let fileURL {
|
||||||
|
try? FileManager.default.removeItem(at: fileURL)
|
||||||
|
}
|
||||||
|
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private func encryptionKey(createIfMissing: Bool) -> SymmetricKey? {
|
||||||
|
if let data = keychain.load(key: Self.keychainKey, service: Self.keychainService), data.count == 32 {
|
||||||
|
return SymmetricKey(data: data)
|
||||||
|
}
|
||||||
|
guard createIfMissing else { return nil }
|
||||||
|
let key = SymmetricKey(size: .bits256)
|
||||||
|
let data = key.withUnsafeBytes { Data($0) }
|
||||||
|
// After-first-unlock so queued mail can flush from background BLE wakes.
|
||||||
|
keychain.save(
|
||||||
|
key: Self.keychainKey,
|
||||||
|
data: data,
|
||||||
|
service: Self.keychainService,
|
||||||
|
accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||||
|
)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func defaultFileURL() -> URL? {
|
||||||
|
guard let base = try? FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
) else { return nil }
|
||||||
|
return base
|
||||||
|
.appendingPathComponent("courier", isDirectory: true)
|
||||||
|
.appendingPathComponent("outbox.sealed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
//
|
||||||
|
// StoreAndForwardMetrics.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Privacy-safe local counters for the store-and-forward stack: bare event
|
||||||
|
/// tallies with no message IDs, peer identities, or timestamps, so delivery
|
||||||
|
/// behavior can be measured on-device without recording who talked to whom.
|
||||||
|
/// Log-only surface — nothing here ever leaves the device.
|
||||||
|
final class StoreAndForwardMetrics {
|
||||||
|
enum Event: String, CaseIterable {
|
||||||
|
/// A private message entered the outbox (no prompt route available).
|
||||||
|
case outboxQueued = "outbox.queued"
|
||||||
|
/// A retained message was re-sent on a flush.
|
||||||
|
case outboxResent = "outbox.resent"
|
||||||
|
/// A delivery/read ack cleared a retained message.
|
||||||
|
case outboxDelivered = "outbox.delivered"
|
||||||
|
/// A retained message was dropped (attempt cap, TTL, or overflow).
|
||||||
|
case outboxDropped = "outbox.dropped"
|
||||||
|
/// We handed sealed mail to a courier.
|
||||||
|
case courierDeposited = "courier.deposited"
|
||||||
|
/// We accepted sealed mail to carry for a third party.
|
||||||
|
case courierAccepted = "courier.accepted"
|
||||||
|
/// We handed carried mail to its recipient over a direct link.
|
||||||
|
case courierHandedOver = "courier.handedOver"
|
||||||
|
/// We pushed carried mail toward a recipient heard via relay.
|
||||||
|
case courierRemoteHandover = "courier.remoteHandover"
|
||||||
|
/// We split spray copies to another courier.
|
||||||
|
case courierSprayed = "courier.sprayed"
|
||||||
|
/// Couriered mail addressed to us was opened and delivered.
|
||||||
|
case courierOpened = "courier.opened"
|
||||||
|
}
|
||||||
|
|
||||||
|
static let shared = StoreAndForwardMetrics()
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var counts: [String: Int]
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
private static let defaultsKey = "chat.bitchat.storeAndForwardMetrics"
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = .standard) {
|
||||||
|
self.defaults = defaults
|
||||||
|
self.counts = defaults.dictionary(forKey: Self.defaultsKey) as? [String: Int] ?? [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func record(_ event: Event) {
|
||||||
|
lock.lock()
|
||||||
|
let total = (counts[event.rawValue] ?? 0) + 1
|
||||||
|
counts[event.rawValue] = total
|
||||||
|
defaults.set(counts, forKey: Self.defaultsKey)
|
||||||
|
lock.unlock()
|
||||||
|
SecureLogger.debug("📊 S&F \(event.rawValue) → \(total)", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot() -> [String: Int] {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Included in the panic wipe alongside the stores it describes.
|
||||||
|
func reset() {
|
||||||
|
lock.lock()
|
||||||
|
counts = [:]
|
||||||
|
defaults.removeObject(forKey: Self.defaultsKey)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ import Foundation
|
|||||||
struct CourierDirectory {
|
struct CourierDirectory {
|
||||||
/// Noise static key for a peer we can address while they're offline.
|
/// Noise static key for a peer we can address while they're offline.
|
||||||
var noiseKey: (PeerID) -> Data?
|
var noiseKey: (PeerID) -> Data?
|
||||||
/// Whether a peer (by Noise static key) may carry our mail.
|
/// Whether a peer (by Noise static key) is a mutual favorite — the
|
||||||
|
/// preferred courier tier. Verified non-favorites are the fallback tier,
|
||||||
|
/// read off the transport snapshot.
|
||||||
var isTrustedCourier: (Data) -> Bool
|
var isTrustedCourier: (Data) -> Bool
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -30,9 +32,13 @@ struct CourierDirectory {
|
|||||||
/// Routes messages using available transports (Mesh, Nostr, etc.)
|
/// Routes messages using available transports (Mesh, Nostr, etc.)
|
||||||
@MainActor
|
@MainActor
|
||||||
final class MessageRouter {
|
final class MessageRouter {
|
||||||
|
typealias QueuedMessage = MessageOutboxStore.QueuedMessage
|
||||||
|
|
||||||
private let transports: [Transport]
|
private let transports: [Transport]
|
||||||
private let now: () -> Date
|
private let now: () -> Date
|
||||||
private let courierDirectory: CourierDirectory
|
private let courierDirectory: CourierDirectory
|
||||||
|
private let outboxStore: MessageOutboxStore?
|
||||||
|
private let metrics: StoreAndForwardMetrics?
|
||||||
|
|
||||||
/// Invoked whenever a retained private message is dropped without a
|
/// Invoked whenever a retained private message is dropped without a
|
||||||
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
|
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
|
||||||
@@ -41,20 +47,11 @@ final class MessageRouter {
|
|||||||
var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)?
|
var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)?
|
||||||
|
|
||||||
/// Invoked when a message with no reachable transport was handed to at
|
/// Invoked when a message with no reachable transport was handed to at
|
||||||
/// least one courier (a connected mutual favorite who will physically
|
/// least one courier (a connected peer who will physically carry the
|
||||||
/// carry the sealed envelope). Delivery stays best-effort: the outbox
|
/// sealed envelope). Delivery stays best-effort: the outbox retains the
|
||||||
/// retains the message until an ack arrives.
|
/// message until an ack arrives.
|
||||||
var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)?
|
var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)?
|
||||||
|
|
||||||
// Outbox entry with timestamp for TTL-based eviction
|
|
||||||
private struct QueuedMessage {
|
|
||||||
let content: String
|
|
||||||
let nickname: String
|
|
||||||
let messageID: String
|
|
||||||
let timestamp: Date
|
|
||||||
var sendAttempts: Int = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
||||||
|
|
||||||
// Outbox limits to prevent unbounded memory growth
|
// Outbox limits to prevent unbounded memory growth
|
||||||
@@ -69,11 +66,16 @@ final class MessageRouter {
|
|||||||
init(
|
init(
|
||||||
transports: [Transport],
|
transports: [Transport],
|
||||||
now: @escaping () -> Date = Date.init,
|
now: @escaping () -> Date = Date.init,
|
||||||
courierDirectory: CourierDirectory? = nil
|
courierDirectory: CourierDirectory? = nil,
|
||||||
|
outboxStore: MessageOutboxStore? = nil,
|
||||||
|
metrics: StoreAndForwardMetrics? = nil
|
||||||
) {
|
) {
|
||||||
self.transports = transports
|
self.transports = transports
|
||||||
self.now = now
|
self.now = now
|
||||||
self.courierDirectory = courierDirectory ?? .favoritesBacked()
|
self.courierDirectory = courierDirectory ?? .favoritesBacked()
|
||||||
|
self.outboxStore = outboxStore
|
||||||
|
self.metrics = metrics
|
||||||
|
self.outbox = outboxStore?.load() ?? [:]
|
||||||
|
|
||||||
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
@@ -134,50 +136,137 @@ final class MessageRouter {
|
|||||||
// may never come. Double delivery is harmless — receivers dedup
|
// may never come. Double delivery is harmless — receivers dedup
|
||||||
// by message ID, and delivered/read acks never downgrade.
|
// by message ID, and delivered/read acks never downgrade.
|
||||||
if !transport.canDeliverPromptly(to: peerID) {
|
if !transport.canDeliverPromptly(to: peerID) {
|
||||||
attemptCourierDeposit(content: content, messageID: messageID, for: peerID)
|
attemptCourierDeposit(messageID: messageID, for: peerID)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
var unsent = message
|
var unsent = message
|
||||||
unsent.sendAttempts = 0
|
unsent.sendAttempts = 0
|
||||||
enqueue(unsent, for: peerID)
|
enqueue(unsent, for: peerID)
|
||||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
|
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
|
||||||
attemptCourierDeposit(content: content, messageID: messageID, for: peerID)
|
attemptCourierDeposit(messageID: messageID, for: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Couriers
|
||||||
|
|
||||||
/// Last resort when no transport can deliver promptly — the peer is
|
/// Last resort when no transport can deliver promptly — the peer is
|
||||||
/// unreachable, or only reachable through a send queue waiting on
|
/// unreachable, or only reachable through a send queue waiting on
|
||||||
/// internet: seal the message to their known static key and hand it to
|
/// internet: seal the message to their known static key and hand it to
|
||||||
/// connected mutual favorites who may physically encounter them. The
|
/// connected couriers who may physically encounter them. Mutual favorites
|
||||||
/// queued copy above stays retained, so direct delivery still wins if
|
/// are preferred; signature-verified strangers fill remaining slots so a
|
||||||
/// the peer reappears first (receivers dedup by message ID).
|
/// crowd without favorites can still carry mail (envelopes are opaque
|
||||||
private func attemptCourierDeposit(content: String, messageID: String, for peerID: PeerID) {
|
/// either way). The queued copy stays retained, so direct delivery still
|
||||||
guard let recipientKey = courierDirectory.noiseKey(peerID) else { return }
|
/// wins if the peer reappears first (receivers dedup by message ID).
|
||||||
|
private func attemptCourierDeposit(messageID: String, for peerID: PeerID) {
|
||||||
|
guard let recipientKey = courierDirectory.noiseKey(peerID),
|
||||||
|
let entry = queuedMessage(messageID, for: peerID) else { return }
|
||||||
|
let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count
|
||||||
|
guard remainingSlots > 0 else { return }
|
||||||
|
|
||||||
for transport in transports {
|
for transport in transports {
|
||||||
let couriers = transport.currentPeerSnapshots()
|
let couriers = eligibleCouriers(
|
||||||
.filter { snapshot in
|
on: transport,
|
||||||
guard snapshot.isConnected,
|
recipientKey: recipientKey,
|
||||||
let key = snapshot.noisePublicKey,
|
excluding: entry.depositedCourierKeys,
|
||||||
key != recipientKey else { return false }
|
limit: remainingSlots
|
||||||
return courierDirectory.isTrustedCourier(key)
|
)
|
||||||
}
|
|
||||||
.prefix(Self.maxCouriersPerMessage)
|
|
||||||
.map(\.peerID)
|
|
||||||
guard !couriers.isEmpty else { continue }
|
guard !couriers.isEmpty else { continue }
|
||||||
if transport.sendCourierMessage(content, messageID: messageID, recipientNoiseKey: recipientKey, via: Array(couriers)) {
|
if transport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) {
|
||||||
SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))…", category: .session)
|
SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))…", category: .session)
|
||||||
|
recordCourierDeposit(messageID: messageID, for: peerID, courierKeys: couriers.map(\.noiseKey))
|
||||||
onMessageCarried?(messageID, peerID)
|
onMessageCarried?(messageID, peerID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A courier candidate just connected: hand them any queued mail they are
|
||||||
|
/// not already carrying. This is what turns couriering from "a favorite
|
||||||
|
/// happened to be around at send time" into eventual spread — deposits
|
||||||
|
/// retry as eligible peers appear, until each message rides with
|
||||||
|
/// `maxCouriersPerMessage` distinct couriers or expires.
|
||||||
|
func courierBecameAvailable(_ peerID: PeerID) {
|
||||||
|
for transport in transports {
|
||||||
|
guard transport.isPeerConnected(peerID),
|
||||||
|
let snapshot = transport.currentPeerSnapshots().first(where: { $0.peerID == peerID && $0.isConnected }),
|
||||||
|
let courierKey = snapshot.noisePublicKey,
|
||||||
|
courierDirectory.isTrustedCourier(courierKey) || snapshot.isVerified else { continue }
|
||||||
|
|
||||||
|
let currentDate = now()
|
||||||
|
for (recipient, queue) in outbox {
|
||||||
|
// Mail *to* this peer flushes directly on connect.
|
||||||
|
guard recipient != peerID,
|
||||||
|
let recipientKey = courierDirectory.noiseKey(recipient),
|
||||||
|
recipientKey != courierKey else { continue }
|
||||||
|
for message in queue {
|
||||||
|
guard message.depositedCourierKeys.count < Self.maxCouriersPerMessage,
|
||||||
|
!message.depositedCourierKeys.contains(courierKey),
|
||||||
|
currentDate.timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds else { continue }
|
||||||
|
if transport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) {
|
||||||
|
SecureLogger.debug("📦 Deposit retry: PM \(message.messageID.prefix(8))… handed to \(peerID.id.prefix(8))… for \(recipient.id.prefix(8))…", category: .session)
|
||||||
|
recordCourierDeposit(messageID: message.messageID, for: recipient, courierKeys: [courierKey])
|
||||||
|
onMessageCarried?(message.messageID, recipient)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct CourierCandidate {
|
||||||
|
let peerID: PeerID
|
||||||
|
let noiseKey: Data
|
||||||
|
}
|
||||||
|
|
||||||
|
private func eligibleCouriers(
|
||||||
|
on transport: Transport,
|
||||||
|
recipientKey: Data,
|
||||||
|
excluding excludedKeys: Set<Data>,
|
||||||
|
limit: Int
|
||||||
|
) -> [CourierCandidate] {
|
||||||
|
guard limit > 0 else { return [] }
|
||||||
|
let candidates = transport.currentPeerSnapshots().compactMap { snapshot -> (CourierCandidate, isFavorite: Bool)? in
|
||||||
|
guard snapshot.isConnected,
|
||||||
|
let key = snapshot.noisePublicKey,
|
||||||
|
key != recipientKey,
|
||||||
|
!excludedKeys.contains(key) else { return nil }
|
||||||
|
let isFavorite = courierDirectory.isTrustedCourier(key)
|
||||||
|
guard isFavorite || snapshot.isVerified else { return nil }
|
||||||
|
return (CourierCandidate(peerID: snapshot.peerID, noiseKey: key), isFavorite)
|
||||||
|
}
|
||||||
|
return candidates
|
||||||
|
.sorted { $0.isFavorite && !$1.isFavorite }
|
||||||
|
.prefix(limit)
|
||||||
|
.map(\.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func queuedMessage(_ messageID: String, for peerID: PeerID) -> QueuedMessage? {
|
||||||
|
outbox[peerID]?.first { $0.messageID == messageID }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recordCourierDeposit(messageID: String, for peerID: PeerID, courierKeys: [Data]) {
|
||||||
|
metrics?.record(.courierDeposited)
|
||||||
|
guard var queue = outbox[peerID],
|
||||||
|
let index = queue.firstIndex(where: { $0.messageID == messageID }) else { return }
|
||||||
|
queue[index].depositedCourierKeys.formUnion(courierKeys)
|
||||||
|
outbox[peerID] = queue
|
||||||
|
persistOutbox()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Outbox Management
|
||||||
|
|
||||||
/// A delivery or read ack confirms receipt; stop retaining the message.
|
/// A delivery or read ack confirms receipt; stop retaining the message.
|
||||||
func markDelivered(_ messageID: String) {
|
func markDelivered(_ messageID: String) {
|
||||||
|
var cleared = false
|
||||||
for (peerID, queue) in outbox {
|
for (peerID, queue) in outbox {
|
||||||
let filtered = queue.filter { $0.messageID != messageID }
|
let filtered = queue.filter { $0.messageID != messageID }
|
||||||
guard filtered.count != queue.count else { continue }
|
guard filtered.count != queue.count else { continue }
|
||||||
outbox[peerID] = filtered.isEmpty ? nil : filtered
|
outbox[peerID] = filtered.isEmpty ? nil : filtered
|
||||||
|
cleared = true
|
||||||
|
}
|
||||||
|
if cleared {
|
||||||
|
metrics?.record(.outboxDelivered)
|
||||||
|
persistOutbox()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,9 +280,26 @@ final class MessageRouter {
|
|||||||
if queue.count > Self.maxMessagesPerPeer {
|
if queue.count > Self.maxMessagesPerPeer {
|
||||||
let evicted = queue.removeFirst()
|
let evicted = queue.removeFirst()
|
||||||
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted.messageID.prefix(8))…", category: .session)
|
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted.messageID.prefix(8))…", category: .session)
|
||||||
onMessageDropped?(evicted.messageID, peerID)
|
dropMessage(evicted.messageID, for: peerID)
|
||||||
}
|
}
|
||||||
outbox[peerID] = queue
|
outbox[peerID] = queue
|
||||||
|
metrics?.record(.outboxQueued)
|
||||||
|
persistOutbox()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dropMessage(_ messageID: String, for peerID: PeerID) {
|
||||||
|
metrics?.record(.outboxDropped)
|
||||||
|
onMessageDropped?(messageID, peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistOutbox() {
|
||||||
|
outboxStore?.save(outbox)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Panic wipe: forget queued mail on disk and in memory.
|
||||||
|
func wipeOutbox() {
|
||||||
|
outbox.removeAll()
|
||||||
|
outboxStore?.wipe()
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
@@ -220,8 +326,6 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Outbox Management
|
|
||||||
|
|
||||||
func flushOutbox(for peerID: PeerID) {
|
func flushOutbox(for peerID: PeerID) {
|
||||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||||
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||||
@@ -233,7 +337,7 @@ final class MessageRouter {
|
|||||||
// Skip expired messages (TTL exceeded)
|
// Skip expired messages (TTL exceeded)
|
||||||
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
||||||
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
|
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
|
||||||
onMessageDropped?(message.messageID, peerID)
|
dropMessage(message.messageID, for: peerID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,16 +345,18 @@ final class MessageRouter {
|
|||||||
// Live link: send and stop retaining.
|
// Live link: send and stop retaining.
|
||||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||||
|
metrics?.record(.outboxResent)
|
||||||
} else if let transport = reachableTransport(for: peerID) {
|
} else if let transport = reachableTransport(for: peerID) {
|
||||||
// Weak signal: send but keep retaining until an ack clears it,
|
// Weak signal: send but keep retaining until an ack clears it,
|
||||||
// bounded by attempt count for peers that never ack.
|
// bounded by attempt count for peers that never ack.
|
||||||
guard message.sendAttempts < Self.maxSendAttempts else {
|
guard message.sendAttempts < Self.maxSendAttempts else {
|
||||||
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
|
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
|
||||||
onMessageDropped?(message.messageID, peerID)
|
dropMessage(message.messageID, for: peerID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||||
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
|
||||||
|
metrics?.record(.outboxResent)
|
||||||
var retained = message
|
var retained = message
|
||||||
retained.sendAttempts += 1
|
retained.sendAttempts += 1
|
||||||
remaining.append(retained)
|
remaining.append(retained)
|
||||||
@@ -264,6 +370,7 @@ final class MessageRouter {
|
|||||||
} else {
|
} else {
|
||||||
outbox[peerID] = remaining
|
outbox[peerID] = remaining
|
||||||
}
|
}
|
||||||
|
persistOutbox()
|
||||||
}
|
}
|
||||||
|
|
||||||
func flushAllOutbox() {
|
func flushAllOutbox() {
|
||||||
@@ -273,6 +380,7 @@ final class MessageRouter {
|
|||||||
/// Periodically clean up expired messages from all outboxes
|
/// Periodically clean up expired messages from all outboxes
|
||||||
func cleanupExpiredMessages() {
|
func cleanupExpiredMessages() {
|
||||||
let now = now()
|
let now = now()
|
||||||
|
var droppedAny = false
|
||||||
for peerID in Array(outbox.keys) {
|
for peerID in Array(outbox.keys) {
|
||||||
var expiredMessageIDs: [String] = []
|
var expiredMessageIDs: [String] = []
|
||||||
outbox[peerID]?.removeAll { message in
|
outbox[peerID]?.removeAll { message in
|
||||||
@@ -285,8 +393,12 @@ final class MessageRouter {
|
|||||||
}
|
}
|
||||||
for messageID in expiredMessageIDs {
|
for messageID in expiredMessageIDs {
|
||||||
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
onMessageDropped?(messageID, peerID)
|
dropMessage(messageID, for: peerID)
|
||||||
|
droppedAny = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if droppedAny {
|
||||||
|
persistOutbox()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,17 @@ struct RelayController {
|
|||||||
isDirectedFragment: Bool,
|
isDirectedFragment: Bool,
|
||||||
isHandshake: Bool,
|
isHandshake: Bool,
|
||||||
isAnnounce: Bool,
|
isAnnounce: Bool,
|
||||||
|
isRequestSync: Bool = false,
|
||||||
degree: Int,
|
degree: Int,
|
||||||
highDegreeThreshold: Int) -> RelayDecision {
|
highDegreeThreshold: Int) -> RelayDecision {
|
||||||
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
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
|
// Suppress obvious non-relays
|
||||||
if ttlCap <= 1 || senderIsSelf || recipientIsSelf {
|
if ttlCap <= 1 || senderIsSelf || recipientIsSelf {
|
||||||
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
|
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
|
||||||
|
|||||||
@@ -11,6 +11,24 @@ struct TransportPeerSnapshot: Equatable, Hashable {
|
|||||||
let isConnected: Bool
|
let isConnected: Bool
|
||||||
let noisePublicKey: Data?
|
let noisePublicKey: Data?
|
||||||
let lastSeen: Date
|
let lastSeen: Date
|
||||||
|
/// Whether the peer's announce was signature-verified (courier tier gate).
|
||||||
|
let isVerified: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
peerID: PeerID,
|
||||||
|
nickname: String,
|
||||||
|
isConnected: Bool,
|
||||||
|
noisePublicKey: Data?,
|
||||||
|
lastSeen: Date,
|
||||||
|
isVerified: Bool = false
|
||||||
|
) {
|
||||||
|
self.peerID = peerID
|
||||||
|
self.nickname = nickname
|
||||||
|
self.isConnected = isConnected
|
||||||
|
self.noisePublicKey = noisePublicKey
|
||||||
|
self.lastSeen = lastSeen
|
||||||
|
self.isVerified = isVerified
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TransportEvent: @unchecked Sendable {
|
enum TransportEvent: @unchecked Sendable {
|
||||||
|
|||||||
@@ -262,7 +262,14 @@ enum TransportConfig {
|
|||||||
static let syncSeenCapacity: Int = 1000
|
static let syncSeenCapacity: Int = 1000
|
||||||
static let syncGCSMaxBytes: Int = 400
|
static let syncGCSMaxBytes: Int = 400
|
||||||
static let syncGCSTargetFpr: Double = 0.01
|
static let syncGCSTargetFpr: Double = 0.01
|
||||||
|
// Fragments and file transfers keep the short window; whole public
|
||||||
|
// messages get hours so a phone walking between partitions carries the
|
||||||
|
// room's recent history with it (see syncPublicMessageMaxAgeSeconds).
|
||||||
static let syncMaxMessageAgeSeconds: TimeInterval = 900
|
static let syncMaxMessageAgeSeconds: TimeInterval = 900
|
||||||
|
// How far back public broadcast messages stay sync-able. Must not exceed
|
||||||
|
// the receive-side acceptance window (BLEPublicMessagePolicy uses this
|
||||||
|
// same constant) or served packets would be dropped as stale.
|
||||||
|
static let syncPublicMessageMaxAgeSeconds: TimeInterval = 6 * 60 * 60
|
||||||
static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0
|
static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0
|
||||||
static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||||
static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0
|
static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||||
@@ -271,4 +278,30 @@ enum TransportConfig {
|
|||||||
static let syncFragmentIntervalSeconds: TimeInterval = 30.0
|
static let syncFragmentIntervalSeconds: TimeInterval = 30.0
|
||||||
static let syncFileTransferIntervalSeconds: TimeInterval = 60.0
|
static let syncFileTransferIntervalSeconds: TimeInterval = 60.0
|
||||||
static let syncMessageIntervalSeconds: TimeInterval = 15.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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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).
|
// - 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.
|
// - Bitstream is MSB-first within each byte.
|
||||||
enum GCSFilter {
|
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
|
// 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
|
// 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 {
|
static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params {
|
||||||
let p = deriveP(targetFpr: targetFpr)
|
let p = deriveP(targetFpr: targetFpr)
|
||||||
guard !ids.isEmpty else {
|
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 cap = estimateMaxElements(sizeBytes: maxBytes, p: p)
|
||||||
let selected = Array(ids.prefix(cap))
|
// Modulus is fixed to the initial candidate count so `m` stays stable
|
||||||
let range = max(1, hashRange(count: selected.count, p: p))
|
// 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)
|
let modulo = UInt64(range)
|
||||||
|
|
||||||
var mapped = selected
|
// Encode the first `count` inputs (input order). The caller passes IDs
|
||||||
.map { h64($0) }
|
// newest-first, so trimming from the tail drops the oldest — which is
|
||||||
.map { mapHash($0, modulo: modulo) }
|
// what lets a since-cursor stay exact: the surviving set is always a
|
||||||
.sorted()
|
// contiguous newest-prefix, never a hash-order-arbitrary subset.
|
||||||
mapped = normalizeMappedValues(mapped, modulo: modulo)
|
func encodeFirst(_ count: Int) -> Data {
|
||||||
|
var mapped = ids.prefix(count)
|
||||||
if mapped.isEmpty {
|
.map { h64($0) }
|
||||||
return Params(p: p, m: range, data: Data())
|
.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 count = min(ids.count, cap)
|
||||||
var trimmedCount = mapped.count
|
var encoded = encodeFirst(count)
|
||||||
|
while encoded.count > maxBytes && count > 1 {
|
||||||
while encoded.count > maxBytes && trimmedCount > 0 {
|
count = max(1, (count * 9) / 10)
|
||||||
if trimmedCount == 1 {
|
encoded = encodeFirst(count)
|
||||||
mapped.removeAll()
|
}
|
||||||
encoded = Data()
|
// A single element that still overflows can't be represented.
|
||||||
break
|
if encoded.count > maxBytes {
|
||||||
}
|
return Params(p: p, m: range, data: Data(), includedCount: 0)
|
||||||
trimmedCount = max(1, (trimmedCount * 9) / 10)
|
|
||||||
mapped = Array(mapped.prefix(trimmedCount))
|
|
||||||
encoded = encode(sorted: mapped, p: p)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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] {
|
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 seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
||||||
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
||||||
var gcsTargetFpr: Double = 0.01 // 1%
|
var gcsTargetFpr: Double = 0.01 // 1%
|
||||||
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
|
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - fragments/files/announces
|
||||||
|
// Whole public messages stay sync-able much longer so devices carry
|
||||||
|
// the room's recent history between partitions and across restarts.
|
||||||
|
var publicMessageMaxAgeSeconds: TimeInterval = 900
|
||||||
var maintenanceIntervalSeconds: TimeInterval = 30.0
|
var maintenanceIntervalSeconds: TimeInterval = 30.0
|
||||||
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||||
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||||
@@ -73,11 +76,14 @@ final class GossipSyncManager {
|
|||||||
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
|
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
|
||||||
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
|
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
|
||||||
var messageSyncIntervalSeconds: TimeInterval = 15.0
|
var messageSyncIntervalSeconds: TimeInterval = 15.0
|
||||||
|
var responseRateLimitMaxResponses: Int = 8
|
||||||
|
var responseRateLimitWindowSeconds: TimeInterval = 30.0
|
||||||
}
|
}
|
||||||
|
|
||||||
private let myPeerID: PeerID
|
private let myPeerID: PeerID
|
||||||
private let config: Config
|
private let config: Config
|
||||||
private let requestSyncManager: RequestSyncManager
|
private let requestSyncManager: RequestSyncManager
|
||||||
|
private let archive: GossipMessageArchive?
|
||||||
weak var delegate: Delegate?
|
weak var delegate: Delegate?
|
||||||
|
|
||||||
// Storage: broadcast packets by type, and latest announce per sender
|
// Storage: broadcast packets by type, and latest announce per sender
|
||||||
@@ -85,17 +91,24 @@ final class GossipSyncManager {
|
|||||||
private var fragments = PacketStore()
|
private var fragments = PacketStore()
|
||||||
private var fileTransfers = PacketStore()
|
private var fileTransfers = PacketStore()
|
||||||
private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
|
private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
|
||||||
|
private var archiveDirty = false
|
||||||
|
|
||||||
// Timer
|
// Timer
|
||||||
private var periodicTimer: DispatchSourceTimer?
|
private var periodicTimer: DispatchSourceTimer?
|
||||||
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
||||||
private var lastStalePeerCleanup: Date = .distantPast
|
private var lastStalePeerCleanup: Date = .distantPast
|
||||||
private var syncSchedules: [SyncSchedule] = []
|
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.myPeerID = myPeerID
|
||||||
self.config = config
|
self.config = config
|
||||||
self.requestSyncManager = requestSyncManager
|
self.requestSyncManager = requestSyncManager
|
||||||
|
self.archive = archive
|
||||||
|
self.responseRateLimiter = SyncResponseRateLimiter(
|
||||||
|
maxResponses: config.responseRateLimitMaxResponses,
|
||||||
|
window: config.responseRateLimitWindowSeconds
|
||||||
|
)
|
||||||
var schedules: [SyncSchedule] = []
|
var schedules: [SyncSchedule] = []
|
||||||
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
|
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
|
||||||
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
|
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))
|
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
|
||||||
}
|
}
|
||||||
syncSchedules = schedules
|
syncSchedules = schedules
|
||||||
|
|
||||||
|
if archive != nil {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
self?.restoreArchivedMessages()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
@@ -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 {
|
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
||||||
|
let maxAgeSeconds = packet.type == MessageType.message.rawValue
|
||||||
|
? config.publicMessageMaxAgeSeconds
|
||||||
|
: config.maxMessageAgeSeconds
|
||||||
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
let ageThresholdMs = UInt64(config.maxMessageAgeSeconds * 1000)
|
let ageThresholdMs = UInt64(maxAgeSeconds * 1000)
|
||||||
|
|
||||||
// If current time is less than threshold, accept all (handle clock issues gracefully)
|
// If current time is less than threshold, accept all (handle clock issues gracefully)
|
||||||
guard nowMs >= ageThresholdMs else { return true }
|
guard nowMs >= ageThresholdMs else { return true }
|
||||||
@@ -190,6 +214,7 @@ final class GossipSyncManager {
|
|||||||
guard isPacketFresh(packet) else { return }
|
guard isPacketFresh(packet) else { return }
|
||||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
|
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
|
||||||
|
archiveDirty = true
|
||||||
case .fragment:
|
case .fragment:
|
||||||
guard isBroadcastRecipient else { return }
|
guard isBroadcastRecipient else { return }
|
||||||
guard isPacketFresh(packet) else { return }
|
guard isPacketFresh(packet) else { return }
|
||||||
@@ -265,7 +290,17 @@ final class GossipSyncManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
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)
|
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
|
// Decode GCS into sorted set and prepare membership checker
|
||||||
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
||||||
func mightContain(_ id: Data) -> Bool {
|
func mightContain(_ id: Data) -> Bool {
|
||||||
@@ -273,6 +308,9 @@ final class GossipSyncManager {
|
|||||||
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
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) {
|
if requestedTypes.contains(.announce) {
|
||||||
for (_, pair) in latestAnnouncementByPeer {
|
for (_, pair) in latestAnnouncementByPeer {
|
||||||
let (idHex, pkt) = pair
|
let (idHex, pkt) = pair
|
||||||
@@ -290,6 +328,7 @@ final class GossipSyncManager {
|
|||||||
if requestedTypes.contains(.message) {
|
if requestedTypes.contains(.message) {
|
||||||
let toSendMsgs = messages.allPackets(isFresh: isPacketFresh)
|
let toSendMsgs = messages.allPackets(isFresh: isPacketFresh)
|
||||||
for pkt in toSendMsgs {
|
for pkt in toSendMsgs {
|
||||||
|
if let since, pkt.timestamp < since { continue }
|
||||||
let idBytes = PacketIdUtil.computeId(pkt)
|
let idBytes = PacketIdUtil.computeId(pkt)
|
||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
@@ -303,6 +342,7 @@ final class GossipSyncManager {
|
|||||||
if requestedTypes.contains(.fragment) {
|
if requestedTypes.contains(.fragment) {
|
||||||
let frags = fragments.allPackets(isFresh: isPacketFresh)
|
let frags = fragments.allPackets(isFresh: isPacketFresh)
|
||||||
for pkt in frags {
|
for pkt in frags {
|
||||||
|
if let since, pkt.timestamp < since { continue }
|
||||||
let idBytes = PacketIdUtil.computeId(pkt)
|
let idBytes = PacketIdUtil.computeId(pkt)
|
||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
@@ -316,6 +356,7 @@ final class GossipSyncManager {
|
|||||||
if requestedTypes.contains(.fileTransfer) {
|
if requestedTypes.contains(.fileTransfer) {
|
||||||
let files = fileTransfers.allPackets(isFresh: isPacketFresh)
|
let files = fileTransfers.allPackets(isFresh: isPacketFresh)
|
||||||
for pkt in files {
|
for pkt in files {
|
||||||
|
if let since, pkt.timestamp < since { continue }
|
||||||
let idBytes = PacketIdUtil.computeId(pkt)
|
let idBytes = PacketIdUtil.computeId(pkt)
|
||||||
if !mightContain(idBytes) {
|
if !mightContain(idBytes) {
|
||||||
var toSend = pkt
|
var toSend = pkt
|
||||||
@@ -368,9 +409,22 @@ final class GossipSyncManager {
|
|||||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
||||||
return req.encode()
|
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 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()
|
return req.encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,28 +435,68 @@ final class GossipSyncManager {
|
|||||||
isPacketFresh(pair.packet)
|
isPacketFresh(pair.packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let messageCountBefore = messages.packets.count
|
||||||
messages.removeExpired(isFresh: isPacketFresh)
|
messages.removeExpired(isFresh: isPacketFresh)
|
||||||
|
if messages.packets.count != messageCountBefore {
|
||||||
|
archiveDirty = true
|
||||||
|
}
|
||||||
fragments.removeExpired(isFresh: isPacketFresh)
|
fragments.removeExpired(isFresh: isPacketFresh)
|
||||||
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Archive (public message persistence)
|
||||||
|
|
||||||
|
/// Rebuild the public message store from disk on launch, dropping
|
||||||
|
/// anything that aged out while the app was dead.
|
||||||
|
private func restoreArchivedMessages() {
|
||||||
|
guard let archive else { return }
|
||||||
|
var restored = 0
|
||||||
|
for data in archive.load() {
|
||||||
|
guard let packet = BitchatPacket.from(data),
|
||||||
|
packet.type == MessageType.message.rawValue,
|
||||||
|
isPacketFresh(packet) else { continue }
|
||||||
|
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||||
|
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
|
||||||
|
restored += 1
|
||||||
|
}
|
||||||
|
if restored > 0 {
|
||||||
|
SecureLogger.debug("Restored \(restored) archived public message(s) for gossip sync", category: .sync)
|
||||||
|
archiveDirty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistArchiveIfDirty() {
|
||||||
|
guard archiveDirty, let archive else { return }
|
||||||
|
archiveDirty = false
|
||||||
|
let packets = messages.allPackets(isFresh: isPacketFresh)
|
||||||
|
.compactMap { $0.toBinaryData(padding: false) }
|
||||||
|
archive.save(packets)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flush the archive outside the maintenance cadence (app backgrounding).
|
||||||
|
func persistNow() {
|
||||||
|
queue.async { [weak self] in
|
||||||
|
self?.persistArchiveIfDirty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func performPeriodicMaintenance(now: Date = Date()) {
|
private func performPeriodicMaintenance(now: Date = Date()) {
|
||||||
cleanupExpiredMessages()
|
cleanupExpiredMessages()
|
||||||
cleanupStaleAnnouncementsIfNeeded(now: now)
|
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||||
|
persistArchiveIfDirty()
|
||||||
requestSyncManager.cleanup() // Cleanup expired sync requests
|
requestSyncManager.cleanup() // Cleanup expired sync requests
|
||||||
|
responseRateLimiter.prune(now: now)
|
||||||
|
|
||||||
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 {
|
for index in syncSchedules.indices {
|
||||||
guard syncSchedules[index].interval > 0 else { continue }
|
guard syncSchedules[index].interval > 0 else { continue }
|
||||||
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
||||||
syncSchedules[index].lastSent = now
|
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) {
|
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
||||||
@@ -436,7 +530,11 @@ final class GossipSyncManager {
|
|||||||
|
|
||||||
private func removeState(for peerID: PeerID) {
|
private func removeState(for peerID: PeerID) {
|
||||||
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
|
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
|
||||||
|
let messageCountBefore = messages.packets.count
|
||||||
messages.remove { PeerID(hexData: $0.senderID) == peerID }
|
messages.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||||
|
if messages.packets.count != messageCountBefore {
|
||||||
|
archiveDirty = true
|
||||||
|
}
|
||||||
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
|
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||||
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
|
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,8 @@ protocol ChatTransportEventContext: AnyObject {
|
|||||||
|
|
||||||
// MARK: Routing & acknowledgements
|
// MARK: Routing & acknowledgements
|
||||||
func flushRouterOutbox(for peerID: PeerID)
|
func flushRouterOutbox(for peerID: PeerID)
|
||||||
|
/// Offer queued mail for *other* peers to this newly connected courier.
|
||||||
|
func retryCourierDeposits(via peerID: PeerID)
|
||||||
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID)
|
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID)
|
||||||
|
|
||||||
// MARK: Delivery status
|
// MARK: Delivery status
|
||||||
@@ -103,6 +105,10 @@ extension ChatViewModel: ChatTransportEventContext {
|
|||||||
messageRouter.flushOutbox(for: peerID)
|
messageRouter.flushOutbox(for: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func retryCourierDeposits(via peerID: PeerID) {
|
||||||
|
messageRouter.courierBecameAvailable(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) {
|
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||||
meshService.sendDeliveryAck(for: messageID, to: peerID)
|
meshService.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
}
|
}
|
||||||
@@ -208,6 +214,7 @@ final class ChatTransportEventCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
context.flushRouterOutbox(for: peerID)
|
context.flushRouterOutbox(for: peerID)
|
||||||
|
context.retryCourierDeposits(via: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,6 +371,11 @@ private extension ChatTransportEventCoordinator {
|
|||||||
|
|
||||||
case .verifyResponse:
|
case .verifyResponse:
|
||||||
context.handleVerifyResponsePayload(from: peerID, payload: payload)
|
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,
|
locationPresenceStore: LocationPresenceStore? = nil,
|
||||||
locationManager: LocationChannelManager = .shared
|
locationManager: LocationChannelManager = .shared
|
||||||
) {
|
) {
|
||||||
|
let meshService = BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
|
||||||
|
meshService.sfMetrics = .shared
|
||||||
self.init(
|
self.init(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
identityManager: identityManager,
|
identityManager: identityManager,
|
||||||
transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager),
|
transport: meshService,
|
||||||
conversations: conversations,
|
conversations: conversations,
|
||||||
peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(),
|
peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(),
|
||||||
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
|
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
|
||||||
locationManager: locationManager
|
locationManager: locationManager,
|
||||||
|
outboxStore: MessageOutboxStore(keychain: keychain),
|
||||||
|
sfMetrics: .shared
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -788,7 +792,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
peerIdentityStore: PeerIdentityStore? = nil,
|
peerIdentityStore: PeerIdentityStore? = nil,
|
||||||
locationPresenceStore: LocationPresenceStore? = nil,
|
locationPresenceStore: LocationPresenceStore? = nil,
|
||||||
locationManager: LocationChannelManager = .shared,
|
locationManager: LocationChannelManager = .shared,
|
||||||
readReceiptsDefaults: UserDefaults? = nil
|
readReceiptsDefaults: UserDefaults? = nil,
|
||||||
|
outboxStore: MessageOutboxStore? = nil,
|
||||||
|
sfMetrics: StoreAndForwardMetrics? = nil
|
||||||
) {
|
) {
|
||||||
let conversations = conversations ?? ConversationStore()
|
let conversations = conversations ?? ConversationStore()
|
||||||
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
||||||
@@ -797,7 +803,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
identityManager: identityManager,
|
identityManager: identityManager,
|
||||||
meshService: transport
|
meshService: transport,
|
||||||
|
outboxStore: outboxStore,
|
||||||
|
sfMetrics: sfMetrics
|
||||||
)
|
)
|
||||||
|
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
@@ -1189,8 +1197,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
// Clear persistent favorites from keychain
|
// Clear persistent favorites from keychain
|
||||||
FavoritesPersistenceService.shared.clearAllFavorites()
|
FavoritesPersistenceService.shared.clearAllFavorites()
|
||||||
|
|
||||||
// Drop courier mail carried for third parties (memory and disk)
|
// Drop courier mail carried for third parties (memory and disk),
|
||||||
|
// our own queued outbox, the carried public history, and the
|
||||||
|
// counters describing all of it
|
||||||
CourierStore.shared.wipe()
|
CourierStore.shared.wipe()
|
||||||
|
messageRouter.wipeOutbox()
|
||||||
|
GossipMessageArchive.wipeDefault()
|
||||||
|
StoreAndForwardMetrics.shared.reset()
|
||||||
|
|
||||||
// Identity manager has cleared persisted identity data above
|
// Identity manager has cleared persisted identity data above
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ struct ChatViewModelServiceBundle {
|
|||||||
keychain: KeychainManagerProtocol,
|
keychain: KeychainManagerProtocol,
|
||||||
idBridge: NostrIdentityBridge,
|
idBridge: NostrIdentityBridge,
|
||||||
identityManager: SecureIdentityStateManagerProtocol,
|
identityManager: SecureIdentityStateManagerProtocol,
|
||||||
meshService: Transport
|
meshService: Transport,
|
||||||
|
outboxStore: MessageOutboxStore? = nil,
|
||||||
|
sfMetrics: StoreAndForwardMetrics? = nil
|
||||||
) {
|
) {
|
||||||
let commandProcessor = CommandProcessor(identityManager: identityManager)
|
let commandProcessor = CommandProcessor(identityManager: identityManager)
|
||||||
let privateChatManager = PrivateChatManager(meshService: meshService)
|
let privateChatManager = PrivateChatManager(meshService: meshService)
|
||||||
@@ -28,7 +30,11 @@ struct ChatViewModelServiceBundle {
|
|||||||
)
|
)
|
||||||
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
let nostrTransport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||||
nostrTransport.senderPeerID = meshService.myPeerID
|
nostrTransport.senderPeerID = meshService.myPeerID
|
||||||
let messageRouter = MessageRouter(transports: [meshService, nostrTransport])
|
let messageRouter = MessageRouter(
|
||||||
|
transports: [meshService, nostrTransport],
|
||||||
|
outboxStore: outboxStore,
|
||||||
|
metrics: sfMetrics
|
||||||
|
)
|
||||||
|
|
||||||
self.commandProcessor = commandProcessor
|
self.commandProcessor = commandProcessor
|
||||||
self.messageRouter = messageRouter
|
self.messageRouter = messageRouter
|
||||||
|
|||||||
@@ -297,7 +297,9 @@ final class NostrInboundPipeline {
|
|||||||
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||||
case .readReceipt:
|
case .readReceipt:
|
||||||
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,7 +351,9 @@ final class NostrInboundPipeline {
|
|||||||
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||||
case .readReceipt:
|
case .readReceipt:
|
||||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -428,7 +432,10 @@ final class NostrInboundPipeline {
|
|||||||
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
||||||
case .readReceipt:
|
case .readReceipt:
|
||||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
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
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,6 +216,51 @@ struct BLEServiceCoreTests {
|
|||||||
cachedServiceUUIDs: [BLEService.serviceUUID, otherService]
|
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 {
|
private func makeService() -> BLEService {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
import CoreGraphics
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import ImageIO
|
||||||
import Testing
|
import Testing
|
||||||
|
import UniformTypeIdentifiers
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
#else
|
#else
|
||||||
@@ -58,6 +61,25 @@ struct ChatMediaPreparationTests {
|
|||||||
#expect(prepared.packet.fileSize == UInt64(prepared.packet.content.count))
|
#expect(prepared.packet.fileSize == UInt64(prepared.packet.content.count))
|
||||||
#expect(prepared.packet.encode() != nil)
|
#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 {
|
private func makeTemporaryImageURL() throws -> URL {
|
||||||
@@ -87,6 +109,50 @@ private func makeTemporaryImageURL() throws -> URL {
|
|||||||
return 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 {
|
private enum ChatMediaPreparationTestError: Error {
|
||||||
case imageEncodingFailed
|
case imageEncodingFailed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,9 +121,11 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
|
|||||||
|
|
||||||
// Routing & acknowledgements
|
// Routing & acknowledgements
|
||||||
private(set) var flushedOutboxPeerIDs: [PeerID] = []
|
private(set) var flushedOutboxPeerIDs: [PeerID] = []
|
||||||
|
private(set) var courierRetryPeerIDs: [PeerID] = []
|
||||||
private(set) var meshDeliveryAcks: [(messageID: String, peerID: PeerID)] = []
|
private(set) var meshDeliveryAcks: [(messageID: String, peerID: PeerID)] = []
|
||||||
|
|
||||||
func flushRouterOutbox(for peerID: PeerID) { flushedOutboxPeerIDs.append(peerID) }
|
func flushRouterOutbox(for peerID: PeerID) { flushedOutboxPeerIDs.append(peerID) }
|
||||||
|
func retryCourierDeposits(via peerID: PeerID) { courierRetryPeerIDs.append(peerID) }
|
||||||
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) {
|
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) {
|
||||||
meshDeliveryAcks.append((messageID, peerID))
|
meshDeliveryAcks.append((messageID, peerID))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ struct CourierStoreTests {
|
|||||||
|
|
||||||
@Test func perDepositorQuota() {
|
@Test func perDepositorQuota() {
|
||||||
let store = makeStore()
|
let store = makeStore()
|
||||||
for _ in 0..<CourierStore.Limits.maxPerDepositor {
|
for _ in 0..<CourierStore.Limits.maxPerFavoriteDepositor {
|
||||||
#expect(store.deposit(makeEnvelope(), from: depositorA))
|
#expect(store.deposit(makeEnvelope(), from: depositorA))
|
||||||
}
|
}
|
||||||
#expect(!store.deposit(makeEnvelope(), from: depositorA))
|
#expect(!store.deposit(makeEnvelope(), from: depositorA))
|
||||||
@@ -122,7 +122,7 @@ struct CourierStoreTests {
|
|||||||
var depositorByte: UInt8 = 1
|
var depositorByte: UInt8 = 1
|
||||||
while deposited < CourierStore.Limits.maxEnvelopes + 1 {
|
while deposited < CourierStore.Limits.maxEnvelopes + 1 {
|
||||||
let depositor = Data(repeating: depositorByte, count: 32)
|
let depositor = Data(repeating: depositorByte, count: 32)
|
||||||
for _ in 0..<CourierStore.Limits.maxPerDepositor where deposited < CourierStore.Limits.maxEnvelopes + 1 {
|
for _ in 0..<CourierStore.Limits.maxPerFavoriteDepositor where deposited < CourierStore.Limits.maxEnvelopes + 1 {
|
||||||
#expect(store.deposit(makeEnvelope(), from: depositor))
|
#expect(store.deposit(makeEnvelope(), from: depositor))
|
||||||
deposited += 1
|
deposited += 1
|
||||||
}
|
}
|
||||||
@@ -173,4 +173,179 @@ struct CourierStoreTests {
|
|||||||
let second = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
|
let second = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
|
||||||
#expect(second.takeEnvelopes(for: recipientKey) == [envelope])
|
#expect(second.takeEnvelopes(for: recipientKey) == [envelope])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Tiers (open couriering)
|
||||||
|
|
||||||
|
@Test func verifiedTierGetsSmallerPerDepositorQuota() {
|
||||||
|
let store = makeStore()
|
||||||
|
for _ in 0..<CourierStore.Limits.maxPerVerifiedDepositor {
|
||||||
|
#expect(store.deposit(makeEnvelope(), from: depositorA, tier: .verified))
|
||||||
|
}
|
||||||
|
#expect(!store.deposit(makeEnvelope(), from: depositorA, tier: .verified))
|
||||||
|
// The same depositor promoted to favorite gets the larger quota.
|
||||||
|
#expect(store.deposit(makeEnvelope(), from: depositorB, tier: .favorite))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func verifiedPoolIsCappedIndependentlyOfFavorites() {
|
||||||
|
let store = makeStore()
|
||||||
|
var depositorByte: UInt8 = 1
|
||||||
|
var accepted = 0
|
||||||
|
while accepted < CourierStore.Limits.maxVerifiedEnvelopes {
|
||||||
|
let depositor = Data(repeating: depositorByte, count: 32)
|
||||||
|
for _ in 0..<CourierStore.Limits.maxPerVerifiedDepositor where accepted < CourierStore.Limits.maxVerifiedEnvelopes {
|
||||||
|
#expect(store.deposit(makeEnvelope(), from: depositor, tier: .verified))
|
||||||
|
accepted += 1
|
||||||
|
}
|
||||||
|
depositorByte += 1
|
||||||
|
}
|
||||||
|
// Verified pool full: another verified deposit is rejected...
|
||||||
|
#expect(!store.deposit(makeEnvelope(), from: Data(repeating: 0xEE, count: 32), tier: .verified))
|
||||||
|
// ...but favorites still have their share.
|
||||||
|
#expect(store.deposit(makeEnvelope(), from: depositorA, tier: .favorite))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func overflowEvictsVerifiedTierBeforeFavorites() {
|
||||||
|
let store = makeStore()
|
||||||
|
let favoriteRecipient = Data(repeating: 0xD0, count: 32)
|
||||||
|
let verifiedRecipient = Data(repeating: 0xD1, count: 32)
|
||||||
|
// Oldest envelope is a favorite deposit; a verified one follows.
|
||||||
|
#expect(store.deposit(makeEnvelope(recipientKey: favoriteRecipient), from: depositorA, tier: .favorite))
|
||||||
|
#expect(store.deposit(makeEnvelope(recipientKey: verifiedRecipient), from: depositorB, tier: .verified))
|
||||||
|
|
||||||
|
// Fill to the total cap with favorite deposits from distinct depositors.
|
||||||
|
var depositorByte: UInt8 = 10
|
||||||
|
var count = 2
|
||||||
|
while count < CourierStore.Limits.maxEnvelopes {
|
||||||
|
let depositor = Data(repeating: depositorByte, count: 32)
|
||||||
|
for _ in 0..<CourierStore.Limits.maxPerFavoriteDepositor where count < CourierStore.Limits.maxEnvelopes {
|
||||||
|
#expect(store.deposit(makeEnvelope(), from: depositor, tier: .favorite))
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
depositorByte += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next favorite deposit evicts the verified envelope, not the
|
||||||
|
// older favorite one.
|
||||||
|
#expect(store.deposit(makeEnvelope(), from: Data(repeating: 0xEF, count: 32), tier: .favorite))
|
||||||
|
#expect(store.takeEnvelopes(for: verifiedRecipient).isEmpty)
|
||||||
|
#expect(store.takeEnvelopes(for: favoriteRecipient).count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func verifiedDepositIsRejectedWhenStoreIsFullOfFavorites() {
|
||||||
|
let store = makeStore()
|
||||||
|
var depositorByte: UInt8 = 10
|
||||||
|
var count = 0
|
||||||
|
while count < CourierStore.Limits.maxEnvelopes {
|
||||||
|
let depositor = Data(repeating: depositorByte, count: 32)
|
||||||
|
for _ in 0..<CourierStore.Limits.maxPerFavoriteDepositor where count < CourierStore.Limits.maxEnvelopes {
|
||||||
|
#expect(store.deposit(makeEnvelope(), from: depositor, tier: .favorite))
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
depositorByte += 1
|
||||||
|
}
|
||||||
|
// A verified deposit must not displace favorite-tier mail.
|
||||||
|
#expect(!store.deposit(makeEnvelope(), from: Data(repeating: 0xEE, count: 32), tier: .verified))
|
||||||
|
// A favorite deposit still can (oldest-favorite eviction).
|
||||||
|
#expect(store.deposit(makeEnvelope(), from: Data(repeating: 0xEF, count: 32), tier: .favorite))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Spray-and-wait
|
||||||
|
|
||||||
|
@Test func sprayHalvesBudgetAndSkipsIneligibleCouriers() {
|
||||||
|
let store = makeStore()
|
||||||
|
let recipientKey = Data(repeating: 0xB0, count: 32)
|
||||||
|
let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(4)
|
||||||
|
#expect(store.deposit(envelope, from: depositorA))
|
||||||
|
|
||||||
|
// The recipient themselves never gets a spray copy (handover path).
|
||||||
|
#expect(store.takeSprayCopies(for: recipientKey).isEmpty)
|
||||||
|
// Neither does the depositor.
|
||||||
|
#expect(store.takeSprayCopies(for: depositorA).isEmpty)
|
||||||
|
|
||||||
|
// A fresh courier gets half the budget.
|
||||||
|
let courierX = Data(repeating: 0xC1, count: 32)
|
||||||
|
let sprayedToX = store.takeSprayCopies(for: courierX)
|
||||||
|
#expect(sprayedToX.count == 1)
|
||||||
|
#expect(sprayedToX.first?.copies == 2)
|
||||||
|
// Same courier again: no double spend.
|
||||||
|
#expect(store.takeSprayCopies(for: courierX).isEmpty)
|
||||||
|
|
||||||
|
// Next courier gets half the remainder (2 -> give 1, keep 1).
|
||||||
|
let courierY = Data(repeating: 0xC2, count: 32)
|
||||||
|
let sprayedToY = store.takeSprayCopies(for: courierY)
|
||||||
|
#expect(sprayedToY.count == 1)
|
||||||
|
#expect(sprayedToY.first?.copies == 1)
|
||||||
|
|
||||||
|
// Budget exhausted (carry-only): nothing left to spray.
|
||||||
|
#expect(store.takeSprayCopies(for: Data(repeating: 0xC3, count: 32)).isEmpty)
|
||||||
|
// The carried original is still deliverable.
|
||||||
|
#expect(store.takeEnvelopes(for: recipientKey).count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func carryOnlyEnvelopesAreNeverSprayed() {
|
||||||
|
let store = makeStore()
|
||||||
|
#expect(store.deposit(makeEnvelope(), from: depositorA))
|
||||||
|
#expect(store.takeSprayCopies(for: Data(repeating: 0xC1, count: 32)).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func duplicateDepositKeepsLargerSprayBudget() {
|
||||||
|
let store = makeStore()
|
||||||
|
let recipientKey = Data(repeating: 0xB0, count: 32)
|
||||||
|
let ciphertext = Data(repeating: 0x42, count: 96)
|
||||||
|
let carryOnly = makeEnvelope(recipientKey: recipientKey, ciphertext: ciphertext)
|
||||||
|
#expect(store.deposit(carryOnly, from: depositorA))
|
||||||
|
#expect(store.deposit(carryOnly.withCopies(4), from: depositorB))
|
||||||
|
|
||||||
|
let sprayed = store.takeSprayCopies(for: Data(repeating: 0xC1, count: 32))
|
||||||
|
#expect(sprayed.first?.copies == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Remote handover (relayed announces)
|
||||||
|
|
||||||
|
@Test func remoteHandoverIsNonDestructiveAndCooledDown() {
|
||||||
|
let store = makeStore()
|
||||||
|
let recipientKey = Data(repeating: 0xB0, count: 32)
|
||||||
|
let envelope = makeEnvelope(recipientKey: recipientKey).withCopies(4)
|
||||||
|
#expect(store.deposit(envelope, from: depositorA))
|
||||||
|
|
||||||
|
let first = store.envelopesForRemoteHandover(recipientNoiseKey: recipientKey, cooldown: 600)
|
||||||
|
#expect(first.count == 1)
|
||||||
|
// The flooded copy carries no spray budget.
|
||||||
|
#expect(first.first?.copies == 1)
|
||||||
|
// Non-destructive: the envelope is still carried...
|
||||||
|
#expect(!store.isEmpty)
|
||||||
|
// ...and inside the cooldown it is not re-flooded.
|
||||||
|
#expect(store.envelopesForRemoteHandover(recipientNoiseKey: recipientKey, cooldown: 600).isEmpty)
|
||||||
|
// A direct encounter still hands it over destructively.
|
||||||
|
#expect(store.takeEnvelopes(for: recipientKey).count == 1)
|
||||||
|
#expect(store.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Legacy persistence
|
||||||
|
|
||||||
|
@Test func legacyPersistedFileLoadsAsFavoriteCarryOnly() throws {
|
||||||
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("courier-legacy-\(UUID().uuidString).json")
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
|
||||||
|
// Envelope persisted by a pre-tier/pre-spray build: no tier, copies,
|
||||||
|
// or spray bookkeeping fields.
|
||||||
|
let recipientKey = Data(repeating: 0xB0, count: 32)
|
||||||
|
let envelope = makeEnvelope(recipientKey: recipientKey)
|
||||||
|
let legacy: [[String: Any]] = [[
|
||||||
|
"recipientTag": envelope.recipientTag.base64EncodedString(),
|
||||||
|
"expiry": envelope.expiry,
|
||||||
|
"ciphertext": envelope.ciphertext.base64EncodedString(),
|
||||||
|
"depositorNoiseKey": depositorA.base64EncodedString(),
|
||||||
|
"storedAt": Self.baseDate.timeIntervalSinceReferenceDate
|
||||||
|
]]
|
||||||
|
let data = try JSONSerialization.data(withJSONObject: legacy)
|
||||||
|
try data.write(to: fileURL)
|
||||||
|
|
||||||
|
let store = CourierStore(persistsToDisk: true, fileURL: fileURL, now: { Self.baseDate })
|
||||||
|
// Carry-only, so never sprayed...
|
||||||
|
#expect(store.takeSprayCopies(for: Data(repeating: 0xC1, count: 32)).isEmpty)
|
||||||
|
// ...but still delivered on encounter.
|
||||||
|
#expect(store.takeEnvelopes(for: recipientKey).count == 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ struct CourierEndToEndTests {
|
|||||||
let bob = makeService()
|
let bob = makeService()
|
||||||
// Alice and Carol are mutual favorites; trust policy is exercised
|
// Alice and Carol are mutual favorites; trust policy is exercised
|
||||||
// separately in depositFromUntrustedPeerIsRejected.
|
// separately in depositFromUntrustedPeerIsRejected.
|
||||||
carol.courierDepositPolicy = { _ in true }
|
carol.courierDepositPolicy = { _, _ in .favorite }
|
||||||
|
|
||||||
let bobDelegate = NoiseCaptureDelegate()
|
let bobDelegate = NoiseCaptureDelegate()
|
||||||
bob.delegate = bobDelegate
|
bob.delegate = bobDelegate
|
||||||
@@ -134,7 +134,7 @@ struct CourierEndToEndTests {
|
|||||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||||
|
|
||||||
// 2. Ferry the deposit to Carol; she carries it (opaque to her).
|
// 2. Ferry the deposit to Carol; she carries it (opaque to her).
|
||||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
|
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||||
let carried = await TestHelpers.waitUntil(
|
let carried = await TestHelpers.waitUntil(
|
||||||
{ !carol.courierStore.isEmpty },
|
{ !carol.courierStore.isEmpty },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.defaultTimeout
|
||||||
@@ -186,7 +186,7 @@ struct CourierEndToEndTests {
|
|||||||
let carol = makeService()
|
let carol = makeService()
|
||||||
let bobIdentity = MockIdentityManager(MockKeychain())
|
let bobIdentity = MockIdentityManager(MockKeychain())
|
||||||
let bob = makeService(identityManager: bobIdentity)
|
let bob = makeService(identityManager: bobIdentity)
|
||||||
carol.courierDepositPolicy = { _ in true }
|
carol.courierDepositPolicy = { _, _ in .favorite }
|
||||||
|
|
||||||
let bobDelegate = NoiseCaptureDelegate()
|
let bobDelegate = NoiseCaptureDelegate()
|
||||||
bob.delegate = bobDelegate
|
bob.delegate = bobDelegate
|
||||||
@@ -215,7 +215,7 @@ struct CourierEndToEndTests {
|
|||||||
#expect(deposited)
|
#expect(deposited)
|
||||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||||
|
|
||||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
|
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||||
let carried = await TestHelpers.waitUntil(
|
let carried = await TestHelpers.waitUntil(
|
||||||
{ !carol.courierStore.isEmpty },
|
{ !carol.courierStore.isEmpty },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.defaultTimeout
|
||||||
@@ -254,7 +254,7 @@ struct CourierEndToEndTests {
|
|||||||
let alice = makeService()
|
let alice = makeService()
|
||||||
let carol = makeService()
|
let carol = makeService()
|
||||||
let bob = makeService()
|
let bob = makeService()
|
||||||
carol.courierDepositPolicy = { _ in true }
|
carol.courierDepositPolicy = { _, _ in .favorite }
|
||||||
|
|
||||||
let aliceOut = PacketTap()
|
let aliceOut = PacketTap()
|
||||||
alice._test_onOutboundPacket = aliceOut.record
|
alice._test_onOutboundPacket = aliceOut.record
|
||||||
@@ -278,7 +278,7 @@ struct CourierEndToEndTests {
|
|||||||
#expect(deposited)
|
#expect(deposited)
|
||||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||||
|
|
||||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
|
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||||
let carried = await TestHelpers.waitUntil(
|
let carried = await TestHelpers.waitUntil(
|
||||||
{ !carol.courierStore.isEmpty },
|
{ !carol.courierStore.isEmpty },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.defaultTimeout
|
||||||
@@ -312,11 +312,11 @@ struct CourierEndToEndTests {
|
|||||||
#expect(carol.courierStore.isEmpty)
|
#expect(carol.courierStore.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func relayedAnnounceDoesNotTriggerCourierHandover() async throws {
|
@Test func relayedAnnounceTriggersNonDestructiveRemoteHandover() async throws {
|
||||||
let alice = makeService()
|
let alice = makeService()
|
||||||
let carol = makeService()
|
let carol = makeService()
|
||||||
let bob = makeService()
|
let bob = makeService()
|
||||||
carol.courierDepositPolicy = { _ in true }
|
carol.courierDepositPolicy = { _, _ in .favorite }
|
||||||
|
|
||||||
let aliceOut = PacketTap()
|
let aliceOut = PacketTap()
|
||||||
alice._test_onOutboundPacket = aliceOut.record
|
alice._test_onOutboundPacket = aliceOut.record
|
||||||
@@ -340,7 +340,7 @@ struct CourierEndToEndTests {
|
|||||||
#expect(deposited)
|
#expect(deposited)
|
||||||
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
let depositPacket = try #require(aliceOut.first(ofType: .courierEnvelope))
|
||||||
|
|
||||||
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID)
|
carol._test_handlePacket(depositPacket, fromPeerID: alice.myPeerID, signingPublicKey: alice.noiseSigningPublicKeyData())
|
||||||
let carried = await TestHelpers.waitUntil(
|
let carried = await TestHelpers.waitUntil(
|
||||||
{ !carol.courierStore.isEmpty },
|
{ !carol.courierStore.isEmpty },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.defaultTimeout
|
||||||
@@ -356,23 +356,24 @@ struct CourierEndToEndTests {
|
|||||||
let directAnnounce = try #require(bobOut.first(ofType: .announce))
|
let directAnnounce = try #require(bobOut.first(ofType: .announce))
|
||||||
|
|
||||||
// A relayed copy has a decremented TTL but a still-valid signature
|
// A relayed copy has a decremented TTL but a still-valid signature
|
||||||
// (TTL is excluded from announce signatures). Envelopes are removed
|
// (TTL is excluded from announce signatures). The recipient is
|
||||||
// from the store optimistically, so handover must wait for a direct
|
// multi-hop away, so a copy floods toward them speculatively while
|
||||||
// encounter instead of chasing a multi-hop path.
|
// the carried original stays put for a future direct encounter.
|
||||||
var relayedAnnounce = directAnnounce
|
var relayedAnnounce = directAnnounce
|
||||||
relayedAnnounce.ttl = directAnnounce.ttl - 1
|
relayedAnnounce.ttl = directAnnounce.ttl - 1
|
||||||
carol._test_handlePacket(relayedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
carol._test_handlePacket(relayedAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||||
|
|
||||||
let leakedOnRelayedAnnounce = await TestHelpers.waitUntil(
|
let remoteHandover = await TestHelpers.waitUntil(
|
||||||
{ carolOut.count(ofType: .courierEnvelope) > 0 },
|
{ carolOut.count(ofType: .courierEnvelope) == 1 },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.defaultTimeout
|
||||||
)
|
)
|
||||||
#expect(!leakedOnRelayedAnnounce)
|
#expect(remoteHandover)
|
||||||
#expect(!carol.courierStore.isEmpty)
|
#expect(!carol.courierStore.isEmpty)
|
||||||
|
|
||||||
// The relayed copy consumed the original announce's dedup key
|
// A second relayed announce inside the cooldown must not re-flood
|
||||||
// (sender/timestamp/payload — TTL excluded), so the direct handover
|
// the same envelope. The original announce's dedup key is consumed
|
||||||
// needs a fresh announce. Wait out the 1s announce throttle first.
|
// (sender/timestamp/payload — TTL excluded), so use a fresh announce;
|
||||||
|
// wait out the 1s announce throttle first.
|
||||||
try await Task.sleep(nanoseconds: 1_100_000_000)
|
try await Task.sleep(nanoseconds: 1_100_000_000)
|
||||||
bob.sendBroadcastAnnounce()
|
bob.sendBroadcastAnnounce()
|
||||||
let reannounced = await TestHelpers.waitUntil(
|
let reannounced = await TestHelpers.waitUntil(
|
||||||
@@ -383,10 +384,32 @@ struct CourierEndToEndTests {
|
|||||||
let freshAnnounce = try #require(
|
let freshAnnounce = try #require(
|
||||||
bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp }
|
bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp }
|
||||||
)
|
)
|
||||||
carol._test_handlePacket(freshAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
var relayedFreshAnnounce = freshAnnounce
|
||||||
|
relayedFreshAnnounce.ttl = freshAnnounce.ttl - 1
|
||||||
|
carol._test_handlePacket(relayedFreshAnnounce, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||||
|
|
||||||
|
let refloodedInCooldown = await TestHelpers.waitUntil(
|
||||||
|
{ carolOut.count(ofType: .courierEnvelope) > 1 },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!refloodedInCooldown)
|
||||||
|
#expect(!carol.courierStore.isEmpty)
|
||||||
|
|
||||||
|
// A later *direct* announce still performs the destructive handover.
|
||||||
|
try await Task.sleep(nanoseconds: 1_100_000_000)
|
||||||
|
bob.sendBroadcastAnnounce()
|
||||||
|
let announcedAgain = await TestHelpers.waitUntil(
|
||||||
|
{ bobOut.all(ofType: .announce).contains { $0.timestamp != directAnnounce.timestamp && $0.timestamp != freshAnnounce.timestamp } },
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
#expect(announcedAgain)
|
||||||
|
let directAgain = try #require(
|
||||||
|
bobOut.all(ofType: .announce).first { $0.timestamp != directAnnounce.timestamp && $0.timestamp != freshAnnounce.timestamp }
|
||||||
|
)
|
||||||
|
carol._test_handlePacket(directAgain, fromPeerID: bob.myPeerID, preseedPeer: false)
|
||||||
|
|
||||||
let handedOver = await TestHelpers.waitUntil(
|
let handedOver = await TestHelpers.waitUntil(
|
||||||
{ carolOut.count(ofType: .courierEnvelope) == 1 },
|
{ carolOut.count(ofType: .courierEnvelope) == 2 },
|
||||||
timeout: TestConstants.defaultTimeout
|
timeout: TestConstants.defaultTimeout
|
||||||
)
|
)
|
||||||
#expect(handedOver)
|
#expect(handedOver)
|
||||||
@@ -417,7 +440,7 @@ struct CourierEndToEndTests {
|
|||||||
|
|
||||||
@Test func depositFromUntrustedPeerIsRejected() async throws {
|
@Test func depositFromUntrustedPeerIsRejected() async throws {
|
||||||
let carol = makeService()
|
let carol = makeService()
|
||||||
carol.courierDepositPolicy = { _ in false } // depositor is not a mutual favorite
|
carol.courierDepositPolicy = { _, _ in nil } // depositor is neither favorite nor verified
|
||||||
|
|
||||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData()
|
let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData()
|
||||||
@@ -433,6 +456,45 @@ struct CourierEndToEndTests {
|
|||||||
ciphertext: sealed
|
ciphertext: sealed
|
||||||
)
|
)
|
||||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
let unsigned = BitchatPacket(
|
||||||
|
type: MessageType.courierEnvelope.rawValue,
|
||||||
|
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||||
|
recipientID: Data(hexString: carol.myPeerID.id),
|
||||||
|
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
|
||||||
|
payload: try #require(envelope.encode()),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 1
|
||||||
|
)
|
||||||
|
let packet = try #require(alice.signPacket(unsigned))
|
||||||
|
|
||||||
|
carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData())
|
||||||
|
let stored = await TestHelpers.waitUntil(
|
||||||
|
{ !carol.courierStore.isEmpty },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unsignedDepositIsRejected() async throws {
|
||||||
|
let carol = makeService()
|
||||||
|
carol.courierDepositPolicy = { _, _ in .favorite }
|
||||||
|
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let bobKey = NoiseEncryptionService(keychain: MockKeychain()).getStaticPublicKeyData()
|
||||||
|
let typedPayload = try #require(BLENoisePayloadFactory.privateMessage(content: "x", messageID: "m-unsigned"))
|
||||||
|
let sealed = try alice.sealCourierPayload(typedPayload, recipientStaticKey: bobKey)
|
||||||
|
let now = Date()
|
||||||
|
let envelope = CourierEnvelope(
|
||||||
|
recipientTag: CourierEnvelope.recipientTag(
|
||||||
|
noiseStaticKey: bobKey,
|
||||||
|
epochDay: CourierEnvelope.epochDay(for: now)
|
||||||
|
),
|
||||||
|
expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000),
|
||||||
|
ciphertext: sealed
|
||||||
|
)
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
// Correct sender, willing policy — but no packet signature: the
|
||||||
|
// courier cannot authenticate the depositor, so it must not carry.
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.courierEnvelope.rawValue,
|
type: MessageType.courierEnvelope.rawValue,
|
||||||
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||||
@@ -443,7 +505,7 @@ struct CourierEndToEndTests {
|
|||||||
ttl: 1
|
ttl: 1
|
||||||
)
|
)
|
||||||
|
|
||||||
carol._test_handlePacket(packet, fromPeerID: alicePeerID)
|
carol._test_handlePacket(packet, fromPeerID: alicePeerID, signingPublicKey: alice.getSigningPublicKeyData())
|
||||||
let stored = await TestHelpers.waitUntil(
|
let stored = await TestHelpers.waitUntil(
|
||||||
{ !carol.courierStore.isEmpty },
|
{ !carol.courierStore.isEmpty },
|
||||||
timeout: TestConstants.shortTimeout
|
timeout: TestConstants.shortTimeout
|
||||||
@@ -459,8 +521,8 @@ struct CourierEndToEndTests {
|
|||||||
preseedConnectedPeer(mallory, in: carol)
|
preseedConnectedPeer(mallory, in: carol)
|
||||||
|
|
||||||
let trustedAliceKey = Data(hexString: alice.myPeerID.id) ?? Data()
|
let trustedAliceKey = Data(hexString: alice.myPeerID.id) ?? Data()
|
||||||
carol.courierDepositPolicy = { depositorKey in
|
carol.courierDepositPolicy = { depositorKey, _ in
|
||||||
depositorKey == trustedAliceKey
|
depositorKey == trustedAliceKey ? .favorite : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let aliceNoise = NoiseEncryptionService(keychain: MockKeychain())
|
let aliceNoise = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ struct GCSFilterTests {
|
|||||||
#expect(truncated.allSatisfy { full.contains($0) })
|
#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() {
|
@Test func requestSyncPacketDecodeRejectsOversizedP() {
|
||||||
let valid = RequestSyncPacket(p: 8, m: 4096, data: Data([0x01, 0x02]))
|
let valid = RequestSyncPacket(p: 8, m: 4096, data: Data([0x01, 0x02]))
|
||||||
#expect(RequestSyncPacket.decode(from: valid.encode()) != nil)
|
#expect(RequestSyncPacket.decode(from: valid.encode()) != nil)
|
||||||
|
|||||||
@@ -194,15 +194,204 @@ struct GossipSyncManagerTests {
|
|||||||
|
|
||||||
manager._performMaintenanceSynchronously(now: Date())
|
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
|
let sentPackets = delegate.packets
|
||||||
#expect(sentPackets.count == 1)
|
#expect(sentPackets.count == 3)
|
||||||
let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) }
|
let decoded = sentPackets.compactMap { RequestSyncPacket.decode(from: $0.payload) }
|
||||||
#expect(decoded.count == 1)
|
#expect(decoded.count == 3)
|
||||||
let types = try #require(decoded.first?.types)
|
let allTypes = decoded.compactMap(\.types).reduce(SyncTypeFlags(rawValue: 0)) { $0.union($1) }
|
||||||
#expect(types.contains(.announce))
|
#expect(allTypes.contains(.announce))
|
||||||
#expect(types.contains(.message))
|
#expect(allTypes.contains(.message))
|
||||||
#expect(types.contains(.fragment))
|
#expect(allTypes.contains(.fragment))
|
||||||
#expect(types.contains(.fileTransfer))
|
#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 {
|
@Test func initialSyncCoalescesEnabledTypes() async throws {
|
||||||
@@ -279,6 +468,79 @@ struct GossipSyncManagerTests {
|
|||||||
#expect(sentPackets.count == 1)
|
#expect(sentPackets.count == 1)
|
||||||
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
|
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Archive persistence
|
||||||
|
|
||||||
|
@Test func publicMessagesRestoreFromArchiveAcrossRestart() async throws {
|
||||||
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("gossip-archive-\(UUID().uuidString).json")
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
|
||||||
|
let senderID = try #require(Data(hexString: "1122334455667788"))
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.message.rawValue,
|
||||||
|
senderID: senderID,
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: Data([0x01, 0x02]),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
let first = GossipSyncManager(
|
||||||
|
myPeerID: myPeerID,
|
||||||
|
requestSyncManager: RequestSyncManager(),
|
||||||
|
archive: GossipMessageArchive(fileURL: fileURL)
|
||||||
|
)
|
||||||
|
first.onPublicPacketSeen(packet)
|
||||||
|
// Maintenance persists the dirty store to disk.
|
||||||
|
first._performMaintenanceSynchronously(now: Date())
|
||||||
|
#expect(FileManager.default.fileExists(atPath: fileURL.path))
|
||||||
|
|
||||||
|
// "App restart": a fresh manager over the same archive re-serves it.
|
||||||
|
let second = GossipSyncManager(
|
||||||
|
myPeerID: myPeerID,
|
||||||
|
requestSyncManager: RequestSyncManager(),
|
||||||
|
archive: GossipMessageArchive(fileURL: fileURL)
|
||||||
|
)
|
||||||
|
let restored = await TestHelpers.waitUntil(
|
||||||
|
{ second._messageCount(for: PeerID(hexData: senderID)) == 1 },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(restored)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func archiveDropsMessagesOlderThanPublicWindow() throws {
|
||||||
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("gossip-archive-\(UUID().uuidString).json")
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
|
||||||
|
var config = GossipSyncManager.Config()
|
||||||
|
config.publicMessageMaxAgeSeconds = 60
|
||||||
|
|
||||||
|
let senderID = try #require(Data(hexString: "1122334455667788"))
|
||||||
|
let stale = BitchatPacket(
|
||||||
|
type: MessageType.message.rawValue,
|
||||||
|
senderID: senderID,
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: UInt64((Date().timeIntervalSince1970 - 120) * 1000),
|
||||||
|
payload: Data([0x01]),
|
||||||
|
signature: nil,
|
||||||
|
ttl: 1
|
||||||
|
)
|
||||||
|
let archive = GossipMessageArchive(fileURL: fileURL)
|
||||||
|
archive.save([stale.toBinaryData(padding: false)!])
|
||||||
|
|
||||||
|
let manager = GossipSyncManager(
|
||||||
|
myPeerID: myPeerID,
|
||||||
|
config: config,
|
||||||
|
requestSyncManager: RequestSyncManager(),
|
||||||
|
archive: archive
|
||||||
|
)
|
||||||
|
manager._performMaintenanceSynchronously(now: Date())
|
||||||
|
#expect(manager._messageCount(for: PeerID(hexData: senderID)) == 0)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class RecordingDelegate: GossipSyncManager.Delegate {
|
private final class RecordingDelegate: GossipSyncManager.Delegate {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ final class MockTransport: Transport {
|
|||||||
private(set) var cancelledTransfers: [String] = []
|
private(set) var cancelledTransfers: [String] = []
|
||||||
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||||
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||||
|
private(set) var sentCourierMessages: [(content: String, messageID: String, recipientNoiseKey: Data, couriers: [PeerID])] = []
|
||||||
private(set) var startServicesCallCount = 0
|
private(set) var startServicesCallCount = 0
|
||||||
private(set) var stopServicesCallCount = 0
|
private(set) var stopServicesCallCount = 0
|
||||||
private(set) var emergencyDisconnectCallCount = 0
|
private(set) var emergencyDisconnectCallCount = 0
|
||||||
@@ -189,6 +190,12 @@ final class MockTransport: Transport {
|
|||||||
sentVerifyResponses.append((peerID, noiseKeyHex, nonceA))
|
sentVerifyResponses.append((peerID, noiseKeyHex, nonceA))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var courierSendResult = true
|
||||||
|
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool {
|
||||||
|
sentCourierMessages.append((content, messageID, recipientNoiseKey, couriers))
|
||||||
|
return courierSendResult
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Test Helpers
|
// MARK: - Test Helpers
|
||||||
|
|
||||||
/// Clears all recorded method calls for fresh assertions
|
/// Clears all recorded method calls for fresh assertions
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import Testing
|
import Testing
|
||||||
|
|
||||||
@@ -108,6 +109,42 @@ struct PacketsTests {
|
|||||||
#expect(decoded.directNeighbors == nil)
|
#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
|
@Test
|
||||||
func privateMessagePacketRejectsUnknownTypeAndTruncation() {
|
func privateMessagePacketRejectsUnknownTypeAndTruncation() {
|
||||||
let unknownTLV = Data([0x7F, 0x01, 0x41])
|
let unknownTLV = Data([0x7F, 0x01, 0x41])
|
||||||
|
|||||||
@@ -105,6 +105,41 @@ struct BLEIngressLinkRegistryTests {
|
|||||||
#expect(result == .failure(.directSenderMismatch(boundPeerID: boundPeer, claimedSenderID: claimedPeer)))
|
#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
|
@Test
|
||||||
func packetContextUsesBoundPeerForRSRValidation() throws {
|
func packetContextUsesBoundPeerForRSRValidation() throws {
|
||||||
let localPeer = PeerID(str: "0011223344556677")
|
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 {
|
private func makeAnnouncePacket(sender: PeerID, ttl: UInt8) -> BitchatPacket {
|
||||||
BitchatPacket(
|
BitchatPacket(
|
||||||
type: MessageType.announce.rawValue,
|
type: MessageType.announce.rawValue,
|
||||||
|
|||||||
@@ -100,11 +100,11 @@ struct BLEPublicMessageHandlerTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
func staleBroadcastIsDropped() {
|
func staleBroadcastIsDropped() {
|
||||||
let now = Date(timeIntervalSince1970: 1_000)
|
let now = Date(timeIntervalSince1970: 1_000_000)
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||||
let handler = makeHandler(recorder: recorder, now: now)
|
let handler = makeHandler(recorder: recorder, now: now)
|
||||||
let staleTimestamp = UInt64((now.timeIntervalSince1970 - 901) * 1000)
|
let staleTimestamp = UInt64((now.timeIntervalSince1970 - TransportConfig.syncPublicMessageMaxAgeSeconds - 1) * 1000)
|
||||||
let packet = makeMessagePacket(sender: remotePeerID, content: "old", timestamp: staleTimestamp)
|
let packet = makeMessagePacket(sender: remotePeerID, content: "old", timestamp: staleTimestamp)
|
||||||
|
|
||||||
handler.handle(packet, from: remotePeerID)
|
handler.handle(packet, from: remotePeerID)
|
||||||
|
|||||||
@@ -36,11 +36,13 @@ struct BLEPublicMessagePolicyTests {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
func staleBroadcastIsRejectedWithAge() {
|
func staleBroadcastIsRejectedWithAge() {
|
||||||
let now = Date(timeIntervalSince1970: 1_000)
|
// The acceptance window matches the gossip public-history window.
|
||||||
|
let staleAge = TransportConfig.syncPublicMessageMaxAgeSeconds + 1
|
||||||
|
let now = Date(timeIntervalSince1970: 1_000_000)
|
||||||
let sender = PeerID(str: "8877665544332211")
|
let sender = PeerID(str: "8877665544332211")
|
||||||
let packet = makePacket(
|
let packet = makePacket(
|
||||||
sender: sender,
|
sender: sender,
|
||||||
timestamp: UInt64((now.timeIntervalSince1970 - 901) * 1000),
|
timestamp: UInt64((now.timeIntervalSince1970 - staleAge) * 1000),
|
||||||
recipientID: nil
|
recipientID: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,7 +53,7 @@ struct BLEPublicMessagePolicyTests {
|
|||||||
now: now
|
now: now
|
||||||
)
|
)
|
||||||
|
|
||||||
#expect(decision == .reject(.staleBroadcast(ageSeconds: 901)))
|
#expect(decision == .reject(.staleBroadcast(ageSeconds: staleAge)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -112,6 +112,36 @@ struct BLERouteForwardingPolicyTests {
|
|||||||
#expect(plan.nextHop == nil)
|
#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(
|
private func forwardingPlan(
|
||||||
_ packet: BitchatPacket,
|
_ packet: BitchatPacket,
|
||||||
local: PeerID,
|
local: PeerID,
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
//
|
||||||
|
// MessageOutboxStoreTests.swift
|
||||||
|
// bitchatTests
|
||||||
|
//
|
||||||
|
// Tests for the encrypted-at-rest outbox persistence.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Testing
|
||||||
|
import Foundation
|
||||||
|
import BitFoundation
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
struct MessageOutboxStoreTests {
|
||||||
|
|
||||||
|
private func makeTempURL() -> URL {
|
||||||
|
FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("outbox-\(UUID().uuidString).sealed")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeMessage(_ id: String, content: String = "hello") -> MessageOutboxStore.QueuedMessage {
|
||||||
|
MessageOutboxStore.QueuedMessage(
|
||||||
|
content: content,
|
||||||
|
nickname: "peer",
|
||||||
|
messageID: id,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_750_000_000),
|
||||||
|
sendAttempts: 2,
|
||||||
|
depositedCourierKeys: [Data(repeating: 0xC1, count: 32)]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func roundTripAcrossInstances() {
|
||||||
|
let fileURL = makeTempURL()
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let peerID = PeerID(str: "0000000000000001")
|
||||||
|
|
||||||
|
let store = MessageOutboxStore(keychain: keychain, fileURL: fileURL)
|
||||||
|
store.save([peerID: [makeMessage("m1")]])
|
||||||
|
|
||||||
|
// Same keychain (encryption key) reads it back, fields intact.
|
||||||
|
let reloaded = MessageOutboxStore(keychain: keychain, fileURL: fileURL).load()
|
||||||
|
#expect(reloaded[peerID]?.count == 1)
|
||||||
|
#expect(reloaded[peerID]?.first?.messageID == "m1")
|
||||||
|
#expect(reloaded[peerID]?.first?.sendAttempts == 2)
|
||||||
|
#expect(reloaded[peerID]?.first?.depositedCourierKeys.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func contentIsNotPlaintextOnDisk() throws {
|
||||||
|
let fileURL = makeTempURL()
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let store = MessageOutboxStore(keychain: MockKeychain(), fileURL: fileURL)
|
||||||
|
store.save([PeerID(str: "0000000000000001"): [makeMessage("m1", content: "very secret message")]])
|
||||||
|
|
||||||
|
let raw = try Data(contentsOf: fileURL)
|
||||||
|
#expect(!raw.isEmpty)
|
||||||
|
// Sealed bytes must not contain the message plaintext.
|
||||||
|
#expect(raw.range(of: Data("very secret message".utf8)) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func loadWithoutKeyReturnsEmpty() {
|
||||||
|
let fileURL = makeTempURL()
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let store = MessageOutboxStore(keychain: MockKeychain(), fileURL: fileURL)
|
||||||
|
store.save([PeerID(str: "0000000000000001"): [makeMessage("m1")]])
|
||||||
|
|
||||||
|
// A different keychain (fresh device / wiped key) cannot read the file.
|
||||||
|
let other = MessageOutboxStore(keychain: MockKeychain(), fileURL: fileURL)
|
||||||
|
#expect(other.load().isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func wipeRemovesFileAndKey() {
|
||||||
|
let fileURL = makeTempURL()
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let store = MessageOutboxStore(keychain: keychain, fileURL: fileURL)
|
||||||
|
store.save([PeerID(str: "0000000000000001"): [makeMessage("m1")]])
|
||||||
|
#expect(FileManager.default.fileExists(atPath: fileURL.path))
|
||||||
|
|
||||||
|
store.wipe()
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
|
||||||
|
#expect(store.load().isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func savingEmptyOutboxRemovesFile() {
|
||||||
|
let fileURL = makeTempURL()
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let store = MessageOutboxStore(keychain: keychain, fileURL: fileURL)
|
||||||
|
let peerID = PeerID(str: "0000000000000001")
|
||||||
|
store.save([peerID: [makeMessage("m1")]])
|
||||||
|
store.save([peerID: []])
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -226,6 +226,197 @@ struct MessageRouterTests {
|
|||||||
|
|
||||||
#expect(transport.sentFavoriteNotifications.count == 1)
|
#expect(transport.sentFavoriteNotifications.count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Courier deposits
|
||||||
|
|
||||||
|
private static func snapshot(_ peerID: PeerID, key: Data, verified: Bool) -> TransportPeerSnapshot {
|
||||||
|
TransportPeerSnapshot(
|
||||||
|
peerID: peerID,
|
||||||
|
nickname: "peer",
|
||||||
|
isConnected: true,
|
||||||
|
noisePublicKey: key,
|
||||||
|
lastSeen: Date(),
|
||||||
|
isVerified: verified
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Directory that resolves one offline recipient and treats a fixed key
|
||||||
|
/// set as mutual favorites.
|
||||||
|
private static func directory(recipient: PeerID, recipientKey: Data, favoriteKeys: Set<Data> = []) -> CourierDirectory {
|
||||||
|
CourierDirectory(
|
||||||
|
noiseKey: { peerID in peerID == recipient ? recipientKey : nil },
|
||||||
|
isTrustedCourier: { favoriteKeys.contains($0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func sendPrivate_depositsWithVerifiedStrangerWhenNoFavoriteAround() async {
|
||||||
|
let recipient = PeerID(str: "00000000000000aa")
|
||||||
|
let recipientKey = Data(repeating: 0xBB, count: 32)
|
||||||
|
let courier = PeerID(str: "00000000000000cc")
|
||||||
|
let courierKey = Data(repeating: 0xCC, count: 32)
|
||||||
|
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.connectedPeers.insert(courier)
|
||||||
|
transport.updatePeerSnapshots([Self.snapshot(courier, key: courierKey, verified: true)])
|
||||||
|
|
||||||
|
let router = MessageRouter(
|
||||||
|
transports: [transport],
|
||||||
|
courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey)
|
||||||
|
)
|
||||||
|
router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cv1")
|
||||||
|
|
||||||
|
#expect(transport.sentCourierMessages.count == 1)
|
||||||
|
#expect(transport.sentCourierMessages.first?.couriers == [courier])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func sendPrivate_neverDepositsWithUnverifiedStranger() async {
|
||||||
|
let recipient = PeerID(str: "00000000000000aa")
|
||||||
|
let courier = PeerID(str: "00000000000000cc")
|
||||||
|
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.connectedPeers.insert(courier)
|
||||||
|
transport.updatePeerSnapshots([Self.snapshot(courier, key: Data(repeating: 0xCC, count: 32), verified: false)])
|
||||||
|
|
||||||
|
let router = MessageRouter(
|
||||||
|
transports: [transport],
|
||||||
|
courierDirectory: Self.directory(recipient: recipient, recipientKey: Data(repeating: 0xBB, count: 32))
|
||||||
|
)
|
||||||
|
router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cv2")
|
||||||
|
|
||||||
|
#expect(transport.sentCourierMessages.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func sendPrivate_prefersFavoriteCouriersOverVerifiedOnes() async {
|
||||||
|
let recipient = PeerID(str: "00000000000000aa")
|
||||||
|
let recipientKey = Data(repeating: 0xBB, count: 32)
|
||||||
|
let favorite = PeerID(str: "00000000000000f0")
|
||||||
|
let favoriteKey = Data(repeating: 0xF0, count: 32)
|
||||||
|
var snapshots = [Self.snapshot(favorite, key: favoriteKey, verified: false)]
|
||||||
|
let transport = MockTransport()
|
||||||
|
transport.connectedPeers.insert(favorite)
|
||||||
|
// Three verified strangers compete for the three courier slots.
|
||||||
|
for byte: UInt8 in [0xC1, 0xC2, 0xC3] {
|
||||||
|
let peer = PeerID(str: String(format: "00000000000000%02x", byte))
|
||||||
|
transport.connectedPeers.insert(peer)
|
||||||
|
snapshots.append(Self.snapshot(peer, key: Data(repeating: byte, count: 32), verified: true))
|
||||||
|
}
|
||||||
|
transport.updatePeerSnapshots(snapshots)
|
||||||
|
|
||||||
|
let router = MessageRouter(
|
||||||
|
transports: [transport],
|
||||||
|
courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey, favoriteKeys: [favoriteKey])
|
||||||
|
)
|
||||||
|
router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cv3")
|
||||||
|
|
||||||
|
let couriers = transport.sentCourierMessages.first?.couriers ?? []
|
||||||
|
#expect(couriers.count == 3)
|
||||||
|
#expect(couriers.contains(favorite))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func courierBecameAvailable_retriesDepositOnceWithoutDoubleBurn() async {
|
||||||
|
let recipient = PeerID(str: "00000000000000aa")
|
||||||
|
let recipientKey = Data(repeating: 0xBB, count: 32)
|
||||||
|
let courier = PeerID(str: "00000000000000cc")
|
||||||
|
let courierKey = Data(repeating: 0xCC, count: 32)
|
||||||
|
|
||||||
|
let transport = MockTransport()
|
||||||
|
let router = MessageRouter(
|
||||||
|
transports: [transport],
|
||||||
|
courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey)
|
||||||
|
)
|
||||||
|
// Nobody around at send time: the message just queues.
|
||||||
|
router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cr1")
|
||||||
|
#expect(transport.sentCourierMessages.isEmpty)
|
||||||
|
|
||||||
|
// A verified courier appears later: the deposit retries.
|
||||||
|
transport.connectedPeers.insert(courier)
|
||||||
|
transport.updatePeerSnapshots([Self.snapshot(courier, key: courierKey, verified: true)])
|
||||||
|
router.courierBecameAvailable(courier)
|
||||||
|
#expect(transport.sentCourierMessages.count == 1)
|
||||||
|
#expect(transport.sentCourierMessages.first?.couriers == [courier])
|
||||||
|
|
||||||
|
// The same courier reconnecting does not receive the same mail twice.
|
||||||
|
router.courierBecameAvailable(courier)
|
||||||
|
#expect(transport.sentCourierMessages.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func courierBecameAvailable_ignoresTheRecipientThemselves() async {
|
||||||
|
let recipient = PeerID(str: "00000000000000aa")
|
||||||
|
let recipientKey = Data(repeating: 0xBB, count: 32)
|
||||||
|
|
||||||
|
let transport = MockTransport()
|
||||||
|
let router = MessageRouter(
|
||||||
|
transports: [transport],
|
||||||
|
courierDirectory: Self.directory(recipient: recipient, recipientKey: recipientKey)
|
||||||
|
)
|
||||||
|
router.sendPrivate("Hello", to: recipient, recipientNickname: "Peer", messageID: "cr2")
|
||||||
|
|
||||||
|
// The recipient connecting is a flush, not a courier opportunity.
|
||||||
|
transport.connectedPeers.insert(recipient)
|
||||||
|
transport.updatePeerSnapshots([Self.snapshot(recipient, key: recipientKey, verified: true)])
|
||||||
|
router.courierBecameAvailable(recipient)
|
||||||
|
#expect(transport.sentCourierMessages.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Outbox persistence
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func queuedMessagesSurviveRouterRestart() async {
|
||||||
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("router-outbox-\(UUID().uuidString).sealed")
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let peerID = PeerID(str: "00000000000000dd")
|
||||||
|
|
||||||
|
let transport = MockTransport()
|
||||||
|
let router = MessageRouter(
|
||||||
|
transports: [transport],
|
||||||
|
outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL)
|
||||||
|
)
|
||||||
|
router.sendPrivate("Survive", to: peerID, recipientNickname: "Peer", messageID: "p1")
|
||||||
|
#expect(transport.sentPrivateMessages.isEmpty)
|
||||||
|
|
||||||
|
// "App restart": a fresh router over the same store, peer now around.
|
||||||
|
let transport2 = MockTransport()
|
||||||
|
transport2.reachablePeers.insert(peerID)
|
||||||
|
let router2 = MessageRouter(
|
||||||
|
transports: [transport2],
|
||||||
|
outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL)
|
||||||
|
)
|
||||||
|
router2.flushOutbox(for: peerID)
|
||||||
|
#expect(transport2.sentPrivateMessages.map(\.messageID) == ["p1"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deliveredMessagesDoNotResurrectAfterRestart() async {
|
||||||
|
let fileURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("router-outbox-\(UUID().uuidString).sealed")
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
let peerID = PeerID(str: "00000000000000de")
|
||||||
|
|
||||||
|
let transport = MockTransport()
|
||||||
|
let router = MessageRouter(
|
||||||
|
transports: [transport],
|
||||||
|
outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL)
|
||||||
|
)
|
||||||
|
router.sendPrivate("Once", to: peerID, recipientNickname: "Peer", messageID: "p2")
|
||||||
|
router.markDelivered("p2")
|
||||||
|
|
||||||
|
let transport2 = MockTransport()
|
||||||
|
transport2.reachablePeers.insert(peerID)
|
||||||
|
let router2 = MessageRouter(
|
||||||
|
transports: [transport2],
|
||||||
|
outboxStore: MessageOutboxStore(keychain: keychain, fileURL: fileURL)
|
||||||
|
)
|
||||||
|
router2.flushOutbox(for: peerID)
|
||||||
|
#expect(transport2.sentPrivateMessages.isEmpty)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mutable wall clock injected into `MessageRouter` so TTL expiry is testable
|
/// Mutable wall clock injected into `MessageRouter` so TTL expiry is testable
|
||||||
|
|||||||
@@ -134,6 +134,25 @@ struct RelayControllerTests {
|
|||||||
#expect(decision.newTTL == TransportConfig.bleFragmentRelayTtlCapDense - 1)
|
#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
|
@Test
|
||||||
func denseGraph_capsTTL() async {
|
func denseGraph_capsTTL() async {
|
||||||
let decision = RelayController.decide(
|
let decision = RelayController.decide(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,23 +24,37 @@ public struct CourierEnvelope: Equatable {
|
|||||||
public let expiry: UInt64
|
public let expiry: UInt64
|
||||||
/// Opaque one-way Noise X ciphertext (sender identity rides inside).
|
/// Opaque one-way Noise X ciphertext (sender identity rides inside).
|
||||||
public let ciphertext: Data
|
public let ciphertext: Data
|
||||||
|
/// Spray-and-wait copy budget: how many redundant copies of this envelope
|
||||||
|
/// the holder may still hand to other couriers (binary split on each
|
||||||
|
/// spray). 1 means carry-only — deliver to the recipient, never re-spray.
|
||||||
|
public let copies: UInt8
|
||||||
|
|
||||||
public static let tagLength = 16
|
public static let tagLength = 16
|
||||||
/// Couriered messages are text-sized; media transfers are out of scope.
|
/// Couriered messages are text-sized; media transfers are out of scope.
|
||||||
public static let maxCiphertextBytes = 16 * 1024
|
public static let maxCiphertextBytes = 16 * 1024
|
||||||
/// Matches the outbox retention policy in MessageRouter.
|
/// Matches the outbox retention policy in MessageRouter.
|
||||||
public static let maxLifetimeSeconds: TimeInterval = 24 * 60 * 60
|
public static let maxLifetimeSeconds: TimeInterval = 24 * 60 * 60
|
||||||
|
/// Cap on the copy budget a depositor can claim, so a malicious envelope
|
||||||
|
/// cannot turn the courier network into an amplifier.
|
||||||
|
public static let maxCopies: UInt8 = 8
|
||||||
|
|
||||||
private enum TLVType: UInt8 {
|
private enum TLVType: UInt8 {
|
||||||
case recipientTag = 0x01
|
case recipientTag = 0x01
|
||||||
case expiry = 0x02
|
case expiry = 0x02
|
||||||
case ciphertext = 0x03
|
case ciphertext = 0x03
|
||||||
|
case copies = 0x04
|
||||||
}
|
}
|
||||||
|
|
||||||
public init(recipientTag: Data, expiry: UInt64, ciphertext: Data) {
|
public init(recipientTag: Data, expiry: UInt64, ciphertext: Data, copies: UInt8 = 1) {
|
||||||
self.recipientTag = recipientTag
|
self.recipientTag = recipientTag
|
||||||
self.expiry = expiry
|
self.expiry = expiry
|
||||||
self.ciphertext = ciphertext
|
self.ciphertext = ciphertext
|
||||||
|
self.copies = min(max(copies, 1), Self.maxCopies)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The same envelope with a different remaining copy budget.
|
||||||
|
public func withCopies(_ copies: UInt8) -> CourierEnvelope {
|
||||||
|
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies)
|
||||||
}
|
}
|
||||||
|
|
||||||
public var isExpired: Bool {
|
public var isExpired: Bool {
|
||||||
@@ -75,6 +89,14 @@ public struct CourierEnvelope: Equatable {
|
|||||||
appendBE(UInt16(ciphertext.count), into: &encoded)
|
appendBE(UInt16(ciphertext.count), into: &encoded)
|
||||||
encoded.append(ciphertext)
|
encoded.append(ciphertext)
|
||||||
|
|
||||||
|
// Omitted when 1 so carry-only envelopes stay byte-identical to the
|
||||||
|
// pre-spray wire format (old clients skip the TLV as unknown anyway).
|
||||||
|
if copies > 1 {
|
||||||
|
encoded.append(TLVType.copies.rawValue)
|
||||||
|
appendBE(UInt16(1), into: &encoded)
|
||||||
|
encoded.append(copies)
|
||||||
|
}
|
||||||
|
|
||||||
return encoded
|
return encoded
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +107,7 @@ public struct CourierEnvelope: Equatable {
|
|||||||
var recipientTag: Data?
|
var recipientTag: Data?
|
||||||
var expiry: UInt64?
|
var expiry: UInt64?
|
||||||
var ciphertext: Data?
|
var ciphertext: Data?
|
||||||
|
var copies: UInt8 = 1
|
||||||
|
|
||||||
while cursor < end {
|
while cursor < end {
|
||||||
let typeRaw = data[cursor]
|
let typeRaw = data[cursor]
|
||||||
@@ -107,6 +130,9 @@ public struct CourierEnvelope: Equatable {
|
|||||||
case .ciphertext:
|
case .ciphertext:
|
||||||
guard length > 0, length <= maxCiphertextBytes else { return nil }
|
guard length > 0, length <= maxCiphertextBytes else { return nil }
|
||||||
ciphertext = Data(value)
|
ciphertext = Data(value)
|
||||||
|
case .copies:
|
||||||
|
guard length == 1 else { return nil }
|
||||||
|
copies = value.first ?? 1
|
||||||
case nil:
|
case nil:
|
||||||
// Unknown TLV: skip for forward compatibility.
|
// Unknown TLV: skip for forward compatibility.
|
||||||
continue
|
continue
|
||||||
@@ -114,7 +140,7 @@ public struct CourierEnvelope: Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard let recipientTag, let expiry, let ciphertext else { return nil }
|
guard let recipientTag, let expiry, let ciphertext else { return nil }
|
||||||
return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext)
|
return CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Recipient Tags
|
// MARK: - Recipient Tags
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
public enum FileTransferLimits {
|
public enum FileTransferLimits {
|
||||||
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
/// Absolute ceiling enforced for any file payload (voice, image, other).
|
||||||
public static let maxPayloadBytes: Int = 1 * 1024 * 1024 // 1 MiB
|
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.
|
/// Voice notes stay small for low-latency relays.
|
||||||
public static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
|
public static let maxVoiceNoteBytes: Int = 512 * 1024 // 512 KiB
|
||||||
/// Compressed images after downscaling should comfortably fit under this budget.
|
/// Compressed images after downscaling should comfortably fit under this budget.
|
||||||
@@ -17,7 +21,7 @@ public enum FileTransferLimits {
|
|||||||
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
|
return maxPayloadBytes + tlvEnvelopeOverhead + binaryEnvelopeOverhead
|
||||||
}()
|
}()
|
||||||
|
|
||||||
public static func isValidPayload(_ size: Int) -> Bool {
|
public static func isValidPayload(_ size: Int, limit: Int = maxPayloadBytes) -> Bool {
|
||||||
size <= maxPayloadBytes
|
size <= limit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,38 @@ struct CourierEnvelopeTests {
|
|||||||
CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext)
|
CourierEnvelope(recipientTag: tag, expiry: expiry, ciphertext: ciphertext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Spray copies
|
||||||
|
|
||||||
|
@Test func copiesRoundTrip() throws {
|
||||||
|
let envelope = makeEnvelope().withCopies(4)
|
||||||
|
let encoded = try #require(envelope.encode())
|
||||||
|
let decoded = try #require(CourierEnvelope.decode(encoded))
|
||||||
|
#expect(decoded.copies == 4)
|
||||||
|
#expect(decoded == envelope)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func carryOnlyEnvelopeEncodesIdenticallyToLegacyFormat() throws {
|
||||||
|
// copies == 1 must be byte-identical to the pre-spray wire format so
|
||||||
|
// old and new clients dedup the same envelope the same way.
|
||||||
|
let envelope = makeEnvelope()
|
||||||
|
#expect(envelope.copies == 1)
|
||||||
|
let encoded = try #require(envelope.encode())
|
||||||
|
let withExplicitOne = try #require(envelope.withCopies(1).encode())
|
||||||
|
#expect(encoded == withExplicitOne)
|
||||||
|
#expect(!encoded.contains(0x04) || CourierEnvelope.decode(encoded)?.copies == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func decodeWithoutCopiesTLVDefaultsToCarryOnly() throws {
|
||||||
|
let encoded = try #require(makeEnvelope().encode())
|
||||||
|
let decoded = try #require(CourierEnvelope.decode(encoded))
|
||||||
|
#expect(decoded.copies == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func copiesAreClampedToPolicyBounds() {
|
||||||
|
#expect(makeEnvelope().withCopies(0).copies == 1)
|
||||||
|
#expect(makeEnvelope().withCopies(200).copies == CourierEnvelope.maxCopies)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Codec
|
// MARK: - Codec
|
||||||
|
|
||||||
@Test func roundTrip() throws {
|
@Test func roundTrip() throws {
|
||||||
|
|||||||
@@ -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