Compare commits

..
Author SHA1 Message Date
islam 520ee63eac Update deprecated GitHub Actions 2026-04-14 20:34:30 +01:00
325 changed files with 11783 additions and 47298 deletions
-85
View File
@@ -1,85 +0,0 @@
name: Arti Binary Provenance
# The Arti xcframework is a vendored binary; these checks turn the policy in
# docs/ARTI-BINARY-PROVENANCE.md into an enforced gate:
# 1. The checked-in binary must match the hash manifest in the provenance doc.
# 2. A PR that changes the binary must also change at least one provenance
# input (Rust source, lockfile, build script, or the doc itself).
on:
push:
branches:
- main
paths:
- "localPackages/Arti/**"
- "docs/ARTI-BINARY-PROVENANCE.md"
pull_request:
paths:
- "localPackages/Arti/**"
- "docs/ARTI-BINARY-PROVENANCE.md"
jobs:
verify-hashes:
name: Verify xcframework hashes against provenance doc
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Compare artifact hashes with manifest
run: |
set -euo pipefail
doc="docs/ARTI-BINARY-PROVENANCE.md"
# Extract the manifest: lines of "<sha256> <path>" from the doc.
grep -E '^[0-9a-f]{64} localPackages/Arti/Frameworks/arti\.xcframework/' "$doc" \
| sort -k2 > expected.txt
if [ ! -s expected.txt ]; then
echo "::error::No hash manifest found in $doc"
exit 1
fi
# Hash the same file set the doc documents.
find localPackages/Arti/Frameworks/arti.xcframework -maxdepth 3 -type f -print0 \
| sort -z | xargs -0 sha256sum | sed 's/ \.\// /' | sort -k2 > actual.txt
if ! diff -u expected.txt actual.txt; then
echo "::error::Checked-in arti.xcframework does not match the manifest in $doc. If the binary change is intentional, rebuild per the doc and update the manifest in the same PR."
exit 1
fi
echo "All $(wc -l < actual.txt) artifact hashes match the provenance manifest."
require-provenance-evidence:
name: Binary changes must ship with provenance inputs
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Check changed files
run: |
set -euo pipefail
base="origin/${{ github.base_ref }}"
git fetch --no-tags --depth=1 origin "${{ github.base_ref }}"
changed=$(git diff --name-only "$base"...HEAD)
echo "Changed files:"
echo "$changed"
if ! echo "$changed" | grep -q '^localPackages/Arti/Frameworks/arti\.xcframework/'; then
echo "No binary artifact changes; nothing to verify."
exit 0
fi
if echo "$changed" | grep -Eq '^(localPackages/Arti/(Cargo\.(toml|lock)|build-ios\.sh|arti-bitchat/)|docs/ARTI-BINARY-PROVENANCE\.md)'; then
echo "Binary change is accompanied by provenance inputs."
exit 0
fi
echo "::error::arti.xcframework changed without matching source, lockfile, build-script, or provenance-doc changes. See docs/ARTI-BINARY-PROVENANCE.md (\"Do not accept an xcframework-only update\")."
exit 1
+7 -144
View File
@@ -10,12 +10,6 @@ jobs:
test:
name: Run Swift Tests (${{ matrix.name }})
runs-on: macos-latest
# A hung test must fail fast, not hold a runner for GitHub's 360-minute
# default (observed: intermittent app-suite hangs starving the queue).
# 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:
fail-fast: false # Don't cancel other matrix jobs when one fails
@@ -32,148 +26,17 @@ jobs:
- name: Checkout code
uses: actions/checkout@v5
# Use the Xcode-bundled Swift toolchain: it always matches the SDK on
# 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: Set up Swift
uses: swift-actions/setup-swift@v3
- name: Cache build artifacts
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ matrix.path }}/.build
key: ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
restore-keys: |
${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-
- name: Build tests
# Built separately so the hang watchdog below times only test
# execution: a cold-cache coverage build on a slow runner can
# legitimately take several minutes, and is already bounded by the
# 15-minute job timeout.
run: swift build --build-tests --enable-code-coverage --package-path ${{ matrix.path }}
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
${{ runner.os }}-${{ matrix.name }}-
- name: Run Tests
# Perf benchmarks are excluded here and run in their own serial step
# below: measuring while parallel test processes contend for cores
# produces noisy numbers, and the XCTest measure machinery has hung
# intermittently under parallel workers on loaded runners. Excluded
# via --skip (not just the env guard): every app run since the
# baselines landed timed out at the 15-minute job limit with the
# baseline tests dispatched into the parallel phase.
#
# The watchdog samples any still-running test processes after 5
# minutes (the suite passes in seconds when healthy; the build is
# done by this step) and kills the run, so a hang fails fast with
# stacks in the log instead of a silent timeout.
env:
BITCHAT_SKIP_PERF_BASELINES: "1"
run: |
swift test --skip-build --parallel --quiet --enable-code-coverage \
--skip PerformanceBaselineTests \
--package-path ${{ matrix.path }} &
test_pid=$!
(
sleep 300
if kill -0 "$test_pid" 2>/dev/null; then
echo "::group::Tests still running after 5 minutes — sampling before kill"
for pid in $(pgrep -if 'swiftpm-testing|xctest|PackageTests' || true); do
echo "--- sample of pid $pid ---"
sample "$pid" 5 2>/dev/null || true
done
echo "::endgroup::"
pkill -KILL -P "$test_pid" 2>/dev/null || true
kill -KILL "$test_pid" 2>/dev/null || true
fi
) &
watchdog_pid=$!
wait "$test_pid" && status=0 || status=$?
kill "$watchdog_pid" 2>/dev/null || true
exit "$status"
# Benchmarks run serially on an otherwise idle runner for stable
# numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate.
- name: Run performance benchmarks (serial)
if: matrix.name == 'app'
timeout-minutes: 6
env:
BITCHAT_PERF_LOG: ${{ github.workspace }}/perf-output.log
run: swift test --quiet --filter PerformanceBaselineTests
# Order-of-magnitude performance regression gate. Floors are deliberately
# generous (see bitchatTests/Performance/perf-floors.json) so this
# 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
if: matrix.name == 'app'
timeout-minutes: 10
run: ./scripts/check-perf-floors.sh perf-output.log
# Informational only: surfaces per-file and total line coverage in the
# job log so coverage trends are visible on every PR. No thresholds —
# this must never be the reason a build goes red.
- name: Coverage summary
run: |
BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }})
PROF="$BIN_PATH/codecov/default.profdata"
XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1)
BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)"
if [ -f "$PROF" ] && [ -f "$BINARY" ]; then
xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \
-ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)' || true
else
echo "No coverage data found; skipping summary."
fi
# SPM tests above only compile the macOS slice; this job covers the
# iOS-conditional code paths (UIKit, CoreBluetooth restoration, etc.).
ios-build:
name: Build iOS app (simulator)
runs-on: macos-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Build iOS (simulator, no signing)
# arm64 only: the vendored arti.xcframework has no x86_64 simulator slice.
run: |
set -o pipefail
xcodebuild -project bitchat.xcodeproj \
-scheme "bitchat (iOS)" \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
ARCHS=arm64 \
CODE_SIGNING_ALLOWED=NO \
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
run: swift test --parallel --quiet --package-path ${{ matrix.path }}
-33
View File
@@ -1,33 +0,0 @@
# 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 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.5.4
MARKETING_VERSION = 1.5.1
CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0
+16 -42
View File
@@ -1,6 +1,6 @@
# bitchat Privacy Policy
*Last updated: June 2026*
*Last updated: January 2025*
## Our Commitment
@@ -9,7 +9,7 @@ bitchat is designed with privacy as its foundation. We believe private communica
## Summary
- **No personal data collection** - We don't collect names, emails, or phone numbers
- **No accounts or company servers** - Mesh chat works peer-to-peer; optional Nostr features use public or user-selected relays
- **No servers** - Everything happens on your device and through peer-to-peer connections
- **No tracking** - We have no analytics, telemetry, or user tracking
- **Open source** - You can verify these claims by reading our code
@@ -17,11 +17,11 @@ bitchat is designed with privacy as its foundation. We believe private communica
### On Your Device Only
1. **Identity Keys**
- Cryptographic private keys generated on first launch or when optional Nostr identities are created
1. **Identity Key**
- A cryptographic key generated on first launch
- Stored locally in your device's secure storage
- Allows you to maintain "favorite" relationships across app restarts
- Private keys never leave your device; public keys are shared when needed for messaging
- Never leaves your device
2. **Nickname**
- The display name you choose (or auto-generated)
@@ -38,19 +38,12 @@ bitchat is designed with privacy as its foundation. We believe private communica
- Stored only on your device
- Allows you to recognize these peers in future sessions
5. **Optional Location Channel State**
- Your selected geohash channel, bookmarked geohashes, teleport flags, and bookmark display names
- Stored locally on your device so the location-channel UI can restore your choices
- Per-geohash Nostr identities are derived locally from a device seed stored in secure storage
- Exact latitude and longitude are not persisted by bitchat
### Temporary Session Data
During each session, bitchat temporarily maintains:
- Active peer connections (forgotten when app closes)
- Routing information for message delivery
- Cached messages for offline peers (12 hours max)
- Your current location while optional location channels are enabled, used locally to compute geohash channels and friendly place names
## What Information is Shared
@@ -69,21 +62,13 @@ When you join a password-protected room:
- Your nickname appears in the member list
- Room owners can see you've joined
### With Nostr Relays (Optional Features)
If you enable Nostr-backed features:
- Private fallback messages to mutual favorites are sent as encrypted NIP-17 gift wraps. Relays can see event metadata, but not message content.
- Public location-channel messages, location notes, and presence are scoped with geohash tags. Relays and other participants can see the geohash tag, event kind, timestamp, and public key used for that geohash.
- Exact GPS coordinates are not included in Nostr events by bitchat. The geohash precision you choose can still reveal an approximate area, from region-level to building-level.
- Automatic presence heartbeats are limited to low-precision geohashes (region, province, and city). More precise geohash posts happen only when you use those channels or location notes.
## What We DON'T Do
bitchat **never**:
- Collects personal information
- Sells or shares your exact GPS location
- Stores data on servers we operate
- Sells your data to advertisers or data brokers
- Tracks your location
- Stores data on servers
- Shares data with third parties
- Uses analytics or telemetry
- Creates user profiles
- Requires registration
@@ -99,27 +84,19 @@ All private messages use end-to-end encryption:
## Your Rights
You have complete control:
- **Delete Local State**: Triple-tap the logo to instantly wipe local keys, sessions, caches, and preferences
- **Leave Anytime**: Close the app and local presence stops; relay-backed presence ages out
- **No Account**: No account record exists for you to delete from us
- **Portability**: Your local state stays on your device unless you send messages, use optional relay-backed features, or export it
- **Delete Everything**: Triple-tap the logo to instantly wipe all data
- **Leave Anytime**: Close the app and your presence disappears
- **No Account**: Nothing to delete from servers because there are none
- **Portability**: Your data never leaves your device unless you export it
## Bluetooth & Permissions
bitchat requires Bluetooth permission to function:
- Used only for peer-to-peer communication
- No location data is accessed or stored
- Bluetooth is not used for tracking
- You can revoke this permission at any time in system settings
## Location Permission
Location permission is optional and is used only for location channels:
- Used to compute local geohash channels and display names
- Requested as when-in-use permission
- Exact coordinates are not shared in messages or stored by bitchat
- Selected and bookmarked geohashes may persist locally until you remove them, use panic wipe, or delete the app
- You can revoke this permission at any time in system settings
## Children's Privacy
bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone.
@@ -129,15 +106,12 @@ bitchat does not knowingly collect information from children. The app has no age
- **Messages**: Deleted from memory when app closes (unless room retention is enabled)
- **Identity Key**: Persists until you delete the app
- **Favorites**: Persist until you remove them or delete the app
- **Location channel choices**: Selected/bookmarked geohashes persist locally until removed, panic-wiped, or the app is deleted
- **Nostr relay data**: Public geohash events and encrypted gift wraps may be retained by relays according to each relay's policy
- **Everything Else**: Exists only during active sessions
## Security Measures
- All communication is encrypted
- No accounts or company servers
- Optional Nostr relays receive only the events needed for Nostr-backed private fallback or public location channels
- No data transmitted to servers (there are none)
- Open source code for public audit
- Regular security updates
- Cryptographic signatures prevent tampering
@@ -147,7 +121,7 @@ bitchat does not knowingly collect information from children. The app has no age
If we update this policy:
- The "Last updated" date will change
- The updated policy will be included in the app
- No retroactive changes can make us collect data already held only in your app
- No retroactive changes can affect data (since we don't collect any)
## Contact
@@ -158,7 +132,7 @@ bitchat is an open source project. For privacy questions:
## Philosophy
Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no company servers, no analytics. Just people talking freely.
Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no servers, no surveillance. Just people talking freely.
---
+4 -10
View File
@@ -13,9 +13,9 @@ let package = Package(
.executable(
name: "bitchat",
targets: ["bitchat"]
)
),
],
dependencies: [
dependencies:[
.package(path: "localPackages/Arti"),
.package(path: "localPackages/BitFoundation"),
.package(path: "localPackages/BitLogger"),
@@ -53,17 +53,11 @@ let package = Package(
path: "bitchatTests",
exclude: [
"Info.plist",
"README.md",
// CI perf gate data (read by scripts/check-perf-floors.sh),
// not a test resource.
"Performance/perf-floors.json"
"README.md"
],
resources: [
.process("Localization"),
// Only the vector fixture: declaring the whole "Noise"
// directory would claim its .swift test files as resources
// and silently drop them from compilation.
.process("Noise/NoiseTestVectors.json")
.process("Noise")
]
)
]
+250 -82
View File
@@ -1,141 +1,309 @@
# bitchat Protocol Whitepaper
# BitChat Protocol Whitepaper
**Version 2.0**
**Version 1.1**
**Date: July 6, 2026**
**Date: July 25, 2025**
---
## Abstract
bitchat is a decentralized, peer-to-peer messaging application for secure, private, censorship-resistant communication that works with or without the internet. Nearby devices form an ad-hoc Bluetooth Low Energy (BLE) mesh; distant peers are reached over the Nostr protocol when a connection exists. A layered store-and-forward stack — a persistent sender outbox, opportunistic couriers with a spray-and-wait copy budget, gossip-synced public history, and Nostr relay mailboxes — delivers messages to peers who are out of range at send time. This document describes the protocol and its delivery guarantees as implemented.
BitChat is a decentralized, peer-to-peer messaging application designed for secure, private, and censorship-resistant communication over ephemeral, ad-hoc networks. This whitepaper details the BitChat Protocol Stack, a layered architecture that combines a modern cryptographic foundation with a flexible application protocol. At its core, BitChat leverages the Noise Protocol Framework (specifically, the `XX` pattern) to establish mutually authenticated, end-to-end encrypted sessions between peers. This document provides a technical specification of the identity management, session lifecycle, message framing, and security considerations that underpin the BitChat network.
---
## 1. Design Goals
## 1. Introduction
* **Confidentiality:** all private communication is end-to-end encrypted; intermediate nodes and couriers carry only opaque ciphertext.
* **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.
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).
## 2. Architecture Overview
The design goals of the BitChat Protocol are:
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
* **Confidentiality:** All communication must be unreadable to third parties.
* **Authentication:** Users must be able to verify the identity of their correspondents.
* **Integrity:** Messages cannot be tampered with in transit.
* **Forward Secrecy:** The compromise of long-term identity keys must not compromise past session keys.
* **Deniability:** It should be difficult to cryptographically prove that a specific user sent a particular message.
* **Resilience:** The protocol must function reliably in lossy, low-bandwidth environments.
* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts.
* **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.
This paper specifies the technical details of the protocol designed to meet these goals.
The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly.
---
## 3. Identity
## 2. Protocol Stack
Each device holds two long-term key pairs in the Keychain:
The BitChat Protocol is a four-layer stack. This layered approach separates concerns, allowing for modularity and future extensibility.
* a **Curve25519 static key** for Noise key agreement — its SHA-256 fingerprint is the peer's stable identity, and
* an **Ed25519 signing key** for packet signatures.
```mermaid
graph TD
A[Application Layer] --> B[Session Layer];
B --> C[Encryption Layer];
C --> D[Transport Layer];
On the mesh, peers appear under short ephemeral IDs derived per session; favoriting pins the full Noise public key so identity survives across sessions. Mutual favorites also exchange Nostr public keys for the internet path. Optional QR verification binds a nickname to a fingerprint in person.
subgraph "BitChat Application"
A
end
## 4. BLE Mesh Layer
subgraph "Message Framing & State"
B
end
### 4.1 Packet Format
subgraph "Noise Protocol Framework"
C
end
A compact binary header (version, type, TTL, timestamp, flags) is followed by an 8-byte sender ID, an optional 8-byte recipient ID, the payload, and an optional Ed25519 signature. Version 2 packets may carry an explicit source route. Signatures exclude the TTL byte so relays can decrement it without invalidating them. Packets other than fragments are padded toward uniform sizes.
subgraph "BLE, Wi-Fi Direct, etc."
D
end
### 4.2 Flood Control
style A fill:#cde4ff
style B fill:#b5d8ff
style C fill:#9ac2ff
style D fill:#7eadff
```
Relaying is a deterministic controlled flood tuned by local connection degree:
* **Application Layer:** Defines the structure of user-facing messages (`BitchatMessage`), acknowledgments (`DeliveryAck`), and other application-level data.
* **Session Layer:** Manages the overall communication packet (`BitchatPacket`). This includes routing information (TTL), message typing, fragmentation, and serialization into a compact binary format.
* **Encryption Layer:** Establishes and manages secure channels using the Noise Protocol Framework. It is responsible for the cryptographic handshake, session management, and transport message encryption/decryption.
* **Transport Layer:** The underlying physical medium used for data transmission, such as Bluetooth Low Energy (BLE). This layer is abstracted away from the core protocol.
* **TTL:** packets originate with TTL 7. Relays clamp: dense graphs (≥ 6 links) cap broadcast TTL at 5; thin chains (≤ 2 links) relay at full incoming depth.
* **Deduplication:** an LRU seen-set (1000 entries, 5-minute expiry) keyed by sender, timestamp, type, and a payload digest drops duplicates. A scheduled relay is cancelled when a duplicate arrives first from another relay.
* **Jitter:** relays wait a random 10220 ms (wider when dense) so duplicate suppression wins often.
* **Fanout subsetting:** broadcast messages are re-sent to a deterministic, message-ID-seeded subset of links (~log₂ of degree) rather than all of them; announces, fragments, and sync packets use full fanout. The ingress link is always excluded (split horizon).
* **Directed traffic** (handshakes, private messages, courier envelopes) relays deterministically with TTL 1 and tight jitter, and is never subset.
---
### 4.3 Routing
## 3. Identity and Key Management
Announcements carry up to 10 direct-neighbor IDs, giving each node a shallow topology map (60 s freshness). When a bidirectionally-confirmed path exists, packets are source-routed along it; otherwise — and whenever a route fails — delivery falls back to flooding.
A peer's identity in BitChat is defined by two persistent cryptographic key pairs, which are generated on first launch and stored securely in the device's Keychain.
### 4.4 Fragmentation
1. **Noise Static Key Pair (`Curve25519`):** This is the long-term identity key used for the Noise Protocol handshake. The public part of this key is shared with peers to establish secure sessions.
2. **Signing Key Pair (`Ed25519`):** This key is used to sign announcements and other protocol messages where non-repudiation is required, such as binding a public key to a nickname.
Packets exceeding the link MTU split into ~469-byte fragments (8-byte fragment ID, index/total header) that relay independently and reassemble at each receiving node (128 concurrent assemblies, 30 s timeout, 1 MiB cap).
### 3.1. Fingerprint
### 4.5 Presence
A user's unique, verifiable fingerprint is the **SHA-256 hash** of their **Noise static public key**. This provides a user-friendly and secure way to verify an identity out-of-band (e.g., by reading it aloud or scanning a QR code).
Signed announcements propagate multi-hop: every 4 s while isolated, backing off to ~1530 s (jittered) when connected. A verified announce retains a peer as *reachable* for 60 s after last contact. Connection scheduling is RSSI-gated with duty-cycled scanning to bound battery drain.
`Fingerprint = SHA256(StaticPublicKey_Curve25519)`
## 5. Encryption
### 3.2. Identity Management
### 5.1 Live Sessions: Noise XX
The `SecureIdentityStateManager` class is responsible for managing all cryptographic identity material and social metadata (petnames, trust levels, etc.). It uses an in-memory cache for performance and persists this cache to the Keychain after encrypting it with a separate AES-GCM key.
Connected peers establish sessions with the Noise `XX` pattern (Curve25519 / ChaCha20-Poly1305 / SHA-256), providing mutual authentication and forward secrecy. All private payloads — messages, delivery acks, read receipts — ride inside the session as typed ciphertext. Intermediate relays see only opaque `noiseEncrypted` packets.
---
### 5.2 Offline Seals: Noise X
## 4. The Social Trust Layer
Courier envelopes are sealed to the recipient's *static* key with the one-way Noise `X` pattern; the sender's identity is authenticated inside the ciphertext. **This path has no forward secrecy** — compromise of the recipient's static key exposes sealed-but-undelivered mail. A prekey scheme is future work.
Beyond cryptographic identity, BitChat incorporates a social trust layer, allowing users to manage their relationships with peers. This functionality is handled by the `SecureIdentityStateManager`.
### 5.3 Nostr Path
### 4.1. Peer Verification
Private messages to mutual favorites are wrapped per NIP-17/NIP-59: a rumor (kind 14) sealed (kind 13) and gift-wrapped (kind 1059) under a throwaway ephemeral key, so relays learn neither sender nor content.
While the Noise handshake cryptographically authenticates a peer's key, it doesn't confirm the real-world identity of the person holding the device. To solve this, users can perform out-of-band (OOB) verification by comparing fingerprints. Once a user confirms that a peer's fingerprint matches the one they expect, they can mark that peer as "verified". This status is stored locally and displayed in the UI, providing a strong assurance of identity for future conversations.
## 6. Store and Forward
### 4.2. Favorites and Blocking
Four mechanisms cover the "recipient is not here right now" problem. All persisted state is wiped by panic mode.
To improve the user experience and provide control over interactions, the protocol supports:
* **Favorites:** Users can mark trusted or frequently contacted peers as "favorites". This is a local designation that can be used by the application to prioritize notifications or display peers more prominently.
* **Blocking:** Users can block peers. When a peer is blocked, the application will discard any incoming packets from that peer's fingerprint at the earliest possible stage, effectively silencing them without notifying the blocked peer.
### 6.1 Sender Outbox
---
Private messages without a prompt route are retained per peer (100 messages/peer, 24 h TTL) and re-sent on reconnect events until a delivery or read ack clears them, or a resend cap (8 attempts) drops them with visible failure. The outbox persists to disk sealed under a ChaChaPoly key held only in the Keychain, so queued mail survives an app kill without ever storing plaintext.
## 5. The Noise Protocol Layer
### 6.2 Couriers
BitChat implements the Noise Protocol Framework to provide strong, authenticated end-to-end encryption.
When no transport can deliver promptly, the message is sealed (§5.2) into a **courier envelope** and handed to up to 3 connected peers who may physically encounter the recipient:
### 5.1. Protocol Name
* **Opaque addressing.** The only routing information is a 16-byte rotating recipient tag — an HMAC of the recipient's static key and the UTC day — computable solely by parties who already know that key. Couriers learn neither sender, recipient, nor content, and tags do not correlate across days.
* **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.
The specific Noise protocol implemented is:
### 6.3 Public History (Gossip Sync)
**`Noise_XX_25519_ChaChaPoly_SHA256`**
Public broadcast messages are cached (1000 packets) and reconciled between peers every ~15 s using compact GCS filters: each side advertises what it holds, the other returns what is missing. Messages stay sync-able for **6 hours** and the cache persists to disk, so a device that walks between two partitions — or relaunches later — serves the room's recent history to whoever missed it. Fragments and file transfers keep a short 15-minute window.
* **`XX` Pattern:** This handshake pattern provides mutual authentication and forward secrecy. It does not require either party to know the other's static public key before the handshake begins. The keys are exchanged and authenticated during the three-part handshake. This is ideal for a decentralized P2P environment.
* **`25519`:** The Diffie-Hellman function used is Curve25519.
* **`ChaChaPoly`:** The AEAD (Authenticated Encryption with Associated Data) cipher is ChaCha20-Poly1305.
* **`SHA256`:** The hash function used for all cryptographic hashing operations is SHA-256.
### 6.4 Nostr Mailboxes
### 5.2. The `XX` Handshake
Gift-wrapped messages rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet.
The `XX` handshake consists of three messages exchanged between an Initiator and a Responder to establish a shared secret and derive transport encryption keys.
### 6.5 Delivery Metrics
```mermaid
sequenceDiagram
participant I as Initiator
participant R as Responder
Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drops — no identities, message IDs, or timestamps) let delivery behavior be measured on-device. They never leave the device and are cleared by the panic wipe.
Note over I, R: Pre-computation: h = SHA256(protocol_name)
## 7. Application Layer
I->>R: -> e
Note right of I: I generates ephemeral key `e_i`.<br/>h = SHA256(h + e_i.pub)
* **Public chat** — signed broadcast messages within the mesh, backed by the gossip-synced history above.
* **Private chat** — end-to-end encrypted messages with delivery and read receipts, over mesh, courier, or Nostr.
* **Location channels** — geohash-scoped public rooms carried over Nostr relays for regional chat beyond radio range.
* **Favorites** — the mutual-trust relationship that unlocks Nostr delivery and the larger courier quota.
* **Media** — files and images fragment over the mesh (1 MiB cap, explicit accept before anything touches disk); couriers carry text only.
* **Panic wipe** — clears identity keys, favorites, carried courier mail, the sealed outbox, archived public history, and metrics.
R->>I: <- e, ee, s, es
Note left of R: R generates ephemeral key `e_r`.<br/>h = SHA256(h + e_r.pub)<br/>MixKey(DH(e_i, e_r))<br/>R sends static key `s_r`, encrypted.<br/>h = SHA256(h + ciphertext)<br/>MixKey(DH(e_i, s_r))
I->>R: -> s, se
Note right of I: I decrypts and verifies `s_r`.<br/>I sends static key `s_i`, encrypted.<br/>h = SHA256(h + ciphertext)<br/>MixKey(DH(s_i, e_r))
Note over I, R: Handshake complete. Transport keys derived.
```
**Handshake Flow:**
1. **Initiator -> Responder:** The initiator generates a new ephemeral key pair (`e_i`) and sends the public part to the responder.
2. **Responder -> Initiator:** The responder receives the initiator's ephemeral public key. It then generates its own ephemeral key pair (`e_r`), performs a DH exchange with the initiator's ephemeral key (`ee`), sends its own static public key (`s_r`) encrypted with the resulting symmetric key, and performs another DH exchange between the initiator's ephemeral key and its own static key (`es`).
3. **Initiator -> Responder:** The initiator receives the responder's message, decrypts the responder's static key, and authenticates it. The initiator then sends its own static key (`s_i`) encrypted and performs a final DH exchange between its static key and the responder's ephemeral key (`se`).
Upon completion, both parties share a set of symmetric keys for bidirectional transport message encryption. The final handshake hash is used for channel binding.
### 5.3. Session Management
The `NoiseSessionManager` class manages all active Noise sessions. It handles:
* Creating sessions for new peers.
* Coordinating the handshake process to prevent race conditions.
* Storing the resulting transport ciphers (`sendCipher`, `receiveCipher`).
* Periodically checking if sessions need to be re-keyed for enhanced security.
---
## 6. The BitChat Session and Application Protocol
Once a Noise session is established, peers exchange `BitchatPacket` structures, which are encrypted as the payload of Noise transport messages.
### 6.1. Binary Packet Format (`BitchatPacket`)
To minimize bandwidth, `BitchatPacket`s are serialized into a compact binary format. The structure is designed to be fixed-size where possible to resist traffic analysis.
| Field | Size (bytes) | Description |
|-----------------|--------------|---------------------------------------------------------------------------------------------------------|
| **Header** | **13** | **Fixed-size header** |
| Version | 1 | Protocol version (currently `1`). |
| Type | 1 | Message type (e.g., `message`, `deliveryAck`, `noiseHandshakeInit`). See `MessageType` enum. |
| TTL | 1 | Time-To-Live for mesh network routing. Decremented at each hop. |
| Timestamp | 8 | `UInt64` millisecond timestamp of packet creation. |
| Flags | 1 | Bitmask for optional fields (`hasRecipient`, `hasSignature`, `isCompressed`). |
| Payload Length | 2 | `UInt16` length of the payload field. |
| **Variable** | **...** | **Variable-size fields** |
| Sender ID | 8 | 8-byte truncated peer ID of the sender. |
| Recipient ID | 8 (optional) | 8-byte truncated peer ID of the recipient. Present if `hasRecipient` flag is set. Broadcast if `0xFF..FF`. |
| Payload | Variable | The actual content of the packet, as defined by the `Type` field. |
| Signature | 64 (optional)| `Ed25519` signature of the packet. Present if `hasSignature` flag is set. |
**Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers.
```mermaid
---
config:
theme: dark
---
---
title: "BitchatPacket"
---
packet
+8: "Version"
+8: "Type"
+8: "TTL"
+64: "Timestamp"
+8: "Flags"
+16: "Payload Length"
+64: "Sender ID"
+64: "Recipient ID (optional)"
+48: "Payload (variable)"
+64: "Signature (optional)"
```
_A representation of the sizes of the fields in `BitchatPacket`_
### 6.2. Application Message Format (`BitchatMessage`)
For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.
| Field | Size (bytes) | Description |
|---------------------|--------------|--------------------------------------------------------------------------|
| Flags | 1 | Bitmask for optional fields (`isRelay`, `isPrivate`, `hasOriginalSender`). |
| Timestamp | 8 | `UInt64` millisecond timestamp of message creation. |
| ID | 1 + len | `UUID` string for the message. |
| Sender | 1 + len | Nickname of the sender. |
| Content | 2 + len | The UTF-8 encoded message content. |
| Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. |
| Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. |
```mermaid
---
config:
theme: dark
---
---
title: "BitchatMessage"
---
packet
+8: "Flags"
+64: "Timestamp"
+24: "ID (variable)"
+32: "Sender (variable)"
+32: "Content (variable)"
+32: "Original Sender (variable) (optional)"
+32: "Recipient Nickname (variable) (optional)"
```
_A representation of the sizes of the fields in `BitchatMessage`_
---
## 7. Message Routing and Propagation
BitChat operates as a decentralized mesh network, meaning there are no central servers to route messages. Packets are propagated through the network from peer to peer. The protocol supports several modes of message delivery.
### 7.1. Direct Connection
This is the simplest case. If Peer A and Peer B are directly connected, they can exchange packets after establishing a mutually authenticated Noise session. All packets are encrypted using the transport ciphers derived from the handshake.
### 7.2. Efficient Gossip with Bloom Filters
To send messages to peers that are not directly connected, BitChat employs a "flooding" or "gossip" protocol. When a peer receives a packet that is not destined for it, it acts as a relay. To prevent infinite routing loops and minimize memory usage, the protocol uses an `OptimizedBloomFilter` to track recently seen packet IDs.
The logic is as follows:
1. A peer receives a packet.
2. It checks the Bloom filter to see if the packet's ID has likely been seen before. If so, the packet is discarded. Bloom filters can have false positives (though they are rare), but they guarantee no false negatives. This means that while some packets may be incorrectly discarded due to false positives, the gossip protocol's redundancy ensures these packets will eventually be received through subsequent exchanges with other peers.
3. If the packet is new, its ID is added to the Bloom filter.
4. The peer decrements the packet's Time-To-Live (TTL) field.
5. If the TTL is greater than zero, the peer re-broadcasts the packet to all of its connected peers, *except* for the peer from which it received the packet.
This mechanism allows packets to "flood" through the network efficiently, maximizing the chance of reaching their destination while using minimal resources to prevent loops.
### 7.3. Time-To-Live (TTL)
Every `BitchatPacket` contains an 8-bit TTL field. This value is set by the originating peer and is decremented by one at each relay hop. If a peer receives a packet and decrements its TTL to 0, it will process the packet (if it is the recipient) but will not relay it further. This is a crucial mechanism to prevent packets from circulating endlessly in the mesh.
### 7.4. Private vs. Broadcast Messages
The routing logic respects the confidentiality of private messages:
* **Private Messages:** A packet with a specific `recipientID` is a private message. Relay nodes forward the entire, encrypted Noise message without being able to access the inner `BitchatPacket` or its payload. Only the final recipient, who shares the correct Noise session keys with the sender, can decrypt the packet.
* **Broadcast Messages:** A packet with the special broadcast `recipientID` (`0xFFFFFFFFFFFFFFFF`) is intended for all peers. Any peer that receives and decrypts a broadcast message will process its content. It will still be relayed according to the flooding algorithm to ensure it reaches the entire network.
### 7.5. Message Reliability and Lifecycle
To function in unreliable, lossy networks, the protocol includes features to track the lifecycle of a message and ensure its delivery.
* **Delivery Acknowledgments (`DeliveryAck`):** When a private message reaches its final destination, the recipient's device sends a `DeliveryAck` packet back to the original sender. This acknowledgment contains the ID of the original message.
* **Read Receipts (`ReadReceipt`):** After a message is displayed on the recipient's screen, the application can send a `ReadReceipt`, also containing the original message ID, to inform the sender that the message has been seen.
* **Message Retry Service:** Senders maintain a `MessageRetryService` which tracks outgoing messages. If a `DeliveryAck` is not received for a message within a certain time window, the service will automatically re-send the message, creating a more resilient user experience.
### 7.6. Fragmentation
Transport layers like BLE have a Maximum Transmission Unit (MTU) that limits the size of a single packet. To handle messages larger than this limit, BitChat implements a fragmentation protocol.
* **`fragmentStart`:** A packet with this type marks the beginning of a fragmented message. It contains metadata about the total size and number of fragments.
* **`fragmentContinue`:** These packets carry the intermediate chunks of the message data.
* **`fragmentEnd`:** This packet carries the final chunk of the message and signals the receiver to begin reassembly.
Receiving peers collect all fragments and reassemble them in the correct order before passing the complete message up to the application layer.
---
## 8. Security Considerations
* **Relay nodes** cannot read private traffic; they forward padded, opaque ciphertext.
* **Couriers** are quota-bounded mailbags. A malicious courier can drop mail (redundant copies and deposit retry mitigate this) but cannot read it, link it across days, or amplify it — copy budgets are capped and every envelope is validated against size and lifetime policy on deposit.
* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path.
## 9. Future Work
* Prekey-based forward secrecy for courier envelopes.
* Couriered media beyond the 16 KiB text cap.
* Probabilistic relay and edge-of-network TTL boosting for very dense and very sparse graphs.
* Multi-hop courier routing informed by encounter history.
* **Replay Attacks:** The Noise transport messages include a nonce that is incremented for each message. The `NoiseCipherState` implements a sliding window replay protection mechanism to detect and discard replayed or out-of-order messages.
* **Denial of Service:** The `NoiseRateLimiter` is implemented to prevent resource exhaustion from rapid, repeated handshake attempts from a single peer.
* **Key-Compromise Impersonation:** The `XX` pattern authenticates both parties, preventing an attacker from impersonating one party to the other.
* **Identity Binding:** While the Noise handshake authenticates the cryptographic keys, binding those keys to a human-readable nickname is handled at the application layer. Users must verify fingerprints out-of-band to prevent man-in-the-middle attacks.
* **Traffic Analysis:** The use of fixed-size padding for all packets helps to obscure the exact nature and content of the communication, making it harder for a network-level adversary to infer information based on message size.
---
*This document describes the protocol as implemented in the current release. The implementation is free and unencumbered software released into the public domain.*
## 9. Conclusion
The BitChat Protocol provides a robust and secure foundation for decentralized, peer-to-peer communication. By layering a flexible application protocol on top of the well-regarded Noise Protocol Framework, it achieves strong confidentiality, authentication, and forward secrecy. The use of a compact binary format and thoughtful security considerations like rate limiting and traffic analysis resistance make it suitable for use in challenging network environments.
+19 -7
View File
@@ -321,7 +321,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 2650;
LastUpgradeCheck = 1640;
};
buildConfigurationList = 3EA424CBD51200895D361189 /* Build configuration list for PBXProject "bitchat" */;
developmentRegion = en;
@@ -446,6 +446,7 @@
CODE_SIGNING_ALLOWED = YES;
CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
LD_RUNPATH_SEARCH_PATHS = (
@@ -470,6 +471,7 @@
CODE_SIGNING_ALLOWED = YES;
CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
LD_RUNPATH_SEARCH_PATHS = (
@@ -496,6 +498,7 @@
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -520,6 +523,7 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
@@ -528,6 +532,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -551,6 +556,7 @@
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = NO;
INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
@@ -560,6 +566,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
SDKROOT = iphoneos;
@@ -583,6 +590,7 @@
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -609,6 +617,7 @@
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
@@ -618,6 +627,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
SDKROOT = iphoneos;
@@ -643,6 +653,7 @@
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
@@ -652,6 +663,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES;
@@ -664,7 +676,6 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
@@ -698,7 +709,6 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -712,10 +722,10 @@
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = "$(MARKETING_VERSION)";
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = "$(SWIFT_VERSION)";
@@ -735,6 +745,7 @@
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = NO;
INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
@@ -744,6 +755,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.5.1;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES;
@@ -756,7 +768,6 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
@@ -790,7 +801,6 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -810,11 +820,11 @@
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = "$(MARKETING_VERSION)";
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = "$(SWIFT_VERSION)";
@@ -831,6 +841,7 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
@@ -839,6 +850,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2650"
LastUpgradeVersion = "1640"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2650"
LastUpgradeVersion = "1640"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
-102
View File
@@ -1,102 +0,0 @@
import BitFoundation
import Combine
import Foundation
enum SharedContentKind: String, Sendable, Equatable {
case text
case url
}
enum RuntimeScenePhase: String, Sendable, Equatable {
case active
case inactive
case background
}
enum TorLifecycleEvent: String, Sendable, Equatable {
case willStart
case willRestart
case didBecomeReady
case preferenceChanged
}
enum AppEvent: Sendable, Equatable {
case launched
case startupCompleted
case scenePhaseChanged(RuntimeScenePhase)
case openedURL(String)
case sharedContentAccepted(SharedContentKind)
case notificationOpened(peerID: PeerID?)
case deepLinkOpened(String)
case torLifecycleChanged(TorLifecycleEvent)
case nostrRelayConnectionChanged(Bool)
case terminationRequested
}
actor AppEventStream {
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) {
for continuation in continuations.values {
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
/// canonical `id` only; `routingPeerID` carries the transport-level peer ID
/// the conversation is keyed under (see `ConversationID.directPeer`).
struct PeerHandle: Sendable, Identifiable {
let id: String
let routingPeerID: PeerID
}
extension PeerHandle: Equatable {
static func == (lhs: PeerHandle, rhs: PeerHandle) -> Bool {
lhs.id == rhs.id
}
}
extension PeerHandle: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
enum ConversationID: Hashable, Sendable {
case mesh
case geohash(String)
case direct(PeerHandle)
init(channelID: ChannelID) {
switch channelID {
case .mesh:
self = .mesh
case .location(let channel):
self = .geohash(channel.geohash.lowercased())
}
}
}
-100
View File
@@ -1,100 +0,0 @@
import BitFoundation
import Combine
import CoreBluetooth
import Foundation
@MainActor
final class AppChromeModel: ObservableObject {
@Published private(set) var hasUnreadPrivateMessages = false
@Published var nickname: String
@Published var showingFingerprintFor: PeerID?
@Published var isAppInfoPresented = false
@Published var isLocationChannelsSheetPresented = false
@Published var showBluetoothAlert = false
@Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown
@Published var showScreenshotPrivacyWarning = false
private let chatViewModel: ChatViewModel
private var cancellables = Set<AnyCancellable>()
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
self.chatViewModel = chatViewModel
self.nickname = chatViewModel.nickname
bind(privateInboxModel: privateInboxModel)
}
var shouldSuppressScreenshotNotification: Bool {
isLocationChannelsSheetPresented || isAppInfoPresented
}
func setNickname(_ nickname: String) {
self.nickname = nickname
if chatViewModel.nickname != nickname {
chatViewModel.nickname = nickname
}
}
func validateAndSaveNickname() {
chatViewModel.validateAndSaveNickname()
if nickname != chatViewModel.nickname {
nickname = chatViewModel.nickname
}
}
func openMostRelevantPrivateChat() {
chatViewModel.openMostRelevantPrivateChat()
}
func showFingerprint(for peerID: PeerID) {
showingFingerprintFor = peerID
}
func clearFingerprint() {
showingFingerprintFor = nil
}
func presentAppInfo() {
isAppInfoPresented = true
}
func triggerScreenshotPrivacyWarning() {
showScreenshotPrivacyWarning = true
}
func panicClearAllData() {
chatViewModel.panicClearAllData()
}
private func bind(privateInboxModel: PrivateInboxModel) {
privateInboxModel.$unreadPeerIDs
.receive(on: DispatchQueue.main)
.sink { [weak self] unreadPeerIDs in
self?.hasUnreadPrivateMessages = !unreadPeerIDs.isEmpty
}
.store(in: &cancellables)
chatViewModel.$nickname
.receive(on: DispatchQueue.main)
.sink { [weak self] nickname in
guard let self, self.nickname != nickname else { return }
self.nickname = nickname
}
.store(in: &cancellables)
chatViewModel.$showBluetoothAlert
.receive(on: DispatchQueue.main)
.assign(to: &$showBluetoothAlert)
chatViewModel.$bluetoothAlertMessage
.receive(on: DispatchQueue.main)
.assign(to: &$bluetoothAlertMessage)
chatViewModel.$bluetoothState
.receive(on: DispatchQueue.main)
.assign(to: &$bluetoothState)
hasUnreadPrivateMessages = !privateInboxModel.unreadPeerIDs.isEmpty
}
}
-388
View File
@@ -1,388 +0,0 @@
import BitFoundation
import Combine
import Foundation
import SwiftUI
import Tor
import UserNotifications
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
@MainActor
final class AppRuntime: ObservableObject {
let chatViewModel: ChatViewModel
let events = AppEventStream()
/// Single source of truth for conversation message state and selection
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models
/// and `ChatViewModel` observe and mutate it through its intent API.
let conversations: ConversationStore
let peerIdentityStore: PeerIdentityStore
let locationPresenceStore: LocationPresenceStore
let publicChatModel: PublicChatModel
let privateInboxModel: PrivateInboxModel
let privateConversationModel: PrivateConversationModel
let verificationModel: VerificationModel
let conversationUIModel: ConversationUIModel
let locationChannelsModel: LocationChannelsModel
let peerListModel: PeerListModel
let appChromeModel: AppChromeModel
private let idBridge: NostrIdentityBridge
private var cancellables = Set<AnyCancellable>()
private var started = false
private var lastNostrRelayConnectedState = false
private var didHandleInitialNostrConnection = false
#if os(iOS)
private var didHandleInitialActive = false
private var didEnterBackground = false
#endif
init(
keychain: KeychainManagerProtocol = KeychainManager(),
idBridge: NostrIdentityBridge = NostrIdentityBridge()
) {
self.idBridge = idBridge
let conversations = ConversationStore()
let peerIdentityStore = PeerIdentityStore()
let locationPresenceStore = LocationPresenceStore()
let locationManager = LocationChannelManager.shared
self.conversations = conversations
self.peerIdentityStore = peerIdentityStore
self.locationPresenceStore = locationPresenceStore
self.chatViewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: SecureIdentityStateManager(keychain),
conversations: conversations,
peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore,
locationManager: locationManager
)
self.publicChatModel = PublicChatModel(conversations: conversations)
self.privateInboxModel = PrivateInboxModel(conversations: conversations)
self.locationChannelsModel = LocationChannelsModel(manager: locationManager)
self.privateConversationModel = PrivateConversationModel(
chatViewModel: self.chatViewModel,
conversations: conversations,
locationChannelsModel: self.locationChannelsModel,
peerIdentityStore: peerIdentityStore
)
self.verificationModel = VerificationModel(
chatViewModel: self.chatViewModel,
privateConversationModel: self.privateConversationModel,
peerIdentityStore: peerIdentityStore
)
self.conversationUIModel = ConversationUIModel(
chatViewModel: self.chatViewModel,
privateConversationModel: self.privateConversationModel,
conversations: conversations
)
self.peerListModel = PeerListModel(
chatViewModel: self.chatViewModel,
conversations: conversations,
locationChannelsModel: self.locationChannelsModel,
peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore
)
self.appChromeModel = AppChromeModel(
chatViewModel: self.chatViewModel,
privateInboxModel: self.privateInboxModel
)
GeoRelayDirectory.shared.prefetchIfNeeded()
bindRuntimeObservers()
NotificationDelegate.shared.runtime = self
}
func start() {
guard !started else {
checkForSharedContent()
return
}
started = true
NotificationDelegate.shared.runtime = self
VerificationService.shared.configure(with: chatViewModel.meshService)
announceInitialTorStatusIfNeeded()
Task(priority: .utility) { [weak self] in
guard let self else { return }
let nickname = await MainActor.run { self.chatViewModel.nickname }
let npub = await MainActor.run {
try? self.idBridge.getCurrentNostrIdentity()?.npub
}
await MainActor.run {
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
}
}
NetworkActivationService.shared.start()
GeohashPresenceService.shared.start()
checkForSharedContent()
record(.launched)
record(.startupCompleted)
}
func handleOpenURL(_ url: URL) {
record(.openedURL(url.absoluteString))
if url.scheme == "bitchat", url.host == "share" {
checkForSharedContent()
}
}
func handleDidBecomeActiveNotification() {
chatViewModel.handleDidBecomeActive()
checkForSharedContent()
}
#if os(macOS)
func handleMacDidBecomeActiveNotification() {
record(.scenePhaseChanged(.active))
chatViewModel.handleDidBecomeActive()
checkForSharedContent()
}
#endif
#if os(iOS)
func handleScenePhaseChange(_ newPhase: ScenePhase) {
switch newPhase {
case .background:
record(.scenePhaseChanged(.background))
TorManager.shared.setAppForeground(false)
TorManager.shared.goDormantOnBackground()
chatViewModel.endGeohashSampling()
NostrRelayManager.shared.disconnect()
didEnterBackground = true
case .active:
record(.scenePhaseChanged(.active))
chatViewModel.meshService.startServices()
TorManager.shared.setAppForeground(true)
let shouldRefreshNostrConnections = didHandleInitialActive && didEnterBackground
if didHandleInitialActive && didEnterBackground {
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
TorManager.shared.ensureRunningOnForeground()
}
} else {
didHandleInitialActive = true
}
didEnterBackground = false
if shouldRefreshNostrConnections && TorManager.shared.isAutoStartAllowed() {
Task.detached {
let _ = await TorManager.shared.awaitReady(timeout: 60)
await MainActor.run {
TorURLSession.shared.rebuild()
NostrRelayManager.shared.resetAllConnections()
}
}
}
chatViewModel.handleDidBecomeActive()
checkForSharedContent()
case .inactive:
record(.scenePhaseChanged(.inactive))
@unknown default:
break
}
}
#endif
func applicationWillTerminate() {
record(.terminationRequested)
chatViewModel.applicationWillTerminate()
}
func handleNotificationResponse(identifier: String, userInfo: [AnyHashable: Any]) {
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
record(.notificationOpened(peerID: peerID))
chatViewModel.startPrivateChat(with: peerID)
}
if let deepLink = userInfo["deeplink"] as? String, let url = URL(string: deepLink) {
record(.deepLinkOpened(deepLink))
openExternalURL(url)
}
}
func presentationOptions(
forNotificationIdentifier identifier: String,
userInfo: [AnyHashable: Any]
) async -> UNNotificationPresentationOptions {
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
if conversations.selectedPrivatePeerID == peerID {
return []
}
return [.banner, .sound]
}
if identifier.hasPrefix("geo-activity-"),
let deepLink = userInfo["deeplink"] as? String,
let geohash = deepLink.components(separatedBy: "/").last,
case .location(let channel) = locationChannelsModel.selectedChannel,
channel.geohash == geohash {
return []
}
return [.banner, .sound]
}
}
private extension AppRuntime {
func bindRuntimeObservers() {
NostrRelayManager.shared.$isConnected
.receive(on: DispatchQueue.main)
.sink { [weak self] isConnected in
self?.handleNostrRelayConnectionChanged(isConnected)
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .TorWillRestart)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.record(.torLifecycleChanged(.willRestart))
self?.chatViewModel.handleTorWillRestart()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.record(.torLifecycleChanged(.didBecomeReady))
self?.chatViewModel.handleTorDidBecomeReady()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .TorWillStart)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.record(.torLifecycleChanged(.willStart))
self?.chatViewModel.handleTorWillStart()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
.receive(on: DispatchQueue.main)
.sink { [weak self] notification in
self?.record(.torLifecycleChanged(.preferenceChanged))
self?.chatViewModel.handleTorPreferenceChanged(notification)
}
.store(in: &cancellables)
#if os(iOS)
NotificationCenter.default.publisher(for: UIApplication.userDidTakeScreenshotNotification)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.handleScreenshotCaptured()
}
.store(in: &cancellables)
#endif
}
func checkForSharedContent() {
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID),
let sharedContent = userDefaults.string(forKey: "sharedContent"),
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
return
}
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
return
}
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate")
switch contentKind {
case .url:
if let data = sharedContent.data(using: .utf8),
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
let url = urlData["url"] {
chatViewModel.sendMessage(url)
} else {
chatViewModel.sendMessage(sharedContent)
}
case .text:
chatViewModel.sendMessage(sharedContent)
}
record(.sharedContentAccepted(contentKind))
}
func handleNostrRelayConnectionChanged(_ isConnected: Bool) {
record(.nostrRelayConnectionChanged(isConnected))
let becameConnected = isConnected && !lastNostrRelayConnectedState
lastNostrRelayConnectedState = isConnected
guard started, becameConnected else { return }
let isInitialConnection = !didHandleInitialNostrConnection
didHandleInitialNostrConnection = true
if !chatViewModel.nostrHandlersSetup {
chatViewModel.setupNostrMessageHandling()
chatViewModel.nostrHandlersSetup = true
}
guard !isInitialConnection else { return }
chatViewModel.resubscribeCurrentGeohash()
chatViewModel.geoChannelCoordinator?.refreshSampling()
}
func announceInitialTorStatusIfNeeded() {
if TorManager.shared.torEnforced &&
!chatViewModel.torStatusAnnounced &&
TorManager.shared.isAutoStartAllowed() {
chatViewModel.torStatusAnnounced = true
chatViewModel.addGeohashOnlySystemMessage(
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
)
} else if !TorManager.shared.torEnforced && !chatViewModel.torStatusAnnounced {
chatViewModel.torStatusAnnounced = true
chatViewModel.addGeohashOnlySystemMessage(
String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development")
)
}
}
func handleScreenshotCaptured() {
if appChromeModel.isLocationChannelsSheetPresented {
appChromeModel.triggerScreenshotPrivacyWarning()
return
}
if appChromeModel.isAppInfoPresented {
return
}
chatViewModel.handleScreenshotCaptured()
}
func openExternalURL(_ url: URL) {
#if os(iOS)
UIApplication.shared.open(url)
#else
NSWorkspace.shared.open(url)
#endif
}
func record(_ event: AppEvent) {
Task {
await events.emit(event)
}
}
}
-918
View File
@@ -1,918 +0,0 @@
//
// ConversationStore.swift
// bitchat
//
// Single source of truth for conversation message state (see
// docs/CONVERSATION-STORE-DESIGN.md). One `Conversation` object per
// `ConversationID`; all mutations flow through the store's intent API and
// every mutation emits a `ConversationChange` after state is consistent.
//
// The store also owns conversation selection: the active public channel and
// the selected private peer (the two UI selection axes) plus the derived
// `selectedConversationID`.
//
// 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
// MARK: - Conversation
/// A single conversation timeline (`.mesh`, `.geohash`, or `.direct`).
///
/// Publishing granularity is per conversation: views observe ONE
/// `Conversation` object, so an append to chat A never invalidates observers
/// of chat B.
///
/// Mutations are `fileprivate` by design only `ConversationStore`'s intent
/// API may mutate a conversation, keeping the store the sole writer.
@MainActor
final class Conversation: ObservableObject, Identifiable {
let id: ConversationID
/// Maximum retained messages; oldest are trimmed on overflow.
let cap: Int
@Published private(set) var messages: [BitchatMessage] = []
@Published private(set) var isUnread: Bool = false
/// Incrementally-maintained message-ID index map for O(1) dedup and
/// delivery-status lookup. Kept in sync on every mutation:
/// - tail append: single insert
/// - out-of-order insert: suffix reindex from the insertion point
/// - trim: full rebuild `removeFirst(k)` is already O(n), so the
/// rebuild does not change the asymptotics, and trim only happens once
/// the cap (1337) is reached. Simple and correct beats the
/// offset-tracking alternative here.
private var indexByMessageID: [String: Int] = [:]
fileprivate init(id: ConversationID, cap: Int) {
self.id = id
self.cap = max(1, cap)
}
// MARK: Reads
func containsMessage(withID messageID: String) -> Bool {
indexByMessageID[messageID] != nil
}
func message(withID messageID: String) -> BitchatMessage? {
guard let index = indexByMessageID[messageID] else { return nil }
return messages[index]
}
/// All message IDs currently in this conversation (unordered).
var messageIDs: Dictionary<String, Int>.Keys {
indexByMessageID.keys
}
// MARK: Store-internal mutations
/// Result of an ordered insert. `trimmedMessageIDs` reports messages
/// evicted by the cap so the store can keep its message-ID
/// conversation map exact.
fileprivate struct InsertResult {
let inserted: Bool
let trimmedMessageIDs: [String]
static let duplicate = InsertResult(inserted: false, trimmedMessageIDs: [])
}
fileprivate enum UpsertOutcome {
case appended(trimmedMessageIDs: [String])
case updated
}
/// Inserts a message in timestamp order, deduplicating by message ID.
/// Fast path appends when the timestamp is >= the current tail;
/// otherwise a binary search finds the upper-bound insertion point so
/// arrival order is preserved among equal timestamps.
/// Reports `inserted: false` if a message with the same ID already exists.
fileprivate func insert(_ message: BitchatMessage) -> InsertResult {
guard indexByMessageID[message.id] == nil else { return .duplicate }
if let last = messages.last, message.timestamp < last.timestamp {
let index = insertionIndex(for: message.timestamp)
messages.insert(message, at: index)
reindex(from: index)
} else {
messages.append(message)
indexByMessageID[message.id] = messages.count - 1
}
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
}
/// Replace-or-append by message ID. An existing message keeps its
/// timeline position (in-place updates like media progress reuse the
/// original timestamp); a new message goes through ordered insertion.
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
if let index = indexByMessageID[message.id] {
messages[index] = message
return .updated
}
let result = insert(message)
return .appended(trimmedMessageIDs: result.trimmedMessageIDs)
}
/// Applies a delivery status keyed by message ID, honoring the
/// no-downgrade rule (the SOLE enforcement point every delivery
/// update flows through the store): equal statuses are skipped, and
/// `.read` is never downgraded to `.delivered` or `.sent`.
/// Returns `true` when the status was applied.
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
guard let index = indexByMessageID[messageID] else { return false }
let message = messages[index]
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
message.deliveryStatus = status
// BitchatMessage is a reference type; write back through the
// subscript so the @Published wrapper emits.
messages[index] = message
return true
}
/// Republishes a message without changing state. Used for mirrored
/// copies that share a BitchatMessage instance: the first conversation's
/// status apply mutated the shared object, so this conversation's
/// observers still need an @Published emission to re-render.
@discardableResult
fileprivate func republishMessage(withID messageID: String) -> Bool {
guard let index = indexByMessageID[messageID] else { return false }
messages[index] = messages[index]
return true
}
@discardableResult
fileprivate func setUnread(_ unread: Bool) -> Bool {
guard isUnread != unread else { return false }
isUnread = unread
return true
}
/// Removes a single message by ID. Returns the removed message, or
/// `nil` when no message with that ID exists.
fileprivate func remove(messageID: String) -> BitchatMessage? {
guard let index = indexByMessageID[messageID] else { return nil }
let removed = messages.remove(at: index)
indexByMessageID.removeValue(forKey: messageID)
reindex(from: index)
return removed
}
/// Removes every message matching `predicate`. Returns the removed
/// message IDs (empty when nothing matched).
fileprivate func removeAll(where predicate: (BitchatMessage) -> Bool) -> [String] {
var removedIDs: [String] = []
messages.removeAll { message in
guard predicate(message) else { return false }
removedIDs.append(message.id)
return true
}
guard !removedIDs.isEmpty else { return [] }
for id in removedIDs {
indexByMessageID.removeValue(forKey: id)
}
reindex(from: 0)
return removedIDs
}
fileprivate func clearMessages() {
messages.removeAll()
indexByMessageID.removeAll()
}
// MARK: Diagnostics
/// Appends human-readable invariant violations for this conversation
/// (empty when healthy): the ID index must be the exact inverse of the
/// messages array, the cap must hold, and timestamps must be
/// non-decreasing (equal timestamps keep arrival order, so only strict
/// inversions are violations). O(messages); allocates only on violation.
fileprivate func collectInvariantViolations(into violations: inout [String], label: String) {
if indexByMessageID.count != messages.count {
violations.append("\(label): index has \(indexByMessageID.count) entries for \(messages.count) messages")
}
if messages.count > cap {
violations.append("\(label): \(messages.count) messages exceeds cap \(cap)")
}
var previousTimestamp: Date?
for position in messages.indices {
let message = messages[position]
// Count equality + every message resolving to its own position
// proves the index is exactly the inverse map (no stale extras).
if let index = indexByMessageID[message.id] {
if index != position {
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(index)")
}
} else {
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
}
if let previousTimestamp, message.timestamp < previousTimestamp {
violations.append("\(label): timestamp order violated at \(position)")
}
previousTimestamp = message.timestamp
}
}
// MARK: Internals
static func shouldSkipStatusUpdate(current: DeliveryStatus?, new: DeliveryStatus) -> Bool {
guard let current else { return false }
if current == new { return true }
switch (current, new) {
case (.read, .delivered), (.read, .sent):
return true
default:
return false
}
}
/// Upper-bound binary search: first index whose timestamp is strictly
/// greater than `timestamp`, so equal-timestamp messages keep arrival
/// order.
private func insertionIndex(for timestamp: Date) -> Int {
var low = 0
var high = messages.count
while low < high {
let mid = (low + high) / 2
if messages[mid].timestamp <= timestamp {
low = mid + 1
} else {
high = mid
}
}
return low
}
private func reindex(from start: Int) {
for index in start..<messages.count {
indexByMessageID[messages[index].id] = index
}
}
/// Trims oldest messages over the cap; returns the trimmed message IDs.
private func trimIfNeeded() -> [String] {
guard messages.count > cap else { return [] }
let overflow = messages.count - cap
let trimmedIDs = messages.prefix(overflow).map(\.id)
for id in trimmedIDs {
indexByMessageID.removeValue(forKey: id)
}
messages.removeFirst(overflow)
reindex(from: 0)
return trimmedIDs
}
}
// MARK: - ConversationChange
/// Typed mutation events for non-UI consumers (delivery tracking,
/// notifications, sync) that need "something changed in conversation X"
/// without subscribing to whole message arrays. Emitted on the store's
/// `changes` subject AFTER the corresponding state is consistent.
enum ConversationChange {
case appended(ConversationID, BitchatMessage)
case updated(ConversationID, messageID: String)
case statusChanged(ConversationID, messageID: String, DeliveryStatus)
case messageRemoved(ConversationID, messageID: String)
case cleared(ConversationID)
case removed(ConversationID)
case migrated(from: ConversationID, to: ConversationID)
case unreadChanged(ConversationID, isUnread: Bool)
}
// MARK: - ConversationStore
/// Sole writer and sole holder of conversation message state. All mutations
/// go through the intent API below; backing collections are `private(set)`.
/// Reads are synchronous writers and readers share the main actor, so
/// after an intent returns every observer sees the result.
@MainActor
final class ConversationStore: ObservableObject {
/// Conversation creation order; published so list-style consumers can
/// observe conversations appearing/disappearing without rebuilding from
/// the dictionary.
@Published private(set) var conversationIDs: [ConversationID] = []
@Published private(set) var selectedConversationID: ConversationID?
@Published private(set) var unreadConversations: Set<ConversationID> = []
// MARK: Selection state
// The two UI selection axes: which public channel is active, and which
// private chat (if any) is open on top of it. `selectedConversationID`
// is derived: the open private chat wins, otherwise the active public
// channel's conversation. Mutate via `setActiveChannel` /
// `setSelectedPrivatePeer` only.
@Published private(set) var activeChannel: ChannelID = .mesh
@Published private(set) var selectedPrivatePeerID: PeerID?
private(set) var conversationsByID: [ConversationID: Conversation] = [:]
/// Store-level message-ID conversation-membership map for ID-only
/// lookups (delivery receipts arrive with a message ID, not a
/// conversation). Maintained incrementally at every mutation point
/// all mutation is centralized in the intent API below, so the map is
/// exact, never scanned or rebuilt.
///
/// The value is a `Set` because a private message can legitimately live
/// in TWO direct conversations: step 2's raw per-peer keying mirrors a
/// message into both the stable-key and ephemeral-peer chats
/// (`mirrorToEphemeralIfNeeded`). A delivery update must reach both
/// copies.
private var conversationIDsByMessageID: [String: Set<ConversationID>] = [:]
/// Monotonic count of messages inserted into any conversation (appends,
/// upsert-appends, migration inserts). Field-observability only: the
/// periodic store audit folds the delta into its heartbeat line so logs
/// carry throughput context. Never read on a hot path.
private(set) var appendCount: Int = 0
/// Sample counter for the mirrored-republish debug log in the ID-only
/// `setDeliveryStatus` fan-out (first + every Nth occurrence).
private var mirroredRepublishLogCount = 0
let changes = PassthroughSubject<ConversationChange, Never>()
// MARK: Intent API
/// Returns the conversation for `id`, creating it (with the cap policy
/// for its kind) on first access.
@discardableResult
func conversation(for id: ConversationID) -> Conversation {
if let existing = conversationsByID[id] {
return existing
}
let conversation = Conversation(id: id, cap: Self.cap(for: id))
conversationsByID[id] = conversation
conversationIDs.append(id)
return conversation
}
/// Appends a message in timestamp order. Returns `false` (and emits
/// nothing) if a message with the same ID is already present.
@discardableResult
func append(_ message: BitchatMessage, to id: ConversationID) -> Bool {
let conversation = conversation(for: id)
let result = conversation.insert(message)
guard result.inserted else { return false }
registerMessageID(message.id, in: id)
unregisterMessageIDs(result.trimmedMessageIDs, from: id)
changes.send(.appended(id, message))
return true
}
/// Replace-or-append by message ID (media progress, edits).
func upsertByID(_ message: BitchatMessage, in id: ConversationID) {
let conversation = conversation(for: id)
switch conversation.upsert(message) {
case .appended(let trimmedMessageIDs):
registerMessageID(message.id, in: id)
unregisterMessageIDs(trimmedMessageIDs, from: id)
changes.send(.appended(id, message))
case .updated:
changes.send(.updated(id, messageID: message.id))
}
}
/// Applies a delivery status keyed by message ID. Returns `false` when
/// the message is unknown or the update would downgrade the status
/// (read beats delivered beats sent).
@discardableResult
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, in id: ConversationID) -> Bool {
guard let conversation = conversationsByID[id],
conversation.applyDeliveryStatus(status, forMessageID: messageID) else {
return false
}
changes.send(.statusChanged(id, messageID: messageID, status))
return true
}
/// Applies a delivery status to EVERY conversation containing
/// `messageID` (ID-only delivery receipts don't know conversations;
/// mirrored private copies live in two direct chats). Returns `false`
/// when the message is unknown or no copy changed (equal status or
/// downgrade read beats delivered beats sent).
///
/// `BitchatMessage` is a reference type, so mirrored copies sharing one
/// instance are mutated by the first conversation's apply. The skipped
/// conversations still hold the changed message, so they get an explicit
/// republish and `.statusChanged` event - otherwise a view observing the
/// mirrored conversation would render stale status. Distinct copies whose
/// update was genuinely rejected (downgrade) are left untouched, guarded
/// by status equality.
@discardableResult
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
guard let ids = conversationIDsByMessageID[messageID] else { return false }
var applied = false
var skipped: [ConversationID] = []
for id in ids {
if setDeliveryStatus(status, forMessageID: messageID, in: id) {
applied = true
} else {
skipped.append(id)
}
}
guard applied else { return false }
for id in skipped {
guard let conversation = conversationsByID[id],
conversation.message(withID: messageID)?.deliveryStatus == status,
conversation.republishMessage(withID: messageID) else { continue }
// Field proof the mirrored-copy republish path actually fires;
// sampled (first + every Nth) so mirrored chats can't spam logs.
mirroredRepublishLogCount += 1
if mirroredRepublishLogCount == 1
|| mirroredRepublishLogCount.isMultiple(of: TransportConfig.conversationStoreMirroredRepublishLogInterval) {
SecureLogger.debug(
"mirrored republish #\(mirroredRepublishLogCount) for \(messageID.prefix(8))… in \(id.auditDescription)",
category: .session
)
}
changes.send(.statusChanged(id, messageID: messageID, status))
}
return true
}
/// Current delivery status of `messageID` in whichever conversation
/// holds it (mirrored copies share status see `setDeliveryStatus`).
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
guard let ids = conversationIDsByMessageID[messageID] else { return nil }
for id in ids {
if let status = conversationsByID[id]?.message(withID: messageID)?.deliveryStatus {
return status
}
}
return nil
}
/// Every conversation currently containing `messageID` (empty when the
/// message is unknown).
func conversationIDs(forMessageID messageID: String) -> Set<ConversationID> {
conversationIDsByMessageID[messageID] ?? []
}
func markRead(_ id: ConversationID) {
guard unreadConversations.contains(id) else { return }
unreadConversations.remove(id)
conversationsByID[id]?.setUnread(false)
changes.send(.unreadChanged(id, isUnread: false))
}
func markUnread(_ id: ConversationID) {
guard !unreadConversations.contains(id) else { return }
let conversation = conversation(for: id)
unreadConversations.insert(id)
conversation.setUnread(true)
changes.send(.unreadChanged(id, isUnread: true))
}
/// Selects a conversation (creating it if needed) or clears the
/// selection with `nil`.
func select(_ id: ConversationID?) {
if let id {
conversation(for: id)
}
guard selectedConversationID != id else { return }
selectedConversationID = id
}
/// Switches the active public channel. While no private chat is open
/// the selection follows the channel.
func setActiveChannel(_ channelID: ChannelID) {
if activeChannel != channelID {
activeChannel = channelID
}
refreshDerivedSelection()
}
/// Opens a private chat (`nil` closes it, returning the selection to the
/// active public channel's conversation).
func setSelectedPrivatePeer(_ peerID: PeerID?) {
if selectedPrivatePeerID != peerID {
selectedPrivatePeerID = peerID
}
refreshDerivedSelection()
}
private func refreshDerivedSelection() {
if let peerID = selectedPrivatePeerID {
select(.directPeer(peerID))
} else {
select(ConversationID(channelID: activeChannel))
}
}
/// Moves all messages from `source` into `destination` (the
/// ephemeralstable peer-ID handoff): dedups by message ID, preserves
/// timestamp order, carries unread state over, and hands off the
/// selection mirroring `ChatPrivateConversationCoordinator`'s
/// migration semantics. The source conversation is removed. Emits a
/// single `.migrated(from:to:)` once the whole move is consistent.
func migrateConversation(from source: ConversationID, to destination: ConversationID) {
guard source != destination, let sourceConversation = conversationsByID[source] else { return }
let destinationConversation = conversation(for: destination)
for message in sourceConversation.messages {
let result = destinationConversation.insert(message)
guard result.inserted else { continue }
registerMessageID(message.id, in: destination)
unregisterMessageIDs(result.trimmedMessageIDs, from: destination)
}
for messageID in sourceConversation.messageIDs {
unregisterMessageID(messageID, from: source)
}
let wasUnread = unreadConversations.contains(source)
let wasSelected = selectedConversationID == source
conversationsByID.removeValue(forKey: source)
conversationIDs.removeAll { $0 == source }
unreadConversations.remove(source)
if wasUnread, !unreadConversations.contains(destination) {
unreadConversations.insert(destination)
destinationConversation.setUnread(true)
}
if wasSelected {
selectedConversationID = destination
// Keep the private-peer selection axis consistent with the
// handed-off selection.
if let peerID = selectedPrivatePeerID,
source == .directPeer(peerID),
case .direct(let destinationHandle) = destination {
selectedPrivatePeerID = destinationHandle.routingPeerID
}
}
changes.send(.migrated(from: source, to: destination))
}
/// Removes a single message by ID from a conversation. Returns the
/// removed message, or `nil` (emitting nothing) when the conversation or
/// message is unknown.
@discardableResult
func removeMessage(withID messageID: String, from id: ConversationID) -> BitchatMessage? {
guard let conversation = conversationsByID[id],
let removed = conversation.remove(messageID: messageID) else {
return nil
}
unregisterMessageID(messageID, from: id)
changes.send(.messageRemoved(id, messageID: messageID))
return removed
}
/// Removes every message matching `predicate` from a conversation,
/// emitting one `.messageRemoved` per removed message after the
/// conversation is consistent. No-op for unknown conversations.
func removeMessages(from id: ConversationID, where predicate: (BitchatMessage) -> Bool) {
guard let conversation = conversationsByID[id] else { return }
let removedIDs = conversation.removeAll(where: predicate)
unregisterMessageIDs(removedIDs, from: id)
for messageID in removedIDs {
changes.send(.messageRemoved(id, messageID: messageID))
}
}
/// Empties a conversation's timeline but keeps the conversation (and
/// its unread/selection state) alive.
func clear(_ id: ConversationID) {
guard let conversation = conversationsByID[id] else { return }
for messageID in conversation.messageIDs {
unregisterMessageID(messageID, from: id)
}
conversation.clearMessages()
changes.send(.cleared(id))
}
/// Removes a conversation entirely, including unread state; clears the
/// selection if it pointed at the removed conversation.
func removeConversation(_ id: ConversationID) {
guard let conversation = conversationsByID.removeValue(forKey: id) else { return }
for messageID in conversation.messageIDs {
unregisterMessageID(messageID, from: id)
}
conversationIDs.removeAll { $0 == id }
unreadConversations.remove(id)
if selectedConversationID == id {
selectedConversationID = nil
}
changes.send(.removed(id))
}
func clearAll() {
let removedIDs = conversationIDs
guard !removedIDs.isEmpty || selectedConversationID != nil else { return }
conversationsByID.removeAll()
conversationIDs.removeAll()
unreadConversations.removeAll()
conversationIDsByMessageID.removeAll()
if selectedConversationID != nil {
selectedConversationID = nil
}
for id in removedIDs {
changes.send(.removed(id))
}
}
// MARK: Diagnostics
/// Total messages across all conversations. O(#conversations) heartbeat
/// logging only, never a hot path.
var totalMessageCount: Int {
conversationsByID.values.reduce(0) { $0 + $1.messages.count }
}
/// Number of distinct message IDs in the store-level membership map.
var messageIDMapCount: Int {
conversationIDsByMessageID.count
}
/// Verifies the store's correctness invariants and returns human-readable
/// violations (empty = healthy). Intended for a periodic field audit:
/// O(total messages) and allocation-free while healthy. Checks:
/// - the `conversationIDs` ordering array matches `conversationsByID`
/// - per conversation: ID index exact, cap held, timestamp order
/// (see `Conversation.collectInvariantViolations`)
/// - the message-ID conversation map matches reality exactly: every
/// mapped membership points at a live conversation actually holding
/// the message, and total memberships equal total messages (with the
/// forward check, equality proves no conversation message is missing
/// from the map)
/// - `unreadConversations` only references existing conversations
/// - `selectedConversationID`, when set, references an existing
/// conversation (`select(_:)` creates on selection and
/// `removeConversation`/`clearAll` clear it, so existence is the
/// invariant for both the channel-derived and direct-peer cases)
func auditInvariants() -> [String] {
var violations: [String] = []
if conversationIDs.count != conversationsByID.count {
violations.append("conversationIDs lists \(conversationIDs.count) conversations but dictionary holds \(conversationsByID.count)")
}
for id in conversationIDs where conversationsByID[id] == nil {
violations.append("conversationIDs lists \(id.auditDescription) but no conversation exists")
}
var totalMessages = 0
for (id, conversation) in conversationsByID {
totalMessages += conversation.messages.count
conversation.collectInvariantViolations(into: &violations, label: id.auditDescription)
}
var totalMappedMemberships = 0
for (messageID, ids) in conversationIDsByMessageID {
totalMappedMemberships += ids.count
if ids.isEmpty {
violations.append("message map: \(messageID.prefix(8))… has an empty membership set")
}
for id in ids {
guard let conversation = conversationsByID[id] else {
violations.append("message map: \(messageID.prefix(8))… claims unknown conversation \(id.auditDescription)")
continue
}
if !conversation.containsMessage(withID: messageID) {
violations.append("message map: \(messageID.prefix(8))… not present in claimed conversation \(id.auditDescription)")
}
}
}
if totalMappedMemberships != totalMessages {
violations.append("message map holds \(totalMappedMemberships) memberships but conversations hold \(totalMessages) messages")
}
for id in unreadConversations where conversationsByID[id] == nil {
violations.append("unreadConversations contains unknown conversation \(id.auditDescription)")
}
if let selected = selectedConversationID, conversationsByID[selected] == nil {
violations.append("selectedConversationID \(selected.auditDescription) has no conversation")
}
return violations
}
// MARK: Internals
private func registerMessageID(_ messageID: String, in id: ConversationID) {
conversationIDsByMessageID[messageID, default: []].insert(id)
// Single choke point for every successful insertion (append, upsert
// append, migration insert) the audit heartbeat's throughput delta.
appendCount += 1
}
private func unregisterMessageID(_ messageID: String, from id: ConversationID) {
guard var ids = conversationIDsByMessageID[messageID] else { return }
ids.remove(id)
if ids.isEmpty {
conversationIDsByMessageID.removeValue(forKey: messageID)
} else {
conversationIDsByMessageID[messageID] = ids
}
}
private func unregisterMessageIDs(_ messageIDs: [String], from id: ConversationID) {
for messageID in messageIDs {
unregisterMessageID(messageID, from: id)
}
}
private static func cap(for id: ConversationID) -> Int {
switch id {
case .mesh:
return TransportConfig.meshTimelineCap
case .geohash:
return TransportConfig.geoTimelineCap
case .direct:
return TransportConfig.privateChatCap
}
}
}
// MARK: - Direct-conversation keying + derived views
extension ConversationID {
/// Direct-conversation ID keyed by the *raw* routing peer ID.
///
/// Direct conversations are deliberately keyed per `PeerID`, not per
/// resolved identity: the private-chat coordinators mirror messages into
/// both the ephemeral and stable peer's conversations
/// (`mirrorToEphemeralIfNeeded`) and consolidate/migrate between them
/// explicitly, so a raw lookup by whichever peer ID is selected always
/// finds the right timeline without an identity-resolution layer.
static func directPeer(_ peerID: PeerID) -> ConversationID {
.direct(PeerHandle(id: "peer:\(peerID.id)", routingPeerID: peerID))
}
}
extension ConversationStore {
/// All direct conversations' messages keyed by routing peer ID the
/// shape `ChatViewModel.privateChats` exposes to the coordinators.
/// Values are the conversations' backing arrays (COW), so building this
/// is O(#conversations), not O(#messages).
func directMessagesByRoutingPeerID() -> [PeerID: [BitchatMessage]] {
var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
messagesByPeerID.reserveCapacity(conversationsByID.count)
for (id, conversation) in conversationsByID {
guard case .direct(let handle) = id else { continue }
messagesByPeerID[handle.routingPeerID] = conversation.messages
}
return messagesByPeerID
}
/// Unread direct conversations as routing peer IDs the shape
/// `ChatViewModel.unreadPrivateMessages` exposes to the coordinators.
func unreadDirectRoutingPeerIDs() -> Set<PeerID> {
var peerIDs = Set<PeerID>()
for id in unreadConversations {
guard case .direct(let handle) = id else { continue }
peerIDs.insert(handle.routingPeerID)
}
return peerIDs
}
/// `true` when any direct conversation contains a message with `messageID`
/// (O(1) via the store-level message-ID conversation map).
func directConversationsContainMessage(withID messageID: String) -> Bool {
conversationIDs(forMessageID: messageID).contains { id in
if case .direct = id { return true }
return false
}
}
/// Message IDs across all direct conversations (read-receipt pruning
/// keeps only receipts whose messages still exist).
func directMessageIDs() -> Set<String> {
var messageIDs = Set<String>()
for (id, conversation) in conversationsByID {
guard case .direct = id else { continue }
messageIDs.formUnion(conversation.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
extension ConversationID {
/// Short, log-safe description for audit/diagnostic lines. Direct
/// conversations truncate the handle so full peer keys never hit logs.
fileprivate var auditDescription: String {
switch self {
case .mesh:
return "mesh"
case .geohash(let geohash):
return "geo:\(geohash)"
case .direct(let handle):
return "direct:\(handle.id.prefix(13))"
}
}
}
#if DEBUG
// Test-only corruption hooks for `auditInvariants()` tests. The store is the
// sole writer by design `Conversation`'s mutators are fileprivate and the
// store's backing collections are private so the inconsistent states the
// audit exists to catch CANNOT be manufactured through the intent API. These
// DEBUG-only hooks deliberately bypass that lockdown to inject exactly those
// impossible states. Never call them outside tests.
extension Conversation {
/// Points an existing message's index entry at the wrong position
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
func _testCorruptIndexEntries() {
guard messages.count >= 2 else { return }
indexByMessageID[messages[0].id] = 1
indexByMessageID[messages[1].id] = 0
}
/// Drops a message's index entry entirely (count mismatch + missing).
func _testRemoveIndexEntry(forMessageID messageID: String) {
indexByMessageID.removeValue(forKey: messageID)
}
/// Swaps the first and last messages while keeping the index consistent,
/// so ONLY the timestamp-order invariant is violated (requires the two
/// messages to have distinct timestamps).
func _testCorruptOrderingPreservingIndex() {
guard messages.count >= 2 else { return }
messages.swapAt(0, messages.count - 1)
indexByMessageID[messages[0].id] = 0
indexByMessageID[messages[messages.count - 1].id] = messages.count - 1
}
}
extension ConversationStore {
/// Adds a map membership that the conversation does not actually hold.
func _testRegisterPhantomMessageID(_ messageID: String, in id: ConversationID) {
conversationIDsByMessageID[messageID, default: []].insert(id)
}
/// Drops a real map membership (conversation message missing from map).
func _testUnregisterMessageID(_ messageID: String, from id: ConversationID) {
conversationIDsByMessageID[messageID]?.remove(id)
if conversationIDsByMessageID[messageID]?.isEmpty == true {
conversationIDsByMessageID.removeValue(forKey: messageID)
}
}
/// Appends past the conversation cap, bypassing trim (map kept exact so
/// only the cap invariant is violated).
func _testAppendBypassingCap(_ message: BitchatMessage, to id: ConversationID) {
let conversation = conversation(for: id)
conversation._testAppendBypassingTrim(message)
conversationIDsByMessageID[message.id, default: []].insert(id)
}
/// Marks a nonexistent conversation unread without creating it.
func _testInsertUnreadConversationID(_ id: ConversationID) {
unreadConversations.insert(id)
}
/// Sets the selection directly, without `select(_:)`'s create-on-select.
func _testSetSelectedConversationID(_ id: ConversationID?) {
selectedConversationID = id
}
}
extension Conversation {
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
messages.append(message)
indexByMessageID[message.id] = messages.count - 1
}
}
#endif
// MARK: - Public timeline derived views
extension ConversationStore {
/// Removes a message by ID from whichever public (mesh/geohash)
/// conversation contains it. Returns the removed message, if any.
@discardableResult
func removePublicMessage(withID messageID: String) -> BitchatMessage? {
for id in conversationIDs(forMessageID: messageID) {
switch id {
case .mesh, .geohash:
return removeMessage(withID: messageID, from: id)
case .direct:
continue
}
}
return nil
}
}
-207
View File
@@ -1,207 +0,0 @@
import BitFoundation
import Combine
import SwiftUI
#if os(iOS)
import UIKit
#endif
@MainActor
final class ConversationUIModel: ObservableObject {
@Published private(set) var showAutocomplete = false
@Published private(set) var autocompleteSuggestions: [String] = []
@Published private(set) var currentNickname: String
@Published private(set) var isBatchingPublic = false
@Published private(set) var canSendMediaInCurrentContext = true
private let chatViewModel: ChatViewModel
private let privateConversationModel: PrivateConversationModel
private let conversations: ConversationStore
private var activeChannel: ChannelID
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
privateConversationModel: PrivateConversationModel,
conversations: ConversationStore
) {
self.chatViewModel = chatViewModel
self.privateConversationModel = privateConversationModel
self.conversations = conversations
self.activeChannel = conversations.activeChannel
self.currentNickname = chatViewModel.nickname
self.isBatchingPublic = chatViewModel.isBatchingPublic
self.showAutocomplete = chatViewModel.showAutocomplete
self.autocompleteSuggestions = chatViewModel.autocompleteSuggestions
self.canSendMediaInCurrentContext = chatViewModel.canSendMediaInCurrentContext
bind()
}
func setCurrentColorScheme(_ colorScheme: ColorScheme) {
chatViewModel.currentColorScheme = colorScheme
}
func setCurrentTheme(_ theme: AppTheme) {
chatViewModel.currentTheme = theme
}
func sendMessage(_ message: String) {
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() {
chatViewModel.sendMessage("/clear")
}
func sendHug(to sender: String) {
chatViewModel.sendMessage("/hug @\(sender)")
}
func sendSlap(to sender: String) {
chatViewModel.sendMessage("/slap @\(sender)")
}
func block(peerID: PeerID?, displayName: String?) {
guard let displayName else { return }
if let peerID, peerID.isGeoChat,
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
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 {
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) {
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
}
func completeNickname(_ nickname: String, in text: inout String) -> Int {
chatViewModel.completeNickname(nickname, in: &text)
}
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme, theme: theme)
}
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
chatViewModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme)
}
func mediaAttachment(for message: BitchatMessage) -> BitchatMessage.Media? {
message.mediaAttachment(for: currentNickname)
}
func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool {
chatViewModel.isSelfSender(peerID: peerID, displayName: displayName)
}
func isSentByCurrentUser(_ message: BitchatMessage) -> Bool {
message.sender == currentNickname || message.sender.hasPrefix(currentNickname + "#")
}
func isMediaMessageFromCurrentUser(_ message: BitchatMessage) -> Bool {
message.sender == currentNickname || message.senderPeerID == chatViewModel.meshService.myPeerID
}
func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? {
if peerID.isGeoDM || peerID.isGeoChat {
return chatViewModel.geohashDisplayName(for: peerID)
}
if let nickname = chatViewModel.meshService.peerNickname(peerID: peerID) {
return nickname
}
return fallbackMessages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
}
#if os(iOS)
func processSelectedImage(_ image: UIImage?) {
chatViewModel.processThenSendImage(image)
}
#endif
func processSelectedImage(from url: URL?) {
#if os(macOS)
chatViewModel.processThenSendImage(from: url)
#endif
}
func sendVoiceNote(at url: URL) {
chatViewModel.sendVoiceNote(at: url)
}
func cancelMediaSend(messageID: String) {
chatViewModel.cancelMediaSend(messageID: messageID)
}
func deleteMediaMessage(messageID: String) {
chatViewModel.deleteMediaMessage(messageID: messageID)
}
private func bind() {
chatViewModel.$nickname
.receive(on: DispatchQueue.main)
.assign(to: &$currentNickname)
chatViewModel.$showAutocomplete
.receive(on: DispatchQueue.main)
.assign(to: &$showAutocomplete)
chatViewModel.$autocompleteSuggestions
.receive(on: DispatchQueue.main)
.assign(to: &$autocompleteSuggestions)
chatViewModel.$isBatchingPublic
.receive(on: DispatchQueue.main)
.assign(to: &$isBatchingPublic)
conversations.$activeChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
self?.activeChannel = channel
self?.refreshComputedState()
}
.store(in: &cancellables)
privateConversationModel.$selectedPeerID
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshComputedState()
}
.store(in: &cancellables)
}
private func refreshComputedState() {
if let selectedPeerID = privateConversationModel.selectedPeerID {
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat)
return
}
switch activeChannel {
case .mesh:
canSendMediaInCurrentContext = true
case .location:
canSendMediaInCurrentContext = false
}
}
}
-176
View File
@@ -1,176 +0,0 @@
import BitFoundation
import Combine
import Foundation
@MainActor
final class LocationChannelsModel: ObservableObject {
@Published private(set) var permissionState: LocationChannelManager.PermissionState
@Published private(set) var availableChannels: [GeohashChannel]
@Published private(set) var selectedChannel: ChannelID
@Published private(set) var teleported: Bool
@Published private(set) var bookmarks: [String]
@Published private(set) var bookmarkNames: [String: String]
@Published private(set) var locationNames: [GeohashChannelLevel: String]
@Published private(set) var userTorEnabled: Bool
private let manager: LocationChannelManager
private let network: NetworkActivationService
private var cancellables = Set<AnyCancellable>()
init(
manager: LocationChannelManager? = nil,
network: NetworkActivationService? = nil
) {
let manager = manager ?? .shared
let network = network ?? .shared
self.manager = manager
self.network = network
self.permissionState = manager.permissionState
self.availableChannels = manager.availableChannels
self.selectedChannel = manager.selectedChannel
self.teleported = manager.teleported
self.bookmarks = manager.bookmarks
self.bookmarkNames = manager.bookmarkNames
self.locationNames = manager.locationNames
self.userTorEnabled = network.userTorEnabled
bind()
}
var currentBuildingGeohash: String? {
availableChannels.first(where: { $0.level == .building })?.geohash
}
func isSelected(_ channel: GeohashChannel) -> Bool {
guard case .location(let selected) = selectedChannel else { return false }
return selected == channel
}
func isBookmarked(_ geohash: String) -> Bool {
manager.isBookmarked(geohash)
}
func enableLocationChannels() {
manager.enableLocationChannels()
}
func refreshChannels() {
manager.refreshChannels()
}
func enableAndRefresh() {
manager.enableLocationChannels()
manager.refreshChannels()
}
func beginLiveRefresh() {
manager.beginLiveRefresh()
}
func endLiveRefresh() {
manager.endLiveRefresh()
}
func select(_ channel: ChannelID) {
manager.select(channel)
}
func markTeleported(for geohash: String, _ flag: Bool) {
manager.markTeleported(for: geohash, flag)
}
func toggleBookmark(_ geohash: String) {
manager.toggleBookmark(geohash)
}
func resolveBookmarkNameIfNeeded(for geohash: String) {
manager.resolveBookmarkNameIfNeeded(for: geohash)
}
func locationName(for level: GeohashChannelLevel) -> String? {
locationNames[level]
}
func setUserTorEnabled(_ enabled: Bool) {
network.setUserTorEnabled(enabled)
}
func refreshMeshChannelsIfNeeded() {
guard case .mesh = selectedChannel,
permissionState == .authorized,
availableChannels.isEmpty else {
return
}
refreshChannels()
}
func openLocationChannel(for geohash: String) {
let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard (2...12).contains(normalized.count),
normalized.allSatisfy({ allowed.contains($0) }) else {
return
}
let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized)
let isRegional = availableChannels.contains { $0.geohash == normalized }
if !isRegional && !availableChannels.isEmpty {
markTeleported(for: normalized, true)
}
select(.location(channel))
}
func teleport(to geohash: String) {
let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized)
markTeleported(for: normalized, true)
select(.location(channel))
}
private func bind() {
manager.$permissionState
.receive(on: DispatchQueue.main)
.assign(to: &$permissionState)
manager.$availableChannels
.receive(on: DispatchQueue.main)
.assign(to: &$availableChannels)
manager.$selectedChannel
.receive(on: DispatchQueue.main)
.assign(to: &$selectedChannel)
manager.$teleported
.receive(on: DispatchQueue.main)
.assign(to: &$teleported)
manager.$bookmarks
.receive(on: DispatchQueue.main)
.assign(to: &$bookmarks)
manager.$bookmarkNames
.receive(on: DispatchQueue.main)
.assign(to: &$bookmarkNames)
manager.$locationNames
.receive(on: DispatchQueue.main)
.assign(to: &$locationNames)
network.$userTorEnabled
.receive(on: DispatchQueue.main)
.assign(to: &$userTorEnabled)
}
private func level(forLength length: Int) -> GeohashChannelLevel {
switch length {
case 0...2: return .region
case 3...4: return .province
case 5: return .city
case 6: return .neighborhood
case 7: return .block
case 8...12: return .building
default: return .block
}
}
}
-51
View File
@@ -1,51 +0,0 @@
import Combine
import Foundation
@MainActor
final class LocationPresenceStore: ObservableObject {
@Published private(set) var currentGeohash: String?
@Published private(set) var geoNicknames: [String: String] = [:]
@Published private(set) var teleportedGeo: Set<String> = []
func setCurrentGeohash(_ geohash: String?) {
currentGeohash = geohash?.lowercased()
}
func setNickname(_ nickname: String, for pubkeyHex: String) {
geoNicknames[pubkeyHex.lowercased()] = nickname
}
func replaceGeoNicknames(_ nicknames: [String: String]) {
geoNicknames = Dictionary(
uniqueKeysWithValues: nicknames.map { key, value in
(key.lowercased(), value)
}
)
}
func clearGeoNicknames() {
geoNicknames.removeAll()
}
func markTeleported(_ pubkeyHex: String) {
teleportedGeo.insert(pubkeyHex.lowercased())
}
func clearTeleported(_ pubkeyHex: String) {
teleportedGeo.remove(pubkeyHex.lowercased())
}
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
teleportedGeo = Set(pubkeys.map { $0.lowercased() })
}
func clearTeleportedGeo() {
teleportedGeo.removeAll()
}
func reset() {
currentGeohash = nil
geoNicknames.removeAll()
teleportedGeo.removeAll()
}
}
-125
View File
@@ -1,125 +0,0 @@
import BitFoundation
import Combine
import Foundation
@MainActor
final class PeerIdentityStore: ObservableObject {
@Published private(set) var encryptionStatuses: [PeerID: EncryptionStatus] = [:]
@Published private(set) var verifiedFingerprints: Set<String> = []
private(set) var peerFingerprintsByPeerID: [PeerID: String] = [:]
private(set) var selectedPrivateChatFingerprint: String?
private var stablePeerIDsByShortID: [PeerID: PeerID] = [:]
private var encryptionStatusCache: [PeerID: EncryptionStatus] = [:]
func stablePeerID(forShortID peerID: PeerID) -> PeerID? {
stablePeerIDsByShortID[peerID]
}
func shortPeerID(forStablePeerID stablePeerID: PeerID) -> PeerID? {
stablePeerIDsByShortID.first(where: { $0.value == stablePeerID })?.key
}
func setStablePeerID(_ stablePeerID: PeerID, forShortID peerID: PeerID) {
stablePeerIDsByShortID[peerID] = stablePeerID
}
func replaceStablePeerIDs(_ mappings: [PeerID: PeerID]) {
stablePeerIDsByShortID = mappings
}
func fingerprint(for peerID: PeerID) -> String? {
peerFingerprintsByPeerID[peerID]
}
func setFingerprint(_ fingerprint: String?, for peerID: PeerID) {
if let fingerprint {
peerFingerprintsByPeerID[peerID] = fingerprint
} else {
peerFingerprintsByPeerID.removeValue(forKey: peerID)
}
}
func replaceFingerprintMappings(_ mappings: [PeerID: String]) {
peerFingerprintsByPeerID = mappings
}
@discardableResult
func migrateFingerprintMapping(
from oldPeerID: PeerID,
to newPeerID: PeerID,
fallback: String? = nil
) -> String? {
let fingerprint = peerFingerprintsByPeerID.removeValue(forKey: oldPeerID) ?? fallback
if let fingerprint {
peerFingerprintsByPeerID[newPeerID] = fingerprint
if selectedPrivateChatFingerprint == nil {
selectedPrivateChatFingerprint = fingerprint
}
}
return fingerprint
}
func setSelectedPrivateChatFingerprint(_ fingerprint: String?) {
selectedPrivateChatFingerprint = fingerprint
}
func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
encryptionStatusCache[peerID]
}
func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) {
encryptionStatusCache[peerID] = status
}
func invalidateEncryptionCache(for peerID: PeerID? = nil) {
if let peerID {
encryptionStatusCache.removeValue(forKey: peerID)
} else {
encryptionStatusCache.removeAll()
}
}
func encryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
encryptionStatuses[peerID]
}
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) {
if let status {
encryptionStatuses[peerID] = status
} else {
encryptionStatuses.removeValue(forKey: peerID)
}
invalidateEncryptionCache(for: peerID)
}
func replaceEncryptionStatuses(_ statuses: [PeerID: EncryptionStatus]) {
encryptionStatuses = statuses
}
func setVerifiedFingerprints(_ fingerprints: Set<String>) {
verifiedFingerprints = fingerprints
}
func setVerified(_ fingerprint: String, verified: Bool) {
if verified {
verifiedFingerprints.insert(fingerprint)
} else {
verifiedFingerprints.remove(fingerprint)
}
}
func isVerified(_ fingerprint: String) -> Bool {
verifiedFingerprints.contains(fingerprint)
}
func clearAll() {
encryptionStatuses.removeAll()
verifiedFingerprints.removeAll()
peerFingerprintsByPeerID.removeAll()
selectedPrivateChatFingerprint = nil
stablePeerIDsByShortID.removeAll()
encryptionStatusCache.removeAll()
}
}
-260
View File
@@ -1,260 +0,0 @@
import BitFoundation
import Combine
import SwiftUI
struct MeshPeerRow: Identifiable, Equatable {
let peerID: PeerID
let displayName: String
let isMe: Bool
let hasUnread: Bool
let isBlocked: Bool
let isFavorite: Bool
let isConnected: Bool
let isReachable: Bool
let isMutualFavorite: Bool
let encryptionStatus: EncryptionStatus
let showsVerifiedBadgeWhenOffline: Bool
var id: String { peerID.id }
}
struct GeohashPersonRow: Identifiable, Equatable {
let id: String
let displayName: String
let isMe: Bool
let isTeleported: Bool
let isBlocked: Bool
}
@MainActor
final class PeerListModel: ObservableObject {
@Published private(set) var allPeers: [BitchatPeer] = []
@Published private(set) var meshRows: [MeshPeerRow] = []
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
@Published private(set) var reachableMeshPeerCount = 0
@Published private(set) var connectedMeshPeerCount = 0
@Published private(set) var visibleGeohashPeerCount = 0
@Published private(set) var renderID = ""
private let chatViewModel: ChatViewModel
private let conversations: ConversationStore
private let locationChannelsModel: LocationChannelsModel
private let peerIdentityStore: PeerIdentityStore
private let locationPresenceStore: LocationPresenceStore
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
conversations: ConversationStore,
locationChannelsModel: LocationChannelsModel? = nil,
peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil
) {
self.chatViewModel = chatViewModel
self.conversations = conversations
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore
self.allPeers = chatViewModel.allPeers
bind()
refresh()
}
func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color {
chatViewModel.colorForMeshPeer(id: peerID, isDark: isDark)
}
func colorForGeohashPerson(id: String, isDark: Bool) -> Color {
chatViewModel.colorForNostrPubkey(id, isDark: isDark)
}
func participantCount(for geohash: String) -> Int {
chatViewModel.geohashParticipantCount(for: geohash)
}
func startConversation(with peerID: PeerID) {
chatViewModel.startPrivateChat(with: peerID)
}
func toggleFavorite(peerID: PeerID) {
chatViewModel.toggleFavorite(peerID: peerID)
}
func openGeohashDirectMessage(with pubkeyHex: String) {
chatViewModel.startGeohashDM(withPubkeyHex: pubkeyHex)
}
func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
chatViewModel.blockGeohashUser(
pubkeyHexLowercased: pubkeyHexLowercased,
displayName: displayName
)
}
func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
chatViewModel.unblockGeohashUser(
pubkeyHexLowercased: pubkeyHexLowercased,
displayName: displayName
)
}
private func bind() {
chatViewModel.$allPeers
.receive(on: DispatchQueue.main)
.sink { [weak self] peers in
self?.allPeers = peers
self?.refresh()
}
.store(in: &cancellables)
chatViewModel.$nickname
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
locationPresenceStore.$teleportedGeo
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
conversations.$unreadConversations
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
peerIdentityStore.$encryptionStatuses
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
peerIdentityStore.$verifiedFingerprints
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
chatViewModel.participantTracker.$visiblePeople
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
locationChannelsModel.$selectedChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
locationChannelsModel.$teleported
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
locationChannelsModel.$availableChannels
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
}
private func refresh() {
let myPeerID = chatViewModel.meshService.myPeerID
let meshRows = allPeers.map { peer in
let isMe = peer.peerID == myPeerID
let verifiedBadge: Bool
if !isMe && !peer.isConnected,
let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) {
verifiedBadge = peerIdentityStore.isVerified(fingerprint)
} else {
verifiedBadge = false
}
return MeshPeerRow(
peerID: peer.peerID,
displayName: isMe ? chatViewModel.nickname : peer.nickname,
isMe: isMe,
hasUnread: chatViewModel.hasUnreadMessages(for: peer.peerID),
isBlocked: !isMe && chatViewModel.isPeerBlocked(peer.peerID),
isFavorite: peer.favoriteStatus?.isFavorite ?? false,
isConnected: peer.isConnected,
isReachable: peer.isReachable,
isMutualFavorite: peer.isMutualFavorite,
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
showsVerifiedBadgeWhenOffline: verifiedBadge
)
}
let meshCounts = meshRows.reduce(into: (reachable: 0, connected: 0)) { counts, row in
guard !row.isMe else { return }
if row.isConnected {
counts.connected += 1
counts.reachable += 1
} else if row.isReachable {
counts.reachable += 1
}
}
let geohashPeople = buildGeohashPeople()
self.meshRows = meshRows
reachableMeshPeerCount = meshCounts.reachable
connectedMeshPeerCount = meshCounts.connected
self.geohashPeople = geohashPeople
visibleGeohashPeerCount = geohashPeople.count
renderID = (
meshRows.map {
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
} +
geohashPeople.map {
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
}
).joined(separator: "|")
}
private func buildGeohashPeople() -> [GeohashPersonRow] {
let myHex = currentGeohashIdentityHex()
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
return chatViewModel.visibleGeohashPeople().map { person in
let isMe = person.id == myHex
return GeohashPersonRow(
id: person.id,
displayName: person.displayName,
isMe: isMe,
isTeleported: teleportedSet.contains(person.id.lowercased()) || (isMe && locationChannelsModel.teleported),
isBlocked: !isMe && chatViewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
)
}
}
private func currentGeohashIdentityHex() -> String? {
guard case .location(let channel) = locationChannelsModel.selectedChannel,
let identity = try? chatViewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) else {
return nil
}
return identity.publicKeyHex.lowercased()
}
}
-329
View File
@@ -1,329 +0,0 @@
import BitFoundation
import Combine
import Foundation
/// Feature model for private (direct) conversations.
///
/// Reads the single-writer `ConversationStore` directly: `messages(for:)`
/// returns the peer's conversation backing array (no mirror dictionary), and
/// the store's typed `changes` subject drives invalidation a change in the
/// SELECTED peer's conversation republishes this model, while appends to
/// other private chats only surface through the unread set. Direct
/// conversations are keyed by raw routing peer ID; the coordinators'
/// ephemeral/stable mirroring guarantees the selected peer's key always
/// holds the full timeline (see `ConversationID.directPeer`).
@MainActor
final class PrivateInboxModel: ObservableObject {
@Published private(set) var selectedPeerID: PeerID?
@Published private(set) var unreadPeerIDs: Set<PeerID> = []
private let conversations: ConversationStore
private var cancellables = Set<AnyCancellable>()
init(conversations: ConversationStore) {
self.conversations = conversations
self.selectedPeerID = conversations.selectedPrivatePeerID
self.unreadPeerIDs = conversations.unreadDirectRoutingPeerIDs()
bind()
}
func messages(for peerID: PeerID?) -> [BitchatMessage] {
guard let peerID else { return [] }
return conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
}
private func bind() {
conversations.$selectedPrivatePeerID
.dropFirst()
.sink { [weak self] peerID in
guard let self, self.selectedPeerID != peerID else { return }
self.selectedPeerID = peerID
}
.store(in: &cancellables)
conversations.changes
.sink { [weak self] change in
self?.apply(change)
}
.store(in: &cancellables)
}
private func apply(_ change: ConversationChange) {
switch change {
case .appended(let id, _),
.updated(let id, _),
.statusChanged(let id, _, _),
.messageRemoved(let id, _),
.cleared(let id):
republishIfSelected(id)
case .unreadChanged(let id, _):
guard isDirect(id) else { return }
refreshUnreadPeerIDs()
case .removed(let id):
guard isDirect(id) else { return }
refreshUnreadPeerIDs()
republishIfSelected(id)
case .migrated(let source, let destination):
guard isDirect(source) || isDirect(destination) else { return }
refreshUnreadPeerIDs()
republishIfSelected(source)
republishIfSelected(destination)
}
}
private func republishIfSelected(_ id: ConversationID) {
guard let selectedPeerID, id == .directPeer(selectedPeerID) else { return }
objectWillChange.send()
}
private func refreshUnreadPeerIDs() {
let next = conversations.unreadDirectRoutingPeerIDs()
guard unreadPeerIDs != next else { return }
unreadPeerIDs = next
}
private func isDirect(_ id: ConversationID) -> Bool {
if case .direct = id { return true }
return false
}
}
enum PrivateConversationAvailability: Equatable {
case bluetoothConnected
case meshReachable
case nostrAvailable
case offline
}
struct PrivateConversationHeaderState: Equatable {
let conversationPeerID: PeerID
let headerPeerID: PeerID
let displayName: String
let availability: PrivateConversationAvailability
let isFavorite: Bool
let encryptionStatus: EncryptionStatus?
var supportsFavoriteToggle: Bool {
!conversationPeerID.isGeoDM
}
}
@MainActor
final class PrivateConversationModel: ObservableObject {
@Published private(set) var selectedPeerID: PeerID?
@Published private(set) var selectedHeaderState: PrivateConversationHeaderState?
private let chatViewModel: ChatViewModel
private let conversations: ConversationStore
private let locationChannelsModel: LocationChannelsModel
private let peerIdentityStore: PeerIdentityStore
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
conversations: ConversationStore,
locationChannelsModel: LocationChannelsModel? = nil,
peerIdentityStore: PeerIdentityStore? = nil
) {
self.chatViewModel = chatViewModel
self.conversations = conversations
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
let initialPeerID = conversations.selectedPrivatePeerID
self.selectedPeerID = initialPeerID
self.selectedHeaderState = initialPeerID.flatMap { peerID in
makeHeaderState(for: peerID)
}
bind()
}
func startConversation(with peerID: PeerID) {
chatViewModel.startPrivateChat(with: peerID)
refreshSelectedConversation()
}
func openConversation(for peerID: PeerID) {
if peerID.isGeoChat {
guard let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) else { return }
chatViewModel.startGeohashDM(withPubkeyHex: full)
} else {
chatViewModel.startPrivateChat(with: peerID)
}
refreshSelectedConversation()
}
func endConversation() {
chatViewModel.endPrivateChat()
refreshSelectedConversation()
}
func toggleFavorite(peerID: PeerID) {
chatViewModel.toggleFavorite(peerID: peerID)
refreshSelectedConversation()
}
func toggleFavoriteForSelectedConversation() {
guard let headerPeerID = selectedHeaderState?.headerPeerID else { return }
toggleFavorite(peerID: headerPeerID)
}
func markMessagesAsRead(from peerID: PeerID) {
chatViewModel.markPrivateMessagesAsRead(from: peerID)
}
private func bind() {
conversations.$selectedPrivatePeerID
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
chatViewModel.$allPeers
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
peerIdentityStore.$encryptionStatuses
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: .favoriteStatusChanged)
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
locationChannelsModel.$selectedChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
}
private func refreshSelectedConversation() {
selectedPeerID = conversations.selectedPrivatePeerID
selectedHeaderState = selectedPeerID.flatMap { peerID in
makeHeaderState(for: peerID)
}
}
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
let peer = chatViewModel.getPeer(byID: headerPeerID)
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: 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
? nil
: chatViewModel.getEncryptionStatus(for: headerPeerID)
return PrivateConversationHeaderState(
conversationPeerID: conversationPeerID,
headerPeerID: headerPeerID,
displayName: displayName,
availability: availability,
isFavorite: chatViewModel.isFavorite(peerID: headerPeerID),
encryptionStatus: encryptionStatus
)
}
private func resolveDisplayName(
for conversationPeerID: PeerID,
headerPeerID: PeerID,
peer: BitchatPeer?
) -> String {
if conversationPeerID.isGeoDM, case .location(let channel) = locationChannelsModel.selectedChannel {
return "#\(channel.geohash)/@\(chatViewModel.geohashDisplayName(for: conversationPeerID))"
}
if let displayName = peer?.displayName {
return displayName
}
if let nickname = chatViewModel.meshService.peerNickname(peerID: headerPeerID) {
return nickname
}
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(
for: Data(hexString: headerPeerID.id) ?? Data()
), !favorite.peerNickname.isEmpty {
return favorite.peerNickname
}
if headerPeerID.id.count == 16 {
let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
if let identity = candidates.first,
let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint) {
if let pet = social.localPetname, !pet.isEmpty {
return pet
}
if !social.claimedNickname.isEmpty {
return social.claimedNickname
}
}
} else if let noiseKey = headerPeerID.noiseKey {
let fingerprint = noiseKey.sha256Fingerprint()
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
if let pet = social.localPetname, !pet.isEmpty {
return pet
}
if !social.claimedNickname.isEmpty {
return social.claimedNickname
}
}
}
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
}
private func resolveAvailability(for headerPeerID: PeerID, peer: BitchatPeer?) -> PrivateConversationAvailability {
if let connectionState = peer?.connectionState {
switch connectionState {
case .bluetoothConnected:
return .bluetoothConnected
case .meshReachable:
return .meshReachable
case .nostrAvailable:
return .nostrAvailable
case .offline:
return .offline
}
}
if chatViewModel.meshService.isPeerReachable(headerPeerID) {
return .meshReachable
}
if let noiseKey = Data(hexString: headerPeerID.id),
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
favoriteStatus.isMutual {
return .nostrAvailable
}
if chatViewModel.meshService.isPeerConnected(headerPeerID) || chatViewModel.connectedPeers.contains(headerPeerID) {
return .bluetoothConnected
}
return .offline
}
}
-77
View File
@@ -1,77 +0,0 @@
import BitFoundation
import Combine
import SwiftUI
/// Feature model for the active public (mesh/geohash) timeline.
///
/// Observes ONE `Conversation` object in the single-writer
/// `ConversationStore` the active channel's so appends to background
/// conversations (other geohashes, private chats) never invalidate it.
/// `messages` reads the observed conversation's backing array directly;
/// there is no mirror copy.
@MainActor
final class PublicChatModel: ObservableObject {
@Published private(set) var activeChannel: ChannelID
/// The active public conversation's timeline.
var messages: [BitchatMessage] { activeConversation.messages }
private let conversations: ConversationStore
private var activeConversation: Conversation
private var activeConversationCancellable: AnyCancellable?
private var cancellables = Set<AnyCancellable>()
init(conversations: ConversationStore) {
let channel = conversations.activeChannel
self.conversations = conversations
self.activeChannel = channel
self.activeConversation = conversations.conversation(for: ConversationID(channelID: channel))
observeActiveConversation()
bind()
}
private func bind() {
conversations.$activeChannel
.dropFirst()
.sink { [weak self] channel in
guard let self else { return }
self.activeChannel = channel
self.retargetActiveConversation(to: channel)
}
.store(in: &cancellables)
// The store replaces a conversation's object when it is removed
// (panic clear); retarget to the fresh instance so the observation
// never goes stale.
conversations.changes
.sink { [weak self] change in
guard let self,
case .removed(let id) = change,
id == self.activeConversation.id else { return }
self.retargetActiveConversation(to: self.activeChannel)
}
.store(in: &cancellables)
}
private func retargetActiveConversation(to channel: ChannelID) {
let conversation = conversations.conversation(for: ConversationID(channelID: channel))
guard conversation !== activeConversation else {
// Same object (e.g. re-selected channel): keep the existing
// observation, but `messages` may still differ from what views
// last rendered, so republish.
objectWillChange.send()
return
}
objectWillChange.send()
activeConversation = conversation
observeActiveConversation()
}
private func observeActiveConversation() {
activeConversationCancellable = activeConversation.objectWillChange
.sink { [weak self] _ in
self?.objectWillChange.send()
}
}
}
-152
View File
@@ -1,152 +0,0 @@
import BitFoundation
import Combine
import Foundation
struct FingerprintPresentationState: Equatable {
let statusPeerID: PeerID
let peerNickname: String
let encryptionStatus: EncryptionStatus
let theirFingerprint: String?
let myFingerprint: String
let isVerified: Bool
var canToggleVerification: Bool {
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
}
}
enum VerificationScanOutcome: Equatable {
case requested(String)
case notFound
case invalid
}
@MainActor
final class VerificationModel: ObservableObject {
@Published private(set) var currentNickname: String
@Published private(set) var selectedPeerID: PeerID?
private let chatViewModel: ChatViewModel
private let peerIdentityStore: PeerIdentityStore
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
privateConversationModel: PrivateConversationModel,
peerIdentityStore: PeerIdentityStore? = nil
) {
self.chatViewModel = chatViewModel
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
self.currentNickname = chatViewModel.nickname
self.selectedPeerID = privateConversationModel.selectedPeerID
bind(privateConversationModel: privateConversationModel)
}
func myQRString() -> String {
let npub = try? chatViewModel.idBridge.getCurrentNostrIdentity()?.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 {
guard let qr = VerificationService.shared.verifyScannedQR(payload) else {
return .invalid
}
guard chatViewModel.beginQRVerification(with: qr) else {
return .notFound
}
return .requested(qr.nickname)
}
func verifyFingerprint(for peerID: PeerID) {
chatViewModel.verifyFingerprint(for: peerID)
}
func unverifyFingerprint(for peerID: PeerID) {
chatViewModel.unverifyFingerprint(for: peerID)
}
func isVerified(peerID: PeerID) -> Bool {
guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false }
return peerIdentityStore.isVerified(fingerprint)
}
func fingerprintPresentation(for peerID: PeerID) -> FingerprintPresentationState {
let statusPeerID = chatViewModel.getShortIDForNoiseKey(peerID)
let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID)
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
return FingerprintPresentationState(
statusPeerID: statusPeerID,
peerNickname: peerNickname,
encryptionStatus: encryptionStatus,
theirFingerprint: theirFingerprint,
myFingerprint: chatViewModel.getMyFingerprint(),
isVerified: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
)
}
private func bind(privateConversationModel: PrivateConversationModel) {
chatViewModel.$nickname
.receive(on: DispatchQueue.main)
.assign(to: &$currentNickname)
privateConversationModel.$selectedPeerID
.receive(on: DispatchQueue.main)
.assign(to: &$selectedPeerID)
peerIdentityStore.$encryptionStatuses
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
peerIdentityStore.$verifiedFingerprints
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
chatViewModel.$allPeers
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
}
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
if let peer = chatViewModel.getPeer(byID: statusPeerID) {
return peer.displayName
}
if let name = chatViewModel.meshService.peerNickname(peerID: statusPeerID) {
return name
}
if let data = peerID.noiseKey {
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: data),
!favorite.peerNickname.isEmpty {
return favorite.peerNickname
}
let fingerprint = data.sha256Fingerprint()
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
if let pet = social.localPetname, !pet.isEmpty {
return pet
}
if !social.claimedNickname.isEmpty {
return social.claimedNickname
}
}
}
return String(localized: "common.unknown", comment: "Label for an unknown peer")
}
}
+201 -45
View File
@@ -6,57 +6,128 @@
// For more information, see <https://unlicense.org>
//
import Tor
import SwiftUI
import BitFoundation
import UserNotifications
@main
struct BitchatApp: App {
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
static let groupID = "group.\(bundleID)"
@StateObject private var runtime: AppRuntime
@AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue
@StateObject private var chatViewModel: ChatViewModel
#if os(iOS)
@Environment(\.scenePhase) var scenePhase
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
// Skip the very first .active-triggered Tor restart on cold launch
@State private var didHandleInitialActive: Bool = false
@State private var didEnterBackground: Bool = false
#elseif os(macOS)
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
#endif
private let idBridge = NostrIdentityBridge()
init() {
_runtime = StateObject(wrappedValue: AppRuntime())
let keychain = KeychainManager()
let idBridge = self.idBridge
_chatViewModel = StateObject(
wrappedValue: ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: SecureIdentityStateManager(keychain)
)
)
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
// Warm up georelay directory and refresh if stale (once/day)
GeoRelayDirectory.shared.prefetchIfNeeded()
}
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.appTheme, AppTheme(rawValue: appThemeRawValue) ?? .matrix)
.environmentObject(runtime.publicChatModel)
.environmentObject(runtime.privateInboxModel)
.environmentObject(runtime.privateConversationModel)
.environmentObject(runtime.verificationModel)
.environmentObject(runtime.conversationUIModel)
.environmentObject(runtime.locationChannelsModel)
.environmentObject(runtime.peerListModel)
.environmentObject(runtime.appChromeModel)
.environmentObject(chatViewModel)
.onAppear {
appDelegate.runtime = runtime
runtime.start()
NotificationDelegate.shared.chatViewModel = chatViewModel
// Inject live Noise service into VerificationService to avoid creating new BLE instances
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
let nickname = chatViewModel.nickname
DispatchQueue.global(qos: .utility).async {
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
}
appDelegate.chatViewModel = chatViewModel
// Initialize network activation policy; will start Tor/Nostr only when allowed
NetworkActivationService.shared.start()
// Start presence service (will wait for Tor readiness)
GeohashPresenceService.shared.start()
// Check for shared content
checkForSharedContent()
}
.onOpenURL { url in
runtime.handleOpenURL(url)
handleURL(url)
}
#if os(iOS)
.onChange(of: scenePhase) { newPhase in
runtime.handleScenePhaseChange(newPhase)
switch newPhase {
case .background:
// Keep BLE mesh running in background; BLEService adapts scanning automatically
// Always send Tor to dormant on background for a clean restart later.
TorManager.shared.setAppForeground(false)
TorManager.shared.goDormantOnBackground()
// Stop geohash sampling while backgrounded
Task { @MainActor in
chatViewModel.endGeohashSampling()
}
// Proactively disconnect Nostr to avoid spurious socket errors while Tor is down
NostrRelayManager.shared.disconnect()
didEnterBackground = true
case .active:
// Restart services when becoming active
chatViewModel.meshService.startServices()
TorManager.shared.setAppForeground(true)
// On initial cold launch, Tor was just started in onAppear.
// Skip the deterministic restart the first time we become active.
if didHandleInitialActive && didEnterBackground {
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
TorManager.shared.ensureRunningOnForeground()
}
} else {
didHandleInitialActive = true
}
didEnterBackground = false
if TorManager.shared.isAutoStartAllowed() {
Task.detached {
let _ = await TorManager.shared.awaitReady(timeout: 60)
await MainActor.run {
// Rebuild proxied sessions to bind to the live Tor after readiness
TorURLSession.shared.rebuild()
// Reconnect Nostr via fresh sessions; will gate until Tor 100%
NostrRelayManager.shared.resetAllConnections()
}
}
}
checkForSharedContent()
case .inactive:
break
@unknown default:
break
}
}
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
runtime.handleDidBecomeActiveNotification()
// Check for shared content when app becomes active
checkForSharedContent()
}
#elseif os(macOS)
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
runtime.handleMacDidBecomeActiveNotification()
// App became active
}
#endif
}
@@ -65,18 +136,66 @@ struct BitchatApp: App {
.windowResizability(.contentSize)
#endif
}
private func handleURL(_ url: URL) {
if url.scheme == "bitchat" && url.host == "share" {
// Handle shared content
checkForSharedContent()
}
}
private func checkForSharedContent() {
// Check app group for shared content from extension
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else {
return
}
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
return
}
// Only process if shared within configured window
if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds {
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
// Clear the shared content
userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate")
// No need to force synchronize here
// Send the shared content immediately on the main queue
DispatchQueue.main.async {
if contentType == "url" {
// Try to parse as JSON first
if let data = sharedContent.data(using: .utf8),
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
let url = urlData["url"] {
// Send plain URL
self.chatViewModel.sendMessage(url)
} else {
// Fallback to simple URL
self.chatViewModel.sendMessage(sharedContent)
}
} else {
self.chatViewModel.sendMessage(sharedContent)
}
}
}
}
}
#if os(iOS)
final class AppDelegate: NSObject, UIApplicationDelegate {
weak var runtime: AppRuntime?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
true
weak var chatViewModel: ChatViewModel?
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
return true
}
func applicationWillTerminate(_ application: UIApplication) {
runtime?.applicationWillTerminate()
chatViewModel?.applicationWillTerminate()
}
}
#endif
@@ -85,42 +204,79 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
import AppKit
final class MacAppDelegate: NSObject, NSApplicationDelegate {
weak var runtime: AppRuntime?
weak var chatViewModel: ChatViewModel?
func applicationWillTerminate(_ notification: Notification) {
runtime?.applicationWillTerminate()
chatViewModel?.applicationWillTerminate()
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
true
return true
}
}
#endif
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
static let shared = NotificationDelegate()
weak var runtime: AppRuntime?
weak var chatViewModel: ChatViewModel?
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
let identifier = response.notification.request.identifier
let userInfo = response.notification.request.content.userInfo
Task { @MainActor in
self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo)
// Check if this is a private message notification
if identifier.hasPrefix("private-") {
// Get peer ID from userInfo
if let peerID = userInfo["peerID"] as? String {
DispatchQueue.main.async {
self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID))
}
}
}
// Handle deeplink (e.g., geohash activity)
if let deep = userInfo["deeplink"] as? String, let url = URL(string: deep) {
#if os(iOS)
DispatchQueue.main.async { UIApplication.shared.open(url) }
#else
DispatchQueue.main.async { NSWorkspace.shared.open(url) }
#endif
}
completionHandler()
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
let identifier = notification.request.identifier
let userInfo = notification.request.content.userInfo
Task {
let options = await self.runtime?.presentationOptions(
forNotificationIdentifier: identifier,
userInfo: userInfo
) ?? [.banner, .sound]
completionHandler(options)
// Check if this is a private message notification
if identifier.hasPrefix("private-") {
// Get peer ID from userInfo
if let peerID = userInfo["peerID"] as? String {
// Don't show notification if the private chat is already open
// Access main-actor-isolated property via Task
Task { @MainActor in
if self.chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
completionHandler([])
} else {
completionHandler([.banner, .sound])
}
}
return
}
}
// Suppress geohash activity notification if we're already in that geohash channel
if identifier.hasPrefix("geo-activity-"),
let deep = userInfo["deeplink"] as? String,
let gh = deep.components(separatedBy: "/").last {
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
completionHandler([])
return
}
}
// Show notification in all other cases
completionHandler([.banner, .sound])
}
}
+123 -161
View File
@@ -1,4 +1,3 @@
import BitFoundation
import Foundation
import ImageIO
import UniformTypeIdentifiers
@@ -14,84 +13,59 @@ enum ImageUtilsError: Error {
}
enum ImageUtils {
private static let compressionQuality: CGFloat = 0.85
// Upper bound for the compressed JPEG. This is only a ceiling: the encoder
// keeps whatever a photo naturally weighs at `defaultMaxDimension` and
// `compressionQuality`, and only steps quality down when a payload would
// exceed this budget. It stays well under `FileTransferLimits.maxImageBytes`
// (512 KiB) so the BLE path never overruns its cap.
//
// Wi-Fi bulk relevance: the old 45 KB / 448 px budget crushed every photo
// to ~40 KB below `TransportConfig.wifiBulkMinPayloadBytes` (64 KiB) so
// `WifiBulkPolicy.shouldOffer` never fired and the AWDL data plane was dead
// in production. A genuinely detailed photo at `defaultMaxDimension` now
// weighs well over 64 KiB, so it becomes Wi-Fi-bulk eligible to a capable
// direct peer while still riding BLE fragmentation for everyone else.
private static let targetImageBytes: Int = 200_000
private static let maxSourceImageBytes: Int = 10 * 1024 * 1024
// Longest-side ceiling for shared photos. 448 px was thumbnail-tier and
// (together with the tiny byte budget) forced every photo below the Wi-Fi
// bulk threshold. 1024 px keeps a shared photo legible and lets detailed
// images clear 64 KiB, without approaching the 512 KiB hard cap.
static let defaultMaxDimension: CGFloat = 1024
private static let compressionQuality: CGFloat = 0.82
private static let targetImageBytes: Int = 45_000
static func processImage(at url: URL, maxDimension: CGFloat = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
try validateImageSource(at: url)
static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL {
// Security H1: Check file size BEFORE reading into memory
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
guard let fileSize = attrs[.size] as? Int else {
throw ImageUtilsError.invalidImage
}
// Allow up to 10MB source images (will be scaled down)
guard fileSize <= 10 * 1024 * 1024 else {
throw ImageUtilsError.invalidImage
}
let data = try Data(contentsOf: url)
#if os(iOS)
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
return try processImage(image, maxDimension: maxDimension)
#else
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
return try processImage(image, maxDimension: maxDimension)
#endif
}
static func validateImageSource(at url: URL) throws {
// Security H1: Check file size BEFORE reading into memory.
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
guard let fileSize = attrs[.size] as? Int,
fileSize > 0,
fileSize <= maxSourceImageBytes else {
throw ImageUtilsError.invalidImage
}
let options = [kCGImageSourceShouldCache: false] as CFDictionary
guard let source = CGImageSourceCreateWithURL(url as CFURL, options),
CGImageSourceGetType(source) != nil else {
throw ImageUtilsError.invalidImage
}
}
#if os(iOS)
static func processImage(_ image: UIImage, maxDimension: CGFloat = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {
return try autoreleasepool {
var dimension = maxDimension
var jpegData: Data?
// Downscale-and-compress until the payload fits the hard image cap.
// A normal photo converges on the first pass; this loop only kicks
// in for near-incompressible inputs (e.g. full-frame noise) that
// would otherwise overrun `maxImageBytes` at the raised dimension.
while true {
let scaled = scaledImage(image, maxDimension: dimension)
// Get CGImage from UIImage - this is the key to stripping metadata
guard let cgImage = scaled.cgImage else {
throw ImageUtilsError.encodingFailed
}
guard let data = compressToBudget(cgImage) else {
throw ImageUtilsError.encodingFailed
}
jpegData = data
if data.count <= FileTransferLimits.maxImageBytes || dimension <= minRetryDimension {
break
}
dimension = (dimension * dimensionRetryFactor).rounded(.down)
}
guard let finalData = jpegData else { throw ImageUtilsError.encodingFailed }
// Scale the image first
let scaled = scaledImage(image, maxDimension: maxDimension)
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
try finalData.write(to: outputURL, options: .atomic)
// Get CGImage from UIImage - this is the key to stripping metadata
guard let cgImage = scaled.cgImage else {
throw ImageUtilsError.encodingFailed
}
// Use CGImageDestination to encode without metadata (same as macOS)
var quality = compressionQuality
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
throw ImageUtilsError.encodingFailed
}
// Compress to target size
while jpegData.count > targetImageBytes && quality > 0.3 {
quality -= 0.1
autoreleasepool {
if let next = encodeJPEG(from: cgImage, quality: quality) {
jpegData = next
}
}
}
let outputURL = try makeOutputURL()
try jpegData.write(to: outputURL, options: .atomic)
return outputURL
}
}
@@ -110,93 +84,6 @@ enum ImageUtils {
UIGraphicsEndImageContext()
return rendered ?? image
}
#else
static func processImage(_ image: NSImage, maxDimension: CGFloat = defaultMaxDimension, outputDirectory: URL? = nil) throws -> URL {
return try autoreleasepool {
var dimension = maxDimension
var jpegData: Data?
// See the iOS path: normal photos converge immediately; the loop
// only shrinks further for near-incompressible inputs so the
// output never overruns `maxImageBytes`.
while true {
let scaled = scaledImage(image, maxDimension: dimension)
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
throw ImageUtilsError.encodingFailed
}
let width = inputCG.width
let height = inputCG.height
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
guard let context = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
throw ImageUtilsError.encodingFailed
}
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
guard let cgImage = context.makeImage() else {
throw ImageUtilsError.encodingFailed
}
guard let data = compressToBudget(cgImage) else {
throw ImageUtilsError.encodingFailed
}
jpegData = data
if data.count <= FileTransferLimits.maxImageBytes || dimension <= minRetryDimension {
break
}
dimension = (dimension * dimensionRetryFactor).rounded(.down)
}
guard let finalData = jpegData else { throw ImageUtilsError.encodingFailed }
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
try finalData.write(to: outputURL, options: .atomic)
return outputURL
}
}
private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
let size = image.size
let maxSide = max(size.width, size.height)
guard maxSide > maxDimension else { return image }
let scale = maxDimension / maxSide
let newSize = NSSize(width: size.width * scale, height: size.height * scale)
let scaledImage = NSImage(size: newSize)
scaledImage.lockFocus()
image.draw(in: NSRect(origin: .zero, size: newSize),
from: NSRect(origin: .zero, size: size),
operation: .copy,
fraction: 1.0)
scaledImage.unlockFocus()
return scaledImage
}
#endif
// When even the quality floor can't get an image under the byte budget,
// shrink the longest side by this factor and re-encode. Bounded below so
// the retry loop always terminates.
private static let dimensionRetryFactor: CGFloat = 0.75
private static let minRetryDimension: CGFloat = 256
/// Encodes `cgImage` to JPEG, stepping quality down toward
/// `targetImageBytes`. Shared by both platforms.
private static func compressToBudget(_ cgImage: CGImage) -> Data? {
var quality = compressionQuality
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
return nil
}
while jpegData.count > targetImageBytes && quality > 0.3 {
quality -= 0.1
autoreleasepool {
if let next = encodeJPEG(from: cgImage, quality: quality) {
jpegData = next
}
}
}
return jpegData
}
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
@@ -218,18 +105,93 @@ enum ImageUtils {
}
return data as Data
}
#else
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL {
return try autoreleasepool {
let scaled = scaledImage(image, maxDimension: maxDimension)
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
throw ImageUtilsError.encodingFailed
}
let width = inputCG.width
let height = inputCG.height
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
guard let context = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
throw ImageUtilsError.encodingFailed
}
context.draw(inputCG, in: CGRect(x: 0, y: 0, width: width, height: height))
guard let cgImage = context.makeImage() else {
throw ImageUtilsError.encodingFailed
}
var quality = compressionQuality
guard var jpegData = encodeJPEG(from: cgImage, quality: quality) else {
throw ImageUtilsError.encodingFailed
}
while jpegData.count > targetImageBytes && quality > 0.3 {
quality -= 0.1
autoreleasepool {
if let next = encodeJPEG(from: cgImage, quality: quality) {
jpegData = next
}
}
}
let outputURL = try makeOutputURL()
try jpegData.write(to: outputURL, options: .atomic)
return outputURL
}
}
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
private static func scaledImage(_ image: NSImage, maxDimension: CGFloat) -> NSImage {
let size = image.size
let maxSide = max(size.width, size.height)
guard maxSide > maxDimension else { return image }
let scale = maxDimension / maxSide
let newSize = NSSize(width: size.width * scale, height: size.height * scale)
let scaledImage = NSImage(size: newSize)
scaledImage.lockFocus()
image.draw(in: NSRect(origin: .zero, size: newSize),
from: NSRect(origin: .zero, size: size),
operation: .copy,
fraction: 1.0)
scaledImage.unlockFocus()
return scaledImage
}
// Shared EXIF-stripping JPEG encoder for both iOS and macOS
private static func encodeJPEG(from cgImage: CGImage, quality: CGFloat) -> Data? {
guard let data = CFDataCreateMutable(nil, 0) else {
return nil
}
guard let destination = CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil) else {
return nil
}
// Security: Strip ALL metadata (EXIF, GPS, TIFF, IPTC, XMP)
// By only specifying compression quality and no metadata keys,
// we ensure a clean JPEG with no privacy-leaking information
let options: [CFString: Any] = [
kCGImageDestinationLossyCompressionQuality: quality
]
CGImageDestinationAddImage(destination, cgImage, options as CFDictionary)
guard CGImageDestinationFinalize(destination) else {
return nil
}
return data as Data
}
#endif
private static func makeOutputURL() throws -> URL {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd_HHmmss"
let fileName = "img_\(formatter.string(from: Date()))_\(UUID().uuidString).jpg"
let fileName = "img_\(formatter.string(from: Date())).jpg"
let directory: URL
if let outputDirectory {
directory = outputDirectory
} else {
directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
}
let directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
return directory.appendingPathComponent(fileName)
}
+4 -4
View File
@@ -127,10 +127,10 @@ struct SocialIdentity: Codable {
}
enum TrustLevel: String, Codable {
case unknown
case casual
case trusted
case verified
case unknown = "unknown"
case casual = "casual"
case trusted = "trusted"
case verified = "verified"
}
// MARK: - Identity Cache
@@ -151,68 +151,38 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// Thread safety
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
// 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.)
// Debouncing for keychain saves
private var saveTimer: Timer?
private let saveDebounceInterval: TimeInterval = 2.0 // Save at most once every 2 seconds
private var pendingSave = false
// Encryption key
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) {
self.keychain = 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.
// Generate or retrieve encryption key from keychain
let loadedKey: SymmetricKey
let keyIsEphemeral: Bool
switch keychain.getIdentityKeyWithResult(forKey: encryptionKeyName) {
case .success(let keyData):
// Try to load from keychain
if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) {
loadedKey = SymmetricKey(data: keyData)
keyIsEphemeral = false
SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true)
case .itemNotFound:
// Genuine first run: generate and persist a new key.
let newKey = SymmetricKey(size: .bits256)
let keyData = newKey.withUnsafeBytes { Data($0) }
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)
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)
}
// Generate new key if needed
else {
loadedKey = SymmetricKey(size: .bits256)
keyIsEphemeral = true
let keyData = loadedKey.withUnsafeBytes { Data($0) }
// Save to keychain
let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName)
SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved)
}
self.encryptionKey = loadedKey
self.encryptionKeyIsEphemeral = keyIsEphemeral
// Only read the persisted cache when we hold the real key; with an
// ephemeral key the decrypt would fail and discard the real cache.
if !keyIsEphemeral {
loadIdentityCache()
}
// Load identity cache on init
loadIdentityCache()
}
deinit {
@@ -232,37 +202,28 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
} catch {
cache = IdentityCache()
let deleted = keychain.deleteIdentityKey(forKey: cacheKey)
SecureLogger.warning(
"Discarded unreadable identity cache; starting fresh (deleted=\(deleted), error=\(error.localizedDescription))",
category: .security
)
// Log error but continue with empty cache
SecureLogger.error(error, context: "Failed to load identity cache", category: .security)
}
}
/// 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() {
// Mark that we need to save
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() {
guard pendingSave else { return }
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 {
let data = try JSONEncoder().encode(cache)
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
@@ -274,14 +235,10 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
SecureLogger.error(error, context: "Failed to save identity cache", category: .security)
}
}
// 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.
// Force immediate save (for app termination)
func forceSave() {
saveTimer?.invalidate()
performSave()
}
-6
View File
@@ -33,18 +33,12 @@
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSBonjourServices</key>
<array>
<string>_bitchat-bulk._tcp</string>
</array>
<key>NSBluetoothAlwaysUsageDescription</key>
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>NSLocalNetworkUsageDescription</key>
<string>bitchat uses peer-to-peer Wi-Fi to transfer large photos and voice notes directly between nearby devices.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>NSMicrophoneUsageDescription</key>
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,6 @@
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
extension BitchatMessage {
@@ -6,40 +6,35 @@
// For more information, see <https://unlicense.org>
//
import class Foundation.DateFormatter
import struct Foundation.AttributedString
import struct Foundation.Data
import struct Foundation.Date
import struct Foundation.TimeInterval
import struct Foundation.UUID
import Foundation
import BitFoundation
/// Represents a user-visible message in the BitChat system.
/// Handles both broadcast messages and private encrypted messages,
/// with support for mentions, replies, and delivery tracking.
/// - Note: This is the primary data model for chat messages
public final class BitchatMessage: Codable {
public let id: String
public let sender: String
public let content: String
public let timestamp: Date
public let isRelay: Bool
public let originalSender: String?
public let isPrivate: Bool
public let recipientNickname: String?
public let senderPeerID: PeerID?
public let mentions: [String]? // Array of mentioned nicknames
public var deliveryStatus: DeliveryStatus? // Delivery tracking
final class BitchatMessage: Codable {
let id: String
let sender: String
let content: String
let timestamp: Date
let isRelay: Bool
let originalSender: String?
let isPrivate: Bool
let recipientNickname: String?
let senderPeerID: PeerID?
let mentions: [String]? // Array of mentioned nicknames
var deliveryStatus: DeliveryStatus? // Delivery tracking
// Cached formatted text (not included in Codable)
private var _cachedFormattedText: [String: AttributedString] = [:]
public func getCachedFormattedText(isDark: Bool, isSelf: Bool, variant: String = "") -> AttributedString? {
return _cachedFormattedText["\(variant)\(isDark)-\(isSelf)"]
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
return _cachedFormattedText["\(isDark)-\(isSelf)"]
}
public func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool, variant: String = "") {
_cachedFormattedText["\(variant)\(isDark)-\(isSelf)"] = text
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
}
// Codable implementation
@@ -48,7 +43,7 @@ public final class BitchatMessage: Codable {
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
}
public init(
init(
id: String? = nil,
sender: String,
content: String,
@@ -78,7 +73,7 @@ public final class BitchatMessage: Codable {
// MARK: - Equatable Conformance
extension BitchatMessage: Equatable {
public static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
return lhs.id == rhs.id &&
lhs.sender == rhs.sender &&
lhs.content == rhs.content &&
@@ -336,14 +331,14 @@ extension BitchatMessage {
return formatter
}()
public var formattedTimestamp: String {
var formattedTimestamp: String {
Self.timestampFormatter.string(from: timestamp)
}
}
extension Array where Element == BitchatMessage {
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
public func cleanedAndDeduped() -> [Element] {
func cleanedAndDeduped() -> [Element] {
let arr = filter { $0.content.trimmed.isEmpty == false }
guard arr.count > 1 else {
return arr
@@ -6,26 +6,26 @@
// For more information, see <https://unlicense.org>
//
import struct Foundation.Data
import struct Foundation.Date
import Foundation
import BitFoundation
/// The core packet structure for all BitChat protocol messages.
/// Encapsulates all data needed for routing through the mesh network,
/// including TTL for hop limiting and optional encryption.
/// - Note: Packets larger than BLE MTU (512 bytes) are automatically fragmented
public struct BitchatPacket: Codable {
struct BitchatPacket: Codable {
let version: UInt8
public let type: UInt8
public let senderID: Data
public let recipientID: Data?
public let timestamp: UInt64
public let payload: Data
public var signature: Data?
public var ttl: UInt8
public var route: [Data]?
public var isRSR: Bool
let type: UInt8
let senderID: Data
let recipientID: Data?
let timestamp: UInt64
let payload: Data
var signature: Data?
var ttl: UInt8
var route: [Data]?
var isRSR: Bool
public init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) {
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) {
self.version = version
self.type = type
self.senderID = senderID
@@ -66,18 +66,18 @@ public struct BitchatPacket: Codable {
BinaryProtocol.encode(self)
}
public func toBinaryData(padding: Bool = true) -> Data? {
func toBinaryData(padding: Bool = true) -> Data? {
BinaryProtocol.encode(self, padding: padding)
}
// Backward-compatible helper (defaults to padded encoding)
public func toBinaryData() -> Data? {
func toBinaryData() -> Data? {
toBinaryData(padding: true)
}
/// Create binary representation for signing (without signature and TTL fields)
/// TTL is excluded because it changes during packet relay operations
public func toBinaryDataForSigning() -> Data? {
func toBinaryDataForSigning() -> Data? {
// Create a copy without signature and with fixed TTL for signing
// TTL must be excluded because it changes during relay
let unsignedPacket = BitchatPacket(
@@ -95,7 +95,7 @@ public struct BitchatPacket: Codable {
return BinaryProtocol.encode(unsignedPacket)
}
public static func from(_ data: Data) -> BitchatPacket? {
static func from(_ data: Data) -> BitchatPacket? {
BinaryProtocol.decode(data)
}
}
+12 -19
View File
@@ -11,38 +11,33 @@ import Foundation
// MARK: - CommandInfo Enum
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 clear
case help
case hug
case message = "msg"
case message = "dm"
case slap
case unblock
case who
case favorite = "fav"
case unfavorite = "unfav"
case favorite
case unfavorite
var id: String { rawValue }
var alias: String { "/" + rawValue }
var placeholder: String? {
switch self {
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
case .clear, .help, .who:
case .clear, .who:
return nil
}
}
var description: String {
switch self {
case .block: String(localized: "content.commands.block")
case .clear: String(localized: "content.commands.clear")
case .help: String(localized: "content.commands.help")
case .hug: String(localized: "content.commands.hug")
case .message: String(localized: "content.commands.message")
case .slap: String(localized: "content.commands.slap")
@@ -52,14 +47,12 @@ enum CommandInfo: String, Identifiable {
case .unfavorite: String(localized: "content.commands.unfavorite")
}
}
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
// The processor rejects favorites in geohash contexts, so only
// suggest them where they actually work: mesh.
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
if isGeoPublic || isGeoDM {
return baseCommands
return baseCommands + [.favorite, .unfavorite]
}
return baseCommands + [.favorite, .unfavorite]
return baseCommands
}
}
@@ -6,7 +6,7 @@
// For more information, see <https://unlicense.org>
//
import struct Foundation.Data
import Foundation
/// Provides privacy-preserving message padding to obscure actual content length.
/// Uses PKCS#7-style padding with random bytes to prevent traffic analysis.
+1 -1
View File
@@ -96,7 +96,7 @@ struct RequestSyncPacket {
}
}
guard let pp = p, let mm = m, let dd = payload, pp >= 1, pp <= GCSFilter.maxP, mm > 0 else { return nil }
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
}
}
+1 -15
View File
@@ -78,7 +78,6 @@
///
import BitLogger
import BitFoundation
import Foundation
import CryptoKit
@@ -93,7 +92,6 @@ enum NoisePattern {
case XX // Most versatile, mutual authentication
case IK // Initiator knows responder's static key
case NK // Anonymous initiator
case X // One-way: single message to a known static key (no response)
}
enum NoiseRole {
@@ -323,13 +321,6 @@ final class NoiseCipherState {
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
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
tag = actualCiphertext.suffix(16)
@@ -602,7 +593,7 @@ final class NoiseHandshakeState {
switch pattern {
case .XX:
break // No pre-message keys
case .IK, .NK, .X:
case .IK, .NK:
if role == .initiator, let remoteStatic = remoteStaticPublic {
symmetricState.mixHash(remoteStatic.rawRepresentation)
} else if role == .responder, let localStatic = localStaticPublic {
@@ -905,7 +896,6 @@ extension NoisePattern {
case .XX: return "XX"
case .IK: return "IK"
case .NK: return "NK"
case .X: return "X"
}
}
@@ -927,10 +917,6 @@ extension NoisePattern {
[.e, .es], // -> e, es
[.e, .ee] // <- e, ee
]
case .X:
return [
[.e, .es, .s, .ss] // -> e, es, s, ss (single one-way message)
]
}
}
}
-22
View File
@@ -1,22 +0,0 @@
import Foundation
enum Base64URLCoding {
static func encode(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
static func decode(_ string: String) -> Data? {
var base64 = string
let padding = (4 - (base64.count % 4)) % 4
if padding > 0 {
base64 += String(repeating: "=", count: padding)
}
base64 = base64
.replacingOccurrences(of: "-", with: "+")
.replacingOccurrences(of: "_", with: "/")
return Data(base64Encoded: base64)
}
}
+30 -15
View File
@@ -7,11 +7,6 @@ import UIKit
import AppKit
#endif
extension Notification.Name {
/// Posted after the geo relay directory successfully refreshes its entries.
static let geoRelayDirectoryDidRefresh = Notification.Name("bitchat.geoRelayDirectoryDidRefresh")
}
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
struct GeoRelayDirectoryDependencies {
var userDefaults: UserDefaults
@@ -170,16 +165,33 @@ final class GeoRelayDirectory {
}
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
/// Ties break by host so every device with the same directory picks the
/// same relay set publishers and subscribers must agree on relays.
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
guard !entries.isEmpty, count > 0 else { return [] }
return entries
.map { (entry: $0, distance: haversineKm(lat, lon, $0.lat, $0.lon)) }
.sorted { ($0.distance, $0.entry.host) < ($1.distance, $1.entry.host) }
.prefix(count)
.map { "wss://\($0.entry.host)" }
if entries.count <= count {
return entries
.sorted { a, b in
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
}
.map { "wss://\($0.host)" }
}
var best: [(entry: Entry, distance: Double)] = []
best.reserveCapacity(count)
for entry in entries {
let distance = haversineKm(lat, lon, entry.lat, entry.lon)
if best.count < count {
let idx = best.firstIndex { $0.distance > distance } ?? best.count
best.insert((entry, distance), at: idx)
} else if let worstDistance = best.last?.distance, distance < worstDistance {
let idx = best.firstIndex { $0.distance > distance } ?? best.count
best.insert((entry, distance), at: idx)
best.removeLast()
}
}
return best.map { "wss://\($0.entry.host)" }
}
// MARK: - Remote Fetch
@@ -277,8 +289,6 @@ final class GeoRelayDirectory {
isFetching = false
retryAttempt = 0
cancelRetry()
// Let waiters (e.g. location notes stuck in a "no relays" state) retry.
dependencies.notificationCenter.post(name: .geoRelayDirectoryDidRefresh, object: nil)
}
@MainActor
@@ -371,7 +381,12 @@ final class GeoRelayDirectory {
if idx == 0 && line.lowercased().contains("relay url") { continue }
let parts = line.split(separator: ",").map { $0.trimmed }
guard parts.count >= 3 else { continue }
guard let host = NostrRelayURL.directoryAddress(parts[0]) else { continue }
var host = parts[0]
host = host.replacingOccurrences(of: "https://", with: "")
host = host.replacingOccurrences(of: "http://", with: "")
host = host.replacingOccurrences(of: "wss://", with: "")
host = host.replacingOccurrences(of: "ws://", with: "")
host = host.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
result.insert(Entry(host: host, lat: lat, lon: lon))
}
-8
View File
@@ -1,4 +1,3 @@
import BitFoundation
import Foundation
import CryptoKit
@@ -82,13 +81,6 @@ final class NostrIdentityBridge {
}
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)
+35 -81
View File
@@ -39,23 +39,22 @@ struct NostrProtocol {
content: content
)
// 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S
// real identity key. NIP-17 requires the seal be signed by the sender
// so the recipient can authenticate who sent the message; signing with
// a throwaway key leaves DMs forgeable/impersonatable.
let senderKey = try senderIdentity.schnorrSigningKey()
// 2. Create ephemeral key for this message
let ephemeralKey = try P256K.Schnorr.PrivateKey()
// Created ephemeral key for seal
// 3. Seal the rumor (encrypt to recipient)
let sealedEvent = try createSeal(
rumor: rumor,
recipientPubkey: recipientPubkey,
senderKey: senderKey
senderKey: ephemeralKey
)
// 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).
// 4. Gift wrap the sealed event (encrypt to recipient again)
let giftWrap = try createGiftWrap(
seal: sealedEvent,
recipientPubkey: recipientPubkey
recipientPubkey: recipientPubkey,
senderKey: ephemeralKey
)
// Created gift wrap
@@ -85,15 +84,7 @@ struct NostrProtocol {
throw error
}
// 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
// 2. Open the seal
let rumor: NostrEvent
do {
rumor = try openSeal(
@@ -105,63 +96,10 @@ struct NostrProtocol {
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
throw error
}
// 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)
return (content: rumor.content, senderPubkey: rumor.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)
static func createEphemeralGeohashEvent(
content: String,
@@ -257,9 +195,10 @@ struct NostrProtocol {
private static func createGiftWrap(
seal: NostrEvent,
recipientPubkey: String
recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey // This is the ephemeral key used for the seal
) throws -> NostrEvent {
let sealJSON = try seal.jsonString()
// Create new ephemeral key for gift wrap
@@ -364,7 +303,7 @@ struct NostrProtocol {
combined.append(nonce24)
combined.append(sealed.ciphertext)
combined.append(sealed.tag)
return "v2:" + Base64URLCoding.encode(combined)
return "v2:" + base64URLEncode(combined)
}
private static func decrypt(
@@ -375,7 +314,7 @@ struct NostrProtocol {
// Expect NIP-44 v2 format
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
let encoded = String(ciphertext.dropFirst(3))
guard let data = Base64URLCoding.decode(encoded),
guard let data = base64URLDecode(encoded),
data.count > (24 + 16),
let senderPubkeyData = Data(hexString: senderPubkey) else {
throw NostrError.invalidCiphertext
@@ -641,14 +580,29 @@ enum NostrError: Error {
case encryptionFailed
}
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url)
private extension NostrProtocol {
static func base64URLEncode(_ data: Data) -> String {
return data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
static func base64URLDecode(_ s: String) -> Data? {
var str = s
let pad = (4 - (str.count % 4)) % 4
if pad > 0 { str += String(repeating: "=", count: pad) }
str = str.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
return Data(base64Encoded: str)
}
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: Data(),
info: Data("nip44-v2".utf8),
info: "nip44-v2".data(using: .utf8)!,
outputByteCount: 32
)
return derivedKey.withUnsafeBytes { Data($0) }
+127 -547
View File
@@ -66,9 +66,6 @@ struct NostrRelayManagerDependencies {
var makeSession: () -> NostrRelaySessionProtocol
var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
var now: () -> Date
/// Uniform random value in [0, 1) used to jitter reconnect backoff.
/// Injectable so tests can pin or sweep the jitter deterministically.
var jitterUnit: () -> Double
}
private extension NostrRelayManagerDependencies {
@@ -96,8 +93,7 @@ private extension NostrRelayManagerDependencies {
scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
},
now: Date.init,
jitterUnit: { Double.random(in: 0..<1) }
now: Date.init
)
}
}
@@ -125,22 +121,19 @@ final class NostrRelayManager: ObservableObject {
var nextReconnectTime: Date?
}
// Default relays carry NIP-17 gift wraps, so avoid relays known to reject kind 1059.
// Default relay list (can be customized)
private static let defaultRelays = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net",
"wss://offchain.pub"
"wss://offchain.pub",
"wss://nostr21.com"
// For local testing, you can add: "ws://localhost:8080"
]
private static let defaultRelaySet = Set(defaultRelays.compactMap { NostrRelayURL.normalized($0) })
private static let defaultRelaySet = Set(defaultRelays)
@Published private(set) var relays: [Relay] = []
@Published private(set) var isConnected = false
/// Whether a relay that carries private messages is connected. DMs
/// target the default (gift-wrap-capable) relay set, so a connected
/// geohash/custom relay alone must not count sends would still queue.
@Published private(set) var isDMRelayConnected = false
private let dependencies: NostrRelayManagerDependencies
private var allowDefaultRelays: Bool = false
@@ -148,51 +141,19 @@ final class NostrRelayManager: ObservableObject {
private var hasLocationPermission: Bool = false
private var connections: [String: NostrRelayConnectionProtocol] = [:]
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
// Not-yet-flushed REQs per relay, bounded by a per-relay cap (oldest by
// insertion order evicted) and an age sweep on connect attempts. Dicts are
// unordered, so each entry carries an insertion sequence and queue time.
private struct PendingSubscription {
let messageString: String // encoded REQ JSON
let queuedAt: Date
let sequence: UInt64
}
private var pendingSubscriptions: [String: [String: PendingSubscription]] = [:] // relay URL -> (subscription id -> pending REQ)
private var pendingSubscriptionSequence: UInt64 = 0
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
private struct InboundEventKey: Hashable {
let subscriptionID: String
let eventID: String
}
private let recentInboundEventKeyLimit = TransportConfig.nostrInboundEventDedupCap
private let recentInboundEventKeyTrimTarget = TransportConfig.nostrInboundEventDedupTrimTarget
private var recentInboundEventKeys = Set<InboundEventKey>()
private var recentInboundEventKeyOrder: [InboundEventKey] = []
private var duplicateInboundEventDropCount = 0
private var duplicateInboundEventDropCountBySubscription: [String: Int] = [:]
private var inboundEventLogCount = 0
// Coalesce duplicate subscribe requests for the same id within a short window.
private let subscribeCoalesceInterval: TimeInterval = 1.0
// Coalesce duplicate subscribe requests for the same id within a short window
private var subscribeCoalesce: [String: Date] = [:]
private var pendingTorConnectionURLs = Set<String>()
private var awaitingTorForConnections = false
private var torReadyWaitAttempts = 0
private var cancellables = Set<AnyCancellable>()
private struct SubscriptionRequestState: Equatable {
let messageString: String
let relayURLs: Set<String>
}
private var subscriptionRequestState: [String: SubscriptionRequestState] = [:]
// Track EOSE per subscription to signal when initial stored events are done
private struct EOSETracker {
var pendingRelays: Set<String>
var callback: () -> Void
let epoch: Int
var timer: Timer?
}
private var eoseTrackers: [String: EOSETracker] = [:]
private var eoseTrackerEpoch = 0
private var pendingEOSECallbacks: [String: () -> Void] = [:]
// Message queue for reliability
// Pending sends held only for relays that are not yet connected.
@@ -202,9 +163,6 @@ final class NostrRelayManager: ObservableObject {
}
private var messageQueue: [PendingSend] = []
private let messageQueueLock = NSLock()
// Total pending sends dropped at the queue cap; drives the sampled
// overflow warning (first + every Nth drop).
private var pendingSendDropCount = 0
private let encoder = JSONEncoder()
private var shouldUseTor: Bool { dependencies.userTorEnabled() }
@@ -275,7 +233,25 @@ final class NostrRelayManager: ObservableObject {
func connect() {
// Global network policy gate
guard dependencies.activationAllowed() else { return }
connectToRelays(relays.map(\.url), shouldLog: true)
if shouldUseTor {
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return }
if !ready {
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
return
}
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
for relay in self.relays {
self.connectToRelay(relay.url)
}
}
} else {
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
for relay in self.relays {
connectToRelay(relay.url)
}
}
}
/// Disconnect from all relays
@@ -285,78 +261,11 @@ final class NostrRelayManager: ObservableObject {
task.cancel(with: .goingAway, reason: nil)
}
connections.removeAll()
markRelaySocketsClosed(resetState: false)
// Sockets are gone, so per-relay subscription state is cleared but
// durable intent (subscriptionRequestState, messageHandlers, parked
// EOSE callbacks) is kept so REQs replay when relays reconnect
// (e.g. background foreground).
// Clear known subscriptions and any queued subs since connections are gone
subscriptions.removeAll()
pendingSubscriptions.removeAll()
// Settle in-flight initial loads instead of leaving callers hanging.
let trackers = eoseTrackers
eoseTrackers.removeAll()
for (_, tracker) in trackers {
tracker.callback()
}
pendingTorConnectionURLs.removeAll()
awaitingTorForConnections = false
torReadyWaitAttempts = 0
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].lastConnectedAt = 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).
func ensureConnections(to relayUrls: [String]) {
@@ -364,12 +273,22 @@ final class NostrRelayManager: ObservableObject {
guard dependencies.activationAllowed() else { return }
let targets = allowedRelayList(from: relayUrls)
guard !targets.isEmpty else { return }
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
// Defer until Tor is fully ready; avoid queuing connection attempts early
dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return }
if ready { self.ensureConnections(to: relayUrls) }
}
return
}
var existing = Set(relays.map { $0.url })
for url in targets where !existing.contains(url) {
relays.append(Relay(url: url))
existing.insert(url)
}
connectToRelays(targets)
for url in targets where connections[url] == nil {
connectToRelay(url)
}
}
/// Send an event to specified relays (or all if none specified)
@@ -377,14 +296,11 @@ final class NostrRelayManager: ObservableObject {
// Global network policy gate
guard dependencies.activationAllowed() else { return }
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
// Fail-closed: nothing touches the network until Tor is up. Queue the
// event locally so it survives a slow bootstrap (queued sends flush
// when relays connect), then kick off connection setup, which itself
// waits for Tor readiness.
let targetRelays = allowedRelayList(from: relayUrls ?? Self.defaultRelays)
guard !targetRelays.isEmpty else { return }
enqueuePendingSend(event, pendingRelays: Set(targetRelays))
ensureConnections(to: targetRelays)
// Defer sends until Tor is ready to avoid premature queueing
dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return }
if ready { self.sendEvent(event, to: relayUrls) }
}
return
}
let requestedRelays = relayUrls ?? Self.defaultRelays
@@ -395,36 +311,16 @@ final class NostrRelayManager: ObservableObject {
// Attempt immediate send to relays with active connections; queue the rest
var stillPending = Set<String>()
for relayUrl in targetRelays {
if let connection = connectedConnection(for: relayUrl) {
if let connection = connections[relayUrl] {
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
} else {
stillPending.insert(relayUrl)
}
}
if !stillPending.isEmpty {
enqueuePendingSend(event, pendingRelays: stillPending)
}
}
private func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set<String>) {
messageQueueLock.lock()
messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays))
let overflow = messageQueue.count - TransportConfig.nostrPendingSendQueueCap
if overflow > 0 {
messageQueue.removeFirst(overflow)
}
messageQueueLock.unlock()
guard overflow > 0 else { return }
// Dropped events are ephemeral (presence/geo), so no status surfacing
// is needed but the drops should be visible. Sampled so a sustained
// relay stall can't flood the log.
pendingSendDropCount += overflow
if pendingSendDropCount == 1 ||
pendingSendDropCount.isMultiple(of: TransportConfig.nostrPendingSendDropLogInterval) {
SecureLogger.warning(
"📤 Relay send queue full — dropped \(pendingSendDropCount) oldest event(s)",
category: .session
)
messageQueueLock.lock()
messageQueue.append(PendingSend(event: event, pendingRelays: stillPending))
messageQueueLock.unlock()
}
}
@@ -437,7 +333,7 @@ final class NostrRelayManager: ObservableObject {
// Flush only for a specific relay
for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i]
if item.pendingRelays.contains(target), let conn = connectedConnection(for: target) {
if item.pendingRelays.contains(target), let conn = connections[target] {
sendToRelay(event: item.event, connection: conn, relayUrl: target)
item.pendingRelays.remove(target)
if item.pendingRelays.isEmpty {
@@ -452,7 +348,7 @@ final class NostrRelayManager: ObservableObject {
for i in (0..<messageQueue.count).reversed() {
var item = messageQueue[i]
for url in item.pendingRelays {
if let conn = connectedConnection(for: url) {
if let conn = connections[url] {
sendToRelay(event: item.event, connection: conn, relayUrl: url)
item.pendingRelays.remove(url)
}
@@ -465,14 +361,6 @@ final class NostrRelayManager: ObservableObject {
}
}
}
private func connectedConnection(for relayUrl: String) -> NostrRelayConnectionProtocol? {
guard let connection = connections[relayUrl],
relays.first(where: { $0.url == relayUrl })?.isConnected == true else {
return nil
}
return connection
}
/// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
func subscribe(
@@ -484,12 +372,24 @@ final class NostrRelayManager: ObservableObject {
) {
// Global network policy gate
guard dependencies.activationAllowed() else { return }
// Coalesce rapid duplicate subscribe requests even while Tor readiness is pending.
// Coalesce rapid duplicate subscribe requests only if a handler already exists
let now = dependencies.now()
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval {
return
if messageHandlers[id] != nil {
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
return
}
}
subscribeCoalesce[id] = now
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
// Defer subscription setup until Tor is ready; avoid queuing subs early
dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return }
if ready {
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler, onEOSE: onEOSE)
}
}
return
}
messageHandlers[id] = handler
let req = NostrRequest.subscribe(id: id, filters: [filter])
@@ -505,30 +405,36 @@ final class NostrRelayManager: ObservableObject {
// Target specific relays if provided; else default. Filter permanently failed relays.
let baseUrls = relayUrls ?? Self.defaultRelays
let urls = allowedRelayList(from: baseUrls).filter { !isPermanentlyFailed($0) }
let requestState = SubscriptionRequestState(messageString: messageString, relayURLs: Set(urls))
if subscriptionRequestState[id] == requestState, subscriptionStateExists(id: id, requestState: requestState) {
return
}
subscriptionRequestState[id] = requestState
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
let urls = allowedRelayList(from: candidateUrls)
// Always queue subscriptions; sending happens when a relay reports connected
var existingSet = Set(relays.map { $0.url })
let existingSet = Set(relays.map { $0.url })
for url in urls where !existingSet.contains(url) {
relays.append(Relay(url: url))
existingSet.insert(url)
}
for url in urls {
queuePendingSubscription(id: id, messageString: messageString, for: url)
var map = self.pendingSubscriptions[url] ?? [:]
map[id] = messageString
self.pendingSubscriptions[url] = map
}
// Initialize EOSE tracking if requested
if let onEOSE = onEOSE {
if urls.isEmpty {
onEOSE()
} else if shouldWaitForTorBeforeConnecting {
parkEOSECallbackUntilTorReady(id: id, callback: onEOSE)
} else {
startEOSETracking(id: id, relayURLs: Set(urls), callback: onEOSE)
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
// Fallback timeout to avoid hanging if a relay never sends EOSE
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
Task { @MainActor in
guard let self = self else { return }
if let t = self.eoseTrackers[id] {
t.timer?.invalidate()
self.eoseTrackers.removeValue(forKey: id)
onEOSE()
}
}
}
eoseTrackers[id] = tracker
}
}
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
@@ -586,8 +492,7 @@ final class NostrRelayManager: ObservableObject {
private func allowedRelayList(from urls: [String]) -> [String] {
var seen = Set<String>()
var result: [String] = []
for rawURL in urls {
guard let url = NostrRelayURL.normalized(rawURL) else { continue }
for url in urls {
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
if seen.insert(url).inserted {
result.append(url)
@@ -599,16 +504,8 @@ final class NostrRelayManager: ObservableObject {
/// Unsubscribe from a subscription
func unsubscribe(id: String) {
messageHandlers.removeValue(forKey: id)
removeRecentInboundEvents(forSubscriptionID: id)
duplicateInboundEventDropCountBySubscription.removeValue(forKey: id)
// Allow immediate re-subscription by clearing coalescer timestamp
subscribeCoalesce.removeValue(forKey: id)
subscriptionRequestState.removeValue(forKey: id)
pendingEOSECallbacks.removeValue(forKey: id)
eoseTrackers.removeValue(forKey: id)
for url in Array(pendingSubscriptions.keys) {
pendingSubscriptions[url]?.removeValue(forKey: id)
}
let req = NostrRequest.close(id: id)
let message = try? encoder.encode(req)
@@ -628,246 +525,6 @@ final class NostrRelayManager: ObservableObject {
}
// MARK: - Private Methods
private var shouldWaitForTorBeforeConnecting: Bool {
shouldUseTor && !dependencies.torIsReady()
}
private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) {
guard dependencies.activationAllowed() else { return }
sweepStalePendingSubscriptions()
let targets = allowedRelayList(from: relayUrls).filter {
connections[$0] == nil && !isPermanentlyFailed($0)
}
guard !targets.isEmpty else { return }
if shouldWaitForTorBeforeConnecting {
queueConnectionsUntilTorReady(targets)
return
}
if shouldLog {
let route = shouldUseTor ? "via Tor" : "direct"
SecureLogger.debug("🌐 Connecting to \(targets.count) Nostr relay(s) (\(route))", category: .session)
}
for url in targets {
connectToRelay(url)
}
}
private func queueConnectionsUntilTorReady(_ relayUrls: [String]) {
let targets = allowedRelayList(from: relayUrls).filter {
connections[$0] == nil && !isPermanentlyFailed($0)
}
guard !targets.isEmpty else { return }
pendingTorConnectionURLs.formUnion(targets)
guard !awaitingTorForConnections else { return }
awaitingTorForConnections = true
let generation = connectionGeneration
dependencies.awaitTorReady { [weak self] ready in
guard let self else { return }
guard generation == self.connectionGeneration else { return }
let pending = Array(self.pendingTorConnectionURLs)
self.pendingTorConnectionURLs.removeAll()
self.awaitingTorForConnections = false
guard ready else {
self.torReadyWaitAttempts += 1
if self.torReadyWaitAttempts < TransportConfig.nostrTorReadyMaxWaitAttempts {
SecureLogger.warning("Tor not ready; re-queueing \(pending.count) relay connection(s) (attempt \(self.torReadyWaitAttempts))", category: .session)
self.queueConnectionsUntilTorReady(pending)
} else {
// Still fail-closed (no network), but unblock any callers
// waiting on EOSE so the UI doesn't hang indefinitely.
// Queued subscriptions/sends are kept and flush if a later
// trigger (e.g. app foreground) brings Tor up.
SecureLogger.error("❌ Tor not ready after \(self.torReadyWaitAttempts) wait(s); aborting relay connections (fail-closed)", category: .session)
self.torReadyWaitAttempts = 0
self.unblockPendingEOSECallbacks(reason: "tor-unavailable")
}
return
}
self.torReadyWaitAttempts = 0
self.connectToRelays(pending, shouldLog: true)
}
}
/// Park an EOSE callback while Tor is not yet ready, and schedule the same
/// fallback timeout `startEOSETracking` uses. Without it, a parked callback
/// would only be unblocked by Tor-readiness retry exhaustion (several
/// awaitReady timeouts, i.e. minutes), leaving callers hanging far past the
/// normal EOSE fallback. If Tor recovers first the callback is promoted to
/// a real EOSE tracker (`startPendingEOSETrackingIfNeeded`), and if retry
/// exhaustion fires first it is drained by `unblockPendingEOSECallbacks`;
/// either way it leaves `pendingEOSECallbacks` and this timer is a no-op.
private func parkEOSECallbackUntilTorReady(id: String, callback: @escaping () -> Void) {
pendingEOSECallbacks[id] = callback
let generation = connectionGeneration
dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
// Stale timers from a previous connection generation are void.
guard generation == self.connectionGeneration else { return }
// Already fired (unsubscribe, retry-exhaustion unblock) or
// promoted to a real EOSE tracker: nothing to do.
guard let callback = self.pendingEOSECallbacks.removeValue(forKey: id) else { return }
SecureLogger.warning("Unblocking Tor-parked EOSE callback for \(id) after fallback timeout", category: .session)
callback()
}
}
}
/// Fire and clear all EOSE callbacks that are parked waiting for Tor.
/// Callers treat EOSE as "initial fetch finished"; firing with no data is
/// safe and prevents indefinite hangs when Tor cannot bootstrap.
private func unblockPendingEOSECallbacks(reason: String) {
guard !pendingEOSECallbacks.isEmpty else { return }
let callbacks = pendingEOSECallbacks
pendingEOSECallbacks.removeAll()
SecureLogger.warning("Unblocking \(callbacks.count) pending EOSE callback(s) without data (\(reason))", category: .session)
for (_, callback) in callbacks {
callback()
}
}
private func subscriptionStateExists(id: String, requestState: SubscriptionRequestState) -> Bool {
guard !requestState.relayURLs.isEmpty else { return true }
return requestState.relayURLs.allSatisfy { url in
pendingSubscriptions[url]?[id]?.messageString == requestState.messageString ||
subscriptions[url]?.contains(id) == true
}
}
private func queuePendingSubscription(id: String, messageString: String, for url: String) {
var map = pendingSubscriptions[url] ?? [:]
pendingSubscriptionSequence &+= 1
map[id] = PendingSubscription(
messageString: messageString,
queuedAt: dependencies.now(),
sequence: pendingSubscriptionSequence
)
// Bound per-relay pending REQs; evict oldest by insertion order. The
// durable intent stays in subscriptionRequestState, so an evicted REQ
// is still replayed if its subscription is active when the relay
// (re)connects.
var evictedCount = 0
while map.count > TransportConfig.nostrPendingSubscriptionsPerRelayCap,
let oldest = map.min(by: { $0.value.sequence < $1.value.sequence }) {
map.removeValue(forKey: oldest.key)
evictedCount += 1
}
if evictedCount > 0 {
// Bounds proof: the cap eviction actually removed entries.
SecureLogger.warning(
"📋 Evicted \(evictedCount) pending sub(s) over cap for \(url)",
category: .session
)
}
pendingSubscriptions[url] = map
}
/// Drop pending REQs older than the TTL. Runs on connect attempts (the
/// natural maintenance path: connect/ensureConnections/reconnects all
/// funnel through connectToRelays) so stale entries for relays that never
/// come up cannot accumulate without bound.
private func sweepStalePendingSubscriptions() {
let now = dependencies.now()
for (url, map) in pendingSubscriptions {
let fresh = map.filter {
now.timeIntervalSince($0.value.queuedAt) <= TransportConfig.nostrPendingSubscriptionTTLSeconds
}
guard fresh.count != map.count else { continue }
// Bounds proof: the age sweep actually removed entries. Warning
// (not debug) stale pending REQs mean a relay never came up.
SecureLogger.warning(
"📋 Swept \(map.count - fresh.count) stale pending sub(s) for \(url)",
category: .session
)
pendingSubscriptions[url] = fresh.isEmpty ? nil : fresh
}
}
private func startEOSETracking(id: String, relayURLs: Set<String>, callback: @escaping () -> Void) {
eoseTrackerEpoch += 1
let epoch = eoseTrackerEpoch
eoseTrackers[id] = EOSETracker(pendingRelays: relayURLs, callback: callback, epoch: epoch)
// Fallback timeout to avoid hanging if a relay never sends EOSE.
dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
guard let tracker = self.eoseTrackers[id], tracker.epoch == epoch else { return }
self.eoseTrackers.removeValue(forKey: id)
tracker.callback()
}
}
}
private func startPendingEOSETrackingIfNeeded(id: String) {
guard eoseTrackers[id] == nil,
let callback = pendingEOSECallbacks.removeValue(forKey: id),
let requestState = subscriptionRequestState[id]
else {
return
}
if requestState.relayURLs.isEmpty {
callback()
} else {
startEOSETracking(id: id, relayURLs: requestState.relayURLs, callback: callback)
}
}
private func shouldDeliverInboundEvent(subscriptionID: String, eventID: String) -> Bool {
guard !eventID.isEmpty else { return true }
let key = InboundEventKey(subscriptionID: subscriptionID, eventID: eventID)
guard recentInboundEventKeys.insert(key).inserted else {
recordDuplicateInboundEventDrop(subscriptionID: subscriptionID)
return false
}
recentInboundEventKeyOrder.append(key)
if recentInboundEventKeyOrder.count > recentInboundEventKeyLimit {
let removeCount = recentInboundEventKeyOrder.count - recentInboundEventKeyTrimTarget
for staleKey in recentInboundEventKeyOrder.prefix(removeCount) {
recentInboundEventKeys.remove(staleKey)
}
recentInboundEventKeyOrder.removeFirst(removeCount)
}
return true
}
private func recordDuplicateInboundEventDrop(subscriptionID: String) {
duplicateInboundEventDropCount += 1
let subscriptionCount = (duplicateInboundEventDropCountBySubscription[subscriptionID] ?? 0) + 1
duplicateInboundEventDropCountBySubscription[subscriptionID] = subscriptionCount
if duplicateInboundEventDropCount == 1 ||
duplicateInboundEventDropCount.isMultiple(of: TransportConfig.nostrDuplicateEventLogInterval) {
SecureLogger.debug(
"Dropped duplicate Nostr event deliveries total=\(duplicateInboundEventDropCount) sub=\(subscriptionID) sub_total=\(subscriptionCount)",
category: .session
)
}
}
private func removeRecentInboundEvents(forSubscriptionID subscriptionID: String) {
guard !recentInboundEventKeyOrder.isEmpty else { return }
var retainedKeys: [InboundEventKey] = []
retainedKeys.reserveCapacity(recentInboundEventKeyOrder.count)
for key in recentInboundEventKeyOrder {
if key.subscriptionID == subscriptionID {
recentInboundEventKeys.remove(key)
} else {
retainedKeys.append(key)
}
}
recentInboundEventKeyOrder = retainedKeys
}
private func connectToRelay(_ urlString: String) {
// Global network policy gate
@@ -893,8 +550,12 @@ final class NostrRelayManager: ObservableObject {
// Attempting to connect to Nostr relay via the proxied session
// If Tor is enforced but not ready, delay connection until it is.
if shouldWaitForTorBeforeConnecting {
queueConnectionsUntilTorReady([urlString])
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return }
if ready { self.connectToRelay(urlString) }
else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) }
}
return
}
@@ -925,35 +586,25 @@ final class NostrRelayManager: ObservableObject {
}
}
/// Send queued subscriptions and replay durable ones for a relay that just
/// (re)connected. Relays drop subscriptions with the socket, so every
/// active subscription targeting this relay must be re-sent.
/// Send any queued subscriptions for a relay that just connected.
private func flushPendingSubscriptions(for relayUrl: String) {
guard let map = pendingSubscriptions[relayUrl], !map.isEmpty else { return }
guard let connection = connections[relayUrl] else { return }
var toSend = (pendingSubscriptions[relayUrl] ?? [:]).mapValues(\.messageString)
for (id, state) in subscriptionRequestState where state.relayURLs.contains(relayUrl) && toSend[id] == nil {
toSend[id] = state.messageString
}
for (id, messageString) in toSend {
for (id, messageString) in map {
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
startPendingEOSETrackingIfNeeded(id: id)
connection.send(.string(messageString)) { [weak self, weak connection] error in
Task { @MainActor [weak self] in
guard let self else { return }
if let error = error {
// Keep the pending entry; the next (re)connect retries it.
SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
} else {
// A stale completion from a socket that has since been
// replaced must not mark the subscription active, or
// the next connection would skip replaying it.
guard let connection, self.connections[relayUrl] === connection else { return }
self.subscriptions[relayUrl, default: []].insert(id)
self.pendingSubscriptions[relayUrl]?.removeValue(forKey: id)
connection.send(.string(messageString)) { error in
if let error = error {
SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
} else {
Task { @MainActor in
var subs = self.subscriptions[relayUrl] ?? Set<String>()
subs.insert(id)
self.subscriptions[relayUrl] = subs
}
}
}
}
pendingSubscriptions[relayUrl] = nil
}
private func receiveMessage(from task: NostrRelayConnectionProtocol, relayUrl: String) {
@@ -991,26 +642,12 @@ final class NostrRelayManager: ObservableObject {
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
switch parsed {
case .event(let subId, let event):
if event.kind != 1059 {
SecureLogger.debug("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
}
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
self.relays[index].messagesReceived += 1
}
guard event.isValidSignature() else {
SecureLogger.warning(
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
category: .session
)
return
}
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
return
}
if event.kind != 1059 {
// Per-event logging floods dev builds in busy geohashes; sample it.
inboundEventLogCount += 1
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
}
}
if let handler = self.messageHandlers[subId] {
handler(event)
} else {
@@ -1020,6 +657,7 @@ final class NostrRelayManager: ObservableObject {
if var tracker = eoseTrackers[subId] {
tracker.pendingRelays.remove(relayUrl)
if tracker.pendingRelays.isEmpty {
tracker.timer?.invalidate()
eoseTrackers.removeValue(forKey: subId)
tracker.callback()
} else {
@@ -1033,9 +671,9 @@ final class NostrRelayManager: ObservableObject {
} else {
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
if isGiftWrap {
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16)) relay=\(relayUrl) reason=\(reason)", category: .session)
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
} else {
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16)) relay=\(relayUrl) reason=\(reason)", category: .session)
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
}
}
case .notice:
@@ -1091,35 +729,19 @@ final class NostrRelayManager: ObservableObject {
private func updateConnectionStatus() {
isConnected = relays.contains { $0.isConnected }
// Relay URLs are normalized before entries are created, so direct
// set membership is sound.
isDMRelayConnected = relays.contains { $0.isConnected && Self.defaultRelaySet.contains($0.url) }
}
/// A relay that drops before sending EOSE must not stall initial-load
/// callbacks; treat it as done and let the remaining relays (or the
/// fallback timeout) drive completion.
private func settleEOSETrackers(droppingRelay relayUrl: String) {
for (id, var tracker) in eoseTrackers where tracker.pendingRelays.contains(relayUrl) {
tracker.pendingRelays.remove(relayUrl)
if tracker.pendingRelays.isEmpty {
eoseTrackers.removeValue(forKey: id)
tracker.callback()
} else {
eoseTrackers[id] = tracker
}
}
}
private func handleDisconnection(relayUrl: String, error: Error) {
// If networking is disallowed, do not schedule reconnection
if !dependencies.activationAllowed() {
connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error)
return
}
connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error)
settleEOSETrackers(droppingRelay: relayUrl)
// If networking is disallowed, do not schedule reconnection
if !dependencies.activationAllowed() {
return
}
// Check if this is a DNS or handshake error; treat as permanent
let errorDescription = error.localizedDescription.lowercased()
@@ -1150,26 +772,16 @@ final class NostrRelayManager: ObservableObject {
return
}
// Calculate backoff interval with ±jitterRatio random jitter so relays
// that dropped together don't all reconnect at the same instant.
let baseBackoffInterval = min(
// Calculate backoff interval
let backoffInterval = min(
initialBackoffInterval * pow(backoffMultiplier, Double(relays[index].reconnectAttempts - 1)),
maxBackoffInterval
)
let jitterRatio = TransportConfig.nostrRelayBackoffJitterRatio
let jitterFactor = 1.0 + (dependencies.jitterUnit() * 2.0 - 1.0) * jitterRatio
let backoffInterval = baseBackoffInterval * jitterFactor
let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval)
relays[index].nextReconnectTime = nextReconnectTime
// Reconnects are bounded by maxReconnectAttempts and exponentially
// backed off, so this is low-frequency: plain debug, no sampling.
SecureLogger.debug(
"🔄 Reconnect \(relayUrl) in \(String(format: "%.1f", backoffInterval))s (base \(String(format: "%.1f", baseBackoffInterval))s, attempt \(relays[index].reconnectAttempts)/\(maxReconnectAttempts))",
category: .session
)
// Schedule reconnection with exponential backoff
let gen = connectionGeneration
dependencies.scheduleAfter(backoffInterval) { [weak self] in
@@ -1189,8 +801,7 @@ final class NostrRelayManager: ObservableObject {
/// Manually retry connection to a specific relay
func retryConnection(to relayUrl: String) {
let normalizedRelayUrl = NostrRelayURL.normalized(relayUrl) ?? relayUrl
guard let index = relays.firstIndex(where: { $0.url == normalizedRelayUrl }) else { return }
guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return }
// Reset reconnection attempts
relays[index].reconnectAttempts = 0
@@ -1198,13 +809,13 @@ final class NostrRelayManager: ObservableObject {
relays[index].lastError = nil
// Disconnect if connected
if let connection = connections[normalizedRelayUrl] {
if let connection = connections[relayUrl] {
connection.cancel(with: .goingAway, reason: nil)
connections.removeValue(forKey: normalizedRelayUrl)
connections.removeValue(forKey: relayUrl)
}
// Attempt immediate reconnection
connectToRelay(normalizedRelayUrl)
connectToRelay(relayUrl)
}
/// Get detailed status for all relays
@@ -1227,31 +838,6 @@ final class NostrRelayManager: ObservableObject {
pendingSubscriptions[relayUrl]?.count ?? 0
}
func debugPendingSubscriptionIDs(for relayUrl: String) -> Set<String> {
guard let map = pendingSubscriptions[relayUrl] else { return [] }
return Set(map.keys)
}
var debugMessageHandlerCount: Int {
messageHandlers.count
}
var debugSubscriptionRequestCount: Int {
subscriptionRequestState.count
}
var debugPendingEOSECallbackCount: Int {
pendingEOSECallbacks.count
}
var debugDuplicateInboundEventDropCount: Int {
duplicateInboundEventDropCount
}
func debugDuplicateInboundEventDropCount(forSubscriptionID subscriptionID: String) -> Int {
duplicateInboundEventDropCountBySubscription[subscriptionID] ?? 0
}
func debugFlushMessageQueue() {
flushMessageQueue(for: nil)
}
@@ -1276,13 +862,6 @@ final class NostrRelayManager: ObservableObject {
// MARK: - Failure classification
private func isPermanentlyFailed(_ url: String) -> Bool {
guard let r = relays.first(where: { $0.url == url }) else { return false }
// Failures decay: after a cooldown the relay gets another chance, so a
// long network outage or transient relay trouble can't blacklist it
// for the rest of the process lifetime.
if let lastDisconnect = r.lastDisconnectedAt,
dependencies.now().timeIntervalSince(lastDisconnect) >= TransportConfig.nostrRelayFailureCooldownSeconds {
return false
}
if r.reconnectAttempts >= maxReconnectAttempts { return true }
if let ns = r.lastError as NSError?, ns.domain == NSURLErrorDomain {
if ns.code == NSURLErrorBadServerResponse || ns.code == NSURLErrorCannotFindHost {
@@ -1314,7 +893,8 @@ private enum ParsedInbound {
if array.count >= 3,
let subId = array[1] as? String,
let eventDict = array[2] as? [String: Any],
let event = try? NostrEvent(from: eventDict) {
let event = try? NostrEvent(from: eventDict),
event.isValidSignature() {
self = .event(subId: subId, event: event)
return
}
-51
View File
@@ -1,51 +0,0 @@
import Foundation
enum NostrRelayURL {
static func normalized(_ rawValue: String, defaultScheme: String? = nil) -> String? {
var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return nil }
if !value.contains("://"), let defaultScheme {
value = "\(defaultScheme)://\(value)"
}
guard var components = URLComponents(string: value),
let rawScheme = components.scheme?.lowercased(),
let rawHost = components.host?.lowercased(),
!rawHost.isEmpty else {
return nil
}
switch rawScheme {
case "wss", "https":
components.scheme = "wss"
if components.port == 443 {
components.port = nil
}
case "ws", "http":
components.scheme = "ws"
if components.port == 80 {
components.port = nil
}
default:
return nil
}
components.host = rawHost
if components.path == "/" {
components.path = ""
}
components.fragment = nil
return components.string
}
static func directoryAddress(_ rawValue: String) -> String? {
guard var normalized = normalized(rawValue, defaultScheme: "wss") else { return nil }
for prefix in ["wss://", "ws://"] where normalized.hasPrefix(prefix) {
normalized.removeFirst(prefix.count)
break
}
return normalized
}
}
@@ -132,3 +132,4 @@ private extension Data {
replaceSubrange(offset..<(offset+4), with: bytes)
}
}
@@ -5,15 +5,15 @@
// Binary encoding utilities for efficient protocol messages
//
import struct Foundation.Data
import struct Foundation.Date
import Foundation
import BitFoundation
// MARK: - Binary Encoding Utilities
extension Data {
// MARK: Writing
@inlinable public mutating func appendUInt8(_ value: UInt8) {
@inlinable mutating func appendUInt8(_ value: UInt8) {
self.append(value)
}
@@ -35,7 +35,7 @@ extension Data {
}
}
public mutating func appendString(_ string: String, maxLength: Int = 255) {
mutating func appendString(_ string: String, maxLength: Int = 255) {
guard let data = string.data(using: .utf8) else { return }
let length = Swift.min(data.count, maxLength)
@@ -48,7 +48,7 @@ extension Data {
self.append(data.prefix(length))
}
public mutating func appendData(_ data: Data, maxLength: Int = 65535) {
mutating func appendData(_ data: Data, maxLength: Int = 65535) {
let length = Swift.min(data.count, maxLength)
if maxLength <= 255 {
@@ -60,12 +60,12 @@ extension Data {
self.append(data.prefix(length))
}
public mutating func appendDate(_ date: Date) {
mutating func appendDate(_ date: Date) {
let timestamp = UInt64(date.timeIntervalSince1970 * 1000) // milliseconds
self.appendUInt64(timestamp)
}
public mutating func appendUUID(_ uuid: String) {
mutating func appendUUID(_ uuid: String) {
// Convert UUID string to 16 bytes
var uuidData = Data(count: 16)
@@ -86,7 +86,7 @@ extension Data {
// MARK: Reading
@inlinable public func readUInt8(at offset: inout Int) -> UInt8? {
@inlinable func readUInt8(at offset: inout Int) -> UInt8? {
guard offset >= 0 && offset < self.count else { return nil }
let value = self[offset]
offset += 1
@@ -120,7 +120,7 @@ extension Data {
return value
}
public func readString(at offset: inout Int, maxLength: Int = 255) -> String? {
func readString(at offset: inout Int, maxLength: Int = 255) -> String? {
let length: Int
if maxLength <= 255 {
@@ -139,7 +139,7 @@ extension Data {
return String(data: stringData, encoding: .utf8)
}
public func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? {
func readData(at offset: inout Int, maxLength: Int = 65535) -> Data? {
let length: Int
if maxLength <= 255 {
@@ -158,12 +158,12 @@ extension Data {
return data
}
public func readDate(at offset: inout Int) -> Date? {
func readDate(at offset: inout Int) -> Date? {
guard let timestamp = readUInt64(at: &offset) else { return nil }
return Date(timeIntervalSince1970: Double(timestamp) / 1000.0)
}
public func readUUID(at offset: inout Int) -> String? {
func readUUID(at offset: inout Int) -> String? {
guard offset + 16 <= self.count else { return nil }
let uuidData = self[offset..<offset + 16]
@@ -184,7 +184,7 @@ extension Data {
return result.uppercased()
}
public func readFixedBytes(at offset: inout Int, count: Int) -> Data? {
func readFixedBytes(at offset: inout Int, count: Int) -> Data? {
guard offset + count <= self.count else { return nil }
let data = self[offset..<offset + count]
@@ -88,31 +88,40 @@
/// - Platform-optimized byte swapping
///
import struct Foundation.Data
import class Foundation.NSData
private import BitLogger
import Foundation
import BitLogger
extension Data {
func trimmingNullBytes() -> Data {
// Find the first null byte
if let nullIndex = self.firstIndex(of: 0) {
return self.prefix(nullIndex)
}
return self
}
}
/// Implements binary encoding and decoding for BitChat protocol messages.
/// Provides static methods for converting between BitchatPacket objects and
/// their binary wire format representation.
/// - Note: All multi-byte values use network byte order (big-endian)
public struct BinaryProtocol {
public static let v1HeaderSize = 14
struct BinaryProtocol {
static let v1HeaderSize = 14
static let v2HeaderSize = 16
public static let senderIDSize = 8
public static let recipientIDSize = 8
public static let signatureSize = 64
static let senderIDSize = 8
static let recipientIDSize = 8
static let signatureSize = 64
// Field offsets within packet header
public struct Offsets {
struct Offsets {
static let version = 0
static let type = 1
static let ttl = 2
static let timestamp = 3
public static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
static let flags = 11 // After version(1) + type(1) + ttl(1) + timestamp(8)
}
public static func headerSize(for version: UInt8) -> Int? {
static func headerSize(for version: UInt8) -> Int? {
switch version {
case 1: return v1HeaderSize
case 2: return v2HeaderSize
@@ -124,11 +133,11 @@ public struct BinaryProtocol {
return version == 2 ? 4 : 2
}
public struct Flags {
public static let hasRecipient: UInt8 = 0x01
public static let hasSignature: UInt8 = 0x02
public static let isCompressed: UInt8 = 0x04
public static let hasRoute: UInt8 = 0x08
struct Flags {
static let hasRecipient: UInt8 = 0x01
static let hasSignature: UInt8 = 0x02
static let isCompressed: UInt8 = 0x04
static let hasRoute: UInt8 = 0x08
static let isRSR: UInt8 = 0x10
}
@@ -257,7 +266,7 @@ public struct BinaryProtocol {
}
// Decode binary data to BitchatPacket
public static func decode(_ data: Data) -> BitchatPacket? {
static func decode(_ data: Data) -> BitchatPacket? {
// Try decode as-is first (robust when padding wasn't applied)
if let pkt = decodeCore(data) { return pkt }
// If that fails, try after removing padding
+7 -12
View File
@@ -7,7 +7,6 @@
//
import Foundation
import BitFoundation
import BitLogger
/// TLV payload for Bluetooth mesh file transfers (voice notes, images, generic files).
@@ -28,14 +27,12 @@ struct BitchatFilePacket {
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
/// `FileTransferLimits.maxWifiBulkPayloadBytes`.
func encode(limit: Int = FileTransferLimits.maxPayloadBytes) -> Data? {
func encode() -> Data? {
let resolvedSize = fileSize ?? UInt64(content.count)
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
guard resolvedSize <= UInt64(limit) else { return nil }
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
guard content.count <= Int(UInt32.max) else { return nil }
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
var big = value.bigEndian
@@ -68,9 +65,7 @@ struct BitchatFilePacket {
}
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
/// the (smaller of the) accepted-offer size and the Wi-Fi bulk ceiling.
static func decode(_ data: Data, limit: Int = FileTransferLimits.maxPayloadBytes) -> BitchatFilePacket? {
static func decode(_ data: Data) -> BitchatFilePacket? {
var cursor = data.startIndex
let end = data.endIndex
@@ -130,7 +125,7 @@ struct BitchatFilePacket {
for byte in value {
size = (size << 8) | UInt64(byte)
}
if size > UInt64(limit) {
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
return nil
}
fileSize = size
@@ -139,7 +134,7 @@ struct BitchatFilePacket {
mimeType = String(data: Data(value), encoding: .utf8)
case .content:
let proposedSize = content.count + value.count
if proposedSize > limit {
if proposedSize > FileTransferLimits.maxPayloadBytes {
return nil
}
content.append(contentsOf: value)
@@ -149,7 +144,7 @@ struct BitchatFilePacket {
}
guard !content.isEmpty else { return nil }
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
return BitchatFilePacket(
fileName: fileName,
fileSize: fileSize ?? UInt64(content.count),
+64 -6
View File
@@ -62,6 +62,40 @@ import Foundation
import CoreBluetooth
import BitFoundation
// MARK: - Message Types
/// Simplified BitChat protocol message types.
/// Reduced from 24 types to just 6 essential ones.
/// All private communication metadata (receipts, status) is embedded in noiseEncrypted payloads.
enum MessageType: UInt8 {
// Public messages (unencrypted)
case announce = 0x01 // "I'm here" with nickname
case message = 0x02 // Public chat message
case leave = 0x03 // "I'm leaving"
case requestSync = 0x21 // GCS filter-based sync request (local-only)
// Noise encryption
case noiseHandshake = 0x10 // Handshake (init or response determined by payload)
case noiseEncrypted = 0x11 // All encrypted payloads (messages, receipts, etc.)
// Fragmentation (simplified)
case fragment = 0x20 // Single fragment type for large messages
case fileTransfer = 0x22 // Binary file/audio/image payloads
var description: String {
switch self {
case .announce: return "announce"
case .message: return "message"
case .leave: return "leave"
case .requestSync: return "requestSync"
case .noiseHandshake: return "noiseHandshake"
case .noiseEncrypted: return "noiseEncrypted"
case .fragment: return "fragment"
case .fileTransfer: return "fileTransfer"
}
}
}
// MARK: - Noise Payload Types
/// Types of payloads embedded within noiseEncrypted messages.
@@ -72,20 +106,15 @@ enum NoisePayloadType: UInt8 {
case privateMessage = 0x01 // Private chat message
case readReceipt = 0x02 // Message was read
case delivered = 0x03 // Message was delivered
// Wi-Fi bulk transport negotiation (AWDL data plane for large media)
case bulkTransferOffer = 0x04 // Offer to move a large file over peer-to-peer Wi-Fi
case bulkTransferResponse = 0x05 // Accept/decline reply to a bulk transfer offer
// Verification (QR-based OOB binding)
case verifyChallenge = 0x10 // Verification challenge
case verifyResponse = 0x11 // Verification response
var description: String {
switch self {
case .privateMessage: return "privateMessage"
case .readReceipt: return "readReceipt"
case .delivered: return "delivered"
case .bulkTransferOffer: return "bulkTransferOffer"
case .bulkTransferResponse: return "bulkTransferResponse"
case .verifyChallenge: return "verifyChallenge"
case .verifyResponse: return "verifyResponse"
}
@@ -103,6 +132,35 @@ enum LazyHandshakeState {
case failed(Error) // Handshake failed
}
// MARK: - Delivery Status
// Delivery status for messages
enum DeliveryStatus: Codable, Equatable, Hashable {
case sending
case sent // Left our device
case delivered(to: String, at: Date) // Confirmed by recipient
case read(by: String, at: Date) // Seen by recipient
case failed(reason: String)
case partiallyDelivered(reached: Int, total: Int) // For rooms
var displayText: String {
switch self {
case .sending:
return "Sending..."
case .sent:
return "Sent"
case .delivered(let nickname, _):
return "Delivered to \(nickname)"
case .read(let nickname, _):
return "Read by \(nickname)"
case .failed(let reason):
return "Failed: \(reason)"
case .partiallyDelivered(let reached, let total):
return "Delivered to \(reached)/\(total)"
}
}
}
// MARK: - Delegate Protocol
protocol BitchatDelegate: AnyObject {
+1 -1
View File
@@ -18,7 +18,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
case .city: return 5
case .province: return 4
case .region: return 2
}
}
}
var displayName: String {
+1 -31
View File
@@ -1,4 +1,3 @@
import BitFoundation
import Foundation
// MARK: - Protocol TLV Packets
@@ -8,28 +7,12 @@ struct AnnouncementPacket {
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
let signingPublicKey: Data // Ed25519 public key for signing
let directNeighbors: [Data]? // 8-byte peer IDs
let capabilities: PeerCapabilities? // advertised feature bits; nil when absent (old clients)
init(
nickname: String,
noisePublicKey: Data,
signingPublicKey: Data,
directNeighbors: [Data]?,
capabilities: PeerCapabilities? = nil
) {
self.nickname = nickname
self.noisePublicKey = noisePublicKey
self.signingPublicKey = signingPublicKey
self.directNeighbors = directNeighbors
self.capabilities = capabilities
}
private enum TLVType: UInt8 {
case nickname = 0x01
case noisePublicKey = 0x02
case signingPublicKey = 0x03
case directNeighbors = 0x04
case capabilities = 0x05
}
func encode() -> Data? {
@@ -65,15 +48,6 @@ struct AnnouncementPacket {
}
}
// TLV for capabilities (optional)
if let capabilities = capabilities {
let capabilityBytes = capabilities.encoded()
guard capabilityBytes.count <= 255 else { return nil }
data.append(TLVType.capabilities.rawValue)
data.append(UInt8(capabilityBytes.count))
data.append(capabilityBytes)
}
return data
}
@@ -83,7 +57,6 @@ struct AnnouncementPacket {
var noisePublicKey: Data?
var signingPublicKey: Data?
var directNeighbors: [Data]?
var capabilities: PeerCapabilities?
while offset + 2 <= data.count {
let typeRaw = data[offset]
@@ -114,8 +87,6 @@ struct AnnouncementPacket {
}
directNeighbors = neighbors
}
case .capabilities:
capabilities = PeerCapabilities(encoded: Data(value))
}
} else {
// Unknown TLV; skip (tolerant decoder for forward compatibility)
@@ -128,8 +99,7 @@ struct AnnouncementPacket {
nickname: nickname,
noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey,
directNeighbors: directNeighbors,
capabilities: capabilities
directNeighbors: directNeighbors
)
}
}
@@ -1,7 +0,0 @@
import BitFoundation
extension PeerCapabilities {
/// Capabilities this build advertises in its announce packets.
/// Each feature adds its bit here when it ships.
static let localSupported: PeerCapabilities = TransportConfig.wifiBulkEnabled ? [.wifiBulk] : []
}
@@ -1,231 +0,0 @@
import BitFoundation
import BitLogger
import Foundation
/// Narrow environment for `BLEAnnounceHandler`.
///
/// All queue hops (collections barrier, BLE-queue link-state reads, main-actor
/// UI notification, delayed re-announce) live inside the closures supplied by
/// `BLEService`, keeping the handler queue-agnostic and synchronously testable.
struct BLEAnnounceHandlerEnvironment {
/// Local peer identity at the time the announce is handled.
let localPeerID: () -> PeerID
/// TTL value used for direct (non-relayed) packets.
let messageTTL: UInt8
/// Current time source.
let now: () -> Date
/// Noise public key already recorded for the peer, if any (registry read).
let existingNoisePublicKey: (PeerID) -> Data?
/// Verifies the packet signature against the announced signing key.
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
/// Direct link state for the peer (BLE-queue read).
let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool)
/// Runs the registry mutation phase under the collections barrier.
let withRegistryBarrier: (() -> Void) -> Void
/// Upserts the verified announce into the peer registry.
/// Must only be called from inside `withRegistryBarrier`.
let upsertVerifiedAnnounce: (
_ peerID: PeerID,
_ announcement: AnnouncementPacket,
_ isConnected: Bool,
_ now: Date
) -> BLEPeerAnnounceUpdate
/// Debounced reconnect-log decision.
/// Must only be called from inside `withRegistryBarrier`.
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
/// Records verified direct-neighbor claims in the mesh topology.
let updateTopology: (_ peerID: PeerID, _ neighbors: [Data]) -> Void
/// Persists the announced cryptographic identity for offline verification.
let persistIdentity: (AnnouncementPacket) -> Void
/// Announce-back dedup check.
let dedupContains: (String) -> Bool
/// Announce-back dedup marking.
let dedupMarkProcessed: (String) -> Void
/// Delivers the announce UI events as one ordered main-actor hop:
/// `.peerConnected` (if flagged) initial gossip sync scheduling (if
/// flagged) peer-ID snapshot + data publish + `.peerListUpdated`.
/// A single closure keeps the original in-order delivery guarantee that
/// separate unstructured tasks would not provide.
let deliverAnnounceUIEvents: (
_ peerID: PeerID,
_ notifyPeerConnected: Bool,
_ scheduleInitialSync: Bool
) -> Void
/// Tracks the announce packet for gossip sync.
let trackPacketSeen: (BitchatPacket) -> Void
/// Reciprocates the announce for bidirectional discovery.
let sendAnnounceBack: () -> Void
/// Schedules a delayed re-announce (afterglow) after the given delay.
let scheduleAfterglow: (TimeInterval) -> Void
}
/// Outcome of an accepted announce, surfaced so the service can run
/// follow-up work (e.g. courier handover) that keys off the announce.
struct BLEAnnounceHandlingResult {
let peerID: PeerID
let announcement: AnnouncementPacket
let isDirectAnnounce: Bool
let isVerified: Bool
}
/// Orchestrates inbound announce packets: preflight validation, signature
/// trust, registry/topology updates, identity persistence, UI notification,
/// gossip tracking, and the reciprocal announce response.
final class BLEAnnounceHandler {
private let environment: BLEAnnounceHandlerEnvironment
init(environment: BLEAnnounceHandlerEnvironment) {
self.environment = environment
}
@discardableResult
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> BLEAnnounceHandlingResult? {
let env = environment
let now = env.now()
let preflight = BLEAnnouncePreflightPolicy.evaluate(
packet: packet,
from: peerID,
localPeerID: env.localPeerID(),
now: now
)
let announcement: AnnouncementPacket
switch preflight {
case .accept(let acceptance):
announcement = acceptance.announcement
case .reject(.malformed):
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))", category: .session)
return nil
case .reject(.senderMismatch(let derivedFromKey)):
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))", category: .security)
return nil
case .reject(.selfAnnounce):
return nil
case .reject(.stale(let ageSeconds)):
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
return nil
}
// Suppress announce logs to reduce noise
// Precompute signature verification outside barrier to reduce contention
let existingNoisePublicKey = env.existingNoisePublicKey(peerID)
let hasSignature = packet.signature != nil
let signatureValid: Bool
if hasSignature {
signatureValid = env.verifySignature(packet, announcement.signingPublicKey)
if !signatureValid {
SecureLogger.warning("⚠️ Signature verification for announce failed \(peerID.id.prefix(8))", category: .security)
}
} else {
signatureValid = false
}
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
hasSignature: hasSignature,
signatureValid: signatureValid,
existingNoisePublicKey: existingNoisePublicKey,
announcedNoisePublicKey: announcement.noisePublicKey
)
if case .reject(.keyMismatch) = trustDecision {
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
}
let verifiedAnnounce = trustDecision.isVerified
var isNewPeer = false
var isReconnectedPeer = false
let directLinkState = env.linkState(peerID)
let isDirectAnnounce = packet.ttl == env.messageTTL
env.withRegistryBarrier {
let hasPeripheralConnection = directLinkState.hasPeripheral
let hasCentralSubscription = directLinkState.hasCentral
// Require verified announce; ignore otherwise (no backward compatibility)
if !verifiedAnnounce {
SecureLogger.warning("❌ Ignoring unverified announce from \(peerID.id.prefix(8))", category: .security)
// Reset flags to prevent post-barrier code from acting on unverified announces
isNewPeer = false
isReconnectedPeer = false
return
}
let update = env.upsertVerifiedAnnounce(
peerID,
announcement,
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
now
)
isNewPeer = update.isNewPeer
isReconnectedPeer = update.wasDisconnected
// Log connection status only for direct connectivity changes; debounce to reduce spam
if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription {
let now = env.now()
if update.isNewPeer {
SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session)
} else if update.wasDisconnected {
if env.shouldEmitReconnectLog(peerID, now) {
SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session)
}
} else if let previousNickname = update.previousNickname, previousNickname != announcement.nickname {
SecureLogger.debug("🔄 Peer \(peerID.id.prefix(8))… changed nickname: \(previousNickname) -> \(announcement.nickname)", category: .session)
}
}
}
// Update topology with verified neighbor claims (only for authenticated announces)
if verifiedAnnounce, let neighbors = announcement.directNeighbors {
env.updateTopology(peerID, neighbors)
}
// Persist cryptographic identity and signing key for robust offline
// 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 shouldSendBack = !env.dedupContains(announceBackID)
if shouldSendBack {
env.dedupMarkProcessed(announceBackID)
}
let responsePlan = BLEAnnounceResponsePolicy.plan(
isDirectAnnounce: isDirectAnnounce,
isNewPeer: isNewPeer,
isReconnectedPeer: isReconnectedPeer,
shouldSendAnnounceBack: shouldSendBack
)
// Only notify of connection for new or reconnected peers when it is a
// direct announce; the list update always follows in the same hop.
env.deliverAnnounceUIEvents(
peerID,
responsePlan.shouldNotifyPeerConnected,
responsePlan.shouldNotifyPeerConnected && responsePlan.shouldScheduleInitialSync
)
// Track for sync (include our own and others' announces)
env.trackPacketSeen(packet)
if responsePlan.shouldSendAnnounceBack {
// Reciprocate announce for bidirectional discovery
// Force send to ensure the peer receives our announce
env.sendAnnounceBack()
}
// Afterglow: on first-seen peers, schedule a short re-announce to push presence one more hop
if responsePlan.shouldScheduleAfterglow {
let delay = Double.random(in: 0.3...0.6)
env.scheduleAfterglow(delay)
}
return BLEAnnounceHandlingResult(
peerID: peerID,
announcement: announcement,
isDirectAnnounce: isDirectAnnounce,
isVerified: verifiedAnnounce
)
}
}
@@ -1,116 +0,0 @@
import BitFoundation
import Foundation
struct BLEAnnouncePreflightAcceptance {
let announcement: AnnouncementPacket
let derivedPeerID: PeerID
}
enum BLEAnnouncePreflightRejection: Equatable {
case malformed
case senderMismatch(derivedPeerID: PeerID)
case selfAnnounce
case stale(ageSeconds: Double)
}
enum BLEAnnouncePreflightDecision {
case accept(BLEAnnouncePreflightAcceptance)
case reject(BLEAnnouncePreflightRejection)
}
enum BLEAnnouncePreflightPolicy {
static func evaluate(
packet: BitchatPacket,
from peerID: PeerID,
localPeerID: PeerID,
now: Date
) -> BLEAnnouncePreflightDecision {
guard let announcement = AnnouncementPacket.decode(from: packet.payload) else {
return .reject(.malformed)
}
let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
guard derivedPeerID == peerID else {
return .reject(.senderMismatch(derivedPeerID: derivedPeerID))
}
guard peerID != localPeerID else {
return .reject(.selfAnnounce)
}
guard !BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) else {
return .reject(.stale(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
timestampMilliseconds: packet.timestamp,
now: now
)))
}
return .accept(BLEAnnouncePreflightAcceptance(
announcement: announcement,
derivedPeerID: derivedPeerID
))
}
}
enum BLEAnnounceTrustRejection: Equatable {
case missingSignature
case invalidSignature
case keyMismatch
}
enum BLEAnnounceTrustDecision: Equatable {
case verified
case reject(BLEAnnounceTrustRejection)
var isVerified: Bool {
self == .verified
}
}
enum BLEAnnounceTrustPolicy {
static func evaluate(
hasSignature: Bool,
signatureValid: Bool,
existingNoisePublicKey: Data?,
announcedNoisePublicKey: Data
) -> BLEAnnounceTrustDecision {
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
return .reject(.keyMismatch)
}
guard hasSignature else {
return .reject(.missingSignature)
}
guard signatureValid else {
return .reject(.invalidSignature)
}
return .verified
}
}
struct BLEAnnounceResponsePlan: Equatable {
let shouldNotifyPeerConnected: Bool
let shouldScheduleInitialSync: Bool
let shouldSendAnnounceBack: Bool
let shouldScheduleAfterglow: Bool
}
enum BLEAnnounceResponsePolicy {
static func plan(
isDirectAnnounce: Bool,
isNewPeer: Bool,
isReconnectedPeer: Bool,
shouldSendAnnounceBack: Bool
) -> BLEAnnounceResponsePlan {
let shouldNotifyPeerConnected = isDirectAnnounce && (isNewPeer || isReconnectedPeer)
return BLEAnnounceResponsePlan(
shouldNotifyPeerConnected: shouldNotifyPeerConnected,
shouldScheduleInitialSync: shouldNotifyPeerConnected,
shouldSendAnnounceBack: shouldSendAnnounceBack,
shouldScheduleAfterglow: isNewPeer
)
}
}
@@ -1,31 +0,0 @@
import Foundation
struct BLEAnnounceThrottle {
private var lastSent: Date
private let normalMinimumInterval: TimeInterval
private let forcedMinimumInterval: TimeInterval
init(
lastSent: Date = .distantPast,
normalMinimumInterval: TimeInterval = TransportConfig.bleAnnounceMinInterval,
forcedMinimumInterval: TimeInterval = TransportConfig.bleForceAnnounceMinIntervalSeconds
) {
self.lastSent = lastSent
self.normalMinimumInterval = normalMinimumInterval
self.forcedMinimumInterval = forcedMinimumInterval
}
func elapsed(since now: Date) -> TimeInterval {
now.timeIntervalSince(lastSent)
}
mutating func shouldSend(force: Bool, now: Date) -> Bool {
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
guard elapsed(since: now) >= minimumInterval else {
return false
}
lastSent = now
return true
}
}
@@ -1,293 +0,0 @@
import Foundation
struct BLEConnectionCandidate<Peripheral> {
let peripheral: Peripheral
let peripheralID: String
let rssi: Int
let name: String
let isConnectable: Bool
let discoveredAt: Date
}
struct BLEExistingConnectionState {
let isConnecting: Bool
let isConnected: Bool
let lastConnectionAttempt: Date?
}
enum BLEPeripheralConnectionState {
case disconnected
case connecting
case connected
}
enum BLEDiscoveryDecision: Equatable {
case ignore
case queued
case scheduleRetry(after: TimeInterval)
case cancelStaleConnection
case connectNow
}
enum BLEConnectionQueueDecision<Peripheral> {
case none
case retryAfter(TimeInterval)
case connect(BLEConnectionCandidate<Peripheral>)
}
final class BLEConnectionScheduler<Peripheral> {
private let maxCentralLinks: Int
private let connectRateLimitInterval: TimeInterval
private let candidateCap: Int
private let weakLinkCooldownSeconds: TimeInterval
private let weakLinkRSSICutoff: Int
private var lastGlobalConnectAttempt: Date = .distantPast
private var candidates: [BLEConnectionCandidate<Peripheral>] = []
private var failureCounts: [String: Int] = [:]
private var recentConnectTimeouts: [String: Date] = [:]
// Tracked separately from connect timeouts: a peer we held a connection
// with and lost (walked out of range) usually comes back, so it only gets
// a brief rediscovery ignore not the timeout backoff/cooldown treatment
// reserved for peers that never answered a connect attempt.
private var recentDisconnects: [String: Date] = [:]
private var lastIsolatedAt: Date?
private let initialDynamicRSSIThreshold: Int
private(set) var dynamicRSSIThreshold: Int
var candidateCount: Int {
candidates.count
}
init(
maxCentralLinks: Int = TransportConfig.bleMaxCentralLinks,
connectRateLimitInterval: TimeInterval = TransportConfig.bleConnectRateLimitInterval,
candidateCap: Int = TransportConfig.bleConnectionCandidatesMax,
weakLinkCooldownSeconds: TimeInterval = TransportConfig.bleWeakLinkCooldownSeconds,
weakLinkRSSICutoff: Int = TransportConfig.bleWeakLinkRSSICutoff,
dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault
) {
self.maxCentralLinks = maxCentralLinks
self.connectRateLimitInterval = connectRateLimitInterval
self.candidateCap = candidateCap
self.weakLinkCooldownSeconds = weakLinkCooldownSeconds
self.weakLinkRSSICutoff = weakLinkRSSICutoff
self.initialDynamicRSSIThreshold = dynamicRSSIThreshold
self.dynamicRSSIThreshold = dynamicRSSIThreshold
}
func handleDiscovery(
_ candidate: BLEConnectionCandidate<Peripheral>,
connectedOrConnectingCount: Int,
existingState: BLEExistingConnectionState?,
peripheralState: BLEPeripheralConnectionState,
now: Date
) -> BLEDiscoveryDecision {
guard candidate.isConnectable else { return .ignore }
if candidate.rssi <= dynamicRSSIThreshold {
enqueue(candidate)
return .queued
}
if connectedOrConnectingCount >= maxCentralLinks {
enqueue(candidate)
return .queued
}
if let retryDelay = rateLimitRetryDelay(now: now) {
enqueue(candidate)
return .scheduleRetry(after: retryDelay)
}
if let existingState {
if existingState.isConnected || existingState.isConnecting {
return .ignore
}
if let lastAttempt = existingState.lastConnectionAttempt,
now.timeIntervalSince(lastAttempt) < 2.0 {
return .ignore
}
}
if let lastTimeout = recentConnectTimeouts[candidate.peripheralID],
now.timeIntervalSince(lastTimeout) < TransportConfig.bleTimeoutDiscoveryIgnoreSeconds {
return .ignore
}
if let lastDisconnect = recentDisconnects[candidate.peripheralID],
now.timeIntervalSince(lastDisconnect) < TransportConfig.bleDisconnectDiscoveryIgnoreSeconds {
return .ignore
}
switch peripheralState {
case .disconnected:
return .connectNow
case .connecting, .connected:
return .cancelStaleConnection
}
}
func enqueue(_ candidate: BLEConnectionCandidate<Peripheral>) {
if let existingIndex = candidates.firstIndex(where: { $0.peripheralID == candidate.peripheralID }) {
candidates[existingIndex] = candidate
} else {
candidates.append(candidate)
}
candidates.sort {
if $0.rssi != $1.rssi { return $0.rssi > $1.rssi }
return $0.discoveredAt < $1.discoveredAt
}
if candidates.count > candidateCap {
candidates.removeLast(candidates.count - candidateCap)
}
}
func nextCandidate(
connectedOrConnectingCount: Int,
isAlreadyConnectingOrConnected: (String) -> Bool,
now: Date
) -> BLEConnectionQueueDecision<Peripheral> {
guard connectedOrConnectingCount < maxCentralLinks else { return .none }
if let retryDelay = rateLimitRetryDelay(now: now) {
return .retryAfter(retryDelay)
}
while !candidates.isEmpty {
candidates.sort { score($0, now: now) > score($1, now: now) }
let candidate = candidates.removeFirst()
guard candidate.isConnectable else { continue }
if let delay = weakLinkRetryDelay(for: candidate, now: now) {
enqueue(candidate)
return .retryAfter(delay)
}
if let delay = disconnectSettleDelay(for: candidate, now: now) {
enqueue(candidate)
return .retryAfter(delay)
}
if isAlreadyConnectingOrConnected(candidate.peripheralID) {
continue
}
return .connect(candidate)
}
return .none
}
func recordConnectionAttempt(at now: Date) {
lastGlobalConnectAttempt = now
}
func recordConnectionSuccess(peripheralID: String) {
failureCounts[peripheralID] = 0
recentConnectTimeouts.removeValue(forKey: peripheralID)
recentDisconnects.removeValue(forKey: peripheralID)
}
func recordConnectionFailure(peripheralID: String) {
failureCounts[peripheralID, default: 0] += 1
}
func recordDisconnectError(peripheralID: String, at now: Date) {
recentDisconnects[peripheralID] = now
}
func recordConnectionTimeout(peripheralID: String, at now: Date) {
recentConnectTimeouts[peripheralID] = now
recordConnectionFailure(peripheralID: peripheralID)
}
func pruneConnectionTimeouts(before cutoff: Date) {
recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= cutoff }
recentDisconnects = recentDisconnects.filter { $0.value >= cutoff }
}
func reset() {
lastGlobalConnectAttempt = .distantPast
candidates.removeAll()
failureCounts.removeAll()
recentConnectTimeouts.removeAll()
recentDisconnects.removeAll()
lastIsolatedAt = nil
dynamicRSSIThreshold = initialDynamicRSSIThreshold
}
@discardableResult
func updateRSSIThreshold(
connectedCount: Int,
connectedOrConnectingLinkCount: Int,
now: Date
) -> Int {
if connectedCount == 0 {
if lastIsolatedAt == nil { lastIsolatedAt = now }
let isolatedAt = lastIsolatedAt ?? now
let elapsed = now.timeIntervalSince(isolatedAt)
dynamicRSSIThreshold = elapsed > TransportConfig.bleIsolationRelaxThresholdSeconds
? TransportConfig.bleRSSIIsolatedRelaxed
: TransportConfig.bleRSSIIsolatedBase
return dynamicRSSIThreshold
}
lastIsolatedAt = nil
// Flaky links are handled per-peripheral (weak-link cooldown, discovery
// ignore window, score bias) never globally, so one flaky distant peer
// can't blind us to every other edge-of-range peer.
var threshold = TransportConfig.bleDynamicRSSIThresholdDefault
if connectedOrConnectingLinkCount >= maxCentralLinks || candidates.count >= candidateCap {
threshold = TransportConfig.bleRSSIConnectedThreshold
}
dynamicRSSIThreshold = threshold
return threshold
}
private func rateLimitRetryDelay(now: Date) -> TimeInterval? {
let elapsed = now.timeIntervalSince(lastGlobalConnectAttempt)
guard elapsed < connectRateLimitInterval else { return nil }
return connectRateLimitInterval - elapsed + 0.05
}
private func weakLinkRetryDelay(
for candidate: BLEConnectionCandidate<Peripheral>,
now: Date
) -> TimeInterval? {
guard let lastTimeout = recentConnectTimeouts[candidate.peripheralID] else { return nil }
let elapsed = now.timeIntervalSince(lastTimeout)
guard elapsed < weakLinkCooldownSeconds && candidate.rssi <= weakLinkRSSICutoff else { return nil }
let remaining = weakLinkCooldownSeconds - elapsed
return min(max(2.0, remaining), 15.0)
}
// The disconnect settle window must hold on the queue path too: a stale
// candidate enqueued while the peripheral was still connected would
// otherwise reconnect immediately via the post-disconnect queue drain,
// bypassing the window and recreating reconnect/cancel thrash.
private func disconnectSettleDelay(
for candidate: BLEConnectionCandidate<Peripheral>,
now: Date
) -> TimeInterval? {
guard let lastDisconnect = recentDisconnects[candidate.peripheralID] else { return nil }
let remaining = TransportConfig.bleDisconnectDiscoveryIgnoreSeconds - now.timeIntervalSince(lastDisconnect)
guard remaining > 0 else { return nil }
return remaining + 0.05
}
private func score(_ candidate: BLEConnectionCandidate<Peripheral>, now: Date) -> Int {
let failures = failureCounts[candidate.peripheralID] ?? 0
let penalty = min(20, 1 << min(4, failures))
let timeoutBias = recentConnectTimeouts[candidate.peripheralID].map {
now.timeIntervalSince($0) < 60 ? 10 : 0
} ?? 0
let base = (candidate.isConnectable ? 1000 : 0) + (candidate.rssi + 100) * 2
let recency = -Int(now.timeIntervalSince(candidate.discoveredAt) * 10)
return base + recency - penalty - timeoutBias
}
}
@@ -1,71 +0,0 @@
import BitFoundation
import Foundation
struct BLEDirectedRelaySpoolEntry {
let recipient: PeerID
let packet: BitchatPacket
}
struct BLEDirectedRelaySpool {
private struct StoredPacket {
let packet: BitchatPacket
let enqueuedAt: Date
}
private var packetsByRecipient: [PeerID: [String: StoredPacket]] = [:]
var isEmpty: Bool {
packetsByRecipient.isEmpty
}
var count: Int {
packetsByRecipient.values.reduce(0) { $0 + $1.count }
}
@discardableResult
mutating func enqueue(
packet: BitchatPacket,
recipient: PeerID,
messageID: String,
enqueuedAt: Date
) -> Bool {
var packets = packetsByRecipient[recipient] ?? [:]
guard packets[messageID] == nil else {
return false
}
packets[messageID] = StoredPacket(packet: packet, enqueuedAt: enqueuedAt)
packetsByRecipient[recipient] = packets
return true
}
mutating func drainUnexpired(now: Date, window: TimeInterval) -> [BLEDirectedRelaySpoolEntry] {
var entries: [BLEDirectedRelaySpoolEntry] = []
for (recipient, packets) in packetsByRecipient {
for stored in packets.values where now.timeIntervalSince(stored.enqueuedAt) <= window {
entries.append(BLEDirectedRelaySpoolEntry(recipient: recipient, packet: stored.packet))
}
}
packetsByRecipient.removeAll()
return entries
}
mutating func pruneExpired(now: Date, window: TimeInterval) {
guard !packetsByRecipient.isEmpty else { return }
var pruned: [PeerID: [String: StoredPacket]] = [:]
for (recipient, packets) in packetsByRecipient {
let freshPackets = packets.filter { now.timeIntervalSince($0.value.enqueuedAt) <= window }
if !freshPackets.isEmpty {
pruned[recipient] = freshPackets
}
}
packetsByRecipient = pruned
}
mutating func removeAll() {
packetsByRecipient.removeAll()
}
}
@@ -1,210 +0,0 @@
import BitFoundation
import CryptoKit
import Foundation
struct BLEFanoutSelection: Equatable {
let peripheralIDs: Set<String>
let centralIDs: Set<String>
}
enum BLEFanoutSelector {
static func selectLinks(
peripheralIDs: [String],
centralIDs: [String],
ingressLink: BLEIngressLinkID?,
excludedLinks: Set<BLEIngressLinkID> = [],
peripheralPeerBindings: [String: PeerID] = [:],
centralPeerBindings: [String: PeerID] = [:],
directedPeerHint: PeerID?,
packetType: UInt8,
messageID: String
) -> BLEFanoutSelection {
let rawAllowed = allowedLinks(
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
ingressLink: ingressLink,
excludedLinks: excludedLinks
)
if let directedPeerHint,
let directedSelection = directLinks(
to: directedPeerHint,
links: rawAllowed,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
) {
return directedSelection
}
if let directedPeerHint,
hasBoundLink(
to: directedPeerHint,
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
) {
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
}
let allowed = collapseDuplicateLinksPerPeer(
rawAllowed,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
)
guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else {
return BLEFanoutSelection(
peripheralIDs: Set(allowed.peripheralIDs),
centralIDs: Set(allowed.centralIDs)
)
}
return BLEFanoutSelection(
peripheralIDs: deterministicSubset(
ids: allowed.peripheralIDs,
k: subsetSize(for: allowed.peripheralIDs.count),
seed: messageID
),
centralIDs: deterministicSubset(
ids: allowed.centralIDs,
k: subsetSize(for: allowed.centralIDs.count),
seed: messageID
)
)
}
private static func allowedLinks(
peripheralIDs: [String],
centralIDs: [String],
ingressLink: BLEIngressLinkID?,
excludedLinks: Set<BLEIngressLinkID>
) -> (peripheralIDs: [String], centralIDs: [String]) {
var allowedPeripheralIDs = peripheralIDs
var allowedCentralIDs = centralIDs
var blockedLinks = excludedLinks
if let ingressLink {
blockedLinks.insert(ingressLink)
}
allowedPeripheralIDs.removeAll { blockedLinks.contains(.peripheral($0)) }
allowedCentralIDs.removeAll { blockedLinks.contains(.central($0)) }
return (allowedPeripheralIDs, allowedCentralIDs)
}
private static func directLinks(
to peerID: PeerID,
links: (peripheralIDs: [String], centralIDs: [String]),
peripheralPeerBindings: [String: PeerID],
centralPeerBindings: [String: PeerID]
) -> BLEFanoutSelection? {
let directLinks = collapseDuplicateLinksPerPeer(
(
peripheralIDs: links.peripheralIDs.filter { peripheralPeerBindings[$0] == peerID },
centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID }
),
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
)
guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else {
return nil
}
return BLEFanoutSelection(
peripheralIDs: Set(directLinks.peripheralIDs),
centralIDs: Set(directLinks.centralIDs)
)
}
private static func hasBoundLink(
to peerID: PeerID,
peripheralIDs: [String],
centralIDs: [String],
peripheralPeerBindings: [String: PeerID],
centralPeerBindings: [String: PeerID]
) -> Bool {
peripheralIDs.contains { peripheralPeerBindings[$0] == peerID }
|| centralIDs.contains { centralPeerBindings[$0] == peerID }
}
// Dual-role pairs hold two live links (we-as-central writing to their
// peripheral, and they-as-central subscribed to ours). Sending the same
// packet down both doubles airtime for nothing the receiver's assembler
// and deduplicator just discard the copy. Keep one link per bound peer,
// preferring the peripheral (write) side: it has per-link flow control
// via canSendWriteWithoutResponse, while notifications share the
// peripheral manager's update queue across all centrals. Links with no
// bound peer yet (pre-announce) pass through untouched.
private static func collapseDuplicateLinksPerPeer(
_ links: (peripheralIDs: [String], centralIDs: [String]),
peripheralPeerBindings: [String: PeerID],
centralPeerBindings: [String: PeerID]
) -> (peripheralIDs: [String], centralIDs: [String]) {
guard !peripheralPeerBindings.isEmpty || !centralPeerBindings.isEmpty else {
return links
}
var seenPeers = Set<PeerID>()
var keptPeripheralIDs: [String] = []
for id in links.peripheralIDs {
if let peer = peripheralPeerBindings[id], !seenPeers.insert(peer).inserted {
continue
}
keptPeripheralIDs.append(id)
}
var keptCentralIDs: [String] = []
for id in links.centralIDs {
if let peer = centralPeerBindings[id], !seenPeers.insert(peer).inserted {
continue
}
keptCentralIDs.append(id)
}
return (keptPeripheralIDs, keptCentralIDs)
}
private static func shouldSubset(packetType: UInt8, directedPeerHint: PeerID?) -> Bool {
directedPeerHint == nil
&& packetType != MessageType.fragment.rawValue
&& packetType != MessageType.announce.rawValue
&& packetType != MessageType.requestSync.rawValue
}
private static func subsetSize(for count: Int) -> Int {
guard count > 0 else { return 0 }
if count <= 2 { return count }
var value = count - 1
var bits = 0
while value > 0 {
value >>= 1
bits += 1
}
return min(count, max(1, bits + 1))
}
private static func deterministicSubset(ids: [String], k: Int, seed: String) -> Set<String> {
guard k > 0 && ids.count > k else { return Set(ids) }
var scored: [(score: [UInt8], id: String)] = []
for id in ids {
let data = (seed + "::" + id).data(using: .utf8) ?? Data()
let digest = Array(SHA256.hash(data: data))
scored.append((digest, id))
}
scored.sort { lhs, rhs in
for index in 0..<min(lhs.score.count, rhs.score.count) {
if lhs.score[index] != rhs.score[index] {
return lhs.score[index] < rhs.score[index]
}
}
return lhs.id < rhs.id
}
return Set(scored.prefix(k).map(\.id))
}
}
@@ -1,125 +0,0 @@
import BitFoundation
import BitLogger
import Foundation
/// Narrow environment for `BLEFileTransferHandler`.
///
/// All queue hops (collections registry reads/writes, main-actor UI
/// notification) live inside the closures supplied by `BLEService`, keeping
/// the handler queue-agnostic and synchronously testable.
struct BLEFileTransferHandlerEnvironment {
/// Local peer identity at the time the transfer is handled.
let localPeerID: () -> PeerID
/// Local nickname used for sender resolution and collision checks.
let localNickname: () -> String
/// Snapshot of known peers keyed by ID (registry read).
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
/// Resolves a display name from a verified packet signature for peers missing from the registry.
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
/// Tracks the broadcast file packet for gossip sync.
let trackPacketSeen: (BitchatPacket) -> Void
/// Enforces the incoming-media storage quota before saving (BCH-01-002).
let enforceStorageQuota: (_ reservingBytes: Int) -> Void
/// Persists the validated file to the incoming-media store; returns the destination URL.
let saveIncomingFile: (
_ data: Data,
_ preferredName: String?,
_ subdirectory: String,
_ fallbackExtension: String?,
_ defaultPrefix: String
) -> URL?
/// Updates the registry last-seen timestamp for the peer (async barrier write).
let updatePeerLastSeen: (PeerID) -> Void
/// Delivers `.messageReceived` to the UI as one main-actor hop.
let deliverMessage: (BitchatMessage) -> Void
}
/// Orchestrates inbound file transfers: self-echo policy, sender display-name
/// resolution, delivery planning, payload validation, quota-checked storage,
/// and UI delivery.
final class BLEFileTransferHandler {
private let environment: BLEFileTransferHandlerEnvironment
init(environment: BLEFileTransferHandlerEnvironment) {
self.environment = environment
}
/// `payloadLimit` defaults to the Bluetooth cap; Wi-Fi bulk deliveries
/// pass the ceiling that was enforced against the accepted offer.
func handle(_ packet: BitchatPacket, from peerID: PeerID, payloadLimit: Int = FileTransferLimits.maxPayloadBytes) {
let env = environment
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
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 {
return
}
if deliveryPlan.shouldTrackForSync {
env.trackPacketSeen(packet)
}
let filePacket: BitchatFilePacket
let mime: MimeType
switch BLEIncomingFileValidator.validate(payload: packet.payload, limit: payloadLimit) {
case .success(let acceptance):
filePacket = acceptance.filePacket
mime = acceptance.mime
case .failure(.malformedPayload):
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
return
case .failure(.payloadTooLarge(let bytes)):
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
return
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)
return
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)
return
}
// BCH-01-002: Enforce storage quota before saving
env.enforceStorageQuota(filePacket.content.count)
guard let destination = env.saveIncomingFile(
filePacket.content,
filePacket.fileName,
"\(mime.category.mediaDir)/incoming",
mime.defaultExtension,
mime.category.rawValue
) else {
return
}
if deliveryPlan.isPrivateMessage {
env.updatePeerLastSeen(peerID)
}
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
let message = BitchatMessage(
sender: senderNickname,
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
timestamp: ts,
isRelay: false,
originalSender: nil,
isPrivate: deliveryPlan.isPrivateMessage,
recipientNickname: nil,
senderPeerID: peerID
)
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
env.deliverMessage(message)
}
}
@@ -1,76 +0,0 @@
import BitFoundation
import Foundation
struct BLEFileTransferDeliveryPlan: Equatable {
let isPrivateMessage: Bool
let shouldTrackForSync: Bool
}
enum BLEFileTransferPolicy {
static func isSelfEcho(packet: BitchatPacket, from peerID: PeerID, localPeerID: PeerID) -> Bool {
peerID == localPeerID && packet.ttl != 0
}
static func deliveryPlan(packet: BitchatPacket, localPeerID: PeerID) -> BLEFileTransferDeliveryPlan? {
guard let recipientID = packet.recipientID else {
return BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true)
}
let isBroadcast = recipientID.allSatisfy { $0 == 0xFF }
if isBroadcast {
return BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true)
}
guard PeerID(hexData: recipientID) == localPeerID else {
return nil
}
return BLEFileTransferDeliveryPlan(isPrivateMessage: true, shouldTrackForSync: false)
}
}
struct BLEIncomingFileAcceptance {
let filePacket: BitchatFilePacket
let mime: MimeType
}
enum BLEIncomingFileRejection: Error, Equatable {
case malformedPayload
case payloadTooLarge(bytes: Int)
case unsupportedMime(mimeType: String?, bytes: Int)
case magicMismatch(mime: MimeType, bytes: Int, prefixHex: String)
}
enum BLEIncomingFileValidator {
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk deliveries
/// pass the ceiling enforced against the accepted offer.
static func validate(
payload: Data,
limit: Int = FileTransferLimits.maxPayloadBytes
) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
guard let filePacket = BitchatFilePacket.decode(payload, limit: limit) else {
return .failure(.malformedPayload)
}
guard FileTransferLimits.isValidPayload(filePacket.content.count, limit: limit) else {
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
}
guard let mime = MimeType(filePacket.mimeType), mime.isAllowed else {
return .failure(.unsupportedMime(
mimeType: filePacket.mimeType,
bytes: filePacket.content.count
))
}
guard mime.matches(data: filePacket.content) else {
return .failure(.magicMismatch(
mime: mime,
bytes: filePacket.content.count,
prefixHex: filePacket.content.prefix(20).map { String(format: "%02x", $0) }.joined(separator: " ")
))
}
return .success(BLEIncomingFileAcceptance(filePacket: filePacket, mime: mime))
}
}
@@ -1,153 +0,0 @@
import BitFoundation
import Foundation
struct BLEFragmentKey: Hashable, Equatable {
let sender: UInt64
let id: UInt64
}
struct BLEFragmentHeader: Equatable {
let key: BLEFragmentKey
let index: Int
let total: Int
let originalType: UInt8
let fragmentData: Data
let isBroadcastFragment: Bool
var idLogString: String {
String(format: "%016llx", key.id)
}
init?(packet: BitchatPacket) {
// Minimum header: 8 bytes ID + 2 index + 2 total + 1 type.
guard packet.payload.count >= 13 else { return nil }
var senderU64: UInt64 = 0
for byte in packet.senderID.prefix(8) {
senderU64 = (senderU64 << 8) | UInt64(byte)
}
var fragmentU64: UInt64 = 0
for byte in packet.payload.prefix(8) {
fragmentU64 = (fragmentU64 << 8) | UInt64(byte)
}
let index = Int((UInt16(packet.payload[8]) << 8) | UInt16(packet.payload[9]))
let total = Int((UInt16(packet.payload[10]) << 8) | UInt16(packet.payload[11]))
guard total > 0 && total <= 10_000 && index >= 0 && index < total else {
return nil
}
let isBroadcastFragment: Bool = {
guard let recipient = packet.recipientID else { return true }
return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF }
}()
self.key = BLEFragmentKey(sender: senderU64, id: fragmentU64)
self.index = index
self.total = total
self.originalType = packet.payload[12]
self.fragmentData = Data(packet.payload.suffix(from: 13))
self.isBroadcastFragment = isBroadcastFragment
}
}
struct BLEFragmentAssemblyBuffer {
enum AppendResult: Equatable {
case stored(header: BLEFragmentHeader, started: Bool)
case complete(header: BLEFragmentHeader, reassembledData: Data, started: Bool)
case oversized(header: BLEFragmentHeader, projectedSize: Int, limit: Int, started: Bool)
}
private struct Metadata {
let type: UInt8
let total: Int
let timestamp: Date
}
private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:]
private var metadataByKey: [BLEFragmentKey: Metadata] = [:]
mutating func removeAll() {
fragmentsByKey.removeAll()
metadataByKey.removeAll()
}
@discardableResult
mutating func removeExpired(before cutoff: Date) -> Int {
let expiredKeys = metadataByKey
.filter { $0.value.timestamp < cutoff }
.map(\.key)
for key in expiredKeys {
fragmentsByKey.removeValue(forKey: key)
metadataByKey.removeValue(forKey: key)
}
return expiredKeys.count
}
mutating func append(
_ header: BLEFragmentHeader,
maxInFlightAssemblies: Int,
now: Date = Date()
) -> AppendResult {
let started = startAssemblyIfNeeded(for: header, maxInFlightAssemblies: maxInFlightAssemblies, now: now)
let currentSize = fragmentsByKey[header.key]?.values.reduce(0) { $0 + $1.count } ?? 0
let limit = Self.assemblyLimit(for: header.originalType)
let projectedSize = currentSize + header.fragmentData.count
guard projectedSize <= limit else {
fragmentsByKey.removeValue(forKey: header.key)
metadataByKey.removeValue(forKey: header.key)
return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started)
}
fragmentsByKey[header.key]?[header.index] = header.fragmentData
guard let fragments = fragmentsByKey[header.key],
fragments.count == header.total else {
return .stored(header: header, started: started)
}
let reassembled = (0..<header.total).reduce(into: Data()) { data, index in
if let fragment = fragments[index] {
data.append(fragment)
}
}
fragmentsByKey.removeValue(forKey: header.key)
metadataByKey.removeValue(forKey: header.key)
return .complete(header: header, reassembledData: reassembled, started: started)
}
private mutating func startAssemblyIfNeeded(
for header: BLEFragmentHeader,
maxInFlightAssemblies: Int,
now: Date
) -> Bool {
guard fragmentsByKey[header.key] == nil else { return false }
if fragmentsByKey.count >= maxInFlightAssemblies,
let oldest = metadataByKey.min(by: { $0.value.timestamp < $1.value.timestamp })?.key {
fragmentsByKey.removeValue(forKey: oldest)
metadataByKey.removeValue(forKey: oldest)
}
fragmentsByKey[header.key] = [:]
metadataByKey[header.key] = Metadata(type: header.originalType, total: header.total, timestamp: now)
return true
}
private static func assemblyLimit(for originalType: UInt8) -> Int {
if originalType == MessageType.fileTransfer.rawValue {
// Allow headroom for TLV metadata and binary framing overhead.
return FileTransferLimits.maxFramedFileBytes
}
return FileTransferLimits.maxPayloadBytes
}
}
@@ -1,94 +0,0 @@
import BitFoundation
import BitLogger
import Foundation
/// Narrow environment for `BLEFragmentHandler`.
///
/// All queue hops (the message-queue entry hop and the collections barrier
/// around the assembly buffer) live on the `BLEService` side the entry hop
/// in `BLEService.handleFragment`, the barrier inside the supplied closures
/// keeping the handler queue-agnostic and synchronously testable.
struct BLEFragmentHandlerEnvironment {
/// Local peer identity at the time the fragment is handled.
let localPeerID: () -> PeerID
/// Tracks broadcast fragments for gossip sync.
let trackPacketSeen: (BitchatPacket) -> Void
/// Appends the fragment to the assembly buffer (collections barrier write).
let appendFragment: (BLEFragmentHeader) -> BLEFragmentAssemblyBuffer.AppendResult
/// Ingress acceptance check for the reassembled inner packet.
let isAcceptedIngressPayload: (_ packet: BitchatPacket, _ innerSender: PeerID) -> Bool
/// Re-enters the receive pipeline with the reassembled packet (TTL already zeroed).
let processReassembledPacket: (_ packet: BitchatPacket, _ from: PeerID) -> Void
}
/// Orchestrates inbound fragments: self-fragment suppression, gossip tracking,
/// assembly-buffer appends, and reassembled-packet validation and re-injection
/// into the receive pipeline.
final class BLEFragmentHandler {
private let environment: BLEFragmentHandlerEnvironment
init(environment: BLEFragmentHandlerEnvironment) {
self.environment = environment
}
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment
// Don't process our own fragments
if peerID == env.localPeerID() {
return
}
guard let header = BLEFragmentHeader(packet: packet) else { return }
if header.isBroadcastFragment {
env.trackPacketSeen(packet)
}
let assemblyResult = env.appendFragment(header)
logFragmentAssemblyResult(assemblyResult)
guard case let .complete(completedHeader, reassembled, _) = assemblyResult else { return }
// Decode the original packet bytes we reassembled, so flags/compression are preserved
if var originalPacket = BinaryProtocol.decode(reassembled) {
// Reassembled packet validation
let innerSender = PeerID(hexData: originalPacket.senderID)
if !env.isAcceptedIngressPayload(originalPacket, innerSender) {
// Cleanup below
} else {
SecureLogger.debug("✅ Reassembled packet id=\(completedHeader.idLogString) type=\(originalPacket.type) bytes=\(reassembled.count)", category: .session)
originalPacket.ttl = 0
env.processReassembledPacket(originalPacket, peerID)
}
} else {
SecureLogger.error("❌ Failed to decode reassembled packet (type=\(completedHeader.originalType), total=\(completedHeader.total))", category: .session)
}
}
private func logFragmentAssemblyResult(_ result: BLEFragmentAssemblyBuffer.AppendResult) {
func logStartedIfNeeded(header: BLEFragmentHeader, started: Bool) {
if started {
SecureLogger.debug("📦 Started fragment assembly id=\(header.idLogString) total=\(header.total)", category: .session)
}
}
switch result {
case let .stored(header, started):
logStartedIfNeeded(header: header, started: started)
SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session)
case let .complete(header, _, started):
logStartedIfNeeded(header: header, started: started)
SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session)
case let .oversized(header, projectedSize, limit, started):
logStartedIfNeeded(header: header, started: started)
SecureLogger.warning(
"🚫 Fragment assembly exceeds size limit (\(projectedSize) bytes > \(limit)), evicting. Type=\(header.originalType) Index=\(header.index)/\(header.total)",
category: .security
)
}
}
}
@@ -1,70 +0,0 @@
import BitFoundation
import Foundation
struct BLEInboundWriteChunk: Equatable {
let offset: Int
let data: Data
}
struct BLEInboundWriteAppendMetadata: Equatable {
let accumulatedBytes: Int
let appendedBytes: Int
let offsets: [Int]
let packetType: UInt8?
}
struct BLEInboundWriteBuffer {
enum AppendResult {
case decoded(packet: BitchatPacket, metadata: BLEInboundWriteAppendMetadata)
case waiting(metadata: BLEInboundWriteAppendMetadata)
case oversized(metadata: BLEInboundWriteAppendMetadata)
}
private var buffersByCentralID: [String: Data] = [:]
mutating func removeAll() {
buffersByCentralID.removeAll()
}
mutating func append(
chunks: [BLEInboundWriteChunk],
for centralID: String,
capBytes: Int
) -> AppendResult {
var combined = buffersByCentralID[centralID] ?? Data()
var appendedBytes = 0
var offsets: [Int] = []
for chunk in chunks where !chunk.data.isEmpty {
offsets.append(chunk.offset)
let end = chunk.offset + chunk.data.count
if combined.count < end {
combined.append(Data(repeating: 0, count: end - combined.count))
}
combined.replaceSubrange(chunk.offset..<end, with: chunk.data)
appendedBytes += chunk.data.count
}
let metadata = BLEInboundWriteAppendMetadata(
accumulatedBytes: combined.count,
appendedBytes: appendedBytes,
offsets: offsets,
packetType: combined.count >= 2 ? combined[1] : nil
)
if let packet = BinaryProtocol.decode(combined) {
buffersByCentralID.removeValue(forKey: centralID)
return .decoded(packet: packet, metadata: metadata)
}
guard combined.count <= capBytes else {
buffersByCentralID.removeValue(forKey: centralID)
return .oversized(metadata: metadata)
}
buffersByCentralID[centralID] = combined
return .waiting(metadata: metadata)
}
}
@@ -1,168 +0,0 @@
import BitLogger
import BitFoundation
import Foundation
struct BLEIncomingFileStore {
private static let quotaBytes: Int64 = 100 * 1024 * 1024
private let fileManager: FileManager
private let baseDirectory: URL?
private let dateProvider: () -> Date
init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) {
self.fileManager = fileManager
self.baseDirectory = baseDirectory
self.dateProvider = dateProvider
}
func save(
data: Data,
preferredName: String?,
subdirectory: String,
fallbackExtension: String?,
defaultPrefix: String
) -> URL? {
do {
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
let sanitized = sanitizedFileName(
preferredName,
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
fallbackExtension: fallbackExtension
)
let destination = uniqueFileURL(in: base, fileName: sanitized)
try data.write(to: destination, options: .atomic)
return destination
} catch {
SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session)
return nil
}
}
func enforceQuota(reservingBytes: Int) {
do {
let base = try filesDirectory()
let incomingDirs = [
base.appendingPathComponent("voicenotes/incoming", isDirectory: true),
base.appendingPathComponent("images/incoming", isDirectory: true),
base.appendingPathComponent("files/incoming", isDirectory: true)
]
var allFiles: [(url: URL, size: Int64, modified: Date)] = []
for dir in incomingDirs where fileManager.fileExists(atPath: dir.path) {
guard let contents = try? fileManager.contentsOfDirectory(
at: dir,
includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey],
options: [.skipsHiddenFiles]
) else { continue }
for fileURL in contents {
guard let attrs = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]),
let size = attrs.fileSize,
let modified = attrs.contentModificationDate else { continue }
allFiles.append((url: fileURL, size: Int64(size), modified: modified))
}
}
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
let targetUsage = Self.quotaBytes - Int64(reservingBytes)
guard currentUsage > targetUsage else { return }
let needToFree = currentUsage - targetUsage
var freedSpace: Int64 = 0
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
guard freedSpace < needToFree else { break }
do {
try fileManager.removeItem(at: file.url)
freedSpace += file.size
SecureLogger.debug("🗑️ BCH-01-002: Deleted old incoming file to free space: \(file.url.lastPathComponent)", category: .security)
} catch {
SecureLogger.warning("⚠️ Failed to delete old file for quota: \(error)", category: .security)
}
}
if freedSpace > 0 {
SecureLogger.info("📊 BCH-01-002: Freed \(ByteCountFormatter.string(fromByteCount: freedSpace, countStyle: .file)) to stay within incoming files quota", category: .security)
}
} catch {
SecureLogger.warning("⚠️ Could not enforce storage quota: \(error)", category: .security)
}
}
private func filesDirectory() throws -> URL {
let root = try baseDirectory ?? fileManager.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let filesDir = root.appendingPathComponent("files", isDirectory: true)
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
return filesDir
}
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
var candidate = (name ?? "")
.replacingOccurrences(of: "\0", with: "")
.precomposedStringWithCanonicalMapping
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "\\", with: "_")
let invalid = CharacterSet(charactersIn: "<>:\"|?*\0").union(.controlCharacters)
candidate = candidate.components(separatedBy: invalid).joined(separator: "_").trimmed
if candidate.isEmpty { candidate = defaultName }
if candidate.hasPrefix(".") { candidate = "_" + candidate }
if candidate.count > 120 {
let ext = (candidate as NSString).pathExtension
let base = (candidate as NSString).deletingPathExtension
candidate = ext.isEmpty
? String(candidate.prefix(120))
: String(base.prefix(max(10, 120 - ext.count - 1))) + "." + ext
}
if let fallbackExtension, (candidate as NSString).pathExtension.isEmpty {
candidate += ".\(fallbackExtension)"
}
return candidate.isEmpty ? defaultName : candidate
}
private func uniqueFileURL(in directory: URL, fileName: String) -> URL {
let directoryPath = directory.standardizedFileURL.path
func isInsideDirectory(_ url: URL) -> Bool {
url.standardizedFileURL.path.hasPrefix(directoryPath + "/")
}
var candidate = directory.appendingPathComponent(fileName)
guard isInsideDirectory(candidate) else {
SecureLogger.warning("⚠️ Path traversal blocked: \(fileName)", category: .security)
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
}
if !fileManager.fileExists(atPath: candidate.path) {
return candidate
}
let baseName = (fileName as NSString).deletingPathExtension
let ext = (fileName as NSString).pathExtension
for counter in 1..<100 {
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
candidate = directory.appendingPathComponent(newName)
guard isInsideDirectory(candidate) else {
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
}
if !fileManager.fileExists(atPath: candidate.path) {
return candidate
}
}
return directory.appendingPathComponent("\(baseName)_\(UUID().uuidString).\(ext.isEmpty ? "dat" : ext)")
}
private static func timestampString(from date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd_HHmmss"
return formatter.string(from: date)
}
}
@@ -1,112 +0,0 @@
import BitFoundation
import Foundation
enum BLEIngressLinkID: Hashable, Equatable {
case peripheral(String)
case central(String)
}
struct BLEIngressPacketContext: Equatable {
let receivedFromPeerID: PeerID
let validationPeerID: PeerID
}
struct BLEIngressLinkRecord: Equatable {
let link: BLEIngressLinkID
let peerID: PeerID
let timestamp: Date
}
enum BLEIngressRejection: Error, Equatable {
case selfLoopback(packetType: UInt8)
case directSenderMismatch(boundPeerID: PeerID, claimedSenderID: PeerID)
}
struct BLEIngressLinkRegistry {
private var ingressByMessageID: [String: BLEIngressLinkRecord] = [:]
var isEmpty: Bool {
ingressByMessageID.isEmpty
}
mutating func removeAll() {
ingressByMessageID.removeAll()
}
func record(for packet: BitchatPacket) -> BLEIngressLinkRecord? {
ingressByMessageID[Self.messageID(for: packet)]
}
func link(for packet: BitchatPacket) -> BLEIngressLinkID? {
record(for: packet)?.link
}
func peerID(for packet: BitchatPacket) -> PeerID? {
record(for: packet)?.peerID
}
mutating func recordIfNew(
_ packet: BitchatPacket,
link: BLEIngressLinkID,
peerID: PeerID,
now: Date = Date(),
lifetime: TimeInterval
) -> Bool {
let messageID = Self.messageID(for: packet)
if let existing = ingressByMessageID[messageID],
now.timeIntervalSince(existing.timestamp) <= lifetime {
return false
}
ingressByMessageID[messageID] = BLEIngressLinkRecord(link: link, peerID: peerID, timestamp: now)
return true
}
mutating func prune(before cutoff: Date) {
ingressByMessageID = ingressByMessageID.filter { $0.value.timestamp >= cutoff }
}
static func packetContext(
for packet: BitchatPacket,
claimedSenderID: PeerID,
boundPeerID: PeerID?,
localPeerID: PeerID,
directAnnounceTTL: UInt8
) -> Result<BLEIngressPacketContext, BLEIngressRejection> {
if claimedSenderID == localPeerID,
!isSelfAuthoredSyncResponse(packet) {
return .failure(.selfLoopback(packetType: packet.type))
}
if let boundPeerID,
boundPeerID != claimedSenderID,
requiresDirectSenderBinding(packet, directAnnounceTTL: directAnnounceTTL) {
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
}
let receivedFromPeerID = boundPeerID ?? claimedSenderID
let validationPeerID = packet.isRSR ? receivedFromPeerID : claimedSenderID
return .success(BLEIngressPacketContext(
receivedFromPeerID: receivedFromPeerID,
validationPeerID: validationPeerID
))
}
static func messageID(for packet: BitchatPacket) -> String {
let senderID = packet.senderID.hexEncodedString()
let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString()
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
}
private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
// REQUEST_SYNC is never relayed, so on a bound link the claimed sender
// must be the link peer it elicits a full store replay, and the
// response is addressed to whoever the sender claims to be.
if packet.type == MessageType.requestSync.rawValue { return true }
return packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
}
private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
packet.isRSR && packet.ttl == 0
}
}
@@ -1,76 +0,0 @@
import BitFoundation
import Foundation
enum BLEIngressPacketGuard {
enum Rejection: Error, Equatable {
case selfLoopback(packetType: UInt8)
case directSenderMismatch(boundPeerID: PeerID, claimedSenderID: PeerID)
case invalidRSR(peerID: PeerID)
case timestampSkew(peerID: PeerID, skewMs: UInt64, maxSkewMs: UInt64)
}
static func evaluate(
packet: BitchatPacket,
claimedSenderID: PeerID,
boundPeerID: PeerID?,
localPeerID: PeerID,
directAnnounceTTL: UInt8,
nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
maxTimestampSkewMs: UInt64 = 120_000,
isValidSyncResponse: (PeerID) -> Bool
) -> Result<BLEIngressPacketContext, Rejection> {
let contextResult = BLEIngressLinkRegistry.packetContext(
for: packet,
claimedSenderID: claimedSenderID,
boundPeerID: boundPeerID,
localPeerID: localPeerID,
directAnnounceTTL: directAnnounceTTL
)
let context: BLEIngressPacketContext
switch contextResult {
case .success(let acceptedContext):
context = acceptedContext
case .failure(.selfLoopback(let packetType)):
return .failure(.selfLoopback(packetType: packetType))
case .failure(.directSenderMismatch(let boundPeerID, let claimedSenderID)):
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
}
switch validatePayload(
packet,
from: context.validationPeerID,
nowMs: nowMs,
maxTimestampSkewMs: maxTimestampSkewMs,
isValidSyncResponse: isValidSyncResponse
) {
case .success:
return .success(context)
case .failure(let rejection):
return .failure(rejection)
}
}
static func validatePayload(
_ packet: BitchatPacket,
from peerID: PeerID,
nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
maxTimestampSkewMs: UInt64 = 120_000,
isValidSyncResponse: (PeerID) -> Bool
) -> Result<Void, Rejection> {
if packet.isRSR {
guard isValidSyncResponse(peerID) else {
return .failure(.invalidRSR(peerID: peerID))
}
return .success(())
}
let packetTime = packet.timestamp
let skew = packetTime > nowMs ? packetTime - nowMs : nowMs - packetTime
guard skew <= maxTimestampSkewMs else {
return .failure(.timestampSkew(peerID: peerID, skewMs: skew, maxSkewMs: maxTimestampSkewMs))
}
return .success(())
}
}
@@ -1,243 +0,0 @@
import BitFoundation
import CoreBluetooth
import Foundation
struct BLEPeripheralLinkState {
let peripheral: CBPeripheral
var characteristic: CBCharacteristic?
var peerID: PeerID?
var isConnecting: Bool
var isConnected: Bool
var lastConnectionAttempt: Date?
var assembler: NotificationStreamAssembler
}
struct BLEDirectLinkState: Equatable {
let hasPeripheral: Bool
let hasCentral: Bool
}
struct BLESubscribedCentralSnapshot {
let centrals: [CBCentral]
let peerIDsByCentralUUID: [String: PeerID]
func central(for peerID: PeerID) -> CBCentral? {
centrals.first { peerIDsByCentralUUID[$0.identifier.uuidString] == peerID }
}
}
/// Owns all BLE link state (peripheral connections we hold as central, and
/// central subscriptions we serve as peripheral). The store has no internal
/// locking: every access must happen on the single owning queue (the BLE
/// queue). Other queues must go through BLEService's `readLinkState`, which
/// hops to that queue. Call `assumeOwnership(of:)` to have debug builds trap
/// any access from the wrong queue.
final class BLELinkStateStore {
private(set) var peripherals: [String: BLEPeripheralLinkState] = [:]
private(set) var peerToPeripheralUUID: [PeerID: String] = [:]
private(set) var subscribedCentrals: [CBCentral] = []
private(set) var centralToPeerID: [String: PeerID] = [:]
#if DEBUG
private var ownerQueue: DispatchQueue?
#endif
/// Pin the store to its owning queue. Debug-only enforcement; release
/// builds are unchanged.
func assumeOwnership(of queue: DispatchQueue) {
#if DEBUG
ownerQueue = queue
#endif
}
@inline(__always)
private func assertOwned() {
#if DEBUG
if let queue = ownerQueue {
dispatchPrecondition(condition: .onQueue(queue))
}
#endif
}
var peripheralStates: [BLEPeripheralLinkState] {
assertOwned()
return Array(peripherals.values)
}
var subscribedCentralSnapshot: BLESubscribedCentralSnapshot {
assertOwned()
return BLESubscribedCentralSnapshot(
centrals: subscribedCentrals,
peerIDsByCentralUUID: centralToPeerID
)
}
var subscribedCentralCount: Int {
assertOwned()
return subscribedCentrals.count
}
var connectedOrConnectingPeripheralCount: Int {
assertOwned()
return peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
}
func state(forPeripheralID peripheralID: String) -> BLEPeripheralLinkState? {
assertOwned()
return peripherals[peripheralID]
}
func setPeripheralState(_ state: BLEPeripheralLinkState, for peripheralID: String) {
assertOwned()
peripherals[peripheralID] = state
}
@discardableResult
func updatePeripheral(
_ peripheralID: String,
_ update: (inout BLEPeripheralLinkState) -> Void
) -> BLEPeripheralLinkState? {
assertOwned()
guard var state = peripherals[peripheralID] else { return nil }
update(&state)
peripherals[peripheralID] = state
return state
}
func beginConnecting(to peripheral: CBPeripheral, at date: Date) {
setPeripheralState(
BLEPeripheralLinkState(
peripheral: peripheral,
characteristic: nil,
peerID: nil,
isConnecting: true,
isConnected: false,
lastConnectionAttempt: date,
assembler: NotificationStreamAssembler()
),
for: peripheral.identifier.uuidString
)
}
func markConnected(_ peripheral: CBPeripheral) {
let peripheralID = peripheral.identifier.uuidString
if updatePeripheral(peripheralID, {
$0.isConnecting = false
$0.isConnected = true
}) == nil {
setPeripheralState(
BLEPeripheralLinkState(
peripheral: peripheral,
characteristic: nil,
peerID: nil,
isConnecting: false,
isConnected: true,
lastConnectionAttempt: nil,
assembler: NotificationStreamAssembler()
),
for: peripheralID
)
}
}
func updateCharacteristic(_ characteristic: CBCharacteristic, forPeripheralID peripheralID: String) {
updatePeripheral(peripheralID) {
$0.characteristic = characteristic
}
}
func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? {
assertOwned()
return peerToPeripheralUUID[peerID].flatMap { peripherals[$0] }
}
func directLinkState(for peerID: PeerID) -> BLEDirectLinkState {
assertOwned()
let peripheralUUID = peerToPeripheralUUID[peerID]
let hasPeripheral = peripheralUUID.flatMap { peripherals[$0]?.isConnected } ?? false
let hasCentral = centralToPeerID.values.contains(peerID)
return BLEDirectLinkState(hasPeripheral: hasPeripheral, hasCentral: hasCentral)
}
func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
assertOwned()
guard let peerID else { return [] }
var links: Set<BLEIngressLinkID> = []
if let peripheralUUID = peerToPeripheralUUID[peerID] {
links.insert(.peripheral(peripheralUUID))
}
for (centralUUID, mappedPeerID) in centralToPeerID where mappedPeerID == peerID {
links.insert(.central(centralUUID))
}
return links
}
func peerID(forPeripheralID peripheralID: String) -> PeerID? {
assertOwned()
return peripherals[peripheralID]?.peerID
}
func peerID(forCentralUUID centralUUID: String) -> PeerID? {
assertOwned()
return centralToPeerID[centralUUID]
}
func addSubscribedCentral(_ central: CBCentral) {
assertOwned()
guard !subscribedCentrals.contains(central) else { return }
subscribedCentrals.append(central)
}
func removeSubscribedCentral(_ central: CBCentral) -> PeerID? {
assertOwned()
let centralUUID = central.identifier.uuidString
subscribedCentrals.removeAll { $0.identifier == central.identifier }
return centralToPeerID.removeValue(forKey: centralUUID)
}
func bindCentral(_ centralUUID: String, to peerID: PeerID) {
assertOwned()
centralToPeerID[centralUUID] = peerID
}
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
assertOwned()
if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil {
peerToPeripheralUUID[peerID] = peripheralUUID
}
}
func removePeripheral(_ peripheralID: String) -> PeerID? {
assertOwned()
let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID
if let peerID {
peerToPeripheralUUID.removeValue(forKey: peerID)
}
return peerID
}
func clearPeripherals() -> [PeerID] {
assertOwned()
let peerIDs = peripherals.compactMap { $0.value.peerID }
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
return peerIDs
}
func clearCentrals() -> [PeerID] {
assertOwned()
let peerIDs = Array(centralToPeerID.values)
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
return peerIDs
}
func clearAll() {
assertOwned()
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
}
}
@@ -1,33 +0,0 @@
import Foundation
final class BLELogRateLimiter {
private let defaultMinimumInterval: TimeInterval
private let queue = DispatchQueue(label: "chat.bitchat.ble.log-rate-limiter")
private var lastLogTimeByKey: [String: Date] = [:]
init(defaultMinimumInterval: TimeInterval) {
self.defaultMinimumInterval = defaultMinimumInterval
}
func shouldLog(
key: String,
now: Date = Date(),
minimumInterval: TimeInterval? = nil
) -> Bool {
queue.sync {
let interval = minimumInterval ?? defaultMinimumInterval
if let lastLogTime = lastLogTimeByKey[key],
now.timeIntervalSince(lastLogTime) < interval {
return false
}
lastLogTimeByKey[key] = now
return true
}
}
func removeAll() {
queue.sync {
lastLogTimeByKey.removeAll()
}
}
}
@@ -1,62 +0,0 @@
import Foundation
struct BLEMaintenancePlan: Equatable {
let shouldSendAnnounce: Bool
let shouldEnsureAdvertising: Bool
let shouldRunCleanup: Bool
let shouldFlushDirectedSpool: Bool
let shouldResetCounter: Bool
}
enum BLEMaintenancePolicy {
static func plan(
cycle: Int,
connectedCount: Int,
peerRegistryIsEmpty: Bool,
elapsedSinceLastAnnounce: TimeInterval,
hasRecentTraffic: Bool,
connectedAnnounceJitterOffset: TimeInterval? = nil,
highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold
) -> BLEMaintenancePlan {
BLEMaintenancePlan(
shouldSendAnnounce: shouldSendAnnounce(
connectedCount: connectedCount,
elapsedSinceLastAnnounce: elapsedSinceLastAnnounce,
hasRecentTraffic: hasRecentTraffic,
connectedAnnounceJitterOffset: connectedAnnounceJitterOffset,
highDegreeThreshold: highDegreeThreshold
),
shouldEnsureAdvertising: peerRegistryIsEmpty,
shouldRunCleanup: cycle.isMultiple(of: 3),
shouldFlushDirectedSpool: !cycle.isMultiple(of: 2),
shouldResetCounter: cycle >= 6
)
}
static func shouldSendAnnounce(
connectedCount: Int,
elapsedSinceLastAnnounce: TimeInterval,
hasRecentTraffic: Bool,
connectedAnnounceJitterOffset: TimeInterval? = nil,
highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold
) -> Bool {
if hasRecentTraffic && elapsedSinceLastAnnounce >= 10.0 {
return true
}
guard connectedCount > 0 else {
return elapsedSinceLastAnnounce >= TransportConfig.bleAnnounceIntervalSeconds
}
let highDegree = connectedCount >= highDegreeThreshold
let base = highDegree ?
TransportConfig.bleConnectedAnnounceBaseSecondsDense :
TransportConfig.bleConnectedAnnounceBaseSecondsSparse
let jitter = highDegree ?
TransportConfig.bleConnectedAnnounceJitterDense :
TransportConfig.bleConnectedAnnounceJitterSparse
let jitterOffset = connectedAnnounceJitterOffset ?? Double.random(in: -jitter...jitter)
return elapsedSinceLastAnnounce >= base + jitterOffset
}
}
@@ -1,132 +0,0 @@
import BitFoundation
import BitLogger
import Foundation
/// Narrow environment for `BLENoisePacketHandler`.
///
/// All queue hops (collections barrier writes, main-actor UI notification)
/// and every `noiseService.*` crypto call live inside the closures supplied by
/// `BLEService`, keeping the handler queue-agnostic and synchronously testable.
struct BLENoisePacketHandlerEnvironment {
/// Local peer identity at the time the packet is handled.
let localPeerID: () -> PeerID
/// Local peer ID bytes used as the sender of handshake responses.
let localPeerIDData: () -> Data
/// TTL value used for direct (non-relayed) packets.
let messageTTL: UInt8
/// Current time source.
let now: () -> Date
/// Processes an inbound handshake message, returning an optional response payload (crypto).
let processHandshakeMessage: (_ peerID: PeerID, _ message: Data) throws -> Data?
/// Whether any Noise session (established or pending) exists for the peer (crypto).
let hasNoiseSession: (PeerID) -> Bool
/// Initiates a fresh Noise handshake with the peer (crypto + send).
let initiateHandshake: (PeerID) -> Void
/// Broadcasts a packet on the mesh (caller is already on the message queue).
let broadcastPacket: (BitchatPacket) -> Void
/// Updates the registry last-seen timestamp for the peer (async barrier write).
let updatePeerLastSeen: (PeerID) -> Void
/// Decrypts an encrypted payload from the peer (crypto).
let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> Data
/// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto).
let clearSession: (PeerID) -> Void
/// Delivers `.noisePayloadReceived` to the UI as one main-actor hop.
let deliverNoisePayload: (
_ peerID: PeerID,
_ type: NoisePayloadType,
_ payload: Data,
_ timestamp: Date
) -> Void
}
/// Orchestrates the Noise session domain for inbound packets: handshake
/// processing (with response), encrypted payload decryption and dispatch,
/// and session recovery on decrypt failure.
final class BLENoisePacketHandler {
private let environment: BLENoisePacketHandlerEnvironment
init(environment: BLENoisePacketHandlerEnvironment) {
self.environment = environment
}
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment
// Use NoiseEncryptionService for handshake processing
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
// Handshake is for us
do {
if let response = try env.processHandshakeMessage(peerID, packet.payload) {
// Send response
let responsePacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: env.localPeerIDData(),
recipientID: Data(hexString: peerID.id),
timestamp: UInt64(env.now().timeIntervalSince1970 * 1000),
payload: response,
signature: nil,
ttl: env.messageTTL
)
// We're on messageQueue from delegate callback
env.broadcastPacket(responsePacket)
}
// Session establishment will trigger onPeerAuthenticated callback
// which will send any pending messages at the right time
} catch {
SecureLogger.error("Failed to process handshake: \(error)")
// Try initiating a new handshake
if !env.hasNoiseSession(peerID) {
env.initiateHandshake(peerID)
}
}
}
}
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment
guard let recipientID = PeerID(hexData: packet.recipientID) else {
SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
return
}
if recipientID != env.localPeerID() {
SecureLogger.debug("🔐 Encrypted message not for me (for \(recipientID.id.prefix(8))…, I am \(env.localPeerID().id.prefix(8))…)", category: .session)
return
}
// Update lastSeen for the peer we received from (important for private messages)
env.updatePeerLastSeen(peerID)
do {
let decrypted = try env.decrypt(packet.payload, peerID)
guard decrypted.count > 0 else { return }
// First byte indicates the payload type
let payloadType = decrypted[0]
let payloadData = decrypted.dropFirst()
guard let noisePayloadType = NoisePayloadType(rawValue: payloadType) else {
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
return
}
SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))", category: .session)
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
} catch NoiseEncryptionError.sessionNotEstablished {
// We received an encrypted message before establishing a session with this peer.
// Trigger a handshake so future messages can be decrypted.
SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake")
if !env.hasNoiseSession(peerID) {
env.initiateHandshake(peerID)
}
} catch {
// Decryption failed - clear the corrupted session and re-initiate handshake
// This handles cases where session state got out of sync (nonce mismatch, etc.)
SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake")
env.clearSession(peerID)
env.initiateHandshake(peerID)
}
}
}
@@ -1,25 +0,0 @@
import Foundation
enum BLENoisePayloadFactory {
static func privateMessage(content: String, messageID: String) -> Data? {
guard let payload = PrivateMessagePacket(messageID: messageID, content: content).encode() else {
return nil
}
return typedPayload(.privateMessage, payload: payload)
}
static func readReceipt(originalMessageID: String) -> Data {
typedPayload(.readReceipt, payload: Data(originalMessageID.utf8))
}
static func delivered(messageID: String) -> Data {
typedPayload(.delivered, payload: Data(messageID.utf8))
}
static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data {
var typed = Data([type.rawValue])
typed.append(payload)
return typed
}
}
@@ -1,46 +0,0 @@
import BitFoundation
import Foundation
struct BLEPendingPrivateMessage: Equatable {
let content: String
let messageID: String
}
struct BLENoiseSessionQueues {
private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:]
private var typedPayloadsByPeerID: [PeerID: [Data]] = [:]
var isEmpty: Bool {
privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty
}
mutating func removeAll() {
privateMessagesByPeerID.removeAll()
typedPayloadsByPeerID.removeAll()
}
mutating func appendPrivateMessage(content: String, messageID: String, for peerID: PeerID) {
privateMessagesByPeerID[peerID, default: []].append(BLEPendingPrivateMessage(content: content, messageID: messageID))
}
mutating func takePrivateMessages(for peerID: PeerID) -> [BLEPendingPrivateMessage] {
let messages = privateMessagesByPeerID[peerID] ?? []
privateMessagesByPeerID.removeValue(forKey: peerID)
return messages
}
mutating func prependPrivateMessages(_ messages: [BLEPendingPrivateMessage], for peerID: PeerID) {
guard !messages.isEmpty else { return }
privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0)
}
mutating func appendTypedPayload(_ payload: Data, for peerID: PeerID) {
typedPayloadsByPeerID[peerID, default: []].append(payload)
}
mutating func takeTypedPayloads(for peerID: PeerID) -> [Data] {
let payloads = typedPayloadsByPeerID[peerID] ?? []
typedPayloadsByPeerID.removeValue(forKey: peerID)
return payloads
}
}
@@ -1,137 +0,0 @@
import BitFoundation
import Foundation
struct BLEOutboundFragmentPlan {
let fragmentPackets: [BitchatPacket]
let fragmentVersion: UInt8
let chunkSize: Int
let spacingMs: Int
var totalFragments: Int {
fragmentPackets.count
}
var shouldPauseScanning: Bool {
totalFragments > 4
}
}
enum BLEOutboundFragmentPlanner {
private static let minimumChunkSize = 64
private static let fragmentIDLength = 8
static func makePlan(
for request: BLEOutboundFragmentTransferRequest,
defaultChunkSize: Int,
bleMaxMTU: Int,
fragmentID: Data = randomFragmentID()
) -> BLEOutboundFragmentPlan? {
guard fragmentID.count == fragmentIDLength,
let fullData = request.packet.toBinaryData(padding: request.pad) else {
return nil
}
let sizing = sizingPolicy(
for: request.packet,
requestedMaxChunk: request.maxChunk,
defaultChunkSize: defaultChunkSize,
bleMaxMTU: bleMaxMTU
)
let chunks = stride(from: 0, to: fullData.count, by: sizing.chunkSize).map { offset in
Data(fullData[offset..<min(offset + sizing.chunkSize, fullData.count)])
}
guard !chunks.isEmpty else { return nil }
let fragmentRecipient: Data? = {
if let directedPeer = request.directedPeer {
return Data(hexString: directedPeer.id)
}
return request.packet.recipientID
}()
let fragmentPackets = chunks.enumerated().map { index, chunk in
makeFragmentPacket(
original: request.packet,
fragmentID: fragmentID,
index: index,
total: chunks.count,
fragmentData: chunk,
fragmentRecipient: fragmentRecipient,
fragmentVersion: sizing.fragmentVersion
)
}
return BLEOutboundFragmentPlan(
fragmentPackets: fragmentPackets,
fragmentVersion: sizing.fragmentVersion,
chunkSize: sizing.chunkSize,
spacingMs: spacingMs(for: request)
)
}
private static func sizingPolicy(
for packet: BitchatPacket,
requestedMaxChunk: Int?,
defaultChunkSize: Int,
bleMaxMTU: Int
) -> (fragmentVersion: UInt8, chunkSize: Int) {
var fragmentVersion: UInt8 = 1
var calculatedChunk = defaultChunkSize
if let route = packet.route, !route.isEmpty {
fragmentVersion = 2
let routeSize = 1 + (route.count * 8)
let overhead = 16 + 8 + 8 + routeSize + 13 + 16
calculatedChunk = max(minimumChunkSize, bleMaxMTU - overhead)
}
return (
fragmentVersion: fragmentVersion,
chunkSize: max(minimumChunkSize, requestedMaxChunk ?? calculatedChunk)
)
}
private static func makeFragmentPacket(
original packet: BitchatPacket,
fragmentID: Data,
index: Int,
total: Int,
fragmentData: Data,
fragmentRecipient: Data?,
fragmentVersion: UInt8
) -> BitchatPacket {
var payload = Data()
payload.append(fragmentID)
payload.append(contentsOf: withUnsafeBytes(of: UInt16(index).bigEndian) { Data($0) })
payload.append(contentsOf: withUnsafeBytes(of: UInt16(total).bigEndian) { Data($0) })
payload.append(packet.type)
payload.append(fragmentData)
return BitchatPacket(
type: MessageType.fragment.rawValue,
senderID: packet.senderID,
recipientID: fragmentRecipient,
timestamp: packet.timestamp,
payload: payload,
signature: nil,
ttl: packet.ttl,
version: fragmentVersion,
route: packet.route,
isRSR: packet.isRSR
)
}
private static func spacingMs(for request: BLEOutboundFragmentTransferRequest) -> Int {
if request.directedPeer != nil || request.packet.recipientID != nil {
return TransportConfig.bleFragmentSpacingDirectedMs
}
return TransportConfig.bleFragmentSpacingMs
}
private static func randomFragmentID() -> Data {
Data((0..<fragmentIDLength).map { _ in UInt8.random(in: 0...255) })
}
}
@@ -1,181 +0,0 @@
import BitFoundation
import Foundation
struct BLEOutboundFragmentTransferRequest {
let packet: BitchatPacket
let pad: Bool
let maxChunk: Int?
let directedPeer: PeerID?
let transferId: String?
var resolvedTransferId: String? {
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
return transferId ?? packet.payload.sha256Hex()
}
}
struct BLEOutboundFragmentTransferScheduler {
enum QueuePosition {
case front
case back
}
enum SubmitResult {
case start(request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?)
case queued(request: BLEOutboundFragmentTransferRequest, transferId: String?, position: QueuePosition)
}
enum CancelResult {
case active(transferId: String, workItems: [DispatchWorkItem])
case pending(transferId: String)
case missing
}
enum SentResult: Equatable {
case progress(sentFragments: Int, totalFragments: Int)
case complete(sentFragments: Int, totalFragments: Int)
case missing
}
private struct ActiveTransferState {
let totalFragments: Int
var sentFragments: Int
var workItems: [DispatchWorkItem]
}
private var activeTransfers: [String: ActiveTransferState] = [:]
private var pendingTransfers: [BLEOutboundFragmentTransferRequest] = []
var activeCount: Int {
activeTransfers.count
}
var pendingCount: Int {
pendingTransfers.count
}
mutating func removeAll() -> [(id: String, workItems: [DispatchWorkItem])] {
let active = activeTransfers.map { ($0.key, $0.value.workItems) }
activeTransfers.removeAll()
pendingTransfers.removeAll()
return active
}
mutating func submit(
_ request: BLEOutboundFragmentTransferRequest,
maxConcurrentTransfers: Int
) -> SubmitResult {
guard let transferId = request.resolvedTransferId else {
return .start(request: request, reservedTransferId: nil)
}
guard activeTransfers.count < maxConcurrentTransfers else {
pendingTransfers.append(request)
return .queued(request: request, transferId: transferId, position: .back)
}
guard activeTransfers[transferId] == nil else {
pendingTransfers.insert(request, at: 0)
return .queued(request: request, transferId: transferId, position: .front)
}
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
return .start(request: request, reservedTransferId: transferId)
}
mutating func activateReservedTransfer(
id transferId: String,
totalFragments: Int,
workItems: [DispatchWorkItem]
) -> Bool {
guard activeTransfers[transferId] != nil else { return false }
activeTransfers[transferId] = ActiveTransferState(
totalFragments: totalFragments,
sentFragments: 0,
workItems: workItems
)
return true
}
mutating func updateWorkItems(_ workItems: [DispatchWorkItem], for transferId: String) -> Bool {
guard var state = activeTransfers[transferId] else { return false }
state.workItems = workItems
activeTransfers[transferId] = state
return true
}
mutating func releaseReservation(_ transferId: String) -> [DispatchWorkItem]? {
activeTransfers.removeValue(forKey: transferId)?.workItems
}
func isActive(_ transferId: String) -> Bool {
activeTransfers[transferId] != nil
}
mutating func cancelTransfer(_ transferId: String) -> CancelResult {
if let active = activeTransfers.removeValue(forKey: transferId) {
return .active(transferId: transferId, workItems: active.workItems)
}
if let pendingIndex = pendingTransfers.firstIndex(where: { $0.resolvedTransferId == transferId || $0.transferId == transferId }) {
pendingTransfers.remove(at: pendingIndex)
return .pending(transferId: transferId)
}
return .missing
}
mutating func markFragmentSent(transferId: String) -> SentResult {
guard var state = activeTransfers[transferId] else { return .missing }
state.sentFragments = min(state.sentFragments + 1, state.totalFragments)
let isComplete = state.sentFragments >= state.totalFragments
if isComplete {
activeTransfers.removeValue(forKey: transferId)
return .complete(sentFragments: state.sentFragments, totalFragments: state.totalFragments)
}
activeTransfers[transferId] = state
return .progress(sentFragments: state.sentFragments, totalFragments: state.totalFragments)
}
mutating func reservePendingStarts(maxConcurrentTransfers: Int) -> [SubmitResult] {
var availableSlots = max(0, maxConcurrentTransfers - activeTransfers.count)
guard availableSlots > 0, !pendingTransfers.isEmpty else { return [] }
var results: [SubmitResult] = []
var blockedFront: [BLEOutboundFragmentTransferRequest] = []
while availableSlots > 0, !pendingTransfers.isEmpty {
let request = pendingTransfers.removeFirst()
availableSlots -= 1
guard let transferId = request.resolvedTransferId else {
results.append(.start(request: request, reservedTransferId: nil))
continue
}
guard activeTransfers.count < maxConcurrentTransfers else {
pendingTransfers.insert(request, at: 0)
results.append(.queued(request: request, transferId: transferId, position: .front))
break
}
guard activeTransfers[transferId] == nil else {
blockedFront.append(request)
results.append(.queued(request: request, transferId: transferId, position: .front))
continue
}
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
results.append(.start(request: request, reservedTransferId: transferId))
}
if !blockedFront.isEmpty {
pendingTransfers.insert(contentsOf: blockedFront, at: 0)
}
return results
}
}
@@ -1,91 +0,0 @@
import BitFoundation
import Foundation
struct BLEOutboundLinkPlan: Equatable {
let directedPeerHint: PeerID?
let fragmentChunkSize: Int?
let selectedLinks: BLEFanoutSelection
let shouldSpoolDirectedPacket: Bool
}
enum BLEOutboundLinkPlanner {
static func plan(
packet: BitchatPacket,
dataCount: Int,
peripheralIDs: [String],
peripheralWriteLimits: [Int],
centralIDs: [String],
centralNotifyLimits: [Int],
ingressRecord: BLEIngressLinkRecord?,
excludedLinks: Set<BLEIngressLinkID>,
peripheralPeerBindings: [String: PeerID] = [:],
centralPeerBindings: [String: PeerID] = [:],
directedOnlyPeer: PeerID?
) -> BLEOutboundLinkPlan {
if let minLimit = minimumLinkLimit(
peripheralWriteLimits: peripheralWriteLimits,
centralNotifyLimits: centralNotifyLimits
), packet.type != MessageType.fragment.rawValue,
dataCount > minLimit {
return BLEOutboundLinkPlan(
directedPeerHint: directedPeerHint(for: packet, explicitPeer: directedOnlyPeer),
fragmentChunkSize: BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLimit),
selectedLinks: BLEFanoutSelection(peripheralIDs: [], centralIDs: []),
shouldSpoolDirectedPacket: false
)
}
let directedPeerHint = directedPeerHint(for: packet, explicitPeer: directedOnlyPeer)
let selectedLinks = BLEFanoutSelector.selectLinks(
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
ingressLink: ingressRecord?.link,
excludedLinks: excludedLinks,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings,
directedPeerHint: directedPeerHint,
packetType: packet.type,
messageID: BLEOutboundPacketPolicy.messageID(for: packet)
)
return BLEOutboundLinkPlan(
directedPeerHint: directedPeerHint,
fragmentChunkSize: nil,
selectedLinks: selectedLinks,
shouldSpoolDirectedPacket: shouldSpoolDirectedPacket(
directedPeerHint: directedPeerHint,
selectedLinks: selectedLinks,
packetType: packet.type
)
)
}
static func directedPeerHint(for packet: BitchatPacket, explicitPeer: PeerID?) -> PeerID? {
if let explicitPeer { return explicitPeer }
if let recipient = PeerID(str: packet.recipientID?.hexEncodedString()), !recipient.isEmpty {
return recipient
}
return nil
}
static func minimumLinkLimit(peripheralWriteLimits: [Int], centralNotifyLimits: [Int]) -> Int? {
[peripheralWriteLimits.min(), centralNotifyLimits.min()]
.compactMap { $0 }
.min()
}
static func shouldSpoolDirectedPacket(
directedPeerHint: PeerID?,
selectedLinks: BLEFanoutSelection,
packetType: UInt8
) -> Bool {
guard directedPeerHint != nil,
selectedLinks.peripheralIDs.isEmpty,
selectedLinks.centralIDs.isEmpty else {
return false
}
return packetType == MessageType.noiseEncrypted.rawValue ||
packetType == MessageType.noiseHandshake.rawValue
}
}
@@ -1,47 +0,0 @@
import Foundation
struct BLEPendingNotification<Target> {
let data: Data
let targets: [Target]?
}
struct BLEOutboundNotificationBuffer<Target> {
enum EnqueueResult {
case enqueued(count: Int)
case full(count: Int)
}
private var notifications: [BLEPendingNotification<Target>] = []
var count: Int {
notifications.count
}
var isEmpty: Bool {
notifications.isEmpty
}
mutating func removeAll() {
notifications.removeAll()
}
mutating func enqueue(data: Data, targets: [Target]?, capCount: Int) -> EnqueueResult {
guard notifications.count < capCount else {
return .full(count: notifications.count)
}
notifications.append(BLEPendingNotification(data: data, targets: targets))
return .enqueued(count: notifications.count)
}
mutating func takeAll() -> [BLEPendingNotification<Target>] {
let pending = notifications
notifications.removeAll()
return pending
}
mutating func prepend(_ pending: [BLEPendingNotification<Target>]) {
guard !pending.isEmpty else { return }
notifications.insert(contentsOf: pending, at: 0)
}
}
@@ -1,43 +0,0 @@
import BitFoundation
import Foundation
enum BLEOutboundPacketPolicy {
private static let fragmentFrameOverhead = 13 + 8 + 8 + 13
static func messageID(for packet: BitchatPacket) -> String {
BLEIngressLinkRegistry.messageID(for: packet)
}
static func padsBLEFrame(for packetType: UInt8) -> Bool {
switch MessageType(rawValue: packetType) {
case .noiseEncrypted, .noiseHandshake:
return true
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope:
return false
}
}
static func priority(for packet: BitchatPacket, data: Data) -> BLEOutboundWritePriority {
guard let messageType = MessageType(rawValue: packet.type) else { return .low }
switch messageType {
case .fragment:
return .fragment(totalFragments: fragmentTotalCount(from: packet.payload))
case .fileTransfer:
return .fileTransfer
default:
return .high
}
}
static func fragmentChunkSize(forLinkLimit limit: Int) -> Int {
max(64, limit - fragmentFrameOverhead)
}
private static func fragmentTotalCount(from payload: Data) -> Int {
guard payload.count >= 12 else { return Int(UInt16.max) }
let totalHigh = Int(payload[10])
let totalLow = Int(payload[11])
let total = (totalHigh << 8) | totalLow
return max(total, 1)
}
}
@@ -1,83 +0,0 @@
import Foundation
struct BLEOutboundWritePriority: Comparable {
let level: Int
let suborder: Int
static let high = BLEOutboundWritePriority(level: 0, suborder: 0)
static func fragment(totalFragments: Int) -> BLEOutboundWritePriority {
BLEOutboundWritePriority(level: 1, suborder: max(1, min(totalFragments, Int(UInt16.max))))
}
static let fileTransfer = BLEOutboundWritePriority(level: 2, suborder: Int.max - 1)
static let low = BLEOutboundWritePriority(level: 2, suborder: Int.max)
static func < (lhs: BLEOutboundWritePriority, rhs: BLEOutboundWritePriority) -> Bool {
if lhs.level != rhs.level { return lhs.level < rhs.level }
return lhs.suborder < rhs.suborder
}
}
struct BLEPendingWrite {
let priority: BLEOutboundWritePriority
let data: Data
}
struct BLEOutboundWriteBuffer {
enum EnqueueResult {
case enqueued(trimmedBytes: Int, remainingBytes: Int)
case oversized(bytes: Int)
}
private var writesByPeripheralID: [String: [BLEPendingWrite]] = [:]
var peripheralIDs: [String] {
Array(writesByPeripheralID.keys)
}
mutating func removeAll() {
writesByPeripheralID.removeAll()
}
mutating func enqueue(
data: Data,
for peripheralID: String,
priority: BLEOutboundWritePriority,
capBytes: Int
) -> EnqueueResult {
guard data.count <= capBytes else {
return .oversized(bytes: data.count)
}
var queue = writesByPeripheralID[peripheralID] ?? []
let item = BLEPendingWrite(priority: priority, data: data)
let insertIndex = queue.firstIndex { item.priority < $0.priority } ?? queue.count
queue.insert(item, at: insertIndex)
var total = queue.reduce(0) { $0 + $1.data.count }
var trimmedBytes = 0
while total > capBytes && !queue.isEmpty {
let removed = queue.removeLast()
trimmedBytes += removed.data.count
total -= removed.data.count
}
writesByPeripheralID[peripheralID] = queue.isEmpty ? nil : queue
return .enqueued(trimmedBytes: trimmedBytes, remainingBytes: total)
}
mutating func takeAll(for peripheralID: String) -> [BLEPendingWrite] {
let items = writesByPeripheralID[peripheralID] ?? []
writesByPeripheralID[peripheralID] = nil
return items
}
mutating func prepend(_ items: [BLEPendingWrite], for peripheralID: String) {
guard !items.isEmpty else { return }
var existing = writesByPeripheralID[peripheralID] ?? []
existing.insert(contentsOf: items, at: 0)
writesByPeripheralID[peripheralID] = existing
}
}
@@ -1,27 +0,0 @@
import Foundation
enum BLEPacketFreshnessPolicy {
static let defaultMaxAgeSeconds: TimeInterval = 900
static func isBroadcastRecipient(_ recipientID: Data?) -> Bool {
guard let recipientID else { return true }
return recipientID.count == 8 && recipientID.allSatisfy { $0 == 0xFF }
}
static func isStale(
timestampMilliseconds: UInt64,
now: Date,
maxAgeSeconds: TimeInterval = defaultMaxAgeSeconds
) -> Bool {
let nowMilliseconds = UInt64(now.timeIntervalSince1970 * 1000)
let maxAgeMilliseconds = UInt64(maxAgeSeconds * 1000)
guard nowMilliseconds >= maxAgeMilliseconds else { return false }
return timestampMilliseconds < nowMilliseconds - maxAgeMilliseconds
}
static func ageSeconds(timestampMilliseconds: UInt64, now: Date) -> Double {
let nowMilliseconds = UInt64(now.timeIntervalSince1970 * 1000)
guard nowMilliseconds >= timestampMilliseconds else { return 0 }
return Double(nowMilliseconds - timestampMilliseconds) / 1000.0
}
}
@@ -1,25 +0,0 @@
import BitFoundation
import Foundation
struct BLEPeerEventDebouncer {
private var lastEmitByPeer: [PeerID: Date] = [:]
var count: Int {
lastEmitByPeer.count
}
@discardableResult
mutating func shouldEmit(peerID: PeerID, now: Date, minimumInterval: TimeInterval) -> Bool {
if let lastEmit = lastEmitByPeer[peerID],
now.timeIntervalSince(lastEmit) < minimumInterval {
return false
}
lastEmitByPeer[peerID] = now
return true
}
mutating func removeAll() {
lastEmitByPeer.removeAll()
}
}
@@ -1,43 +0,0 @@
import Foundation
enum BLEPeerPublishDecision: Equatable {
case publishNow
case schedule(delay: TimeInterval)
case skip
}
struct BLEPeerPublishCoalescer {
private var lastPublishAt: Date
private var publishPending: Bool
private let minimumInterval: TimeInterval
init(
lastPublishAt: Date = .distantPast,
publishPending: Bool = false,
minimumInterval: TimeInterval = 0.1
) {
self.lastPublishAt = lastPublishAt
self.publishPending = publishPending
self.minimumInterval = minimumInterval
}
mutating func requestPublish(now: Date) -> BLEPeerPublishDecision {
let elapsed = now.timeIntervalSince(lastPublishAt)
if elapsed >= minimumInterval {
lastPublishAt = now
return .publishNow
}
guard !publishPending else {
return .skip
}
publishPending = true
return .schedule(delay: minimumInterval - elapsed)
}
mutating func scheduledPublishFired(now: Date) {
lastPublishAt = now
publishPending = false
}
}
-219
View File
@@ -1,219 +0,0 @@
import BitFoundation
import Foundation
struct BLEPeerInfo: Equatable {
let peerID: PeerID
var nickname: String
var isConnected: Bool
var noisePublicKey: Data?
var signingPublicKey: Data?
var isVerifiedNickname: Bool
var lastSeen: Date
var capabilities: PeerCapabilities = []
}
struct BLEPeerAnnounceUpdate: Equatable {
let isNewPeer: Bool
let wasDisconnected: Bool
let previousNickname: String?
}
struct BLEPeerLinkPresence: Equatable {
var hasPeripheral: Bool
var hasCentral: Bool
}
struct BLERemovedPeer: Equatable {
let peerID: PeerID
let nickname: String
}
struct BLEPeerConnectivityChanges: Equatable {
var disconnectedPeerIDs: [PeerID] = []
var removedPeers: [BLERemovedPeer] = []
}
struct BLEPeerRegistry {
private var peers: [PeerID: BLEPeerInfo] = [:]
var isEmpty: Bool {
peers.isEmpty
}
var count: Int {
peers.count
}
var peerIDs: [PeerID] {
Array(peers.keys)
}
var connectedCount: Int {
peers.values.filter(\.isConnected).count
}
var connectedPeerIDs: [PeerID] {
peers.values.compactMap { $0.isConnected ? $0.peerID : nil }
}
var connectedRoutingData: [Data] {
peers.values.filter(\.isConnected).compactMap { $0.peerID.routingData }
}
var snapshotByID: [PeerID: BLEPeerInfo] {
peers
}
mutating func removeAll() {
peers.removeAll()
}
func info(for peerID: PeerID) -> BLEPeerInfo? {
peers[peerID]
}
mutating func upsert(_ info: BLEPeerInfo) {
peers[info.peerID] = info
}
@discardableResult
mutating func remove(_ peerID: PeerID) -> BLEPeerInfo? {
peers.removeValue(forKey: peerID)
}
func isConnected(_ peerID: PeerID) -> Bool {
peers[peerID.toShort()]?.isConnected ?? false
}
func isReachable(_ peerID: PeerID, now: Date) -> Bool {
let shortID = peerID.toShort()
let meshAttached = connectedCount > 0
guard let info = peers[shortID] else { return false }
if info.isConnected { return true }
guard meshAttached else { return false }
let retention: TimeInterval = info.isVerifiedNickname
? TransportConfig.bleReachabilityRetentionVerifiedSeconds
: TransportConfig.bleReachabilityRetentionUnverifiedSeconds
return now.timeIntervalSince(info.lastSeen) <= retention
}
func nickname(for peerID: PeerID, connectedOnly: Bool) -> String? {
guard let peer = peers[peerID] else { return nil }
if connectedOnly && !peer.isConnected { return nil }
return peer.nickname
}
func fingerprint(for peerID: PeerID) -> String? {
peers[peerID]?.noisePublicKey?.sha256Fingerprint()
}
func capabilities(for peerID: PeerID) -> PeerCapabilities {
peers[peerID.toShort()]?.capabilities ?? []
}
func displayNicknames(selfNickname: String) -> [PeerID: String] {
let connected = peers.filter { $0.value.isConnected }
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
return PeerDisplayNameResolver.resolve(tuples, selfNickname: selfNickname)
}
func transportSnapshots(selfNickname: String) -> [TransportPeerSnapshot] {
let snapshot = Array(peers.values)
let resolvedNames = PeerDisplayNameResolver.resolve(
snapshot.map { ($0.peerID, $0.nickname, $0.isConnected) },
selfNickname: selfNickname
)
return snapshot.map { info in
TransportPeerSnapshot(
peerID: info.peerID,
nickname: resolvedNames[info.peerID] ?? info.nickname,
isConnected: info.isConnected,
noisePublicKey: info.noisePublicKey,
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) {
guard var info = peers[peerID] else { return }
info.isConnected = false
peers[peerID] = info
}
mutating func updateLastSeen(_ peerID: PeerID, at date: Date) {
guard var peer = peers[peerID] else { return }
peer.lastSeen = date
peers[peerID] = peer
}
mutating func upsertVerifiedAnnounce(
peerID: PeerID,
nickname: String,
noisePublicKey: Data,
signingPublicKey: Data?,
isConnected: Bool,
now: Date,
capabilities: PeerCapabilities = []
) -> BLEPeerAnnounceUpdate {
let existing = peers[peerID]
let update = BLEPeerAnnounceUpdate(
isNewPeer: existing == nil,
wasDisconnected: existing?.isConnected == false,
previousNickname: existing?.nickname
)
peers[peerID] = BLEPeerInfo(
peerID: existing?.peerID ?? peerID,
nickname: nickname,
isConnected: isConnected,
noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey,
isVerifiedNickname: true,
lastSeen: now,
capabilities: capabilities
)
return update
}
mutating func reconcileConnectivity(
now: Date,
linkStates: [PeerID: BLEPeerLinkPresence]
) -> BLEPeerConnectivityChanges {
var changes = BLEPeerConnectivityChanges()
for (peerID, peer) in Array(peers) {
let age = now.timeIntervalSince(peer.lastSeen)
let retention: TimeInterval = peer.isVerifiedNickname
? TransportConfig.bleReachabilityRetentionVerifiedSeconds
: TransportConfig.bleReachabilityRetentionUnverifiedSeconds
if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds {
let state = linkStates[peerID] ?? BLEPeerLinkPresence(hasPeripheral: false, hasCentral: false)
if !state.hasPeripheral && !state.hasCentral {
var updated = peer
updated.isConnected = false
peers[peerID] = updated
changes.disconnectedPeerIDs.append(peerID)
}
}
if !peer.isConnected && age > retention {
peers.removeValue(forKey: peerID)
changes.removedPeers.append(BLERemovedPeer(peerID: peerID, nickname: peer.nickname))
}
}
return changes
}
}
@@ -1,60 +0,0 @@
import BitFoundation
import Foundation
enum BLEPeerSenderDisplayName {
static func resolveKnownPeer(
peerID: PeerID,
localPeerID: PeerID,
localNickname: String,
peers: [PeerID: BLEPeerInfo],
allowConnectedUnverified: Bool
) -> String? {
if peerID == localPeerID {
return localNickname
}
guard let info = peers[peerID] else { return nil }
if info.isVerifiedNickname {
return collisionResolvedName(
displayName: info.nickname,
collisionNickname: info.nickname,
peerID: peerID,
localNickname: localNickname,
peers: peers
)
}
if allowConnectedUnverified, info.isConnected {
let displayName = info.nickname.isEmpty ? anonymousNickname(for: peerID) : info.nickname
return collisionResolvedName(
displayName: displayName,
collisionNickname: info.nickname,
peerID: peerID,
localNickname: localNickname,
peers: peers
)
}
return nil
}
static func anonymousNickname(for peerID: PeerID) -> String {
"anon" + String(peerID.id.prefix(4))
}
private static func collisionResolvedName(
displayName: String,
collisionNickname: String,
peerID: PeerID,
localNickname: String,
peers: [PeerID: BLEPeerInfo]
) -> String {
let hasCollision = peers.values.contains {
$0.isConnected && $0.nickname == collisionNickname && $0.peerID != peerID
} || localNickname == collisionNickname
guard hasCollision else { return displayName }
return displayName + "#" + String(peerID.id.prefix(4))
}
}
@@ -1,131 +0,0 @@
import BitFoundation
import BitLogger
import Foundation
/// Narrow environment for `BLEPublicMessageHandler`.
///
/// All queue hops (collections registry reads, BLE-queue link-state reads,
/// main-actor UI notification) live inside the closures supplied by
/// `BLEService`, keeping the handler queue-agnostic and synchronously testable.
struct BLEPublicMessageHandlerEnvironment {
/// Local peer identity at the time the message is handled.
let localPeerID: () -> PeerID
/// Local nickname used for sender resolution and collision checks.
let localNickname: () -> String
/// Current time source.
let now: () -> Date
/// Snapshot of known peers keyed by ID (registry read).
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.
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
/// Tracks the broadcast message packet for gossip sync.
let trackPacketSeen: (BitchatPacket) -> Void
/// Direct link state for the peer (BLE-queue read).
let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool)
/// Resolves and consumes the original message ID for our own re-broadcast.
let takeSelfBroadcastMessageID: (BitchatPacket) -> String?
/// Delivers `.publicMessageReceived` to the UI as one main-actor hop.
let deliverPublicMessage: (
_ peerID: PeerID,
_ nickname: String,
_ content: String,
_ timestamp: Date,
_ messageID: String?
) -> Void
}
/// Orchestrates inbound public (broadcast) messages: freshness/self-echo
/// policy, sender display-name resolution, gossip tracking, payload decoding,
/// and UI delivery.
final class BLEPublicMessageHandler {
private let environment: BLEPublicMessageHandlerEnvironment
init(environment: BLEPublicMessageHandlerEnvironment) {
self.environment = environment
}
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment
let now = env.now()
let messageDecision = BLEPublicMessagePolicy.evaluate(
packet: packet,
from: peerID,
localPeerID: env.localPeerID(),
now: now
)
let messagePolicy: BLEPublicMessageAcceptance
switch messageDecision {
case .accept(let acceptance):
messagePolicy = acceptance
case .reject(.selfEcho):
return
case .reject(.staleBroadcast(let ageSeconds)):
SecureLogger.debug("⏰ Ignoring stale broadcast message from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
return
}
// Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks.
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(
peerID: peerID,
localPeerID: env.localPeerID(),
localNickname: env.localNickname(),
peers: peersSnapshot,
allowConnectedUnverified: false
) ?? signedDisplayName else {
SecureLogger.warning("🚫 Dropping public message from unknown peer \(peerID.id.prefix(8))", category: .security)
return
}
if messagePolicy.shouldTrackForSync {
env.trackPacketSeen(packet)
}
guard let content = String(data: packet.payload, encoding: .utf8) else {
SecureLogger.error("❌ Failed to decode message payload as UTF-8", category: .session)
return
}
// Determine if we have a direct link to the sender
let directLink = env.linkState(peerID)
let hasDirectLink = directLink.hasPeripheral || directLink.hasCentral
let pathTag = hasDirectLink ? "direct" : "mesh"
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)) chars=\(content.count) bytes=\(packet.payload.count)", category: .session)
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
var resolvedSelfMessageID: String? = nil
if peerID == env.localPeerID() {
resolvedSelfMessageID = env.takeSelfBroadcastMessageID(packet)
}
env.deliverPublicMessage(peerID, senderNickname, content, ts, resolvedSelfMessageID)
}
}
@@ -1,49 +0,0 @@
import BitFoundation
import Foundation
struct BLEPublicMessageAcceptance: Equatable {
let shouldTrackForSync: Bool
}
enum BLEPublicMessageRejection: Equatable {
case selfEcho
case staleBroadcast(ageSeconds: Double)
}
enum BLEPublicMessageDecision: Equatable {
case accept(BLEPublicMessageAcceptance)
case reject(BLEPublicMessageRejection)
}
enum BLEPublicMessagePolicy {
static func evaluate(
packet: BitchatPacket,
from peerID: PeerID,
localPeerID: PeerID,
now: Date
) -> BLEPublicMessageDecision {
if peerID == localPeerID && packet.ttl != 0 {
return .reject(.selfEcho)
}
let isBroadcast = BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID)
// Acceptance window matches the gossip-sync serving window: a peer
// walking between partitions carries hours of public history, so the
// receive side must not drop what sync legitimately serves.
if isBroadcast,
BLEPacketFreshnessPolicy.isStale(
timestampMilliseconds: packet.timestamp,
now: now,
maxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds
) {
return .reject(.staleBroadcast(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
timestampMilliseconds: packet.timestamp,
now: now
)))
}
return .accept(BLEPublicMessageAcceptance(
shouldTrackForSync: isBroadcast && packet.type == MessageType.message.rawValue
))
}
}
@@ -1,95 +0,0 @@
import BitFoundation
import Foundation
struct BLEReceivedPacketContext: Equatable {
let senderID: PeerID
let messageID: String
let messageType: MessageType?
let shouldDeduplicate: Bool
let logsHandlingDetails: Bool
}
struct BLEReceivePipeline {
static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext {
let senderID = PeerID(hexData: packet.senderID)
// 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 allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID
let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay
return BLEReceivedPacketContext(
senderID: senderID,
messageID: messageID,
messageType: messageType,
shouldDeduplicate: shouldDeduplicate,
logsHandlingDetails: messageType != .announce
)
}
static func shouldCancelScheduledRelayForDuplicate(connectedPeerCount: Int) -> Bool {
connectedPeerCount > 2
}
static func relayDecision(
for packet: BitchatPacket,
senderID: PeerID,
localPeerID: PeerID,
degree: Int,
highDegreeThreshold: Int
) -> RelayDecision {
RelayController.decide(
ttl: packet.ttl,
senderIsSelf: senderID == localPeerID,
recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID,
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
// Courier envelopes are directed opaque ciphertext like DMs; a
// remote handover toward a relayed announce rides this same
// deterministic relay treatment instead of the broadcast clamp.
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|| packet.type == MessageType.courierEnvelope.rawValue) && packet.recipientID != nil,
isFragment: packet.type == MessageType.fragment.rawValue,
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
isAnnounce: packet.type == MessageType.announce.rawValue,
isRequestSync: packet.type == MessageType.requestSync.rawValue,
degree: degree,
highDegreeThreshold: highDegreeThreshold
)
}
}
struct BLERecentTrafficTracker: Equatable {
private var packetTimestamps: [Date] = []
var count: Int {
packetTimestamps.count
}
mutating func removeAll() {
packetTimestamps.removeAll()
}
mutating func recordPacket(at now: Date) {
packetTimestamps.append(now)
prune(at: now)
}
func hasTraffic(within seconds: TimeInterval, now: Date) -> Bool {
let cutoff = now.addingTimeInterval(-seconds)
return packetTimestamps.contains { $0 >= cutoff }
}
private mutating func prune(at now: Date) {
let cutoff = now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds)
if packetTimestamps.count > TransportConfig.bleRecentPacketWindowMaxCount {
packetTimestamps.removeFirst(packetTimestamps.count - TransportConfig.bleRecentPacketWindowMaxCount)
}
packetTimestamps.removeAll { $0 < cutoff }
}
}
@@ -1,101 +0,0 @@
import BitFoundation
import Foundation
struct BLERouteForwardingPlan {
let shouldSuppressFloodRelay: Bool
let forwardPacket: BitchatPacket?
let nextHop: PeerID?
static let allowFloodRelay = BLERouteForwardingPlan(
shouldSuppressFloodRelay: false,
forwardPacket: nil,
nextHop: nil
)
static let suppressFloodRelay = BLERouteForwardingPlan(
shouldSuppressFloodRelay: true,
forwardPacket: nil,
nextHop: nil
)
static func forward(_ packet: BitchatPacket, to nextHop: PeerID) -> BLERouteForwardingPlan {
BLERouteForwardingPlan(
shouldSuppressFloodRelay: true,
forwardPacket: packet,
nextHop: nextHop
)
}
}
struct BLERouteForwardingPolicy {
static func plan(
for packet: BitchatPacket,
localPeerID: PeerID,
localRoutingData: Data?,
routingPeer: (Data) -> PeerID?,
isPeerConnected: (PeerID) -> Bool
) -> BLERouteForwardingPlan {
// REQUEST_SYNC is link-local: never forward it, on the flood path or
// the source-routed path. A crafted request with a route and TTL
// headroom must not be able to fan a full-store replay out to the next
// hop. Suppressing here also short-circuits the flood relay.
if packet.type == MessageType.requestSync.rawValue {
return .suppressFloodRelay
}
if PeerID(hexData: packet.recipientID) == localPeerID {
return .suppressFloodRelay
}
guard let route = packet.route, !route.isEmpty else {
return .allowFloodRelay
}
guard packet.ttl > 1 else {
return .suppressFloodRelay
}
guard let localRoutingData else {
return .allowFloodRelay
}
guard let localIndex = route.firstIndex(of: localRoutingData) else {
return forward(packet, toRouteData: route[0], routingPeer: routingPeer, isPeerConnected: isPeerConnected)
}
if localIndex == route.count - 1 {
guard let destinationPeer = PeerID(hexData: packet.recipientID),
isPeerConnected(destinationPeer) else {
return .allowFloodRelay
}
return .forward(relayed(packet), to: destinationPeer)
}
return forward(
packet,
toRouteData: route[localIndex + 1],
routingPeer: routingPeer,
isPeerConnected: isPeerConnected
)
}
private static func forward(
_ packet: BitchatPacket,
toRouteData routeData: Data,
routingPeer: (Data) -> PeerID?,
isPeerConnected: (PeerID) -> Bool
) -> BLERouteForwardingPlan {
guard let nextPeer = routingPeer(routeData),
isPeerConnected(nextPeer) else {
return .allowFloodRelay
}
return .forward(relayed(packet), to: nextPeer)
}
private static func relayed(_ packet: BitchatPacket) -> BitchatPacket {
var relayPacket = packet
relayPacket.ttl = packet.ttl - 1
return relayPacket
}
}
@@ -1,35 +0,0 @@
import Foundation
enum BLEScanDutyPlan: Equatable {
case continuous
case dutyCycle(onDuration: TimeInterval, offDuration: TimeInterval)
}
enum BLEScanDutyPolicy {
static func plan(
dutyEnabled: Bool,
appIsActive: Bool,
connectedCount: Int,
hasRecentTraffic: Bool,
highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold
) -> BLEScanDutyPlan {
let forceContinuousScan = connectedCount <= 2 || hasRecentTraffic
let shouldDutyCycle = dutyEnabled && appIsActive && connectedCount > 0 && !forceContinuousScan
guard shouldDutyCycle else {
return .continuous
}
if connectedCount >= highDegreeThreshold {
return .dutyCycle(
onDuration: TransportConfig.bleDutyOnDurationDense,
offDuration: TransportConfig.bleDutyOffDurationDense
)
}
return .dutyCycle(
onDuration: TransportConfig.bleDutyOnDuration,
offDuration: TransportConfig.bleDutyOffDuration
)
}
}
@@ -1,43 +0,0 @@
import Foundation
struct BLEScheduledRelayStore {
private var relays: [String: DispatchWorkItem] = [:]
var count: Int {
relays.count
}
var isEmpty: Bool {
relays.isEmpty
}
mutating func schedule(_ workItem: DispatchWorkItem, messageID: String) {
relays[messageID] = workItem
}
@discardableResult
mutating func remove(messageID: String) -> DispatchWorkItem? {
relays.removeValue(forKey: messageID)
}
@discardableResult
mutating func cancel(messageID: String) -> Bool {
guard let workItem = relays.removeValue(forKey: messageID) else {
return false
}
workItem.cancel()
return true
}
mutating func cancelAll() {
relays.values.forEach { $0.cancel() }
relays.removeAll()
}
mutating func removeAllIfOverCapacity(_ maxCount: Int) {
if relays.count > maxCount {
relays.removeAll()
}
}
}
@@ -1,40 +0,0 @@
import BitFoundation
import Foundation
struct BLESelfBroadcastTracker {
private struct Entry {
let messageID: String
let sentAt: Date
}
private var entriesByDedupID: [String: Entry] = [:]
var isEmpty: Bool {
entriesByDedupID.isEmpty
}
var count: Int {
entriesByDedupID.count
}
mutating func record(messageID: String, packet: BitchatPacket, sentAt: Date) {
entriesByDedupID[Self.dedupID(for: packet)] = Entry(messageID: messageID, sentAt: sentAt)
}
mutating func takeMessageID(for packet: BitchatPacket) -> String? {
entriesByDedupID.removeValue(forKey: Self.dedupID(for: packet))?.messageID
}
mutating func prune(before cutoff: Date) {
guard !entriesByDedupID.isEmpty else { return }
entriesByDedupID = entriesByDedupID.filter { cutoff <= $0.value.sentAt }
}
mutating func removeAll() {
entriesByDedupID.removeAll()
}
static func dedupID(for packet: BitchatPacket) -> String {
"\(packet.senderID.hexEncodedString())-\(packet.timestamp)-\(packet.type)"
}
}
File diff suppressed because it is too large Load Diff
@@ -1,71 +0,0 @@
import Foundation
enum BLESubscriptionAnnounceDecision: Equatable {
case allowed
case rateLimited(backoffSeconds: TimeInterval, attemptCount: Int, suppressAnnounce: Bool)
}
struct BLESubscriptionAnnounceLimiter {
private struct State {
var lastAnnounceTime: Date
var attemptCount: Int
var currentBackoffSeconds: TimeInterval
}
private var states: [String: State] = [:]
var trackedCentralCount: Int {
states.count
}
mutating func removeAll() {
states.removeAll()
}
mutating func decision(for centralID: String, now: Date) -> BLESubscriptionAnnounceDecision {
pruneStaleEntries(now: now)
guard let existing = states[centralID] else {
recordAllowedAttempt(for: centralID, now: now)
return .allowed
}
let timeSinceLastAnnounce = now.timeIntervalSince(existing.lastAnnounceTime)
guard timeSinceLastAnnounce < existing.currentBackoffSeconds else {
recordAllowedAttempt(for: centralID, now: now)
return .allowed
}
let newAttemptCount = existing.attemptCount + 1
let newBackoff = min(
existing.currentBackoffSeconds * TransportConfig.bleSubscriptionRateLimitBackoffFactor,
TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds
)
states[centralID] = State(
lastAnnounceTime: now,
attemptCount: newAttemptCount,
currentBackoffSeconds: newBackoff
)
return .rateLimited(
backoffSeconds: existing.currentBackoffSeconds,
attemptCount: existing.attemptCount,
suppressAnnounce: newAttemptCount >= TransportConfig.bleSubscriptionRateLimitMaxAttempts
)
}
private mutating func recordAllowedAttempt(for centralID: String, now: Date) {
states[centralID] = State(
lastAnnounceTime: now,
attemptCount: 1,
currentBackoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds
)
}
private mutating func pruneStaleEntries(now: Date) {
let windowSeconds = TransportConfig.bleSubscriptionRateLimitWindowSeconds
states = states.filter { _, state in
now.timeIntervalSince(state.lastAnnounceTime) < windowSeconds
}
}
}
+29 -46
View File
@@ -28,9 +28,9 @@ struct CommandGeoParticipant {
protocol CommandContextProvider: AnyObject {
// MARK: - State Properties
var nickname: String { get }
var activeChannel: ChannelID { get }
var selectedPrivateChatPeer: PeerID? { get }
var blockedUsers: Set<String> { get }
var privateChats: [PeerID: [BitchatMessage]] { get set }
var idBridge: NostrIdentityBridge { get }
// MARK: - Peer Lookup
@@ -42,8 +42,6 @@ protocol CommandContextProvider: AnyObject {
func startPrivateChat(with peerID: PeerID)
func sendPrivateMessage(_ content: String, to peerID: PeerID)
func clearCurrentPublicTimeline()
/// Empties the peer's chat (single-writer store intent for `/clear`).
func clearPrivateChat(_ peerID: PeerID)
func sendPublicRaw(_ content: String)
// MARK: - System Messages
@@ -51,9 +49,8 @@ protocol CommandContextProvider: AnyObject {
func addPublicSystemMessage(_ content: String)
// MARK: - Favorites
/// Toggles the favorite via the unified peer flow, which persists by the
/// real noise key and notifies the peer over mesh or Nostr.
func toggleFavorite(peerID: PeerID)
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
}
/// Processes chat commands in a focused, efficient way
@@ -78,7 +75,7 @@ final class CommandProcessor {
// Geohash context: disable favoriting in public geohash or GeoDM
let inGeoPublic: Bool = {
switch contextProvider?.activeChannel ?? .mesh {
switch LocationChannelManager.shared.selectedChannel {
case .mesh: return false
case .location: return true
}
@@ -106,28 +103,11 @@ final class CommandProcessor {
case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: false)
case "/help":
return .success(message: Self.helpText)
default:
return .error(message: "unknown command: \(cmd) — type /help for commands")
return .error(message: "unknown command: \(cmd)")
}
}
/// Local-only command reference, printed as a system message. The
/// suggestion panel hides once arguments are typed, and typos used to
/// dead-end in a bare "unknown command" this is the way out.
static let helpText = """
commands:
/msg @name [message] start a private chat
/who list who's here
/clear clear this chat
/hug @name send a hug
/slap @name slap with a large trout
/block @name · /unblock @name
/fav @name · /unfav @name favorites (mesh only)
/help this list
"""
// MARK: - Command Handlers
private func handleMessage(_ args: String) -> CommandResult {
@@ -155,7 +135,7 @@ final class CommandProcessor {
private func handleWho() -> CommandResult {
// Show geohash participants when in a geohash channel; otherwise mesh peers
switch contextProvider?.activeChannel ?? .mesh {
switch LocationChannelManager.shared.selectedChannel {
case .location(let ch):
// Geohash context: show visible geohash participants (exclude self)
guard let vm = contextProvider else { return .success(message: "nobody around") }
@@ -179,7 +159,7 @@ final class CommandProcessor {
private func handleClear() -> CommandResult {
if let peerID = contextProvider?.selectedPrivateChatPeer {
contextProvider?.clearPrivateChat(peerID)
contextProvider?.privateChats[peerID]?.removeAll()
} else {
contextProvider?.clearCurrentPublicTimeline()
}
@@ -336,31 +316,34 @@ final class CommandProcessor {
guard !targetName.isEmpty else {
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
}
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
let noisePublicKey = Data(hexString: peerID.id) else {
return .error(message: "can't find peer: \(nickname)")
}
// Resolve current state by the peer's real noise key. The resolved
// peerID is either the short 16-hex mesh ID or the full 64-hex
// noise-key ID (offline favorite row) never the noise key itself.
let isCurrentlyFavorite: Bool
if let noiseKey = peerID.noiseKey {
isCurrentlyFavorite = FavoritesPersistenceService.shared.isFavorite(noiseKey)
if add {
let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: noisePublicKey,
peerNostrPublicKey: existingFavorite?.peerNostrPublicKey,
peerNickname: nickname
)
contextProvider?.toggleFavorite(peerID: peerID)
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true)
return .success(message: "added \(nickname) to favorites")
} else {
isCurrentlyFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.isFavorite ?? false
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
contextProvider?.toggleFavorite(peerID: peerID)
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false)
return .success(message: "removed \(nickname) from favorites")
}
guard add != isCurrentlyFavorite else {
return .success(message: add ? "\(nickname) is already a favorite" : "\(nickname) is not a favorite")
}
// toggleFavorite persists by the real noise key and notifies the peer.
contextProvider?.toggleFavorite(peerID: peerID)
return .success(message: add ? "added \(nickname) to favorites" : "removed \(nickname) from favorites")
}
}
-352
View File
@@ -1,352 +0,0 @@
//
// CourierStore.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import BitLogger
import Combine
import Foundation
/// Trust level of a courier deposit, decided by the caller's policy.
/// Favorites get the larger quota and are never evicted to make room for
/// verified-tier mail; verified (signature-verified announce, not a mutual
/// favorite) get a small quota so a crowd of strangers can still carry mail.
enum CourierDepositTier: String, Codable {
case favorite
case verified
}
/// Holds courier envelopes this device is carrying for offline third parties.
///
/// Envelopes are opaque ciphertext; this store never learns sender,
/// recipient, or content. Strict quotas keep the device from becoming a
/// public mailbag: bounded count, bounded per-depositor count by trust tier,
/// bounded size, and a 24-hour lifetime aligned with the outbox retention
/// policy. Carried mail is included in the panic wipe.
final class CourierStore {
struct StoredEnvelope: Codable, Equatable {
let recipientTag: Data
let expiry: UInt64
let ciphertext: Data
let depositorNoiseKey: Data
let storedAt: Date
var tier: CourierDepositTier
/// Remaining spray-and-wait budget (1 = carry-only).
var copies: UInt8
/// Couriers this envelope was already sprayed to, so a repeat announce
/// from the same peer doesn't burn budget on a copy they already hold.
var sprayedTo: Set<Data>
/// Last speculative multi-hop handover toward a relayed announce.
var lastRemoteHandoverAt: Date?
var envelope: CourierEnvelope {
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies)
}
init(
recipientTag: Data,
expiry: UInt64,
ciphertext: Data,
depositorNoiseKey: Data,
storedAt: Date,
tier: CourierDepositTier,
copies: UInt8,
sprayedTo: Set<Data> = [],
lastRemoteHandoverAt: Date? = nil
) {
self.recipientTag = recipientTag
self.expiry = expiry
self.ciphertext = ciphertext
self.depositorNoiseKey = depositorNoiseKey
self.storedAt = storedAt
self.tier = tier
self.copies = copies
self.sprayedTo = sprayedTo
self.lastRemoteHandoverAt = lastRemoteHandoverAt
}
// Files written before tiers/spray lack the newer fields; treat that
// mail as favorite-tier carry-only, which is what it was.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
recipientTag = try container.decode(Data.self, forKey: .recipientTag)
expiry = try container.decode(UInt64.self, forKey: .expiry)
ciphertext = try container.decode(Data.self, forKey: .ciphertext)
depositorNoiseKey = try container.decode(Data.self, forKey: .depositorNoiseKey)
storedAt = try container.decode(Date.self, forKey: .storedAt)
tier = try container.decodeIfPresent(CourierDepositTier.self, forKey: .tier) ?? .favorite
copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1
sprayedTo = try container.decodeIfPresent(Set<Data>.self, forKey: .sprayedTo) ?? []
lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt)
}
}
enum Limits {
static let maxEnvelopes = 40
/// Verified-tier mail can never crowd out favorites' share.
static let maxVerifiedEnvelopes = 20
static let maxPerFavoriteDepositor = 5
static let maxPerVerifiedDepositor = 2
/// Slack on top of the 24h lifetime for depositor clock skew.
static let maxExpirySlack: TimeInterval = 60 * 60
}
static let shared = CourierStore()
/// Number of envelopes currently carried, published on the main thread
/// so the UI can show a "carrying mail" indicator.
@Published private(set) var carriedCount: Int = 0
/// Fast path so hot code (announce handling) can skip tag computation.
var isEmpty: Bool {
queue.sync { envelopes.isEmpty }
}
private var envelopes: [StoredEnvelope] = []
private let queue = DispatchQueue(label: "chat.bitchat.courier.store")
private let fileURL: URL?
private let now: () -> Date
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
/// when `persistsToDisk` is false.
init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) {
self.now = now
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
loadFromDisk()
}
// MARK: - Depositing (courier side)
/// Accept an envelope from a depositor. Returns false when quotas or
/// validity checks reject it. Trust policy (which tier a depositor gets,
/// if any) is the caller's responsibility; this store only enforces
/// resource bounds.
@discardableResult
func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data, tier: CourierDepositTier = .favorite) -> Bool {
let date = now()
guard envelope.recipientTag.count == CourierEnvelope.tagLength,
!envelope.ciphertext.isEmpty,
envelope.ciphertext.count <= CourierEnvelope.maxCiphertextBytes,
!envelope.isExpired(at: date) else {
return false
}
// Reject expiries beyond the policy lifetime so depositors can't pin
// storage longer than the outbox would retain the message itself.
let maxExpiry = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + Limits.maxExpirySlack)
guard envelope.expiry <= UInt64(maxExpiry.timeIntervalSince1970 * 1000) else {
return false
}
return queue.sync {
pruneExpiredLocked(at: date)
// Identical ciphertext is the same envelope; accept idempotently,
// keeping the larger spray budget (bounded by maxCopies either way).
if let existing = envelopes.firstIndex(where: { $0.ciphertext == envelope.ciphertext }) {
envelopes[existing].copies = max(envelopes[existing].copies, envelope.copies)
persistLocked()
return true
}
let perDepositorLimit = tier == .favorite ? Limits.maxPerFavoriteDepositor : Limits.maxPerVerifiedDepositor
guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < perDepositorLimit else {
SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached (\(tier.rawValue))", category: .session)
return false
}
if tier == .verified,
envelopes.filter({ $0.tier == .verified }).count >= Limits.maxVerifiedEnvelopes {
SecureLogger.debug("📦 Courier deposit rejected: verified-tier pool full", category: .session)
return false
}
if envelopes.count >= Limits.maxEnvelopes {
// Oldest-first eviction, shedding verified-tier mail before
// favorites' so open couriering can't crowd out trusted mail.
// A verified deposit never displaces a favorite: when only
// favorite mail is stored, it is rejected instead.
if let victim = envelopes.firstIndex(where: { $0.tier == .verified }) {
let evicted = envelopes.remove(at: victim)
SecureLogger.debug("📦 Courier store full - evicted verified envelope stored at \(evicted.storedAt)", category: .session)
} else if tier == .favorite {
let evicted = envelopes.removeFirst()
SecureLogger.debug("📦 Courier store full - evicted favorite envelope stored at \(evicted.storedAt)", category: .session)
} else {
SecureLogger.debug("📦 Courier deposit rejected: store full of favorite-tier mail", category: .session)
return false
}
}
envelopes.append(StoredEnvelope(
recipientTag: envelope.recipientTag,
expiry: envelope.expiry,
ciphertext: envelope.ciphertext,
depositorNoiseKey: depositorNoiseKey,
storedAt: date,
tier: tier,
copies: envelope.copies
))
persistLocked()
return true
}
}
// MARK: - Handover (on encountering a peer)
/// Remove and return all envelopes addressed to the given peer, matching
/// the rotating recipient tag across adjacent days. Envelopes are removed
/// optimistically: handover happens over a live link, and the depositor's
/// outbox still retains the original for direct delivery.
func takeEnvelopes(for noiseStaticKey: Data) -> [CourierEnvelope] {
let date = now()
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date)
return queue.sync {
pruneExpiredLocked(at: date)
let matched = envelopes.filter { candidates.contains($0.recipientTag) }
guard !matched.isEmpty else { return [] }
envelopes.removeAll { stored in matched.contains(stored) }
persistLocked()
return matched.map(\.envelope)
}
}
/// Envelopes addressed to a recipient we heard from via a *relayed*
/// announce. Non-destructive: a multi-hop send is speculative, so the
/// envelope stays carried until a direct handover or expiry. The per-
/// envelope cooldown keeps repeated announces from re-flooding the mesh.
func envelopesForRemoteHandover(recipientNoiseKey: Data, cooldown: TimeInterval) -> [CourierEnvelope] {
let date = now()
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: recipientNoiseKey, around: date)
return queue.sync {
pruneExpiredLocked(at: date)
var matched: [CourierEnvelope] = []
for index in envelopes.indices where candidates.contains(envelopes[index].recipientTag) {
if let last = envelopes[index].lastRemoteHandoverAt,
date.timeIntervalSince(last) < cooldown {
continue
}
envelopes[index].lastRemoteHandoverAt = date
// The delivered copy carries no spray budget.
matched.append(envelopes[index].envelope.withCopies(1))
}
if !matched.isEmpty { persistLocked() }
return matched
}
}
// MARK: - Spray-and-wait (on encountering another courier)
/// Envelopes to re-deposit with a courier we just encountered, each with
/// half its remaining budget (binary spray). Skips envelopes the courier
/// deposited, envelopes addressed to them (those ride the handover path),
/// carry-only envelopes, and couriers already sprayed.
func takeSprayCopies(for courierNoiseKey: Data) -> [CourierEnvelope] {
let date = now()
let courierTags = CourierEnvelope.candidateTags(noiseStaticKey: courierNoiseKey, around: date)
return queue.sync {
pruneExpiredLocked(at: date)
var sprayed: [CourierEnvelope] = []
for index in envelopes.indices {
let stored = envelopes[index]
guard stored.copies > 1,
stored.depositorNoiseKey != courierNoiseKey,
!stored.sprayedTo.contains(courierNoiseKey),
!courierTags.contains(stored.recipientTag) else { continue }
let given = stored.copies / 2
envelopes[index].copies = stored.copies - given
envelopes[index].sprayedTo.insert(courierNoiseKey)
sprayed.append(stored.envelope.withCopies(given))
}
if !sprayed.isEmpty { persistLocked() }
return sprayed
}
}
// MARK: - Maintenance
func pruneExpired() {
let date = now()
queue.sync {
pruneExpiredLocked(at: date)
persistLocked()
}
}
/// Panic wipe: drop all carried mail from memory and disk.
func wipe() {
queue.sync {
envelopes.removeAll()
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
publishCountLocked()
}
}
// MARK: - Internals (call only on `queue`)
private func pruneExpiredLocked(at date: Date) {
let before = envelopes.count
envelopes.removeAll { $0.envelope.isExpired(at: date) }
if envelopes.count != before {
SecureLogger.debug("📦 Courier store pruned \(before - envelopes.count) expired envelope(s)", category: .session)
}
}
private func publishCountLocked() {
let count = envelopes.count
DispatchQueue.main.async { [weak self] in
self?.carriedCount = count
}
}
private func persistLocked() {
publishCountLocked()
guard let fileURL else { return }
do {
if envelopes.isEmpty {
try? FileManager.default.removeItem(at: fileURL)
return
}
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let data = try JSONEncoder().encode(envelopes)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtection)
#endif
try data.write(to: fileURL, options: options)
} catch {
SecureLogger.error("Failed to persist courier store: \(error)", category: .session)
}
}
private func loadFromDisk() {
guard let fileURL else { return }
queue.sync {
guard let data = try? Data(contentsOf: fileURL),
let stored = try? JSONDecoder().decode([StoredEnvelope].self, from: data) else {
return
}
envelopes = stored
pruneExpiredLocked(at: now())
publishCountLocked()
}
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
) else { return nil }
return base
.appendingPathComponent("courier", isDirectory: true)
.appendingPathComponent("envelopes.json")
}
}
@@ -1,156 +0,0 @@
//
// MessageOutboxStore.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import BitLogger
import CryptoKit
import Foundation
import Security
/// Disk persistence for the MessageRouter outbox, so private messages queued
/// for an offline peer survive an app kill instead of silently evaporating.
///
/// Nothing else in the app persists message plaintext, and this store keeps
/// that property: the outbox is sealed with a ChaChaPoly key that lives only
/// in the Keychain (after-first-unlock, this device only), on top of iOS file
/// protection. Wiped on panic alongside the courier store.
final class MessageOutboxStore {
struct QueuedMessage: Codable, Equatable {
let content: String
let nickname: String
let messageID: String
let timestamp: Date
var sendAttempts: Int
/// Noise keys of couriers already carrying this message, so deposit
/// retries add couriers instead of re-burning the same ones.
var depositedCourierKeys: Set<Data>
init(
content: String,
nickname: String,
messageID: String,
timestamp: Date,
sendAttempts: Int = 0,
depositedCourierKeys: Set<Data> = []
) {
self.content = content
self.nickname = nickname
self.messageID = messageID
self.timestamp = timestamp
self.sendAttempts = sendAttempts
self.depositedCourierKeys = depositedCourierKeys
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
content = try container.decode(String.self, forKey: .content)
nickname = try container.decode(String.self, forKey: .nickname)
messageID = try container.decode(String.self, forKey: .messageID)
timestamp = try container.decode(Date.self, forKey: .timestamp)
sendAttempts = try container.decodeIfPresent(Int.self, forKey: .sendAttempts) ?? 0
depositedCourierKeys = try container.decodeIfPresent(Set<Data>.self, forKey: .depositedCourierKeys) ?? []
}
}
private static let keychainService = "chat.bitchat.outbox"
private static let keychainKey = "outbox-encryption-key"
private let fileURL: URL?
private let keychain: KeychainManagerProtocol
init(keychain: KeychainManagerProtocol, fileURL: URL? = nil) {
self.keychain = keychain
self.fileURL = fileURL ?? Self.defaultFileURL()
}
// MARK: - API (call from the router's actor; IO is small and atomic)
func load() -> [PeerID: [QueuedMessage]] {
guard let fileURL,
let sealed = try? Data(contentsOf: fileURL),
let key = encryptionKey(createIfMissing: false),
let box = try? ChaChaPoly.SealedBox(combined: sealed),
let plaintext = try? ChaChaPoly.open(box, using: key),
let decoded = try? JSONDecoder().decode([String: [QueuedMessage]].self, from: plaintext) else {
return [:]
}
var outbox: [PeerID: [QueuedMessage]] = [:]
for (peerID, queue) in decoded where !queue.isEmpty {
outbox[PeerID(str: peerID)] = queue
}
return outbox
}
func save(_ outbox: [PeerID: [QueuedMessage]]) {
guard let fileURL else { return }
let flattened = outbox.filter { !$0.value.isEmpty }
guard !flattened.isEmpty else {
try? FileManager.default.removeItem(at: fileURL)
return
}
guard let key = encryptionKey(createIfMissing: true) else {
SecureLogger.error("Outbox not persisted: no encryption key available", category: .session)
return
}
do {
let keyed = Dictionary(uniqueKeysWithValues: flattened.map { ($0.key.id, $0.value) })
let plaintext = try JSONEncoder().encode(keyed)
let sealed = try ChaChaPoly.seal(plaintext, using: key).combined
try FileManager.default.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
var options: Data.WritingOptions = [.atomic]
#if os(iOS)
options.insert(.completeFileProtection)
#endif
try sealed.write(to: fileURL, options: options)
} catch {
SecureLogger.error("Failed to persist outbox: \(error)", category: .session)
}
}
/// Panic wipe: drop the queued mail and the key that could ever read it.
func wipe() {
if let fileURL {
try? FileManager.default.removeItem(at: fileURL)
}
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
}
// MARK: - Internals
private func encryptionKey(createIfMissing: Bool) -> SymmetricKey? {
if let data = keychain.load(key: Self.keychainKey, service: Self.keychainService), data.count == 32 {
return SymmetricKey(data: data)
}
guard createIfMissing else { return nil }
let key = SymmetricKey(size: .bits256)
let data = key.withUnsafeBytes { Data($0) }
// After-first-unlock so queued mail can flush from background BLE wakes.
keychain.save(
key: Self.keychainKey,
data: data,
service: Self.keychainService,
accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
)
return key
}
private static func defaultFileURL() -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
) else { return nil }
return base
.appendingPathComponent("courier", isDirectory: true)
.appendingPathComponent("outbox.sealed")
}
}
@@ -1,74 +0,0 @@
//
// StoreAndForwardMetrics.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitLogger
import Foundation
/// Privacy-safe local counters for the store-and-forward stack: bare event
/// tallies with no message IDs, peer identities, or timestamps, so delivery
/// behavior can be measured on-device without recording who talked to whom.
/// Log-only surface nothing here ever leaves the device.
final class StoreAndForwardMetrics {
enum Event: String, CaseIterable {
/// A private message entered the outbox (no prompt route available).
case outboxQueued = "outbox.queued"
/// A retained message was re-sent on a flush.
case outboxResent = "outbox.resent"
/// A delivery/read ack cleared a retained message.
case outboxDelivered = "outbox.delivered"
/// A retained message was dropped (attempt cap, TTL, or overflow).
case outboxDropped = "outbox.dropped"
/// We handed sealed mail to a courier.
case courierDeposited = "courier.deposited"
/// We accepted sealed mail to carry for a third party.
case courierAccepted = "courier.accepted"
/// We handed carried mail to its recipient over a direct link.
case courierHandedOver = "courier.handedOver"
/// We pushed carried mail toward a recipient heard via relay.
case courierRemoteHandover = "courier.remoteHandover"
/// We split spray copies to another courier.
case courierSprayed = "courier.sprayed"
/// Couriered mail addressed to us was opened and delivered.
case courierOpened = "courier.opened"
}
static let shared = StoreAndForwardMetrics()
private let lock = NSLock()
private var counts: [String: Int]
private let defaults: UserDefaults
private static let defaultsKey = "chat.bitchat.storeAndForwardMetrics"
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.counts = defaults.dictionary(forKey: Self.defaultsKey) as? [String: Int] ?? [:]
}
func record(_ event: Event) {
lock.lock()
let total = (counts[event.rawValue] ?? 0) + 1
counts[event.rawValue] = total
defaults.set(counts, forKey: Self.defaultsKey)
lock.unlock()
SecureLogger.debug("📊 S&F \(event.rawValue)\(total)", category: .session)
}
func snapshot() -> [String: Int] {
lock.lock()
defer { lock.unlock() }
return counts
}
/// Included in the panic wipe alongside the stores it describes.
func reset() {
lock.lock()
counts = [:]
defaults.removeObject(forKey: Self.defaultsKey)
lock.unlock()
}
}
@@ -34,23 +34,7 @@ final class FavoritesPersistenceService: ObservableObject {
static let shared = FavoritesPersistenceService()
/// Default keychain for the `shared` singleton. Under test this is an
/// in-memory keychain so touching `shared` never blocks on securityd
/// (`SecItemCopyMatching` can hang in test environments) and never reads
/// or writes the developer's real keychain. Production behavior is
/// unchanged. Tests that need their own instance keep injecting a mock
/// via `init(keychain:)`.
private nonisolated static func makeDefaultKeychain() -> KeychainManagerProtocol {
// PreviewKeychainManager lives in _PreviewHelpers, a development
// asset excluded from archive builds release code must not
// reference it. Tests always run Debug, so the guard is lossless.
#if DEBUG
if TestEnvironment.isRunningTests { return PreviewKeychainManager() }
#endif
return KeychainManager()
}
init(keychain: KeychainManagerProtocol = FavoritesPersistenceService.makeDefaultKeychain()) {
init(keychain: KeychainManagerProtocol = KeychainManager()) {
self.keychain = keychain
loadFavorites()
@@ -141,13 +125,7 @@ final class FavoritesPersistenceService: ObservableObject {
peerNostrPublicKey: String? = nil
) {
let existing = favorites[peerNoisePublicKey]
// Callers that can't resolve the live nickname pass the "Unknown"
// placeholder (e.g. a notification arriving before the announce);
// never let it clobber a real stored nickname.
let incoming = peerNickname.flatMap { name in
(name.isEmpty || name == "Unknown") ? nil : name
}
let displayName = incoming ?? existing?.peerNickname ?? "Unknown"
let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown"
SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
+67 -1
View File
@@ -7,10 +7,76 @@
//
import BitLogger
import BitFoundation
import Foundation
import Security
// MARK: - Keychain Error Types
// BCH-01-009: Proper error classification to distinguish expected states from critical failures
/// Result of a keychain read operation with proper error classification
enum KeychainReadResult {
case success(Data)
case itemNotFound // Expected: key doesn't exist yet
case accessDenied // Critical: app lacks keychain access
case deviceLocked // Recoverable: device is locked
case authenticationFailed // Recoverable: biometric/passcode failed
case otherError(OSStatus) // Unexpected error
var isRecoverableError: Bool {
switch self {
case .deviceLocked, .authenticationFailed:
return true
default:
return false
}
}
}
/// Result of a keychain save operation with proper error classification
enum KeychainSaveResult {
case success
case duplicateItem // Can retry with update
case accessDenied // Critical: app lacks keychain access
case deviceLocked // Recoverable: device is locked
case storageFull // Critical: no space available
case otherError(OSStatus)
var isRecoverableError: Bool {
switch self {
case .duplicateItem, .deviceLocked:
return true
default:
return false
}
}
}
protocol KeychainManagerProtocol {
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool
func getIdentityKey(forKey key: String) -> Data?
func deleteIdentityKey(forKey key: String) -> Bool
func deleteAllKeychainData() -> Bool
func secureClear(_ data: inout Data)
func secureClear(_ string: inout String)
func verifyIdentityKeyExists() -> Bool
// BCH-01-009: Methods with proper error classification
/// Get identity key with detailed result for error handling
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult
/// Save identity key with detailed result for error handling
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
/// Save data with a custom service name
func save(key: String, data: Data, service: String, accessible: CFString?)
/// Load data from a custom service
func load(key: String, service: String) -> Data?
/// Delete data from a custom service
func delete(key: String, service: String)
}
final class KeychainManager: KeychainManagerProtocol {
// Use consistent service name for all keychain items
private let service = BitchatApp.bundleID
+2 -19
View File
@@ -1,5 +1,4 @@
import BitLogger
import Combine
import Foundation
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
@@ -15,9 +14,7 @@ struct LocationNotesDependencies {
var sendEvent: SendEvent
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
var now: () -> Date
// Fires when the geo relay directory refreshes; used to retry after "no relays".
var relayDirectoryUpdates: AnyPublisher<Void, Never> = Empty(completeImmediately: false).eraseToAnyPublisher()
private static let idBridge = NostrIdentityBridge()
static let live = LocationNotesDependencies(
@@ -42,11 +39,7 @@ struct LocationNotesDependencies {
deriveIdentity: { geohash in
try idBridge.deriveIdentity(forGeohash: geohash)
},
now: { Date() },
relayDirectoryUpdates: NotificationCenter.default
.publisher(for: .geoRelayDirectoryDidRefresh)
.map { _ in () }
.eraseToAnyPublisher()
now: { Date() }
)
}
@@ -84,7 +77,6 @@ final class LocationNotesManager: ObservableObject {
@Published private(set) var errorMessage: String?
private var subscriptionID: String?
private var noteIDs = Set<String>() // O(1) duplicate detection
private var directoryUpdateCancellable: AnyCancellable?
private let dependencies: LocationNotesDependencies
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
@@ -109,15 +101,6 @@ final class LocationNotesManager: ObservableObject {
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
}
subscribe()
// The relay directory may load after init (remote fetch over Tor);
// retry automatically instead of staying stuck on "no relays".
directoryUpdateCancellable = dependencies.relayDirectoryUpdates
.sink { [weak self] in
Task { @MainActor [weak self] in
guard let self, self.state == .noRelays else { return }
self.subscribe()
}
}
}
func setGeohash(_ newGeohash: String) {
@@ -594,22 +594,6 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
}
}
/// Removes all persisted location state and resets the in-memory view.
/// Used by the panic wipe selected channel, teleport set and bookmarks
/// (which reveal where the user has been) must not survive on device.
func panicWipe() {
storage.removeObject(forKey: selectedChannelKey)
storage.removeObject(forKey: teleportedStoreKey)
storage.removeObject(forKey: bookmarksKey)
storage.removeObject(forKey: bookmarkNamesKey)
teleportedSet.removeAll()
bookmarkMembership.removeAll()
bookmarks = []
bookmarkNames = [:]
teleported = false
selectedChannel = .mesh
}
private static func normalizeGeohash(_ s: String) -> String {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
return s
+29 -280
View File
@@ -2,80 +2,27 @@ import BitLogger
import BitFoundation
import Foundation
/// Trust and identity lookups the router needs to pick couriers. Backed by
/// the favorites store in production; injectable for tests.
struct CourierDirectory {
/// Noise static key for a peer we can address while they're offline.
var noiseKey: (PeerID) -> Data?
/// Whether a peer (by Noise static key) is a mutual favorite the
/// preferred courier tier. Verified non-favorites are the fallback tier,
/// read off the transport snapshot.
var isTrustedCourier: (Data) -> Bool
@MainActor
static func favoritesBacked() -> CourierDirectory {
CourierDirectory(
noiseKey: { peerID in
// Offline favorites are addressed by the full 64-hex
// noise-key ID, which carries the key itself; the favorites
// lookup only resolves short 16-hex IDs.
peerID.noiseKey
?? FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNoisePublicKey
},
isTrustedCourier: { noiseKey in
FavoritesPersistenceService.shared.isMutualFavorite(noiseKey)
}
)
}
}
/// Routes messages using available transports (Mesh, Nostr, etc.)
@MainActor
final class MessageRouter {
typealias QueuedMessage = MessageOutboxStore.QueuedMessage
private let transports: [Transport]
private let now: () -> Date
private let courierDirectory: CourierDirectory
private let outboxStore: MessageOutboxStore?
private let metrics: StoreAndForwardMetrics?
/// Invoked whenever a retained private message is dropped without a
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
/// so the UI can surface the failure instead of leaving the message in a
/// stale "sending/sent" state forever.
var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)?
/// Invoked when a message with no reachable transport was handed to at
/// least one courier (a connected peer who will physically carry the
/// sealed envelope). Delivery stays best-effort: the outbox retains the
/// message until an ack arrives.
var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)?
// Outbox entry with timestamp for TTL-based eviction
private struct QueuedMessage {
let content: String
let nickname: String
let messageID: String
let timestamp: Date
}
private var outbox: [PeerID: [QueuedMessage]] = [:]
// Outbox limits to prevent unbounded memory growth
private static let maxMessagesPerPeer = 100
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
// Bound resends of messages sent on a weak reachability signal that never
// get a delivery ack (e.g. peer on an old client that doesn't ack).
private static let maxSendAttempts = 8
// Redundant couriers improve delivery odds; receivers dedup by message ID.
private static let maxCouriersPerMessage = 3
init(
transports: [Transport],
now: @escaping () -> Date = Date.init,
courierDirectory: CourierDirectory? = nil,
outboxStore: MessageOutboxStore? = nil,
metrics: StoreAndForwardMetrics? = nil
) {
init(transports: [Transport]) {
self.transports = transports
self.now = now
self.courierDirectory = courierDirectory ?? .favoritesBacked()
self.outboxStore = outboxStore
self.metrics = metrics
self.outbox = outboxStore?.load() ?? [:]
// Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver(
@@ -92,7 +39,7 @@ final class MessageRouter {
}
// Handle key updates
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
note.userInfo?["isKeyUpdate"] is Bool {
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
let peerID = PeerID(publicKey: newKey)
Task { @MainActor in
self.flushOutbox(for: peerID)
@@ -114,194 +61,26 @@ final class MessageRouter {
// MARK: - Message Sending
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
if let transport = connectedTransport(for: peerID) {
// A live link is a strong delivery signal; trust it outright.
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
return
}
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: now(), sendAttempts: 1)
if let transport = reachableTransport(for: peerID) {
// Reachability without a connection is a freshness heuristic (e.g.
// the mesh retention window), so the send can silently go nowhere.
// Send now, but retain a copy until a delivery/read ack clears it;
// receivers dedup resends by message ID.
SecureLogger.debug("Routing PM via \(type(of: transport)) (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
enqueue(message, for: peerID)
// "Reachable" without prompt delivery means the send only joined
// a queue (Nostr with relays down): also hand a sealed copy to
// any connected couriers rather than waiting for internet that
// may never come. Double delivery is harmless receivers dedup
// by message ID, and delivered/read acks never downgrade.
if !transport.canDeliverPromptly(to: peerID) {
attemptCourierDeposit(messageID: messageID, for: peerID)
}
} else {
var unsent = message
unsent.sendAttempts = 0
enqueue(unsent, for: peerID)
// Queue for later with timestamp for TTL tracking
if outbox[peerID] == nil { outbox[peerID] = [] }
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: Date())
outbox[peerID]?.append(message)
// Enforce per-peer size limit with FIFO eviction
if let count = outbox[peerID]?.count, count > Self.maxMessagesPerPeer {
let evicted = outbox[peerID]?.removeFirst()
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted?.messageID.prefix(8) ?? "?")", category: .session)
}
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
attemptCourierDeposit(messageID: messageID, for: peerID)
}
}
// MARK: - Couriers
/// Last resort when no transport can deliver promptly the peer is
/// unreachable, or only reachable through a send queue waiting on
/// internet: seal the message to their known static key and hand it to
/// connected couriers who may physically encounter them. Mutual favorites
/// are preferred; signature-verified strangers fill remaining slots so a
/// crowd without favorites can still carry mail (envelopes are opaque
/// either way). The queued copy stays retained, so direct delivery still
/// wins if the peer reappears first (receivers dedup by message ID).
private func attemptCourierDeposit(messageID: String, for peerID: PeerID) {
guard let recipientKey = courierDirectory.noiseKey(peerID),
let entry = queuedMessage(messageID, for: peerID) else { return }
let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count
guard remainingSlots > 0 else { return }
for transport in transports {
let couriers = eligibleCouriers(
on: transport,
recipientKey: recipientKey,
excluding: entry.depositedCourierKeys,
limit: remainingSlots
)
guard !couriers.isEmpty else { continue }
if transport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) {
SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))", category: .session)
recordCourierDeposit(messageID: messageID, for: peerID, courierKeys: couriers.map(\.noiseKey))
onMessageCarried?(messageID, peerID)
return
}
}
}
/// A courier candidate just connected: hand them any queued mail they are
/// not already carrying. This is what turns couriering from "a favorite
/// happened to be around at send time" into eventual spread deposits
/// retry as eligible peers appear, until each message rides with
/// `maxCouriersPerMessage` distinct couriers or expires.
func courierBecameAvailable(_ peerID: PeerID) {
for transport in transports {
guard transport.isPeerConnected(peerID),
let snapshot = transport.currentPeerSnapshots().first(where: { $0.peerID == peerID && $0.isConnected }),
let courierKey = snapshot.noisePublicKey,
courierDirectory.isTrustedCourier(courierKey) || snapshot.isVerified else { continue }
let currentDate = now()
for (recipient, queue) in outbox {
// Mail *to* this peer flushes directly on connect.
guard recipient != peerID,
let recipientKey = courierDirectory.noiseKey(recipient),
recipientKey != courierKey else { continue }
for message in queue {
guard message.depositedCourierKeys.count < Self.maxCouriersPerMessage,
!message.depositedCourierKeys.contains(courierKey),
currentDate.timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds else { continue }
if transport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) {
SecureLogger.debug("📦 Deposit retry: PM \(message.messageID.prefix(8))… handed to \(peerID.id.prefix(8))… for \(recipient.id.prefix(8))", category: .session)
recordCourierDeposit(messageID: message.messageID, for: recipient, courierKeys: [courierKey])
onMessageCarried?(message.messageID, recipient)
}
}
}
return
}
}
private struct CourierCandidate {
let peerID: PeerID
let noiseKey: Data
}
private func eligibleCouriers(
on transport: Transport,
recipientKey: Data,
excluding excludedKeys: Set<Data>,
limit: Int
) -> [CourierCandidate] {
guard limit > 0 else { return [] }
let candidates = transport.currentPeerSnapshots().compactMap { snapshot -> (CourierCandidate, isFavorite: Bool)? in
guard snapshot.isConnected,
let key = snapshot.noisePublicKey,
key != recipientKey,
!excludedKeys.contains(key) else { return nil }
let isFavorite = courierDirectory.isTrustedCourier(key)
guard isFavorite || snapshot.isVerified else { return nil }
return (CourierCandidate(peerID: snapshot.peerID, noiseKey: key), isFavorite)
}
return candidates
.sorted { $0.isFavorite && !$1.isFavorite }
.prefix(limit)
.map(\.0)
}
private func queuedMessage(_ messageID: String, for peerID: PeerID) -> QueuedMessage? {
outbox[peerID]?.first { $0.messageID == messageID }
}
private func recordCourierDeposit(messageID: String, for peerID: PeerID, courierKeys: [Data]) {
metrics?.record(.courierDeposited)
guard var queue = outbox[peerID],
let index = queue.firstIndex(where: { $0.messageID == messageID }) else { return }
queue[index].depositedCourierKeys.formUnion(courierKeys)
outbox[peerID] = queue
persistOutbox()
}
// MARK: - Outbox Management
/// A delivery or read ack confirms receipt; stop retaining the message.
func markDelivered(_ messageID: String) {
var cleared = false
for (peerID, queue) in outbox {
let filtered = queue.filter { $0.messageID != messageID }
guard filtered.count != queue.count else { continue }
outbox[peerID] = filtered.isEmpty ? nil : filtered
cleared = true
}
if cleared {
metrics?.record(.outboxDelivered)
persistOutbox()
}
}
private func enqueue(_ message: QueuedMessage, for peerID: PeerID) {
var queue = outbox[peerID] ?? []
// Re-sending an already-queued ID replaces the entry (keeps attempt count fresh)
queue.removeAll { $0.messageID == message.messageID }
queue.append(message)
// Enforce per-peer size limit with FIFO eviction
if queue.count > Self.maxMessagesPerPeer {
let evicted = queue.removeFirst()
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted.messageID.prefix(8))", category: .session)
dropMessage(evicted.messageID, for: peerID)
}
outbox[peerID] = queue
metrics?.record(.outboxQueued)
persistOutbox()
}
private func dropMessage(_ messageID: String, for peerID: PeerID) {
metrics?.record(.outboxDropped)
onMessageDropped?(messageID, peerID)
}
private func persistOutbox() {
outboxStore?.save(outbox)
}
/// Panic wipe: forget queued mail on disk and in memory.
func wipeOutbox() {
outbox.removeAll()
outboxStore?.wipe()
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
if let transport = reachableTransport(for: peerID) {
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
@@ -326,40 +105,25 @@ final class MessageRouter {
}
}
// MARK: - Outbox Management
func flushOutbox(for peerID: PeerID) {
guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
let now = now()
let now = Date()
var remaining: [QueuedMessage] = []
for message in queued {
// Skip expired messages (TTL exceeded)
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
dropMessage(message.messageID, for: peerID)
continue
}
if let transport = connectedTransport(for: peerID) {
// Live link: send and stop retaining.
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
if let transport = reachableTransport(for: peerID) {
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
metrics?.record(.outboxResent)
} else if let transport = reachableTransport(for: peerID) {
// Weak signal: send but keep retaining until an ack clears it,
// bounded by attempt count for peers that never ack.
guard message.sendAttempts < Self.maxSendAttempts else {
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
dropMessage(message.messageID, for: peerID)
continue
}
SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
metrics?.record(.outboxResent)
var retained = message
retained.sendAttempts += 1
remaining.append(retained)
} else {
remaining.append(message)
}
@@ -370,7 +134,6 @@ final class MessageRouter {
} else {
outbox[peerID] = remaining
}
persistOutbox()
}
func flushAllOutbox() {
@@ -379,26 +142,12 @@ final class MessageRouter {
/// Periodically clean up expired messages from all outboxes
func cleanupExpiredMessages() {
let now = now()
var droppedAny = false
let now = Date()
for peerID in Array(outbox.keys) {
var expiredMessageIDs: [String] = []
outbox[peerID]?.removeAll { message in
guard now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds else { return false }
expiredMessageIDs.append(message.messageID)
return true
}
outbox[peerID]?.removeAll { now.timeIntervalSince($0.timestamp) > Self.messageTTLSeconds }
if outbox[peerID]?.isEmpty == true {
outbox.removeValue(forKey: peerID)
}
for messageID in expiredMessageIDs {
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
dropMessage(messageID, for: peerID)
droppedAny = true
}
}
if droppedAny {
persistOutbox()
}
}
}
@@ -44,11 +44,7 @@ final class NetworkActivationService: ObservableObject {
private let permissionProvider: () -> LocationChannelManager.PermissionState
private let mutualFavoritesProvider: () -> Set<Data>
private let torController: NetworkActivationTorControlling
// Resolved lazily: NostrRelayManager.init() reads NetworkActivationService.shared
// (via its live dependencies), so capturing NostrRelayManager.shared here would
// re-enter whichever singleton's dispatch_once started first and trap at launch.
private lazy var relayController: NetworkActivationRelayControlling = relayControllerProvider()
private let relayControllerProvider: () -> NetworkActivationRelayControlling
private let relayController: NetworkActivationRelayControlling
private let proxyController: NetworkActivationProxyControlling
private let notificationCenter: NotificationCenter
@@ -59,7 +55,7 @@ final class NetworkActivationService: ObservableObject {
permissionProvider = { LocationChannelManager.shared.permissionState }
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
torController = TorManager.shared
relayControllerProvider = { NostrRelayManager.shared }
relayController = NostrRelayManager.shared
proxyController = TorURLSession.shared
notificationCenter = .default
}
@@ -81,7 +77,7 @@ final class NetworkActivationService: ObservableObject {
self.permissionProvider = permissionProvider
self.mutualFavoritesProvider = mutualFavoritesProvider
self.torController = torController
self.relayControllerProvider = { relayController }
self.relayController = relayController
self.proxyController = proxyController
self.notificationCenter = notificationCenter
}
+2 -45
View File
@@ -369,49 +369,6 @@ final class NoiseEncryptionService {
func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
}
// MARK: - Courier Envelopes (one-way Noise X)
/// Domain separation for courier envelopes so X-pattern transcripts can
/// never be confused with interactive XX handshakes.
private static let courierPrologue = Data("bitchat-courier-v1".utf8)
/// Encrypt a payload to a peer's known static key without an interactive
/// handshake (Noise X pattern). Used for store-and-forward envelopes
/// carried by couriers while the recipient is offline.
/// - Warning: One-way messages have no forward secrecy: a later compromise
/// of the recipient's static key exposes envelopes captured in transit.
/// Use established sessions whenever the peer is reachable.
func sealCourierPayload(_ payload: Data, recipientStaticKey: Data) throws -> Data {
let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientStaticKey)
let handshake = NoiseHandshakeState(
role: .initiator,
pattern: .X,
keychain: keychain,
localStaticKey: staticIdentityKey,
remoteStaticKey: remoteKey,
prologue: Self.courierPrologue
)
return try handshake.writeMessage(payload: payload)
}
/// Decrypt a courier envelope addressed to our static key. Returns the
/// payload and the sender's authenticated static public key (the `ss`
/// DH in the X pattern binds the sender's identity to the ciphertext).
func openCourierPayload(_ envelopeCiphertext: Data) throws -> (payload: Data, senderStaticKey: Data) {
let handshake = NoiseHandshakeState(
role: .responder,
pattern: .X,
keychain: keychain,
localStaticKey: staticIdentityKey,
prologue: Self.courierPrologue
)
let payload = try handshake.readMessage(envelopeCiphertext)
guard let senderKey = handshake.getRemoteStaticPublicKey() else {
throw NoiseError.missingKeys
}
return (payload: payload, senderStaticKey: senderKey.rawRepresentation)
}
/// Clear persistent identity (for panic mode)
func clearPersistentIdentity() {
@@ -471,7 +428,7 @@ final class NoiseEncryptionService {
private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data {
var out = Data()
// context
let context = Data("bitchat-announce-v1".utf8)
let context = "bitchat-announce-v1".data(using: .utf8) ?? Data()
out.append(UInt8(min(context.count, 255)))
out.append(context.prefix(255))
// peerID (expect 8 bytes; pad/truncate to 8 for canonicalization)
@@ -487,7 +444,7 @@ final class NoiseEncryptionService {
out.append(ed32)
if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) }
// nickname length + bytes
let nickData = Data(nickname.utf8)
let nickData = nickname.data(using: .utf8) ?? Data()
out.append(UInt8(min(nickData.count, 255)))
out.append(nickData.prefix(255))
// timestamp

Some files were not shown because too many files have changed in this diff Show More