Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
021af3a22d | ||
|
|
8c9c1cc6ac | ||
|
|
55d51ba084 | ||
|
|
dc99d641c8 | ||
|
|
020de96519 | ||
|
|
d499c3e415 | ||
|
|
4baeaab717 | ||
|
|
74b8a47d02 | ||
|
|
ef848857b7 | ||
|
|
d5cf64f99e | ||
|
|
35fb9fdd42 | ||
|
|
3e9230229d | ||
|
|
78a291ab77 | ||
|
|
eacd8f0750 | ||
|
|
6886035632 | ||
|
|
e642f696cb | ||
|
|
3f677776bb | ||
|
|
5dffb04912 | ||
|
|
81c45e9649 | ||
|
|
963d3a089f | ||
|
|
f79c2e3e9f | ||
|
|
0c3136282e | ||
|
|
b043324035 | ||
|
|
bbe5e1ef4e | ||
|
|
28ffc53f3f | ||
|
|
8415c52913 | ||
|
|
a0c517c018 | ||
|
|
81a10f73f0 | ||
|
|
87910541ef | ||
|
|
f9032cf2b9 | ||
|
|
d4f0c49787 | ||
|
|
2360140760 | ||
|
|
70229f0be1 | ||
|
|
cee2bcd535 | ||
|
|
3c610a83cd | ||
|
|
60be88a4f5 | ||
|
|
ede6368296 | ||
|
|
276cde44e7 | ||
|
|
201dbac49a | ||
|
|
5fdc15d5af | ||
|
|
c2a5668569 | ||
|
|
7341696280 | ||
|
|
295f855b6f | ||
|
|
3ea4188699 | ||
|
|
a66c591f8e | ||
|
|
75da63c9d7 | ||
|
|
d285c6ad53 | ||
|
|
8296630cf3 | ||
|
|
c74e212ea3 | ||
|
|
7a0c821807 | ||
|
|
0d251ad20c | ||
|
|
c8ceac1968 | ||
|
|
9ccff9cce4 | ||
|
|
66536063ca | ||
|
|
e191e9c6f2 | ||
|
|
b31a63ce37 | ||
|
|
0f26a27980 | ||
|
|
68eeba97ff | ||
|
|
f688e529f6 | ||
|
|
96e32ba990 | ||
|
|
0a2f4d9c9d | ||
|
|
914135adb0 | ||
|
|
cd7ffa0df9 | ||
|
|
bbe1ed0652 | ||
|
|
f07b032b99 | ||
|
|
2cbcb290f7 | ||
|
|
9cf7c80518 | ||
|
|
f76fd8a538 | ||
|
|
09c2c12838 | ||
|
|
8378ff949a | ||
|
|
ca63893197 | ||
|
|
fdf28aa5bb |
@@ -0,0 +1,30 @@
|
|||||||
|
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
|
||||||
@@ -12,7 +12,10 @@ jobs:
|
|||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
# A hung test must fail fast, not hold a runner for GitHub's 360-minute
|
# A hung test must fail fast, not hold a runner for GitHub's 360-minute
|
||||||
# default (observed: intermittent app-suite hangs starving the queue).
|
# default (observed: intermittent app-suite hangs starving the queue).
|
||||||
timeout-minutes: 15
|
# The long steps carry tighter individual bounds (5-minute test watchdog,
|
||||||
|
# 6-minute benchmark step, 10-minute floor gate that may re-run the
|
||||||
|
# benchmarks up to twice on a noisy runner); this is the backstop.
|
||||||
|
timeout-minutes: 25
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false # Don't cancel other matrix jobs when one fails
|
fail-fast: false # Don't cancel other matrix jobs when one fails
|
||||||
@@ -29,17 +32,22 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Swift
|
# Use the Xcode-bundled Swift toolchain: it always matches the SDK on
|
||||||
uses: swift-actions/setup-swift@v2
|
# the runner image. A standalone swift.org toolchain (setup-swift) broke
|
||||||
|
# whenever the image's Xcode moved ahead of it ("this SDK is not
|
||||||
|
# supported by the compiler").
|
||||||
|
- name: Note toolchain version (cache key)
|
||||||
|
id: swift-version
|
||||||
|
run: echo "version=$(swift --version 2>/dev/null | head -1 | shasum | cut -c1-12)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Cache build artifacts
|
- name: Cache build artifacts
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: ${{ matrix.path }}/.build
|
path: ${{ matrix.path }}/.build
|
||||||
key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
|
key: ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
|
${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
|
||||||
${{ runner.os }}-${{ matrix.name }}-
|
${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-
|
||||||
|
|
||||||
- name: Build tests
|
- name: Build tests
|
||||||
# Built separately so the hang watchdog below times only test
|
# Built separately so the hang watchdog below times only test
|
||||||
@@ -97,9 +105,14 @@ jobs:
|
|||||||
|
|
||||||
# Order-of-magnitude performance regression gate. Floors are deliberately
|
# Order-of-magnitude performance regression gate. Floors are deliberately
|
||||||
# generous (see bitchatTests/Performance/perf-floors.json) so this
|
# generous (see bitchatTests/Performance/perf-floors.json) so this
|
||||||
# catches algorithmic regressions, never runner variance.
|
# catches algorithmic regressions, never runner variance. If a metric
|
||||||
|
# still lands below floor (a saturated runner can dip one), the script
|
||||||
|
# re-runs the benchmarks — appending to the same log and keeping each
|
||||||
|
# benchmark's best value per metric — so noise clears on retry while a
|
||||||
|
# real regression fails every attempt. Floors are never lowered by this.
|
||||||
- name: Performance floor gate
|
- name: Performance floor gate
|
||||||
if: matrix.name == 'app'
|
if: matrix.name == 'app'
|
||||||
|
timeout-minutes: 10
|
||||||
run: ./scripts/check-perf-floors.sh perf-output.log
|
run: ./scripts/check-perf-floors.sh perf-output.log
|
||||||
|
|
||||||
# Informational only: surfaces per-file and total line coverage in the
|
# Informational only: surfaces per-file and total line coverage in the
|
||||||
@@ -140,3 +153,27 @@ jobs:
|
|||||||
ARCHS=arm64 \
|
ARCHS=arm64 \
|
||||||
CODE_SIGNING_ALLOWED=NO \
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
build
|
build
|
||||||
|
|
||||||
|
# Advisory only: SwiftLint reports style violations without ever failing the
|
||||||
|
# build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so
|
||||||
|
# it can never break the documented xcodebuild path or block a merge.
|
||||||
|
lint:
|
||||||
|
name: SwiftLint (advisory)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
# This job runs a third-party container image, so give it the least
|
||||||
|
# privilege we can: a read-only token, and no credentials left in the
|
||||||
|
# checkout for the container to find.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
container:
|
||||||
|
# Tag for readability, digest for immutability (tags can be repointed).
|
||||||
|
# Bump both together, deliberately — never a floating tag.
|
||||||
|
image: ghcr.io/realm/swiftlint:0.65.0@sha256:a482729f4b58741875af1566f23397f3f6db300372756fc31606d0a4527fab9e
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Run SwiftLint
|
||||||
|
run: swiftlint lint --reporter github-actions-logging
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"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"]}}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# 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
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Build artifacts and generated sources; keeps local `swiftlint` runs clean
|
||||||
|
# (CI checkouts are fresh, so this only matters in a working tree).
|
||||||
|
excluded:
|
||||||
|
- .build
|
||||||
|
- .swiftpm
|
||||||
|
- .DerivedData
|
||||||
|
- DerivedData
|
||||||
|
- build
|
||||||
|
- localPackages/*/.build
|
||||||
|
|
||||||
|
disabled_rules:
|
||||||
|
- line_length
|
||||||
|
- type_name
|
||||||
|
- identifier_name
|
||||||
|
- statement_position
|
||||||
|
- implicit_optional_initialization
|
||||||
|
- force_try
|
||||||
|
- vertical_whitespace
|
||||||
|
- for_where
|
||||||
|
- control_statement
|
||||||
|
- void_function_in_ternary
|
||||||
|
- redundant_discardable_let # SwiftUI breaks without it
|
||||||
|
# To be enabled as we fix the issues
|
||||||
|
- trailing_whitespace
|
||||||
|
- cyclomatic_complexity
|
||||||
|
- function_body_length
|
||||||
|
- function_parameter_count
|
||||||
|
- type_body_length
|
||||||
|
- file_length
|
||||||
|
- large_tuple
|
||||||
|
- force_cast
|
||||||
|
- multiple_closures_with_trailing_closure
|
||||||
|
- nesting
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
MARKETING_VERSION = 1.5.2
|
MARKETING_VERSION = 1.7.0
|
||||||
CURRENT_PROJECT_VERSION = 1
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ let package = Package(
|
|||||||
.executable(
|
.executable(
|
||||||
name: "bitchat",
|
name: "bitchat",
|
||||||
targets: ["bitchat"]
|
targets: ["bitchat"]
|
||||||
),
|
)
|
||||||
],
|
],
|
||||||
dependencies:[
|
dependencies: [
|
||||||
.package(path: "localPackages/Arti"),
|
.package(path: "localPackages/Arti"),
|
||||||
.package(path: "localPackages/BitFoundation"),
|
.package(path: "localPackages/BitFoundation"),
|
||||||
.package(path: "localPackages/BitLogger"),
|
.package(path: "localPackages/BitLogger"),
|
||||||
|
|||||||
@@ -1,309 +1,141 @@
|
|||||||
# BitChat Protocol Whitepaper
|
# bitchat Protocol Whitepaper
|
||||||
|
|
||||||
**Version 1.1**
|
**Version 2.0**
|
||||||
|
|
||||||
**Date: July 25, 2025**
|
**Date: July 6, 2026**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Abstract
|
## Abstract
|
||||||
|
|
||||||
BitChat is a decentralized, peer-to-peer messaging application designed for secure, private, and censorship-resistant communication over ephemeral, ad-hoc networks. This whitepaper details the BitChat Protocol Stack, a layered architecture that combines a modern cryptographic foundation with a flexible application protocol. At its core, BitChat leverages the Noise Protocol Framework (specifically, the `XX` pattern) to establish mutually authenticated, end-to-end encrypted sessions between peers. This document provides a technical specification of the identity management, session lifecycle, message framing, and security considerations that underpin the BitChat network.
|
bitchat is a decentralized, peer-to-peer messaging application for secure, private, censorship-resistant communication that works with or without the internet. Nearby devices form an ad-hoc Bluetooth Low Energy (BLE) mesh; distant peers are reached over the Nostr protocol when a connection exists. A layered store-and-forward stack — a persistent sender outbox, opportunistic couriers with a spray-and-wait copy budget, gossip-synced public history, and Nostr relay mailboxes — delivers messages to peers who are out of range at send time. This document describes the protocol and its delivery guarantees as implemented.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Introduction
|
## 1. Design Goals
|
||||||
|
|
||||||
In an era of centralized communication platforms, BitChat offers a resilient alternative by operating without central servers. It is designed for scenarios where internet connectivity is unavailable or untrustworthy, such as protests, natural disasters, or remote areas. Communication occurs directly between devices over transports like Bluetooth Low Energy (BLE).
|
* **Confidentiality:** all private communication is end-to-end encrypted; intermediate nodes and couriers carry only opaque ciphertext.
|
||||||
|
* **Authentication:** peers are identified by cryptographic keys; announcements are signed and verified.
|
||||||
|
* **Resilience:** the network functions in lossy, low-bandwidth, partitioned environments with churning membership.
|
||||||
|
* **Eventual delivery:** a message to an out-of-range peer should still arrive — relayed by the mesh, carried by a moving person, or resting on an internet relay — within a bounded retention window.
|
||||||
|
* **Ephemerality by default:** no plaintext message content is ever written to disk. Everything the store-and-forward stack persists is either sealed ciphertext or already-public broadcast traffic, and all of it dies with the panic wipe.
|
||||||
|
|
||||||
The design goals of the BitChat Protocol are:
|
## 2. Architecture Overview
|
||||||
|
|
||||||
* **Confidentiality:** All communication must be unreadable to third parties.
|
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
|
||||||
* **Authentication:** Users must be able to verify the identity of their correspondents.
|
|
||||||
* **Integrity:** Messages cannot be tampered with in transit.
|
|
||||||
* **Forward Secrecy:** The compromise of long-term identity keys must not compromise past session keys.
|
|
||||||
* **Deniability:** It should be difficult to cryptographically prove that a specific user sent a particular message.
|
|
||||||
* **Resilience:** The protocol must function reliably in lossy, low-bandwidth environments.
|
|
||||||
|
|
||||||
This paper specifies the technical details of the protocol designed to meet these goals.
|
* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts.
|
||||||
|
* **Nostr** — private messages to mutual favorites travel as NIP-17 gift-wrapped events over public relays (over Tor where enabled), bridging separate meshes through the internet.
|
||||||
|
|
||||||
---
|
The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly.
|
||||||
|
|
||||||
## 2. Protocol Stack
|
## 3. Identity
|
||||||
|
|
||||||
The BitChat Protocol is a four-layer stack. This layered approach separates concerns, allowing for modularity and future extensibility.
|
Each device holds two long-term key pairs in the Keychain:
|
||||||
|
|
||||||
```mermaid
|
* a **Curve25519 static key** for Noise key agreement — its SHA-256 fingerprint is the peer's stable identity, and
|
||||||
graph TD
|
* an **Ed25519 signing key** for packet signatures.
|
||||||
A[Application Layer] --> B[Session Layer];
|
|
||||||
B --> C[Encryption Layer];
|
|
||||||
C --> D[Transport Layer];
|
|
||||||
|
|
||||||
subgraph "BitChat Application"
|
On the mesh, peers appear under short ephemeral IDs derived per session; favoriting pins the full Noise public key so identity survives across sessions. Mutual favorites also exchange Nostr public keys for the internet path. Optional QR verification binds a nickname to a fingerprint in person.
|
||||||
A
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Message Framing & State"
|
## 4. BLE Mesh Layer
|
||||||
B
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "Noise Protocol Framework"
|
### 4.1 Packet Format
|
||||||
C
|
|
||||||
end
|
|
||||||
|
|
||||||
subgraph "BLE, Wi-Fi Direct, etc."
|
A compact binary header (version, type, TTL, timestamp, flags) is followed by an 8-byte sender ID, an optional 8-byte recipient ID, the payload, and an optional Ed25519 signature. Version 2 packets may carry an explicit source route. Signatures exclude the TTL byte so relays can decrement it without invalidating them. Packets other than fragments are padded toward uniform sizes.
|
||||||
D
|
|
||||||
end
|
|
||||||
|
|
||||||
style A fill:#cde4ff
|
### 4.2 Flood Control
|
||||||
style B fill:#b5d8ff
|
|
||||||
style C fill:#9ac2ff
|
|
||||||
style D fill:#7eadff
|
|
||||||
```
|
|
||||||
|
|
||||||
* **Application Layer:** Defines the structure of user-facing messages (`BitchatMessage`), acknowledgments (`DeliveryAck`), and other application-level data.
|
Relaying is a deterministic controlled flood tuned by local connection degree:
|
||||||
* **Session Layer:** Manages the overall communication packet (`BitchatPacket`). This includes routing information (TTL), message typing, fragmentation, and serialization into a compact binary format.
|
|
||||||
* **Encryption Layer:** Establishes and manages secure channels using the Noise Protocol Framework. It is responsible for the cryptographic handshake, session management, and transport message encryption/decryption.
|
|
||||||
* **Transport Layer:** The underlying physical medium used for data transmission, such as Bluetooth Low Energy (BLE). This layer is abstracted away from the core protocol.
|
|
||||||
|
|
||||||
---
|
* **TTL:** packets originate with TTL 7. Relays clamp: dense graphs (≥ 6 links) cap broadcast TTL at 5; thin chains (≤ 2 links) relay at full incoming depth.
|
||||||
|
* **Deduplication:** an LRU seen-set (1000 entries, 5-minute expiry) keyed by sender, timestamp, type, and a payload digest drops duplicates. A scheduled relay is cancelled when a duplicate arrives first from another relay.
|
||||||
|
* **Jitter:** relays wait a random 10–220 ms (wider when dense) so duplicate suppression wins often.
|
||||||
|
* **Fanout subsetting:** broadcast messages are re-sent to a deterministic, message-ID-seeded subset of links (~log₂ of degree) rather than all of them; announces, fragments, and sync packets use full fanout. The ingress link is always excluded (split horizon).
|
||||||
|
* **Directed traffic** (handshakes, private messages, courier envelopes) relays deterministically with TTL − 1 and tight jitter, and is never subset.
|
||||||
|
|
||||||
## 3. Identity and Key Management
|
### 4.3 Routing
|
||||||
|
|
||||||
A peer's identity in BitChat is defined by two persistent cryptographic key pairs, which are generated on first launch and stored securely in the device's Keychain.
|
Announcements carry up to 10 direct-neighbor IDs, giving each node a shallow topology map (60 s freshness). When a bidirectionally-confirmed path exists, packets are source-routed along it; otherwise — and whenever a route fails — delivery falls back to flooding.
|
||||||
|
|
||||||
1. **Noise Static Key Pair (`Curve25519`):** This is the long-term identity key used for the Noise Protocol handshake. The public part of this key is shared with peers to establish secure sessions.
|
### 4.4 Fragmentation
|
||||||
2. **Signing Key Pair (`Ed25519`):** This key is used to sign announcements and other protocol messages where non-repudiation is required, such as binding a public key to a nickname.
|
|
||||||
|
|
||||||
### 3.1. Fingerprint
|
Packets exceeding the link MTU split into ~469-byte fragments (8-byte fragment ID, index/total header) that relay independently and reassemble at each receiving node (128 concurrent assemblies, 30 s timeout, 1 MiB cap).
|
||||||
|
|
||||||
A user's unique, verifiable fingerprint is the **SHA-256 hash** of their **Noise static public key**. This provides a user-friendly and secure way to verify an identity out-of-band (e.g., by reading it aloud or scanning a QR code).
|
### 4.5 Presence
|
||||||
|
|
||||||
`Fingerprint = SHA256(StaticPublicKey_Curve25519)`
|
Signed announcements propagate multi-hop: every 4 s while isolated, backing off to ~15–30 s (jittered) when connected. A verified announce retains a peer as *reachable* for 60 s after last contact. Connection scheduling is RSSI-gated with duty-cycled scanning to bound battery drain.
|
||||||
|
|
||||||
### 3.2. Identity Management
|
## 5. Encryption
|
||||||
|
|
||||||
The `SecureIdentityStateManager` class is responsible for managing all cryptographic identity material and social metadata (petnames, trust levels, etc.). It uses an in-memory cache for performance and persists this cache to the Keychain after encrypting it with a separate AES-GCM key.
|
### 5.1 Live Sessions: Noise XX
|
||||||
|
|
||||||
---
|
Connected peers establish sessions with the Noise `XX` pattern (Curve25519 / ChaCha20-Poly1305 / SHA-256), providing mutual authentication and forward secrecy. All private payloads — messages, delivery acks, read receipts — ride inside the session as typed ciphertext. Intermediate relays see only opaque `noiseEncrypted` packets.
|
||||||
|
|
||||||
## 4. The Social Trust Layer
|
### 5.2 Offline Seals: Noise X
|
||||||
|
|
||||||
Beyond cryptographic identity, BitChat incorporates a social trust layer, allowing users to manage their relationships with peers. This functionality is handled by the `SecureIdentityStateManager`.
|
Courier envelopes are sealed to the recipient's *static* key with the one-way Noise `X` pattern; the sender's identity is authenticated inside the ciphertext. **This path has no forward secrecy** — compromise of the recipient's static key exposes sealed-but-undelivered mail. A prekey scheme is future work.
|
||||||
|
|
||||||
### 4.1. Peer Verification
|
### 5.3 Nostr Path
|
||||||
|
|
||||||
While the Noise handshake cryptographically authenticates a peer's key, it doesn't confirm the real-world identity of the person holding the device. To solve this, users can perform out-of-band (OOB) verification by comparing fingerprints. Once a user confirms that a peer's fingerprint matches the one they expect, they can mark that peer as "verified". This status is stored locally and displayed in the UI, providing a strong assurance of identity for future conversations.
|
Private messages to mutual favorites are wrapped per NIP-17/NIP-59: a rumor (kind 14) sealed (kind 13) and gift-wrapped (kind 1059) under a throwaway ephemeral key, so relays learn neither sender nor content.
|
||||||
|
|
||||||
### 4.2. Favorites and Blocking
|
## 6. Store and Forward
|
||||||
|
|
||||||
To improve the user experience and provide control over interactions, the protocol supports:
|
Four mechanisms cover the "recipient is not here right now" problem. All persisted state is wiped by panic mode.
|
||||||
* **Favorites:** Users can mark trusted or frequently contacted peers as "favorites". This is a local designation that can be used by the application to prioritize notifications or display peers more prominently.
|
|
||||||
* **Blocking:** Users can block peers. When a peer is blocked, the application will discard any incoming packets from that peer's fingerprint at the earliest possible stage, effectively silencing them without notifying the blocked peer.
|
|
||||||
|
|
||||||
---
|
### 6.1 Sender Outbox
|
||||||
|
|
||||||
## 5. The Noise Protocol Layer
|
Private messages without a prompt route are retained per peer (100 messages/peer, 24 h TTL) and re-sent on reconnect events until a delivery or read ack clears them, or a resend cap (8 attempts) drops them with visible failure. The outbox persists to disk sealed under a ChaChaPoly key held only in the Keychain, so queued mail survives an app kill without ever storing plaintext.
|
||||||
|
|
||||||
BitChat implements the Noise Protocol Framework to provide strong, authenticated end-to-end encryption.
|
### 6.2 Couriers
|
||||||
|
|
||||||
### 5.1. Protocol Name
|
When no transport can deliver promptly, the message is sealed (§5.2) into a **courier envelope** and handed to up to 3 connected peers who may physically encounter the recipient:
|
||||||
|
|
||||||
The specific Noise protocol implemented is:
|
* **Opaque addressing.** The only routing information is a 16-byte rotating recipient tag — an HMAC of the recipient's static key and the UTC day — computable solely by parties who already know that key. Couriers learn neither sender, recipient, nor content, and tags do not correlate across days.
|
||||||
|
* **Trust tiers.** Mutual favorites may deposit 5 envelopes each; any peer with a signature-verified announce may deposit 2, into a bounded pool (20 of 40 slots) that can never crowd out favorites' mail. Envelopes are capped at 16 KiB and 24 h; overflow evicts oldest verified-tier mail first.
|
||||||
|
* **Deposit retry.** Queued messages are re-deposited whenever a new eligible courier connects, until 3 distinct couriers carry the message or it expires.
|
||||||
|
* **Spray and wait.** Envelopes carry a copy budget (initially 4, capped at 8). A courier meeting another eligible courier hands over half its remaining budget, so mail diffuses through a moving crowd instead of riding one person. Budgets, spray history, and carried mail persist across app restarts (iOS file protection).
|
||||||
|
* **Handover.** On a verified *direct* announce from the recipient, matching envelopes are delivered over the live link and removed. On a verified *relayed* announce, a copy floods toward the recipient as a directed packet while the carried original stays put, throttled to one attempt per envelope per 10 minutes.
|
||||||
|
* Receivers dedup by message ID, so redundant copies and the retained outbox original are harmless. Couriered mail from blocked senders is dropped at decryption time.
|
||||||
|
|
||||||
**`Noise_XX_25519_ChaChaPoly_SHA256`**
|
### 6.3 Public History (Gossip Sync)
|
||||||
|
|
||||||
* **`XX` Pattern:** This handshake pattern provides mutual authentication and forward secrecy. It does not require either party to know the other's static public key before the handshake begins. The keys are exchanged and authenticated during the three-part handshake. This is ideal for a decentralized P2P environment.
|
Public broadcast messages are cached (1000 packets) and reconciled between peers every ~15 s using compact GCS filters: each side advertises what it holds, the other returns what is missing. Messages stay sync-able for **6 hours** and the cache persists to disk, so a device that walks between two partitions — or relaunches later — serves the room's recent history to whoever missed it. Fragments and file transfers keep a short 15-minute window.
|
||||||
* **`25519`:** The Diffie-Hellman function used is Curve25519.
|
|
||||||
* **`ChaChaPoly`:** The AEAD (Authenticated Encryption with Associated Data) cipher is ChaCha20-Poly1305.
|
|
||||||
* **`SHA256`:** The hash function used for all cryptographic hashing operations is SHA-256.
|
|
||||||
|
|
||||||
### 5.2. The `XX` Handshake
|
### 6.4 Nostr Mailboxes
|
||||||
|
|
||||||
The `XX` handshake consists of three messages exchanged between an Initiator and a Responder to establish a shared secret and derive transport encryption keys.
|
Gift-wrapped messages rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet.
|
||||||
|
|
||||||
```mermaid
|
### 6.5 Delivery Metrics
|
||||||
sequenceDiagram
|
|
||||||
participant I as Initiator
|
|
||||||
participant R as Responder
|
|
||||||
|
|
||||||
Note over I, R: Pre-computation: h = SHA256(protocol_name)
|
Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drops — no identities, message IDs, or timestamps) let delivery behavior be measured on-device. They never leave the device and are cleared by the panic wipe.
|
||||||
|
|
||||||
I->>R: -> e
|
## 7. Application Layer
|
||||||
Note right of I: I generates ephemeral key `e_i`.<br/>h = SHA256(h + e_i.pub)
|
|
||||||
|
|
||||||
R->>I: <- e, ee, s, es
|
* **Public chat** — signed broadcast messages within the mesh, backed by the gossip-synced history above.
|
||||||
Note left of R: R generates ephemeral key `e_r`.<br/>h = SHA256(h + e_r.pub)<br/>MixKey(DH(e_i, e_r))<br/>R sends static key `s_r`, encrypted.<br/>h = SHA256(h + ciphertext)<br/>MixKey(DH(e_i, s_r))
|
* **Private chat** — end-to-end encrypted messages with delivery and read receipts, over mesh, courier, or Nostr.
|
||||||
|
* **Location channels** — geohash-scoped public rooms carried over Nostr relays for regional chat beyond radio range.
|
||||||
I->>R: -> s, se
|
* **Favorites** — the mutual-trust relationship that unlocks Nostr delivery and the larger courier quota.
|
||||||
Note right of I: I decrypts and verifies `s_r`.<br/>I sends static key `s_i`, encrypted.<br/>h = SHA256(h + ciphertext)<br/>MixKey(DH(s_i, e_r))
|
* **Media** — files and images fragment over the mesh (1 MiB cap, explicit accept before anything touches disk); couriers carry text only.
|
||||||
|
* **Panic wipe** — clears identity keys, favorites, carried courier mail, the sealed outbox, archived public history, and metrics.
|
||||||
Note over I, R: Handshake complete. Transport keys derived.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Handshake Flow:**
|
|
||||||
|
|
||||||
1. **Initiator -> Responder:** The initiator generates a new ephemeral key pair (`e_i`) and sends the public part to the responder.
|
|
||||||
2. **Responder -> Initiator:** The responder receives the initiator's ephemeral public key. It then generates its own ephemeral key pair (`e_r`), performs a DH exchange with the initiator's ephemeral key (`ee`), sends its own static public key (`s_r`) encrypted with the resulting symmetric key, and performs another DH exchange between the initiator's ephemeral key and its own static key (`es`).
|
|
||||||
3. **Initiator -> Responder:** The initiator receives the responder's message, decrypts the responder's static key, and authenticates it. The initiator then sends its own static key (`s_i`) encrypted and performs a final DH exchange between its static key and the responder's ephemeral key (`se`).
|
|
||||||
|
|
||||||
Upon completion, both parties share a set of symmetric keys for bidirectional transport message encryption. The final handshake hash is used for channel binding.
|
|
||||||
|
|
||||||
### 5.3. Session Management
|
|
||||||
|
|
||||||
The `NoiseSessionManager` class manages all active Noise sessions. It handles:
|
|
||||||
* Creating sessions for new peers.
|
|
||||||
* Coordinating the handshake process to prevent race conditions.
|
|
||||||
* Storing the resulting transport ciphers (`sendCipher`, `receiveCipher`).
|
|
||||||
* Periodically checking if sessions need to be re-keyed for enhanced security.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. The BitChat Session and Application Protocol
|
|
||||||
|
|
||||||
Once a Noise session is established, peers exchange `BitchatPacket` structures, which are encrypted as the payload of Noise transport messages.
|
|
||||||
|
|
||||||
### 6.1. Binary Packet Format (`BitchatPacket`)
|
|
||||||
|
|
||||||
To minimize bandwidth, `BitchatPacket`s are serialized into a compact binary format. The structure is designed to be fixed-size where possible to resist traffic analysis.
|
|
||||||
|
|
||||||
| Field | Size (bytes) | Description |
|
|
||||||
|-----------------|--------------|---------------------------------------------------------------------------------------------------------|
|
|
||||||
| **Header** | **13** | **Fixed-size header** |
|
|
||||||
| Version | 1 | Protocol version (currently `1`). |
|
|
||||||
| Type | 1 | Message type (e.g., `message`, `deliveryAck`, `noiseHandshakeInit`). See `MessageType` enum. |
|
|
||||||
| TTL | 1 | Time-To-Live for mesh network routing. Decremented at each hop. |
|
|
||||||
| Timestamp | 8 | `UInt64` millisecond timestamp of packet creation. |
|
|
||||||
| Flags | 1 | Bitmask for optional fields (`hasRecipient`, `hasSignature`, `isCompressed`). |
|
|
||||||
| Payload Length | 2 | `UInt16` length of the payload field. |
|
|
||||||
| **Variable** | **...** | **Variable-size fields** |
|
|
||||||
| Sender ID | 8 | 8-byte truncated peer ID of the sender. |
|
|
||||||
| Recipient ID | 8 (optional) | 8-byte truncated peer ID of the recipient. Present if `hasRecipient` flag is set. Broadcast if `0xFF..FF`. |
|
|
||||||
| Payload | Variable | The actual content of the packet, as defined by the `Type` field. |
|
|
||||||
| Signature | 64 (optional)| `Ed25519` signature of the packet. Present if `hasSignature` flag is set. |
|
|
||||||
|
|
||||||
**Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
---
|
|
||||||
config:
|
|
||||||
theme: dark
|
|
||||||
---
|
|
||||||
---
|
|
||||||
title: "BitchatPacket"
|
|
||||||
---
|
|
||||||
packet
|
|
||||||
+8: "Version"
|
|
||||||
+8: "Type"
|
|
||||||
+8: "TTL"
|
|
||||||
+64: "Timestamp"
|
|
||||||
+8: "Flags"
|
|
||||||
+16: "Payload Length"
|
|
||||||
+64: "Sender ID"
|
|
||||||
+64: "Recipient ID (optional)"
|
|
||||||
+48: "Payload (variable)"
|
|
||||||
+64: "Signature (optional)"
|
|
||||||
```
|
|
||||||
_A representation of the sizes of the fields in `BitchatPacket`_
|
|
||||||
|
|
||||||
### 6.2. Application Message Format (`BitchatMessage`)
|
|
||||||
|
|
||||||
For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.
|
|
||||||
|
|
||||||
| Field | Size (bytes) | Description |
|
|
||||||
|---------------------|--------------|--------------------------------------------------------------------------|
|
|
||||||
| Flags | 1 | Bitmask for optional fields (`isRelay`, `isPrivate`, `hasOriginalSender`). |
|
|
||||||
| Timestamp | 8 | `UInt64` millisecond timestamp of message creation. |
|
|
||||||
| ID | 1 + len | `UUID` string for the message. |
|
|
||||||
| Sender | 1 + len | Nickname of the sender. |
|
|
||||||
| Content | 2 + len | The UTF-8 encoded message content. |
|
|
||||||
| Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. |
|
|
||||||
| Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. |
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
---
|
|
||||||
config:
|
|
||||||
theme: dark
|
|
||||||
---
|
|
||||||
---
|
|
||||||
title: "BitchatMessage"
|
|
||||||
---
|
|
||||||
packet
|
|
||||||
+8: "Flags"
|
|
||||||
+64: "Timestamp"
|
|
||||||
+24: "ID (variable)"
|
|
||||||
+32: "Sender (variable)"
|
|
||||||
+32: "Content (variable)"
|
|
||||||
+32: "Original Sender (variable) (optional)"
|
|
||||||
+32: "Recipient Nickname (variable) (optional)"
|
|
||||||
```
|
|
||||||
_A representation of the sizes of the fields in `BitchatMessage`_
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Message Routing and Propagation
|
|
||||||
|
|
||||||
BitChat operates as a decentralized mesh network, meaning there are no central servers to route messages. Packets are propagated through the network from peer to peer. The protocol supports several modes of message delivery.
|
|
||||||
|
|
||||||
### 7.1. Direct Connection
|
|
||||||
|
|
||||||
This is the simplest case. If Peer A and Peer B are directly connected, they can exchange packets after establishing a mutually authenticated Noise session. All packets are encrypted using the transport ciphers derived from the handshake.
|
|
||||||
|
|
||||||
### 7.2. Efficient Gossip with Bloom Filters
|
|
||||||
|
|
||||||
To send messages to peers that are not directly connected, BitChat employs a "flooding" or "gossip" protocol. When a peer receives a packet that is not destined for it, it acts as a relay. To prevent infinite routing loops and minimize memory usage, the protocol uses an `OptimizedBloomFilter` to track recently seen packet IDs.
|
|
||||||
|
|
||||||
The logic is as follows:
|
|
||||||
|
|
||||||
1. A peer receives a packet.
|
|
||||||
2. It checks the Bloom filter to see if the packet's ID has likely been seen before. If so, the packet is discarded. Bloom filters can have false positives (though they are rare), but they guarantee no false negatives. This means that while some packets may be incorrectly discarded due to false positives, the gossip protocol's redundancy ensures these packets will eventually be received through subsequent exchanges with other peers.
|
|
||||||
3. If the packet is new, its ID is added to the Bloom filter.
|
|
||||||
4. The peer decrements the packet's Time-To-Live (TTL) field.
|
|
||||||
5. If the TTL is greater than zero, the peer re-broadcasts the packet to all of its connected peers, *except* for the peer from which it received the packet.
|
|
||||||
|
|
||||||
This mechanism allows packets to "flood" through the network efficiently, maximizing the chance of reaching their destination while using minimal resources to prevent loops.
|
|
||||||
|
|
||||||
### 7.3. Time-To-Live (TTL)
|
|
||||||
|
|
||||||
Every `BitchatPacket` contains an 8-bit TTL field. This value is set by the originating peer and is decremented by one at each relay hop. If a peer receives a packet and decrements its TTL to 0, it will process the packet (if it is the recipient) but will not relay it further. This is a crucial mechanism to prevent packets from circulating endlessly in the mesh.
|
|
||||||
|
|
||||||
### 7.4. Private vs. Broadcast Messages
|
|
||||||
|
|
||||||
The routing logic respects the confidentiality of private messages:
|
|
||||||
|
|
||||||
* **Private Messages:** A packet with a specific `recipientID` is a private message. Relay nodes forward the entire, encrypted Noise message without being able to access the inner `BitchatPacket` or its payload. Only the final recipient, who shares the correct Noise session keys with the sender, can decrypt the packet.
|
|
||||||
* **Broadcast Messages:** A packet with the special broadcast `recipientID` (`0xFFFFFFFFFFFFFFFF`) is intended for all peers. Any peer that receives and decrypts a broadcast message will process its content. It will still be relayed according to the flooding algorithm to ensure it reaches the entire network.
|
|
||||||
|
|
||||||
### 7.5. Message Reliability and Lifecycle
|
|
||||||
|
|
||||||
To function in unreliable, lossy networks, the protocol includes features to track the lifecycle of a message and ensure its delivery.
|
|
||||||
|
|
||||||
* **Delivery Acknowledgments (`DeliveryAck`):** When a private message reaches its final destination, the recipient's device sends a `DeliveryAck` packet back to the original sender. This acknowledgment contains the ID of the original message.
|
|
||||||
* **Read Receipts (`ReadReceipt`):** After a message is displayed on the recipient's screen, the application can send a `ReadReceipt`, also containing the original message ID, to inform the sender that the message has been seen.
|
|
||||||
* **Message Retry Service:** Senders maintain a `MessageRetryService` which tracks outgoing messages. If a `DeliveryAck` is not received for a message within a certain time window, the service will automatically re-send the message, creating a more resilient user experience.
|
|
||||||
|
|
||||||
### 7.6. Fragmentation
|
|
||||||
|
|
||||||
Transport layers like BLE have a Maximum Transmission Unit (MTU) that limits the size of a single packet. To handle messages larger than this limit, BitChat implements a fragmentation protocol.
|
|
||||||
|
|
||||||
* **`fragmentStart`:** A packet with this type marks the beginning of a fragmented message. It contains metadata about the total size and number of fragments.
|
|
||||||
* **`fragmentContinue`:** These packets carry the intermediate chunks of the message data.
|
|
||||||
* **`fragmentEnd`:** This packet carries the final chunk of the message and signals the receiver to begin reassembly.
|
|
||||||
|
|
||||||
Receiving peers collect all fragments and reassemble them in the correct order before passing the complete message up to the application layer.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Security Considerations
|
## 8. Security Considerations
|
||||||
|
|
||||||
* **Replay Attacks:** The Noise transport messages include a nonce that is incremented for each message. The `NoiseCipherState` implements a sliding window replay protection mechanism to detect and discard replayed or out-of-order messages.
|
* **Relay nodes** cannot read private traffic; they forward padded, opaque ciphertext.
|
||||||
* **Denial of Service:** The `NoiseRateLimiter` is implemented to prevent resource exhaustion from rapid, repeated handshake attempts from a single peer.
|
* **Couriers** are quota-bounded mailbags. A malicious courier can drop mail (redundant copies and deposit retry mitigate this) but cannot read it, link it across days, or amplify it — copy budgets are capped and every envelope is validated against size and lifetime policy on deposit.
|
||||||
* **Key-Compromise Impersonation:** The `XX` pattern authenticates both parties, preventing an attacker from impersonating one party to the other.
|
* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
|
||||||
* **Identity Binding:** While the Noise handshake authenticates the cryptographic keys, binding those keys to a human-readable nickname is handled at the application layer. Users must verify fingerprints out-of-band to prevent man-in-the-middle attacks.
|
* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
|
||||||
* **Traffic Analysis:** The use of fixed-size padding for all packets helps to obscure the exact nature and content of the communication, making it harder for a network-level adversary to infer information based on message size.
|
* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
|
||||||
|
* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path.
|
||||||
|
|
||||||
|
## 9. Future Work
|
||||||
|
|
||||||
|
* Prekey-based forward secrecy for courier envelopes.
|
||||||
|
* Couriered media beyond the 16 KiB text cap.
|
||||||
|
* Probabilistic relay and edge-of-network TTL boosting for very dense and very sparse graphs.
|
||||||
|
* Multi-hop courier routing informed by encounter history.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. Conclusion
|
*This document describes the protocol as implemented in the current release. The implementation is free and unencumbered software released into the public domain.*
|
||||||
|
|
||||||
The BitChat Protocol provides a robust and secure foundation for decentralized, peer-to-peer communication. By layering a flexible application protocol on top of the well-regarded Noise Protocol Framework, it achieves strong confidentiality, authentication, and forward secrecy. The use of a compact binary format and thoughtful security considerations like rate limiting and traffic analysis resistance make it suitable for use in challenging network environments.
|
|
||||||
|
|||||||
@@ -528,7 +528,6 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
@@ -561,7 +560,6 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.2;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -620,7 +618,6 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.2;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -655,7 +652,6 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = 1.5.2;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -716,7 +712,6 @@
|
|||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -749,7 +744,6 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = 1.5.2;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -816,7 +810,6 @@
|
|||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
@@ -846,7 +839,6 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
|||||||
@@ -36,34 +36,12 @@ 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,6 +10,10 @@ 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
|
||||||
@@ -18,6 +22,9 @@ 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
|
||||||
@@ -59,6 +66,33 @@ 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,8 +18,6 @@ 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
|
||||||
@@ -28,6 +26,7 @@ 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>()
|
||||||
@@ -41,7 +40,7 @@ final class AppRuntime: ObservableObject {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
init(
|
init(
|
||||||
keychain: KeychainManagerProtocol = KeychainManager(),
|
keychain: KeychainManagerProtocol = KeychainManager.makeDefault(),
|
||||||
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
||||||
) {
|
) {
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
@@ -50,8 +49,6 @@ 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,
|
||||||
@@ -91,6 +88,24 @@ 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()
|
||||||
@@ -202,7 +217,16 @@ final class AppRuntime: ObservableObject {
|
|||||||
chatViewModel.applicationWillTerminate()
|
chatViewModel.applicationWillTerminate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleNotificationResponse(identifier: String, userInfo: [AnyHashable: Any]) {
|
func handleNotificationResponse(
|
||||||
|
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,16 +796,6 @@ 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,6 +12,9 @@ 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
|
||||||
@@ -49,6 +52,14 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
chatViewModel.sendMessage(message)
|
chatViewModel.sendMessage(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resends a failed private message through the normal send path,
|
||||||
|
/// removing the failed original so the re-submission replaces it
|
||||||
|
/// instead of stacking a duplicate under the red bubble.
|
||||||
|
func resendFailedPrivateMessage(_ message: BitchatMessage) {
|
||||||
|
chatViewModel.removePrivateMessage(withID: message.id)
|
||||||
|
chatViewModel.sendMessage(message.content)
|
||||||
|
}
|
||||||
|
|
||||||
func clearCurrentConversation() {
|
func clearCurrentConversation() {
|
||||||
chatViewModel.sendMessage("/clear")
|
chatViewModel.sendMessage("/clear")
|
||||||
}
|
}
|
||||||
@@ -67,11 +78,23 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
if let peerID, peerID.isGeoChat,
|
if let peerID, peerID.isGeoChat,
|
||||||
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
|
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||||
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName)
|
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName)
|
||||||
|
} else if let peerID, !peerID.isGeoDM, !peerID.isGeoChat {
|
||||||
|
// Mesh: block the peer's stable Noise identity resolved from the
|
||||||
|
// tapped peerID rather than re-resolving a display-name string.
|
||||||
|
chatViewModel.blockMeshPeer(peerID: peerID, displayName: displayName)
|
||||||
} else {
|
} else {
|
||||||
chatViewModel.sendMessage("/block \(displayName)")
|
chatViewModel.sendMessage("/block \(displayName)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mesh counterpart of `block(peerID:displayName:)`. Resolves the unblock by
|
||||||
|
/// the tapped peer's stable identity so the exact row is unblocked — this
|
||||||
|
/// also works for offline peers, which the `/unblock <displayName>` command
|
||||||
|
/// cannot resolve.
|
||||||
|
func unblock(peerID: PeerID, displayName: String) {
|
||||||
|
chatViewModel.unblockMeshPeer(peerID: peerID, displayName: displayName)
|
||||||
|
}
|
||||||
|
|
||||||
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
||||||
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
|
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
|
||||||
}
|
}
|
||||||
@@ -130,6 +153,17 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -155,6 +189,10 @@ 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
|
||||||
@@ -173,7 +211,9 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
|
|
||||||
private func refreshComputedState() {
|
private func refreshComputedState() {
|
||||||
if let selectedPeerID = privateConversationModel.selectedPeerID {
|
if let selectedPeerID = privateConversationModel.selectedPeerID {
|
||||||
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat)
|
// Media transfer is not wired for groups in v1; keep it off so the
|
||||||
|
// composer can't strand a media placeholder that never sends.
|
||||||
|
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat || selectedPeerID.isGroup)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import BitFoundation
|
|
||||||
import Combine
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@@ -12,20 +11,25 @@ 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 var cancellables = Set<AnyCancellable>()
|
private let gateway: GatewayService
|
||||||
|
|
||||||
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
|
||||||
@@ -160,6 +164,10 @@ 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 {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
//
|
||||||
|
// 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,10 +25,6 @@ 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]
|
||||||
}
|
}
|
||||||
@@ -94,10 +90,6 @@ 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,6 +14,9 @@ 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 }
|
||||||
}
|
}
|
||||||
@@ -26,11 +29,22 @@ 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
|
||||||
@@ -129,6 +143,13 @@ 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
|
||||||
@@ -183,13 +204,12 @@ 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 verifiedBadge: Bool
|
let fingerprint = isMe ? nil : chatViewModel.getFingerprint(for: peer.peerID)
|
||||||
if !isMe && !peer.isConnected,
|
let isVerifiedFingerprint = fingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
||||||
let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) {
|
let verifiedBadge = !peer.isConnected && isVerifiedFingerprint
|
||||||
verifiedBadge = peerIdentityStore.isVerified(fingerprint)
|
// Vouched is subordinate to verified: never show both seals.
|
||||||
} else {
|
let vouchedBadge = !isVerifiedFingerprint
|
||||||
verifiedBadge = false
|
&& (fingerprint.map { chatViewModel.isVouchedFingerprint($0) } ?? false)
|
||||||
}
|
|
||||||
|
|
||||||
return MeshPeerRow(
|
return MeshPeerRow(
|
||||||
peerID: peer.peerID,
|
peerID: peer.peerID,
|
||||||
@@ -202,7 +222,8 @@ 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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,22 +238,40 @@ 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,7 +108,13 @@ struct PrivateConversationHeaderState: Equatable {
|
|||||||
let encryptionStatus: EncryptionStatus?
|
let encryptionStatus: EncryptionStatus?
|
||||||
|
|
||||||
var supportsFavoriteToggle: Bool {
|
var supportsFavoriteToggle: Bool {
|
||||||
!conversationPeerID.isGeoDM
|
!conversationPeerID.isGeoDM && !conversationPeerID.isGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group chats have no single peer identity behind the header: no
|
||||||
|
/// fingerprint screen, no per-peer encryption badge.
|
||||||
|
var isGroupConversation: Bool {
|
||||||
|
conversationPeerID.isGroup
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +212,13 @@ 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
|
||||||
@@ -229,10 +242,36 @@ 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)
|
||||||
let availability = resolveAvailability(for: headerPeerID, peer: peer)
|
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
|
||||||
|
// never resolve to a reachable mesh peer, so resolveAvailability would
|
||||||
|
// report .offline. Report .nostrAvailable so the header shows the
|
||||||
|
// globe instead of a misleading "offline" tag.
|
||||||
|
let availability = conversationPeerID.isGeoDM
|
||||||
|
? .nostrAvailable
|
||||||
|
: resolveAvailability(for: headerPeerID, peer: peer)
|
||||||
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
|
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
|
||||||
? nil
|
? nil
|
||||||
: chatViewModel.getEncryptionStatus(for: headerPeerID)
|
: chatViewModel.getEncryptionStatus(for: headerPeerID)
|
||||||
|
|||||||
@@ -3,12 +3,19 @@ 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
|
||||||
@@ -48,10 +55,6 @@ 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
|
||||||
@@ -82,14 +85,33 @@ 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: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
isVerified: isVerified,
|
||||||
|
voucherCount: vouchers.count,
|
||||||
|
voucherNames: voucherNames
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +144,17 @@ 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: 848 B After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 356 B After Width: | Height: | Size: 460 B |
|
Before Width: | Height: | Size: 429 B After Width: | Height: | Size: 833 B |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 429 B After Width: | Height: | Size: 833 B |
|
Before Width: | Height: | Size: 597 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 50 KiB |
@@ -27,6 +27,66 @@
|
|||||||
"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" : {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 505 B |
|
After Width: | Height: | Size: 930 B |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 930 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 73 KiB |
@@ -40,6 +40,7 @@ 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()
|
||||||
@@ -71,7 +72,7 @@ struct BitchatApp: App {
|
|||||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
weak var runtime: AppRuntime?
|
weak var runtime: AppRuntime?
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,12 +104,20 @@ 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(identifier: identifier, userInfo: userInfo)
|
self.runtime?.handleNotificationResponse(
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
//
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
//
|
||||||
|
// 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,23 +181,35 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensures only one voice note plays at a time.
|
/// Something that can hold the app's single audio-playback slot and yield it
|
||||||
|
/// 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: VoiceNotePlaybackController?
|
private weak var activeController: (any ExclusivePlayback)?
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
func activate(_ controller: VoiceNotePlaybackController) {
|
func activate(_ controller: any ExclusivePlayback) {
|
||||||
if activeController === controller {
|
if activeController === controller {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
activeController?.pause()
|
activeController?.pauseForExclusivity()
|
||||||
activeController = controller
|
activeController = controller
|
||||||
}
|
}
|
||||||
|
|
||||||
func deactivate(_ controller: VoiceNotePlaybackController) {
|
func deactivate(_ controller: any ExclusivePlayback) {
|
||||||
if activeController === controller {
|
if activeController === controller {
|
||||||
activeController = nil
|
activeController = nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import AVFoundation
|
|||||||
actor VoiceRecorder {
|
actor VoiceRecorder {
|
||||||
enum RecorderError: Error {
|
enum RecorderError: Error {
|
||||||
case microphoneAccessDenied
|
case microphoneAccessDenied
|
||||||
case recorderInitializationFailed
|
|
||||||
case recordingInProgress
|
case recordingInProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,12 +56,6 @@ 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,8 +88,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +96,6 @@ 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.
|
||||||
@@ -110,7 +107,6 @@ 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.
|
||||||
@@ -126,11 +122,35 @@ 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 = "unknown"
|
case unknown
|
||||||
case casual = "casual"
|
case casual
|
||||||
case trusted = "trusted"
|
/// Transitively trusted: vouched for by at least one peer *I* verified.
|
||||||
case verified = "verified"
|
/// Derived at read time — never written to persistent storage.
|
||||||
|
case vouched
|
||||||
|
case trusted
|
||||||
|
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
|
||||||
@@ -155,8 +175,20 @@ 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> = []
|
||||||
|
|
||||||
// Schema version for future migrations
|
// Vouching (transitive verification). All three fields are Optional so
|
||||||
var version: Int = 1
|
// caches persisted before this feature decode cleanly — the synthesized
|
||||||
|
// 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,8 +108,6 @@ 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
|
||||||
@@ -123,7 +121,6 @@ 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()
|
||||||
@@ -133,6 +130,16 @@ 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.
|
||||||
@@ -151,38 +158,68 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
// Thread safety
|
// Thread safety
|
||||||
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
||||||
|
|
||||||
// Debouncing for keychain saves
|
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
|
||||||
private var saveTimer: Timer?
|
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
|
||||||
private let saveDebounceInterval: TimeInterval = 2.0 // Save at most once every 2 seconds
|
// than a retained DispatchSourceTimer: a lingering, never-cancelled timer
|
||||||
|
// keeps the dispatch machinery alive and prevents the unit-test process from
|
||||||
|
// exiting. (The original code used Timer.scheduledTimer on a GCD queue with
|
||||||
|
// no run loop, so saves never actually fired.)
|
||||||
private var pendingSave = false
|
private var pendingSave = false
|
||||||
|
|
||||||
// Encryption key
|
// Encryption key
|
||||||
private let encryptionKey: SymmetricKey
|
private let encryptionKey: SymmetricKey
|
||||||
|
/// True when `encryptionKey` is a throwaway generated this session because the
|
||||||
|
/// persisted key could not be read (device locked / access denied). In that
|
||||||
|
/// state we must NOT persist (it would overwrite the real cache with data the
|
||||||
|
/// next launch can't decrypt) and must NOT delete the existing cache.
|
||||||
|
private let encryptionKeyIsEphemeral: Bool
|
||||||
|
|
||||||
init(_ keychain: KeychainManagerProtocol) {
|
init(_ keychain: KeychainManagerProtocol) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
|
|
||||||
// Generate or retrieve encryption key from keychain
|
// Retrieve (or, only on genuine first run, generate) the cache
|
||||||
|
// encryption key. We MUST distinguish "key doesn't exist yet" from a
|
||||||
|
// transient failure (device locked / access denied): the legacy
|
||||||
|
// getIdentityKey(forKey:) collapses both to nil, and generating+saving a
|
||||||
|
// new key deletes the existing one first — permanently orphaning the
|
||||||
|
// encrypted cache on a launch that merely couldn't read the key.
|
||||||
let loadedKey: SymmetricKey
|
let loadedKey: SymmetricKey
|
||||||
|
let keyIsEphemeral: Bool
|
||||||
|
|
||||||
// Try to load from keychain
|
switch keychain.getIdentityKeyWithResult(forKey: encryptionKeyName) {
|
||||||
if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) {
|
case .success(let keyData):
|
||||||
loadedKey = SymmetricKey(data: keyData)
|
loadedKey = SymmetricKey(data: keyData)
|
||||||
|
keyIsEphemeral = false
|
||||||
SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true)
|
SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true)
|
||||||
}
|
|
||||||
// Generate new key if needed
|
case .itemNotFound:
|
||||||
else {
|
// Genuine first run: generate and persist a new key.
|
||||||
loadedKey = SymmetricKey(size: .bits256)
|
let newKey = SymmetricKey(size: .bits256)
|
||||||
let keyData = loadedKey.withUnsafeBytes { Data($0) }
|
let keyData = newKey.withUnsafeBytes { Data($0) }
|
||||||
// Save to keychain
|
|
||||||
let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName)
|
let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName)
|
||||||
|
loadedKey = newKey
|
||||||
|
// If even the save failed, treat the key as ephemeral so we don't
|
||||||
|
// later try to persist a cache the next launch can't read.
|
||||||
|
keyIsEphemeral = !saved
|
||||||
SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved)
|
SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved)
|
||||||
|
|
||||||
|
case .deviceLocked, .authenticationFailed, .accessDenied, .otherError:
|
||||||
|
// Transient/critical read failure. Do NOT overwrite the persisted
|
||||||
|
// key. Use a session-only ephemeral key; the real key and cache are
|
||||||
|
// left intact for a healthy launch.
|
||||||
|
SecureLogger.warning("Identity cache key unavailable; using ephemeral key for this session (not persisting)", category: .security)
|
||||||
|
loadedKey = SymmetricKey(size: .bits256)
|
||||||
|
keyIsEphemeral = true
|
||||||
}
|
}
|
||||||
|
|
||||||
self.encryptionKey = loadedKey
|
self.encryptionKey = loadedKey
|
||||||
|
self.encryptionKeyIsEphemeral = keyIsEphemeral
|
||||||
|
|
||||||
// Load identity cache on init
|
// Only read the persisted cache when we hold the real key; with an
|
||||||
loadIdentityCache()
|
// ephemeral key the decrypt would fail and discard the real cache.
|
||||||
|
if !keyIsEphemeral {
|
||||||
|
loadIdentityCache()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
@@ -211,23 +248,28 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persists the cache. Always invoked on `queue` under a barrier (its callers
|
||||||
|
/// run inside `queue.async(.barrier)`), so it simply marks the cache dirty
|
||||||
|
/// and persists it on the same serialized context — no timer, nothing left
|
||||||
|
/// scheduled to keep the process alive.
|
||||||
private func saveIdentityCache() {
|
private func saveIdentityCache() {
|
||||||
// Mark that we need to save
|
|
||||||
pendingSave = true
|
pendingSave = true
|
||||||
|
performSave()
|
||||||
// Cancel any existing timer
|
|
||||||
saveTimer?.invalidate()
|
|
||||||
|
|
||||||
// Schedule a new save after the debounce interval
|
|
||||||
saveTimer = Timer.scheduledTimer(withTimeInterval: saveDebounceInterval, repeats: false) { [weak self] _ in
|
|
||||||
self?.performSave()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Writes the cache to the keychain. Must run on `queue` with exclusive
|
||||||
|
/// (barrier) access.
|
||||||
private func performSave() {
|
private func performSave() {
|
||||||
guard pendingSave else { return }
|
guard pendingSave else { return }
|
||||||
pendingSave = false
|
pendingSave = false
|
||||||
|
|
||||||
|
// Never persist under an ephemeral key — it would overwrite the real
|
||||||
|
// cache with data the next launch cannot decrypt.
|
||||||
|
guard !encryptionKeyIsEphemeral else {
|
||||||
|
SecureLogger.debug("Skipping identity cache save (ephemeral key this session)", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try JSONEncoder().encode(cache)
|
let data = try JSONEncoder().encode(cache)
|
||||||
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
||||||
@@ -240,9 +282,13 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Force immediate save (for app termination)
|
// Force immediate save (for app termination / lifecycle events). Mutations
|
||||||
|
// already persist synchronously via saveIdentityCache, so this is normally a
|
||||||
|
// no-op (performSave early-returns when nothing is pending). Runs directly on
|
||||||
|
// the caller's thread — deliberately NOT a `queue.sync(barrier)`, which is
|
||||||
|
// reachable from `deinit` and from async tests on the swift-concurrency
|
||||||
|
// cooperative pool where a blocking barrier-sync can starve/deadlock it.
|
||||||
func forceSave() {
|
func forceSave() {
|
||||||
saveTimer?.invalidate()
|
|
||||||
performSave()
|
performSave()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,21 +318,13 @@ 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 and lastHandshake
|
// Update signing key
|
||||||
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
||||||
let updated = CryptographicIdentity(
|
self.cryptographicIdentities[fingerprint] = existing
|
||||||
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 {
|
||||||
@@ -295,8 +333,7 @@ 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
|
||||||
}
|
}
|
||||||
@@ -461,11 +498,7 @@ 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(
|
self.ephemeralSessions[peerID] = EphemeralIdentity(handshakeState: handshakeState)
|
||||||
peerID: peerID,
|
|
||||||
sessionStart: Date(),
|
|
||||||
handshakeState: handshakeState
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,8 +544,12 @@ 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
|
||||||
@@ -537,6 +574,159 @@ 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,13 +13,6 @@ 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,7 +7,6 @@ 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
|
||||||
|
|
||||||
@@ -77,14 +76,13 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -11,15 +11,24 @@ import Foundation
|
|||||||
// MARK: - CommandInfo Enum
|
// MARK: - CommandInfo Enum
|
||||||
|
|
||||||
enum CommandInfo: String, Identifiable {
|
enum CommandInfo: String, Identifiable {
|
||||||
|
// Raw values must match the aliases CommandProcessor actually accepts —
|
||||||
|
// the suggestion panel is the app's only command-discovery surface, and
|
||||||
|
// suggesting a spelling the processor rejects teaches users dead ends.
|
||||||
case block
|
case block
|
||||||
case clear
|
case clear
|
||||||
|
case group
|
||||||
|
case help
|
||||||
case hug
|
case hug
|
||||||
case message = "dm"
|
case message = "msg"
|
||||||
case slap
|
case slap
|
||||||
|
case pay
|
||||||
case unblock
|
case unblock
|
||||||
case who
|
case who
|
||||||
case favorite
|
case favorite = "fav"
|
||||||
case unfavorite
|
case unfavorite = "unfav"
|
||||||
|
case ping
|
||||||
|
case trace
|
||||||
|
case drop
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
@@ -27,9 +36,15 @@ enum CommandInfo: String, Identifiable {
|
|||||||
|
|
||||||
var placeholder: String? {
|
var placeholder: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace:
|
||||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
||||||
case .clear, .who:
|
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:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,21 +53,36 @@ 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 .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] {
|
||||||
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
|
var commands: [CommandInfo] = [.block, .unblock, .clear, .drop, .help, .hug, .message, .slap, .who]
|
||||||
if isGeoPublic || isGeoDM {
|
// Cashu tokens are bearer instruments: in a public geohash any nearby
|
||||||
return baseCommands + [.favorite, .unfavorite]
|
// stranger can redeem one, so don't *suggest* /pay there (the
|
||||||
|
// processor still allows it behind an explicit "public" confirm).
|
||||||
|
// Payments make sense in every DM and in mesh public.
|
||||||
|
if !isGeoPublic {
|
||||||
|
commands.append(.pay)
|
||||||
}
|
}
|
||||||
return baseCommands
|
// The processor rejects favorites, groups, and mesh diagnostics in
|
||||||
|
// geohash contexts, so only suggest them where they work: mesh.
|
||||||
|
if isGeoPublic || isGeoDM {
|
||||||
|
return commands
|
||||||
|
}
|
||||||
|
return commands + [.favorite, .unfavorite, .ping, .trace, .group]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
|
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
|
||||||
@@ -12,6 +23,29 @@ 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
|
||||||
@@ -88,7 +122,9 @@ struct RequestSyncPacket {
|
|||||||
sinceTimestamp = ts
|
sinceTimestamp = ts
|
||||||
}
|
}
|
||||||
case 0x06:
|
case 0x06:
|
||||||
if let fid = String(data: v, encoding: .utf8) {
|
// Same acceptance cap as the GCS payload; an oversized filter
|
||||||
|
// 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:
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ enum NoisePattern {
|
|||||||
case XX // Most versatile, mutual authentication
|
case XX // Most versatile, mutual authentication
|
||||||
case IK // Initiator knows responder's static key
|
case IK // Initiator knows responder's static key
|
||||||
case NK // Anonymous initiator
|
case NK // Anonymous initiator
|
||||||
|
case X // One-way: single message to a known static key (no response)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum NoiseRole {
|
enum NoiseRole {
|
||||||
@@ -322,6 +323,13 @@ final class NoiseCipherState {
|
|||||||
throw NoiseError.replayDetected
|
throw NoiseError.replayDetected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The 4-byte nonce prefix has been stripped, so the remaining bytes
|
||||||
|
// must still hold at least the 16-byte Poly1305 tag. The up-front
|
||||||
|
// `ciphertext.count >= 16` guard is not sufficient here (it counts
|
||||||
|
// the nonce), and `prefix(count - 16)` would trap on a short payload.
|
||||||
|
guard actualCiphertext.count >= 16 else {
|
||||||
|
throw NoiseError.invalidCiphertext
|
||||||
|
}
|
||||||
// Split ciphertext and tag
|
// Split ciphertext and tag
|
||||||
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
|
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
|
||||||
tag = actualCiphertext.suffix(16)
|
tag = actualCiphertext.suffix(16)
|
||||||
@@ -594,7 +602,7 @@ final class NoiseHandshakeState {
|
|||||||
switch pattern {
|
switch pattern {
|
||||||
case .XX:
|
case .XX:
|
||||||
break // No pre-message keys
|
break // No pre-message keys
|
||||||
case .IK, .NK:
|
case .IK, .NK, .X:
|
||||||
if role == .initiator, let remoteStatic = remoteStaticPublic {
|
if role == .initiator, let remoteStatic = remoteStaticPublic {
|
||||||
symmetricState.mixHash(remoteStatic.rawRepresentation)
|
symmetricState.mixHash(remoteStatic.rawRepresentation)
|
||||||
} else if role == .responder, let localStatic = localStaticPublic {
|
} else if role == .responder, let localStatic = localStaticPublic {
|
||||||
@@ -715,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
|
||||||
@@ -897,6 +905,7 @@ extension NoisePattern {
|
|||||||
case .XX: return "XX"
|
case .XX: return "XX"
|
||||||
case .IK: return "IK"
|
case .IK: return "IK"
|
||||||
case .NK: return "NK"
|
case .NK: return "NK"
|
||||||
|
case .X: return "X"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -918,6 +927,10 @@ extension NoisePattern {
|
|||||||
[.e, .es], // -> e, es
|
[.e, .es], // -> e, es
|
||||||
[.e, .ee] // <- e, ee
|
[.e, .ee] // <- e, ee
|
||||||
]
|
]
|
||||||
|
case .X:
|
||||||
|
return [
|
||||||
|
[.e, .es, .s, .ss] // -> e, es, s, ss (single one-way message)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,6 @@ 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,5 +14,4 @@ enum NoiseSecurityError: Error {
|
|||||||
case messageTooLarge
|
case messageTooLarge
|
||||||
case invalidPeerID
|
case invalidPeerID
|
||||||
case rateLimitExceeded
|
case rateLimitExceeded
|
||||||
case handshakeTimeout
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ 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)
|
||||||
|
|
||||||
@@ -23,8 +21,6 @@ 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,
|
||||||
@@ -37,12 +33,10 @@ 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,14 +6,12 @@ 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
|
||||||
@@ -39,12 +37,6 @@ 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()) {
|
init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,14 +37,6 @@ 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())"
|
||||||
@@ -57,31 +49,19 @@ final class NostrIdentityBridge {
|
|||||||
|
|
||||||
/// Clear all Nostr identity associations and current identity
|
/// Clear all Nostr identity associations and current identity
|
||||||
func clearAllAssociations() {
|
func clearAllAssociations() {
|
||||||
let query: [String: Any] = [
|
// Must go through the injected keychain, not raw SecItem calls:
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
// under test that keychain is in-memory, and a direct delete here
|
||||||
kSecAttrService as String: keychainService,
|
// would wipe the developer's real Nostr identity on every test run.
|
||||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
keychain.deleteAll(service: keychainService)
|
||||||
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
|
||||||
|
// actual secp256k1 private keys; if left cached, post-panic geohash
|
||||||
|
// messages would still be signed with pre-panic keys (linkable across the
|
||||||
|
// wipe) until the app is force-quit.
|
||||||
|
cacheLock.lock()
|
||||||
|
derivedIdentityCache.removeAll()
|
||||||
|
cacheLock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Per-Geohash Identities (Location Channels)
|
// MARK: - Per-Geohash Identities (Location Channels)
|
||||||
@@ -106,6 +86,13 @@ 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.
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
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,6 +19,11 @@ 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
|
||||||
@@ -39,22 +44,23 @@ struct NostrProtocol {
|
|||||||
content: content
|
content: content
|
||||||
)
|
)
|
||||||
|
|
||||||
// 2. Create ephemeral key for this message
|
// 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S
|
||||||
let ephemeralKey = try P256K.Schnorr.PrivateKey()
|
// real identity key. NIP-17 requires the seal be signed by the sender
|
||||||
// Created ephemeral key for seal
|
// so the recipient can authenticate who sent the message; signing with
|
||||||
|
// a throwaway key leaves DMs forgeable/impersonatable.
|
||||||
// 3. Seal the rumor (encrypt to recipient)
|
let senderKey = try senderIdentity.schnorrSigningKey()
|
||||||
let sealedEvent = try createSeal(
|
let sealedEvent = try createSeal(
|
||||||
rumor: rumor,
|
rumor: rumor,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey,
|
||||||
senderKey: ephemeralKey
|
senderKey: senderKey
|
||||||
)
|
)
|
||||||
|
|
||||||
// 4. Gift wrap the sealed event (encrypt to recipient again)
|
// 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap
|
||||||
|
// layer hides the sender's identity from relays; createGiftWrap mints
|
||||||
|
// its own ephemeral key internally).
|
||||||
let giftWrap = try createGiftWrap(
|
let giftWrap = try createGiftWrap(
|
||||||
seal: sealedEvent,
|
seal: sealedEvent,
|
||||||
recipientPubkey: recipientPubkey,
|
recipientPubkey: recipientPubkey
|
||||||
senderKey: ephemeralKey
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Created gift wrap
|
// Created gift wrap
|
||||||
@@ -84,7 +90,15 @@ struct NostrProtocol {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Open the seal
|
// 2. Authenticate the seal. The seal MUST be signed by the sender's real
|
||||||
|
// identity key (NIP-17); without this check a DM is forgeable by anyone
|
||||||
|
// who knows the recipient's npub. Verify the seal's own signature.
|
||||||
|
guard seal.isValidSignature() else {
|
||||||
|
SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session)
|
||||||
|
throw NostrError.invalidEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Open the seal
|
||||||
let rumor: NostrEvent
|
let rumor: NostrEvent
|
||||||
do {
|
do {
|
||||||
rumor = try openSeal(
|
rumor = try openSeal(
|
||||||
@@ -97,9 +111,62 @@ struct NostrProtocol {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at)
|
// 4. The sender claimed inside the rumor must match the key that actually
|
||||||
|
// signed the seal, otherwise the sender field is unauthenticated and
|
||||||
|
// spoofable.
|
||||||
|
guard seal.pubkey == rumor.pubkey else {
|
||||||
|
SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session)
|
||||||
|
throw NostrError.invalidEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the seal signer's pubkey as the authenticated sender.
|
||||||
|
return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
static func createPrivateMessageWithInvalidSealSignatureForTesting(
|
||||||
|
content: String,
|
||||||
|
recipientPubkey: String,
|
||||||
|
senderIdentity: NostrIdentity
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
let rumor = NostrEvent(
|
||||||
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .dm,
|
||||||
|
tags: [],
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
var seal = try createSeal(
|
||||||
|
rumor: rumor,
|
||||||
|
recipientPubkey: recipientPubkey,
|
||||||
|
senderKey: senderIdentity.schnorrSigningKey()
|
||||||
|
)
|
||||||
|
seal.sig = String(repeating: "0", count: 128)
|
||||||
|
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
|
||||||
|
content: String,
|
||||||
|
recipientPubkey: String,
|
||||||
|
rumorIdentity: NostrIdentity,
|
||||||
|
sealSignerIdentity: NostrIdentity
|
||||||
|
) throws -> NostrEvent {
|
||||||
|
let rumor = NostrEvent(
|
||||||
|
pubkey: rumorIdentity.publicKeyHex,
|
||||||
|
createdAt: Date(),
|
||||||
|
kind: .dm,
|
||||||
|
tags: [],
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let seal = try createSeal(
|
||||||
|
rumor: rumor,
|
||||||
|
recipientPubkey: recipientPubkey,
|
||||||
|
senderKey: sealSignerIdentity.schnorrSigningKey()
|
||||||
|
)
|
||||||
|
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Create a geohash-scoped ephemeral public message (kind 20000)
|
/// Create a geohash-scoped ephemeral public message (kind 20000)
|
||||||
static func createEphemeralGeohashEvent(
|
static func createEphemeralGeohashEvent(
|
||||||
content: String,
|
content: String,
|
||||||
@@ -108,6 +175,63 @@ 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])
|
||||||
@@ -115,15 +239,7 @@ struct NostrProtocol {
|
|||||||
if teleported {
|
if teleported {
|
||||||
tags.append(["t", "teleport"])
|
tags.append(["t", "teleport"])
|
||||||
}
|
}
|
||||||
let event = NostrEvent(
|
return tags
|
||||||
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)
|
||||||
@@ -144,17 +260,105 @@ 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(),
|
||||||
@@ -166,6 +370,24 @@ struct NostrProtocol {
|
|||||||
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(
|
||||||
@@ -195,8 +417,7 @@ struct NostrProtocol {
|
|||||||
|
|
||||||
private static func createGiftWrap(
|
private static func createGiftWrap(
|
||||||
seal: NostrEvent,
|
seal: NostrEvent,
|
||||||
recipientPubkey: String,
|
recipientPubkey: String
|
||||||
senderKey: P256K.Schnorr.PrivateKey // This is the ephemeral key used for the seal
|
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
|
|
||||||
let sealJSON = try seal.jsonString()
|
let sealJSON = try seal.jsonString()
|
||||||
@@ -413,37 +634,6 @@ 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
|
||||||
@@ -573,11 +763,8 @@ 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)
|
||||||
@@ -587,7 +774,7 @@ private extension NostrProtocol {
|
|||||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||||
salt: Data(),
|
salt: Data(),
|
||||||
info: "nip44-v2".data(using: .utf8)!,
|
info: Data("nip44-v2".utf8),
|
||||||
outputByteCount: 32
|
outputByteCount: 32
|
||||||
)
|
)
|
||||||
return derivedKey.withUnsafeBytes { Data($0) }
|
return derivedKey.withUnsafeBytes { Data($0) }
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ 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
|
||||||
@@ -137,6 +136,10 @@ 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
|
||||||
@@ -180,11 +183,26 @@ 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 done
|
// Track EOSE per subscription to signal when initial stored events are
|
||||||
|
// 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 {
|
||||||
var pendingRelays: Set<String>
|
/// Targets the REQ has not been delivered to yet (still connecting).
|
||||||
|
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
|
||||||
@@ -281,6 +299,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
task.cancel(with: .goingAway, reason: nil)
|
task.cancel(with: .goingAway, reason: nil)
|
||||||
}
|
}
|
||||||
connections.removeAll()
|
connections.removeAll()
|
||||||
|
markRelaySocketsClosed(resetState: false)
|
||||||
// Sockets are gone, so per-relay subscription state is cleared — but
|
// Sockets are gone, so per-relay subscription state is cleared — but
|
||||||
// durable intent (subscriptionRequestState, messageHandlers, parked
|
// durable intent (subscriptionRequestState, messageHandlers, parked
|
||||||
// EOSE callbacks) is kept so REQs replay when relays reconnect
|
// EOSE callbacks) is kept so REQs replay when relays reconnect
|
||||||
@@ -299,6 +318,59 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
updateConnectionStatus()
|
updateConnectionStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Panic wipe reset: close sockets and drop every user/session-specific
|
||||||
|
/// relay intent without invoking old callbacks. Unlike `disconnect()`, this
|
||||||
|
/// must not preserve subscription replay state because geohash DM handlers
|
||||||
|
/// can capture pre-wipe Nostr private keys.
|
||||||
|
func resetForPanicWipe() {
|
||||||
|
connectionGeneration &+= 1
|
||||||
|
for (_, task) in connections {
|
||||||
|
task.cancel(with: .goingAway, reason: nil)
|
||||||
|
}
|
||||||
|
connections.removeAll()
|
||||||
|
markRelaySocketsClosed(resetState: true)
|
||||||
|
subscriptions.removeAll()
|
||||||
|
pendingSubscriptions.removeAll()
|
||||||
|
messageHandlers.removeAll()
|
||||||
|
subscriptionRequestState.removeAll()
|
||||||
|
subscribeCoalesce.removeAll()
|
||||||
|
eoseTrackers.removeAll()
|
||||||
|
pendingEOSECallbacks.removeAll()
|
||||||
|
pendingTorConnectionURLs.removeAll()
|
||||||
|
awaitingTorForConnections = false
|
||||||
|
torReadyWaitAttempts = 0
|
||||||
|
recentInboundEventKeys.removeAll()
|
||||||
|
recentInboundEventKeyOrder.removeAll()
|
||||||
|
duplicateInboundEventDropCount = 0
|
||||||
|
duplicateInboundEventDropCountBySubscription.removeAll()
|
||||||
|
inboundEventLogCount = 0
|
||||||
|
Self.pendingGiftWrapIDs.removeAll()
|
||||||
|
|
||||||
|
messageQueueLock.lock()
|
||||||
|
messageQueue.removeAll()
|
||||||
|
pendingSendDropCount = 0
|
||||||
|
messageQueueLock.unlock()
|
||||||
|
|
||||||
|
updateConnectionStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func markRelaySocketsClosed(resetState: Bool) {
|
||||||
|
let now = dependencies.now()
|
||||||
|
for index in relays.indices {
|
||||||
|
relays[index].isConnected = false
|
||||||
|
relays[index].nextReconnectTime = nil
|
||||||
|
if resetState {
|
||||||
|
relays[index].lastError = nil
|
||||||
|
relays[index].lastDisconnectedAt = nil
|
||||||
|
relays[index].messagesSent = 0
|
||||||
|
relays[index].messagesReceived = 0
|
||||||
|
relays[index].reconnectAttempts = 0
|
||||||
|
} else {
|
||||||
|
relays[index].lastDisconnectedAt = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Ensure connections exist to the given relay URLs (idempotent).
|
/// Ensure connections exist to the given relay URLs (idempotent).
|
||||||
func ensureConnections(to relayUrls: [String]) {
|
func ensureConnections(to relayUrls: [String]) {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
@@ -736,7 +808,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(pendingRelays: relayURLs, callback: callback, epoch: epoch)
|
eoseTrackers[id] = EOSETracker(awaitingSend: relayURLs, awaitingEOSE: [], 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
|
||||||
@@ -876,8 +948,19 @@ 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 { continue }
|
if self.subscriptions[relayUrl]?.contains(id) == true {
|
||||||
|
// 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 }
|
||||||
@@ -959,8 +1042,12 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
case .eose(let subId):
|
case .eose(let subId):
|
||||||
if var tracker = eoseTrackers[subId] {
|
if var tracker = eoseTrackers[subId] {
|
||||||
tracker.pendingRelays.remove(relayUrl)
|
// An EOSE proves the relay received the REQ even if the local
|
||||||
if tracker.pendingRelays.isEmpty {
|
// send completion hasn't run yet.
|
||||||
|
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 {
|
||||||
@@ -1016,7 +1103,6 @@ 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 {
|
||||||
@@ -1032,15 +1118,20 @@ 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 where tracker.pendingRelays.contains(relayUrl) {
|
for (id, var tracker) in eoseTrackers
|
||||||
tracker.pendingRelays.remove(relayUrl)
|
where tracker.awaitingSend.contains(relayUrl) || tracker.awaitingEOSE.contains(relayUrl) {
|
||||||
if tracker.pendingRelays.isEmpty {
|
tracker.awaitingSend.remove(relayUrl)
|
||||||
|
tracker.awaitingEOSE.remove(relayUrl)
|
||||||
|
if tracker.isComplete {
|
||||||
eoseTrackers.removeValue(forKey: id)
|
eoseTrackers.removeValue(forKey: id)
|
||||||
tracker.callback()
|
tracker.callback()
|
||||||
} else {
|
} else {
|
||||||
@@ -1049,6 +1140,24 @@ 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)
|
||||||
@@ -1170,6 +1279,18 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
return Set(map.keys)
|
return Set(map.keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var debugMessageHandlerCount: Int {
|
||||||
|
messageHandlers.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var debugSubscriptionRequestCount: Int {
|
||||||
|
subscriptionRequestState.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var debugPendingEOSECallbackCount: Int {
|
||||||
|
pendingEOSECallbacks.count
|
||||||
|
}
|
||||||
|
|
||||||
var debugDuplicateInboundEventDropCount: Int {
|
var debugDuplicateInboundEventDropCount: Int {
|
||||||
duplicateInboundEventDropCount
|
duplicateInboundEventDropCount
|
||||||
}
|
}
|
||||||
@@ -1389,6 +1510,29 @@ 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
|
||||||
|
|||||||
@@ -132,4 +132,3 @@ private extension Data {
|
|||||||
replaceSubrange(offset..<(offset+4), with: bytes)
|
replaceSubrange(offset..<(offset+4), with: bytes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 like padding and timing obfuscation
|
/// - Privacy features: message padding and randomized relay jitter
|
||||||
/// - Integration points for end-to-end encryption
|
/// - Integration points for end-to-end encryption
|
||||||
///
|
///
|
||||||
/// ## Protocol Design
|
/// ## Protocol Design
|
||||||
@@ -38,18 +38,20 @@
|
|||||||
/// 7. **Decoding**: Binary data parsed back to message objects
|
/// 7. **Decoding**: Binary data parsed back to message objects
|
||||||
///
|
///
|
||||||
/// ## Security Considerations
|
/// ## Security Considerations
|
||||||
/// - Message padding obscures actual content length
|
/// - Message padding (to 256/512/1024/2048-byte blocks) obscures actual content length
|
||||||
/// - Timing obfuscation prevents traffic analysis
|
/// - Randomized relay jitter reduces the traffic-analysis signal; there is no
|
||||||
|
/// 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**: User chat messages (broadcast or directed)
|
/// - **Message**: Public chat messages
|
||||||
/// - **Fragment**: Multi-part message handling
|
/// - **Fragment**: Multi-part message handling
|
||||||
/// - **Delivery/Read**: Message acknowledgments
|
/// - **NoiseHandshake/NoiseEncrypted**: Encrypted channel establishment and
|
||||||
/// - **Noise**: Encrypted channel establishment
|
/// all private payloads (messages, delivery acks, read receipts)
|
||||||
/// - **Version**: Protocol version negotiation
|
/// - **CourierEnvelope**: Sealed store-and-forward mail
|
||||||
|
/// - **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:
|
||||||
@@ -72,17 +74,28 @@ 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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,6 +127,12 @@ 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?)
|
||||||
@@ -133,6 +152,14 @@ 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
//
|
||||||
|
// 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 for building-level precision (8 characters).
|
/// Validates a geohash string at any channel precision (1-12 characters).
|
||||||
/// - Parameter geohash: The geohash string to validate
|
/// - Parameter geohash: The geohash string to validate
|
||||||
/// - Returns: true if valid 8-character base32 geohash, false otherwise
|
/// - Returns: true if a non-empty base32 geohash of at most 12 characters
|
||||||
static func isValidBuildingGeohash(_ geohash: String) -> Bool {
|
static func isValidGeohash(_ geohash: String) -> Bool {
|
||||||
guard geohash.count == 8 else { return false }
|
guard (1...12).contains(geohash.count) else { return false }
|
||||||
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
|||||||
case .city: return 5
|
case .city: return 5
|
||||||
case .province: return 4
|
case .province: return 4
|
||||||
case .region: return 2
|
case .region: return 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
//
|
||||||
|
// 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,3 +1,4 @@
|
|||||||
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// MARK: - Protocol TLV Packets
|
// MARK: - Protocol TLV Packets
|
||||||
@@ -7,12 +8,35 @@ 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? {
|
||||||
@@ -48,6 +72,24 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +99,8 @@ 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]
|
||||||
@@ -87,6 +131,12 @@ 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)
|
||||||
@@ -99,7 +149,9 @@ struct AnnouncementPacket {
|
|||||||
nickname: nickname,
|
nickname: nickname,
|
||||||
noisePublicKey: noisePublicKey,
|
noisePublicKey: noisePublicKey,
|
||||||
signingPublicKey: signingPublicKey,
|
signingPublicKey: signingPublicKey,
|
||||||
directNeighbors: directNeighbors
|
directNeighbors: directNeighbors,
|
||||||
|
capabilities: capabilities,
|
||||||
|
bridgeGeohash: bridgeGeohash
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import BitFoundation
|
||||||
|
|
||||||
|
extension PeerCapabilities {
|
||||||
|
/// Capabilities this build advertises in its announce packets.
|
||||||
|
/// Each feature adds its bit here when it ships.
|
||||||
|
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups]
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
//
|
||||||
|
// 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()]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
//
|
||||||
|
// 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,13 +11,6 @@ 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?) {
|
||||||
@@ -73,26 +66,6 @@ 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":
|
||||||
|
|||||||
@@ -59,6 +59,15 @@ struct BLEAnnounceHandlerEnvironment {
|
|||||||
let scheduleAfterglow: (TimeInterval) -> Void
|
let scheduleAfterglow: (TimeInterval) -> Void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Outcome of an accepted announce, surfaced so the service can run
|
||||||
|
/// follow-up work (e.g. courier handover) that keys off the announce.
|
||||||
|
struct BLEAnnounceHandlingResult {
|
||||||
|
let peerID: PeerID
|
||||||
|
let announcement: AnnouncementPacket
|
||||||
|
let isDirectAnnounce: Bool
|
||||||
|
let isVerified: Bool
|
||||||
|
}
|
||||||
|
|
||||||
/// Orchestrates inbound announce packets: preflight validation, signature
|
/// Orchestrates inbound announce packets: preflight validation, signature
|
||||||
/// trust, registry/topology updates, identity persistence, UI notification,
|
/// trust, registry/topology updates, identity persistence, UI notification,
|
||||||
/// gossip tracking, and the reciprocal announce response.
|
/// gossip tracking, and the reciprocal announce response.
|
||||||
@@ -69,7 +78,8 @@ final class BLEAnnounceHandler {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
}
|
}
|
||||||
|
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
@discardableResult
|
||||||
|
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> BLEAnnounceHandlingResult? {
|
||||||
let env = environment
|
let env = environment
|
||||||
let now = env.now()
|
let now = env.now()
|
||||||
let preflight = BLEAnnouncePreflightPolicy.evaluate(
|
let preflight = BLEAnnouncePreflightPolicy.evaluate(
|
||||||
@@ -85,15 +95,15 @@ final class BLEAnnounceHandler {
|
|||||||
announcement = acceptance.announcement
|
announcement = acceptance.announcement
|
||||||
case .reject(.malformed):
|
case .reject(.malformed):
|
||||||
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))…", category: .session)
|
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))…", category: .session)
|
||||||
return
|
return nil
|
||||||
case .reject(.senderMismatch(let derivedFromKey)):
|
case .reject(.senderMismatch(let derivedFromKey)):
|
||||||
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security)
|
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security)
|
||||||
return
|
return nil
|
||||||
case .reject(.selfAnnounce):
|
case .reject(.selfAnnounce):
|
||||||
return
|
return nil
|
||||||
case .reject(.stale(let ageSeconds)):
|
case .reject(.stale(let ageSeconds)):
|
||||||
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
|
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suppress announce logs to reduce noise
|
// Suppress announce logs to reduce noise
|
||||||
@@ -168,8 +178,13 @@ final class BLEAnnounceHandler {
|
|||||||
env.updateTopology(peerID, neighbors)
|
env.updateTopology(peerID, neighbors)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist cryptographic identity and signing key for robust offline verification
|
// Persist cryptographic identity and signing key for robust offline
|
||||||
env.persistIdentity(announcement)
|
// verification — only for verified announces. Persisting unverified
|
||||||
|
// announces would let an attacker who replays a victim's noisePublicKey
|
||||||
|
// overwrite the victim's stored signing key/nickname (identity poisoning).
|
||||||
|
if verifiedAnnounce {
|
||||||
|
env.persistIdentity(announcement)
|
||||||
|
}
|
||||||
|
|
||||||
let announceBackID = "announce-back-\(peerID)"
|
let announceBackID = "announce-back-\(peerID)"
|
||||||
let shouldSendBack = !env.dedupContains(announceBackID)
|
let shouldSendBack = !env.dedupContains(announceBackID)
|
||||||
@@ -205,5 +220,12 @@ final class BLEAnnounceHandler {
|
|||||||
let delay = Double.random(in: 0.3...0.6)
|
let delay = Double.random(in: 0.3...0.6)
|
||||||
env.scheduleAfterglow(delay)
|
env.scheduleAfterglow(delay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return BLEAnnounceHandlingResult(
|
||||||
|
peerID: peerID,
|
||||||
|
announcement: announcement,
|
||||||
|
isDirectAnnounce: isDirectAnnounce,
|
||||||
|
isVerified: verifiedAnnounce
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,35 @@ enum BLEFanoutSelector {
|
|||||||
packetType: UInt8,
|
packetType: UInt8,
|
||||||
messageID: String
|
messageID: String
|
||||||
) -> BLEFanoutSelection {
|
) -> BLEFanoutSelection {
|
||||||
|
let rawAllowed = allowedLinks(
|
||||||
|
peripheralIDs: peripheralIDs,
|
||||||
|
centralIDs: centralIDs,
|
||||||
|
ingressLink: ingressLink,
|
||||||
|
excludedLinks: excludedLinks
|
||||||
|
)
|
||||||
|
|
||||||
|
if let directedPeerHint,
|
||||||
|
let directedSelection = directLinks(
|
||||||
|
to: directedPeerHint,
|
||||||
|
links: rawAllowed,
|
||||||
|
peripheralPeerBindings: peripheralPeerBindings,
|
||||||
|
centralPeerBindings: centralPeerBindings
|
||||||
|
) {
|
||||||
|
return directedSelection
|
||||||
|
}
|
||||||
|
if let directedPeerHint,
|
||||||
|
hasBoundLink(
|
||||||
|
to: directedPeerHint,
|
||||||
|
peripheralIDs: peripheralIDs,
|
||||||
|
centralIDs: centralIDs,
|
||||||
|
peripheralPeerBindings: peripheralPeerBindings,
|
||||||
|
centralPeerBindings: centralPeerBindings
|
||||||
|
) {
|
||||||
|
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
|
||||||
|
}
|
||||||
|
|
||||||
let allowed = collapseDuplicateLinksPerPeer(
|
let allowed = collapseDuplicateLinksPerPeer(
|
||||||
allowedLinks(
|
rawAllowed,
|
||||||
peripheralIDs: peripheralIDs,
|
|
||||||
centralIDs: centralIDs,
|
|
||||||
ingressLink: ingressLink,
|
|
||||||
excludedLinks: excludedLinks
|
|
||||||
),
|
|
||||||
peripheralPeerBindings: peripheralPeerBindings,
|
peripheralPeerBindings: peripheralPeerBindings,
|
||||||
centralPeerBindings: centralPeerBindings
|
centralPeerBindings: centralPeerBindings
|
||||||
)
|
)
|
||||||
@@ -71,6 +93,42 @@ enum BLEFanoutSelector {
|
|||||||
return (allowedPeripheralIDs, allowedCentralIDs)
|
return (allowedPeripheralIDs, allowedCentralIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func directLinks(
|
||||||
|
to peerID: PeerID,
|
||||||
|
links: (peripheralIDs: [String], centralIDs: [String]),
|
||||||
|
peripheralPeerBindings: [String: PeerID],
|
||||||
|
centralPeerBindings: [String: PeerID]
|
||||||
|
) -> BLEFanoutSelection? {
|
||||||
|
let directLinks = collapseDuplicateLinksPerPeer(
|
||||||
|
(
|
||||||
|
peripheralIDs: links.peripheralIDs.filter { peripheralPeerBindings[$0] == peerID },
|
||||||
|
centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID }
|
||||||
|
),
|
||||||
|
peripheralPeerBindings: peripheralPeerBindings,
|
||||||
|
centralPeerBindings: centralPeerBindings
|
||||||
|
)
|
||||||
|
|
||||||
|
guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return BLEFanoutSelection(
|
||||||
|
peripheralIDs: Set(directLinks.peripheralIDs),
|
||||||
|
centralIDs: Set(directLinks.centralIDs)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func hasBoundLink(
|
||||||
|
to peerID: PeerID,
|
||||||
|
peripheralIDs: [String],
|
||||||
|
centralIDs: [String],
|
||||||
|
peripheralPeerBindings: [String: PeerID],
|
||||||
|
centralPeerBindings: [String: PeerID]
|
||||||
|
) -> Bool {
|
||||||
|
peripheralIDs.contains { peripheralPeerBindings[$0] == peerID }
|
||||||
|
|| centralIDs.contains { centralPeerBindings[$0] == peerID }
|
||||||
|
}
|
||||||
|
|
||||||
// Dual-role pairs hold two live links (we-as-central writing to their
|
// Dual-role pairs hold two live links (we-as-central writing to their
|
||||||
// peripheral, and they-as-central subscribed to ours). Sending the same
|
// peripheral, and they-as-central subscribed to ours). Sending the same
|
||||||
// packet down both doubles airtime for nothing — the receiver's assembler
|
// packet down both doubles airtime for nothing — the receiver's assembler
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ 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.
|
||||||
@@ -44,25 +46,32 @@ final class BLEFileTransferHandler {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
}
|
}
|
||||||
|
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
/// Returns `false` when the packet fails sender authentication and must
|
||||||
|
/// 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 }
|
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return true }
|
||||||
|
|
||||||
let peersSnapshot = env.peersSnapshot()
|
|
||||||
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
|
||||||
peerID: peerID,
|
|
||||||
localPeerID: env.localPeerID(),
|
|
||||||
localNickname: env.localNickname(),
|
|
||||||
peers: peersSnapshot,
|
|
||||||
allowConnectedUnverified: true
|
|
||||||
) ?? env.signedSenderDisplayName(packet, peerID) else {
|
|
||||||
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
|
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let peersSnapshot = env.peersSnapshot()
|
||||||
|
guard let senderNickname = resolveSenderNickname(
|
||||||
|
packet: packet,
|
||||||
|
from: peerID,
|
||||||
|
isBroadcast: !deliveryPlan.isPrivateMessage,
|
||||||
|
peers: peersSnapshot,
|
||||||
|
env: env
|
||||||
|
) else {
|
||||||
|
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if deliveryPlan.shouldTrackForSync {
|
if deliveryPlan.shouldTrackForSync {
|
||||||
env.trackPacketSeen(packet)
|
env.trackPacketSeen(packet)
|
||||||
}
|
}
|
||||||
@@ -75,16 +84,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
|
return true
|
||||||
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
|
return true
|
||||||
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
|
return true
|
||||||
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// BCH-01-002: Enforce storage quota before saving
|
// BCH-01-002: Enforce storage quota before saving
|
||||||
@@ -97,7 +106,7 @@ final class BLEFileTransferHandler {
|
|||||||
mime.defaultExtension,
|
mime.defaultExtension,
|
||||||
mime.category.rawValue
|
mime.category.rawValue
|
||||||
) else {
|
) else {
|
||||||
return
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if deliveryPlan.isPrivateMessage {
|
if deliveryPlan.isPrivateMessage {
|
||||||
@@ -113,11 +122,66 @@ 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,9 +61,11 @@ 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]] = [:]
|
||||||
@@ -105,7 +107,15 @@ 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 {
|
||||||
@@ -138,10 +148,58 @@ struct BLEFragmentAssemblyBuffer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fragmentsByKey[header.key] = [:]
|
fragmentsByKey[header.key] = [:]
|
||||||
metadataByKey[header.key] = Metadata(type: header.originalType, total: header.total, timestamp: now)
|
metadataByKey[header.key] = Metadata(
|
||||||
|
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,13 +33,21 @@ final class BLEFragmentHandler {
|
|||||||
|
|
||||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
let env = environment
|
let env = environment
|
||||||
// Don't process our own fragments
|
guard let header = BLEFragmentHeader(packet: packet) else { return }
|
||||||
|
|
||||||
|
// 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,10 +78,21 @@ struct BLEIngressLinkRegistry {
|
|||||||
return .failure(.selfLoopback(packetType: packet.type))
|
return .failure(.selfLoopback(packetType: packet.type))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let boundPeerID,
|
if let boundPeerID, boundPeerID != claimedSenderID {
|
||||||
boundPeerID != claimedSenderID,
|
if requiresDirectSenderBinding(packet) {
|
||||||
requiresDirectSenderBinding(packet, directAnnounceTTL: directAnnounceTTL) {
|
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
|
||||||
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
|
||||||
@@ -98,7 +109,14 @@ struct BLEIngressLinkRegistry {
|
|||||||
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
|
private static func requiresDirectSenderBinding(_ packet: BitchatPacket) -> 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,9 +203,19 @@ final class BLELinkStateStore {
|
|||||||
|
|
||||||
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
|
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
|
||||||
assertOwned()
|
assertOwned()
|
||||||
if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil {
|
var previousPeerID: PeerID?
|
||||||
peerToPeripheralUUID[peerID] = peripheralUUID
|
let updated = updatePeripheral(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,9 +25,4 @@ final class BLELogRateLimiter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeAll() {
|
|
||||||
queue.sync {
|
|
||||||
lastLogTimeByKey.removeAll()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ enum BLEOutboundPacketPolicy {
|
|||||||
switch MessageType(rawValue: packetType) {
|
switch MessageType(rawValue: packetType) {
|
||||||
case .noiseEncrypted, .noiseHandshake:
|
case .noiseEncrypted, .noiseHandshake:
|
||||||
return true
|
return true
|
||||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer:
|
// voiceFrame is deliberately unpadded: padding to the 512 block would
|
||||||
|
// 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,6 +9,9 @@ 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 {
|
||||||
@@ -107,6 +110,24 @@ 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) }
|
||||||
@@ -125,19 +146,12 @@ 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
|
||||||
@@ -156,7 +170,9 @@ 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(
|
||||||
@@ -172,7 +188,9 @@ 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
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ struct BLEPublicMessageHandlerEnvironment {
|
|||||||
let now: () -> Date
|
let now: () -> Date
|
||||||
/// 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 known signing public key.
|
||||||
|
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 message packet for gossip sync.
|
/// Tracks the broadcast message packet for gossip sync.
|
||||||
@@ -68,14 +70,39 @@ final class BLEPublicMessageHandler {
|
|||||||
// Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks.
|
// Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks.
|
||||||
let peersSnapshot = env.peersSnapshot()
|
let peersSnapshot = env.peersSnapshot()
|
||||||
|
|
||||||
|
// Public messages are always signed by their sender. `senderID` is
|
||||||
|
// attacker-controlled, so registry membership alone is NOT proof of
|
||||||
|
// identity — a peer in the registry as "verified" could be impersonated
|
||||||
|
// by anyone spoofing their senderID. Require a valid packet signature
|
||||||
|
// from the claimed sender (our own echoes are exempt; they are matched
|
||||||
|
// by self-broadcast tracking below).
|
||||||
|
//
|
||||||
|
// Verify against the signing key already in the (synchronously-updated)
|
||||||
|
// peer registry first: identity-cache persistence is asynchronous, so a
|
||||||
|
// message arriving right after a verified announce would otherwise be
|
||||||
|
// dropped because `signedSenderDisplayName` only searches the persisted
|
||||||
|
// cache. Fall back to that persisted-identity lookup for peers not (yet)
|
||||||
|
// in the registry.
|
||||||
|
let isSelf = peerID == env.localPeerID()
|
||||||
|
let registrySigningKey = peersSnapshot[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 {
|
||||||
|
SecureLogger.warning("🚫 Dropping public message with missing/invalid signature for claimed sender \(peerID.id.prefix(8))…", category: .security)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticity is established; prefer the registry's collision-resolved
|
||||||
|
// display name, then the signature-derived name.
|
||||||
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||||
peerID: peerID,
|
peerID: peerID,
|
||||||
localPeerID: env.localPeerID(),
|
localPeerID: env.localPeerID(),
|
||||||
localNickname: env.localNickname(),
|
localNickname: env.localNickname(),
|
||||||
peers: peersSnapshot,
|
peers: peersSnapshot,
|
||||||
allowConnectedUnverified: false
|
allowConnectedUnverified: false
|
||||||
) ?? env.signedSenderDisplayName(packet, peerID) else {
|
) ?? signedDisplayName else {
|
||||||
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))…", category: .security)
|
SecureLogger.warning("🚫 Dropping public message from unknown peer \(peerID.id.prefix(8))…", category: .security)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,15 @@ enum BLEPublicMessagePolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let isBroadcast = BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID)
|
let isBroadcast = BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID)
|
||||||
|
// Acceptance window matches the gossip-sync serving window: a peer
|
||||||
|
// walking between partitions carries hours of public history, so the
|
||||||
|
// receive side must not drop what sync legitimately serves.
|
||||||
if isBroadcast,
|
if isBroadcast,
|
||||||
BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) {
|
BLEPacketFreshnessPolicy.isStale(
|
||||||
|
timestampMilliseconds: packet.timestamp,
|
||||||
|
now: now,
|
||||||
|
maxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds
|
||||||
|
) {
|
||||||
return .reject(.staleBroadcast(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
|
return .reject(.staleBroadcast(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
|
||||||
timestampMilliseconds: packet.timestamp,
|
timestampMilliseconds: packet.timestamp,
|
||||||
now: now
|
now: now
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ struct BLEReceivedPacketContext: Equatable {
|
|||||||
struct BLEReceivePipeline {
|
struct BLEReceivePipeline {
|
||||||
static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext {
|
static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext {
|
||||||
let senderID = PeerID(hexData: packet.senderID)
|
let senderID = PeerID(hexData: packet.senderID)
|
||||||
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)"
|
// Include a payload digest so that distinct packets sharing the same
|
||||||
|
// sender/timestamp(ms)/type are not collapsed as duplicates. The
|
||||||
|
// post-handshake flush sends queued messages, delivery and read receipts
|
||||||
|
// back-to-back within a single millisecond; without the digest every
|
||||||
|
// packet after the first would be silently dropped.
|
||||||
|
let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString()
|
||||||
|
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
||||||
let messageType = MessageType(rawValue: packet.type)
|
let messageType = MessageType(rawValue: packet.type)
|
||||||
let allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID
|
let allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID
|
||||||
let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay
|
let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay
|
||||||
@@ -42,11 +48,29 @@ struct BLEReceivePipeline {
|
|||||||
senderIsSelf: senderID == localPeerID,
|
senderIsSelf: senderID == localPeerID,
|
||||||
recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID,
|
recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID,
|
||||||
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
|
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
|
||||||
isDirectedEncrypted: packet.type == MessageType.noiseEncrypted.rawValue && packet.recipientID != nil,
|
// Courier envelopes are directed opaque ciphertext like DMs; a
|
||||||
|
// remote handover toward a relayed announce rides this same
|
||||||
|
// deterministic relay treatment instead of the broadcast clamp.
|
||||||
|
// 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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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,6 +35,14 @@ struct BLERouteForwardingPolicy {
|
|||||||
routingPeer: (Data) -> PeerID?,
|
routingPeer: (Data) -> PeerID?,
|
||||||
isPeerConnected: (PeerID) -> Bool
|
isPeerConnected: (PeerID) -> Bool
|
||||||
) -> BLERouteForwardingPlan {
|
) -> BLERouteForwardingPlan {
|
||||||
|
// REQUEST_SYNC is link-local: never forward it, on the flood path or
|
||||||
|
// the source-routed path. A crafted request with a route and TTL
|
||||||
|
// headroom must not be able to fan a full-store replay out to the next
|
||||||
|
// hop. Suppressing here also short-circuits the flood relay.
|
||||||
|
if packet.type == MessageType.requestSync.rawValue {
|
||||||
|
return .suppressFloodRelay
|
||||||
|
}
|
||||||
|
|
||||||
if PeerID(hexData: packet.recipientID) == localPeerID {
|
if PeerID(hexData: packet.recipientID) == localPeerID {
|
||||||
return .suppressFloodRelay
|
return .suppressFloodRelay
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
//
|
||||||
|
// 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) + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
//
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
//
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||