mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 08:45:19 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f02319f6f | ||
|
|
970802c923 | ||
|
|
935dcdbd51 | ||
|
|
bf5c9a3850 | ||
|
|
9cbfd131da | ||
|
|
20255c9916 | ||
|
|
b62f98cc62 | ||
|
|
02aa1d67db | ||
|
|
e0e90af9fd | ||
|
|
94e7f91b7b | ||
|
|
00c7f0460a | ||
|
|
175da268dd | ||
|
|
f6f7fa704a | ||
|
|
4f5d25808f | ||
|
|
a05c583fb5 | ||
|
|
25efa62587 | ||
|
|
0d93ce0874 | ||
|
|
20cdd6653a | ||
|
|
862484c0d4 | ||
|
|
e1e481a1a7 | ||
|
|
aed3e6a273 | ||
|
|
b481a70458 | ||
|
|
2b7fd2002b | ||
|
|
ccccb2dd8c | ||
|
|
385e9e2aab | ||
|
|
48035872e1 | ||
|
|
d4594b9504 | ||
|
|
9ae440be6a | ||
|
|
fd2dffdda7 | ||
|
|
953d24f7f2 | ||
|
|
c2a5668569 | ||
|
|
6dd8dd055a | ||
|
|
85112d809b | ||
|
|
e4b3cff5fa | ||
|
|
fb80403cd4 | ||
|
|
7ad19ab4c3 | ||
|
|
b1ff5e6b82 | ||
|
|
ee148a018b | ||
|
|
26b247c329 | ||
|
|
81168adad1 | ||
|
|
18dcc747d7 | ||
|
|
6056d20a13 | ||
|
|
112c0ec1ed | ||
|
|
688b954fb8 | ||
|
|
f5dde45c18 | ||
|
|
7341696280 | ||
|
|
295f855b6f | ||
|
|
3ea4188699 | ||
|
|
a66c591f8e | ||
|
|
75da63c9d7 | ||
|
|
d285c6ad53 | ||
|
|
8296630cf3 | ||
|
|
c74e212ea3 | ||
|
|
7a0c821807 | ||
|
|
0d251ad20c | ||
|
|
c8ceac1968 | ||
|
|
9ccff9cce4 | ||
|
|
66536063ca | ||
|
|
e191e9c6f2 | ||
|
|
b31a63ce37 | ||
|
|
0f26a27980 | ||
|
|
68eeba97ff | ||
|
|
f688e529f6 | ||
|
|
96e32ba990 | ||
|
|
0a2f4d9c9d | ||
|
|
914135adb0 | ||
|
|
cd7ffa0df9 | ||
|
|
bbe1ed0652 | ||
|
|
f07b032b99 | ||
|
|
2cbcb290f7 | ||
|
|
9cf7c80518 | ||
|
|
f76fd8a538 | ||
|
|
09c2c12838 | ||
|
|
8378ff949a | ||
|
|
ca63893197 | ||
|
|
fdf28aa5bb | ||
|
|
266827ceff | ||
|
|
97bc3f53bc | ||
|
|
9dc0ba6991 | ||
|
|
af954b05ea | ||
|
|
c8381737bb | ||
|
|
fa136d8973 | ||
|
|
7fbe6a4a7e | ||
|
|
4791114406 | ||
|
|
1a6a08f92a | ||
|
|
0b007eee1a | ||
|
|
1480b51c76 | ||
|
|
0e38ccfb3b | ||
|
|
e74f36927f | ||
|
|
cf3b85f65c | ||
|
|
8899cb7f9e | ||
|
|
22be3d6392 | ||
|
|
8a867a17a1 | ||
|
|
ed86ed1065 | ||
|
|
e74d9a7937 | ||
|
|
8289a6d05e | ||
|
|
38331e62f1 | ||
|
|
7fb1f4a219 | ||
|
|
99d1d1dccd | ||
|
|
879d8cba12 | ||
|
|
ac3a2f2d34 | ||
|
|
45650854e7 | ||
|
|
75dd83d9cc | ||
|
|
93d01b8fa6 | ||
|
|
6c0dbbbd0d | ||
|
|
09087b74cc | ||
|
|
cc76086615 | ||
|
|
638f3f5005 | ||
|
|
6091ee83ad | ||
|
|
82736c4991 | ||
|
|
707b22878d | ||
|
|
80bed1f395 | ||
|
|
4b287f7490 | ||
|
|
3caf2d7663 | ||
|
|
14d025cc2a | ||
|
|
19b28cf49d | ||
|
|
a2825ca288 | ||
|
|
3a995e20b6 | ||
|
|
6bda919dd4 | ||
|
|
4093ee6733 | ||
|
|
eb2c128cab |
@@ -0,0 +1,85 @@
|
||||
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
|
||||
@@ -10,6 +10,12 @@ 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
|
||||
@@ -26,17 +32,148 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Swift
|
||||
uses: swift-actions/setup-swift@v2
|
||||
# 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: Cache build artifacts
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ matrix.path }}/.build
|
||||
key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
|
||||
key: ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
|
||||
${{ runner.os }}-${{ matrix.name }}-
|
||||
${{ 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 }}
|
||||
|
||||
- name: Run Tests
|
||||
run: swift test --parallel --quiet --package-path ${{ matrix.path }}
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Build artifacts and generated sources; keeps local `swiftlint` runs clean
|
||||
# (CI checkouts are fresh, so this only matters in a working tree).
|
||||
excluded:
|
||||
- .build
|
||||
- .swiftpm
|
||||
- .DerivedData
|
||||
- DerivedData
|
||||
- build
|
||||
- localPackages/*/.build
|
||||
|
||||
disabled_rules:
|
||||
- line_length
|
||||
- type_name
|
||||
- identifier_name
|
||||
- statement_position
|
||||
- implicit_optional_initialization
|
||||
- force_try
|
||||
- vertical_whitespace
|
||||
- for_where
|
||||
- control_statement
|
||||
- void_function_in_ternary
|
||||
- redundant_discardable_let # SwiftUI breaks without it
|
||||
# To be enabled as we fix the issues
|
||||
- trailing_whitespace
|
||||
- cyclomatic_complexity
|
||||
- function_body_length
|
||||
- function_parameter_count
|
||||
- type_body_length
|
||||
- file_length
|
||||
- large_tuple
|
||||
- force_cast
|
||||
- multiple_closures_with_trailing_closure
|
||||
- nesting
|
||||
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.5.1
|
||||
MARKETING_VERSION = 1.5.4
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
|
||||
+42
-16
@@ -1,6 +1,6 @@
|
||||
# bitchat Privacy Policy
|
||||
|
||||
*Last updated: January 2025*
|
||||
*Last updated: June 2026*
|
||||
|
||||
## 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 servers** - Everything happens on your device and through peer-to-peer connections
|
||||
- **No accounts or company servers** - Mesh chat works peer-to-peer; optional Nostr features use public or user-selected relays
|
||||
- **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 Key**
|
||||
- A cryptographic key generated on first launch
|
||||
1. **Identity Keys**
|
||||
- Cryptographic private keys generated on first launch or when optional Nostr identities are created
|
||||
- Stored locally in your device's secure storage
|
||||
- Allows you to maintain "favorite" relationships across app restarts
|
||||
- Never leaves your device
|
||||
- Private keys never leave your device; public keys are shared when needed for messaging
|
||||
|
||||
2. **Nickname**
|
||||
- The display name you choose (or auto-generated)
|
||||
@@ -38,12 +38,19 @@ 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
|
||||
|
||||
@@ -62,13 +69,21 @@ 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
|
||||
- Tracks your location
|
||||
- Stores data on servers
|
||||
- Shares data with third parties
|
||||
- Sells or shares your exact GPS location
|
||||
- Stores data on servers we operate
|
||||
- Sells your data to advertisers or data brokers
|
||||
- Uses analytics or telemetry
|
||||
- Creates user profiles
|
||||
- Requires registration
|
||||
@@ -84,19 +99,27 @@ All private messages use end-to-end encryption:
|
||||
## Your Rights
|
||||
|
||||
You have complete control:
|
||||
- **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
|
||||
- **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
|
||||
|
||||
## 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.
|
||||
@@ -106,12 +129,15 @@ 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 data transmitted to servers (there are none)
|
||||
- No accounts or company servers
|
||||
- Optional Nostr relays receive only the events needed for Nostr-backed private fallback or public location channels
|
||||
- Open source code for public audit
|
||||
- Regular security updates
|
||||
- Cryptographic signatures prevent tampering
|
||||
@@ -121,7 +147,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 affect data (since we don't collect any)
|
||||
- No retroactive changes can make us collect data already held only in your app
|
||||
|
||||
## Contact
|
||||
|
||||
@@ -132,7 +158,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 servers, no surveillance. 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 company servers, no analytics. Just people talking freely.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+10
-4
@@ -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,11 +53,17 @@ let package = Package(
|
||||
path: "bitchatTests",
|
||||
exclude: [
|
||||
"Info.plist",
|
||||
"README.md"
|
||||
"README.md",
|
||||
// CI perf gate data (read by scripts/check-perf-floors.sh),
|
||||
// not a test resource.
|
||||
"Performance/perf-floors.json"
|
||||
],
|
||||
resources: [
|
||||
.process("Localization"),
|
||||
.process("Noise")
|
||||
// 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")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
+82
-250
@@ -1,309 +1,141 @@
|
||||
# BitChat Protocol Whitepaper
|
||||
# bitchat Protocol Whitepaper
|
||||
|
||||
**Version 1.1**
|
||||
**Version 2.0**
|
||||
|
||||
**Date: July 25, 2025**
|
||||
**Date: July 6, 2026**
|
||||
|
||||
---
|
||||
|
||||
## Abstract
|
||||
|
||||
BitChat is a decentralized, peer-to-peer messaging application designed for secure, private, and censorship-resistant communication over ephemeral, ad-hoc networks. This whitepaper details the BitChat Protocol Stack, a layered architecture that combines a modern cryptographic foundation with a flexible application protocol. At its core, BitChat leverages the Noise Protocol Framework (specifically, the `XX` pattern) to establish mutually authenticated, end-to-end encrypted sessions between peers. This document provides a technical specification of the identity management, session lifecycle, message framing, and security considerations that underpin the BitChat network.
|
||||
bitchat is a decentralized, peer-to-peer messaging application for secure, private, censorship-resistant communication that works with or without the internet. Nearby devices form an ad-hoc Bluetooth Low Energy (BLE) mesh; distant peers are reached over the Nostr protocol when a connection exists. A layered store-and-forward stack — a persistent sender outbox, opportunistic couriers with a spray-and-wait copy budget, gossip-synced public history, and Nostr relay mailboxes — delivers messages to peers who are out of range at send time. This document describes the protocol and its delivery guarantees as implemented.
|
||||
|
||||
---
|
||||
|
||||
## 1. Introduction
|
||||
## 1. Design Goals
|
||||
|
||||
In an era of centralized communication platforms, BitChat offers a resilient alternative by operating without central servers. It is designed for scenarios where internet connectivity is unavailable or untrustworthy, such as protests, natural disasters, or remote areas. Communication occurs directly between devices over transports like Bluetooth Low Energy (BLE).
|
||||
* **Confidentiality:** all private communication is end-to-end encrypted; intermediate nodes and couriers carry only opaque ciphertext.
|
||||
* **Authentication:** peers are identified by cryptographic keys; announcements are signed and verified.
|
||||
* **Resilience:** the network functions in lossy, low-bandwidth, partitioned environments with churning membership.
|
||||
* **Eventual delivery:** a message to an out-of-range peer should still arrive — relayed by the mesh, carried by a moving person, or resting on an internet relay — within a bounded retention window.
|
||||
* **Ephemerality by default:** no plaintext message content is ever written to disk. Everything the store-and-forward stack persists is either sealed ciphertext or already-public broadcast traffic, and all of it dies with the panic wipe.
|
||||
|
||||
The design goals of the BitChat Protocol are:
|
||||
## 2. Architecture Overview
|
||||
|
||||
* **Confidentiality:** All communication must be unreadable to third parties.
|
||||
* **Authentication:** Users must be able to verify the identity of their correspondents.
|
||||
* **Integrity:** Messages cannot be tampered with in transit.
|
||||
* **Forward Secrecy:** The compromise of long-term identity keys must not compromise past session keys.
|
||||
* **Deniability:** It should be difficult to cryptographically prove that a specific user sent a particular message.
|
||||
* **Resilience:** The protocol must function reliably in lossy, low-bandwidth environments.
|
||||
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
|
||||
|
||||
This paper specifies the technical details of the protocol designed to meet these goals.
|
||||
* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts.
|
||||
* **Nostr** — private messages to mutual favorites travel as NIP-17 gift-wrapped events over public relays (over Tor where enabled), bridging separate meshes through the internet.
|
||||
|
||||
---
|
||||
The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly.
|
||||
|
||||
## 2. Protocol Stack
|
||||
## 3. Identity
|
||||
|
||||
The BitChat Protocol is a four-layer stack. This layered approach separates concerns, allowing for modularity and future extensibility.
|
||||
Each device holds two long-term key pairs in the Keychain:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Application Layer] --> B[Session Layer];
|
||||
B --> C[Encryption Layer];
|
||||
C --> D[Transport Layer];
|
||||
* a **Curve25519 static key** for Noise key agreement — its SHA-256 fingerprint is the peer's stable identity, and
|
||||
* an **Ed25519 signing key** for packet signatures.
|
||||
|
||||
subgraph "BitChat Application"
|
||||
A
|
||||
end
|
||||
On the mesh, peers appear under short ephemeral IDs derived per session; favoriting pins the full Noise public key so identity survives across sessions. Mutual favorites also exchange Nostr public keys for the internet path. Optional QR verification binds a nickname to a fingerprint in person.
|
||||
|
||||
subgraph "Message Framing & State"
|
||||
B
|
||||
end
|
||||
## 4. BLE Mesh Layer
|
||||
|
||||
subgraph "Noise Protocol Framework"
|
||||
C
|
||||
end
|
||||
### 4.1 Packet Format
|
||||
|
||||
subgraph "BLE, Wi-Fi Direct, etc."
|
||||
D
|
||||
end
|
||||
A compact binary header (version, type, TTL, timestamp, flags) is followed by an 8-byte sender ID, an optional 8-byte recipient ID, the payload, and an optional Ed25519 signature. Version 2 packets may carry an explicit source route. Signatures exclude the TTL byte so relays can decrement it without invalidating them. Packets other than fragments are padded toward uniform sizes.
|
||||
|
||||
style A fill:#cde4ff
|
||||
style B fill:#b5d8ff
|
||||
style C fill:#9ac2ff
|
||||
style D fill:#7eadff
|
||||
```
|
||||
### 4.2 Flood Control
|
||||
|
||||
* **Application Layer:** Defines the structure of user-facing messages (`BitchatMessage`), acknowledgments (`DeliveryAck`), and other application-level data.
|
||||
* **Session Layer:** Manages the overall communication packet (`BitchatPacket`). This includes routing information (TTL), message typing, fragmentation, and serialization into a compact binary format.
|
||||
* **Encryption Layer:** Establishes and manages secure channels using the Noise Protocol Framework. It is responsible for the cryptographic handshake, session management, and transport message encryption/decryption.
|
||||
* **Transport Layer:** The underlying physical medium used for data transmission, such as Bluetooth Low Energy (BLE). This layer is abstracted away from the core protocol.
|
||||
Relaying is a deterministic controlled flood tuned by local connection degree:
|
||||
|
||||
---
|
||||
* **TTL:** packets originate with TTL 7. Relays clamp: dense graphs (≥ 6 links) cap broadcast TTL at 5; thin chains (≤ 2 links) relay at full incoming depth.
|
||||
* **Deduplication:** an LRU seen-set (1000 entries, 5-minute expiry) keyed by sender, timestamp, type, and a payload digest drops duplicates. A scheduled relay is cancelled when a duplicate arrives first from another relay.
|
||||
* **Jitter:** relays wait a random 10–220 ms (wider when dense) so duplicate suppression wins often.
|
||||
* **Fanout subsetting:** broadcast messages are re-sent to a deterministic, message-ID-seeded subset of links (~log₂ of degree) rather than all of them; announces, fragments, and sync packets use full fanout. The ingress link is always excluded (split horizon).
|
||||
* **Directed traffic** (handshakes, private messages, courier envelopes) relays deterministically with TTL − 1 and tight jitter, and is never subset.
|
||||
|
||||
## 3. Identity and Key Management
|
||||
### 4.3 Routing
|
||||
|
||||
A peer's identity in BitChat is defined by two persistent cryptographic key pairs, which are generated on first launch and stored securely in the device's Keychain.
|
||||
Announcements carry up to 10 direct-neighbor IDs, giving each node a shallow topology map (60 s freshness). When a bidirectionally-confirmed path exists, packets are source-routed along it; otherwise — and whenever a route fails — delivery falls back to flooding.
|
||||
|
||||
1. **Noise Static Key Pair (`Curve25519`):** This is the long-term identity key used for the Noise Protocol handshake. The public part of this key is shared with peers to establish secure sessions.
|
||||
2. **Signing Key Pair (`Ed25519`):** This key is used to sign announcements and other protocol messages where non-repudiation is required, such as binding a public key to a nickname.
|
||||
### 4.4 Fragmentation
|
||||
|
||||
### 3.1. Fingerprint
|
||||
Packets exceeding the link MTU split into ~469-byte fragments (8-byte fragment ID, index/total header) that relay independently and reassemble at each receiving node (128 concurrent assemblies, 30 s timeout, 1 MiB cap).
|
||||
|
||||
A user's unique, verifiable fingerprint is the **SHA-256 hash** of their **Noise static public key**. This provides a user-friendly and secure way to verify an identity out-of-band (e.g., by reading it aloud or scanning a QR code).
|
||||
### 4.5 Presence
|
||||
|
||||
`Fingerprint = SHA256(StaticPublicKey_Curve25519)`
|
||||
Signed announcements propagate multi-hop: every 4 s while isolated, backing off to ~15–30 s (jittered) when connected. A verified announce retains a peer as *reachable* for 60 s after last contact. Connection scheduling is RSSI-gated with duty-cycled scanning to bound battery drain.
|
||||
|
||||
### 3.2. Identity Management
|
||||
## 5. Encryption
|
||||
|
||||
The `SecureIdentityStateManager` class is responsible for managing all cryptographic identity material and social metadata (petnames, trust levels, etc.). It uses an in-memory cache for performance and persists this cache to the Keychain after encrypting it with a separate AES-GCM key.
|
||||
### 5.1 Live Sessions: Noise XX
|
||||
|
||||
---
|
||||
Connected peers establish sessions with the Noise `XX` pattern (Curve25519 / ChaCha20-Poly1305 / SHA-256), providing mutual authentication and forward secrecy. All private payloads — messages, delivery acks, read receipts — ride inside the session as typed ciphertext. Intermediate relays see only opaque `noiseEncrypted` packets.
|
||||
|
||||
## 4. The Social Trust Layer
|
||||
### 5.2 Offline Seals: Noise X
|
||||
|
||||
Beyond cryptographic identity, BitChat incorporates a social trust layer, allowing users to manage their relationships with peers. This functionality is handled by the `SecureIdentityStateManager`.
|
||||
Courier envelopes are sealed to the recipient's *static* key with the one-way Noise `X` pattern; the sender's identity is authenticated inside the ciphertext. **This path has no forward secrecy** — compromise of the recipient's static key exposes sealed-but-undelivered mail. A prekey scheme is future work.
|
||||
|
||||
### 4.1. Peer Verification
|
||||
### 5.3 Nostr Path
|
||||
|
||||
While the Noise handshake cryptographically authenticates a peer's key, it doesn't confirm the real-world identity of the person holding the device. To solve this, users can perform out-of-band (OOB) verification by comparing fingerprints. Once a user confirms that a peer's fingerprint matches the one they expect, they can mark that peer as "verified". This status is stored locally and displayed in the UI, providing a strong assurance of identity for future conversations.
|
||||
Private messages to mutual favorites are wrapped per NIP-17/NIP-59: a rumor (kind 14) sealed (kind 13) and gift-wrapped (kind 1059) under a throwaway ephemeral key, so relays learn neither sender nor content.
|
||||
|
||||
### 4.2. Favorites and Blocking
|
||||
## 6. Store and Forward
|
||||
|
||||
To improve the user experience and provide control over interactions, the protocol supports:
|
||||
* **Favorites:** Users can mark trusted or frequently contacted peers as "favorites". This is a local designation that can be used by the application to prioritize notifications or display peers more prominently.
|
||||
* **Blocking:** Users can block peers. When a peer is blocked, the application will discard any incoming packets from that peer's fingerprint at the earliest possible stage, effectively silencing them without notifying the blocked peer.
|
||||
Four mechanisms cover the "recipient is not here right now" problem. All persisted state is wiped by panic mode.
|
||||
|
||||
---
|
||||
### 6.1 Sender Outbox
|
||||
|
||||
## 5. The Noise Protocol Layer
|
||||
Private messages without a prompt route are retained per peer (100 messages/peer, 24 h TTL) and re-sent on reconnect events until a delivery or read ack clears them, or a resend cap (8 attempts) drops them with visible failure. The outbox persists to disk sealed under a ChaChaPoly key held only in the Keychain, so queued mail survives an app kill without ever storing plaintext.
|
||||
|
||||
BitChat implements the Noise Protocol Framework to provide strong, authenticated end-to-end encryption.
|
||||
### 6.2 Couriers
|
||||
|
||||
### 5.1. Protocol Name
|
||||
When no transport can deliver promptly, the message is sealed (§5.2) into a **courier envelope** and handed to up to 3 connected peers who may physically encounter the recipient:
|
||||
|
||||
The specific Noise protocol implemented is:
|
||||
* **Opaque addressing.** The only routing information is a 16-byte rotating recipient tag — an HMAC of the recipient's static key and the UTC day — computable solely by parties who already know that key. Couriers learn neither sender, recipient, nor content, and tags do not correlate across days.
|
||||
* **Trust tiers.** Mutual favorites may deposit 5 envelopes each; any peer with a signature-verified announce may deposit 2, into a bounded pool (20 of 40 slots) that can never crowd out favorites' mail. Envelopes are capped at 16 KiB and 24 h; overflow evicts oldest verified-tier mail first.
|
||||
* **Deposit retry.** Queued messages are re-deposited whenever a new eligible courier connects, until 3 distinct couriers carry the message or it expires.
|
||||
* **Spray and wait.** Envelopes carry a copy budget (initially 4, capped at 8). A courier meeting another eligible courier hands over half its remaining budget, so mail diffuses through a moving crowd instead of riding one person. Budgets, spray history, and carried mail persist across app restarts (iOS file protection).
|
||||
* **Handover.** On a verified *direct* announce from the recipient, matching envelopes are delivered over the live link and removed. On a verified *relayed* announce, a copy floods toward the recipient as a directed packet while the carried original stays put, throttled to one attempt per envelope per 10 minutes.
|
||||
* Receivers dedup by message ID, so redundant copies and the retained outbox original are harmless. Couriered mail from blocked senders is dropped at decryption time.
|
||||
|
||||
**`Noise_XX_25519_ChaChaPoly_SHA256`**
|
||||
### 6.3 Public History (Gossip Sync)
|
||||
|
||||
* **`XX` Pattern:** This handshake pattern provides mutual authentication and forward secrecy. It does not require either party to know the other's static public key before the handshake begins. The keys are exchanged and authenticated during the three-part handshake. This is ideal for a decentralized P2P environment.
|
||||
* **`25519`:** The Diffie-Hellman function used is Curve25519.
|
||||
* **`ChaChaPoly`:** The AEAD (Authenticated Encryption with Associated Data) cipher is ChaCha20-Poly1305.
|
||||
* **`SHA256`:** The hash function used for all cryptographic hashing operations is SHA-256.
|
||||
Public broadcast messages are cached (1000 packets) and reconciled between peers every ~15 s using compact GCS filters: each side advertises what it holds, the other returns what is missing. Messages stay sync-able for **6 hours** and the cache persists to disk, so a device that walks between two partitions — or relaunches later — serves the room's recent history to whoever missed it. Fragments and file transfers keep a short 15-minute window.
|
||||
|
||||
### 5.2. The `XX` Handshake
|
||||
### 6.4 Nostr Mailboxes
|
||||
|
||||
The `XX` handshake consists of three messages exchanged between an Initiator and a Responder to establish a shared secret and derive transport encryption keys.
|
||||
Gift-wrapped messages rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant I as Initiator
|
||||
participant R as Responder
|
||||
### 6.5 Delivery Metrics
|
||||
|
||||
Note over I, R: Pre-computation: h = SHA256(protocol_name)
|
||||
Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drops — no identities, message IDs, or timestamps) let delivery behavior be measured on-device. They never leave the device and are cleared by the panic wipe.
|
||||
|
||||
I->>R: -> e
|
||||
Note right of I: I generates ephemeral key `e_i`.<br/>h = SHA256(h + e_i.pub)
|
||||
## 7. Application Layer
|
||||
|
||||
R->>I: <- e, ee, s, es
|
||||
Note left of R: R generates ephemeral key `e_r`.<br/>h = SHA256(h + e_r.pub)<br/>MixKey(DH(e_i, e_r))<br/>R sends static key `s_r`, encrypted.<br/>h = SHA256(h + ciphertext)<br/>MixKey(DH(e_i, s_r))
|
||||
|
||||
I->>R: -> s, se
|
||||
Note right of I: I decrypts and verifies `s_r`.<br/>I sends static key `s_i`, encrypted.<br/>h = SHA256(h + ciphertext)<br/>MixKey(DH(s_i, e_r))
|
||||
|
||||
Note over I, R: Handshake complete. Transport keys derived.
|
||||
```
|
||||
|
||||
**Handshake Flow:**
|
||||
|
||||
1. **Initiator -> Responder:** The initiator generates a new ephemeral key pair (`e_i`) and sends the public part to the responder.
|
||||
2. **Responder -> Initiator:** The responder receives the initiator's ephemeral public key. It then generates its own ephemeral key pair (`e_r`), performs a DH exchange with the initiator's ephemeral key (`ee`), sends its own static public key (`s_r`) encrypted with the resulting symmetric key, and performs another DH exchange between the initiator's ephemeral key and its own static key (`es`).
|
||||
3. **Initiator -> Responder:** The initiator receives the responder's message, decrypts the responder's static key, and authenticates it. The initiator then sends its own static key (`s_i`) encrypted and performs a final DH exchange between its static key and the responder's ephemeral key (`se`).
|
||||
|
||||
Upon completion, both parties share a set of symmetric keys for bidirectional transport message encryption. The final handshake hash is used for channel binding.
|
||||
|
||||
### 5.3. Session Management
|
||||
|
||||
The `NoiseSessionManager` class manages all active Noise sessions. It handles:
|
||||
* Creating sessions for new peers.
|
||||
* Coordinating the handshake process to prevent race conditions.
|
||||
* Storing the resulting transport ciphers (`sendCipher`, `receiveCipher`).
|
||||
* Periodically checking if sessions need to be re-keyed for enhanced security.
|
||||
|
||||
---
|
||||
|
||||
## 6. The BitChat Session and Application Protocol
|
||||
|
||||
Once a Noise session is established, peers exchange `BitchatPacket` structures, which are encrypted as the payload of Noise transport messages.
|
||||
|
||||
### 6.1. Binary Packet Format (`BitchatPacket`)
|
||||
|
||||
To minimize bandwidth, `BitchatPacket`s are serialized into a compact binary format. The structure is designed to be fixed-size where possible to resist traffic analysis.
|
||||
|
||||
| Field | Size (bytes) | Description |
|
||||
|-----------------|--------------|---------------------------------------------------------------------------------------------------------|
|
||||
| **Header** | **13** | **Fixed-size header** |
|
||||
| Version | 1 | Protocol version (currently `1`). |
|
||||
| Type | 1 | Message type (e.g., `message`, `deliveryAck`, `noiseHandshakeInit`). See `MessageType` enum. |
|
||||
| TTL | 1 | Time-To-Live for mesh network routing. Decremented at each hop. |
|
||||
| Timestamp | 8 | `UInt64` millisecond timestamp of packet creation. |
|
||||
| Flags | 1 | Bitmask for optional fields (`hasRecipient`, `hasSignature`, `isCompressed`). |
|
||||
| Payload Length | 2 | `UInt16` length of the payload field. |
|
||||
| **Variable** | **...** | **Variable-size fields** |
|
||||
| Sender ID | 8 | 8-byte truncated peer ID of the sender. |
|
||||
| Recipient ID | 8 (optional) | 8-byte truncated peer ID of the recipient. Present if `hasRecipient` flag is set. Broadcast if `0xFF..FF`. |
|
||||
| Payload | Variable | The actual content of the packet, as defined by the `Type` field. |
|
||||
| Signature | 64 (optional)| `Ed25519` signature of the packet. Present if `hasSignature` flag is set. |
|
||||
|
||||
**Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers.
|
||||
|
||||
```mermaid
|
||||
---
|
||||
config:
|
||||
theme: dark
|
||||
---
|
||||
---
|
||||
title: "BitchatPacket"
|
||||
---
|
||||
packet
|
||||
+8: "Version"
|
||||
+8: "Type"
|
||||
+8: "TTL"
|
||||
+64: "Timestamp"
|
||||
+8: "Flags"
|
||||
+16: "Payload Length"
|
||||
+64: "Sender ID"
|
||||
+64: "Recipient ID (optional)"
|
||||
+48: "Payload (variable)"
|
||||
+64: "Signature (optional)"
|
||||
```
|
||||
_A representation of the sizes of the fields in `BitchatPacket`_
|
||||
|
||||
### 6.2. Application Message Format (`BitchatMessage`)
|
||||
|
||||
For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.
|
||||
|
||||
| Field | Size (bytes) | Description |
|
||||
|---------------------|--------------|--------------------------------------------------------------------------|
|
||||
| Flags | 1 | Bitmask for optional fields (`isRelay`, `isPrivate`, `hasOriginalSender`). |
|
||||
| Timestamp | 8 | `UInt64` millisecond timestamp of message creation. |
|
||||
| ID | 1 + len | `UUID` string for the message. |
|
||||
| Sender | 1 + len | Nickname of the sender. |
|
||||
| Content | 2 + len | The UTF-8 encoded message content. |
|
||||
| Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. |
|
||||
| Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. |
|
||||
|
||||
```mermaid
|
||||
---
|
||||
config:
|
||||
theme: dark
|
||||
---
|
||||
---
|
||||
title: "BitchatMessage"
|
||||
---
|
||||
packet
|
||||
+8: "Flags"
|
||||
+64: "Timestamp"
|
||||
+24: "ID (variable)"
|
||||
+32: "Sender (variable)"
|
||||
+32: "Content (variable)"
|
||||
+32: "Original Sender (variable) (optional)"
|
||||
+32: "Recipient Nickname (variable) (optional)"
|
||||
```
|
||||
_A representation of the sizes of the fields in `BitchatMessage`_
|
||||
|
||||
---
|
||||
|
||||
## 7. Message Routing and Propagation
|
||||
|
||||
BitChat operates as a decentralized mesh network, meaning there are no central servers to route messages. Packets are propagated through the network from peer to peer. The protocol supports several modes of message delivery.
|
||||
|
||||
### 7.1. Direct Connection
|
||||
|
||||
This is the simplest case. If Peer A and Peer B are directly connected, they can exchange packets after establishing a mutually authenticated Noise session. All packets are encrypted using the transport ciphers derived from the handshake.
|
||||
|
||||
### 7.2. Efficient Gossip with Bloom Filters
|
||||
|
||||
To send messages to peers that are not directly connected, BitChat employs a "flooding" or "gossip" protocol. When a peer receives a packet that is not destined for it, it acts as a relay. To prevent infinite routing loops and minimize memory usage, the protocol uses an `OptimizedBloomFilter` to track recently seen packet IDs.
|
||||
|
||||
The logic is as follows:
|
||||
|
||||
1. A peer receives a packet.
|
||||
2. It checks the Bloom filter to see if the packet's ID has likely been seen before. If so, the packet is discarded. Bloom filters can have false positives (though they are rare), but they guarantee no false negatives. This means that while some packets may be incorrectly discarded due to false positives, the gossip protocol's redundancy ensures these packets will eventually be received through subsequent exchanges with other peers.
|
||||
3. If the packet is new, its ID is added to the Bloom filter.
|
||||
4. The peer decrements the packet's Time-To-Live (TTL) field.
|
||||
5. If the TTL is greater than zero, the peer re-broadcasts the packet to all of its connected peers, *except* for the peer from which it received the packet.
|
||||
|
||||
This mechanism allows packets to "flood" through the network efficiently, maximizing the chance of reaching their destination while using minimal resources to prevent loops.
|
||||
|
||||
### 7.3. Time-To-Live (TTL)
|
||||
|
||||
Every `BitchatPacket` contains an 8-bit TTL field. This value is set by the originating peer and is decremented by one at each relay hop. If a peer receives a packet and decrements its TTL to 0, it will process the packet (if it is the recipient) but will not relay it further. This is a crucial mechanism to prevent packets from circulating endlessly in the mesh.
|
||||
|
||||
### 7.4. Private vs. Broadcast Messages
|
||||
|
||||
The routing logic respects the confidentiality of private messages:
|
||||
|
||||
* **Private Messages:** A packet with a specific `recipientID` is a private message. Relay nodes forward the entire, encrypted Noise message without being able to access the inner `BitchatPacket` or its payload. Only the final recipient, who shares the correct Noise session keys with the sender, can decrypt the packet.
|
||||
* **Broadcast Messages:** A packet with the special broadcast `recipientID` (`0xFFFFFFFFFFFFFFFF`) is intended for all peers. Any peer that receives and decrypts a broadcast message will process its content. It will still be relayed according to the flooding algorithm to ensure it reaches the entire network.
|
||||
|
||||
### 7.5. Message Reliability and Lifecycle
|
||||
|
||||
To function in unreliable, lossy networks, the protocol includes features to track the lifecycle of a message and ensure its delivery.
|
||||
|
||||
* **Delivery Acknowledgments (`DeliveryAck`):** When a private message reaches its final destination, the recipient's device sends a `DeliveryAck` packet back to the original sender. This acknowledgment contains the ID of the original message.
|
||||
* **Read Receipts (`ReadReceipt`):** After a message is displayed on the recipient's screen, the application can send a `ReadReceipt`, also containing the original message ID, to inform the sender that the message has been seen.
|
||||
* **Message Retry Service:** Senders maintain a `MessageRetryService` which tracks outgoing messages. If a `DeliveryAck` is not received for a message within a certain time window, the service will automatically re-send the message, creating a more resilient user experience.
|
||||
|
||||
### 7.6. Fragmentation
|
||||
|
||||
Transport layers like BLE have a Maximum Transmission Unit (MTU) that limits the size of a single packet. To handle messages larger than this limit, BitChat implements a fragmentation protocol.
|
||||
|
||||
* **`fragmentStart`:** A packet with this type marks the beginning of a fragmented message. It contains metadata about the total size and number of fragments.
|
||||
* **`fragmentContinue`:** These packets carry the intermediate chunks of the message data.
|
||||
* **`fragmentEnd`:** This packet carries the final chunk of the message and signals the receiver to begin reassembly.
|
||||
|
||||
Receiving peers collect all fragments and reassemble them in the correct order before passing the complete message up to the application layer.
|
||||
|
||||
---
|
||||
* **Public chat** — signed broadcast messages within the mesh, backed by the gossip-synced history above.
|
||||
* **Private chat** — end-to-end encrypted messages with delivery and read receipts, over mesh, courier, or Nostr.
|
||||
* **Location channels** — geohash-scoped public rooms carried over Nostr relays for regional chat beyond radio range.
|
||||
* **Favorites** — the mutual-trust relationship that unlocks Nostr delivery and the larger courier quota.
|
||||
* **Media** — files and images fragment over the mesh (1 MiB cap, explicit accept before anything touches disk); couriers carry text only.
|
||||
* **Panic wipe** — clears identity keys, favorites, carried courier mail, the sealed outbox, archived public history, and metrics.
|
||||
|
||||
## 8. Security Considerations
|
||||
|
||||
* **Replay Attacks:** The Noise transport messages include a nonce that is incremented for each message. The `NoiseCipherState` implements a sliding window replay protection mechanism to detect and discard replayed or out-of-order messages.
|
||||
* **Denial of Service:** The `NoiseRateLimiter` is implemented to prevent resource exhaustion from rapid, repeated handshake attempts from a single peer.
|
||||
* **Key-Compromise Impersonation:** The `XX` pattern authenticates both parties, preventing an attacker from impersonating one party to the other.
|
||||
* **Identity Binding:** While the Noise handshake authenticates the cryptographic keys, binding those keys to a human-readable nickname is handled at the application layer. Users must verify fingerprints out-of-band to prevent man-in-the-middle attacks.
|
||||
* **Traffic Analysis:** The use of fixed-size padding for all packets helps to obscure the exact nature and content of the communication, making it harder for a network-level adversary to infer information based on message size.
|
||||
* **Relay nodes** cannot read private traffic; they forward padded, opaque ciphertext.
|
||||
* **Couriers** are quota-bounded mailbags. A malicious courier can drop mail (redundant copies and deposit retry mitigate this) but cannot read it, link it across days, or amplify it — copy budgets are capped and every envelope is validated against size and lifetime policy on deposit.
|
||||
* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
|
||||
* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
|
||||
* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
|
||||
* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path.
|
||||
|
||||
## 9. Future Work
|
||||
|
||||
* Prekey-based forward secrecy for courier envelopes.
|
||||
* Couriered media beyond the 16 KiB text cap.
|
||||
* Probabilistic relay and edge-of-network TTL boosting for very dense and very sparse graphs.
|
||||
* Multi-hop courier routing informed by encounter history.
|
||||
|
||||
---
|
||||
|
||||
## 9. Conclusion
|
||||
|
||||
The BitChat Protocol provides a robust and secure foundation for decentralized, peer-to-peer communication. By layering a flexible application protocol on top of the well-regarded Noise Protocol Framework, it achieves strong confidentiality, authentication, and forward secrecy. The use of a compact binary format and thoughtful security considerations like rate limiting and traffic analysis resistance make it suitable for use in challenging network environments.
|
||||
*This document describes the protocol as implemented in the current release. The implementation is free and unencumbered software released into the public domain.*
|
||||
|
||||
Generated
+7
-19
@@ -321,7 +321,7 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1640;
|
||||
LastUpgradeCheck = 2650;
|
||||
};
|
||||
buildConfigurationList = 3EA424CBD51200895D361189 /* Build configuration list for PBXProject "bitchat" */;
|
||||
developmentRegion = en;
|
||||
@@ -446,7 +446,6 @@
|
||||
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 = (
|
||||
@@ -471,7 +470,6 @@
|
||||
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 = (
|
||||
@@ -498,7 +496,6 @@
|
||||
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)",
|
||||
@@ -523,7 +520,6 @@
|
||||
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)";
|
||||
@@ -532,7 +528,6 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
@@ -556,7 +551,6 @@
|
||||
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;
|
||||
@@ -566,7 +560,6 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -590,7 +583,6 @@
|
||||
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)",
|
||||
@@ -617,7 +609,6 @@
|
||||
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;
|
||||
@@ -627,7 +618,6 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.5.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -653,7 +643,6 @@
|
||||
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;
|
||||
@@ -663,7 +652,6 @@
|
||||
"@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;
|
||||
@@ -676,6 +664,7 @@
|
||||
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";
|
||||
@@ -709,6 +698,7 @@
|
||||
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;
|
||||
@@ -722,10 +712,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)";
|
||||
@@ -745,7 +735,6 @@
|
||||
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;
|
||||
@@ -755,7 +744,6 @@
|
||||
"@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;
|
||||
@@ -768,6 +756,7 @@
|
||||
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";
|
||||
@@ -801,6 +790,7 @@
|
||||
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;
|
||||
@@ -820,11 +810,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)";
|
||||
@@ -841,7 +831,6 @@
|
||||
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)";
|
||||
@@ -850,7 +839,6 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(MARKETING_VERSION)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1640"
|
||||
LastUpgradeVersion = "2650"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1640"
|
||||
LastUpgradeVersion = "2650"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -66,12 +66,12 @@ actor AppEventStream {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
let displayName: String?
|
||||
let noisePublicKeyHex: String?
|
||||
let nostrPublicKey: String?
|
||||
}
|
||||
|
||||
extension PeerHandle: Equatable {
|
||||
@@ -100,264 +100,3 @@ enum ConversationID: Hashable, Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class IdentityResolver {
|
||||
private var handlesByRoutingPeerID: [PeerID: PeerHandle] = [:]
|
||||
private var handlesByNoiseKey: [String: PeerHandle] = [:]
|
||||
private var handlesByNostrKey: [String: PeerHandle] = [:]
|
||||
|
||||
func register(peers: [BitchatPeer]) {
|
||||
for peer in peers {
|
||||
_ = register(peer: peer)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func register(peer: BitchatPeer) -> PeerHandle {
|
||||
let handle = buildHandle(
|
||||
routingPeerID: peer.peerID,
|
||||
displayName: peer.displayName,
|
||||
noisePublicKeyHex: peer.noisePublicKey.isEmpty ? nil : peer.noisePublicKey.hexEncodedString().lowercased(),
|
||||
nostrPublicKey: normalizedNostrKey(peer.nostrPublicKey)
|
||||
)
|
||||
cache(handle)
|
||||
return handle
|
||||
}
|
||||
|
||||
func canonicalHandle(for peerID: PeerID, displayName: String? = nil) -> PeerHandle {
|
||||
if let handle = handlesByRoutingPeerID[peerID] {
|
||||
return handle
|
||||
}
|
||||
|
||||
if peerID.isNoiseKeyHex, let handle = handlesByNoiseKey[peerID.bare] {
|
||||
return handle
|
||||
}
|
||||
|
||||
if (peerID.isGeoDM || peerID.isGeoChat), let handle = handlesByNostrKey[peerID.bare] {
|
||||
return handle
|
||||
}
|
||||
|
||||
let handle = buildHandle(
|
||||
routingPeerID: peerID,
|
||||
displayName: displayName,
|
||||
noisePublicKeyHex: peerID.isNoiseKeyHex ? peerID.bare : nil,
|
||||
nostrPublicKey: (peerID.isGeoDM || peerID.isGeoChat) ? peerID.bare : nil
|
||||
)
|
||||
cache(handle)
|
||||
return handle
|
||||
}
|
||||
|
||||
private func buildHandle(
|
||||
routingPeerID: PeerID,
|
||||
displayName: String?,
|
||||
noisePublicKeyHex: String?,
|
||||
nostrPublicKey: String?
|
||||
) -> PeerHandle {
|
||||
let canonicalID: String
|
||||
if let noisePublicKeyHex {
|
||||
canonicalID = "noise:\(noisePublicKeyHex)"
|
||||
} else if let nostrPublicKey {
|
||||
canonicalID = "nostr:\(nostrPublicKey)"
|
||||
} else {
|
||||
canonicalID = "mesh:\(routingPeerID.id)"
|
||||
}
|
||||
|
||||
let normalizedDisplayName: String?
|
||||
if let displayName, !displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
normalizedDisplayName = displayName
|
||||
} else {
|
||||
normalizedDisplayName = nil
|
||||
}
|
||||
|
||||
return PeerHandle(
|
||||
id: canonicalID,
|
||||
routingPeerID: routingPeerID,
|
||||
displayName: normalizedDisplayName,
|
||||
noisePublicKeyHex: noisePublicKeyHex,
|
||||
nostrPublicKey: nostrPublicKey
|
||||
)
|
||||
}
|
||||
|
||||
private func normalizedNostrKey(_ nostrPublicKey: String?) -> String? {
|
||||
guard let nostrPublicKey else { return nil }
|
||||
let trimmed = nostrPublicKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private func cache(_ handle: PeerHandle) {
|
||||
handlesByRoutingPeerID[handle.routingPeerID] = handle
|
||||
if let noisePublicKeyHex = handle.noisePublicKeyHex {
|
||||
handlesByNoiseKey[noisePublicKeyHex] = handle
|
||||
}
|
||||
if let nostrPublicKey = handle.nostrPublicKey {
|
||||
handlesByNostrKey[nostrPublicKey] = handle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class ConversationStore: ObservableObject {
|
||||
@Published private(set) var activeChannel: ChannelID = .mesh
|
||||
@Published private(set) var selectedPrivatePeerID: PeerID?
|
||||
@Published private(set) var selectedConversationID: ConversationID = .mesh
|
||||
@Published private(set) var unreadConversations: Set<ConversationID> = []
|
||||
@Published private(set) var messagesByConversation: [ConversationID: [BitchatMessage]] = [:]
|
||||
|
||||
private var directHandlesByConversation: [ConversationID: PeerHandle] = [:]
|
||||
|
||||
func setActiveChannel(_ channelID: ChannelID) {
|
||||
activeChannel = channelID
|
||||
if selectedPrivatePeerID == nil {
|
||||
selectedConversationID = ConversationID(channelID: channelID)
|
||||
}
|
||||
}
|
||||
|
||||
func setSelectedPeerID(
|
||||
_ peerID: PeerID?,
|
||||
activeChannel: ChannelID,
|
||||
identityResolver: IdentityResolver
|
||||
) {
|
||||
self.activeChannel = activeChannel
|
||||
selectedPrivatePeerID = peerID
|
||||
|
||||
if let peerID {
|
||||
selectedConversationID = directConversationID(
|
||||
for: peerID,
|
||||
identityResolver: identityResolver
|
||||
)
|
||||
} else {
|
||||
selectedConversationID = ConversationID(channelID: activeChannel)
|
||||
}
|
||||
}
|
||||
|
||||
func replaceMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) {
|
||||
messagesByConversation[conversationID] = normalized(messages)
|
||||
}
|
||||
|
||||
func replaceMessages(_ messages: [BitchatMessage], for channelID: ChannelID) {
|
||||
replaceMessages(messages, for: ConversationID(channelID: channelID))
|
||||
}
|
||||
|
||||
func synchronizePublicConversation(_ messages: [BitchatMessage], activeChannel: ChannelID) {
|
||||
setActiveChannel(activeChannel)
|
||||
replaceMessages(messages, for: activeChannel)
|
||||
}
|
||||
|
||||
func messages(for conversationID: ConversationID) -> [BitchatMessage] {
|
||||
messagesByConversation[conversationID] ?? []
|
||||
}
|
||||
|
||||
func directMessages(
|
||||
for peerID: PeerID,
|
||||
identityResolver: IdentityResolver
|
||||
) -> [BitchatMessage] {
|
||||
messages(for: directConversationID(for: peerID, identityResolver: identityResolver))
|
||||
}
|
||||
|
||||
func directMessagesByPeerID() -> [PeerID: [BitchatMessage]] {
|
||||
var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
|
||||
|
||||
for (conversationID, handle) in directHandlesByConversation {
|
||||
messagesByPeerID[handle.routingPeerID] = messages(for: conversationID)
|
||||
}
|
||||
|
||||
return messagesByPeerID
|
||||
}
|
||||
|
||||
func unreadDirectPeerIDs() -> Set<PeerID> {
|
||||
unreadConversations.reduce(into: Set<PeerID>()) { result, conversationID in
|
||||
guard case .direct(let handle) = conversationID else { return }
|
||||
result.insert(directHandlesByConversation[conversationID]?.routingPeerID ?? handle.routingPeerID)
|
||||
}
|
||||
}
|
||||
|
||||
func synchronizeSelection(
|
||||
activeChannel: ChannelID,
|
||||
selectedPeerID: PeerID?,
|
||||
identityResolver: IdentityResolver
|
||||
) {
|
||||
setSelectedPeerID(
|
||||
selectedPeerID,
|
||||
activeChannel: activeChannel,
|
||||
identityResolver: identityResolver
|
||||
)
|
||||
}
|
||||
|
||||
func synchronizePrivateChats(
|
||||
_ privateChats: [PeerID: [BitchatMessage]],
|
||||
unreadPeerIDs: Set<PeerID>,
|
||||
identityResolver: IdentityResolver
|
||||
) {
|
||||
var liveConversations = Set<ConversationID>()
|
||||
|
||||
for (peerID, messages) in privateChats {
|
||||
let handle = identityResolver.canonicalHandle(for: peerID, displayName: messages.last?.sender)
|
||||
let conversationID = ConversationID.direct(handle)
|
||||
liveConversations.insert(conversationID)
|
||||
directHandlesByConversation[conversationID] = handle
|
||||
messagesByConversation[conversationID] = normalized(messages)
|
||||
}
|
||||
|
||||
let staleDirectConversations = messagesByConversation.keys.filter { conversationID in
|
||||
guard case .direct = conversationID else { return false }
|
||||
return !liveConversations.contains(conversationID)
|
||||
}
|
||||
|
||||
for conversationID in staleDirectConversations {
|
||||
messagesByConversation.removeValue(forKey: conversationID)
|
||||
unreadConversations.remove(conversationID)
|
||||
directHandlesByConversation.removeValue(forKey: conversationID)
|
||||
}
|
||||
|
||||
let publicUnread = unreadConversations.filter { conversationID in
|
||||
switch conversationID {
|
||||
case .mesh, .geohash:
|
||||
return true
|
||||
case .direct:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
unreadConversations = unreadPeerIDs.reduce(into: publicUnread) { result, peerID in
|
||||
let handle = identityResolver.canonicalHandle(for: peerID)
|
||||
result.insert(.direct(handle))
|
||||
}
|
||||
}
|
||||
|
||||
func markRead(_ conversationID: ConversationID) {
|
||||
unreadConversations.remove(conversationID)
|
||||
}
|
||||
|
||||
func markRead(
|
||||
peerID: PeerID,
|
||||
identityResolver: IdentityResolver
|
||||
) {
|
||||
markRead(directConversationID(for: peerID, identityResolver: identityResolver))
|
||||
}
|
||||
|
||||
private func normalized(_ messages: [BitchatMessage]) -> [BitchatMessage] {
|
||||
var uniqueMessages: [String: BitchatMessage] = [:]
|
||||
|
||||
for message in messages {
|
||||
uniqueMessages[message.id] = message
|
||||
}
|
||||
|
||||
return uniqueMessages.values.sorted { lhs, rhs in
|
||||
if lhs.timestamp != rhs.timestamp {
|
||||
return lhs.timestamp < rhs.timestamp
|
||||
}
|
||||
return lhs.id < rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
private func directConversationID(
|
||||
for peerID: PeerID,
|
||||
identityResolver: IdentityResolver
|
||||
) -> ConversationID {
|
||||
let handle = identityResolver.canonicalHandle(for: peerID)
|
||||
let conversationID = ConversationID.direct(handle)
|
||||
directHandlesByConversation[conversationID] = handle
|
||||
return conversationID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ final class AppChromeModel: ObservableObject {
|
||||
private let chatViewModel: ChatViewModel
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
/// Bulletin-board coordinator, created on first use of the board sheet.
|
||||
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
||||
|
||||
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.nickname = chatViewModel.nickname
|
||||
@@ -59,6 +62,28 @@ final class AppChromeModel: ObservableObject {
|
||||
isAppInfoPresented = true
|
||||
}
|
||||
|
||||
/// Builds the mesh topology map model from the transport's gossiped
|
||||
/// graph plus the live nickname table. Unknown nodes (heard about via a
|
||||
/// neighbor claim but never announced to us) fall back to a short ID.
|
||||
func meshTopologyDisplayModel() -> MeshTopologyDisplayModel {
|
||||
let mesh = chatViewModel.meshService
|
||||
guard let snapshot = mesh.currentMeshTopology() else { return .empty }
|
||||
let nicknames = mesh.getPeerNicknames()
|
||||
|
||||
let nodes = snapshot.nodes.map { peerID -> MeshTopologyDisplayModel.Node in
|
||||
let isSelf = peerID == snapshot.localPeerID
|
||||
let label: String
|
||||
if isSelf {
|
||||
label = chatViewModel.nickname
|
||||
} else {
|
||||
label = nicknames[peerID] ?? "\(peerID.id.prefix(8))…"
|
||||
}
|
||||
return MeshTopologyDisplayModel.Node(id: peerID.id, label: label, isSelf: isSelf)
|
||||
}
|
||||
let edges = snapshot.edges.map { ($0.a.id, $0.b.id) }
|
||||
return MeshTopologyDisplayModel(nodes: nodes, edges: edges)
|
||||
}
|
||||
|
||||
func triggerScreenshotPrivacyWarning() {
|
||||
showScreenshotPrivacyWarning = true
|
||||
}
|
||||
|
||||
@@ -14,7 +14,10 @@ import AppKit
|
||||
final class AppRuntime: ObservableObject {
|
||||
let chatViewModel: ChatViewModel
|
||||
let events = AppEventStream()
|
||||
let conversationStore: ConversationStore
|
||||
/// 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
|
||||
@@ -42,30 +45,28 @@ final class AppRuntime: ObservableObject {
|
||||
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
||||
) {
|
||||
self.idBridge = idBridge
|
||||
let identityResolver = IdentityResolver()
|
||||
let conversationStore = ConversationStore()
|
||||
let conversations = ConversationStore()
|
||||
let peerIdentityStore = PeerIdentityStore()
|
||||
let locationPresenceStore = LocationPresenceStore()
|
||||
let locationManager = LocationChannelManager.shared
|
||||
self.conversationStore = conversationStore
|
||||
self.conversations = conversations
|
||||
self.peerIdentityStore = peerIdentityStore
|
||||
self.locationPresenceStore = locationPresenceStore
|
||||
self.chatViewModel = ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: idBridge,
|
||||
identityManager: SecureIdentityStateManager(keychain),
|
||||
conversationStore: conversationStore,
|
||||
identityResolver: identityResolver,
|
||||
conversations: conversations,
|
||||
peerIdentityStore: peerIdentityStore,
|
||||
locationPresenceStore: locationPresenceStore,
|
||||
locationManager: locationManager
|
||||
)
|
||||
self.publicChatModel = PublicChatModel(conversationStore: conversationStore)
|
||||
self.privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
|
||||
self.publicChatModel = PublicChatModel(conversations: conversations)
|
||||
self.privateInboxModel = PrivateInboxModel(conversations: conversations)
|
||||
self.locationChannelsModel = LocationChannelsModel(manager: locationManager)
|
||||
self.privateConversationModel = PrivateConversationModel(
|
||||
chatViewModel: self.chatViewModel,
|
||||
conversationStore: conversationStore,
|
||||
conversations: conversations,
|
||||
locationChannelsModel: self.locationChannelsModel,
|
||||
peerIdentityStore: peerIdentityStore
|
||||
)
|
||||
@@ -77,11 +78,11 @@ final class AppRuntime: ObservableObject {
|
||||
self.conversationUIModel = ConversationUIModel(
|
||||
chatViewModel: self.chatViewModel,
|
||||
privateConversationModel: self.privateConversationModel,
|
||||
conversationStore: conversationStore
|
||||
conversations: conversations
|
||||
)
|
||||
self.peerListModel = PeerListModel(
|
||||
chatViewModel: self.chatViewModel,
|
||||
conversationStore: conversationStore,
|
||||
conversations: conversations,
|
||||
locationChannelsModel: self.locationChannelsModel,
|
||||
peerIdentityStore: peerIdentityStore,
|
||||
locationPresenceStore: locationPresenceStore
|
||||
@@ -104,7 +105,7 @@ final class AppRuntime: ObservableObject {
|
||||
|
||||
started = true
|
||||
NotificationDelegate.shared.runtime = self
|
||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
||||
VerificationService.shared.configure(with: chatViewModel.meshService)
|
||||
announceInitialTorStatusIfNeeded()
|
||||
|
||||
Task(priority: .utility) { [weak self] in
|
||||
@@ -218,7 +219,7 @@ final class AppRuntime: ObservableObject {
|
||||
userInfo: [AnyHashable: Any]
|
||||
) async -> UNNotificationPresentationOptions {
|
||||
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
|
||||
if conversationStore.selectedPrivatePeerID == peerID {
|
||||
if conversations.selectedPrivatePeerID == peerID {
|
||||
return []
|
||||
}
|
||||
return [.banner, .sound]
|
||||
@@ -348,12 +349,12 @@ private extension AppRuntime {
|
||||
TorManager.shared.isAutoStartAllowed() {
|
||||
chatViewModel.torStatusAnnounced = true
|
||||
chatViewModel.addGeohashOnlySystemMessage(
|
||||
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
||||
String(localized: "system.tor.starting", defaultValue: "starting tor...", 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")
|
||||
String(localized: "system.tor.dev_bypass", defaultValue: "development build: Tor bypass enabled.", comment: "System message when Tor bypass is enabled in development")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,918 @@
|
||||
//
|
||||
// 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
|
||||
/// ephemeral↔stable 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
|
||||
}
|
||||
}
|
||||
@@ -15,19 +15,19 @@ final class ConversationUIModel: ObservableObject {
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private let privateConversationModel: PrivateConversationModel
|
||||
private let conversationStore: ConversationStore
|
||||
private let conversations: ConversationStore
|
||||
private var activeChannel: ChannelID
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
chatViewModel: ChatViewModel,
|
||||
privateConversationModel: PrivateConversationModel,
|
||||
conversationStore: ConversationStore
|
||||
conversations: ConversationStore
|
||||
) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.privateConversationModel = privateConversationModel
|
||||
self.conversationStore = conversationStore
|
||||
self.activeChannel = conversationStore.activeChannel
|
||||
self.conversations = conversations
|
||||
self.activeChannel = conversations.activeChannel
|
||||
self.currentNickname = chatViewModel.nickname
|
||||
self.isBatchingPublic = chatViewModel.isBatchingPublic
|
||||
self.showAutocomplete = chatViewModel.showAutocomplete
|
||||
@@ -41,10 +41,22 @@ final class ConversationUIModel: ObservableObject {
|
||||
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")
|
||||
}
|
||||
@@ -63,11 +75,23 @@ final class ConversationUIModel: ObservableObject {
|
||||
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)
|
||||
}
|
||||
@@ -76,12 +100,12 @@ final class ConversationUIModel: ObservableObject {
|
||||
chatViewModel.completeNickname(nickname, in: &text)
|
||||
}
|
||||
|
||||
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
|
||||
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme)
|
||||
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
|
||||
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme, theme: theme)
|
||||
}
|
||||
|
||||
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
|
||||
chatViewModel.formatMessageHeader(message, colorScheme: colorScheme)
|
||||
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
|
||||
chatViewModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme)
|
||||
}
|
||||
|
||||
func mediaAttachment(for message: BitchatMessage) -> BitchatMessage.Media? {
|
||||
@@ -151,7 +175,7 @@ final class ConversationUIModel: ObservableObject {
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$isBatchingPublic)
|
||||
|
||||
conversationStore.$activeChannel
|
||||
conversations.$activeChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] channel in
|
||||
self?.activeChannel = channel
|
||||
@@ -169,7 +193,9 @@ final class ConversationUIModel: ObservableObject {
|
||||
|
||||
private func refreshComputedState() {
|
||||
if let selectedPeerID = privateConversationModel.selectedPeerID {
|
||||
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat)
|
||||
// Media transfer is not wired for groups in v1; keep it off so the
|
||||
// composer can't strand a media placeholder that never sends.
|
||||
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat || selectedPeerID.isGroup)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -12,20 +12,26 @@ final class LocationChannelsModel: ObservableObject {
|
||||
@Published private(set) var bookmarkNames: [String: String]
|
||||
@Published private(set) var locationNames: [GeohashChannelLevel: String]
|
||||
@Published private(set) var userTorEnabled: Bool
|
||||
@Published private(set) var gatewayEnabled: Bool
|
||||
|
||||
private let manager: LocationChannelManager
|
||||
private let network: NetworkActivationService
|
||||
private let gateway: GatewayService
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
manager: LocationChannelManager? = nil,
|
||||
network: NetworkActivationService? = nil
|
||||
network: NetworkActivationService? = nil,
|
||||
gateway: GatewayService? = nil
|
||||
) {
|
||||
let manager = manager ?? .shared
|
||||
let network = network ?? .shared
|
||||
let gateway = gateway ?? .shared
|
||||
|
||||
self.manager = manager
|
||||
self.network = network
|
||||
self.gateway = gateway
|
||||
self.gatewayEnabled = gateway.isEnabled
|
||||
self.permissionState = manager.permissionState
|
||||
self.availableChannels = manager.availableChannels
|
||||
self.selectedChannel = manager.selectedChannel
|
||||
@@ -96,6 +102,10 @@ final class LocationChannelsModel: ObservableObject {
|
||||
network.setUserTorEnabled(enabled)
|
||||
}
|
||||
|
||||
func setGatewayEnabled(_ enabled: Bool) {
|
||||
gateway.setEnabled(enabled)
|
||||
}
|
||||
|
||||
func refreshMeshChannelsIfNeeded() {
|
||||
guard case .mesh = selectedChannel,
|
||||
permissionState == .authorized,
|
||||
@@ -160,6 +170,10 @@ final class LocationChannelsModel: ObservableObject {
|
||||
network.$userTorEnabled
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$userTorEnabled)
|
||||
|
||||
gateway.$isEnabled
|
||||
.receive(on: DispatchQueue.main)
|
||||
.assign(to: &$gatewayEnabled)
|
||||
}
|
||||
|
||||
private func level(forLength length: Int) -> GeohashChannelLevel {
|
||||
|
||||
@@ -14,6 +14,9 @@ struct MeshPeerRow: Identifiable, Equatable {
|
||||
let isMutualFavorite: Bool
|
||||
let encryptionStatus: EncryptionStatus
|
||||
let showsVerifiedBadgeWhenOffline: Bool
|
||||
/// Vouched-for by someone I verified, without an explicit verification of
|
||||
/// mine — rendered as the unfilled seal (verified gets the filled one).
|
||||
let showsVouchedBadge: Bool
|
||||
|
||||
var id: String { peerID.id }
|
||||
}
|
||||
@@ -26,18 +29,29 @@ struct GeohashPersonRow: Identifiable, Equatable {
|
||||
let isBlocked: Bool
|
||||
}
|
||||
|
||||
struct GroupChatRow: Identifiable, Equatable {
|
||||
let peerID: PeerID
|
||||
let name: String
|
||||
let memberCount: Int
|
||||
let isCreator: Bool
|
||||
let hasUnread: Bool
|
||||
|
||||
var id: String { peerID.id }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
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 groupRows: [GroupChatRow] = []
|
||||
@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 conversationStore: ConversationStore
|
||||
private let conversations: ConversationStore
|
||||
private let locationChannelsModel: LocationChannelsModel
|
||||
private let peerIdentityStore: PeerIdentityStore
|
||||
private let locationPresenceStore: LocationPresenceStore
|
||||
@@ -45,13 +59,13 @@ final class PeerListModel: ObservableObject {
|
||||
|
||||
init(
|
||||
chatViewModel: ChatViewModel,
|
||||
conversationStore: ConversationStore,
|
||||
conversations: ConversationStore,
|
||||
locationChannelsModel: LocationChannelsModel? = nil,
|
||||
peerIdentityStore: PeerIdentityStore? = nil,
|
||||
locationPresenceStore: LocationPresenceStore? = nil
|
||||
) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.conversationStore = conversationStore
|
||||
self.conversations = conversations
|
||||
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
|
||||
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
||||
self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore
|
||||
@@ -122,7 +136,14 @@ final class PeerListModel: ObservableObject {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
conversationStore.$unreadConversations
|
||||
conversations.$unreadConversations
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.groupStore.$groups
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
@@ -183,13 +204,12 @@ final class PeerListModel: ObservableObject {
|
||||
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
|
||||
}
|
||||
let fingerprint = isMe ? nil : chatViewModel.getFingerprint(for: peer.peerID)
|
||||
let isVerifiedFingerprint = fingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
||||
let verifiedBadge = !peer.isConnected && isVerifiedFingerprint
|
||||
// Vouched is subordinate to verified: never show both seals.
|
||||
let vouchedBadge = !isVerifiedFingerprint
|
||||
&& (fingerprint.map { chatViewModel.isVouchedFingerprint($0) } ?? false)
|
||||
|
||||
return MeshPeerRow(
|
||||
peerID: peer.peerID,
|
||||
@@ -202,7 +222,8 @@ final class PeerListModel: ObservableObject {
|
||||
isReachable: peer.isReachable,
|
||||
isMutualFavorite: peer.isMutualFavorite,
|
||||
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
|
||||
showsVerifiedBadgeWhenOffline: verifiedBadge
|
||||
showsVerifiedBadgeWhenOffline: verifiedBadge,
|
||||
showsVouchedBadge: vouchedBadge
|
||||
)
|
||||
}
|
||||
|
||||
@@ -217,22 +238,40 @@ final class PeerListModel: ObservableObject {
|
||||
}
|
||||
|
||||
let geohashPeople = buildGeohashPeople()
|
||||
let groupRows = buildGroupRows()
|
||||
|
||||
self.meshRows = meshRows
|
||||
reachableMeshPeerCount = meshCounts.reachable
|
||||
connectedMeshPeerCount = meshCounts.connected
|
||||
self.geohashPeople = geohashPeople
|
||||
visibleGeohashPeerCount = geohashPeople.count
|
||||
self.groupRows = groupRows
|
||||
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)"
|
||||
} +
|
||||
groupRows.map {
|
||||
"group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)"
|
||||
}
|
||||
).joined(separator: "|")
|
||||
}
|
||||
|
||||
private func buildGroupRows() -> [GroupChatRow] {
|
||||
let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint()
|
||||
return chatViewModel.groupStore.groups.map { group in
|
||||
GroupChatRow(
|
||||
peerID: group.peerID,
|
||||
name: group.name,
|
||||
memberCount: group.members.count,
|
||||
isCreator: group.creatorFingerprint == myFingerprint,
|
||||
hasUnread: chatViewModel.hasUnreadMessages(for: group.peerID)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func buildGeohashPeople() -> [GeohashPersonRow] {
|
||||
let myHex = currentGeohashIdentityHex()
|
||||
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
|
||||
|
||||
@@ -2,68 +2,93 @@ 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> = []
|
||||
@Published private(set) var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
|
||||
|
||||
private let conversationStore: ConversationStore
|
||||
private let conversations: ConversationStore
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(conversationStore: ConversationStore) {
|
||||
self.conversationStore = conversationStore
|
||||
init(conversations: ConversationStore) {
|
||||
self.conversations = conversations
|
||||
self.selectedPeerID = conversations.selectedPrivatePeerID
|
||||
self.unreadPeerIDs = conversations.unreadDirectRoutingPeerIDs()
|
||||
|
||||
bind()
|
||||
refreshMessages()
|
||||
}
|
||||
|
||||
func messages(for peerID: PeerID?) -> [BitchatMessage] {
|
||||
guard let peerID else { return [] }
|
||||
return messagesByPeerID[peerID] ?? []
|
||||
return conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
conversationStore.$selectedPrivatePeerID
|
||||
.receive(on: DispatchQueue.main)
|
||||
conversations.$selectedPrivatePeerID
|
||||
.dropFirst()
|
||||
.sink { [weak self] peerID in
|
||||
self?.selectedPeerID = peerID
|
||||
self?.refreshMessages()
|
||||
guard let self, self.selectedPeerID != peerID else { return }
|
||||
self.selectedPeerID = peerID
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
conversationStore.$unreadConversations
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.unreadPeerIDs = self?.conversationStore.unreadDirectPeerIDs() ?? []
|
||||
self?.refreshMessages()
|
||||
conversations.changes
|
||||
.sink { [weak self] change in
|
||||
self?.apply(change)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
conversationStore.$messagesByConversation
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshMessages()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
selectedPeerID = conversationStore.selectedPrivatePeerID
|
||||
unreadPeerIDs = conversationStore.unreadDirectPeerIDs()
|
||||
}
|
||||
|
||||
private func refreshMessages() {
|
||||
var nextMessagesByPeerID = conversationStore.directMessagesByPeerID()
|
||||
var peerIDs = Set(nextMessagesByPeerID.keys)
|
||||
peerIDs.formUnion(conversationStore.unreadDirectPeerIDs())
|
||||
if let selectedPeerID = conversationStore.selectedPrivatePeerID {
|
||||
peerIDs.insert(selectedPeerID)
|
||||
}
|
||||
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)
|
||||
|
||||
for peerID in peerIDs where nextMessagesByPeerID[peerID] == nil {
|
||||
nextMessagesByPeerID[peerID] = []
|
||||
}
|
||||
case .unreadChanged(let id, _):
|
||||
guard isDirect(id) else { return }
|
||||
refreshUnreadPeerIDs()
|
||||
|
||||
messagesByPeerID = nextMessagesByPeerID
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +108,13 @@ struct PrivateConversationHeaderState: Equatable {
|
||||
let encryptionStatus: EncryptionStatus?
|
||||
|
||||
var supportsFavoriteToggle: Bool {
|
||||
!conversationPeerID.isGeoDM
|
||||
!conversationPeerID.isGeoDM && !conversationPeerID.isGroup
|
||||
}
|
||||
|
||||
/// Group chats have no single peer identity behind the header: no
|
||||
/// fingerprint screen, no per-peer encryption badge.
|
||||
var isGroupConversation: Bool {
|
||||
conversationPeerID.isGroup
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,22 +124,22 @@ final class PrivateConversationModel: ObservableObject {
|
||||
@Published private(set) var selectedHeaderState: PrivateConversationHeaderState?
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private let conversationStore: ConversationStore
|
||||
private let conversations: ConversationStore
|
||||
private let locationChannelsModel: LocationChannelsModel
|
||||
private let peerIdentityStore: PeerIdentityStore
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
init(
|
||||
chatViewModel: ChatViewModel,
|
||||
conversationStore: ConversationStore,
|
||||
conversations: ConversationStore,
|
||||
locationChannelsModel: LocationChannelsModel? = nil,
|
||||
peerIdentityStore: PeerIdentityStore? = nil
|
||||
) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.conversationStore = conversationStore
|
||||
self.conversations = conversations
|
||||
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
|
||||
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
||||
let initialPeerID = conversationStore.selectedPrivatePeerID
|
||||
let initialPeerID = conversations.selectedPrivatePeerID
|
||||
self.selectedPeerID = initialPeerID
|
||||
self.selectedHeaderState = initialPeerID.flatMap { peerID in
|
||||
makeHeaderState(for: peerID)
|
||||
@@ -153,7 +184,7 @@ final class PrivateConversationModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
conversationStore.$selectedPrivatePeerID
|
||||
conversations.$selectedPrivatePeerID
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
@@ -181,6 +212,13 @@ final class PrivateConversationModel: ObservableObject {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.groupStore.$groups
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshSelectedConversation()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
@@ -197,17 +235,43 @@ final class PrivateConversationModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func refreshSelectedConversation() {
|
||||
selectedPeerID = conversationStore.selectedPrivatePeerID
|
||||
selectedPeerID = conversations.selectedPrivatePeerID
|
||||
selectedHeaderState = selectedPeerID.flatMap { peerID in
|
||||
makeHeaderState(for: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
|
||||
// Group chats: the "peer" is the whole crew. Name + member count in
|
||||
// the header; availability reads as mesh since group traffic floods
|
||||
// the local mesh, and the per-peer encryption badge does not apply.
|
||||
if conversationPeerID.isGroup {
|
||||
let displayName: String
|
||||
if let group = chatViewModel.groupStore.group(for: conversationPeerID) {
|
||||
displayName = "#\(group.name) (\(group.members.count))"
|
||||
} else {
|
||||
displayName = String(localized: "common.unknown", defaultValue: "unknown", comment: "Fallback label for unknown peer")
|
||||
}
|
||||
return PrivateConversationHeaderState(
|
||||
conversationPeerID: conversationPeerID,
|
||||
headerPeerID: conversationPeerID,
|
||||
displayName: displayName,
|
||||
availability: .meshReachable,
|
||||
isFavorite: false,
|
||||
encryptionStatus: nil
|
||||
)
|
||||
}
|
||||
|
||||
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
||||
let availability = resolveAvailability(for: headerPeerID, peer: peer)
|
||||
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
|
||||
// never resolve to a reachable mesh peer, so resolveAvailability would
|
||||
// report .offline. Report .nostrAvailable so the header shows the
|
||||
// globe instead of a misleading "offline" tag.
|
||||
let availability = conversationPeerID.isGeoDM
|
||||
? .nostrAvailable
|
||||
: resolveAvailability(for: headerPeerID, peer: peer)
|
||||
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
|
||||
? nil
|
||||
: chatViewModel.getEncryptionStatus(for: headerPeerID)
|
||||
@@ -264,7 +328,7 @@ final class PrivateConversationModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
|
||||
return String(localized: "common.unknown", defaultValue: "unknown", comment: "Fallback label for unknown peer")
|
||||
}
|
||||
|
||||
private func resolveAvailability(for headerPeerID: PeerID, peer: BitchatPeer?) -> PrivateConversationAvailability {
|
||||
|
||||
@@ -2,40 +2,76 @@ 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
|
||||
@Published private(set) var messages: [BitchatMessage] = []
|
||||
|
||||
private let conversationStore: ConversationStore
|
||||
/// 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(conversationStore: ConversationStore) {
|
||||
self.activeChannel = conversationStore.activeChannel
|
||||
self.conversationStore = conversationStore
|
||||
init(conversations: ConversationStore) {
|
||||
let channel = conversations.activeChannel
|
||||
self.conversations = conversations
|
||||
self.activeChannel = channel
|
||||
self.activeConversation = conversations.conversation(for: ConversationID(channelID: channel))
|
||||
|
||||
observeActiveConversation()
|
||||
bind()
|
||||
refreshMessages()
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
conversationStore.$activeChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
conversations.$activeChannel
|
||||
.dropFirst()
|
||||
.sink { [weak self] channel in
|
||||
self?.activeChannel = channel
|
||||
self?.refreshMessages()
|
||||
guard let self else { return }
|
||||
self.activeChannel = channel
|
||||
self.retargetActiveConversation(to: channel)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
conversationStore.$messagesByConversation
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refreshMessages()
|
||||
// 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 refreshMessages() {
|
||||
messages = conversationStore.messages(for: ConversationID(channelID: activeChannel))
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,14 @@ struct FingerprintPresentationState: Equatable {
|
||||
let theirFingerprint: String?
|
||||
let myFingerprint: String
|
||||
let isVerified: Bool
|
||||
/// Number of currently-valid vouches from peers the user verified
|
||||
/// (0 when the peer is explicitly verified — the stronger badge wins).
|
||||
let voucherCount: Int
|
||||
/// Display names of the (verified) vouchers, where known.
|
||||
let voucherNames: [String]
|
||||
|
||||
/// Vouched for by ≥1 peer the user verified (and not explicitly verified).
|
||||
var isVouched: Bool { voucherCount > 0 }
|
||||
|
||||
var canToggleVerification: Bool {
|
||||
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
|
||||
@@ -82,6 +90,24 @@ final class VerificationModel: ObservableObject {
|
||||
let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID)
|
||||
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
|
||||
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
|
||||
let isVerified = theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
||||
|
||||
// Vouch state is recomputed on read: only vouchers still in the
|
||||
// verified set count, so removing a verification silently retires the
|
||||
// vouches that peer gave.
|
||||
let vouchers: [VouchRecord]
|
||||
if !isVerified, let theirFingerprint {
|
||||
vouchers = chatViewModel.identityManager.validVouchers(for: theirFingerprint)
|
||||
} else {
|
||||
vouchers = []
|
||||
}
|
||||
let voucherNames = vouchers.compactMap { record -> String? in
|
||||
guard let social = chatViewModel.identityManager.getSocialIdentity(for: record.voucherFingerprint) else {
|
||||
return nil
|
||||
}
|
||||
if let petname = social.localPetname, !petname.isEmpty { return petname }
|
||||
return social.claimedNickname.isEmpty ? nil : social.claimedNickname
|
||||
}
|
||||
|
||||
return FingerprintPresentationState(
|
||||
statusPeerID: statusPeerID,
|
||||
@@ -89,7 +115,9 @@ final class VerificationModel: ObservableObject {
|
||||
encryptionStatus: encryptionStatus,
|
||||
theirFingerprint: theirFingerprint,
|
||||
myFingerprint: chatViewModel.getMyFingerprint(),
|
||||
isVerified: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
||||
isVerified: isVerified,
|
||||
voucherCount: vouchers.count,
|
||||
voucherNames: voucherNames
|
||||
)
|
||||
}
|
||||
|
||||
@@ -122,6 +150,17 @@ final class VerificationModel: ObservableObject {
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Vouch state changes (ChatVouchCoordinator.notifyPeerTrustChanged)
|
||||
// are signalled via this notification rather than a published
|
||||
// property, so an open fingerprint sheet refreshes its vouched badge
|
||||
// live when a vouch batch is accepted.
|
||||
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.objectWillChange.send()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
|
||||
@@ -147,6 +186,6 @@ final class VerificationModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
return String(localized: "common.unknown", comment: "Label for an unknown peer")
|
||||
return String(localized: "common.unknown", defaultValue: "unknown", comment: "Label for an unknown peer")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
#if DEBUG
|
||||
import BitLogger
|
||||
#endif
|
||||
|
||||
@main
|
||||
struct BitchatApp: App {
|
||||
@@ -15,6 +18,7 @@ struct BitchatApp: App {
|
||||
static let groupID = "group.\(bundleID)"
|
||||
|
||||
@StateObject private var runtime: AppRuntime
|
||||
@AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue
|
||||
#if os(iOS)
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
@@ -30,6 +34,7 @@ struct BitchatApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environment(\.appTheme, AppTheme(rawValue: appThemeRawValue) ?? .matrix)
|
||||
.environmentObject(runtime.publicChatModel)
|
||||
.environmentObject(runtime.privateInboxModel)
|
||||
.environmentObject(runtime.privateConversationModel)
|
||||
@@ -41,6 +46,11 @@ struct BitchatApp: App {
|
||||
.onAppear {
|
||||
appDelegate.runtime = runtime
|
||||
runtime.start()
|
||||
#if DEBUG
|
||||
// Arm the opt-in UDP log sink from persisted config (no-op
|
||||
// until a collector host is set in the App Info sheet).
|
||||
LogNetworkSink.shared.reloadConfiguration()
|
||||
#endif
|
||||
}
|
||||
.onOpenURL { url in
|
||||
runtime.handleOpenURL(url)
|
||||
@@ -69,7 +79,7 @@ struct BitchatApp: App {
|
||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
weak var runtime: AppRuntime?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -126,11 +126,35 @@ struct SocialIdentity: Codable {
|
||||
var notes: String?
|
||||
}
|
||||
|
||||
/// Trust ladder: unknown → casual → vouched → trusted → verified.
|
||||
///
|
||||
/// Persistence compatibility: `TrustLevel` is stored by its *String* raw
|
||||
/// value ("unknown", "casual", …), not by ordinal position, so inserting
|
||||
/// `vouched` mid-ladder cannot corrupt previously persisted values — every
|
||||
/// pre-existing case keeps the exact raw value it was written with. The
|
||||
/// `vouched` tier is additionally never persisted into `SocialIdentity`
|
||||
/// (it's recomputed on read from stored vouches), so downgraded builds never
|
||||
/// encounter the unfamiliar raw value.
|
||||
enum TrustLevel: String, Codable {
|
||||
case unknown = "unknown"
|
||||
case casual = "casual"
|
||||
case trusted = "trusted"
|
||||
case verified = "verified"
|
||||
case unknown
|
||||
case casual
|
||||
/// Transitively trusted: vouched for by at least one peer *I* verified.
|
||||
/// Derived at read time — never written to persistent storage.
|
||||
case vouched
|
||||
case trusted
|
||||
case verified
|
||||
}
|
||||
|
||||
// MARK: - Vouching (transitive verification)
|
||||
|
||||
/// One accepted vouch: a peer I verified (the voucher) attested that they
|
||||
/// verified the vouchee. Validity is recomputed on read — a record only
|
||||
/// counts while its voucher remains in `verifiedFingerprints` and its
|
||||
/// timestamp is within `VouchAttestation.maxAge` — so unverifying a voucher
|
||||
/// silently invalidates the vouches they gave without a cascade delete.
|
||||
struct VouchRecord: Codable, Equatable {
|
||||
let voucherFingerprint: String
|
||||
let timestamp: Date
|
||||
}
|
||||
|
||||
// MARK: - Identity Cache
|
||||
@@ -154,7 +178,22 @@ struct IdentityCache: Codable {
|
||||
|
||||
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
||||
var blockedNostrPubkeys: Set<String> = []
|
||||
|
||||
|
||||
// Vouching (transitive verification). All three fields are Optional so
|
||||
// caches persisted before this feature decode cleanly — the synthesized
|
||||
// decoder uses decodeIfPresent for optionals, and a missing key must not
|
||||
// trip the "unreadable cache" recovery path that discards everything.
|
||||
|
||||
// Vouchee fingerprint -> accepted vouches (capped per vouchee)
|
||||
var vouchesByVouchee: [String: [VouchRecord]]? = nil
|
||||
|
||||
// Peer fingerprint -> when we last sent them a vouch batch (rate limit)
|
||||
var vouchBatchSentAt: [String: Date]? = nil
|
||||
|
||||
// Fingerprint -> when we verified it (orders outgoing vouch batches;
|
||||
// entries verified before this field exists sort as oldest)
|
||||
var verifiedAt: [String: Date]? = nil
|
||||
|
||||
// Schema version for future migrations
|
||||
var version: Int = 1
|
||||
}
|
||||
|
||||
@@ -133,6 +133,17 @@ protocol SecureIdentityStateManagerProtocol {
|
||||
func setVerified(fingerprint: String, verified: Bool)
|
||||
func isVerified(fingerprint: String) -> Bool
|
||||
func getVerifiedFingerprints() -> Set<String>
|
||||
|
||||
// MARK: Vouching (transitive verification)
|
||||
@discardableResult
|
||||
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool
|
||||
func validVouchers(for fingerprint: String) -> [VouchRecord]
|
||||
func isVouched(fingerprint: String) -> Bool
|
||||
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel
|
||||
func lastVouchBatchSent(to fingerprint: String) -> Date?
|
||||
func markVouchBatchSent(to fingerprint: String, at date: Date)
|
||||
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
|
||||
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
|
||||
}
|
||||
|
||||
/// Singleton manager for secure identity state persistence and retrieval.
|
||||
@@ -151,38 +162,68 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
// Thread safety
|
||||
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
|
||||
|
||||
// Debouncing for keychain saves
|
||||
private var saveTimer: Timer?
|
||||
private let saveDebounceInterval: TimeInterval = 2.0 // Save at most once every 2 seconds
|
||||
// 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.)
|
||||
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
|
||||
|
||||
// Generate or retrieve encryption key from keychain
|
||||
|
||||
// Retrieve (or, only on genuine first run, generate) the cache
|
||||
// encryption key. We MUST distinguish "key doesn't exist yet" from a
|
||||
// transient failure (device locked / access denied): the legacy
|
||||
// getIdentityKey(forKey:) collapses both to nil, and generating+saving a
|
||||
// new key deletes the existing one first — permanently orphaning the
|
||||
// encrypted cache on a launch that merely couldn't read the key.
|
||||
let loadedKey: SymmetricKey
|
||||
|
||||
// Try to load from keychain
|
||||
if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) {
|
||||
let keyIsEphemeral: Bool
|
||||
|
||||
switch keychain.getIdentityKeyWithResult(forKey: encryptionKeyName) {
|
||||
case .success(let keyData):
|
||||
loadedKey = SymmetricKey(data: keyData)
|
||||
keyIsEphemeral = false
|
||||
SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true)
|
||||
}
|
||||
// Generate new key if needed
|
||||
else {
|
||||
loadedKey = SymmetricKey(size: .bits256)
|
||||
let keyData = loadedKey.withUnsafeBytes { Data($0) }
|
||||
// Save to keychain
|
||||
|
||||
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)
|
||||
loadedKey = SymmetricKey(size: .bits256)
|
||||
keyIsEphemeral = true
|
||||
}
|
||||
|
||||
|
||||
self.encryptionKey = loadedKey
|
||||
|
||||
// Load identity cache on init
|
||||
loadIdentityCache()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -211,23 +252,28 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the cache. Always invoked on `queue` under a barrier (its callers
|
||||
/// run inside `queue.async(.barrier)`), so it simply marks the cache dirty
|
||||
/// and persists it on the same serialized context — no timer, nothing left
|
||||
/// scheduled to keep the process alive.
|
||||
private func saveIdentityCache() {
|
||||
// Mark that we need to save
|
||||
pendingSave = true
|
||||
|
||||
// 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()
|
||||
}
|
||||
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)
|
||||
@@ -239,10 +285,14 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
SecureLogger.error(error, context: "Failed to save identity cache", category: .security)
|
||||
}
|
||||
}
|
||||
|
||||
// Force immediate save (for app termination)
|
||||
|
||||
// Force immediate save (for app termination / lifecycle events). Mutations
|
||||
// already persist synchronously via saveIdentityCache, so this is normally a
|
||||
// no-op (performSave early-returns when nothing is pending). Runs directly on
|
||||
// the caller's thread — deliberately NOT a `queue.sync(barrier)`, which is
|
||||
// reachable from `deinit` and from async tests on the swift-concurrency
|
||||
// cooperative pool where a blocking barrier-sync can starve/deadlock it.
|
||||
func forceSave() {
|
||||
saveTimer?.invalidate()
|
||||
performSave()
|
||||
}
|
||||
|
||||
@@ -511,16 +561,20 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
queue.async(flags: .barrier) {
|
||||
if verified {
|
||||
self.cache.verifiedFingerprints.insert(fingerprint)
|
||||
var verifiedAt = self.cache.verifiedAt ?? [:]
|
||||
verifiedAt[fingerprint] = Date()
|
||||
self.cache.verifiedAt = verifiedAt
|
||||
} else {
|
||||
self.cache.verifiedFingerprints.remove(fingerprint)
|
||||
self.cache.verifiedAt?.removeValue(forKey: fingerprint)
|
||||
}
|
||||
|
||||
|
||||
// Update trust level if social identity exists
|
||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||
identity.trustLevel = verified ? .verified : .casual
|
||||
self.cache.socialIdentities[fingerprint] = identity
|
||||
}
|
||||
|
||||
|
||||
self.saveIdentityCache()
|
||||
}
|
||||
}
|
||||
@@ -537,6 +591,159 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Vouching (transitive verification)
|
||||
|
||||
/// Maximum vouchers retained per vouchee (most recent kept).
|
||||
static let maxVouchersPerVouchee = 8
|
||||
|
||||
/// Records an accepted vouch, enforcing every accept-policy gate that can
|
||||
/// be evaluated against stored state (signature verification is the
|
||||
/// caller's job — it needs the sender's announce-bound signing key):
|
||||
/// - the voucher must be a fingerprint *I* verified
|
||||
/// - self-vouches are ignored
|
||||
/// - vouches for peers I already verified are ignored (nothing to add)
|
||||
/// - attestations outside the validity window are ignored
|
||||
/// - at most `maxVouchersPerVouchee` vouchers are kept per vouchee
|
||||
///
|
||||
/// Returns true when the vouch was stored (or refreshed).
|
||||
@discardableResult
|
||||
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
|
||||
recordVouch(
|
||||
voucheeFingerprint: voucheeFingerprint,
|
||||
voucherFingerprint: voucherFingerprint,
|
||||
timestamp: timestamp,
|
||||
now: Date()
|
||||
)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date, now: Date) -> Bool {
|
||||
queue.sync(flags: .barrier) {
|
||||
guard voucheeFingerprint != voucherFingerprint,
|
||||
self.cache.verifiedFingerprints.contains(voucherFingerprint),
|
||||
!self.cache.verifiedFingerprints.contains(voucheeFingerprint) else {
|
||||
return false
|
||||
}
|
||||
let age = now.timeIntervalSince(timestamp)
|
||||
guard age <= VouchAttestation.maxAge, age >= -VouchAttestation.maxClockSkew else {
|
||||
return false
|
||||
}
|
||||
|
||||
var records = self.cache.vouchesByVouchee?[voucheeFingerprint] ?? []
|
||||
if let index = records.firstIndex(where: { $0.voucherFingerprint == voucherFingerprint }) {
|
||||
let newest = max(records[index].timestamp, timestamp)
|
||||
records[index] = VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: newest)
|
||||
} else {
|
||||
records.append(VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: timestamp))
|
||||
}
|
||||
// Keep the most recent vouchers up to the cap.
|
||||
records.sort { $0.timestamp > $1.timestamp }
|
||||
let capped = Array(records.prefix(Self.maxVouchersPerVouchee))
|
||||
guard capped.contains(where: { $0.voucherFingerprint == voucherFingerprint }) else {
|
||||
return false // Full of fresher vouches; nothing changed.
|
||||
}
|
||||
|
||||
var vouches = self.cache.vouchesByVouchee ?? [:]
|
||||
vouches[voucheeFingerprint] = capped
|
||||
self.cache.vouchesByVouchee = vouches
|
||||
self.saveIdentityCache()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// The vouches that currently count for `fingerprint`. Validity is
|
||||
/// recomputed here rather than maintained by cascade deletes: a record
|
||||
/// only counts while its voucher is still verified-by-me and its
|
||||
/// timestamp is within the expiry window.
|
||||
func validVouchers(for fingerprint: String) -> [VouchRecord] {
|
||||
validVouchers(for: fingerprint, now: Date())
|
||||
}
|
||||
|
||||
func validVouchers(for fingerprint: String, now: Date) -> [VouchRecord] {
|
||||
queue.sync {
|
||||
self.validVouchersLocked(for: fingerprint, now: now)
|
||||
}
|
||||
}
|
||||
|
||||
/// Requires `queue`.
|
||||
private func validVouchersLocked(for fingerprint: String, now: Date) -> [VouchRecord] {
|
||||
guard let records = cache.vouchesByVouchee?[fingerprint] else { return [] }
|
||||
return records.filter { record in
|
||||
record.voucherFingerprint != fingerprint
|
||||
&& cache.verifiedFingerprints.contains(record.voucherFingerprint)
|
||||
&& now.timeIntervalSince(record.timestamp) <= VouchAttestation.maxAge
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the peer has at least one valid vouch and no explicit
|
||||
/// verification of ours.
|
||||
func isVouched(fingerprint: String) -> Bool {
|
||||
isVouched(fingerprint: fingerprint, now: Date())
|
||||
}
|
||||
|
||||
func isVouched(fingerprint: String, now: Date) -> Bool {
|
||||
queue.sync {
|
||||
guard !self.cache.verifiedFingerprints.contains(fingerprint) else { return false }
|
||||
return !self.validVouchersLocked(for: fingerprint, now: now).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
/// The trust level to display: explicit verification wins, then the
|
||||
/// persisted level, with `vouched` layered in (derived, never persisted)
|
||||
/// between `casual` and `trusted`.
|
||||
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel {
|
||||
effectiveTrustLevel(for: fingerprint, now: Date())
|
||||
}
|
||||
|
||||
func effectiveTrustLevel(for fingerprint: String, now: Date) -> TrustLevel {
|
||||
queue.sync {
|
||||
if self.cache.verifiedFingerprints.contains(fingerprint) { return .verified }
|
||||
let stored = self.cache.socialIdentities[fingerprint]?.trustLevel ?? .unknown
|
||||
let vouched = !self.validVouchersLocked(for: fingerprint, now: now).isEmpty
|
||||
switch stored {
|
||||
case .verified, .trusted:
|
||||
return stored
|
||||
case .vouched, .casual, .unknown:
|
||||
if vouched { return .vouched }
|
||||
// `.vouched` should never be persisted; degrade defensively.
|
||||
return stored == .vouched ? .casual : stored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func lastVouchBatchSent(to fingerprint: String) -> Date? {
|
||||
queue.sync { cache.vouchBatchSentAt?[fingerprint] }
|
||||
}
|
||||
|
||||
func markVouchBatchSent(to fingerprint: String, at date: Date) {
|
||||
queue.async(flags: .barrier) {
|
||||
var sentAt = self.cache.vouchBatchSentAt ?? [:]
|
||||
sentAt[fingerprint] = date
|
||||
self.cache.vouchBatchSentAt = sentAt
|
||||
self.saveIdentityCache()
|
||||
}
|
||||
}
|
||||
|
||||
/// The peer's announce-bound Ed25519 signing key, if seen this session.
|
||||
func signingPublicKey(forFingerprint fingerprint: String) -> Data? {
|
||||
queue.sync { cryptographicIdentities[fingerprint]?.signingPublicKey }
|
||||
}
|
||||
|
||||
/// Verified fingerprints ordered most recently verified first (entries
|
||||
/// without a recorded verification time sort last), excluding the given
|
||||
/// fingerprint. Feeds the outgoing vouch batch.
|
||||
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
|
||||
queue.sync {
|
||||
let verifiedAt = cache.verifiedAt ?? [:]
|
||||
let ordered = cache.verifiedFingerprints
|
||||
.filter { $0 != fingerprint }
|
||||
.sorted {
|
||||
(verifiedAt[$0] ?? .distantPast, $0) > (verifiedAt[$1] ?? .distantPast, $1)
|
||||
}
|
||||
return Array(ordered.prefix(limit))
|
||||
}
|
||||
}
|
||||
|
||||
var debugNicknameIndex: [String: Set<String>] {
|
||||
queue.sync { cache.nicknameIndex }
|
||||
}
|
||||
|
||||
@@ -33,12 +33,18 @@
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_bitchat-bulk._tcp</string>
|
||||
</array>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>bitchat uses peer-to-peer Wi-Fi to transfer large photos and voice notes directly between nearby devices.</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
|
||||
+22080
-67
File diff suppressed because it is too large
Load Diff
@@ -11,48 +11,74 @@ 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 group
|
||||
case help
|
||||
case hug
|
||||
case message = "dm"
|
||||
case message = "msg"
|
||||
case slap
|
||||
case pay
|
||||
case unblock
|
||||
case who
|
||||
case favorite
|
||||
case unfavorite
|
||||
|
||||
case favorite = "fav"
|
||||
case unfavorite = "unfav"
|
||||
case ping
|
||||
case trace
|
||||
|
||||
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, .who:
|
||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace:
|
||||
return "<" + String(localized: "content.input.nickname_placeholder", defaultValue: "nickname") + ">"
|
||||
case .group:
|
||||
return "<" + String(localized: "content.input.group_placeholder", defaultValue: "create|invite|leave|list") + ">"
|
||||
case .pay:
|
||||
return "<" + String(localized: "content.input.token_placeholder", defaultValue: "token") + ">"
|
||||
case .clear, .help, .who:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .block: String(localized: "content.commands.block")
|
||||
case .clear: String(localized: "content.commands.clear")
|
||||
case .hug: String(localized: "content.commands.hug")
|
||||
case .message: String(localized: "content.commands.message")
|
||||
case .slap: String(localized: "content.commands.slap")
|
||||
case .unblock: String(localized: "content.commands.unblock")
|
||||
case .who: String(localized: "content.commands.who")
|
||||
case .favorite: String(localized: "content.commands.favorite")
|
||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
||||
case .block: String(localized: "content.commands.block", defaultValue: "block or list blocked peers")
|
||||
case .clear: String(localized: "content.commands.clear", defaultValue: "clear chat messages")
|
||||
case .group: String(localized: "content.commands.group", defaultValue: "create or manage private groups")
|
||||
case .help: String(localized: "content.commands.help", defaultValue: "show available commands")
|
||||
case .hug: String(localized: "content.commands.hug", defaultValue: "send someone a warm hug")
|
||||
case .message: String(localized: "content.commands.message", defaultValue: "send private message")
|
||||
case .pay: String(localized: "content.commands.pay", defaultValue: "send a cashu ecash token in this chat")
|
||||
case .slap: String(localized: "content.commands.slap", defaultValue: "slap someone with a trout")
|
||||
case .unblock: String(localized: "content.commands.unblock", defaultValue: "unblock a peer")
|
||||
case .who: String(localized: "content.commands.who", defaultValue: "see who's online")
|
||||
case .favorite: String(localized: "content.commands.favorite", defaultValue: "add to favorites")
|
||||
case .unfavorite: String(localized: "content.commands.unfavorite", defaultValue: "remove from favorites")
|
||||
case .ping: String(localized: "content.commands.ping", defaultValue: "measure round-trip time to a mesh peer")
|
||||
case .trace: String(localized: "content.commands.trace", defaultValue: "estimate the mesh path to a peer")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
||||
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
|
||||
if isGeoPublic || isGeoDM {
|
||||
return baseCommands + [.favorite, .unfavorite]
|
||||
var commands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
|
||||
// Cashu tokens are bearer instruments: in a public geohash any nearby
|
||||
// stranger can redeem one, so don't *suggest* /pay there (the
|
||||
// processor still allows it behind an explicit "public" confirm).
|
||||
// Payments make sense in every DM and in mesh public.
|
||||
if !isGeoPublic {
|
||||
commands.append(.pay)
|
||||
}
|
||||
return baseCommands
|
||||
// The processor rejects favorites, groups and mesh diagnostics in
|
||||
// geohash contexts, so only suggest them where they actually work: mesh.
|
||||
if isGeoPublic || isGeoDM {
|
||||
return commands
|
||||
}
|
||||
return commands + [.favorite, .unfavorite, .group, .ping, .trace]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
// REQUEST_SYNC payload TLV (type, length16, value)
|
||||
// - 0x01: P (uint8) — Golomb-Rice parameter
|
||||
// - 0x02: M (uint32, big-endian) — hash range (N * 2^P)
|
||||
// - 0x03: data (opaque) — GR bitstream bytes (MSB-first)
|
||||
// - 0x04: types (SyncTypeFlags) — packet types the filter covers
|
||||
// - 0x05: sinceTimestamp (uint64, big-endian) — filter coverage cursor
|
||||
// - 0x06: fragmentIdFilter (UTF-8) — comma-separated 16-hex-char (8-byte)
|
||||
// fragment stream IDs; restricts the fragment diff to exactly those
|
||||
// streams (targeted resync for stalled reassemblies)
|
||||
struct RequestSyncPacket {
|
||||
/// Maximum fragment IDs one 0x06 filter may carry. Each ID encodes as
|
||||
/// 16 hex chars plus a comma separator, so the largest encoded value is
|
||||
/// 60 * 17 - 1 = 1019 bytes, which fits the 1024-byte decoder cap.
|
||||
static let maxFragmentIdFilterCount = 60
|
||||
|
||||
let p: Int
|
||||
let m: UInt32
|
||||
let data: Data
|
||||
@@ -12,6 +23,29 @@ struct RequestSyncPacket {
|
||||
let sinceTimestamp: UInt64?
|
||||
let fragmentIdFilter: String?
|
||||
|
||||
/// Encodes 8-byte fragment stream IDs as the 0x06 filter string,
|
||||
/// dropping malformed IDs and capping at `maxFragmentIdFilterCount`.
|
||||
static func encodeFragmentIdFilter(_ fragmentIDs: [Data]) -> String? {
|
||||
let tokens = fragmentIDs
|
||||
.filter { $0.count == 8 }
|
||||
.prefix(maxFragmentIdFilterCount)
|
||||
.map { $0.hexEncodedString() }
|
||||
guard !tokens.isEmpty else { return nil }
|
||||
return tokens.joined(separator: ",")
|
||||
}
|
||||
|
||||
/// Decodes a 0x06 filter string back into 8-byte fragment stream IDs,
|
||||
/// ignoring malformed tokens and capping at `maxFragmentIdFilterCount`.
|
||||
static func decodeFragmentIdFilter(_ filter: String?) -> Set<Data>? {
|
||||
guard let filter else { return nil }
|
||||
var ids: Set<Data> = []
|
||||
for token in filter.split(separator: ",").prefix(maxFragmentIdFilterCount) {
|
||||
guard token.count == 16, let id = Data(hexString: String(token)) else { continue }
|
||||
ids.insert(id)
|
||||
}
|
||||
return ids.isEmpty ? nil : ids
|
||||
}
|
||||
|
||||
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
|
||||
self.p = p
|
||||
self.m = m
|
||||
@@ -88,7 +122,9 @@ struct RequestSyncPacket {
|
||||
sinceTimestamp = ts
|
||||
}
|
||||
case 0x06:
|
||||
if let fid = String(data: v, encoding: .utf8) {
|
||||
// Same acceptance cap as the GCS payload; an oversized filter
|
||||
// is ignored rather than failing the whole request.
|
||||
if v.count <= maxAcceptBytes, let fid = String(data: v, encoding: .utf8) {
|
||||
fragmentIdFilter = fid
|
||||
}
|
||||
default:
|
||||
@@ -96,7 +132,7 @@ struct RequestSyncPacket {
|
||||
}
|
||||
}
|
||||
|
||||
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
|
||||
guard let pp = p, let mm = m, let dd = payload, pp >= 1, pp <= GCSFilter.maxP, mm > 0 else { return nil }
|
||||
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ enum NoisePattern {
|
||||
case XX // Most versatile, mutual authentication
|
||||
case IK // Initiator knows responder's static key
|
||||
case NK // Anonymous initiator
|
||||
case X // One-way: single message to a known static key (no response)
|
||||
}
|
||||
|
||||
enum NoiseRole {
|
||||
@@ -322,6 +323,13 @@ 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)
|
||||
@@ -594,7 +602,7 @@ final class NoiseHandshakeState {
|
||||
switch pattern {
|
||||
case .XX:
|
||||
break // No pre-message keys
|
||||
case .IK, .NK:
|
||||
case .IK, .NK, .X:
|
||||
if role == .initiator, let remoteStatic = remoteStaticPublic {
|
||||
symmetricState.mixHash(remoteStatic.rawRepresentation)
|
||||
} else if role == .responder, let localStatic = localStaticPublic {
|
||||
@@ -897,6 +905,7 @@ extension NoisePattern {
|
||||
case .XX: return "XX"
|
||||
case .IK: return "IK"
|
||||
case .NK: return "NK"
|
||||
case .X: return "X"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -918,6 +927,10 @@ extension NoisePattern {
|
||||
[.e, .es], // -> e, es
|
||||
[.e, .ee] // <- e, ee
|
||||
]
|
||||
case .X:
|
||||
return [
|
||||
[.e, .es, .s, .ss] // -> e, es, s, ss (single one-way message)
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,11 @@ 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
|
||||
@@ -165,33 +170,16 @@ 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 [] }
|
||||
|
||||
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)" }
|
||||
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)" }
|
||||
}
|
||||
|
||||
// MARK: - Remote Fetch
|
||||
@@ -289,6 +277,8 @@ 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
|
||||
|
||||
@@ -82,6 +82,13 @@ 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)
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import BitFoundation
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// NIP-13 proof-of-work for Nostr events.
|
||||
///
|
||||
/// Outgoing kind-20000 geohash messages mine a `["nonce", "<value>", "<target>"]`
|
||||
/// tag so the event ID carries at least `target` leading zero bits. Inbound
|
||||
/// events are scored (never hard-rejected — the network has clients that do
|
||||
/// not mine): validated PoW at or above `rateLimitBypassBits` relaxes the
|
||||
/// per-sender public rate limit, everything else keeps the strict limits.
|
||||
enum NostrPoW {
|
||||
|
||||
// MARK: - Tuning
|
||||
|
||||
/// Difficulty (leading zero bits of the event ID) mined onto outgoing
|
||||
/// geohash messages. 8 bits is ~256 hash attempts — typically well under
|
||||
/// 100 ms on any supported device.
|
||||
static let targetBits = 8
|
||||
|
||||
/// Inbound events whose validated NIP-13 difficulty is at least this many
|
||||
/// bits skip the per-sender rate-limit bucket (the content-flood bucket
|
||||
/// still applies). See `MessageRateLimiter.allow`.
|
||||
static let rateLimitBypassBits = 8
|
||||
|
||||
/// Hard cap on mining wall-clock time. When it hits, the committed target
|
||||
/// steps down until a difficulty reachable in a small extra budget is
|
||||
/// found and the message is sent anyway — mining never blocks sending.
|
||||
static let miningTimeCap: TimeInterval = 2.0
|
||||
|
||||
/// Budget for each stepped-down attempt after the main cap (or a task
|
||||
/// cancellation) hits.
|
||||
private static let fallbackTimeCap: TimeInterval = 0.15
|
||||
|
||||
/// The hot loop checks the deadline and task cancellation every this many
|
||||
/// hash attempts.
|
||||
private static let checkInterval: UInt64 = 1024
|
||||
|
||||
/// The nonce value is a fixed-width hex counter so the serialized event
|
||||
/// template can be mutated in place without reallocation.
|
||||
private static let nonceLength = 16
|
||||
|
||||
// MARK: - Scoring
|
||||
|
||||
/// Number of leading zero bits in a byte sequence (NIP-13 difficulty of
|
||||
/// an event-ID hash).
|
||||
static func leadingZeroBits<Bytes: Sequence<UInt8>>(_ bytes: Bytes) -> Int {
|
||||
var total = 0
|
||||
for byte in bytes {
|
||||
if byte == 0 {
|
||||
total += 8
|
||||
} else {
|
||||
total += byte.leadingZeroBitCount
|
||||
break
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/// Validated NIP-13 difficulty of an inbound event.
|
||||
///
|
||||
/// The committed target in the nonce tag is what counts: the actual
|
||||
/// leading zero bits of the ID must meet it (otherwise the claim is void
|
||||
/// and the event scores 0), and work beyond the commitment earns no extra
|
||||
/// credit — this stops spammers who mine a low target from getting lucky
|
||||
/// high scores. Events without a well-formed commitment score 0.
|
||||
static func validatedDifficulty(idHex: String, tags: [[String]]) -> Int {
|
||||
guard let nonceTag = tags.last(where: { $0.first == "nonce" }),
|
||||
nonceTag.count >= 3,
|
||||
let committed = Int(nonceTag[2]),
|
||||
committed > 0, committed <= 256,
|
||||
let idData = Data(hexString: idHex)
|
||||
else {
|
||||
return 0
|
||||
}
|
||||
return leadingZeroBits(idData) >= committed ? committed : 0
|
||||
}
|
||||
|
||||
// MARK: - Mining
|
||||
|
||||
/// Mine a `["nonce", value, target]` tag for the given unsigned-event
|
||||
/// fields. Nonisolated async: runs off the calling actor.
|
||||
///
|
||||
/// Bounded by `miningTimeCap`: when the cap hits — or the surrounding
|
||||
/// task is cancelled — the committed target steps down (halving to 0,
|
||||
/// which any hash satisfies) so the event still ships promptly with an
|
||||
/// honest commitment at the difficulty actually reached. Returns nil only
|
||||
/// if canonical serialization fails; the caller then sends unmined.
|
||||
static func mineNonceTag(
|
||||
pubkey: String,
|
||||
createdAt: Int,
|
||||
kind: Int,
|
||||
tags: [[String]],
|
||||
content: String,
|
||||
targetBits: Int = NostrPoW.targetBits
|
||||
) async -> [String]? {
|
||||
var target = min(max(targetBits, 0), 256)
|
||||
var budget = miningTimeCap
|
||||
while true {
|
||||
if let tag = mineAttempt(
|
||||
pubkey: pubkey,
|
||||
createdAt: createdAt,
|
||||
kind: kind,
|
||||
baseTags: tags,
|
||||
content: content,
|
||||
targetBits: target,
|
||||
budget: budget
|
||||
) {
|
||||
return tag
|
||||
}
|
||||
// Target 0 succeeds on the first hash, so reaching it with nil
|
||||
// means serialization itself failed — give up on mining.
|
||||
if target == 0 { return nil }
|
||||
target /= 2
|
||||
budget = fallbackTimeCap
|
||||
}
|
||||
}
|
||||
|
||||
/// One bounded mining pass at a fixed committed target. Allocation-light:
|
||||
/// the canonical serialization is built once and only the fixed-width
|
||||
/// nonce bytes are rewritten per attempt (the event ID is recomputed for
|
||||
/// every attempt, per NIP-13). Returns nil on timeout/cancellation or if
|
||||
/// the template could not be built.
|
||||
private static func mineAttempt(
|
||||
pubkey: String,
|
||||
createdAt: Int,
|
||||
kind: Int,
|
||||
baseTags: [[String]],
|
||||
content: String,
|
||||
targetBits: Int,
|
||||
budget: TimeInterval
|
||||
) -> [String]? {
|
||||
let targetString = String(targetBits)
|
||||
guard let template = serializedTemplate(
|
||||
pubkey: pubkey,
|
||||
createdAt: createdAt,
|
||||
kind: kind,
|
||||
baseTags: baseTags,
|
||||
content: content,
|
||||
targetString: targetString
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
var buffer = template.buffer
|
||||
let nonceRange = template.nonceRange
|
||||
|
||||
let deadline = DispatchTime.now().uptimeNanoseconds &+ UInt64(budget * 1_000_000_000)
|
||||
let hexDigits = [UInt8]("0123456789abcdef".utf8)
|
||||
var nonce = UInt64.random(in: .min ... .max)
|
||||
var attempts: UInt64 = 0
|
||||
|
||||
while true {
|
||||
// Write the nonce as 16 lowercase hex chars, in place.
|
||||
var value = nonce
|
||||
var index = nonceRange.upperBound
|
||||
while index > nonceRange.lowerBound {
|
||||
index -= 1
|
||||
buffer[index] = hexDigits[Int(value & 0xF)]
|
||||
value >>= 4
|
||||
}
|
||||
|
||||
if leadingZeroBits(SHA256.hash(data: buffer)) >= targetBits {
|
||||
// Identical to the bytes just written into the buffer.
|
||||
return ["nonce", String(format: "%016llx", nonce), targetString]
|
||||
}
|
||||
|
||||
nonce &+= 1
|
||||
attempts &+= 1
|
||||
if attempts % checkInterval == 0,
|
||||
Task.isCancelled || DispatchTime.now().uptimeNanoseconds >= deadline {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical NIP-01 serialization of the event with a placeholder nonce,
|
||||
/// plus the byte range of the nonce value inside it.
|
||||
///
|
||||
/// The range is located by serializing twice with two same-length
|
||||
/// placeholders and diffing the buffers — the only differing bytes are
|
||||
/// the nonce value, so this stays correct however `JSONSerialization`
|
||||
/// escapes the surrounding fields (and even if the content contains the
|
||||
/// placeholder text itself).
|
||||
private static func serializedTemplate(
|
||||
pubkey: String,
|
||||
createdAt: Int,
|
||||
kind: Int,
|
||||
baseTags: [[String]],
|
||||
content: String,
|
||||
targetString: String
|
||||
) -> (buffer: Data, nonceRange: Range<Int>)? {
|
||||
func serialize(noncePlaceholder: String) -> Data? {
|
||||
var tags = baseTags
|
||||
tags.append(["nonce", noncePlaceholder, targetString])
|
||||
let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content]
|
||||
return try? JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
|
||||
}
|
||||
|
||||
guard let zeros = serialize(noncePlaceholder: String(repeating: "0", count: nonceLength)),
|
||||
let effs = serialize(noncePlaceholder: String(repeating: "f", count: nonceLength)),
|
||||
zeros.count == effs.count
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var firstDiff = -1
|
||||
var lastDiff = -1
|
||||
for index in 0..<zeros.count where zeros[index] != effs[index] {
|
||||
if firstDiff < 0 { firstDiff = index }
|
||||
lastDiff = index
|
||||
}
|
||||
guard firstDiff >= 0, lastDiff - firstDiff + 1 == nonceLength else { return nil }
|
||||
return (zeros, firstDiff..<(firstDiff + nonceLength))
|
||||
}
|
||||
}
|
||||
@@ -39,22 +39,23 @@ struct NostrProtocol {
|
||||
content: content
|
||||
)
|
||||
|
||||
// 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)
|
||||
// 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()
|
||||
let sealedEvent = try createSeal(
|
||||
rumor: rumor,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: ephemeralKey
|
||||
senderKey: senderKey
|
||||
)
|
||||
|
||||
// 4. Gift wrap the sealed event (encrypt to recipient again)
|
||||
|
||||
// 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap
|
||||
// layer hides the sender's identity from relays; createGiftWrap mints
|
||||
// its own ephemeral key internally).
|
||||
let giftWrap = try createGiftWrap(
|
||||
seal: sealedEvent,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: ephemeralKey
|
||||
recipientPubkey: recipientPubkey
|
||||
)
|
||||
|
||||
// Created gift wrap
|
||||
@@ -84,7 +85,15 @@ struct NostrProtocol {
|
||||
throw error
|
||||
}
|
||||
|
||||
// 2. Open the seal
|
||||
// 2. Authenticate the seal. The seal MUST be signed by the sender's real
|
||||
// identity key (NIP-17); without this check a DM is forgeable by anyone
|
||||
// who knows the recipient's npub. Verify the seal's own signature.
|
||||
guard seal.isValidSignature() else {
|
||||
SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session)
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
// 3. Open the seal
|
||||
let rumor: NostrEvent
|
||||
do {
|
||||
rumor = try openSeal(
|
||||
@@ -96,10 +105,63 @@ struct NostrProtocol {
|
||||
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
|
||||
throw error
|
||||
}
|
||||
|
||||
return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at)
|
||||
|
||||
// 4. The sender claimed inside the rumor must match the key that actually
|
||||
// signed the seal, otherwise the sender field is unauthenticated and
|
||||
// spoofable.
|
||||
guard seal.pubkey == rumor.pubkey else {
|
||||
SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session)
|
||||
throw NostrError.invalidEvent
|
||||
}
|
||||
|
||||
// Return the seal signer's pubkey as the authenticated sender.
|
||||
return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func createPrivateMessageWithInvalidSealSignatureForTesting(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
let rumor = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .dm,
|
||||
tags: [],
|
||||
content: content
|
||||
)
|
||||
var seal = try createSeal(
|
||||
rumor: rumor,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: senderIdentity.schnorrSigningKey()
|
||||
)
|
||||
seal.sig = String(repeating: "0", count: 128)
|
||||
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
||||
}
|
||||
|
||||
static func createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
|
||||
content: String,
|
||||
recipientPubkey: String,
|
||||
rumorIdentity: NostrIdentity,
|
||||
sealSignerIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
let rumor = NostrEvent(
|
||||
pubkey: rumorIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .dm,
|
||||
tags: [],
|
||||
content: content
|
||||
)
|
||||
let seal = try createSeal(
|
||||
rumor: rumor,
|
||||
recipientPubkey: recipientPubkey,
|
||||
senderKey: sealSignerIdentity.schnorrSigningKey()
|
||||
)
|
||||
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Create a geohash-scoped ephemeral public message (kind 20000)
|
||||
static func createEphemeralGeohashEvent(
|
||||
content: String,
|
||||
@@ -108,6 +170,63 @@ struct NostrProtocol {
|
||||
nickname: String? = nil,
|
||||
teleported: Bool = false
|
||||
) throws -> NostrEvent {
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported),
|
||||
content: content
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a kind-20000 geohash message carrying a NIP-13 proof-of-work
|
||||
/// nonce tag (see `NostrPoW`). Mining runs off the calling actor and is
|
||||
/// bounded by `NostrPoW.miningTimeCap`; when the cap hits (or the
|
||||
/// surrounding task is cancelled) the event ships at the highest
|
||||
/// committed difficulty still met, and if mining is impossible it ships
|
||||
/// unmined — sending is never blocked.
|
||||
static func createMinedEphemeralGeohashEvent(
|
||||
content: String,
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
nickname: String? = nil,
|
||||
teleported: Bool = false,
|
||||
powTargetBits: Int = NostrPoW.targetBits
|
||||
) async throws -> NostrEvent {
|
||||
var tags = ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported)
|
||||
// Fix created_at up front: the mined nonce commits to the full
|
||||
// serialized event, so the signed event must reuse the exact value.
|
||||
let createdAt = Int(Date().timeIntervalSince1970)
|
||||
if let nonceTag = await NostrPoW.mineNonceTag(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: createdAt,
|
||||
kind: EventKind.ephemeralEvent.rawValue,
|
||||
tags: tags,
|
||||
content: content,
|
||||
targetBits: powTargetBits
|
||||
) {
|
||||
tags.append(nonceTag)
|
||||
}
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)),
|
||||
kind: .ephemeralEvent,
|
||||
tags: tags,
|
||||
content: content
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Tags for a kind-20000 geohash message (shared by the plain and mined
|
||||
/// variants).
|
||||
private static func ephemeralGeohashTags(
|
||||
geohash: String,
|
||||
nickname: String?,
|
||||
teleported: Bool
|
||||
) -> [[String]] {
|
||||
var tags = [["g", geohash]]
|
||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||
tags.append(["n", nickname])
|
||||
@@ -115,15 +234,7 @@ struct NostrProtocol {
|
||||
if teleported {
|
||||
tags.append(["t", "teleport"])
|
||||
}
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: tags,
|
||||
content: content
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
return tags
|
||||
}
|
||||
|
||||
/// Create a geohash presence heartbeat (kind 20001)
|
||||
@@ -195,10 +306,9 @@ struct NostrProtocol {
|
||||
|
||||
private static func createGiftWrap(
|
||||
seal: NostrEvent,
|
||||
recipientPubkey: String,
|
||||
senderKey: P256K.Schnorr.PrivateKey // This is the ephemeral key used for the seal
|
||||
recipientPubkey: String
|
||||
) throws -> NostrEvent {
|
||||
|
||||
|
||||
let sealJSON = try seal.jsonString()
|
||||
|
||||
// Create new ephemeral key for gift wrap
|
||||
@@ -587,7 +697,7 @@ private extension NostrProtocol {
|
||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||
salt: Data(),
|
||||
info: "nip44-v2".data(using: .utf8)!,
|
||||
info: Data("nip44-v2".utf8),
|
||||
outputByteCount: 32
|
||||
)
|
||||
return derivedKey.withUnsafeBytes { Data($0) }
|
||||
|
||||
@@ -66,6 +66,9 @@ 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 {
|
||||
@@ -93,7 +96,8 @@ private extension NostrRelayManagerDependencies {
|
||||
scheduleAfter: { delay, action in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||
},
|
||||
now: Date.init
|
||||
now: Date.init,
|
||||
jitterUnit: { Double.random(in: 0..<1) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +137,10 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
@Published private(set) var relays: [Relay] = []
|
||||
@Published private(set) var isConnected = false
|
||||
/// Whether a relay that carries private messages is connected. DMs
|
||||
/// target the default (gift-wrap-capable) relay set, so a connected
|
||||
/// geohash/custom relay alone must not count — sends would still queue.
|
||||
@Published private(set) var isDMRelayConnected = false
|
||||
|
||||
private let dependencies: NostrRelayManagerDependencies
|
||||
private var allowDefaultRelays: Bool = false
|
||||
@@ -140,13 +148,34 @@ 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
|
||||
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
|
||||
// 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 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
|
||||
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 {
|
||||
@@ -159,9 +188,10 @@ final class NostrRelayManager: ObservableObject {
|
||||
private struct EOSETracker {
|
||||
var pendingRelays: Set<String>
|
||||
var callback: () -> Void
|
||||
var timer: Timer?
|
||||
let epoch: Int
|
||||
}
|
||||
private var eoseTrackers: [String: EOSETracker] = [:]
|
||||
private var eoseTrackerEpoch = 0
|
||||
private var pendingEOSECallbacks: [String: () -> Void] = [:]
|
||||
|
||||
// Message queue for reliability
|
||||
@@ -172,6 +202,9 @@ 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() }
|
||||
|
||||
@@ -252,19 +285,78 @@ final class NostrRelayManager: ObservableObject {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeAll()
|
||||
// Clear known subscriptions and any queued subs since connections are gone
|
||||
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).
|
||||
subscriptions.removeAll()
|
||||
pendingSubscriptions.removeAll()
|
||||
subscriptionRequestState.removeAll()
|
||||
pendingEOSECallbacks.removeAll()
|
||||
for (_, tracker) in eoseTrackers {
|
||||
tracker.timer?.invalidate()
|
||||
}
|
||||
// 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]) {
|
||||
@@ -285,11 +377,14 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Global network policy gate
|
||||
guard dependencies.activationAllowed() else { return }
|
||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||
// 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) }
|
||||
}
|
||||
// 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)
|
||||
return
|
||||
}
|
||||
let requestedRelays = relayUrls ?? Self.defaultRelays
|
||||
@@ -307,9 +402,29 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
if !stillPending.isEmpty {
|
||||
messageQueueLock.lock()
|
||||
messageQueue.append(PendingSend(event: event, pendingRelays: stillPending))
|
||||
messageQueueLock.unlock()
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,16 +519,14 @@ final class NostrRelayManager: ObservableObject {
|
||||
existingSet.insert(url)
|
||||
}
|
||||
for url in urls {
|
||||
var map = self.pendingSubscriptions[url] ?? [:]
|
||||
map[id] = messageString
|
||||
self.pendingSubscriptions[url] = map
|
||||
queuePendingSubscription(id: id, messageString: messageString, for: url)
|
||||
}
|
||||
// Initialize EOSE tracking if requested
|
||||
if let onEOSE = onEOSE {
|
||||
if urls.isEmpty {
|
||||
onEOSE()
|
||||
} else if shouldWaitForTorBeforeConnecting {
|
||||
pendingEOSECallbacks[id] = onEOSE
|
||||
parkEOSECallbackUntilTorReady(id: id, callback: onEOSE)
|
||||
} else {
|
||||
startEOSETracking(id: id, relayURLs: Set(urls), callback: onEOSE)
|
||||
}
|
||||
@@ -486,11 +599,12 @@ 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[id]?.timer?.invalidate()
|
||||
eoseTrackers.removeValue(forKey: id)
|
||||
for url in Array(pendingSubscriptions.keys) {
|
||||
pendingSubscriptions[url]?.removeValue(forKey: id)
|
||||
@@ -521,6 +635,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -561,37 +676,135 @@ final class NostrRelayManager: ObservableObject {
|
||||
self.awaitingTorForConnections = false
|
||||
|
||||
guard ready else {
|
||||
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
||||
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] == requestState.messageString ||
|
||||
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) {
|
||||
eoseTrackers[id]?.timer?.invalidate()
|
||||
var tracker = EOSETracker(pendingRelays: relayURLs, callback: callback, timer: nil)
|
||||
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.
|
||||
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
if let tracker = self.eoseTrackers[id] {
|
||||
tracker.timer?.invalidate()
|
||||
self.eoseTrackers.removeValue(forKey: id)
|
||||
callback()
|
||||
}
|
||||
guard let tracker = self.eoseTrackers[id], tracker.epoch == epoch else { return }
|
||||
self.eoseTrackers.removeValue(forKey: id)
|
||||
tracker.callback()
|
||||
}
|
||||
}
|
||||
eoseTrackers[id] = tracker
|
||||
}
|
||||
|
||||
private func startPendingEOSETrackingIfNeeded(id: String) {
|
||||
@@ -608,6 +821,53 @@ final class NostrRelayManager: ObservableObject {
|
||||
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
|
||||
@@ -665,26 +925,35 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Send any queued subscriptions for a relay that just connected.
|
||||
/// 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.
|
||||
private func flushPendingSubscriptions(for relayUrl: String) {
|
||||
guard let map = pendingSubscriptions[relayUrl], !map.isEmpty else { return }
|
||||
guard let connection = connections[relayUrl] else { return }
|
||||
for (id, messageString) in map {
|
||||
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 {
|
||||
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
|
||||
startPendingEOSETrackingIfNeeded(id: 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
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pendingSubscriptions[relayUrl] = nil
|
||||
}
|
||||
|
||||
private func receiveMessage(from task: NostrRelayConnectionProtocol, relayUrl: String) {
|
||||
@@ -722,12 +991,26 @@ 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 {
|
||||
@@ -737,7 +1020,6 @@ 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 {
|
||||
@@ -809,19 +1091,35 @@ 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) }
|
||||
}
|
||||
|
||||
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
|
||||
/// 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) {
|
||||
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()
|
||||
@@ -852,16 +1150,26 @@ final class NostrRelayManager: ObservableObject {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate backoff interval
|
||||
let backoffInterval = min(
|
||||
// Calculate backoff interval with ±jitterRatio random jitter so relays
|
||||
// that dropped together don't all reconnect at the same instant.
|
||||
let baseBackoffInterval = 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
|
||||
@@ -919,6 +1227,31 @@ 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)
|
||||
}
|
||||
@@ -943,6 +1276,13 @@ 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 {
|
||||
@@ -974,8 +1314,7 @@ 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),
|
||||
event.isValidSignature() {
|
||||
let event = try? NostrEvent(from: eventDict) {
|
||||
self = .event(subId: subId, event: event)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -132,4 +132,3 @@ private extension Data {
|
||||
replaceSubrange(offset..<(offset+4), with: bytes)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,12 +28,14 @@ struct BitchatFilePacket {
|
||||
|
||||
/// Encodes the packet using v2 canonical TLVs (4-byte FILE_SIZE, 4-byte CONTENT length).
|
||||
/// Returns `nil` when fields exceed protocol limits (e.g., content > UInt32.max).
|
||||
func encode() -> Data? {
|
||||
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
|
||||
/// `FileTransferLimits.maxWifiBulkPayloadBytes`.
|
||||
func encode(limit: Int = FileTransferLimits.maxPayloadBytes) -> Data? {
|
||||
let resolvedSize = fileSize ?? UInt64(content.count)
|
||||
guard resolvedSize <= UInt64(UInt32.max) else { return nil }
|
||||
guard resolvedSize <= UInt64(FileTransferLimits.maxPayloadBytes) else { return nil }
|
||||
guard resolvedSize <= UInt64(limit) else { return nil }
|
||||
guard content.count <= Int(UInt32.max) else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
|
||||
|
||||
func appendBE<T: FixedWidthInteger>(_ value: T, into data: inout Data) {
|
||||
var big = value.bigEndian
|
||||
@@ -66,7 +68,9 @@ struct BitchatFilePacket {
|
||||
}
|
||||
|
||||
/// Decodes TLV payloads, tolerating legacy encodings (FILE_SIZE len=8, CONTENT len=2) when possible.
|
||||
static func decode(_ data: Data) -> BitchatFilePacket? {
|
||||
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk transfers pass
|
||||
/// the (smaller of the) accepted-offer size and the Wi-Fi bulk ceiling.
|
||||
static func decode(_ data: Data, limit: Int = FileTransferLimits.maxPayloadBytes) -> BitchatFilePacket? {
|
||||
var cursor = data.startIndex
|
||||
let end = data.endIndex
|
||||
|
||||
@@ -126,7 +130,7 @@ struct BitchatFilePacket {
|
||||
for byte in value {
|
||||
size = (size << 8) | UInt64(byte)
|
||||
}
|
||||
if size > UInt64(FileTransferLimits.maxPayloadBytes) {
|
||||
if size > UInt64(limit) {
|
||||
return nil
|
||||
}
|
||||
fileSize = size
|
||||
@@ -135,7 +139,7 @@ struct BitchatFilePacket {
|
||||
mimeType = String(data: Data(value), encoding: .utf8)
|
||||
case .content:
|
||||
let proposedSize = content.count + value.count
|
||||
if proposedSize > FileTransferLimits.maxPayloadBytes {
|
||||
if proposedSize > limit {
|
||||
return nil
|
||||
}
|
||||
content.append(contentsOf: value)
|
||||
@@ -145,7 +149,7 @@ struct BitchatFilePacket {
|
||||
}
|
||||
|
||||
guard !content.isEmpty else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count) else { return nil }
|
||||
guard FileTransferLimits.isValidPayload(content.count, limit: limit) else { return nil }
|
||||
return BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
fileSize: fileSize ?? UInt64(content.count),
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
/// - Efficient binary message encoding
|
||||
/// - Message fragmentation for large payloads
|
||||
/// - TTL-based routing for mesh networks
|
||||
/// - Privacy features like padding and timing obfuscation
|
||||
/// - Privacy features: message padding and randomized relay jitter
|
||||
/// - Integration points for end-to-end encryption
|
||||
///
|
||||
/// ## Protocol Design
|
||||
@@ -38,18 +38,20 @@
|
||||
/// 7. **Decoding**: Binary data parsed back to message objects
|
||||
///
|
||||
/// ## Security Considerations
|
||||
/// - Message padding obscures actual content length
|
||||
/// - Timing obfuscation prevents traffic analysis
|
||||
/// - Message padding (to 256/512/1024/2048-byte blocks) obscures actual content length
|
||||
/// - Randomized relay jitter reduces the traffic-analysis signal; there is no
|
||||
/// cover traffic or per-message timing obfuscation
|
||||
/// - Integration with Noise Protocol for E2E encryption
|
||||
/// - No persistent identifiers in protocol headers
|
||||
///
|
||||
/// ## Message Types
|
||||
/// - **Announce/Leave**: Peer presence notifications
|
||||
/// - **Message**: User chat messages (broadcast or directed)
|
||||
/// - **Message**: Public chat messages
|
||||
/// - **Fragment**: Multi-part message handling
|
||||
/// - **Delivery/Read**: Message acknowledgments
|
||||
/// - **Noise**: Encrypted channel establishment
|
||||
/// - **Version**: Protocol version negotiation
|
||||
/// - **NoiseHandshake/NoiseEncrypted**: Encrypted channel establishment and
|
||||
/// all private payloads (messages, delivery acks, read receipts)
|
||||
/// - **CourierEnvelope**: Sealed store-and-forward mail
|
||||
/// - **RequestSync/FileTransfer**: Gossip history sync and media transfer
|
||||
///
|
||||
/// ## Future Extensions
|
||||
/// The protocol is designed to be extensible:
|
||||
@@ -72,17 +74,30 @@ 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
|
||||
// Private groups
|
||||
case groupInvite = 0x06 // Creator-signed group state (invite)
|
||||
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
|
||||
// Verification (QR-based OOB binding)
|
||||
case verifyChallenge = 0x10 // Verification challenge
|
||||
case verifyResponse = 0x11 // Verification response
|
||||
|
||||
// Transitive verification (web of trust)
|
||||
case vouch = 0x12 // Batch of vouch attestations
|
||||
|
||||
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 .groupInvite: return "groupInvite"
|
||||
case .groupKeyUpdate: return "groupKeyUpdate"
|
||||
case .verifyChallenge: return "verifyChallenge"
|
||||
case .verifyResponse: return "verifyResponse"
|
||||
case .vouch: return "vouch"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,6 +129,9 @@ protocol BitchatDelegate: AnyObject {
|
||||
// Low-level events for better separation of concerns
|
||||
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||
|
||||
// Encrypted group broadcast (opaque envelope; decrypted by the group coordinator)
|
||||
func didReceiveGroupMessage(payload: Data, timestamp: Date)
|
||||
|
||||
// Bluetooth state updates for user notifications
|
||||
func didUpdateBluetoothState(_ state: CBManagerState)
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||
@@ -133,6 +151,10 @@ extension BitchatDelegate {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceiveGroupMessage(payload: Data, timestamp: Date) {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
|
||||
// Default empty implementation
|
||||
}
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
//
|
||||
// BoardPackets.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
// MARK: - Board wire format (MessageType.boardPost payloads)
|
||||
//
|
||||
// TLV layout (type u8, length u16 big-endian, value), matching REQUEST_SYNC:
|
||||
// - 0x01: kind (u8) — 0x01 post, 0x02 tombstone
|
||||
// - 0x02: postID (16B random)
|
||||
// - 0x03: geohash (UTF-8, empty = mesh-local board, max 12 chars)
|
||||
// - 0x04: content (UTF-8, 1...512 bytes) [post]
|
||||
// - 0x05: authorSigningKey (32B Ed25519 public key)
|
||||
// - 0x06: authorNickname (UTF-8, max 64 bytes)
|
||||
// - 0x07: createdAt (u64 big-endian, ms) [post]
|
||||
// - 0x08: expiresAt (u64 big-endian, ms, max 7 days after createdAt) [post]
|
||||
// - 0x09: flags (u8, bit0 = urgent) [post]
|
||||
// - 0x0A: signature (64B Ed25519)
|
||||
// - 0x0B: deletedAt (u64 big-endian, ms) [tombstone]
|
||||
// Unknown TLVs are skipped for forward compatibility.
|
||||
|
||||
enum BoardWireConstants {
|
||||
static let postIDLength = 16
|
||||
static let signingKeyLength = 32
|
||||
static let signatureLength = 64
|
||||
static let contentMaxBytes = 512
|
||||
static let nicknameMaxBytes = 64
|
||||
static let geohashMaxLength = 12
|
||||
/// Posts may live at most 7 days past their creation timestamp.
|
||||
static let maxLifetimeMs: UInt64 = 7 * 24 * 60 * 60 * 1000
|
||||
static let postSigningContext = "bitchat-board-v1"
|
||||
static let tombstoneSigningContext = "bitchat-board-del-v1"
|
||||
static let geohashAlphabet = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
}
|
||||
|
||||
private enum BoardTLVType: UInt8 {
|
||||
case kind = 0x01
|
||||
case postID = 0x02
|
||||
case geohash = 0x03
|
||||
case content = 0x04
|
||||
case authorSigningKey = 0x05
|
||||
case authorNickname = 0x06
|
||||
case createdAt = 0x07
|
||||
case expiresAt = 0x08
|
||||
case flags = 0x09
|
||||
case signature = 0x0A
|
||||
case deletedAt = 0x0B
|
||||
}
|
||||
|
||||
private enum BoardWireKind: UInt8 {
|
||||
case post = 0x01
|
||||
case tombstone = 0x02
|
||||
}
|
||||
|
||||
/// A signed, persistent bulletin-board notice.
|
||||
struct BoardPostPacket: Equatable {
|
||||
let postID: Data
|
||||
/// Empty string scopes the post to the mesh-local board.
|
||||
let geohash: String
|
||||
let content: String
|
||||
let authorSigningKey: Data
|
||||
let authorNickname: String
|
||||
let createdAt: UInt64
|
||||
let expiresAt: UInt64
|
||||
let flags: UInt8
|
||||
let signature: Data
|
||||
|
||||
static let urgentFlag: UInt8 = 0x01
|
||||
|
||||
var isUrgent: Bool { flags & Self.urgentFlag != 0 }
|
||||
|
||||
/// Canonical bytes covered by the Ed25519 signature. Variable-length
|
||||
/// fields are length-prefixed so no two field combinations can collide.
|
||||
static func signingBytes(
|
||||
postID: Data,
|
||||
geohash: String,
|
||||
content: String,
|
||||
authorSigningKey: Data,
|
||||
authorNickname: String,
|
||||
createdAt: UInt64,
|
||||
expiresAt: UInt64,
|
||||
flags: UInt8
|
||||
) -> Data {
|
||||
var out = Data()
|
||||
BoardWireEncoding.appendContext(BoardWireConstants.postSigningContext, to: &out)
|
||||
out.append(postID)
|
||||
BoardWireEncoding.appendLengthPrefixed(Data(geohash.utf8), to: &out)
|
||||
BoardWireEncoding.appendLengthPrefixed(Data(content.utf8), to: &out)
|
||||
out.append(authorSigningKey)
|
||||
BoardWireEncoding.appendLengthPrefixed(Data(authorNickname.utf8), to: &out)
|
||||
BoardWireEncoding.appendUInt64(createdAt, to: &out)
|
||||
BoardWireEncoding.appendUInt64(expiresAt, to: &out)
|
||||
out.append(flags)
|
||||
return out
|
||||
}
|
||||
|
||||
var signingBytes: Data {
|
||||
Self.signingBytes(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: authorSigningKey,
|
||||
authorNickname: authorNickname,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: flags
|
||||
)
|
||||
}
|
||||
|
||||
func verifySignature() -> Bool {
|
||||
BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// A signed deletion marker. Only the author's key can produce one; receivers
|
||||
/// keep it until the post's original expiry so the delete outruns the post.
|
||||
struct BoardTombstonePacket: Equatable {
|
||||
let postID: Data
|
||||
let authorSigningKey: Data
|
||||
let deletedAt: UInt64
|
||||
let signature: Data
|
||||
|
||||
static func signingBytes(postID: Data, deletedAt: UInt64) -> Data {
|
||||
var out = Data()
|
||||
BoardWireEncoding.appendContext(BoardWireConstants.tombstoneSigningContext, to: &out)
|
||||
out.append(postID)
|
||||
BoardWireEncoding.appendUInt64(deletedAt, to: &out)
|
||||
return out
|
||||
}
|
||||
|
||||
var signingBytes: Data {
|
||||
Self.signingBytes(postID: postID, deletedAt: deletedAt)
|
||||
}
|
||||
|
||||
func verifySignature() -> Bool {
|
||||
BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoded board payload: either a live post or a tombstone.
|
||||
enum BoardWire: Equatable {
|
||||
case post(BoardPostPacket)
|
||||
case tombstone(BoardTombstonePacket)
|
||||
|
||||
func encode() -> Data {
|
||||
var out = Data()
|
||||
func putTLV(_ t: BoardTLVType, _ v: Data) {
|
||||
out.append(t.rawValue)
|
||||
let len = UInt16(v.count)
|
||||
out.append(UInt8((len >> 8) & 0xFF))
|
||||
out.append(UInt8(len & 0xFF))
|
||||
out.append(v)
|
||||
}
|
||||
switch self {
|
||||
case .post(let post):
|
||||
putTLV(.kind, Data([BoardWireKind.post.rawValue]))
|
||||
putTLV(.postID, post.postID)
|
||||
putTLV(.geohash, Data(post.geohash.utf8))
|
||||
putTLV(.content, Data(post.content.utf8))
|
||||
putTLV(.authorSigningKey, post.authorSigningKey)
|
||||
putTLV(.authorNickname, Data(post.authorNickname.utf8))
|
||||
putTLV(.createdAt, BoardWireEncoding.uint64Data(post.createdAt))
|
||||
putTLV(.expiresAt, BoardWireEncoding.uint64Data(post.expiresAt))
|
||||
putTLV(.flags, Data([post.flags]))
|
||||
putTLV(.signature, post.signature)
|
||||
case .tombstone(let tombstone):
|
||||
putTLV(.kind, Data([BoardWireKind.tombstone.rawValue]))
|
||||
putTLV(.postID, tombstone.postID)
|
||||
putTLV(.authorSigningKey, tombstone.authorSigningKey)
|
||||
putTLV(.deletedAt, BoardWireEncoding.uint64Data(tombstone.deletedAt))
|
||||
putTLV(.signature, tombstone.signature)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// Structural decode; the caller must still verify the signature before
|
||||
/// ingesting (`verifySignature()`).
|
||||
static func decode(from data: Data) -> BoardWire? {
|
||||
var off = data.startIndex
|
||||
var kind: BoardWireKind?
|
||||
var postID: Data?
|
||||
var geohash: String?
|
||||
var content: String?
|
||||
var contentBytes = 0
|
||||
var authorSigningKey: Data?
|
||||
var authorNickname: String?
|
||||
var nicknameBytes = 0
|
||||
var createdAt: UInt64?
|
||||
var expiresAt: UInt64?
|
||||
var flags: UInt8?
|
||||
var signature: Data?
|
||||
var deletedAt: UInt64?
|
||||
|
||||
while off + 3 <= data.endIndex {
|
||||
let t = data[off]; off += 1
|
||||
let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2
|
||||
guard off + len <= data.endIndex else { return nil }
|
||||
let v = data.subdata(in: off..<(off + len)); off += len
|
||||
switch BoardTLVType(rawValue: t) {
|
||||
case .kind:
|
||||
guard v.count == 1 else { return nil }
|
||||
kind = BoardWireKind(rawValue: v[v.startIndex])
|
||||
case .postID:
|
||||
guard v.count == BoardWireConstants.postIDLength else { return nil }
|
||||
postID = v
|
||||
case .geohash:
|
||||
guard v.count <= BoardWireConstants.geohashMaxLength else { return nil }
|
||||
geohash = String(data: v, encoding: .utf8)
|
||||
case .content:
|
||||
guard v.count <= BoardWireConstants.contentMaxBytes else { return nil }
|
||||
contentBytes = v.count
|
||||
content = String(data: v, encoding: .utf8)
|
||||
case .authorSigningKey:
|
||||
guard v.count == BoardWireConstants.signingKeyLength else { return nil }
|
||||
authorSigningKey = v
|
||||
case .authorNickname:
|
||||
guard v.count <= BoardWireConstants.nicknameMaxBytes else { return nil }
|
||||
nicknameBytes = v.count
|
||||
authorNickname = String(data: v, encoding: .utf8)
|
||||
case .createdAt:
|
||||
createdAt = BoardWireEncoding.uint64(from: v)
|
||||
case .expiresAt:
|
||||
expiresAt = BoardWireEncoding.uint64(from: v)
|
||||
case .flags:
|
||||
guard v.count == 1 else { return nil }
|
||||
flags = v[v.startIndex]
|
||||
case .signature:
|
||||
guard v.count == BoardWireConstants.signatureLength else { return nil }
|
||||
signature = v
|
||||
case .deletedAt:
|
||||
deletedAt = BoardWireEncoding.uint64(from: v)
|
||||
case nil:
|
||||
continue // forward compatible; ignore unknown TLVs
|
||||
}
|
||||
}
|
||||
|
||||
guard let postID, let authorSigningKey, let signature else { return nil }
|
||||
|
||||
switch kind {
|
||||
case .post:
|
||||
guard let geohash, let content, let authorNickname,
|
||||
let createdAt, let expiresAt, let flags,
|
||||
contentBytes >= 1,
|
||||
nicknameBytes <= BoardWireConstants.nicknameMaxBytes,
|
||||
isValidGeohashField(geohash),
|
||||
expiresAt > createdAt,
|
||||
expiresAt - createdAt <= BoardWireConstants.maxLifetimeMs else {
|
||||
return nil
|
||||
}
|
||||
return .post(BoardPostPacket(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: content,
|
||||
authorSigningKey: authorSigningKey,
|
||||
authorNickname: authorNickname,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: flags,
|
||||
signature: signature
|
||||
))
|
||||
case .tombstone:
|
||||
guard let deletedAt else { return nil }
|
||||
return .tombstone(BoardTombstonePacket(
|
||||
postID: postID,
|
||||
authorSigningKey: authorSigningKey,
|
||||
deletedAt: deletedAt,
|
||||
signature: signature
|
||||
))
|
||||
case nil:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func verifySignature() -> Bool {
|
||||
switch self {
|
||||
case .post(let post): return post.verifySignature()
|
||||
case .tombstone(let tombstone): return tombstone.verifySignature()
|
||||
}
|
||||
}
|
||||
|
||||
/// Cheap TLV peek for relay policy: is this payload an urgent post?
|
||||
/// Avoids a full decode on the hot relay path.
|
||||
static func urgentFlag(in data: Data) -> Bool {
|
||||
var off = data.startIndex
|
||||
while off + 3 <= data.endIndex {
|
||||
let t = data[off]; off += 1
|
||||
let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2
|
||||
guard off + len <= data.endIndex else { return false }
|
||||
if t == BoardTLVType.flags.rawValue, len == 1 {
|
||||
return data[off] & BoardPostPacket.urgentFlag != 0
|
||||
}
|
||||
off += len
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/// Empty geohash = mesh-local board; otherwise 1-12 chars of the geohash
|
||||
/// base32 alphabet.
|
||||
private static func isValidGeohashField(_ geohash: String) -> Bool {
|
||||
geohash.isEmpty || geohash.allSatisfy { BoardWireConstants.geohashAlphabet.contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
enum BoardWireEncoding {
|
||||
static func appendContext(_ context: String, to out: inout Data) {
|
||||
let bytes = Data(context.utf8)
|
||||
out.append(UInt8(min(bytes.count, 255)))
|
||||
out.append(bytes.prefix(255))
|
||||
}
|
||||
|
||||
static func appendLengthPrefixed(_ value: Data, to out: inout Data) {
|
||||
let len = UInt16(min(value.count, Int(UInt16.max)))
|
||||
out.append(UInt8((len >> 8) & 0xFF))
|
||||
out.append(UInt8(len & 0xFF))
|
||||
out.append(value.prefix(Int(UInt16.max)))
|
||||
}
|
||||
|
||||
static func appendUInt64(_ value: UInt64, to out: inout Data) {
|
||||
var be = value.bigEndian
|
||||
withUnsafeBytes(of: &be) { out.append(contentsOf: $0) }
|
||||
}
|
||||
|
||||
static func uint64Data(_ value: UInt64) -> Data {
|
||||
var out = Data()
|
||||
appendUInt64(value, to: &out)
|
||||
return out
|
||||
}
|
||||
|
||||
static func uint64(from data: Data) -> UInt64? {
|
||||
guard data.count == 8 else { return nil }
|
||||
var value: UInt64 = 0
|
||||
for byte in data { value = (value << 8) | UInt64(byte) }
|
||||
return value
|
||||
}
|
||||
|
||||
static func verify(signature: Data, over message: Data, publicKey: Data) -> Bool {
|
||||
guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else {
|
||||
return false
|
||||
}
|
||||
return key.isValidSignature(signature, for: message)
|
||||
}
|
||||
}
|
||||
@@ -18,23 +18,23 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
||||
case .city: return 5
|
||||
case .province: return 4
|
||||
case .region: return 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .building:
|
||||
return String(localized: "location_levels.building", comment: "Name for building-level location channel")
|
||||
return String(localized: "location_levels.building", defaultValue: "building", comment: "Name for building-level location channel")
|
||||
case .block:
|
||||
return String(localized: "location_levels.block", comment: "Name for block-level location channel")
|
||||
return String(localized: "location_levels.block", defaultValue: "block", comment: "Name for block-level location channel")
|
||||
case .neighborhood:
|
||||
return String(localized: "location_levels.neighborhood", comment: "Name for neighborhood-level location channel")
|
||||
return String(localized: "location_levels.neighborhood", defaultValue: "neighborhood", comment: "Name for neighborhood-level location channel")
|
||||
case .city:
|
||||
return String(localized: "location_levels.city", comment: "Name for city-level location channel")
|
||||
return String(localized: "location_levels.city", defaultValue: "city", comment: "Name for city-level location channel")
|
||||
case .province:
|
||||
return String(localized: "location_levels.province", comment: "Name for province-level location channel")
|
||||
return String(localized: "location_levels.province", defaultValue: "province", comment: "Name for province-level location channel")
|
||||
case .region:
|
||||
return String(localized: "location_levels.region", comment: "Name for region-level location channel")
|
||||
return String(localized: "location_levels.region", defaultValue: "region", comment: "Name for region-level location channel")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//
|
||||
// NostrCarrierPacket.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Wire payload for `MessageType.nostrCarrier` (0x28): a complete, signed
|
||||
/// Nostr event ferried over the mesh between a mesh-only peer and an
|
||||
/// internet gateway peer.
|
||||
///
|
||||
/// - `toGateway` rides a DIRECTED packet (recipientID = the gateway peer):
|
||||
/// a mesh-only sender asks the gateway to publish its locally signed
|
||||
/// geohash event to Nostr relays.
|
||||
/// - `fromGateway` rides a BROADCAST packet (default TTL): the gateway
|
||||
/// rebroadcasts inbound relay events so mesh-only peers see the channel.
|
||||
///
|
||||
/// The carried event is public geohash chat — already plaintext on Nostr —
|
||||
/// so the carrier adds no encryption. It IS signed by the originator's
|
||||
/// per-geohash identity, so neither the gateway nor any mesh relay can forge
|
||||
/// or alter it undetected: gateways and receivers verify the Schnorr
|
||||
/// signature before acting on it.
|
||||
///
|
||||
/// TLV encoding with 2-byte big-endian lengths (the event JSON exceeds the
|
||||
/// 1-byte TLV range used by smaller packets). Unknown TLV types are skipped
|
||||
/// for forward compatibility.
|
||||
struct NostrCarrierPacket: Equatable {
|
||||
enum Direction: UInt8 {
|
||||
case toGateway = 0x01
|
||||
case fromGateway = 0x02
|
||||
}
|
||||
|
||||
let direction: Direction
|
||||
let geohash: String
|
||||
/// Complete signed Nostr event JSON (id, pubkey, created_at, kind, tags,
|
||||
/// content, sig).
|
||||
let eventJSON: Data
|
||||
|
||||
/// BLE airtime cap for a carried event.
|
||||
static let maxEventJSONBytes = 16 * 1024
|
||||
static let maxGeohashLength = 12
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case direction = 0x01
|
||||
case geohash = 0x02
|
||||
case eventJSON = 0x03
|
||||
}
|
||||
|
||||
init?(direction: Direction, geohash: String, eventJSON: Data) {
|
||||
let geohashBytes = Data(geohash.utf8)
|
||||
guard !geohashBytes.isEmpty,
|
||||
geohashBytes.count <= Self.maxGeohashLength,
|
||||
!eventJSON.isEmpty,
|
||||
eventJSON.count <= Self.maxEventJSONBytes else {
|
||||
return nil
|
||||
}
|
||||
self.direction = direction
|
||||
self.geohash = geohash
|
||||
self.eventJSON = eventJSON
|
||||
}
|
||||
|
||||
init?(direction: Direction, geohash: String, event: NostrEvent) {
|
||||
guard let json = try? event.jsonString(), !json.isEmpty else { return nil }
|
||||
self.init(direction: direction, geohash: geohash, eventJSON: Data(json.utf8))
|
||||
}
|
||||
|
||||
/// Decodes the carried event. Callers MUST still verify
|
||||
/// `event.isValidSignature()` before publishing or displaying it.
|
||||
func event() -> NostrEvent? {
|
||||
guard let dict = try? JSONSerialization.jsonObject(with: eventJSON) as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
return try? NostrEvent(from: dict)
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
var data = Data()
|
||||
data.reserveCapacity(eventJSON.count + geohash.utf8.count + 12)
|
||||
|
||||
func appendTLV(_ type: TLVType, _ value: Data) {
|
||||
data.append(type.rawValue)
|
||||
data.append(UInt8((value.count >> 8) & 0xFF))
|
||||
data.append(UInt8(value.count & 0xFF))
|
||||
data.append(value)
|
||||
}
|
||||
|
||||
appendTLV(.direction, Data([direction.rawValue]))
|
||||
appendTLV(.geohash, Data(geohash.utf8))
|
||||
appendTLV(.eventJSON, eventJSON)
|
||||
return data
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) -> NostrCarrierPacket? {
|
||||
// Defensive slice re-base (Data slices keep parent indices).
|
||||
let data = Data(data)
|
||||
var offset = 0
|
||||
var direction: Direction?
|
||||
var geohash: String?
|
||||
var eventJSON: Data?
|
||||
|
||||
while offset + 3 <= data.count {
|
||||
let typeRaw = data[offset]
|
||||
let length = (Int(data[offset + 1]) << 8) | Int(data[offset + 2])
|
||||
offset += 3
|
||||
guard offset + length <= data.count else { return nil }
|
||||
let value = data.subdata(in: offset..<offset + length)
|
||||
offset += length
|
||||
|
||||
switch TLVType(rawValue: typeRaw) {
|
||||
case .direction:
|
||||
guard value.count == 1, let parsed = Direction(rawValue: value[0]) else { return nil }
|
||||
direction = parsed
|
||||
case .geohash:
|
||||
guard let parsed = String(data: value, encoding: .utf8) else { return nil }
|
||||
geohash = parsed
|
||||
case .eventJSON:
|
||||
eventJSON = value
|
||||
case nil:
|
||||
// Unknown TLV; skip (tolerant decoder for forward compatibility).
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
guard offset == data.count,
|
||||
let direction,
|
||||
let geohash,
|
||||
let eventJSON else {
|
||||
return nil
|
||||
}
|
||||
return NostrCarrierPacket(direction: direction, geohash: geohash, eventJSON: eventJSON)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
// MARK: - Protocol TLV Packets
|
||||
@@ -7,12 +8,28 @@ struct AnnouncementPacket {
|
||||
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
||||
let signingPublicKey: Data // Ed25519 public key for signing
|
||||
let directNeighbors: [Data]? // 8-byte peer IDs
|
||||
let capabilities: PeerCapabilities? // advertised feature bits; nil when absent (old clients)
|
||||
|
||||
init(
|
||||
nickname: String,
|
||||
noisePublicKey: Data,
|
||||
signingPublicKey: Data,
|
||||
directNeighbors: [Data]?,
|
||||
capabilities: PeerCapabilities? = nil
|
||||
) {
|
||||
self.nickname = nickname
|
||||
self.noisePublicKey = noisePublicKey
|
||||
self.signingPublicKey = signingPublicKey
|
||||
self.directNeighbors = directNeighbors
|
||||
self.capabilities = capabilities
|
||||
}
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case nickname = 0x01
|
||||
case noisePublicKey = 0x02
|
||||
case signingPublicKey = 0x03
|
||||
case directNeighbors = 0x04
|
||||
case capabilities = 0x05
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
@@ -48,6 +65,15 @@ struct AnnouncementPacket {
|
||||
}
|
||||
}
|
||||
|
||||
// TLV for capabilities (optional)
|
||||
if let capabilities = capabilities {
|
||||
let capabilityBytes = capabilities.encoded()
|
||||
guard capabilityBytes.count <= 255 else { return nil }
|
||||
data.append(TLVType.capabilities.rawValue)
|
||||
data.append(UInt8(capabilityBytes.count))
|
||||
data.append(capabilityBytes)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -57,6 +83,7 @@ struct AnnouncementPacket {
|
||||
var noisePublicKey: Data?
|
||||
var signingPublicKey: Data?
|
||||
var directNeighbors: [Data]?
|
||||
var capabilities: PeerCapabilities?
|
||||
|
||||
while offset + 2 <= data.count {
|
||||
let typeRaw = data[offset]
|
||||
@@ -87,6 +114,8 @@ struct AnnouncementPacket {
|
||||
}
|
||||
directNeighbors = neighbors
|
||||
}
|
||||
case .capabilities:
|
||||
capabilities = PeerCapabilities(encoded: Data(value))
|
||||
}
|
||||
} else {
|
||||
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||
@@ -99,7 +128,8 @@ struct AnnouncementPacket {
|
||||
nickname: nickname,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey,
|
||||
directNeighbors: directNeighbors
|
||||
directNeighbors: directNeighbors,
|
||||
capabilities: capabilities
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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 = {
|
||||
var caps: PeerCapabilities = [.prekeys, .vouch, .groups]
|
||||
if TransportConfig.wifiBulkEnabled { caps.insert(.wifiBulk) }
|
||||
return caps
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//
|
||||
// VouchAttestation.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// A signed statement that the *sender of the enclosing Noise payload* has
|
||||
/// verified the identity described here ("transitive verification").
|
||||
///
|
||||
/// The voucher's identity is deliberately implicit: attestations only travel
|
||||
/// inside an authenticated Noise session (`NoisePayloadType.vouch`), so the
|
||||
/// receiver verifies the Ed25519 signature against the session peer's
|
||||
/// announce-bound signing key and stores the vouch keyed by that peer's
|
||||
/// fingerprint. Nothing in the attestation names the voucher, so a captured
|
||||
/// attestation cannot be replayed by a third party whose signing key doesn't
|
||||
/// match.
|
||||
///
|
||||
/// Wire format — single attestation (TLV, 1-byte type + 1-byte length):
|
||||
/// - `0x01` voucheeFingerprint: 32 bytes, SHA-256 of the vouchee's Noise static key
|
||||
/// - `0x02` voucheeSigningKey: 32 bytes, Ed25519; anchors the vouch to a concrete identity
|
||||
/// - `0x03` timestamp: 8 bytes big-endian, milliseconds since 1970
|
||||
/// - `0x04` signature: 64 bytes, Ed25519 by the VOUCHER's signing key over
|
||||
/// `"bitchat-vouch-v1" | voucheeFingerprint | voucheeSigningKey | timestamp`
|
||||
///
|
||||
/// Unknown TLV types are skipped for forward compatibility.
|
||||
///
|
||||
/// Batch format (the `vouch` Noise payload body):
|
||||
/// `[count: UInt8]` then per attestation `[length: UInt16 BE][attestation TLV]`.
|
||||
struct VouchAttestation: Equatable {
|
||||
static let signingContext = "bitchat-vouch-v1"
|
||||
/// Receiver-side expiry for attestations.
|
||||
static let maxAge: TimeInterval = 30 * 24 * 60 * 60
|
||||
/// Tolerated clock skew for attestations timestamped in the future.
|
||||
static let maxClockSkew: TimeInterval = 60 * 60
|
||||
/// Upper bound of attestations carried/accepted in one batch payload.
|
||||
static let maxBatchCount = 16
|
||||
|
||||
static let fingerprintSize = 32
|
||||
static let signingKeySize = 32
|
||||
static let signatureSize = 64
|
||||
|
||||
let voucheeFingerprint: Data // 32 bytes
|
||||
let voucheeSigningKey: Data // 32 bytes
|
||||
let timestampMs: UInt64
|
||||
let signature: Data // 64 bytes
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case voucheeFingerprint = 0x01
|
||||
case voucheeSigningKey = 0x02
|
||||
case timestamp = 0x03
|
||||
case signature = 0x04
|
||||
}
|
||||
|
||||
var voucheeFingerprintHex: String { voucheeFingerprint.hexEncodedString() }
|
||||
|
||||
var timestamp: Date { Date(timeIntervalSince1970: TimeInterval(timestampMs) / 1000) }
|
||||
|
||||
/// The exact bytes the voucher signs.
|
||||
static func signableBytes(
|
||||
voucheeFingerprint: Data,
|
||||
voucheeSigningKey: Data,
|
||||
timestampMs: UInt64
|
||||
) -> Data {
|
||||
var message = Data(signingContext.utf8)
|
||||
message.append(voucheeFingerprint)
|
||||
message.append(voucheeSigningKey)
|
||||
var timestampBE = timestampMs.bigEndian
|
||||
withUnsafeBytes(of: ×tampBE) { message.append(contentsOf: $0) }
|
||||
return message
|
||||
}
|
||||
|
||||
var signableBytes: Data {
|
||||
Self.signableBytes(
|
||||
voucheeFingerprint: voucheeFingerprint,
|
||||
voucheeSigningKey: voucheeSigningKey,
|
||||
timestampMs: timestampMs
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds and signs an attestation. `sign` is the voucher's Ed25519
|
||||
/// signing primitive (e.g. `Transport.noiseSignData`).
|
||||
static func build(
|
||||
voucheeFingerprint: Data,
|
||||
voucheeSigningKey: Data,
|
||||
timestampMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
sign: (Data) -> Data?
|
||||
) -> VouchAttestation? {
|
||||
guard voucheeFingerprint.count == fingerprintSize,
|
||||
voucheeSigningKey.count == signingKeySize else { return nil }
|
||||
let message = signableBytes(
|
||||
voucheeFingerprint: voucheeFingerprint,
|
||||
voucheeSigningKey: voucheeSigningKey,
|
||||
timestampMs: timestampMs
|
||||
)
|
||||
guard let signature = sign(message), signature.count == signatureSize else { return nil }
|
||||
return VouchAttestation(
|
||||
voucheeFingerprint: voucheeFingerprint,
|
||||
voucheeSigningKey: voucheeSigningKey,
|
||||
timestampMs: timestampMs,
|
||||
signature: signature
|
||||
)
|
||||
}
|
||||
|
||||
/// Verifies the Ed25519 signature against the voucher's announce-bound
|
||||
/// signing key.
|
||||
func verifySignature(voucherSigningKey: Data) -> Bool {
|
||||
guard let publicKey = try? Curve25519.Signing.PublicKey(rawRepresentation: voucherSigningKey) else {
|
||||
return false
|
||||
}
|
||||
return publicKey.isValidSignature(signature, for: signableBytes)
|
||||
}
|
||||
|
||||
/// Whether the attestation is outside its validity window (older than
|
||||
/// `maxAge`, or timestamped implausibly far in the future).
|
||||
func isExpired(now: Date = Date()) -> Bool {
|
||||
let age = now.timeIntervalSince(timestamp)
|
||||
return age > Self.maxAge || age < -Self.maxClockSkew
|
||||
}
|
||||
|
||||
// MARK: - Encoding
|
||||
|
||||
func encode() -> Data? {
|
||||
guard voucheeFingerprint.count == Self.fingerprintSize,
|
||||
voucheeSigningKey.count == Self.signingKeySize,
|
||||
signature.count == Self.signatureSize else { return nil }
|
||||
var data = Data()
|
||||
func appendTLV(_ type: TLVType, _ value: Data) {
|
||||
data.append(type.rawValue)
|
||||
data.append(UInt8(value.count))
|
||||
data.append(value)
|
||||
}
|
||||
appendTLV(.voucheeFingerprint, voucheeFingerprint)
|
||||
appendTLV(.voucheeSigningKey, voucheeSigningKey)
|
||||
var timestampBE = timestampMs.bigEndian
|
||||
appendTLV(.timestamp, withUnsafeBytes(of: ×tampBE) { Data($0) })
|
||||
appendTLV(.signature, signature)
|
||||
return data
|
||||
}
|
||||
|
||||
static func decode(from data: Data) -> VouchAttestation? {
|
||||
var fingerprint: Data?
|
||||
var signingKey: Data?
|
||||
var timestampMs: UInt64?
|
||||
var signature: Data?
|
||||
|
||||
var offset = data.startIndex
|
||||
while offset < data.endIndex {
|
||||
guard data.index(offset, offsetBy: 2, limitedBy: data.endIndex) != nil,
|
||||
offset + 1 < data.endIndex else { return nil }
|
||||
let type = data[offset]
|
||||
let length = Int(data[offset + 1])
|
||||
let valueStart = offset + 2
|
||||
guard let valueEnd = data.index(valueStart, offsetBy: length, limitedBy: data.endIndex) else {
|
||||
return nil
|
||||
}
|
||||
let value = Data(data[valueStart..<valueEnd])
|
||||
switch TLVType(rawValue: type) {
|
||||
case .voucheeFingerprint:
|
||||
guard value.count == fingerprintSize else { return nil }
|
||||
fingerprint = value
|
||||
case .voucheeSigningKey:
|
||||
guard value.count == signingKeySize else { return nil }
|
||||
signingKey = value
|
||||
case .timestamp:
|
||||
guard value.count == 8 else { return nil }
|
||||
timestampMs = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
|
||||
case .signature:
|
||||
guard value.count == signatureSize else { return nil }
|
||||
signature = value
|
||||
case nil:
|
||||
break // Unknown TLV: skip for forward compatibility.
|
||||
}
|
||||
offset = valueEnd
|
||||
}
|
||||
|
||||
guard let fingerprint, let signingKey, let timestampMs, let signature else { return nil }
|
||||
return VouchAttestation(
|
||||
voucheeFingerprint: fingerprint,
|
||||
voucheeSigningKey: signingKey,
|
||||
timestampMs: timestampMs,
|
||||
signature: signature
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Batch encoding
|
||||
|
||||
/// Encodes up to `maxBatchCount` attestations into one payload body.
|
||||
static func encodeList(_ attestations: [VouchAttestation]) -> Data? {
|
||||
guard !attestations.isEmpty, attestations.count <= maxBatchCount else { return nil }
|
||||
var data = Data()
|
||||
data.append(UInt8(attestations.count))
|
||||
for attestation in attestations {
|
||||
guard let encoded = attestation.encode(), encoded.count <= Int(UInt16.max) else { return nil }
|
||||
var lengthBE = UInt16(encoded.count).bigEndian
|
||||
withUnsafeBytes(of: &lengthBE) { data.append(contentsOf: $0) }
|
||||
data.append(encoded)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
/// Decodes a batch payload, dropping malformed entries and ignoring
|
||||
/// anything beyond `maxBatchCount` (sender-declared count is not trusted).
|
||||
static func decodeList(from data: Data) -> [VouchAttestation] {
|
||||
guard data.count > 1 else { return [] }
|
||||
let declaredCount = Int(data[data.startIndex])
|
||||
let limit = min(declaredCount, maxBatchCount)
|
||||
var attestations: [VouchAttestation] = []
|
||||
var offset = data.startIndex + 1
|
||||
while attestations.count < limit, offset < data.endIndex {
|
||||
guard let lengthEnd = data.index(offset, offsetBy: 2, limitedBy: data.endIndex) else { break }
|
||||
let length = Int(data[offset]) << 8 | Int(data[offset + 1])
|
||||
guard let entryEnd = data.index(lengthEnd, offsetBy: length, limitedBy: data.endIndex) else { break }
|
||||
if let attestation = decode(from: Data(data[lengthEnd..<entryEnd])) {
|
||||
attestations.append(attestation)
|
||||
}
|
||||
offset = entryEnd
|
||||
}
|
||||
return attestations
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -41,13 +41,16 @@ final class BLEConnectionScheduler<Peripheral> {
|
||||
private let candidateCap: Int
|
||||
private let weakLinkCooldownSeconds: TimeInterval
|
||||
private let weakLinkRSSICutoff: Int
|
||||
private let recentTimeoutWindowSeconds: TimeInterval
|
||||
private let recentTimeoutCountThreshold: 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
|
||||
@@ -63,8 +66,6 @@ final class BLEConnectionScheduler<Peripheral> {
|
||||
candidateCap: Int = TransportConfig.bleConnectionCandidatesMax,
|
||||
weakLinkCooldownSeconds: TimeInterval = TransportConfig.bleWeakLinkCooldownSeconds,
|
||||
weakLinkRSSICutoff: Int = TransportConfig.bleWeakLinkRSSICutoff,
|
||||
recentTimeoutWindowSeconds: TimeInterval = TransportConfig.bleRecentTimeoutWindowSeconds,
|
||||
recentTimeoutCountThreshold: Int = TransportConfig.bleRecentTimeoutCountThreshold,
|
||||
dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault
|
||||
) {
|
||||
self.maxCentralLinks = maxCentralLinks
|
||||
@@ -72,8 +73,6 @@ final class BLEConnectionScheduler<Peripheral> {
|
||||
self.candidateCap = candidateCap
|
||||
self.weakLinkCooldownSeconds = weakLinkCooldownSeconds
|
||||
self.weakLinkRSSICutoff = weakLinkRSSICutoff
|
||||
self.recentTimeoutWindowSeconds = recentTimeoutWindowSeconds
|
||||
self.recentTimeoutCountThreshold = recentTimeoutCountThreshold
|
||||
self.initialDynamicRSSIThreshold = dynamicRSSIThreshold
|
||||
self.dynamicRSSIThreshold = dynamicRSSIThreshold
|
||||
}
|
||||
@@ -114,7 +113,12 @@ final class BLEConnectionScheduler<Peripheral> {
|
||||
}
|
||||
|
||||
if let lastTimeout = recentConnectTimeouts[candidate.peripheralID],
|
||||
now.timeIntervalSince(lastTimeout) < 15 {
|
||||
now.timeIntervalSince(lastTimeout) < TransportConfig.bleTimeoutDiscoveryIgnoreSeconds {
|
||||
return .ignore
|
||||
}
|
||||
|
||||
if let lastDisconnect = recentDisconnects[candidate.peripheralID],
|
||||
now.timeIntervalSince(lastDisconnect) < TransportConfig.bleDisconnectDiscoveryIgnoreSeconds {
|
||||
return .ignore
|
||||
}
|
||||
|
||||
@@ -163,6 +167,11 @@ final class BLEConnectionScheduler<Peripheral> {
|
||||
return .retryAfter(delay)
|
||||
}
|
||||
|
||||
if let delay = disconnectSettleDelay(for: candidate, now: now) {
|
||||
enqueue(candidate)
|
||||
return .retryAfter(delay)
|
||||
}
|
||||
|
||||
if isAlreadyConnectingOrConnected(candidate.peripheralID) {
|
||||
continue
|
||||
}
|
||||
@@ -180,6 +189,7 @@ final class BLEConnectionScheduler<Peripheral> {
|
||||
func recordConnectionSuccess(peripheralID: String) {
|
||||
failureCounts[peripheralID] = 0
|
||||
recentConnectTimeouts.removeValue(forKey: peripheralID)
|
||||
recentDisconnects.removeValue(forKey: peripheralID)
|
||||
}
|
||||
|
||||
func recordConnectionFailure(peripheralID: String) {
|
||||
@@ -187,7 +197,7 @@ final class BLEConnectionScheduler<Peripheral> {
|
||||
}
|
||||
|
||||
func recordDisconnectError(peripheralID: String, at now: Date) {
|
||||
recentConnectTimeouts[peripheralID] = now
|
||||
recentDisconnects[peripheralID] = now
|
||||
}
|
||||
|
||||
func recordConnectionTimeout(peripheralID: String, at now: Date) {
|
||||
@@ -197,6 +207,7 @@ final class BLEConnectionScheduler<Peripheral> {
|
||||
|
||||
func pruneConnectionTimeouts(before cutoff: Date) {
|
||||
recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= cutoff }
|
||||
recentDisconnects = recentDisconnects.filter { $0.value >= cutoff }
|
||||
}
|
||||
|
||||
func reset() {
|
||||
@@ -204,6 +215,7 @@ final class BLEConnectionScheduler<Peripheral> {
|
||||
candidates.removeAll()
|
||||
failureCounts.removeAll()
|
||||
recentConnectTimeouts.removeAll()
|
||||
recentDisconnects.removeAll()
|
||||
lastIsolatedAt = nil
|
||||
dynamicRSSIThreshold = initialDynamicRSSIThreshold
|
||||
}
|
||||
@@ -225,18 +237,14 @@ final class BLEConnectionScheduler<Peripheral> {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
let recentTimeouts = recentConnectTimeouts.filter {
|
||||
now.timeIntervalSince($0.value) < recentTimeoutWindowSeconds
|
||||
}.count
|
||||
if recentTimeouts >= recentTimeoutCountThreshold {
|
||||
threshold = max(threshold, TransportConfig.bleRSSIHighTimeoutThreshold)
|
||||
}
|
||||
|
||||
dynamicRSSIThreshold = threshold
|
||||
return threshold
|
||||
}
|
||||
@@ -258,6 +266,20 @@ final class BLEConnectionScheduler<Peripheral> {
|
||||
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))
|
||||
|
||||
@@ -13,17 +13,45 @@ enum BLEFanoutSelector {
|
||||
centralIDs: [String],
|
||||
ingressLink: BLEIngressLinkID?,
|
||||
excludedLinks: Set<BLEIngressLinkID> = [],
|
||||
peripheralPeerBindings: [String: PeerID] = [:],
|
||||
centralPeerBindings: [String: PeerID] = [:],
|
||||
directedPeerHint: PeerID?,
|
||||
packetType: UInt8,
|
||||
messageID: String
|
||||
) -> BLEFanoutSelection {
|
||||
let allowed = allowedLinks(
|
||||
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),
|
||||
@@ -65,6 +93,79 @@ enum BLEFanoutSelector {
|
||||
return (allowedPeripheralIDs, allowedCentralIDs)
|
||||
}
|
||||
|
||||
private static func directLinks(
|
||||
to peerID: PeerID,
|
||||
links: (peripheralIDs: [String], centralIDs: [String]),
|
||||
peripheralPeerBindings: [String: PeerID],
|
||||
centralPeerBindings: [String: PeerID]
|
||||
) -> BLEFanoutSelection? {
|
||||
let directLinks = collapseDuplicateLinksPerPeer(
|
||||
(
|
||||
peripheralIDs: links.peripheralIDs.filter { peripheralPeerBindings[$0] == peerID },
|
||||
centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID }
|
||||
),
|
||||
peripheralPeerBindings: peripheralPeerBindings,
|
||||
centralPeerBindings: centralPeerBindings
|
||||
)
|
||||
|
||||
guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return BLEFanoutSelection(
|
||||
peripheralIDs: Set(directLinks.peripheralIDs),
|
||||
centralIDs: Set(directLinks.centralIDs)
|
||||
)
|
||||
}
|
||||
|
||||
private static func hasBoundLink(
|
||||
to peerID: PeerID,
|
||||
peripheralIDs: [String],
|
||||
centralIDs: [String],
|
||||
peripheralPeerBindings: [String: PeerID],
|
||||
centralPeerBindings: [String: PeerID]
|
||||
) -> Bool {
|
||||
peripheralIDs.contains { peripheralPeerBindings[$0] == peerID }
|
||||
|| centralIDs.contains { centralPeerBindings[$0] == peerID }
|
||||
}
|
||||
|
||||
// Dual-role pairs hold two live links (we-as-central writing to their
|
||||
// peripheral, and they-as-central subscribed to ours). Sending the same
|
||||
// packet down both doubles airtime for nothing — the receiver's assembler
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -42,12 +42,17 @@ enum BLEIncomingFileRejection: Error, Equatable {
|
||||
}
|
||||
|
||||
enum BLEIncomingFileValidator {
|
||||
static func validate(payload: Data) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
||||
guard let filePacket = BitchatFilePacket.decode(payload) else {
|
||||
/// `limit` defaults to the Bluetooth payload cap; Wi-Fi bulk deliveries
|
||||
/// pass the ceiling enforced against the accepted offer.
|
||||
static func validate(
|
||||
payload: Data,
|
||||
limit: Int = FileTransferLimits.maxPayloadBytes
|
||||
) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
||||
guard let filePacket = BitchatFilePacket.decode(payload, limit: limit) else {
|
||||
return .failure(.malformedPayload)
|
||||
}
|
||||
|
||||
guard FileTransferLimits.isValidPayload(filePacket.content.count) else {
|
||||
guard FileTransferLimits.isValidPayload(filePacket.content.count, limit: limit) else {
|
||||
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,9 @@ struct BLEFragmentAssemblyBuffer {
|
||||
let type: UInt8
|
||||
let total: Int
|
||||
let timestamp: Date
|
||||
let isBroadcast: Bool
|
||||
var lastFragmentAt: Date
|
||||
var lastResyncRequestAt: Date?
|
||||
}
|
||||
|
||||
private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:]
|
||||
@@ -105,7 +108,15 @@ struct BLEFragmentAssemblyBuffer {
|
||||
return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started)
|
||||
}
|
||||
|
||||
// Only actual progress resets the stall clock: fragment packets
|
||||
// bypass the packet deduplicator, so relayed duplicates of an
|
||||
// already-held index must not keep suppressing the targeted
|
||||
// REQUEST_SYNC for a stalled stream.
|
||||
let isNewIndex = fragmentsByKey[header.key]?[header.index] == nil
|
||||
fragmentsByKey[header.key]?[header.index] = header.fragmentData
|
||||
if isNewIndex {
|
||||
metadataByKey[header.key]?.lastFragmentAt = now
|
||||
}
|
||||
|
||||
guard let fragments = fragmentsByKey[header.key],
|
||||
fragments.count == header.total else {
|
||||
@@ -138,10 +149,59 @@ struct BLEFragmentAssemblyBuffer {
|
||||
}
|
||||
|
||||
fragmentsByKey[header.key] = [:]
|
||||
metadataByKey[header.key] = Metadata(type: header.originalType, total: header.total, timestamp: now)
|
||||
metadataByKey[header.key] = Metadata(
|
||||
type: header.originalType,
|
||||
total: header.total,
|
||||
timestamp: now,
|
||||
isBroadcast: header.isBroadcastFragment,
|
||||
lastFragmentAt: now
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Fragment stream IDs (8-byte, big-endian) of incomplete broadcast
|
||||
/// reassemblies that have not seen a new fragment for `stalledAfter`
|
||||
/// seconds — candidates for a targeted REQUEST_SYNC. Each returned
|
||||
/// stream is marked so it is not re-requested within `retryAfter`.
|
||||
/// At most `RequestSyncPacket.maxFragmentIdFilterCount` streams are
|
||||
/// returned per pass — the wire filter cannot carry more — selected
|
||||
/// oldest-stall first; overflow streams stay unmarked and eligible for
|
||||
/// the next pass. Directed reassemblies are excluded: peers only archive
|
||||
/// broadcast fragments for gossip sync, so a targeted request cannot
|
||||
/// recover them.
|
||||
mutating func stalledBroadcastFragmentIDs(
|
||||
stalledAfter: TimeInterval,
|
||||
retryAfter: TimeInterval,
|
||||
now: Date = Date()
|
||||
) -> [Data] {
|
||||
var candidates: [(key: BLEFragmentKey, lastFragmentAt: Date)] = []
|
||||
for (key, metadata) in metadataByKey {
|
||||
guard metadata.isBroadcast,
|
||||
let fragments = fragmentsByKey[key],
|
||||
fragments.count < metadata.total,
|
||||
now.timeIntervalSince(metadata.lastFragmentAt) >= stalledAfter else { continue }
|
||||
if let lastRequest = metadata.lastResyncRequestAt,
|
||||
now.timeIntervalSince(lastRequest) < retryAfter { continue }
|
||||
candidates.append((key: key, lastFragmentAt: metadata.lastFragmentAt))
|
||||
}
|
||||
|
||||
// Mark only the streams that will actually go on the wire, so the
|
||||
// overflow is not silently suppressed for `retryAfter`.
|
||||
let selected = candidates
|
||||
.sorted {
|
||||
if $0.lastFragmentAt != $1.lastFragmentAt {
|
||||
return $0.lastFragmentAt < $1.lastFragmentAt
|
||||
}
|
||||
return ($0.key.sender, $0.key.id) < ($1.key.sender, $1.key.id)
|
||||
}
|
||||
.prefix(RequestSyncPacket.maxFragmentIdFilterCount)
|
||||
|
||||
return selected.map { candidate in
|
||||
metadataByKey[candidate.key]?.lastResyncRequestAt = now
|
||||
return withUnsafeBytes(of: candidate.key.id.bigEndian) { Data($0) }
|
||||
}
|
||||
}
|
||||
|
||||
private static func assemblyLimit(for originalType: UInt8) -> Int {
|
||||
if originalType == MessageType.fileTransfer.rawValue {
|
||||
// Allow headroom for TLV metadata and binary framing overhead.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,11 @@ struct BLEIngressLinkRegistry {
|
||||
}
|
||||
|
||||
private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
|
||||
packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
|
||||
// REQUEST_SYNC is never relayed, so on a bound link the claimed sender
|
||||
// must be the link peer — it elicits a full store replay, and the
|
||||
// response is addressed to whoever the sender claims to be.
|
||||
if packet.type == MessageType.requestSync.rawValue { return true }
|
||||
return packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
|
||||
}
|
||||
|
||||
private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
|
||||
|
||||
@@ -26,36 +26,69 @@ struct BLESubscribedCentralSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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] {
|
||||
Array(peripherals.values)
|
||||
assertOwned()
|
||||
return Array(peripherals.values)
|
||||
}
|
||||
|
||||
var subscribedCentralSnapshot: BLESubscribedCentralSnapshot {
|
||||
BLESubscribedCentralSnapshot(
|
||||
assertOwned()
|
||||
return BLESubscribedCentralSnapshot(
|
||||
centrals: subscribedCentrals,
|
||||
peerIDsByCentralUUID: centralToPeerID
|
||||
)
|
||||
}
|
||||
|
||||
var subscribedCentralCount: Int {
|
||||
subscribedCentrals.count
|
||||
assertOwned()
|
||||
return subscribedCentrals.count
|
||||
}
|
||||
|
||||
var connectedOrConnectingPeripheralCount: Int {
|
||||
peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
|
||||
assertOwned()
|
||||
return peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
|
||||
}
|
||||
|
||||
func state(forPeripheralID peripheralID: String) -> BLEPeripheralLinkState? {
|
||||
peripherals[peripheralID]
|
||||
assertOwned()
|
||||
return peripherals[peripheralID]
|
||||
}
|
||||
|
||||
func setPeripheralState(_ state: BLEPeripheralLinkState, for peripheralID: String) {
|
||||
assertOwned()
|
||||
peripherals[peripheralID] = state
|
||||
}
|
||||
|
||||
@@ -64,6 +97,7 @@ final class BLELinkStateStore {
|
||||
_ peripheralID: String,
|
||||
_ update: (inout BLEPeripheralLinkState) -> Void
|
||||
) -> BLEPeripheralLinkState? {
|
||||
assertOwned()
|
||||
guard var state = peripherals[peripheralID] else { return nil }
|
||||
update(&state)
|
||||
peripherals[peripheralID] = state
|
||||
@@ -113,10 +147,12 @@ final class BLELinkStateStore {
|
||||
}
|
||||
|
||||
func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? {
|
||||
peerToPeripheralUUID[peerID].flatMap { peripherals[$0] }
|
||||
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)
|
||||
@@ -124,6 +160,7 @@ final class BLELinkStateStore {
|
||||
}
|
||||
|
||||
func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
|
||||
assertOwned()
|
||||
guard let peerID else { return [] }
|
||||
|
||||
var links: Set<BLEIngressLinkID> = []
|
||||
@@ -137,35 +174,42 @@ final class BLELinkStateStore {
|
||||
}
|
||||
|
||||
func peerID(forPeripheralID peripheralID: String) -> PeerID? {
|
||||
peripherals[peripheralID]?.peerID
|
||||
assertOwned()
|
||||
return peripherals[peripheralID]?.peerID
|
||||
}
|
||||
|
||||
func peerID(forCentralUUID centralUUID: String) -> PeerID? {
|
||||
centralToPeerID[centralUUID]
|
||||
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)
|
||||
@@ -174,6 +218,7 @@ final class BLELinkStateStore {
|
||||
}
|
||||
|
||||
func clearPeripherals() -> [PeerID] {
|
||||
assertOwned()
|
||||
let peerIDs = peripherals.compactMap { $0.value.peerID }
|
||||
peripherals.removeAll()
|
||||
peerToPeripheralUUID.removeAll()
|
||||
@@ -181,6 +226,7 @@ final class BLELinkStateStore {
|
||||
}
|
||||
|
||||
func clearCentrals() -> [PeerID] {
|
||||
assertOwned()
|
||||
let peerIDs = Array(centralToPeerID.values)
|
||||
subscribedCentrals.removeAll()
|
||||
centralToPeerID.removeAll()
|
||||
@@ -188,6 +234,7 @@ final class BLELinkStateStore {
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
assertOwned()
|
||||
peripherals.removeAll()
|
||||
peerToPeripheralUUID.removeAll()
|
||||
subscribedCentrals.removeAll()
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ enum BLEOutboundLinkPlanner {
|
||||
centralNotifyLimits: [Int],
|
||||
ingressRecord: BLEIngressLinkRecord?,
|
||||
excludedLinks: Set<BLEIngressLinkID>,
|
||||
peripheralPeerBindings: [String: PeerID] = [:],
|
||||
centralPeerBindings: [String: PeerID] = [:],
|
||||
directedOnlyPeer: PeerID?
|
||||
) -> BLEOutboundLinkPlan {
|
||||
if let minLimit = minimumLinkLimit(
|
||||
@@ -39,6 +41,8 @@ enum BLEOutboundLinkPlanner {
|
||||
centralIDs: centralIDs,
|
||||
ingressLink: ingressRecord?.link,
|
||||
excludedLinks: excludedLinks,
|
||||
peripheralPeerBindings: peripheralPeerBindings,
|
||||
centralPeerBindings: centralPeerBindings,
|
||||
directedPeerHint: directedPeerHint,
|
||||
packetType: packet.type,
|
||||
messageID: BLEOutboundPacketPolicy.messageID(for: packet)
|
||||
|
||||
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
|
||||
switch MessageType(rawValue: packetType) {
|
||||
case .noiseEncrypted, .noiseHandshake:
|
||||
return true
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer:
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope, .boardPost, .prekeyBundle, .groupMessage, .nostrCarrier, .ping, .pong:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ struct BLEPeerInfo: Equatable {
|
||||
var signingPublicKey: Data?
|
||||
var isVerifiedNickname: Bool
|
||||
var lastSeen: Date
|
||||
var capabilities: PeerCapabilities = []
|
||||
}
|
||||
|
||||
struct BLEPeerAnnounceUpdate: Equatable {
|
||||
@@ -107,6 +108,15 @@ struct BLEPeerRegistry {
|
||||
peers[peerID]?.noisePublicKey?.sha256Fingerprint()
|
||||
}
|
||||
|
||||
func capabilities(for peerID: PeerID) -> PeerCapabilities {
|
||||
peers[peerID.toShort()]?.capabilities ?? []
|
||||
}
|
||||
|
||||
/// Peers whose last verified announce advertised the given capability.
|
||||
func peers(advertising capability: PeerCapabilities) -> [PeerID] {
|
||||
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
|
||||
}
|
||||
|
||||
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
||||
let connected = peers.filter { $0.value.isConnected }
|
||||
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
||||
@@ -125,7 +135,8 @@ struct BLEPeerRegistry {
|
||||
nickname: resolvedNames[info.peerID] ?? info.nickname,
|
||||
isConnected: info.isConnected,
|
||||
noisePublicKey: info.noisePublicKey,
|
||||
lastSeen: info.lastSeen
|
||||
lastSeen: info.lastSeen,
|
||||
isVerified: info.isVerifiedNickname
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -156,7 +167,8 @@ struct BLEPeerRegistry {
|
||||
noisePublicKey: Data,
|
||||
signingPublicKey: Data?,
|
||||
isConnected: Bool,
|
||||
now: Date
|
||||
now: Date,
|
||||
capabilities: PeerCapabilities = []
|
||||
) -> BLEPeerAnnounceUpdate {
|
||||
let existing = peers[peerID]
|
||||
let update = BLEPeerAnnounceUpdate(
|
||||
@@ -172,7 +184,8 @@ struct BLEPeerRegistry {
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: signingPublicKey,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: now
|
||||
lastSeen: now,
|
||||
capabilities: capabilities
|
||||
)
|
||||
|
||||
return update
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,15 @@ enum BLEPublicMessagePolicy {
|
||||
}
|
||||
|
||||
let isBroadcast = BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID)
|
||||
// Acceptance window matches the gossip-sync serving window: a peer
|
||||
// walking between partitions carries hours of public history, so the
|
||||
// receive side must not drop what sync legitimately serves.
|
||||
if isBroadcast,
|
||||
BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) {
|
||||
BLEPacketFreshnessPolicy.isStale(
|
||||
timestampMilliseconds: packet.timestamp,
|
||||
now: now,
|
||||
maxAgeSeconds: TransportConfig.syncPublicMessageMaxAgeSeconds
|
||||
) {
|
||||
return .reject(.staleBroadcast(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
|
||||
timestampMilliseconds: packet.timestamp,
|
||||
now: now
|
||||
|
||||
@@ -12,7 +12,13 @@ struct BLEReceivedPacketContext: Equatable {
|
||||
struct BLEReceivePipeline {
|
||||
static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext {
|
||||
let senderID = PeerID(hexData: packet.senderID)
|
||||
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)"
|
||||
// Include a payload digest so that distinct packets sharing the same
|
||||
// sender/timestamp(ms)/type are not collapsed as duplicates. The
|
||||
// post-handshake flush sends queued messages, delivery and read receipts
|
||||
// back-to-back within a single millisecond; without the digest every
|
||||
// packet after the first would be silently dropped.
|
||||
let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString()
|
||||
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
||||
let messageType = MessageType(rawValue: packet.type)
|
||||
let allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID
|
||||
let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay
|
||||
@@ -42,11 +48,28 @@ struct BLEReceivePipeline {
|
||||
senderIsSelf: senderID == localPeerID,
|
||||
recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID,
|
||||
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
isDirectedEncrypted: packet.type == MessageType.noiseEncrypted.rawValue && packet.recipientID != nil,
|
||||
// Courier envelopes are directed opaque ciphertext like DMs; a
|
||||
// remote handover toward a relayed announce rides this same
|
||||
// deterministic relay treatment instead of the broadcast clamp.
|
||||
// Directed nostrCarrier uplinks (mesh-only peer -> gateway) need
|
||||
// the same multi-hop treatment to reach a non-adjacent gateway.
|
||||
// Ping/pong diagnostics also ride it: probes need the same
|
||||
// deterministic multi-hop relay as DMs (always relay, jitter,
|
||||
// no TTL cap) so RTT and hop counts reflect the real path.
|
||||
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue
|
||||
|| packet.type == MessageType.courierEnvelope.rawValue
|
||||
|| packet.type == MessageType.nostrCarrier.rawValue
|
||||
|| packet.type == MessageType.ping.rawValue
|
||||
|| packet.type == MessageType.pong.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,
|
||||
// Board posts relay like broadcast messages; urgent ones get the
|
||||
// announce-class TTL headroom so alerts travel the extra hop.
|
||||
isUrgentBoardPost: packet.type == MessageType.boardPost.rawValue
|
||||
&& BoardWire.urgentFlag(in: packet.payload),
|
||||
degree: degree,
|
||||
highDegreeThreshold: highDegreeThreshold
|
||||
)
|
||||
|
||||
@@ -35,6 +35,14 @@ struct BLERouteForwardingPolicy {
|
||||
routingPeer: (Data) -> PeerID?,
|
||||
isPeerConnected: (PeerID) -> Bool
|
||||
) -> BLERouteForwardingPlan {
|
||||
// REQUEST_SYNC is link-local: never forward it, on the flood path or
|
||||
// the source-routed path. A crafted request with a route and TTL
|
||||
// headroom must not be able to fan a full-store replay out to the next
|
||||
// hop. Suppressing here also short-circuits the flood relay.
|
||||
if packet.type == MessageType.requestSync.rawValue {
|
||||
return .suppressFloodRelay
|
||||
}
|
||||
|
||||
if PeerID(hexData: packet.recipientID) == localPeerID {
|
||||
return .suppressFloodRelay
|
||||
}
|
||||
|
||||
+1629
-482
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Tracks whether source-routed sends to a recipient appear to be working.
|
||||
///
|
||||
/// A routed unicast rides exactly one path, so a broken hop silently loses the
|
||||
/// packet where a flood would have healed around it. Rather than building a
|
||||
/// retransmission machine (MessageRouter already retries at a higher layer),
|
||||
/// this cache degrades: a routed send that sees no inbound traffic from the
|
||||
/// recipient within the confirmation window marks the route as failed, and
|
||||
/// subsequent sends fall back to flooding until the suppression TTL lapses.
|
||||
struct BLESourceRouteFailureCache {
|
||||
struct Config {
|
||||
/// How long a routed send may go unconfirmed before it counts as a
|
||||
/// route failure.
|
||||
var confirmationWindowSeconds: TimeInterval = TransportConfig.bleSourceRouteConfirmationWindowSeconds
|
||||
/// How long to flood instead of routing after a failure.
|
||||
var suppressionSeconds: TimeInterval = TransportConfig.bleSourceRouteSuppressionSeconds
|
||||
}
|
||||
|
||||
private struct State {
|
||||
var pendingSince: Date?
|
||||
var suppressedUntil: Date?
|
||||
}
|
||||
|
||||
private let config: Config
|
||||
private var states: [PeerID: State] = [:]
|
||||
|
||||
init(config: Config = Config()) {
|
||||
self.config = config
|
||||
}
|
||||
|
||||
/// Whether the next directed send to `recipient` may carry a source
|
||||
/// route. Flips the recipient into suppression when the last routed send
|
||||
/// went unconfirmed past the confirmation window.
|
||||
mutating func shouldAttemptRoute(to recipient: PeerID, now: Date = Date()) -> Bool {
|
||||
guard var state = states[recipient] else { return true }
|
||||
|
||||
if let until = state.suppressedUntil {
|
||||
guard now >= until else { return false }
|
||||
state.suppressedUntil = nil
|
||||
}
|
||||
|
||||
if let pending = state.pendingSince,
|
||||
now.timeIntervalSince(pending) > config.confirmationWindowSeconds {
|
||||
// The routed send was never confirmed: treat the route as broken
|
||||
// and flood until the suppression window lapses.
|
||||
state.pendingSince = nil
|
||||
state.suppressedUntil = now.addingTimeInterval(config.suppressionSeconds)
|
||||
states[recipient] = state
|
||||
return false
|
||||
}
|
||||
|
||||
states[recipient] = state
|
||||
return true
|
||||
}
|
||||
|
||||
/// Records that a source-routed packet was sent to `recipient`. Keeps the
|
||||
/// earliest unconfirmed send so back-to-back packets share one deadline.
|
||||
mutating func noteRoutedSend(to recipient: PeerID, now: Date = Date()) {
|
||||
var state = states[recipient] ?? State()
|
||||
if state.pendingSince == nil {
|
||||
state.pendingSince = now
|
||||
}
|
||||
states[recipient] = state
|
||||
}
|
||||
|
||||
/// Any inbound packet authored by `peer` confirms the pending routed send
|
||||
/// (delivery acks and replies arrive this way). Deliberately does not
|
||||
/// lift an active suppression: that traffic may have arrived via flood.
|
||||
mutating func noteInboundActivity(from peer: PeerID) {
|
||||
guard var state = states[peer] else { return }
|
||||
state.pendingSince = nil
|
||||
if state.suppressedUntil == nil {
|
||||
states.removeValue(forKey: peer)
|
||||
} else {
|
||||
states[peer] = state
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops entries that can no longer influence a routing decision. An
|
||||
/// expired-but-unconverted pending entry is kept for as long as the
|
||||
/// suppression it would trigger could still be active.
|
||||
mutating func prune(now: Date = Date()) {
|
||||
let pendingRetention = config.confirmationWindowSeconds + config.suppressionSeconds
|
||||
states = states.filter { _, state in
|
||||
if let until = state.suppressedUntil, now < until { return true }
|
||||
if let pending = state.pendingSince,
|
||||
now.timeIntervalSince(pending) <= pendingRetention {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Decides whether an outbound directed packet should carry a v2 source
|
||||
/// route. Pure gating logic so BLEService's hot send path stays a thin wire.
|
||||
enum BLESourceRouteOriginationPolicy {
|
||||
/// Why a packet kept flood/direct-write behavior instead of routing.
|
||||
/// The `rawValue` doubles as the greppable `[ROUTE]` log reason.
|
||||
enum FloodReason: String {
|
||||
case relayedNotOriginator = "not originator"
|
||||
case broadcast = "broadcast recipient"
|
||||
case noTTLHeadroom = "link-local ttl"
|
||||
case recipientDirect = "recipient direct"
|
||||
case routeSuppressed = "route suppressed→flood"
|
||||
case noPath = "no v2 path"
|
||||
}
|
||||
|
||||
/// The routing decision for an originated packet.
|
||||
enum Decision: Equatable {
|
||||
/// Keep flood/direct-write behavior unchanged; carries the reason.
|
||||
case flood(FloodReason)
|
||||
/// Originate a v2 source route over these intermediate hops.
|
||||
case route([Data])
|
||||
}
|
||||
|
||||
/// Returns whether to originate a v2 source route (with its hops) or keep
|
||||
/// flood/direct-write, with the reason for the latter.
|
||||
///
|
||||
/// Routes are only originated when every gate passes:
|
||||
/// - we authored the packet (relays must not rewrite and re-sign someone
|
||||
/// else's packet; route-following for in-flight routed packets lives in
|
||||
/// `BLERouteForwardingPolicy`),
|
||||
/// - the packet is directed at a single peer (not broadcast),
|
||||
/// - the packet has TTL headroom to traverse hops (link-local TTL-0
|
||||
/// packets like REQUEST_SYNC never route),
|
||||
/// - the recipient is not directly connected (a direct write already
|
||||
/// delivers in one hop),
|
||||
/// - routing to the recipient is not suppressed by a recent unconfirmed
|
||||
/// routed send, and
|
||||
/// - the topology yields a complete path.
|
||||
static func decide(
|
||||
for packet: BitchatPacket,
|
||||
to recipient: PeerID,
|
||||
localPeerIDData: Data,
|
||||
isRecipientConnected: (PeerID) -> Bool,
|
||||
shouldAttemptRoute: (PeerID) -> Bool,
|
||||
computeRoute: (PeerID) -> [Data]?
|
||||
) -> Decision {
|
||||
guard packet.senderID == localPeerIDData else { return .flood(.relayedNotOriginator) }
|
||||
guard let recipientData = packet.recipientID,
|
||||
recipientData.count == 8,
|
||||
!recipientData.allSatisfy({ $0 == 0xFF }) else { return .flood(.broadcast) }
|
||||
guard packet.ttl > 1 else { return .flood(.noTTLHeadroom) }
|
||||
guard !isRecipientConnected(recipient) else { return .flood(.recipientDirect) }
|
||||
guard shouldAttemptRoute(recipient) else { return .flood(.routeSuppressed) }
|
||||
guard let route = computeRoute(recipient), !route.isEmpty else { return .flood(.noPath) }
|
||||
return .route(route)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//
|
||||
// BoardManager.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// UI-facing coordinator for the bulletin board: builds and signs posts and
|
||||
/// tombstones with the device's Noise signing key, hands them to the mesh
|
||||
/// transport, and mirrors the store's live posts for SwiftUI.
|
||||
@MainActor
|
||||
final class BoardManager: ObservableObject {
|
||||
/// Live posts across all boards, newest state from the store.
|
||||
@Published private(set) var posts: [BoardPostPacket] = []
|
||||
|
||||
private let transport: Transport
|
||||
private let store: BoardStore
|
||||
private let publishToNostr: (_ content: String, _ geohash: String, _ nickname: String) -> Void
|
||||
private var cancellable: AnyCancellable?
|
||||
|
||||
init(
|
||||
transport: Transport,
|
||||
store: BoardStore = .shared,
|
||||
publishToNostr: ((String, String, String) -> Void)? = nil
|
||||
) {
|
||||
self.transport = transport
|
||||
self.store = store
|
||||
self.publishToNostr = publishToNostr ?? Self.livePublishToNostr
|
||||
cancellable = store.$postsSnapshot
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] snapshot in
|
||||
self?.posts = snapshot
|
||||
}
|
||||
}
|
||||
|
||||
/// Posts for one board context, urgent first, then newest first.
|
||||
func posts(forGeohash geohash: String) -> [BoardPostPacket] {
|
||||
posts
|
||||
.filter { $0.geohash == geohash }
|
||||
.sorted {
|
||||
if $0.isUrgent != $1.isUrgent { return $0.isUrgent }
|
||||
return $0.createdAt > $1.createdAt
|
||||
}
|
||||
}
|
||||
|
||||
func isOwnPost(_ post: BoardPostPacket) -> Bool {
|
||||
let key = transport.noiseSigningPublicKeyData()
|
||||
return !key.isEmpty && key == post.authorSigningKey
|
||||
}
|
||||
|
||||
/// Creates, signs, and broadcasts a board post. Returns false when the
|
||||
/// content is empty/oversized or signing fails.
|
||||
@discardableResult
|
||||
func createPost(
|
||||
content: String,
|
||||
geohash: String,
|
||||
urgent: Bool,
|
||||
expiryDays: Int,
|
||||
nickname: String
|
||||
) -> Bool {
|
||||
guard let trimmed = content.trimmedOrNilIfEmpty,
|
||||
trimmed.utf8.count <= BoardWireConstants.contentMaxBytes else {
|
||||
return false
|
||||
}
|
||||
let signingKey = transport.noiseSigningPublicKeyData()
|
||||
guard signingKey.count == BoardWireConstants.signingKeyLength else { return false }
|
||||
|
||||
var cleanNickname = nickname
|
||||
while cleanNickname.utf8.count > BoardWireConstants.nicknameMaxBytes {
|
||||
cleanNickname.removeLast()
|
||||
}
|
||||
let createdAt = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let lifetimeMs = min(
|
||||
UInt64(max(1, expiryDays)) * 24 * 60 * 60 * 1000,
|
||||
BoardWireConstants.maxLifetimeMs
|
||||
)
|
||||
let expiresAt = createdAt + lifetimeMs
|
||||
let flags: UInt8 = urgent ? BoardPostPacket.urgentFlag : 0
|
||||
var postID = Data(count: BoardWireConstants.postIDLength)
|
||||
let status = postID.withUnsafeMutableBytes { buffer -> Int32 in
|
||||
guard let base = buffer.baseAddress else { return -1 }
|
||||
return SecRandomCopyBytes(kSecRandomDefault, buffer.count, base)
|
||||
}
|
||||
guard status == errSecSuccess else { return false }
|
||||
|
||||
let signingBytes = BoardPostPacket.signingBytes(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: trimmed,
|
||||
authorSigningKey: signingKey,
|
||||
authorNickname: cleanNickname,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: flags
|
||||
)
|
||||
guard let signature = transport.noiseSignData(signingBytes) else {
|
||||
SecureLogger.error("Board: failed to sign post", category: .session)
|
||||
return false
|
||||
}
|
||||
let post = BoardPostPacket(
|
||||
postID: postID,
|
||||
geohash: geohash,
|
||||
content: trimmed,
|
||||
authorSigningKey: signingKey,
|
||||
authorNickname: cleanNickname,
|
||||
createdAt: createdAt,
|
||||
expiresAt: expiresAt,
|
||||
flags: flags,
|
||||
signature: signature
|
||||
)
|
||||
transport.sendBoardPayload(BoardWire.post(post).encode())
|
||||
|
||||
// One-way Nostr bridge (v1): geohash posts also go out as kind-1
|
||||
// location notes so online users see them. No inbound merge yet.
|
||||
if !geohash.isEmpty {
|
||||
publishToNostr(trimmed, geohash, cleanNickname)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Signs and broadcasts a tombstone for one of our own posts.
|
||||
@discardableResult
|
||||
func deletePost(_ post: BoardPostPacket) -> Bool {
|
||||
guard isOwnPost(post) else { return false }
|
||||
let deletedAt = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let signingBytes = BoardTombstonePacket.signingBytes(postID: post.postID, deletedAt: deletedAt)
|
||||
guard let signature = transport.noiseSignData(signingBytes) else {
|
||||
SecureLogger.error("Board: failed to sign tombstone", category: .session)
|
||||
return false
|
||||
}
|
||||
let tombstone = BoardTombstonePacket(
|
||||
postID: post.postID,
|
||||
authorSigningKey: post.authorSigningKey,
|
||||
deletedAt: deletedAt,
|
||||
signature: signature
|
||||
)
|
||||
transport.sendBoardPayload(BoardWire.tombstone(tombstone).encode())
|
||||
return true
|
||||
}
|
||||
|
||||
private static func livePublishToNostr(content: String, geohash: String, nickname: String) {
|
||||
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount)
|
||||
guard !relays.isEmpty else {
|
||||
SecureLogger.debug("Board: no geo relays for \(geohash); skipping Nostr bridge", category: .session)
|
||||
return
|
||||
}
|
||||
do {
|
||||
let identity = try NostrIdentityBridge().deriveIdentity(forGeohash: geohash)
|
||||
let event = try NostrProtocol.createGeohashTextNote(
|
||||
content: content,
|
||||
geohash: geohash,
|
||||
senderIdentity: identity,
|
||||
nickname: nickname
|
||||
)
|
||||
NostrRelayManager.shared.sendEvent(event, to: relays)
|
||||
} catch {
|
||||
SecureLogger.error("Board: failed to bridge post to Nostr: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
//
|
||||
// BoardStore.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// Outcome of feeding a board packet into the store, so the transport can
|
||||
/// decide whether the packet is still worth relaying.
|
||||
enum BoardIngestResult {
|
||||
/// New post or tombstone accepted (or a quota rejected it locally while
|
||||
/// it remains valid for other devices).
|
||||
case accepted
|
||||
/// Already known; nothing changed.
|
||||
case duplicate
|
||||
/// Invalid, expired, or deleted; do not relay.
|
||||
case rejected
|
||||
}
|
||||
|
||||
/// Persistent storage for bulletin-board posts and their tombstones.
|
||||
///
|
||||
/// Posts are signed public notices designed to outlive chat: they stay on
|
||||
/// disk until their author-chosen expiry (max 7 days) and re-enter gossip
|
||||
/// sync after a restart. Tombstones are retained until the deleted post's
|
||||
/// original expiry so the delete keeps outrunning stale copies of the post.
|
||||
///
|
||||
/// The on-disk format is the raw signed packets themselves (like
|
||||
/// `GossipMessageArchive`); state is rebuilt by re-verifying and re-ingesting
|
||||
/// them on launch. Wiped on panic.
|
||||
final class BoardStore {
|
||||
enum Limits {
|
||||
static let maxPosts = 200
|
||||
static let maxPostsPerAuthor = 5
|
||||
/// Retention for a tombstone whose post we never saw: we cannot know
|
||||
/// the original expiry, so cap at the max post lifetime.
|
||||
static let orphanTombstoneLifetimeMs = BoardWireConstants.maxLifetimeMs
|
||||
/// Orphan tombstones name posts nobody here has seen, so their volume
|
||||
/// is entirely sender-controlled; cap them like posts.
|
||||
static let maxOrphanTombstones = 100
|
||||
static let maxOrphanTombstonesPerAuthor = 5
|
||||
/// Allowance for clock skew between peers when judging received
|
||||
/// timestamps against local time.
|
||||
static let clockSkewMs: UInt64 = 60 * 60 * 1000
|
||||
}
|
||||
|
||||
private struct StoredPost {
|
||||
let post: BoardPostPacket
|
||||
let packet: BitchatPacket
|
||||
let rawPacket: Data
|
||||
}
|
||||
|
||||
private struct StoredTombstone {
|
||||
let tombstone: BoardTombstonePacket
|
||||
let packet: BitchatPacket
|
||||
let rawPacket: Data
|
||||
let retainUntil: UInt64
|
||||
/// True when no matching post was known at ingest time; only these
|
||||
/// count against the orphan caps.
|
||||
let isOrphan: Bool
|
||||
}
|
||||
|
||||
/// On-disk entry: the raw signed packet, plus the retention deadline for
|
||||
/// tombstones (derived from the deleted post's original expiry, which is
|
||||
/// no longer recoverable once the post is gone).
|
||||
private struct PersistedEntry: Codable {
|
||||
let packet: Data
|
||||
let retainUntil: UInt64?
|
||||
}
|
||||
|
||||
static let shared = BoardStore()
|
||||
|
||||
/// Live posts, published on the main thread for the board UI.
|
||||
@Published private(set) var postsSnapshot: [BoardPostPacket] = []
|
||||
|
||||
private var posts: [StoredPost] = []
|
||||
private var tombstones: [StoredTombstone] = []
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.board.store")
|
||||
private let fileURL: URL?
|
||||
private let now: () -> Date
|
||||
|
||||
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
|
||||
/// when `persistsToDisk` is false.
|
||||
init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) {
|
||||
self.now = now
|
||||
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
// MARK: - Ingest
|
||||
|
||||
/// Ingest a board packet whose payload decodes to `wire`. The caller must
|
||||
/// have verified the wire signature already (`BoardWire.verifySignature`).
|
||||
@discardableResult
|
||||
func ingest(_ wire: BoardWire, packet: BitchatPacket) -> BoardIngestResult {
|
||||
guard let rawPacket = packet.toBinaryData(padding: false) else { return .rejected }
|
||||
let nowMs = currentMs()
|
||||
return queue.sync {
|
||||
let result = ingestLocked(wire, packet: packet, rawPacket: rawPacket, nowMs: nowMs)
|
||||
if result == .accepted {
|
||||
persistLocked()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reads
|
||||
|
||||
/// Live posts scoped to one board (geohash, or "" for the mesh board).
|
||||
func posts(forGeohash geohash: String) -> [BoardPostPacket] {
|
||||
let nowMs = currentMs()
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(nowMs: nowMs)
|
||||
return posts.map(\.post).filter { $0.geohash == geohash }
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw signed packets (posts and live tombstones) for gossip sync rounds.
|
||||
func syncCandidates() -> [BitchatPacket] {
|
||||
let nowMs = currentMs()
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(nowMs: nowMs)
|
||||
return posts.map(\.packet) + tombstones.map(\.packet)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Maintenance
|
||||
|
||||
func pruneExpired() {
|
||||
let nowMs = currentMs()
|
||||
queue.sync {
|
||||
pruneExpiredLocked(nowMs: nowMs)
|
||||
persistLocked()
|
||||
}
|
||||
}
|
||||
|
||||
/// Panic wipe: drop all board data from memory and disk.
|
||||
func wipe() {
|
||||
queue.sync {
|
||||
posts.removeAll()
|
||||
tombstones.removeAll()
|
||||
if let fileURL {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
publishSnapshotLocked()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals (call only on `queue`)
|
||||
|
||||
private func ingestLocked(
|
||||
_ wire: BoardWire,
|
||||
packet: BitchatPacket,
|
||||
rawPacket: Data,
|
||||
nowMs: UInt64,
|
||||
retainUntilOverride: UInt64? = nil
|
||||
) -> BoardIngestResult {
|
||||
pruneExpiredLocked(nowMs: nowMs)
|
||||
switch wire {
|
||||
case .post(let post):
|
||||
return ingestPostLocked(post, packet: packet, rawPacket: rawPacket, nowMs: nowMs)
|
||||
case .tombstone(let tombstone):
|
||||
return ingestTombstoneLocked(tombstone, packet: packet, rawPacket: rawPacket, nowMs: nowMs, retainUntilOverride: retainUntilOverride)
|
||||
}
|
||||
}
|
||||
|
||||
private func ingestPostLocked(_ post: BoardPostPacket, packet: BitchatPacket, rawPacket: Data, nowMs: UInt64) -> BoardIngestResult {
|
||||
guard post.expiresAt > nowMs else { return .rejected }
|
||||
// Receive-time sanity (this is the single chokepoint for radio, sync,
|
||||
// and disk restores): the decoder only enforces the createdAt to
|
||||
// expiresAt span, so a forged future createdAt would sort ahead of
|
||||
// honest posts and hold a store slot without ever pruning.
|
||||
guard post.createdAt <= nowMs &+ Limits.clockSkewMs,
|
||||
post.expiresAt <= nowMs &+ BoardWireConstants.maxLifetimeMs &+ Limits.clockSkewMs else {
|
||||
return .rejected
|
||||
}
|
||||
if tombstones.contains(where: { $0.tombstone.postID == post.postID && $0.tombstone.authorSigningKey == post.authorSigningKey }) {
|
||||
return .rejected
|
||||
}
|
||||
guard !posts.contains(where: { $0.post.postID == post.postID }) else { return .duplicate }
|
||||
|
||||
posts.append(StoredPost(post: post, packet: packet, rawPacket: rawPacket))
|
||||
|
||||
// Per-author cap, then global cap; oldest posts are evicted first.
|
||||
let authorPosts = posts.filter { $0.post.authorSigningKey == post.authorSigningKey }
|
||||
if authorPosts.count > Limits.maxPostsPerAuthor {
|
||||
evictOldestLocked(from: authorPosts, keep: Limits.maxPostsPerAuthor)
|
||||
}
|
||||
if posts.count > Limits.maxPosts {
|
||||
evictOldestLocked(from: posts, keep: Limits.maxPosts)
|
||||
}
|
||||
publishSnapshotLocked()
|
||||
// Even when the new post itself was the eviction victim it stays
|
||||
// valid mesh-wide; peers with room should still receive it.
|
||||
return .accepted
|
||||
}
|
||||
|
||||
private func ingestTombstoneLocked(
|
||||
_ tombstone: BoardTombstonePacket,
|
||||
packet: BitchatPacket,
|
||||
rawPacket: Data,
|
||||
nowMs: UInt64,
|
||||
retainUntilOverride: UInt64? = nil
|
||||
) -> BoardIngestResult {
|
||||
guard !tombstones.contains(where: { $0.tombstone.postID == tombstone.postID }) else { return .duplicate }
|
||||
|
||||
// Cap retention by both the claimed deletion time (so a doctored file
|
||||
// cannot pin a tombstone past any legal expiry) and the receive time:
|
||||
// deletedAt is sender-chosen, so a far-future value must not retain
|
||||
// the tombstone longer than any post still able to arrive could live.
|
||||
let maxRetain = min(
|
||||
tombstone.deletedAt &+ Limits.orphanTombstoneLifetimeMs,
|
||||
nowMs &+ Limits.orphanTombstoneLifetimeMs &+ Limits.clockSkewMs
|
||||
)
|
||||
let retainUntil: UInt64
|
||||
let isOrphan: Bool
|
||||
if let index = posts.firstIndex(where: { $0.post.postID == tombstone.postID }) {
|
||||
let target = posts[index].post
|
||||
// Only the author's key can delete: the tombstone signature was
|
||||
// already verified against its embedded key, so it suffices to
|
||||
// require that key to be the post's author key.
|
||||
guard target.authorSigningKey == tombstone.authorSigningKey else { return .rejected }
|
||||
retainUntil = target.expiresAt
|
||||
isOrphan = false
|
||||
posts.remove(at: index)
|
||||
publishSnapshotLocked()
|
||||
} else if let retainUntilOverride {
|
||||
// Restored from disk: the post is long gone, so trust the
|
||||
// retention deadline recorded when the delete was first applied.
|
||||
// Orphans were already capped when first ingested off the air.
|
||||
retainUntil = min(retainUntilOverride, maxRetain)
|
||||
isOrphan = false
|
||||
} else {
|
||||
// Post unknown (tombstone raced ahead); keep it around so the
|
||||
// post is suppressed if it arrives later.
|
||||
retainUntil = maxRetain
|
||||
isOrphan = true
|
||||
}
|
||||
guard retainUntil > nowMs else { return .rejected }
|
||||
tombstones.append(StoredTombstone(tombstone: tombstone, packet: packet, rawPacket: rawPacket, retainUntil: retainUntil, isOrphan: isOrphan))
|
||||
if isOrphan {
|
||||
enforceOrphanTombstoneCapsLocked(author: tombstone.authorSigningKey)
|
||||
}
|
||||
// Like posts, a locally evicted tombstone stays valid mesh-wide.
|
||||
return .accepted
|
||||
}
|
||||
|
||||
/// Orphan tombstones reference posts we never saw, so a peer can mint
|
||||
/// unlimited valid ones for random IDs; bound them per author and
|
||||
/// globally, evicting the oldest received first (array order).
|
||||
private func enforceOrphanTombstoneCapsLocked(author: Data) {
|
||||
let authorOrphans = tombstones.filter { $0.isOrphan && $0.tombstone.authorSigningKey == author }
|
||||
if authorOrphans.count > Limits.maxOrphanTombstonesPerAuthor {
|
||||
removeTombstonesLocked(authorOrphans.prefix(authorOrphans.count - Limits.maxOrphanTombstonesPerAuthor))
|
||||
}
|
||||
let orphans = tombstones.filter(\.isOrphan)
|
||||
if orphans.count > Limits.maxOrphanTombstones {
|
||||
removeTombstonesLocked(orphans.prefix(orphans.count - Limits.maxOrphanTombstones))
|
||||
}
|
||||
}
|
||||
|
||||
private func removeTombstonesLocked(_ victims: ArraySlice<StoredTombstone>) {
|
||||
guard !victims.isEmpty else { return }
|
||||
let victimIDs = Set(victims.map { $0.tombstone.postID })
|
||||
tombstones.removeAll { victimIDs.contains($0.tombstone.postID) }
|
||||
}
|
||||
|
||||
private func evictOldestLocked(from candidates: [StoredPost], keep: Int) {
|
||||
let victims = candidates
|
||||
.sorted { $0.post.createdAt < $1.post.createdAt }
|
||||
.prefix(max(0, candidates.count - keep))
|
||||
guard !victims.isEmpty else { return }
|
||||
let victimIDs = Set(victims.map { $0.post.postID })
|
||||
posts.removeAll { victimIDs.contains($0.post.postID) }
|
||||
}
|
||||
|
||||
private func pruneExpiredLocked(nowMs: UInt64) {
|
||||
let postsBefore = posts.count
|
||||
posts.removeAll { $0.post.expiresAt <= nowMs }
|
||||
tombstones.removeAll { $0.retainUntil <= nowMs }
|
||||
if posts.count != postsBefore {
|
||||
publishSnapshotLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private func publishSnapshotLocked() {
|
||||
let snapshot = posts.map(\.post)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.postsSnapshot = snapshot
|
||||
}
|
||||
}
|
||||
|
||||
private func currentMs() -> UInt64 {
|
||||
UInt64(max(0, now().timeIntervalSince1970) * 1000)
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
private func persistLocked() {
|
||||
guard let fileURL else { return }
|
||||
let payloads = posts.map { PersistedEntry(packet: $0.rawPacket, retainUntil: nil) }
|
||||
+ tombstones.map { PersistedEntry(packet: $0.rawPacket, retainUntil: $0.retainUntil) }
|
||||
do {
|
||||
if payloads.isEmpty {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
return
|
||||
}
|
||||
try FileManager.default.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let data = try JSONEncoder().encode(payloads)
|
||||
var options: Data.WritingOptions = [.atomic]
|
||||
#if os(iOS)
|
||||
options.insert(.completeFileProtection)
|
||||
#endif
|
||||
try data.write(to: fileURL, options: options)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to persist board store: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFromDisk() {
|
||||
guard let fileURL,
|
||||
let data = try? Data(contentsOf: fileURL),
|
||||
let payloads = try? JSONDecoder().decode([PersistedEntry].self, from: data) else {
|
||||
return
|
||||
}
|
||||
let nowMs = currentMs()
|
||||
queue.sync {
|
||||
for entry in payloads {
|
||||
guard let packet = BitchatPacket.from(entry.packet),
|
||||
packet.type == MessageType.boardPost.rawValue,
|
||||
let wire = BoardWire.decode(from: packet.payload),
|
||||
wire.verifySignature() else { continue }
|
||||
_ = ingestLocked(wire, packet: packet, rawPacket: entry.packet, nowMs: nowMs, retainUntilOverride: entry.retainUntil)
|
||||
}
|
||||
publishSnapshotLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private static func defaultFileURL() -> URL? {
|
||||
guard let base = try? FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
) else { return nil }
|
||||
return base
|
||||
.appendingPathComponent("board", isDirectory: true)
|
||||
.appendingPathComponent("posts.json")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
//
|
||||
// CashuTokenDecoder.swift
|
||||
// bitchat
|
||||
//
|
||||
// Decodes Cashu ecash tokens (V3 `cashuA` = base64url JSON, V4 `cashuB` =
|
||||
// base64url CBOR) just far enough to summarize them for the UI: total
|
||||
// amount, unit, mint host, and memo. The app never contacts a mint — tokens
|
||||
// are bearer strings and redemption is delegated to an external wallet.
|
||||
//
|
||||
// This parses attacker-controlled message content, so every path is
|
||||
// bounds-checked, size-capped, and returns nil instead of trapping.
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum CashuTokenDecoder {
|
||||
|
||||
struct TokenInfo: Equatable {
|
||||
/// Token serialization version: "A" (JSON) or "B" (CBOR).
|
||||
let version: String
|
||||
/// Sum of all proof amounts; nil when no valid amounts were found.
|
||||
let amount: Int?
|
||||
/// Currency unit as declared by the token (commonly "sat"), if any.
|
||||
let unit: String?
|
||||
/// Host of the (first) mint URL, for display.
|
||||
let mintHost: String?
|
||||
/// Optional sender memo, sanitized for display.
|
||||
let memo: String?
|
||||
|
||||
/// "500 sat" style summary, defaulting the unit to sats per NUT-00.
|
||||
var displayAmount: String? {
|
||||
amount.map { "\($0) \(unit ?? "sat")" }
|
||||
}
|
||||
}
|
||||
|
||||
/// Upper bound on accepted token length in characters. Real tokens are a
|
||||
/// few KB; anything much bigger is abuse we shouldn't spend CPU on.
|
||||
static let maxTokenLength = 60_000
|
||||
/// Per-proof and total amount sanity caps (order of total sats in existence).
|
||||
private static let maxAmount: Int64 = 2_100_000_000_000_000
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Extracts the bare `cashuA…`/`cashuB…` token from raw text that may be
|
||||
/// a `cashu:`/`cashu://` URI and/or percent-encoded. Returns nil when the
|
||||
/// input doesn't look like a Cashu token at all.
|
||||
static func bareToken(from raw: String) -> String? {
|
||||
var token = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let lower = token.lowercased()
|
||||
if lower.hasPrefix("cashu://") {
|
||||
token = String(token.dropFirst(8))
|
||||
} else if lower.hasPrefix("cashu:") {
|
||||
token = String(token.dropFirst(6))
|
||||
}
|
||||
if token.contains("%"), let decoded = token.removingPercentEncoding {
|
||||
token = decoded
|
||||
}
|
||||
guard token.count >= 12, token.count <= maxTokenLength else { return nil }
|
||||
guard token.hasPrefix("cashuA") || token.hasPrefix("cashuB") else { return nil }
|
||||
// Base64 / base64url payload charset ('.' appears in some legacy multi-part tokens)
|
||||
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_+/=."))
|
||||
guard token.unicodeScalars.allSatisfy({ allowed.contains($0) }) else { return nil }
|
||||
return token
|
||||
}
|
||||
|
||||
/// Decodes a token (raw or `cashu:` URI form) into a display summary.
|
||||
///
|
||||
/// In the default (permissive) mode this is for *rendering*: V3 tokens
|
||||
/// must parse as JSON, but a V4 token whose CBOR we cannot walk still
|
||||
/// returns a generic `TokenInfo` (version "B", no amount) because the
|
||||
/// payload may use encodings this minimal reader doesn't support — an
|
||||
/// unknown chip is fine for display.
|
||||
///
|
||||
/// In `strict` mode (used by the `/pay` SEND path) there is no permissive
|
||||
/// fallback: the token must cleanly decode to a known version *and* carry
|
||||
/// a positive amount, otherwise this returns nil. This stops base64 junk
|
||||
/// and truncated V4 tokens from being relayed as if they were valid money.
|
||||
static func decode(_ raw: String, strict: Bool = false) -> TokenInfo? {
|
||||
guard let token = bareToken(from: raw) else { return nil }
|
||||
let version = String(token[token.index(token.startIndex, offsetBy: 5)])
|
||||
guard let payload = base64URLDecode(String(token.dropFirst(6))), !payload.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let info: TokenInfo?
|
||||
switch version {
|
||||
case "A":
|
||||
info = decodeV3(payload)
|
||||
case "B":
|
||||
if let walked = decodeV4(payload) {
|
||||
info = walked
|
||||
} else if strict {
|
||||
// Couldn't cleanly walk the CBOR — refuse to send it.
|
||||
return nil
|
||||
} else {
|
||||
info = TokenInfo(version: "B", amount: nil, unit: nil, mintHost: nil, memo: nil)
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
guard let info else { return nil }
|
||||
if strict {
|
||||
// A sendable token must resolve to a positive, sane amount.
|
||||
guard let amount = info.amount, amount > 0 else { return nil }
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// MARK: - Base64url
|
||||
|
||||
private static func base64URLDecode(_ input: String) -> Data? {
|
||||
var s = input
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
// Normalize padding (wallets emit both padded and unpadded forms)
|
||||
s = s.replacingOccurrences(of: "=", with: "")
|
||||
let remainder = s.count % 4
|
||||
if remainder == 1 { return nil }
|
||||
if remainder > 0 { s += String(repeating: "=", count: 4 - remainder) }
|
||||
return Data(base64Encoded: s)
|
||||
}
|
||||
|
||||
// MARK: - V3 (JSON)
|
||||
|
||||
private static func decodeV3(_ payload: Data) -> TokenInfo? {
|
||||
guard let obj = (try? JSONSerialization.jsonObject(with: payload)) as? [String: Any],
|
||||
let entries = obj["token"] as? [[String: Any]],
|
||||
!entries.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
var total: Int64 = 0
|
||||
var sawAmount = false
|
||||
var mintHost: String?
|
||||
for entry in entries {
|
||||
if mintHost == nil, let mint = entry["mint"] as? String {
|
||||
mintHost = sanitizedHost(from: mint)
|
||||
}
|
||||
for proof in (entry["proofs"] as? [[String: Any]]) ?? [] {
|
||||
guard let number = proof["amount"] as? NSNumber else { continue }
|
||||
let value = number.int64Value
|
||||
guard value > 0, value <= maxAmount else { continue }
|
||||
total += value
|
||||
guard total <= maxAmount else { return nil }
|
||||
sawAmount = true
|
||||
}
|
||||
}
|
||||
return TokenInfo(
|
||||
version: "A",
|
||||
amount: sawAmount ? Int(total) : nil,
|
||||
unit: sanitizedUnit(obj["unit"] as? String),
|
||||
mintHost: mintHost,
|
||||
memo: sanitizedMemo(obj["memo"] as? String)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - V4 (CBOR)
|
||||
|
||||
/// Minimal walk of the NUT-00 TokenV4 CBOR map:
|
||||
/// { "m": mint, "u": unit, "d": memo, "t": [ { "i": bytes, "p": [ { "a": amount, … } ] } ] }
|
||||
private static func decodeV4(_ payload: Data) -> TokenInfo? {
|
||||
var reader = CBORReader(data: payload)
|
||||
guard case .map(let pairs)? = reader.parseValue(depth: 0) else { return nil }
|
||||
var mintHost: String?
|
||||
var unit: String?
|
||||
var memo: String?
|
||||
var total: Int64 = 0
|
||||
var sawAmount = false
|
||||
for (key, value) in pairs {
|
||||
guard case .text(let name) = key else { continue }
|
||||
switch (name, value) {
|
||||
case ("m", .text(let mint)):
|
||||
mintHost = sanitizedHost(from: mint)
|
||||
case ("u", .text(let u)):
|
||||
unit = sanitizedUnit(u)
|
||||
case ("d", .text(let d)):
|
||||
memo = sanitizedMemo(d)
|
||||
case ("t", .array(let groups)):
|
||||
for case .map(let group) in groups {
|
||||
for case (.text("p"), .array(let proofs)) in group {
|
||||
for case .map(let proof) in proofs {
|
||||
for case (.text("a"), .unsigned(let amount)) in proof {
|
||||
guard amount > 0, amount <= UInt64(maxAmount) else { continue }
|
||||
total += Int64(amount)
|
||||
guard total <= maxAmount else { return nil }
|
||||
sawAmount = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
return TokenInfo(
|
||||
version: "B",
|
||||
amount: sawAmount ? Int(total) : nil,
|
||||
unit: unit,
|
||||
mintHost: mintHost,
|
||||
memo: memo
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Display Sanitization (values are attacker-controlled)
|
||||
|
||||
private static func sanitizedHost(from mint: String) -> String? {
|
||||
guard mint.count <= 512, let host = URL(string: mint)?.host, !host.isEmpty else { return nil }
|
||||
return String(host.lowercased().prefix(48))
|
||||
}
|
||||
|
||||
private static func sanitizedUnit(_ unit: String?) -> String? {
|
||||
guard let unit, !unit.isEmpty, unit.count <= 12,
|
||||
unit.unicodeScalars.allSatisfy({ CharacterSet.alphanumerics.contains($0) }) else {
|
||||
return nil
|
||||
}
|
||||
return unit
|
||||
}
|
||||
|
||||
private static func sanitizedMemo(_ memo: String?) -> String? {
|
||||
guard let memo, memo.count <= 512 else { return nil }
|
||||
let stripped = CharacterSet.controlCharacters.union(.newlines)
|
||||
var cleaned = ""
|
||||
cleaned.unicodeScalars.append(contentsOf: memo.unicodeScalars.filter { !stripped.contains($0) })
|
||||
cleaned = cleaned.trimmingCharacters(in: .whitespaces)
|
||||
guard !cleaned.isEmpty else { return nil }
|
||||
return String(cleaned.prefix(80))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Minimal CBOR Reader
|
||||
|
||||
/// Just enough definite-length CBOR to traverse a TokenV4 map. Bounded in
|
||||
/// depth, item count, and byte length; indefinite-length items and anything
|
||||
/// else exotic make the parse fail (the caller degrades to a generic chip).
|
||||
private struct CBORReader {
|
||||
indirect enum Value {
|
||||
case unsigned(UInt64)
|
||||
case text(String)
|
||||
case array([Value])
|
||||
case map([(Value, Value)])
|
||||
/// Parsed-and-skipped content we don't need (byte strings, negatives, floats…)
|
||||
case opaque
|
||||
}
|
||||
|
||||
private let bytes: [UInt8]
|
||||
private var index = 0
|
||||
/// Total item budget so hostile nesting can't run away.
|
||||
private var itemBudget = 50_000
|
||||
private static let maxDepth = 16
|
||||
private static let maxContainerCount: UInt64 = 10_000
|
||||
|
||||
init(data: Data) {
|
||||
bytes = [UInt8](data)
|
||||
}
|
||||
|
||||
mutating func parseValue(depth: Int) -> Value? {
|
||||
guard depth < Self.maxDepth, itemBudget > 0 else { return nil }
|
||||
itemBudget -= 1
|
||||
guard let (major, argument) = readHead() else { return nil }
|
||||
switch major {
|
||||
case 0: // unsigned int
|
||||
return .unsigned(argument)
|
||||
case 1: // negative int (argument already consumed)
|
||||
return .opaque
|
||||
case 2: // byte string
|
||||
return readBytes(count: argument) != nil ? .opaque : nil
|
||||
case 3: // text string
|
||||
guard let raw = readBytes(count: argument) else { return nil }
|
||||
return String(bytes: raw, encoding: .utf8).map(Value.text) ?? .opaque
|
||||
case 4: // array
|
||||
guard argument <= Self.maxContainerCount else { return nil }
|
||||
var items: [Value] = []
|
||||
items.reserveCapacity(Int(min(argument, 64)))
|
||||
for _ in 0..<argument {
|
||||
guard let item = parseValue(depth: depth + 1) else { return nil }
|
||||
items.append(item)
|
||||
}
|
||||
return .array(items)
|
||||
case 5: // map
|
||||
guard argument <= Self.maxContainerCount else { return nil }
|
||||
var pairs: [(Value, Value)] = []
|
||||
pairs.reserveCapacity(Int(min(argument, 64)))
|
||||
for _ in 0..<argument {
|
||||
guard let key = parseValue(depth: depth + 1),
|
||||
let value = parseValue(depth: depth + 1) else { return nil }
|
||||
pairs.append((key, value))
|
||||
}
|
||||
return .map(pairs)
|
||||
case 6: // tag: skip the tag number, parse the tagged value
|
||||
return parseValue(depth: depth + 1)
|
||||
case 7: // simple values / floats (payload consumed by readHead)
|
||||
return .opaque
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads a CBOR head byte plus its argument. Rejects indefinite lengths.
|
||||
private mutating func readHead() -> (major: UInt8, argument: UInt64)? {
|
||||
guard index < bytes.count else { return nil }
|
||||
let head = bytes[index]
|
||||
index += 1
|
||||
let major = head >> 5
|
||||
let info = head & 0x1F
|
||||
switch info {
|
||||
case 0...23:
|
||||
return (major, UInt64(info))
|
||||
case 24:
|
||||
return readUInt(width: 1).map { (major, $0) }
|
||||
case 25:
|
||||
return readUInt(width: 2).map { (major, $0) }
|
||||
case 26:
|
||||
return readUInt(width: 4).map { (major, $0) }
|
||||
case 27:
|
||||
return readUInt(width: 8).map { (major, $0) }
|
||||
default: // 28-30 reserved, 31 indefinite
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func readUInt(width: Int) -> UInt64? {
|
||||
guard bytes.count - index >= width else { return nil }
|
||||
var value: UInt64 = 0
|
||||
for _ in 0..<width {
|
||||
value = (value << 8) | UInt64(bytes[index])
|
||||
index += 1
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private mutating func readBytes(count: UInt64) -> [UInt8]? {
|
||||
guard count <= UInt64(bytes.count - index) else { return nil }
|
||||
let length = Int(count)
|
||||
let slice = Array(bytes[index..<(index + length)])
|
||||
index += length
|
||||
return slice
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,17 @@ struct CommandGeoParticipant {
|
||||
let displayName: String
|
||||
}
|
||||
|
||||
/// The conversation a command was typed into, captured when the command is
|
||||
/// issued so deferred output (e.g. an async /ping result, which can arrive
|
||||
/// many seconds later) lands there even if the user switches chats first.
|
||||
enum CommandOutputDestination: Equatable {
|
||||
/// The #mesh public timeline. Commands that defer output (/ping) are
|
||||
/// mesh-only, so a non-DM origin is always the mesh timeline.
|
||||
case meshTimeline
|
||||
/// The private chat that was open when the command was typed.
|
||||
case privateChat(PeerID)
|
||||
}
|
||||
|
||||
/// Protocol defining what CommandProcessor needs from its context.
|
||||
/// This breaks the circular dependency between CommandProcessor and ChatViewModel.
|
||||
@MainActor
|
||||
@@ -31,7 +42,6 @@ protocol CommandContextProvider: AnyObject {
|
||||
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
|
||||
@@ -43,15 +53,36 @@ 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)
|
||||
/// Sends a normal public message (with local echo) to the active channel.
|
||||
func sendPublicMessage(_ content: String)
|
||||
|
||||
// MARK: - System Messages
|
||||
func addLocalPrivateSystemMessage(_ content: String, to peerID: PeerID)
|
||||
func addPublicSystemMessage(_ content: String)
|
||||
/// The conversation the user is typing into right now. Commands that
|
||||
/// finish asynchronously capture this BEFORE starting async work, so a
|
||||
/// chat switch cannot misroute their deferred output.
|
||||
func currentCommandDestination() -> CommandOutputDestination
|
||||
/// Routes deferred command output (e.g. an async /ping result) into the
|
||||
/// conversation captured when the command was issued.
|
||||
func addCommandOutput(_ content: String, to destination: CommandOutputDestination)
|
||||
|
||||
// MARK: - Favorites
|
||||
/// 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)
|
||||
|
||||
// MARK: - Groups
|
||||
// Group logic lives in `ChatGroupCoordinator`; these forward the parsed
|
||||
// /group subcommands.
|
||||
func groupCreate(named name: String) -> CommandResult
|
||||
func groupInvite(nickname: String) -> CommandResult
|
||||
func groupRemove(nickname: String) -> CommandResult
|
||||
func groupLeave() -> CommandResult
|
||||
func groupList() -> CommandResult
|
||||
}
|
||||
|
||||
/// Processes chat commands in a focused, efficient way
|
||||
@@ -98,17 +129,51 @@ final class CommandProcessor {
|
||||
return handleBlock(args)
|
||||
case "/unblock":
|
||||
return handleUnblock(args)
|
||||
case "/group":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "groups are only for mesh peers in #mesh") }
|
||||
return handleGroup(args)
|
||||
case "/fav":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||
return handleFavorite(args, add: true)
|
||||
case "/unfav":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||
return handleFavorite(args, add: false)
|
||||
case "/ping":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "ping only works for mesh peers in #mesh") }
|
||||
return handlePing(args)
|
||||
case "/trace":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "trace only works for mesh peers in #mesh") }
|
||||
return handleTrace(args)
|
||||
case "/pay":
|
||||
return handlePay(args)
|
||||
case "/help":
|
||||
return .success(message: Self.helpText)
|
||||
default:
|
||||
return .error(message: "unknown command: \(cmd)")
|
||||
return .error(message: "unknown command: \(cmd) — type /help for commands")
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
/group create <name> — start an encrypted group
|
||||
/group invite @name · /group remove @name — manage members (creator)
|
||||
/group leave · /group list — leave or list your groups
|
||||
/ping @name — measure round-trip time (mesh only)
|
||||
/trace @name — estimated mesh path (mesh only)
|
||||
/pay <token> — send a cashu ecash token in this chat
|
||||
/help — this list
|
||||
"""
|
||||
|
||||
// MARK: - Command Handlers
|
||||
|
||||
private func handleMessage(_ args: String) -> CommandResult {
|
||||
@@ -160,7 +225,7 @@ final class CommandProcessor {
|
||||
|
||||
private func handleClear() -> CommandResult {
|
||||
if let peerID = contextProvider?.selectedPrivateChatPeer {
|
||||
contextProvider?.privateChats[peerID]?.removeAll()
|
||||
contextProvider?.clearPrivateChat(peerID)
|
||||
} else {
|
||||
contextProvider?.clearCurrentPublicTimeline()
|
||||
}
|
||||
@@ -312,39 +377,168 @@ final class CommandProcessor {
|
||||
return .error(message: "cannot unblock \(nickname): not found")
|
||||
}
|
||||
|
||||
private static let groupUsage = "usage: /group create <name> · invite @name · remove @name · leave · list"
|
||||
|
||||
private func handleGroup(_ args: String) -> CommandResult {
|
||||
let parts = args.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
|
||||
guard let subcommand = parts.first else {
|
||||
return .error(message: Self.groupUsage)
|
||||
}
|
||||
let rest = parts.count > 1 ? String(parts[1]) : ""
|
||||
guard let provider = contextProvider else { return .handled }
|
||||
|
||||
switch subcommand {
|
||||
case "create":
|
||||
return provider.groupCreate(named: rest)
|
||||
case "invite":
|
||||
return provider.groupInvite(nickname: rest)
|
||||
case "remove":
|
||||
return provider.groupRemove(nickname: rest)
|
||||
case "leave":
|
||||
return provider.groupLeave()
|
||||
case "list":
|
||||
return provider.groupList()
|
||||
default:
|
||||
return .error(message: Self.groupUsage)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mesh Diagnostics
|
||||
|
||||
private enum MeshPeerResolution {
|
||||
case resolved(peerID: PeerID, nickname: String)
|
||||
case failed(CommandResult)
|
||||
}
|
||||
|
||||
/// Resolves a mesh peer for /ping and /trace. Geohash identities are
|
||||
/// rejected — diagnostics measure the BLE mesh, not Nostr.
|
||||
private func resolveMeshPeer(_ args: String, command: String) -> MeshPeerResolution {
|
||||
let targetName = args.trimmed
|
||||
guard !targetName.isEmpty else {
|
||||
return .failed(.error(message: "usage: /\(command) <nickname>"))
|
||||
}
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
!peerID.isGeoDM, !peerID.isGeoChat else {
|
||||
return .failed(.error(message: "cannot \(command) \(nickname): not found on mesh"))
|
||||
}
|
||||
return .resolved(peerID: peerID, nickname: nickname)
|
||||
}
|
||||
|
||||
private func handlePing(_ args: String) -> CommandResult {
|
||||
let target: (peerID: PeerID, nickname: String)
|
||||
switch resolveMeshPeer(args, command: "ping") {
|
||||
case .resolved(let peerID, let nickname): target = (peerID, nickname)
|
||||
case .failed(let result): return result
|
||||
}
|
||||
|
||||
let nickname = target.nickname
|
||||
let currentProvider = contextProvider
|
||||
// Capture the origin conversation now: the pong can arrive up to
|
||||
// meshPingTimeoutSeconds later, and reading the selected chat at
|
||||
// callback time would misroute the result after a chat switch.
|
||||
let destination = contextProvider?.currentCommandDestination() ?? .meshTimeline
|
||||
meshService?.sendMeshPing(to: target.peerID) { [weak currentProvider] result in
|
||||
let provider = currentProvider
|
||||
guard let result else {
|
||||
provider?.addCommandOutput("no reply from \(nickname)", to: destination)
|
||||
return
|
||||
}
|
||||
let hopText: String = result.hops.map { hops in
|
||||
hops == 1 ? " · direct (1 hop)" : " · \(hops) hops"
|
||||
} ?? ""
|
||||
provider?.addCommandOutput("pong from \(nickname): \(result.rttMs) ms\(hopText)", to: destination)
|
||||
}
|
||||
return .success(message: "pinging \(nickname)…")
|
||||
}
|
||||
|
||||
private func handleTrace(_ args: String) -> CommandResult {
|
||||
let target: (peerID: PeerID, nickname: String)
|
||||
switch resolveMeshPeer(args, command: "trace") {
|
||||
case .resolved(let peerID, let nickname): target = (peerID, nickname)
|
||||
case .failed(let result): return result
|
||||
}
|
||||
|
||||
guard let mesh = meshService,
|
||||
let intermediates = mesh.computeMeshPath(to: target.peerID) else {
|
||||
return .success(message: "no known path to \(target.nickname)")
|
||||
}
|
||||
// Graph-derived from gossiped neighbor claims, not route-recorded —
|
||||
// present it as an estimate.
|
||||
let hopNames = intermediates.map { hop in
|
||||
mesh.peerNickname(peerID: hop) ?? "\(hop.id.prefix(8))…"
|
||||
}
|
||||
let chain = (["you"] + hopNames + [target.nickname]).joined(separator: " → ")
|
||||
let hops = intermediates.count + 1
|
||||
return .success(message: "estimated path: \(chain) (\(hops) hop\(hops == 1 ? "" : "s"))")
|
||||
}
|
||||
|
||||
/// `/pay <cashu-token>` — validates the token decodes, then sends it as
|
||||
/// the message body in the current chat. Cashu tokens are bearer
|
||||
/// instruments (whoever redeems first gets the funds), so posting one to
|
||||
/// a public channel requires an explicit `/pay <token> public` confirm.
|
||||
/// The app never contacts a mint; it only relays the string.
|
||||
private func handlePay(_ args: String) -> CommandResult {
|
||||
var parts = args.trimmed.split(separator: " ").map(String.init)
|
||||
guard !parts.isEmpty else {
|
||||
return .success(message: "usage: /pay <token> — paste a cashu token: /pay cashuA…")
|
||||
}
|
||||
|
||||
let confirmedPublic = parts.count > 1 && parts.last?.lowercased() == "public"
|
||||
if confirmedPublic { parts.removeLast() }
|
||||
|
||||
guard parts.count == 1, let token = CashuTokenDecoder.bareToken(from: parts[0]) else {
|
||||
return .error(message: "that doesn't look like a cashu token — expected cashuA… or cashuB…")
|
||||
}
|
||||
guard let info = CashuTokenDecoder.decode(token, strict: true) else {
|
||||
return .error(message: "invalid cashu token — it doesn't decode to a known token with an amount, not sending it")
|
||||
}
|
||||
|
||||
let summary = info.displayAmount ?? "a cashu token"
|
||||
|
||||
if let peerID = contextProvider?.selectedPrivateChatPeer {
|
||||
contextProvider?.sendPrivateMessage(token, to: peerID)
|
||||
return .success(message: "sent \(summary) — cashu is a bearer token; whoever redeems it first gets the funds")
|
||||
}
|
||||
|
||||
guard confirmedPublic else {
|
||||
return .error(message: "this is a public channel — anyone reading it can redeem the token. send anyway: /pay <token> public")
|
||||
}
|
||||
|
||||
contextProvider?.sendPublicMessage(token)
|
||||
return .success(message: "sent \(summary) to the public channel — anyone here can redeem it")
|
||||
}
|
||||
|
||||
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
|
||||
let targetName = args.trimmed
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
|
||||
}
|
||||
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let noisePublicKey = Data(hexString: peerID.id) else {
|
||||
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
|
||||
return .error(message: "can't find peer: \(nickname)")
|
||||
}
|
||||
|
||||
if add {
|
||||
let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: noisePublicKey,
|
||||
peerNostrPublicKey: existingFavorite?.peerNostrPublicKey,
|
||||
peerNickname: nickname
|
||||
)
|
||||
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true)
|
||||
|
||||
return .success(message: "added \(nickname) to favorites")
|
||||
|
||||
// Resolve current state by the peer's real noise key. The resolved
|
||||
// peerID is either the short 16-hex mesh ID or the full 64-hex
|
||||
// noise-key ID (offline favorite row) — never the noise key itself.
|
||||
let isCurrentlyFavorite: Bool
|
||||
if let noiseKey = peerID.noiseKey {
|
||||
isCurrentlyFavorite = FavoritesPersistenceService.shared.isFavorite(noiseKey)
|
||||
} else {
|
||||
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
||||
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false)
|
||||
|
||||
return .success(message: "removed \(nickname) from favorites")
|
||||
isCurrentlyFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.isFavorite ?? false
|
||||
}
|
||||
|
||||
guard add != isCurrentlyFavorite else {
|
||||
return .success(message: add ? "\(nickname) is already a favorite" : "\(nickname) is not a favorite")
|
||||
}
|
||||
|
||||
// toggleFavorite persists by the real noise key and notifies the peer.
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
|
||||
return .success(message: add ? "added \(nickname) to favorites" : "removed \(nickname) from favorites")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
//
|
||||
// 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?
|
||||
/// Prekey-sealed (envelope v2) discriminator; nil for static-sealed v1.
|
||||
let prekeyID: UInt32?
|
||||
|
||||
var envelope: CourierEnvelope {
|
||||
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext, copies: copies, prekeyID: prekeyID)
|
||||
}
|
||||
|
||||
init(
|
||||
recipientTag: Data,
|
||||
expiry: UInt64,
|
||||
ciphertext: Data,
|
||||
depositorNoiseKey: Data,
|
||||
storedAt: Date,
|
||||
tier: CourierDepositTier,
|
||||
copies: UInt8,
|
||||
sprayedTo: Set<Data> = [],
|
||||
lastRemoteHandoverAt: Date? = nil,
|
||||
prekeyID: UInt32? = nil
|
||||
) {
|
||||
self.recipientTag = recipientTag
|
||||
self.expiry = expiry
|
||||
self.ciphertext = ciphertext
|
||||
self.depositorNoiseKey = depositorNoiseKey
|
||||
self.storedAt = storedAt
|
||||
self.tier = tier
|
||||
self.copies = copies
|
||||
self.sprayedTo = sprayedTo
|
||||
self.lastRemoteHandoverAt = lastRemoteHandoverAt
|
||||
self.prekeyID = prekeyID
|
||||
}
|
||||
|
||||
// Files written before tiers/spray lack the newer fields; treat that
|
||||
// mail as favorite-tier carry-only, which is what it was.
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
recipientTag = try container.decode(Data.self, forKey: .recipientTag)
|
||||
expiry = try container.decode(UInt64.self, forKey: .expiry)
|
||||
ciphertext = try container.decode(Data.self, forKey: .ciphertext)
|
||||
depositorNoiseKey = try container.decode(Data.self, forKey: .depositorNoiseKey)
|
||||
storedAt = try container.decode(Date.self, forKey: .storedAt)
|
||||
tier = try container.decodeIfPresent(CourierDepositTier.self, forKey: .tier) ?? .favorite
|
||||
copies = try container.decodeIfPresent(UInt8.self, forKey: .copies) ?? 1
|
||||
sprayedTo = try container.decodeIfPresent(Set<Data>.self, forKey: .sprayedTo) ?? []
|
||||
lastRemoteHandoverAt = try container.decodeIfPresent(Date.self, forKey: .lastRemoteHandoverAt)
|
||||
prekeyID = try container.decodeIfPresent(UInt32.self, forKey: .prekeyID)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
prekeyID: envelope.prekeyID
|
||||
))
|
||||
persistLocked()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handover (on encountering a peer)
|
||||
|
||||
/// Remove and return all envelopes addressed to the given peer, matching
|
||||
/// the rotating recipient tag across adjacent days. Envelopes are removed
|
||||
/// optimistically: handover happens over a live link, and the depositor's
|
||||
/// outbox still retains the original for direct delivery.
|
||||
func takeEnvelopes(for noiseStaticKey: Data) -> [CourierEnvelope] {
|
||||
let date = now()
|
||||
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date)
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
let matched = envelopes.filter { candidates.contains($0.recipientTag) }
|
||||
guard !matched.isEmpty else { return [] }
|
||||
envelopes.removeAll { stored in matched.contains(stored) }
|
||||
persistLocked()
|
||||
return matched.map(\.envelope)
|
||||
}
|
||||
}
|
||||
|
||||
/// Envelopes addressed to a recipient we heard from via a *relayed*
|
||||
/// announce. Non-destructive: a multi-hop send is speculative, so the
|
||||
/// envelope stays carried until a direct handover or expiry. The per-
|
||||
/// envelope cooldown keeps repeated announces from re-flooding the mesh.
|
||||
func envelopesForRemoteHandover(recipientNoiseKey: Data, cooldown: TimeInterval) -> [CourierEnvelope] {
|
||||
let date = now()
|
||||
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: recipientNoiseKey, around: date)
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
var matched: [CourierEnvelope] = []
|
||||
for index in envelopes.indices where candidates.contains(envelopes[index].recipientTag) {
|
||||
if let last = envelopes[index].lastRemoteHandoverAt,
|
||||
date.timeIntervalSince(last) < cooldown {
|
||||
continue
|
||||
}
|
||||
envelopes[index].lastRemoteHandoverAt = date
|
||||
// The delivered copy carries no spray budget.
|
||||
matched.append(envelopes[index].envelope.withCopies(1))
|
||||
}
|
||||
if !matched.isEmpty { persistLocked() }
|
||||
return matched
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Spray-and-wait (on encountering another courier)
|
||||
|
||||
/// Envelopes to re-deposit with a courier we just encountered, each with
|
||||
/// half its remaining budget (binary spray). Skips envelopes the courier
|
||||
/// deposited, envelopes addressed to them (those ride the handover path),
|
||||
/// carry-only envelopes, and couriers already sprayed.
|
||||
func takeSprayCopies(for courierNoiseKey: Data) -> [CourierEnvelope] {
|
||||
let date = now()
|
||||
let courierTags = CourierEnvelope.candidateTags(noiseStaticKey: courierNoiseKey, around: date)
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
var sprayed: [CourierEnvelope] = []
|
||||
for index in envelopes.indices {
|
||||
let stored = envelopes[index]
|
||||
guard stored.copies > 1,
|
||||
stored.depositorNoiseKey != courierNoiseKey,
|
||||
!stored.sprayedTo.contains(courierNoiseKey),
|
||||
!courierTags.contains(stored.recipientTag) else { continue }
|
||||
let given = stored.copies / 2
|
||||
envelopes[index].copies = stored.copies - given
|
||||
envelopes[index].sprayedTo.insert(courierNoiseKey)
|
||||
sprayed.append(stored.envelope.withCopies(given))
|
||||
}
|
||||
if !sprayed.isEmpty { persistLocked() }
|
||||
return sprayed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Maintenance
|
||||
|
||||
func pruneExpired() {
|
||||
let date = now()
|
||||
queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
persistLocked()
|
||||
}
|
||||
}
|
||||
|
||||
/// Panic wipe: drop all carried mail from memory and disk.
|
||||
func wipe() {
|
||||
queue.sync {
|
||||
envelopes.removeAll()
|
||||
if let fileURL {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
publishCountLocked()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals (call only on `queue`)
|
||||
|
||||
private func pruneExpiredLocked(at date: Date) {
|
||||
let before = envelopes.count
|
||||
envelopes.removeAll { $0.envelope.isExpired(at: date) }
|
||||
if envelopes.count != before {
|
||||
SecureLogger.debug("📦 Courier store pruned \(before - envelopes.count) expired envelope(s)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
private func publishCountLocked() {
|
||||
let count = envelopes.count
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.carriedCount = count
|
||||
}
|
||||
}
|
||||
|
||||
private func persistLocked() {
|
||||
publishCountLocked()
|
||||
guard let fileURL else { return }
|
||||
do {
|
||||
if envelopes.isEmpty {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
return
|
||||
}
|
||||
try FileManager.default.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let data = try JSONEncoder().encode(envelopes)
|
||||
var options: Data.WritingOptions = [.atomic]
|
||||
#if os(iOS)
|
||||
options.insert(.completeFileProtection)
|
||||
#endif
|
||||
try data.write(to: fileURL, options: options)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to persist courier store: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFromDisk() {
|
||||
guard let fileURL else { return }
|
||||
queue.sync {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let stored = try? JSONDecoder().decode([StoredEnvelope].self, from: data) else {
|
||||
return
|
||||
}
|
||||
envelopes = stored
|
||||
pruneExpiredLocked(at: now())
|
||||
publishCountLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private static func defaultFileURL() -> URL? {
|
||||
guard let base = try? FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
) else { return nil }
|
||||
return base
|
||||
.appendingPathComponent("courier", isDirectory: true)
|
||||
.appendingPathComponent("envelopes.json")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//
|
||||
// MessageOutboxStore.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Disk persistence for the MessageRouter outbox, so private messages queued
|
||||
/// for an offline peer survive an app kill instead of silently evaporating.
|
||||
///
|
||||
/// Nothing else in the app persists message plaintext, and this store keeps
|
||||
/// that property: the outbox is sealed with a ChaChaPoly key that lives only
|
||||
/// in the Keychain (after-first-unlock, this device only), on top of iOS file
|
||||
/// protection. Wiped on panic alongside the courier store.
|
||||
final class MessageOutboxStore {
|
||||
struct QueuedMessage: Codable, Equatable {
|
||||
let content: String
|
||||
let nickname: String
|
||||
let messageID: String
|
||||
let timestamp: Date
|
||||
var sendAttempts: Int
|
||||
/// Noise keys of couriers already carrying this message, so deposit
|
||||
/// retries add couriers instead of re-burning the same ones.
|
||||
var depositedCourierKeys: Set<Data>
|
||||
|
||||
init(
|
||||
content: String,
|
||||
nickname: String,
|
||||
messageID: String,
|
||||
timestamp: Date,
|
||||
sendAttempts: Int = 0,
|
||||
depositedCourierKeys: Set<Data> = []
|
||||
) {
|
||||
self.content = content
|
||||
self.nickname = nickname
|
||||
self.messageID = messageID
|
||||
self.timestamp = timestamp
|
||||
self.sendAttempts = sendAttempts
|
||||
self.depositedCourierKeys = depositedCourierKeys
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
content = try container.decode(String.self, forKey: .content)
|
||||
nickname = try container.decode(String.self, forKey: .nickname)
|
||||
messageID = try container.decode(String.self, forKey: .messageID)
|
||||
timestamp = try container.decode(Date.self, forKey: .timestamp)
|
||||
sendAttempts = try container.decodeIfPresent(Int.self, forKey: .sendAttempts) ?? 0
|
||||
depositedCourierKeys = try container.decodeIfPresent(Set<Data>.self, forKey: .depositedCourierKeys) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
private static let keychainService = "chat.bitchat.outbox"
|
||||
private static let keychainKey = "outbox-encryption-key"
|
||||
|
||||
private let fileURL: URL?
|
||||
private let keychain: KeychainManagerProtocol
|
||||
|
||||
init(keychain: KeychainManagerProtocol, fileURL: URL? = nil) {
|
||||
self.keychain = keychain
|
||||
self.fileURL = fileURL ?? Self.defaultFileURL()
|
||||
}
|
||||
|
||||
// MARK: - API (call from the router's actor; IO is small and atomic)
|
||||
|
||||
func load() -> [PeerID: [QueuedMessage]] {
|
||||
guard let fileURL,
|
||||
let sealed = try? Data(contentsOf: fileURL),
|
||||
let key = encryptionKey(createIfMissing: false),
|
||||
let box = try? ChaChaPoly.SealedBox(combined: sealed),
|
||||
let plaintext = try? ChaChaPoly.open(box, using: key),
|
||||
let decoded = try? JSONDecoder().decode([String: [QueuedMessage]].self, from: plaintext) else {
|
||||
return [:]
|
||||
}
|
||||
var outbox: [PeerID: [QueuedMessage]] = [:]
|
||||
for (peerID, queue) in decoded where !queue.isEmpty {
|
||||
outbox[PeerID(str: peerID)] = queue
|
||||
}
|
||||
return outbox
|
||||
}
|
||||
|
||||
func save(_ outbox: [PeerID: [QueuedMessage]]) {
|
||||
guard let fileURL else { return }
|
||||
let flattened = outbox.filter { !$0.value.isEmpty }
|
||||
guard !flattened.isEmpty else {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
return
|
||||
}
|
||||
guard let key = encryptionKey(createIfMissing: true) else {
|
||||
SecureLogger.error("Outbox not persisted: no encryption key available", category: .session)
|
||||
return
|
||||
}
|
||||
do {
|
||||
let keyed = Dictionary(uniqueKeysWithValues: flattened.map { ($0.key.id, $0.value) })
|
||||
let plaintext = try JSONEncoder().encode(keyed)
|
||||
let sealed = try ChaChaPoly.seal(plaintext, using: key).combined
|
||||
try FileManager.default.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
var options: Data.WritingOptions = [.atomic]
|
||||
#if os(iOS)
|
||||
options.insert(.completeFileProtection)
|
||||
#endif
|
||||
try sealed.write(to: fileURL, options: options)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to persist outbox: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
/// Panic wipe: drop the queued mail and the key that could ever read it.
|
||||
func wipe() {
|
||||
if let fileURL {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
keychain.delete(key: Self.keychainKey, service: Self.keychainService)
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private func encryptionKey(createIfMissing: Bool) -> SymmetricKey? {
|
||||
if let data = keychain.load(key: Self.keychainKey, service: Self.keychainService), data.count == 32 {
|
||||
return SymmetricKey(data: data)
|
||||
}
|
||||
guard createIfMissing else { return nil }
|
||||
let key = SymmetricKey(size: .bits256)
|
||||
let data = key.withUnsafeBytes { Data($0) }
|
||||
// After-first-unlock so queued mail can flush from background BLE wakes.
|
||||
keychain.save(
|
||||
key: Self.keychainKey,
|
||||
data: data,
|
||||
service: Self.keychainService,
|
||||
accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
)
|
||||
return key
|
||||
}
|
||||
|
||||
private static func defaultFileURL() -> URL? {
|
||||
guard let base = try? FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
) else { return nil }
|
||||
return base
|
||||
.appendingPathComponent("courier", isDirectory: true)
|
||||
.appendingPathComponent("outbox.sealed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// StoreAndForwardMetrics.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Privacy-safe local counters for the store-and-forward stack: bare event
|
||||
/// tallies with no message IDs, peer identities, or timestamps, so delivery
|
||||
/// behavior can be measured on-device without recording who talked to whom.
|
||||
/// Log-only surface — nothing here ever leaves the device.
|
||||
final class StoreAndForwardMetrics {
|
||||
enum Event: String, CaseIterable {
|
||||
/// A private message entered the outbox (no prompt route available).
|
||||
case outboxQueued = "outbox.queued"
|
||||
/// A retained message was re-sent on a flush.
|
||||
case outboxResent = "outbox.resent"
|
||||
/// A delivery/read ack cleared a retained message.
|
||||
case outboxDelivered = "outbox.delivered"
|
||||
/// A retained message was dropped (attempt cap, TTL, or overflow).
|
||||
case outboxDropped = "outbox.dropped"
|
||||
/// We handed sealed mail to a courier.
|
||||
case courierDeposited = "courier.deposited"
|
||||
/// We accepted sealed mail to carry for a third party.
|
||||
case courierAccepted = "courier.accepted"
|
||||
/// We handed carried mail to its recipient over a direct link.
|
||||
case courierHandedOver = "courier.handedOver"
|
||||
/// We pushed carried mail toward a recipient heard via relay.
|
||||
case courierRemoteHandover = "courier.remoteHandover"
|
||||
/// We split spray copies to another courier.
|
||||
case courierSprayed = "courier.sprayed"
|
||||
/// Couriered mail addressed to us was opened and delivered.
|
||||
case courierOpened = "courier.opened"
|
||||
}
|
||||
|
||||
static let shared = StoreAndForwardMetrics()
|
||||
|
||||
private let lock = NSLock()
|
||||
private var counts: [String: Int]
|
||||
private let defaults: UserDefaults
|
||||
private static let defaultsKey = "chat.bitchat.storeAndForwardMetrics"
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
self.counts = defaults.dictionary(forKey: Self.defaultsKey) as? [String: Int] ?? [:]
|
||||
}
|
||||
|
||||
func record(_ event: Event) {
|
||||
lock.lock()
|
||||
let total = (counts[event.rawValue] ?? 0) + 1
|
||||
counts[event.rawValue] = total
|
||||
defaults.set(counts, forKey: Self.defaultsKey)
|
||||
lock.unlock()
|
||||
SecureLogger.debug("📊 S&F \(event.rawValue) → \(total)", category: .session)
|
||||
}
|
||||
|
||||
func snapshot() -> [String: Int] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return counts
|
||||
}
|
||||
|
||||
/// Included in the panic wipe alongside the stores it describes.
|
||||
func reset() {
|
||||
lock.lock()
|
||||
counts = [:]
|
||||
defaults.removeObject(forKey: Self.defaultsKey)
|
||||
lock.unlock()
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,23 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
|
||||
static let shared = FavoritesPersistenceService()
|
||||
|
||||
init(keychain: KeychainManagerProtocol = KeychainManager()) {
|
||||
/// Default keychain for the `shared` singleton. Under test this is an
|
||||
/// in-memory keychain so touching `shared` never blocks on securityd
|
||||
/// (`SecItemCopyMatching` can hang in test environments) and never reads
|
||||
/// or writes the developer's real keychain. Production behavior is
|
||||
/// unchanged. Tests that need their own instance keep injecting a mock
|
||||
/// via `init(keychain:)`.
|
||||
private nonisolated static func makeDefaultKeychain() -> KeychainManagerProtocol {
|
||||
// PreviewKeychainManager lives in _PreviewHelpers, a development
|
||||
// asset excluded from archive builds — release code must not
|
||||
// reference it. Tests always run Debug, so the guard is lossless.
|
||||
#if DEBUG
|
||||
if TestEnvironment.isRunningTests { return PreviewKeychainManager() }
|
||||
#endif
|
||||
return KeychainManager()
|
||||
}
|
||||
|
||||
init(keychain: KeychainManagerProtocol = FavoritesPersistenceService.makeDefaultKeychain()) {
|
||||
self.keychain = keychain
|
||||
loadFavorites()
|
||||
|
||||
@@ -125,7 +141,13 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
peerNostrPublicKey: String? = nil
|
||||
) {
|
||||
let existing = favorites[peerNoisePublicKey]
|
||||
let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown"
|
||||
// Callers that can't resolve the live nickname pass the "Unknown"
|
||||
// placeholder (e.g. a notification arriving before the announce);
|
||||
// never let it clobber a real stored nickname.
|
||||
let incoming = peerNickname.flatMap { name in
|
||||
(name.isEmpty || name == "Unknown") ? nil : name
|
||||
}
|
||||
let displayName = incoming ?? existing?.peerNickname ?? "Unknown"
|
||||
|
||||
SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
|
||||
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
//
|
||||
// GatewayService.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
|
||||
|
||||
/// Policy engine for gateway mode: an opt-in "share my internet with the
|
||||
/// mesh" bridge. While the toggle is on, this device advertises the
|
||||
/// `.gateway` capability bit, publishes signed geohash events deposited by
|
||||
/// mesh-only peers to Nostr relays (uplink), and rebroadcasts inbound relay
|
||||
/// events onto the mesh (downlink) so mesh-only peers can take part in the
|
||||
/// local geohash channel. Mesh-only peers need no toggle: their uplink
|
||||
/// engages automatically when relays are unreachable and a gateway peer
|
||||
/// exists.
|
||||
///
|
||||
/// Threat model:
|
||||
/// - Keys never leave the originating device. Mesh-only senders sign events
|
||||
/// locally with their per-geohash ephemeral identity; the gateway carries
|
||||
/// only the finished, signed event.
|
||||
/// - The gateway cannot forge or alter events: every carried event is
|
||||
/// Schnorr-verified here before it is published or rebroadcast, and again
|
||||
/// independently by relays and receivers.
|
||||
/// - Carried contents are public geohash chat, already plaintext on Nostr,
|
||||
/// so the mesh carrier adds no confidentiality loss.
|
||||
///
|
||||
/// Loop-prevention rules:
|
||||
/// 1. An event learned from a `fromGateway` mesh broadcast is never
|
||||
/// re-published to relays, never re-uplinked, and never rebroadcast
|
||||
/// (`meshBroadcastEventIDs`), so a second gateway on the same mesh cannot
|
||||
/// echo mesh-carried traffic back out. Mesh-level propagation of the
|
||||
/// original broadcast packet is the TTL relay's job, not ours.
|
||||
/// 2. An uplink deposit is published at most once (`publishedEventIDs`) and
|
||||
/// a relay event is rebroadcast at most once (`rebroadcastEventIDs`), so
|
||||
/// repeat deposits and relay echoes are absorbed.
|
||||
/// 3. Uplink is only attempted for locally composed events at the send site
|
||||
/// (`GeohashSubscriptionManager.sendGeohash`); events received over the
|
||||
/// carrier never re-enter the uplink path. This is a call-site convention;
|
||||
/// the `meshBroadcastEventIDs`/`publishedEventIDs` backstops in
|
||||
/// `uplinkViaMesh` enforce it defensively and are unit-tested.
|
||||
/// Rules 1 and 2 are enforced here and unit-tested.
|
||||
/// Rebroadcast storms at the mesh layer are additionally bounded by the BLE
|
||||
/// `MessageDeduplicator` and packet TTL, and receivers dedup carried events
|
||||
/// against their own relay subscriptions via the Nostr event-ID cache in
|
||||
/// `NostrInboundPipeline`.
|
||||
///
|
||||
/// All dependencies are closure-injected (repo convention) so the policy
|
||||
/// layer is unit-testable without relays or radios.
|
||||
@MainActor
|
||||
final class GatewayService: ObservableObject {
|
||||
enum Limits {
|
||||
/// Uplink deposits held while relays are unreachable (CourierStore-style
|
||||
/// bounded mailbag: bounded total, bounded per depositor).
|
||||
static let maxQueuedUplinks = 20
|
||||
static let maxQueuedUplinksPerDepositor = 5
|
||||
/// Uplink deposits accepted per depositor per minute.
|
||||
static let uplinkEventsPerMinutePerDepositor = 10
|
||||
/// Downlink mesh rebroadcasts per minute — BLE airtime is precious.
|
||||
/// Beyond the budget events queue (bounded, drop-oldest) and drain on
|
||||
/// a scheduled timer once the window frees (also re-driven by the next
|
||||
/// inbound relay event); a quiet channel does not strand its backlog.
|
||||
static let downlinkEventsPerMinute = 30
|
||||
static let maxPendingDownlinks = 30
|
||||
/// Accepted clock skew for a carried ephemeral event; anything older
|
||||
/// is stale replay the relays would drop anyway.
|
||||
static let maxEventAgeSeconds: TimeInterval = 15 * 60
|
||||
/// Bounded loop-prevention ID caches (oldest evicted).
|
||||
static let maxTrackedEventIDs = 512
|
||||
}
|
||||
|
||||
struct QueuedUplink {
|
||||
let depositor: PeerID
|
||||
let geohash: String
|
||||
let event: NostrEvent
|
||||
let queuedAt: Date
|
||||
}
|
||||
|
||||
static let shared = GatewayService()
|
||||
|
||||
/// The user toggle. While true this device advertises `.gateway` and
|
||||
/// bridges mesh <-> Nostr for geohash channels.
|
||||
@Published private(set) var isEnabled: Bool
|
||||
|
||||
// MARK: Wiring (set once by the bootstrapper; fakes in tests)
|
||||
|
||||
/// Publishes a verified event to the geo relays for a geohash.
|
||||
var publishToRelays: (@MainActor (NostrEvent, String) -> Void)?
|
||||
/// Broadcasts an encoded `fromGateway` carrier payload on the mesh.
|
||||
var broadcastToMesh: (@MainActor (Data) -> Void)?
|
||||
/// Sends an encoded `toGateway` carrier payload directed to a gateway
|
||||
/// peer. Returns false when the transport could not accept it.
|
||||
var sendToGatewayPeer: (@MainActor (Data, PeerID) -> Bool)?
|
||||
/// Reachable mesh peers currently advertising the `.gateway` capability.
|
||||
var availableGatewayPeers: (@MainActor () -> [PeerID])?
|
||||
/// Whether any Nostr relay connection is currently working.
|
||||
var relaysConnected: (@MainActor () -> Bool)?
|
||||
/// The geohash channel the local user is viewing, if any.
|
||||
var currentGeohash: (@MainActor () -> String?)?
|
||||
/// Injects a verified carried event into the same inbound pipeline as
|
||||
/// relay-received events (blocking, rate limits, dedup, rendering).
|
||||
var injectInbound: (@MainActor (NostrEvent) -> Void)?
|
||||
/// Fired on toggle changes (advertise/withdraw the capability bit and
|
||||
/// force a re-announce).
|
||||
var onEnabledChanged: (@MainActor (Bool) -> Void)?
|
||||
/// Schedules a downlink-drain closure to run after a delay. Injected so
|
||||
/// the drain timer is deterministic in tests; nil arms a real `Task`.
|
||||
var scheduleDrainTimer: (@MainActor (TimeInterval, @escaping @MainActor () -> Void) -> Void)?
|
||||
|
||||
// MARK: State
|
||||
|
||||
/// Loop rule 1: event IDs seen in `fromGateway` mesh broadcasts.
|
||||
private var meshBroadcastEventIDs: BoundedIDSet
|
||||
/// Loop rule 2 (uplink): event IDs this gateway already published.
|
||||
private var publishedEventIDs: BoundedIDSet
|
||||
/// Loop rule 2 (downlink): event IDs this gateway already rebroadcast.
|
||||
private var rebroadcastEventIDs: BoundedIDSet
|
||||
|
||||
private(set) var queuedUplinks: [QueuedUplink] = []
|
||||
private var uplinkDepositTimes: [PeerID: [Date]] = [:]
|
||||
private var downlinkSendTimes: [Date] = []
|
||||
private var pendingDownlinks: [(event: NostrEvent, geohash: String)] = []
|
||||
/// True while a drain timer is armed, so a burst schedules at most one.
|
||||
private var downlinkDrainScheduled = false
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let now: () -> Date
|
||||
private static let enabledKey = "gateway.userEnabled"
|
||||
|
||||
init(defaults: UserDefaults = .standard, now: @escaping () -> Date = Date.init) {
|
||||
self.defaults = defaults
|
||||
self.now = now
|
||||
self.isEnabled = defaults.bool(forKey: Self.enabledKey)
|
||||
self.meshBroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
|
||||
self.publishedEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
|
||||
self.rebroadcastEventIDs = BoundedIDSet(capacity: Limits.maxTrackedEventIDs)
|
||||
}
|
||||
|
||||
// MARK: - Toggle
|
||||
|
||||
func setEnabled(_ enabled: Bool) {
|
||||
guard enabled != isEnabled else { return }
|
||||
isEnabled = enabled
|
||||
defaults.set(enabled, forKey: Self.enabledKey)
|
||||
if !enabled {
|
||||
queuedUplinks.removeAll()
|
||||
pendingDownlinks.removeAll()
|
||||
uplinkDepositTimes.removeAll()
|
||||
}
|
||||
SecureLogger.info("[GW] mode \(enabled ? "enabled" : "disabled")", category: .gateway)
|
||||
onEnabledChanged?(enabled)
|
||||
}
|
||||
|
||||
// MARK: - Mesh carrier ingress (both roles)
|
||||
|
||||
/// Entry point for received `nostrCarrier` packets. `directedToUs` is
|
||||
/// true for packets addressed to this device (uplink deposits); false
|
||||
/// for broadcasts (downlink rebroadcasts from a gateway).
|
||||
func handleMeshCarrier(_ payload: Data, from peerID: PeerID, directedToUs: Bool) {
|
||||
guard let carrier = NostrCarrierPacket.decode(payload) else {
|
||||
SecureLogger.debug("[GW] carrier drop (undecodable) from \(peerID.id.prefix(8))…", category: .gateway)
|
||||
return
|
||||
}
|
||||
switch carrier.direction {
|
||||
case .toGateway:
|
||||
// Uplink deposits are directed; a broadcast toGateway is malformed.
|
||||
guard directedToUs else { return }
|
||||
handleUplinkDeposit(carrier, from: peerID)
|
||||
case .fromGateway:
|
||||
// Downlink rides broadcast only; a directed fromGateway is malformed.
|
||||
guard !directedToUs else { return }
|
||||
handleDownlinkBroadcast(carrier)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Uplink (gateway role: mesh peer -> internet)
|
||||
|
||||
private func handleUplinkDeposit(_ carrier: NostrCarrierPacket, from depositor: PeerID) {
|
||||
guard isEnabled else { return }
|
||||
// Cheap structural checks first (parse, size, geohash, kind, #g tag,
|
||||
// age) — no crypto — so junk and stale replays are dropped before we
|
||||
// ever pay for a MainActor Schnorr verify.
|
||||
guard let event = structurallyValidEvent(from: carrier) else {
|
||||
SecureLogger.info("[GW] uplink reject (validation) from \(depositor.id.prefix(8))…", category: .gateway)
|
||||
return
|
||||
}
|
||||
// Dedup by the carried event ID BEFORE verification. Loop rule 1: a
|
||||
// fromGateway-learned event is mesh-carried and must never be
|
||||
// re-published. Loop rule 2: repeat deposits of an already handled
|
||||
// event are absorbed. A replay of one valid deposit is short-circuited
|
||||
// here without a per-packet signature verify.
|
||||
guard !meshBroadcastEventIDs.contains(event.id),
|
||||
!publishedEventIDs.contains(event.id),
|
||||
!queuedUplinks.contains(where: { $0.event.id == event.id }) else {
|
||||
SecureLogger.debug("[GW] uplink skip (loop/dup) \(event.id.prefix(8))…", category: .gateway)
|
||||
return
|
||||
}
|
||||
// Consume the per-depositor rate token BEFORE the expensive verify so
|
||||
// a flood of distinct forged/junk deposits is bounded by cheap work,
|
||||
// not by main-actor Schnorr verifications.
|
||||
guard allowUplinkDeposit(from: depositor) else {
|
||||
SecureLogger.info("[GW] uplink reject (rate-limit) from \(depositor.id.prefix(8))…", category: .gateway)
|
||||
return
|
||||
}
|
||||
// Only now pay for cryptographic verification; receivers verify again.
|
||||
guard event.isValidSignature() else {
|
||||
SecureLogger.info("[GW] uplink reject (sig-fail) from \(depositor.id.prefix(8))…", category: .gateway)
|
||||
return
|
||||
}
|
||||
|
||||
let accepted: Bool
|
||||
if relaysConnected?() ?? false {
|
||||
publish(event, geohash: carrier.geohash)
|
||||
accepted = true
|
||||
} else {
|
||||
accepted = enqueueUplink(QueuedUplink(depositor: depositor, geohash: carrier.geohash, event: event, queuedAt: now()))
|
||||
if accepted {
|
||||
SecureLogger.info("[GW] uplink accept→queue \(event.id.prefix(8))… (relays down)", category: .gateway)
|
||||
}
|
||||
}
|
||||
|
||||
// Only render on our own timeline what we actually accepted for
|
||||
// publish or queue: a quota-dropped deposit is never published and,
|
||||
// being directed, no other peer will ever see it, so showing it would
|
||||
// diverge our timeline permanently from what reached the channel.
|
||||
if accepted, currentGeohash?() == carrier.geohash {
|
||||
injectInbound?(event)
|
||||
}
|
||||
}
|
||||
|
||||
/// Publish everything queued while relays were unreachable. Called when
|
||||
/// relay connectivity comes back.
|
||||
func flushQueuedUplinks() {
|
||||
guard isEnabled, relaysConnected?() ?? false, !queuedUplinks.isEmpty else { return }
|
||||
let queued = queuedUplinks
|
||||
queuedUplinks.removeAll()
|
||||
for item in queued where !publishedEventIDs.contains(item.event.id) {
|
||||
publish(item.event, geohash: item.geohash)
|
||||
}
|
||||
}
|
||||
|
||||
private func publish(_ event: NostrEvent, geohash: String) {
|
||||
publishedEventIDs.insert(event.id)
|
||||
publishToRelays?(event, geohash)
|
||||
SecureLogger.info("[GW] uplink accept→publish \(event.id.prefix(8))… to relays for #\(geohash)", category: .gateway)
|
||||
}
|
||||
|
||||
/// Returns true when the item was actually stored for later publish.
|
||||
@discardableResult
|
||||
private func enqueueUplink(_ item: QueuedUplink) -> Bool {
|
||||
let fromDepositor = queuedUplinks.filter { $0.depositor == item.depositor }.count
|
||||
guard fromDepositor < Limits.maxQueuedUplinksPerDepositor else {
|
||||
SecureLogger.info("[GW] uplink reject (quota) for \(item.depositor.id.prefix(8))…", category: .gateway)
|
||||
return false
|
||||
}
|
||||
if queuedUplinks.count >= Limits.maxQueuedUplinks {
|
||||
queuedUplinks.removeFirst(queuedUplinks.count - Limits.maxQueuedUplinks + 1)
|
||||
}
|
||||
queuedUplinks.append(item)
|
||||
return true
|
||||
}
|
||||
|
||||
private func allowUplinkDeposit(from depositor: PeerID) -> Bool {
|
||||
let cutoff = now().addingTimeInterval(-60)
|
||||
var times = uplinkDepositTimes[depositor, default: []]
|
||||
times.removeAll { $0 < cutoff }
|
||||
guard times.count < Limits.uplinkEventsPerMinutePerDepositor else {
|
||||
uplinkDepositTimes[depositor] = times
|
||||
return false
|
||||
}
|
||||
times.append(now())
|
||||
uplinkDepositTimes[depositor] = times
|
||||
// Bound the tracker itself against a churn of spoofed depositors.
|
||||
if uplinkDepositTimes.count > Limits.maxTrackedEventIDs {
|
||||
uplinkDepositTimes = uplinkDepositTimes.filter { !$0.value.isEmpty && $0.value.contains { $0 >= cutoff } }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Downlink (gateway role: internet -> mesh)
|
||||
|
||||
/// Called for every event the gateway's own geohash-channel subscription
|
||||
/// delivers. Wraps it in a `fromGateway` carrier and broadcasts it on
|
||||
/// the mesh, within the airtime budget.
|
||||
func rebroadcastRelayEvent(_ event: NostrEvent, geohash: String) {
|
||||
guard isEnabled, broadcastToMesh != nil else { return }
|
||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||
// Freshness + geohash gate BEFORE spending any budget. A channel
|
||||
// (re)subscribe backfills up to an hour of history (limit 200), but
|
||||
// every receiver's `validatedEvent` drops anything older than the
|
||||
// same window — so rebroadcasting backfill would burn the whole
|
||||
// per-minute budget on events no mesh peer accepts. Also require the
|
||||
// event's own `#g` tag to match the carrier geohash.
|
||||
guard isFresh(event),
|
||||
event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == geohash }) else {
|
||||
SecureLogger.debug("[GW] downlink drop (stale/mismatch) \(event.id.prefix(8))…", category: .gateway)
|
||||
return
|
||||
}
|
||||
// Loop rule 1: never rebroadcast mesh-carried events back onto the
|
||||
// mesh. Loop rule 2: rebroadcast each relay event at most once — but
|
||||
// mark only AFTER it is actually sent (in `drainPendingDownlinks`), so
|
||||
// an event dropped by the queue overflow stays retryable on relay
|
||||
// redelivery. Guard against a redelivery re-queueing an event that is
|
||||
// still waiting to be sent.
|
||||
guard !meshBroadcastEventIDs.contains(event.id),
|
||||
!rebroadcastEventIDs.contains(event.id),
|
||||
!pendingDownlinks.contains(where: { $0.event.id == event.id }) else {
|
||||
SecureLogger.debug("[GW] downlink skip (loop/dup) \(event.id.prefix(8))…", category: .gateway)
|
||||
return
|
||||
}
|
||||
// Verify before spending BLE airtime; receivers verify again.
|
||||
guard event.isValidSignature() else {
|
||||
SecureLogger.debug("[GW] downlink drop (sig-fail) \(event.id.prefix(8))…", category: .gateway)
|
||||
return
|
||||
}
|
||||
|
||||
pendingDownlinks.append((event, geohash))
|
||||
if pendingDownlinks.count > Limits.maxPendingDownlinks {
|
||||
// Bandwidth guard: drop-oldest — fresher chat is worth more. The
|
||||
// dropped event is not yet in `rebroadcastEventIDs`, so a later
|
||||
// relay redelivery can still carry it.
|
||||
pendingDownlinks.removeFirst(pendingDownlinks.count - Limits.maxPendingDownlinks)
|
||||
SecureLogger.info("[GW] downlink drop-oldest (budget), \(pendingDownlinks.count) pending", category: .gateway)
|
||||
}
|
||||
drainPendingDownlinks()
|
||||
}
|
||||
|
||||
private func drainPendingDownlinks() {
|
||||
let cutoff = now().addingTimeInterval(-60)
|
||||
downlinkSendTimes.removeAll { $0 < cutoff }
|
||||
while !pendingDownlinks.isEmpty,
|
||||
downlinkSendTimes.count < Limits.downlinkEventsPerMinute {
|
||||
let (event, geohash) = pendingDownlinks.removeFirst()
|
||||
// A queued event may have aged past the window while it waited;
|
||||
// don't burn airtime on what receivers would now drop.
|
||||
guard isFresh(event) else { continue }
|
||||
guard let carrier = NostrCarrierPacket(direction: .fromGateway, geohash: geohash, event: event),
|
||||
let payload = carrier.encode() else { continue }
|
||||
broadcastToMesh?(payload)
|
||||
SecureLogger.info("[GW] downlink rebroadcast \(event.id.prefix(8))… to mesh for #\(geohash)", category: .gateway)
|
||||
// Mark-after-send: only now is the relay event definitively
|
||||
// rebroadcast (loop rule 2).
|
||||
rebroadcastEventIDs.insert(event.id)
|
||||
downlinkSendTimes.append(now())
|
||||
}
|
||||
// Budget exhausted with events still queued: arm a timer to drain when
|
||||
// the window frees, instead of stranding them until the next inbound
|
||||
// relay event (which may never come on a channel that went quiet).
|
||||
scheduleDownlinkDrainIfNeeded()
|
||||
}
|
||||
|
||||
/// Arms a single timer to drain the backlog once the per-minute window
|
||||
/// frees. No-op when nothing is pending or a drain is already scheduled.
|
||||
private func scheduleDownlinkDrainIfNeeded() {
|
||||
guard !pendingDownlinks.isEmpty, !downlinkDrainScheduled else { return }
|
||||
// The window frees when the oldest recorded send ages out of 60s.
|
||||
let oldest = downlinkSendTimes.min() ?? now()
|
||||
let delay = max(0.05, 60 - now().timeIntervalSince(oldest))
|
||||
downlinkDrainScheduled = true
|
||||
SecureLogger.info("[GW] drain-timer armed (\(String(format: "%.1f", delay))s, \(pendingDownlinks.count) pending)", category: .gateway)
|
||||
let fire: @MainActor () -> Void = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.downlinkDrainScheduled = false
|
||||
SecureLogger.info("[GW] drain-timer fired", category: .gateway)
|
||||
self.drainPendingDownlinks()
|
||||
}
|
||||
if let scheduleDrainTimer {
|
||||
scheduleDrainTimer(delay, fire)
|
||||
} else {
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
|
||||
fire()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Downlink (receiver role: carried event arrives over mesh)
|
||||
|
||||
private func handleDownlinkBroadcast(_ carrier: NostrCarrierPacket) {
|
||||
guard let event = validatedEvent(from: carrier) else { return }
|
||||
// Mark only AFTER signature verification, so a forged copy carrying a
|
||||
// real event's ID cannot poison the never-republish set, and use the
|
||||
// marking as dedup: the same broadcast relayed along several mesh
|
||||
// paths injects once (the pipeline's Nostr event-ID cache additionally
|
||||
// dedups against our own relay subscription).
|
||||
guard meshBroadcastEventIDs.insert(event.id) else { return }
|
||||
// Only inject events for the channel we're viewing; the inbound
|
||||
// pipeline files public messages under the current geohash.
|
||||
guard currentGeohash?() == carrier.geohash else { return }
|
||||
injectInbound?(event)
|
||||
}
|
||||
|
||||
// MARK: - Uplink (sender role: mesh-only peer with no relays)
|
||||
|
||||
/// Hands a locally signed event to a mesh gateway peer when we have no
|
||||
/// working relay connection. Returns true when the event was sent.
|
||||
///
|
||||
/// v1 is deliberately fire-and-forget: no gateway ack. The event also
|
||||
/// stays in `NostrRelayManager`'s own pending queue, so if our internet
|
||||
/// comes back the relays dedup the duplicate publish by event ID.
|
||||
///
|
||||
/// Loop rule 3: call sites only pass freshly composed events (see
|
||||
/// `GeohashSubscriptionManager.sendGeohash`); received carrier events
|
||||
/// never reach this path, and the mesh-carried guard below backstops it.
|
||||
func uplinkViaMesh(event: NostrEvent, geohash: String) -> Bool {
|
||||
if relaysConnected?() ?? true { return false }
|
||||
guard !meshBroadcastEventIDs.contains(event.id),
|
||||
!publishedEventIDs.contains(event.id) else {
|
||||
return false
|
||||
}
|
||||
// A single gateway is enough — relays fan out from there, and BLE
|
||||
// airtime is precious.
|
||||
guard let gateway = availableGatewayPeers?().first else { return false }
|
||||
guard let carrier = NostrCarrierPacket(direction: .toGateway, geohash: geohash, event: event),
|
||||
let payload = carrier.encode() else {
|
||||
return false
|
||||
}
|
||||
guard sendToGatewayPeer?(payload, gateway) ?? false else { return false }
|
||||
SecureLogger.info("[GW] uplink send \(event.id.prefix(8))… for #\(geohash) via gateway \(gateway.id.prefix(8))…", category: .gateway)
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Validation
|
||||
|
||||
/// Structural and cryptographic checks every carried event must pass
|
||||
/// before a gateway publishes it or a receiver displays it. Ordered
|
||||
/// cheap-first; Schnorr verification runs last.
|
||||
private func validatedEvent(from carrier: NostrCarrierPacket) -> NostrEvent? {
|
||||
guard let event = structurallyValidEvent(from: carrier),
|
||||
event.isValidSignature() else {
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
/// The cheap half of `validatedEvent`: parse + size + geohash + kind +
|
||||
/// `#g` tag + freshness, with NO signature verification. Callers that can
|
||||
/// dedup or rate-limit on the carried ID run this first so the expensive
|
||||
/// Schnorr verify is reached only for events that survive the cheap gates.
|
||||
private func structurallyValidEvent(from carrier: NostrCarrierPacket) -> NostrEvent? {
|
||||
guard carrier.eventJSON.count <= NostrCarrierPacket.maxEventJSONBytes,
|
||||
Self.isValidGeohash(carrier.geohash),
|
||||
let event = carrier.event(),
|
||||
event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue,
|
||||
event.tags.contains(where: { $0.count >= 2 && $0[0] == "g" && $0[1] == carrier.geohash }),
|
||||
isFresh(event) else {
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
/// True when `event.created_at` is within the accepted clock skew — the
|
||||
/// SAME freshness window receivers enforce, so a gateway never spends
|
||||
/// airtime on events every receiver would drop as stale.
|
||||
private func isFresh(_ event: NostrEvent) -> Bool {
|
||||
abs(now().timeIntervalSince1970 - TimeInterval(event.created_at)) <= Limits.maxEventAgeSeconds
|
||||
}
|
||||
|
||||
static func isValidGeohash(_ geohash: String) -> Bool {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
return (1...NostrCarrierPacket.maxGeohashLength).contains(geohash.count)
|
||||
&& geohash.allSatisfy { allowed.contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Insertion-ordered string set with a fixed capacity; the oldest entry is
|
||||
/// evicted when full.
|
||||
private struct BoundedIDSet {
|
||||
private var members: Set<String> = []
|
||||
private var order: [String] = []
|
||||
let capacity: Int
|
||||
|
||||
init(capacity: Int) {
|
||||
self.capacity = capacity
|
||||
}
|
||||
|
||||
func contains(_ id: String) -> Bool {
|
||||
members.contains(id)
|
||||
}
|
||||
|
||||
/// Returns false when the ID was already present.
|
||||
@discardableResult
|
||||
mutating func insert(_ id: String) -> Bool {
|
||||
guard members.insert(id).inserted else { return false }
|
||||
order.append(id)
|
||||
if order.count > capacity {
|
||||
members.remove(order.removeFirst())
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
//
|
||||
// GroupProtocol.swift
|
||||
// bitchat
|
||||
//
|
||||
// Wire formats and crypto for private groups: creator-signed group state
|
||||
// (invites and key updates over Noise) and ChaCha20-Poly1305 group messages
|
||||
// broadcast as MessageType.groupMessage (0x25).
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
// MARK: - Models
|
||||
|
||||
/// A member of a private group as pinned in the creator-signed roster.
|
||||
struct GroupMember: Codable, Equatable {
|
||||
/// SHA-256 fingerprint (64 hex chars) of the member's Noise static key.
|
||||
let fingerprint: String
|
||||
/// The member's Ed25519 signing public key (32 bytes, from their announce).
|
||||
let signingKey: Data
|
||||
/// Nickname at invite time; display fallback when the peer is offline.
|
||||
var nickname: String
|
||||
}
|
||||
|
||||
/// Creator-managed encrypted group. Metadata only — the symmetric key lives
|
||||
/// in the keychain (see `GroupStore`).
|
||||
struct BitchatGroup: Codable, Equatable {
|
||||
static let maxMembers = 16
|
||||
static let groupIDLength = 16
|
||||
static let keyLength = 32
|
||||
|
||||
/// 16 random bytes; travels in cleartext on group message packets so
|
||||
/// relays can dedup/filter without membership.
|
||||
let groupID: Data
|
||||
var name: String
|
||||
/// Bumps on every key rotation; messages are bound to the epoch they
|
||||
/// were sealed under.
|
||||
var epoch: UInt32
|
||||
var members: [GroupMember]
|
||||
/// Fingerprint of the creator — the only identity allowed to sign group
|
||||
/// state (invites, key updates) in v1.
|
||||
let creatorFingerprint: String
|
||||
|
||||
/// Virtual conversation ID this group's chat is keyed under.
|
||||
var peerID: PeerID { PeerID(groupID: groupID) }
|
||||
|
||||
var creator: GroupMember? {
|
||||
members.first { $0.fingerprint == creatorFingerprint }
|
||||
}
|
||||
|
||||
func isMember(fingerprint: String) -> Bool {
|
||||
members.contains { $0.fingerprint == fingerprint }
|
||||
}
|
||||
|
||||
func member(withSigningKey signingKey: Data) -> GroupMember? {
|
||||
members.first { $0.signingKey == signingKey }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TLV helpers
|
||||
|
||||
enum GroupTLVError: Error, Equatable {
|
||||
/// A TLV value exceeded the 16-bit length field. Encoding fails instead
|
||||
/// of silently truncating (which would ship a value the receiver drops).
|
||||
case valueTooLong
|
||||
}
|
||||
|
||||
private enum GroupTLV {
|
||||
/// Appends a (type, 16-bit length, value) triple. Throws rather than
|
||||
/// truncating when `value` does not fit the 16-bit length field, so an
|
||||
/// oversize field surfaces a send failure instead of a silently truncated
|
||||
/// blob the recipient rejects during decrypt/verify.
|
||||
static func put(_ type: UInt8, _ value: Data, into out: inout Data) throws {
|
||||
guard value.count <= Int(UInt16.max) else { throw GroupTLVError.valueTooLong }
|
||||
out.append(type)
|
||||
let length = UInt16(value.count)
|
||||
out.append(UInt8((length >> 8) & 0xFF))
|
||||
out.append(UInt8(length & 0xFF))
|
||||
out.append(value)
|
||||
}
|
||||
|
||||
/// Iterates (type, value) pairs; returns nil on malformed framing.
|
||||
static func parse(_ data: Data) -> [(type: UInt8, value: Data)]? {
|
||||
var fields: [(UInt8, Data)] = []
|
||||
var offset = data.startIndex
|
||||
while offset < data.endIndex {
|
||||
guard data.distance(from: offset, to: data.endIndex) >= 3 else { return nil }
|
||||
let type = data[offset]
|
||||
let high = Int(data[data.index(offset, offsetBy: 1)])
|
||||
let low = Int(data[data.index(offset, offsetBy: 2)])
|
||||
let length = (high << 8) | low
|
||||
let valueStart = data.index(offset, offsetBy: 3)
|
||||
guard data.distance(from: valueStart, to: data.endIndex) >= length else { return nil }
|
||||
let valueEnd = data.index(valueStart, offsetBy: length)
|
||||
fields.append((type, Data(data[valueStart..<valueEnd])))
|
||||
offset = valueEnd
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
static func epochData(_ epoch: UInt32) -> Data {
|
||||
var bigEndian = epoch.bigEndian
|
||||
return withUnsafeBytes(of: &bigEndian) { Data($0) }
|
||||
}
|
||||
|
||||
static func epoch(from data: Data) -> UInt32? {
|
||||
guard data.count == 4 else { return nil }
|
||||
return data.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
|
||||
}
|
||||
|
||||
static func timestampData(_ timestampMs: UInt64) -> Data {
|
||||
var bigEndian = timestampMs.bigEndian
|
||||
return withUnsafeBytes(of: &bigEndian) { Data($0) }
|
||||
}
|
||||
|
||||
static func timestamp(from data: Data) -> UInt64? {
|
||||
guard data.count == 8 else { return nil }
|
||||
return data.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Roster wire form
|
||||
|
||||
enum GroupRosterCoding {
|
||||
private static let fingerprintLength = 32
|
||||
private static let signingKeyLength = 32
|
||||
private static let maxNicknameBytes = 64
|
||||
|
||||
/// Deterministic roster blob: count byte, then per member the raw 32-byte
|
||||
/// fingerprint, 32-byte signing key, and length-prefixed UTF-8 nickname.
|
||||
/// The creator signature covers the SHA-256 of these exact bytes.
|
||||
static func encode(_ members: [GroupMember]) -> Data? {
|
||||
guard members.count <= BitchatGroup.maxMembers else { return nil }
|
||||
var out = Data([UInt8(members.count)])
|
||||
for member in members {
|
||||
guard let fingerprintData = Data(hexString: member.fingerprint),
|
||||
fingerprintData.count == fingerprintLength,
|
||||
member.signingKey.count == signingKeyLength else { return nil }
|
||||
out.append(fingerprintData)
|
||||
out.append(member.signingKey)
|
||||
// Truncate on a Character boundary so the byte prefix is always
|
||||
// valid UTF-8; a raw byte-prefix could split a multi-byte scalar
|
||||
// and make the whole signed roster undecodable on the recipient.
|
||||
let nickname = truncatedNicknameBytes(member.nickname)
|
||||
out.append(UInt8(nickname.count))
|
||||
out.append(nickname)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) -> [GroupMember]? {
|
||||
guard let count = data.first, count <= UInt8(BitchatGroup.maxMembers) else { return nil }
|
||||
var members: [GroupMember] = []
|
||||
var offset = data.index(after: data.startIndex)
|
||||
for _ in 0..<count {
|
||||
let fixed = fingerprintLength + signingKeyLength + 1
|
||||
guard data.distance(from: offset, to: data.endIndex) >= fixed else { return nil }
|
||||
let fingerprintEnd = data.index(offset, offsetBy: fingerprintLength)
|
||||
let fingerprint = Data(data[offset..<fingerprintEnd]).hexEncodedString()
|
||||
let signingKeyEnd = data.index(fingerprintEnd, offsetBy: signingKeyLength)
|
||||
let signingKey = Data(data[fingerprintEnd..<signingKeyEnd])
|
||||
let nickLength = Int(data[signingKeyEnd])
|
||||
let nickStart = data.index(after: signingKeyEnd)
|
||||
guard data.distance(from: nickStart, to: data.endIndex) >= nickLength else { return nil }
|
||||
let nickEnd = data.index(nickStart, offsetBy: nickLength)
|
||||
guard let nickname = String(data: Data(data[nickStart..<nickEnd]), encoding: .utf8) else { return nil }
|
||||
members.append(GroupMember(fingerprint: fingerprint, signingKey: signingKey, nickname: nickname))
|
||||
offset = nickEnd
|
||||
}
|
||||
guard offset == data.endIndex else { return nil }
|
||||
return members
|
||||
}
|
||||
|
||||
/// UTF-8 bytes of `nickname` trimmed to at most `maxNicknameBytes`,
|
||||
/// dropping whole Characters so the result is never split mid-scalar.
|
||||
private static func truncatedNicknameBytes(_ nickname: String) -> Data {
|
||||
var candidate = nickname
|
||||
while Data(candidate.utf8).count > maxNicknameBytes {
|
||||
candidate.removeLast()
|
||||
}
|
||||
return Data(candidate.utf8)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Group state payload (groupInvite / groupKeyUpdate over Noise)
|
||||
|
||||
/// Creator-signed group state. The same wire form serves invites (0x06) and
|
||||
/// key updates (0x07); receivers verify the creator signature — computed over
|
||||
/// "bitchat-group-v1" | groupID | epoch | SHA256(key) | SHA256(roster) —
|
||||
/// against the creator's signing key pinned in the roster, and require the
|
||||
/// Noise session peer to BE the creator before accepting any state.
|
||||
struct GroupStatePayload: Equatable {
|
||||
let groupID: Data
|
||||
let name: String
|
||||
/// Symmetric ChaCha20-Poly1305 group key (32 bytes) for `epoch`.
|
||||
let key: Data
|
||||
let epoch: UInt32
|
||||
let members: [GroupMember]
|
||||
let creatorFingerprint: String
|
||||
/// Ed25519 signature by the creator.
|
||||
let signature: Data
|
||||
|
||||
private enum FieldType: UInt8 {
|
||||
case groupID = 0x01
|
||||
case name = 0x02
|
||||
case key = 0x03
|
||||
case epoch = 0x04
|
||||
case roster = 0x05
|
||||
case creatorFingerprint = 0x06
|
||||
case signature = 0x07
|
||||
}
|
||||
|
||||
static let signingDomain = Data("bitchat-group-v1".utf8)
|
||||
|
||||
/// The bytes the creator signs. Binding the key, roster, and name by hash
|
||||
/// keeps the signed content fixed-size. The name is covered so a relay
|
||||
/// that caches/replays a signed state (e.g. store-and-forward) cannot swap
|
||||
/// the display name while keeping a valid creator signature.
|
||||
static func signingContent(groupID: Data, epoch: UInt32, key: Data, rosterBlob: Data, name: String) -> Data {
|
||||
var content = signingDomain
|
||||
content.append(groupID)
|
||||
content.append(GroupTLV.epochData(epoch))
|
||||
content.append(key.sha256Hash())
|
||||
content.append(rosterBlob.sha256Hash())
|
||||
content.append(Data(name.utf8).sha256Hash())
|
||||
return content
|
||||
}
|
||||
|
||||
/// Builds a signed state payload. Returns nil when the roster cannot be
|
||||
/// encoded (over cap, malformed member) or signing fails.
|
||||
static func makeSigned(
|
||||
group: BitchatGroup,
|
||||
key: Data,
|
||||
sign: (Data) -> Data?
|
||||
) -> GroupStatePayload? {
|
||||
guard let rosterBlob = GroupRosterCoding.encode(group.members) else { return nil }
|
||||
let content = signingContent(groupID: group.groupID, epoch: group.epoch, key: key, rosterBlob: rosterBlob, name: group.name)
|
||||
guard let signature = sign(content) else { return nil }
|
||||
return GroupStatePayload(
|
||||
groupID: group.groupID,
|
||||
name: group.name,
|
||||
key: key,
|
||||
epoch: group.epoch,
|
||||
members: group.members,
|
||||
creatorFingerprint: group.creatorFingerprint,
|
||||
signature: signature
|
||||
)
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
guard let rosterBlob = GroupRosterCoding.encode(members),
|
||||
let fingerprintData = Data(hexString: creatorFingerprint),
|
||||
fingerprintData.count == 32 else { return nil }
|
||||
var out = Data()
|
||||
do {
|
||||
try GroupTLV.put(FieldType.groupID.rawValue, groupID, into: &out)
|
||||
try GroupTLV.put(FieldType.name.rawValue, Data(name.utf8), into: &out)
|
||||
try GroupTLV.put(FieldType.key.rawValue, key, into: &out)
|
||||
try GroupTLV.put(FieldType.epoch.rawValue, GroupTLV.epochData(epoch), into: &out)
|
||||
try GroupTLV.put(FieldType.roster.rawValue, rosterBlob, into: &out)
|
||||
try GroupTLV.put(FieldType.creatorFingerprint.rawValue, fingerprintData, into: &out)
|
||||
try GroupTLV.put(FieldType.signature.rawValue, signature, into: &out)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) -> GroupStatePayload? {
|
||||
guard let fields = GroupTLV.parse(data) else { return nil }
|
||||
var groupID: Data?
|
||||
var name: String?
|
||||
var key: Data?
|
||||
var epoch: UInt32?
|
||||
var rosterBlob: Data?
|
||||
var members: [GroupMember]?
|
||||
var creatorFingerprint: String?
|
||||
var signature: Data?
|
||||
|
||||
for (type, value) in fields {
|
||||
switch FieldType(rawValue: type) {
|
||||
case .groupID where value.count == BitchatGroup.groupIDLength:
|
||||
groupID = value
|
||||
case .name:
|
||||
name = String(data: value, encoding: .utf8)
|
||||
case .key where value.count == BitchatGroup.keyLength:
|
||||
key = value
|
||||
case .epoch:
|
||||
epoch = GroupTLV.epoch(from: value)
|
||||
case .roster:
|
||||
rosterBlob = value
|
||||
members = GroupRosterCoding.decode(value)
|
||||
case .creatorFingerprint where value.count == 32:
|
||||
creatorFingerprint = value.hexEncodedString()
|
||||
case .signature where value.count == 64:
|
||||
signature = value
|
||||
default:
|
||||
break // forward compatible; ignore unknown TLVs
|
||||
}
|
||||
}
|
||||
|
||||
guard let groupID, let name, let key, let epoch,
|
||||
rosterBlob != nil, let members, !members.isEmpty,
|
||||
let creatorFingerprint, let signature else { return nil }
|
||||
return GroupStatePayload(
|
||||
groupID: groupID,
|
||||
name: name,
|
||||
key: key,
|
||||
epoch: epoch,
|
||||
members: members,
|
||||
creatorFingerprint: creatorFingerprint,
|
||||
signature: signature
|
||||
)
|
||||
}
|
||||
|
||||
/// Verifies the creator signature against the creator's signing key
|
||||
/// pinned in the roster, and that the creator is actually in the roster.
|
||||
func verifyCreatorSignature() -> Bool {
|
||||
guard members.count <= BitchatGroup.maxMembers,
|
||||
let creator = members.first(where: { $0.fingerprint == creatorFingerprint }),
|
||||
let rosterBlob = GroupRosterCoding.encode(members) else { return false }
|
||||
let content = GroupStatePayload.signingContent(groupID: groupID, epoch: epoch, key: key, rosterBlob: rosterBlob, name: name)
|
||||
return GroupCrypto.verify(signature: signature, for: content, publicKey: creator.signingKey)
|
||||
}
|
||||
|
||||
var asGroup: BitchatGroup {
|
||||
BitchatGroup(
|
||||
groupID: groupID,
|
||||
name: name,
|
||||
epoch: epoch,
|
||||
members: members,
|
||||
creatorFingerprint: creatorFingerprint
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Group message envelope (MessageType 0x25 payload)
|
||||
|
||||
/// Cleartext framing of a group message broadcast. Only the group ID, epoch,
|
||||
/// and nonce are visible to relays; everything about the message — sender,
|
||||
/// content, timestamps — is inside the ChaCha20-Poly1305 ciphertext.
|
||||
struct GroupMessageEnvelope: Equatable {
|
||||
let groupID: Data
|
||||
let epoch: UInt32
|
||||
let nonce: Data
|
||||
/// ChaChaPoly ciphertext || 16-byte tag.
|
||||
let ciphertext: Data
|
||||
|
||||
private enum FieldType: UInt8 {
|
||||
case groupID = 0x01
|
||||
case epoch = 0x02
|
||||
case nonce = 0x03
|
||||
case ciphertext = 0x04
|
||||
}
|
||||
|
||||
func encode() throws -> Data {
|
||||
var out = Data()
|
||||
try GroupTLV.put(FieldType.groupID.rawValue, groupID, into: &out)
|
||||
try GroupTLV.put(FieldType.epoch.rawValue, GroupTLV.epochData(epoch), into: &out)
|
||||
try GroupTLV.put(FieldType.nonce.rawValue, nonce, into: &out)
|
||||
try GroupTLV.put(FieldType.ciphertext.rawValue, ciphertext, into: &out)
|
||||
return out
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) -> GroupMessageEnvelope? {
|
||||
guard let fields = GroupTLV.parse(data) else { return nil }
|
||||
var groupID: Data?
|
||||
var epoch: UInt32?
|
||||
var nonce: Data?
|
||||
var ciphertext: Data?
|
||||
for (type, value) in fields {
|
||||
switch FieldType(rawValue: type) {
|
||||
case .groupID where value.count == BitchatGroup.groupIDLength:
|
||||
groupID = value
|
||||
case .epoch:
|
||||
epoch = GroupTLV.epoch(from: value)
|
||||
case .nonce where value.count == 12:
|
||||
nonce = value
|
||||
case .ciphertext where !value.isEmpty:
|
||||
ciphertext = value
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
guard let groupID, let epoch, let nonce, let ciphertext else { return nil }
|
||||
return GroupMessageEnvelope(groupID: groupID, epoch: epoch, nonce: nonce, ciphertext: ciphertext)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypted, signature-verified inner content of a group message.
|
||||
struct GroupMessagePlaintext: Equatable {
|
||||
let messageID: String
|
||||
let senderSigningKey: Data
|
||||
let senderNickname: String
|
||||
let timestampMs: UInt64
|
||||
let content: String
|
||||
}
|
||||
|
||||
// MARK: - Crypto
|
||||
|
||||
enum GroupCryptoError: Error, Equatable {
|
||||
case malformedPayload
|
||||
case signingFailed
|
||||
case sealFailed
|
||||
case wrongEpoch
|
||||
case decryptionFailed
|
||||
case badSenderSignature
|
||||
}
|
||||
|
||||
enum GroupCrypto {
|
||||
static let messageSigningDomain = Data("bitchat-group-msg-v1".utf8)
|
||||
|
||||
private enum InnerField: UInt8 {
|
||||
case messageID = 0x01
|
||||
case senderSigningKey = 0x02
|
||||
case senderNickname = 0x03
|
||||
case timestamp = 0x04
|
||||
case content = 0x05
|
||||
case signature = 0x06
|
||||
}
|
||||
|
||||
/// Bytes the sender signs: domain | groupID | epoch | messageID | timestamp | content.
|
||||
/// Covering the epoch stops a current member from re-sealing another
|
||||
/// member's decrypted inner bytes under a later epoch key (the signature
|
||||
/// would no longer verify at the new epoch).
|
||||
static func messageSigningContent(groupID: Data, epoch: UInt32, messageID: String, timestampMs: UInt64, content: String) -> Data {
|
||||
var data = messageSigningDomain
|
||||
data.append(groupID)
|
||||
data.append(GroupTLV.epochData(epoch))
|
||||
data.append(Data(messageID.utf8))
|
||||
data.append(GroupTLV.timestampData(timestampMs))
|
||||
data.append(Data(content.utf8))
|
||||
return data
|
||||
}
|
||||
|
||||
static func verify(signature: Data, for data: Data, publicKey: Data) -> Bool {
|
||||
guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else { return false }
|
||||
return key.isValidSignature(signature, for: data)
|
||||
}
|
||||
|
||||
/// Seals a group message: builds the signed inner TLV and encrypts it with
|
||||
/// the epoch key. The cleartext group ID and epoch are bound into the AEAD
|
||||
/// as additional data so ciphertext cannot be replayed across groups or
|
||||
/// epochs. Returns the encoded 0x25 packet payload.
|
||||
static func sealMessage(
|
||||
content: String,
|
||||
messageID: String,
|
||||
senderNickname: String,
|
||||
senderSigningKey: Data,
|
||||
timestampMs: UInt64,
|
||||
groupID: Data,
|
||||
epoch: UInt32,
|
||||
key: Data,
|
||||
sign: (Data) -> Data?
|
||||
) throws -> Data {
|
||||
let signingContent = messageSigningContent(
|
||||
groupID: groupID,
|
||||
epoch: epoch,
|
||||
messageID: messageID,
|
||||
timestampMs: timestampMs,
|
||||
content: content
|
||||
)
|
||||
guard let signature = sign(signingContent), signature.count == 64 else {
|
||||
throw GroupCryptoError.signingFailed
|
||||
}
|
||||
|
||||
var inner = Data()
|
||||
try GroupTLV.put(InnerField.messageID.rawValue, Data(messageID.utf8), into: &inner)
|
||||
try GroupTLV.put(InnerField.senderSigningKey.rawValue, senderSigningKey, into: &inner)
|
||||
try GroupTLV.put(InnerField.senderNickname.rawValue, Data(senderNickname.utf8), into: &inner)
|
||||
try GroupTLV.put(InnerField.timestamp.rawValue, GroupTLV.timestampData(timestampMs), into: &inner)
|
||||
try GroupTLV.put(InnerField.content.rawValue, Data(content.utf8), into: &inner)
|
||||
try GroupTLV.put(InnerField.signature.rawValue, signature, into: &inner)
|
||||
|
||||
do {
|
||||
let symmetricKey = SymmetricKey(data: key)
|
||||
var aad = groupID
|
||||
aad.append(GroupTLV.epochData(epoch))
|
||||
let sealed = try ChaChaPoly.seal(inner, using: symmetricKey, authenticating: aad)
|
||||
var ciphertext = sealed.ciphertext
|
||||
ciphertext.append(sealed.tag)
|
||||
let envelope = GroupMessageEnvelope(
|
||||
groupID: groupID,
|
||||
epoch: epoch,
|
||||
nonce: Data(sealed.nonce),
|
||||
ciphertext: ciphertext
|
||||
)
|
||||
return try envelope.encode()
|
||||
} catch {
|
||||
throw GroupCryptoError.sealFailed
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a group message envelope with the epoch key: decrypts, parses the
|
||||
/// inner TLV, and verifies the sender's Ed25519 signature. Roster
|
||||
/// membership of the sender is the CALLER's check — this function only
|
||||
/// proves the payload was authored by `senderSigningKey`.
|
||||
static func openMessage(_ envelope: GroupMessageEnvelope, key: Data) throws -> GroupMessagePlaintext {
|
||||
let inner: Data
|
||||
do {
|
||||
let symmetricKey = SymmetricKey(data: key)
|
||||
var aad = envelope.groupID
|
||||
aad.append(GroupTLV.epochData(envelope.epoch))
|
||||
let nonce = try ChaChaPoly.Nonce(data: envelope.nonce)
|
||||
guard envelope.ciphertext.count > 16 else { throw GroupCryptoError.decryptionFailed }
|
||||
let tag = envelope.ciphertext.suffix(16)
|
||||
let body = envelope.ciphertext.prefix(envelope.ciphertext.count - 16)
|
||||
let sealedBox = try ChaChaPoly.SealedBox(nonce: nonce, ciphertext: body, tag: tag)
|
||||
inner = try ChaChaPoly.open(sealedBox, using: symmetricKey, authenticating: aad)
|
||||
} catch {
|
||||
throw GroupCryptoError.decryptionFailed
|
||||
}
|
||||
|
||||
guard let fields = GroupTLV.parse(inner) else { throw GroupCryptoError.malformedPayload }
|
||||
var messageID: String?
|
||||
var senderSigningKey: Data?
|
||||
var senderNickname: String?
|
||||
var timestampMs: UInt64?
|
||||
var content: String?
|
||||
var signature: Data?
|
||||
for (type, value) in fields {
|
||||
switch InnerField(rawValue: type) {
|
||||
case .messageID:
|
||||
messageID = String(data: value, encoding: .utf8)
|
||||
case .senderSigningKey where value.count == 32:
|
||||
senderSigningKey = value
|
||||
case .senderNickname:
|
||||
senderNickname = String(data: value, encoding: .utf8)
|
||||
case .timestamp:
|
||||
timestampMs = GroupTLV.timestamp(from: value)
|
||||
case .content:
|
||||
content = String(data: value, encoding: .utf8)
|
||||
case .signature where value.count == 64:
|
||||
signature = value
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
guard let messageID, !messageID.isEmpty,
|
||||
let senderSigningKey,
|
||||
let senderNickname,
|
||||
let timestampMs,
|
||||
let content,
|
||||
let signature else { throw GroupCryptoError.malformedPayload }
|
||||
|
||||
let signingContent = messageSigningContent(
|
||||
groupID: envelope.groupID,
|
||||
epoch: envelope.epoch,
|
||||
messageID: messageID,
|
||||
timestampMs: timestampMs,
|
||||
content: content
|
||||
)
|
||||
guard verify(signature: signature, for: signingContent, publicKey: senderSigningKey) else {
|
||||
throw GroupCryptoError.badSenderSignature
|
||||
}
|
||||
|
||||
return GroupMessagePlaintext(
|
||||
messageID: messageID,
|
||||
senderSigningKey: senderSigningKey,
|
||||
senderNickname: senderNickname,
|
||||
timestampMs: timestampMs,
|
||||
content: content
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
//
|
||||
// GroupStore.swift
|
||||
// bitchat
|
||||
//
|
||||
// Persistence for private groups: symmetric keys in the keychain, metadata
|
||||
// (roster, name, epoch) as protected JSON in Application Support. Both are
|
||||
// dropped by the panic wipe.
|
||||
//
|
||||
// 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
|
||||
import Security
|
||||
|
||||
@MainActor
|
||||
final class GroupStore: ObservableObject {
|
||||
/// All groups this device is a member of, in creation/join order.
|
||||
@Published private(set) var groups: [BitchatGroup] = []
|
||||
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let fileURL: URL?
|
||||
|
||||
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
|
||||
/// when `persistsToDisk` is false.
|
||||
init(keychain: KeychainManagerProtocol, persistsToDisk: Bool = true, fileURL: URL? = nil) {
|
||||
self.keychain = keychain
|
||||
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
// MARK: - Reads
|
||||
|
||||
func group(withID groupID: Data) -> BitchatGroup? {
|
||||
groups.first { $0.groupID == groupID }
|
||||
}
|
||||
|
||||
func group(for peerID: PeerID) -> BitchatGroup? {
|
||||
guard let groupID = peerID.groupIDData else { return nil }
|
||||
return group(withID: groupID)
|
||||
}
|
||||
|
||||
/// Current-epoch symmetric key for the group, from the keychain.
|
||||
func key(forGroupID groupID: Data) -> Data? {
|
||||
keychain.getIdentityKey(forKey: Self.keychainKey(for: groupID))
|
||||
}
|
||||
|
||||
// MARK: - Mutations
|
||||
|
||||
/// Creates a new group with a random 16-byte ID and 32-byte key at
|
||||
/// epoch 1, with the creator as sole member. Returns nil when key
|
||||
/// generation or persistence fails.
|
||||
func createGroup(named name: String, creator: GroupMember) -> BitchatGroup? {
|
||||
guard let groupID = Self.randomBytes(BitchatGroup.groupIDLength),
|
||||
let key = Self.randomBytes(BitchatGroup.keyLength) else { return nil }
|
||||
let group = BitchatGroup(
|
||||
groupID: groupID,
|
||||
name: name,
|
||||
epoch: 1,
|
||||
members: [creator],
|
||||
creatorFingerprint: creator.fingerprint
|
||||
)
|
||||
guard upsert(group, key: key) else { return nil }
|
||||
return group
|
||||
}
|
||||
|
||||
/// Inserts or replaces a group and its current key. Rejects rosters over
|
||||
/// the hard cap or groups whose creator is missing from the roster.
|
||||
@discardableResult
|
||||
func upsert(_ group: BitchatGroup, key: Data) -> Bool {
|
||||
guard group.groupID.count == BitchatGroup.groupIDLength,
|
||||
key.count == BitchatGroup.keyLength,
|
||||
!group.members.isEmpty,
|
||||
group.members.count <= BitchatGroup.maxMembers,
|
||||
group.creator != nil else { return false }
|
||||
guard keychain.saveIdentityKey(key, forKey: Self.keychainKey(for: group.groupID)) else {
|
||||
SecureLogger.error("Failed to store group key in keychain", category: .security)
|
||||
return false
|
||||
}
|
||||
if let index = groups.firstIndex(where: { $0.groupID == group.groupID }) {
|
||||
groups[index] = group
|
||||
} else {
|
||||
groups.append(group)
|
||||
}
|
||||
persist()
|
||||
return true
|
||||
}
|
||||
|
||||
/// Updates the roster of an existing group without changing key or epoch
|
||||
/// (creator-side invite). Enforces the member cap.
|
||||
@discardableResult
|
||||
func updateRoster(groupID: Data, members: [GroupMember]) -> BitchatGroup? {
|
||||
guard let index = groups.firstIndex(where: { $0.groupID == groupID }),
|
||||
!members.isEmpty,
|
||||
members.count <= BitchatGroup.maxMembers,
|
||||
members.contains(where: { $0.fingerprint == groups[index].creatorFingerprint }) else { return nil }
|
||||
groups[index].members = members
|
||||
persist()
|
||||
return groups[index]
|
||||
}
|
||||
|
||||
/// Rotates the group key (creator-side removal/rotation): new random key,
|
||||
/// epoch + 1, and the given roster. Returns the updated group and new key.
|
||||
func rotateKey(groupID: Data, members: [GroupMember]) -> (group: BitchatGroup, key: Data)? {
|
||||
guard let existing = group(withID: groupID),
|
||||
let newKey = Self.randomBytes(BitchatGroup.keyLength) else { return nil }
|
||||
var rotated = existing
|
||||
rotated.epoch = existing.epoch &+ 1
|
||||
rotated.members = members
|
||||
guard upsert(rotated, key: newKey) else { return nil }
|
||||
return (rotated, newKey)
|
||||
}
|
||||
|
||||
func removeGroup(withID groupID: Data) {
|
||||
groups.removeAll { $0.groupID == groupID }
|
||||
_ = keychain.deleteIdentityKey(forKey: Self.keychainKey(for: groupID))
|
||||
persist()
|
||||
}
|
||||
|
||||
/// Panic wipe: drop all group keys and metadata from memory and disk.
|
||||
/// (The panic flow also nukes the whole keychain; deleting per-group keys
|
||||
/// here keeps the store safe to wipe on its own.)
|
||||
func wipe() {
|
||||
for group in groups {
|
||||
_ = keychain.deleteIdentityKey(forKey: Self.keychainKey(for: group.groupID))
|
||||
}
|
||||
groups.removeAll()
|
||||
if let fileURL {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private static func keychainKey(for groupID: Data) -> String {
|
||||
"groupKey-\(groupID.hexEncodedString())"
|
||||
}
|
||||
|
||||
private static func randomBytes(_ count: Int) -> Data? {
|
||||
var bytes = Data(count: count)
|
||||
let status = bytes.withUnsafeMutableBytes { buffer -> OSStatus in
|
||||
guard let baseAddress = buffer.baseAddress else { return errSecParam }
|
||||
return SecRandomCopyBytes(kSecRandomDefault, count, baseAddress)
|
||||
}
|
||||
return status == errSecSuccess ? bytes : nil
|
||||
}
|
||||
|
||||
private func persist() {
|
||||
guard let fileURL else { return }
|
||||
do {
|
||||
if groups.isEmpty {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
return
|
||||
}
|
||||
try FileManager.default.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let data = try JSONEncoder().encode(groups)
|
||||
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 group store: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFromDisk() {
|
||||
guard let fileURL,
|
||||
let data = try? Data(contentsOf: fileURL),
|
||||
let stored = try? JSONDecoder().decode([BitchatGroup].self, from: data) else {
|
||||
return
|
||||
}
|
||||
// Only groups whose key survived in the keychain are usable.
|
||||
groups = stored.filter { key(forGroupID: $0.groupID) != nil }
|
||||
}
|
||||
|
||||
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("groups", isDirectory: true)
|
||||
.appendingPathComponent("groups.json")
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import BitLogger
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
|
||||
@@ -14,7 +15,9 @@ 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(
|
||||
@@ -39,7 +42,11 @@ struct LocationNotesDependencies {
|
||||
deriveIdentity: { geohash in
|
||||
try idBridge.deriveIdentity(forGeohash: geohash)
|
||||
},
|
||||
now: { Date() }
|
||||
now: { Date() },
|
||||
relayDirectoryUpdates: NotificationCenter.default
|
||||
.publisher(for: .geoRelayDirectoryDidRefresh)
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -77,15 +84,16 @@ 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)
|
||||
|
||||
private enum Strings {
|
||||
static let noRelays = String(localized: "location_notes.error.no_relays", comment: "Shown when no geo relays are available near the selected location")
|
||||
static let noRelays = String(localized: "location_notes.error.no_relays", defaultValue: "no geo relays available near this location. try again soon.", comment: "Shown when no geo relays are available near the selected location")
|
||||
|
||||
static func failedToSend(_ detail: String) -> String {
|
||||
String(
|
||||
format: String(localized: "location_notes.error.failed_to_send", comment: "Shown when a location note fails to send"),
|
||||
format: String(localized: "location_notes.error.failed_to_send", defaultValue: "failed to send note. %@", comment: "Shown when a location note fails to send"),
|
||||
locale: .current,
|
||||
detail
|
||||
)
|
||||
@@ -101,6 +109,15 @@ 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,6 +594,22 @@ 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
|
||||
|
||||
@@ -10,6 +10,10 @@ final class MeshTopologyTracker {
|
||||
private var claims: [RoutingID: Set<RoutingID>] = [:]
|
||||
// Last time we received an update from a node
|
||||
private var lastSeen: [RoutingID: Date] = [:]
|
||||
// Highest protocol version observed from each node's decoded packets.
|
||||
// Nodes absent from this map are assumed v1-only and are never used as
|
||||
// hops (or targets) for version-gated routes.
|
||||
private var observedVersions: [RoutingID: (version: UInt8, seenAt: Date)] = [:]
|
||||
|
||||
// Maximum age for topology claims to be considered fresh for routing
|
||||
// Routes computed using stale topology can fail when the network has changed
|
||||
@@ -19,48 +23,75 @@ final class MeshTopologyTracker {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.claims.removeAll()
|
||||
self.lastSeen.removeAll()
|
||||
self.observedVersions.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the topology with a node's self-reported neighbor list
|
||||
func updateNeighbors(for sourceData: Data?, neighbors: [Data]) {
|
||||
func updateNeighbors(for sourceData: Data?, neighbors: [Data], at now: Date = Date()) {
|
||||
guard let source = sanitize(sourceData) else { return }
|
||||
// Sanitize neighbors and exclude self-loops
|
||||
let validNeighbors = Set(neighbors.compactMap { sanitize($0) }).subtracting([source])
|
||||
|
||||
|
||||
queue.sync(flags: .barrier) {
|
||||
self.claims[source] = validNeighbors
|
||||
self.lastSeen[source] = Date()
|
||||
self.lastSeen[source] = now
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the protocol version observed on a decoded packet from a node.
|
||||
/// Only versions above the v1 baseline are stored; the highest wins.
|
||||
func recordObservedVersion(_ version: UInt8, for peerData: Data?, at now: Date = Date()) {
|
||||
guard version > 1, let peer = sanitize(peerData) else { return }
|
||||
queue.sync(flags: .barrier) {
|
||||
let current = self.observedVersions[peer]?.version ?? 1
|
||||
self.observedVersions[peer] = (version: max(version, current), seenAt: now)
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw directed neighbor claims, for diagnostics (topology map, /trace).
|
||||
/// Callers treat the claims as advisory: announces cap `directNeighbors`
|
||||
/// at 10, so an edge may be claimed by only one of its endpoints.
|
||||
func adjacencySnapshot() -> [Data: Set<Data>] {
|
||||
queue.sync { claims }
|
||||
}
|
||||
|
||||
func removePeer(_ data: Data?) {
|
||||
guard let peer = sanitize(data) else { return }
|
||||
queue.sync(flags: .barrier) {
|
||||
self.claims.removeValue(forKey: peer)
|
||||
self.lastSeen.removeValue(forKey: peer)
|
||||
self.observedVersions.removeValue(forKey: peer)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Prune nodes that haven't updated their topology in `age` seconds
|
||||
func prune(olderThan age: TimeInterval) {
|
||||
let deadline = Date().addingTimeInterval(-age)
|
||||
func prune(olderThan age: TimeInterval, now: Date = Date()) {
|
||||
let deadline = now.addingTimeInterval(-age)
|
||||
queue.sync(flags: .barrier) {
|
||||
let stale = self.lastSeen.filter { $0.value < deadline }
|
||||
for (peer, _) in stale {
|
||||
self.claims.removeValue(forKey: peer)
|
||||
self.lastSeen.removeValue(forKey: peer)
|
||||
}
|
||||
self.observedVersions = self.observedVersions.filter { $0.value.seenAt >= deadline }
|
||||
}
|
||||
}
|
||||
|
||||
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10) -> [Data]? {
|
||||
/// BFS over confirmed, fresh edges. When `requiringVersion` is set, every
|
||||
/// node on the path except the source (i.e. all intermediate hops and the
|
||||
/// target) must have been observed speaking at least that protocol
|
||||
/// version — a v1-only hop cannot decode a v2 routed packet.
|
||||
func computeRoute(from start: Data?, to goal: Data?, maxHops: Int = 10, requiringVersion: UInt8? = nil, now: Date = Date()) -> [Data]? {
|
||||
guard let source = sanitize(start), let target = sanitize(goal) else { return nil }
|
||||
if source == target { return [] } // Direct connection, no intermediate hops
|
||||
|
||||
return queue.sync {
|
||||
let now = Date()
|
||||
let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold)
|
||||
func meetsRequiredVersion(_ peer: RoutingID) -> Bool {
|
||||
guard let requiringVersion else { return true }
|
||||
return (observedVersions[peer]?.version ?? 1) >= requiringVersion
|
||||
}
|
||||
|
||||
// BFS
|
||||
var visited: Set<RoutingID> = [source]
|
||||
@@ -86,6 +117,10 @@ final class MeshTopologyTracker {
|
||||
for neighbor in neighbors {
|
||||
if visited.contains(neighbor) { continue }
|
||||
|
||||
// Version gate: skip nodes not known to speak the
|
||||
// required protocol version.
|
||||
guard meetsRequiredVersion(neighbor) else { continue }
|
||||
|
||||
// CONFIRMED EDGE CHECK:
|
||||
// 'last' claims 'neighbor' (checked above)
|
||||
// Does 'neighbor' claim 'last'?
|
||||
|
||||
@@ -2,27 +2,80 @@ 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 {
|
||||
private let transports: [Transport]
|
||||
typealias QueuedMessage = MessageOutboxStore.QueuedMessage
|
||||
|
||||
// Outbox entry with timestamp for TTL-based eviction
|
||||
private struct QueuedMessage {
|
||||
let content: String
|
||||
let nickname: String
|
||||
let messageID: String
|
||||
let timestamp: Date
|
||||
}
|
||||
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)?
|
||||
|
||||
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]) {
|
||||
init(
|
||||
transports: [Transport],
|
||||
now: @escaping () -> Date = Date.init,
|
||||
courierDirectory: CourierDirectory? = nil,
|
||||
outboxStore: MessageOutboxStore? = nil,
|
||||
metrics: StoreAndForwardMetrics? = nil
|
||||
) {
|
||||
self.transports = transports
|
||||
self.now = now
|
||||
self.courierDirectory = courierDirectory ?? .favoritesBacked()
|
||||
self.outboxStore = outboxStore
|
||||
self.metrics = metrics
|
||||
self.outbox = outboxStore?.load() ?? [:]
|
||||
|
||||
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
||||
NotificationCenter.default.addObserver(
|
||||
@@ -39,7 +92,7 @@ final class MessageRouter {
|
||||
}
|
||||
// Handle key updates
|
||||
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
|
||||
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
||||
note.userInfo?["isKeyUpdate"] is Bool {
|
||||
let peerID = PeerID(publicKey: newKey)
|
||||
Task { @MainActor in
|
||||
self.flushOutbox(for: peerID)
|
||||
@@ -61,24 +114,192 @@ final class MessageRouter {
|
||||
// MARK: - Message Sending
|
||||
|
||||
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||
if let transport = reachableTransport(for: peerID) {
|
||||
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||
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)
|
||||
} else {
|
||||
// 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)
|
||||
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)
|
||||
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
enqueue(message, for: peerID)
|
||||
// "Reachable" without prompt delivery means the send only joined
|
||||
// a queue (Nostr with relays down): also hand a sealed copy to
|
||||
// any connected couriers rather than waiting for internet that
|
||||
// may never come. Double delivery is harmless — receivers dedup
|
||||
// by message ID, and delivered/read acks never downgrade.
|
||||
if !transport.canDeliverPromptly(to: peerID) {
|
||||
attemptCourierDeposit(messageID: messageID, for: peerID)
|
||||
}
|
||||
} else {
|
||||
var unsent = message
|
||||
unsent.sendAttempts = 0
|
||||
enqueue(unsent, for: peerID)
|
||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
|
||||
attemptCourierDeposit(messageID: messageID, for: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Couriers
|
||||
|
||||
/// Last resort when no transport can deliver promptly — the peer is
|
||||
/// unreachable, or only reachable through a send queue waiting on
|
||||
/// internet: seal the message to their known static key and hand it to
|
||||
/// connected couriers who may physically encounter them. Mutual favorites
|
||||
/// are preferred; signature-verified strangers fill remaining slots so a
|
||||
/// crowd without favorites can still carry mail (envelopes are opaque
|
||||
/// either way). The queued copy stays retained, so direct delivery still
|
||||
/// wins if the peer reappears first (receivers dedup by message ID).
|
||||
private func attemptCourierDeposit(messageID: String, for peerID: PeerID) {
|
||||
guard let recipientKey = courierDirectory.noiseKey(peerID),
|
||||
let entry = queuedMessage(messageID, for: peerID) else { return }
|
||||
let remainingSlots = Self.maxCouriersPerMessage - entry.depositedCourierKeys.count
|
||||
guard remainingSlots > 0 else { return }
|
||||
|
||||
for transport in transports {
|
||||
let couriers = eligibleCouriers(
|
||||
on: transport,
|
||||
recipientKey: recipientKey,
|
||||
excluding: entry.depositedCourierKeys,
|
||||
limit: remainingSlots
|
||||
)
|
||||
guard !couriers.isEmpty else { continue }
|
||||
if transport.sendCourierMessage(entry.content, messageID: messageID, recipientNoiseKey: recipientKey, via: couriers.map(\.peerID)) {
|
||||
SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))…", category: .session)
|
||||
recordCourierDeposit(messageID: messageID, for: peerID, courierKeys: couriers.map(\.noiseKey))
|
||||
onMessageCarried?(messageID, peerID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A courier candidate just connected: hand them any queued mail they are
|
||||
/// not already carrying. This is what turns couriering from "a favorite
|
||||
/// happened to be around at send time" into eventual spread — deposits
|
||||
/// retry as eligible peers appear, until each message rides with
|
||||
/// `maxCouriersPerMessage` distinct couriers or expires.
|
||||
func courierBecameAvailable(_ peerID: PeerID) {
|
||||
for transport in transports {
|
||||
guard transport.isPeerConnected(peerID),
|
||||
let snapshot = transport.currentPeerSnapshots().first(where: { $0.peerID == peerID && $0.isConnected }),
|
||||
let courierKey = snapshot.noisePublicKey,
|
||||
courierDirectory.isTrustedCourier(courierKey) || snapshot.isVerified else { continue }
|
||||
|
||||
let currentDate = now()
|
||||
for (recipient, queue) in outbox {
|
||||
// Mail *to* this peer flushes directly on connect.
|
||||
guard recipient != peerID,
|
||||
let recipientKey = courierDirectory.noiseKey(recipient),
|
||||
recipientKey != courierKey else { continue }
|
||||
for message in queue {
|
||||
guard message.depositedCourierKeys.count < Self.maxCouriersPerMessage,
|
||||
!message.depositedCourierKeys.contains(courierKey),
|
||||
currentDate.timeIntervalSince(message.timestamp) <= Self.messageTTLSeconds else { continue }
|
||||
if transport.sendCourierMessage(message.content, messageID: message.messageID, recipientNoiseKey: recipientKey, via: [peerID]) {
|
||||
SecureLogger.debug("📦 Deposit retry: PM \(message.messageID.prefix(8))… handed to \(peerID.id.prefix(8))… for \(recipient.id.prefix(8))…", category: .session)
|
||||
recordCourierDeposit(messageID: message.messageID, for: recipient, courierKeys: [courierKey])
|
||||
onMessageCarried?(message.messageID, recipient)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private struct CourierCandidate {
|
||||
let peerID: PeerID
|
||||
let noiseKey: Data
|
||||
}
|
||||
|
||||
private func eligibleCouriers(
|
||||
on transport: Transport,
|
||||
recipientKey: Data,
|
||||
excluding excludedKeys: Set<Data>,
|
||||
limit: Int
|
||||
) -> [CourierCandidate] {
|
||||
guard limit > 0 else { return [] }
|
||||
let candidates = transport.currentPeerSnapshots().compactMap { snapshot -> (CourierCandidate, isFavorite: Bool)? in
|
||||
guard snapshot.isConnected,
|
||||
let key = snapshot.noisePublicKey,
|
||||
key != recipientKey,
|
||||
!excludedKeys.contains(key) else { return nil }
|
||||
let isFavorite = courierDirectory.isTrustedCourier(key)
|
||||
guard isFavorite || snapshot.isVerified else { return nil }
|
||||
return (CourierCandidate(peerID: snapshot.peerID, noiseKey: key), isFavorite)
|
||||
}
|
||||
return candidates
|
||||
.sorted { $0.isFavorite && !$1.isFavorite }
|
||||
.prefix(limit)
|
||||
.map(\.0)
|
||||
}
|
||||
|
||||
private func queuedMessage(_ messageID: String, for peerID: PeerID) -> QueuedMessage? {
|
||||
outbox[peerID]?.first { $0.messageID == messageID }
|
||||
}
|
||||
|
||||
private func recordCourierDeposit(messageID: String, for peerID: PeerID, courierKeys: [Data]) {
|
||||
metrics?.record(.courierDeposited)
|
||||
guard var queue = outbox[peerID],
|
||||
let index = queue.firstIndex(where: { $0.messageID == messageID }) else { return }
|
||||
queue[index].depositedCourierKeys.formUnion(courierKeys)
|
||||
outbox[peerID] = queue
|
||||
persistOutbox()
|
||||
}
|
||||
|
||||
// MARK: - Outbox Management
|
||||
|
||||
/// A delivery or read ack confirms receipt; stop retaining the message.
|
||||
func markDelivered(_ messageID: String) {
|
||||
var cleared = false
|
||||
for (peerID, queue) in outbox {
|
||||
let filtered = queue.filter { $0.messageID != messageID }
|
||||
guard filtered.count != queue.count else { continue }
|
||||
outbox[peerID] = filtered.isEmpty ? nil : filtered
|
||||
cleared = true
|
||||
}
|
||||
if cleared {
|
||||
metrics?.record(.outboxDelivered)
|
||||
persistOutbox()
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -105,25 +326,40 @@ 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 = Date()
|
||||
let now = now()
|
||||
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 = reachableTransport(for: peerID) {
|
||||
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
@@ -134,6 +370,7 @@ final class MessageRouter {
|
||||
} else {
|
||||
outbox[peerID] = remaining
|
||||
}
|
||||
persistOutbox()
|
||||
}
|
||||
|
||||
func flushAllOutbox() {
|
||||
@@ -142,12 +379,26 @@ final class MessageRouter {
|
||||
|
||||
/// Periodically clean up expired messages from all outboxes
|
||||
func cleanupExpiredMessages() {
|
||||
let now = Date()
|
||||
let now = now()
|
||||
var droppedAny = false
|
||||
for peerID in Array(outbox.keys) {
|
||||
outbox[peerID]?.removeAll { now.timeIntervalSince($0.timestamp) > Self.messageTTLSeconds }
|
||||
var expiredMessageIDs: [String] = []
|
||||
outbox[peerID]?.removeAll { message in
|
||||
guard now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds else { return false }
|
||||
expiredMessageIDs.append(message.messageID)
|
||||
return true
|
||||
}
|
||||
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,7 +44,11 @@ final class NetworkActivationService: ObservableObject {
|
||||
private let permissionProvider: () -> LocationChannelManager.PermissionState
|
||||
private let mutualFavoritesProvider: () -> Set<Data>
|
||||
private let torController: NetworkActivationTorControlling
|
||||
private let relayController: NetworkActivationRelayControlling
|
||||
// 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 proxyController: NetworkActivationProxyControlling
|
||||
private let notificationCenter: NotificationCenter
|
||||
|
||||
@@ -55,7 +59,7 @@ final class NetworkActivationService: ObservableObject {
|
||||
permissionProvider = { LocationChannelManager.shared.permissionState }
|
||||
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
|
||||
torController = TorManager.shared
|
||||
relayController = NostrRelayManager.shared
|
||||
relayControllerProvider = { NostrRelayManager.shared }
|
||||
proxyController = TorURLSession.shared
|
||||
notificationCenter = .default
|
||||
}
|
||||
@@ -77,7 +81,7 @@ final class NetworkActivationService: ObservableObject {
|
||||
self.permissionProvider = permissionProvider
|
||||
self.mutualFavoritesProvider = mutualFavoritesProvider
|
||||
self.torController = torController
|
||||
self.relayController = relayController
|
||||
self.relayControllerProvider = { relayController }
|
||||
self.proxyController = proxyController
|
||||
self.notificationCenter = notificationCenter
|
||||
}
|
||||
|
||||
@@ -116,30 +116,30 @@ enum EncryptionStatus: Equatable {
|
||||
var description: String {
|
||||
switch self {
|
||||
case .none:
|
||||
return String(localized: "encryption.status.failed", comment: "Status text when encryption failed")
|
||||
return String(localized: "encryption.status.failed", defaultValue: "encryption failed", comment: "Status text when encryption failed")
|
||||
case .noHandshake:
|
||||
return String(localized: "encryption.status.not_encrypted", comment: "Status text when no encryption handshake happened")
|
||||
return String(localized: "encryption.status.not_encrypted", defaultValue: "not encrypted", comment: "Status text when no encryption handshake happened")
|
||||
case .noiseHandshaking:
|
||||
return String(localized: "encryption.status.establishing", comment: "Status text when encryption is being established")
|
||||
return String(localized: "encryption.status.establishing", defaultValue: "establishing encryption...", comment: "Status text when encryption is being established")
|
||||
case .noiseSecured:
|
||||
return String(localized: "encryption.status.secured", comment: "Status text when encryption is secured but not verified")
|
||||
return String(localized: "encryption.status.secured", defaultValue: "encrypted", comment: "Status text when encryption is secured but not verified")
|
||||
case .noiseVerified:
|
||||
return String(localized: "encryption.status.verified", comment: "Status text when encryption is verified")
|
||||
return String(localized: "encryption.status.verified", defaultValue: "encrypted & verified", comment: "Status text when encryption is verified")
|
||||
}
|
||||
}
|
||||
|
||||
var accessibilityDescription: String {
|
||||
switch self {
|
||||
case .none:
|
||||
return String(localized: "encryption.accessibility.failed", comment: "Accessibility text when encryption failed")
|
||||
return String(localized: "encryption.accessibility.failed", defaultValue: "encryption failed", comment: "Accessibility text when encryption failed")
|
||||
case .noHandshake:
|
||||
return String(localized: "encryption.accessibility.not_encrypted", comment: "Accessibility text when encryption is not established")
|
||||
return String(localized: "encryption.accessibility.not_encrypted", defaultValue: "not encrypted", comment: "Accessibility text when encryption is not established")
|
||||
case .noiseHandshaking:
|
||||
return String(localized: "encryption.accessibility.establishing", comment: "Accessibility text when encryption is being established")
|
||||
return String(localized: "encryption.accessibility.establishing", defaultValue: "establishing encryption", comment: "Accessibility text when encryption is being established")
|
||||
case .noiseSecured:
|
||||
return String(localized: "encryption.accessibility.secured", comment: "Accessibility text when encryption is secured")
|
||||
return String(localized: "encryption.accessibility.secured", defaultValue: "encrypted", comment: "Accessibility text when encryption is secured")
|
||||
case .noiseVerified:
|
||||
return String(localized: "encryption.accessibility.verified", comment: "Accessibility text when encryption is verified")
|
||||
return String(localized: "encryption.accessibility.verified", defaultValue: "encrypted and verified", comment: "Accessibility text when encryption is verified")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,6 +172,10 @@ final class NoiseEncryptionService {
|
||||
// Security components
|
||||
private let rateLimiter = NoiseRateLimiter()
|
||||
private let keychain: KeychainManagerProtocol
|
||||
|
||||
// One-time prekeys for forward-secret courier sealing (lazy generation
|
||||
// inside the store; the batch is minted on first bundle build).
|
||||
private let localPrekeys: LocalPrekeyStore
|
||||
|
||||
// Session maintenance
|
||||
private var rekeyTimer: Timer?
|
||||
@@ -200,6 +204,7 @@ final class NoiseEncryptionService {
|
||||
|
||||
init(keychain: KeychainManagerProtocol) {
|
||||
self.keychain = keychain
|
||||
self.localPrekeys = LocalPrekeyStore(keychain: keychain)
|
||||
|
||||
// BCH-01-009: Load or create static identity key with proper error handling
|
||||
let loadedKey: Curve25519.KeyAgreement.PrivateKey
|
||||
@@ -369,13 +374,155 @@ 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)
|
||||
}
|
||||
|
||||
// MARK: - One-Time Prekey Envelopes (forward-secret Noise X)
|
||||
|
||||
/// Domain separation for prekey-sealed envelopes: distinct from both the
|
||||
/// interactive XX transcripts and static-sealed courier envelopes, and
|
||||
/// bound to the specific prekey ID so a ciphertext cannot be replayed
|
||||
/// against a different prekey.
|
||||
private static let prekeyProloguePrefix = Data("bitchat-prekey-v1".utf8)
|
||||
|
||||
private static func prekeyPrologue(for prekeyID: UInt32) -> Data {
|
||||
var prologue = prekeyProloguePrefix
|
||||
var big = prekeyID.bigEndian
|
||||
withUnsafeBytes(of: &big) { prologue.append(contentsOf: $0) }
|
||||
return prologue
|
||||
}
|
||||
|
||||
/// Encrypt a payload to one of the recipient's gossiped one-time prekeys
|
||||
/// (Noise X where the responder static is the prekey, not the identity
|
||||
/// key). Unlike `sealCourierPayload`, this is forward secret: once the
|
||||
/// recipient consumes the prekey and its grace window lapses, the private
|
||||
/// key is deleted and captured ciphertext becomes undecryptable even if
|
||||
/// the recipient's identity key is later compromised. The initiator's
|
||||
/// static still rides inside (encrypted), so the recipient authenticates
|
||||
/// the sender exactly as with static-sealed envelopes.
|
||||
func sealPrekeyPayload(_ payload: Data, recipientPrekey: PrekeyBundle.Prekey) throws -> Data {
|
||||
let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientPrekey.publicKey)
|
||||
let handshake = NoiseHandshakeState(
|
||||
role: .initiator,
|
||||
pattern: .X,
|
||||
keychain: keychain,
|
||||
localStaticKey: staticIdentityKey,
|
||||
remoteStaticKey: remoteKey,
|
||||
prologue: Self.prekeyPrologue(for: recipientPrekey.id)
|
||||
)
|
||||
return try handshake.writeMessage(payload: payload)
|
||||
}
|
||||
|
||||
/// Decrypt an envelope sealed to one of our one-time prekeys. On success
|
||||
/// the prekey is marked consumed (its private key survives a 48h grace
|
||||
/// window for spray-and-wait redeliveries, then is deleted for good).
|
||||
/// Returns the payload, the sender's authenticated static key (same
|
||||
/// contract as `openCourierPayload`), and whether this open actually
|
||||
/// retired the prekey — false for a redelivery of already-consumed mail —
|
||||
/// so the caller can re-gossip the shrunken bundle only when it changed.
|
||||
func openPrekeyPayload(_ envelopeCiphertext: Data, prekeyID: UInt32) throws -> (payload: Data, senderStaticKey: Data, consumedPrekey: Bool) {
|
||||
guard let prekeyPrivate = localPrekeys.privateKey(for: prekeyID) else {
|
||||
SecureLogger.info("[PREKEY] open failed (unknown prekey id=\(prekeyID))", category: .session)
|
||||
throw NoiseEncryptionError.unknownPrekey
|
||||
}
|
||||
let handshake = NoiseHandshakeState(
|
||||
role: .responder,
|
||||
pattern: .X,
|
||||
keychain: keychain,
|
||||
localStaticKey: prekeyPrivate,
|
||||
prologue: Self.prekeyPrologue(for: prekeyID)
|
||||
)
|
||||
let payload = try handshake.readMessage(envelopeCiphertext)
|
||||
guard let senderKey = handshake.getRemoteStaticPublicKey() else {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
let consumedPrekey = localPrekeys.markConsumed(prekeyID)
|
||||
return (payload: payload, senderStaticKey: senderKey.rawRepresentation, consumedPrekey: consumedPrekey)
|
||||
}
|
||||
|
||||
/// Current signed prekey bundle for gossip, minting the initial batch on
|
||||
/// first use. Nil only when signing fails.
|
||||
func currentPrekeyBundle() -> PrekeyBundle? {
|
||||
let (prekeys, generatedAt) = localPrekeys.currentBundlePrekeys()
|
||||
guard !prekeys.isEmpty else { return nil }
|
||||
let unsigned = PrekeyBundle(
|
||||
noiseStaticPublicKey: getStaticPublicKeyData(),
|
||||
prekeys: prekeys,
|
||||
generatedAt: generatedAt,
|
||||
signature: Data(count: PrekeyBundle.signatureLength)
|
||||
)
|
||||
guard let signature = signData(unsigned.signableBytes()) else { return nil }
|
||||
return PrekeyBundle(
|
||||
noiseStaticPublicKey: unsigned.noiseStaticPublicKey,
|
||||
prekeys: prekeys,
|
||||
generatedAt: generatedAt,
|
||||
signature: signature
|
||||
)
|
||||
}
|
||||
|
||||
/// Verify a peer's bundle signature against their announce-bound Ed25519
|
||||
/// signing key.
|
||||
func verifyPrekeyBundleSignature(_ bundle: PrekeyBundle, signingPublicKey: Data) -> Bool {
|
||||
verifySignature(bundle.signature, for: bundle.signableBytes(), publicKey: signingPublicKey)
|
||||
}
|
||||
|
||||
/// Prune dead prekeys and top the batch back up when consumption runs it
|
||||
/// low. Returns true when the published bundle changed and should be
|
||||
/// re-gossiped.
|
||||
@discardableResult
|
||||
func replenishPrekeysIfNeeded() -> Bool {
|
||||
localPrekeys.replenishIfNeeded()
|
||||
}
|
||||
|
||||
/// Clear persistent identity (for panic mode)
|
||||
func clearPersistentIdentity() {
|
||||
// Clear from keychain
|
||||
let deletedStatic = keychain.deleteIdentityKey(forKey: "noiseStaticKey")
|
||||
let deletedSigning = keychain.deleteIdentityKey(forKey: "ed25519SigningKey")
|
||||
SecureLogger.logKeyOperation(.delete, keyType: "identity keys", success: deletedStatic && deletedSigning)
|
||||
// One-time prekey privates go with the identity they were bound to.
|
||||
localPrekeys.wipe()
|
||||
SecureLogger.warning("Panic mode activated - identity cleared", category: .security)
|
||||
// Stop rekey timer
|
||||
stopRekeyTimer()
|
||||
@@ -428,7 +575,7 @@ final class NoiseEncryptionService {
|
||||
private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data {
|
||||
var out = Data()
|
||||
// context
|
||||
let context = "bitchat-announce-v1".data(using: .utf8) ?? Data()
|
||||
let context = Data("bitchat-announce-v1".utf8)
|
||||
out.append(UInt8(min(context.count, 255)))
|
||||
out.append(context.prefix(255))
|
||||
// peerID (expect 8 bytes; pad/truncate to 8 for canonicalization)
|
||||
@@ -444,7 +591,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 = nickname.data(using: .utf8) ?? Data()
|
||||
let nickData = Data(nickname.utf8)
|
||||
out.append(UInt8(min(nickData.count, 255)))
|
||||
out.append(nickData.prefix(255))
|
||||
// timestamp
|
||||
@@ -769,4 +916,7 @@ struct NoiseMessage: Codable {
|
||||
enum NoiseEncryptionError: Error {
|
||||
case handshakeRequired
|
||||
case sessionNotEstablished
|
||||
/// Envelope references a prekey ID we don't hold (never ours, already
|
||||
/// deleted after its grace window, or wiped in a panic).
|
||||
case unknownPrekey
|
||||
}
|
||||
|
||||
@@ -14,7 +14,13 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
let registerPendingGiftWrap: @MainActor (String) -> Void
|
||||
let sendEvent: @MainActor (NostrEvent) -> Void
|
||||
let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
|
||||
/// Emits whether a relay that carries private messages is up
|
||||
/// (fail-closed behind Tor). A connected geohash/custom relay alone
|
||||
/// doesn't count: DM sends target the default relay set and would
|
||||
/// still queue.
|
||||
let relayConnectivity: @MainActor () -> AnyPublisher<Bool, Never>
|
||||
|
||||
@MainActor
|
||||
static func live(idBridge: NostrIdentityBridge) -> Dependencies {
|
||||
Dependencies(
|
||||
notificationCenter: .default,
|
||||
@@ -26,7 +32,8 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
||||
scheduleAfter: { delay, action in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||
}
|
||||
},
|
||||
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -49,6 +56,10 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
|
||||
// Reachability Cache (thread-safe)
|
||||
private var reachablePeers: Set<PeerID> = []
|
||||
// Mirror of the relay manager's connection state, cached here because
|
||||
// canDeliverPromptly is called synchronously off the main actor.
|
||||
private var relaysConnected = false
|
||||
private var relayConnectivityCancellable: AnyCancellable?
|
||||
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
|
||||
|
||||
@MainActor
|
||||
@@ -72,6 +83,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.reachablePeers = Set(reachable)
|
||||
}
|
||||
|
||||
relayConnectivityCancellable = self.dependencies.relayConnectivity()
|
||||
.sink { [weak self] connected in
|
||||
guard let self else { return }
|
||||
self.queue.async(flags: .barrier) { self.relaysConnected = connected }
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -125,34 +142,32 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
||||
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
||||
queue.sync {
|
||||
// Check if exact match
|
||||
// Callers address peers by either the short 16-hex ID or the full
|
||||
// 64-hex noise key (offline favorites), so compare in short form.
|
||||
let short = peerID.toShort()
|
||||
return queue.sync {
|
||||
if reachablePeers.contains(peerID) { return true }
|
||||
// Check for short ID match
|
||||
if peerID.isShort {
|
||||
return reachablePeers.contains(where: { $0.toShort() == peerID })
|
||||
}
|
||||
return false
|
||||
return reachablePeers.contains(where: { $0.toShort() == short })
|
||||
}
|
||||
}
|
||||
|
||||
func canDeliverPromptly(to peerID: PeerID) -> Bool {
|
||||
// A known npub makes a peer "reachable", but with no relay
|
||||
// connection a send only joins the local queue. Answering honestly
|
||||
// here lets the router hand a sealed copy to a courier in parallel
|
||||
// instead of waiting for internet that may never come.
|
||||
isPeerReachable(peerID) && queue.sync { relaysConnected }
|
||||
}
|
||||
|
||||
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||
func getPeerNicknames() -> [PeerID : String] { [:] }
|
||||
func getPeerNicknames() -> [PeerID: String] { [:] }
|
||||
|
||||
func getFingerprint(for peerID: PeerID) -> String? { nil }
|
||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
||||
func triggerHandshake(with peerID: PeerID) { /* no-op */ }
|
||||
|
||||
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
||||
private static var cachedNoiseService: NoiseEncryptionService?
|
||||
func getNoiseService() -> NoiseEncryptionService {
|
||||
if let noiseService = Self.cachedNoiseService {
|
||||
return noiseService
|
||||
}
|
||||
let noiseService = NoiseEncryptionService(keychain: keychain)
|
||||
Self.cachedNoiseService = noiseService
|
||||
return noiseService
|
||||
}
|
||||
|
||||
// Nostr does not use Noise sessions here; the inert Transport defaults
|
||||
// for the noise* identity hooks apply.
|
||||
|
||||
// Public broadcast not supported over Nostr here
|
||||
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
|
||||
|
||||
@@ -111,7 +111,7 @@ final class NotificationService {
|
||||
|
||||
func requestAuthorization() {
|
||||
guard !isRunningTests else { return }
|
||||
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
|
||||
if granted {
|
||||
// Permission granted
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
//
|
||||
// LocalPrekeyStore.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
|
||||
|
||||
/// Owns this device's one-time Curve25519 prekey private keys.
|
||||
///
|
||||
/// Privates persist in the Keychain (single blob, same protection class as
|
||||
/// the identity keys). A batch of `batchSize` unconsumed prekeys backs the
|
||||
/// gossiped bundle; when consumption drops the unconsumed count below
|
||||
/// `replenishThreshold`, the batch tops back up and the bundle's
|
||||
/// `generatedAt` bumps so peers replace their cached copy.
|
||||
///
|
||||
/// Redelivery grace: spray-and-wait means the same prekey-sealed ciphertext
|
||||
/// (or a re-seal of the same message to the same prekey ID) can arrive via
|
||||
/// several couriers days apart. A consumed prekey's private key is therefore
|
||||
/// retained for `consumedGraceSeconds` after first use and only then deleted.
|
||||
/// Tradeoff: during the grace window a compromise of the device still exposes
|
||||
/// mail sealed to that prekey — the forward-secrecy clock starts at deletion,
|
||||
/// not at first open. Refusing new ciphertexts while accepting redeliveries
|
||||
/// is not possible (the recipient cannot distinguish them), so the window is
|
||||
/// kept short and fixed.
|
||||
final class LocalPrekeyStore {
|
||||
struct Record: Codable {
|
||||
let id: UInt32
|
||||
let privateKey: Data
|
||||
let createdAt: Date
|
||||
var consumedAt: Date?
|
||||
}
|
||||
|
||||
private struct Persisted: Codable {
|
||||
var records: [Record]
|
||||
var nextID: UInt32
|
||||
var generatedAt: UInt64
|
||||
}
|
||||
|
||||
enum Policy {
|
||||
static let batchSize = PrekeyBundle.maxPrekeys
|
||||
static let replenishThreshold = 3
|
||||
/// How long a consumed prekey private survives for duplicate courier
|
||||
/// deliveries of mail sealed to it.
|
||||
static let consumedGraceSeconds: TimeInterval = 48 * 60 * 60
|
||||
/// Unconsumed prekeys older than this are rotated out: no honest
|
||||
/// sender seals to a bundle that stale (see
|
||||
/// `PrekeyBundleStore.Limits.maxBundleAgeForSealingSeconds`).
|
||||
static let unconsumedRetentionSeconds: TimeInterval = 30 * 24 * 60 * 60
|
||||
}
|
||||
|
||||
private static let keychainKey = "prekeysV1"
|
||||
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let now: () -> Date
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.prekeys.local")
|
||||
|
||||
// Guarded by `queue`.
|
||||
private var records: [Record] = []
|
||||
private var nextID: UInt32 = 0
|
||||
private var generatedAt: UInt64 = 0
|
||||
private var loaded = false
|
||||
|
||||
init(keychain: KeychainManagerProtocol, now: @escaping () -> Date = Date.init) {
|
||||
self.keychain = keychain
|
||||
self.now = now
|
||||
}
|
||||
|
||||
// MARK: - Bundle contents (public prekeys)
|
||||
|
||||
/// Unconsumed public prekeys for the gossiped bundle, generating the
|
||||
/// initial batch on first use. Sorted by ID for canonical signing bytes.
|
||||
func currentBundlePrekeys() -> (prekeys: [PrekeyBundle.Prekey], generatedAt: UInt64) {
|
||||
queue.sync {
|
||||
loadLocked()
|
||||
_ = replenishLocked()
|
||||
let prekeys = records
|
||||
.filter { $0.consumedAt == nil }
|
||||
.sorted { $0.id < $1.id }
|
||||
.compactMap { record -> PrekeyBundle.Prekey? in
|
||||
guard let key = try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: record.privateKey) else { return nil }
|
||||
return PrekeyBundle.Prekey(id: record.id, publicKey: key.publicKey.rawRepresentation)
|
||||
}
|
||||
return (prekeys, generatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Opening (private prekeys)
|
||||
|
||||
/// Private key for a prekey ID: unconsumed, or consumed within the
|
||||
/// redelivery grace window.
|
||||
func privateKey(for id: UInt32) -> Curve25519.KeyAgreement.PrivateKey? {
|
||||
queue.sync {
|
||||
loadLocked()
|
||||
let date = now()
|
||||
guard let record = records.first(where: { $0.id == id }) else { return nil }
|
||||
if let consumedAt = record.consumedAt,
|
||||
date.timeIntervalSince(consumedAt) > Policy.consumedGraceSeconds {
|
||||
return nil
|
||||
}
|
||||
return try? Curve25519.KeyAgreement.PrivateKey(rawRepresentation: record.privateKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a prekey consumed (starts its grace clock). Idempotent: a
|
||||
/// redelivery within the grace window does not restart the clock.
|
||||
///
|
||||
/// Returns true when this call actually retired a prekey, i.e. the
|
||||
/// published bundle shrank. Consuming a prekey drops it from
|
||||
/// `currentBundlePrekeys()`, so `generatedAt` must advance strictly too:
|
||||
/// otherwise peers that cached the old bundle reject the same-`generatedAt`
|
||||
/// replacement in `PrekeyBundleStore.ingest`, keep assigning the consumed
|
||||
/// ID, and their mail starts failing `unknownPrekey` once the 48h grace
|
||||
/// lapses. The caller re-gossips on a true result.
|
||||
@discardableResult
|
||||
func markConsumed(_ id: UInt32) -> Bool {
|
||||
queue.sync {
|
||||
loadLocked()
|
||||
guard let index = records.firstIndex(where: { $0.id == id }),
|
||||
records[index].consumedAt == nil else { return false }
|
||||
records[index].consumedAt = now()
|
||||
advanceGeneratedAtLocked()
|
||||
persistLocked()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Prunes dead prekeys and tops the unconsumed batch back up when it runs
|
||||
/// low. Returns true when the published bundle changed (caller should
|
||||
/// re-gossip).
|
||||
@discardableResult
|
||||
func replenishIfNeeded() -> Bool {
|
||||
queue.sync {
|
||||
loadLocked()
|
||||
return replenishLocked()
|
||||
}
|
||||
}
|
||||
|
||||
var unconsumedCount: Int {
|
||||
queue.sync {
|
||||
loadLocked()
|
||||
return records.filter { $0.consumedAt == nil }.count
|
||||
}
|
||||
}
|
||||
|
||||
/// Panic wipe: drop all prekey privates from memory and the Keychain.
|
||||
func wipe() {
|
||||
queue.sync {
|
||||
records.removeAll()
|
||||
nextID = 0
|
||||
generatedAt = 0
|
||||
loaded = true
|
||||
_ = keychain.deleteIdentityKey(forKey: Self.keychainKey)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals (call only on `queue`)
|
||||
|
||||
private func replenishLocked() -> Bool {
|
||||
let date = now()
|
||||
|
||||
// Consumed prekeys past the grace window are gone for good; stale
|
||||
// unconsumed ones rotate out (their bundle is too old to seal to).
|
||||
let recordsBefore = records.count
|
||||
let unconsumedBefore = records.filter { $0.consumedAt == nil }.count
|
||||
records.removeAll { record in
|
||||
if let consumedAt = record.consumedAt {
|
||||
return date.timeIntervalSince(consumedAt) > Policy.consumedGraceSeconds
|
||||
}
|
||||
return date.timeIntervalSince(record.createdAt) > Policy.unconsumedRetentionSeconds
|
||||
}
|
||||
// Only a change to the *unconsumed* set alters the published bundle;
|
||||
// grace-expired consumed keys were never in it.
|
||||
let unconsumed = records.filter { $0.consumedAt == nil }.count
|
||||
var bundleChanged = unconsumed != unconsumedBefore
|
||||
|
||||
if unconsumed < Policy.replenishThreshold {
|
||||
for _ in unconsumed..<Policy.batchSize {
|
||||
let key = Curve25519.KeyAgreement.PrivateKey()
|
||||
records.append(Record(id: nextID, privateKey: key.rawRepresentation, createdAt: date, consumedAt: nil))
|
||||
nextID &+= 1
|
||||
}
|
||||
advanceGeneratedAtLocked()
|
||||
bundleChanged = true
|
||||
SecureLogger.debug("🔑 Replenished one-time prekeys (unconsumed was \(unconsumed))", category: .security)
|
||||
}
|
||||
|
||||
if bundleChanged || records.count != recordsBefore { persistLocked() }
|
||||
return bundleChanged
|
||||
}
|
||||
|
||||
/// Advance `generatedAt` strictly monotonically. Uses wall-clock millis but
|
||||
/// never repeats or regresses, so two changes within the same millisecond
|
||||
/// still produce distinct, increasing stamps that peers' monotonic ingest
|
||||
/// accepts.
|
||||
private func advanceGeneratedAtLocked() {
|
||||
let nowMillis = UInt64(max(0, now().timeIntervalSince1970 * 1000))
|
||||
generatedAt = max(nowMillis, generatedAt &+ 1)
|
||||
}
|
||||
|
||||
private func loadLocked() {
|
||||
guard !loaded else { return }
|
||||
loaded = true
|
||||
guard let data = keychain.getIdentityKey(forKey: Self.keychainKey),
|
||||
let persisted = try? JSONDecoder().decode(Persisted.self, from: data) else {
|
||||
return
|
||||
}
|
||||
records = persisted.records
|
||||
nextID = persisted.nextID
|
||||
generatedAt = persisted.generatedAt
|
||||
}
|
||||
|
||||
private func persistLocked() {
|
||||
let persisted = Persisted(records: records, nextID: nextID, generatedAt: generatedAt)
|
||||
guard let data = try? JSONEncoder().encode(persisted) else {
|
||||
SecureLogger.error("Failed to encode prekey store", category: .keychain)
|
||||
return
|
||||
}
|
||||
if !keychain.saveIdentityKey(data, forKey: Self.keychainKey) {
|
||||
SecureLogger.error("Failed to persist prekey store", category: .keychain)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
//
|
||||
// PrekeyBundleStore.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Signature-verified one-time prekey bundles received from other peers.
|
||||
///
|
||||
/// One bundle per Noise static key: a newer `generatedAt` replaces the cached
|
||||
/// copy, keeping the IDs we already sealed with marked used so a prekey is
|
||||
/// never reused across messages. Assignments are remembered per message ID so
|
||||
/// deposit retries of the same message re-use its prekey (and its budget)
|
||||
/// instead of burning a fresh one per courier.
|
||||
///
|
||||
/// Only public key material lives here; it persists to disk so a sender can
|
||||
/// prekey-seal for recipients met long ago. Included in the panic wipe.
|
||||
final class PrekeyBundleStore {
|
||||
struct StoredBundle: Codable {
|
||||
let noiseKey: Data
|
||||
var generatedAt: UInt64
|
||||
var prekeyIDs: [UInt32]
|
||||
var prekeyPublicKeys: [Data]
|
||||
/// IDs this device already sealed with (never reused).
|
||||
var usedIDs: Set<UInt32>
|
||||
/// messageID → prekey ID, so re-deposits of one message share one prekey.
|
||||
var assignments: [String: UInt32]
|
||||
var updatedAt: Date
|
||||
}
|
||||
|
||||
enum Limits {
|
||||
static let maxPeers = 200
|
||||
/// Don't seal to bundles older than this: the owner may have rotated
|
||||
/// the unconsumed keys out (see `LocalPrekeyStore.Policy`).
|
||||
static let maxBundleAgeForSealingSeconds: TimeInterval = 7 * 24 * 60 * 60
|
||||
}
|
||||
|
||||
static let shared = PrekeyBundleStore()
|
||||
|
||||
private var bundles: [Data: StoredBundle] = [:]
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.prekeys.bundles")
|
||||
private let fileURL: URL?
|
||||
private let maxPeers: Int
|
||||
private let now: () -> Date
|
||||
|
||||
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
|
||||
/// when `persistsToDisk` is false.
|
||||
init(
|
||||
persistsToDisk: Bool = true,
|
||||
fileURL: URL? = nil,
|
||||
maxPeers: Int = Limits.maxPeers,
|
||||
now: @escaping () -> Date = Date.init
|
||||
) {
|
||||
self.now = now
|
||||
self.maxPeers = maxPeers
|
||||
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
// MARK: - Ingest
|
||||
|
||||
/// Stores a bundle whose signature the caller has already verified
|
||||
/// against the owner's announce-bound signing key. Returns false when an
|
||||
/// equal-or-newer bundle is already cached (nothing changed).
|
||||
@discardableResult
|
||||
func ingest(_ bundle: PrekeyBundle) -> Bool {
|
||||
guard bundle.noiseStaticPublicKey.count == PrekeyBundle.keyLength,
|
||||
!bundle.prekeys.isEmpty else { return false }
|
||||
return queue.sync {
|
||||
if let existing = bundles[bundle.noiseStaticPublicKey],
|
||||
existing.generatedAt >= bundle.generatedAt {
|
||||
return false
|
||||
}
|
||||
let previous = bundles[bundle.noiseStaticPublicKey]
|
||||
let newIDs = Set(bundle.prekeys.map(\.id))
|
||||
// Keep consumption state for IDs the fresh bundle still offers
|
||||
// (a top-up keeps the owner's unconsumed keys); drop the rest.
|
||||
let carriedUsed = (previous?.usedIDs ?? []).intersection(newIDs)
|
||||
let carriedAssignments = (previous?.assignments ?? [:]).filter { newIDs.contains($0.value) }
|
||||
bundles[bundle.noiseStaticPublicKey] = StoredBundle(
|
||||
noiseKey: bundle.noiseStaticPublicKey,
|
||||
generatedAt: bundle.generatedAt,
|
||||
prekeyIDs: bundle.prekeys.map(\.id),
|
||||
prekeyPublicKeys: bundle.prekeys.map(\.publicKey),
|
||||
usedIDs: carriedUsed,
|
||||
assignments: carriedAssignments,
|
||||
updatedAt: now()
|
||||
)
|
||||
enforceCapLocked()
|
||||
persistLocked()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sealing support
|
||||
|
||||
/// Whether an unexpired bundle with sealable prekeys is cached for a peer.
|
||||
func hasUsableBundle(for noiseKey: Data) -> Bool {
|
||||
queue.sync {
|
||||
guard let bundle = bundles[noiseKey], isFreshLocked(bundle) else { return false }
|
||||
return bundle.usedIDs.count < bundle.prekeyIDs.count
|
||||
}
|
||||
}
|
||||
|
||||
/// The prekey to seal a message with: the message's existing assignment if
|
||||
/// any (re-deposits reuse it), else the lowest unused ID, which is then
|
||||
/// marked used. Nil when no fresh bundle is cached or all its prekeys are
|
||||
/// spent — callers fall back to static sealing.
|
||||
func assignPrekey(messageID: String, recipientNoiseKey: Data) -> PrekeyBundle.Prekey? {
|
||||
queue.sync {
|
||||
guard var bundle = bundles[recipientNoiseKey], isFreshLocked(bundle) else { return nil }
|
||||
|
||||
if let assigned = bundle.assignments[messageID],
|
||||
let index = bundle.prekeyIDs.firstIndex(of: assigned) {
|
||||
return PrekeyBundle.Prekey(id: assigned, publicKey: bundle.prekeyPublicKeys[index])
|
||||
}
|
||||
|
||||
guard let index = bundle.prekeyIDs.indices
|
||||
.filter({ !bundle.usedIDs.contains(bundle.prekeyIDs[$0]) })
|
||||
.min(by: { bundle.prekeyIDs[$0] < bundle.prekeyIDs[$1] }) else {
|
||||
return nil
|
||||
}
|
||||
let id = bundle.prekeyIDs[index]
|
||||
bundle.usedIDs.insert(id)
|
||||
bundle.assignments[messageID] = id
|
||||
bundle.updatedAt = now()
|
||||
bundles[recipientNoiseKey] = bundle
|
||||
persistLocked()
|
||||
return PrekeyBundle.Prekey(id: id, publicKey: bundle.prekeyPublicKeys[index])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Maintenance
|
||||
|
||||
/// Panic wipe: drop all cached bundles from memory and disk.
|
||||
func wipe() {
|
||||
queue.sync {
|
||||
bundles.removeAll()
|
||||
if let fileURL {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals (call only on `queue`)
|
||||
|
||||
private func isFreshLocked(_ bundle: StoredBundle) -> Bool {
|
||||
let ageSeconds = now().timeIntervalSince1970 - Double(bundle.generatedAt) / 1000
|
||||
return ageSeconds <= Limits.maxBundleAgeForSealingSeconds
|
||||
}
|
||||
|
||||
private func enforceCapLocked() {
|
||||
while bundles.count > maxPeers {
|
||||
guard let victim = bundles.min(by: { $0.value.updatedAt < $1.value.updatedAt }) else { return }
|
||||
bundles.removeValue(forKey: victim.key)
|
||||
}
|
||||
}
|
||||
|
||||
private func persistLocked() {
|
||||
guard let fileURL else { return }
|
||||
do {
|
||||
if bundles.isEmpty {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
return
|
||||
}
|
||||
try FileManager.default.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let data = try JSONEncoder().encode(Array(bundles.values))
|
||||
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 prekey bundle store: \(error)", category: .security)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFromDisk() {
|
||||
guard let fileURL else { return }
|
||||
queue.sync {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let stored = try? JSONDecoder().decode([StoredBundle].self, from: data) else {
|
||||
return
|
||||
}
|
||||
for bundle in stored where bundle.prekeyIDs.count == bundle.prekeyPublicKeys.count {
|
||||
bundles[bundle.noiseKey] = bundle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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("prekeys", isDirectory: true)
|
||||
.appendingPathComponent("bundles.json")
|
||||
}
|
||||
}
|
||||
@@ -8,14 +8,25 @@
|
||||
|
||||
import BitLogger
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Manages all private chat functionality
|
||||
/// Manages private chat session policy (selection, read receipts,
|
||||
/// consolidation). Message storage lives in the single-writer
|
||||
/// `ConversationStore` (docs/CONVERSATION-STORE-DESIGN.md); the
|
||||
/// `privateChats` / `unreadMessages` properties below are read-only views
|
||||
/// derived from it.
|
||||
@MainActor
|
||||
final class PrivateChatManager: ObservableObject {
|
||||
@Published var privateChats: [PeerID: [BitchatMessage]] = [:]
|
||||
@Published var selectedPeer: PeerID? = nil
|
||||
@Published var unreadMessages: Set<PeerID> = []
|
||||
/// Read-only mirror of `ConversationStore.selectedPrivatePeerID` — the
|
||||
/// store is the sole owner of conversation selection. Kept `@Published`
|
||||
/// so existing observers (`objectWillChange` forwarding into
|
||||
/// `ChatViewModel`) keep firing on selection changes. Mutate via
|
||||
/// `startChat(with:)` / `endChat()`, which route through the store's
|
||||
/// `setSelectedPrivatePeer` intent.
|
||||
@Published private(set) var selectedPeer: PeerID? = nil
|
||||
private var selectedPeerMirrorCancellable: AnyCancellable? = nil
|
||||
|
||||
private var selectedPeerFingerprint: String? = nil
|
||||
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
||||
@@ -25,13 +36,51 @@ final class PrivateChatManager: ObservableObject {
|
||||
weak var messageRouter: MessageRouter?
|
||||
// Peer service for looking up peer info during consolidation
|
||||
weak var unifiedPeerService: UnifiedPeerService?
|
||||
|
||||
init(meshService: Transport? = nil) {
|
||||
self.meshService = meshService
|
||||
/// Single source of truth for message and selection state; injected by
|
||||
/// the bootstrapper (`wireServiceGraph`).
|
||||
var conversationStore: ConversationStore? {
|
||||
didSet { bindSelectionMirror() }
|
||||
}
|
||||
|
||||
// Cap for messages stored per private chat
|
||||
private let privateChatCap = TransportConfig.privateChatCap
|
||||
init(meshService: Transport? = nil, conversationStore: ConversationStore? = nil) {
|
||||
self.meshService = meshService
|
||||
self.conversationStore = conversationStore
|
||||
bindSelectionMirror() // didSet does not fire during init
|
||||
}
|
||||
|
||||
/// Keeps `selectedPeer` in lock-step with the store's selection axis
|
||||
/// (including store-internal handoffs such as conversation migration).
|
||||
private func bindSelectionMirror() {
|
||||
guard let store = conversationStore else {
|
||||
selectedPeerMirrorCancellable = nil
|
||||
return
|
||||
}
|
||||
selectedPeerMirrorCancellable = store.$selectedPrivatePeerID
|
||||
.sink { [weak self] peerID in
|
||||
guard let self, self.selectedPeer != peerID else { return }
|
||||
self.selectedPeer = peerID
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Derived message state (read-only compat views)
|
||||
|
||||
/// All private chats keyed by routing peer ID, derived from the store.
|
||||
/// Mutations go through the store's intent API only.
|
||||
@MainActor
|
||||
var privateChats: [PeerID: [BitchatMessage]] {
|
||||
conversationStore?.directMessagesByRoutingPeerID() ?? [:]
|
||||
}
|
||||
|
||||
/// Unread chats, derived from the store's unread state.
|
||||
@MainActor
|
||||
var unreadMessages: Set<PeerID> {
|
||||
conversationStore?.unreadDirectRoutingPeerIDs() ?? []
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func messages(for peerID: PeerID) -> [BitchatMessage] {
|
||||
conversationStore?.conversationsByID[.directPeer(peerID)]?.messages ?? []
|
||||
}
|
||||
|
||||
// MARK: - Message Consolidation
|
||||
|
||||
@@ -44,57 +93,51 @@ final class PrivateChatManager: ObservableObject {
|
||||
/// - Returns: True if any unread messages were found during consolidation
|
||||
@MainActor
|
||||
func consolidateMessages(for peerID: PeerID, peerNickname: String, persistedReadReceipts: Set<String>) -> Bool {
|
||||
guard let meshService = meshService else { return false }
|
||||
guard let meshService = meshService, let store = conversationStore else { return false }
|
||||
var hasUnreadMessages = false
|
||||
|
||||
// 1. Consolidate from stable Noise key (64-char hex)
|
||||
if let peer = unifiedPeerService?.getPeer(by: peerID) {
|
||||
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
||||
let nostrMessages = messages(for: noiseKeyHex)
|
||||
|
||||
if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex], !nostrMessages.isEmpty {
|
||||
if privateChats[peerID] == nil {
|
||||
privateChats[peerID] = []
|
||||
}
|
||||
|
||||
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
|
||||
if noiseKeyHex != peerID, !nostrMessages.isEmpty {
|
||||
for message in nostrMessages {
|
||||
if !existingMessageIds.contains(message.id) {
|
||||
// Update senderPeerID for correct read receipts
|
||||
let updatedMessage = BitchatMessage(
|
||||
id: message.id,
|
||||
sender: message.sender,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
isRelay: message.isRelay,
|
||||
originalSender: message.originalSender,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientNickname: message.recipientNickname,
|
||||
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID,
|
||||
mentions: message.mentions,
|
||||
deliveryStatus: message.deliveryStatus
|
||||
)
|
||||
privateChats[peerID]?.append(updatedMessage)
|
||||
// Update senderPeerID for correct read receipts
|
||||
let updatedMessage = BitchatMessage(
|
||||
id: message.id,
|
||||
sender: message.sender,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
isRelay: message.isRelay,
|
||||
originalSender: message.originalSender,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientNickname: message.recipientNickname,
|
||||
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID,
|
||||
mentions: message.mentions,
|
||||
deliveryStatus: message.deliveryStatus
|
||||
)
|
||||
// Store append dedups by message ID (skips ones the
|
||||
// target chat already has).
|
||||
guard store.append(updatedMessage, to: .directPeer(peerID)) else { continue }
|
||||
|
||||
// Check for recent unread messages (< 60s, not sent by us, not already read)
|
||||
// Use persistedReadReceipts to correctly identify already-read messages after app restart
|
||||
if message.senderPeerID != meshService.myPeerID {
|
||||
let messageAge = Date().timeIntervalSince(message.timestamp)
|
||||
if messageAge < 60 && !persistedReadReceipts.contains(message.id) {
|
||||
hasUnreadMessages = true
|
||||
}
|
||||
// Check for recent unread messages (< 60s, not sent by us, not already read)
|
||||
// Use persistedReadReceipts to correctly identify already-read messages after app restart
|
||||
if message.senderPeerID != meshService.myPeerID {
|
||||
let messageAge = Date().timeIntervalSince(message.timestamp)
|
||||
if messageAge < 60 && !persistedReadReceipts.contains(message.id) {
|
||||
hasUnreadMessages = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
||||
|
||||
if hasUnreadMessages {
|
||||
unreadMessages.insert(peerID)
|
||||
} else if unreadMessages.contains(noiseKeyHex) {
|
||||
unreadMessages.remove(noiseKeyHex)
|
||||
store.markUnread(.directPeer(peerID))
|
||||
} else {
|
||||
store.markRead(.directPeer(noiseKeyHex))
|
||||
}
|
||||
|
||||
privateChats.removeValue(forKey: noiseKeyHex)
|
||||
store.removeConversation(.directPeer(noiseKeyHex))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,52 +155,43 @@ final class PrivateChatManager: ObservableObject {
|
||||
}
|
||||
|
||||
if !tempPeerIDsToConsolidate.isEmpty {
|
||||
if privateChats[peerID] == nil {
|
||||
privateChats[peerID] = []
|
||||
}
|
||||
|
||||
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
|
||||
var consolidatedCount = 0
|
||||
var hadUnreadTemp = false
|
||||
let unreadPeerIDs = unreadMessages
|
||||
|
||||
for tempPeerID in tempPeerIDsToConsolidate {
|
||||
if unreadMessages.contains(tempPeerID) {
|
||||
if unreadPeerIDs.contains(tempPeerID) {
|
||||
hadUnreadTemp = true
|
||||
}
|
||||
|
||||
if let tempMessages = privateChats[tempPeerID] {
|
||||
for message in tempMessages {
|
||||
if !existingMessageIds.contains(message.id) {
|
||||
let updatedMessage = BitchatMessage(
|
||||
id: message.id,
|
||||
sender: message.sender,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
isRelay: message.isRelay,
|
||||
originalSender: message.originalSender,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientNickname: message.recipientNickname,
|
||||
senderPeerID: peerID,
|
||||
mentions: message.mentions,
|
||||
deliveryStatus: message.deliveryStatus
|
||||
)
|
||||
privateChats[peerID]?.append(updatedMessage)
|
||||
consolidatedCount += 1
|
||||
}
|
||||
for message in messages(for: tempPeerID) {
|
||||
let updatedMessage = BitchatMessage(
|
||||
id: message.id,
|
||||
sender: message.sender,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
isRelay: message.isRelay,
|
||||
originalSender: message.originalSender,
|
||||
isPrivate: message.isPrivate,
|
||||
recipientNickname: message.recipientNickname,
|
||||
senderPeerID: peerID,
|
||||
mentions: message.mentions,
|
||||
deliveryStatus: message.deliveryStatus
|
||||
)
|
||||
if store.append(updatedMessage, to: .directPeer(peerID)) {
|
||||
consolidatedCount += 1
|
||||
}
|
||||
privateChats.removeValue(forKey: tempPeerID)
|
||||
unreadMessages.remove(tempPeerID)
|
||||
}
|
||||
store.removeConversation(.directPeer(tempPeerID))
|
||||
}
|
||||
|
||||
if hadUnreadTemp {
|
||||
unreadMessages.insert(peerID)
|
||||
store.markUnread(.directPeer(peerID))
|
||||
hasUnreadMessages = true
|
||||
SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session)
|
||||
}
|
||||
|
||||
if consolidatedCount > 0 {
|
||||
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
||||
SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session)
|
||||
}
|
||||
}
|
||||
@@ -168,102 +202,82 @@ final class PrivateChatManager: ObservableObject {
|
||||
/// Syncs the read receipt tracking between manager and view model for sent messages
|
||||
@MainActor
|
||||
func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set<String>) {
|
||||
guard let messages = privateChats[peerID] else { return }
|
||||
|
||||
for message in messages {
|
||||
for message in messages(for: peerID) {
|
||||
if message.sender == nickname {
|
||||
if let status = message.deliveryStatus {
|
||||
switch status {
|
||||
case .read, .delivered:
|
||||
externalReceipts.insert(message.id)
|
||||
sentReadReceipts.insert(message.id)
|
||||
case .failed, .partiallyDelivered, .sending, .sent:
|
||||
case .failed, .partiallyDelivered, .sending, .sent, .carried:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a private chat with a peer
|
||||
|
||||
/// Start a private chat with a peer. Selection is mutated through the
|
||||
/// store's intent (the store owns it); the manager keeps its side
|
||||
/// effects (fingerprint tracking, read receipts, unread clearing).
|
||||
@MainActor
|
||||
func startChat(with peerID: PeerID) {
|
||||
selectedPeer = peerID
|
||||
|
||||
// Also creates the conversation if needed and updates the derived
|
||||
// `selectedConversationID`; `selectedPeer` mirrors the change.
|
||||
conversationStore?.setSelectedPrivatePeer(peerID)
|
||||
|
||||
// Store fingerprint for persistence across reconnections
|
||||
if let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||
selectedPeerFingerprint = fingerprint
|
||||
}
|
||||
|
||||
|
||||
// Mark messages as read
|
||||
markAsRead(from: peerID)
|
||||
|
||||
// Initialize chat if needed
|
||||
if privateChats[peerID] == nil {
|
||||
privateChats[peerID] = []
|
||||
}
|
||||
}
|
||||
|
||||
/// End the current private chat
|
||||
|
||||
/// End the current private chat (selection returns to the active public
|
||||
/// channel's conversation).
|
||||
func endChat() {
|
||||
selectedPeer = nil
|
||||
conversationStore?.setSelectedPrivatePeer(nil)
|
||||
selectedPeerFingerprint = nil
|
||||
}
|
||||
|
||||
/// Remove duplicate messages by ID and keep chronological order
|
||||
func sanitizeChat(for peerID: PeerID) {
|
||||
guard let arr = privateChats[peerID] else { return }
|
||||
if arr.count <= 1 {
|
||||
return
|
||||
}
|
||||
/// No-op since the `ConversationStore` cutover: the store maintains
|
||||
/// chronological order and dedups by message ID on every insert, so the
|
||||
/// per-append re-sort/dedup sweep this performed is no longer needed.
|
||||
/// Kept only for API compatibility until step 5 removes the callers.
|
||||
func sanitizeChat(for peerID: PeerID) {}
|
||||
|
||||
var indexByID: [String: Int] = [:]
|
||||
indexByID.reserveCapacity(arr.count)
|
||||
var deduped: [BitchatMessage] = []
|
||||
deduped.reserveCapacity(arr.count)
|
||||
|
||||
for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
|
||||
if let existing = indexByID[msg.id] {
|
||||
deduped[existing] = msg
|
||||
} else {
|
||||
indexByID[msg.id] = deduped.count
|
||||
deduped.append(msg)
|
||||
}
|
||||
}
|
||||
|
||||
privateChats[peerID] = deduped
|
||||
}
|
||||
|
||||
/// Mark messages from a peer as read
|
||||
@MainActor
|
||||
func markAsRead(from peerID: PeerID) {
|
||||
unreadMessages.remove(peerID)
|
||||
|
||||
conversationStore?.markRead(.directPeer(peerID))
|
||||
|
||||
// Send read receipts for unread messages that haven't been sent yet
|
||||
if let messages = privateChats[peerID] {
|
||||
for message in messages {
|
||||
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
|
||||
sendReadReceipt(for: message)
|
||||
}
|
||||
for message in messages(for: peerID) {
|
||||
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
|
||||
sendReadReceipt(for: message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
|
||||
private func sendReadReceipt(for message: BitchatMessage) {
|
||||
guard !sentReadReceipts.contains(message.id),
|
||||
let senderPeerID = message.senderPeerID else {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
sentReadReceipts.insert(message.id)
|
||||
|
||||
|
||||
// Create read receipt using the simplified method
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: message.id,
|
||||
readerID: meshService?.myPeerID ?? PeerID(str: ""),
|
||||
readerNickname: meshService?.myNickname ?? ""
|
||||
)
|
||||
|
||||
|
||||
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
|
||||
if let router = messageRouter {
|
||||
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session)
|
||||
|
||||
@@ -18,10 +18,18 @@ struct RelayController {
|
||||
isDirectedFragment: Bool,
|
||||
isHandshake: Bool,
|
||||
isAnnounce: Bool,
|
||||
isRequestSync: Bool = false,
|
||||
isUrgentBoardPost: Bool = false,
|
||||
degree: Int,
|
||||
highDegreeThreshold: Int) -> RelayDecision {
|
||||
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
||||
|
||||
// REQUEST_SYNC is link-local: never relay it, even when a peer crafts
|
||||
// one with TTL headroom to turn every reachable node into a responder.
|
||||
if isRequestSync {
|
||||
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
|
||||
}
|
||||
|
||||
// Suppress obvious non-relays
|
||||
if ttlCap <= 1 || senderIsSelf || recipientIsSelf {
|
||||
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
|
||||
@@ -39,7 +47,12 @@ struct RelayController {
|
||||
}
|
||||
|
||||
if isFragment {
|
||||
let ttlLimit = min(ttlCap, TransportConfig.bleFragmentRelayTtlCap)
|
||||
// Dense graphs clamp harder to contain full-fanout fragment floods;
|
||||
// sparse graphs get full depth so media reaches as far as text.
|
||||
let fragmentCap = degree >= highDegreeThreshold
|
||||
? TransportConfig.bleFragmentRelayTtlCapDense
|
||||
: TransportConfig.bleFragmentRelayTtlCap
|
||||
let ttlLimit = min(ttlCap, fragmentCap)
|
||||
guard ttlLimit > 1 else {
|
||||
return RelayDecision(shouldRelay: false, newTTL: ttlLimit, delayMs: 0)
|
||||
}
|
||||
@@ -50,12 +63,17 @@ struct RelayController {
|
||||
|
||||
// TTL clamping for broadcast
|
||||
// - Dense graphs: keep lower but still allow multi-hop bridging
|
||||
// - Announces get a bit more headroom
|
||||
// - Thin chains (degree <= 2): every hop counts and flood cost is
|
||||
// minimal, so relay at full incoming depth
|
||||
// - Announces (and urgent board posts) get a bit more headroom
|
||||
let ttlLimit: UInt8 = {
|
||||
if degree >= highDegreeThreshold {
|
||||
return max(UInt8(2), min(ttlCap, UInt8(5)))
|
||||
}
|
||||
let preferred = UInt8(isAnnounce ? 7 : 6)
|
||||
if degree <= 2 {
|
||||
return ttlCap
|
||||
}
|
||||
let preferred = UInt8((isAnnounce || isUrgentBoardPost) ? 7 : 6)
|
||||
return max(UInt8(2), min(ttlCap, preferred))
|
||||
}()
|
||||
let newTTL = ttlLimit &- 1
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// TestEnvironment.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Process-level test-environment detection for singletons that must swap a
|
||||
/// real OS-backed dependency (keychain, persistent defaults, notifications)
|
||||
/// for an in-memory one under test. Mirrors the detection already used by
|
||||
/// `NotificationService` and `LocationStateManager`.
|
||||
enum TestEnvironment {
|
||||
/// True when running under XCTest / Swift Testing or in CI.
|
||||
static let isRunningTests: Bool = {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
return NSClassFromString("XCTestCase") != nil ||
|
||||
env["XCTestConfigurationFilePath"] != nil ||
|
||||
env["XCTestBundlePath"] != nil ||
|
||||
env["GITHUB_ACTIONS"] != nil ||
|
||||
env["CI"] != nil
|
||||
}()
|
||||
}
|
||||
@@ -11,12 +11,67 @@ struct TransportPeerSnapshot: Equatable, Hashable {
|
||||
let isConnected: Bool
|
||||
let noisePublicKey: Data?
|
||||
let lastSeen: Date
|
||||
/// Whether the peer's announce was signature-verified (courier tier gate).
|
||||
let isVerified: Bool
|
||||
|
||||
init(
|
||||
peerID: PeerID,
|
||||
nickname: String,
|
||||
isConnected: Bool,
|
||||
noisePublicKey: Data?,
|
||||
lastSeen: Date,
|
||||
isVerified: Bool = false
|
||||
) {
|
||||
self.peerID = peerID
|
||||
self.nickname = nickname
|
||||
self.isConnected = isConnected
|
||||
self.noisePublicKey = noisePublicKey
|
||||
self.lastSeen = lastSeen
|
||||
self.isVerified = isVerified
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of a `/ping` probe over the mesh.
|
||||
struct MeshPingResult: Equatable {
|
||||
/// Round-trip time in milliseconds.
|
||||
let rttMs: Int
|
||||
/// Total hops to the peer (1 = directly connected), derived from the
|
||||
/// pong's TTL decrements; nil when the reply carried inconsistent TTLs.
|
||||
let hops: Int?
|
||||
}
|
||||
|
||||
/// Undirected mesh link between two peers, normalized so `(a, b)` and
|
||||
/// `(b, a)` collapse to one edge.
|
||||
struct MeshTopologyEdge: Hashable {
|
||||
let a: PeerID
|
||||
let b: PeerID
|
||||
|
||||
init(_ first: PeerID, _ second: PeerID) {
|
||||
if first < second {
|
||||
a = first
|
||||
b = second
|
||||
} else {
|
||||
a = second
|
||||
b = first
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Point-in-time view of the mesh graph learned from gossiped announces
|
||||
/// (each announce carries up to 10 `directNeighbors`).
|
||||
struct MeshTopologySnapshot: Equatable {
|
||||
let localPeerID: PeerID
|
||||
let nodes: [PeerID]
|
||||
let edges: [MeshTopologyEdge]
|
||||
}
|
||||
|
||||
enum TransportEvent: @unchecked Sendable {
|
||||
case messageReceived(BitchatMessage)
|
||||
case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||
case noisePayloadReceived(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||
/// Encrypted group broadcast (MessageType 0x25). Opaque here — the group
|
||||
/// coordinator decrypts and authenticates against the roster.
|
||||
case groupMessageReceived(payload: Data, timestamp: Date)
|
||||
case peerConnected(PeerID)
|
||||
case peerDisconnected(PeerID)
|
||||
case peerListUpdated([PeerID])
|
||||
@@ -54,6 +109,11 @@ protocol Transport: AnyObject {
|
||||
// Connectivity and peers
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool
|
||||
/// Whether a send to this peer is likely to leave the device promptly.
|
||||
/// Distinct from reachability: Nostr claims any favorite with a known
|
||||
/// npub as reachable even with no relay connection, where a send only
|
||||
/// joins a queue waiting for internet that may never come.
|
||||
func canDeliverPromptly(to peerID: PeerID) -> Bool
|
||||
func peerNickname(peerID: PeerID) -> String?
|
||||
func getPeerNicknames() -> [PeerID: String]
|
||||
|
||||
@@ -61,7 +121,27 @@ protocol Transport: AnyObject {
|
||||
func getFingerprint(for peerID: PeerID) -> String?
|
||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState
|
||||
func triggerHandshake(with peerID: PeerID)
|
||||
func getNoiseService() -> NoiseEncryptionService
|
||||
|
||||
// Noise identity/session access. Narrow, purpose-named wrappers so the
|
||||
// underlying NoiseEncryptionService (and its peer-binding/session
|
||||
// orchestration) is never exposed outside the transport.
|
||||
/// The remote static public key of the Noise session with `peerID`, if established.
|
||||
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data?
|
||||
/// Fingerprint of our own Noise static identity key.
|
||||
func noiseIdentityFingerprint() -> String
|
||||
/// Our Noise static public key (Curve25519 key agreement).
|
||||
func noiseStaticPublicKeyData() -> Data
|
||||
/// Our Noise signing public key (Ed25519).
|
||||
func noiseSigningPublicKeyData() -> Data
|
||||
/// Signs `data` with our Noise signing key.
|
||||
func noiseSignData(_ data: Data) -> Data?
|
||||
/// Verifies an Ed25519 `signature` over `data` against `publicKey`.
|
||||
func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool
|
||||
/// Registers session-lifecycle callbacks (peer authenticated / handshake required).
|
||||
func installNoiseSessionCallbacks(
|
||||
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
|
||||
onHandshakeRequired: @escaping (PeerID) -> Void
|
||||
)
|
||||
|
||||
// Messaging
|
||||
func sendMessage(_ content: String, mentions: [String])
|
||||
@@ -75,18 +155,90 @@ protocol Transport: AnyObject {
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
||||
func cancelTransfer(_ transferId: String)
|
||||
|
||||
// Courier store-and-forward (mesh transports only): seal a message to the
|
||||
// recipient's static key and hand it to connected couriers for physical
|
||||
// delivery while the recipient is offline. Returns false when the
|
||||
// transport cannot courier (no connected courier, or unsupported).
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
|
||||
|
||||
// Private groups (mesh transports only): creator-signed state travels
|
||||
// 1:1 over Noise sessions; group messages flood like public broadcasts.
|
||||
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID)
|
||||
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID)
|
||||
func broadcastGroupMessage(_ envelope: Data)
|
||||
|
||||
// Mesh diagnostics (optional for transports). Defaults are inert so
|
||||
// queue-backed transports (e.g. NostrTransport) stay untouched.
|
||||
/// Sends a directed ping probe; the completion fires exactly once on the
|
||||
/// main actor with the measured result, or nil on timeout/unsupported.
|
||||
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void)
|
||||
/// Estimated intermediate hops toward `peerID` from gossiped topology
|
||||
/// ([] = direct link, nil = no known path).
|
||||
func computeMeshPath(to peerID: PeerID) -> [PeerID]?
|
||||
/// Current mesh graph for the topology map; nil when unsupported.
|
||||
func currentMeshTopology() -> MeshTopologySnapshot?
|
||||
|
||||
// Bulletin board (mesh transports only): broadcast a pre-signed board
|
||||
// payload (post or tombstone) so it spreads over relay and gossip sync.
|
||||
func sendBoardPayload(_ payload: Data)
|
||||
|
||||
// QR verification (optional for transports)
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
|
||||
// Vouching / transitive verification (optional for transports)
|
||||
/// Capabilities the peer advertised in its last verified announce;
|
||||
/// empty for peers that predate the capabilities TLV.
|
||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
|
||||
/// Sends an encoded vouch-attestation batch inside the Noise session.
|
||||
func sendVouchAttestations(_ payload: Data, to peerID: PeerID)
|
||||
/// Appends a peer-authenticated observer. Unlike
|
||||
/// `installNoiseSessionCallbacks` this never touches the (single-slot)
|
||||
/// handshake-required callback, so secondary features can observe
|
||||
/// session establishment without disturbing the primary registration.
|
||||
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void)
|
||||
|
||||
// Pending file management (BCH-01-002: files held in memory until user accepts)
|
||||
func acceptPendingFile(id: String) -> URL?
|
||||
func declinePendingFile(id: String)
|
||||
}
|
||||
|
||||
extension Transport {
|
||||
// Reachability implies prompt delivery for transports that hand packets
|
||||
// straight to the radio; queue-backed transports override this.
|
||||
func canDeliverPromptly(to peerID: PeerID) -> Bool { isPeerReachable(peerID) }
|
||||
|
||||
// Noise identity hooks default to inert for transports that do not carry
|
||||
// Noise sessions (e.g. NostrTransport).
|
||||
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil }
|
||||
func noiseIdentityFingerprint() -> String { "" }
|
||||
func noiseStaticPublicKeyData() -> Data { Data() }
|
||||
func noiseSigningPublicKeyData() -> Data { Data() }
|
||||
func noiseSignData(_ data: Data) -> Data? { nil }
|
||||
func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool { false }
|
||||
func installNoiseSessionCallbacks(
|
||||
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
|
||||
onHandshakeRequired: @escaping (PeerID) -> Void
|
||||
) {}
|
||||
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
|
||||
func sendVouchAttestations(_ payload: Data, to peerID: PeerID) {}
|
||||
func addPeerAuthenticatedObserver(_ handler: @escaping (PeerID, String) -> Void) {}
|
||||
func sendGroupInvite(_ statePayload: Data, to peerID: PeerID) {}
|
||||
func sendGroupKeyUpdate(_ statePayload: Data, to peerID: PeerID) {}
|
||||
func broadcastGroupMessage(_ envelope: Data) {}
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
|
||||
|
||||
// Mesh diagnostics are mesh-transport-only; other transports report
|
||||
// "no reply"/"no path" rather than pretending to measure anything.
|
||||
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
|
||||
Task { @MainActor in completion(nil) }
|
||||
}
|
||||
func computeMeshPath(to peerID: PeerID) -> [PeerID]? { nil }
|
||||
func currentMeshTopology() -> MeshTopologySnapshot? { nil }
|
||||
func sendBoardPayload(_ payload: Data) {}
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||
func cancelTransfer(_ transferId: String) {}
|
||||
@@ -119,6 +271,8 @@ extension BitchatDelegate {
|
||||
)
|
||||
case let .noisePayloadReceived(peerID, type, payload, timestamp):
|
||||
didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp)
|
||||
case let .groupMessageReceived(payload, timestamp):
|
||||
didReceiveGroupMessage(payload: payload, timestamp: timestamp)
|
||||
case .peerConnected(let peerID):
|
||||
didConnectToPeer(peerID)
|
||||
case .peerDisconnected(let peerID):
|
||||
|
||||
@@ -11,13 +11,22 @@ enum TransportConfig {
|
||||
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
|
||||
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
||||
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
||||
static let bleFragmentRelayTtlCap: UInt8 = 5 // Clamp fragment TTL to contain floods
|
||||
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
|
||||
// reaches as far as text. Dense graphs clamp harder in RelayController.
|
||||
static let bleFragmentRelayTtlCap: UInt8 = 7
|
||||
static let bleFragmentRelayTtlCapDense: UInt8 = 5 // Contain fragment floods in dense graphs
|
||||
|
||||
// Mesh diagnostics (/ping)
|
||||
static let meshPingTimeoutSeconds: TimeInterval = 10 // Give up on a probe after this window
|
||||
static let meshPingInboundMaxPerLink: Int = 5 // Inbound ping budget per ingress link (claimed sender is spoofable)...
|
||||
static let meshPingInboundWindowSeconds: TimeInterval = 10 // ...per sliding window (anti-amplification)
|
||||
|
||||
// UI / Storage Caps
|
||||
static let privateChatCap: Int = 1337
|
||||
static let meshTimelineCap: Int = 1337
|
||||
static let geoTimelineCap: Int = 1337
|
||||
static let contentLRUCap: Int = 2000
|
||||
static let geoSamplingEventLRUCap: Int = 2000
|
||||
|
||||
// Timers
|
||||
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
|
||||
@@ -40,14 +49,27 @@ enum TransportConfig {
|
||||
static let blePendingNotificationsCapCount: Int = 128
|
||||
static let bleNotificationRetryDelayMs: Int = 25
|
||||
static let bleNotificationRetryMaxAttempts: Int = 80
|
||||
// Sample interval for notification backpressure logs (fire per fragment
|
||||
// during media transfers).
|
||||
static let bleBackpressureLogInterval: Int = 25
|
||||
|
||||
// Nostr
|
||||
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
|
||||
static let nostrInboundEventDedupCap: Int = 4096
|
||||
static let nostrInboundEventDedupTrimTarget: Int = 3072
|
||||
static let nostrDuplicateEventLogInterval: Int = 50
|
||||
// Sample interval for per-event debug logs on the inbound hot path.
|
||||
static let nostrInboundEventLogInterval: Int = 100
|
||||
|
||||
// Conversation store diagnostics (field observability)
|
||||
// Sample interval for the periodic store-audit "OK" heartbeat line
|
||||
// (first + every Nth audit); violations always log at error level.
|
||||
static let conversationStoreAuditLogInterval: Int = 10
|
||||
// Sample interval for the mirrored-republish debug line in the ID-only
|
||||
// delivery fan-out (first + every Nth republish).
|
||||
static let conversationStoreMirroredRepublishLogInterval: Int = 25
|
||||
|
||||
// UI thresholds
|
||||
static let uiLateInsertThreshold: TimeInterval = 15.0
|
||||
// Geohash public chats are more sensitive to ordering; use a tighter threshold
|
||||
static let uiLateInsertThresholdGeo: TimeInterval = 0.0
|
||||
static let uiProcessedNostrEventsCap: Int = 2000
|
||||
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
|
||||
|
||||
@@ -76,19 +98,21 @@ enum TransportConfig {
|
||||
// BLE maintenance & thresholds
|
||||
static let bleMaintenanceInterval: TimeInterval = 5.0
|
||||
static let bleMaintenanceLeewaySeconds: Int = 1
|
||||
static let bleIsolationRelaxThresholdSeconds: TimeInterval = 60
|
||||
static let bleRecentTimeoutWindowSeconds: TimeInterval = 60
|
||||
static let bleRecentTimeoutCountThreshold: Int = 3
|
||||
static let bleRSSIIsolatedBase: Int = -90
|
||||
static let bleRSSIIsolatedRelaxed: Int = -92
|
||||
static let bleIsolationRelaxThresholdSeconds: TimeInterval = 30
|
||||
// Isolated nodes accept the weakest usable links — a fringe connection
|
||||
// beats no connection. Relaxed floor sits at CoreBluetooth's practical
|
||||
// reporting limit so prolonged isolation gates on nothing but decode.
|
||||
static let bleRSSIIsolatedBase: Int = -95
|
||||
static let bleRSSIIsolatedRelaxed: Int = -100
|
||||
static let bleRSSIConnectedThreshold: Int = -85
|
||||
static let bleRSSIHighTimeoutThreshold: Int = -80
|
||||
// How long without seeing traffic before we sanity-check the direct link
|
||||
// Lowered to make connected→reachable icon changes react faster when walking out of range
|
||||
static let blePeerInactivityTimeoutSeconds: TimeInterval = 8.0
|
||||
// How long to retain a peer as "reachable" (not directly connected) since lastSeen
|
||||
static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 21.0 // 21s for verified/favorites
|
||||
static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 21.0 // 21s for unknown/unverified
|
||||
// How long to retain a peer as "reachable" (not directly connected) since lastSeen.
|
||||
// Must comfortably exceed the worst-case dense announce interval (38s) plus a
|
||||
// missed cycle, so duty-cycled nodes don't forget peers between announces.
|
||||
static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 60.0 // verified/favorites
|
||||
static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 45.0 // unknown/unverified
|
||||
static let bleFragmentLifetimeSeconds: TimeInterval = 30.0
|
||||
static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0
|
||||
static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0
|
||||
@@ -145,7 +169,27 @@ enum TransportConfig {
|
||||
static let nostrRelayMaxBackoffSeconds: TimeInterval = 300.0
|
||||
static let nostrRelayBackoffMultiplier: Double = 2.0
|
||||
static let nostrRelayMaxReconnectAttempts: Int = 10
|
||||
// Reconnect delays get ±20% random jitter so relays that dropped together
|
||||
// (e.g. a network blip) don't thundering-herd the same reconnect instant.
|
||||
static let nostrRelayBackoffJitterRatio: Double = 0.2
|
||||
static let nostrRelayDefaultFetchLimit: Int = 100
|
||||
// How many consecutive Tor-readiness waits (each bounded by TorManager's
|
||||
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
|
||||
static let nostrTorReadyMaxWaitAttempts: Int = 3
|
||||
static let nostrPendingSendQueueCap: Int = 200
|
||||
// Sample interval for the send-queue overflow warning (first + every Nth
|
||||
// dropped event). Drops are ephemeral presence/geo traffic — log-only.
|
||||
static let nostrPendingSendDropLogInterval: Int = 10
|
||||
// Pending (not-yet-flushed) REQs are bounded per relay: oldest-by-insertion
|
||||
// eviction at the cap, plus an age sweep on connect attempts. Durable
|
||||
// subscription intent survives in subscriptionRequestState either way.
|
||||
static let nostrPendingSubscriptionsPerRelayCap: Int = 64
|
||||
static let nostrPendingSubscriptionTTLSeconds: TimeInterval = 600.0
|
||||
// Fallback deadline for treating a subscription's initial fetch as complete
|
||||
// when a relay never sends EOSE (generous to cover Tor circuit setup).
|
||||
static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0
|
||||
// After this long, a relay marked permanently failed gets another chance.
|
||||
static let nostrRelayFailureCooldownSeconds: TimeInterval = 600.0
|
||||
|
||||
// Geo relay directory
|
||||
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
|
||||
@@ -169,8 +213,29 @@ enum TransportConfig {
|
||||
static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts
|
||||
static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown
|
||||
|
||||
// Store-and-forward for directed packets at relays
|
||||
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
|
||||
// Source routing (v2 directed packets)
|
||||
// Longest path we will originate, in intermediate hops between us and the
|
||||
// recipient. Keep small: every hop must be a fresh, confirmed, v2-capable
|
||||
// node, and long stale paths fail more often than floods.
|
||||
static let bleSourceRouteMaxIntermediateHops: Int = 4
|
||||
// A routed send with no inbound traffic from the recipient within this
|
||||
// window counts as a route failure.
|
||||
static let bleSourceRouteConfirmationWindowSeconds: TimeInterval = 10.0
|
||||
// After a route failure, directed sends to that recipient flood instead
|
||||
// of routing until this lapses.
|
||||
static let bleSourceRouteSuppressionSeconds: TimeInterval = 60.0
|
||||
|
||||
// Targeted fragment resync (REQUEST_SYNC fragmentIdFilter)
|
||||
// A broadcast reassembly with no new fragment for this long is stalled
|
||||
// and triggers a targeted REQUEST_SYNC naming its fragment stream.
|
||||
static let bleFragmentResyncStallSeconds: TimeInterval = 5.0
|
||||
// Minimum spacing between targeted resync requests for the same stream.
|
||||
static let bleFragmentResyncRetrySeconds: TimeInterval = 10.0
|
||||
|
||||
// Store-and-forward for directed packets at relays. Spooled packets retry
|
||||
// on each maintenance flush until the window lapses; a longer window lets
|
||||
// brief link gaps (walking between rooms, reconnect churn) heal themselves.
|
||||
static let bleDirectedSpoolWindowSeconds: TimeInterval = 60.0
|
||||
|
||||
// Log/UI debounce windows
|
||||
// Shorter debounce so UI reacts faster while still suppressing duplicate callbacks
|
||||
@@ -180,6 +245,12 @@ enum TransportConfig {
|
||||
// Weak-link cooldown after connection timeouts
|
||||
static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0
|
||||
static let bleWeakLinkRSSICutoff: Int = -90
|
||||
// Rediscovery ignore windows after a failed link, by failure kind:
|
||||
// a connect attempt that timed out means the peer likely isn't reachable,
|
||||
// so back off; a dropped established connection (walked out of range)
|
||||
// usually returns, so only pause long enough for CoreBluetooth to settle.
|
||||
static let bleTimeoutDiscoveryIgnoreSeconds: TimeInterval = 15.0
|
||||
static let bleDisconnectDiscoveryIgnoreSeconds: TimeInterval = 3.0
|
||||
|
||||
// Content hashing / formatting
|
||||
static let contentKeyPrefixLength: Int = 256
|
||||
@@ -215,7 +286,14 @@ enum TransportConfig {
|
||||
static let syncSeenCapacity: Int = 1000
|
||||
static let syncGCSMaxBytes: Int = 400
|
||||
static let syncGCSTargetFpr: Double = 0.01
|
||||
// Fragments and file transfers keep the short window; whole public
|
||||
// messages get hours so a phone walking between partitions carries the
|
||||
// room's recent history with it (see syncPublicMessageMaxAgeSeconds).
|
||||
static let syncMaxMessageAgeSeconds: TimeInterval = 900
|
||||
// How far back public broadcast messages stay sync-able. Must not exceed
|
||||
// the receive-side acceptance window (BLEPublicMessagePolicy uses this
|
||||
// same constant) or served packets would be dropped as stale.
|
||||
static let syncPublicMessageMaxAgeSeconds: TimeInterval = 6 * 60 * 60
|
||||
static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0
|
||||
static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||
static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||
@@ -224,4 +302,42 @@ enum TransportConfig {
|
||||
static let syncFragmentIntervalSeconds: TimeInterval = 30.0
|
||||
static let syncFileTransferIntervalSeconds: TimeInterval = 60.0
|
||||
static let syncMessageIntervalSeconds: TimeInterval = 15.0
|
||||
static let syncResponseRateLimitMaxResponses: Int = 8
|
||||
static let syncResponseRateLimitWindowSeconds: TimeInterval = 30.0
|
||||
|
||||
// Wi-Fi bulk transport (peer-to-peer AWDL data plane for large media).
|
||||
// BLE stays the control plane: offers/responses ride the Noise session,
|
||||
// only the sealed chunk stream moves to TCP over AWDL.
|
||||
static let wifiBulkEnabled: Bool = true
|
||||
// Below this size BLE fragmentation is fast enough that negotiation
|
||||
// overhead isn't worth it.
|
||||
static let wifiBulkMinPayloadBytes: Int = 64 * 1024
|
||||
static let wifiBulkChunkBytes: Int = 64 * 1024
|
||||
// Offer unanswered for this long → fall back to BLE fragmentation.
|
||||
static let wifiBulkOfferTimeoutSeconds: TimeInterval = 10.0
|
||||
// Hard ceiling on how long the Bonjour listener/connection may live.
|
||||
static let wifiBulkTransferWindowSeconds: TimeInterval = 60.0
|
||||
static let wifiBulkServiceType: String = "_bitchat-bulk._tcp"
|
||||
static let wifiBulkMaxConcurrentIncoming: Int = 4
|
||||
|
||||
// Courier store-and-forward
|
||||
// Initial spray-and-wait budget per deposited envelope: each courier may
|
||||
// hand half its remaining copies to another courier on encounter, so a
|
||||
// message diffuses through a moving crowd instead of riding one person.
|
||||
static let courierInitialCopies: UInt8 = 4
|
||||
// Cooldown between speculative multi-hop handovers of the same envelope
|
||||
// toward a recipient heard only via relayed announces.
|
||||
static let courierRemoteHandoverCooldownSeconds: TimeInterval = 10 * 60
|
||||
|
||||
// One-time prekey bundles (forward-secret courier sealing)
|
||||
// Own gossip-sync round for bundles: modest cadence, bounded peer count,
|
||||
// and a long freshness window so bundles persist mesh-wide while their
|
||||
// owners are away.
|
||||
static let syncPrekeyBundleCapacity: Int = 200
|
||||
static let syncPrekeyBundleIntervalSeconds: TimeInterval = 60.0
|
||||
static let syncPrekeyBundleMaxAgeSeconds: TimeInterval = 24 * 60 * 60
|
||||
// Unforced re-broadcasts of our own (unchanged) bundle, piggybacked on
|
||||
// announces, keep it alive in peers' gossip stores; changed bundles are
|
||||
// sent immediately.
|
||||
static let prekeyBundleRebroadcastSeconds: TimeInterval = 60 * 60
|
||||
}
|
||||
|
||||
@@ -86,45 +86,44 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
var enrichedPeers: [BitchatPeer] = []
|
||||
var connected: Set<PeerID> = []
|
||||
var addedPeerIDs: Set<PeerID> = []
|
||||
|
||||
var meshNoiseKeys: Set<Data> = []
|
||||
|
||||
// Phase 1: Add all mesh peers (connected and reachable)
|
||||
for peerInfo in meshPeers {
|
||||
let peerID = peerInfo.peerID
|
||||
guard peerID != meshService.myPeerID else { continue } // Never add self
|
||||
|
||||
|
||||
let peer = buildPeerFromMesh(
|
||||
peerInfo: peerInfo,
|
||||
favorites: favorites,
|
||||
meshAttached: hasAnyConnected
|
||||
)
|
||||
|
||||
|
||||
enrichedPeers.append(peer)
|
||||
if peer.isConnected { connected.insert(peerID) }
|
||||
addedPeerIDs.insert(peerID)
|
||||
|
||||
|
||||
// Update fingerprint cache
|
||||
if let publicKey = peerInfo.noisePublicKey {
|
||||
meshNoiseKeys.insert(publicKey)
|
||||
fingerprintCache[peerID] = publicKey.sha256Fingerprint()
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Add offline favorites that we actively favorite
|
||||
|
||||
// Phase 2: Add offline favorites that we actively favorite.
|
||||
// Mesh rows use the short 16-hex peer ID while favorites are keyed by
|
||||
// the full 32-byte noise key, so dedup must compare noise keys — a
|
||||
// PeerID comparison between the two forms can never match.
|
||||
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
||||
if meshNoiseKeys.contains(favoriteKey) { continue }
|
||||
|
||||
let peerID = PeerID(hexData: favoriteKey)
|
||||
|
||||
// Skip if already added (connected peer)
|
||||
if addedPeerIDs.contains(peerID) { continue }
|
||||
|
||||
// Skip if connected under different ID but same nickname
|
||||
let isConnectedByNickname = enrichedPeers.contains {
|
||||
$0.nickname == favorite.peerNickname && $0.isConnected
|
||||
}
|
||||
if isConnectedByNickname { continue }
|
||||
|
||||
|
||||
let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID)
|
||||
enrichedPeers.append(peer)
|
||||
addedPeerIDs.insert(peerID)
|
||||
|
||||
|
||||
// Update fingerprint cache
|
||||
fingerprintCache[peerID] = favoriteKey.sha256Fingerprint()
|
||||
}
|
||||
@@ -257,7 +256,29 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
/// Block or unblock a mesh peer by its stable Noise identity.
|
||||
///
|
||||
/// The block is keyed by the peer's fingerprint, resolved from `peerID`
|
||||
/// (cache / mesh session / known-peer Noise key). This works even when the
|
||||
/// peer is offline — including offline favorites — so the exact tapped peer
|
||||
/// is (un)blocked unambiguously instead of being re-resolved by a
|
||||
/// display-name string that two peers could share.
|
||||
/// - Returns: the resolved fingerprint, or `nil` if the identity is unknown.
|
||||
@discardableResult
|
||||
func setBlocked(_ peerID: PeerID, blocked: Bool) -> String? {
|
||||
guard let fingerprint = getFingerprint(for: peerID) else {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Cannot \(blocked ? "block" : "unblock") - unknown identity for peer: \(peerID)",
|
||||
category: .session
|
||||
)
|
||||
return nil
|
||||
}
|
||||
identityManager.setBlocked(fingerprint, isBlocked: blocked)
|
||||
updatePeers()
|
||||
return fingerprint
|
||||
}
|
||||
|
||||
/// Toggle favorite status
|
||||
func toggleFavorite(_ peerID: PeerID) {
|
||||
guard let peer = getPeer(by: peerID) else {
|
||||
|
||||
@@ -4,9 +4,11 @@ import Foundation
|
||||
final class VerificationService {
|
||||
static let shared = VerificationService()
|
||||
|
||||
// Injected Noise service from the running transport (do NOT create new BLEService)
|
||||
private var noise: NoiseEncryptionService?
|
||||
func configure(with noise: NoiseEncryptionService) { self.noise = noise }
|
||||
// Injected running transport (do NOT create new BLEService). Noise
|
||||
// identity operations go through the transport's narrow noise* wrappers
|
||||
// so the raw NoiseEncryptionService is never exposed.
|
||||
private var transport: Transport?
|
||||
func configure(with transport: Transport) { self.transport = transport }
|
||||
|
||||
/// Encapsulates the data encoded into a verification QR
|
||||
struct VerificationQR: Codable {
|
||||
@@ -77,16 +79,16 @@ final class VerificationService {
|
||||
if let c = Cache.last, c.nick == nickname, c.npub == npub, Date().timeIntervalSince(c.builtAt) < 60 {
|
||||
return c.value
|
||||
}
|
||||
guard let noise = noise else { return nil }
|
||||
let noiseKey = noise.getStaticPublicKeyData().hexEncodedString()
|
||||
let signKey = noise.getSigningPublicKeyData().hexEncodedString()
|
||||
guard let transport = transport else { return nil }
|
||||
let noiseKey = transport.noiseStaticPublicKeyData().hexEncodedString()
|
||||
let signKey = transport.noiseSigningPublicKeyData().hexEncodedString()
|
||||
let ts = Int64(Date().timeIntervalSince1970)
|
||||
var nonce = Data(count: 16)
|
||||
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||
let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
|
||||
let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "")
|
||||
let msg = payload.canonicalBytes()
|
||||
guard let sig = noise.signData(msg) else { return nil }
|
||||
guard let sig = transport.noiseSignData(msg) else { return nil }
|
||||
let signed = VerificationQR(v: payload.v,
|
||||
noiseKeyHex: payload.noiseKeyHex,
|
||||
signKeyHex: payload.signKeyHex,
|
||||
@@ -108,8 +110,8 @@ final class VerificationService {
|
||||
if now - Double(qr.ts) > maxAge { return nil }
|
||||
// Verify signature using embedded ed25519 signKey
|
||||
guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil }
|
||||
guard let noise = noise else { return nil }
|
||||
let ok = noise.verifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey)
|
||||
guard let transport = transport else { return nil }
|
||||
let ok = transport.noiseVerifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey)
|
||||
return ok ? qr : nil
|
||||
}
|
||||
|
||||
@@ -133,7 +135,7 @@ final class VerificationService {
|
||||
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
|
||||
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
|
||||
msg.append(nonceA)
|
||||
guard let noise = noise, let sig = noise.signData(msg) else { return nil }
|
||||
guard let transport = transport, let sig = transport.noiseSignData(msg) else { return nil }
|
||||
var tlv = Data()
|
||||
tlv.append(0x01); tlv.append(UInt8(min(nk.count, 255))); tlv.append(nk.prefix(255))
|
||||
tlv.append(0x02); tlv.append(UInt8(min(nonceA.count, 255))); tlv.append(nonceA.prefix(255))
|
||||
@@ -178,7 +180,7 @@ final class VerificationService {
|
||||
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
|
||||
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
|
||||
msg.append(nonceA)
|
||||
guard let noise = noise, let pub = Data(hexString: signerPublicKeyHex) else { return false }
|
||||
return noise.verifySignature(signature, for: msg, publicKey: pub)
|
||||
guard let transport = transport, let pub = Data(hexString: signerPublicKeyHex) else { return false }
|
||||
return transport.noiseVerifySignature(signature, for: msg, publicKey: pub)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,466 @@
|
||||
//
|
||||
// WifiBulkChannel.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// Shared frame-stream reading over an `NWConnection`. All callbacks fire on
|
||||
/// the connection's dispatch queue.
|
||||
enum WifiBulkStream {
|
||||
/// Largest sealed frame body on the wire: one plaintext chunk plus AEAD overhead.
|
||||
static func maxFrameBodyBytes(chunkBytes: Int) -> Int {
|
||||
chunkBytes + WifiBulkCrypto.frameOverhead
|
||||
}
|
||||
|
||||
/// Reads frames until `onFrame` returns false (stop) or the stream
|
||||
/// errors/closes. `onFrame` returning true keeps the loop alive.
|
||||
static func readFrames(
|
||||
on connection: NWConnection,
|
||||
buffer: WifiBulkFrameBuffer,
|
||||
maxFrameBodyBytes: Int,
|
||||
onFrame: @escaping (Data) -> Bool,
|
||||
onError: @escaping (String) -> Void
|
||||
) {
|
||||
// Drain any frames already buffered before touching the socket.
|
||||
do {
|
||||
while let body = try buffer.nextFrameBody() {
|
||||
guard onFrame(body) else { return }
|
||||
}
|
||||
} catch {
|
||||
onError("frame decode failed: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
connection.receive(
|
||||
minimumIncompleteLength: 1,
|
||||
maximumLength: maxFrameBodyBytes + WifiBulkCrypto.framePrefixLength
|
||||
) { data, _, isComplete, error in
|
||||
if let data, !data.isEmpty {
|
||||
buffer.append(data)
|
||||
}
|
||||
if let error {
|
||||
onError("receive failed: \(error)")
|
||||
return
|
||||
}
|
||||
if isComplete {
|
||||
// Peer closed: hand over whatever complete frames remain, then
|
||||
// report the close (sessions that already got what they need
|
||||
// will have stopped the loop from inside onFrame).
|
||||
do {
|
||||
while let body = try buffer.nextFrameBody() {
|
||||
guard onFrame(body) else { return }
|
||||
}
|
||||
} catch {
|
||||
onError("frame decode failed: \(error)")
|
||||
return
|
||||
}
|
||||
onError("connection closed by peer")
|
||||
return
|
||||
}
|
||||
readFrames(
|
||||
on: connection,
|
||||
buffer: buffer,
|
||||
maxFrameBodyBytes: maxFrameBodyBytes,
|
||||
onFrame: onFrame,
|
||||
onError: onError
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sender side of the bulk channel: publishes the per-transfer Bonjour
|
||||
/// listener, requires the first inbound frame to prove knowledge of the
|
||||
/// Noise-exchanged channel key, then streams sealed chunks and waits for the
|
||||
/// receiver's verified receipt.
|
||||
///
|
||||
/// The listener starts at offer time (Bonjour registration takes a moment)
|
||||
/// but data can only flow after `activate(key:)` supplies the channel key
|
||||
/// derived from the accepted response.
|
||||
final class WifiBulkSenderSession {
|
||||
private let queue: DispatchQueue
|
||||
private let payload: Data
|
||||
private let transferID: Data
|
||||
private let payloadHash: Data
|
||||
private let chunkBytes: Int
|
||||
private let parameters: NWParameters
|
||||
private let service: NWListener.Service?
|
||||
private let maxCandidateConnections = 4
|
||||
|
||||
private var key: SymmetricKey?
|
||||
private var listener: NWListener?
|
||||
/// Connections that have not yet produced a valid auth frame.
|
||||
private var candidates: [NWConnection] = []
|
||||
private var authenticated: NWConnection?
|
||||
private var finished = false
|
||||
|
||||
let totalChunks: Int
|
||||
|
||||
/// Test hook: fires once the listener is ready, with its bound port.
|
||||
var onListenerReady: ((UInt16) -> Void)?
|
||||
var onChunkSent: ((_ sent: Int, _ total: Int) -> Void)?
|
||||
var onCompleted: (() -> Void)?
|
||||
var onFailed: ((String) -> Void)?
|
||||
|
||||
init(
|
||||
payload: Data,
|
||||
transferID: Data,
|
||||
chunkBytes: Int,
|
||||
parameters: NWParameters,
|
||||
service: NWListener.Service?,
|
||||
queue: DispatchQueue
|
||||
) {
|
||||
self.payload = payload
|
||||
self.transferID = transferID
|
||||
self.payloadHash = Data(SHA256.hash(data: payload))
|
||||
self.chunkBytes = chunkBytes
|
||||
self.parameters = parameters
|
||||
self.service = service
|
||||
self.queue = queue
|
||||
self.totalChunks = (payload.count + chunkBytes - 1) / chunkBytes
|
||||
}
|
||||
|
||||
deinit {
|
||||
cancelNetworkResources()
|
||||
}
|
||||
|
||||
/// Starts the listener. Returns false when the listener cannot be created
|
||||
/// (caller falls back to BLE immediately).
|
||||
func start() -> Bool {
|
||||
let listener: NWListener
|
||||
do {
|
||||
listener = try NWListener(using: parameters)
|
||||
} catch {
|
||||
SecureLogger.error("[WIFI] listener creation failed: \(error)", category: .transport)
|
||||
return false
|
||||
}
|
||||
listener.service = service
|
||||
listener.stateUpdateHandler = { [weak self] state in
|
||||
guard let self else { return }
|
||||
switch state {
|
||||
case .ready:
|
||||
if let port = listener.port?.rawValue {
|
||||
self.onListenerReady?(port)
|
||||
}
|
||||
case .failed(let error):
|
||||
self.fail("listener failed: \(error)")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
listener.newConnectionHandler = { [weak self] connection in
|
||||
self?.acceptCandidate(connection)
|
||||
}
|
||||
self.listener = listener
|
||||
listener.start(queue: queue)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Supplies the channel key once the receiver accepted the offer; begins
|
||||
/// authenticating any connections that raced ahead of the response.
|
||||
func activate(key: SymmetricKey) {
|
||||
guard !finished, self.key == nil else { return }
|
||||
self.key = key
|
||||
for candidate in candidates {
|
||||
beginAuthentication(on: candidate, key: key)
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
finished = true
|
||||
cancelNetworkResources()
|
||||
}
|
||||
|
||||
// MARK: - Connection handling
|
||||
|
||||
private func acceptCandidate(_ connection: NWConnection) {
|
||||
guard !finished, authenticated == nil, candidates.count < maxCandidateConnections else {
|
||||
connection.cancel()
|
||||
return
|
||||
}
|
||||
candidates.append(connection)
|
||||
connection.stateUpdateHandler = { [weak self, weak connection] state in
|
||||
guard let self, let connection else { return }
|
||||
if case .failed = state {
|
||||
self.dropCandidate(connection)
|
||||
}
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
if let key {
|
||||
beginAuthentication(on: connection, key: key)
|
||||
}
|
||||
}
|
||||
|
||||
private func dropCandidate(_ connection: NWConnection) {
|
||||
if let index = candidates.firstIndex(where: { $0 === connection }) {
|
||||
candidates.remove(at: index)
|
||||
connection.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private func beginAuthentication(on connection: NWConnection, key: SymmetricKey) {
|
||||
let buffer = WifiBulkFrameBuffer(maxBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes))
|
||||
WifiBulkStream.readFrames(
|
||||
on: connection,
|
||||
buffer: buffer,
|
||||
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
|
||||
onFrame: { [weak self, weak connection] body in
|
||||
guard let self, let connection, !self.finished, self.authenticated == nil else { return false }
|
||||
guard WifiBulkCrypto.validateClientAuthFrameBody(body, transferID: self.transferID, key: key) else {
|
||||
// Bonjour-level gatecrasher: no channel key, no service.
|
||||
SecureLogger.warning("[WIFI] AWDL connect rejected (invalid auth frame)", category: .security)
|
||||
self.dropCandidate(connection)
|
||||
return false
|
||||
}
|
||||
self.promoteAuthenticated(connection, key: key, residualBuffer: buffer)
|
||||
return false
|
||||
},
|
||||
onError: { [weak self, weak connection] _ in
|
||||
guard let self, let connection, self.authenticated !== connection else { return }
|
||||
self.dropCandidate(connection)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func promoteAuthenticated(_ connection: NWConnection, key: SymmetricKey, residualBuffer: WifiBulkFrameBuffer) {
|
||||
authenticated = connection
|
||||
// One authenticated peer is all a transfer needs: stop advertising and
|
||||
// shed the other candidates.
|
||||
listener?.cancel()
|
||||
listener = nil
|
||||
for candidate in candidates where candidate !== connection {
|
||||
candidate.cancel()
|
||||
}
|
||||
candidates.removeAll()
|
||||
SecureLogger.info("[WIFI] AWDL connected (sender), streaming \(totalChunks) chunk(s)", category: .transport)
|
||||
streamChunk(at: 0, over: connection, key: key, receiptBuffer: residualBuffer)
|
||||
}
|
||||
|
||||
// MARK: - Streaming
|
||||
|
||||
private func streamChunk(at index: Int, over connection: NWConnection, key: SymmetricKey, receiptBuffer: WifiBulkFrameBuffer) {
|
||||
guard !finished else { return }
|
||||
guard index < totalChunks else {
|
||||
awaitReceipt(on: connection, key: key, buffer: receiptBuffer)
|
||||
return
|
||||
}
|
||||
|
||||
let start = payload.index(payload.startIndex, offsetBy: index * chunkBytes)
|
||||
let end = payload.index(start, offsetBy: min(chunkBytes, payload.distance(from: start, to: payload.endIndex)))
|
||||
let chunk = Data(payload[start..<end])
|
||||
|
||||
let body: Data
|
||||
do {
|
||||
body = try WifiBulkCrypto.sealFrameBody(chunk, direction: .senderToReceiver, counter: UInt64(index), key: key)
|
||||
} catch {
|
||||
fail("chunk seal failed: \(error)")
|
||||
return
|
||||
}
|
||||
|
||||
connection.send(content: WifiBulkCrypto.frameData(body: body), completion: .contentProcessed { [weak self] error in
|
||||
guard let self, !self.finished else { return }
|
||||
if let error {
|
||||
self.fail("send failed: \(error)")
|
||||
return
|
||||
}
|
||||
self.onChunkSent?(index + 1, self.totalChunks)
|
||||
self.streamChunk(at: index + 1, over: connection, key: key, receiptBuffer: receiptBuffer)
|
||||
})
|
||||
}
|
||||
|
||||
private func awaitReceipt(on connection: NWConnection, key: SymmetricKey, buffer: WifiBulkFrameBuffer) {
|
||||
WifiBulkStream.readFrames(
|
||||
on: connection,
|
||||
buffer: buffer,
|
||||
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
|
||||
onFrame: { [weak self] body in
|
||||
guard let self, !self.finished else { return false }
|
||||
guard WifiBulkCrypto.validateReceiptFrameBody(body, payloadHash: self.payloadHash, key: key) else {
|
||||
self.fail("invalid receipt frame")
|
||||
return false
|
||||
}
|
||||
self.finished = true
|
||||
self.cancelNetworkResources()
|
||||
self.onCompleted?()
|
||||
return false
|
||||
},
|
||||
onError: { [weak self] reason in
|
||||
self?.fail("receipt wait failed: \(reason)")
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Teardown
|
||||
|
||||
private func fail(_ reason: String) {
|
||||
guard !finished else { return }
|
||||
finished = true
|
||||
cancelNetworkResources()
|
||||
onFailed?(reason)
|
||||
}
|
||||
|
||||
private func cancelNetworkResources() {
|
||||
listener?.cancel()
|
||||
listener = nil
|
||||
authenticated?.cancel()
|
||||
authenticated = nil
|
||||
for candidate in candidates {
|
||||
candidate.cancel()
|
||||
}
|
||||
candidates.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
/// Receiver side of the bulk channel: connects to the sender's per-transfer
|
||||
/// endpoint, proves knowledge of the channel key with the first frame, then
|
||||
/// reassembles sealed chunks, verifies the offer hash, and returns a receipt.
|
||||
final class WifiBulkReceiverSession {
|
||||
private let queue: DispatchQueue
|
||||
private let connection: NWConnection
|
||||
private let key: SymmetricKey
|
||||
private let transferID: Data
|
||||
private let payloadHash: Data
|
||||
private let chunkBytes: Int
|
||||
private let assembler: WifiBulkPayloadAssembler
|
||||
|
||||
private var finished = false
|
||||
|
||||
var onCompleted: ((Data) -> Void)?
|
||||
var onFailed: ((String) -> Void)?
|
||||
|
||||
/// Fails (returns nil) when the offer exceeds `sizeCap` — the receiver
|
||||
/// enforces the cap it advertised, not the sender's word.
|
||||
init?(
|
||||
endpoint: NWEndpoint,
|
||||
parameters: NWParameters,
|
||||
key: SymmetricKey,
|
||||
transferID: Data,
|
||||
expectedSize: UInt64,
|
||||
expectedHash: Data,
|
||||
sizeCap: Int,
|
||||
chunkBytes: Int,
|
||||
queue: DispatchQueue
|
||||
) {
|
||||
guard let assembler = WifiBulkPayloadAssembler(
|
||||
key: key,
|
||||
expectedSize: expectedSize,
|
||||
expectedHash: expectedHash,
|
||||
sizeCap: sizeCap
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
self.assembler = assembler
|
||||
self.connection = NWConnection(to: endpoint, using: parameters)
|
||||
self.key = key
|
||||
self.transferID = transferID
|
||||
self.payloadHash = expectedHash
|
||||
self.chunkBytes = chunkBytes
|
||||
self.queue = queue
|
||||
}
|
||||
|
||||
deinit {
|
||||
connection.cancel()
|
||||
}
|
||||
|
||||
func start() {
|
||||
connection.stateUpdateHandler = { [weak self] state in
|
||||
guard let self else { return }
|
||||
switch state {
|
||||
case .ready:
|
||||
SecureLogger.info("[WIFI] AWDL connect established (receiver)", category: .transport)
|
||||
self.sendAuthFrameAndReceive()
|
||||
case .failed(let error):
|
||||
SecureLogger.info("[WIFI] AWDL connect failed: \(error)", category: .transport)
|
||||
self.fail("connect failed: \(error)")
|
||||
case .waiting(let error):
|
||||
// .waiting can resolve on its own, but a per-transfer channel
|
||||
// has a peer actively listening; treat unreachable as fatal so
|
||||
// the sender's fallback isn't left to the window timeout alone.
|
||||
self.fail("connection waiting: \(error)")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
finished = true
|
||||
connection.cancel()
|
||||
}
|
||||
|
||||
private func sendAuthFrameAndReceive() {
|
||||
guard !finished else { return }
|
||||
let authBody: Data
|
||||
do {
|
||||
authBody = try WifiBulkCrypto.makeClientAuthFrameBody(transferID: transferID, key: key)
|
||||
} catch {
|
||||
fail("auth frame seal failed: \(error)")
|
||||
return
|
||||
}
|
||||
connection.send(content: WifiBulkCrypto.frameData(body: authBody), completion: .contentProcessed { [weak self] error in
|
||||
guard let self, !self.finished else { return }
|
||||
if let error {
|
||||
self.fail("auth frame send failed: \(error)")
|
||||
return
|
||||
}
|
||||
self.receiveChunks()
|
||||
})
|
||||
}
|
||||
|
||||
private func receiveChunks() {
|
||||
let buffer = WifiBulkFrameBuffer(maxBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes))
|
||||
WifiBulkStream.readFrames(
|
||||
on: connection,
|
||||
buffer: buffer,
|
||||
maxFrameBodyBytes: WifiBulkStream.maxFrameBodyBytes(chunkBytes: chunkBytes),
|
||||
onFrame: { [weak self] body in
|
||||
guard let self, !self.finished else { return false }
|
||||
do {
|
||||
guard let payload = try self.assembler.consume(frameBody: body) else {
|
||||
return true // keep reading
|
||||
}
|
||||
self.sendReceiptAndComplete(payload)
|
||||
return false
|
||||
} catch {
|
||||
self.fail("chunk rejected: \(error)")
|
||||
return false
|
||||
}
|
||||
},
|
||||
onError: { [weak self] reason in
|
||||
self?.fail(reason)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func sendReceiptAndComplete(_ payload: Data) {
|
||||
let receiptBody: Data
|
||||
do {
|
||||
receiptBody = try WifiBulkCrypto.makeReceiptFrameBody(payloadHash: payloadHash, key: key)
|
||||
} catch {
|
||||
fail("receipt seal failed: \(error)")
|
||||
return
|
||||
}
|
||||
connection.send(content: WifiBulkCrypto.frameData(body: receiptBody), completion: .contentProcessed { [weak self] _ in
|
||||
// Receipt is best-effort from the receiver's perspective: the
|
||||
// payload is already verified. Close the channel either way.
|
||||
guard let self, !self.finished else { return }
|
||||
self.finished = true
|
||||
self.connection.cancel()
|
||||
self.onCompleted?(payload)
|
||||
})
|
||||
}
|
||||
|
||||
private func fail(_ reason: String) {
|
||||
guard !finished else { return }
|
||||
finished = true
|
||||
connection.cancel()
|
||||
onFailed?(reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//
|
||||
// WifiBulkCrypto.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
/// Channel security for the Wi-Fi bulk data plane.
|
||||
///
|
||||
/// The TCP stream is encrypted and authenticated independently of TLS: both
|
||||
/// endpoints exchanged random 32-byte tokens inside the established Noise
|
||||
/// session, so only they can derive the ChaChaPoly channel key via
|
||||
/// HKDF-SHA256 (domain "bitchat-bulk-v1", transferID as salt). A Bonjour-level
|
||||
/// gatecrasher that connects to the listener cannot produce a single valid
|
||||
/// frame and is disconnected.
|
||||
///
|
||||
/// Stream format: length-prefixed frames, each a ChaChaPoly sealed box in
|
||||
/// combined form (12-byte nonce ‖ ciphertext ‖ 16-byte tag). Nonces are
|
||||
/// structured, never random: [direction byte][3 zero bytes][8-byte BE counter],
|
||||
/// and the reader requires the exact expected nonce for each frame, so frames
|
||||
/// cannot be replayed, reordered, or reflected across directions.
|
||||
enum WifiBulkCryptoError: Error, Equatable {
|
||||
case invalidParameters
|
||||
case frameTooLarge
|
||||
case truncatedFrame
|
||||
case nonceMismatch
|
||||
case authenticationFailed
|
||||
case emptyChunk
|
||||
case payloadOverflow
|
||||
case hashMismatch
|
||||
}
|
||||
|
||||
enum WifiBulkFrameDirection: UInt8 {
|
||||
/// Data chunks: counters 0, 1, 2, …
|
||||
case senderToReceiver = 0x00
|
||||
/// Counter 0 = client auth frame, counter 1 = final receipt.
|
||||
case receiverToSender = 0x01
|
||||
}
|
||||
|
||||
enum WifiBulkCrypto {
|
||||
static let keyDomain = "bitchat-bulk-v1"
|
||||
static let nonceLength = 12
|
||||
static let tagLength = 16
|
||||
/// AEAD overhead per frame body (nonce + tag).
|
||||
static let frameOverhead = nonceLength + tagLength
|
||||
/// 4-byte big-endian length prefix per frame.
|
||||
static let framePrefixLength = 4
|
||||
|
||||
// MARK: Key derivation
|
||||
|
||||
/// Derives the ChaChaPoly channel key from the two Noise-exchanged tokens.
|
||||
/// Deterministic: same tokens + transferID always yield the same key.
|
||||
static func deriveKey(senderToken: Data, receiverToken: Data, transferID: Data) -> SymmetricKey? {
|
||||
guard senderToken.count == WifiBulkWire.tokenLength,
|
||||
receiverToken.count == WifiBulkWire.tokenLength,
|
||||
transferID.count == WifiBulkWire.transferIDLength else {
|
||||
return nil
|
||||
}
|
||||
var inputKeyMaterial = Data()
|
||||
inputKeyMaterial.append(senderToken)
|
||||
inputKeyMaterial.append(receiverToken)
|
||||
return HKDF<SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: inputKeyMaterial),
|
||||
salt: transferID,
|
||||
info: Data(keyDomain.utf8),
|
||||
outputByteCount: 32
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Frame sealing
|
||||
|
||||
static func nonceData(direction: WifiBulkFrameDirection, counter: UInt64) -> Data {
|
||||
var nonce = Data(count: nonceLength)
|
||||
nonce[0] = direction.rawValue
|
||||
var counterBE = counter.bigEndian
|
||||
withUnsafeBytes(of: &counterBE) { nonce.replaceSubrange(4..<nonceLength, with: $0) }
|
||||
return nonce
|
||||
}
|
||||
|
||||
/// Seals one frame body (nonce ‖ ciphertext ‖ tag), without length prefix.
|
||||
static func sealFrameBody(
|
||||
_ plaintext: Data,
|
||||
direction: WifiBulkFrameDirection,
|
||||
counter: UInt64,
|
||||
key: SymmetricKey
|
||||
) throws -> Data {
|
||||
let nonce = try ChaChaPoly.Nonce(data: nonceData(direction: direction, counter: counter))
|
||||
return try ChaChaPoly.seal(plaintext, using: key, nonce: nonce).combined
|
||||
}
|
||||
|
||||
/// Opens one frame body, enforcing the exact expected nonce.
|
||||
static func openFrameBody(
|
||||
_ body: Data,
|
||||
direction: WifiBulkFrameDirection,
|
||||
counter: UInt64,
|
||||
key: SymmetricKey
|
||||
) throws -> Data {
|
||||
guard body.count >= frameOverhead else { throw WifiBulkCryptoError.truncatedFrame }
|
||||
guard body.prefix(nonceLength) == nonceData(direction: direction, counter: counter) else {
|
||||
throw WifiBulkCryptoError.nonceMismatch
|
||||
}
|
||||
do {
|
||||
let box = try ChaChaPoly.SealedBox(combined: body)
|
||||
return try ChaChaPoly.open(box, using: key)
|
||||
} catch {
|
||||
throw WifiBulkCryptoError.authenticationFailed
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefixes a frame body with its 4-byte big-endian length for the wire.
|
||||
static func frameData(body: Data) -> Data {
|
||||
var framed = Data(capacity: framePrefixLength + body.count)
|
||||
var lengthBE = UInt32(body.count).bigEndian
|
||||
withUnsafeBytes(of: &lengthBE) { framed.append(contentsOf: $0) }
|
||||
framed.append(body)
|
||||
return framed
|
||||
}
|
||||
|
||||
// MARK: Control frames
|
||||
|
||||
/// First frame on the wire, receiver → sender: proves the connecting
|
||||
/// client holds the Noise-exchanged secret before any data flows.
|
||||
static func makeClientAuthFrameBody(transferID: Data, key: SymmetricKey) throws -> Data {
|
||||
try sealFrameBody(transferID, direction: .receiverToSender, counter: 0, key: key)
|
||||
}
|
||||
|
||||
static func validateClientAuthFrameBody(_ body: Data, transferID: Data, key: SymmetricKey) -> Bool {
|
||||
(try? openFrameBody(body, direction: .receiverToSender, counter: 0, key: key)) == transferID
|
||||
}
|
||||
|
||||
/// Final frame, receiver → sender: acknowledges the fully verified payload.
|
||||
static func makeReceiptFrameBody(payloadHash: Data, key: SymmetricKey) throws -> Data {
|
||||
try sealFrameBody(payloadHash, direction: .receiverToSender, counter: 1, key: key)
|
||||
}
|
||||
|
||||
static func validateReceiptFrameBody(_ body: Data, payloadHash: Data, key: SymmetricKey) -> Bool {
|
||||
(try? openFrameBody(body, direction: .receiverToSender, counter: 1, key: key)) == payloadHash
|
||||
}
|
||||
}
|
||||
|
||||
/// Incremental length-prefix parser for the frame stream. Bounded: bodies
|
||||
/// larger than `maxBodyBytes` throw instead of buffering unboundedly.
|
||||
final class WifiBulkFrameBuffer {
|
||||
private var buffer = Data()
|
||||
private let maxBodyBytes: Int
|
||||
|
||||
init(maxBodyBytes: Int) {
|
||||
self.maxBodyBytes = maxBodyBytes
|
||||
}
|
||||
|
||||
func append(_ data: Data) {
|
||||
buffer.append(data)
|
||||
}
|
||||
|
||||
/// Extracts the next complete frame body, or nil when more bytes are needed.
|
||||
func nextFrameBody() throws -> Data? {
|
||||
guard buffer.count >= WifiBulkCrypto.framePrefixLength else { return nil }
|
||||
let length = buffer.prefix(WifiBulkCrypto.framePrefixLength).reduce(Int(0)) { ($0 << 8) | Int($1) }
|
||||
guard length <= maxBodyBytes else { throw WifiBulkCryptoError.frameTooLarge }
|
||||
guard buffer.count >= WifiBulkCrypto.framePrefixLength + length else { return nil }
|
||||
let body = Data(buffer.dropFirst(WifiBulkCrypto.framePrefixLength).prefix(length))
|
||||
buffer.removeFirst(WifiBulkCrypto.framePrefixLength + length)
|
||||
return body
|
||||
}
|
||||
}
|
||||
|
||||
/// Receiver-side reassembly: opens sequential data frames, enforces the size
|
||||
/// negotiated in the accepted offer, and verifies the final SHA-256.
|
||||
final class WifiBulkPayloadAssembler {
|
||||
private let key: SymmetricKey
|
||||
private let expectedSize: Int
|
||||
private let expectedHash: Data
|
||||
private var received = Data()
|
||||
private var counter: UInt64 = 0
|
||||
|
||||
/// Fails when the offer exceeds the receiver-enforced cap.
|
||||
init?(key: SymmetricKey, expectedSize: UInt64, expectedHash: Data, sizeCap: Int) {
|
||||
guard expectedSize > 0,
|
||||
expectedSize <= UInt64(sizeCap),
|
||||
expectedHash.count == WifiBulkWire.hashLength else {
|
||||
return nil
|
||||
}
|
||||
self.key = key
|
||||
self.expectedSize = Int(expectedSize)
|
||||
self.expectedHash = expectedHash
|
||||
}
|
||||
|
||||
var isComplete: Bool { received.count == expectedSize }
|
||||
|
||||
/// Consumes one sealed data frame body. Returns the verified payload when
|
||||
/// the final byte arrives; throws on tampering, overflow, or hash mismatch.
|
||||
func consume(frameBody: Data) throws -> Data? {
|
||||
let chunk = try WifiBulkCrypto.openFrameBody(
|
||||
frameBody,
|
||||
direction: .senderToReceiver,
|
||||
counter: counter,
|
||||
key: key
|
||||
)
|
||||
guard !chunk.isEmpty else { throw WifiBulkCryptoError.emptyChunk }
|
||||
counter += 1
|
||||
guard received.count + chunk.count <= expectedSize else {
|
||||
throw WifiBulkCryptoError.payloadOverflow
|
||||
}
|
||||
received.append(chunk)
|
||||
guard isComplete else { return nil }
|
||||
guard Data(SHA256.hash(data: received)) == expectedHash else {
|
||||
throw WifiBulkCryptoError.hashMismatch
|
||||
}
|
||||
return received
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//
|
||||
// WifiBulkMessages.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// TLV payloads for negotiating a Wi-Fi bulk transfer inside an established
|
||||
/// Noise session (`NoisePayloadType.bulkTransferOffer` / `.bulkTransferResponse`).
|
||||
///
|
||||
/// Both messages ride the encrypted Noise channel, so every field — including
|
||||
/// the session tokens and the random Bonjour instance name — is only visible
|
||||
/// to the two endpoints. TLV format matches `BitchatFilePacket`: 1-byte type,
|
||||
/// 2-byte big-endian length, value. Unknown TLVs are skipped for forward
|
||||
/// compatibility.
|
||||
enum WifiBulkWire {
|
||||
static let transferIDLength = 16
|
||||
static let tokenLength = 32
|
||||
static let hashLength = 32
|
||||
/// Bonjour instance names are capped at 63 UTF-8 bytes.
|
||||
static let maxServiceNameBytes = 63
|
||||
|
||||
static func appendTLV(_ type: UInt8, value: Data, into data: inout Data) {
|
||||
data.append(type)
|
||||
var length = UInt16(value.count).bigEndian
|
||||
withUnsafeBytes(of: &length) { data.append(contentsOf: $0) }
|
||||
data.append(value)
|
||||
}
|
||||
|
||||
/// Iterates well-formed TLVs, handing each (type, value) to `visit`.
|
||||
/// Returns false when the buffer is structurally malformed.
|
||||
static func parseTLVs(_ data: Data, visit: (UInt8, Data) -> Void) -> Bool {
|
||||
var cursor = data.startIndex
|
||||
let end = data.endIndex
|
||||
while cursor < end {
|
||||
let type = data[cursor]
|
||||
cursor = data.index(after: cursor)
|
||||
guard data.distance(from: cursor, to: end) >= 2 else { return false }
|
||||
let length = Int(data[cursor]) << 8 | Int(data[data.index(after: cursor)])
|
||||
cursor = data.index(cursor, offsetBy: 2)
|
||||
guard data.distance(from: cursor, to: end) >= length else { return false }
|
||||
let valueEnd = data.index(cursor, offsetBy: length)
|
||||
visit(type, Data(data[cursor..<valueEnd]))
|
||||
cursor = valueEnd
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/// Sender → receiver: proposal to move an already-encoded file payload over
|
||||
/// a peer-to-peer Wi-Fi (AWDL) TCP channel instead of BLE fragmentation.
|
||||
struct WifiBulkOffer: Equatable {
|
||||
/// Random per-transfer identifier; also the HKDF salt.
|
||||
let transferID: Data
|
||||
/// Exact byte count of the payload that will cross the channel.
|
||||
let fileSize: UInt64
|
||||
/// SHA-256 over the payload bytes as they cross the channel, verified by
|
||||
/// the receiver after reassembly.
|
||||
let payloadHash: Data
|
||||
/// Sender's random half of the channel secret.
|
||||
let token: Data
|
||||
/// Random Bonjour instance name the sender publishes for this transfer.
|
||||
/// Never derived from nickname or peer ID.
|
||||
let serviceName: String
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case transferID = 0x01
|
||||
case fileSize = 0x02
|
||||
case payloadHash = 0x03
|
||||
case token = 0x04
|
||||
case serviceName = 0x05
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
guard transferID.count == WifiBulkWire.transferIDLength,
|
||||
payloadHash.count == WifiBulkWire.hashLength,
|
||||
token.count == WifiBulkWire.tokenLength else { return nil }
|
||||
let nameData = Data(serviceName.utf8)
|
||||
guard !nameData.isEmpty, nameData.count <= WifiBulkWire.maxServiceNameBytes else { return nil }
|
||||
|
||||
var encoded = Data()
|
||||
WifiBulkWire.appendTLV(TLVType.transferID.rawValue, value: transferID, into: &encoded)
|
||||
var sizeBE = fileSize.bigEndian
|
||||
WifiBulkWire.appendTLV(TLVType.fileSize.rawValue, value: withUnsafeBytes(of: &sizeBE) { Data($0) }, into: &encoded)
|
||||
WifiBulkWire.appendTLV(TLVType.payloadHash.rawValue, value: payloadHash, into: &encoded)
|
||||
WifiBulkWire.appendTLV(TLVType.token.rawValue, value: token, into: &encoded)
|
||||
WifiBulkWire.appendTLV(TLVType.serviceName.rawValue, value: nameData, into: &encoded)
|
||||
return encoded
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) -> WifiBulkOffer? {
|
||||
var transferID: Data?
|
||||
var fileSize: UInt64?
|
||||
var payloadHash: Data?
|
||||
var token: Data?
|
||||
var serviceName: String?
|
||||
|
||||
let wellFormed = WifiBulkWire.parseTLVs(data) { type, value in
|
||||
switch TLVType(rawValue: type) {
|
||||
case .transferID where value.count == WifiBulkWire.transferIDLength:
|
||||
transferID = value
|
||||
case .fileSize where value.count == 8:
|
||||
fileSize = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
|
||||
case .payloadHash where value.count == WifiBulkWire.hashLength:
|
||||
payloadHash = value
|
||||
case .token where value.count == WifiBulkWire.tokenLength:
|
||||
token = value
|
||||
case .serviceName where !value.isEmpty && value.count <= WifiBulkWire.maxServiceNameBytes:
|
||||
serviceName = String(data: value, encoding: .utf8)
|
||||
default:
|
||||
break // Unknown or malformed field: ignore; required checks below.
|
||||
}
|
||||
}
|
||||
guard wellFormed,
|
||||
let transferID, let fileSize, let payloadHash, let token, let serviceName else {
|
||||
return nil
|
||||
}
|
||||
return WifiBulkOffer(
|
||||
transferID: transferID,
|
||||
fileSize: fileSize,
|
||||
payloadHash: payloadHash,
|
||||
token: token,
|
||||
serviceName: serviceName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Receiver → sender: accept (with the receiver's token half) or decline.
|
||||
struct WifiBulkResponse: Equatable {
|
||||
let transferID: Data
|
||||
let accepted: Bool
|
||||
/// Receiver's random half of the channel secret; present iff accepted.
|
||||
let token: Data?
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
case transferID = 0x01
|
||||
case accepted = 0x02
|
||||
case token = 0x03
|
||||
}
|
||||
|
||||
static func accept(transferID: Data, token: Data) -> WifiBulkResponse {
|
||||
WifiBulkResponse(transferID: transferID, accepted: true, token: token)
|
||||
}
|
||||
|
||||
static func decline(transferID: Data) -> WifiBulkResponse {
|
||||
WifiBulkResponse(transferID: transferID, accepted: false, token: nil)
|
||||
}
|
||||
|
||||
func encode() -> Data? {
|
||||
guard transferID.count == WifiBulkWire.transferIDLength else { return nil }
|
||||
if accepted {
|
||||
guard token?.count == WifiBulkWire.tokenLength else { return nil }
|
||||
}
|
||||
|
||||
var encoded = Data()
|
||||
WifiBulkWire.appendTLV(TLVType.transferID.rawValue, value: transferID, into: &encoded)
|
||||
WifiBulkWire.appendTLV(TLVType.accepted.rawValue, value: Data([accepted ? 1 : 0]), into: &encoded)
|
||||
if accepted, let token {
|
||||
WifiBulkWire.appendTLV(TLVType.token.rawValue, value: token, into: &encoded)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
static func decode(_ data: Data) -> WifiBulkResponse? {
|
||||
var transferID: Data?
|
||||
var accepted: Bool?
|
||||
var token: Data?
|
||||
|
||||
let wellFormed = WifiBulkWire.parseTLVs(data) { type, value in
|
||||
switch TLVType(rawValue: type) {
|
||||
case .transferID where value.count == WifiBulkWire.transferIDLength:
|
||||
transferID = value
|
||||
case .accepted where value.count == 1:
|
||||
accepted = value.first == 1
|
||||
case .token where value.count == WifiBulkWire.tokenLength:
|
||||
token = value
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
guard wellFormed, let transferID, let accepted else { return nil }
|
||||
if accepted {
|
||||
guard let token else { return nil }
|
||||
return WifiBulkResponse(transferID: transferID, accepted: true, token: token)
|
||||
}
|
||||
return WifiBulkResponse(transferID: transferID, accepted: false, token: nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//
|
||||
// WifiBulkPolicy.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Pure eligibility decisions for the Wi-Fi bulk data plane. Anything that
|
||||
/// fails these gates rides BLE fragmentation exactly as before — the BLE
|
||||
/// fallback is the common case and must stay bulletproof.
|
||||
enum WifiBulkPolicy {
|
||||
struct SendCandidate {
|
||||
let payloadBytes: Int
|
||||
let peerCapabilities: PeerCapabilities
|
||||
/// Direct BLE link (1 hop). Multi-hop recipients stay on BLE: AWDL
|
||||
/// only reaches direct neighbors, and relays can't proxy the channel.
|
||||
let isDirectlyConnected: Bool
|
||||
/// The offer rides the Noise session, so one must already exist.
|
||||
let hasEstablishedNoiseSession: Bool
|
||||
}
|
||||
|
||||
static func shouldOffer(
|
||||
_ candidate: SendCandidate,
|
||||
enabled: Bool = TransportConfig.wifiBulkEnabled,
|
||||
minPayloadBytes: Int = TransportConfig.wifiBulkMinPayloadBytes,
|
||||
maxPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes
|
||||
) -> Bool {
|
||||
enabled
|
||||
&& candidate.payloadBytes > minPayloadBytes
|
||||
&& candidate.payloadBytes <= maxPayloadBytes
|
||||
&& candidate.peerCapabilities.contains(.wifiBulk)
|
||||
&& candidate.isDirectlyConnected
|
||||
&& candidate.hasEstablishedNoiseSession
|
||||
}
|
||||
|
||||
/// Receiver-side gate. Field lengths were validated at decode; this
|
||||
/// enforces the size cap (from the local ceiling, not the sender's word)
|
||||
/// and local enablement.
|
||||
static func shouldAccept(
|
||||
offer: WifiBulkOffer,
|
||||
senderIsDirectlyConnected: Bool,
|
||||
activeIncomingTransfers: Int,
|
||||
enabled: Bool = TransportConfig.wifiBulkEnabled,
|
||||
maxPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes,
|
||||
maxConcurrentIncoming: Int = TransportConfig.wifiBulkMaxConcurrentIncoming
|
||||
) -> Bool {
|
||||
enabled
|
||||
&& senderIsDirectlyConnected
|
||||
&& activeIncomingTransfers < maxConcurrentIncoming
|
||||
&& offer.fileSize > 0
|
||||
&& offer.fileSize <= UInt64(maxPayloadBytes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
//
|
||||
// WifiBulkTransferService.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// Narrow environment for `WifiBulkTransferService`. All BLE-service queue
|
||||
/// hops live inside the closures supplied by `BLEService`, keeping this
|
||||
/// service independently testable.
|
||||
struct WifiBulkTransferServiceEnvironment {
|
||||
/// Sends a typed payload inside the established Noise session with the
|
||||
/// peer. Returns false when no established session exists (the caller
|
||||
/// falls back to BLE).
|
||||
let sendNoisePayload: (_ typedPayload: Data, _ peerID: PeerID) -> Bool
|
||||
/// Whether the peer is on a direct BLE link right now.
|
||||
let isPeerConnected: (PeerID) -> Bool
|
||||
/// Delivers a fully received, hash-verified payload (encoded
|
||||
/// `BitchatFilePacket` TLV) into the normal incoming-file pipeline.
|
||||
let deliverReceivedFile: (_ payload: Data, _ peerID: PeerID, _ payloadLimit: Int) -> Void
|
||||
/// Progress bus hooks mirroring the BLE fragmentation path so the UI is
|
||||
/// unchanged (chunks report as "fragments").
|
||||
let progressStart: (_ transferId: String, _ totalChunks: Int) -> Void
|
||||
let progressChunkSent: (_ transferId: String) -> Void
|
||||
/// Silently forgets progress state ahead of a BLE fallback re-start.
|
||||
let progressReset: (_ transferId: String) -> Void
|
||||
/// Emits the cancelled event for user-cancelled transfers.
|
||||
let progressCancel: (_ transferId: String) -> Void
|
||||
}
|
||||
|
||||
/// Knobs with test overrides; production values come from `TransportConfig`.
|
||||
struct WifiBulkTransferServiceConfig {
|
||||
var serviceType: String = TransportConfig.wifiBulkServiceType
|
||||
var chunkBytes: Int = TransportConfig.wifiBulkChunkBytes
|
||||
var offerTimeout: TimeInterval = TransportConfig.wifiBulkOfferTimeoutSeconds
|
||||
var transferWindow: TimeInterval = TransportConfig.wifiBulkTransferWindowSeconds
|
||||
var maxIncomingPayloadBytes: Int = FileTransferLimits.maxWifiBulkPayloadBytes
|
||||
var maxConcurrentIncoming: Int = TransportConfig.wifiBulkMaxConcurrentIncoming
|
||||
/// Tests disable peer-to-peer so loopback interfaces stay usable.
|
||||
var usePeerToPeer: Bool = true
|
||||
/// Tests disable Bonjour publication (unit-test hosts may lack mDNS access).
|
||||
var publishBonjourService: Bool = true
|
||||
}
|
||||
|
||||
/// Orchestrates the Wi-Fi bulk data plane: BLE/Noise carries the offer and
|
||||
/// response (control plane), then the payload crosses a per-transfer TCP
|
||||
/// channel over AWDL, sealed with a key both sides derived from the
|
||||
/// Noise-exchanged tokens. Any failure at any stage falls back to BLE
|
||||
/// fragmentation exactly once; the receiver side fails silently and lets the
|
||||
/// sender's timeout drive that fallback.
|
||||
final class WifiBulkTransferService {
|
||||
private let queue = DispatchQueue(label: "com.bitchat.wifi-bulk", qos: .userInitiated)
|
||||
private let environment: WifiBulkTransferServiceEnvironment
|
||||
private let config: WifiBulkTransferServiceConfig
|
||||
|
||||
private final class OutgoingTransfer {
|
||||
let transferID: Data
|
||||
let transferId: String
|
||||
let peerID: PeerID
|
||||
let token: Data
|
||||
let fallback: () -> Void
|
||||
var session: WifiBulkSenderSession?
|
||||
var offerTimeout: DispatchWorkItem?
|
||||
var windowTimeout: DispatchWorkItem?
|
||||
var accepted = false
|
||||
var finished = false
|
||||
|
||||
init(transferID: Data, transferId: String, peerID: PeerID, token: Data, fallback: @escaping () -> Void) {
|
||||
self.transferID = transferID
|
||||
self.transferId = transferId
|
||||
self.peerID = peerID
|
||||
self.token = token
|
||||
self.fallback = fallback
|
||||
}
|
||||
}
|
||||
|
||||
private final class IncomingTransfer {
|
||||
let offer: WifiBulkOffer
|
||||
let peerID: PeerID
|
||||
let key: SymmetricKey
|
||||
var browser: NWBrowser?
|
||||
var session: WifiBulkReceiverSession?
|
||||
var windowTimeout: DispatchWorkItem?
|
||||
|
||||
init(offer: WifiBulkOffer, peerID: PeerID, key: SymmetricKey) {
|
||||
self.offer = offer
|
||||
self.peerID = peerID
|
||||
self.key = key
|
||||
}
|
||||
}
|
||||
|
||||
private var outgoing: [Data: OutgoingTransfer] = [:]
|
||||
private var incoming: [Data: IncomingTransfer] = [:]
|
||||
|
||||
init(
|
||||
environment: WifiBulkTransferServiceEnvironment,
|
||||
config: WifiBulkTransferServiceConfig = WifiBulkTransferServiceConfig()
|
||||
) {
|
||||
self.environment = environment
|
||||
self.config = config
|
||||
}
|
||||
|
||||
// MARK: - Sender
|
||||
|
||||
/// Offers `payload` over the Wi-Fi bulk channel. `fallbackToBLE` runs at
|
||||
/// most once, on decline, timeout, or any mid-transfer error.
|
||||
func sendFile(payload: Data, to peerID: PeerID, transferId: String, fallbackToBLE: @escaping () -> Void) {
|
||||
queue.async { [weak self] in
|
||||
self?.beginOutgoing(payload: payload, peerID: peerID, transferId: transferId, fallbackToBLE: fallbackToBLE)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a decrypted `bulkTransferResponse` Noise payload.
|
||||
func handleResponsePayload(_ payload: Data, from peerID: PeerID) {
|
||||
queue.async { [weak self] in
|
||||
self?.processResponse(payload, from: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
/// User-initiated cancel from the UI (mirrors BLE `cancelTransfer`).
|
||||
func cancelTransfer(transferId: String) {
|
||||
queue.async { [weak self] in
|
||||
guard let self,
|
||||
let transfer = self.outgoing.values.first(where: { $0.transferId == transferId }) else { return }
|
||||
self.finishOutgoing(transfer, outcome: .cancelled, reason: "cancelled by user")
|
||||
}
|
||||
}
|
||||
|
||||
/// Tears down every transfer (service shutdown / emergency disconnect).
|
||||
/// In-flight outgoing transfers do NOT fall back — the transport is going away.
|
||||
func stop() {
|
||||
queue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
for transfer in self.outgoing.values {
|
||||
transfer.finished = true
|
||||
transfer.offerTimeout?.cancel()
|
||||
transfer.windowTimeout?.cancel()
|
||||
transfer.session?.cancel()
|
||||
}
|
||||
self.outgoing.removeAll()
|
||||
for transfer in self.incoming.values {
|
||||
self.tearDownIncomingResources(transfer)
|
||||
}
|
||||
self.incoming.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private enum OutgoingOutcome {
|
||||
case completed
|
||||
case fallback
|
||||
case cancelled
|
||||
}
|
||||
|
||||
private func beginOutgoing(payload: Data, peerID: PeerID, transferId: String, fallbackToBLE: @escaping () -> Void) {
|
||||
let transferID = Self.randomData(WifiBulkWire.transferIDLength)
|
||||
let token = Self.randomData(WifiBulkWire.tokenLength)
|
||||
// Random per-transfer instance name — never the nickname or peer ID.
|
||||
let serviceName = Self.randomData(16).hexEncodedString()
|
||||
|
||||
let offer = WifiBulkOffer(
|
||||
transferID: transferID,
|
||||
fileSize: UInt64(payload.count),
|
||||
payloadHash: Data(SHA256.hash(data: payload)),
|
||||
token: token,
|
||||
serviceName: serviceName
|
||||
)
|
||||
guard let offerData = offer.encode() else {
|
||||
SecureLogger.info("[WIFI] fallback→BLE to \(peerID.id.prefix(8))… (offer encode failed)", category: .transport)
|
||||
fallbackToBLE()
|
||||
return
|
||||
}
|
||||
|
||||
let transfer = OutgoingTransfer(
|
||||
transferID: transferID,
|
||||
transferId: transferId,
|
||||
peerID: peerID,
|
||||
token: token,
|
||||
fallback: fallbackToBLE
|
||||
)
|
||||
|
||||
let session = WifiBulkSenderSession(
|
||||
payload: payload,
|
||||
transferID: transferID,
|
||||
chunkBytes: config.chunkBytes,
|
||||
parameters: makeParameters(),
|
||||
service: config.publishBonjourService
|
||||
? NWListener.Service(name: serviceName, type: config.serviceType)
|
||||
: nil,
|
||||
queue: queue
|
||||
)
|
||||
if let onListenerReady = _test_onListenerReady {
|
||||
session.onListenerReady = { port in onListenerReady(transferID, port) }
|
||||
}
|
||||
session.onChunkSent = { [weak self, weak transfer] sent, total in
|
||||
guard let self, let transfer, !transfer.finished else { return }
|
||||
// Hold the final tick until the receipt confirms delivery, so the
|
||||
// progress bus only emits .completed for verified transfers.
|
||||
if sent < total {
|
||||
self.environment.progressChunkSent(transfer.transferId)
|
||||
}
|
||||
}
|
||||
session.onCompleted = { [weak self, weak transfer] in
|
||||
guard let self, let transfer, !transfer.finished else { return }
|
||||
self.environment.progressChunkSent(transfer.transferId)
|
||||
self.finishOutgoing(transfer, outcome: .completed, reason: "receipt verified")
|
||||
}
|
||||
session.onFailed = { [weak self, weak transfer] reason in
|
||||
guard let self, let transfer else { return }
|
||||
self.finishOutgoing(transfer, outcome: .fallback, reason: reason)
|
||||
}
|
||||
transfer.session = session
|
||||
outgoing[transferID] = transfer
|
||||
|
||||
guard session.start() else {
|
||||
finishOutgoing(transfer, outcome: .fallback, reason: "listener unavailable")
|
||||
return
|
||||
}
|
||||
guard environment.sendNoisePayload(
|
||||
BLENoisePayloadFactory.typedPayload(.bulkTransferOffer, payload: offerData),
|
||||
peerID
|
||||
) else {
|
||||
finishOutgoing(transfer, outcome: .fallback, reason: "no established noise session")
|
||||
return
|
||||
}
|
||||
|
||||
SecureLogger.info("[WIFI] offer sent \(payload.count) bytes to \(peerID.id.prefix(8))… over \(serviceName.prefix(8))…", category: .transport)
|
||||
environment.progressStart(transferId, session.totalChunks)
|
||||
|
||||
let offerTimeout = DispatchWorkItem { [weak self, weak transfer] in
|
||||
guard let self, let transfer, !transfer.accepted else { return }
|
||||
self.finishOutgoing(transfer, outcome: .fallback, reason: "offer timed out")
|
||||
}
|
||||
transfer.offerTimeout = offerTimeout
|
||||
queue.asyncAfter(deadline: .now() + config.offerTimeout, execute: offerTimeout)
|
||||
|
||||
let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in
|
||||
guard let self, let transfer else { return }
|
||||
self.finishOutgoing(transfer, outcome: .fallback, reason: "transfer window expired")
|
||||
}
|
||||
transfer.windowTimeout = windowTimeout
|
||||
queue.asyncAfter(deadline: .now() + config.transferWindow, execute: windowTimeout)
|
||||
}
|
||||
|
||||
private func processResponse(_ payload: Data, from peerID: PeerID) {
|
||||
guard let response = WifiBulkResponse.decode(payload),
|
||||
let transfer = outgoing[response.transferID],
|
||||
transfer.peerID.toShort() == peerID.toShort(),
|
||||
!transfer.accepted, !transfer.finished else {
|
||||
return
|
||||
}
|
||||
|
||||
guard response.accepted, let receiverToken = response.token else {
|
||||
finishOutgoing(transfer, outcome: .fallback, reason: "offer declined")
|
||||
return
|
||||
}
|
||||
guard let key = WifiBulkCrypto.deriveKey(
|
||||
senderToken: transfer.token,
|
||||
receiverToken: receiverToken,
|
||||
transferID: transfer.transferID
|
||||
) else {
|
||||
finishOutgoing(transfer, outcome: .fallback, reason: "key derivation failed")
|
||||
return
|
||||
}
|
||||
|
||||
transfer.accepted = true
|
||||
transfer.offerTimeout?.cancel()
|
||||
transfer.offerTimeout = nil
|
||||
SecureLogger.info("[WIFI] offer accepted by \(peerID.id.prefix(8))…, activating channel", category: .transport)
|
||||
transfer.session?.activate(key: key)
|
||||
}
|
||||
|
||||
private func finishOutgoing(_ transfer: OutgoingTransfer, outcome: OutgoingOutcome, reason: String) {
|
||||
guard !transfer.finished else { return }
|
||||
transfer.finished = true
|
||||
transfer.offerTimeout?.cancel()
|
||||
transfer.windowTimeout?.cancel()
|
||||
transfer.session?.cancel()
|
||||
outgoing.removeValue(forKey: transfer.transferID)
|
||||
|
||||
switch outcome {
|
||||
case .completed:
|
||||
SecureLogger.info("[WIFI] transfer \(transfer.transferId.prefix(8))… complete (\(reason))", category: .transport)
|
||||
case .fallback:
|
||||
SecureLogger.info("[WIFI] fallback→BLE \(transfer.transferId.prefix(8))… (\(reason))", category: .transport)
|
||||
environment.progressReset(transfer.transferId)
|
||||
transfer.fallback()
|
||||
case .cancelled:
|
||||
SecureLogger.info("[WIFI] transfer \(transfer.transferId.prefix(8))… cancelled", category: .transport)
|
||||
environment.progressCancel(transfer.transferId)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Receiver
|
||||
|
||||
/// Handles a decrypted `bulkTransferOffer` Noise payload.
|
||||
func handleOfferPayload(_ payload: Data, from peerID: PeerID) {
|
||||
queue.async { [weak self] in
|
||||
self?.processOffer(payload, from: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func processOffer(_ payload: Data, from peerID: PeerID) {
|
||||
guard let offer = WifiBulkOffer.decode(payload) else { return }
|
||||
guard incoming[offer.transferID] == nil else { return }
|
||||
|
||||
guard WifiBulkPolicy.shouldAccept(
|
||||
offer: offer,
|
||||
senderIsDirectlyConnected: environment.isPeerConnected(peerID),
|
||||
activeIncomingTransfers: incoming.count,
|
||||
maxPayloadBytes: config.maxIncomingPayloadBytes,
|
||||
maxConcurrentIncoming: config.maxConcurrentIncoming
|
||||
) else {
|
||||
decline(offer: offer, peerID: peerID)
|
||||
return
|
||||
}
|
||||
|
||||
let token = Self.randomData(WifiBulkWire.tokenLength)
|
||||
guard let key = WifiBulkCrypto.deriveKey(
|
||||
senderToken: offer.token,
|
||||
receiverToken: token,
|
||||
transferID: offer.transferID
|
||||
),
|
||||
let responseData = WifiBulkResponse.accept(transferID: offer.transferID, token: token).encode() else {
|
||||
decline(offer: offer, peerID: peerID)
|
||||
return
|
||||
}
|
||||
guard environment.sendNoisePayload(
|
||||
BLENoisePayloadFactory.typedPayload(.bulkTransferResponse, payload: responseData),
|
||||
peerID
|
||||
) else {
|
||||
return // No session to answer on; the sender's timeout handles fallback.
|
||||
}
|
||||
|
||||
let transfer = IncomingTransfer(offer: offer, peerID: peerID, key: key)
|
||||
incoming[offer.transferID] = transfer
|
||||
SecureLogger.info("[WIFI] offer accept \(offer.fileSize) bytes from \(peerID.id.prefix(8))…", category: .transport)
|
||||
|
||||
startBrowsing(for: transfer)
|
||||
|
||||
let windowTimeout = DispatchWorkItem { [weak self, weak transfer] in
|
||||
guard let self, let transfer else { return }
|
||||
SecureLogger.info("[WIFI] incoming window expired", category: .transport)
|
||||
self.tearDownIncoming(transfer)
|
||||
}
|
||||
transfer.windowTimeout = windowTimeout
|
||||
queue.asyncAfter(deadline: .now() + config.transferWindow, execute: windowTimeout)
|
||||
}
|
||||
|
||||
private func decline(offer: WifiBulkOffer, peerID: PeerID) {
|
||||
SecureLogger.info("[WIFI] offer decline \(offer.fileSize) bytes from \(peerID.id.prefix(8))…", category: .transport)
|
||||
guard let responseData = WifiBulkResponse.decline(transferID: offer.transferID).encode() else { return }
|
||||
_ = environment.sendNoisePayload(
|
||||
BLENoisePayloadFactory.typedPayload(.bulkTransferResponse, payload: responseData),
|
||||
peerID
|
||||
)
|
||||
}
|
||||
|
||||
private func startBrowsing(for transfer: IncomingTransfer) {
|
||||
let browser = NWBrowser(
|
||||
for: .bonjour(type: config.serviceType, domain: nil),
|
||||
using: makeParameters()
|
||||
)
|
||||
transfer.browser = browser
|
||||
browser.browseResultsChangedHandler = { [weak self, weak transfer] results, _ in
|
||||
guard let self, let transfer, transfer.session == nil else { return }
|
||||
let match = results.first { result in
|
||||
if case .service(let name, _, _, _) = result.endpoint {
|
||||
return name == transfer.offer.serviceName
|
||||
}
|
||||
return false
|
||||
}
|
||||
guard let match else { return }
|
||||
self.connect(transfer, to: match.endpoint)
|
||||
}
|
||||
browser.stateUpdateHandler = { [weak self, weak transfer] state in
|
||||
guard let self, let transfer else { return }
|
||||
if case .failed(let error) = state {
|
||||
SecureLogger.warning("[WIFI] browser failed: \(error)", category: .transport)
|
||||
self.tearDownIncoming(transfer)
|
||||
}
|
||||
}
|
||||
browser.start(queue: queue)
|
||||
}
|
||||
|
||||
/// Test hook: connects an accepted incoming transfer straight to an
|
||||
/// endpoint, standing in for Bonjour discovery on hosts without mDNS.
|
||||
func _test_connectIncoming(transferID: Data, to endpoint: NWEndpoint) {
|
||||
queue.async { [weak self] in
|
||||
guard let self, let transfer = self.incoming[transferID], transfer.session == nil else { return }
|
||||
self.connect(transfer, to: endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
private func connect(_ transfer: IncomingTransfer, to endpoint: NWEndpoint) {
|
||||
transfer.browser?.cancel()
|
||||
transfer.browser = nil
|
||||
|
||||
guard let session = WifiBulkReceiverSession(
|
||||
endpoint: endpoint,
|
||||
parameters: makeParameters(),
|
||||
key: transfer.key,
|
||||
transferID: transfer.offer.transferID,
|
||||
expectedSize: transfer.offer.fileSize,
|
||||
expectedHash: transfer.offer.payloadHash,
|
||||
sizeCap: config.maxIncomingPayloadBytes,
|
||||
chunkBytes: config.chunkBytes,
|
||||
queue: queue
|
||||
) else {
|
||||
tearDownIncoming(transfer)
|
||||
return
|
||||
}
|
||||
session.onCompleted = { [weak self, weak transfer] payload in
|
||||
guard let self, let transfer else { return }
|
||||
SecureLogger.info("[WIFI] transfer complete, received \(payload.count) bytes from \(transfer.peerID.id.prefix(8))…", category: .transport)
|
||||
self.environment.deliverReceivedFile(payload, transfer.peerID, self.config.maxIncomingPayloadBytes)
|
||||
self.tearDownIncoming(transfer)
|
||||
}
|
||||
session.onFailed = { [weak self, weak transfer] reason in
|
||||
guard let self, let transfer else { return }
|
||||
SecureLogger.info("[WIFI] incoming failed (\(reason)); sender falls back to BLE", category: .transport)
|
||||
self.tearDownIncoming(transfer)
|
||||
}
|
||||
transfer.session = session
|
||||
session.start()
|
||||
}
|
||||
|
||||
private func tearDownIncoming(_ transfer: IncomingTransfer) {
|
||||
tearDownIncomingResources(transfer)
|
||||
incoming.removeValue(forKey: transfer.offer.transferID)
|
||||
}
|
||||
|
||||
private func tearDownIncomingResources(_ transfer: IncomingTransfer) {
|
||||
transfer.windowTimeout?.cancel()
|
||||
transfer.windowTimeout = nil
|
||||
transfer.browser?.cancel()
|
||||
transfer.browser = nil
|
||||
transfer.session?.cancel()
|
||||
transfer.session = nil
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makeParameters() -> NWParameters {
|
||||
let parameters = NWParameters.tcp
|
||||
if config.usePeerToPeer {
|
||||
parameters.includePeerToPeer = true
|
||||
// Keep the channel off infrastructure-independent radios we never
|
||||
// want (cellular/wired); AWDL rides on the peer-to-peer flag.
|
||||
parameters.prohibitedInterfaceTypes = [.cellular, .wiredEthernet, .loopback]
|
||||
}
|
||||
return parameters
|
||||
}
|
||||
|
||||
/// Cryptographically secure random bytes (Swift's default RNG is CSPRNG-backed).
|
||||
private static func randomData(_ count: Int) -> Data {
|
||||
Data((0..<count).map { _ in UInt8.random(in: .min ... .max) })
|
||||
}
|
||||
|
||||
// MARK: - Test observability
|
||||
|
||||
/// Test hook: reports each outgoing listener's bound port, standing in
|
||||
/// for Bonjour resolution on hosts without mDNS. Set before `sendFile`.
|
||||
var _test_onListenerReady: ((_ transferID: Data, _ port: UInt16) -> Void)?
|
||||
|
||||
var _test_activeOutgoingCount: Int {
|
||||
queue.sync { outgoing.count }
|
||||
}
|
||||
|
||||
var _test_activeIncomingCount: Int {
|
||||
queue.sync { incoming.count }
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,18 @@ import CryptoKit
|
||||
// - Golomb-Rice with parameter P: q = (x - 1) >> P encoded as unary (q ones then a zero), then write P-bit remainder r = (x - 1) & ((1<<P)-1).
|
||||
// - Bitstream is MSB-first within each byte.
|
||||
enum GCSFilter {
|
||||
struct Params { let p: Int; let m: UInt32; let data: Data }
|
||||
// `includedCount` is how many of the input `ids` (in input order) the
|
||||
// returned filter actually encodes. It can be below `ids.count` when the
|
||||
// Golomb-Rice encoding overflows the byte budget and the tail is trimmed.
|
||||
// Callers that derive a since-cursor need this: trimming drops from the
|
||||
// input tail, so the first `includedCount` inputs are exactly what the
|
||||
// filter covers.
|
||||
struct Params { let p: Int; let m: UInt32; let data: Data; let includedCount: Int }
|
||||
|
||||
// Highest Golomb-Rice parameter we accept from the wire. P maps to an FPR
|
||||
// of ~1/2^P; beyond 32 the remainder width exceeds any practical filter
|
||||
// and shifts in decode would silently overflow to garbage values.
|
||||
static let maxP = 32
|
||||
|
||||
// Derive P from FPR (~ 1 / 2^P)
|
||||
static func deriveP(targetFpr: Double) -> Int {
|
||||
@@ -30,42 +41,46 @@ enum GCSFilter {
|
||||
static func buildFilter(ids: [Data], maxBytes: Int, targetFpr: Double) -> Params {
|
||||
let p = deriveP(targetFpr: targetFpr)
|
||||
guard !ids.isEmpty else {
|
||||
return Params(p: p, m: 1, data: Data())
|
||||
return Params(p: p, m: 1, data: Data(), includedCount: 0)
|
||||
}
|
||||
|
||||
let cap = estimateMaxElements(sizeBytes: maxBytes, p: p)
|
||||
let selected = Array(ids.prefix(cap))
|
||||
let range = max(1, hashRange(count: selected.count, p: p))
|
||||
// Modulus is fixed to the initial candidate count so `m` stays stable
|
||||
// as the tail is trimmed to fit the byte budget below.
|
||||
let range = max(1, hashRange(count: min(ids.count, cap), p: p))
|
||||
let modulo = UInt64(range)
|
||||
|
||||
var mapped = selected
|
||||
.map { h64($0) }
|
||||
.map { mapHash($0, modulo: modulo) }
|
||||
.sorted()
|
||||
mapped = normalizeMappedValues(mapped, modulo: modulo)
|
||||
|
||||
if mapped.isEmpty {
|
||||
return Params(p: p, m: range, data: Data())
|
||||
// Encode the first `count` inputs (input order). The caller passes IDs
|
||||
// newest-first, so trimming from the tail drops the oldest — which is
|
||||
// what lets a since-cursor stay exact: the surviving set is always a
|
||||
// contiguous newest-prefix, never a hash-order-arbitrary subset.
|
||||
func encodeFirst(_ count: Int) -> Data {
|
||||
var mapped = ids.prefix(count)
|
||||
.map { h64($0) }
|
||||
.map { mapHash($0, modulo: modulo) }
|
||||
.sorted()
|
||||
mapped = normalizeMappedValues(mapped, modulo: modulo)
|
||||
return mapped.isEmpty ? Data() : encode(sorted: mapped, p: p)
|
||||
}
|
||||
|
||||
var encoded = encode(sorted: mapped, p: p)
|
||||
var trimmedCount = mapped.count
|
||||
|
||||
while encoded.count > maxBytes && trimmedCount > 0 {
|
||||
if trimmedCount == 1 {
|
||||
mapped.removeAll()
|
||||
encoded = Data()
|
||||
break
|
||||
}
|
||||
trimmedCount = max(1, (trimmedCount * 9) / 10)
|
||||
mapped = Array(mapped.prefix(trimmedCount))
|
||||
encoded = encode(sorted: mapped, p: p)
|
||||
var count = min(ids.count, cap)
|
||||
var encoded = encodeFirst(count)
|
||||
while encoded.count > maxBytes && count > 1 {
|
||||
count = max(1, (count * 9) / 10)
|
||||
encoded = encodeFirst(count)
|
||||
}
|
||||
// A single element that still overflows can't be represented.
|
||||
if encoded.count > maxBytes {
|
||||
return Params(p: p, m: range, data: Data(), includedCount: 0)
|
||||
}
|
||||
|
||||
return Params(p: p, m: range, data: encoded)
|
||||
return Params(p: p, m: range, data: encoded, includedCount: encoded.isEmpty ? 0 : count)
|
||||
}
|
||||
|
||||
static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {
|
||||
// Reject out-of-range parameters rather than decoding garbage: callers
|
||||
// treat the result as "peer has nothing" and fall back to sending data.
|
||||
guard p >= 1, p <= maxP, m > 1 else { return [] }
|
||||
var values: [UInt64] = []
|
||||
let reader = BitReader(data)
|
||||
var acc: UInt64 = 0
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//
|
||||
// GossipMessageArchive.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
/// Disk persistence for the gossip-sync public message store, so the recent
|
||||
/// public history a device carries survives app restarts. This is what lets
|
||||
/// a phone act as a town crier: walk between two mesh partitions (or relaunch
|
||||
/// hours later) and sync the room's backlog to whoever missed it.
|
||||
///
|
||||
/// Contents are signed public broadcasts — already visible to anyone in radio
|
||||
/// range — so file protection (no additional sealing) is the right at-rest
|
||||
/// posture. Wiped on panic.
|
||||
final class GossipMessageArchive {
|
||||
private let fileURL: URL?
|
||||
|
||||
init(fileURL: URL? = nil) {
|
||||
self.fileURL = fileURL ?? Self.defaultFileURL()
|
||||
}
|
||||
|
||||
/// Raw binary packets, decoded and freshness-filtered by the caller.
|
||||
func load() -> [Data] {
|
||||
guard let fileURL,
|
||||
let data = try? Data(contentsOf: fileURL),
|
||||
let packets = try? JSONDecoder().decode([Data].self, from: data) else {
|
||||
return []
|
||||
}
|
||||
return packets
|
||||
}
|
||||
|
||||
func save(_ packets: [Data]) {
|
||||
guard let fileURL else { return }
|
||||
guard !packets.isEmpty else {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
return
|
||||
}
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let data = try JSONEncoder().encode(packets)
|
||||
var options: Data.WritingOptions = [.atomic]
|
||||
#if os(iOS)
|
||||
options.insert(.completeFileProtection)
|
||||
#endif
|
||||
try data.write(to: fileURL, options: options)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to persist gossip archive: \(error)", category: .sync)
|
||||
}
|
||||
}
|
||||
|
||||
func wipe() {
|
||||
guard let fileURL else { return }
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
|
||||
/// Panic-wipe hook for callers that don't hold the live instance.
|
||||
static func wipeDefault() {
|
||||
GossipMessageArchive().wipe()
|
||||
}
|
||||
|
||||
private static func defaultFileURL() -> URL? {
|
||||
guard let base = try? FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
) else { return nil }
|
||||
return base
|
||||
.appendingPathComponent("sync", isDirectory: true)
|
||||
.appendingPathComponent("public-messages.json")
|
||||
}
|
||||
}
|
||||
@@ -64,41 +64,82 @@ final class GossipSyncManager {
|
||||
var seenCapacity: Int = 1000 // max packets per sync (cap across types)
|
||||
var gcsMaxBytes: Int = 400 // filter size budget (128..1024)
|
||||
var gcsTargetFpr: Double = 0.01 // 1%
|
||||
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - discard older messages
|
||||
var maxMessageAgeSeconds: TimeInterval = 900 // 15 min - fragments/files/announces
|
||||
// Whole public messages stay sync-able much longer so devices carry
|
||||
// the room's recent history between partitions and across restarts.
|
||||
var publicMessageMaxAgeSeconds: TimeInterval = 900
|
||||
var maintenanceIntervalSeconds: TimeInterval = 30.0
|
||||
var stalePeerCleanupIntervalSeconds: TimeInterval = 60.0
|
||||
var stalePeerTimeoutSeconds: TimeInterval = 60.0
|
||||
var fragmentCapacity: Int = 600
|
||||
var fileTransferCapacity: Int = 200
|
||||
var groupMessageCapacity: Int = 200
|
||||
var fragmentSyncIntervalSeconds: TimeInterval = 30.0
|
||||
var fileTransferSyncIntervalSeconds: TimeInterval = 60.0
|
||||
var messageSyncIntervalSeconds: TimeInterval = 15.0
|
||||
// Board posts are few but long-lived (days, until each post's own
|
||||
// expiry), so they get a slow round with their own capacity instead
|
||||
// of competing with the 15-minute message window.
|
||||
var boardCapacity: Int = 200
|
||||
var boardSyncIntervalSeconds: TimeInterval = 60.0
|
||||
var responseRateLimitMaxResponses: Int = 8
|
||||
var responseRateLimitWindowSeconds: TimeInterval = 30.0
|
||||
// Prekey bundles: one per peer, own sync round, long freshness so
|
||||
// bundles persist mesh-wide while their owners are offline.
|
||||
var prekeyBundleCapacity: Int = 200
|
||||
var prekeyBundleSyncIntervalSeconds: TimeInterval = 60.0
|
||||
var prekeyBundleMaxAgeSeconds: TimeInterval = 24 * 60 * 60
|
||||
}
|
||||
|
||||
private let myPeerID: PeerID
|
||||
private let config: Config
|
||||
private let requestSyncManager: RequestSyncManager
|
||||
private let archive: GossipMessageArchive?
|
||||
weak var delegate: Delegate?
|
||||
|
||||
/// Source of raw signed board packets (posts + tombstones). The board
|
||||
/// store is the single owner of board retention (expiry, tombstones,
|
||||
/// caps, persistence), so sync rounds query it instead of keeping a
|
||||
/// second copy here. Must be thread-safe; set before `start()`.
|
||||
var boardPacketsProvider: (() -> [BitchatPacket])?
|
||||
|
||||
// Storage: broadcast packets by type, and latest announce per sender
|
||||
private var messages = PacketStore()
|
||||
private var fragments = PacketStore()
|
||||
private var fileTransfers = PacketStore()
|
||||
private var latestAnnouncementByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
|
||||
private var groupMessages = PacketStore()
|
||||
private var latestAnnouncementByPeer: [PeerID: BitchatPacket] = [:]
|
||||
// Latest verified prekey bundle per owner. Unlike announces, bundles are
|
||||
// NOT dropped on leave/stale peer: their whole purpose is reaching a
|
||||
// sender while the owner is away.
|
||||
private var latestPrekeyBundleByPeer: [PeerID: (id: String, packet: BitchatPacket)] = [:]
|
||||
private var archiveDirty = false
|
||||
|
||||
// Timer
|
||||
private var periodicTimer: DispatchSourceTimer?
|
||||
private let queue = DispatchQueue(label: "mesh.sync", qos: .utility)
|
||||
private var lastStalePeerCleanup: Date = .distantPast
|
||||
private var syncSchedules: [SyncSchedule] = []
|
||||
private var responseRateLimiter: SyncResponseRateLimiter
|
||||
|
||||
init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) {
|
||||
init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager, archive: GossipMessageArchive? = nil) {
|
||||
self.myPeerID = myPeerID
|
||||
self.config = config
|
||||
self.requestSyncManager = requestSyncManager
|
||||
self.archive = archive
|
||||
self.responseRateLimiter = SyncResponseRateLimiter(
|
||||
maxResponses: config.responseRateLimitMaxResponses,
|
||||
window: config.responseRateLimitWindowSeconds
|
||||
)
|
||||
var schedules: [SyncSchedule] = []
|
||||
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
|
||||
// Group messages ride the public-message cadence; old clients
|
||||
// ignore the extended bit and answer with announces/messages only.
|
||||
var messageTypes: SyncTypeFlags = .publicMessages
|
||||
if config.groupMessageCapacity > 0 {
|
||||
messageTypes.formUnion(.groupMessage)
|
||||
}
|
||||
schedules.append(SyncSchedule(types: messageTypes, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
|
||||
}
|
||||
if config.fragmentCapacity > 0 && config.fragmentSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .fragment, interval: config.fragmentSyncIntervalSeconds, lastSent: .distantPast))
|
||||
@@ -106,7 +147,19 @@ final class GossipSyncManager {
|
||||
if config.fileTransferCapacity > 0 && config.fileTransferSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .fileTransfer, interval: config.fileTransferSyncIntervalSeconds, lastSent: .distantPast))
|
||||
}
|
||||
if config.prekeyBundleCapacity > 0 && config.prekeyBundleSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .prekeyBundle, interval: config.prekeyBundleSyncIntervalSeconds, lastSent: .distantPast))
|
||||
}
|
||||
if config.boardCapacity > 0 && config.boardSyncIntervalSeconds > 0 {
|
||||
schedules.append(SyncSchedule(types: .board, interval: config.boardSyncIntervalSeconds, lastSent: .distantPast))
|
||||
}
|
||||
syncSchedules = schedules
|
||||
|
||||
if archive != nil {
|
||||
queue.async { [weak self] in
|
||||
self?.restoreArchivedMessages()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func start() {
|
||||
@@ -130,12 +183,21 @@ final class GossipSyncManager {
|
||||
guard let self = self else { return }
|
||||
|
||||
var types: SyncTypeFlags = .publicMessages
|
||||
if self.config.groupMessageCapacity > 0 {
|
||||
types.formUnion(.groupMessage)
|
||||
}
|
||||
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
|
||||
types.formUnion(.fragment)
|
||||
}
|
||||
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
|
||||
types.formUnion(.fileTransfer)
|
||||
}
|
||||
if self.config.prekeyBundleCapacity > 0 && self.config.prekeyBundleSyncIntervalSeconds > 0 {
|
||||
types.formUnion(.prekeyBundle)
|
||||
}
|
||||
if self.config.boardCapacity > 0 && self.config.boardSyncIntervalSeconds > 0 && self.boardPacketsProvider != nil {
|
||||
types.formUnion(.board)
|
||||
}
|
||||
self.sendRequestSync(to: peerID, types: types)
|
||||
}
|
||||
}
|
||||
@@ -146,10 +208,23 @@ final class GossipSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to check if a packet is within the age threshold
|
||||
// Helper to check if a packet is within the age threshold. Whole public
|
||||
// messages get the long town-crier window; fragments, file transfers and
|
||||
// announces keep the short one.
|
||||
private func isPacketFresh(_ packet: BitchatPacket) -> Bool {
|
||||
let maxAgeSeconds: TimeInterval
|
||||
switch packet.type {
|
||||
// Group messages share the whole-message window: members off the mesh
|
||||
// for a while should backfill their crew's history like public chat.
|
||||
case MessageType.message.rawValue, MessageType.groupMessage.rawValue:
|
||||
maxAgeSeconds = config.publicMessageMaxAgeSeconds
|
||||
case MessageType.prekeyBundle.rawValue:
|
||||
maxAgeSeconds = config.prekeyBundleMaxAgeSeconds
|
||||
default:
|
||||
maxAgeSeconds = config.maxMessageAgeSeconds
|
||||
}
|
||||
let nowMs = UInt64(Date().timeIntervalSince1970 * 1000)
|
||||
let ageThresholdMs = UInt64(config.maxMessageAgeSeconds * 1000)
|
||||
let ageThresholdMs = UInt64(maxAgeSeconds * 1000)
|
||||
|
||||
// If current time is less than threshold, accept all (handle clock issues gracefully)
|
||||
guard nowMs >= ageThresholdMs else { return true }
|
||||
@@ -182,14 +257,14 @@ final class GossipSyncManager {
|
||||
removeState(for: sender)
|
||||
return
|
||||
}
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
let sender = PeerID(hexData: packet.senderID)
|
||||
latestAnnouncementByPeer[sender] = (id: idHex, packet: packet)
|
||||
latestAnnouncementByPeer[sender] = packet
|
||||
case .message:
|
||||
guard isBroadcastRecipient else { return }
|
||||
guard isPacketFresh(packet) else { return }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
|
||||
archiveDirty = true
|
||||
case .fragment:
|
||||
guard isBroadcastRecipient else { return }
|
||||
guard isPacketFresh(packet) else { return }
|
||||
@@ -200,6 +275,36 @@ final class GossipSyncManager {
|
||||
guard isPacketFresh(packet) else { return }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
fileTransfers.insert(idHex: idHex, packet: packet, capacity: max(1, config.fileTransferCapacity))
|
||||
case .prekeyBundle:
|
||||
// Callers only feed verified bundles here (own bundles at send
|
||||
// time, peers' after signature verification), so gossip never
|
||||
// spreads a bundle this node couldn't attribute.
|
||||
guard isBroadcastRecipient else { return }
|
||||
guard isPacketFresh(packet) else { return }
|
||||
// Key by the bundle's authenticated identity (its noise static key),
|
||||
// NOT the unauthenticated packet senderID. Otherwise one valid
|
||||
// bundle re-broadcast under many fabricated sender IDs would create
|
||||
// one cache entry each and exhaust the per-owner cap, starving
|
||||
// legitimate bundles. One owner ⇒ at most one entry.
|
||||
guard let bundle = PrekeyBundle.decode(packet.payload) else { return }
|
||||
let owner = PeerID(publicKey: bundle.noiseStaticPublicKey)
|
||||
if let existing = latestPrekeyBundleByPeer[owner],
|
||||
existing.packet.timestamp >= packet.timestamp {
|
||||
return
|
||||
}
|
||||
// Bounded owner count; replacing a known owner's bundle is always
|
||||
// allowed so the cap can't block refreshes.
|
||||
guard latestPrekeyBundleByPeer[owner] != nil
|
||||
|| latestPrekeyBundleByPeer.count < max(1, config.prekeyBundleCapacity) else { return }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
latestPrekeyBundleByPeer[owner] = (id: idHex, packet: packet)
|
||||
case .groupMessage:
|
||||
// Opaque ciphertext to non-members; carried and served like any
|
||||
// other broadcast so members get backfill from any relay.
|
||||
guard isBroadcastRecipient else { return }
|
||||
guard isPacketFresh(packet) else { return }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
groupMessages.insert(idHex: idHex, packet: packet, capacity: max(1, config.groupMessageCapacity))
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -208,7 +313,6 @@ final class GossipSyncManager {
|
||||
private func sendPeriodicSync(for types: SyncTypeFlags) {
|
||||
// Unicast sync to connected peers to allow RSR attribution
|
||||
if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty {
|
||||
SecureLogger.debug("Sending periodic sync to \(connectedPeers.count) connected peers", category: .sync)
|
||||
for peerID in connectedPeers {
|
||||
sendRequestSync(to: peerID, types: types)
|
||||
}
|
||||
@@ -233,11 +337,29 @@ final class GossipSyncManager {
|
||||
delegate?.sendPacket(signed)
|
||||
}
|
||||
|
||||
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
|
||||
/// Targeted fragment recovery: ask connected peers for the specific
|
||||
/// fragment streams whose reassembly has stalled, instead of waiting on
|
||||
/// the next periodic GCS fragment round to cover them.
|
||||
func requestMissingFragments(fragmentIDs: [Data]) {
|
||||
queue.async { [weak self] in
|
||||
self?._requestMissingFragments(fragmentIDs)
|
||||
}
|
||||
}
|
||||
|
||||
private func _requestMissingFragments(_ fragmentIDs: [Data]) {
|
||||
guard let filter = RequestSyncPacket.encodeFragmentIdFilter(fragmentIDs) else { return }
|
||||
guard let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty else { return }
|
||||
SecureLogger.info("[ROUTE] targeted-resync \(fragmentIDs.count) stalled fragment stream(s) from \(connectedPeers.count) peer(s)", category: .mesh)
|
||||
for peerID in connectedPeers {
|
||||
sendRequestSync(to: peerID, types: .fragment, fragmentIdFilter: filter)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags, fragmentIdFilter: String? = nil) {
|
||||
// Register the request for RSR validation
|
||||
requestSyncManager.registerRequest(to: peerID)
|
||||
|
||||
let payload = buildGcsPayload(for: types)
|
||||
|
||||
let payload = buildGcsPayload(for: types, fragmentIdFilter: fragmentIdFilter)
|
||||
var recipient = Data()
|
||||
var temp = peerID.id
|
||||
while temp.count >= 2 && recipient.count < 8 {
|
||||
@@ -265,7 +387,20 @@ final class GossipSyncManager {
|
||||
}
|
||||
|
||||
private func _handleRequestSync(from peerID: PeerID, request: RequestSyncPacket) {
|
||||
// A response can replay the whole store, so bound how often one peer
|
||||
// can trigger a diff pass regardless of how fast it asks.
|
||||
guard responseRateLimiter.shouldRespond(to: peerID, now: Date()) else {
|
||||
SecureLogger.warning("[SYNC] rate-limited REQUEST_SYNC from \(peerID.id.prefix(8))…", category: .sync)
|
||||
return
|
||||
}
|
||||
let requestedTypes = (request.types ?? .publicMessages)
|
||||
// The requester's filter only covers packets at or after this cursor;
|
||||
// older packets are outside the filter but not missing, and without
|
||||
// the cursor they would be re-sent every round.
|
||||
let since = request.sinceTimestamp
|
||||
// One concise per-request summary (RSR is already rate-limited per
|
||||
// peer), never per-packet spam.
|
||||
var servedCount = 0
|
||||
// Decode GCS into sorted set and prepare membership checker
|
||||
let sorted = GCSFilter.decodeToSortedSet(p: request.p, m: request.m, data: request.data)
|
||||
func mightContain(_ id: Data) -> Bool {
|
||||
@@ -273,8 +408,84 @@ final class GossipSyncManager {
|
||||
return GCSFilter.contains(sortedValues: sorted, candidate: bucket)
|
||||
}
|
||||
|
||||
// Announces are exempt from the since-cursor: they carry the signing
|
||||
// keys needed to verify everything else, and there is at most one per
|
||||
// peer, so the resend cost is negligible.
|
||||
if requestedTypes.contains(.announce) {
|
||||
for (_, pair) in latestAnnouncementByPeer {
|
||||
for (_, pkt) in latestAnnouncementByPeer {
|
||||
guard isPacketFresh(pkt) else { continue }
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
servedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if requestedTypes.contains(.message) {
|
||||
let toSendMsgs = messages.allPackets(isFresh: isPacketFresh)
|
||||
for pkt in toSendMsgs {
|
||||
if let since, pkt.timestamp < since { continue }
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
servedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if requestedTypes.contains(.fragment) {
|
||||
// A fragment-ID filter narrows the diff to exactly the named
|
||||
// fragment streams (targeted resync for stalled reassemblies)
|
||||
// and bypasses the since-cursor for them; the GCS filter still
|
||||
// excludes the pieces the requester already holds. Fragment
|
||||
// payloads start with the 8-byte stream ID.
|
||||
let fragmentIdFilter = RequestSyncPacket.decodeFragmentIdFilter(request.fragmentIdFilter)
|
||||
let frags = fragments.allPackets(isFresh: isPacketFresh)
|
||||
for pkt in frags {
|
||||
if let fragmentIdFilter {
|
||||
guard fragmentIdFilter.contains(Data(pkt.payload.prefix(8))) else { continue }
|
||||
} else if let since, pkt.timestamp < since {
|
||||
continue
|
||||
}
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
servedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if requestedTypes.contains(.fileTransfer) {
|
||||
let files = fileTransfers.allPackets(isFresh: isPacketFresh)
|
||||
for pkt in files {
|
||||
if let since, pkt.timestamp < since { continue }
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
servedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Like announces, prekey bundles are exempt from the since-cursor:
|
||||
// there is at most one per owner (newer replaces older), so the
|
||||
// resend cost is bounded and a joining peer must be able to learn
|
||||
// bundles generated long before it arrived.
|
||||
if requestedTypes.contains(.prekeyBundle) {
|
||||
for (_, pair) in latestPrekeyBundleByPeer {
|
||||
let (idHex, pkt) = pair
|
||||
guard isPacketFresh(pkt) else { continue }
|
||||
let idBytes = Data(hexString: idHex) ?? Data()
|
||||
@@ -283,56 +494,54 @@ final class GossipSyncManager {
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
servedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if requestedTypes.contains(.message) {
|
||||
let toSendMsgs = messages.allPackets(isFresh: isPacketFresh)
|
||||
for pkt in toSendMsgs {
|
||||
if requestedTypes.contains(.groupMessage) {
|
||||
let groupPkts = groupMessages.allPackets(isFresh: isPacketFresh)
|
||||
for pkt in groupPkts {
|
||||
if let since, pkt.timestamp < since { continue }
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
servedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if requestedTypes.contains(.fragment) {
|
||||
let frags = fragments.allPackets(isFresh: isPacketFresh)
|
||||
for pkt in frags {
|
||||
if requestedTypes.contains(.boardPost) {
|
||||
// The board store already filters to live posts and tombstones;
|
||||
// no freshness window applies (posts sync until their own expiry).
|
||||
let boardPackets = boardPacketsProvider?() ?? []
|
||||
for pkt in boardPackets {
|
||||
if let since, pkt.timestamp < since { continue }
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
servedCount += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if requestedTypes.contains(.fileTransfer) {
|
||||
let files = fileTransfers.allPackets(isFresh: isPacketFresh)
|
||||
for pkt in files {
|
||||
let idBytes = PacketIdUtil.computeId(pkt)
|
||||
if !mightContain(idBytes) {
|
||||
var toSend = pkt
|
||||
toSend.ttl = 0
|
||||
toSend.isRSR = true // Mark as solicited response
|
||||
delegate?.sendPacket(to: peerID, packet: toSend)
|
||||
}
|
||||
}
|
||||
if servedCount > 0 {
|
||||
SecureLogger.info("[SYNC] served \(servedCount) packet(s) [\(requestedTypes.debugShortLabel)] to \(peerID.id.prefix(8))…", category: .sync)
|
||||
}
|
||||
}
|
||||
|
||||
// Build REQUEST_SYNC payload using current candidates and GCS params
|
||||
private func buildGcsPayload(for types: SyncTypeFlags) -> Data {
|
||||
private func buildGcsPayload(for types: SyncTypeFlags, fragmentIdFilter: String? = nil) -> Data {
|
||||
var candidates: [BitchatPacket] = []
|
||||
if types.contains(.announce) {
|
||||
for (_, pair) in latestAnnouncementByPeer where isPacketFresh(pair.packet) {
|
||||
candidates.append(pair.packet)
|
||||
for (_, pkt) in latestAnnouncementByPeer where isPacketFresh(pkt) {
|
||||
candidates.append(pkt)
|
||||
}
|
||||
}
|
||||
if types.contains(.message) {
|
||||
@@ -344,9 +553,20 @@ final class GossipSyncManager {
|
||||
if types.contains(.fileTransfer) {
|
||||
candidates.append(contentsOf: fileTransfers.allPackets(isFresh: isPacketFresh))
|
||||
}
|
||||
if types.contains(.prekeyBundle) {
|
||||
for (_, pair) in latestPrekeyBundleByPeer where isPacketFresh(pair.packet) {
|
||||
candidates.append(pair.packet)
|
||||
}
|
||||
}
|
||||
if types.contains(.groupMessage) {
|
||||
candidates.append(contentsOf: groupMessages.allPackets(isFresh: isPacketFresh))
|
||||
}
|
||||
if types.contains(.boardPost) {
|
||||
candidates.append(contentsOf: boardPacketsProvider?() ?? [])
|
||||
}
|
||||
if candidates.isEmpty {
|
||||
let p = GCSFilter.deriveP(targetFpr: config.gcsTargetFpr)
|
||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types, fragmentIdFilter: fragmentIdFilter)
|
||||
return req.encode()
|
||||
}
|
||||
|
||||
@@ -360,48 +580,119 @@ final class GossipSyncManager {
|
||||
cap = max(1, config.fragmentCapacity)
|
||||
} else if types == .fileTransfer {
|
||||
cap = max(1, config.fileTransferCapacity)
|
||||
} else if types == .prekeyBundle {
|
||||
cap = max(1, config.prekeyBundleCapacity)
|
||||
} else if types == .board {
|
||||
cap = max(1, config.boardCapacity)
|
||||
} else {
|
||||
cap = max(1, config.seenCapacity)
|
||||
}
|
||||
let takeN = min(candidates.count, min(nMax, cap))
|
||||
if takeN <= 0 {
|
||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types)
|
||||
let req = RequestSyncPacket(p: p, m: 1, data: Data(), types: types, fragmentIdFilter: fragmentIdFilter)
|
||||
return req.encode()
|
||||
}
|
||||
let ids: [Data] = candidates.prefix(takeN).map { PacketIdUtil.computeId($0) }
|
||||
let included = Array(candidates.prefix(takeN))
|
||||
let ids: [Data] = included.map { PacketIdUtil.computeId($0) }
|
||||
let params = GCSFilter.buildFilter(ids: ids, maxBytes: config.gcsMaxBytes, targetFpr: config.gcsTargetFpr)
|
||||
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types)
|
||||
// When the filter can't cover every candidate — either the store
|
||||
// exceeds `takeN` or the encoder trimmed the tail to fit the byte
|
||||
// budget — tell the responder how far back the filter actually
|
||||
// reaches. `includedCount` counts inputs in newest-first order, so the
|
||||
// covered set is a contiguous newest-prefix and the oldest included
|
||||
// timestamp is an exact cursor. Packets older than it are outside the
|
||||
// filter but not missing; without the cursor the responder would
|
||||
// re-send that entire tail every round.
|
||||
let covered = params.includedCount
|
||||
let sinceTimestamp: UInt64? = (covered < candidates.count && covered > 0)
|
||||
? included[covered - 1].timestamp
|
||||
: nil
|
||||
let req = RequestSyncPacket(p: params.p, m: params.m, data: params.data, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
|
||||
return req.encode()
|
||||
}
|
||||
|
||||
// Periodic cleanup of expired messages and announcements
|
||||
private func cleanupExpiredMessages() {
|
||||
// Remove expired announcements
|
||||
latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pair in
|
||||
isPacketFresh(pair.packet)
|
||||
latestAnnouncementByPeer = latestAnnouncementByPeer.filter { _, pkt in
|
||||
isPacketFresh(pkt)
|
||||
}
|
||||
|
||||
let messageCountBefore = messages.packets.count
|
||||
messages.removeExpired(isFresh: isPacketFresh)
|
||||
if messages.packets.count != messageCountBefore {
|
||||
archiveDirty = true
|
||||
}
|
||||
fragments.removeExpired(isFresh: isPacketFresh)
|
||||
fileTransfers.removeExpired(isFresh: isPacketFresh)
|
||||
latestPrekeyBundleByPeer = latestPrekeyBundleByPeer.filter { _, pair in
|
||||
isPacketFresh(pair.packet)
|
||||
}
|
||||
groupMessages.removeExpired(isFresh: isPacketFresh)
|
||||
}
|
||||
|
||||
// MARK: - Archive (public message persistence)
|
||||
|
||||
/// Rebuild the public message store from disk on launch, dropping
|
||||
/// anything that aged out while the app was dead.
|
||||
private func restoreArchivedMessages() {
|
||||
guard let archive else { return }
|
||||
var restored = 0
|
||||
for data in archive.load() {
|
||||
guard let packet = BitchatPacket.from(data),
|
||||
packet.type == MessageType.message.rawValue,
|
||||
isPacketFresh(packet) else { continue }
|
||||
let idHex = PacketIdUtil.computeId(packet).hexEncodedString()
|
||||
messages.insert(idHex: idHex, packet: packet, capacity: max(1, config.seenCapacity))
|
||||
restored += 1
|
||||
}
|
||||
if restored > 0 {
|
||||
SecureLogger.debug("Restored \(restored) archived public message(s) for gossip sync", category: .sync)
|
||||
archiveDirty = true
|
||||
}
|
||||
}
|
||||
|
||||
private func persistArchiveIfDirty() {
|
||||
guard archiveDirty, let archive else { return }
|
||||
archiveDirty = false
|
||||
let packets = messages.allPackets(isFresh: isPacketFresh)
|
||||
.compactMap { $0.toBinaryData(padding: false) }
|
||||
archive.save(packets)
|
||||
}
|
||||
|
||||
/// Flush the archive outside the maintenance cadence (app backgrounding).
|
||||
func persistNow() {
|
||||
queue.async { [weak self] in
|
||||
self?.persistArchiveIfDirty()
|
||||
}
|
||||
}
|
||||
|
||||
private func performPeriodicMaintenance(now: Date = Date()) {
|
||||
cleanupExpiredMessages()
|
||||
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||
persistArchiveIfDirty()
|
||||
requestSyncManager.cleanup() // Cleanup expired sync requests
|
||||
responseRateLimiter.prune(now: now)
|
||||
|
||||
var dueTypes: SyncTypeFlags = []
|
||||
// One request per due schedule rather than a union filter: each type
|
||||
// group gets the full GCS capacity and its own since-cursor, so heavy
|
||||
// fragment traffic can't crowd messages out of the filter.
|
||||
var dueLabels: [String] = []
|
||||
for index in syncSchedules.indices {
|
||||
guard syncSchedules[index].interval > 0 else { continue }
|
||||
// No board source wired up means nothing to offer or store;
|
||||
// skip the round entirely.
|
||||
if syncSchedules[index].types == .board && boardPacketsProvider == nil { continue }
|
||||
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
||||
syncSchedules[index].lastSent = now
|
||||
dueTypes.formUnion(syncSchedules[index].types)
|
||||
sendPeriodicSync(for: syncSchedules[index].types)
|
||||
dueLabels.append(syncSchedules[index].types.debugShortLabel)
|
||||
}
|
||||
}
|
||||
|
||||
if !dueTypes.isEmpty {
|
||||
sendPeriodicSync(for: dueTypes)
|
||||
// One concise summary per maintenance cycle, not per packet.
|
||||
if !dueLabels.isEmpty {
|
||||
let peerCount = delegate?.getConnectedPeers().count ?? 0
|
||||
SecureLogger.info("[SYNC] cycle: request [\(dueLabels.joined(separator: " "))] to \(peerCount) peer(s)", category: .sync)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,8 +709,8 @@ final class GossipSyncManager {
|
||||
let nowMs = UInt64(now.timeIntervalSince1970 * 1000)
|
||||
guard nowMs >= timeoutMs else { return }
|
||||
let cutoff = nowMs - timeoutMs
|
||||
let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pair in
|
||||
pair.packet.timestamp < cutoff ? peerID : nil
|
||||
let stalePeerIDs = latestAnnouncementByPeer.compactMap { peerID, pkt in
|
||||
pkt.timestamp < cutoff ? peerID : nil
|
||||
}
|
||||
guard !stalePeerIDs.isEmpty else { return }
|
||||
for peerKey in stalePeerIDs {
|
||||
@@ -435,10 +726,17 @@ final class GossipSyncManager {
|
||||
}
|
||||
|
||||
private func removeState(for peerID: PeerID) {
|
||||
// Deliberately keeps the peer's prekey bundle: bundles exist to reach
|
||||
// owners who left the mesh, and they age out on their own schedule.
|
||||
_ = latestAnnouncementByPeer.removeValue(forKey: peerID)
|
||||
let messageCountBefore = messages.packets.count
|
||||
messages.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
if messages.packets.count != messageCountBefore {
|
||||
archiveDirty = true
|
||||
}
|
||||
fragments.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
fileTransfers.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
groupMessages.remove { PeerID(hexData: $0.senderID) == peerID }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,6 +754,12 @@ extension GossipSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
func _hasPrekeyBundle(for peerID: PeerID) -> Bool {
|
||||
queue.sync {
|
||||
latestPrekeyBundleByPeer[peerID] != nil
|
||||
}
|
||||
}
|
||||
|
||||
func _messageCount(for peerID: PeerID) -> Int {
|
||||
queue.sync {
|
||||
messages.allPackets { _ in true }.filter { PeerID(hexData: $0.senderID) == peerID }.count
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Sliding-window limiter for REQUEST_SYNC responses.
|
||||
///
|
||||
/// A single sync response can replay the entire gossip store, so a peer that
|
||||
/// requests in a tight loop must not be able to drain the airtime and battery
|
||||
/// of everyone in radio range. Legitimate peers send at most a few requests
|
||||
/// per maintenance tick (one per type schedule, plus the initial sync).
|
||||
struct SyncResponseRateLimiter {
|
||||
private let maxResponses: Int
|
||||
private let window: TimeInterval
|
||||
private var history: [PeerID: [Date]] = [:]
|
||||
|
||||
init(maxResponses: Int, window: TimeInterval) {
|
||||
self.maxResponses = max(1, maxResponses)
|
||||
self.window = max(0, window)
|
||||
}
|
||||
|
||||
/// Returns true (and records the response) if the peer is under its
|
||||
/// response budget for the current window.
|
||||
mutating func shouldRespond(to peerID: PeerID, now: Date) -> Bool {
|
||||
let cutoff = now.addingTimeInterval(-window)
|
||||
var recent = (history[peerID] ?? []).filter { $0 >= cutoff }
|
||||
guard recent.count < maxResponses else {
|
||||
history[peerID] = recent
|
||||
return false
|
||||
}
|
||||
recent.append(now)
|
||||
history[peerID] = recent
|
||||
return true
|
||||
}
|
||||
|
||||
/// Drops history outside the window so departed peers don't accumulate.
|
||||
mutating func prune(now: Date) {
|
||||
let cutoff = now.addingTimeInterval(-window)
|
||||
history = history.compactMapValues { dates in
|
||||
let recent = dates.filter { $0 >= cutoff }
|
||||
return recent.isEmpty ? nil : recent
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,25 @@ struct SyncTypeFlags: OptionSet {
|
||||
let rawValue: UInt64
|
||||
|
||||
init(rawValue: UInt64) {
|
||||
self.rawValue = rawValue & 0x00FF_FFFF_FFFF_FFFF // Trim to max 8 bytes
|
||||
// Drop any bit that doesn't map to a known message type. Wire data can
|
||||
// carry up to 8 bytes of flags; without this mask, bits with no type
|
||||
// (a truncated/garbled field, or a type a newer peer added) would live
|
||||
// in the set as phantom membership that no `contains` check matches and
|
||||
// `toData` re-serializes — a meaningless "accepted but does nothing"
|
||||
// state. Masking here keeps every instance normalized at the source.
|
||||
self.rawValue = rawValue & SyncTypeFlags.knownTypeMask
|
||||
}
|
||||
|
||||
/// Union of every bit that maps to a message type. Derived from the
|
||||
/// bit↔type table so it tracks automatically when a type is added.
|
||||
private static let knownTypeMask: UInt64 = {
|
||||
var mask: UInt64 = 0
|
||||
for bit in 0..<64 where SyncTypeFlags.type(forBit: bit) != nil {
|
||||
mask |= (1 << UInt64(bit))
|
||||
}
|
||||
return mask
|
||||
}()
|
||||
|
||||
private static func bitIndex(for type: MessageType) -> Int? {
|
||||
switch type {
|
||||
case .announce: return 0
|
||||
@@ -20,6 +36,29 @@ struct SyncTypeFlags: OptionSet {
|
||||
case .fragment: return 5
|
||||
case .requestSync: return 6
|
||||
case .fileTransfer: return 7
|
||||
// Extended bits are compat-safe by construction: `toData()` encodes
|
||||
// the bitfield little-endian with trailing zero bytes trimmed (bit 10
|
||||
// widens the wire form from 1 to 2 bytes inside the length-prefixed
|
||||
// REQUEST_SYNC TLV 0x04), and `decode(_:)` accepts 1...8 bytes while
|
||||
// `type(forBit:)` maps unknown bits to nil — so old clients simply
|
||||
// ignore the group bit and answer with the types they know.
|
||||
case .boardPost: return 8
|
||||
case .groupMessage: return 10
|
||||
// Courier envelopes are directed deposits between trusted peers and
|
||||
// must never spread via gossip sync.
|
||||
case .courierEnvelope: return nil
|
||||
// The bitfield is a wire-tolerant little-endian UInt64 (1-8 bytes,
|
||||
// unknown high bits ignored by `type(forBit:)`), so bits 8+ need no
|
||||
// format change: old clients decode the wider flags and simply never
|
||||
// match the new bits.
|
||||
case .prekeyBundle: return 9
|
||||
// Gateway carriers are ephemeral live traffic (uplinks are directed,
|
||||
// downlinks are rate-budgeted rebroadcasts); replaying them via sync
|
||||
// would waste airtime and extend their lifetime.
|
||||
case .nostrCarrier: return nil
|
||||
// Ping/pong are ephemeral directed probes; replaying them via gossip
|
||||
// sync would only produce stale, unanswerable echoes.
|
||||
case .ping, .pong: return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +72,12 @@ struct SyncTypeFlags: OptionSet {
|
||||
case 5: return .fragment
|
||||
case 6: return .requestSync
|
||||
case 7: return .fileTransfer
|
||||
// Bit 8 spills the encoded bitfield into a second byte. Decoders since
|
||||
// type-aware sync (#853) accept 1-8 bytes and map unknown bits to no
|
||||
// known type, so old clients ignore board rounds instead of choking.
|
||||
case 8: return .boardPost
|
||||
case 9: return .prekeyBundle
|
||||
case 10: return .groupMessage
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
@@ -42,6 +87,9 @@ struct SyncTypeFlags: OptionSet {
|
||||
static let message = SyncTypeFlags(messageTypes: [.message])
|
||||
static let fragment = SyncTypeFlags(messageTypes: [.fragment])
|
||||
static let fileTransfer = SyncTypeFlags(messageTypes: [.fileTransfer])
|
||||
static let board = SyncTypeFlags(messageTypes: [.boardPost])
|
||||
static let prekeyBundle = SyncTypeFlags(messageTypes: [.prekeyBundle])
|
||||
static let groupMessage = SyncTypeFlags(messageTypes: [.groupMessage])
|
||||
|
||||
static let publicMessages = SyncTypeFlags(messageTypes: [.announce, .message])
|
||||
|
||||
@@ -102,4 +150,19 @@ struct SyncTypeFlags: OptionSet {
|
||||
}
|
||||
return SyncTypeFlags(rawValue: raw)
|
||||
}
|
||||
|
||||
/// Compact human label for the `[SYNC]` observability logs, e.g. "msg,frag,pre".
|
||||
/// (Referenced only from DEBUG log lines, but kept unconditional so the
|
||||
/// log-call arguments compile in release too, where the log itself no-ops.)
|
||||
var debugShortLabel: String {
|
||||
var parts: [String] = []
|
||||
if contains(.announce) { parts.append("ann") }
|
||||
if contains(.message) { parts.append("msg") }
|
||||
if contains(.fragment) { parts.append("frag") }
|
||||
if contains(.fileTransfer) { parts.append("file") }
|
||||
if contains(.prekeyBundle) { parts.append("pre") }
|
||||
if contains(.groupMessage) { parts.append("grp") }
|
||||
if contains(.boardPost) { parts.append("board") }
|
||||
return parts.isEmpty ? "none" : parts.joined(separator: ",")
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user