Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02828731b9 | ||
|
|
f5b2150f11 | ||
|
|
b28fb26504 | ||
|
|
ba0861a446 | ||
|
|
424c47be82 | ||
|
|
20c1c0f687 | ||
|
|
ebdcdf83dd | ||
|
|
2c04f65c83 | ||
|
|
293380e671 | ||
|
|
5aaa209020 |
@@ -1,30 +0,0 @@
|
|||||||
name: Dead Code
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
periphery:
|
|
||||||
name: Periphery scan
|
|
||||||
runs-on: macos-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
# Advisory, like SwiftLint (#1361): findings annotate the PR but don't
|
|
||||||
# block merges. Drop continue-on-error once the baseline proves stable.
|
|
||||||
continue-on-error: true
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Install Periphery
|
|
||||||
# homebrew-core formula; the peripheryapp tap lags years behind.
|
|
||||||
run: brew install periphery
|
|
||||||
|
|
||||||
- name: Scan for dead code
|
|
||||||
# Config comes from .periphery.yml; known findings (mostly iOS-only
|
|
||||||
# code invisible to a macOS scan) are suppressed by the committed
|
|
||||||
# baseline. --strict fails the step when NEW dead code appears.
|
|
||||||
run: periphery scan --strict --disable-update-check
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat17PrekeyBundleStoreC12StoredBundleV8noiseKey10Foundation4DataVvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# Periphery dead-code scan configuration (https://github.com/peripheryapp/periphery)
|
|
||||||
#
|
|
||||||
# CI runs the macOS scheme only (an iOS scan needs a device destination and
|
|
||||||
# doubles the build time). macOS-only scans falsely flag iOS-only code —
|
|
||||||
# state restoration, screenshot handlers, background BLE sampling — so those
|
|
||||||
# findings live in .periphery.baseline.json rather than being "fixed".
|
|
||||||
# When auditing by hand, scan BOTH schemes and intersect:
|
|
||||||
# periphery scan --schemes "bitchat (iOS)" -- -destination 'generic/platform=iOS' ARCHS=arm64
|
|
||||||
project: bitchat.xcodeproj
|
|
||||||
schemes:
|
|
||||||
- bitchat (macOS)
|
|
||||||
retain_swift_ui_previews: true
|
|
||||||
relative_results: true
|
|
||||||
baseline: .periphery.baseline.json
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
MARKETING_VERSION = 1.7.0
|
MARKETING_VERSION = 1.5.3
|
||||||
CURRENT_PROJECT_VERSION = 1
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
|
|||||||
@@ -1,141 +1,309 @@
|
|||||||
# bitchat Protocol Whitepaper
|
# BitChat Protocol Whitepaper
|
||||||
|
|
||||||
**Version 2.0**
|
**Version 1.1**
|
||||||
|
|
||||||
**Date: July 6, 2026**
|
**Date: July 25, 2025**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Abstract
|
## Abstract
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Design Goals
|
## 1. Introduction
|
||||||
|
|
||||||
* **Confidentiality:** all private communication is end-to-end encrypted; intermediate nodes and couriers carry only opaque ciphertext.
|
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).
|
||||||
* **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.
|
|
||||||
|
|
||||||
## 2. Architecture Overview
|
The design goals of the BitChat Protocol are:
|
||||||
|
|
||||||
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
|
* **Confidentiality:** All communication must be unreadable to third parties.
|
||||||
|
* **Authentication:** Users must be able to verify the identity of their correspondents.
|
||||||
|
* **Integrity:** Messages cannot be tampered with in transit.
|
||||||
|
* **Forward Secrecy:** The compromise of long-term identity keys must not compromise past session keys.
|
||||||
|
* **Deniability:** It should be difficult to cryptographically prove that a specific user sent a particular message.
|
||||||
|
* **Resilience:** The protocol must function reliably in lossy, low-bandwidth environments.
|
||||||
|
|
||||||
* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts.
|
This paper specifies the technical details of the protocol designed to meet these goals.
|
||||||
* **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.
|
---
|
||||||
|
|
||||||
## 3. Identity
|
## 2. Protocol Stack
|
||||||
|
|
||||||
Each device holds two long-term key pairs in the Keychain:
|
The BitChat Protocol is a four-layer stack. This layered approach separates concerns, allowing for modularity and future extensibility.
|
||||||
|
|
||||||
* a **Curve25519 static key** for Noise key agreement — its SHA-256 fingerprint is the peer's stable identity, and
|
```mermaid
|
||||||
* an **Ed25519 signing key** for packet signatures.
|
graph TD
|
||||||
|
A[Application Layer] --> B[Session Layer];
|
||||||
|
B --> C[Encryption Layer];
|
||||||
|
C --> D[Transport Layer];
|
||||||
|
|
||||||
On the mesh, peers appear under short ephemeral IDs derived per session; favoriting pins the full Noise public key so identity survives across sessions. Mutual favorites also exchange Nostr public keys for the internet path. Optional QR verification binds a nickname to a fingerprint in person.
|
subgraph "BitChat Application"
|
||||||
|
A
|
||||||
|
end
|
||||||
|
|
||||||
## 4. BLE Mesh Layer
|
subgraph "Message Framing & State"
|
||||||
|
B
|
||||||
|
end
|
||||||
|
|
||||||
### 4.1 Packet Format
|
subgraph "Noise Protocol Framework"
|
||||||
|
C
|
||||||
|
end
|
||||||
|
|
||||||
A compact binary header (version, type, TTL, timestamp, flags) is followed by an 8-byte sender ID, an optional 8-byte recipient ID, the payload, and an optional Ed25519 signature. Version 2 packets may carry an explicit source route. Signatures exclude the TTL byte so relays can decrement it without invalidating them. Packets other than fragments are padded toward uniform sizes.
|
subgraph "BLE, Wi-Fi Direct, etc."
|
||||||
|
D
|
||||||
|
end
|
||||||
|
|
||||||
### 4.2 Flood Control
|
style A fill:#cde4ff
|
||||||
|
style B fill:#b5d8ff
|
||||||
|
style C fill:#9ac2ff
|
||||||
|
style D fill:#7eadff
|
||||||
|
```
|
||||||
|
|
||||||
Relaying is a deterministic controlled flood tuned by local connection degree:
|
* **Application Layer:** Defines the structure of user-facing messages (`BitchatMessage`), acknowledgments (`DeliveryAck`), and other application-level data.
|
||||||
|
* **Session Layer:** Manages the overall communication packet (`BitchatPacket`). This includes routing information (TTL), message typing, fragmentation, and serialization into a compact binary format.
|
||||||
|
* **Encryption Layer:** Establishes and manages secure channels using the Noise Protocol Framework. It is responsible for the cryptographic handshake, session management, and transport message encryption/decryption.
|
||||||
|
* **Transport Layer:** The underlying physical medium used for data transmission, such as Bluetooth Low Energy (BLE). This layer is abstracted away from the core protocol.
|
||||||
|
|
||||||
* **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.
|
|
||||||
|
|
||||||
### 4.3 Routing
|
## 3. Identity and Key Management
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
### 4.4 Fragmentation
|
1. **Noise Static Key Pair (`Curve25519`):** This is the long-term identity key used for the Noise Protocol handshake. The public part of this key is shared with peers to establish secure sessions.
|
||||||
|
2. **Signing Key Pair (`Ed25519`):** This key is used to sign announcements and other protocol messages where non-repudiation is required, such as binding a public key to a nickname.
|
||||||
|
|
||||||
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).
|
### 3.1. Fingerprint
|
||||||
|
|
||||||
### 4.5 Presence
|
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).
|
||||||
|
|
||||||
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.
|
`Fingerprint = SHA256(StaticPublicKey_Curve25519)`
|
||||||
|
|
||||||
## 5. Encryption
|
### 3.2. Identity Management
|
||||||
|
|
||||||
### 5.1 Live Sessions: Noise XX
|
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.
|
||||||
|
|
||||||
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.
|
---
|
||||||
|
|
||||||
### 5.2 Offline Seals: Noise X
|
## 4. The Social Trust Layer
|
||||||
|
|
||||||
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.
|
Beyond cryptographic identity, BitChat incorporates a social trust layer, allowing users to manage their relationships with peers. This functionality is handled by the `SecureIdentityStateManager`.
|
||||||
|
|
||||||
### 5.3 Nostr Path
|
### 4.1. Peer Verification
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## 6. Store and Forward
|
### 4.2. Favorites and Blocking
|
||||||
|
|
||||||
Four mechanisms cover the "recipient is not here right now" problem. All persisted state is wiped by panic mode.
|
To improve the user experience and provide control over interactions, the protocol supports:
|
||||||
|
* **Favorites:** Users can mark trusted or frequently contacted peers as "favorites". This is a local designation that can be used by the application to prioritize notifications or display peers more prominently.
|
||||||
|
* **Blocking:** Users can block peers. When a peer is blocked, the application will discard any incoming packets from that peer's fingerprint at the earliest possible stage, effectively silencing them without notifying the blocked peer.
|
||||||
|
|
||||||
### 6.1 Sender Outbox
|
---
|
||||||
|
|
||||||
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.
|
## 5. The Noise Protocol Layer
|
||||||
|
|
||||||
### 6.2 Couriers
|
BitChat implements the Noise Protocol Framework to provide strong, authenticated end-to-end encryption.
|
||||||
|
|
||||||
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:
|
### 5.1. Protocol Name
|
||||||
|
|
||||||
* **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.
|
The specific Noise protocol implemented is:
|
||||||
* **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.
|
|
||||||
|
|
||||||
### 6.3 Public History (Gossip Sync)
|
**`Noise_XX_25519_ChaChaPoly_SHA256`**
|
||||||
|
|
||||||
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.
|
* **`XX` Pattern:** This handshake pattern provides mutual authentication and forward secrecy. It does not require either party to know the other's static public key before the handshake begins. The keys are exchanged and authenticated during the three-part handshake. This is ideal for a decentralized P2P environment.
|
||||||
|
* **`25519`:** The Diffie-Hellman function used is Curve25519.
|
||||||
|
* **`ChaChaPoly`:** The AEAD (Authenticated Encryption with Associated Data) cipher is ChaCha20-Poly1305.
|
||||||
|
* **`SHA256`:** The hash function used for all cryptographic hashing operations is SHA-256.
|
||||||
|
|
||||||
### 6.4 Nostr Mailboxes
|
### 5.2. The `XX` Handshake
|
||||||
|
|
||||||
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.
|
The `XX` handshake consists of three messages exchanged between an Initiator and a Responder to establish a shared secret and derive transport encryption keys.
|
||||||
|
|
||||||
### 6.5 Delivery Metrics
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant I as Initiator
|
||||||
|
participant R as Responder
|
||||||
|
|
||||||
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.
|
Note over I, R: Pre-computation: h = SHA256(protocol_name)
|
||||||
|
|
||||||
## 7. Application Layer
|
I->>R: -> e
|
||||||
|
Note right of I: I generates ephemeral key `e_i`.<br/>h = SHA256(h + e_i.pub)
|
||||||
|
|
||||||
* **Public chat** — signed broadcast messages within the mesh, backed by the gossip-synced history above.
|
R->>I: <- e, ee, s, es
|
||||||
* **Private chat** — end-to-end encrypted messages with delivery and read receipts, over mesh, courier, or Nostr.
|
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))
|
||||||
* **Location channels** — geohash-scoped public rooms carried over Nostr relays for regional chat beyond radio range.
|
|
||||||
* **Favorites** — the mutual-trust relationship that unlocks Nostr delivery and the larger courier quota.
|
I->>R: -> s, se
|
||||||
* **Media** — files and images fragment over the mesh (1 MiB cap, explicit accept before anything touches disk); couriers carry text only.
|
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))
|
||||||
* **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
|
||||||
|
|
||||||
* **Relay nodes** cannot read private traffic; they forward padded, opaque ciphertext.
|
* **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.
|
||||||
* **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.
|
* **Denial of Service:** The `NoiseRateLimiter` is implemented to prevent resource exhaustion from rapid, repeated handshake attempts from a single peer.
|
||||||
* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
|
* **Key-Compromise Impersonation:** The `XX` pattern authenticates both parties, preventing an attacker from impersonating one party to the other.
|
||||||
* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
|
* **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.
|
||||||
* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
|
* **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.
|
||||||
* **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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*This document describes the protocol as implemented in the current release. The implementation is free and unencumbered software released into the public domain.*
|
## 9. Conclusion
|
||||||
|
|
||||||
|
The BitChat Protocol provides a robust and secure foundation for decentralized, peer-to-peer communication. By layering a flexible application protocol on top of the well-regarded Noise Protocol Framework, it achieves strong confidentiality, authentication, and forward secrecy. The use of a compact binary format and thoughtful security considerations like rate limiting and traffic analysis resistance make it suitable for use in challenging network environments.
|
||||||
|
|||||||
@@ -528,6 +528,7 @@
|
|||||||
"@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";
|
||||||
@@ -560,6 +561,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
MARKETING_VERSION = 1.5.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -618,6 +620,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
|
MARKETING_VERSION = 1.5.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -652,6 +655,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
|
MARKETING_VERSION = 1.5.3;
|
||||||
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;
|
||||||
@@ -712,6 +716,7 @@
|
|||||||
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)";
|
||||||
@@ -744,6 +749,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
|
MARKETING_VERSION = 1.5.3;
|
||||||
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;
|
||||||
@@ -810,6 +816,7 @@
|
|||||||
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;
|
||||||
@@ -839,6 +846,7 @@
|
|||||||
"@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";
|
||||||
|
|||||||
@@ -36,12 +36,34 @@ enum AppEvent: Sendable, Equatable {
|
|||||||
actor AppEventStream {
|
actor AppEventStream {
|
||||||
private var continuations: [UUID: AsyncStream<AppEvent>.Continuation] = [:]
|
private var continuations: [UUID: AsyncStream<AppEvent>.Continuation] = [:]
|
||||||
|
|
||||||
|
func stream() -> AsyncStream<AppEvent> {
|
||||||
|
let id = UUID()
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
continuations[id] = continuation
|
||||||
|
continuation.onTermination = { [id] _ in
|
||||||
|
Task {
|
||||||
|
await self.removeContinuation(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func emit(_ event: AppEvent) {
|
func emit(_ event: AppEvent) {
|
||||||
for continuation in continuations.values {
|
for continuation in continuations.values {
|
||||||
continuation.yield(event)
|
continuation.yield(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func finish() {
|
||||||
|
for continuation in continuations.values {
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
continuations.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeContinuation(_ id: UUID) {
|
||||||
|
continuations.removeValue(forKey: id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Identity key for a direct conversation. Equality and hashing use the
|
/// Identity key for a direct conversation. Equality and hashing use the
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ final class AppChromeModel: ObservableObject {
|
|||||||
@Published var showingFingerprintFor: PeerID?
|
@Published var showingFingerprintFor: PeerID?
|
||||||
@Published var isAppInfoPresented = false
|
@Published var isAppInfoPresented = false
|
||||||
@Published var isLocationChannelsSheetPresented = false
|
@Published var isLocationChannelsSheetPresented = false
|
||||||
@Published var isNoticesSheetPresented = false
|
|
||||||
/// When the sheet is opened for "notes left here" (empty mesh timeline),
|
|
||||||
/// it should land on the geo tab instead of the channel-derived default.
|
|
||||||
@Published var noticesSheetPrefersGeoTab = false
|
|
||||||
@Published var showBluetoothAlert = false
|
@Published var showBluetoothAlert = false
|
||||||
@Published var bluetoothAlertMessage = ""
|
@Published var bluetoothAlertMessage = ""
|
||||||
@Published var bluetoothState: CBManagerState = .unknown
|
@Published var bluetoothState: CBManagerState = .unknown
|
||||||
@@ -22,9 +18,6 @@ final class AppChromeModel: ObservableObject {
|
|||||||
private let chatViewModel: ChatViewModel
|
private let chatViewModel: ChatViewModel
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
/// Bulletin-board coordinator, created on first use of the board sheet.
|
|
||||||
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
|
||||||
|
|
||||||
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
|
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
|
||||||
self.chatViewModel = chatViewModel
|
self.chatViewModel = chatViewModel
|
||||||
self.nickname = chatViewModel.nickname
|
self.nickname = chatViewModel.nickname
|
||||||
@@ -66,33 +59,6 @@ final class AppChromeModel: ObservableObject {
|
|||||||
isAppInfoPresented = true
|
isAppInfoPresented = true
|
||||||
}
|
}
|
||||||
|
|
||||||
func presentNotices(geoTab: Bool = false) {
|
|
||||||
noticesSheetPrefersGeoTab = geoTab
|
|
||||||
isNoticesSheetPresented = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds the mesh topology map model from the transport's gossiped
|
|
||||||
/// graph plus the live nickname table. Unknown nodes (heard about via a
|
|
||||||
/// neighbor claim but never announced to us) fall back to a short ID.
|
|
||||||
func meshTopologyDisplayModel() -> MeshTopologyDisplayModel {
|
|
||||||
let mesh = chatViewModel.meshService
|
|
||||||
guard let snapshot = mesh.currentMeshTopology() else { return .empty }
|
|
||||||
let nicknames = mesh.getPeerNicknames()
|
|
||||||
|
|
||||||
let nodes = snapshot.nodes.map { peerID -> MeshTopologyDisplayModel.Node in
|
|
||||||
let isSelf = peerID == snapshot.localPeerID
|
|
||||||
let label: String
|
|
||||||
if isSelf {
|
|
||||||
label = chatViewModel.nickname
|
|
||||||
} else {
|
|
||||||
label = nicknames[peerID] ?? "\(peerID.id.prefix(8))…"
|
|
||||||
}
|
|
||||||
return MeshTopologyDisplayModel.Node(id: peerID.id, label: label, isSelf: isSelf)
|
|
||||||
}
|
|
||||||
let edges = snapshot.edges.map { ($0.a.id, $0.b.id) }
|
|
||||||
return MeshTopologyDisplayModel(nodes: nodes, edges: edges)
|
|
||||||
}
|
|
||||||
|
|
||||||
func triggerScreenshotPrivacyWarning() {
|
func triggerScreenshotPrivacyWarning() {
|
||||||
showScreenshotPrivacyWarning = true
|
showScreenshotPrivacyWarning = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ final class AppRuntime: ObservableObject {
|
|||||||
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models
|
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models
|
||||||
/// and `ChatViewModel` observe and mutate it through its intent API.
|
/// and `ChatViewModel` observe and mutate it through its intent API.
|
||||||
let conversations: ConversationStore
|
let conversations: ConversationStore
|
||||||
|
let peerIdentityStore: PeerIdentityStore
|
||||||
|
let locationPresenceStore: LocationPresenceStore
|
||||||
let publicChatModel: PublicChatModel
|
let publicChatModel: PublicChatModel
|
||||||
let privateInboxModel: PrivateInboxModel
|
let privateInboxModel: PrivateInboxModel
|
||||||
let privateConversationModel: PrivateConversationModel
|
let privateConversationModel: PrivateConversationModel
|
||||||
@@ -26,7 +28,6 @@ final class AppRuntime: ObservableObject {
|
|||||||
let locationChannelsModel: LocationChannelsModel
|
let locationChannelsModel: LocationChannelsModel
|
||||||
let peerListModel: PeerListModel
|
let peerListModel: PeerListModel
|
||||||
let appChromeModel: AppChromeModel
|
let appChromeModel: AppChromeModel
|
||||||
let boardAlertsModel: BoardAlertsModel
|
|
||||||
|
|
||||||
private let idBridge: NostrIdentityBridge
|
private let idBridge: NostrIdentityBridge
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
@@ -40,7 +41,7 @@ final class AppRuntime: ObservableObject {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
init(
|
init(
|
||||||
keychain: KeychainManagerProtocol = KeychainManager.makeDefault(),
|
keychain: KeychainManagerProtocol = KeychainManager(),
|
||||||
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
||||||
) {
|
) {
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
@@ -49,6 +50,8 @@ final class AppRuntime: ObservableObject {
|
|||||||
let locationPresenceStore = LocationPresenceStore()
|
let locationPresenceStore = LocationPresenceStore()
|
||||||
let locationManager = LocationChannelManager.shared
|
let locationManager = LocationChannelManager.shared
|
||||||
self.conversations = conversations
|
self.conversations = conversations
|
||||||
|
self.peerIdentityStore = peerIdentityStore
|
||||||
|
self.locationPresenceStore = locationPresenceStore
|
||||||
self.chatViewModel = ChatViewModel(
|
self.chatViewModel = ChatViewModel(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
@@ -88,24 +91,6 @@ final class AppRuntime: ObservableObject {
|
|||||||
chatViewModel: self.chatViewModel,
|
chatViewModel: self.chatViewModel,
|
||||||
privateInboxModel: self.privateInboxModel
|
privateInboxModel: self.privateInboxModel
|
||||||
)
|
)
|
||||||
let chatViewModel = self.chatViewModel
|
|
||||||
self.boardAlertsModel = BoardAlertsModel(
|
|
||||||
arrivals: BoardStore.shared.postArrivals.eraseToAnyPublisher(),
|
|
||||||
wipes: BoardStore.shared.didWipe.eraseToAnyPublisher(),
|
|
||||||
dependencies: BoardAlertsModel.Dependencies(
|
|
||||||
isOwnPost: { post in
|
|
||||||
let key = chatViewModel.meshService.noiseSigningPublicKeyData()
|
|
||||||
return !key.isEmpty && key == post.authorSigningKey
|
|
||||||
},
|
|
||||||
emitSystemLine: { content, geohash in
|
|
||||||
if geohash.isEmpty {
|
|
||||||
chatViewModel.addMeshOnlySystemMessage(content)
|
|
||||||
} else {
|
|
||||||
chatViewModel.addGeohashSystemMessage(content, geohash: geohash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||||
bindRuntimeObservers()
|
bindRuntimeObservers()
|
||||||
@@ -217,16 +202,7 @@ final class AppRuntime: ObservableObject {
|
|||||||
chatViewModel.applicationWillTerminate()
|
chatViewModel.applicationWillTerminate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleNotificationResponse(
|
func handleNotificationResponse(identifier: String, userInfo: [AnyHashable: Any]) {
|
||||||
identifier: String,
|
|
||||||
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
|
|
||||||
userInfo: [AnyHashable: Any]
|
|
||||||
) {
|
|
||||||
if actionIdentifier == NotificationService.waveActionID {
|
|
||||||
chatViewModel.sendMeshWave()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
|
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
|
||||||
record(.notificationOpened(peerID: peerID))
|
record(.notificationOpened(peerID: peerID))
|
||||||
chatViewModel.startPrivateChat(with: peerID)
|
chatViewModel.startPrivateChat(with: peerID)
|
||||||
|
|||||||
@@ -796,6 +796,16 @@ extension ConversationStore {
|
|||||||
return messageIDs
|
return messageIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes every direct conversation (panic clear).
|
||||||
|
func removeAllDirectConversations() {
|
||||||
|
let directIDs = conversationIDs.filter { id in
|
||||||
|
if case .direct = id { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for id in directIDs {
|
||||||
|
removeConversation(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Diagnostics support
|
// MARK: - Diagnostics support
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
@Published private(set) var currentNickname: String
|
@Published private(set) var currentNickname: String
|
||||||
@Published private(set) var isBatchingPublic = false
|
@Published private(set) var isBatchingPublic = false
|
||||||
@Published private(set) var canSendMediaInCurrentContext = true
|
@Published private(set) var canSendMediaInCurrentContext = true
|
||||||
/// Who is talking live in the public mesh channel right now (floor
|
|
||||||
/// courtesy: the composer mic tints "busy" while someone holds the floor).
|
|
||||||
@Published private(set) var activeLiveVoiceTalker: String?
|
|
||||||
|
|
||||||
private let chatViewModel: ChatViewModel
|
private let chatViewModel: ChatViewModel
|
||||||
private let privateConversationModel: PrivateConversationModel
|
private let privateConversationModel: PrivateConversationModel
|
||||||
@@ -153,17 +150,6 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
chatViewModel.sendVoiceNote(at: url)
|
chatViewModel.sendVoiceNote(at: url)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Capture backend for the mic gesture: live PTT when the current DM
|
|
||||||
/// peer can hear it now, classic voice note otherwise.
|
|
||||||
func makeVoiceCaptureSession() -> VoiceCaptureSession {
|
|
||||||
chatViewModel.makeVoiceCaptureSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether this message is a live voice burst still streaming in.
|
|
||||||
func isLiveVoiceMessage(_ message: BitchatMessage) -> Bool {
|
|
||||||
chatViewModel.liveVoiceCoordinator.isLiveVoiceMessage(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancelMediaSend(messageID: String) {
|
func cancelMediaSend(messageID: String) {
|
||||||
chatViewModel.cancelMediaSend(messageID: messageID)
|
chatViewModel.cancelMediaSend(messageID: messageID)
|
||||||
}
|
}
|
||||||
@@ -189,10 +175,6 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.assign(to: &$isBatchingPublic)
|
.assign(to: &$isBatchingPublic)
|
||||||
|
|
||||||
chatViewModel.$activePublicVoiceTalker
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.assign(to: &$activeLiveVoiceTalker)
|
|
||||||
|
|
||||||
conversations.$activeChannel
|
conversations.$activeChannel
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] channel in
|
.sink { [weak self] channel in
|
||||||
@@ -211,9 +193,7 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
|
|
||||||
private func refreshComputedState() {
|
private func refreshComputedState() {
|
||||||
if let selectedPeerID = privateConversationModel.selectedPeerID {
|
if let selectedPeerID = privateConversationModel.selectedPeerID {
|
||||||
// Media transfer is not wired for groups in v1; keep it off so the
|
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat)
|
||||||
// composer can't strand a media placeholder that never sends.
|
|
||||||
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat || selectedPeerID.isGroup)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import BitFoundation
|
||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@@ -11,25 +12,20 @@ final class LocationChannelsModel: ObservableObject {
|
|||||||
@Published private(set) var bookmarkNames: [String: String]
|
@Published private(set) var bookmarkNames: [String: String]
|
||||||
@Published private(set) var locationNames: [GeohashChannelLevel: String]
|
@Published private(set) var locationNames: [GeohashChannelLevel: String]
|
||||||
@Published private(set) var userTorEnabled: Bool
|
@Published private(set) var userTorEnabled: Bool
|
||||||
@Published private(set) var gatewayEnabled: Bool
|
|
||||||
|
|
||||||
private let manager: LocationChannelManager
|
private let manager: LocationChannelManager
|
||||||
private let network: NetworkActivationService
|
private let network: NetworkActivationService
|
||||||
private let gateway: GatewayService
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
init(
|
init(
|
||||||
manager: LocationChannelManager? = nil,
|
manager: LocationChannelManager? = nil,
|
||||||
network: NetworkActivationService? = nil,
|
network: NetworkActivationService? = nil
|
||||||
gateway: GatewayService? = nil
|
|
||||||
) {
|
) {
|
||||||
let manager = manager ?? .shared
|
let manager = manager ?? .shared
|
||||||
let network = network ?? .shared
|
let network = network ?? .shared
|
||||||
let gateway = gateway ?? .shared
|
|
||||||
|
|
||||||
self.manager = manager
|
self.manager = manager
|
||||||
self.network = network
|
self.network = network
|
||||||
self.gateway = gateway
|
|
||||||
self.gatewayEnabled = gateway.isEnabled
|
|
||||||
self.permissionState = manager.permissionState
|
self.permissionState = manager.permissionState
|
||||||
self.availableChannels = manager.availableChannels
|
self.availableChannels = manager.availableChannels
|
||||||
self.selectedChannel = manager.selectedChannel
|
self.selectedChannel = manager.selectedChannel
|
||||||
@@ -164,10 +160,6 @@ final class LocationChannelsModel: ObservableObject {
|
|||||||
network.$userTorEnabled
|
network.$userTorEnabled
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.assign(to: &$userTorEnabled)
|
.assign(to: &$userTorEnabled)
|
||||||
|
|
||||||
gateway.$isEnabled
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.assign(to: &$gatewayEnabled)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func level(forLength length: Int) -> GeohashChannelLevel {
|
private func level(forLength length: Int) -> GeohashChannelLevel {
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
//
|
|
||||||
// NearbyNotesCounter.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Counts unexpired location notes left at the user's current building-level
|
|
||||||
// geohash so the empty mesh timeline can say "📍 3 notes left here". Only
|
|
||||||
// subscribes while a view holds it active, and only when location notes are
|
|
||||||
// enabled and location permission is already granted (it never prompts).
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
final class NearbyNotesCounter: ObservableObject {
|
|
||||||
static let shared = NearbyNotesCounter()
|
|
||||||
|
|
||||||
@Published private(set) var noteCount = 0
|
|
||||||
|
|
||||||
private var manager: LocationNotesManager?
|
|
||||||
private var managerCancellable: AnyCancellable?
|
|
||||||
private var channelsCancellable: AnyCancellable?
|
|
||||||
private var settingCancellable: AnyCancellable?
|
|
||||||
private var activeHolders = 0
|
|
||||||
private let locationManager: LocationChannelManager
|
|
||||||
|
|
||||||
init(locationManager: LocationChannelManager = .shared) {
|
|
||||||
self.locationManager = locationManager
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Begins (or keeps) the notes subscription for the current building
|
|
||||||
/// geohash. Balanced by `deactivate()`; ref-counted so multiple views can
|
|
||||||
/// hold it.
|
|
||||||
func activate() {
|
|
||||||
activeHolders += 1
|
|
||||||
guard activeHolders == 1 else { return }
|
|
||||||
channelsCancellable = locationManager.$availableChannels
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] _ in self?.retarget() }
|
|
||||||
// The app-info kill switch must take effect immediately, not on the
|
|
||||||
// next location change or remount.
|
|
||||||
settingCancellable = NotificationCenter.default
|
|
||||||
.publisher(for: LocationNotesSettings.didChangeNotification)
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] _ in self?.retarget() }
|
|
||||||
retarget()
|
|
||||||
}
|
|
||||||
|
|
||||||
func deactivate() {
|
|
||||||
activeHolders = max(0, activeHolders - 1)
|
|
||||||
guard activeHolders == 0 else { return }
|
|
||||||
channelsCancellable = nil
|
|
||||||
settingCancellable = nil
|
|
||||||
managerCancellable = nil
|
|
||||||
manager?.cancel()
|
|
||||||
manager = nil
|
|
||||||
noteCount = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private func retarget() {
|
|
||||||
guard activeHolders > 0,
|
|
||||||
LocationNotesSettings.enabled,
|
|
||||||
locationManager.permissionState == .authorized,
|
|
||||||
let geohash = locationManager.availableChannels
|
|
||||||
.first(where: { $0.level == .building })?.geohash
|
|
||||||
else {
|
|
||||||
managerCancellable = nil
|
|
||||||
manager?.cancel()
|
|
||||||
manager = nil
|
|
||||||
noteCount = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if let manager {
|
|
||||||
manager.setGeohash(geohash)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let fresh = LocationNotesManager(geohash: geohash)
|
|
||||||
manager = fresh
|
|
||||||
managerCancellable = fresh.$notes
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] notes in
|
|
||||||
let now = Date()
|
|
||||||
self?.noteCount = notes.filter { $0.expiresAt.map { $0 > now } ?? true }.count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,6 +25,10 @@ final class PeerIdentityStore: ObservableObject {
|
|||||||
stablePeerIDsByShortID[peerID] = stablePeerID
|
stablePeerIDsByShortID[peerID] = stablePeerID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func replaceStablePeerIDs(_ mappings: [PeerID: PeerID]) {
|
||||||
|
stablePeerIDsByShortID = mappings
|
||||||
|
}
|
||||||
|
|
||||||
func fingerprint(for peerID: PeerID) -> String? {
|
func fingerprint(for peerID: PeerID) -> String? {
|
||||||
peerFingerprintsByPeerID[peerID]
|
peerFingerprintsByPeerID[peerID]
|
||||||
}
|
}
|
||||||
@@ -90,6 +94,10 @@ final class PeerIdentityStore: ObservableObject {
|
|||||||
invalidateEncryptionCache(for: peerID)
|
invalidateEncryptionCache(for: peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func replaceEncryptionStatuses(_ statuses: [PeerID: EncryptionStatus]) {
|
||||||
|
encryptionStatuses = statuses
|
||||||
|
}
|
||||||
|
|
||||||
func setVerifiedFingerprints(_ fingerprints: Set<String>) {
|
func setVerifiedFingerprints(_ fingerprints: Set<String>) {
|
||||||
verifiedFingerprints = fingerprints
|
verifiedFingerprints = fingerprints
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ struct MeshPeerRow: Identifiable, Equatable {
|
|||||||
let isMutualFavorite: Bool
|
let isMutualFavorite: Bool
|
||||||
let encryptionStatus: EncryptionStatus
|
let encryptionStatus: EncryptionStatus
|
||||||
let showsVerifiedBadgeWhenOffline: Bool
|
let showsVerifiedBadgeWhenOffline: Bool
|
||||||
/// Vouched-for by someone I verified, without an explicit verification of
|
|
||||||
/// mine — rendered as the unfilled seal (verified gets the filled one).
|
|
||||||
let showsVouchedBadge: Bool
|
|
||||||
|
|
||||||
var id: String { peerID.id }
|
var id: String { peerID.id }
|
||||||
}
|
}
|
||||||
@@ -29,22 +26,11 @@ struct GeohashPersonRow: Identifiable, Equatable {
|
|||||||
let isBlocked: Bool
|
let isBlocked: Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GroupChatRow: Identifiable, Equatable {
|
|
||||||
let peerID: PeerID
|
|
||||||
let name: String
|
|
||||||
let memberCount: Int
|
|
||||||
let isCreator: Bool
|
|
||||||
let hasUnread: Bool
|
|
||||||
|
|
||||||
var id: String { peerID.id }
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class PeerListModel: ObservableObject {
|
final class PeerListModel: ObservableObject {
|
||||||
@Published private(set) var allPeers: [BitchatPeer] = []
|
@Published private(set) var allPeers: [BitchatPeer] = []
|
||||||
@Published private(set) var meshRows: [MeshPeerRow] = []
|
@Published private(set) var meshRows: [MeshPeerRow] = []
|
||||||
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
|
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
|
||||||
@Published private(set) var groupRows: [GroupChatRow] = []
|
|
||||||
@Published private(set) var reachableMeshPeerCount = 0
|
@Published private(set) var reachableMeshPeerCount = 0
|
||||||
@Published private(set) var connectedMeshPeerCount = 0
|
@Published private(set) var connectedMeshPeerCount = 0
|
||||||
@Published private(set) var visibleGeohashPeerCount = 0
|
@Published private(set) var visibleGeohashPeerCount = 0
|
||||||
@@ -143,13 +129,6 @@ final class PeerListModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
chatViewModel.groupStore.$groups
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.refresh()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
peerIdentityStore.$encryptionStatuses
|
peerIdentityStore.$encryptionStatuses
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
@@ -204,12 +183,13 @@ final class PeerListModel: ObservableObject {
|
|||||||
let myPeerID = chatViewModel.meshService.myPeerID
|
let myPeerID = chatViewModel.meshService.myPeerID
|
||||||
let meshRows = allPeers.map { peer in
|
let meshRows = allPeers.map { peer in
|
||||||
let isMe = peer.peerID == myPeerID
|
let isMe = peer.peerID == myPeerID
|
||||||
let fingerprint = isMe ? nil : chatViewModel.getFingerprint(for: peer.peerID)
|
let verifiedBadge: Bool
|
||||||
let isVerifiedFingerprint = fingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
if !isMe && !peer.isConnected,
|
||||||
let verifiedBadge = !peer.isConnected && isVerifiedFingerprint
|
let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) {
|
||||||
// Vouched is subordinate to verified: never show both seals.
|
verifiedBadge = peerIdentityStore.isVerified(fingerprint)
|
||||||
let vouchedBadge = !isVerifiedFingerprint
|
} else {
|
||||||
&& (fingerprint.map { chatViewModel.isVouchedFingerprint($0) } ?? false)
|
verifiedBadge = false
|
||||||
|
}
|
||||||
|
|
||||||
return MeshPeerRow(
|
return MeshPeerRow(
|
||||||
peerID: peer.peerID,
|
peerID: peer.peerID,
|
||||||
@@ -222,8 +202,7 @@ final class PeerListModel: ObservableObject {
|
|||||||
isReachable: peer.isReachable,
|
isReachable: peer.isReachable,
|
||||||
isMutualFavorite: peer.isMutualFavorite,
|
isMutualFavorite: peer.isMutualFavorite,
|
||||||
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
|
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
|
||||||
showsVerifiedBadgeWhenOffline: verifiedBadge,
|
showsVerifiedBadgeWhenOffline: verifiedBadge
|
||||||
showsVouchedBadge: vouchedBadge
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,40 +217,22 @@ final class PeerListModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let geohashPeople = buildGeohashPeople()
|
let geohashPeople = buildGeohashPeople()
|
||||||
let groupRows = buildGroupRows()
|
|
||||||
|
|
||||||
self.meshRows = meshRows
|
self.meshRows = meshRows
|
||||||
reachableMeshPeerCount = meshCounts.reachable
|
reachableMeshPeerCount = meshCounts.reachable
|
||||||
connectedMeshPeerCount = meshCounts.connected
|
connectedMeshPeerCount = meshCounts.connected
|
||||||
self.geohashPeople = geohashPeople
|
self.geohashPeople = geohashPeople
|
||||||
visibleGeohashPeerCount = geohashPeople.count
|
visibleGeohashPeerCount = geohashPeople.count
|
||||||
self.groupRows = groupRows
|
|
||||||
renderID = (
|
renderID = (
|
||||||
meshRows.map {
|
meshRows.map {
|
||||||
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
||||||
} +
|
} +
|
||||||
geohashPeople.map {
|
geohashPeople.map {
|
||||||
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
|
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
|
||||||
} +
|
|
||||||
groupRows.map {
|
|
||||||
"group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)"
|
|
||||||
}
|
}
|
||||||
).joined(separator: "|")
|
).joined(separator: "|")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildGroupRows() -> [GroupChatRow] {
|
|
||||||
let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint()
|
|
||||||
return chatViewModel.groupStore.groups.map { group in
|
|
||||||
GroupChatRow(
|
|
||||||
peerID: group.peerID,
|
|
||||||
name: group.name,
|
|
||||||
memberCount: group.members.count,
|
|
||||||
isCreator: group.creatorFingerprint == myFingerprint,
|
|
||||||
hasUnread: chatViewModel.hasUnreadMessages(for: group.peerID)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func buildGeohashPeople() -> [GeohashPersonRow] {
|
private func buildGeohashPeople() -> [GeohashPersonRow] {
|
||||||
let myHex = currentGeohashIdentityHex()
|
let myHex = currentGeohashIdentityHex()
|
||||||
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
|
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
|
||||||
|
|||||||
@@ -108,13 +108,7 @@ struct PrivateConversationHeaderState: Equatable {
|
|||||||
let encryptionStatus: EncryptionStatus?
|
let encryptionStatus: EncryptionStatus?
|
||||||
|
|
||||||
var supportsFavoriteToggle: Bool {
|
var supportsFavoriteToggle: Bool {
|
||||||
!conversationPeerID.isGeoDM && !conversationPeerID.isGroup
|
!conversationPeerID.isGeoDM
|
||||||
}
|
|
||||||
|
|
||||||
/// Group chats have no single peer identity behind the header: no
|
|
||||||
/// fingerprint screen, no per-peer encryption badge.
|
|
||||||
var isGroupConversation: Bool {
|
|
||||||
conversationPeerID.isGroup
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,13 +206,6 @@ final class PrivateConversationModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
chatViewModel.groupStore.$groups
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.refreshSelectedConversation()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
@@ -242,26 +229,6 @@ final class PrivateConversationModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
|
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
|
||||||
// Group chats: the "peer" is the whole crew. Name + member count in
|
|
||||||
// the header; availability reads as mesh since group traffic floods
|
|
||||||
// the local mesh, and the per-peer encryption badge does not apply.
|
|
||||||
if conversationPeerID.isGroup {
|
|
||||||
let displayName: String
|
|
||||||
if let group = chatViewModel.groupStore.group(for: conversationPeerID) {
|
|
||||||
displayName = "#\(group.name) (\(group.members.count))"
|
|
||||||
} else {
|
|
||||||
displayName = String(localized: "common.unknown", comment: "Fallback label for unknown peer")
|
|
||||||
}
|
|
||||||
return PrivateConversationHeaderState(
|
|
||||||
conversationPeerID: conversationPeerID,
|
|
||||||
headerPeerID: conversationPeerID,
|
|
||||||
displayName: displayName,
|
|
||||||
availability: .meshReachable,
|
|
||||||
isFavorite: false,
|
|
||||||
encryptionStatus: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
||||||
|
|||||||
@@ -3,19 +3,12 @@ import Combine
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct FingerprintPresentationState: Equatable {
|
struct FingerprintPresentationState: Equatable {
|
||||||
|
let statusPeerID: PeerID
|
||||||
let peerNickname: String
|
let peerNickname: String
|
||||||
let encryptionStatus: EncryptionStatus
|
let encryptionStatus: EncryptionStatus
|
||||||
let theirFingerprint: String?
|
let theirFingerprint: String?
|
||||||
let myFingerprint: String
|
let myFingerprint: String
|
||||||
let isVerified: Bool
|
let isVerified: Bool
|
||||||
/// Number of currently-valid vouches from peers the user verified
|
|
||||||
/// (0 when the peer is explicitly verified — the stronger badge wins).
|
|
||||||
let voucherCount: Int
|
|
||||||
/// Display names of the (verified) vouchers, where known.
|
|
||||||
let voucherNames: [String]
|
|
||||||
|
|
||||||
/// Vouched for by ≥1 peer the user verified (and not explicitly verified).
|
|
||||||
var isVouched: Bool { voucherCount > 0 }
|
|
||||||
|
|
||||||
var canToggleVerification: Bool {
|
var canToggleVerification: Bool {
|
||||||
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
|
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
|
||||||
@@ -55,6 +48,10 @@ final class VerificationModel: ObservableObject {
|
|||||||
return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? ""
|
return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
|
||||||
|
chatViewModel.beginQRVerification(with: qr)
|
||||||
|
}
|
||||||
|
|
||||||
func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome {
|
func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome {
|
||||||
guard let qr = VerificationService.shared.verifyScannedQR(payload) else {
|
guard let qr = VerificationService.shared.verifyScannedQR(payload) else {
|
||||||
return .invalid
|
return .invalid
|
||||||
@@ -85,33 +82,14 @@ final class VerificationModel: ObservableObject {
|
|||||||
let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID)
|
let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID)
|
||||||
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
|
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
|
||||||
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
|
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
|
||||||
let isVerified = theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
|
||||||
|
|
||||||
// Vouch state is recomputed on read: only vouchers still in the
|
|
||||||
// verified set count, so removing a verification silently retires the
|
|
||||||
// vouches that peer gave.
|
|
||||||
let vouchers: [VouchRecord]
|
|
||||||
if !isVerified, let theirFingerprint {
|
|
||||||
vouchers = chatViewModel.identityManager.validVouchers(for: theirFingerprint)
|
|
||||||
} else {
|
|
||||||
vouchers = []
|
|
||||||
}
|
|
||||||
let voucherNames = vouchers.compactMap { record -> String? in
|
|
||||||
guard let social = chatViewModel.identityManager.getSocialIdentity(for: record.voucherFingerprint) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if let petname = social.localPetname, !petname.isEmpty { return petname }
|
|
||||||
return social.claimedNickname.isEmpty ? nil : social.claimedNickname
|
|
||||||
}
|
|
||||||
|
|
||||||
return FingerprintPresentationState(
|
return FingerprintPresentationState(
|
||||||
|
statusPeerID: statusPeerID,
|
||||||
peerNickname: peerNickname,
|
peerNickname: peerNickname,
|
||||||
encryptionStatus: encryptionStatus,
|
encryptionStatus: encryptionStatus,
|
||||||
theirFingerprint: theirFingerprint,
|
theirFingerprint: theirFingerprint,
|
||||||
myFingerprint: chatViewModel.getMyFingerprint(),
|
myFingerprint: chatViewModel.getMyFingerprint(),
|
||||||
isVerified: isVerified,
|
isVerified: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
||||||
voucherCount: vouchers.count,
|
|
||||||
voucherNames: voucherNames
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,17 +122,6 @@ final class VerificationModel: ObservableObject {
|
|||||||
self?.objectWillChange.send()
|
self?.objectWillChange.send()
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
// Vouch state changes (ChatVouchCoordinator.notifyPeerTrustChanged)
|
|
||||||
// are signalled via this notification rather than a published
|
|
||||||
// property, so an open fingerprint sheet refreshes its vouched badge
|
|
||||||
// live when a vouch batch is accepted.
|
|
||||||
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] _ in
|
|
||||||
self?.objectWillChange.send()
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
|
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 848 B |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 460 B After Width: | Height: | Size: 356 B |
|
Before Width: | Height: | Size: 833 B After Width: | Height: | Size: 429 B |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 833 B After Width: | Height: | Size: 429 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 597 B |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 5.2 KiB |
@@ -27,66 +27,6 @@
|
|||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "mac_16x16.png",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "16x16"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "mac_16x16@2x.png",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "16x16"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "mac_32x32.png",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "32x32"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "mac_32x32@2x.png",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "32x32"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "mac_128x128.png",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "128x128"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "mac_128x128@2x.png",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "128x128"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "mac_256x256.png",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "256x256"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "mac_256x256@2x.png",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "256x256"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "mac_512x512.png",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "1x",
|
|
||||||
"size" : "512x512"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename" : "mac_512x512@2x.png",
|
|
||||||
"idiom" : "mac",
|
|
||||||
"scale" : "2x",
|
|
||||||
"size" : "512x512"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"info" : {
|
"info" : {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 505 B |
|
Before Width: | Height: | Size: 930 B |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 930 B |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 73 KiB |
@@ -40,7 +40,6 @@ struct BitchatApp: App {
|
|||||||
.environmentObject(runtime.locationChannelsModel)
|
.environmentObject(runtime.locationChannelsModel)
|
||||||
.environmentObject(runtime.peerListModel)
|
.environmentObject(runtime.peerListModel)
|
||||||
.environmentObject(runtime.appChromeModel)
|
.environmentObject(runtime.appChromeModel)
|
||||||
.environmentObject(runtime.boardAlertsModel)
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
appDelegate.runtime = runtime
|
appDelegate.runtime = runtime
|
||||||
runtime.start()
|
runtime.start()
|
||||||
@@ -104,20 +103,12 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
|
|
||||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||||
let identifier = response.notification.request.identifier
|
let identifier = response.notification.request.identifier
|
||||||
let actionIdentifier = response.actionIdentifier
|
|
||||||
let userInfo = response.notification.request.content.userInfo
|
let userInfo = response.notification.request.content.userInfo
|
||||||
|
|
||||||
// Complete only after the response is handled: for a background
|
|
||||||
// action (👋 wave) the system may suspend the app the moment the
|
|
||||||
// completion handler runs, which would drop the queued send.
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.runtime?.handleNotificationResponse(
|
self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo)
|
||||||
identifier: identifier,
|
|
||||||
actionIdentifier: actionIdentifier,
|
|
||||||
userInfo: userInfo
|
|
||||||
)
|
|
||||||
completionHandler()
|
|
||||||
}
|
}
|
||||||
|
completionHandler()
|
||||||
}
|
}
|
||||||
|
|
||||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
//
|
|
||||||
// PTTAudioCodec.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import AVFoundation
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Streaming PCM -> AAC-LC encoder for live voice. Stateful (the AAC encoder
|
|
||||||
/// carries a bit reservoir across frames); one instance per burst.
|
|
||||||
/// Not thread-safe — confine to one queue.
|
|
||||||
final class PTTFrameEncoder {
|
|
||||||
private let converter: AVAudioConverter
|
|
||||||
private var pendingInput: [AVAudioPCMBuffer] = []
|
|
||||||
|
|
||||||
init?() {
|
|
||||||
guard let pcm = PTTAudioFormat.pcmFormat,
|
|
||||||
let aac = PTTAudioFormat.aacFormat,
|
|
||||||
let converter = AVAudioConverter(from: pcm, to: aac)
|
|
||||||
else { return nil }
|
|
||||||
converter.bitRate = PTTAudioFormat.bitRate
|
|
||||||
self.converter = converter
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Feeds PCM (16 kHz mono float) and returns every complete AAC frame the
|
|
||||||
/// encoder produced. Frames come out ~130 bytes each at 16 kbps.
|
|
||||||
func encode(_ buffer: AVAudioPCMBuffer) -> [Data] {
|
|
||||||
pendingInput.append(buffer)
|
|
||||||
return drainConverter()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func drainConverter() -> [Data] {
|
|
||||||
var frames: [Data] = []
|
|
||||||
while true {
|
|
||||||
let output = AVAudioCompressedBuffer(
|
|
||||||
format: converter.outputFormat,
|
|
||||||
packetCapacity: 8,
|
|
||||||
maximumPacketSize: max(converter.maximumOutputPacketSize, 1)
|
|
||||||
)
|
|
||||||
var error: NSError?
|
|
||||||
let status = converter.convert(to: output, error: &error) { [weak self] _, outStatus in
|
|
||||||
guard let self, let next = self.pendingInput.first else {
|
|
||||||
outStatus.pointee = .noDataNow
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
self.pendingInput.removeFirst()
|
|
||||||
outStatus.pointee = .haveData
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
if status == .error {
|
|
||||||
SecureLogger.error("PTT encode failed: \(error?.localizedDescription ?? "unknown")", category: .session)
|
|
||||||
return frames
|
|
||||||
}
|
|
||||||
frames.append(contentsOf: Self.extractPackets(from: output))
|
|
||||||
// .haveData means the output buffer filled and more may be ready;
|
|
||||||
// anything else means the converter wants more input.
|
|
||||||
if status != .haveData { return frames }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func extractPackets(from buffer: AVAudioCompressedBuffer) -> [Data] {
|
|
||||||
guard buffer.packetCount > 0, let descriptions = buffer.packetDescriptions else { return [] }
|
|
||||||
var frames: [Data] = []
|
|
||||||
frames.reserveCapacity(Int(buffer.packetCount))
|
|
||||||
for index in 0..<Int(buffer.packetCount) {
|
|
||||||
let description = descriptions[index]
|
|
||||||
guard description.mDataByteSize > 0 else { continue }
|
|
||||||
let start = buffer.data.advanced(by: Int(description.mStartOffset))
|
|
||||||
frames.append(Data(bytes: start, count: Int(description.mDataByteSize)))
|
|
||||||
}
|
|
||||||
return frames
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Streaming AAC-LC -> PCM decoder for live voice. Stateful; one instance per
|
|
||||||
/// inbound burst. Not thread-safe — confine to one queue/actor.
|
|
||||||
final class PTTFrameDecoder {
|
|
||||||
private let converter: AVAudioConverter
|
|
||||||
private let pcmFormat: AVAudioFormat
|
|
||||||
private let aacFormat: AVAudioFormat
|
|
||||||
|
|
||||||
init?() {
|
|
||||||
guard let pcm = PTTAudioFormat.pcmFormat,
|
|
||||||
let aac = PTTAudioFormat.aacFormat,
|
|
||||||
let converter = AVAudioConverter(from: aac, to: pcm)
|
|
||||||
else { return nil }
|
|
||||||
self.converter = converter
|
|
||||||
self.pcmFormat = pcm
|
|
||||||
self.aacFormat = aac
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decodes one raw AAC frame to PCM. Returns nil for malformed input or
|
|
||||||
/// while the decoder is still priming (the first frame of a stream).
|
|
||||||
func decode(_ frame: Data) -> AVAudioPCMBuffer? {
|
|
||||||
guard !frame.isEmpty, frame.count <= 8 * 1024 else { return nil }
|
|
||||||
|
|
||||||
let input = AVAudioCompressedBuffer(format: aacFormat, packetCapacity: 1, maximumPacketSize: frame.count)
|
|
||||||
frame.withUnsafeBytes { raw in
|
|
||||||
guard let base = raw.baseAddress else { return }
|
|
||||||
input.data.copyMemory(from: base, byteCount: frame.count)
|
|
||||||
}
|
|
||||||
input.byteLength = UInt32(frame.count)
|
|
||||||
input.packetCount = 1
|
|
||||||
input.packetDescriptions?.pointee = AudioStreamPacketDescription(
|
|
||||||
mStartOffset: 0,
|
|
||||||
mVariableFramesInPacket: 0,
|
|
||||||
mDataByteSize: UInt32(frame.count)
|
|
||||||
)
|
|
||||||
|
|
||||||
guard let output = AVAudioPCMBuffer(
|
|
||||||
pcmFormat: pcmFormat,
|
|
||||||
frameCapacity: PTTAudioFormat.samplesPerFrame * 2
|
|
||||||
) else { return nil }
|
|
||||||
|
|
||||||
var consumed = false
|
|
||||||
var error: NSError?
|
|
||||||
let status = converter.convert(to: output, error: &error) { _, outStatus in
|
|
||||||
if consumed {
|
|
||||||
outStatus.pointee = .noDataNow
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
consumed = true
|
|
||||||
outStatus.pointee = .haveData
|
|
||||||
return input
|
|
||||||
}
|
|
||||||
guard status != .error else {
|
|
||||||
SecureLogger.debug("PTT decode failed: \(error?.localizedDescription ?? "unknown")", category: .session)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return output.frameLength > 0 ? output : nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sample-rate/channel converter from the microphone's native format to the
|
|
||||||
/// 16 kHz mono processing format. Stateful; not thread-safe.
|
|
||||||
final class PTTInputResampler {
|
|
||||||
private let converter: AVAudioConverter
|
|
||||||
private let outputFormat: AVAudioFormat
|
|
||||||
private let ratio: Double
|
|
||||||
|
|
||||||
init?(inputFormat: AVAudioFormat) {
|
|
||||||
guard let pcm = PTTAudioFormat.pcmFormat,
|
|
||||||
let converter = AVAudioConverter(from: inputFormat, to: pcm)
|
|
||||||
else { return nil }
|
|
||||||
self.converter = converter
|
|
||||||
self.outputFormat = pcm
|
|
||||||
self.ratio = PTTAudioFormat.sampleRate / inputFormat.sampleRate
|
|
||||||
}
|
|
||||||
|
|
||||||
func resample(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
|
|
||||||
let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 64
|
|
||||||
guard let output = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: capacity) else { return nil }
|
|
||||||
|
|
||||||
var consumed = false
|
|
||||||
var error: NSError?
|
|
||||||
let status = converter.convert(to: output, error: &error) { _, outStatus in
|
|
||||||
if consumed {
|
|
||||||
outStatus.pointee = .noDataNow
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
consumed = true
|
|
||||||
outStatus.pointee = .haveData
|
|
||||||
return buffer
|
|
||||||
}
|
|
||||||
guard status != .error else {
|
|
||||||
SecureLogger.debug("PTT resample failed: \(error?.localizedDescription ?? "unknown")", category: .session)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return output.frameLength > 0 ? output : nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
//
|
|
||||||
// PTTAudioFormat.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import AVFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Shared audio parameters for live push-to-talk: AAC-LC, 16 kHz, mono,
|
|
||||||
/// ~16 kbps — deliberately identical to `VoiceRecorder`'s voice-note settings
|
|
||||||
/// so a burst's finalized `.m4a` and its live frames sound the same.
|
|
||||||
enum PTTAudioFormat {
|
|
||||||
static let sampleRate: Double = 16_000
|
|
||||||
static let channelCount: AVAudioChannelCount = 1
|
|
||||||
static let bitRate = 16_000
|
|
||||||
/// AAC-LC frame size is fixed by the codec: 1024 samples = 64 ms at 16 kHz.
|
|
||||||
static let samplesPerFrame: AVAudioFrameCount = 1024
|
|
||||||
static var frameDuration: TimeInterval { Double(samplesPerFrame) / sampleRate }
|
|
||||||
|
|
||||||
/// Uncompressed processing format (deinterleaved float PCM).
|
|
||||||
static var pcmFormat: AVAudioFormat? {
|
|
||||||
AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: channelCount)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compressed wire format.
|
|
||||||
static var aacFormat: AVAudioFormat? {
|
|
||||||
var description = AudioStreamBasicDescription(
|
|
||||||
mSampleRate: sampleRate,
|
|
||||||
mFormatID: kAudioFormatMPEG4AAC,
|
|
||||||
mFormatFlags: 0,
|
|
||||||
mBytesPerPacket: 0,
|
|
||||||
mFramesPerPacket: samplesPerFrame,
|
|
||||||
mBytesPerFrame: 0,
|
|
||||||
mChannelsPerFrame: channelCount,
|
|
||||||
mBitsPerChannel: 0,
|
|
||||||
mReserved: 0
|
|
||||||
)
|
|
||||||
return AVAudioFormat(streamDescription: &description)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Voice-note container settings for the finalized `.m4a`, mirroring
|
|
||||||
/// `VoiceRecorder.startRecording()`.
|
|
||||||
static var voiceNoteFileSettings: [String: Any] {
|
|
||||||
[
|
|
||||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
||||||
AVSampleRateKey: sampleRate,
|
|
||||||
AVNumberOfChannelsKey: Int(channelCount),
|
|
||||||
AVEncoderBitRateKey: bitRate
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds ADTS-framed AAC so a receiver can persist a burst progressively:
|
|
||||||
/// unlike `.m4a` (whose moov atom only exists after close), an ADTS `.aac`
|
|
||||||
/// stream is playable at any prefix — a partially received burst is still a
|
|
||||||
/// replayable voice note.
|
|
||||||
enum ADTSFramer {
|
|
||||||
private static let headerSize = 7
|
|
||||||
/// MPEG-4 sampling frequency index for 16 kHz.
|
|
||||||
private static let samplingFrequencyIndex: UInt8 = 8
|
|
||||||
private static let channelConfiguration: UInt8 = 1
|
|
||||||
|
|
||||||
/// Wraps one raw AAC-LC frame in an ADTS header.
|
|
||||||
static func frame(_ aacFrame: Data) -> Data {
|
|
||||||
let frameLength = aacFrame.count + headerSize
|
|
||||||
var data = Data(capacity: frameLength)
|
|
||||||
// Syncword 0xFFF, MPEG-4, layer 00, no CRC.
|
|
||||||
data.append(0xFF)
|
|
||||||
data.append(0xF1)
|
|
||||||
// Profile AAC-LC (audio object type 2 -> bits 01), frequency index,
|
|
||||||
// private bit 0, channel config high bit.
|
|
||||||
data.append((0b01 << 6) | (samplingFrequencyIndex << 2) | ((channelConfiguration >> 2) & 0x1))
|
|
||||||
data.append(((channelConfiguration & 0x3) << 6) | UInt8((frameLength >> 11) & 0x3))
|
|
||||||
data.append(UInt8((frameLength >> 3) & 0xFF))
|
|
||||||
data.append(UInt8((frameLength & 0x7) << 5) | 0x1F)
|
|
||||||
// Buffer fullness 0x7FF (VBR), one AAC frame per ADTS frame.
|
|
||||||
data.append(0xFC)
|
|
||||||
data.append(aacFrame)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
//
|
|
||||||
// PTTBurstPlayer.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import AVFoundation
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Plays one inbound live voice burst with a small jitter buffer.
|
|
||||||
///
|
|
||||||
/// Frames are decoded and scheduled back-to-back on an `AVAudioPlayerNode`;
|
|
||||||
/// an underrun (missing/late packets) simply pauses output until the next
|
|
||||||
/// buffer arrives, which self-heals timing without explicit silence
|
|
||||||
/// insertion. Playback starts once `TransportConfig.pttJitterBufferSeconds`
|
|
||||||
/// of audio is queued or `pttJitterDeadlineSeconds` has elapsed.
|
|
||||||
@MainActor
|
|
||||||
final class PTTBurstPlayer {
|
|
||||||
private let engine = AVAudioEngine()
|
|
||||||
private let node = AVAudioPlayerNode()
|
|
||||||
private let decoder: PTTFrameDecoder
|
|
||||||
|
|
||||||
private var queuedBuffers: [AVAudioPCMBuffer] = []
|
|
||||||
private var queuedDuration: TimeInterval = 0
|
|
||||||
private var scheduledCount = 0
|
|
||||||
private var engineStarted = false
|
|
||||||
private var finished = false
|
|
||||||
private var stopped = false
|
|
||||||
private var deadlineTask: Task<Void, Never>?
|
|
||||||
|
|
||||||
private(set) var isPlaying = false
|
|
||||||
|
|
||||||
init?() {
|
|
||||||
guard let format = PTTAudioFormat.pcmFormat, let decoder = PTTFrameDecoder() else { return nil }
|
|
||||||
self.decoder = decoder
|
|
||||||
engine.attach(node)
|
|
||||||
engine.connect(node, to: engine.mainMixerNode, format: format)
|
|
||||||
|
|
||||||
deadlineTask = Task { [weak self] in
|
|
||||||
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttJitterDeadlineSeconds * 1_000_000_000))
|
|
||||||
self?.startIfReady(force: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decodes and queues frames (in burst order). Starts playback when the
|
|
||||||
/// jitter buffer fills.
|
|
||||||
func enqueue(_ frames: [Data]) {
|
|
||||||
guard !stopped else { return }
|
|
||||||
for frame in frames {
|
|
||||||
guard let pcm = decoder.decode(frame) else { continue }
|
|
||||||
if engineStarted {
|
|
||||||
schedule(pcm)
|
|
||||||
} else {
|
|
||||||
queuedBuffers.append(pcm)
|
|
||||||
queuedDuration += Double(pcm.frameLength) / PTTAudioFormat.sampleRate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
startIfReady(force: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The burst ended: stop once everything scheduled has played out.
|
|
||||||
func finishAfterDrain() {
|
|
||||||
finished = true
|
|
||||||
stopIfDrained()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Immediate stop (cancel, another playback taking over, teardown).
|
|
||||||
func stop() {
|
|
||||||
guard !stopped else { return }
|
|
||||||
stopped = true
|
|
||||||
deadlineTask?.cancel()
|
|
||||||
queuedBuffers = []
|
|
||||||
if engineStarted {
|
|
||||||
node.stop()
|
|
||||||
engine.stop()
|
|
||||||
}
|
|
||||||
isPlaying = false
|
|
||||||
VoiceNotePlaybackCoordinator.shared.deactivate(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func startIfReady(force: Bool) {
|
|
||||||
guard !engineStarted, !stopped, !queuedBuffers.isEmpty else { return }
|
|
||||||
guard force || queuedDuration >= TransportConfig.pttJitterBufferSeconds else { return }
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
do {
|
|
||||||
let session = AVAudioSession.sharedInstance()
|
|
||||||
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
|
|
||||||
try session.setActive(true, options: [])
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("PTT playback session activation failed: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
engine.prepare()
|
|
||||||
do {
|
|
||||||
try engine.start()
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session)
|
|
||||||
stopped = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
engineStarted = true
|
|
||||||
isPlaying = true
|
|
||||||
VoiceNotePlaybackCoordinator.shared.activate(self)
|
|
||||||
node.play()
|
|
||||||
|
|
||||||
let buffered = queuedBuffers
|
|
||||||
queuedBuffers = []
|
|
||||||
queuedDuration = 0
|
|
||||||
for buffer in buffered {
|
|
||||||
schedule(buffer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func schedule(_ buffer: AVAudioPCMBuffer) {
|
|
||||||
scheduledCount += 1
|
|
||||||
node.scheduleBuffer(buffer) { [weak self] in
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
self.scheduledCount -= 1
|
|
||||||
self.stopIfDrained()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func stopIfDrained() {
|
|
||||||
guard finished, scheduledCount <= 0 else { return }
|
|
||||||
stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension PTTBurstPlayer: ExclusivePlayback {
|
|
||||||
/// A live stream can't meaningfully pause; yielding the floor stops it.
|
|
||||||
/// The burst keeps assembling to file, so nothing is lost.
|
|
||||||
nonisolated func pauseForExclusivity() {
|
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
//
|
|
||||||
// PTTCaptureEngine.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import AVFoundation
|
|
||||||
import BitLogger
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Captures microphone audio for a live push-to-talk burst, producing both:
|
|
||||||
/// - live AAC frames via `onFrames` (called on the capture queue), and
|
|
||||||
/// - a finalized `.m4a` voice note on `stop()` — the same artifact
|
|
||||||
/// `VoiceRecorder` produces, so the existing voice-note send pipeline
|
|
||||||
/// handles delivery to receivers that missed the live stream.
|
|
||||||
final class PTTCaptureEngine {
|
|
||||||
/// Hard cap matching `VoiceRecorder.maxRecordingDuration`: past it the
|
|
||||||
/// engine keeps running (the UI owns the gesture) but stops encoding.
|
|
||||||
private static let maxCaptureDuration: TimeInterval = 120
|
|
||||||
|
|
||||||
/// Recreated on every `start()`: an engine whose input unit was
|
|
||||||
/// instantiated against an earlier (playback-only or inactive) audio
|
|
||||||
/// session keeps reporting a dead 0 Hz / 2 ch input format and fails to
|
|
||||||
/// enable the mic (AURemoteIO -10851, observed on iPhone field tests).
|
|
||||||
private var engine = AVAudioEngine()
|
|
||||||
private let queue = DispatchQueue(label: "chat.bitchat.ptt.capture", qos: .userInitiated)
|
|
||||||
|
|
||||||
// Capture-queue-confined state.
|
|
||||||
private var resampler: PTTInputResampler?
|
|
||||||
private var encoder: PTTFrameEncoder?
|
|
||||||
private var file: AVAudioFile?
|
|
||||||
private var fileURL: URL?
|
|
||||||
private var encodedFrameCount = 0
|
|
||||||
private var running = false
|
|
||||||
private var captureStart = Date()
|
|
||||||
/// Whether `engine.start()` succeeded for the current capture (main-actor
|
|
||||||
/// callers only; see `stopEngineIfStarted`).
|
|
||||||
private var engineStarted = false
|
|
||||||
|
|
||||||
/// Called on the capture queue with each batch of encoded AAC frames.
|
|
||||||
var onFrames: (([Data]) -> Void)?
|
|
||||||
|
|
||||||
enum CaptureError: Error {
|
|
||||||
case inputUnavailable
|
|
||||||
case audioSetupFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
func start(outputURL: URL) throws {
|
|
||||||
#if os(iOS)
|
|
||||||
try Self.configureAudioSession()
|
|
||||||
#endif
|
|
||||||
|
|
||||||
// Fresh engine per capture so its input unit binds to the session
|
|
||||||
// that is active *now* (see `engine` doc comment).
|
|
||||||
engine = AVAudioEngine()
|
|
||||||
let inputFormat = engine.inputNode.outputFormat(forBus: 0)
|
|
||||||
guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else {
|
|
||||||
SecureLogger.error("PTT: capture input unavailable (input reports \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session)
|
|
||||||
throw CaptureError.inputUnavailable
|
|
||||||
}
|
|
||||||
guard let resampler = PTTInputResampler(inputFormat: inputFormat),
|
|
||||||
let encoder = PTTFrameEncoder(),
|
|
||||||
let pcmFormat = PTTAudioFormat.pcmFormat
|
|
||||||
else { throw CaptureError.audioSetupFailed }
|
|
||||||
|
|
||||||
let file = try AVAudioFile(
|
|
||||||
forWriting: outputURL,
|
|
||||||
settings: PTTAudioFormat.voiceNoteFileSettings,
|
|
||||||
commonFormat: pcmFormat.commonFormat,
|
|
||||||
interleaved: pcmFormat.isInterleaved
|
|
||||||
)
|
|
||||||
|
|
||||||
queue.sync {
|
|
||||||
self.resampler = resampler
|
|
||||||
self.encoder = encoder
|
|
||||||
self.file = file
|
|
||||||
self.fileURL = outputURL
|
|
||||||
self.encodedFrameCount = 0
|
|
||||||
self.captureStart = Date()
|
|
||||||
self.running = true
|
|
||||||
}
|
|
||||||
|
|
||||||
engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
|
|
||||||
self?.queue.async { self?.process(buffer) }
|
|
||||||
}
|
|
||||||
engine.prepare()
|
|
||||||
do {
|
|
||||||
try engine.start()
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("PTT: capture engine failed to start (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch): \(error)", category: .session)
|
|
||||||
engine.inputNode.removeTap(onBus: 0)
|
|
||||||
queue.sync { self.teardown(deleteFile: true) }
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
engineStarted = true
|
|
||||||
SecureLogger.info("PTT: capture engine running (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Stops capture and finalizes the `.m4a`. Returns the file URL and the
|
|
||||||
/// number of encoded AAC frames (each `PTTAudioFormat.frameDuration` long).
|
|
||||||
func stop() -> (url: URL?, encodedFrames: Int) {
|
|
||||||
stopEngineIfStarted()
|
|
||||||
let result: (URL?, Int) = queue.sync {
|
|
||||||
let url = fileURL
|
|
||||||
let frames = encodedFrameCount
|
|
||||||
teardown(deleteFile: false)
|
|
||||||
return (url, frames)
|
|
||||||
}
|
|
||||||
#if os(iOS)
|
|
||||||
Self.deactivateAudioSession()
|
|
||||||
#endif
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancel() {
|
|
||||||
stopEngineIfStarted()
|
|
||||||
queue.sync { teardown(deleteFile: true) }
|
|
||||||
#if os(iOS)
|
|
||||||
Self.deactivateAudioSession()
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Touching `inputNode` on an engine that never started instantiates its
|
|
||||||
/// input unit against whatever session is active and spams AURemoteIO
|
|
||||||
/// errors — a canceled-before-start hold must not touch the engine.
|
|
||||||
private func stopEngineIfStarted() {
|
|
||||||
guard engineStarted else { return }
|
|
||||||
engineStarted = false
|
|
||||||
engine.inputNode.removeTap(onBus: 0)
|
|
||||||
engine.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Capture queue
|
|
||||||
|
|
||||||
private func process(_ buffer: AVAudioPCMBuffer) {
|
|
||||||
guard running,
|
|
||||||
Date().timeIntervalSince(captureStart) < Self.maxCaptureDuration,
|
|
||||||
let resampled = resampler?.resample(buffer)
|
|
||||||
else { return }
|
|
||||||
|
|
||||||
do {
|
|
||||||
try file?.write(from: resampled)
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("PTT capture file write failed: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let frames = encoder?.encode(resampled), !frames.isEmpty else { return }
|
|
||||||
encodedFrameCount += frames.count
|
|
||||||
onFrames?(frames)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func teardown(deleteFile: Bool) {
|
|
||||||
running = false
|
|
||||||
// Releasing the AVAudioFile finalizes the .m4a container.
|
|
||||||
file = nil
|
|
||||||
encoder = nil
|
|
||||||
resampler = nil
|
|
||||||
if deleteFile, let url = fileURL {
|
|
||||||
try? FileManager.default.removeItem(at: url)
|
|
||||||
}
|
|
||||||
fileURL = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Audio session (iOS)
|
|
||||||
|
|
||||||
#if os(iOS)
|
|
||||||
private static func configureAudioSession() throws {
|
|
||||||
let session = AVAudioSession.sharedInstance()
|
|
||||||
#if targetEnvironment(simulator)
|
|
||||||
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothA2DP])
|
|
||||||
#else
|
|
||||||
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP])
|
|
||||||
#endif
|
|
||||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func deactivateAudioSession() {
|
|
||||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
//
|
|
||||||
// PTTSettings.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
#if os(iOS)
|
|
||||||
import UIKit
|
|
||||||
#elseif os(macOS)
|
|
||||||
import AppKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/// User preference for live push-to-talk voice. One switch controls both
|
|
||||||
/// directions: streaming your holds live, and auto-playing inbound bursts.
|
|
||||||
/// Off means voice messages behave exactly like classic voice notes.
|
|
||||||
enum PTTSettings {
|
|
||||||
private static let liveVoiceEnabledKey = "ptt.liveVoiceEnabled"
|
|
||||||
|
|
||||||
static var liveVoiceEnabled: Bool {
|
|
||||||
get { UserDefaults.standard.object(forKey: liveVoiceEnabledKey) as? Bool ?? true }
|
|
||||||
set { UserDefaults.standard.set(newValue, forKey: liveVoiceEnabledKey) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Autoplay is foreground-only: audio must never start from the
|
|
||||||
/// background.
|
|
||||||
@MainActor
|
|
||||||
static var isAppActive: Bool {
|
|
||||||
#if os(iOS)
|
|
||||||
return UIApplication.shared.applicationState == .active
|
|
||||||
#elseif os(macOS)
|
|
||||||
return NSApplication.shared.isActive
|
|
||||||
#else
|
|
||||||
return true
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
//
|
|
||||||
// VoiceCaptureSession.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 Foundation
|
|
||||||
|
|
||||||
/// Capture backend behind the composer's hold-to-record gesture.
|
|
||||||
/// `VoiceRecordingViewModel` drives one session per press; the concrete type
|
|
||||||
/// decides *how* audio leaves the device: `VoiceNoteCaptureSession` records a
|
|
||||||
/// note delivered on release (today's behavior), `PTTLiveVoiceSession`
|
|
||||||
/// additionally streams frames live while the button is held.
|
|
||||||
@MainActor
|
|
||||||
protocol VoiceCaptureSession: AnyObject {
|
|
||||||
/// Whether audio is leaving the device in real time while recording —
|
|
||||||
/// drives the composer's LIVE treatment.
|
|
||||||
var isLive: Bool { get }
|
|
||||||
func requestPermission() async -> Bool
|
|
||||||
func start() async throws
|
|
||||||
/// Stops capture and returns the finalized voice-note file, or nil when
|
|
||||||
/// nothing valid was captured.
|
|
||||||
func finish() async -> URL?
|
|
||||||
func cancel() async
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
|
|
||||||
@MainActor
|
|
||||||
final class VoiceNoteCaptureSession: VoiceCaptureSession {
|
|
||||||
var isLive: Bool { false }
|
|
||||||
|
|
||||||
func requestPermission() async -> Bool {
|
|
||||||
await VoiceRecorder.shared.requestPermission()
|
|
||||||
}
|
|
||||||
|
|
||||||
func start() async throws {
|
|
||||||
try await VoiceRecorder.shared.startRecording()
|
|
||||||
}
|
|
||||||
|
|
||||||
func finish() async -> URL? {
|
|
||||||
await VoiceRecorder.shared.stopRecording()
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancel() async {
|
|
||||||
await VoiceRecorder.shared.cancelRecording()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Live push-to-talk backend: streams `VoiceBurstPacket`s to one peer while
|
|
||||||
/// recording, then finalizes the same audio as a standard voice note whose
|
|
||||||
/// file name carries the burst ID (`voice_<burstID>.m4a`) so receivers that
|
|
||||||
/// heard the live stream absorb the note silently instead of seeing a
|
|
||||||
/// duplicate.
|
|
||||||
@MainActor
|
|
||||||
final class PTTLiveVoiceSession: VoiceCaptureSession {
|
|
||||||
let burstID: Data
|
|
||||||
|
|
||||||
private let sendPacket: (Data) -> Void
|
|
||||||
private let capture = PTTCaptureEngine()
|
|
||||||
/// Capture-queue-confined stream state: packetizes frames and lazily
|
|
||||||
/// emits START so packet order is guaranteed by queue serialization.
|
|
||||||
private final class StreamState {
|
|
||||||
var packetizer: VoiceBurstPacketizer
|
|
||||||
var sentStart = false
|
|
||||||
init(burstID: Data) {
|
|
||||||
packetizer = VoiceBurstPacketizer(burstID: burstID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private let stream: StreamState
|
|
||||||
private var startDate: Date?
|
|
||||||
private var completed = false
|
|
||||||
|
|
||||||
var isLive: Bool { true }
|
|
||||||
|
|
||||||
/// - Parameter sendPacket: delivers one encoded `VoiceBurstPacket` to the
|
|
||||||
/// target peer; must be safe to call from any queue (BLEService hops to
|
|
||||||
/// its own message queue internally).
|
|
||||||
init(sendPacket: @escaping (Data) -> Void) {
|
|
||||||
self.burstID = VoiceBurstPacket.makeBurstID()
|
|
||||||
self.sendPacket = sendPacket
|
|
||||||
self.stream = StreamState(burstID: burstID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func requestPermission() async -> Bool {
|
|
||||||
await VoiceRecorder.shared.requestPermission()
|
|
||||||
}
|
|
||||||
|
|
||||||
func start() async throws {
|
|
||||||
let outputURL = try Self.makeOutputURL(burstID: burstID)
|
|
||||||
let sendPacket = sendPacket
|
|
||||||
let stream = stream
|
|
||||||
capture.onFrames = { frames in
|
|
||||||
if !stream.sentStart {
|
|
||||||
stream.sentStart = true
|
|
||||||
if let start = VoiceBurstPacket(
|
|
||||||
burstID: stream.packetizer.burstID,
|
|
||||||
seq: 0,
|
|
||||||
kind: .start(codec: .aacLC16kMono)
|
|
||||||
) {
|
|
||||||
sendPacket(start.encode())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for frame in frames {
|
|
||||||
for packet in stream.packetizer.add(frame) {
|
|
||||||
sendPacket(packet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Flush per callback batch: at ~130-byte frames the budget fits
|
|
||||||
// one frame per packet anyway, and holding residue would add
|
|
||||||
// ~100 ms of avoidable latency.
|
|
||||||
for packet in stream.packetizer.flush() {
|
|
||||||
sendPacket(packet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
try capture.start(outputURL: outputURL)
|
|
||||||
} catch {
|
|
||||||
// The HAL can briefly report a dead input right after the audio
|
|
||||||
// session (re)activates while the route settles; one retry after
|
|
||||||
// a short pause covers it (observed on iPhone field tests).
|
|
||||||
SecureLogger.warning("PTT: capture start failed (\(error)) — retrying once after route settle", category: .session)
|
|
||||||
try? await Task.sleep(nanoseconds: 150_000_000)
|
|
||||||
try capture.start(outputURL: outputURL)
|
|
||||||
}
|
|
||||||
startDate = Date()
|
|
||||||
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) capture started", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
func finish() async -> URL? {
|
|
||||||
guard !completed else { return nil }
|
|
||||||
completed = true
|
|
||||||
|
|
||||||
let elapsed = startDate.map { Date().timeIntervalSince($0) } ?? 0
|
|
||||||
let (url, encodedFrames) = capture.stop()
|
|
||||||
// stop() drained the capture queue, so touching `stream` is safe now.
|
|
||||||
|
|
||||||
guard elapsed >= VoiceRecorder.minRecordingDuration, let url else {
|
|
||||||
sendControlPacket(.canceled)
|
|
||||||
if let url {
|
|
||||||
try? FileManager.default.removeItem(at: url)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for packet in stream.packetizer.flush() {
|
|
||||||
sendPacket(packet)
|
|
||||||
}
|
|
||||||
let durationMs = UInt32((Double(encodedFrames) * PTTAudioFormat.frameDuration * 1000).rounded())
|
|
||||||
sendControlPacket(.end(totalDataPackets: stream.packetizer.dataPacketCount, durationMs: durationMs))
|
|
||||||
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) finished — \(stream.packetizer.dataPacketCount) data packets, \(encodedFrames) frames, \(durationMs) ms", category: .session)
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancel() async {
|
|
||||||
guard !completed else { return }
|
|
||||||
completed = true
|
|
||||||
capture.cancel()
|
|
||||||
sendControlPacket(.canceled)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) {
|
|
||||||
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
|
|
||||||
sendPacket(packet.encode())
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func makeOutputURL(burstID: Data) throws -> URL {
|
|
||||||
let base = try FileManager.default.url(
|
|
||||||
for: .applicationSupportDirectory,
|
|
||||||
in: .userDomainMask,
|
|
||||||
appropriateFor: nil,
|
|
||||||
create: true
|
|
||||||
)
|
|
||||||
let directory = base
|
|
||||||
.appendingPathComponent("files", isDirectory: true)
|
|
||||||
.appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
|
||||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
|
||||||
return directory.appendingPathComponent("voice_\(burstID.hexEncodedString()).m4a")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -181,35 +181,23 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Something that can hold the app's single audio-playback slot and yield it
|
/// Ensures only one voice note plays at a time.
|
||||||
/// when another playback starts (voice notes pause; live bursts stop).
|
|
||||||
protocol ExclusivePlayback: AnyObject {
|
|
||||||
func pauseForExclusivity()
|
|
||||||
}
|
|
||||||
|
|
||||||
extension VoiceNotePlaybackController: ExclusivePlayback {
|
|
||||||
func pauseForExclusivity() {
|
|
||||||
pause()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ensures only one voice playback (note or live burst) runs at a time.
|
|
||||||
final class VoiceNotePlaybackCoordinator {
|
final class VoiceNotePlaybackCoordinator {
|
||||||
static let shared = VoiceNotePlaybackCoordinator()
|
static let shared = VoiceNotePlaybackCoordinator()
|
||||||
|
|
||||||
private weak var activeController: (any ExclusivePlayback)?
|
private weak var activeController: VoiceNotePlaybackController?
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
func activate(_ controller: any ExclusivePlayback) {
|
func activate(_ controller: VoiceNotePlaybackController) {
|
||||||
if activeController === controller {
|
if activeController === controller {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
activeController?.pauseForExclusivity()
|
activeController?.pause()
|
||||||
activeController = controller
|
activeController = controller
|
||||||
}
|
}
|
||||||
|
|
||||||
func deactivate(_ controller: any ExclusivePlayback) {
|
func deactivate(_ controller: VoiceNotePlaybackController) {
|
||||||
if activeController === controller {
|
if activeController === controller {
|
||||||
activeController = nil
|
activeController = nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import AVFoundation
|
|||||||
actor VoiceRecorder {
|
actor VoiceRecorder {
|
||||||
enum RecorderError: Error {
|
enum RecorderError: Error {
|
||||||
case microphoneAccessDenied
|
case microphoneAccessDenied
|
||||||
|
case recorderInitializationFailed
|
||||||
case recordingInProgress
|
case recordingInProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ final class WaveformCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func purgeAll() {
|
||||||
|
queue.async(flags: .barrier) { [weak self] in
|
||||||
|
self?.cache.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
|
private func computeWaveform(url: URL, bins: Int) -> [Float]? {
|
||||||
guard bins > 0 else { return nil }
|
guard bins > 0 else { return nil }
|
||||||
// Use autoreleasepool to manage memory from audio buffer allocations
|
// Use autoreleasepool to manage memory from audio buffer allocations
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ import BitFoundation
|
|||||||
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
|
||||||
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
|
||||||
struct EphemeralIdentity {
|
struct EphemeralIdentity {
|
||||||
|
let peerID: PeerID // 8 random bytes
|
||||||
|
let sessionStart: Date
|
||||||
var handshakeState: HandshakeState
|
var handshakeState: HandshakeState
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +98,7 @@ enum HandshakeState {
|
|||||||
case initiated
|
case initiated
|
||||||
case inProgress
|
case inProgress
|
||||||
case completed(fingerprint: String)
|
case completed(fingerprint: String)
|
||||||
|
case failed(reason: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair.
|
/// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair.
|
||||||
@@ -107,6 +110,7 @@ struct CryptographicIdentity: Codable {
|
|||||||
// Optional Ed25519 signing public key (used to authenticate public messages)
|
// Optional Ed25519 signing public key (used to authenticate public messages)
|
||||||
var signingPublicKey: Data? = nil
|
var signingPublicKey: Data? = nil
|
||||||
let firstSeen: Date
|
let firstSeen: Date
|
||||||
|
let lastHandshake: Date?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents the social layer of identity - user-assigned names and trust relationships.
|
/// Represents the social layer of identity - user-assigned names and trust relationships.
|
||||||
@@ -122,37 +126,13 @@ struct SocialIdentity: Codable {
|
|||||||
var notes: String?
|
var notes: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trust ladder: unknown → casual → vouched → trusted → verified.
|
|
||||||
///
|
|
||||||
/// Persistence compatibility: `TrustLevel` is stored by its *String* raw
|
|
||||||
/// value ("unknown", "casual", …), not by ordinal position, so inserting
|
|
||||||
/// `vouched` mid-ladder cannot corrupt previously persisted values — every
|
|
||||||
/// pre-existing case keeps the exact raw value it was written with. The
|
|
||||||
/// `vouched` tier is additionally never persisted into `SocialIdentity`
|
|
||||||
/// (it's recomputed on read from stored vouches), so downgraded builds never
|
|
||||||
/// encounter the unfamiliar raw value.
|
|
||||||
enum TrustLevel: String, Codable {
|
enum TrustLevel: String, Codable {
|
||||||
case unknown
|
case unknown
|
||||||
case casual
|
case casual
|
||||||
/// Transitively trusted: vouched for by at least one peer *I* verified.
|
|
||||||
/// Derived at read time — never written to persistent storage.
|
|
||||||
case vouched
|
|
||||||
case trusted
|
case trusted
|
||||||
case verified
|
case verified
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Vouching (transitive verification)
|
|
||||||
|
|
||||||
/// One accepted vouch: a peer I verified (the voucher) attested that they
|
|
||||||
/// verified the vouchee. Validity is recomputed on read — a record only
|
|
||||||
/// counts while its voucher remains in `verifiedFingerprints` and its
|
|
||||||
/// timestamp is within `VouchAttestation.maxAge` — so unverifying a voucher
|
|
||||||
/// silently invalidates the vouches they gave without a cascade delete.
|
|
||||||
struct VouchRecord: Codable, Equatable {
|
|
||||||
let voucherFingerprint: String
|
|
||||||
let timestamp: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Identity Cache
|
// MARK: - Identity Cache
|
||||||
|
|
||||||
/// Persistent storage for identity mappings and relationships.
|
/// Persistent storage for identity mappings and relationships.
|
||||||
@@ -174,21 +154,9 @@ struct IdentityCache: Codable {
|
|||||||
|
|
||||||
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
||||||
var blockedNostrPubkeys: Set<String> = []
|
var blockedNostrPubkeys: Set<String> = []
|
||||||
|
|
||||||
// Vouching (transitive verification). All three fields are Optional so
|
// Schema version for future migrations
|
||||||
// caches persisted before this feature decode cleanly — the synthesized
|
var version: Int = 1
|
||||||
// decoder uses decodeIfPresent for optionals, and a missing key must not
|
|
||||||
// trip the "unreadable cache" recovery path that discards everything.
|
|
||||||
|
|
||||||
// Vouchee fingerprint -> accepted vouches (capped per vouchee)
|
|
||||||
var vouchesByVouchee: [String: [VouchRecord]]? = nil
|
|
||||||
|
|
||||||
// Peer fingerprint -> when we last sent them a vouch batch (rate limit)
|
|
||||||
var vouchBatchSentAt: [String: Date]? = nil
|
|
||||||
|
|
||||||
// Fingerprint -> when we verified it (orders outgoing vouch batches;
|
|
||||||
// entries verified before this field exists sort as oldest)
|
|
||||||
var verifiedAt: [String: Date]? = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
func updateSocialIdentity(_ identity: SocialIdentity)
|
func updateSocialIdentity(_ identity: SocialIdentity)
|
||||||
|
|
||||||
// MARK: Favorites Management
|
// MARK: Favorites Management
|
||||||
|
func getFavorites() -> Set<String>
|
||||||
|
func setFavorite(_ fingerprint: String, isFavorite: Bool)
|
||||||
func isFavorite(fingerprint: String) -> Bool
|
func isFavorite(fingerprint: String) -> Bool
|
||||||
|
|
||||||
// MARK: Blocked Users Management
|
// MARK: Blocked Users Management
|
||||||
@@ -121,7 +123,8 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
// MARK: Ephemeral Session Management
|
// MARK: Ephemeral Session Management
|
||||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
|
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
|
||||||
|
func updateHandshakeState(peerID: PeerID, state: HandshakeState)
|
||||||
|
|
||||||
// MARK: Cleanup
|
// MARK: Cleanup
|
||||||
func clearAllIdentityData()
|
func clearAllIdentityData()
|
||||||
func removeEphemeralSession(peerID: PeerID)
|
func removeEphemeralSession(peerID: PeerID)
|
||||||
@@ -130,16 +133,6 @@ protocol SecureIdentityStateManagerProtocol {
|
|||||||
func setVerified(fingerprint: String, verified: Bool)
|
func setVerified(fingerprint: String, verified: Bool)
|
||||||
func isVerified(fingerprint: String) -> Bool
|
func isVerified(fingerprint: String) -> Bool
|
||||||
func getVerifiedFingerprints() -> Set<String>
|
func getVerifiedFingerprints() -> Set<String>
|
||||||
|
|
||||||
// MARK: Vouching (transitive verification)
|
|
||||||
@discardableResult
|
|
||||||
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool
|
|
||||||
func validVouchers(for fingerprint: String) -> [VouchRecord]
|
|
||||||
func isVouched(fingerprint: String) -> Bool
|
|
||||||
func lastVouchBatchSent(to fingerprint: String) -> Date?
|
|
||||||
func markVouchBatchSent(to fingerprint: String, at date: Date)
|
|
||||||
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
|
|
||||||
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Singleton manager for secure identity state persistence and retrieval.
|
/// Singleton manager for secure identity state persistence and retrieval.
|
||||||
@@ -318,13 +311,21 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
fingerprint: fingerprint,
|
fingerprint: fingerprint,
|
||||||
publicKey: noisePublicKey,
|
publicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
|
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
|
||||||
firstSeen: existing.firstSeen
|
firstSeen: existing.firstSeen,
|
||||||
|
lastHandshake: now
|
||||||
)
|
)
|
||||||
self.cryptographicIdentities[fingerprint] = existing
|
self.cryptographicIdentities[fingerprint] = existing
|
||||||
} else {
|
} else {
|
||||||
// Update signing key
|
// Update signing key and lastHandshake
|
||||||
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
||||||
self.cryptographicIdentities[fingerprint] = existing
|
let updated = CryptographicIdentity(
|
||||||
|
fingerprint: existing.fingerprint,
|
||||||
|
publicKey: existing.publicKey,
|
||||||
|
signingPublicKey: existing.signingPublicKey,
|
||||||
|
firstSeen: existing.firstSeen,
|
||||||
|
lastHandshake: now
|
||||||
|
)
|
||||||
|
self.cryptographicIdentities[fingerprint] = updated
|
||||||
}
|
}
|
||||||
// Persist updated state (already assigned in branches above)
|
// Persist updated state (already assigned in branches above)
|
||||||
} else {
|
} else {
|
||||||
@@ -333,7 +334,8 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
fingerprint: fingerprint,
|
fingerprint: fingerprint,
|
||||||
publicKey: noisePublicKey,
|
publicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey,
|
||||||
firstSeen: now
|
firstSeen: now,
|
||||||
|
lastHandshake: now
|
||||||
)
|
)
|
||||||
self.cryptographicIdentities[fingerprint] = entry
|
self.cryptographicIdentities[fingerprint] = entry
|
||||||
}
|
}
|
||||||
@@ -498,7 +500,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
|
|
||||||
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
|
||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
self.ephemeralSessions[peerID] = EphemeralIdentity(handshakeState: handshakeState)
|
self.ephemeralSessions[peerID] = EphemeralIdentity(
|
||||||
|
peerID: peerID,
|
||||||
|
sessionStart: Date(),
|
||||||
|
handshakeState: handshakeState
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,20 +550,16 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
queue.async(flags: .barrier) {
|
queue.async(flags: .barrier) {
|
||||||
if verified {
|
if verified {
|
||||||
self.cache.verifiedFingerprints.insert(fingerprint)
|
self.cache.verifiedFingerprints.insert(fingerprint)
|
||||||
var verifiedAt = self.cache.verifiedAt ?? [:]
|
|
||||||
verifiedAt[fingerprint] = Date()
|
|
||||||
self.cache.verifiedAt = verifiedAt
|
|
||||||
} else {
|
} else {
|
||||||
self.cache.verifiedFingerprints.remove(fingerprint)
|
self.cache.verifiedFingerprints.remove(fingerprint)
|
||||||
self.cache.verifiedAt?.removeValue(forKey: fingerprint)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update trust level if social identity exists
|
// Update trust level if social identity exists
|
||||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||||
identity.trustLevel = verified ? .verified : .casual
|
identity.trustLevel = verified ? .verified : .casual
|
||||||
self.cache.socialIdentities[fingerprint] = identity
|
self.cache.socialIdentities[fingerprint] = identity
|
||||||
}
|
}
|
||||||
|
|
||||||
self.saveIdentityCache()
|
self.saveIdentityCache()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -574,159 +576,6 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Vouching (transitive verification)
|
|
||||||
|
|
||||||
/// Maximum vouchers retained per vouchee (most recent kept).
|
|
||||||
static let maxVouchersPerVouchee = 8
|
|
||||||
|
|
||||||
/// Records an accepted vouch, enforcing every accept-policy gate that can
|
|
||||||
/// be evaluated against stored state (signature verification is the
|
|
||||||
/// caller's job — it needs the sender's announce-bound signing key):
|
|
||||||
/// - the voucher must be a fingerprint *I* verified
|
|
||||||
/// - self-vouches are ignored
|
|
||||||
/// - vouches for peers I already verified are ignored (nothing to add)
|
|
||||||
/// - attestations outside the validity window are ignored
|
|
||||||
/// - at most `maxVouchersPerVouchee` vouchers are kept per vouchee
|
|
||||||
///
|
|
||||||
/// Returns true when the vouch was stored (or refreshed).
|
|
||||||
@discardableResult
|
|
||||||
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
|
|
||||||
recordVouch(
|
|
||||||
voucheeFingerprint: voucheeFingerprint,
|
|
||||||
voucherFingerprint: voucherFingerprint,
|
|
||||||
timestamp: timestamp,
|
|
||||||
now: Date()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date, now: Date) -> Bool {
|
|
||||||
queue.sync(flags: .barrier) {
|
|
||||||
guard voucheeFingerprint != voucherFingerprint,
|
|
||||||
self.cache.verifiedFingerprints.contains(voucherFingerprint),
|
|
||||||
!self.cache.verifiedFingerprints.contains(voucheeFingerprint) else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
let age = now.timeIntervalSince(timestamp)
|
|
||||||
guard age <= VouchAttestation.maxAge, age >= -VouchAttestation.maxClockSkew else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
var records = self.cache.vouchesByVouchee?[voucheeFingerprint] ?? []
|
|
||||||
if let index = records.firstIndex(where: { $0.voucherFingerprint == voucherFingerprint }) {
|
|
||||||
let newest = max(records[index].timestamp, timestamp)
|
|
||||||
records[index] = VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: newest)
|
|
||||||
} else {
|
|
||||||
records.append(VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: timestamp))
|
|
||||||
}
|
|
||||||
// Keep the most recent vouchers up to the cap.
|
|
||||||
records.sort { $0.timestamp > $1.timestamp }
|
|
||||||
let capped = Array(records.prefix(Self.maxVouchersPerVouchee))
|
|
||||||
guard capped.contains(where: { $0.voucherFingerprint == voucherFingerprint }) else {
|
|
||||||
return false // Full of fresher vouches; nothing changed.
|
|
||||||
}
|
|
||||||
|
|
||||||
var vouches = self.cache.vouchesByVouchee ?? [:]
|
|
||||||
vouches[voucheeFingerprint] = capped
|
|
||||||
self.cache.vouchesByVouchee = vouches
|
|
||||||
self.saveIdentityCache()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The vouches that currently count for `fingerprint`. Validity is
|
|
||||||
/// recomputed here rather than maintained by cascade deletes: a record
|
|
||||||
/// only counts while its voucher is still verified-by-me and its
|
|
||||||
/// timestamp is within the expiry window.
|
|
||||||
func validVouchers(for fingerprint: String) -> [VouchRecord] {
|
|
||||||
validVouchers(for: fingerprint, now: Date())
|
|
||||||
}
|
|
||||||
|
|
||||||
func validVouchers(for fingerprint: String, now: Date) -> [VouchRecord] {
|
|
||||||
queue.sync {
|
|
||||||
self.validVouchersLocked(for: fingerprint, now: now)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Requires `queue`.
|
|
||||||
private func validVouchersLocked(for fingerprint: String, now: Date) -> [VouchRecord] {
|
|
||||||
guard let records = cache.vouchesByVouchee?[fingerprint] else { return [] }
|
|
||||||
return records.filter { record in
|
|
||||||
record.voucherFingerprint != fingerprint
|
|
||||||
&& cache.verifiedFingerprints.contains(record.voucherFingerprint)
|
|
||||||
&& now.timeIntervalSince(record.timestamp) <= VouchAttestation.maxAge
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// True when the peer has at least one valid vouch and no explicit
|
|
||||||
/// verification of ours.
|
|
||||||
func isVouched(fingerprint: String) -> Bool {
|
|
||||||
isVouched(fingerprint: fingerprint, now: Date())
|
|
||||||
}
|
|
||||||
|
|
||||||
func isVouched(fingerprint: String, now: Date) -> Bool {
|
|
||||||
queue.sync {
|
|
||||||
guard !self.cache.verifiedFingerprints.contains(fingerprint) else { return false }
|
|
||||||
return !self.validVouchersLocked(for: fingerprint, now: now).isEmpty
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The trust level to display: explicit verification wins, then the
|
|
||||||
/// persisted level, with `vouched` layered in (derived, never persisted)
|
|
||||||
/// between `casual` and `trusted`.
|
|
||||||
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel {
|
|
||||||
effectiveTrustLevel(for: fingerprint, now: Date())
|
|
||||||
}
|
|
||||||
|
|
||||||
func effectiveTrustLevel(for fingerprint: String, now: Date) -> TrustLevel {
|
|
||||||
queue.sync {
|
|
||||||
if self.cache.verifiedFingerprints.contains(fingerprint) { return .verified }
|
|
||||||
let stored = self.cache.socialIdentities[fingerprint]?.trustLevel ?? .unknown
|
|
||||||
let vouched = !self.validVouchersLocked(for: fingerprint, now: now).isEmpty
|
|
||||||
switch stored {
|
|
||||||
case .verified, .trusted:
|
|
||||||
return stored
|
|
||||||
case .vouched, .casual, .unknown:
|
|
||||||
if vouched { return .vouched }
|
|
||||||
// `.vouched` should never be persisted; degrade defensively.
|
|
||||||
return stored == .vouched ? .casual : stored
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func lastVouchBatchSent(to fingerprint: String) -> Date? {
|
|
||||||
queue.sync { cache.vouchBatchSentAt?[fingerprint] }
|
|
||||||
}
|
|
||||||
|
|
||||||
func markVouchBatchSent(to fingerprint: String, at date: Date) {
|
|
||||||
queue.async(flags: .barrier) {
|
|
||||||
var sentAt = self.cache.vouchBatchSentAt ?? [:]
|
|
||||||
sentAt[fingerprint] = date
|
|
||||||
self.cache.vouchBatchSentAt = sentAt
|
|
||||||
self.saveIdentityCache()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The peer's announce-bound Ed25519 signing key, if seen this session.
|
|
||||||
func signingPublicKey(forFingerprint fingerprint: String) -> Data? {
|
|
||||||
queue.sync { cryptographicIdentities[fingerprint]?.signingPublicKey }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verified fingerprints ordered most recently verified first (entries
|
|
||||||
/// without a recorded verification time sort last), excluding the given
|
|
||||||
/// fingerprint. Feeds the outgoing vouch batch.
|
|
||||||
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
|
|
||||||
queue.sync {
|
|
||||||
let verifiedAt = cache.verifiedAt ?? [:]
|
|
||||||
let ordered = cache.verifiedFingerprints
|
|
||||||
.filter { $0 != fingerprint }
|
|
||||||
.sorted {
|
|
||||||
(verifiedAt[$0] ?? .distantPast, $0) > (verifiedAt[$1] ?? .distantPast, $1)
|
|
||||||
}
|
|
||||||
return Array(ordered.prefix(limit))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var debugNicknameIndex: [String: Set<String>] {
|
var debugNicknameIndex: [String: Set<String>] {
|
||||||
queue.sync { cache.nicknameIndex }
|
queue.sync { cache.nicknameIndex }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ extension BitchatMessage {
|
|||||||
enum Media {
|
enum Media {
|
||||||
case voice(URL)
|
case voice(URL)
|
||||||
case image(URL)
|
case image(URL)
|
||||||
|
|
||||||
|
var url: URL {
|
||||||
|
switch self {
|
||||||
|
case .voice(let url), .image(let url):
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
// Cache the directory lookup to avoid repeated FileManager calls during view rendering
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ struct BitchatPeer: Equatable {
|
|||||||
let peerID: PeerID // Hex-encoded peer ID
|
let peerID: PeerID // Hex-encoded peer ID
|
||||||
let noisePublicKey: Data
|
let noisePublicKey: Data
|
||||||
let nickname: String
|
let nickname: String
|
||||||
|
let lastSeen: Date
|
||||||
let isConnected: Bool
|
let isConnected: Bool
|
||||||
let isReachable: Bool
|
let isReachable: Bool
|
||||||
|
|
||||||
@@ -76,13 +77,14 @@ struct BitchatPeer: Equatable {
|
|||||||
peerID: PeerID,
|
peerID: PeerID,
|
||||||
noisePublicKey: Data,
|
noisePublicKey: Data,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
lastSeen _: Date = Date(),
|
lastSeen: Date = Date(),
|
||||||
isConnected: Bool = false,
|
isConnected: Bool = false,
|
||||||
isReachable: Bool = false
|
isReachable: Bool = false
|
||||||
) {
|
) {
|
||||||
self.peerID = peerID
|
self.peerID = peerID
|
||||||
self.noisePublicKey = noisePublicKey
|
self.noisePublicKey = noisePublicKey
|
||||||
self.nickname = nickname
|
self.nickname = nickname
|
||||||
|
self.lastSeen = lastSeen
|
||||||
self.isConnected = isConnected
|
self.isConnected = isConnected
|
||||||
self.isReachable = isReachable
|
self.isReachable = isReachable
|
||||||
|
|
||||||
|
|||||||
@@ -16,19 +16,14 @@ enum CommandInfo: String, Identifiable {
|
|||||||
// suggesting a spelling the processor rejects teaches users dead ends.
|
// suggesting a spelling the processor rejects teaches users dead ends.
|
||||||
case block
|
case block
|
||||||
case clear
|
case clear
|
||||||
case group
|
|
||||||
case help
|
case help
|
||||||
case hug
|
case hug
|
||||||
case message = "msg"
|
case message = "msg"
|
||||||
case slap
|
case slap
|
||||||
case pay
|
|
||||||
case unblock
|
case unblock
|
||||||
case who
|
case who
|
||||||
case favorite = "fav"
|
case favorite = "fav"
|
||||||
case unfavorite = "unfav"
|
case unfavorite = "unfav"
|
||||||
case ping
|
|
||||||
case trace
|
|
||||||
case drop
|
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
@@ -36,14 +31,8 @@ enum CommandInfo: String, Identifiable {
|
|||||||
|
|
||||||
var placeholder: String? {
|
var placeholder: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace:
|
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
||||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
||||||
case .group:
|
|
||||||
return "<" + String(localized: "content.input.group_placeholder") + ">"
|
|
||||||
case .pay:
|
|
||||||
return "<" + String(localized: "content.input.token_placeholder") + ">"
|
|
||||||
case .drop:
|
|
||||||
return "<" + String(localized: "content.input.note_placeholder") + ">"
|
|
||||||
case .clear, .help, .who:
|
case .clear, .help, .who:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -53,36 +42,24 @@ enum CommandInfo: String, Identifiable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .block: String(localized: "content.commands.block")
|
case .block: String(localized: "content.commands.block")
|
||||||
case .clear: String(localized: "content.commands.clear")
|
case .clear: String(localized: "content.commands.clear")
|
||||||
case .group: String(localized: "content.commands.group")
|
|
||||||
case .help: String(localized: "content.commands.help")
|
case .help: String(localized: "content.commands.help")
|
||||||
case .hug: String(localized: "content.commands.hug")
|
case .hug: String(localized: "content.commands.hug")
|
||||||
case .message: String(localized: "content.commands.message")
|
case .message: String(localized: "content.commands.message")
|
||||||
case .pay: String(localized: "content.commands.pay")
|
|
||||||
case .slap: String(localized: "content.commands.slap")
|
case .slap: String(localized: "content.commands.slap")
|
||||||
case .unblock: String(localized: "content.commands.unblock")
|
case .unblock: String(localized: "content.commands.unblock")
|
||||||
case .who: String(localized: "content.commands.who")
|
case .who: String(localized: "content.commands.who")
|
||||||
case .favorite: String(localized: "content.commands.favorite")
|
case .favorite: String(localized: "content.commands.favorite")
|
||||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
case .unfavorite: String(localized: "content.commands.unfavorite")
|
||||||
case .ping: String(localized: "content.commands.ping")
|
|
||||||
case .trace: String(localized: "content.commands.trace")
|
|
||||||
case .drop: String(localized: "content.commands.drop")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
||||||
var commands: [CommandInfo] = [.block, .unblock, .clear, .drop, .help, .hug, .message, .slap, .who]
|
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
|
||||||
// Cashu tokens are bearer instruments: in a public geohash any nearby
|
// The processor rejects favorites in geohash contexts, so only
|
||||||
// stranger can redeem one, so don't *suggest* /pay there (the
|
// suggest them where they actually work: mesh.
|
||||||
// processor still allows it behind an explicit "public" confirm).
|
|
||||||
// Payments make sense in every DM and in mesh public.
|
|
||||||
if !isGeoPublic {
|
|
||||||
commands.append(.pay)
|
|
||||||
}
|
|
||||||
// The processor rejects favorites, groups, and mesh diagnostics in
|
|
||||||
// geohash contexts, so only suggest them where they work: mesh.
|
|
||||||
if isGeoPublic || isGeoDM {
|
if isGeoPublic || isGeoDM {
|
||||||
return commands
|
return baseCommands
|
||||||
}
|
}
|
||||||
return commands + [.favorite, .unfavorite, .ping, .trace, .group]
|
return baseCommands + [.favorite, .unfavorite]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,10 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// REQUEST_SYNC payload TLV (type, length16, value)
|
// REQUEST_SYNC payload TLV (type, length16, value)
|
||||||
// - 0x01: P (uint8) — Golomb-Rice parameter
|
// - 0x01: P (uint8) — Golomb-Rice parameter
|
||||||
// - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
|
// - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
|
||||||
// - 0x03: data (opaque) — GR bitstream bytes (MSB-first)
|
// - 0x03: data (opaque) — GR bitstream bytes (MSB-first)
|
||||||
// - 0x04: types (SyncTypeFlags) — packet types the filter covers
|
|
||||||
// - 0x05: sinceTimestamp (uint64, big-endian) — filter coverage cursor
|
|
||||||
// - 0x06: fragmentIdFilter (UTF-8) — comma-separated 16-hex-char (8-byte)
|
|
||||||
// fragment stream IDs; restricts the fragment diff to exactly those
|
|
||||||
// streams (targeted resync for stalled reassemblies)
|
|
||||||
struct RequestSyncPacket {
|
struct RequestSyncPacket {
|
||||||
/// Maximum fragment IDs one 0x06 filter may carry. Each ID encodes as
|
|
||||||
/// 16 hex chars plus a comma separator, so the largest encoded value is
|
|
||||||
/// 60 * 17 - 1 = 1019 bytes, which fits the 1024-byte decoder cap.
|
|
||||||
static let maxFragmentIdFilterCount = 60
|
|
||||||
|
|
||||||
let p: Int
|
let p: Int
|
||||||
let m: UInt32
|
let m: UInt32
|
||||||
let data: Data
|
let data: Data
|
||||||
@@ -23,29 +12,6 @@ struct RequestSyncPacket {
|
|||||||
let sinceTimestamp: UInt64?
|
let sinceTimestamp: UInt64?
|
||||||
let fragmentIdFilter: String?
|
let fragmentIdFilter: String?
|
||||||
|
|
||||||
/// Encodes 8-byte fragment stream IDs as the 0x06 filter string,
|
|
||||||
/// dropping malformed IDs and capping at `maxFragmentIdFilterCount`.
|
|
||||||
static func encodeFragmentIdFilter(_ fragmentIDs: [Data]) -> String? {
|
|
||||||
let tokens = fragmentIDs
|
|
||||||
.filter { $0.count == 8 }
|
|
||||||
.prefix(maxFragmentIdFilterCount)
|
|
||||||
.map { $0.hexEncodedString() }
|
|
||||||
guard !tokens.isEmpty else { return nil }
|
|
||||||
return tokens.joined(separator: ",")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decodes a 0x06 filter string back into 8-byte fragment stream IDs,
|
|
||||||
/// ignoring malformed tokens and capping at `maxFragmentIdFilterCount`.
|
|
||||||
static func decodeFragmentIdFilter(_ filter: String?) -> Set<Data>? {
|
|
||||||
guard let filter else { return nil }
|
|
||||||
var ids: Set<Data> = []
|
|
||||||
for token in filter.split(separator: ",").prefix(maxFragmentIdFilterCount) {
|
|
||||||
guard token.count == 16, let id = Data(hexString: String(token)) else { continue }
|
|
||||||
ids.insert(id)
|
|
||||||
}
|
|
||||||
return ids.isEmpty ? nil : ids
|
|
||||||
}
|
|
||||||
|
|
||||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
|
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
|
||||||
self.p = p
|
self.p = p
|
||||||
self.m = m
|
self.m = m
|
||||||
@@ -122,9 +88,7 @@ struct RequestSyncPacket {
|
|||||||
sinceTimestamp = ts
|
sinceTimestamp = ts
|
||||||
}
|
}
|
||||||
case 0x06:
|
case 0x06:
|
||||||
// Same acceptance cap as the GCS payload; an oversized filter
|
if let fid = String(data: v, encoding: .utf8) {
|
||||||
// is ignored rather than failing the whole request.
|
|
||||||
if v.count <= maxAcceptBytes, let fid = String(data: v, encoding: .utf8) {
|
|
||||||
fragmentIdFilter = fid
|
fragmentIdFilter = fid
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -723,7 +723,7 @@ final class NoiseHandshakeState {
|
|||||||
return messageBuffer
|
return messageBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
func readMessage(_ message: Data, expectedPayloadLength _: Int = 0) throws -> Data {
|
func readMessage(_ message: Data, expectedPayloadLength: Int = 0) throws -> Data {
|
||||||
|
|
||||||
guard currentPattern < messagePatterns.count else {
|
guard currentPattern < messagePatterns.count else {
|
||||||
throw NoiseError.handshakeComplete
|
throw NoiseError.handshakeComplete
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ enum NoiseSecurityConstants {
|
|||||||
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
|
||||||
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
|
||||||
|
|
||||||
|
// Handshake timeout - abandon incomplete handshakes
|
||||||
|
static let handshakeTimeout: TimeInterval = 60 // 1 minute
|
||||||
|
|
||||||
|
// Maximum concurrent sessions per peer
|
||||||
|
static let maxSessionsPerPeer = 3
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
static let maxHandshakesPerMinute = 10
|
static let maxHandshakesPerMinute = 10
|
||||||
static let maxMessagesPerSecond = 100
|
static let maxMessagesPerSecond = 100
|
||||||
|
|||||||
@@ -14,4 +14,5 @@ enum NoiseSecurityError: Error {
|
|||||||
case messageTooLarge
|
case messageTooLarge
|
||||||
case invalidPeerID
|
case invalidPeerID
|
||||||
case rateLimitExceeded
|
case rateLimitExceeded
|
||||||
|
case handshakeTimeout
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import BitFoundation
|
|||||||
|
|
||||||
final class NoiseSessionManager {
|
final class NoiseSessionManager {
|
||||||
private var sessions: [PeerID: NoiseSession] = [:]
|
private var sessions: [PeerID: NoiseSession] = [:]
|
||||||
|
private let localStaticKey: Curve25519.KeyAgreement.PrivateKey
|
||||||
|
private let keychain: KeychainManagerProtocol
|
||||||
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
||||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||||
|
|
||||||
@@ -21,6 +23,8 @@ final class NoiseSessionManager {
|
|||||||
var onSessionFailed: ((PeerID, Error) -> Void)?
|
var onSessionFailed: ((PeerID, Error) -> Void)?
|
||||||
|
|
||||||
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
init(localStaticKey: Curve25519.KeyAgreement.PrivateKey, keychain: KeychainManagerProtocol) {
|
||||||
|
self.localStaticKey = localStaticKey
|
||||||
|
self.keychain = keychain
|
||||||
self.sessionFactory = { peerID, role in
|
self.sessionFactory = { peerID, role in
|
||||||
SecureNoiseSession(
|
SecureNoiseSession(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
@@ -33,10 +37,12 @@ final class NoiseSessionManager {
|
|||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
init(
|
init(
|
||||||
localStaticKey _: Curve25519.KeyAgreement.PrivateKey,
|
localStaticKey: Curve25519.KeyAgreement.PrivateKey,
|
||||||
keychain _: KeychainManagerProtocol,
|
keychain: KeychainManagerProtocol,
|
||||||
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
|
sessionFactory: @escaping (PeerID, NoiseRole) -> NoiseSession
|
||||||
) {
|
) {
|
||||||
|
self.localStaticKey = localStaticKey
|
||||||
|
self.keychain = keychain
|
||||||
self.sessionFactory = sessionFactory
|
self.sessionFactory = sessionFactory
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ struct NostrIdentity: Codable {
|
|||||||
let privateKey: Data
|
let privateKey: Data
|
||||||
let publicKey: Data
|
let publicKey: Data
|
||||||
let npub: String // Bech32-encoded public key
|
let npub: String // Bech32-encoded public key
|
||||||
|
let createdAt: Date
|
||||||
|
|
||||||
/// Memberwise initializer
|
/// Memberwise initializer
|
||||||
init(privateKey: Data, publicKey: Data, npub: String, createdAt _: Date) {
|
init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) {
|
||||||
self.privateKey = privateKey
|
self.privateKey = privateKey
|
||||||
self.publicKey = publicKey
|
self.publicKey = publicKey
|
||||||
self.npub = npub
|
self.npub = npub
|
||||||
|
self.createdAt = createdAt
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a new Nostr identity
|
/// Generate a new Nostr identity
|
||||||
@@ -37,6 +39,12 @@ struct NostrIdentity: Codable {
|
|||||||
self.privateKey = privateKeyData
|
self.privateKey = privateKeyData
|
||||||
self.publicKey = xOnlyPubkey
|
self.publicKey = xOnlyPubkey
|
||||||
self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
|
self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
|
||||||
|
self.createdAt = Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get signing key for event signatures
|
||||||
|
func signingKey() throws -> P256K.Signing.PrivateKey {
|
||||||
|
try P256K.Signing.PrivateKey(dataRepresentation: privateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get Schnorr signing key for Nostr event signatures
|
/// Get Schnorr signing key for Nostr event signatures
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ final class NostrIdentityBridge {
|
|||||||
|
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) {
|
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +37,14 @@ final class NostrIdentityBridge {
|
|||||||
return nostrIdentity
|
return nostrIdentity
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Associate a Nostr identity with a Noise public key (for favorites)
|
||||||
|
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
|
||||||
|
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||||
|
if let data = nostrPubkey.data(using: .utf8) {
|
||||||
|
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get Nostr public key associated with a Noise public key
|
/// Get Nostr public key associated with a Noise public key
|
||||||
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
func getNostrPublicKey(for noisePublicKey: Data) -> String? {
|
||||||
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
|
||||||
@@ -49,10 +57,29 @@ final class NostrIdentityBridge {
|
|||||||
|
|
||||||
/// Clear all Nostr identity associations and current identity
|
/// Clear all Nostr identity associations and current identity
|
||||||
func clearAllAssociations() {
|
func clearAllAssociations() {
|
||||||
// Must go through the injected keychain, not raw SecItem calls:
|
let query: [String: Any] = [
|
||||||
// under test that keychain is in-memory, and a direct delete here
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
// would wipe the developer's real Nostr identity on every test run.
|
kSecAttrService as String: keychainService,
|
||||||
keychain.deleteAll(service: keychainService)
|
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||||
|
kSecReturnAttributes as String: true
|
||||||
|
]
|
||||||
|
|
||||||
|
var result: AnyObject?
|
||||||
|
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||||
|
if status == errSecSuccess, let items = result as? [[String: Any]] {
|
||||||
|
for item in items {
|
||||||
|
var deleteQuery: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: keychainService
|
||||||
|
]
|
||||||
|
if let account = item[kSecAttrAccount as String] as? String {
|
||||||
|
deleteQuery[kSecAttrAccount as String] = account
|
||||||
|
}
|
||||||
|
SecItemDelete(deleteQuery as CFDictionary)
|
||||||
|
}
|
||||||
|
} else if status == errSecItemNotFound {
|
||||||
|
// nothing persisted; no action needed
|
||||||
|
}
|
||||||
|
|
||||||
deviceSeedCache = nil
|
deviceSeedCache = nil
|
||||||
// Also drop the in-memory derived per-geohash identities. These hold the
|
// Also drop the in-memory derived per-geohash identities. These hold the
|
||||||
@@ -86,13 +113,6 @@ final class NostrIdentityBridge {
|
|||||||
return seed
|
return seed
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Derive a deterministic, unlinkable Nostr identity for a mesh-bridge
|
|
||||||
/// rendezvous cell. Distinct HMAC label keeps it unlinkable from the
|
|
||||||
/// geohash-chat identity for the same cell string.
|
|
||||||
func deriveIdentity(forBridgeRendezvous cell: String) throws -> NostrIdentity {
|
|
||||||
try deriveIdentity(forGeohash: "bridge|" + cell)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||||
/// if the candidate is not a valid secp256k1 private key.
|
/// if the candidate is not a valid secp256k1 private key.
|
||||||
|
|||||||
@@ -1,215 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import CryptoKit
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// NIP-13 proof-of-work for Nostr events.
|
|
||||||
///
|
|
||||||
/// Outgoing kind-20000 geohash messages mine a `["nonce", "<value>", "<target>"]`
|
|
||||||
/// tag so the event ID carries at least `target` leading zero bits. Inbound
|
|
||||||
/// events are scored (never hard-rejected — the network has clients that do
|
|
||||||
/// not mine): validated PoW at or above `rateLimitBypassBits` relaxes the
|
|
||||||
/// per-sender public rate limit, everything else keeps the strict limits.
|
|
||||||
enum NostrPoW {
|
|
||||||
|
|
||||||
// MARK: - Tuning
|
|
||||||
|
|
||||||
/// Difficulty (leading zero bits of the event ID) mined onto outgoing
|
|
||||||
/// geohash messages. 8 bits is ~256 hash attempts — typically well under
|
|
||||||
/// 100 ms on any supported device.
|
|
||||||
static let targetBits = 8
|
|
||||||
|
|
||||||
/// Inbound events whose validated NIP-13 difficulty is at least this many
|
|
||||||
/// bits skip the per-sender rate-limit bucket (the content-flood bucket
|
|
||||||
/// still applies). See `MessageRateLimiter.allow`.
|
|
||||||
static let rateLimitBypassBits = 8
|
|
||||||
|
|
||||||
/// Hard cap on mining wall-clock time. When it hits, the committed target
|
|
||||||
/// steps down until a difficulty reachable in a small extra budget is
|
|
||||||
/// found and the message is sent anyway — mining never blocks sending.
|
|
||||||
static let miningTimeCap: TimeInterval = 2.0
|
|
||||||
|
|
||||||
/// Budget for each stepped-down attempt after the main cap (or a task
|
|
||||||
/// cancellation) hits.
|
|
||||||
private static let fallbackTimeCap: TimeInterval = 0.15
|
|
||||||
|
|
||||||
/// The hot loop checks the deadline and task cancellation every this many
|
|
||||||
/// hash attempts.
|
|
||||||
private static let checkInterval: UInt64 = 1024
|
|
||||||
|
|
||||||
/// The nonce value is a fixed-width hex counter so the serialized event
|
|
||||||
/// template can be mutated in place without reallocation.
|
|
||||||
private static let nonceLength = 16
|
|
||||||
|
|
||||||
// MARK: - Scoring
|
|
||||||
|
|
||||||
/// Number of leading zero bits in a byte sequence (NIP-13 difficulty of
|
|
||||||
/// an event-ID hash).
|
|
||||||
static func leadingZeroBits<Bytes: Sequence<UInt8>>(_ bytes: Bytes) -> Int {
|
|
||||||
var total = 0
|
|
||||||
for byte in bytes {
|
|
||||||
if byte == 0 {
|
|
||||||
total += 8
|
|
||||||
} else {
|
|
||||||
total += byte.leadingZeroBitCount
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validated NIP-13 difficulty of an inbound event.
|
|
||||||
///
|
|
||||||
/// The committed target in the nonce tag is what counts: the actual
|
|
||||||
/// leading zero bits of the ID must meet it (otherwise the claim is void
|
|
||||||
/// and the event scores 0), and work beyond the commitment earns no extra
|
|
||||||
/// credit — this stops spammers who mine a low target from getting lucky
|
|
||||||
/// high scores. Events without a well-formed commitment score 0.
|
|
||||||
static func validatedDifficulty(idHex: String, tags: [[String]]) -> Int {
|
|
||||||
guard let nonceTag = tags.last(where: { $0.first == "nonce" }),
|
|
||||||
nonceTag.count >= 3,
|
|
||||||
let committed = Int(nonceTag[2]),
|
|
||||||
committed > 0, committed <= 256,
|
|
||||||
let idData = Data(hexString: idHex)
|
|
||||||
else {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return leadingZeroBits(idData) >= committed ? committed : 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Mining
|
|
||||||
|
|
||||||
/// Mine a `["nonce", value, target]` tag for the given unsigned-event
|
|
||||||
/// fields. Nonisolated async: runs off the calling actor.
|
|
||||||
///
|
|
||||||
/// Bounded by `miningTimeCap`: when the cap hits — or the surrounding
|
|
||||||
/// task is cancelled — the committed target steps down (halving to 0,
|
|
||||||
/// which any hash satisfies) so the event still ships promptly with an
|
|
||||||
/// honest commitment at the difficulty actually reached. Returns nil only
|
|
||||||
/// if canonical serialization fails; the caller then sends unmined.
|
|
||||||
static func mineNonceTag(
|
|
||||||
pubkey: String,
|
|
||||||
createdAt: Int,
|
|
||||||
kind: Int,
|
|
||||||
tags: [[String]],
|
|
||||||
content: String,
|
|
||||||
targetBits: Int = NostrPoW.targetBits
|
|
||||||
) async -> [String]? {
|
|
||||||
var target = min(max(targetBits, 0), 256)
|
|
||||||
var budget = miningTimeCap
|
|
||||||
while true {
|
|
||||||
if let tag = mineAttempt(
|
|
||||||
pubkey: pubkey,
|
|
||||||
createdAt: createdAt,
|
|
||||||
kind: kind,
|
|
||||||
baseTags: tags,
|
|
||||||
content: content,
|
|
||||||
targetBits: target,
|
|
||||||
budget: budget
|
|
||||||
) {
|
|
||||||
return tag
|
|
||||||
}
|
|
||||||
// Target 0 succeeds on the first hash, so reaching it with nil
|
|
||||||
// means serialization itself failed — give up on mining.
|
|
||||||
if target == 0 { return nil }
|
|
||||||
target /= 2
|
|
||||||
budget = fallbackTimeCap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One bounded mining pass at a fixed committed target. Allocation-light:
|
|
||||||
/// the canonical serialization is built once and only the fixed-width
|
|
||||||
/// nonce bytes are rewritten per attempt (the event ID is recomputed for
|
|
||||||
/// every attempt, per NIP-13). Returns nil on timeout/cancellation or if
|
|
||||||
/// the template could not be built.
|
|
||||||
private static func mineAttempt(
|
|
||||||
pubkey: String,
|
|
||||||
createdAt: Int,
|
|
||||||
kind: Int,
|
|
||||||
baseTags: [[String]],
|
|
||||||
content: String,
|
|
||||||
targetBits: Int,
|
|
||||||
budget: TimeInterval
|
|
||||||
) -> [String]? {
|
|
||||||
let targetString = String(targetBits)
|
|
||||||
guard let template = serializedTemplate(
|
|
||||||
pubkey: pubkey,
|
|
||||||
createdAt: createdAt,
|
|
||||||
kind: kind,
|
|
||||||
baseTags: baseTags,
|
|
||||||
content: content,
|
|
||||||
targetString: targetString
|
|
||||||
) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var buffer = template.buffer
|
|
||||||
let nonceRange = template.nonceRange
|
|
||||||
|
|
||||||
let deadline = DispatchTime.now().uptimeNanoseconds &+ UInt64(budget * 1_000_000_000)
|
|
||||||
let hexDigits = [UInt8]("0123456789abcdef".utf8)
|
|
||||||
var nonce = UInt64.random(in: .min ... .max)
|
|
||||||
var attempts: UInt64 = 0
|
|
||||||
|
|
||||||
while true {
|
|
||||||
// Write the nonce as 16 lowercase hex chars, in place.
|
|
||||||
var value = nonce
|
|
||||||
var index = nonceRange.upperBound
|
|
||||||
while index > nonceRange.lowerBound {
|
|
||||||
index -= 1
|
|
||||||
buffer[index] = hexDigits[Int(value & 0xF)]
|
|
||||||
value >>= 4
|
|
||||||
}
|
|
||||||
|
|
||||||
if leadingZeroBits(SHA256.hash(data: buffer)) >= targetBits {
|
|
||||||
// Identical to the bytes just written into the buffer.
|
|
||||||
return ["nonce", String(format: "%016llx", nonce), targetString]
|
|
||||||
}
|
|
||||||
|
|
||||||
nonce &+= 1
|
|
||||||
attempts &+= 1
|
|
||||||
if attempts % checkInterval == 0,
|
|
||||||
Task.isCancelled || DispatchTime.now().uptimeNanoseconds >= deadline {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Canonical NIP-01 serialization of the event with a placeholder nonce,
|
|
||||||
/// plus the byte range of the nonce value inside it.
|
|
||||||
///
|
|
||||||
/// The range is located by serializing twice with two same-length
|
|
||||||
/// placeholders and diffing the buffers — the only differing bytes are
|
|
||||||
/// the nonce value, so this stays correct however `JSONSerialization`
|
|
||||||
/// escapes the surrounding fields (and even if the content contains the
|
|
||||||
/// placeholder text itself).
|
|
||||||
private static func serializedTemplate(
|
|
||||||
pubkey: String,
|
|
||||||
createdAt: Int,
|
|
||||||
kind: Int,
|
|
||||||
baseTags: [[String]],
|
|
||||||
content: String,
|
|
||||||
targetString: String
|
|
||||||
) -> (buffer: Data, nonceRange: Range<Int>)? {
|
|
||||||
func serialize(noncePlaceholder: String) -> Data? {
|
|
||||||
var tags = baseTags
|
|
||||||
tags.append(["nonce", noncePlaceholder, targetString])
|
|
||||||
let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content]
|
|
||||||
return try? JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let zeros = serialize(noncePlaceholder: String(repeating: "0", count: nonceLength)),
|
|
||||||
let effs = serialize(noncePlaceholder: String(repeating: "f", count: nonceLength)),
|
|
||||||
zeros.count == effs.count
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var firstDiff = -1
|
|
||||||
var lastDiff = -1
|
|
||||||
for index in 0..<zeros.count where zeros[index] != effs[index] {
|
|
||||||
if firstDiff < 0 { firstDiff = index }
|
|
||||||
lastDiff = index
|
|
||||||
}
|
|
||||||
guard firstDiff >= 0, lastDiff - firstDiff + 1 == nonceLength else { return nil }
|
|
||||||
return (zeros, firstDiff..<(firstDiff + nonceLength))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,11 +19,6 @@ struct NostrProtocol {
|
|||||||
case giftWrap = 1059 // NIP-59 gift wrap
|
case giftWrap = 1059 // NIP-59 gift wrap
|
||||||
case ephemeralEvent = 20000
|
case ephemeralEvent = 20000
|
||||||
case geohashPresence = 20001
|
case geohashPresence = 20001
|
||||||
case deletion = 5 // NIP-09 event deletion request
|
|
||||||
/// Sealed courier envelope parked on relays under its rotating
|
|
||||||
/// recipient tag (`#x`). Regular (stored) kind so it survives until
|
|
||||||
/// its NIP-40 expiration — the whole point is store-and-forward.
|
|
||||||
case courierDrop = 1401
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a NIP-17 private message
|
/// Create a NIP-17 private message
|
||||||
@@ -175,63 +170,6 @@ struct NostrProtocol {
|
|||||||
nickname: String? = nil,
|
nickname: String? = nil,
|
||||||
teleported: Bool = false
|
teleported: Bool = false
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
let event = NostrEvent(
|
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .ephemeralEvent,
|
|
||||||
tags: ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported),
|
|
||||||
content: content
|
|
||||||
)
|
|
||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
|
||||||
return try event.sign(with: schnorrKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a kind-20000 geohash message carrying a NIP-13 proof-of-work
|
|
||||||
/// nonce tag (see `NostrPoW`). Mining runs off the calling actor and is
|
|
||||||
/// bounded by `NostrPoW.miningTimeCap`; when the cap hits (or the
|
|
||||||
/// surrounding task is cancelled) the event ships at the highest
|
|
||||||
/// committed difficulty still met, and if mining is impossible it ships
|
|
||||||
/// unmined — sending is never blocked.
|
|
||||||
static func createMinedEphemeralGeohashEvent(
|
|
||||||
content: String,
|
|
||||||
geohash: String,
|
|
||||||
senderIdentity: NostrIdentity,
|
|
||||||
nickname: String? = nil,
|
|
||||||
teleported: Bool = false,
|
|
||||||
powTargetBits: Int = NostrPoW.targetBits
|
|
||||||
) async throws -> NostrEvent {
|
|
||||||
var tags = ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported)
|
|
||||||
// Fix created_at up front: the mined nonce commits to the full
|
|
||||||
// serialized event, so the signed event must reuse the exact value.
|
|
||||||
let createdAt = Int(Date().timeIntervalSince1970)
|
|
||||||
if let nonceTag = await NostrPoW.mineNonceTag(
|
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
|
||||||
createdAt: createdAt,
|
|
||||||
kind: EventKind.ephemeralEvent.rawValue,
|
|
||||||
tags: tags,
|
|
||||||
content: content,
|
|
||||||
targetBits: powTargetBits
|
|
||||||
) {
|
|
||||||
tags.append(nonceTag)
|
|
||||||
}
|
|
||||||
let event = NostrEvent(
|
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
|
||||||
createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)),
|
|
||||||
kind: .ephemeralEvent,
|
|
||||||
tags: tags,
|
|
||||||
content: content
|
|
||||||
)
|
|
||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
|
||||||
return try event.sign(with: schnorrKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tags for a kind-20000 geohash message (shared by the plain and mined
|
|
||||||
/// variants).
|
|
||||||
private static func ephemeralGeohashTags(
|
|
||||||
geohash: String,
|
|
||||||
nickname: String?,
|
|
||||||
teleported: Bool
|
|
||||||
) -> [[String]] {
|
|
||||||
var tags = [["g", geohash]]
|
var tags = [["g", geohash]]
|
||||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||||
tags.append(["n", nickname])
|
tags.append(["n", nickname])
|
||||||
@@ -239,7 +177,15 @@ struct NostrProtocol {
|
|||||||
if teleported {
|
if teleported {
|
||||||
tags.append(["t", "teleport"])
|
tags.append(["t", "teleport"])
|
||||||
}
|
}
|
||||||
return tags
|
let event = NostrEvent(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .ephemeralEvent,
|
||||||
|
tags: tags,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
|
return try event.sign(with: schnorrKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a geohash presence heartbeat (kind 20001)
|
/// Create a geohash presence heartbeat (kind 20001)
|
||||||
@@ -260,105 +206,17 @@ struct NostrProtocol {
|
|||||||
return try event.sign(with: schnorrKey)
|
return try event.sign(with: schnorrKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Mesh bridge (rendezvous) events
|
|
||||||
|
|
||||||
/// Create a mesh-bridge public message (kind 20000) for a geohash-cell
|
|
||||||
/// rendezvous. The distinct `r` tag keeps bridge traffic out of geohash
|
|
||||||
/// channel subscriptions (which filter on `#g`); `m` carries the original
|
|
||||||
/// mesh message ID so receivers dedup the bridged copy against the radio
|
|
||||||
/// copy by timeline ID.
|
|
||||||
static func createBridgeMeshEvent(
|
|
||||||
content: String,
|
|
||||||
cell: String,
|
|
||||||
senderIdentity: NostrIdentity,
|
|
||||||
nickname: String? = nil,
|
|
||||||
meshMessageID: String? = nil
|
|
||||||
) throws -> NostrEvent {
|
|
||||||
var tags = [["r", cell]]
|
|
||||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
|
||||||
tags.append(["n", nickname])
|
|
||||||
}
|
|
||||||
if let meshMessageID = meshMessageID?.trimmedOrNilIfEmpty {
|
|
||||||
tags.append(["m", meshMessageID])
|
|
||||||
}
|
|
||||||
let event = NostrEvent(
|
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .ephemeralEvent,
|
|
||||||
tags: tags,
|
|
||||||
content: content
|
|
||||||
)
|
|
||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
|
||||||
return try event.sign(with: schnorrKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a mesh-bridge presence heartbeat (kind 20001) on a rendezvous
|
|
||||||
/// cell: empty content, `r` tag only — the bridge analogue of geohash
|
|
||||||
/// presence, counted into "people across the bridge".
|
|
||||||
static func createBridgePresenceEvent(
|
|
||||||
cell: String,
|
|
||||||
senderIdentity: NostrIdentity
|
|
||||||
) throws -> NostrEvent {
|
|
||||||
let event = NostrEvent(
|
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .geohashPresence,
|
|
||||||
tags: [["r", cell]],
|
|
||||||
content: ""
|
|
||||||
)
|
|
||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
|
||||||
return try event.sign(with: schnorrKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a courier drop (kind 1401): an opaque sealed courier envelope
|
|
||||||
/// parked on relays. `x` is the hex recipient tag the recipient (or a
|
|
||||||
/// gateway acting for them) subscribes for; the NIP-40 expiration tracks
|
|
||||||
/// the envelope expiry so honoring relays garbage-collect the drop. The
|
|
||||||
/// signing identity should be a throwaway — the envelope authenticates
|
|
||||||
/// its sender internally via Noise-X, and linking drops to a stable
|
|
||||||
/// publisher key would leak courier traffic patterns.
|
|
||||||
static func createCourierDropEvent(
|
|
||||||
envelope: Data,
|
|
||||||
recipientTagHex: String,
|
|
||||||
expiresAt: Date,
|
|
||||||
senderIdentity: NostrIdentity
|
|
||||||
) throws -> NostrEvent {
|
|
||||||
let tags = [
|
|
||||||
["x", recipientTagHex],
|
|
||||||
["expiration", String(Int(expiresAt.timeIntervalSince1970))],
|
|
||||||
]
|
|
||||||
let event = NostrEvent(
|
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .courierDrop,
|
|
||||||
tags: tags,
|
|
||||||
content: envelope.base64EncodedString()
|
|
||||||
)
|
|
||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
|
||||||
return try event.sign(with: schnorrKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
||||||
/// An optional `expiresAt` adds a NIP-40 expiration tag so honoring relays
|
|
||||||
/// drop the note in step with a bridged board post's expiry.
|
|
||||||
static func createGeohashTextNote(
|
static func createGeohashTextNote(
|
||||||
content: String,
|
content: String,
|
||||||
geohash: String,
|
geohash: String,
|
||||||
senderIdentity: NostrIdentity,
|
senderIdentity: NostrIdentity,
|
||||||
nickname: String? = nil,
|
nickname: String? = nil
|
||||||
expiresAt: Date? = nil,
|
|
||||||
urgent: Bool = false
|
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
var tags = [["g", geohash]]
|
var tags = [["g", geohash]]
|
||||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||||
tags.append(["n", nickname])
|
tags.append(["n", nickname])
|
||||||
}
|
}
|
||||||
if let expiresAt {
|
|
||||||
tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))])
|
|
||||||
}
|
|
||||||
if urgent {
|
|
||||||
tags.append(["t", "urgent"])
|
|
||||||
}
|
|
||||||
let event = NostrEvent(
|
let event = NostrEvent(
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
createdAt: Date(),
|
createdAt: Date(),
|
||||||
@@ -369,25 +227,7 @@ struct NostrProtocol {
|
|||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||||
return try event.sign(with: schnorrKey)
|
return try event.sign(with: schnorrKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a NIP-09 deletion request for one of our own events. Relays that
|
|
||||||
/// honor NIP-09 drop the referenced event; it must be signed by the same
|
|
||||||
/// key that signed the original.
|
|
||||||
static func createDeleteEvent(
|
|
||||||
ofEventID eventID: String,
|
|
||||||
senderIdentity: NostrIdentity
|
|
||||||
) throws -> NostrEvent {
|
|
||||||
let event = NostrEvent(
|
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
|
||||||
createdAt: Date(),
|
|
||||||
kind: .deletion,
|
|
||||||
tags: [["e", eventID]],
|
|
||||||
content: ""
|
|
||||||
)
|
|
||||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
|
||||||
return try event.sign(with: schnorrKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
private static func createSeal(
|
private static func createSeal(
|
||||||
@@ -634,6 +474,37 @@ struct NostrProtocol {
|
|||||||
return sharedSecretData
|
return sharedSecretData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Direct version that doesn't try to add prefixes
|
||||||
|
private static func deriveSharedSecretDirect(
|
||||||
|
privateKey: P256K.Schnorr.PrivateKey,
|
||||||
|
publicKey: Data
|
||||||
|
) throws -> Data {
|
||||||
|
// Direct shared secret calculation
|
||||||
|
|
||||||
|
// Convert Schnorr private key to KeyAgreement private key
|
||||||
|
let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey(
|
||||||
|
dataRepresentation: privateKey.dataRepresentation
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use the public key as-is (should already have prefix)
|
||||||
|
let keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey(
|
||||||
|
dataRepresentation: publicKey,
|
||||||
|
format: .compressed
|
||||||
|
)
|
||||||
|
|
||||||
|
// Perform ECDH
|
||||||
|
let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement(
|
||||||
|
with: keyAgreementPublicKey,
|
||||||
|
format: .compressed
|
||||||
|
)
|
||||||
|
|
||||||
|
// Convert SharedSecret to Data
|
||||||
|
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
|
||||||
|
|
||||||
|
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
|
||||||
|
return sharedSecretData
|
||||||
|
}
|
||||||
|
|
||||||
private static func randomizedTimestamp() -> Date {
|
private static func randomizedTimestamp() -> Date {
|
||||||
// Add random offset to current time for privacy
|
// Add random offset to current time for privacy
|
||||||
// This prevents timing correlation attacks while the actual message timestamp
|
// This prevents timing correlation attacks while the actual message timestamp
|
||||||
@@ -763,8 +634,11 @@ struct NostrEvent: Codable {
|
|||||||
|
|
||||||
enum NostrError: Error {
|
enum NostrError: Error {
|
||||||
case invalidPublicKey
|
case invalidPublicKey
|
||||||
|
case invalidPrivateKey
|
||||||
case invalidEvent
|
case invalidEvent
|
||||||
case invalidCiphertext
|
case invalidCiphertext
|
||||||
|
case signingFailed
|
||||||
|
case encryptionFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
let url: String
|
let url: String
|
||||||
var isConnected: Bool = false
|
var isConnected: Bool = false
|
||||||
var lastError: Error?
|
var lastError: Error?
|
||||||
|
var lastConnectedAt: Date?
|
||||||
var messagesSent: Int = 0
|
var messagesSent: Int = 0
|
||||||
var messagesReceived: Int = 0
|
var messagesReceived: Int = 0
|
||||||
var reconnectAttempts: Int = 0
|
var reconnectAttempts: Int = 0
|
||||||
@@ -136,10 +137,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
@Published private(set) var relays: [Relay] = []
|
@Published private(set) var relays: [Relay] = []
|
||||||
@Published private(set) var isConnected = false
|
@Published private(set) var isConnected = false
|
||||||
/// Whether a relay that carries private messages is connected. DMs
|
|
||||||
/// target the default (gift-wrap-capable) relay set, so a connected
|
|
||||||
/// geohash/custom relay alone must not count — sends would still queue.
|
|
||||||
@Published private(set) var isDMRelayConnected = false
|
|
||||||
|
|
||||||
private let dependencies: NostrRelayManagerDependencies
|
private let dependencies: NostrRelayManagerDependencies
|
||||||
private var allowDefaultRelays: Bool = false
|
private var allowDefaultRelays: Bool = false
|
||||||
@@ -183,26 +180,11 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
private var subscriptionRequestState: [String: SubscriptionRequestState] = [:]
|
private var subscriptionRequestState: [String: SubscriptionRequestState] = [:]
|
||||||
|
|
||||||
// Track EOSE per subscription to signal when initial stored events are
|
// Track EOSE per subscription to signal when initial stored events are done
|
||||||
// done. Completion is scoped to relays the REQ actually reached: targets
|
|
||||||
// still mid-connect must not hold the callback hostage until the fallback
|
|
||||||
// timer (a dead relay of five used to pin "loading" for the full 10s).
|
|
||||||
private struct EOSETracker {
|
private struct EOSETracker {
|
||||||
/// Targets the REQ has not been delivered to yet (still connecting).
|
var pendingRelays: Set<String>
|
||||||
var awaitingSend: Set<String>
|
|
||||||
/// Relays that received the REQ and have not sent EOSE yet.
|
|
||||||
var awaitingEOSE: Set<String>
|
|
||||||
/// True once any relay received the REQ (or answered with EOSE) —
|
|
||||||
/// completion with zero sends would mean "done" without ever asking.
|
|
||||||
var didSend = false
|
|
||||||
var callback: () -> Void
|
var callback: () -> Void
|
||||||
let epoch: Int
|
let epoch: Int
|
||||||
|
|
||||||
/// Done when every relay that got the REQ has resolved, provided at
|
|
||||||
/// least one did — or when every target dropped out entirely.
|
|
||||||
var isComplete: Bool {
|
|
||||||
(didSend && awaitingEOSE.isEmpty) || (awaitingSend.isEmpty && awaitingEOSE.isEmpty)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
private var eoseTrackers: [String: EOSETracker] = [:]
|
private var eoseTrackers: [String: EOSETracker] = [:]
|
||||||
private var eoseTrackerEpoch = 0
|
private var eoseTrackerEpoch = 0
|
||||||
@@ -361,6 +343,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
relays[index].nextReconnectTime = nil
|
relays[index].nextReconnectTime = nil
|
||||||
if resetState {
|
if resetState {
|
||||||
relays[index].lastError = nil
|
relays[index].lastError = nil
|
||||||
|
relays[index].lastConnectedAt = nil
|
||||||
relays[index].lastDisconnectedAt = nil
|
relays[index].lastDisconnectedAt = nil
|
||||||
relays[index].messagesSent = 0
|
relays[index].messagesSent = 0
|
||||||
relays[index].messagesReceived = 0
|
relays[index].messagesReceived = 0
|
||||||
@@ -808,7 +791,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private func startEOSETracking(id: String, relayURLs: Set<String>, callback: @escaping () -> Void) {
|
private func startEOSETracking(id: String, relayURLs: Set<String>, callback: @escaping () -> Void) {
|
||||||
eoseTrackerEpoch += 1
|
eoseTrackerEpoch += 1
|
||||||
let epoch = eoseTrackerEpoch
|
let epoch = eoseTrackerEpoch
|
||||||
eoseTrackers[id] = EOSETracker(awaitingSend: relayURLs, awaitingEOSE: [], callback: callback, epoch: epoch)
|
eoseTrackers[id] = EOSETracker(pendingRelays: relayURLs, callback: callback, epoch: epoch)
|
||||||
// Fallback timeout to avoid hanging if a relay never sends EOSE.
|
// Fallback timeout to avoid hanging if a relay never sends EOSE.
|
||||||
dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in
|
dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
@@ -948,19 +931,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
toSend[id] = state.messageString
|
toSend[id] = state.messageString
|
||||||
}
|
}
|
||||||
for (id, messageString) in toSend {
|
for (id, messageString) in toSend {
|
||||||
if self.subscriptions[relayUrl]?.contains(id) == true {
|
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
|
||||||
// Already subscribed on this relay (e.g. a tracker promoted
|
|
||||||
// after an earlier flush): its EOSE is coming, count it.
|
|
||||||
markEOSESubscribed(id: id, relayUrl: relayUrl)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
startPendingEOSETrackingIfNeeded(id: id)
|
startPendingEOSETrackingIfNeeded(id: id)
|
||||||
// Mark at send *initiation*, not in the async completion: a fast
|
|
||||||
// relay's EOSE could otherwise complete the tracker while this
|
|
||||||
// relay — REQ already on the wire — still sat in awaitingSend.
|
|
||||||
// If the send fails the socket is going down with it, and the
|
|
||||||
// disconnect settle (or the fallback timer) releases the wait.
|
|
||||||
markEOSESubscribed(id: id, relayUrl: relayUrl)
|
|
||||||
connection.send(.string(messageString)) { [weak self, weak connection] error in
|
connection.send(.string(messageString)) { [weak self, weak connection] error in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -1042,12 +1014,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
case .eose(let subId):
|
case .eose(let subId):
|
||||||
if var tracker = eoseTrackers[subId] {
|
if var tracker = eoseTrackers[subId] {
|
||||||
// An EOSE proves the relay received the REQ even if the local
|
tracker.pendingRelays.remove(relayUrl)
|
||||||
// send completion hasn't run yet.
|
if tracker.pendingRelays.isEmpty {
|
||||||
tracker.awaitingSend.remove(relayUrl)
|
|
||||||
tracker.awaitingEOSE.remove(relayUrl)
|
|
||||||
tracker.didSend = true
|
|
||||||
if tracker.isComplete {
|
|
||||||
eoseTrackers.removeValue(forKey: subId)
|
eoseTrackers.removeValue(forKey: subId)
|
||||||
tracker.callback()
|
tracker.callback()
|
||||||
} else {
|
} else {
|
||||||
@@ -1103,6 +1071,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
relays[index].isConnected = isConnected
|
relays[index].isConnected = isConnected
|
||||||
relays[index].lastError = error
|
relays[index].lastError = error
|
||||||
if isConnected {
|
if isConnected {
|
||||||
|
relays[index].lastConnectedAt = dependencies.now()
|
||||||
relays[index].reconnectAttempts = 0 // Reset on successful connection
|
relays[index].reconnectAttempts = 0 // Reset on successful connection
|
||||||
relays[index].nextReconnectTime = nil
|
relays[index].nextReconnectTime = nil
|
||||||
} else {
|
} else {
|
||||||
@@ -1118,20 +1087,15 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
private func updateConnectionStatus() {
|
private func updateConnectionStatus() {
|
||||||
isConnected = relays.contains { $0.isConnected }
|
isConnected = relays.contains { $0.isConnected }
|
||||||
// Relay URLs are normalized before entries are created, so direct
|
|
||||||
// set membership is sound.
|
|
||||||
isDMRelayConnected = relays.contains { $0.isConnected && Self.defaultRelaySet.contains($0.url) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A relay that drops before sending EOSE must not stall initial-load
|
/// A relay that drops before sending EOSE must not stall initial-load
|
||||||
/// callbacks; treat it as done and let the remaining relays (or the
|
/// callbacks; treat it as done and let the remaining relays (or the
|
||||||
/// fallback timeout) drive completion.
|
/// fallback timeout) drive completion.
|
||||||
private func settleEOSETrackers(droppingRelay relayUrl: String) {
|
private func settleEOSETrackers(droppingRelay relayUrl: String) {
|
||||||
for (id, var tracker) in eoseTrackers
|
for (id, var tracker) in eoseTrackers where tracker.pendingRelays.contains(relayUrl) {
|
||||||
where tracker.awaitingSend.contains(relayUrl) || tracker.awaitingEOSE.contains(relayUrl) {
|
tracker.pendingRelays.remove(relayUrl)
|
||||||
tracker.awaitingSend.remove(relayUrl)
|
if tracker.pendingRelays.isEmpty {
|
||||||
tracker.awaitingEOSE.remove(relayUrl)
|
|
||||||
if tracker.isComplete {
|
|
||||||
eoseTrackers.removeValue(forKey: id)
|
eoseTrackers.removeValue(forKey: id)
|
||||||
tracker.callback()
|
tracker.callback()
|
||||||
} else {
|
} else {
|
||||||
@@ -1140,24 +1104,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether any of `relayUrls` currently holds a live connection. Lets
|
|
||||||
/// subscribers distinguish "loaded, empty" from "never reached a relay"
|
|
||||||
/// when an EOSE fallback fires.
|
|
||||||
func isAnyRelayConnected(among relayUrls: [String]) -> Bool {
|
|
||||||
let targets = Set(relayUrls)
|
|
||||||
return relays.contains { targets.contains($0.url) && $0.isConnected }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marks the REQ as delivered to `relayUrl`: EOSE completion now waits on
|
|
||||||
/// this relay instead of the never-connected remainder.
|
|
||||||
private func markEOSESubscribed(id: String, relayUrl: String) {
|
|
||||||
guard var tracker = eoseTrackers[id],
|
|
||||||
tracker.awaitingSend.remove(relayUrl) != nil else { return }
|
|
||||||
tracker.awaitingEOSE.insert(relayUrl)
|
|
||||||
tracker.didSend = true
|
|
||||||
eoseTrackers[id] = tracker
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleDisconnection(relayUrl: String, error: Error) {
|
private func handleDisconnection(relayUrl: String, error: Error) {
|
||||||
connections.removeValue(forKey: relayUrl)
|
connections.removeValue(forKey: relayUrl)
|
||||||
subscriptions.removeValue(forKey: relayUrl)
|
subscriptions.removeValue(forKey: relayUrl)
|
||||||
@@ -1510,29 +1456,6 @@ struct NostrFilter: Encodable {
|
|||||||
filter.limit = limit
|
filter.limit = limit
|
||||||
return filter
|
return filter
|
||||||
}
|
}
|
||||||
|
|
||||||
// For the mesh bridge: rendezvous messages (kind 20000) and presence
|
|
||||||
// (kind 20001) tagged `#r` with one or more cells (own + neighbors).
|
|
||||||
static func bridgeRendezvous(_ cells: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
|
||||||
var filter = NostrFilter()
|
|
||||||
filter.kinds = [20000, 20001]
|
|
||||||
filter.since = since?.timeIntervalSince1970.toInt()
|
|
||||||
filter.tagFilters = ["r": cells]
|
|
||||||
filter.limit = limit
|
|
||||||
return filter
|
|
||||||
}
|
|
||||||
|
|
||||||
// For courier drops: sealed envelopes (kind 1401) parked under rotating
|
|
||||||
// recipient tags (`#x`, hex). Callers pass every candidate tag (adjacent
|
|
||||||
// UTC days x recipients) in one filter.
|
|
||||||
static func courierDrops(recipientTagsHex: [String], since: Date? = nil, limit: Int = 100) -> NostrFilter {
|
|
||||||
var filter = NostrFilter()
|
|
||||||
filter.kinds = [NostrProtocol.EventKind.courierDrop.rawValue]
|
|
||||||
filter.since = since?.timeIntervalSince1970.toInt()
|
|
||||||
filter.tagFilters = ["x": recipientTagsHex]
|
|
||||||
filter.limit = limit
|
|
||||||
return filter
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamic coding key for tag filters
|
// Dynamic coding key for tag filters
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
/// - Efficient binary message encoding
|
/// - Efficient binary message encoding
|
||||||
/// - Message fragmentation for large payloads
|
/// - Message fragmentation for large payloads
|
||||||
/// - TTL-based routing for mesh networks
|
/// - TTL-based routing for mesh networks
|
||||||
/// - Privacy features: message padding and randomized relay jitter
|
/// - Privacy features like padding and timing obfuscation
|
||||||
/// - Integration points for end-to-end encryption
|
/// - Integration points for end-to-end encryption
|
||||||
///
|
///
|
||||||
/// ## Protocol Design
|
/// ## Protocol Design
|
||||||
@@ -38,20 +38,18 @@
|
|||||||
/// 7. **Decoding**: Binary data parsed back to message objects
|
/// 7. **Decoding**: Binary data parsed back to message objects
|
||||||
///
|
///
|
||||||
/// ## Security Considerations
|
/// ## Security Considerations
|
||||||
/// - Message padding (to 256/512/1024/2048-byte blocks) obscures actual content length
|
/// - Message padding obscures actual content length
|
||||||
/// - Randomized relay jitter reduces the traffic-analysis signal; there is no
|
/// - Timing obfuscation prevents traffic analysis
|
||||||
/// cover traffic or per-message timing obfuscation
|
|
||||||
/// - Integration with Noise Protocol for E2E encryption
|
/// - Integration with Noise Protocol for E2E encryption
|
||||||
/// - No persistent identifiers in protocol headers
|
/// - No persistent identifiers in protocol headers
|
||||||
///
|
///
|
||||||
/// ## Message Types
|
/// ## Message Types
|
||||||
/// - **Announce/Leave**: Peer presence notifications
|
/// - **Announce/Leave**: Peer presence notifications
|
||||||
/// - **Message**: Public chat messages
|
/// - **Message**: User chat messages (broadcast or directed)
|
||||||
/// - **Fragment**: Multi-part message handling
|
/// - **Fragment**: Multi-part message handling
|
||||||
/// - **NoiseHandshake/NoiseEncrypted**: Encrypted channel establishment and
|
/// - **Delivery/Read**: Message acknowledgments
|
||||||
/// all private payloads (messages, delivery acks, read receipts)
|
/// - **Noise**: Encrypted channel establishment
|
||||||
/// - **CourierEnvelope**: Sealed store-and-forward mail
|
/// - **Version**: Protocol version negotiation
|
||||||
/// - **RequestSync/FileTransfer**: Gossip history sync and media transfer
|
|
||||||
///
|
///
|
||||||
/// ## Future Extensions
|
/// ## Future Extensions
|
||||||
/// The protocol is designed to be extensible:
|
/// The protocol is designed to be extensible:
|
||||||
@@ -74,28 +72,17 @@ 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
|
||||||
// Private groups (0x04/0x05 reserved by other features)
|
|
||||||
case groupInvite = 0x06 // Creator-signed group state (invite)
|
|
||||||
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
|
||||||
// Live voice (push-to-talk)
|
|
||||||
case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket)
|
|
||||||
// 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
|
||||||
// Transitive verification (web of trust)
|
|
||||||
case vouch = 0x12 // Batch of vouch attestations
|
|
||||||
|
|
||||||
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 .groupInvite: return "groupInvite"
|
|
||||||
case .groupKeyUpdate: return "groupKeyUpdate"
|
|
||||||
case .voiceFrame: return "voiceFrame"
|
|
||||||
case .verifyChallenge: return "verifyChallenge"
|
case .verifyChallenge: return "verifyChallenge"
|
||||||
case .verifyResponse: return "verifyResponse"
|
case .verifyResponse: return "verifyResponse"
|
||||||
case .vouch: return "vouch"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,12 +114,6 @@ protocol BitchatDelegate: AnyObject {
|
|||||||
// Low-level events for better separation of concerns
|
// Low-level events for better separation of concerns
|
||||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||||
|
|
||||||
// Encrypted group broadcast (opaque envelope; decrypted by the group coordinator)
|
|
||||||
func didReceiveGroupMessage(payload: Data, timestamp: Date)
|
|
||||||
|
|
||||||
// Public live-voice burst packet (signature-verified by the transport)
|
|
||||||
func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date)
|
|
||||||
|
|
||||||
// Bluetooth state updates for user notifications
|
// Bluetooth state updates for user notifications
|
||||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||||
@@ -152,14 +133,6 @@ extension BitchatDelegate {
|
|||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
|
|
||||||
func didReceiveGroupMessage(payload: Data, timestamp: Date) {
|
|
||||||
// Default empty implementation
|
|
||||||
}
|
|
||||||
|
|
||||||
func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date) {
|
|
||||||
// Default empty implementation
|
|
||||||
}
|
|
||||||
|
|
||||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||||
// Default empty implementation
|
// Default empty implementation
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,348 +0,0 @@
|
|||||||
//
|
|
||||||
// BoardPackets.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import CryptoKit
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
// MARK: - Board wire format (MessageType.boardPost payloads)
|
|
||||||
//
|
|
||||||
// TLV layout (type u8, length u16 big-endian, value), matching REQUEST_SYNC:
|
|
||||||
// - 0x01: kind (u8) — 0x01 post, 0x02 tombstone
|
|
||||||
// - 0x02: postID (16B random)
|
|
||||||
// - 0x03: geohash (UTF-8, empty = mesh-local board, max 12 chars)
|
|
||||||
// - 0x04: content (UTF-8, 1...512 bytes) [post]
|
|
||||||
// - 0x05: authorSigningKey (32B Ed25519 public key)
|
|
||||||
// - 0x06: authorNickname (UTF-8, max 64 bytes)
|
|
||||||
// - 0x07: createdAt (u64 big-endian, ms) [post]
|
|
||||||
// - 0x08: expiresAt (u64 big-endian, ms, max 7 days after createdAt) [post]
|
|
||||||
// - 0x09: flags (u8, bit0 = urgent) [post]
|
|
||||||
// - 0x0A: signature (64B Ed25519)
|
|
||||||
// - 0x0B: deletedAt (u64 big-endian, ms) [tombstone]
|
|
||||||
// Unknown TLVs are skipped for forward compatibility.
|
|
||||||
|
|
||||||
enum BoardWireConstants {
|
|
||||||
static let postIDLength = 16
|
|
||||||
static let signingKeyLength = 32
|
|
||||||
static let signatureLength = 64
|
|
||||||
static let contentMaxBytes = 512
|
|
||||||
static let nicknameMaxBytes = 64
|
|
||||||
static let geohashMaxLength = 12
|
|
||||||
/// Posts may live at most 7 days past their creation timestamp.
|
|
||||||
static let maxLifetimeMs: UInt64 = 7 * 24 * 60 * 60 * 1000
|
|
||||||
static let postSigningContext = "bitchat-board-v1"
|
|
||||||
static let tombstoneSigningContext = "bitchat-board-del-v1"
|
|
||||||
static let geohashAlphabet = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum BoardTLVType: UInt8 {
|
|
||||||
case kind = 0x01
|
|
||||||
case postID = 0x02
|
|
||||||
case geohash = 0x03
|
|
||||||
case content = 0x04
|
|
||||||
case authorSigningKey = 0x05
|
|
||||||
case authorNickname = 0x06
|
|
||||||
case createdAt = 0x07
|
|
||||||
case expiresAt = 0x08
|
|
||||||
case flags = 0x09
|
|
||||||
case signature = 0x0A
|
|
||||||
case deletedAt = 0x0B
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum BoardWireKind: UInt8 {
|
|
||||||
case post = 0x01
|
|
||||||
case tombstone = 0x02
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A signed, persistent bulletin-board notice.
|
|
||||||
struct BoardPostPacket: Equatable {
|
|
||||||
let postID: Data
|
|
||||||
/// Empty string scopes the post to the mesh-local board.
|
|
||||||
let geohash: String
|
|
||||||
let content: String
|
|
||||||
let authorSigningKey: Data
|
|
||||||
let authorNickname: String
|
|
||||||
let createdAt: UInt64
|
|
||||||
let expiresAt: UInt64
|
|
||||||
let flags: UInt8
|
|
||||||
let signature: Data
|
|
||||||
|
|
||||||
static let urgentFlag: UInt8 = 0x01
|
|
||||||
|
|
||||||
var isUrgent: Bool { flags & Self.urgentFlag != 0 }
|
|
||||||
|
|
||||||
/// Canonical bytes covered by the Ed25519 signature. Variable-length
|
|
||||||
/// fields are length-prefixed so no two field combinations can collide.
|
|
||||||
static func signingBytes(
|
|
||||||
postID: Data,
|
|
||||||
geohash: String,
|
|
||||||
content: String,
|
|
||||||
authorSigningKey: Data,
|
|
||||||
authorNickname: String,
|
|
||||||
createdAt: UInt64,
|
|
||||||
expiresAt: UInt64,
|
|
||||||
flags: UInt8
|
|
||||||
) -> Data {
|
|
||||||
var out = Data()
|
|
||||||
BoardWireEncoding.appendContext(BoardWireConstants.postSigningContext, to: &out)
|
|
||||||
out.append(postID)
|
|
||||||
BoardWireEncoding.appendLengthPrefixed(Data(geohash.utf8), to: &out)
|
|
||||||
BoardWireEncoding.appendLengthPrefixed(Data(content.utf8), to: &out)
|
|
||||||
out.append(authorSigningKey)
|
|
||||||
BoardWireEncoding.appendLengthPrefixed(Data(authorNickname.utf8), to: &out)
|
|
||||||
BoardWireEncoding.appendUInt64(createdAt, to: &out)
|
|
||||||
BoardWireEncoding.appendUInt64(expiresAt, to: &out)
|
|
||||||
out.append(flags)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
var signingBytes: Data {
|
|
||||||
Self.signingBytes(
|
|
||||||
postID: postID,
|
|
||||||
geohash: geohash,
|
|
||||||
content: content,
|
|
||||||
authorSigningKey: authorSigningKey,
|
|
||||||
authorNickname: authorNickname,
|
|
||||||
createdAt: createdAt,
|
|
||||||
expiresAt: expiresAt,
|
|
||||||
flags: flags
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func verifySignature() -> Bool {
|
|
||||||
BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A signed deletion marker. Only the author's key can produce one; receivers
|
|
||||||
/// keep it until the post's original expiry so the delete outruns the post.
|
|
||||||
struct BoardTombstonePacket: Equatable {
|
|
||||||
let postID: Data
|
|
||||||
let authorSigningKey: Data
|
|
||||||
let deletedAt: UInt64
|
|
||||||
let signature: Data
|
|
||||||
|
|
||||||
static func signingBytes(postID: Data, deletedAt: UInt64) -> Data {
|
|
||||||
var out = Data()
|
|
||||||
BoardWireEncoding.appendContext(BoardWireConstants.tombstoneSigningContext, to: &out)
|
|
||||||
out.append(postID)
|
|
||||||
BoardWireEncoding.appendUInt64(deletedAt, to: &out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
var signingBytes: Data {
|
|
||||||
Self.signingBytes(postID: postID, deletedAt: deletedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func verifySignature() -> Bool {
|
|
||||||
BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decoded board payload: either a live post or a tombstone.
|
|
||||||
enum BoardWire: Equatable {
|
|
||||||
case post(BoardPostPacket)
|
|
||||||
case tombstone(BoardTombstonePacket)
|
|
||||||
|
|
||||||
func encode() -> Data {
|
|
||||||
var out = Data()
|
|
||||||
func putTLV(_ t: BoardTLVType, _ v: Data) {
|
|
||||||
out.append(t.rawValue)
|
|
||||||
let len = UInt16(v.count)
|
|
||||||
out.append(UInt8((len >> 8) & 0xFF))
|
|
||||||
out.append(UInt8(len & 0xFF))
|
|
||||||
out.append(v)
|
|
||||||
}
|
|
||||||
switch self {
|
|
||||||
case .post(let post):
|
|
||||||
putTLV(.kind, Data([BoardWireKind.post.rawValue]))
|
|
||||||
putTLV(.postID, post.postID)
|
|
||||||
putTLV(.geohash, Data(post.geohash.utf8))
|
|
||||||
putTLV(.content, Data(post.content.utf8))
|
|
||||||
putTLV(.authorSigningKey, post.authorSigningKey)
|
|
||||||
putTLV(.authorNickname, Data(post.authorNickname.utf8))
|
|
||||||
putTLV(.createdAt, BoardWireEncoding.uint64Data(post.createdAt))
|
|
||||||
putTLV(.expiresAt, BoardWireEncoding.uint64Data(post.expiresAt))
|
|
||||||
putTLV(.flags, Data([post.flags]))
|
|
||||||
putTLV(.signature, post.signature)
|
|
||||||
case .tombstone(let tombstone):
|
|
||||||
putTLV(.kind, Data([BoardWireKind.tombstone.rawValue]))
|
|
||||||
putTLV(.postID, tombstone.postID)
|
|
||||||
putTLV(.authorSigningKey, tombstone.authorSigningKey)
|
|
||||||
putTLV(.deletedAt, BoardWireEncoding.uint64Data(tombstone.deletedAt))
|
|
||||||
putTLV(.signature, tombstone.signature)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Structural decode; the caller must still verify the signature before
|
|
||||||
/// ingesting (`verifySignature()`).
|
|
||||||
static func decode(from data: Data) -> BoardWire? {
|
|
||||||
var off = data.startIndex
|
|
||||||
var kind: BoardWireKind?
|
|
||||||
var postID: Data?
|
|
||||||
var geohash: String?
|
|
||||||
var content: String?
|
|
||||||
var contentBytes = 0
|
|
||||||
var authorSigningKey: Data?
|
|
||||||
var authorNickname: String?
|
|
||||||
var nicknameBytes = 0
|
|
||||||
var createdAt: UInt64?
|
|
||||||
var expiresAt: UInt64?
|
|
||||||
var flags: UInt8?
|
|
||||||
var signature: Data?
|
|
||||||
var deletedAt: UInt64?
|
|
||||||
|
|
||||||
while off + 3 <= data.endIndex {
|
|
||||||
let t = data[off]; off += 1
|
|
||||||
let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2
|
|
||||||
guard off + len <= data.endIndex else { return nil }
|
|
||||||
let v = data.subdata(in: off..<(off + len)); off += len
|
|
||||||
switch BoardTLVType(rawValue: t) {
|
|
||||||
case .kind:
|
|
||||||
guard v.count == 1 else { return nil }
|
|
||||||
kind = BoardWireKind(rawValue: v[v.startIndex])
|
|
||||||
case .postID:
|
|
||||||
guard v.count == BoardWireConstants.postIDLength else { return nil }
|
|
||||||
postID = v
|
|
||||||
case .geohash:
|
|
||||||
guard v.count <= BoardWireConstants.geohashMaxLength else { return nil }
|
|
||||||
geohash = String(data: v, encoding: .utf8)
|
|
||||||
case .content:
|
|
||||||
guard v.count <= BoardWireConstants.contentMaxBytes else { return nil }
|
|
||||||
contentBytes = v.count
|
|
||||||
content = String(data: v, encoding: .utf8)
|
|
||||||
case .authorSigningKey:
|
|
||||||
guard v.count == BoardWireConstants.signingKeyLength else { return nil }
|
|
||||||
authorSigningKey = v
|
|
||||||
case .authorNickname:
|
|
||||||
guard v.count <= BoardWireConstants.nicknameMaxBytes else { return nil }
|
|
||||||
nicknameBytes = v.count
|
|
||||||
authorNickname = String(data: v, encoding: .utf8)
|
|
||||||
case .createdAt:
|
|
||||||
createdAt = BoardWireEncoding.uint64(from: v)
|
|
||||||
case .expiresAt:
|
|
||||||
expiresAt = BoardWireEncoding.uint64(from: v)
|
|
||||||
case .flags:
|
|
||||||
guard v.count == 1 else { return nil }
|
|
||||||
flags = v[v.startIndex]
|
|
||||||
case .signature:
|
|
||||||
guard v.count == BoardWireConstants.signatureLength else { return nil }
|
|
||||||
signature = v
|
|
||||||
case .deletedAt:
|
|
||||||
deletedAt = BoardWireEncoding.uint64(from: v)
|
|
||||||
case nil:
|
|
||||||
continue // forward compatible; ignore unknown TLVs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let postID, let authorSigningKey, let signature else { return nil }
|
|
||||||
|
|
||||||
switch kind {
|
|
||||||
case .post:
|
|
||||||
guard let geohash, let content, let authorNickname,
|
|
||||||
let createdAt, let expiresAt, let flags,
|
|
||||||
contentBytes >= 1,
|
|
||||||
nicknameBytes <= BoardWireConstants.nicknameMaxBytes,
|
|
||||||
isValidGeohashField(geohash),
|
|
||||||
expiresAt > createdAt,
|
|
||||||
expiresAt - createdAt <= BoardWireConstants.maxLifetimeMs else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return .post(BoardPostPacket(
|
|
||||||
postID: postID,
|
|
||||||
geohash: geohash,
|
|
||||||
content: content,
|
|
||||||
authorSigningKey: authorSigningKey,
|
|
||||||
authorNickname: authorNickname,
|
|
||||||
createdAt: createdAt,
|
|
||||||
expiresAt: expiresAt,
|
|
||||||
flags: flags,
|
|
||||||
signature: signature
|
|
||||||
))
|
|
||||||
case .tombstone:
|
|
||||||
guard let deletedAt else { return nil }
|
|
||||||
return .tombstone(BoardTombstonePacket(
|
|
||||||
postID: postID,
|
|
||||||
authorSigningKey: authorSigningKey,
|
|
||||||
deletedAt: deletedAt,
|
|
||||||
signature: signature
|
|
||||||
))
|
|
||||||
case nil:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func verifySignature() -> Bool {
|
|
||||||
switch self {
|
|
||||||
case .post(let post): return post.verifySignature()
|
|
||||||
case .tombstone(let tombstone): return tombstone.verifySignature()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cheap TLV peek for relay policy: is this payload an urgent post?
|
|
||||||
/// Avoids a full decode on the hot relay path.
|
|
||||||
static func urgentFlag(in data: Data) -> Bool {
|
|
||||||
var off = data.startIndex
|
|
||||||
while off + 3 <= data.endIndex {
|
|
||||||
let t = data[off]; off += 1
|
|
||||||
let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2
|
|
||||||
guard off + len <= data.endIndex else { return false }
|
|
||||||
if t == BoardTLVType.flags.rawValue, len == 1 {
|
|
||||||
return data[off] & BoardPostPacket.urgentFlag != 0
|
|
||||||
}
|
|
||||||
off += len
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Empty geohash = mesh-local board; otherwise 1-12 chars of the geohash
|
|
||||||
/// base32 alphabet.
|
|
||||||
private static func isValidGeohashField(_ geohash: String) -> Bool {
|
|
||||||
geohash.isEmpty || geohash.allSatisfy { BoardWireConstants.geohashAlphabet.contains($0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum BoardWireEncoding {
|
|
||||||
static func appendContext(_ context: String, to out: inout Data) {
|
|
||||||
let bytes = Data(context.utf8)
|
|
||||||
out.append(UInt8(min(bytes.count, 255)))
|
|
||||||
out.append(bytes.prefix(255))
|
|
||||||
}
|
|
||||||
|
|
||||||
static func appendLengthPrefixed(_ value: Data, to out: inout Data) {
|
|
||||||
let len = UInt16(min(value.count, Int(UInt16.max)))
|
|
||||||
out.append(UInt8((len >> 8) & 0xFF))
|
|
||||||
out.append(UInt8(len & 0xFF))
|
|
||||||
out.append(value.prefix(Int(UInt16.max)))
|
|
||||||
}
|
|
||||||
|
|
||||||
static func appendUInt64(_ value: UInt64, to out: inout Data) {
|
|
||||||
var be = value.bigEndian
|
|
||||||
withUnsafeBytes(of: &be) { out.append(contentsOf: $0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
static func uint64Data(_ value: UInt64) -> Data {
|
|
||||||
var out = Data()
|
|
||||||
appendUInt64(value, to: &out)
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
static func uint64(from data: Data) -> UInt64? {
|
|
||||||
guard data.count == 8 else { return nil }
|
|
||||||
var value: UInt64 = 0
|
|
||||||
for byte in data { value = (value << 8) | UInt64(byte) }
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
static func verify(signature: Data, over message: Data, publicKey: Data) -> Bool {
|
|
||||||
guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return key.isValidSignature(signature, for: message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,11 +10,11 @@ enum Geohash {
|
|||||||
return map
|
return map
|
||||||
}()
|
}()
|
||||||
|
|
||||||
/// Validates a geohash string at any channel precision (1-12 characters).
|
/// Validates a geohash string for building-level precision (8 characters).
|
||||||
/// - Parameter geohash: The geohash string to validate
|
/// - Parameter geohash: The geohash string to validate
|
||||||
/// - Returns: true if a non-empty base32 geohash of at most 12 characters
|
/// - Returns: true if valid 8-character base32 geohash, false otherwise
|
||||||
static func isValidGeohash(_ geohash: String) -> Bool {
|
static func isValidBuildingGeohash(_ geohash: String) -> Bool {
|
||||||
guard (1...12).contains(geohash.count) else { return false }
|
guard geohash.count == 8 else { return false }
|
||||||
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
//
|
|
||||||
// NostrCarrierPacket.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Wire payload for `MessageType.nostrCarrier` (0x28): a complete, signed
|
|
||||||
/// Nostr event ferried over the mesh between a mesh-only peer and an
|
|
||||||
/// internet gateway peer.
|
|
||||||
///
|
|
||||||
/// - `toGateway` rides a DIRECTED packet (recipientID = the gateway peer):
|
|
||||||
/// a mesh-only sender asks the gateway to publish its locally signed
|
|
||||||
/// geohash event to Nostr relays.
|
|
||||||
/// - `fromGateway` rides a BROADCAST packet (default TTL): the gateway
|
|
||||||
/// rebroadcasts inbound relay events so mesh-only peers see the channel.
|
|
||||||
///
|
|
||||||
/// The carried event is public geohash chat — already plaintext on Nostr —
|
|
||||||
/// so the carrier adds no encryption. It IS signed by the originator's
|
|
||||||
/// per-geohash identity, so neither the gateway nor any mesh relay can forge
|
|
||||||
/// or alter it undetected: gateways and receivers verify the Schnorr
|
|
||||||
/// signature before acting on it.
|
|
||||||
///
|
|
||||||
/// TLV encoding with 2-byte big-endian lengths (the event JSON exceeds the
|
|
||||||
/// 1-byte TLV range used by smaller packets). Unknown TLV types are skipped
|
|
||||||
/// for forward compatibility.
|
|
||||||
struct NostrCarrierPacket: Equatable {
|
|
||||||
enum Direction: UInt8 {
|
|
||||||
case toGateway = 0x01
|
|
||||||
case fromGateway = 0x02
|
|
||||||
/// Mesh-bridge uplink: a mesh-only peer asks a bridge gateway to
|
|
||||||
/// publish its signed rendezvous event. Directed, like `toGateway`.
|
|
||||||
case toBridge = 0x03
|
|
||||||
/// Mesh-bridge downlink: a bridge gateway rebroadcasts a rendezvous
|
|
||||||
/// event from a remote island. Broadcast, like `fromGateway`.
|
|
||||||
/// Old clients fail the Direction decode on 0x03/0x04 and drop the
|
|
||||||
/// carrier quietly — bridge traffic degrades to invisible, not junk.
|
|
||||||
case fromBridge = 0x04
|
|
||||||
}
|
|
||||||
|
|
||||||
let direction: Direction
|
|
||||||
let geohash: String
|
|
||||||
/// Complete signed Nostr event JSON (id, pubkey, created_at, kind, tags,
|
|
||||||
/// content, sig).
|
|
||||||
let eventJSON: Data
|
|
||||||
|
|
||||||
/// BLE airtime cap for a carried event.
|
|
||||||
static let maxEventJSONBytes = 16 * 1024
|
|
||||||
static let maxGeohashLength = 12
|
|
||||||
|
|
||||||
private enum TLVType: UInt8 {
|
|
||||||
case direction = 0x01
|
|
||||||
case geohash = 0x02
|
|
||||||
case eventJSON = 0x03
|
|
||||||
}
|
|
||||||
|
|
||||||
init?(direction: Direction, geohash: String, eventJSON: Data) {
|
|
||||||
let geohashBytes = Data(geohash.utf8)
|
|
||||||
guard !geohashBytes.isEmpty,
|
|
||||||
geohashBytes.count <= Self.maxGeohashLength,
|
|
||||||
!eventJSON.isEmpty,
|
|
||||||
eventJSON.count <= Self.maxEventJSONBytes else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
self.direction = direction
|
|
||||||
self.geohash = geohash
|
|
||||||
self.eventJSON = eventJSON
|
|
||||||
}
|
|
||||||
|
|
||||||
init?(direction: Direction, geohash: String, event: NostrEvent) {
|
|
||||||
guard let json = try? event.jsonString(), !json.isEmpty else { return nil }
|
|
||||||
self.init(direction: direction, geohash: geohash, eventJSON: Data(json.utf8))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decodes the carried event. Callers MUST still verify
|
|
||||||
/// `event.isValidSignature()` before publishing or displaying it.
|
|
||||||
func event() -> NostrEvent? {
|
|
||||||
guard let dict = try? JSONSerialization.jsonObject(with: eventJSON) as? [String: Any] else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return try? NostrEvent(from: dict)
|
|
||||||
}
|
|
||||||
|
|
||||||
func encode() -> Data? {
|
|
||||||
var data = Data()
|
|
||||||
data.reserveCapacity(eventJSON.count + geohash.utf8.count + 12)
|
|
||||||
|
|
||||||
func appendTLV(_ type: TLVType, _ value: Data) {
|
|
||||||
data.append(type.rawValue)
|
|
||||||
data.append(UInt8((value.count >> 8) & 0xFF))
|
|
||||||
data.append(UInt8(value.count & 0xFF))
|
|
||||||
data.append(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
appendTLV(.direction, Data([direction.rawValue]))
|
|
||||||
appendTLV(.geohash, Data(geohash.utf8))
|
|
||||||
appendTLV(.eventJSON, eventJSON)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
static func decode(_ data: Data) -> NostrCarrierPacket? {
|
|
||||||
// Defensive slice re-base (Data slices keep parent indices).
|
|
||||||
let data = Data(data)
|
|
||||||
var offset = 0
|
|
||||||
var direction: Direction?
|
|
||||||
var geohash: String?
|
|
||||||
var eventJSON: Data?
|
|
||||||
|
|
||||||
while offset + 3 <= data.count {
|
|
||||||
let typeRaw = data[offset]
|
|
||||||
let length = (Int(data[offset + 1]) << 8) | Int(data[offset + 2])
|
|
||||||
offset += 3
|
|
||||||
guard offset + length <= data.count else { return nil }
|
|
||||||
let value = data.subdata(in: offset..<offset + length)
|
|
||||||
offset += length
|
|
||||||
|
|
||||||
switch TLVType(rawValue: typeRaw) {
|
|
||||||
case .direction:
|
|
||||||
guard value.count == 1, let parsed = Direction(rawValue: value[0]) else { return nil }
|
|
||||||
direction = parsed
|
|
||||||
case .geohash:
|
|
||||||
guard let parsed = String(data: value, encoding: .utf8) else { return nil }
|
|
||||||
geohash = parsed
|
|
||||||
case .eventJSON:
|
|
||||||
eventJSON = value
|
|
||||||
case nil:
|
|
||||||
// Unknown TLV; skip (tolerant decoder for forward compatibility).
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
guard offset == data.count,
|
|
||||||
let direction,
|
|
||||||
let geohash,
|
|
||||||
let eventJSON else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return NostrCarrierPacket(direction: direction, geohash: geohash, eventJSON: eventJSON)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// MARK: - Protocol TLV Packets
|
// MARK: - Protocol TLV Packets
|
||||||
@@ -8,35 +7,12 @@ 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)
|
|
||||||
/// Rendezvous geohash cell this peer bridges, when advertising `.bridge`.
|
|
||||||
/// Coarse (cell-level) by design; lets mesh-only peers compose correctly
|
|
||||||
/// tagged rendezvous events without their own location fix.
|
|
||||||
let bridgeGeohash: String?
|
|
||||||
|
|
||||||
init(
|
|
||||||
nickname: String,
|
|
||||||
noisePublicKey: Data,
|
|
||||||
signingPublicKey: Data,
|
|
||||||
directNeighbors: [Data]?,
|
|
||||||
capabilities: PeerCapabilities? = nil,
|
|
||||||
bridgeGeohash: String? = nil
|
|
||||||
) {
|
|
||||||
self.nickname = nickname
|
|
||||||
self.noisePublicKey = noisePublicKey
|
|
||||||
self.signingPublicKey = signingPublicKey
|
|
||||||
self.directNeighbors = directNeighbors
|
|
||||||
self.capabilities = capabilities
|
|
||||||
self.bridgeGeohash = bridgeGeohash
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
case bridgeGeohash = 0x06
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode() -> Data? {
|
func encode() -> Data? {
|
||||||
@@ -72,24 +48,6 @@ 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TLV for bridge rendezvous cell (optional; old clients skip it)
|
|
||||||
if let bridgeGeohash = bridgeGeohash,
|
|
||||||
let cellData = bridgeGeohash.data(using: .utf8),
|
|
||||||
!cellData.isEmpty, cellData.count <= 12 {
|
|
||||||
data.append(TLVType.bridgeGeohash.rawValue)
|
|
||||||
data.append(UInt8(cellData.count))
|
|
||||||
data.append(cellData)
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,8 +57,6 @@ struct AnnouncementPacket {
|
|||||||
var noisePublicKey: Data?
|
var noisePublicKey: Data?
|
||||||
var signingPublicKey: Data?
|
var signingPublicKey: Data?
|
||||||
var directNeighbors: [Data]?
|
var directNeighbors: [Data]?
|
||||||
var capabilities: PeerCapabilities?
|
|
||||||
var bridgeGeohash: String?
|
|
||||||
|
|
||||||
while offset + 2 <= data.count {
|
while offset + 2 <= data.count {
|
||||||
let typeRaw = data[offset]
|
let typeRaw = data[offset]
|
||||||
@@ -131,12 +87,6 @@ struct AnnouncementPacket {
|
|||||||
}
|
}
|
||||||
directNeighbors = neighbors
|
directNeighbors = neighbors
|
||||||
}
|
}
|
||||||
case .capabilities:
|
|
||||||
capabilities = PeerCapabilities(encoded: Data(value))
|
|
||||||
case .bridgeGeohash:
|
|
||||||
if length <= 12 {
|
|
||||||
bridgeGeohash = String(data: value, encoding: .utf8)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||||
@@ -149,9 +99,7 @@ struct AnnouncementPacket {
|
|||||||
nickname: nickname,
|
nickname: nickname,
|
||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey,
|
||||||
directNeighbors: directNeighbors,
|
directNeighbors: directNeighbors
|
||||||
capabilities: capabilities,
|
|
||||||
bridgeGeohash: bridgeGeohash
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
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 = [.vouch, .prekeys, .groups]
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
//
|
|
||||||
// VoiceBurstPacket.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import Security
|
|
||||||
|
|
||||||
/// Audio codec of a live voice burst. START packets carry it so receivers can
|
|
||||||
/// reject bursts they can't decode instead of feeding garbage to the decoder.
|
|
||||||
enum VoiceBurstCodec: UInt8 {
|
|
||||||
/// AAC-LC, 16 kHz, mono, ~16 kbps — matches the voice-note recorder, so
|
|
||||||
/// the finalized `.m4a` and the live frames come from the same encoder
|
|
||||||
/// settings.
|
|
||||||
case aacLC16kMono = 0x01
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One packet of a live push-to-talk voice burst (the inner payload of
|
|
||||||
/// `NoisePayloadType.voiceFrame`, and — for public mesh bursts — the payload
|
|
||||||
/// of `MessageType.voiceFrame`).
|
|
||||||
///
|
|
||||||
/// Wire format:
|
|
||||||
/// ```
|
|
||||||
/// [burstID: 8][seq: UInt16 BE][flags: UInt8][payload…]
|
|
||||||
/// ```
|
|
||||||
/// - flags 0x01 (START): payload = [codec: UInt8]
|
|
||||||
/// - flags 0x02 (END): payload = [totalDataPackets: UInt16 BE][durationMs: UInt32 BE]
|
|
||||||
/// - flags 0x04 (CANCELED): empty payload; receivers discard the burst
|
|
||||||
/// - flags 0x00 (data): payload = repeated [length: UInt16 BE][AAC frame]
|
|
||||||
struct VoiceBurstPacket: Equatable {
|
|
||||||
enum Kind: Equatable {
|
|
||||||
case start(codec: VoiceBurstCodec)
|
|
||||||
case frames([Data])
|
|
||||||
case end(totalDataPackets: UInt16, durationMs: UInt32)
|
|
||||||
case canceled
|
|
||||||
}
|
|
||||||
|
|
||||||
static let burstIDSize = 8
|
|
||||||
private static let headerSize = burstIDSize + 2 + 1
|
|
||||||
/// Sanity cap on frames per packet; real packets carry 1-2 frames.
|
|
||||||
static let maxFramesPerPacket = 8
|
|
||||||
|
|
||||||
private enum Flags {
|
|
||||||
static let start: UInt8 = 0x01
|
|
||||||
static let end: UInt8 = 0x02
|
|
||||||
static let canceled: UInt8 = 0x04
|
|
||||||
}
|
|
||||||
|
|
||||||
let burstID: Data
|
|
||||||
let seq: UInt16
|
|
||||||
let kind: Kind
|
|
||||||
|
|
||||||
init?(burstID: Data, seq: UInt16, kind: Kind) {
|
|
||||||
guard burstID.count == Self.burstIDSize else { return nil }
|
|
||||||
if case .frames(let frames) = kind {
|
|
||||||
guard !frames.isEmpty,
|
|
||||||
frames.count <= Self.maxFramesPerPacket,
|
|
||||||
frames.allSatisfy({ !$0.isEmpty && $0.count <= Int(UInt16.max) })
|
|
||||||
else { return nil }
|
|
||||||
}
|
|
||||||
self.burstID = burstID
|
|
||||||
self.seq = seq
|
|
||||||
self.kind = kind
|
|
||||||
}
|
|
||||||
|
|
||||||
func encode() -> Data {
|
|
||||||
var data = Data(capacity: Self.headerSize + payloadSize)
|
|
||||||
data.append(burstID)
|
|
||||||
data.append(UInt8((seq >> 8) & 0xFF))
|
|
||||||
data.append(UInt8(seq & 0xFF))
|
|
||||||
switch kind {
|
|
||||||
case .start(let codec):
|
|
||||||
data.append(Flags.start)
|
|
||||||
data.append(codec.rawValue)
|
|
||||||
case .frames(let frames):
|
|
||||||
data.append(0)
|
|
||||||
for frame in frames {
|
|
||||||
let length = UInt16(frame.count)
|
|
||||||
data.append(UInt8((length >> 8) & 0xFF))
|
|
||||||
data.append(UInt8(length & 0xFF))
|
|
||||||
data.append(frame)
|
|
||||||
}
|
|
||||||
case .end(let totalDataPackets, let durationMs):
|
|
||||||
data.append(Flags.end)
|
|
||||||
data.append(UInt8((totalDataPackets >> 8) & 0xFF))
|
|
||||||
data.append(UInt8(totalDataPackets & 0xFF))
|
|
||||||
for shift in stride(from: 24, through: 0, by: -8) {
|
|
||||||
data.append(UInt8((durationMs >> UInt32(shift)) & 0xFF))
|
|
||||||
}
|
|
||||||
case .canceled:
|
|
||||||
data.append(Flags.canceled)
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
static func decode(_ data: Data) -> VoiceBurstPacket? {
|
|
||||||
// Work on a re-based copy so subscripting is offset-safe.
|
|
||||||
let data = Data(data)
|
|
||||||
guard data.count >= headerSize else { return nil }
|
|
||||||
|
|
||||||
let burstID = data.prefix(burstIDSize)
|
|
||||||
let seq = (UInt16(data[burstIDSize]) << 8) | UInt16(data[burstIDSize + 1])
|
|
||||||
let flags = data[burstIDSize + 2]
|
|
||||||
let payload = data.dropFirst(headerSize)
|
|
||||||
|
|
||||||
let kind: Kind
|
|
||||||
switch flags {
|
|
||||||
case Flags.start:
|
|
||||||
guard let codecByte = payload.first,
|
|
||||||
let codec = VoiceBurstCodec(rawValue: codecByte)
|
|
||||||
else { return nil }
|
|
||||||
kind = .start(codec: codec)
|
|
||||||
case Flags.end:
|
|
||||||
guard payload.count >= 6 else { return nil }
|
|
||||||
let bytes = Array(payload)
|
|
||||||
let total = (UInt16(bytes[0]) << 8) | UInt16(bytes[1])
|
|
||||||
let duration = bytes[2...5].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
|
||||||
kind = .end(totalDataPackets: total, durationMs: duration)
|
|
||||||
case Flags.canceled:
|
|
||||||
kind = .canceled
|
|
||||||
case 0:
|
|
||||||
var frames: [Data] = []
|
|
||||||
var offset = payload.startIndex
|
|
||||||
while offset < payload.endIndex {
|
|
||||||
guard payload.distance(from: offset, to: payload.endIndex) >= 2 else { return nil }
|
|
||||||
let length = (Int(payload[offset]) << 8) | Int(payload[payload.index(after: offset)])
|
|
||||||
offset = payload.index(offset, offsetBy: 2)
|
|
||||||
guard length > 0,
|
|
||||||
payload.distance(from: offset, to: payload.endIndex) >= length,
|
|
||||||
frames.count < maxFramesPerPacket
|
|
||||||
else { return nil }
|
|
||||||
let end = payload.index(offset, offsetBy: length)
|
|
||||||
frames.append(Data(payload[offset..<end]))
|
|
||||||
offset = end
|
|
||||||
}
|
|
||||||
guard !frames.isEmpty else { return nil }
|
|
||||||
kind = .frames(frames)
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return VoiceBurstPacket(burstID: Data(burstID), seq: seq, kind: kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func makeBurstID() -> Data {
|
|
||||||
var bytes = Data(count: burstIDSize)
|
|
||||||
let result = bytes.withUnsafeMutableBytes {
|
|
||||||
SecRandomCopyBytes(kSecRandomDefault, burstIDSize, $0.baseAddress!)
|
|
||||||
}
|
|
||||||
guard result == errSecSuccess else {
|
|
||||||
return Data((0..<burstIDSize).map { _ in UInt8.random(in: .min ... .max) })
|
|
||||||
}
|
|
||||||
return bytes
|
|
||||||
}
|
|
||||||
|
|
||||||
private var payloadSize: Int {
|
|
||||||
switch kind {
|
|
||||||
case .start: return 1
|
|
||||||
case .frames(let frames): return frames.reduce(0) { $0 + 2 + $1.count }
|
|
||||||
case .end: return 6
|
|
||||||
case .canceled: return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Greedy packetizer for outgoing bursts: batches encoded frames into
|
|
||||||
/// `VoiceBurstPacket`s without exceeding the byte budget that keeps each
|
|
||||||
/// packet in a single BLE frame after Noise encryption and padding.
|
|
||||||
/// Not thread-safe — confine to one queue.
|
|
||||||
struct VoiceBurstPacketizer {
|
|
||||||
let burstID: Data
|
|
||||||
private let budget: Int
|
|
||||||
private var pendingFrames: [Data] = []
|
|
||||||
private var pendingSize = 0
|
|
||||||
/// seq 0 is reserved for START; data packets start at 1.
|
|
||||||
private(set) var nextSeq: UInt16 = 1
|
|
||||||
private(set) var dataPacketCount: UInt16 = 0
|
|
||||||
|
|
||||||
init(burstID: Data, budget: Int = TransportConfig.pttMaxBurstContentBytes) {
|
|
||||||
self.burstID = burstID
|
|
||||||
self.budget = budget
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adds one encoded frame, returning any packets that became full.
|
|
||||||
/// Frames larger than the budget are dropped (the encoder's ~130-byte
|
|
||||||
/// frames never hit this; it guards against misconfiguration looping).
|
|
||||||
mutating func add(_ frame: Data) -> [Data] {
|
|
||||||
let frameCost = 2 + frame.count
|
|
||||||
guard VoiceBurstPacket.burstIDSize + 3 + frameCost <= budget else { return [] }
|
|
||||||
|
|
||||||
var packets: [Data] = []
|
|
||||||
if !pendingFrames.isEmpty,
|
|
||||||
VoiceBurstPacket.burstIDSize + 3 + pendingSize + frameCost > budget
|
|
||||||
|| pendingFrames.count >= VoiceBurstPacket.maxFramesPerPacket {
|
|
||||||
packets.append(contentsOf: flush())
|
|
||||||
}
|
|
||||||
pendingFrames.append(frame)
|
|
||||||
pendingSize += frameCost
|
|
||||||
return packets
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Emits any buffered frames as a final data packet.
|
|
||||||
mutating func flush() -> [Data] {
|
|
||||||
guard !pendingFrames.isEmpty,
|
|
||||||
let packet = VoiceBurstPacket(burstID: burstID, seq: nextSeq, kind: .frames(pendingFrames))
|
|
||||||
else {
|
|
||||||
pendingFrames = []
|
|
||||||
pendingSize = 0
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
pendingFrames = []
|
|
||||||
pendingSize = 0
|
|
||||||
nextSeq &+= 1
|
|
||||||
dataPacketCount &+= 1
|
|
||||||
return [packet.encode()]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
//
|
|
||||||
// VouchAttestation.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import CryptoKit
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// A signed statement that the *sender of the enclosing Noise payload* has
|
|
||||||
/// verified the identity described here ("transitive verification").
|
|
||||||
///
|
|
||||||
/// The voucher's identity is deliberately implicit: attestations only travel
|
|
||||||
/// inside an authenticated Noise session (`NoisePayloadType.vouch`), so the
|
|
||||||
/// receiver verifies the Ed25519 signature against the session peer's
|
|
||||||
/// announce-bound signing key and stores the vouch keyed by that peer's
|
|
||||||
/// fingerprint. Nothing in the attestation names the voucher, so a captured
|
|
||||||
/// attestation cannot be replayed by a third party whose signing key doesn't
|
|
||||||
/// match.
|
|
||||||
///
|
|
||||||
/// Wire format — single attestation (TLV, 1-byte type + 1-byte length):
|
|
||||||
/// - `0x01` voucheeFingerprint: 32 bytes, SHA-256 of the vouchee's Noise static key
|
|
||||||
/// - `0x02` voucheeSigningKey: 32 bytes, Ed25519; anchors the vouch to a concrete identity
|
|
||||||
/// - `0x03` timestamp: 8 bytes big-endian, milliseconds since 1970
|
|
||||||
/// - `0x04` signature: 64 bytes, Ed25519 by the VOUCHER's signing key over
|
|
||||||
/// `"bitchat-vouch-v1" | voucheeFingerprint | voucheeSigningKey | timestamp`
|
|
||||||
///
|
|
||||||
/// Unknown TLV types are skipped for forward compatibility.
|
|
||||||
///
|
|
||||||
/// Batch format (the `vouch` Noise payload body):
|
|
||||||
/// `[count: UInt8]` then per attestation `[length: UInt16 BE][attestation TLV]`.
|
|
||||||
struct VouchAttestation: Equatable {
|
|
||||||
static let signingContext = "bitchat-vouch-v1"
|
|
||||||
/// Receiver-side expiry for attestations.
|
|
||||||
static let maxAge: TimeInterval = 30 * 24 * 60 * 60
|
|
||||||
/// Tolerated clock skew for attestations timestamped in the future.
|
|
||||||
static let maxClockSkew: TimeInterval = 60 * 60
|
|
||||||
/// Upper bound of attestations carried/accepted in one batch payload.
|
|
||||||
static let maxBatchCount = 16
|
|
||||||
|
|
||||||
static let fingerprintSize = 32
|
|
||||||
static let signingKeySize = 32
|
|
||||||
static let signatureSize = 64
|
|
||||||
|
|
||||||
let voucheeFingerprint: Data // 32 bytes
|
|
||||||
let voucheeSigningKey: Data // 32 bytes
|
|
||||||
let timestampMs: UInt64
|
|
||||||
let signature: Data // 64 bytes
|
|
||||||
|
|
||||||
private enum TLVType: UInt8 {
|
|
||||||
case voucheeFingerprint = 0x01
|
|
||||||
case voucheeSigningKey = 0x02
|
|
||||||
case timestamp = 0x03
|
|
||||||
case signature = 0x04
|
|
||||||
}
|
|
||||||
|
|
||||||
var voucheeFingerprintHex: String { voucheeFingerprint.hexEncodedString() }
|
|
||||||
|
|
||||||
var timestamp: Date { Date(timeIntervalSince1970: TimeInterval(timestampMs) / 1000) }
|
|
||||||
|
|
||||||
/// The exact bytes the voucher signs.
|
|
||||||
static func signableBytes(
|
|
||||||
voucheeFingerprint: Data,
|
|
||||||
voucheeSigningKey: Data,
|
|
||||||
timestampMs: UInt64
|
|
||||||
) -> Data {
|
|
||||||
var message = Data(signingContext.utf8)
|
|
||||||
message.append(voucheeFingerprint)
|
|
||||||
message.append(voucheeSigningKey)
|
|
||||||
var timestampBE = timestampMs.bigEndian
|
|
||||||
withUnsafeBytes(of: ×tampBE) { message.append(contentsOf: $0) }
|
|
||||||
return message
|
|
||||||
}
|
|
||||||
|
|
||||||
var signableBytes: Data {
|
|
||||||
Self.signableBytes(
|
|
||||||
voucheeFingerprint: voucheeFingerprint,
|
|
||||||
voucheeSigningKey: voucheeSigningKey,
|
|
||||||
timestampMs: timestampMs
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds and signs an attestation. `sign` is the voucher's Ed25519
|
|
||||||
/// signing primitive (e.g. `Transport.noiseSignData`).
|
|
||||||
static func build(
|
|
||||||
voucheeFingerprint: Data,
|
|
||||||
voucheeSigningKey: Data,
|
|
||||||
timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
|
|
||||||
sign: (Data) -> Data?
|
|
||||||
) -> VouchAttestation? {
|
|
||||||
guard voucheeFingerprint.count == fingerprintSize,
|
|
||||||
voucheeSigningKey.count == signingKeySize else { return nil }
|
|
||||||
let message = signableBytes(
|
|
||||||
voucheeFingerprint: voucheeFingerprint,
|
|
||||||
voucheeSigningKey: voucheeSigningKey,
|
|
||||||
timestampMs: timestampMs
|
|
||||||
)
|
|
||||||
guard let signature = sign(message), signature.count == signatureSize else { return nil }
|
|
||||||
return VouchAttestation(
|
|
||||||
voucheeFingerprint: voucheeFingerprint,
|
|
||||||
voucheeSigningKey: voucheeSigningKey,
|
|
||||||
timestampMs: timestampMs,
|
|
||||||
signature: signature
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verifies the Ed25519 signature against the voucher's announce-bound
|
|
||||||
/// signing key.
|
|
||||||
func verifySignature(voucherSigningKey: Data) -> Bool {
|
|
||||||
guard let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: voucherSigningKey) else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return publicKey.isValidSignature(signature, for: signableBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the attestation is outside its validity window (older than
|
|
||||||
/// `maxAge`, or timestamped implausibly far in the future).
|
|
||||||
func isExpired(now: Date = Date()) -> Bool {
|
|
||||||
let age = now.timeIntervalSince(timestamp)
|
|
||||||
return age > Self.maxAge || age < -Self.maxClockSkew
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Encoding
|
|
||||||
|
|
||||||
func encode() -> Data? {
|
|
||||||
guard voucheeFingerprint.count == Self.fingerprintSize,
|
|
||||||
voucheeSigningKey.count == Self.signingKeySize,
|
|
||||||
signature.count == Self.signatureSize else { return nil }
|
|
||||||
var data = Data()
|
|
||||||
func appendTLV(_ type: TLVType, _ value: Data) {
|
|
||||||
data.append(type.rawValue)
|
|
||||||
data.append(UInt8(value.count))
|
|
||||||
data.append(value)
|
|
||||||
}
|
|
||||||
appendTLV(.voucheeFingerprint, voucheeFingerprint)
|
|
||||||
appendTLV(.voucheeSigningKey, voucheeSigningKey)
|
|
||||||
var timestampBE = timestampMs.bigEndian
|
|
||||||
appendTLV(.timestamp, withUnsafeBytes(of: ×tampBE) { Data($0) })
|
|
||||||
appendTLV(.signature, signature)
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
static func decode(from data: Data) -> VouchAttestation? {
|
|
||||||
var fingerprint: Data?
|
|
||||||
var signingKey: Data?
|
|
||||||
var timestampMs: UInt64?
|
|
||||||
var signature: Data?
|
|
||||||
|
|
||||||
var offset = data.startIndex
|
|
||||||
while offset < data.endIndex {
|
|
||||||
guard data.index(offset, offsetBy: 2, limitedBy: data.endIndex) != nil,
|
|
||||||
offset + 1 < data.endIndex else { return nil }
|
|
||||||
let type = data[offset]
|
|
||||||
let length = Int(data[offset + 1])
|
|
||||||
let valueStart = offset + 2
|
|
||||||
guard let valueEnd = data.index(valueStart, offsetBy: length, limitedBy: data.endIndex) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let value = Data(data[valueStart..<valueEnd])
|
|
||||||
switch TLVType(rawValue: type) {
|
|
||||||
case .voucheeFingerprint:
|
|
||||||
guard value.count == fingerprintSize else { return nil }
|
|
||||||
fingerprint = value
|
|
||||||
case .voucheeSigningKey:
|
|
||||||
guard value.count == signingKeySize else { return nil }
|
|
||||||
signingKey = value
|
|
||||||
case .timestamp:
|
|
||||||
guard value.count == 8 else { return nil }
|
|
||||||
timestampMs = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
|
|
||||||
case .signature:
|
|
||||||
guard value.count == signatureSize else { return nil }
|
|
||||||
signature = value
|
|
||||||
case nil:
|
|
||||||
break // Unknown TLV: skip for forward compatibility.
|
|
||||||
}
|
|
||||||
offset = valueEnd
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let fingerprint, let signingKey, let timestampMs, let signature else { return nil }
|
|
||||||
return VouchAttestation(
|
|
||||||
voucheeFingerprint: fingerprint,
|
|
||||||
voucheeSigningKey: signingKey,
|
|
||||||
timestampMs: timestampMs,
|
|
||||||
signature: signature
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Batch encoding
|
|
||||||
|
|
||||||
/// Encodes up to `maxBatchCount` attestations into one payload body.
|
|
||||||
static func encodeList(_ attestations: [VouchAttestation]) -> Data? {
|
|
||||||
guard !attestations.isEmpty, attestations.count <= maxBatchCount else { return nil }
|
|
||||||
var data = Data()
|
|
||||||
data.append(UInt8(attestations.count))
|
|
||||||
for attestation in attestations {
|
|
||||||
guard let encoded = attestation.encode(), encoded.count <= Int(UInt16.max) else { return nil }
|
|
||||||
var lengthBE = UInt16(encoded.count).bigEndian
|
|
||||||
withUnsafeBytes(of: &lengthBE) { data.append(contentsOf: $0) }
|
|
||||||
data.append(encoded)
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decodes a batch payload, dropping malformed entries and ignoring
|
|
||||||
/// anything beyond `maxBatchCount` (sender-declared count is not trusted).
|
|
||||||
static func decodeList(from data: Data) -> [VouchAttestation] {
|
|
||||||
guard data.count > 1 else { return [] }
|
|
||||||
let declaredCount = Int(data[data.startIndex])
|
|
||||||
let limit = min(declaredCount, maxBatchCount)
|
|
||||||
var attestations: [VouchAttestation] = []
|
|
||||||
var offset = data.startIndex + 1
|
|
||||||
while attestations.count < limit, offset < data.endIndex {
|
|
||||||
guard let lengthEnd = data.index(offset, offsetBy: 2, limitedBy: data.endIndex) else { break }
|
|
||||||
let length = Int(data[offset]) << 8 | Int(data[offset + 1])
|
|
||||||
guard let entryEnd = data.index(lengthEnd, offsetBy: length, limitedBy: data.endIndex) else { break }
|
|
||||||
if let attestation = decode(from: Data(data[lengthEnd..<entryEnd])) {
|
|
||||||
attestations.append(attestation)
|
|
||||||
}
|
|
||||||
offset = entryEnd
|
|
||||||
}
|
|
||||||
return attestations
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,14 @@ import Foundation
|
|||||||
/// Manages autocomplete functionality for chat
|
/// Manages autocomplete functionality for chat
|
||||||
final class AutocompleteService {
|
final class AutocompleteService {
|
||||||
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
|
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
|
||||||
|
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
|
||||||
|
|
||||||
|
private let commands = [
|
||||||
|
"/msg", "/who", "/clear",
|
||||||
|
"/hug", "/slap", "/fav", "/unfav",
|
||||||
|
"/block", "/unblock"
|
||||||
|
]
|
||||||
|
|
||||||
/// Get autocomplete suggestions for current text
|
/// Get autocomplete suggestions for current text
|
||||||
func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) {
|
func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) {
|
||||||
let textToPosition = String(text.prefix(cursorPosition))
|
let textToPosition = String(text.prefix(cursorPosition))
|
||||||
@@ -66,6 +73,26 @@ final class AutocompleteService {
|
|||||||
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
|
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func getCommandSuggestions(_ text: String) -> ([String], NSRange)? {
|
||||||
|
guard let regex = commandRegex else { return nil }
|
||||||
|
|
||||||
|
let nsText = text as NSString
|
||||||
|
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length))
|
||||||
|
|
||||||
|
guard let match = matches.last else { return nil }
|
||||||
|
|
||||||
|
let fullRange = match.range(at: 0)
|
||||||
|
let captureRange = match.range(at: 1)
|
||||||
|
let prefix = nsText.substring(with: captureRange).lowercased()
|
||||||
|
|
||||||
|
let suggestions = commands
|
||||||
|
.filter { $0.hasPrefix("/\(prefix)") }
|
||||||
|
.sorted()
|
||||||
|
.prefix(5)
|
||||||
|
|
||||||
|
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
|
||||||
|
}
|
||||||
|
|
||||||
private func needsArgument(command: String) -> Bool {
|
private func needsArgument(command: String) -> Bool {
|
||||||
switch command {
|
switch command {
|
||||||
case "/who", "/clear":
|
case "/who", "/clear":
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ struct BLEFileTransferHandlerEnvironment {
|
|||||||
let localNickname: () -> String
|
let localNickname: () -> String
|
||||||
/// Snapshot of known peers keyed by ID (registry read).
|
/// Snapshot of known peers keyed by ID (registry read).
|
||||||
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
|
||||||
/// Verifies a packet's signature against a candidate signing key (registry path).
|
|
||||||
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
|
||||||
/// Resolves a display name from a verified packet signature for peers missing from the registry.
|
/// Resolves a display name from a verified packet signature for peers missing from the registry.
|
||||||
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
|
||||||
/// Tracks the broadcast file packet for gossip sync.
|
/// Tracks the broadcast file packet for gossip sync.
|
||||||
@@ -46,32 +44,25 @@ final class BLEFileTransferHandler {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `false` when the packet fails sender authentication and must
|
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
/// not be relayed onward. Every other outcome returns `true`: files
|
|
||||||
/// directed to another peer are forwarded untouched, and local-only drops
|
|
||||||
/// (malformed payload, quota, save failure) don't affect multi-hop
|
|
||||||
/// delivery to nodes that may handle them fine.
|
|
||||||
@discardableResult
|
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
|
||||||
let env = environment
|
let env = environment
|
||||||
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return true }
|
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
|
||||||
|
|
||||||
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
let peersSnapshot = env.peersSnapshot()
|
let peersSnapshot = env.peersSnapshot()
|
||||||
guard let senderNickname = resolveSenderNickname(
|
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||||
packet: packet,
|
peerID: peerID,
|
||||||
from: peerID,
|
localPeerID: env.localPeerID(),
|
||||||
isBroadcast: !deliveryPlan.isPrivateMessage,
|
localNickname: env.localNickname(),
|
||||||
peers: peersSnapshot,
|
peers: peersSnapshot,
|
||||||
env: env
|
allowConnectedUnverified: true
|
||||||
) else {
|
) ?? env.signedSenderDisplayName(packet, peerID) else {
|
||||||
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
||||||
return false
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
if deliveryPlan.shouldTrackForSync {
|
if deliveryPlan.shouldTrackForSync {
|
||||||
env.trackPacketSeen(packet)
|
env.trackPacketSeen(packet)
|
||||||
}
|
}
|
||||||
@@ -84,16 +75,16 @@ final class BLEFileTransferHandler {
|
|||||||
mime = acceptance.mime
|
mime = acceptance.mime
|
||||||
case .failure(.malformedPayload):
|
case .failure(.malformedPayload):
|
||||||
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
|
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
|
||||||
return true
|
return
|
||||||
case .failure(.payloadTooLarge(let bytes)):
|
case .failure(.payloadTooLarge(let bytes)):
|
||||||
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
|
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
|
||||||
return true
|
return
|
||||||
case .failure(.unsupportedMime(let mimeType, let bytes)):
|
case .failure(.unsupportedMime(let mimeType, let bytes)):
|
||||||
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security)
|
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security)
|
||||||
return true
|
return
|
||||||
case .failure(.magicMismatch(let mime, let bytes, let prefixHex)):
|
case .failure(.magicMismatch(let mime, let bytes, let prefixHex)):
|
||||||
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
|
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
|
||||||
return true
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-002: Enforce storage quota before saving
|
// BCH-01-002: Enforce storage quota before saving
|
||||||
@@ -106,7 +97,7 @@ final class BLEFileTransferHandler {
|
|||||||
mime.defaultExtension,
|
mime.defaultExtension,
|
||||||
mime.category.rawValue
|
mime.category.rawValue
|
||||||
) else {
|
) else {
|
||||||
return true
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if deliveryPlan.isPrivateMessage {
|
if deliveryPlan.isPrivateMessage {
|
||||||
@@ -122,66 +113,11 @@ final class BLEFileTransferHandler {
|
|||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
isPrivate: deliveryPlan.isPrivateMessage,
|
isPrivate: deliveryPlan.isPrivateMessage,
|
||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeerID: peerID,
|
senderPeerID: peerID
|
||||||
// Received messages need an explicit status: BitchatMessage
|
|
||||||
// defaults private messages to .sending, which the media views
|
|
||||||
// render as an in-flight send (empty reveal mask, disabled tap).
|
|
||||||
deliveryStatus: deliveryPlan.isPrivateMessage
|
|
||||||
? .delivered(to: env.localNickname(), at: ts)
|
|
||||||
: nil
|
|
||||||
)
|
)
|
||||||
|
|
||||||
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
||||||
|
|
||||||
env.deliverMessage(message)
|
env.deliverMessage(message)
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves the authenticated display name for a file transfer's sender.
|
|
||||||
///
|
|
||||||
/// Directed (private) transfers are addressed to us specifically and keep
|
|
||||||
/// the lenient connected-peer path. Broadcast transfers carry an
|
|
||||||
/// attacker-controllable `senderID` exactly like public messages and public
|
|
||||||
/// voice frames — registry membership alone is NOT proof of identity, so a
|
|
||||||
/// valid packet signature from the claimed sender is required before we
|
|
||||||
/// trust it. Without this, a peer that observed a public voice burst could
|
|
||||||
/// spoof a broadcast `voice_<burstID>.m4a` note under the talker's ID and
|
|
||||||
/// overwrite the signature-verified live bubble with attacker audio.
|
|
||||||
private func resolveSenderNickname(
|
|
||||||
packet: BitchatPacket,
|
|
||||||
from peerID: PeerID,
|
|
||||||
isBroadcast: Bool,
|
|
||||||
peers: [PeerID: BLEPeerInfo],
|
|
||||||
env: BLEFileTransferHandlerEnvironment
|
|
||||||
) -> String? {
|
|
||||||
guard isBroadcast else {
|
|
||||||
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
|
||||||
peerID: peerID,
|
|
||||||
localPeerID: env.localPeerID(),
|
|
||||||
localNickname: env.localNickname(),
|
|
||||||
peers: peers,
|
|
||||||
allowConnectedUnverified: true
|
|
||||||
) ?? env.signedSenderDisplayName(packet, peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Our own broadcasts replayed back via gossip sync (ttl==0) are
|
|
||||||
// trivially authentic and cannot be verified against the peer registry
|
|
||||||
// or identity cache, so exempt self exactly as `BLEPublicMessageHandler`
|
|
||||||
// does. Verify against the signing key already in the
|
|
||||||
// (synchronously-updated) registry first, then fall back to the
|
|
||||||
// persisted-identity signature lookup for peers not yet cached there.
|
|
||||||
let isSelf = peerID == env.localPeerID()
|
|
||||||
let registrySigningKey = peers[peerID]?.signingPublicKey
|
|
||||||
let verifiedViaRegistry = !isSelf && (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false)
|
|
||||||
let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID)
|
|
||||||
guard isSelf || verifiedViaRegistry || signedDisplayName != nil else { return nil }
|
|
||||||
|
|
||||||
return BLEPeerSenderDisplayName.resolveKnownPeer(
|
|
||||||
peerID: peerID,
|
|
||||||
localPeerID: env.localPeerID(),
|
|
||||||
localNickname: env.localNickname(),
|
|
||||||
peers: peers,
|
|
||||||
allowConnectedUnverified: false
|
|
||||||
) ?? signedDisplayName
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,11 +61,9 @@ struct BLEFragmentAssemblyBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct Metadata {
|
private struct Metadata {
|
||||||
|
let type: UInt8
|
||||||
let total: Int
|
let total: Int
|
||||||
let timestamp: Date
|
let timestamp: Date
|
||||||
let isBroadcast: Bool
|
|
||||||
var lastFragmentAt: Date
|
|
||||||
var lastResyncRequestAt: Date?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:]
|
private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:]
|
||||||
@@ -107,15 +105,7 @@ struct BLEFragmentAssemblyBuffer {
|
|||||||
return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started)
|
return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only actual progress resets the stall clock: fragment packets
|
|
||||||
// bypass the packet deduplicator, so relayed duplicates of an
|
|
||||||
// already-held index must not keep suppressing the targeted
|
|
||||||
// REQUEST_SYNC for a stalled stream.
|
|
||||||
let isNewIndex = fragmentsByKey[header.key]?[header.index] == nil
|
|
||||||
fragmentsByKey[header.key]?[header.index] = header.fragmentData
|
fragmentsByKey[header.key]?[header.index] = header.fragmentData
|
||||||
if isNewIndex {
|
|
||||||
metadataByKey[header.key]?.lastFragmentAt = now
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let fragments = fragmentsByKey[header.key],
|
guard let fragments = fragmentsByKey[header.key],
|
||||||
fragments.count == header.total else {
|
fragments.count == header.total else {
|
||||||
@@ -148,58 +138,10 @@ struct BLEFragmentAssemblyBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fragmentsByKey[header.key] = [:]
|
fragmentsByKey[header.key] = [:]
|
||||||
metadataByKey[header.key] = Metadata(
|
metadataByKey[header.key] = Metadata(type: header.originalType, total: header.total, timestamp: now)
|
||||||
total: header.total,
|
|
||||||
timestamp: now,
|
|
||||||
isBroadcast: header.isBroadcastFragment,
|
|
||||||
lastFragmentAt: now
|
|
||||||
)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fragment stream IDs (8-byte, big-endian) of incomplete broadcast
|
|
||||||
/// reassemblies that have not seen a new fragment for `stalledAfter`
|
|
||||||
/// seconds — candidates for a targeted REQUEST_SYNC. Each returned
|
|
||||||
/// stream is marked so it is not re-requested within `retryAfter`.
|
|
||||||
/// At most `RequestSyncPacket.maxFragmentIdFilterCount` streams are
|
|
||||||
/// returned per pass — the wire filter cannot carry more — selected
|
|
||||||
/// oldest-stall first; overflow streams stay unmarked and eligible for
|
|
||||||
/// the next pass. Directed reassemblies are excluded: peers only archive
|
|
||||||
/// broadcast fragments for gossip sync, so a targeted request cannot
|
|
||||||
/// recover them.
|
|
||||||
mutating func stalledBroadcastFragmentIDs(
|
|
||||||
stalledAfter: TimeInterval,
|
|
||||||
retryAfter: TimeInterval,
|
|
||||||
now: Date = Date()
|
|
||||||
) -> [Data] {
|
|
||||||
var candidates: [(key: BLEFragmentKey, lastFragmentAt: Date)] = []
|
|
||||||
for (key, metadata) in metadataByKey {
|
|
||||||
guard metadata.isBroadcast,
|
|
||||||
let fragments = fragmentsByKey[key],
|
|
||||||
fragments.count < metadata.total,
|
|
||||||
now.timeIntervalSince(metadata.lastFragmentAt) >= stalledAfter else { continue }
|
|
||||||
if let lastRequest = metadata.lastResyncRequestAt,
|
|
||||||
now.timeIntervalSince(lastRequest) < retryAfter { continue }
|
|
||||||
candidates.append((key: key, lastFragmentAt: metadata.lastFragmentAt))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark only the streams that will actually go on the wire, so the
|
|
||||||
// overflow is not silently suppressed for `retryAfter`.
|
|
||||||
let selected = candidates
|
|
||||||
.sorted {
|
|
||||||
if $0.lastFragmentAt != $1.lastFragmentAt {
|
|
||||||
return $0.lastFragmentAt < $1.lastFragmentAt
|
|
||||||
}
|
|
||||||
return ($0.key.sender, $0.key.id) < ($1.key.sender, $1.key.id)
|
|
||||||
}
|
|
||||||
.prefix(RequestSyncPacket.maxFragmentIdFilterCount)
|
|
||||||
|
|
||||||
return selected.map { candidate in
|
|
||||||
metadataByKey[candidate.key]?.lastResyncRequestAt = now
|
|
||||||
return withUnsafeBytes(of: candidate.key.id.bigEndian) { Data($0) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func assemblyLimit(for originalType: UInt8) -> Int {
|
private static func assemblyLimit(for originalType: UInt8) -> Int {
|
||||||
if originalType == MessageType.fileTransfer.rawValue {
|
if originalType == MessageType.fileTransfer.rawValue {
|
||||||
// Allow headroom for TLV metadata and binary framing overhead.
|
// Allow headroom for TLV metadata and binary framing overhead.
|
||||||
|
|||||||
@@ -33,21 +33,13 @@ final class BLEFragmentHandler {
|
|||||||
|
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
let env = environment
|
let env = environment
|
||||||
guard let header = BLEFragmentHeader(packet: packet) else { return }
|
// Don't process our own fragments
|
||||||
|
|
||||||
// Sync replay legitimately hands us our own fragments back (the RSR
|
|
||||||
// ttl=0 restore path): after a relaunch the fragment store starts
|
|
||||||
// empty, so our sync filter doesn't cover them and peers re-offer
|
|
||||||
// them. Record them as seen — the next round's filter then covers
|
|
||||||
// them and the redelivery stops — but skip assembly: we authored
|
|
||||||
// the original, there is nothing to reassemble.
|
|
||||||
if peerID == env.localPeerID() {
|
if peerID == env.localPeerID() {
|
||||||
if header.isBroadcastFragment {
|
|
||||||
env.trackPacketSeen(packet)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard let header = BLEFragmentHeader(packet: packet) else { return }
|
||||||
|
|
||||||
if header.isBroadcastFragment {
|
if header.isBroadcastFragment {
|
||||||
env.trackPacketSeen(packet)
|
env.trackPacketSeen(packet)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,21 +78,10 @@ struct BLEIngressLinkRegistry {
|
|||||||
return .failure(.selfLoopback(packetType: packet.type))
|
return .failure(.selfLoopback(packetType: packet.type))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let boundPeerID, boundPeerID != claimedSenderID {
|
if let boundPeerID,
|
||||||
if requiresDirectSenderBinding(packet) {
|
boundPeerID != claimedSenderID,
|
||||||
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
|
requiresDirectSenderBinding(packet, directAnnounceTTL: directAnnounceTTL) {
|
||||||
}
|
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
|
||||||
// A direct announce claiming a new sender on a bound link is either
|
|
||||||
// a spoof or a legitimate peer-ID rotation on a connection that
|
|
||||||
// outlived the old ID. Attribute it to the claimed sender and let
|
|
||||||
// it through: announces are self-authenticating, and only a
|
|
||||||
// signature-verified announce may rebind the link (BLEService).
|
|
||||||
if isDirectAnnounce(packet, directAnnounceTTL: directAnnounceTTL) {
|
|
||||||
return .success(BLEIngressPacketContext(
|
|
||||||
receivedFromPeerID: claimedSenderID,
|
|
||||||
validationPeerID: claimedSenderID
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let receivedFromPeerID = boundPeerID ?? claimedSenderID
|
let receivedFromPeerID = boundPeerID ?? claimedSenderID
|
||||||
@@ -109,14 +98,7 @@ struct BLEIngressLinkRegistry {
|
|||||||
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func requiresDirectSenderBinding(_ packet: BitchatPacket) -> Bool {
|
private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
|
||||||
// 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.
|
|
||||||
packet.type == MessageType.requestSync.rawValue
|
|
||||||
}
|
|
||||||
|
|
||||||
static func isDirectAnnounce(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
|
|
||||||
packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
|
packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -203,19 +203,9 @@ final class BLELinkStateStore {
|
|||||||
|
|
||||||
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
|
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
|
||||||
assertOwned()
|
assertOwned()
|
||||||
var previousPeerID: PeerID?
|
if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil {
|
||||||
let updated = updatePeripheral(peripheralUUID) {
|
peerToPeripheralUUID[peerID] = peripheralUUID
|
||||||
previousPeerID = $0.peerID
|
|
||||||
$0.peerID = peerID
|
|
||||||
}
|
}
|
||||||
guard updated != nil else { return }
|
|
||||||
// Rebinding (peer-ID rotation): drop the retired ID's reverse mapping
|
|
||||||
// so the old peer no longer claims this link.
|
|
||||||
if let previousPeerID, previousPeerID != peerID,
|
|
||||||
peerToPeripheralUUID[previousPeerID] == peripheralUUID {
|
|
||||||
peerToPeripheralUUID.removeValue(forKey: previousPeerID)
|
|
||||||
}
|
|
||||||
peerToPeripheralUUID[peerID] = peripheralUUID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func removePeripheral(_ peripheralID: String) -> PeerID? {
|
func removePeripheral(_ peripheralID: String) -> PeerID? {
|
||||||
|
|||||||
@@ -25,4 +25,9 @@ final class BLELogRateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func removeAll() {
|
||||||
|
queue.sync {
|
||||||
|
lastLogTimeByKey.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,12 @@ enum BLEOutboundPacketPolicy {
|
|||||||
switch MessageType(rawValue: packetType) {
|
switch MessageType(rawValue: packetType) {
|
||||||
case .noiseEncrypted, .noiseHandshake:
|
case .noiseEncrypted, .noiseHandshake:
|
||||||
return true
|
return true
|
||||||
// voiceFrame is deliberately unpadded: padding to the 512 block would
|
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope:
|
||||||
// push every ~490-byte signed voice packet over the MTU into the
|
|
||||||
// fragment path.
|
|
||||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .ping, .pong, .nostrCarrier, .prekeyBundle, .groupMessage, .voiceFrame:
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func priority(for packet: BitchatPacket, data _: Data) -> BLEOutboundWritePriority {
|
static func priority(for packet: BitchatPacket, data: Data) -> BLEOutboundWritePriority {
|
||||||
guard let messageType = MessageType(rawValue: packet.type) else { return .low }
|
guard let messageType = MessageType(rawValue: packet.type) else { return .low }
|
||||||
switch messageType {
|
switch messageType {
|
||||||
case .fragment:
|
case .fragment:
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ struct BLEPeerInfo: Equatable {
|
|||||||
var signingPublicKey: Data?
|
var signingPublicKey: Data?
|
||||||
var isVerifiedNickname: Bool
|
var isVerifiedNickname: Bool
|
||||||
var lastSeen: Date
|
var lastSeen: Date
|
||||||
var capabilities: PeerCapabilities = []
|
|
||||||
/// Rendezvous cell from the peer's announce when it advertises `.bridge`.
|
|
||||||
var bridgeGeohash: String?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BLEPeerAnnounceUpdate: Equatable {
|
struct BLEPeerAnnounceUpdate: Equatable {
|
||||||
@@ -110,24 +107,6 @@ struct BLEPeerRegistry {
|
|||||||
peers[peerID]?.noisePublicKey?.sha256Fingerprint()
|
peers[peerID]?.noisePublicKey?.sha256Fingerprint()
|
||||||
}
|
}
|
||||||
|
|
||||||
func capabilities(for peerID: PeerID) -> PeerCapabilities {
|
|
||||||
peers[peerID.toShort()]?.capabilities ?? []
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Peers whose last verified announce advertised the given capability.
|
|
||||||
func peers(advertising capability: PeerCapabilities) -> [PeerID] {
|
|
||||||
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A rendezvous cell advertised by any bridge-capable peer, if one is
|
|
||||||
/// known — lets location-less devices join the island's rendezvous.
|
|
||||||
func advertisedBridgeGeohash() -> String? {
|
|
||||||
peers.values
|
|
||||||
.filter { $0.capabilities.contains(.bridge) }
|
|
||||||
.compactMap(\.bridgeGeohash)
|
|
||||||
.first
|
|
||||||
}
|
|
||||||
|
|
||||||
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) }
|
||||||
@@ -146,12 +125,19 @@ 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
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func collisionResolvedNickname(for peerID: PeerID, selfNickname: String) -> String? {
|
||||||
|
guard let info = peers[peerID], info.isVerifiedNickname else { return nil }
|
||||||
|
let hasCollision = peers.values.contains {
|
||||||
|
$0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID
|
||||||
|
} || selfNickname == info.nickname
|
||||||
|
return hasCollision ? info.nickname + "#" + String(peerID.id.prefix(4)) : info.nickname
|
||||||
|
}
|
||||||
|
|
||||||
mutating func markDisconnected(_ peerID: PeerID) {
|
mutating func markDisconnected(_ peerID: PeerID) {
|
||||||
guard var info = peers[peerID] else { return }
|
guard var info = peers[peerID] else { return }
|
||||||
info.isConnected = false
|
info.isConnected = false
|
||||||
@@ -170,9 +156,7 @@ struct BLEPeerRegistry {
|
|||||||
noisePublicKey: Data,
|
noisePublicKey: Data,
|
||||||
signingPublicKey: Data?,
|
signingPublicKey: Data?,
|
||||||
isConnected: Bool,
|
isConnected: Bool,
|
||||||
now: Date,
|
now: Date
|
||||||
capabilities: PeerCapabilities = [],
|
|
||||||
bridgeGeohash: String? = nil
|
|
||||||
) -> BLEPeerAnnounceUpdate {
|
) -> BLEPeerAnnounceUpdate {
|
||||||
let existing = peers[peerID]
|
let existing = peers[peerID]
|
||||||
let update = BLEPeerAnnounceUpdate(
|
let update = BLEPeerAnnounceUpdate(
|
||||||
@@ -188,9 +172,7 @@ struct BLEPeerRegistry {
|
|||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey,
|
||||||
isVerifiedNickname: true,
|
isVerifiedNickname: true,
|
||||||
lastSeen: now,
|
lastSeen: now
|
||||||
capabilities: capabilities,
|
|
||||||
bridgeGeohash: bridgeGeohash
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return update
|
return update
|
||||||
|
|||||||
@@ -27,15 +27,8 @@ 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(
|
BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) {
|
||||||
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,29 +48,11 @@ struct BLEReceivePipeline {
|
|||||||
senderIsSelf: senderID == localPeerID,
|
senderIsSelf: senderID == localPeerID,
|
||||||
recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID,
|
recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID,
|
||||||
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
|
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
|
||||||
// Courier envelopes are directed opaque ciphertext like DMs; a
|
isDirectedEncrypted: packet.type == MessageType.noiseEncrypted.rawValue && packet.recipientID != nil,
|
||||||
// remote handover toward a relayed announce rides this same
|
|
||||||
// deterministic relay treatment instead of the broadcast clamp.
|
|
||||||
// Ping/pong diagnostics ride it too: probes need the same
|
|
||||||
// deterministic multi-hop relay as DMs (always relay, jitter,
|
|
||||||
// no TTL cap) so RTT and hop counts reflect the real path.
|
|
||||||
// Directed nostrCarrier uplinks (mesh-only peer -> gateway) need
|
|
||||||
// the same multi-hop treatment to reach a non-adjacent gateway.
|
|
||||||
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|
|
||||||
|| packet.type == MessageType.courierEnvelope.rawValue
|
|
||||||
|| packet.type == MessageType.ping.rawValue
|
|
||||||
|| packet.type == MessageType.pong.rawValue
|
|
||||||
|| packet.type == MessageType.nostrCarrier.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,
|
|
||||||
// Board posts relay like broadcast messages; urgent ones get the
|
|
||||||
// announce-class TTL headroom so alerts travel the extra hop.
|
|
||||||
isUrgentBoardPost: packet.type == MessageType.boardPost.rawValue
|
|
||||||
&& BoardWire.urgentFlag(in: packet.payload),
|
|
||||||
isVoiceFrame: packet.type == MessageType.voiceFrame.rawValue,
|
|
||||||
degree: degree,
|
degree: degree,
|
||||||
highDegreeThreshold: highDegreeThreshold
|
highDegreeThreshold: highDegreeThreshold
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Remembers recently seen bitchat peripherals (fresh discoveries and dropped
|
|
||||||
/// links) so the service can arm pending background connections against them
|
|
||||||
/// when the app leaves the foreground. Generic over the peripheral type so
|
|
||||||
/// the eviction/expiry logic is testable without CoreBluetooth.
|
|
||||||
final class BLERecentPeripheralCache<Peripheral> {
|
|
||||||
private struct Entry {
|
|
||||||
let peripheral: Peripheral
|
|
||||||
var lastSeen: Date
|
|
||||||
}
|
|
||||||
|
|
||||||
private var entries: [String: Entry] = [:]
|
|
||||||
private let capacity: Int
|
|
||||||
private let maxAge: TimeInterval
|
|
||||||
|
|
||||||
init(
|
|
||||||
capacity: Int = TransportConfig.bleRecentPeripheralCacheCap,
|
|
||||||
maxAge: TimeInterval = TransportConfig.bleRecentPeripheralMaxAgeSeconds
|
|
||||||
) {
|
|
||||||
self.capacity = capacity
|
|
||||||
self.maxAge = maxAge
|
|
||||||
}
|
|
||||||
|
|
||||||
var count: Int { entries.count }
|
|
||||||
|
|
||||||
func record(_ peripheral: Peripheral, peripheralID: String, at now: Date) {
|
|
||||||
entries[peripheralID] = Entry(peripheral: peripheral, lastSeen: now)
|
|
||||||
guard entries.count > capacity else { return }
|
|
||||||
// Inserts overshoot capacity by at most one; evict the stalest entry
|
|
||||||
if let stalest = entries.min(by: { $0.value.lastSeen < $1.value.lastSeen }) {
|
|
||||||
entries.removeValue(forKey: stalest.key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Most-recently-seen peripherals eligible for a pending background
|
|
||||||
/// connect, freshest first, capped at `limit`. Expired entries are
|
|
||||||
/// pruned as a side effect.
|
|
||||||
func reconnectTargets(
|
|
||||||
now: Date,
|
|
||||||
limit: Int,
|
|
||||||
excluding: (String) -> Bool
|
|
||||||
) -> [(peripheralID: String, peripheral: Peripheral)] {
|
|
||||||
let cutoff = now.addingTimeInterval(-maxAge)
|
|
||||||
entries = entries.filter { $0.value.lastSeen >= cutoff }
|
|
||||||
guard limit > 0 else { return [] }
|
|
||||||
return entries
|
|
||||||
.filter { !excluding($0.key) }
|
|
||||||
.sorted { $0.value.lastSeen > $1.value.lastSeen }
|
|
||||||
.prefix(limit)
|
|
||||||
.map { (peripheralID: $0.key, peripheral: $0.value.peripheral) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -35,14 +35,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Tracks whether source-routed sends to a recipient appear to be working.
|
|
||||||
///
|
|
||||||
/// A routed unicast rides exactly one path, so a broken hop silently loses the
|
|
||||||
/// packet where a flood would have healed around it. Rather than building a
|
|
||||||
/// retransmission machine (MessageRouter already retries at a higher layer),
|
|
||||||
/// this cache degrades: a routed send that sees no inbound traffic from the
|
|
||||||
/// recipient within the confirmation window marks the route as failed, and
|
|
||||||
/// subsequent sends fall back to flooding until the suppression TTL lapses.
|
|
||||||
struct BLESourceRouteFailureCache {
|
|
||||||
struct Config {
|
|
||||||
/// How long a routed send may go unconfirmed before it counts as a
|
|
||||||
/// route failure.
|
|
||||||
var confirmationWindowSeconds: TimeInterval = TransportConfig.bleSourceRouteConfirmationWindowSeconds
|
|
||||||
/// How long to flood instead of routing after a failure.
|
|
||||||
var suppressionSeconds: TimeInterval = TransportConfig.bleSourceRouteSuppressionSeconds
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct State {
|
|
||||||
var pendingSince: Date?
|
|
||||||
var suppressedUntil: Date?
|
|
||||||
}
|
|
||||||
|
|
||||||
private let config: Config
|
|
||||||
private var states: [PeerID: State] = [:]
|
|
||||||
|
|
||||||
init(config: Config = Config()) {
|
|
||||||
self.config = config
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the next directed send to `recipient` may carry a source
|
|
||||||
/// route. Flips the recipient into suppression when the last routed send
|
|
||||||
/// went unconfirmed past the confirmation window.
|
|
||||||
mutating func shouldAttemptRoute(to recipient: PeerID, now: Date = Date()) -> Bool {
|
|
||||||
guard var state = states[recipient] else { return true }
|
|
||||||
|
|
||||||
if let until = state.suppressedUntil {
|
|
||||||
guard now >= until else { return false }
|
|
||||||
state.suppressedUntil = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if let pending = state.pendingSince,
|
|
||||||
now.timeIntervalSince(pending) > config.confirmationWindowSeconds {
|
|
||||||
// The routed send was never confirmed: treat the route as broken
|
|
||||||
// and flood until the suppression window lapses.
|
|
||||||
state.pendingSince = nil
|
|
||||||
state.suppressedUntil = now.addingTimeInterval(config.suppressionSeconds)
|
|
||||||
states[recipient] = state
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
states[recipient] = state
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records that a source-routed packet was sent to `recipient`. Keeps the
|
|
||||||
/// earliest unconfirmed send so back-to-back packets share one deadline.
|
|
||||||
mutating func noteRoutedSend(to recipient: PeerID, now: Date = Date()) {
|
|
||||||
var state = states[recipient] ?? State()
|
|
||||||
if state.pendingSince == nil {
|
|
||||||
state.pendingSince = now
|
|
||||||
}
|
|
||||||
states[recipient] = state
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Any inbound packet authored by `peer` confirms the pending routed send
|
|
||||||
/// (delivery acks and replies arrive this way). Deliberately does not
|
|
||||||
/// lift an active suppression: that traffic may have arrived via flood.
|
|
||||||
mutating func noteInboundActivity(from peer: PeerID) {
|
|
||||||
guard var state = states[peer] else { return }
|
|
||||||
state.pendingSince = nil
|
|
||||||
if state.suppressedUntil == nil {
|
|
||||||
states.removeValue(forKey: peer)
|
|
||||||
} else {
|
|
||||||
states[peer] = state
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drops entries that can no longer influence a routing decision. An
|
|
||||||
/// expired-but-unconverted pending entry is kept for as long as the
|
|
||||||
/// suppression it would trigger could still be active.
|
|
||||||
mutating func prune(now: Date = Date()) {
|
|
||||||
let pendingRetention = config.confirmationWindowSeconds + config.suppressionSeconds
|
|
||||||
states = states.filter { _, state in
|
|
||||||
if let until = state.suppressedUntil, now < until { return true }
|
|
||||||
if let pending = state.pendingSince,
|
|
||||||
now.timeIntervalSince(pending) <= pendingRetention {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import BitFoundation
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Decides whether an outbound directed packet should carry a v2 source
|
|
||||||
/// route. Pure gating logic so BLEService's hot send path stays a thin wire.
|
|
||||||
enum BLESourceRouteOriginationPolicy {
|
|
||||||
/// Returns the intermediate-hop route to attach, or nil to keep the
|
|
||||||
/// current flood/direct-write behavior unchanged.
|
|
||||||
///
|
|
||||||
/// Routes are only originated when every gate passes:
|
|
||||||
/// - we authored the packet (relays must not rewrite and re-sign someone
|
|
||||||
/// else's packet; route-following for in-flight routed packets lives in
|
|
||||||
/// `BLERouteForwardingPolicy`),
|
|
||||||
/// - the packet is directed at a single peer (not broadcast),
|
|
||||||
/// - the packet has TTL headroom to traverse hops (link-local TTL-0
|
|
||||||
/// packets like REQUEST_SYNC never route),
|
|
||||||
/// - the recipient is not directly connected (a direct write already
|
|
||||||
/// delivers in one hop),
|
|
||||||
/// - routing to the recipient is not suppressed by a recent unconfirmed
|
|
||||||
/// routed send, and
|
|
||||||
/// - the topology yields a complete path.
|
|
||||||
static func route(
|
|
||||||
for packet: BitchatPacket,
|
|
||||||
to recipient: PeerID,
|
|
||||||
localPeerIDData: Data,
|
|
||||||
isRecipientConnected: (PeerID) -> Bool,
|
|
||||||
shouldAttemptRoute: (PeerID) -> Bool,
|
|
||||||
computeRoute: (PeerID) -> [Data]?
|
|
||||||
) -> [Data]? {
|
|
||||||
guard packet.senderID == localPeerIDData else { return nil }
|
|
||||||
guard let recipientData = packet.recipientID,
|
|
||||||
recipientData.count == 8,
|
|
||||||
!recipientData.allSatisfy({ $0 == 0xFF }) else { return nil }
|
|
||||||
guard packet.ttl > 1 else { return nil }
|
|
||||||
guard !isRecipientConnected(recipient) else { return nil }
|
|
||||||
guard shouldAttemptRoute(recipient) else { return nil }
|
|
||||||
guard let route = computeRoute(recipient), !route.isEmpty else { return nil }
|
|
||||||
return route
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
//
|
|
||||||
// BoardAlertsModel.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Turns newly arriving board posts into local, scope-matched chat alerts.
|
|
||||||
/// Everything here is derived from posts the mesh already synced — no extra
|
|
||||||
/// wire traffic, nothing another peer can't already see.
|
|
||||||
///
|
|
||||||
/// - Urgent, recent pins get one system line in the matching chat (geo pin →
|
|
||||||
/// that geohash's timeline, mesh pin → mesh chat), collapsed when several
|
|
||||||
/// arrive together.
|
|
||||||
/// - Every other new pin just marks the header's pin icon until the notices
|
|
||||||
/// sheet is opened.
|
|
||||||
@MainActor
|
|
||||||
final class BoardAlertsModel: ObservableObject {
|
|
||||||
struct Dependencies {
|
|
||||||
/// Own posts never alert; the author already knows.
|
|
||||||
var isOwnPost: @MainActor (BoardPostPacket) -> Bool
|
|
||||||
/// Appends a local system line to a scope's chat timeline
|
|
||||||
/// (geohash, or "" for mesh chat).
|
|
||||||
var emitSystemLine: @MainActor (_ content: String, _ geohash: String) -> Void
|
|
||||||
var now: () -> Date = Date.init
|
|
||||||
/// Schedules the collapsed flush of pending urgent alerts; tests
|
|
||||||
/// inject a synchronous hook.
|
|
||||||
var scheduleFlush: (_ flush: @escaping @MainActor () -> Void) -> Void = { flush in
|
|
||||||
Task { @MainActor in
|
|
||||||
try? await Task.sleep(nanoseconds: UInt64(BoardAlertsModel.collapseDelaySeconds * 1_000_000_000))
|
|
||||||
flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Posts older than this at arrival are backfilled history carried in by
|
|
||||||
/// a peer, not something happening now; they badge but never line the chat.
|
|
||||||
static let inlineRecencyWindow: TimeInterval = 30 * 60
|
|
||||||
/// Urgent arrivals within this window collapse into one line.
|
|
||||||
static let collapseDelaySeconds: TimeInterval = 4
|
|
||||||
private static let alertContentMaxChars = 120
|
|
||||||
|
|
||||||
/// Unseen new pins by postID (hex) → geohash scope, cleared when the
|
|
||||||
/// notices sheet opens.
|
|
||||||
@Published private(set) var unseenPostScopes: [String: String] = [:]
|
|
||||||
|
|
||||||
/// PostIDs already handled this session, so store eviction/re-sync churn
|
|
||||||
/// can't re-alert. Bounded by session wire volume (32-byte strings).
|
|
||||||
private var handledPostIDs = Set<String>()
|
|
||||||
private var pendingUrgent: [String: [BoardPostPacket]] = [:]
|
|
||||||
private var flushScheduled = false
|
|
||||||
private let dependencies: Dependencies
|
|
||||||
private var cancellable: AnyCancellable?
|
|
||||||
private var wipeCancellable: AnyCancellable?
|
|
||||||
|
|
||||||
private enum Strings {
|
|
||||||
static func urgentSingle(author: String, content: String) -> String {
|
|
||||||
String(
|
|
||||||
format: String(localized: "notices.alert.urgent_single", defaultValue: "📌 urgent notice from @%@: %@", comment: "Local chat line when one urgent notice is pinned nearby"),
|
|
||||||
locale: .current,
|
|
||||||
author, content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func urgentCollapsed(_ count: Int) -> String {
|
|
||||||
String(
|
|
||||||
format: String(localized: "notices.alert.urgent_collapsed", defaultValue: "📌 %lld new urgent notices — tap the pin to view", comment: "Local chat line when several urgent notices arrive together"),
|
|
||||||
locale: .current,
|
|
||||||
count
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init(
|
|
||||||
arrivals: AnyPublisher<BoardPostPacket, Never>,
|
|
||||||
wipes: AnyPublisher<Void, Never> = Empty(completeImmediately: false).eraseToAnyPublisher(),
|
|
||||||
dependencies: Dependencies
|
|
||||||
) {
|
|
||||||
self.dependencies = dependencies
|
|
||||||
cancellable = arrivals
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] post in
|
|
||||||
self?.handleArrival(post)
|
|
||||||
}
|
|
||||||
wipeCancellable = wipes
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] in
|
|
||||||
self?.reset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func unseenCount(forGeohash geohash: String) -> Int {
|
|
||||||
unseenPostScopes.values.reduce(0) { $0 + ($1 == geohash ? 1 : 0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marks pins in the given scopes as seen — only the scopes the notices
|
|
||||||
/// sheet actually shows, so unseen pins for other geohash channels keep
|
|
||||||
/// their badge until visited.
|
|
||||||
func markSeen(forScopes scopes: Set<String>) {
|
|
||||||
guard unseenPostScopes.contains(where: { scopes.contains($0.value) }) else { return }
|
|
||||||
unseenPostScopes = unseenPostScopes.filter { !scopes.contains($0.value) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Panic wipe: drop everything derived from pre-wipe posts, including
|
|
||||||
/// urgent lines still waiting on the collapse flush.
|
|
||||||
func reset() {
|
|
||||||
pendingUrgent.removeAll()
|
|
||||||
handledPostIDs.removeAll()
|
|
||||||
guard !unseenPostScopes.isEmpty else { return }
|
|
||||||
unseenPostScopes.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleArrival(_ post: BoardPostPacket) {
|
|
||||||
let postID = post.postID.hexEncodedString()
|
|
||||||
guard !handledPostIDs.contains(postID) else { return }
|
|
||||||
handledPostIDs.insert(postID)
|
|
||||||
guard !dependencies.isOwnPost(post) else { return }
|
|
||||||
|
|
||||||
unseenPostScopes[postID] = post.geohash
|
|
||||||
|
|
||||||
let createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000)
|
|
||||||
guard post.isUrgent,
|
|
||||||
dependencies.now().timeIntervalSince(createdAt) <= Self.inlineRecencyWindow else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pendingUrgent[post.geohash, default: []].append(post)
|
|
||||||
if !flushScheduled {
|
|
||||||
flushScheduled = true
|
|
||||||
dependencies.scheduleFlush { [weak self] in
|
|
||||||
self?.flushPendingUrgent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func flushPendingUrgent() {
|
|
||||||
flushScheduled = false
|
|
||||||
let pending = pendingUrgent
|
|
||||||
pendingUrgent.removeAll()
|
|
||||||
for (geohash, posts) in pending {
|
|
||||||
guard let first = posts.first else { continue }
|
|
||||||
let line: String
|
|
||||||
if posts.count == 1 {
|
|
||||||
let author = first.authorNickname.trimmedOrNilIfEmpty ?? "anon"
|
|
||||||
line = Strings.urgentSingle(author: author, content: Self.truncated(first.content))
|
|
||||||
} else {
|
|
||||||
line = Strings.urgentCollapsed(posts.count)
|
|
||||||
}
|
|
||||||
dependencies.emitSystemLine(line, geohash)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func truncated(_ content: String) -> String {
|
|
||||||
guard content.count > alertContentMaxChars else { return content }
|
|
||||||
return content.prefix(alertContentMaxChars) + "…"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
//
|
|
||||||
// BoardManager.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import BitLogger
|
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// UI-facing coordinator for the bulletin board: builds and signs posts and
|
|
||||||
/// tombstones with the device's Noise signing key, hands them to the mesh
|
|
||||||
/// transport, and mirrors the store's live posts for SwiftUI.
|
|
||||||
@MainActor
|
|
||||||
final class BoardManager: ObservableObject {
|
|
||||||
/// Live posts across all boards, newest state from the store.
|
|
||||||
@Published private(set) var posts: [BoardPostPacket] = []
|
|
||||||
|
|
||||||
private let transport: Transport
|
|
||||||
/// Publishes a bridged kind-1 note (expiring with the board post via
|
|
||||||
/// NIP-40) and returns its Nostr event id, or nil when bridging failed or
|
|
||||||
/// was skipped.
|
|
||||||
private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String, _ expiresAtMs: UInt64, _ urgent: Bool) -> String?
|
|
||||||
/// Requests NIP-09 deletion of a previously bridged note.
|
|
||||||
private let deleteFromNostr: (_ eventID: String, _ geohash: String) -> Void
|
|
||||||
/// Bridged Nostr event ids by postID, for merged deletes. In-memory only:
|
|
||||||
/// after a relaunch a delete still tombstones the board copy, but the
|
|
||||||
/// Nostr copy is left to expire with relay retention.
|
|
||||||
private var bridgedEventIDs: [Data: String] = [:]
|
|
||||||
private var cancellable: AnyCancellable?
|
|
||||||
|
|
||||||
init(
|
|
||||||
transport: Transport,
|
|
||||||
store: BoardStore = .shared,
|
|
||||||
publishToNostr: ((String, String, String, UInt64, Bool) -> String?)? = nil,
|
|
||||||
deleteFromNostr: ((String, String) -> Void)? = nil
|
|
||||||
) {
|
|
||||||
self.transport = transport
|
|
||||||
self.publishToNostr = publishToNostr ?? Self.livePublishToNostr
|
|
||||||
self.deleteFromNostr = deleteFromNostr ?? Self.liveDeleteFromNostr
|
|
||||||
cancellable = store.$postsSnapshot
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak self] snapshot in
|
|
||||||
self?.posts = snapshot
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Posts for one board context, urgent first, then newest first.
|
|
||||||
func posts(forGeohash geohash: String) -> [BoardPostPacket] {
|
|
||||||
posts
|
|
||||||
.filter { $0.geohash == geohash }
|
|
||||||
.sorted {
|
|
||||||
if $0.isUrgent != $1.isUrgent { return $0.isUrgent }
|
|
||||||
return $0.createdAt > $1.createdAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isOwnPost(_ post: BoardPostPacket) -> Bool {
|
|
||||||
let key = transport.noiseSigningPublicKeyData()
|
|
||||||
return !key.isEmpty && key == post.authorSigningKey
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates, signs, and broadcasts a board post. Returns false when the
|
|
||||||
/// content is empty/oversized or signing fails.
|
|
||||||
@discardableResult
|
|
||||||
func createPost(
|
|
||||||
content: String,
|
|
||||||
geohash: String,
|
|
||||||
urgent: Bool,
|
|
||||||
expiryDays: Int,
|
|
||||||
nickname: String
|
|
||||||
) -> Bool {
|
|
||||||
guard let trimmed = content.trimmedOrNilIfEmpty,
|
|
||||||
trimmed.utf8.count <= BoardWireConstants.contentMaxBytes else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
let signingKey = transport.noiseSigningPublicKeyData()
|
|
||||||
guard signingKey.count == BoardWireConstants.signingKeyLength else { return false }
|
|
||||||
|
|
||||||
var cleanNickname = nickname
|
|
||||||
while cleanNickname.utf8.count > BoardWireConstants.nicknameMaxBytes {
|
|
||||||
cleanNickname.removeLast()
|
|
||||||
}
|
|
||||||
let createdAt = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
||||||
let lifetimeMs = min(
|
|
||||||
UInt64(max(1, expiryDays)) * 24 * 60 * 60 * 1000,
|
|
||||||
BoardWireConstants.maxLifetimeMs
|
|
||||||
)
|
|
||||||
let expiresAt = createdAt + lifetimeMs
|
|
||||||
let flags: UInt8 = urgent ? BoardPostPacket.urgentFlag : 0
|
|
||||||
var postID = Data(count: BoardWireConstants.postIDLength)
|
|
||||||
let status = postID.withUnsafeMutableBytes { buffer -> Int32 in
|
|
||||||
guard let base = buffer.baseAddress else { return -1 }
|
|
||||||
return SecRandomCopyBytes(kSecRandomDefault, buffer.count, base)
|
|
||||||
}
|
|
||||||
guard status == errSecSuccess else { return false }
|
|
||||||
|
|
||||||
let signingBytes = BoardPostPacket.signingBytes(
|
|
||||||
postID: postID,
|
|
||||||
geohash: geohash,
|
|
||||||
content: trimmed,
|
|
||||||
authorSigningKey: signingKey,
|
|
||||||
authorNickname: cleanNickname,
|
|
||||||
createdAt: createdAt,
|
|
||||||
expiresAt: expiresAt,
|
|
||||||
flags: flags
|
|
||||||
)
|
|
||||||
guard let signature = transport.noiseSignData(signingBytes) else {
|
|
||||||
SecureLogger.error("Board: failed to sign post", category: .session)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
let post = BoardPostPacket(
|
|
||||||
postID: postID,
|
|
||||||
geohash: geohash,
|
|
||||||
content: trimmed,
|
|
||||||
authorSigningKey: signingKey,
|
|
||||||
authorNickname: cleanNickname,
|
|
||||||
createdAt: createdAt,
|
|
||||||
expiresAt: expiresAt,
|
|
||||||
flags: flags,
|
|
||||||
signature: signature
|
|
||||||
)
|
|
||||||
transport.sendBoardPayload(BoardWire.post(post).encode())
|
|
||||||
|
|
||||||
// Nostr bridge: geohash posts also go out as kind-1 location notes so
|
|
||||||
// online users see them. Remember the event id for merged deletes.
|
|
||||||
if !geohash.isEmpty, let eventID = publishToNostr(trimmed, geohash, cleanNickname, expiresAt, urgent) {
|
|
||||||
bridgedEventIDs[postID] = eventID
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Signs and broadcasts a tombstone for one of our own posts.
|
|
||||||
@discardableResult
|
|
||||||
func deletePost(_ post: BoardPostPacket) -> Bool {
|
|
||||||
guard isOwnPost(post) else { return false }
|
|
||||||
let deletedAt = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
||||||
let signingBytes = BoardTombstonePacket.signingBytes(postID: post.postID, deletedAt: deletedAt)
|
|
||||||
guard let signature = transport.noiseSignData(signingBytes) else {
|
|
||||||
SecureLogger.error("Board: failed to sign tombstone", category: .session)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
let tombstone = BoardTombstonePacket(
|
|
||||||
postID: post.postID,
|
|
||||||
authorSigningKey: post.authorSigningKey,
|
|
||||||
deletedAt: deletedAt,
|
|
||||||
signature: signature
|
|
||||||
)
|
|
||||||
transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode())
|
|
||||||
|
|
||||||
// Merged delete: also retract the bridged Nostr copy when we still
|
|
||||||
// know its event id.
|
|
||||||
if !post.geohash.isEmpty, let eventID = bridgedEventIDs.removeValue(forKey: post.postID) {
|
|
||||||
deleteFromNostr(eventID, post.geohash)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func livePublishToNostr(content: String, geohash: String, nickname: String, expiresAtMs: UInt64, urgent: Bool) -> String? {
|
|
||||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
|
||||||
guard !relays.isEmpty else {
|
|
||||||
SecureLogger.debug("Board: no geo relays for \(geohash); skipping Nostr bridge", category: .session)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
|
|
||||||
let event = try NostrProtocol.createGeohashTextNote(
|
|
||||||
content: content,
|
|
||||||
geohash: geohash,
|
|
||||||
senderIdentity: identity,
|
|
||||||
nickname: nickname,
|
|
||||||
expiresAt: Date(timeIntervalSince1970: TimeInterval(expiresAtMs) / 1000),
|
|
||||||
urgent: urgent
|
|
||||||
)
|
|
||||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
|
||||||
return event.id
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Board: failed to bridge post to Nostr: \(error)", category: .session)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func liveDeleteFromNostr(eventID: String, geohash: String) {
|
|
||||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
|
||||||
guard !relays.isEmpty else { return }
|
|
||||||
do {
|
|
||||||
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
|
|
||||||
let deletion = try NostrProtocol.createDeleteEvent(ofEventID: eventID, senderIdentity: identity)
|
|
||||||
NostrRelayManager.shared.sendEvent(deletion, to: relays)
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Board: failed to delete bridged Nostr note: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,368 +0,0 @@
|
|||||||
//
|
|
||||||
// BoardStore.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import BitFoundation
|
|
||||||
import BitLogger
|
|
||||||
import Combine
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// Outcome of feeding a board packet into the store, so the transport can
|
|
||||||
/// decide whether the packet is still worth relaying.
|
|
||||||
enum BoardIngestResult {
|
|
||||||
/// New post or tombstone accepted (or a quota rejected it locally while
|
|
||||||
/// it remains valid for other devices).
|
|
||||||
case accepted
|
|
||||||
/// Already known; nothing changed.
|
|
||||||
case duplicate
|
|
||||||
/// Invalid, expired, or deleted; do not relay.
|
|
||||||
case rejected
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Persistent storage for bulletin-board posts and their tombstones.
|
|
||||||
///
|
|
||||||
/// Posts are signed public notices designed to outlive chat: they stay on
|
|
||||||
/// disk until their author-chosen expiry (max 7 days) and re-enter gossip
|
|
||||||
/// sync after a restart. Tombstones are retained until the deleted post's
|
|
||||||
/// original expiry so the delete keeps outrunning stale copies of the post.
|
|
||||||
///
|
|
||||||
/// The on-disk format is the raw signed packets themselves (like
|
|
||||||
/// `GossipMessageArchive`); state is rebuilt by re-verifying and re-ingesting
|
|
||||||
/// them on launch. Wiped on panic.
|
|
||||||
final class BoardStore {
|
|
||||||
enum Limits {
|
|
||||||
static let maxPosts = 200
|
|
||||||
static let maxPostsPerAuthor = 5
|
|
||||||
/// Retention for a tombstone whose post we never saw: we cannot know
|
|
||||||
/// the original expiry, so cap at the max post lifetime.
|
|
||||||
static let orphanTombstoneLifetimeMs = BoardWireConstants.maxLifetimeMs
|
|
||||||
/// Orphan tombstones name posts nobody here has seen, so their volume
|
|
||||||
/// is entirely sender-controlled; cap them like posts.
|
|
||||||
static let maxOrphanTombstones = 100
|
|
||||||
static let maxOrphanTombstonesPerAuthor = 5
|
|
||||||
/// Allowance for clock skew between peers when judging received
|
|
||||||
/// timestamps against local time.
|
|
||||||
static let clockSkewMs: UInt64 = 60 * 60 * 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct StoredPost {
|
|
||||||
let post: BoardPostPacket
|
|
||||||
let packet: BitchatPacket
|
|
||||||
let rawPacket: Data
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct StoredTombstone {
|
|
||||||
let tombstone: BoardTombstonePacket
|
|
||||||
let packet: BitchatPacket
|
|
||||||
let rawPacket: Data
|
|
||||||
let retainUntil: UInt64
|
|
||||||
/// True when no matching post was known at ingest time; only these
|
|
||||||
/// count against the orphan caps.
|
|
||||||
let isOrphan: Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
/// On-disk entry: the raw signed packet, plus the retention deadline for
|
|
||||||
/// tombstones (derived from the deleted post's original expiry, which is
|
|
||||||
/// no longer recoverable once the post is gone).
|
|
||||||
private struct PersistedEntry: Codable {
|
|
||||||
let packet: Data
|
|
||||||
let retainUntil: UInt64?
|
|
||||||
}
|
|
||||||
|
|
||||||
static let shared = BoardStore()
|
|
||||||
|
|
||||||
/// Live posts, published on the main thread for the board UI.
|
|
||||||
@Published private(set) var postsSnapshot: [BoardPostPacket] = []
|
|
||||||
|
|
||||||
/// Fires on the main thread for each post newly accepted from the wire
|
|
||||||
/// (radio, sync, or local echo) — not for disk restores. Drives the
|
|
||||||
/// local new-pin chat alerts; duplicates never fire twice because the
|
|
||||||
/// store rejects them.
|
|
||||||
let postArrivals = PassthroughSubject<BoardPostPacket, Never>()
|
|
||||||
|
|
||||||
/// Fires on the main thread after a panic wipe so derived state (pending
|
|
||||||
/// alerts, unseen badges) is dropped along with the posts themselves.
|
|
||||||
let didWipe = PassthroughSubject<Void, Never>()
|
|
||||||
|
|
||||||
private var posts: [StoredPost] = []
|
|
||||||
private var tombstones: [StoredTombstone] = []
|
|
||||||
private let queue = DispatchQueue(label: "chat.bitchat.board.store")
|
|
||||||
private let fileURL: URL?
|
|
||||||
private let now: () -> Date
|
|
||||||
|
|
||||||
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
|
|
||||||
/// when `persistsToDisk` is false.
|
|
||||||
init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) {
|
|
||||||
self.now = now
|
|
||||||
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
|
|
||||||
loadFromDisk()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Ingest
|
|
||||||
|
|
||||||
/// Ingest a board packet whose payload decodes to `wire`. The caller must
|
|
||||||
/// have verified the wire signature already (`BoardWire.verifySignature`).
|
|
||||||
@discardableResult
|
|
||||||
func ingest(_ wire: BoardWire, packet: BitchatPacket) -> BoardIngestResult {
|
|
||||||
guard let rawPacket = packet.toBinaryData(padding: false) else { return .rejected }
|
|
||||||
let nowMs = currentMs()
|
|
||||||
return queue.sync {
|
|
||||||
let result = ingestLocked(wire, packet: packet, rawPacket: rawPacket, nowMs: nowMs)
|
|
||||||
if result == .accepted {
|
|
||||||
persistLocked()
|
|
||||||
if case .post(let post) = wire {
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.postArrivals.send(post)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Reads
|
|
||||||
|
|
||||||
/// Live posts scoped to one board (geohash, or "" for the mesh board).
|
|
||||||
func posts(forGeohash geohash: String) -> [BoardPostPacket] {
|
|
||||||
let nowMs = currentMs()
|
|
||||||
return queue.sync {
|
|
||||||
pruneExpiredLocked(nowMs: nowMs)
|
|
||||||
return posts.map(\.post).filter { $0.geohash == geohash }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Raw signed packets (posts and live tombstones) for gossip sync rounds.
|
|
||||||
func syncCandidates() -> [BitchatPacket] {
|
|
||||||
let nowMs = currentMs()
|
|
||||||
return queue.sync {
|
|
||||||
pruneExpiredLocked(nowMs: nowMs)
|
|
||||||
return posts.map(\.packet) + tombstones.map(\.packet)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Maintenance
|
|
||||||
|
|
||||||
/// Panic wipe: drop all board data from memory and disk.
|
|
||||||
func wipe() {
|
|
||||||
queue.sync {
|
|
||||||
posts.removeAll()
|
|
||||||
tombstones.removeAll()
|
|
||||||
if let fileURL {
|
|
||||||
try? FileManager.default.removeItem(at: fileURL)
|
|
||||||
}
|
|
||||||
publishSnapshotLocked()
|
|
||||||
}
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.didWipe.send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Internals (call only on `queue`)
|
|
||||||
|
|
||||||
private func ingestLocked(
|
|
||||||
_ wire: BoardWire,
|
|
||||||
packet: BitchatPacket,
|
|
||||||
rawPacket: Data,
|
|
||||||
nowMs: UInt64,
|
|
||||||
retainUntilOverride: UInt64? = nil
|
|
||||||
) -> BoardIngestResult {
|
|
||||||
pruneExpiredLocked(nowMs: nowMs)
|
|
||||||
switch wire {
|
|
||||||
case .post(let post):
|
|
||||||
return ingestPostLocked(post, packet: packet, rawPacket: rawPacket, nowMs: nowMs)
|
|
||||||
case .tombstone(let tombstone):
|
|
||||||
return ingestTombstoneLocked(tombstone, packet: packet, rawPacket: rawPacket, nowMs: nowMs, retainUntilOverride: retainUntilOverride)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func ingestPostLocked(_ post: BoardPostPacket, packet: BitchatPacket, rawPacket: Data, nowMs: UInt64) -> BoardIngestResult {
|
|
||||||
guard post.expiresAt > nowMs else { return .rejected }
|
|
||||||
// Receive-time sanity (this is the single chokepoint for radio, sync,
|
|
||||||
// and disk restores): the decoder only enforces the createdAt to
|
|
||||||
// expiresAt span, so a forged future createdAt would sort ahead of
|
|
||||||
// honest posts and hold a store slot without ever pruning.
|
|
||||||
guard post.createdAt <= nowMs &+ Limits.clockSkewMs,
|
|
||||||
post.expiresAt <= nowMs &+ BoardWireConstants.maxLifetimeMs &+ Limits.clockSkewMs else {
|
|
||||||
return .rejected
|
|
||||||
}
|
|
||||||
if tombstones.contains(where: { $0.tombstone.postID == post.postID && $0.tombstone.authorSigningKey == post.authorSigningKey }) {
|
|
||||||
return .rejected
|
|
||||||
}
|
|
||||||
guard !posts.contains(where: { $0.post.postID == post.postID }) else { return .duplicate }
|
|
||||||
|
|
||||||
posts.append(StoredPost(post: post, packet: packet, rawPacket: rawPacket))
|
|
||||||
|
|
||||||
// Per-author cap, then global cap; oldest posts are evicted first.
|
|
||||||
let authorPosts = posts.filter { $0.post.authorSigningKey == post.authorSigningKey }
|
|
||||||
if authorPosts.count > Limits.maxPostsPerAuthor {
|
|
||||||
evictOldestLocked(from: authorPosts, keep: Limits.maxPostsPerAuthor)
|
|
||||||
}
|
|
||||||
if posts.count > Limits.maxPosts {
|
|
||||||
evictOldestLocked(from: posts, keep: Limits.maxPosts)
|
|
||||||
}
|
|
||||||
publishSnapshotLocked()
|
|
||||||
// Even when the new post itself was the eviction victim it stays
|
|
||||||
// valid mesh-wide; peers with room should still receive it.
|
|
||||||
return .accepted
|
|
||||||
}
|
|
||||||
|
|
||||||
private func ingestTombstoneLocked(
|
|
||||||
_ tombstone: BoardTombstonePacket,
|
|
||||||
packet: BitchatPacket,
|
|
||||||
rawPacket: Data,
|
|
||||||
nowMs: UInt64,
|
|
||||||
retainUntilOverride: UInt64? = nil
|
|
||||||
) -> BoardIngestResult {
|
|
||||||
guard !tombstones.contains(where: { $0.tombstone.postID == tombstone.postID }) else { return .duplicate }
|
|
||||||
|
|
||||||
// Cap retention by both the claimed deletion time (so a doctored file
|
|
||||||
// cannot pin a tombstone past any legal expiry) and the receive time:
|
|
||||||
// deletedAt is sender-chosen, so a far-future value must not retain
|
|
||||||
// the tombstone longer than any post still able to arrive could live.
|
|
||||||
let maxRetain = min(
|
|
||||||
tombstone.deletedAt &+ Limits.orphanTombstoneLifetimeMs,
|
|
||||||
nowMs &+ Limits.orphanTombstoneLifetimeMs &+ Limits.clockSkewMs
|
|
||||||
)
|
|
||||||
let retainUntil: UInt64
|
|
||||||
let isOrphan: Bool
|
|
||||||
if let index = posts.firstIndex(where: { $0.post.postID == tombstone.postID }) {
|
|
||||||
let target = posts[index].post
|
|
||||||
// Only the author's key can delete: the tombstone signature was
|
|
||||||
// already verified against its embedded key, so it suffices to
|
|
||||||
// require that key to be the post's author key.
|
|
||||||
guard target.authorSigningKey == tombstone.authorSigningKey else { return .rejected }
|
|
||||||
retainUntil = target.expiresAt
|
|
||||||
isOrphan = false
|
|
||||||
posts.remove(at: index)
|
|
||||||
publishSnapshotLocked()
|
|
||||||
} else if let retainUntilOverride {
|
|
||||||
// Restored from disk: the post is long gone, so trust the
|
|
||||||
// retention deadline recorded when the delete was first applied.
|
|
||||||
// Orphans were already capped when first ingested off the air.
|
|
||||||
retainUntil = min(retainUntilOverride, maxRetain)
|
|
||||||
isOrphan = false
|
|
||||||
} else {
|
|
||||||
// Post unknown (tombstone raced ahead); keep it around so the
|
|
||||||
// post is suppressed if it arrives later.
|
|
||||||
retainUntil = maxRetain
|
|
||||||
isOrphan = true
|
|
||||||
}
|
|
||||||
guard retainUntil > nowMs else { return .rejected }
|
|
||||||
tombstones.append(StoredTombstone(tombstone: tombstone, packet: packet, rawPacket: rawPacket, retainUntil: retainUntil, isOrphan: isOrphan))
|
|
||||||
if isOrphan {
|
|
||||||
enforceOrphanTombstoneCapsLocked(author: tombstone.authorSigningKey)
|
|
||||||
}
|
|
||||||
// Like posts, a locally evicted tombstone stays valid mesh-wide.
|
|
||||||
return .accepted
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Orphan tombstones reference posts we never saw, so a peer can mint
|
|
||||||
/// unlimited valid ones for random IDs; bound them per author and
|
|
||||||
/// globally, evicting the oldest received first (array order).
|
|
||||||
private func enforceOrphanTombstoneCapsLocked(author: Data) {
|
|
||||||
let authorOrphans = tombstones.filter { $0.isOrphan && $0.tombstone.authorSigningKey == author }
|
|
||||||
if authorOrphans.count > Limits.maxOrphanTombstonesPerAuthor {
|
|
||||||
removeTombstonesLocked(authorOrphans.prefix(authorOrphans.count - Limits.maxOrphanTombstonesPerAuthor))
|
|
||||||
}
|
|
||||||
let orphans = tombstones.filter(\.isOrphan)
|
|
||||||
if orphans.count > Limits.maxOrphanTombstones {
|
|
||||||
removeTombstonesLocked(orphans.prefix(orphans.count - Limits.maxOrphanTombstones))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func removeTombstonesLocked(_ victims: ArraySlice<StoredTombstone>) {
|
|
||||||
guard !victims.isEmpty else { return }
|
|
||||||
let victimIDs = Set(victims.map { $0.tombstone.postID })
|
|
||||||
tombstones.removeAll { victimIDs.contains($0.tombstone.postID) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func evictOldestLocked(from candidates: [StoredPost], keep: Int) {
|
|
||||||
let victims = candidates
|
|
||||||
.sorted { $0.post.createdAt < $1.post.createdAt }
|
|
||||||
.prefix(max(0, candidates.count - keep))
|
|
||||||
guard !victims.isEmpty else { return }
|
|
||||||
let victimIDs = Set(victims.map { $0.post.postID })
|
|
||||||
posts.removeAll { victimIDs.contains($0.post.postID) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func pruneExpiredLocked(nowMs: UInt64) {
|
|
||||||
let postsBefore = posts.count
|
|
||||||
posts.removeAll { $0.post.expiresAt <= nowMs }
|
|
||||||
tombstones.removeAll { $0.retainUntil <= nowMs }
|
|
||||||
if posts.count != postsBefore {
|
|
||||||
publishSnapshotLocked()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func publishSnapshotLocked() {
|
|
||||||
let snapshot = posts.map(\.post)
|
|
||||||
DispatchQueue.main.async { [weak self] in
|
|
||||||
self?.postsSnapshot = snapshot
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func currentMs() -> UInt64 {
|
|
||||||
UInt64(max(0, now().timeIntervalSince1970) * 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Persistence
|
|
||||||
|
|
||||||
private func persistLocked() {
|
|
||||||
guard let fileURL else { return }
|
|
||||||
let payloads = posts.map { PersistedEntry(packet: $0.rawPacket, retainUntil: nil) }
|
|
||||||
+ tombstones.map { PersistedEntry(packet: $0.rawPacket, retainUntil: $0.retainUntil) }
|
|
||||||
do {
|
|
||||||
if payloads.isEmpty {
|
|
||||||
try? FileManager.default.removeItem(at: fileURL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try FileManager.default.createDirectory(
|
|
||||||
at: fileURL.deletingLastPathComponent(),
|
|
||||||
withIntermediateDirectories: true
|
|
||||||
)
|
|
||||||
let data = try JSONEncoder().encode(payloads)
|
|
||||||
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 board store: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadFromDisk() {
|
|
||||||
guard let fileURL,
|
|
||||||
let data = try? Data(contentsOf: fileURL),
|
|
||||||
let payloads = try? JSONDecoder().decode([PersistedEntry].self, from: data) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let nowMs = currentMs()
|
|
||||||
queue.sync {
|
|
||||||
for entry in payloads {
|
|
||||||
guard let packet = BitchatPacket.from(entry.packet),
|
|
||||||
packet.type == MessageType.boardPost.rawValue,
|
|
||||||
let wire = BoardWire.decode(from: packet.payload),
|
|
||||||
wire.verifySignature() else { continue }
|
|
||||||
_ = ingestLocked(wire, packet: packet, rawPacket: entry.packet, nowMs: nowMs, retainUntilOverride: entry.retainUntil)
|
|
||||||
}
|
|
||||||
publishSnapshotLocked()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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("board", isDirectory: true)
|
|
||||||
.appendingPathComponent("posts.json")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
//
|
|
||||||
// UnifiedNotices.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
/// One row in the unified notices sheet: a mesh board post or a Nostr
|
|
||||||
/// location note, normalized for display.
|
|
||||||
struct NoticeItem: Identifiable, Equatable {
|
|
||||||
enum Source: Equatable {
|
|
||||||
/// Signed board post carried by the mesh.
|
|
||||||
case board(BoardPostPacket)
|
|
||||||
/// Kind-1 location note seen on geo relays.
|
|
||||||
case nostr(LocationNotesManager.Note)
|
|
||||||
}
|
|
||||||
|
|
||||||
let id: String
|
|
||||||
let author: String
|
|
||||||
let content: String
|
|
||||||
let createdAt: Date
|
|
||||||
let isUrgent: Bool
|
|
||||||
/// When the notice fades (board expiry or a note's NIP-40 tag, as dead
|
|
||||||
/// drops carry). Nil means it only ages out of the relay window.
|
|
||||||
let expiresAt: Date?
|
|
||||||
let source: Source
|
|
||||||
|
|
||||||
var isBoardPost: Bool {
|
|
||||||
if case .board = source { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
init(post: BoardPostPacket) {
|
|
||||||
id = post.postID.hexEncodedString()
|
|
||||||
author = post.authorNickname.trimmedOrNilIfEmpty ?? "anon"
|
|
||||||
content = post.content
|
|
||||||
createdAt = Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000)
|
|
||||||
isUrgent = post.isUrgent
|
|
||||||
expiresAt = post.expiresAt > 0
|
|
||||||
? Date(timeIntervalSince1970: TimeInterval(post.expiresAt) / 1000)
|
|
||||||
: nil
|
|
||||||
source = .board(post)
|
|
||||||
}
|
|
||||||
|
|
||||||
init(note: LocationNotesManager.Note) {
|
|
||||||
id = note.id
|
|
||||||
let display = note.displayName
|
|
||||||
author = display.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
|
|
||||||
.first.map(String.init) ?? display
|
|
||||||
content = note.content
|
|
||||||
createdAt = note.createdAt
|
|
||||||
isUrgent = note.isUrgent
|
|
||||||
expiresAt = note.expiresAt
|
|
||||||
source = .nostr(note)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Merges mesh board posts and Nostr location notes into one deduplicated
|
|
||||||
/// list for the notices sheet's geo tab.
|
|
||||||
enum UnifiedNotices {
|
|
||||||
/// Board posts on geohash channels are bridged to Nostr as kind-1 notes at
|
|
||||||
/// post time, so the same notice arrives twice. The copies share content
|
|
||||||
/// and nickname but are signed by unlinkable keys; match them
|
|
||||||
/// heuristically by content + author within a time window.
|
|
||||||
static let bridgeDedupeWindow: TimeInterval = 15 * 60
|
|
||||||
|
|
||||||
/// Returns board posts and notes as one list, urgent posts first, then
|
|
||||||
/// newest first. Notes that look like bridged copies of a board post are
|
|
||||||
/// dropped — the board copy wins because it carries urgency and supports
|
|
||||||
/// merged deletion. The geohash must match exactly: the notes
|
|
||||||
/// subscription also surfaces neighboring cells, and a same-text note
|
|
||||||
/// from a neighbor is not the bridged copy.
|
|
||||||
static func merge(posts: [BoardPostPacket], notes: [LocationNotesManager.Note]) -> [NoticeItem] {
|
|
||||||
var items = posts.map(NoticeItem.init(post:))
|
|
||||||
for note in notes {
|
|
||||||
let noteNickname = note.nickname?.trimmedOrNilIfEmpty ?? "anon"
|
|
||||||
let isBridgedCopy = posts.contains { post in
|
|
||||||
post.geohash == note.geohash
|
|
||||||
&& post.content == note.content
|
|
||||||
&& (post.authorNickname.trimmedOrNilIfEmpty ?? "anon") == noteNickname
|
|
||||||
&& abs(Date(timeIntervalSince1970: TimeInterval(post.createdAt) / 1000).timeIntervalSince(note.createdAt)) <= bridgeDedupeWindow
|
|
||||||
}
|
|
||||||
if !isBridgedCopy {
|
|
||||||
items.append(NoticeItem(note: note))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return items.sorted {
|
|
||||||
if $0.isUrgent != $1.isUrgent { return $0.isUrgent }
|
|
||||||
return $0.createdAt > $1.createdAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,339 +0,0 @@
|
|||||||
//
|
|
||||||
// CashuTokenDecoder.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// Decodes Cashu ecash tokens (V3 `cashuA` = base64url JSON, V4 `cashuB` =
|
|
||||||
// base64url CBOR) just far enough to summarize them for the UI: total
|
|
||||||
// amount, unit, mint host, and memo. The app never contacts a mint — tokens
|
|
||||||
// are bearer strings and redemption is delegated to an external wallet.
|
|
||||||
//
|
|
||||||
// This parses attacker-controlled message content, so every path is
|
|
||||||
// bounds-checked, size-capped, and returns nil instead of trapping.
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
enum CashuTokenDecoder {
|
|
||||||
|
|
||||||
struct TokenInfo: Equatable {
|
|
||||||
/// Token serialization version: "A" (JSON) or "B" (CBOR).
|
|
||||||
let version: String
|
|
||||||
/// Sum of all proof amounts; nil when no valid amounts were found.
|
|
||||||
let amount: Int?
|
|
||||||
/// Currency unit as declared by the token (commonly "sat"), if any.
|
|
||||||
let unit: String?
|
|
||||||
/// Host of the (first) mint URL, for display.
|
|
||||||
let mintHost: String?
|
|
||||||
/// Optional sender memo, sanitized for display.
|
|
||||||
let memo: String?
|
|
||||||
|
|
||||||
/// "500 sat" style summary, defaulting the unit to sats per NUT-00.
|
|
||||||
var displayAmount: String? {
|
|
||||||
amount.map { "\($0) \(unit ?? "sat")" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Upper bound on accepted token length in characters. Real tokens are a
|
|
||||||
/// few KB; anything much bigger is abuse we shouldn't spend CPU on.
|
|
||||||
static let maxTokenLength = 60_000
|
|
||||||
/// Per-proof and total amount sanity caps (order of total sats in existence).
|
|
||||||
private static let maxAmount: Int64 = 2_100_000_000_000_000
|
|
||||||
|
|
||||||
// MARK: - Public API
|
|
||||||
|
|
||||||
/// Extracts the bare `cashuA…`/`cashuB…` token from raw text that may be
|
|
||||||
/// a `cashu:`/`cashu://` URI and/or percent-encoded. Returns nil when the
|
|
||||||
/// input doesn't look like a Cashu token at all.
|
|
||||||
static func bareToken(from raw: String) -> String? {
|
|
||||||
var token = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let lower = token.lowercased()
|
|
||||||
if lower.hasPrefix("cashu://") {
|
|
||||||
token = String(token.dropFirst(8))
|
|
||||||
} else if lower.hasPrefix("cashu:") {
|
|
||||||
token = String(token.dropFirst(6))
|
|
||||||
}
|
|
||||||
if token.contains("%"), let decoded = token.removingPercentEncoding {
|
|
||||||
token = decoded
|
|
||||||
}
|
|
||||||
guard token.count >= 12, token.count <= maxTokenLength else { return nil }
|
|
||||||
guard token.hasPrefix("cashuA") || token.hasPrefix("cashuB") else { return nil }
|
|
||||||
// Base64 / base64url payload charset ('.' appears in some legacy multi-part tokens)
|
|
||||||
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_+/=."))
|
|
||||||
guard token.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil }
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decodes a token (raw or `cashu:` URI form) into a display summary.
|
|
||||||
///
|
|
||||||
/// In the default (permissive) mode this is for *rendering*: V3 tokens
|
|
||||||
/// must parse as JSON, but a V4 token whose CBOR we cannot walk still
|
|
||||||
/// returns a generic `TokenInfo` (version "B", no amount) because the
|
|
||||||
/// payload may use encodings this minimal reader doesn't support — an
|
|
||||||
/// unknown chip is fine for display.
|
|
||||||
///
|
|
||||||
/// In `strict` mode (used by the `/pay` SEND path) there is no permissive
|
|
||||||
/// fallback: the token must cleanly decode to a known version *and* carry
|
|
||||||
/// a positive amount, otherwise this returns nil. This stops base64 junk
|
|
||||||
/// and truncated V4 tokens from being relayed as if they were valid money.
|
|
||||||
static func decode(_ raw: String, strict: Bool = false) -> TokenInfo? {
|
|
||||||
guard let token = bareToken(from: raw) else { return nil }
|
|
||||||
let version = String(token[token.index(token.startIndex, offsetBy: 5)])
|
|
||||||
guard let payload = base64URLDecode(String(token.dropFirst(6))), !payload.isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let info: TokenInfo?
|
|
||||||
switch version {
|
|
||||||
case "A":
|
|
||||||
info = decodeV3(payload)
|
|
||||||
case "B":
|
|
||||||
if let walked = decodeV4(payload) {
|
|
||||||
info = walked
|
|
||||||
} else if strict {
|
|
||||||
// Couldn't cleanly walk the CBOR — refuse to send it.
|
|
||||||
return nil
|
|
||||||
} else {
|
|
||||||
info = TokenInfo(version: "B", amount: nil, unit: nil, mintHost: nil, memo: nil)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
guard let info else { return nil }
|
|
||||||
if strict {
|
|
||||||
// A sendable token must resolve to a positive, sane amount.
|
|
||||||
guard let amount = info.amount, amount > 0 else { return nil }
|
|
||||||
}
|
|
||||||
return info
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Base64url
|
|
||||||
|
|
||||||
private static func base64URLDecode(_ input: String) -> Data? {
|
|
||||||
var s = input
|
|
||||||
.replacingOccurrences(of: "-", with: "+")
|
|
||||||
.replacingOccurrences(of: "_", with: "/")
|
|
||||||
// Normalize padding (wallets emit both padded and unpadded forms)
|
|
||||||
s = s.replacingOccurrences(of: "=", with: "")
|
|
||||||
let remainder = s.count % 4
|
|
||||||
if remainder == 1 { return nil }
|
|
||||||
if remainder > 0 { s += String(repeating: "=", count: 4 - remainder) }
|
|
||||||
return Data(base64Encoded: s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - V3 (JSON)
|
|
||||||
|
|
||||||
private static func decodeV3(_ payload: Data) -> TokenInfo? {
|
|
||||||
guard let obj = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any],
|
|
||||||
let entries = obj["token"] as? [[String: Any]],
|
|
||||||
!entries.isEmpty else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var total: Int64 = 0
|
|
||||||
var sawAmount = false
|
|
||||||
var mintHost: String?
|
|
||||||
for entry in entries {
|
|
||||||
if mintHost == nil, let mint = entry["mint"] as? String {
|
|
||||||
mintHost = sanitizedHost(from: mint)
|
|
||||||
}
|
|
||||||
for proof in (entry["proofs"] as? [[String: Any]]) ?? [] {
|
|
||||||
guard let number = proof["amount"] as? NSNumber else { continue }
|
|
||||||
let value = number.int64Value
|
|
||||||
guard value > 0, value <= maxAmount else { continue }
|
|
||||||
total += value
|
|
||||||
guard total <= maxAmount else { return nil }
|
|
||||||
sawAmount = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return TokenInfo(
|
|
||||||
version: "A",
|
|
||||||
amount: sawAmount ? Int(total) : nil,
|
|
||||||
unit: sanitizedUnit(obj["unit"] as? String),
|
|
||||||
mintHost: mintHost,
|
|
||||||
memo: sanitizedMemo(obj["memo"] as? String)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - V4 (CBOR)
|
|
||||||
|
|
||||||
/// Minimal walk of the NUT-00 TokenV4 CBOR map:
|
|
||||||
/// { "m": mint, "u": unit, "d": memo, "t": [ { "i": bytes, "p": [ { "a": amount, … } ] } ] }
|
|
||||||
private static func decodeV4(_ payload: Data) -> TokenInfo? {
|
|
||||||
var reader = CBORReader(data: payload)
|
|
||||||
guard case .map(let pairs)? = reader.parseValue(depth: 0) else { return nil }
|
|
||||||
var mintHost: String?
|
|
||||||
var unit: String?
|
|
||||||
var memo: String?
|
|
||||||
var total: Int64 = 0
|
|
||||||
var sawAmount = false
|
|
||||||
for (key, value) in pairs {
|
|
||||||
guard case .text(let name) = key else { continue }
|
|
||||||
switch (name, value) {
|
|
||||||
case ("m", .text(let mint)):
|
|
||||||
mintHost = sanitizedHost(from: mint)
|
|
||||||
case ("u", .text(let u)):
|
|
||||||
unit = sanitizedUnit(u)
|
|
||||||
case ("d", .text(let d)):
|
|
||||||
memo = sanitizedMemo(d)
|
|
||||||
case ("t", .array(let groups)):
|
|
||||||
for case .map(let group) in groups {
|
|
||||||
for case (.text("p"), .array(let proofs)) in group {
|
|
||||||
for case .map(let proof) in proofs {
|
|
||||||
for case (.text("a"), .unsigned(let amount)) in proof {
|
|
||||||
guard amount > 0, amount <= UInt64(maxAmount) else { continue }
|
|
||||||
total += Int64(amount)
|
|
||||||
guard total <= maxAmount else { return nil }
|
|
||||||
sawAmount = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return TokenInfo(
|
|
||||||
version: "B",
|
|
||||||
amount: sawAmount ? Int(total) : nil,
|
|
||||||
unit: unit,
|
|
||||||
mintHost: mintHost,
|
|
||||||
memo: memo
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Display Sanitization (values are attacker-controlled)
|
|
||||||
|
|
||||||
private static func sanitizedHost(from mint: String) -> String? {
|
|
||||||
guard mint.count <= 512, let host = URL(string: mint)?.host, !host.isEmpty else { return nil }
|
|
||||||
return String(host.lowercased().prefix(48))
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func sanitizedUnit(_ unit: String?) -> String? {
|
|
||||||
guard let unit, !unit.isEmpty, unit.count <= 12,
|
|
||||||
unit.unicodeScalars.allSatisfy({ CharacterSet.alphanumerics.contains($0) }) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return unit
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func sanitizedMemo(_ memo: String?) -> String? {
|
|
||||||
guard let memo, memo.count <= 512 else { return nil }
|
|
||||||
let stripped = CharacterSet.controlCharacters.union(.newlines)
|
|
||||||
var cleaned = ""
|
|
||||||
cleaned.unicodeScalars.append(contentsOf: memo.unicodeScalars.filter { !stripped.contains($0) })
|
|
||||||
cleaned = cleaned.trimmingCharacters(in: .whitespaces)
|
|
||||||
guard !cleaned.isEmpty else { return nil }
|
|
||||||
return String(cleaned.prefix(80))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Minimal CBOR Reader
|
|
||||||
|
|
||||||
/// Just enough definite-length CBOR to traverse a TokenV4 map. Bounded in
|
|
||||||
/// depth, item count, and byte length; indefinite-length items and anything
|
|
||||||
/// else exotic make the parse fail (the caller degrades to a generic chip).
|
|
||||||
private struct CBORReader {
|
|
||||||
indirect enum Value {
|
|
||||||
case unsigned(UInt64)
|
|
||||||
case text(String)
|
|
||||||
case array([Value])
|
|
||||||
case map([(Value, Value)])
|
|
||||||
/// Parsed-and-skipped content we don't need (byte strings, negatives, floats…)
|
|
||||||
case opaque
|
|
||||||
}
|
|
||||||
|
|
||||||
private let bytes: [UInt8]
|
|
||||||
private var index = 0
|
|
||||||
/// Total item budget so hostile nesting can't run away.
|
|
||||||
private var itemBudget = 50_000
|
|
||||||
private static let maxDepth = 16
|
|
||||||
private static let maxContainerCount: UInt64 = 10_000
|
|
||||||
|
|
||||||
init(data: Data) {
|
|
||||||
bytes = [UInt8](data)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func parseValue(depth: Int) -> Value? {
|
|
||||||
guard depth < Self.maxDepth, itemBudget > 0 else { return nil }
|
|
||||||
itemBudget -= 1
|
|
||||||
guard let (major, argument) = readHead() else { return nil }
|
|
||||||
switch major {
|
|
||||||
case 0: // unsigned int
|
|
||||||
return .unsigned(argument)
|
|
||||||
case 1: // negative int (argument already consumed)
|
|
||||||
return .opaque
|
|
||||||
case 2: // byte string
|
|
||||||
return readBytes(count: argument) != nil ? .opaque : nil
|
|
||||||
case 3: // text string
|
|
||||||
guard let raw = readBytes(count: argument) else { return nil }
|
|
||||||
return String(bytes: raw, encoding: .utf8).map(Value.text) ?? .opaque
|
|
||||||
case 4: // array
|
|
||||||
guard argument <= Self.maxContainerCount else { return nil }
|
|
||||||
var items: [Value] = []
|
|
||||||
items.reserveCapacity(Int(min(argument, 64)))
|
|
||||||
for _ in 0..<argument {
|
|
||||||
guard let item = parseValue(depth: depth + 1) else { return nil }
|
|
||||||
items.append(item)
|
|
||||||
}
|
|
||||||
return .array(items)
|
|
||||||
case 5: // map
|
|
||||||
guard argument <= Self.maxContainerCount else { return nil }
|
|
||||||
var pairs: [(Value, Value)] = []
|
|
||||||
pairs.reserveCapacity(Int(min(argument, 64)))
|
|
||||||
for _ in 0..<argument {
|
|
||||||
guard let key = parseValue(depth: depth + 1),
|
|
||||||
let value = parseValue(depth: depth + 1) else { return nil }
|
|
||||||
pairs.append((key, value))
|
|
||||||
}
|
|
||||||
return .map(pairs)
|
|
||||||
case 6: // tag: skip the tag number, parse the tagged value
|
|
||||||
return parseValue(depth: depth + 1)
|
|
||||||
case 7: // simple values / floats (payload consumed by readHead)
|
|
||||||
return .opaque
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads a CBOR head byte plus its argument. Rejects indefinite lengths.
|
|
||||||
private mutating func readHead() -> (major: UInt8, argument: UInt64)? {
|
|
||||||
guard index < bytes.count else { return nil }
|
|
||||||
let head = bytes[index]
|
|
||||||
index += 1
|
|
||||||
let major = head >> 5
|
|
||||||
let info = head & 0x1F
|
|
||||||
switch info {
|
|
||||||
case 0...23:
|
|
||||||
return (major, UInt64(info))
|
|
||||||
case 24:
|
|
||||||
return readUInt(width: 1).map { (major, $0) }
|
|
||||||
case 25:
|
|
||||||
return readUInt(width: 2).map { (major, $0) }
|
|
||||||
case 26:
|
|
||||||
return readUInt(width: 4).map { (major, $0) }
|
|
||||||
case 27:
|
|
||||||
return readUInt(width: 8).map { (major, $0) }
|
|
||||||
default: // 28-30 reserved, 31 indefinite
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private mutating func readUInt(width: Int) -> UInt64? {
|
|
||||||
guard bytes.count - index >= width else { return nil }
|
|
||||||
var value: UInt64 = 0
|
|
||||||
for _ in 0..<width {
|
|
||||||
value = (value << 8) | UInt64(bytes[index])
|
|
||||||
index += 1
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
private mutating func readBytes(count: UInt64) -> [UInt8]? {
|
|
||||||
guard count <= UInt64(bytes.count - index) else { return nil }
|
|
||||||
let length = Int(count)
|
|
||||||
let slice = Array(bytes[index..<(index + length)])
|
|
||||||
index += length
|
|
||||||
return slice
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,17 +22,6 @@ struct CommandGeoParticipant {
|
|||||||
let displayName: String
|
let displayName: String
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The conversation a command was typed into, captured when the command is
|
|
||||||
/// issued so deferred output (e.g. an async /ping result, which can arrive
|
|
||||||
/// many seconds later) lands there even if the user switches chats first.
|
|
||||||
enum CommandOutputDestination: Equatable {
|
|
||||||
/// The #mesh public timeline. Commands that defer output (/ping) are
|
|
||||||
/// mesh-only, so a non-DM origin is always the mesh timeline.
|
|
||||||
case meshTimeline
|
|
||||||
/// The private chat that was open when the command was typed.
|
|
||||||
case privateChat(PeerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Protocol defining what CommandProcessor needs from its context.
|
/// Protocol defining what CommandProcessor needs from its context.
|
||||||
/// This breaks the circular dependency between CommandProcessor and ChatViewModel.
|
/// This breaks the circular dependency between CommandProcessor and ChatViewModel.
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -56,33 +45,14 @@ protocol CommandContextProvider: AnyObject {
|
|||||||
/// Empties the peer's chat (single-writer store intent for `/clear`).
|
/// Empties the peer's chat (single-writer store intent for `/clear`).
|
||||||
func clearPrivateChat(_ peerID: PeerID)
|
func clearPrivateChat(_ peerID: PeerID)
|
||||||
func sendPublicRaw(_ content: String)
|
func sendPublicRaw(_ content: String)
|
||||||
/// Sends a normal public message (with local echo) to the active channel.
|
|
||||||
func sendPublicMessage(_ content: String)
|
|
||||||
|
|
||||||
// MARK: - System Messages
|
// MARK: - System Messages
|
||||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
|
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
|
||||||
func addPublicSystemMessage(_ content: String)
|
func addPublicSystemMessage(_ content: String)
|
||||||
/// The conversation the user is typing into right now. Commands that
|
|
||||||
/// finish asynchronously capture this BEFORE starting async work, so a
|
|
||||||
/// chat switch cannot misroute their deferred output.
|
|
||||||
func currentCommandDestination() -> CommandOutputDestination
|
|
||||||
/// Routes deferred command output (e.g. an async /ping result) into the
|
|
||||||
/// conversation captured when the command was issued.
|
|
||||||
func addCommandOutput(_ content: String, to destination: CommandOutputDestination)
|
|
||||||
|
|
||||||
// MARK: - Favorites
|
// MARK: - Favorites
|
||||||
/// Toggles the favorite via the unified peer flow, which persists by the
|
|
||||||
/// real noise key and notifies the peer over mesh or Nostr.
|
|
||||||
func toggleFavorite(peerID: PeerID)
|
func toggleFavorite(peerID: PeerID)
|
||||||
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||||
// MARK: - Groups
|
|
||||||
// Group logic lives in `ChatGroupCoordinator`; these forward the parsed
|
|
||||||
// /group subcommands.
|
|
||||||
func groupCreate(named name: String) -> CommandResult
|
|
||||||
func groupInvite(nickname: String) -> CommandResult
|
|
||||||
func groupRemove(nickname: String) -> CommandResult
|
|
||||||
func groupLeave() -> CommandResult
|
|
||||||
func groupList() -> CommandResult
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Processes chat commands in a focused, efficient way
|
/// Processes chat commands in a focused, efficient way
|
||||||
@@ -129,25 +99,12 @@ final class CommandProcessor {
|
|||||||
return handleBlock(args)
|
return handleBlock(args)
|
||||||
case "/unblock":
|
case "/unblock":
|
||||||
return handleUnblock(args)
|
return handleUnblock(args)
|
||||||
case "/group":
|
|
||||||
if inGeoPublic || inGeoDM { return .error(message: "groups are only for mesh peers in #mesh") }
|
|
||||||
return handleGroup(args)
|
|
||||||
case "/fav":
|
case "/fav":
|
||||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||||
return handleFavorite(args, add: true)
|
return handleFavorite(args, add: true)
|
||||||
case "/unfav":
|
case "/unfav":
|
||||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||||
return handleFavorite(args, add: false)
|
return handleFavorite(args, add: false)
|
||||||
case "/ping":
|
|
||||||
if inGeoPublic || inGeoDM { return .error(message: "ping only works for mesh peers in #mesh") }
|
|
||||||
return handlePing(args)
|
|
||||||
case "/trace":
|
|
||||||
if inGeoPublic || inGeoDM { return .error(message: "trace only works for mesh peers in #mesh") }
|
|
||||||
return handleTrace(args)
|
|
||||||
case "/pay":
|
|
||||||
return handlePay(args)
|
|
||||||
case "/drop":
|
|
||||||
return handleDrop(args)
|
|
||||||
case "/help":
|
case "/help":
|
||||||
return .success(message: Self.helpText)
|
return .success(message: Self.helpText)
|
||||||
default:
|
default:
|
||||||
@@ -167,42 +124,9 @@ final class CommandProcessor {
|
|||||||
/slap @name — slap with a large trout
|
/slap @name — slap with a large trout
|
||||||
/block @name · /unblock @name
|
/block @name · /unblock @name
|
||||||
/fav @name · /unfav @name — favorites (mesh only)
|
/fav @name · /unfav @name — favorites (mesh only)
|
||||||
/group create <name> — start an encrypted group
|
|
||||||
/group invite @name · /group remove @name — manage members (creator)
|
|
||||||
/group leave · /group list — leave or list your groups
|
|
||||||
/ping @name — measure round-trip time (mesh only)
|
|
||||||
/trace @name — estimated mesh path (mesh only)
|
|
||||||
/pay <token> — send a cashu ecash token in this chat
|
|
||||||
/drop <message> — pin a note to this place for 24h (needs location)
|
|
||||||
/help — this list
|
/help — this list
|
||||||
"""
|
"""
|
||||||
|
|
||||||
/// /drop <text> — a dead drop: pins a note to the current building-level
|
|
||||||
/// geohash with a 24h NIP-40 expiry. Anyone who passes through here and
|
|
||||||
/// looks at notices (or hits the empty-timeline "notes left here" hint)
|
|
||||||
/// reads it.
|
|
||||||
private func handleDrop(_ args: String) -> CommandResult {
|
|
||||||
guard LocationNotesSettings.enabled else {
|
|
||||||
return .error(message: "location notes are off — enable them in the info screen")
|
|
||||||
}
|
|
||||||
guard let content = args.trimmedOrNilIfEmpty else {
|
|
||||||
return .error(message: "usage: /drop <message>")
|
|
||||||
}
|
|
||||||
let location = LocationChannelManager.shared
|
|
||||||
guard location.permissionState == .authorized else {
|
|
||||||
return .error(message: "leaving a note needs location — enable it in the info screen")
|
|
||||||
}
|
|
||||||
guard let geohash = location.availableChannels.first(where: { $0.level == .building })?.geohash else {
|
|
||||||
location.refreshChannels()
|
|
||||||
return .error(message: "still finding this place — try again in a moment")
|
|
||||||
}
|
|
||||||
guard let nickname = contextProvider?.nickname,
|
|
||||||
LocationNotesManager.postDrop(content: content, nickname: nickname, geohash: geohash) else {
|
|
||||||
return .error(message: "no geo relays reachable — note not left")
|
|
||||||
}
|
|
||||||
return .success(message: "📍 note left here — it fades in 24h")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Command Handlers
|
// MARK: - Command Handlers
|
||||||
|
|
||||||
private func handleMessage(_ args: String) -> CommandResult {
|
private func handleMessage(_ args: String) -> CommandResult {
|
||||||
@@ -406,168 +330,39 @@ final class CommandProcessor {
|
|||||||
return .error(message: "cannot unblock \(nickname): not found")
|
return .error(message: "cannot unblock \(nickname): not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let groupUsage = "usage: /group create <name> · invite @name · remove @name · leave · list"
|
|
||||||
|
|
||||||
private func handleGroup(_ args: String) -> CommandResult {
|
|
||||||
let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
|
|
||||||
guard let subcommand = parts.first else {
|
|
||||||
return .error(message: Self.groupUsage)
|
|
||||||
}
|
|
||||||
let rest = parts.count > 1 ? String(parts[1]) : ""
|
|
||||||
guard let provider = contextProvider else { return .handled }
|
|
||||||
|
|
||||||
switch subcommand {
|
|
||||||
case "create":
|
|
||||||
return provider.groupCreate(named: rest)
|
|
||||||
case "invite":
|
|
||||||
return provider.groupInvite(nickname: rest)
|
|
||||||
case "remove":
|
|
||||||
return provider.groupRemove(nickname: rest)
|
|
||||||
case "leave":
|
|
||||||
return provider.groupLeave()
|
|
||||||
case "list":
|
|
||||||
return provider.groupList()
|
|
||||||
default:
|
|
||||||
return .error(message: Self.groupUsage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Mesh Diagnostics
|
|
||||||
|
|
||||||
private enum MeshPeerResolution {
|
|
||||||
case resolved(peerID: PeerID, nickname: String)
|
|
||||||
case failed(CommandResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves a mesh peer for /ping and /trace. Geohash identities are
|
|
||||||
/// rejected — diagnostics measure the BLE mesh, not Nostr.
|
|
||||||
private func resolveMeshPeer(_ args: String, command: String) -> MeshPeerResolution {
|
|
||||||
let targetName = args.trimmed
|
|
||||||
guard !targetName.isEmpty else {
|
|
||||||
return .failed(.error(message: "usage: /\(command) <nickname>"))
|
|
||||||
}
|
|
||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
|
||||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
|
||||||
!peerID.isGeoDM, !peerID.isGeoChat else {
|
|
||||||
return .failed(.error(message: "cannot \(command) \(nickname): not found on mesh"))
|
|
||||||
}
|
|
||||||
return .resolved(peerID: peerID, nickname: nickname)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handlePing(_ args: String) -> CommandResult {
|
|
||||||
let target: (peerID: PeerID, nickname: String)
|
|
||||||
switch resolveMeshPeer(args, command: "ping") {
|
|
||||||
case .resolved(let peerID, let nickname): target = (peerID, nickname)
|
|
||||||
case .failed(let result): return result
|
|
||||||
}
|
|
||||||
|
|
||||||
let nickname = target.nickname
|
|
||||||
let currentProvider = contextProvider
|
|
||||||
// Capture the origin conversation now: the pong can arrive up to
|
|
||||||
// meshPingTimeoutSeconds later, and reading the selected chat at
|
|
||||||
// callback time would misroute the result after a chat switch.
|
|
||||||
let destination = contextProvider?.currentCommandDestination() ?? .meshTimeline
|
|
||||||
meshService?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in
|
|
||||||
let provider = currentProvider
|
|
||||||
guard let result else {
|
|
||||||
provider?.addCommandOutput("no reply from \(nickname)", to: destination)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let hopText: String = result.hops.map { hops in
|
|
||||||
hops == 1 ? " · direct (1 hop)" : " · \(hops) hops"
|
|
||||||
} ?? ""
|
|
||||||
provider?.addCommandOutput("pong from \(nickname): \(result.rttMs) ms\(hopText)", to: destination)
|
|
||||||
}
|
|
||||||
return .success(message: "pinging \(nickname)…")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleTrace(_ args: String) -> CommandResult {
|
|
||||||
let target: (peerID: PeerID, nickname: String)
|
|
||||||
switch resolveMeshPeer(args, command: "trace") {
|
|
||||||
case .resolved(let peerID, let nickname): target = (peerID, nickname)
|
|
||||||
case .failed(let result): return result
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let mesh = meshService,
|
|
||||||
let intermediates = mesh.computeMeshPath(to: target.peerID) else {
|
|
||||||
return .success(message: "no known path to \(target.nickname)")
|
|
||||||
}
|
|
||||||
// Graph-derived from gossiped neighbor claims, not route-recorded —
|
|
||||||
// present it as an estimate.
|
|
||||||
let hopNames = intermediates.map { hop in
|
|
||||||
mesh.peerNickname(peerID: hop) ?? "\(hop.id.prefix(8))…"
|
|
||||||
}
|
|
||||||
let chain = (["you"] + hopNames + [target.nickname]).joined(separator: " → ")
|
|
||||||
let hops = intermediates.count + 1
|
|
||||||
return .success(message: "estimated path: \(chain) (\(hops) hop\(hops == 1 ? "" : "s"))")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `/pay <cashu-token>` — validates the token decodes, then sends it as
|
|
||||||
/// the message body in the current chat. Cashu tokens are bearer
|
|
||||||
/// instruments (whoever redeems first gets the funds), so posting one to
|
|
||||||
/// a public channel requires an explicit `/pay <token> public` confirm.
|
|
||||||
/// The app never contacts a mint; it only relays the string.
|
|
||||||
private func handlePay(_ args: String) -> CommandResult {
|
|
||||||
var parts = args.trimmed.split(separator: " ").map(String.init)
|
|
||||||
guard !parts.isEmpty else {
|
|
||||||
return .success(message: "usage: /pay <token> — paste a cashu token: /pay cashuA…")
|
|
||||||
}
|
|
||||||
|
|
||||||
let confirmedPublic = parts.count > 1 && parts.last?.lowercased() == "public"
|
|
||||||
if confirmedPublic { parts.removeLast() }
|
|
||||||
|
|
||||||
guard parts.count == 1, let token = CashuTokenDecoder.bareToken(from: parts[0]) else {
|
|
||||||
return .error(message: "that doesn't look like a cashu token — expected cashuA… or cashuB…")
|
|
||||||
}
|
|
||||||
guard let info = CashuTokenDecoder.decode(token, strict: true) else {
|
|
||||||
return .error(message: "invalid cashu token — it doesn't decode to a known token with an amount, not sending it")
|
|
||||||
}
|
|
||||||
|
|
||||||
let summary = info.displayAmount ?? "a cashu token"
|
|
||||||
|
|
||||||
if let peerID = contextProvider?.selectedPrivateChatPeer {
|
|
||||||
contextProvider?.sendPrivateMessage(token, to: peerID)
|
|
||||||
return .success(message: "sent \(summary) — cashu is a bearer token; whoever redeems it first gets the funds")
|
|
||||||
}
|
|
||||||
|
|
||||||
guard confirmedPublic else {
|
|
||||||
return .error(message: "this is a public channel — anyone reading it can redeem the token. send anyway: /pay <token> public")
|
|
||||||
}
|
|
||||||
|
|
||||||
contextProvider?.sendPublicMessage(token)
|
|
||||||
return .success(message: "sent \(summary) to the public channel — anyone here can redeem it")
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
||||||
let targetName = args.trimmed
|
let targetName = args.trimmed
|
||||||
guard !targetName.isEmpty else {
|
guard !targetName.isEmpty else {
|
||||||
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
|
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
|
||||||
}
|
}
|
||||||
|
|
||||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||||
|
|
||||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
|
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||||
|
let noisePublicKey = Data(hexString: peerID.id) else {
|
||||||
return .error(message: "can't find peer: \(nickname)")
|
return .error(message: "can't find peer: \(nickname)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve current state by the peer's real noise key. The resolved
|
if add {
|
||||||
// peerID is either the short 16-hex mesh ID or the full 64-hex
|
let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)
|
||||||
// noise-key ID (offline favorite row) — never the noise key itself.
|
FavoritesPersistenceService.shared.addFavorite(
|
||||||
let isCurrentlyFavorite: Bool
|
peerNoisePublicKey: noisePublicKey,
|
||||||
if let noiseKey = peerID.noiseKey {
|
peerNostrPublicKey: existingFavorite?.peerNostrPublicKey,
|
||||||
isCurrentlyFavorite = FavoritesPersistenceService.shared.isFavorite(noiseKey)
|
peerNickname: nickname
|
||||||
|
)
|
||||||
|
|
||||||
|
contextProvider?.toggleFavorite(peerID: peerID)
|
||||||
|
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true)
|
||||||
|
|
||||||
|
return .success(message: "added \(nickname) to favorites")
|
||||||
} else {
|
} else {
|
||||||
isCurrentlyFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.isFavorite ?? false
|
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
||||||
|
|
||||||
|
contextProvider?.toggleFavorite(peerID: peerID)
|
||||||
|
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false)
|
||||||
|
|
||||||
|
return .success(message: "removed \(nickname) from favorites")
|
||||||
}
|
}
|
||||||
|
|
||||||
guard add != isCurrentlyFavorite else {
|
|
||||||
return .success(message: add ? "\(nickname) is already a favorite" : "\(nickname) is not a favorite")
|
|
||||||
}
|
|
||||||
|
|
||||||
// toggleFavorite persists by the real noise key and notifies the peer.
|
|
||||||
contextProvider?.toggleFavorite(peerID: peerID)
|
|
||||||
|
|
||||||
return .success(message: add ? "added \(nickname) to favorites" : "removed \(nickname) from favorites")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,22 +11,13 @@ 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; this store never learns sender,
|
/// Envelopes are opaque ciphertext deposited by mutual favorites; this store
|
||||||
/// recipient, or content. Strict quotas keep the device from becoming a
|
/// never learns sender, recipient, or content. Strict quotas keep the device
|
||||||
/// public mailbag: bounded count, bounded per-depositor count by trust tier,
|
/// from becoming a public mailbag: bounded count, bounded per-depositor
|
||||||
/// bounded size, and a 24-hour lifetime aligned with the outbox retention
|
/// count, bounded size, and a 24-hour lifetime aligned with the outbox
|
||||||
/// policy. Carried mail is included in the panic wipe.
|
/// retention 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
|
||||||
@@ -34,73 +25,15 @@ 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?
|
|
||||||
/// Last publish of this envelope as a bridge courier drop on relays.
|
|
||||||
var lastBridgePublishAt: Date?
|
|
||||||
/// Prekey-sealed (envelope v2) discriminator; nil for static-sealed v1.
|
|
||||||
let prekeyID: UInt32?
|
|
||||||
|
|
||||||
var envelope: CourierEnvelope {
|
var envelope: CourierEnvelope {
|
||||||
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID)
|
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext)
|
||||||
}
|
|
||||||
|
|
||||||
init(
|
|
||||||
recipientTag: Data,
|
|
||||||
expiry: UInt64,
|
|
||||||
ciphertext: Data,
|
|
||||||
depositorNoiseKey: Data,
|
|
||||||
storedAt: Date,
|
|
||||||
tier: CourierDepositTier,
|
|
||||||
copies: UInt8,
|
|
||||||
sprayedTo: Set<Data> = [],
|
|
||||||
lastRemoteHandoverAt: Date? = nil,
|
|
||||||
lastBridgePublishAt: Date? = nil,
|
|
||||||
prekeyID: UInt32? = 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
|
|
||||||
self.lastBridgePublishAt = lastBridgePublishAt
|
|
||||||
self.prekeyID = prekeyID
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
lastBridgePublishAt = try container.decodeIfPresent(Date.self, forKey: .lastBridgePublishAt)
|
|
||||||
prekeyID = try container.decodeIfPresent(UInt32.self, forKey: .prekeyID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Limits {
|
enum Limits {
|
||||||
static let maxEnvelopes = 40
|
static let maxEnvelopes = 20
|
||||||
/// Verified-tier mail can never crowd out favorites' share.
|
static let maxPerDepositor = 5
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -132,11 +65,10 @@ 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 (which tier a depositor gets,
|
/// validity checks reject it. Trust policy (mutual favorite) is the
|
||||||
/// if any) is the caller's responsibility; this store only enforces
|
/// caller's responsibility; this store only enforces resource bounds.
|
||||||
/// resource bounds.
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data, tier: CourierDepositTier = .favorite) -> Bool {
|
func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data) -> 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,
|
||||||
@@ -154,39 +86,18 @@ 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.
|
||||||
// keeping the larger spray budget (bounded by maxCopies either way).
|
if envelopes.contains(where: { $0.ciphertext == envelope.ciphertext }) {
|
||||||
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 {
|
||||||
let perDepositorLimit = tier == .favorite ? Limits.maxPerFavoriteDepositor : Limits.maxPerVerifiedDepositor
|
SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached", category: .session)
|
||||||
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, shedding verified-tier mail before
|
// Oldest-first eviction, matching outbox overflow behavior.
|
||||||
// favorites' so open couriering can't crowd out trusted mail.
|
let evicted = envelopes.removeFirst()
|
||||||
// A verified deposit never displaces a favorite: when only
|
SecureLogger.debug("📦 Courier store full - evicted envelope stored at \(evicted.storedAt)", category: .session)
|
||||||
// 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(
|
||||||
@@ -194,10 +105,7 @@ 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,
|
|
||||||
prekeyID: envelope.prekeyID
|
|
||||||
))
|
))
|
||||||
persistLocked()
|
persistLocked()
|
||||||
return true
|
return true
|
||||||
@@ -223,84 +131,16 @@ 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Envelopes to park on relays as bridge courier drops. Non-destructive
|
|
||||||
/// like remote handover — the relay copy is speculative, so the carried
|
|
||||||
/// copy stays until direct handover or expiry. The per-envelope cooldown
|
|
||||||
/// keeps relay churn from republishing the same mail; publishing relays
|
|
||||||
/// dedup identical events by ID anyway.
|
|
||||||
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
|
|
||||||
let date = now()
|
|
||||||
return queue.sync {
|
|
||||||
pruneExpiredLocked(at: date)
|
|
||||||
var matched: [CourierEnvelope] = []
|
|
||||||
for index in envelopes.indices {
|
|
||||||
if let last = envelopes[index].lastBridgePublishAt,
|
|
||||||
date.timeIntervalSince(last) < cooldown {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
envelopes[index].lastBridgePublishAt = date
|
|
||||||
// The relay 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() {
|
||||||
|
let date = now()
|
||||||
|
queue.sync {
|
||||||
|
pruneExpiredLocked(at: date)
|
||||||
|
persistLocked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Panic wipe: drop all carried mail from memory and disk.
|
/// Panic wipe: drop all carried mail from memory and disk.
|
||||||
func wipe() {
|
func wipe() {
|
||||||
queue.sync {
|
queue.sync {
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
//
|
|
||||||
// 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
//
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Included in the panic wipe alongside the stores it describes.
|
|
||||||
func reset() {
|
|
||||||
lock.lock()
|
|
||||||
counts = [:]
|
|
||||||
defaults.removeObject(forKey: Self.defaultsKey)
|
|
||||||
lock.unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -34,7 +34,23 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
static let shared = FavoritesPersistenceService()
|
static let shared = FavoritesPersistenceService()
|
||||||
|
|
||||||
init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) {
|
/// Default keychain for the `shared` singleton. Under test this is an
|
||||||
|
/// in-memory keychain so touching `shared` never blocks on securityd
|
||||||
|
/// (`SecItemCopyMatching` can hang in test environments) and never reads
|
||||||
|
/// or writes the developer's real keychain. Production behavior is
|
||||||
|
/// unchanged. Tests that need their own instance keep injecting a mock
|
||||||
|
/// via `init(keychain:)`.
|
||||||
|
private nonisolated static func makeDefaultKeychain() -> KeychainManagerProtocol {
|
||||||
|
// PreviewKeychainManager lives in _PreviewHelpers, a development
|
||||||
|
// asset excluded from archive builds — release code must not
|
||||||
|
// reference it. Tests always run Debug, so the guard is lossless.
|
||||||
|
#if DEBUG
|
||||||
|
if TestEnvironment.isRunningTests { return PreviewKeychainManager() }
|
||||||
|
#endif
|
||||||
|
return KeychainManager()
|
||||||
|
}
|
||||||
|
|
||||||
|
init(keychain: KeychainManagerProtocol = FavoritesPersistenceService.makeDefaultKeychain()) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
loadFavorites()
|
loadFavorites()
|
||||||
|
|
||||||
@@ -125,13 +141,7 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
peerNostrPublicKey: String? = nil
|
peerNostrPublicKey: String? = nil
|
||||||
) {
|
) {
|
||||||
let existing = favorites[peerNoisePublicKey]
|
let existing = favorites[peerNoisePublicKey]
|
||||||
// Callers that can't resolve the live nickname pass the "Unknown"
|
let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown"
|
||||||
// placeholder (e.g. a notification arriving before the announce);
|
|
||||||
// never let it clobber a real stored nickname.
|
|
||||||
let incoming = peerNickname.flatMap { name in
|
|
||||||
(name.isEmpty || name == "Unknown") ? nil : name
|
|
||||||
}
|
|
||||||
let displayName = incoming ?? existing?.peerNickname ?? "Unknown"
|
|
||||||
|
|
||||||
SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
|
SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
//
|
|
||||||
// BoundedIDSet.swift
|
|
||||||
// bitchat
|
|
||||||
//
|
|
||||||
// This is free and unencumbered software released into the public domain.
|
|
||||||
// For more information, see <https://unlicense.org>
|
|
||||||
//
|
|
||||||
|
|
||||||
/// Insertion-ordered string set with a fixed capacity; the oldest entry is
|
|
||||||
/// evicted when full. Shared by the gateway and bridge loop-prevention
|
|
||||||
/// caches.
|
|
||||||
struct BoundedIDSet {
|
|
||||||
private var members: Set<String> = []
|
|
||||||
private var order: [String] = []
|
|
||||||
let capacity: Int
|
|
||||||
|
|
||||||
init(capacity: Int) {
|
|
||||||
self.capacity = capacity
|
|
||||||
}
|
|
||||||
|
|
||||||
func contains(_ id: String) -> Bool {
|
|
||||||
members.contains(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns false when the ID was already present.
|
|
||||||
@discardableResult
|
|
||||||
mutating func insert(_ id: String) -> Bool {
|
|
||||||
guard members.insert(id).inserted else { return false }
|
|
||||||
order.append(id)
|
|
||||||
if order.count > capacity {
|
|
||||||
members.remove(order.removeFirst())
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
//
|
|
||||||
// BridgeCourierService.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 Foundation
|
|
||||||
import Security
|
|
||||||
|
|
||||||
/// Courier delivery over the internet bridge: sealed courier envelopes are
|
|
||||||
/// parked on relays as kind-1401 "drops" tagged with their rotating
|
|
||||||
/// recipient tag, so delivery stops requiring a physical courier to bump
|
|
||||||
/// into the recipient.
|
|
||||||
///
|
|
||||||
/// Three duties, all gated on the bridge toggle:
|
|
||||||
/// - Sender: when the message router seals mail for an unreachable peer, a
|
|
||||||
/// copy is published as a drop (queued until relays connect). The drop is
|
|
||||||
/// signed with a fresh throwaway key per publish — the envelope
|
|
||||||
/// authenticates its sender internally via Noise-X, and a stable publisher
|
|
||||||
/// key would leak courier traffic patterns to relays.
|
|
||||||
/// - Recipient: subscribes for its own candidate tags (adjacent UTC days)
|
|
||||||
/// and opens matching drops directly.
|
|
||||||
/// - Gateway (bridge + gateway toggles): additionally watches the tags of
|
|
||||||
/// verified local mesh peers and hands matching drops to them as directed
|
|
||||||
/// courier packets, so mesh-only recipients are served too.
|
|
||||||
///
|
|
||||||
/// Privacy: a drop reveals to relays only that "someone" is messaging "some
|
|
||||||
/// 16-byte day-rotating tag". Only parties who already know the recipient's
|
|
||||||
/// Noise static key can compute the tag; the payload is an opaque Noise-X
|
|
||||||
/// seal. Duplicate deliveries (drop + physical courier + direct link) are
|
|
||||||
/// absorbed downstream by message-ID dedup.
|
|
||||||
@MainActor
|
|
||||||
final class BridgeCourierService: ObservableObject {
|
|
||||||
enum Limits {
|
|
||||||
/// Drops waiting for relay connectivity (bounded, drop-oldest).
|
|
||||||
static let maxPendingDrops = 20
|
|
||||||
/// Republish cooldown for gateway-held envelopes.
|
|
||||||
static let heldEnvelopePublishCooldown: TimeInterval = 30 * 60
|
|
||||||
/// Local peers a gateway watches drops for (x3 candidate tags each).
|
|
||||||
static let maxWatchedPeers = 16
|
|
||||||
/// Tag-set refresh cadence (also covers UTC day rollover).
|
|
||||||
static let refreshIntervalSeconds: TimeInterval = 30 * 60
|
|
||||||
/// Minimum spacing for announce-driven refreshes.
|
|
||||||
static let announceRefreshDebounceSeconds: TimeInterval = 60
|
|
||||||
/// Encoded envelope cap for a drop (16 KiB ciphertext + TLV slack).
|
|
||||||
static let maxDropEnvelopeBytes = 20 * 1024
|
|
||||||
static let maxTrackedIDs = 512
|
|
||||||
}
|
|
||||||
|
|
||||||
static let shared = BridgeCourierService()
|
|
||||||
|
|
||||||
// MARK: Wiring (set once by the bootstrapper; fakes in tests)
|
|
||||||
|
|
||||||
var bridgeEnabled: (@MainActor () -> Bool)?
|
|
||||||
var relaysConnected: (@MainActor () -> Bool)?
|
|
||||||
/// Publishes a signed drop event to the default (DM) relays.
|
|
||||||
var publishEvent: (@MainActor (NostrEvent) -> Void)?
|
|
||||||
/// (Re)opens the drop subscription for the given hex tags.
|
|
||||||
var openSubscription: (@MainActor ([String]) -> Void)?
|
|
||||||
var closeSubscription: (@MainActor () -> Void)?
|
|
||||||
/// Our own Noise static public key.
|
|
||||||
var myNoiseKey: (@MainActor () -> Data?)?
|
|
||||||
/// Verified reachable local peers with known Noise keys.
|
|
||||||
var localVerifiedPeers: (@MainActor () -> [(peerID: PeerID, noiseKey: Data)])?
|
|
||||||
/// Seals content into a carry-only envelope for a recipient key.
|
|
||||||
var sealEnvelope: (@MainActor (String, String, Data) -> CourierEnvelope?)?
|
|
||||||
/// Opens a drop addressed to us (tag verified inside).
|
|
||||||
var openEnvelope: (@MainActor (CourierEnvelope) -> Void)?
|
|
||||||
/// Hands a drop to a matching local peer as a directed courier packet.
|
|
||||||
var deliverToPeer: (@MainActor (CourierEnvelope, PeerID) -> Void)?
|
|
||||||
/// Held envelopes eligible for (re)publish, honoring the cooldown.
|
|
||||||
var heldEnvelopes: (@MainActor (TimeInterval) -> [CourierEnvelope])?
|
|
||||||
/// Timer injection for tests; nil arms a real `Task`.
|
|
||||||
var scheduleTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)?
|
|
||||||
|
|
||||||
// MARK: State
|
|
||||||
|
|
||||||
private(set) var myTagsHex: Set<String> = []
|
|
||||||
private(set) var watchedPeerTags: [(peerID: PeerID, tagsHex: Set<String>)] = []
|
|
||||||
private(set) var pendingDrops: [(envelope: CourierEnvelope, dedupKey: String?)] = []
|
|
||||||
/// Message IDs already published as drops (sender-side dedup).
|
|
||||||
private var publishedDropKeys: BoundedIDSet
|
|
||||||
/// Drop event IDs already handled (multi-relay dedup).
|
|
||||||
private var seenDropEventIDs: BoundedIDSet
|
|
||||||
private var subscriptionOpen = false
|
|
||||||
private var lastSubscribedTags: Set<String> = []
|
|
||||||
private var refreshTimerArmed = false
|
|
||||||
private var lastAnnounceRefresh = Date.distantPast
|
|
||||||
|
|
||||||
private let now: () -> Date
|
|
||||||
|
|
||||||
init(now: @escaping () -> Date = Date.init) {
|
|
||||||
self.now = now
|
|
||||||
self.publishedDropKeys = BoundedIDSet(capacity: Limits.maxTrackedIDs)
|
|
||||||
self.seenDropEventIDs = BoundedIDSet(capacity: Limits.maxTrackedIDs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Sender role
|
|
||||||
|
|
||||||
/// Parallel-deposit a sealed copy of an outbound private message as a
|
|
||||||
/// relay drop. Called by the message router alongside physical courier
|
|
||||||
/// deposits; idempotent per message ID. Returns true when a fresh drop
|
|
||||||
/// was sealed (published now or queued for the next relay connection) —
|
|
||||||
/// the router marks the message "carried" so the sender sees progress.
|
|
||||||
@discardableResult
|
|
||||||
func depositDrop(content: String, messageID: String, recipientNoiseKey: Data) -> Bool {
|
|
||||||
guard bridgeEnabled?() ?? false else { return false }
|
|
||||||
guard !publishedDropKeys.contains(messageID) else { return false }
|
|
||||||
guard let envelope = sealEnvelope?(content, messageID, recipientNoiseKey) else { return false }
|
|
||||||
publishedDropKeys.insert(messageID)
|
|
||||||
publishDrop(envelope)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Publishes held envelopes (mail we carry for others) as drops,
|
|
||||||
/// honoring the per-envelope cooldown.
|
|
||||||
func publishHeldEnvelopes() {
|
|
||||||
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else { return }
|
|
||||||
for envelope in heldEnvelopes?(Limits.heldEnvelopePublishCooldown) ?? [] {
|
|
||||||
publishDrop(envelope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func publishDrop(_ envelope: CourierEnvelope) {
|
|
||||||
guard let encoded = envelope.encode(),
|
|
||||||
encoded.count <= Limits.maxDropEnvelopeBytes,
|
|
||||||
!envelope.isExpired else { return }
|
|
||||||
guard relaysConnected?() ?? false else {
|
|
||||||
pendingDrops.append((envelope, nil))
|
|
||||||
if pendingDrops.count > Limits.maxPendingDrops {
|
|
||||||
pendingDrops.removeFirst(pendingDrops.count - Limits.maxPendingDrops)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let identity = Self.makeThrowawayIdentity(),
|
|
||||||
let event = try? NostrProtocol.createCourierDropEvent(
|
|
||||||
envelope: encoded,
|
|
||||||
recipientTagHex: envelope.recipientTag.hexEncodedString(),
|
|
||||||
expiresAt: Date(timeIntervalSince1970: TimeInterval(envelope.expiry) / 1000),
|
|
||||||
senderIdentity: identity
|
|
||||||
) else {
|
|
||||||
SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
publishEvent?(event)
|
|
||||||
SecureLogger.debug("📦🌉 Published courier drop for tag \(envelope.recipientTag.hexEncodedString().prefix(8))…", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drops queued while relays were unreachable publish on reconnect.
|
|
||||||
func flushPendingDrops() {
|
|
||||||
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false, !pendingDrops.isEmpty else { return }
|
|
||||||
let queued = pendingDrops
|
|
||||||
pendingDrops.removeAll()
|
|
||||||
for item in queued {
|
|
||||||
publishDrop(item.envelope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Subscription (recipient + gateway watch)
|
|
||||||
|
|
||||||
/// Recomputes the watched tag set and (re)opens the subscription.
|
|
||||||
/// Call on toggle changes, relay connectivity changes, and periodically
|
|
||||||
/// (tags rotate daily); idempotent.
|
|
||||||
func refresh() {
|
|
||||||
armRefreshTimerIfNeeded()
|
|
||||||
guard bridgeEnabled?() ?? false, relaysConnected?() ?? false else {
|
|
||||||
if subscriptionOpen {
|
|
||||||
closeSubscription?()
|
|
||||||
subscriptionOpen = false
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let date = now()
|
|
||||||
if let myKey = myNoiseKey?() {
|
|
||||||
myTagsHex = Set(CourierEnvelope.candidateTags(noiseStaticKey: myKey, around: date).map { $0.hexEncodedString() })
|
|
||||||
} else {
|
|
||||||
myTagsHex = []
|
|
||||||
}
|
|
||||||
// While bridging with internet, every device watches drops for its
|
|
||||||
// verified local peers — the single-switch analogue of gateway duty.
|
|
||||||
let peers = (localVerifiedPeers?() ?? []).prefix(Limits.maxWatchedPeers)
|
|
||||||
watchedPeerTags = peers.map { peer in
|
|
||||||
(peer.peerID, Set(CourierEnvelope.candidateTags(noiseStaticKey: peer.noiseKey, around: date).map { $0.hexEncodedString() }))
|
|
||||||
}
|
|
||||||
let allTags = myTagsHex.union(watchedPeerTags.flatMap(\.tagsHex))
|
|
||||||
guard !allTags.isEmpty else {
|
|
||||||
if subscriptionOpen {
|
|
||||||
closeSubscription?()
|
|
||||||
subscriptionOpen = false
|
|
||||||
lastSubscribedTags = []
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Resubscribe only when the watched set actually changed — refresh
|
|
||||||
// fires on every verified announce (field logs showed the drop
|
|
||||||
// subscription rebuilt every ~60s for an unchanged tag set).
|
|
||||||
if !subscriptionOpen || allTags != lastSubscribedTags {
|
|
||||||
openSubscription?(allTags.sorted())
|
|
||||||
subscriptionOpen = true
|
|
||||||
lastSubscribedTags = allTags
|
|
||||||
}
|
|
||||||
flushPendingDrops()
|
|
||||||
publishHeldEnvelopes()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Announce-driven refresh, debounced — a newly verified peer should be
|
|
||||||
/// watched promptly, but announce storms must not thrash subscriptions.
|
|
||||||
func refreshAfterVerifiedAnnounce() {
|
|
||||||
guard bridgeEnabled?() ?? false else { return }
|
|
||||||
guard now().timeIntervalSince(lastAnnounceRefresh) >= Limits.announceRefreshDebounceSeconds else { return }
|
|
||||||
lastAnnounceRefresh = now()
|
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func armRefreshTimerIfNeeded() {
|
|
||||||
guard bridgeEnabled?() ?? false, !refreshTimerArmed else { return }
|
|
||||||
refreshTimerArmed = true
|
|
||||||
let fire: @MainActor () -> Void = { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
self.refreshTimerArmed = false
|
|
||||||
self.refresh()
|
|
||||||
}
|
|
||||||
if let scheduleTimer {
|
|
||||||
scheduleTimer(Limits.refreshIntervalSeconds, fire)
|
|
||||||
} else {
|
|
||||||
Task { @MainActor in
|
|
||||||
try? await Task.sleep(nanoseconds: UInt64(Limits.refreshIntervalSeconds * 1_000_000_000))
|
|
||||||
fire()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Inbound drops
|
|
||||||
|
|
||||||
/// Entry point for every drop event the subscription delivers (the relay
|
|
||||||
/// manager has already verified the event signature).
|
|
||||||
func handleDropEvent(_ event: NostrEvent) {
|
|
||||||
guard bridgeEnabled?() ?? false else { return }
|
|
||||||
guard event.kind == NostrProtocol.EventKind.courierDrop.rawValue else { return }
|
|
||||||
guard seenDropEventIDs.insert(event.id) else { return }
|
|
||||||
guard let data = Data(base64Encoded: event.content),
|
|
||||||
data.count <= Limits.maxDropEnvelopeBytes,
|
|
||||||
let envelope = CourierEnvelope.decode(data),
|
|
||||||
!envelope.isExpired else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let tagHex = envelope.recipientTag.hexEncodedString()
|
|
||||||
// The envelope's own tag must match the event's filterable tag —
|
|
||||||
// otherwise a mislabeled drop could ride a subscription it doesn't
|
|
||||||
// belong to.
|
|
||||||
guard event.tags.contains(where: { $0.count >= 2 && $0[0] == "x" && $0[1] == tagHex }) else { return }
|
|
||||||
|
|
||||||
if myTagsHex.contains(tagHex) {
|
|
||||||
SecureLogger.info("📦🌉 Courier drop for us arrived via bridge", category: .session)
|
|
||||||
openEnvelope?(envelope)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if let match = watchedPeerTags.first(where: { $0.tagsHex.contains(tagHex) }) {
|
|
||||||
SecureLogger.info("📦🌉 Courier drop fetched for local peer \(match.peerID.id.prefix(8))…", category: .session)
|
|
||||||
deliverToPeer?(envelope, match.peerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
|
|
||||||
/// A fresh random Nostr identity for signing one drop.
|
|
||||||
static func makeThrowawayIdentity() -> NostrIdentity? {
|
|
||||||
for _ in 0..<10 {
|
|
||||||
var bytes = Data(count: 32)
|
|
||||||
let status = bytes.withUnsafeMutableBytes { ptr in
|
|
||||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
|
||||||
}
|
|
||||||
guard status == errSecSuccess else { return nil }
|
|
||||||
if let identity = try? NostrIdentity(privateKeyData: bytes) {
|
|
||||||
return identity
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||