Compare commits

..
10 Commits
Author SHA1 Message Date
jackandClaude Fable 5 02828731b9 Drop couriered mail from blocked senders at envelope open
The UI-layer block check (isPeerBlocked in the transport event
coordinator) resolves a fingerprint from the live session or peer list,
but a couriered message arrives precisely when its sender is absent —
no session, no registry entry — so the check failed open and a blocked
identity's mail was delivered anyway. Gate in openCourierEnvelope,
where the sealed sender's full static key is in hand.

End-to-end test ferries a full deposit→carry→handover round and
verifies the envelope from a blocked sender never reaches the delegate
(confirmed failing without the gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:34:46 +02:00
jackandClaude Fable 5 f5b2150f11 Merge branch 'main' into feat/courier
Resolve DeliveryStatusView conflict: adopt main's bitchatDescription
extension (tooltip + VoiceOver via body modifiers) and fold the
.carried case into it, dropping the branch's Strings enum.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:27:17 +02:00
jackandGitHub b28fb26504 Merge branch 'main' into feat/courier 2026-07-01 23:52:52 +02:00
jackandGitHub ba0861a446 Merge branch 'main' into feat/courier 2026-07-01 23:02:13 +02:00
jackandClaude Fable 5 424c47be82 Use Xcode-bundled Swift in CI instead of a standalone toolchain
The unpinned setup-swift action installs Swift 6.1, which refuses the
SDK on runner images that have rolled to Xcode 26.5 ("this SDK is not
supported by the compiler"). Jobs passed or failed depending on which
image they landed on. The Xcode-bundled toolchain always matches the
image's SDK, and matches local development. Cache keys now include the
toolchain version so artifacts from one compiler are never restored
into builds with another.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 22:54:03 +02:00
jackandClaude Fable 5 20c1c0f687 Gate courier handover on direct announces and isolate store test
Envelopes are removed from the courier store optimistically, so releasing
them on a relayed (multi-hop) announce risks losing carried mail to a
speculative flood that never reaches the recipient. Handover now also
requires the announce to have arrived directly (full TTL), i.e. an actual
encounter with a live link; regression test builds a relayed copy of a
genuinely signed announce (TTL is excluded from announce signatures).

Also make CourierStore's on-disk location injectable so the persistence
test round-trips through a temp directory instead of wiping the real
Application Support store, and reattach BLEAnnounceHandler's doc comment
to the class it describes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 22:48:10 +02:00
jack ebdcdf83dd Authenticate courier deposits by ingress peer 2026-06-19 17:39:00 +02:00
jack 2c04f65c83 Fix courier handoff verification and directed sends 2026-06-17 10:04:16 +02:00
jackandGitHub 293380e671 Merge branch 'main' into feat/courier 2026-06-17 10:03:07 +02:00
jackandClaude Fable 5 5aaa209020 Friend-courier store-and-forward: mutual favorites carry sealed messages to offline peers
When a private message has no reachable transport, the router now seals it
to the recipient's Noise static key (new one-way Noise X pattern) and hands
the envelope to up to three connected mutual favorites. Couriers store the
opaque ciphertext under strict quotas (20 total, 5 per depositor, 16 KiB,
24 h) and hand it over when the recipient's announce matches a rotating
HMAC recipient tag; the recipient opens it and the message flows through
the normal private-message pipeline, so dedup and delivery acks just work.

- CourierEnvelope TLV + courierEnvelope (0x04) message type in BitFoundation
- Noise X one-way pattern reusing the existing handshake machinery,
  domain-separated by a courier prologue; sender identity authenticated
  via the ss DH (no forward secrecy - documented tradeoff)
- CourierStore with eviction, file persistence, and panic-wipe integration
- Rotating recipient tags (HMAC over epoch day) so carried envelopes don't
  correlate for observers who don't already know the recipient's key
- New "carried" delivery status with figure.walk glyph; header indicator
  while carrying mail for others
- Three-node end-to-end test ferrying packets through real BLEService
  instances, plus codec/crypto/store/router suites (986 tests green)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 11:13:16 +02:00
425 changed files with 8823 additions and 108078 deletions
+23 -209
View File
@@ -1,228 +1,42 @@
name: Propose GeoRelay Data Update name: Fetch GeoRelays Data
on: on:
schedule: schedule:
- cron: "0 6 * * 0" - cron: '0 6 * * 0'
workflow_dispatch: workflow_dispatch:
# Default to read-only. The publishing job receives only the scopes required
# to push its branch and publish either a PR or a tracking issue.
permissions:
contents: read
concurrency:
group: georelay-data-update
cancel-in-progress: false
env:
SOURCE_REPOSITORY: https://github.com/permissionlesstech/georelays.git
UPDATE_BRANCH: automation/georelay-data
TRACKING_ISSUE_TITLE: GeoRelay update awaiting pull request
jobs:
propose-relay-data:
name: Validate and propose relay data
runs-on: ubuntu-latest
timeout-minutes: 10
permissions: permissions:
contents: write contents: write
pull-requests: write pull-requests: write
issues: write
jobs:
update-relay-data:
runs-on: ubuntu-latest
steps: steps:
- name: Checkout reviewed base - name: Checkout repository
# Pinned actions/checkout v5 so a mutable action tag cannot change the uses: actions/checkout@v4
# code that receives this job's write-capable token.
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
with: with:
ref: main token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 fetch-depth: 0
# Do not expose the write token to fetch/validation subprocesses.
persist-credentials: false
- name: Test GeoRelay validator - name: Fetch GeoRelays
run: | run: |
set -euo pipefail wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
python3 -m unittest discover -s scripts/tests -p "test_*.py" -v mv nostr_relays.csv ./relays/online_relays_gps.csv
- name: Fetch candidate over pinned HTTPS policy - name: Check for changes
id: upstream id: git-check
run: | run: |
set -euo pipefail git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
source_commit=$(git ls-remote --refs "$SOURCE_REPOSITORY" refs/heads/main | awk 'NR == 1 { print $1 }')
if [[ ! "$source_commit" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::Could not resolve an immutable upstream commit"
exit 1
fi
source_url="https://raw.githubusercontent.com/permissionlesstech/georelays/$source_commit/nostr_relays.csv"
effective_url=$(curl --fail --show-error --silent --location --proto "=https" --proto-redir "=https" --tlsv1.2 --max-time 60 --retry 3 --retry-all-errors --output "$RUNNER_TEMP/georelays-candidate.csv" --write-out "%{url_effective}" "$source_url")
if [[ "$effective_url" != "$source_url" ]]; then
echo "::error::Unexpected GeoRelay redirect target: $effective_url"
exit 1
fi
echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT"
echo "source_url=$source_url" >> "$GITHUB_OUTPUT"
- name: Validate candidate against reviewed baseline - name: Commit and push changes
id: validation if: steps.git-check.outputs.changes == 'true'
run: | run: |
set -euo pipefail git config --local user.email "action@github.com"
python3 scripts/validate_georelays.py --input "$RUNNER_TEMP/georelays-candidate.csv" --baseline relays/online_relays_gps.csv --output relays/online_relays_gps.csv --github-output "$GITHUB_OUTPUT" git config --local user.name "GitHub Action"
git add relays/online_relays_gps.csv
- name: Check for a reviewed-file change git commit -m "Automated update of relay data - $(date -u)"
id: changes git push
run: |
set -euo pipefail
if git diff --quiet -- relays/online_relays_gps.csv; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Upstream GeoRelay data already matches main." >> "$GITHUB_STEP_SUMMARY"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
git diff --stat -- relays/online_relays_gps.csv
fi
- name: Push automation branch and publish review request
if: steps.changes.outputs.changed == 'true'
env: env:
GH_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SOURCE_COMMIT: ${{ steps.upstream.outputs.source_commit }}
SOURCE_URL: ${{ steps.upstream.outputs.source_url }}
DATA_ROWS: ${{ steps.validation.outputs.data_rows }}
UNIQUE_RELAYS: ${{ steps.validation.outputs.unique_relays }}
DATA_SHA256: ${{ steps.validation.outputs.sha256 }}
run: |
set -euo pipefail
# Scope credential exposure to this final publishing step.
gh auth setup-git
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git switch -C "$UPDATE_BRANCH"
git add -- relays/online_relays_gps.csv
git diff --cached --quiet && {
echo "::error::Expected a staged GeoRelay data change"
exit 1
}
git commit -m "Update reviewed georelay directory" -m "Upstream-commit: $SOURCE_COMMIT"
remote_ref="refs/remotes/origin/$UPDATE_BRANCH"
if git fetch --no-tags origin "+refs/heads/$UPDATE_BRANCH:$remote_ref" 2>/dev/null; then
remote_sha=$(git rev-parse "$remote_ref")
git push --force-with-lease="refs/heads/$UPDATE_BRANCH:$remote_sha" origin "HEAD:refs/heads/$UPDATE_BRANCH"
else
git push origin "HEAD:refs/heads/$UPDATE_BRANCH"
fi
body_file="$RUNNER_TEMP/georelay-pr-body.md"
{
echo "## Automated GeoRelay data proposal"
echo
echo "- Source: $SOURCE_URL"
echo "- Upstream commit: $SOURCE_COMMIT"
echo "- Data rows: $DATA_ROWS"
echo "- Unique normalized relays: $UNIQUE_RELAYS"
echo "- SHA-256: $DATA_SHA256"
echo
echo "The candidate passed strict UTF-8, schema, size, row-count, secure-host, coordinate, duplicate-conflict, and baseline-delta validation."
echo
echo "This PR is intentionally not auto-merged. Review the relay additions/removals before merging."
} > "$body_file"
existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$UPDATE_BRANCH" --json number --jq '.[0].number // empty')
pr_error="$RUNNER_TEMP/georelay-pr-error.txt"
pr_url=""
if [[ -n "$existing_pr" ]]; then
if gh pr edit "$existing_pr" --repo "$GITHUB_REPOSITORY" --title "Update reviewed GeoRelay directory" --body-file "$body_file" 2> "$pr_error"; then
pr_url=$(gh pr view "$existing_pr" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
fi
else
if created_pr_url=$(gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$UPDATE_BRANCH" --title "Update reviewed GeoRelay directory" --body-file "$body_file" 2> "$pr_error"); then
pr_url="$created_pr_url"
fi
fi
tracking_issue_numbers=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$TRACKING_ISSUE_TITLE\" in:title" --limit 100 --json number,title --jq ".[] | select(.title == \"$TRACKING_ISSUE_TITLE\") | .number")
tracking_issues=()
if [[ -n "$tracking_issue_numbers" ]]; then
mapfile -t tracking_issues <<< "$tracking_issue_numbers"
fi
if [[ -n "$pr_url" ]]; then
for issue_number in "${tracking_issues[@]}"; do
gh issue close "$issue_number" --repo "$GITHUB_REPOSITORY" --comment "A pull request is now available at $pr_url; closing this fallback tracking issue."
done
echo "Published GeoRelay review PR: $pr_url" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
echo "::warning::GITHUB_TOKEN could not create or update the GeoRelay pull request; publishing the issues-write fallback."
if [[ -s "$pr_error" ]]; then
cat "$pr_error" >&2
fi
compare_url="https://github.com/${GITHUB_REPOSITORY}/compare/main...${UPDATE_BRANCH}?expand=1"
issue_body_file="$RUNNER_TEMP/georelay-tracking-issue-body.md"
{
echo "## Validated GeoRelay update awaiting review"
echo
echo "The automation branch was updated, but this workflow token could not create or update the pull request. Use the compare link below to create it manually."
echo
echo "- Compare and create PR: $compare_url"
echo "- Automation branch: $UPDATE_BRANCH"
echo "- Source: $SOURCE_URL"
echo "- Upstream commit: $SOURCE_COMMIT"
echo "- Data rows: $DATA_ROWS"
echo "- Unique normalized relays: $UNIQUE_RELAYS"
echo "- SHA-256: $DATA_SHA256"
echo
echo "The snapshot passed the repository's strict validator before the branch was pushed."
} > "$issue_body_file"
if (( ${#tracking_issues[@]} > 0 )); then
primary_issue="${tracking_issues[0]}"
gh issue edit "$primary_issue" --repo "$GITHUB_REPOSITORY" --title "$TRACKING_ISSUE_TITLE" --body-file "$issue_body_file"
issue_url=$(gh issue view "$primary_issue" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
for duplicate_issue in "${tracking_issues[@]:1}"; do
gh issue close "$duplicate_issue" --repo "$GITHUB_REPOSITORY" --comment "Closing duplicate GeoRelay automation tracking issue; #$primary_issue is canonical."
done
else
issue_url=$(gh issue create --repo "$GITHUB_REPOSITORY" --title "$TRACKING_ISSUE_TITLE" --body-file "$issue_body_file")
fi
# Do not claim success until the fallback issue was confirmed.
[[ -n "$issue_url" ]]
echo "Published GeoRelay tracking issue fallback: $issue_url" >> "$GITHUB_STEP_SUMMARY"
- name: Clean obsolete automation review state
if: steps.changes.outputs.changed == 'false'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh auth setup-git
existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$UPDATE_BRANCH" --json number --jq '.[0].number // empty')
if [[ -n "$existing_pr" ]]; then
gh pr close "$existing_pr" --repo "$GITHUB_REPOSITORY" --comment "Upstream now matches the reviewed file on main; closing this obsolete automation proposal."
echo "Closed obsolete PR #$existing_pr." >> "$GITHUB_STEP_SUMMARY"
fi
tracking_issue_numbers=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$TRACKING_ISSUE_TITLE\" in:title" --limit 100 --json number,title --jq ".[] | select(.title == \"$TRACKING_ISSUE_TITLE\") | .number")
if [[ -n "$tracking_issue_numbers" ]]; then
while IFS= read -r issue_number; do
gh issue close "$issue_number" --repo "$GITHUB_REPOSITORY" --comment "Upstream now matches the reviewed file on main; closing this obsolete automation tracker."
echo "Closed obsolete tracking issue #$issue_number." >> "$GITHUB_STEP_SUMMARY"
done <<< "$tracking_issue_numbers"
fi
if git ls-remote --exit-code --heads origin "refs/heads/$UPDATE_BRANCH" > /dev/null; then
git push origin --delete "$UPDATE_BRANCH"
echo "Deleted obsolete automation branch $UPDATE_BRANCH." >> "$GITHUB_STEP_SUMMARY"
else
ls_remote_status=$?
if (( ls_remote_status != 2 )); then
echo "::error::Could not inspect the obsolete automation branch"
exit "$ls_remote_status"
fi
fi
-30
View File
@@ -1,30 +0,0 @@
name: Dead Code
on:
push:
branches:
- main
pull_request:
jobs:
periphery:
name: Periphery scan
runs-on: macos-latest
timeout-minutes: 30
# Advisory, like SwiftLint (#1361): findings annotate the PR but don't
# block merges. Drop continue-on-error once the baseline proves stable.
continue-on-error: true
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Install Periphery
# homebrew-core formula; the peripheryapp tap lags years behind.
run: brew install periphery
- name: Scan for dead code
# Config comes from .periphery.yml; known findings (mostly iOS-only
# code invisible to a macOS scan) are suppressed by the committed
# baseline. --strict fails the step when NEW dead code appears.
run: periphery scan --strict --disable-update-check
+21 -87
View File
@@ -94,24 +94,6 @@ jobs:
kill "$watchdog_pid" 2>/dev/null || true kill "$watchdog_pid" 2>/dev/null || true
exit "$status" exit "$status"
# Read coverage before the serial benchmark command below rebuilds the
# test binary without instrumentation. Reporting against that newer
# binary makes llvm-cov reject the profile as out of date.
# Informational only: there is deliberately no percentage threshold, but
# a broken/missing report is a CI configuration error and must be visible.
- 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
echo "::error::Coverage profile or test binary is missing"
exit 1
fi
xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \
-ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)'
# Benchmarks run serially on an otherwise idle runner for stable # Benchmarks run serially on an otherwise idle runner for stable
# numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate. # numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate.
- name: Run performance benchmarks (serial) - name: Run performance benchmarks (serial)
@@ -133,10 +115,26 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
run: ./scripts/check-perf-floors.sh perf-output.log run: ./scripts/check-perf-floors.sh perf-output.log
# SPM tests do not link the shipping app targets. This job covers the # Informational only: surfaces per-file and total line coverage in the
# iOS-conditional paths and both universal Release link configurations. # 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: ios-build:
name: Build Release apps (universal) name: Build iOS app (simulator)
runs-on: macos-latest runs-on: macos-latest
timeout-minutes: 15 timeout-minutes: 15
@@ -144,82 +142,18 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Check clean recipe safety
run: bash scripts/check-just-clean-safety.sh
- name: Build iOS (simulator, no signing) - name: Build iOS (simulator, no signing)
# Build both simulator architectures so CI validates every vendored # arm64 only: the vendored arti.xcframework has no x86_64 simulator slice.
# Arti simulator slice and the configuration that ships.
run: | run: |
set -o pipefail set -o pipefail
xcodebuild -project bitchat.xcodeproj \ xcodebuild -project bitchat.xcodeproj \
-scheme "bitchat (iOS)" \ -scheme "bitchat (iOS)" \
-configuration Release \
-sdk iphonesimulator \ -sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \ -destination 'generic/platform=iOS Simulator' \
ARCHS='arm64 x86_64' \ ARCHS=arm64 \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_ALLOWED=NO \
build build
- name: Build macOS (universal, no signing)
run: |
set -o pipefail
xcodebuild -project bitchat.xcodeproj \
-scheme "bitchat (macOS)" \
-configuration Release \
-destination 'generic/platform=macOS' \
ARCHS='arm64 x86_64' \
ONLY_ACTIVE_ARCH=NO \
CODE_SIGNING_ALLOWED=NO \
build
# The SwiftPM matrix runs on macOS and cannot execute UIKit/CoreBluetooth
# conditional tests. Build the shared iOS test target and run it on the first
# available iPhone simulator from the runner image instead of hard-coding a
# model that changes when GitHub updates Xcode. The suite intentionally runs
# in one test runner: a number of integration tests exercise process-global
# stores and notification centers, so overlapping workers can corrupt each
# other's fixtures and turn sub-second tests into multi-minute timeouts.
ios-tests:
name: Run iOS simulator tests
runs-on: macos-latest
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Select available iPhone simulator
id: destination
run: |
destinations=$(xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -showdestinations)
destination_id=$(awk -F'id:' '
/platform:iOS Simulator/ && /name:iPhone/ && !found {
value=$2
sub(/,.*/, "", value)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
print value
found=1
}
' <<< "$destinations")
if [ -z "$destination_id" ]; then
echo "::error::No available iPhone simulator destination found"
exit 1
fi
echo "id=$destination_id" >> "$GITHUB_OUTPUT"
- name: Run iOS tests
run: |
set -o pipefail
xcodebuild -project bitchat.xcodeproj \
-scheme "bitchat (iOS)" \
-sdk iphonesimulator \
-destination "platform=iOS Simulator,id=${{ steps.destination.outputs.id }}" \
-parallel-testing-enabled NO \
CODE_SIGNING_ALLOWED=NO \
test
# Advisory only: SwiftLint reports style violations without ever failing the # Advisory only: SwiftLint reports style violations without ever failing the
# build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so # 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. # it can never break the documented xcodebuild path or block a merge.
-1
View File
@@ -80,4 +80,3 @@ build.log
# Local configs # Local configs
Local.xcconfig Local.xcconfig
*.profraw
-1
View File
@@ -1 +0,0 @@
{"v1":{"usrs":["param-buf-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-dataDir-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","param-len-arti_bootstrap_summary(_:_:)-s:3Tor22arti_bootstrap_summary33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSpys4Int8VG_AEtF","param-socksPort-arti_start(_:_:)-s:3Tor10arti_start33_954FD7701B4E47ABB5F166D1CF862DC9LLys5Int32VSPys4Int8VG_s6UInt16VtF","s:13BitFoundation16PeerCapabilitiesV8wifiBulkACvpZ","s:13BitFoundation18KeychainReadResultO18isRecoverableErrorSbvp","s:13BitFoundation23KeychainManagerProtocolP11secureClearyySSzF","s:18bitchatTests_macOS12MockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC11resetCountsyyF","s:18bitchatTests_macOS20TrackingMockKeychainC11secureClearyySSzF","s:18bitchatTests_macOS20TrackingMockKeychainC25totalSecureClearCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC26secureClearStringCallCountSivp","s:18bitchatTests_macOS20TrackingMockKeychainC27_secureClearStringCallCount06_AB6D1M24FD239F2969C82F4108818260LLSivp","s:18bitchatTests_macOS24FailingCacheSaveKeychain33_22380C7A11A569A0B83FA83F34C498A7LLC11secureClearyySSzF","s:18bitchatTests_macOS24MockGeohashPresenceTimer33_483587EFB96650EE130EFB09BBA2A1AALLC7handleryycvp","s:3Tor0A7ManagerC21goDormantOnBackgroundyyF","s:7bitchat10AppRuntimeC24handleScreenshotCaptured33_C8B369AD8BC1D9963A50CEDA77A4332ALLyyF","s:7bitchat10AppRuntimeC33handleDidBecomeActiveNotificationyyF","s:7bitchat10BLEServiceC18logBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LLyySSF","s:7bitchat10BLEServiceC20centralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC22captureBluetoothStatus33_69191C53E68500C17D98DBCF2BDA7100LL7contextySS_tF","s:7bitchat10BLEServiceC23peripheralRestorationID33_69191C53E68500C17D98DBCF2BDA7100LLSSvpZ","s:7bitchat10BLEServiceC29scheduleBluetoothStatusSample33_69191C53E68500C17D98DBCF2BDA7100LL5after7contextySd_SStF","s:7bitchat10QRScanViewV8isActiveSbvp","s:7bitchat15BLEPeerRegistryV5countSivp","s:7bitchat15KeychainManagerC11secureClearyySSzF","s:7bitchat15PaymentChipViewV7openURL33_10AC50641B1EBCD52E5092A2E521D236LL7SwiftUI13OpenURLActionVvp","s:7bitchat15TransportConfigO29uiBatchDispatchStaggerSecondsSdvpZ","s:7bitchat15TransportConfigO35uiShareExtensionDismissDelaySecondsSdvpZ","s:7bitchat15TransportConfigO38bleBackgroundPendingConnectSlotReserveSivpZ","s:7bitchat17GossipSyncManagerC10persistNowyyF","s:7bitchat17NostrRelayManagerC15InboundEventKey33_E4160FE8A9A2C9D6308EAAD5A8B5CB07LLV7eventIDSSvp","s:7bitchat25LocationNotesDependenciesV3now10Foundation4DateVycvp","s:7bitchat25NWPathReachabilityMonitorC7monitor33_84633C9DBCAF57538179C1E04DB8E015LL7Network0bD0CSgvp"]}}
-21
View File
@@ -1,21 +0,0 @@
# Periphery dead-code scan configuration (https://github.com/peripheryapp/periphery)
#
# CI runs the macOS scheme only (an iOS scan needs a device destination and
# doubles the build time). macOS-only scans falsely flag iOS-only code —
# state restoration, screenshot handlers, background BLE sampling — so those
# findings live in .periphery.baseline.json rather than being "fixed".
# When auditing by hand, scan BOTH schemes and intersect:
# periphery scan --schemes "bitchat (iOS)" -- -destination 'generic/platform=iOS' ARCHS=arm64
project: bitchat.xcodeproj
schemes:
- bitchat (macOS)
retain_swift_ui_previews: true
# Codable properties are (de)serialized via synthesized conformances the
# indexer doesn't always attribute reads to: PrekeyBundleStore.StoredBundle
# .noiseKey flaked CI as "assign-only" even while read in loadFromDisk —
# and slipped past its baselined USR. Retaining Codable properties outright
# is deterministic; a truly-dead Codable field is a persisted-format change
# anyway, never a safe mechanical delete.
retain_codable_properties: true
relative_results: true
baseline: .periphery.baseline.json
-1
View File
@@ -2,7 +2,6 @@
# (CI checkouts are fresh, so this only matters in a working tree). # (CI checkouts are fresh, so this only matters in a working tree).
excluded: excluded:
- .build - .build
- .claude
- .swiftpm - .swiftpm
- .DerivedData - .DerivedData
- DerivedData - DerivedData
-3
View File
@@ -3,6 +3,3 @@ DEVELOPMENT_TEAM = ABC123
// Unique bundle id to be able to register and run locally // Unique bundle id to be able to register and run locally
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.$(DEVELOPMENT_TEAM) PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.$(DEVELOPMENT_TEAM)
// App and share extension must use an App Group registered to your team.
APP_GROUP_ID = group.chat.bitchat.$(DEVELOPMENT_TEAM)
+1 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.7.1 MARKETING_VERSION = 1.5.3
CURRENT_PROJECT_VERSION = 1 CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0 IPHONEOS_DEPLOYMENT_TARGET = 16.0
+95 -54
View File
@@ -1,66 +1,107 @@
# BitChat developer commands # BitChat macOS Build Justfile
# # Handles temporary modifications needed to build and run on macOS
# Builds use a repository-local, ignored DerivedData directory. No recipe
# patches, restores, or removes tracked project/configuration files.
project := "bitchat.xcodeproj"
macos_scheme := "bitchat (macOS)"
ios_scheme := "bitchat (iOS)"
derived_data := ".DerivedData"
# Default recipe - shows available commands
default: default:
@echo "BitChat developer commands:" @echo "BitChat macOS Build Commands:"
@echo " just run Build and run the macOS app" @echo " just run - Build and run the macOS app"
@echo " just build Build the macOS app without signing" @echo " just build - Build the macOS app only"
@echo " just test Run the SwiftPM test suite" @echo " just clean - Clean build artifacts and restore original files"
@echo " just test-ios Run tests on the iPhone 17 simulator" @echo " just check - Check prerequisites"
@echo " just clean Remove repo-local build artifacts only" @echo ""
@echo " just nuke Also remove nested package build caches" @echo "Original files are preserved - modifications are temporary for builds only"
@echo " just check Validate the development environment"
# Static guard against reintroducing source-restoring or source-deleting clean # Check prerequisites
# behavior. CI runs the same script directly. check:
check-clean-safety:
@bash scripts/check-just-clean-safety.sh
check: check-clean-safety
@echo "Checking prerequisites..." @echo "Checking prerequisites..."
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install full Xcode." && exit 1) @command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
@developer_dir="$$(xcode-select -p 2>/dev/null)"; case "$$developer_dir" in *.app/Contents/Developer) ;; *) echo "❌ Full Xcode is not selected. Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"; exit 1;; esac @xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
@xcodebuild -version @test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1)
@echo "✅ Development environment ready (a signing identity is not required for just build)" @xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
@echo "✅ All prerequisites met"
build: check # Backup original files
backup:
@echo "Backing up original project configuration..."
@if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi
@if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi
# Restore original files
restore:
@echo "Restoring original project configuration..."
@if [ -f project.yml.backup ]; then mv project.yml.backup project.yml; fi
@# Restore iOS-specific files
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
@# Use git to restore all modified files except Justfile
@git checkout -- project.yml bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Could not restore some files with git"
@# Remove any backup files
@rm -f bitchat.xcodeproj/project.pbxproj.backup bitchat/Info.plist.backup 2>/dev/null || true
# Apply macOS-specific modifications
patch-for-macos: backup
@echo "Temporarily hiding iOS-specific files for macOS build..."
@# Move iOS-specific files out of the way temporarily
@if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi
# Build the macOS app
build: #check generate
@echo "Building BitChat for macOS..." @echo "Building BitChat for macOS..."
@xcodebuild -project "{{project}}" -scheme "{{macos_scheme}}" -configuration Debug -derivedDataPath "{{derived_data}}" CODE_SIGNING_ALLOWED=NO build @xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
# Run the macOS app
run: build run: build
@app="{{derived_data}}/Build/Products/Debug/bitchat.app"; test -d "$$app" || (echo "❌ Built app not found at $$app" && exit 1); open "$$app" @echo "Launching BitChat..."
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
# Backward-compatible alias for the old quick-run recipe. # Clean build artifacts and restore original files
dev-run: run clean: restore
@echo "Cleaning build artifacts..."
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
@# Only remove the generated project if we have a backup, otherwise use git
@if [ -f bitchat.xcodeproj/project.pbxproj.backup ]; then \
rm -rf bitchat.xcodeproj; \
else \
git checkout -- bitchat.xcodeproj/project.pbxproj 2>/dev/null || echo "⚠️ Could not restore project.pbxproj"; \
fi
@rm -f project-macos.yml 2>/dev/null || true
@echo "✅ Cleaned and restored original files"
test: # Quick run without cleaning (for development)
@swift test dev-run: check
@echo "Quick development build..."
test-ios: check @xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
@xcodebuild -project "{{project}}" -scheme "{{ios_scheme}}" -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -derivedDataPath "{{derived_data}}" test @find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
# Artifact-only cleanup. In particular, this recipe never invokes Git and
# never writes, moves, restores, or removes source/configuration files.
clean:
@echo "Cleaning repo-local build artifacts..."
@rm -rf -- "{{derived_data}}" ".build"
@echo "✅ Cleaned {{derived_data}} and .build; tracked files were untouched"
# Retain the familiar command, but keep it artifact-only as well.
nuke: clean
@echo "Cleaning nested package build caches..."
@find localPackages -type d -name .build -prune -exec rm -rf -- {} +
@rm -rf -- ".cache"
@echo "✅ Removed repository build caches; tracked files were untouched"
# Show app info
info: info:
@echo "BitChat - decentralized mesh messaging" @echo "BitChat - Decentralized Mesh Messaging"
@echo "macOS 13+ and iOS 16+" @echo "======================================"
@echo "Bluetooth mesh behavior requires physical Bluetooth-capable devices" @echo "• Native macOS SwiftUI app"
@echo "• Bluetooth LE mesh networking"
@echo "• End-to-end encryption"
@echo "• No internet required"
@echo "• Works offline with nearby devices"
@echo ""
@echo "Requirements:"
@echo "• macOS 13.0+ (Ventura)"
@echo "• Bluetooth LE capable Mac"
@echo "• Physical device (no simulator support)"
@echo ""
@echo "Usage:"
@echo "• Set nickname and start chatting"
@echo "• Use /join #channel for group chats"
@echo "• Use /msg @user for private messages"
@echo "• Triple-tap logo for emergency wipe"
# Force clean everything (nuclear option)
nuke:
@echo "🧨 Nuclear clean - removing all build artifacts and backups..."
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
@rm -rf bitchat.xcodeproj 2>/dev/null || true
@rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true
@rm -f bitchat/Info.plist.backup 2>/dev/null || true
@# Restore iOS-specific files if they were moved
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
@git checkout bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
@echo "✅ Nuclear clean complete"
+117 -107
View File
@@ -1,155 +1,165 @@
# bitchat Privacy Policy # bitchat Privacy Policy
*Last updated: July 2026* *Last updated: June 2026*
## Our Commitment ## Our Commitment
bitchat is designed for private, account-free communication. This policy describes what the app keeps on your device, what it sends when you use mesh or optional internet features, and how long local data can remain. bitchat is designed with privacy as its foundation. We believe private communication is a fundamental human right. This policy explains how bitchat protects your privacy.
## Summary ## Summary
- **No project-operated accounts or messaging servers** — Bluetooth mesh is peer-to-peer; optional internet features use public or user-selected Nostr relays. - **No personal data collection** - We don't collect names, emails, or phone numbers
- **No analytics, advertising, telemetry, or tracking** — the app does not contain an analytics or advertising SDK. - **No accounts or company servers** - Mesh chat works peer-to-peer; optional Nostr features use public or user-selected relays
- **No sale of data** — the project does not sell user data or build advertising profiles. - **No tracking** - We have no analytics, telemetry, or user tracking
- **Open source** — the storage, networking, and cryptography described here can be inspected in the source code. - **Open source** - You can verify these claims by reading our code
## What bitchat Stores on Your Device ## What Information bitchat Stores
1. **Identity and cryptographic keys** ### On Your Device Only
- Noise, signing, group, prekey, and optional Nostr identity material is generated locally.
- Secret keys are stored in the system keychain as device-only items. Public keys are shared when required for messaging, verification, groups, or Nostr events.
- Keys remain until they are rotated, removed by the relevant feature, or erased with panic wipe. Because operating-system keychains can outlive an uninstall, bitchat records a non-secret install marker and deletes surviving app keys before use after a later reinstall.
2. **Nickname, preferences, and relationships** 1. **Identity Keys**
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally. - Cryptographic private keys generated on first launch or when optional Nostr identities are created
- The share extension can retain one item you choose to share in the app-group preferences for up to 24 hours. The app shows the destination and a preview for review; it does not send the item automatically. The item is cleared when you add it to the composer, cancel, panic-wipe, or it expires. - Stored locally in your device's secure storage
- Allows you to maintain "favorite" relationships across app restarts
- Private keys never leave your device; public keys are shared when needed for messaging
3. **Private group state** 2. **Nickname**
- Group names, rosters, creator identity, and key epoch are stored as protected files in Application Support. - The display name you choose (or auto-generated)
- Current group keys are stored in the keychain. Group state remains until you leave or remove the group, panic-wipe the app, or remove the app. - Stored only on your device
- Shared with peers you communicate with
4. **Queued and carried private messages** 3. **Message History** (if enabled)
- An outgoing private message that has not been acknowledged may remain for up to 24 hours in a bounded, encrypted outbox. The outbox is sealed with ChaCha20-Poly1305 and its key is stored in the keychain. - When room owners enable retention, messages are saved locally
- A device acting as a courier may store a bounded opaque end-to-end encrypted envelope for another user for up to 24 hours. The courier cannot read its message content. - Stored encrypted on your device
- A panic wipe deletes both stores. - You can delete this at any time
5. **Recent public mesh messages and notices** 4. **Favorite Peers**
- Signed public mesh messages may be kept in a protected local gossip archive for up to 15 minutes so they can cross mesh partitions and survive a short relaunch. - Public keys of peers you mark as favorites
- Public bulletin-board posts and deletion tombstones persist until the post's author-selected expiry, at most seven days. Both stores are bounded and panic-wipeable. - Stored only on your device
- These items are public to the mesh or board where they are posted; they are not confidential messages. - Allows you to recognize these peers in future sessions
6. **Media attachments** 5. **Optional Location Channel State**
- Voice notes and images you send or receive can be stored under Application Support so they remain playable while referenced by the app. - Your selected geohash channel, bookmarked geohashes, teleport flags, and bookmark display names
- Incoming media is subject to a 100 MB quota with oldest-file eviction. Media is deleted by panic wipe or app removal; some outgoing media can otherwise remain on disk. - 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
7. **Optional location-channel state** ### Temporary Session Data
- Your selected geohash channel, bookmarks, teleport flags, and bookmark display names are stored locally so the UI can restore them.
- Per-geohash Nostr identities are derived locally from a device seed stored in the keychain.
- bitchat does not persist exact latitude or longitude and does not include exact coordinates in mesh or Nostr messages.
## 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
While running, bitchat maintains active connections, routing state, deduplication state, and bounded in-memory conversation timelines. Closing the app clears the in-memory timelines and active connections, but it does not erase the persistent stores listed above. ## What Information is Shared
## What Is Shared ### With Other bitchat Users
### With Nearby Mesh Users When you use bitchat, nearby peers can see:
- Your chosen nickname
- Your ephemeral public key (changes each session)
- Messages you send to public rooms or directly to them
- Your approximate Bluetooth signal strength (for connection quality)
Depending on the feature you use, nearby peers can receive: ### With Room Members
- Your chosen nickname and public Noise/signing identity material. When you join a password-protected room:
- Announce metadata such as supported capability flags and a bounded list of short direct-neighbor identifiers. When the bridge is enabled, an announce can also include its coarse rendezvous geohash cell. - Your messages are visible to others with the password
- Public mesh messages, public notices, and group-control packets you intentionally send. - Your nickname appears in the member list
- Private ciphertext addressed to them, or opaque courier ciphertext they agree to carry. - Room owners can see you've joined
- Radio metadata available to the receiver, such as approximate Bluetooth signal strength.
Noise identity keys can persist across sessions; do not treat them as anonymous identifiers. Panic wipe rotates local identity state. ### With Nostr Relays (Optional Features)
### With Private Group Members 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.
Private group members receive the group's name, roster, key epoch, and encrypted group traffic needed to participate. Group messages are confidential to devices holding the current group key, subject to the security of those devices and members. ## What We DON'T Do
### With Nostr Relays and Internet Gateways bitchat **never**:
- Collects personal information
- Sells or shares your exact GPS location
- Stores data on servers we operate
- Sells your data to advertisers or data brokers
- Uses analytics or telemetry
- Creates user profiles
- Requires registration
Internet-backed features are optional. When enabled or used: ## Encryption
- Private fallback messages use BitChat's app-specific encrypted envelopes. This format is not NIP-17, NIP-44, or NIP-59 compatible. Relays can observe the recipient public-key tag, event timing and size, and network metadata, but not the message plaintext or stable sender identity. All private messages use end-to-end encryption:
- Public location-channel messages, notes, notices, and presence include a geohash tag, event kind, timestamp, and a public key. A geohash reveals an approximate area; finer precision reveals a smaller area. - **X25519** for key exchange
- The optional mesh bridge publishes bridge-enabled public mesh messages and presence to a neighborhood rendezvous cell. Those messages are public to participants and relays for that cell. A per-message “nearby only” choice prevents that message from crossing the bridge. - **AES-256-GCM** for message encryption
- Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata. - **Ed25519** for digital signatures
- A device with gateway features enabled may relay signed bridge/location traffic or opaque courier envelopes for nearby mesh devices. - **Argon2id** for password-protected rooms
Nostr relays are operated by third parties. Their retention, logging, availability, and privacy practices are outside the project's control. Public events and encrypted events may remain on relays according to each relay's policy. ## Your Rights
## Location and Apple Services You have complete control:
- **Delete Local State**: Triple-tap the logo to instantly wipe local keys, sessions, caches, and preferences
- **Leave Anytime**: Close the app and local presence stops; relay-backed presence ages out
- **No Account**: No account record exists for you to delete from us
- **Portability**: Your local state stays on your device unless you send messages, use optional relay-backed features, or export it
Location permission is optional and requested as when-in-use access. It is used to compute geohash channels, bridge rendezvous cells, and nearby place labels. ## Bluetooth & Permissions
- Exact coordinates are not included in bitchat mesh or Nostr payloads and are not persisted by bitchat. bitchat requires Bluetooth permission to function:
- A selected geohash can still reveal an approximate area to peers and relays. - Used only for peer-to-peer communication
- When bitchat asks the operating system for a friendly place name, Apple's `CLGeocoder` service may process the location under Apple's privacy terms. - Bluetooth is not used for tracking
- Revoking location permission stops live location sampling. Saved bookmarks remain until you remove them, panic-wipe the app, or remove the app. - You can revoke this permission at any time in system settings
## Microphone, Camera, and Media Permissions ## Location Permission
- Microphone access is used only while you record a voice note or actively hold live push-to-talk. The resulting audio is sent to the mesh conversation you selected; public-conversation audio is public to that mesh, while private-conversation audio uses the private transport protections described below. Location permission is optional and is used only for location channels:
- Voice-note and live-audio files can remain in Application Support under the media retention rules above. - Used to compute local geohash channels and display names
- Camera access is used to scan peer-verification QR codes. Photo-library access is used when you choose an image to send. - Requested as when-in-use permission
- These permissions can be revoked in system settings. bitchat does not record microphone or camera input while the related capture UI is inactive. - 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
## Cryptography - You can revoke this permission at any time in system settings
Private and public features use different protections:
- Mesh private sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256.
- Private group messages use ChaCha20-Poly1305; group state and relevant mesh packets use Ed25519 signatures.
- Nostr events use secp256k1 Schnorr signatures. BitChat private envelopes use secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305. The envelope format is proprietary, only interoperates with BitChat clients, and does not provide forward secrecy against later compromise of the recipient's static Nostr private key.
- The persistent private-message outbox uses ChaCha20-Poly1305 with a key held in the keychain. Some other protected local identity state uses AES-GCM.
- Public mesh, bridge, geohash, and board content is signed or authenticated as appropriate but is intentionally not confidential.
No cryptographic system can protect content after a recipient reads, copies, screenshots, or exports it.
## Data Retention Summary
- **In-memory chat timelines and active connections:** until the app closes or state is cleared.
- **Queued outgoing private messages:** until acknowledged, dropped by bounded policy, or 24 hours, whichever comes first.
- **Opaque courier envelopes:** until handed off, evicted by bounded policy, or 24 hours, whichever comes first.
- **Recent public mesh gossip:** up to 15 minutes.
- **Public board posts and tombstones:** until expiry, at most seven days.
- **Groups, favorites, preferences, identity keys, bookmarks, and media:** until removed by the feature, panic wipe, quota eviction where applicable, or app removal.
- **Nostr data:** according to the policies of the relays that receive it.
## Your Controls
- **Panic wipe:** Triple-tap the logo to synchronously cancel in-flight media work and clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app.
- **Feature controls:** Location channels, mesh bridge, internet gateway, and related internet behaviors can be disabled in the app. Some already-published relay data cannot be recalled.
- **System permissions:** Bluetooth, location, microphone, camera, and photo-library access can be revoked in system settings.
- **No account:** The project operates no account record for you to request or export.
## What the Project Does Not Do
bitchat does not:
- Operate an account database or project-owned messaging backend.
- Include advertising, analytics, or tracking SDKs.
- Sell user data or create advertising profiles.
- Include exact GPS coordinates in bitchat mesh or Nostr message payloads.
## Children's Privacy ## Children's Privacy
The project does not knowingly operate a service that collects children's personal data. The app has no account registration or age-verification system. Users and guardians should understand that public mesh, board, bridge, and location-channel posts are visible to other participants and may be relayed. bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone.
## Data Retention
- **Messages**: Deleted from memory when app closes (unless room retention is enabled)
- **Identity Key**: Persists until you delete the app
- **Favorites**: Persist until you remove them or delete the app
- **Location channel choices**: Selected/bookmarked geohashes persist locally until removed, panic-wiped, or the app is deleted
- **Nostr relay data**: Public geohash events and encrypted gift wraps may be retained by relays according to each relay's policy
- **Everything Else**: Exists only during active sessions
## Security Measures
- All communication is encrypted
- No accounts or company servers
- Optional Nostr relays receive only the events needed for Nostr-backed private fallback or public location channels
- Open source code for public audit
- Regular security updates
- Cryptographic signatures prevent tampering
## Changes to This Policy ## Changes to This Policy
Material behavior changes will be reflected in this document and its “Last updated” date. Updating this policy cannot retroactively retrieve data that remained only on a user's device. If we update this policy:
- The "Last updated" date will change
- The updated policy will be included in the app
- No retroactive changes can make us collect data already held only in your app
## Contact ## Contact
bitchat is an open source project. For privacy questions: bitchat is an open source project. For privacy questions:
- View our source code: [https://github.com/permissionlesstech/bitchat/tree/main](https://github.com/permissionlesstech/bitchat/tree/main)
- Open an issue on GitHub
- Join the discussion in public rooms
- View the source: [https://github.com/permissionlesstech/bitchat](https://github.com/permissionlesstech/bitchat) ## Philosophy
- Open an issue on GitHub.
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.
--- ---
*This policy is released into the public domain under The Unlicense, like the project itself.* *This policy is released into the public domain under The Unlicense, just like bitchat itself.*
+1 -5
View File
@@ -63,11 +63,7 @@ let package = Package(
// Only the vector fixture: declaring the whole "Noise" // Only the vector fixture: declaring the whole "Noise"
// directory would claim its .swift test files as resources // directory would claim its .swift test files as resources
// and silently drop them from compilation. // and silently drop them from compilation.
.process("Noise/NoiseTestVectors.json"), .process("Noise/NoiseTestVectors.json")
// Frozen envelopes produced by the released iOS (733098bb)
// and Android (b7f0b33d) private-DM implementations; prove
// receive compatibility independently of the local generator.
.process("Nostr/Fixtures")
] ]
) )
] ]
+14 -52
View File
@@ -19,7 +19,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback) - **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE - **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers - **Privacy First**: No accounts, no phone numbers, no persistent identifiers
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, BitChat private envelopes for Nostr fallback - **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface - **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
- **Universal App**: Native support for iOS and macOS - **Universal App**: Native support for iOS and macOS
- **Emergency Wipe**: Triple-tap to instantly clear all data - **Emergency Wipe**: Triple-tap to instantly clear all data
@@ -44,15 +44,9 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor
- **Global Reach**: Connect with users worldwide via internet relays - **Global Reach**: Connect with users worldwide via internet relays
- **Location Channels**: Geographic chat rooms using geohash coordinates - **Location Channels**: Geographic chat rooms using geohash coordinates
- **290+ Relay Network**: Distributed across the globe for reliability - **290+ Relay Network**: Distributed across the globe for reliability
- **BitChat Private Envelopes**: App-specific encrypted private messages over Nostr relays - **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area - **Ephemeral Keys**: Fresh cryptographic identity per geohash area
BitChat's private-envelope format is proprietary and is **not** NIP-17,
NIP-44, or NIP-59 compatible. It uses Nostr as a relay transport but only
interoperates with BitChat clients: private payloads travel inside kind-1059
events whose `v2:`-prefixed content is a BitChat-specific XChaCha20-Poly1305
construction, not NIP-44 encryption.
### Channel Types ### Channel Types
#### `mesh #bluetooth` #### `mesh #bluetooth`
@@ -86,7 +80,7 @@ Private messages use **intelligent transport selection**:
2. **Nostr Fallback** (when Bluetooth unavailable) 2. **Nostr Fallback** (when Bluetooth unavailable)
- Uses recipient's Nostr public key - Uses recipient's Nostr public key
- BitChat's app-specific private-envelope encryption - NIP-17 gift-wrapping for privacy
- Routes through global relay network - Routes through global relay network
3. **Smart Queuing** (when neither available) 3. **Smart Queuing** (when neither available)
@@ -100,61 +94,29 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
### Option 1: Using Xcode ### Option 1: Using Xcode
```bash ```bash
cd bitchat
open bitchat.xcodeproj open bitchat.xcodeproj
``` ```
For a signed device build, create your ignored local configuration and replace To run on a device there're a few steps to prepare the code:
the example team ID with your Apple Developer Team ID: - Clone the local configs: `cp Configs/Local.xcconfig.example Configs/Local.xcconfig`
- Add your Developer Team ID into the newly created `Configs/Local.xcconfig`
```bash - Bundle ID would be set to `chat.bitchat.<team_id>` (unless you set to something else)
cp Configs/Local.xcconfig.example Configs/Local.xcconfig - Entitlements need to be updated manually (TODO: Automate):
``` - Search and replace `group.chat.bitchat` with `group.<your_bundle_id>` (e.g. `group.chat.bitchat.ABC123`)
`Local.xcconfig.example` derives unique app and App Group identifiers from that
team ID. The entitlement files already reference `$(APP_GROUP_ID)`, so tracked
project or entitlement files do not need to be edited.
Useful command-line checks from the repository root:
```bash
# macOS Debug build without signing
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" \
-configuration Debug CODE_SIGNING_ALLOWED=NO build
# Full SwiftPM test suite
swift test
# iOS simulator tests
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" \
-sdk iphonesimulator \
-destination 'platform=iOS Simulator,name=iPhone 17' test
```
If `iPhone 17` is unavailable, choose an installed simulator from:
```bash
xcodebuild -showdestinations -project bitchat.xcodeproj -scheme "bitchat (iOS)"
```
### Option 2: Using `just` ### Option 2: Using `just`
```bash ```bash
brew install just brew install just
just check
just run
``` ```
`just build` and `just run` use the current `bitchat (macOS)` scheme and keep Want to try this on macos: `just run` will set it up and run from source.
Xcode output in the ignored `.DerivedData/` directory. They never patch source, Run `just clean` afterwards to restore things to original state for mobile app building and development.
project, configuration, or entitlement files.
`just clean` removes only `.DerivedData/` and `.build/`. It does not invoke Git
or restore tracked files, so uncommitted work is preserved. `just test` runs the
SwiftPM suite and `just test-ios` runs the iPhone 17 simulator suite.
## Localization ## Localization
- App localizations live in `bitchat/Localizable.xcstrings`. - Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`.
- Share extension strings are separate in `bitchatShareExtension/Localization/Localizable.xcstrings`. - Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`.
- Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible. - Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible.
- Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates. - Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates.
+249 -83
View File
@@ -1,143 +1,309 @@
# bitchat Protocol Whitepaper # BitChat Protocol Whitepaper
**Version 2.0** **Version 1.1**
**Date: July 6, 2026** **Date: July 25, 2025**
--- ---
## Abstract ## Abstract
bitchat is a decentralized, peer-to-peer messaging application for secure, private, censorship-resistant communication that works with or without the internet. Nearby devices form an ad-hoc Bluetooth Low Energy (BLE) mesh; distant peers are reached over the Nostr protocol when a connection exists. A layered store-and-forward stack — a persistent sender outbox, opportunistic couriers with a spray-and-wait copy budget, gossip-synced public history, and Nostr relay mailboxes — delivers messages to peers who are out of range at send time. This document describes the protocol and its delivery guarantees as implemented. BitChat is a decentralized, peer-to-peer messaging application designed for secure, private, and censorship-resistant communication over ephemeral, ad-hoc networks. This whitepaper details the BitChat Protocol Stack, a layered architecture that combines a modern cryptographic foundation with a flexible application protocol. At its core, BitChat leverages the Noise Protocol Framework (specifically, the `XX` pattern) to establish mutually authenticated, end-to-end encrypted sessions between peers. This document provides a technical specification of the identity management, session lifecycle, message framing, and security considerations that underpin the BitChat network.
--- ---
## 1. Design Goals ## 1. Introduction
* **Confidentiality:** all private communication is end-to-end encrypted; intermediate nodes and couriers carry only opaque ciphertext. In an era of centralized communication platforms, BitChat offers a resilient alternative by operating without central servers. It is designed for scenarios where internet connectivity is unavailable or untrustworthy, such as protests, natural disasters, or remote areas. Communication occurs directly between devices over transports like Bluetooth Low Energy (BLE).
* **Authentication:** peers are identified by cryptographic keys; announcements are signed and verified.
* **Resilience:** the network functions in lossy, low-bandwidth, partitioned environments with churning membership.
* **Eventual delivery:** a message to an out-of-range peer should still arrive — relayed by the mesh, carried by a moving person, or resting on an internet relay — within a bounded retention window.
* **Ephemerality by default:** no plaintext message content is ever written to disk. Everything the store-and-forward stack persists is either sealed ciphertext or already-public broadcast traffic, and all of it dies with the panic wipe.
## 2. Architecture Overview The design goals of the BitChat Protocol are:
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`: * **Confidentiality:** All communication must be unreadable to third parties.
* **Authentication:** Users must be able to verify the identity of their correspondents.
* **Integrity:** Messages cannot be tampered with in transit.
* **Forward Secrecy:** The compromise of long-term identity keys must not compromise past session keys.
* **Deniability:** It should be difficult to cryptographically prove that a specific user sent a particular message.
* **Resilience:** The protocol must function reliably in lossy, low-bandwidth environments.
* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts. This paper specifies the technical details of the protocol designed to meet these goals.
* **Nostr** — private messages to mutual favorites travel in BitChat's app-specific encrypted envelopes over public relays (over Tor where enabled), bridging separate meshes through the internet.
The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly. ---
## 3. Identity ## 2. Protocol Stack
Each device holds two long-term key pairs in the Keychain: The BitChat Protocol is a four-layer stack. This layered approach separates concerns, allowing for modularity and future extensibility.
* a **Curve25519 static key** for Noise key agreement — its SHA-256 fingerprint is the peer's stable identity, and ```mermaid
* an **Ed25519 signing key** for packet signatures. graph TD
A[Application Layer] --> B[Session Layer];
B --> C[Encryption Layer];
C --> D[Transport Layer];
On the mesh, peers appear under short ephemeral IDs derived per session; favoriting pins the full Noise public key so identity survives across sessions. Mutual favorites also exchange Nostr public keys for the internet path. Optional QR verification binds a nickname to a fingerprint in person. subgraph "BitChat Application"
A
end
## 4. BLE Mesh Layer subgraph "Message Framing & State"
B
end
### 4.1 Packet Format subgraph "Noise Protocol Framework"
C
end
A compact binary header (version, type, TTL, timestamp, flags) is followed by an 8-byte sender ID, an optional 8-byte recipient ID, the payload, and an optional Ed25519 signature. Version 2 packets may carry an explicit source route. Signatures exclude the TTL byte so relays can decrement it without invalidating them. Packets other than fragments are padded toward uniform sizes. subgraph "BLE, Wi-Fi Direct, etc."
D
end
### 4.2 Flood Control style A fill:#cde4ff
style B fill:#b5d8ff
style C fill:#9ac2ff
style D fill:#7eadff
```
Relaying is a deterministic controlled flood tuned by local connection degree: * **Application Layer:** Defines the structure of user-facing messages (`BitchatMessage`), acknowledgments (`DeliveryAck`), and other application-level data.
* **Session Layer:** Manages the overall communication packet (`BitchatPacket`). This includes routing information (TTL), message typing, fragmentation, and serialization into a compact binary format.
* **Encryption Layer:** Establishes and manages secure channels using the Noise Protocol Framework. It is responsible for the cryptographic handshake, session management, and transport message encryption/decryption.
* **Transport Layer:** The underlying physical medium used for data transmission, such as Bluetooth Low Energy (BLE). This layer is abstracted away from the core protocol.
* **TTL:** packets originate with TTL 7. Relays clamp: dense graphs (≥ 6 links) cap broadcast TTL at 5; thin chains (≤ 2 links) relay at full incoming depth. ---
* **Deduplication:** an LRU seen-set (1000 entries, 5-minute expiry) keyed by sender, timestamp, type, and a payload digest drops duplicates. A scheduled relay is cancelled when a duplicate arrives first from another relay.
* **Jitter:** relays wait a random 10220 ms (wider when dense) so duplicate suppression wins often.
* **Fanout subsetting:** broadcast messages are re-sent to a deterministic, message-ID-seeded subset of links (~log₂ of degree) rather than all of them; announces, fragments, and sync packets use full fanout. The ingress link is always excluded (split horizon).
* **Directed traffic** (handshakes, private messages, courier envelopes) relays deterministically with TTL 1 and tight jitter, and is never subset.
### 4.3 Routing ## 3. Identity and Key Management
Announcements carry up to 10 direct-neighbor IDs, giving each node a shallow topology map (60 s freshness). When a bidirectionally-confirmed path exists, packets are source-routed along it; otherwise — and whenever a route fails — delivery falls back to flooding. A peer's identity in BitChat is defined by two persistent cryptographic key pairs, which are generated on first launch and stored securely in the device's Keychain.
### 4.4 Fragmentation 1. **Noise Static Key Pair (`Curve25519`):** This is the long-term identity key used for the Noise Protocol handshake. The public part of this key is shared with peers to establish secure sessions.
2. **Signing Key Pair (`Ed25519`):** This key is used to sign announcements and other protocol messages where non-repudiation is required, such as binding a public key to a nickname.
Packets exceeding the link MTU split into ~469-byte fragments (8-byte fragment ID, index/total header) that relay independently and reassemble at each receiving node (128 concurrent assemblies, 30 s timeout, 1 MiB cap). ### 3.1. Fingerprint
### 4.5 Presence A user's unique, verifiable fingerprint is the **SHA-256 hash** of their **Noise static public key**. This provides a user-friendly and secure way to verify an identity out-of-band (e.g., by reading it aloud or scanning a QR code).
Signed announcements propagate multi-hop: every 4 s while isolated, backing off to ~1530 s (jittered) when connected. A verified announce retains a peer as *reachable* for 60 s after last contact. Connection scheduling is RSSI-gated with duty-cycled scanning to bound battery drain. `Fingerprint = SHA256(StaticPublicKey_Curve25519)`
## 5. Encryption ### 3.2. Identity Management
### 5.1 Live Sessions: Noise XX The `SecureIdentityStateManager` class is responsible for managing all cryptographic identity material and social metadata (petnames, trust levels, etc.). It uses an in-memory cache for performance and persists this cache to the Keychain after encrypting it with a separate AES-GCM key.
Connected peers establish sessions with the Noise `XX` pattern (Curve25519 / ChaCha20-Poly1305 / SHA-256), providing mutual authentication and forward secrecy. All private payloads — messages, delivery acks, read receipts — ride inside the session as typed ciphertext. Intermediate relays see only opaque `noiseEncrypted` packets. ---
### 5.2 Offline Seals: Noise X ## 4. The Social Trust Layer
Courier envelopes are sealed to the recipient's *static* key with the one-way Noise `X` pattern; the sender's identity is authenticated inside the ciphertext. **This path has no forward secrecy** — compromise of the recipient's static key exposes sealed-but-undelivered mail. A prekey scheme is future work. Beyond cryptographic identity, BitChat incorporates a social trust layer, allowing users to manage their relationships with peers. This functionality is handled by the `SecureIdentityStateManager`.
### 5.3 Nostr Path ### 4.1. Peer Verification
Private messages to mutual favorites use BitChat's proprietary private-envelope protocol. An unsigned inner message (kind 14) is encrypted and placed in a sender-signed seal (kind 13); that seal is encrypted again inside a public envelope (kind 1059) signed by a one-time key, so relays learn neither the stable sender identity nor the content. Each encrypted content field is `v2:` followed by base64url of a 24-byte nonce, XChaCha20-Poly1305 ciphertext, and its 16-byte tag. Keys come from secp256k1 ECDH and HKDF-SHA256 (the derivation reuses a "nip44-v2" info label but is not the NIP-44 key schedule). 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.
This format reuses NIP-17/NIP-59 kind numbers but is **not NIP-17, NIP-44, or NIP-59 compatible** and interoperates only with BitChat clients. The outer `p` tag exposes the recipient's Nostr public key to relays; the plaintext and stable sender identity remain inside authenticated ciphertext. Public seal and envelope timestamps are randomized by up to ±15 minutes, while the actual message timestamp is encrypted. The protocol does not provide forward secrecy: compromise of the recipient's static Nostr private key can expose stored envelopes addressed to that key. ### 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. **`Noise_XX_25519_ChaChaPoly_SHA256`**
* **Trust tiers.** Mutual favorites may deposit 5 envelopes each; any peer with a signature-verified announce may deposit 2, into a bounded pool (20 of 40 slots) that can never crowd out favorites' mail. Envelopes are capped at 16 KiB and 24 h; overflow evicts oldest verified-tier mail first.
* **Deposit retry.** Queued messages are re-deposited whenever a new eligible courier connects, until 3 distinct couriers carry the message or it expires.
* **Spray and wait.** Envelopes carry a copy budget (initially 4, capped at 8). A courier meeting another eligible courier hands over half its remaining budget, so mail diffuses through a moving crowd instead of riding one person. Budgets, spray history, and carried mail persist across app restarts (iOS file protection).
* **Handover.** On a verified *direct* announce from the recipient, matching envelopes are delivered over the live link and removed. On a verified *relayed* announce, a copy floods toward the recipient as a directed packet while the carried original stays put, throttled to one attempt per envelope per 10 minutes.
* Receivers dedup by message ID, so redundant copies and the retained outbox original are harmless. Couriered mail from blocked senders is dropped at decryption time.
### 6.3 Public History (Gossip Sync) * **`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.
BitChat private envelopes 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))
* **Public chat** — signed broadcast messages within the mesh, backed by the gossip-synced history above. I->>R: -> s, se
* **Private chat** — end-to-end encrypted messages with delivery and read receipts, over mesh, courier, or Nostr. 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))
* **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. Note over I, R: Handshake complete. Transport keys derived.
* **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.
**Handshake Flow:**
1. **Initiator -> Responder:** The initiator generates a new ephemeral key pair (`e_i`) and sends the public part to the responder.
2. **Responder -> Initiator:** The responder receives the initiator's ephemeral public key. It then generates its own ephemeral key pair (`e_r`), performs a DH exchange with the initiator's ephemeral key (`ee`), sends its own static public key (`s_r`) encrypted with the resulting symmetric key, and performs another DH exchange between the initiator's ephemeral key and its own static key (`es`).
3. **Initiator -> Responder:** The initiator receives the responder's message, decrypts the responder's static key, and authenticates it. The initiator then sends its own static key (`s_i`) encrypted and performs a final DH exchange between its static key and the responder's ephemeral key (`se`).
Upon completion, both parties share a set of symmetric keys for bidirectional transport message encryption. The final handshake hash is used for channel binding.
### 5.3. Session Management
The `NoiseSessionManager` class manages all active Noise sessions. It handles:
* Creating sessions for new peers.
* Coordinating the handshake process to prevent race conditions.
* Storing the resulting transport ciphers (`sendCipher`, `receiveCipher`).
* Periodically checking if sessions need to be re-keyed for enhanced security.
---
## 6. The BitChat Session and Application Protocol
Once a Noise session is established, peers exchange `BitchatPacket` structures, which are encrypted as the payload of Noise transport messages.
### 6.1. Binary Packet Format (`BitchatPacket`)
To minimize bandwidth, `BitchatPacket`s are serialized into a compact binary format. The structure is designed to be fixed-size where possible to resist traffic analysis.
| Field | Size (bytes) | Description |
|-----------------|--------------|---------------------------------------------------------------------------------------------------------|
| **Header** | **13** | **Fixed-size header** |
| Version | 1 | Protocol version (currently `1`). |
| Type | 1 | Message type (e.g., `message`, `deliveryAck`, `noiseHandshakeInit`). See `MessageType` enum. |
| TTL | 1 | Time-To-Live for mesh network routing. Decremented at each hop. |
| Timestamp | 8 | `UInt64` millisecond timestamp of packet creation. |
| Flags | 1 | Bitmask for optional fields (`hasRecipient`, `hasSignature`, `isCompressed`). |
| Payload Length | 2 | `UInt16` length of the payload field. |
| **Variable** | **...** | **Variable-size fields** |
| Sender ID | 8 | 8-byte truncated peer ID of the sender. |
| Recipient ID | 8 (optional) | 8-byte truncated peer ID of the recipient. Present if `hasRecipient` flag is set. Broadcast if `0xFF..FF`. |
| Payload | Variable | The actual content of the packet, as defined by the `Type` field. |
| Signature | 64 (optional)| `Ed25519` signature of the packet. Present if `hasSignature` flag is set. |
**Padding:** All packets are padded to the next standard block size (256, 512, 1024, or 2048 bytes) using a PKCS#7-style scheme to obscure the true message length from network observers.
```mermaid
---
config:
theme: dark
---
---
title: "BitchatPacket"
---
packet
+8: "Version"
+8: "Type"
+8: "TTL"
+64: "Timestamp"
+8: "Flags"
+16: "Payload Length"
+64: "Sender ID"
+64: "Recipient ID (optional)"
+48: "Payload (variable)"
+64: "Signature (optional)"
```
_A representation of the sizes of the fields in `BitchatPacket`_
### 6.2. Application Message Format (`BitchatMessage`)
For packets of type `message`, the payload is a binary-serialized `BitchatMessage` containing the chat content.
| Field | Size (bytes) | Description |
|---------------------|--------------|--------------------------------------------------------------------------|
| Flags | 1 | Bitmask for optional fields (`isRelay`, `isPrivate`, `hasOriginalSender`). |
| Timestamp | 8 | `UInt64` millisecond timestamp of message creation. |
| ID | 1 + len | `UUID` string for the message. |
| Sender | 1 + len | Nickname of the sender. |
| Content | 2 + len | The UTF-8 encoded message content. |
| Original Sender | 1 + len (opt)| Nickname of the original sender if the message is a relay. |
| Recipient Nickname | 1 + len (opt)| Nickname of the recipient for private messages. |
```mermaid
---
config:
theme: dark
---
---
title: "BitchatMessage"
---
packet
+8: "Flags"
+64: "Timestamp"
+24: "ID (variable)"
+32: "Sender (variable)"
+32: "Content (variable)"
+32: "Original Sender (variable) (optional)"
+32: "Recipient Nickname (variable) (optional)"
```
_A representation of the sizes of the fields in `BitchatMessage`_
---
## 7. Message Routing and Propagation
BitChat operates as a decentralized mesh network, meaning there are no central servers to route messages. Packets are propagated through the network from peer to peer. The protocol supports several modes of message delivery.
### 7.1. Direct Connection
This is the simplest case. If Peer A and Peer B are directly connected, they can exchange packets after establishing a mutually authenticated Noise session. All packets are encrypted using the transport ciphers derived from the handshake.
### 7.2. Efficient Gossip with Bloom Filters
To send messages to peers that are not directly connected, BitChat employs a "flooding" or "gossip" protocol. When a peer receives a packet that is not destined for it, it acts as a relay. To prevent infinite routing loops and minimize memory usage, the protocol uses an `OptimizedBloomFilter` to track recently seen packet IDs.
The logic is as follows:
1. A peer receives a packet.
2. It checks the Bloom filter to see if the packet's ID has likely been seen before. If so, the packet is discarded. Bloom filters can have false positives (though they are rare), but they guarantee no false negatives. This means that while some packets may be incorrectly discarded due to false positives, the gossip protocol's redundancy ensures these packets will eventually be received through subsequent exchanges with other peers.
3. If the packet is new, its ID is added to the Bloom filter.
4. The peer decrements the packet's Time-To-Live (TTL) field.
5. If the TTL is greater than zero, the peer re-broadcasts the packet to all of its connected peers, *except* for the peer from which it received the packet.
This mechanism allows packets to "flood" through the network efficiently, maximizing the chance of reaching their destination while using minimal resources to prevent loops.
### 7.3. Time-To-Live (TTL)
Every `BitchatPacket` contains an 8-bit TTL field. This value is set by the originating peer and is decremented by one at each relay hop. If a peer receives a packet and decrements its TTL to 0, it will process the packet (if it is the recipient) but will not relay it further. This is a crucial mechanism to prevent packets from circulating endlessly in the mesh.
### 7.4. Private vs. Broadcast Messages
The routing logic respects the confidentiality of private messages:
* **Private Messages:** A packet with a specific `recipientID` is a private message. Relay nodes forward the entire, encrypted Noise message without being able to access the inner `BitchatPacket` or its payload. Only the final recipient, who shares the correct Noise session keys with the sender, can decrypt the packet.
* **Broadcast Messages:** A packet with the special broadcast `recipientID` (`0xFFFFFFFFFFFFFFFF`) is intended for all peers. Any peer that receives and decrypts a broadcast message will process its content. It will still be relayed according to the flooding algorithm to ensure it reaches the entire network.
### 7.5. Message Reliability and Lifecycle
To function in unreliable, lossy networks, the protocol includes features to track the lifecycle of a message and ensure its delivery.
* **Delivery Acknowledgments (`DeliveryAck`):** When a private message reaches its final destination, the recipient's device sends a `DeliveryAck` packet back to the original sender. This acknowledgment contains the ID of the original message.
* **Read Receipts (`ReadReceipt`):** After a message is displayed on the recipient's screen, the application can send a `ReadReceipt`, also containing the original message ID, to inform the sender that the message has been seen.
* **Message Retry Service:** Senders maintain a `MessageRetryService` which tracks outgoing messages. If a `DeliveryAck` is not received for a message within a certain time window, the service will automatically re-send the message, creating a more resilient user experience.
### 7.6. Fragmentation
Transport layers like BLE have a Maximum Transmission Unit (MTU) that limits the size of a single packet. To handle messages larger than this limit, BitChat implements a fragmentation protocol.
* **`fragmentStart`:** A packet with this type marks the beginning of a fragmented message. It contains metadata about the total size and number of fragments.
* **`fragmentContinue`:** These packets carry the intermediate chunks of the message data.
* **`fragmentEnd`:** This packet carries the final chunk of the message and signals the receiver to begin reassembly.
Receiving peers collect all fragments and reassemble them in the correct order before passing the complete message up to the application layer.
---
## 8. Security Considerations ## 8. Security Considerations
* **Relay nodes** cannot read private traffic; they forward padded, opaque ciphertext. * **Replay Attacks:** The Noise transport messages include a nonce that is incremented for each message. The `NoiseCipherState` implements a sliding window replay protection mechanism to detect and discard replayed or out-of-order messages.
* **Couriers** are quota-bounded mailbags. A malicious courier can drop mail (redundant copies and deposit retry mitigate this) but cannot read it, link it across days, or amplify it — copy budgets are capped and every envelope is validated against size and lifetime policy on deposit. * **Denial of Service:** The `NoiseRateLimiter` is implemented to prevent resource exhaustion from rapid, repeated handshake attempts from a single peer.
* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting. * **Key-Compromise Impersonation:** The `XX` pattern authenticates both parties, preventing an attacker from impersonating one party to the other.
* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces. * **Identity Binding:** While the Noise handshake authenticates the cryptographic keys, binding those keys to a human-readable nickname is handled at the application layer. Users must verify fingerprints out-of-band to prevent man-in-the-middle attacks.
* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor. * **Traffic Analysis:** The use of fixed-size padding for all packets helps to obscure the exact nature and content of the communication, making it harder for a network-level adversary to infer information based on message size.
* **No forward secrecy for sealed mail or Nostr private envelopes** (§5.25.3) means compromise of a recipient's static key can expose retained ciphertext addressed to that key.
## 9. Future Work
* Prekey-based forward secrecy for courier envelopes.
* Couriered media beyond the 16 KiB text cap.
* Probabilistic relay and edge-of-network TTL boosting for very dense and very sparse graphs.
* Multi-hop courier routing informed by encounter history.
--- ---
*This document describes the protocol as implemented in the current release. The implementation is free and unencumbered software released into the public domain.* ## 9. Conclusion
The BitChat Protocol provides a robust and secure foundation for decentralized, peer-to-peer communication. By layering a flexible application protocol on top of the well-regarded Noise Protocol Framework, it achieves strong confidentiality, authentication, and forward secrecy. The use of a compact binary format and thoughtful security considerations like rate limiting and traffic analysis resistance make it suitable for use in challenging network environments.
+9 -15
View File
@@ -70,7 +70,6 @@
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = { A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
Services/SharedContentHandoff.swift,
Services/TransportConfig.swift, Services/TransportConfig.swift,
); );
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */; target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
@@ -93,8 +92,7 @@
A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */ = { A6E32D232E762EAB0032EA8A /* Exceptions for "bitchatShareExtension" folder in "bitchatShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
Info.plist, ShareViewController.swift,
bitchatShareExtension.entitlements,
); );
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */; target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
}; };
@@ -260,13 +258,9 @@
buildConfigurationList = E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */; buildConfigurationList = E4EA6DC648DF55FF84032EB5 /* Build configuration list for PBXNativeTarget "bitchatShareExtension" */;
buildPhases = ( buildPhases = (
0A08E70F08F55FD5BA8C7EF3 /* Sources */, 0A08E70F08F55FD5BA8C7EF3 /* Sources */,
7E9B64F63F93443FB7BA12DF /* Resources */,
); );
buildRules = ( buildRules = (
); );
fileSystemSynchronizedGroups = (
A6E32D212E762EAB0032EA8A /* bitchatShareExtension */,
);
name = bitchatShareExtension; name = bitchatShareExtension;
productName = bitchatShareExtension; productName = bitchatShareExtension;
productReference = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; productReference = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */;
@@ -338,7 +332,6 @@
es, es,
ar, ar,
de, de,
fa,
fr, fr,
he, he,
id, id,
@@ -395,13 +388,6 @@
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */, E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,
); );
}; };
7E9B64F63F93443FB7BA12DF /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@@ -542,6 +528,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -574,6 +561,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.5.3;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -632,6 +620,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.5.3;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -666,6 +655,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.5.3;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -726,6 +716,7 @@
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = "$(MARKETING_VERSION)";
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@@ -758,6 +749,7 @@
"@executable_path/../Frameworks", "@executable_path/../Frameworks",
); );
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.5.3;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat; PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES; REGISTER_APP_GROUPS = YES;
@@ -824,6 +816,7 @@
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)"; IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)"; MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = "$(MARKETING_VERSION)";
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
@@ -853,6 +846,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = "$(MARKETING_VERSION)";
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension"; PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER).ShareExtension";
SDKROOT = iphoneos; SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+28 -1
View File
@@ -2,6 +2,11 @@ import BitFoundation
import Combine import Combine
import Foundation import Foundation
enum SharedContentKind: String, Sendable, Equatable {
case text
case url
}
enum RuntimeScenePhase: String, Sendable, Equatable { enum RuntimeScenePhase: String, Sendable, Equatable {
case active case active
case inactive case inactive
@@ -20,7 +25,7 @@ enum AppEvent: Sendable, Equatable {
case startupCompleted case startupCompleted
case scenePhaseChanged(RuntimeScenePhase) case scenePhaseChanged(RuntimeScenePhase)
case openedURL(String) case openedURL(String)
case sharedContentReadyForReview(SharedContentKind) case sharedContentAccepted(SharedContentKind)
case notificationOpened(peerID: PeerID?) case notificationOpened(peerID: PeerID?)
case deepLinkOpened(String) case deepLinkOpened(String)
case torLifecycleChanged(TorLifecycleEvent) case torLifecycleChanged(TorLifecycleEvent)
@@ -31,12 +36,34 @@ enum AppEvent: Sendable, Equatable {
actor AppEventStream { actor AppEventStream {
private var continuations: [UUID: AsyncStream<AppEvent>.Continuation] = [:] private var continuations: [UUID: AsyncStream<AppEvent>.Continuation] = [:]
func stream() -> AsyncStream<AppEvent> {
let id = UUID()
return AsyncStream { continuation in
continuations[id] = continuation
continuation.onTermination = { [id] _ in
Task {
await self.removeContinuation(id)
}
}
}
}
func emit(_ event: AppEvent) { func emit(_ event: AppEvent) {
for continuation in continuations.values { for continuation in continuations.values {
continuation.yield(event) continuation.yield(event)
} }
} }
func finish() {
for continuation in continuations.values {
continuation.finish()
}
continuations.removeAll()
}
private func removeContinuation(_ id: UUID) {
continuations.removeValue(forKey: id)
}
} }
/// Identity key for a direct conversation. Equality and hashing use the /// Identity key for a direct conversation. Equality and hashing use the
+1 -50
View File
@@ -10,32 +10,16 @@ final class AppChromeModel: ObservableObject {
@Published var showingFingerprintFor: PeerID? @Published var showingFingerprintFor: PeerID?
@Published var isAppInfoPresented = false @Published var isAppInfoPresented = false
@Published var isLocationChannelsSheetPresented = false @Published var isLocationChannelsSheetPresented = false
@Published var isNoticesSheetPresented = false
/// When the sheet is opened for "notes left here" (empty mesh timeline),
/// it should land on the geo tab instead of the channel-derived default.
@Published var noticesSheetPrefersGeoTab = false
@Published var showBluetoothAlert = false @Published var showBluetoothAlert = false
@Published var bluetoothAlertMessage = "" @Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown @Published var bluetoothState: CBManagerState = .unknown
@Published var showScreenshotPrivacyWarning = false @Published var showScreenshotPrivacyWarning = false
private let chatViewModel: ChatViewModel private let chatViewModel: ChatViewModel
private let onPanicWipe: () -> Void
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
/// The composer owns capture state above ChatViewModel. ContentView
/// installs this hook so both panic entry points synchronously stop it.
private var prepareForPanic: (@MainActor () -> Void)?
/// Bulletin-board coordinator, created on first use of the board sheet. init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
init(
chatViewModel: ChatViewModel,
privateInboxModel: PrivateInboxModel,
onPanicWipe: @escaping () -> Void = {}
) {
self.chatViewModel = chatViewModel self.chatViewModel = chatViewModel
self.onPanicWipe = onPanicWipe
self.nickname = chatViewModel.nickname self.nickname = chatViewModel.nickname
bind(privateInboxModel: privateInboxModel) bind(privateInboxModel: privateInboxModel)
@@ -75,44 +59,11 @@ final class AppChromeModel: ObservableObject {
isAppInfoPresented = true isAppInfoPresented = true
} }
func presentNotices(geoTab: Bool = false) {
noticesSheetPrefersGeoTab = geoTab
isNoticesSheetPresented = true
}
/// Builds the mesh topology map model from the transport's gossiped
/// graph plus the live nickname table. Unknown nodes (heard about via a
/// neighbor claim but never announced to us) fall back to a short ID.
func meshTopologyDisplayModel() -> MeshTopologyDisplayModel {
let mesh = chatViewModel.meshService
guard let snapshot = mesh.currentMeshTopology() else { return .empty }
let nicknames = mesh.getPeerNicknames()
let nodes = snapshot.nodes.map { peerID -> MeshTopologyDisplayModel.Node in
let isSelf = peerID == snapshot.localPeerID
let label: String
if isSelf {
label = chatViewModel.nickname
} else {
label = nicknames[peerID] ?? "\(peerID.id.prefix(8))"
}
return MeshTopologyDisplayModel.Node(id: peerID.id, label: label, isSelf: isSelf)
}
let edges = snapshot.edges.map { ($0.a.id, $0.b.id) }
return MeshTopologyDisplayModel(nodes: nodes, edges: edges)
}
func triggerScreenshotPrivacyWarning() { func triggerScreenshotPrivacyWarning() {
showScreenshotPrivacyWarning = true showScreenshotPrivacyWarning = true
} }
func setPanicPreparation(_ preparation: (@MainActor () -> Void)?) {
prepareForPanic = preparation
}
func panicClearAllData() { func panicClearAllData() {
prepareForPanic?()
onPanicWipe()
chatViewModel.panicClearAllData() chatViewModel.panicClearAllData()
} }
+38 -77
View File
@@ -18,6 +18,8 @@ final class AppRuntime: ObservableObject {
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models /// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models
/// and `ChatViewModel` observe and mutate it through its intent API. /// and `ChatViewModel` observe and mutate it through its intent API.
let conversations: ConversationStore let conversations: ConversationStore
let peerIdentityStore: PeerIdentityStore
let locationPresenceStore: LocationPresenceStore
let publicChatModel: PublicChatModel let publicChatModel: PublicChatModel
let privateInboxModel: PrivateInboxModel let privateInboxModel: PrivateInboxModel
let privateConversationModel: PrivateConversationModel let privateConversationModel: PrivateConversationModel
@@ -26,8 +28,6 @@ final class AppRuntime: ObservableObject {
let locationChannelsModel: LocationChannelsModel let locationChannelsModel: LocationChannelsModel
let peerListModel: PeerListModel let peerListModel: PeerListModel
let appChromeModel: AppChromeModel let appChromeModel: AppChromeModel
let boardAlertsModel: BoardAlertsModel
let sharedContentImportModel: SharedContentImportModel
private let idBridge: NostrIdentityBridge private let idBridge: NostrIdentityBridge
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@@ -41,9 +41,8 @@ final class AppRuntime: ObservableObject {
#endif #endif
init( init(
keychain: KeychainManagerProtocol = KeychainManager.makeDefault(), keychain: KeychainManagerProtocol = KeychainManager(),
idBridge: NostrIdentityBridge = NostrIdentityBridge(), idBridge: NostrIdentityBridge = NostrIdentityBridge()
sharedContentStore: SharedContentStore? = nil
) { ) {
self.idBridge = idBridge self.idBridge = idBridge
let conversations = ConversationStore() let conversations = ConversationStore()
@@ -51,6 +50,8 @@ final class AppRuntime: ObservableObject {
let locationPresenceStore = LocationPresenceStore() let locationPresenceStore = LocationPresenceStore()
let locationManager = LocationChannelManager.shared let locationManager = LocationChannelManager.shared
self.conversations = conversations self.conversations = conversations
self.peerIdentityStore = peerIdentityStore
self.locationPresenceStore = locationPresenceStore
self.chatViewModel = ChatViewModel( self.chatViewModel = ChatViewModel(
keychain: keychain, keychain: keychain,
idBridge: idBridge, idBridge: idBridge,
@@ -86,48 +87,17 @@ final class AppRuntime: ObservableObject {
peerIdentityStore: peerIdentityStore, peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore locationPresenceStore: locationPresenceStore
) )
let resolvedSharedContentStore: SharedContentStore?
if let sharedContentStore {
resolvedSharedContentStore = sharedContentStore
} else if let sharedDefaults = UserDefaults(suiteName: BitchatApp.groupID) {
resolvedSharedContentStore = SharedContentStore(defaults: sharedDefaults)
} else {
resolvedSharedContentStore = nil
}
let sharedContentImportModel = SharedContentImportModel(store: resolvedSharedContentStore)
self.sharedContentImportModel = sharedContentImportModel
self.appChromeModel = AppChromeModel( self.appChromeModel = AppChromeModel(
chatViewModel: self.chatViewModel, chatViewModel: self.chatViewModel,
privateInboxModel: self.privateInboxModel, privateInboxModel: self.privateInboxModel
onPanicWipe: { sharedContentImportModel.discardAll() }
) )
let chatViewModel = self.chatViewModel
self.boardAlertsModel = BoardAlertsModel(
arrivals: BoardStore.shared.postArrivals.eraseToAnyPublisher(),
wipes: BoardStore.shared.didWipe.eraseToAnyPublisher(),
dependencies: BoardAlertsModel.Dependencies(
isOwnPost: { post in
let key = chatViewModel.meshService.noiseSigningPublicKeyData()
return !key.isEmpty && key == post.authorSigningKey
},
emitSystemLine: { content, geohash in
if geohash.isEmpty {
chatViewModel.addMeshOnlySystemMessage(content)
} else {
chatViewModel.addGeohashSystemMessage(content, geohash: geohash)
}
}
)
)
if chatViewModel.networkActivationAllowed {
GeoRelayDirectory.shared.prefetchIfNeeded() GeoRelayDirectory.shared.prefetchIfNeeded()
}
bindRuntimeObservers() bindRuntimeObservers()
NotificationDelegate.shared.runtime = self NotificationDelegate.shared.runtime = self
} }
func start() { func start() {
guard chatViewModel.networkActivationAllowed else { return }
guard !started else { guard !started else {
checkForSharedContent() checkForSharedContent()
return return
@@ -166,14 +136,12 @@ final class AppRuntime: ObservableObject {
} }
func handleDidBecomeActiveNotification() { func handleDidBecomeActiveNotification() {
guard chatViewModel.networkActivationAllowed else { return }
chatViewModel.handleDidBecomeActive() chatViewModel.handleDidBecomeActive()
checkForSharedContent() checkForSharedContent()
} }
#if os(macOS) #if os(macOS)
func handleMacDidBecomeActiveNotification() { func handleMacDidBecomeActiveNotification() {
guard chatViewModel.networkActivationAllowed else { return }
record(.scenePhaseChanged(.active)) record(.scenePhaseChanged(.active))
chatViewModel.handleDidBecomeActive() chatViewModel.handleDidBecomeActive()
checkForSharedContent() checkForSharedContent()
@@ -192,7 +160,6 @@ final class AppRuntime: ObservableObject {
didEnterBackground = true didEnterBackground = true
case .active: case .active:
guard chatViewModel.networkActivationAllowed else { return }
record(.scenePhaseChanged(.active)) record(.scenePhaseChanged(.active))
chatViewModel.meshService.startServices() chatViewModel.meshService.startServices()
TorManager.shared.setAppForeground(true) TorManager.shared.setAppForeground(true)
@@ -235,17 +202,7 @@ final class AppRuntime: ObservableObject {
chatViewModel.applicationWillTerminate() chatViewModel.applicationWillTerminate()
} }
func handleNotificationResponse( func handleNotificationResponse(identifier: String, userInfo: [AnyHashable: Any]) {
identifier: String,
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
userInfo: [AnyHashable: Any]
) {
guard chatViewModel.networkActivationAllowed else { return }
if actionIdentifier == NotificationService.waveActionID {
chatViewModel.sendMeshWave()
return
}
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) { if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
record(.notificationOpened(peerID: peerID)) record(.notificationOpened(peerID: peerID))
chatViewModel.startPrivateChat(with: peerID) chatViewModel.startPrivateChat(with: peerID)
@@ -292,8 +249,6 @@ private extension AppRuntime {
NotificationCenter.default.publisher(for: .TorWillRestart) NotificationCenter.default.publisher(for: .TorWillRestart)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] _ in .sink { [weak self] _ in
guard self?.chatViewModel.networkActivationAllowed == true
else { return }
self?.record(.torLifecycleChanged(.willRestart)) self?.record(.torLifecycleChanged(.willRestart))
self?.chatViewModel.handleTorWillRestart() self?.chatViewModel.handleTorWillRestart()
} }
@@ -302,8 +257,6 @@ private extension AppRuntime {
NotificationCenter.default.publisher(for: .TorDidBecomeReady) NotificationCenter.default.publisher(for: .TorDidBecomeReady)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] _ in .sink { [weak self] _ in
guard self?.chatViewModel.networkActivationAllowed == true
else { return }
self?.record(.torLifecycleChanged(.didBecomeReady)) self?.record(.torLifecycleChanged(.didBecomeReady))
self?.chatViewModel.handleTorDidBecomeReady() self?.chatViewModel.handleTorDidBecomeReady()
} }
@@ -312,8 +265,6 @@ private extension AppRuntime {
NotificationCenter.default.publisher(for: .TorWillStart) NotificationCenter.default.publisher(for: .TorWillStart)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] _ in .sink { [weak self] _ in
guard self?.chatViewModel.networkActivationAllowed == true
else { return }
self?.record(.torLifecycleChanged(.willStart)) self?.record(.torLifecycleChanged(.willStart))
self?.chatViewModel.handleTorWillStart() self?.chatViewModel.handleTorWillStart()
} }
@@ -322,8 +273,6 @@ private extension AppRuntime {
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged) NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] notification in .sink { [weak self] notification in
guard self?.chatViewModel.networkActivationAllowed == true
else { return }
self?.record(.torLifecycleChanged(.preferenceChanged)) self?.record(.torLifecycleChanged(.preferenceChanged))
self?.chatViewModel.handleTorPreferenceChanged(notification) self?.chatViewModel.handleTorPreferenceChanged(notification)
} }
@@ -340,22 +289,36 @@ private extension AppRuntime {
} }
func checkForSharedContent() { func checkForSharedContent() {
let previousID = sharedContentImportModel.offer?.id guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID),
guard let payload = sharedContentImportModel.refresh( let sharedContent = userDefaults.string(forKey: "sharedContent"),
destination: currentSharedContentDestination let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
) else { return } return
if previousID != payload.id {
record(.sharedContentReadyForReview(payload.kind))
}
} }
var currentSharedContentDestination: SharedContentDestination { guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
SharedContentDestination.resolve( return
selectedPrivatePeerID: privateConversationModel.selectedPeerID, }
privateDisplayName: privateConversationModel.selectedHeaderState?.displayName,
activeChannel: locationChannelsModel.selectedChannel let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
)
userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate")
switch contentKind {
case .url:
if let data = sharedContent.data(using: .utf8),
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
let url = urlData["url"] {
chatViewModel.sendMessage(url)
} else {
chatViewModel.sendMessage(sharedContent)
}
case .text:
chatViewModel.sendMessage(sharedContent)
}
record(.sharedContentAccepted(contentKind))
} }
func handleNostrRelayConnectionChanged(_ isConnected: Bool) { func handleNostrRelayConnectionChanged(_ isConnected: Bool) {
@@ -364,9 +327,7 @@ private extension AppRuntime {
let becameConnected = isConnected && !lastNostrRelayConnectedState let becameConnected = isConnected && !lastNostrRelayConnectedState
lastNostrRelayConnectedState = isConnected lastNostrRelayConnectedState = isConnected
guard chatViewModel.networkActivationAllowed, guard started, becameConnected else { return }
started,
becameConnected else { return }
let isInitialConnection = !didHandleInitialNostrConnection let isInitialConnection = !didHandleInitialNostrConnection
didHandleInitialNostrConnection = true didHandleInitialNostrConnection = true
+35 -58
View File
@@ -39,17 +39,15 @@ final class Conversation: ObservableObject, Identifiable {
@Published private(set) var messages: [BitchatMessage] = [] @Published private(set) var messages: [BitchatMessage] = []
@Published private(set) var isUnread: Bool = false @Published private(set) var isUnread: Bool = false
/// Incrementally-maintained message-ID logical-index map for O(1) /// Incrementally-maintained message-ID index map for O(1) dedup and
/// dedup and delivery-status lookup. Logical indexes are physical array /// delivery-status lookup. Kept in sync on every mutation:
/// indexes plus `indexOffset`; trimming from the head advances the offset /// - tail append: single insert
/// instead of rewriting every surviving dictionary entry. This matters /// - out-of-order insert: suffix reindex from the insertion point
/// after the 1337-message cap is reached, when every steady-state tail /// - trim: full rebuild `removeFirst(k)` is already O(n), so the
/// append evicts one old row. /// rebuild does not change the asymptotics, and trim only happens once
/// /// the cap (1337) is reached. Simple and correct beats the
/// Out-of-order inserts and middle removals still reindex only the /// offset-tracking alternative here.
/// affected suffix. Full filtering resets the offset while rebuilding.
private var indexByMessageID: [String: Int] = [:] private var indexByMessageID: [String: Int] = [:]
private var indexOffset = 0
fileprivate init(id: ConversationID, cap: Int) { fileprivate init(id: ConversationID, cap: Int) {
self.id = id self.id = id
@@ -63,7 +61,7 @@ final class Conversation: ObservableObject, Identifiable {
} }
func message(withID messageID: String) -> BitchatMessage? { func message(withID messageID: String) -> BitchatMessage? {
guard let index = physicalIndex(forMessageID: messageID) else { return nil } guard let index = indexByMessageID[messageID] else { return nil }
return messages[index] return messages[index]
} }
@@ -103,7 +101,7 @@ final class Conversation: ObservableObject, Identifiable {
reindex(from: index) reindex(from: index)
} else { } else {
messages.append(message) messages.append(message)
indexByMessageID[message.id] = indexOffset + messages.count - 1 indexByMessageID[message.id] = messages.count - 1
} }
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded()) return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
@@ -113,7 +111,7 @@ final class Conversation: ObservableObject, Identifiable {
/// timeline position (in-place updates like media progress reuse the /// timeline position (in-place updates like media progress reuse the
/// original timestamp); a new message goes through ordered insertion. /// original timestamp); a new message goes through ordered insertion.
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome { fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
if let index = physicalIndex(forMessageID: message.id) { if let index = indexByMessageID[message.id] {
messages[index] = message messages[index] = message
return .updated return .updated
} }
@@ -127,7 +125,7 @@ final class Conversation: ObservableObject, Identifiable {
/// `.read` is never downgraded to `.delivered` or `.sent`. /// `.read` is never downgraded to `.delivered` or `.sent`.
/// Returns `true` when the status was applied. /// Returns `true` when the status was applied.
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
guard let index = physicalIndex(forMessageID: messageID) else { return false } guard let index = indexByMessageID[messageID] else { return false }
let message = messages[index] let message = messages[index]
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false } guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
@@ -144,7 +142,7 @@ final class Conversation: ObservableObject, Identifiable {
/// observers still need an @Published emission to re-render. /// observers still need an @Published emission to re-render.
@discardableResult @discardableResult
fileprivate func republishMessage(withID messageID: String) -> Bool { fileprivate func republishMessage(withID messageID: String) -> Bool {
guard let index = physicalIndex(forMessageID: messageID) else { return false } guard let index = indexByMessageID[messageID] else { return false }
messages[index] = messages[index] messages[index] = messages[index]
return true return true
} }
@@ -159,14 +157,10 @@ final class Conversation: ObservableObject, Identifiable {
/// Removes a single message by ID. Returns the removed message, or /// Removes a single message by ID. Returns the removed message, or
/// `nil` when no message with that ID exists. /// `nil` when no message with that ID exists.
fileprivate func remove(messageID: String) -> BitchatMessage? { fileprivate func remove(messageID: String) -> BitchatMessage? {
guard let index = physicalIndex(forMessageID: messageID) else { return nil } guard let index = indexByMessageID[messageID] else { return nil }
let removed = messages.remove(at: index) let removed = messages.remove(at: index)
indexByMessageID.removeValue(forKey: messageID) indexByMessageID.removeValue(forKey: messageID)
if index == 0 {
indexOffset += 1
} else {
reindex(from: index) reindex(from: index)
}
return removed return removed
} }
@@ -183,7 +177,6 @@ final class Conversation: ObservableObject, Identifiable {
for id in removedIDs { for id in removedIDs {
indexByMessageID.removeValue(forKey: id) indexByMessageID.removeValue(forKey: id)
} }
indexOffset = 0
reindex(from: 0) reindex(from: 0)
return removedIDs return removedIDs
} }
@@ -191,7 +184,6 @@ final class Conversation: ObservableObject, Identifiable {
fileprivate func clearMessages() { fileprivate func clearMessages() {
messages.removeAll() messages.removeAll()
indexByMessageID.removeAll() indexByMessageID.removeAll()
indexOffset = 0
} }
// MARK: Diagnostics // MARK: Diagnostics
@@ -213,10 +205,9 @@ final class Conversation: ObservableObject, Identifiable {
let message = messages[position] let message = messages[position]
// Count equality + every message resolving to its own position // Count equality + every message resolving to its own position
// proves the index is exactly the inverse map (no stale extras). // proves the index is exactly the inverse map (no stale extras).
if let logicalIndex = indexByMessageID[message.id] { if let index = indexByMessageID[message.id] {
let expectedIndex = indexOffset + position if index != position {
if logicalIndex != expectedIndex { violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(index)")
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(logicalIndex - indexOffset)")
} }
} else { } else {
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index") violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
@@ -234,25 +225,8 @@ final class Conversation: ObservableObject, Identifiable {
guard let current else { return false } guard let current else { return false }
if current == new { return true } if current == new { return true }
// Never downgrade to a weaker delivery state. Ordering of certainty:
// sending < sent < carried < delivered < read. A late `.sent` write
// (e.g. the optimistic stamp after routing) must not clobber the
// `.carried` the router already set when it handed a copy to a
// courier/bridge, nor a `.delivered`/`.read` ack. A late asynchronous
// failure is weaker than a confirmed recipient receipt too, so it may
// not replace `.delivered`/`.read`. Same for the
// `.sending` stamp a pre-handshake resend emits asynchronously: it
// can land after the message already reached `.sent`, and "Sent" was
// already truthful. (`.failed` `.sending` stays allowed so a real
// failure retry is visible.)
switch (current, new) { switch (current, new) {
case (.read, .delivered), (.read, .carried), (.read, .sent), (.read, .sending), (.read, .failed): case (.read, .delivered), (.read, .sent):
return true
case (.delivered, .carried), (.delivered, .sent), (.delivered, .sending), (.delivered, .failed):
return true
case (.carried, .sent), (.carried, .sending):
return true
case (.sent, .sending):
return true return true
default: default:
return false return false
@@ -278,17 +252,10 @@ final class Conversation: ObservableObject, Identifiable {
private func reindex(from start: Int) { private func reindex(from start: Int) {
for index in start..<messages.count { for index in start..<messages.count {
indexByMessageID[messages[index].id] = indexOffset + index indexByMessageID[messages[index].id] = index
} }
} }
private func physicalIndex(forMessageID messageID: String) -> Int? {
guard let logicalIndex = indexByMessageID[messageID] else { return nil }
let index = logicalIndex - indexOffset
guard messages.indices.contains(index) else { return nil }
return index
}
/// Trims oldest messages over the cap; returns the trimmed message IDs. /// Trims oldest messages over the cap; returns the trimmed message IDs.
private func trimIfNeeded() -> [String] { private func trimIfNeeded() -> [String] {
guard messages.count > cap else { return [] } guard messages.count > cap else { return [] }
@@ -298,7 +265,7 @@ final class Conversation: ObservableObject, Identifiable {
indexByMessageID.removeValue(forKey: id) indexByMessageID.removeValue(forKey: id)
} }
messages.removeFirst(overflow) messages.removeFirst(overflow)
indexOffset += overflow reindex(from: 0)
return trimmedIDs return trimmedIDs
} }
} }
@@ -829,6 +796,16 @@ extension ConversationStore {
return messageIDs return messageIDs
} }
/// Removes every direct conversation (panic clear).
func removeAllDirectConversations() {
let directIDs = conversationIDs.filter { id in
if case .direct = id { return true }
return false
}
for id in directIDs {
removeConversation(id)
}
}
} }
// MARK: - Diagnostics support // MARK: - Diagnostics support
@@ -860,8 +837,8 @@ extension Conversation {
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages. /// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
func _testCorruptIndexEntries() { func _testCorruptIndexEntries() {
guard messages.count >= 2 else { return } guard messages.count >= 2 else { return }
indexByMessageID[messages[0].id] = indexOffset + 1 indexByMessageID[messages[0].id] = 1
indexByMessageID[messages[1].id] = indexOffset indexByMessageID[messages[1].id] = 0
} }
/// Drops a message's index entry entirely (count mismatch + missing). /// Drops a message's index entry entirely (count mismatch + missing).
@@ -875,8 +852,8 @@ extension Conversation {
func _testCorruptOrderingPreservingIndex() { func _testCorruptOrderingPreservingIndex() {
guard messages.count >= 2 else { return } guard messages.count >= 2 else { return }
messages.swapAt(0, messages.count - 1) messages.swapAt(0, messages.count - 1)
indexByMessageID[messages[0].id] = indexOffset indexByMessageID[messages[0].id] = 0
indexByMessageID[messages[messages.count - 1].id] = indexOffset + messages.count - 1 indexByMessageID[messages[messages.count - 1].id] = messages.count - 1
} }
} }
@@ -916,7 +893,7 @@ extension ConversationStore {
extension Conversation { extension Conversation {
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) { fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
messages.append(message) messages.append(message)
indexByMessageID[message.id] = indexOffset + messages.count - 1 indexByMessageID[message.id] = messages.count - 1
} }
} }
#endif #endif
+1 -33
View File
@@ -12,10 +12,6 @@ final class ConversationUIModel: ObservableObject {
@Published private(set) var currentNickname: String @Published private(set) var currentNickname: String
@Published private(set) var isBatchingPublic = false @Published private(set) var isBatchingPublic = false
@Published private(set) var canSendMediaInCurrentContext = true @Published private(set) var canSendMediaInCurrentContext = true
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
/// Who is talking live in the public mesh channel right now (floor
/// courtesy: the composer mic tints "busy" while someone holds the floor).
@Published private(set) var activeLiveVoiceTalker: String?
private let chatViewModel: ChatViewModel private let chatViewModel: ChatViewModel
private let privateConversationModel: PrivateConversationModel private let privateConversationModel: PrivateConversationModel
@@ -154,24 +150,6 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.sendVoiceNote(at: url) chatViewModel.sendVoiceNote(at: url)
} }
func resolveLegacyPrivateMediaConsent(requestID: UUID, approved: Bool) {
chatViewModel.resolveLegacyPrivateMediaConsent(
requestID: requestID,
approved: approved
)
}
/// Capture backend for the mic gesture: live PTT when the current DM
/// peer can hear it now, classic voice note otherwise.
func makeVoiceCaptureSession() -> VoiceCaptureSession {
chatViewModel.makeVoiceCaptureSession()
}
/// Whether this message is a live voice burst still streaming in.
func isLiveVoiceMessage(_ message: BitchatMessage) -> Bool {
chatViewModel.liveVoiceCoordinator.isLiveVoiceMessage(message)
}
func cancelMediaSend(messageID: String) { func cancelMediaSend(messageID: String) {
chatViewModel.cancelMediaSend(messageID: messageID) chatViewModel.cancelMediaSend(messageID: messageID)
} }
@@ -197,14 +175,6 @@ final class ConversationUIModel: ObservableObject {
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.assign(to: &$isBatchingPublic) .assign(to: &$isBatchingPublic)
chatViewModel.$activePublicVoiceTalker
.receive(on: DispatchQueue.main)
.assign(to: &$activeLiveVoiceTalker)
chatViewModel.$legacyPrivateMediaConsentRequest
.receive(on: DispatchQueue.main)
.assign(to: &$legacyPrivateMediaConsentRequest)
conversations.$activeChannel conversations.$activeChannel
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] channel in .sink { [weak self] channel in
@@ -223,9 +193,7 @@ final class ConversationUIModel: ObservableObject {
private func refreshComputedState() { private func refreshComputedState() {
if let selectedPeerID = privateConversationModel.selectedPeerID { if let selectedPeerID = privateConversationModel.selectedPeerID {
// Media transfer is not wired for groups in v1; keep it off so the canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat)
// composer can't strand a media placeholder that never sends.
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat || selectedPeerID.isGroup)
return return
} }
+3 -11
View File
@@ -1,3 +1,4 @@
import BitFoundation
import Combine import Combine
import Foundation import Foundation
@@ -11,25 +12,20 @@ final class LocationChannelsModel: ObservableObject {
@Published private(set) var bookmarkNames: [String: String] @Published private(set) var bookmarkNames: [String: String]
@Published private(set) var locationNames: [GeohashChannelLevel: String] @Published private(set) var locationNames: [GeohashChannelLevel: String]
@Published private(set) var userTorEnabled: Bool @Published private(set) var userTorEnabled: Bool
@Published private(set) var gatewayEnabled: Bool
private let manager: LocationChannelManager private let manager: LocationChannelManager
private let network: NetworkActivationService private let network: NetworkActivationService
private let gateway: GatewayService private var cancellables = Set<AnyCancellable>()
init( init(
manager: LocationChannelManager? = nil, manager: LocationChannelManager? = nil,
network: NetworkActivationService? = nil, network: NetworkActivationService? = nil
gateway: GatewayService? = nil
) { ) {
let manager = manager ?? .shared let manager = manager ?? .shared
let network = network ?? .shared let network = network ?? .shared
let gateway = gateway ?? .shared
self.manager = manager self.manager = manager
self.network = network self.network = network
self.gateway = gateway
self.gatewayEnabled = gateway.isEnabled
self.permissionState = manager.permissionState self.permissionState = manager.permissionState
self.availableChannels = manager.availableChannels self.availableChannels = manager.availableChannels
self.selectedChannel = manager.selectedChannel self.selectedChannel = manager.selectedChannel
@@ -164,10 +160,6 @@ final class LocationChannelsModel: ObservableObject {
network.$userTorEnabled network.$userTorEnabled
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.assign(to: &$userTorEnabled) .assign(to: &$userTorEnabled)
gateway.$isEnabled
.receive(on: DispatchQueue.main)
.assign(to: &$gatewayEnabled)
} }
private func level(forLength length: Int) -> GeohashChannelLevel { private func level(forLength length: Int) -> GeohashChannelLevel {
+9 -110
View File
@@ -7,146 +7,45 @@ final class LocationPresenceStore: ObservableObject {
@Published private(set) var geoNicknames: [String: String] = [:] @Published private(set) var geoNicknames: [String: String] = [:]
@Published private(set) var teleportedGeo: Set<String> = [] @Published private(set) var teleportedGeo: Set<String> = []
private let teleportedGeoCapacity: Int
private var teleportedGeoOrder: [String] = []
private let geoNicknameCapacity: Int
private var geoNicknameOrder: [String] = []
init(
teleportedGeoCapacity: Int = TransportConfig.geoTeleportedParticipantsCap,
geoNicknameCapacity: Int = TransportConfig.geoNicknameParticipantsCap
) {
self.teleportedGeoCapacity = max(0, teleportedGeoCapacity)
self.geoNicknameCapacity = max(0, geoNicknameCapacity)
}
func setCurrentGeohash(_ geohash: String?) { func setCurrentGeohash(_ geohash: String?) {
let normalized = geohash?.lowercased() currentGeohash = geohash?.lowercased()
if currentGeohash != normalized {
// Presence markers are scoped to the active geohash channel.
clearTeleportedGeo()
clearGeoNicknames()
}
currentGeohash = normalized
} }
func setNickname(_ nickname: String, for pubkeyHex: String) { func setNickname(_ nickname: String, for pubkeyHex: String) {
guard geoNicknameCapacity > 0 else { geoNicknames[pubkeyHex.lowercased()] = nickname
clearGeoNicknames()
return
}
let key = pubkeyHex.lowercased()
if geoNicknames[key] != nil {
geoNicknames[key] = nickname
return
}
while geoNicknameOrder.count >= geoNicknameCapacity, let oldest = geoNicknameOrder.first {
geoNicknameOrder.removeFirst()
geoNicknames.removeValue(forKey: oldest)
}
geoNicknames[key] = nickname
geoNicknameOrder.append(key)
} }
func replaceGeoNicknames(_ nicknames: [String: String]) { func replaceGeoNicknames(_ nicknames: [String: String]) {
guard geoNicknameCapacity > 0 else { geoNicknames = Dictionary(
clearGeoNicknames() uniqueKeysWithValues: nicknames.map { key, value in
return (key.lowercased(), value)
} }
)
var seen: Set<String> = []
var ordered: [String] = []
var normalized: [String: String] = [:]
for (key, value) in nicknames {
let lower = key.lowercased()
guard seen.insert(lower).inserted else { continue }
ordered.append(lower)
normalized[lower] = value
}
if ordered.count > geoNicknameCapacity {
let kept = Array(ordered.suffix(geoNicknameCapacity))
ordered = kept
normalized = Dictionary(uniqueKeysWithValues: kept.compactMap { key in
normalized[key].map { (key, $0) }
})
}
geoNicknameOrder = ordered
geoNicknames = normalized
} }
func clearGeoNicknames() { func clearGeoNicknames() {
geoNicknames.removeAll() geoNicknames.removeAll()
geoNicknameOrder.removeAll()
}
func retainGeoNicknames(keeping pubkeys: Set<String>) {
let allowed = Set(pubkeys.map { $0.lowercased() })
geoNicknameOrder = geoNicknameOrder.filter { allowed.contains($0) }
geoNicknames = geoNicknames.filter { allowed.contains($0.key) }
} }
func markTeleported(_ pubkeyHex: String) { func markTeleported(_ pubkeyHex: String) {
guard teleportedGeoCapacity > 0 else { teleportedGeo.insert(pubkeyHex.lowercased())
clearTeleportedGeo()
return
}
let key = pubkeyHex.lowercased()
guard !teleportedGeo.contains(key) else { return }
while teleportedGeoOrder.count >= teleportedGeoCapacity, let oldest = teleportedGeoOrder.first {
teleportedGeoOrder.removeFirst()
teleportedGeo.remove(oldest)
}
teleportedGeo.insert(key)
teleportedGeoOrder.append(key)
} }
func clearTeleported(_ pubkeyHex: String) { func clearTeleported(_ pubkeyHex: String) {
let key = pubkeyHex.lowercased() teleportedGeo.remove(pubkeyHex.lowercased())
teleportedGeo.remove(key)
teleportedGeoOrder.removeAll { $0 == key }
} }
func replaceTeleportedGeo(_ pubkeys: Set<String>) { func replaceTeleportedGeo(_ pubkeys: Set<String>) {
guard teleportedGeoCapacity > 0 else { teleportedGeo = Set(pubkeys.map { $0.lowercased() })
clearTeleportedGeo()
return
}
var seen: Set<String> = []
var ordered: [String] = []
for key in pubkeys.map({ $0.lowercased() }) where !seen.contains(key) {
seen.insert(key)
ordered.append(key)
}
if ordered.count > teleportedGeoCapacity {
ordered = Array(ordered.suffix(teleportedGeoCapacity))
}
teleportedGeoOrder = ordered
teleportedGeo = Set(ordered)
}
func retainTeleportedGeo(keeping pubkeys: Set<String>) {
let allowed = Set(pubkeys.map { $0.lowercased() })
teleportedGeoOrder = teleportedGeoOrder.filter { allowed.contains($0) }
teleportedGeo = teleportedGeo.intersection(allowed)
} }
func clearTeleportedGeo() { func clearTeleportedGeo() {
teleportedGeo.removeAll() teleportedGeo.removeAll()
teleportedGeoOrder.removeAll()
} }
func reset() { func reset() {
currentGeohash = nil currentGeohash = nil
geoNicknames.removeAll() geoNicknames.removeAll()
geoNicknameOrder.removeAll()
teleportedGeo.removeAll() teleportedGeo.removeAll()
teleportedGeoOrder.removeAll()
} }
} }
-147
View File
@@ -1,147 +0,0 @@
//
// NearbyNotesCounter.swift
// bitchat
//
// Counts unexpired location notes left at the user's current building-level
// geohash so the empty mesh timeline can say "📍 3 notes left here". Only
// subscribes while a view holds it active, and only when location notes are
// enabled and location permission is already granted (it never prompts).
// This is free and unencumbered software released into the public domain.
//
import Combine
import Foundation
@MainActor
final class NearbyNotesCounter: ObservableObject {
static let shared = NearbyNotesCounter()
@Published private(set) var noteCount = 0
/// Whether an explicit notes act (the empty-timeline "check for notes"
/// tap, opening the notices sheet's geo tab, or a successful /drop) has
/// unlocked the counter this session. Until then nothing subscribes:
/// merely looking at the mesh timeline must not open a building-precision
/// relay REQ that leaks location passively.
@Published private(set) var revealed = false
private var manager: LocationNotesManager?
private var managerCancellable: AnyCancellable?
private var channelsCancellable: AnyCancellable?
private var permissionCancellable: AnyCancellable?
private var settingCancellable: AnyCancellable?
private var activeHolders = 0
private let locationManager: LocationChannelManager
private let managerFactory: @MainActor (String) -> LocationNotesManager
private let releaseManager: @MainActor (LocationNotesManager?) -> Void
private let locationNotesEnabled: @MainActor () -> Bool
private let locationNotesSettingsPublisher: AnyPublisher<Void, Never>
init(
locationManager: LocationChannelManager = .shared,
managerFactory: @escaping @MainActor (String) -> LocationNotesManager = { LocationNotesPool.shared.acquire($0) },
releaseManager: @escaping @MainActor (LocationNotesManager?) -> Void = { LocationNotesPool.shared.release($0) },
locationNotesEnabled: @escaping @MainActor () -> Bool = { LocationNotesSettings.enabled },
locationNotesSettings: AnyPublisher<Void, Never>? = nil
) {
self.locationManager = locationManager
self.managerFactory = managerFactory
self.releaseManager = releaseManager
self.locationNotesEnabled = locationNotesEnabled
self.locationNotesSettingsPublisher = locationNotesSettings
?? NotificationCenter.default
.publisher(for: LocationNotesSettings.didChangeNotification)
.map { _ in () }
.eraseToAnyPublisher()
}
/// Whether the empty-timeline "check for notes" hint should render.
/// The permission gate matters: `retarget()` never subscribes without
/// location authorization, so offering the hint to an unauthorized
/// install would be a silent dead-end tap, `revealed` flips, the hint
/// vanishes, and nothing else happens for the session. The hint never
/// prompts; it simply stays hidden until permission exists. The caller
/// passes its own observed permission state so the hint re-renders when
/// authorization changes.
func offersRevealHint(permissionState: LocationChannelManager.PermissionState) -> Bool {
!revealed && locationNotesEnabled() && permissionState == .authorized
}
/// Marks the one explicit act that lets the counter subscribe. Sticky for
/// the rest of the session (the singleton's lifetime); `deactivate()`
/// deliberately does not reset it.
func reveal() {
guard !revealed else { return }
revealed = true
retarget()
}
/// Begins (or keeps) the notes subscription for the current building
/// geohash. Balanced by `deactivate()`; ref-counted so multiple views can
/// hold it.
func activate() {
activeHolders += 1
guard activeHolders == 1 else { return }
channelsCancellable = locationManager.$availableChannels
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.retarget() }
// CoreLocation can revoke authorization while the view remains
// mounted. `availableChannels` deliberately retains its last value,
// so permission must be an independent invalidation signal or the
// building REQ survives on stale coordinates.
permissionCancellable = locationManager.$permissionState
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.retarget() }
// The app-info kill switch must take effect immediately, not on the
// next location change or remount.
settingCancellable = locationNotesSettingsPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in self?.retarget() }
retarget()
}
func deactivate() {
activeHolders = max(0, activeHolders - 1)
guard activeHolders == 0 else { return }
channelsCancellable = nil
permissionCancellable = nil
settingCancellable = nil
managerCancellable = nil
releaseManager(manager)
manager = nil
noteCount = 0
}
private func retarget() {
guard activeHolders > 0,
revealed,
locationNotesEnabled(),
locationManager.permissionState == .authorized,
let geohash = locationManager.availableChannels
.first(where: { $0.level == .building })?.geohash
else {
managerCancellable = nil
releaseManager(manager)
manager = nil
noteCount = 0
return
}
if let manager {
guard manager.geohash != geohash.lowercased() else { return }
// Pooled managers are shared; never retarget one in place
// release the old cell and acquire the new one.
managerCancellable = nil
releaseManager(manager)
self.manager = nil
}
let fresh = managerFactory(geohash)
manager = fresh
managerCancellable = fresh.$notes
.receive(on: DispatchQueue.main)
.sink { [weak self] notes in
let now = Date()
self?.noteCount = notes.filter { $0.expiresAt.map { $0 > now } ?? true }.count
}
}
}
+8
View File
@@ -25,6 +25,10 @@ final class PeerIdentityStore: ObservableObject {
stablePeerIDsByShortID[peerID] = stablePeerID stablePeerIDsByShortID[peerID] = stablePeerID
} }
func replaceStablePeerIDs(_ mappings: [PeerID: PeerID]) {
stablePeerIDsByShortID = mappings
}
func fingerprint(for peerID: PeerID) -> String? { func fingerprint(for peerID: PeerID) -> String? {
peerFingerprintsByPeerID[peerID] peerFingerprintsByPeerID[peerID]
} }
@@ -90,6 +94,10 @@ final class PeerIdentityStore: ObservableObject {
invalidateEncryptionCache(for: peerID) invalidateEncryptionCache(for: peerID)
} }
func replaceEncryptionStatuses(_ statuses: [PeerID: EncryptionStatus]) {
encryptionStatuses = statuses
}
func setVerifiedFingerprints(_ fingerprints: Set<String>) { func setVerifiedFingerprints(_ fingerprints: Set<String>) {
verifiedFingerprints = fingerprints verifiedFingerprints = fingerprints
} }
+8 -47
View File
@@ -14,9 +14,6 @@ struct MeshPeerRow: Identifiable, Equatable {
let isMutualFavorite: Bool let isMutualFavorite: Bool
let encryptionStatus: EncryptionStatus let encryptionStatus: EncryptionStatus
let showsVerifiedBadgeWhenOffline: Bool let showsVerifiedBadgeWhenOffline: Bool
/// Vouched-for by someone I verified, without an explicit verification of
/// mine rendered as the unfilled seal (verified gets the filled one).
let showsVouchedBadge: Bool
var id: String { peerID.id } var id: String { peerID.id }
} }
@@ -29,22 +26,11 @@ struct GeohashPersonRow: Identifiable, Equatable {
let isBlocked: Bool let isBlocked: Bool
} }
struct GroupChatRow: Identifiable, Equatable {
let peerID: PeerID
let name: String
let memberCount: Int
let isCreator: Bool
let hasUnread: Bool
var id: String { peerID.id }
}
@MainActor @MainActor
final class PeerListModel: ObservableObject { final class PeerListModel: ObservableObject {
@Published private(set) var allPeers: [BitchatPeer] = [] @Published private(set) var allPeers: [BitchatPeer] = []
@Published private(set) var meshRows: [MeshPeerRow] = [] @Published private(set) var meshRows: [MeshPeerRow] = []
@Published private(set) var geohashPeople: [GeohashPersonRow] = [] @Published private(set) var geohashPeople: [GeohashPersonRow] = []
@Published private(set) var groupRows: [GroupChatRow] = []
@Published private(set) var reachableMeshPeerCount = 0 @Published private(set) var reachableMeshPeerCount = 0
@Published private(set) var connectedMeshPeerCount = 0 @Published private(set) var connectedMeshPeerCount = 0
@Published private(set) var visibleGeohashPeerCount = 0 @Published private(set) var visibleGeohashPeerCount = 0
@@ -143,13 +129,6 @@ final class PeerListModel: ObservableObject {
} }
.store(in: &cancellables) .store(in: &cancellables)
chatViewModel.groupStore.$groups
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
}
.store(in: &cancellables)
peerIdentityStore.$encryptionStatuses peerIdentityStore.$encryptionStatuses
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] _ in .sink { [weak self] _ in
@@ -204,12 +183,13 @@ final class PeerListModel: ObservableObject {
let myPeerID = chatViewModel.meshService.myPeerID let myPeerID = chatViewModel.meshService.myPeerID
let meshRows = allPeers.map { peer in let meshRows = allPeers.map { peer in
let isMe = peer.peerID == myPeerID let isMe = peer.peerID == myPeerID
let fingerprint = isMe ? nil : chatViewModel.getFingerprint(for: peer.peerID) let verifiedBadge: Bool
let isVerifiedFingerprint = fingerprint.map { peerIdentityStore.isVerified($0) } ?? false if !isMe && !peer.isConnected,
let verifiedBadge = !peer.isConnected && isVerifiedFingerprint let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) {
// Vouched is subordinate to verified: never show both seals. verifiedBadge = peerIdentityStore.isVerified(fingerprint)
let vouchedBadge = !isVerifiedFingerprint } else {
&& (fingerprint.map { chatViewModel.isVouchedFingerprint($0) } ?? false) verifiedBadge = false
}
return MeshPeerRow( return MeshPeerRow(
peerID: peer.peerID, peerID: peer.peerID,
@@ -222,8 +202,7 @@ final class PeerListModel: ObservableObject {
isReachable: peer.isReachable, isReachable: peer.isReachable,
isMutualFavorite: peer.isMutualFavorite, isMutualFavorite: peer.isMutualFavorite,
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID), encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
showsVerifiedBadgeWhenOffline: verifiedBadge, showsVerifiedBadgeWhenOffline: verifiedBadge
showsVouchedBadge: vouchedBadge
) )
} }
@@ -238,40 +217,22 @@ final class PeerListModel: ObservableObject {
} }
let geohashPeople = buildGeohashPeople() let geohashPeople = buildGeohashPeople()
let groupRows = buildGroupRows()
self.meshRows = meshRows self.meshRows = meshRows
reachableMeshPeerCount = meshCounts.reachable reachableMeshPeerCount = meshCounts.reachable
connectedMeshPeerCount = meshCounts.connected connectedMeshPeerCount = meshCounts.connected
self.geohashPeople = geohashPeople self.geohashPeople = geohashPeople
visibleGeohashPeerCount = geohashPeople.count visibleGeohashPeerCount = geohashPeople.count
self.groupRows = groupRows
renderID = ( renderID = (
meshRows.map { meshRows.map {
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)" "\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
} + } +
geohashPeople.map { geohashPeople.map {
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)" "geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
} +
groupRows.map {
"group:\($0.id)-\($0.name)-\($0.memberCount)-\($0.hasUnread)"
} }
).joined(separator: "|") ).joined(separator: "|")
} }
private func buildGroupRows() -> [GroupChatRow] {
let myFingerprint = chatViewModel.meshService.noiseIdentityFingerprint()
return chatViewModel.groupStore.groups.map { group in
GroupChatRow(
peerID: group.peerID,
name: group.name,
memberCount: group.members.count,
isCreator: group.creatorFingerprint == myFingerprint,
hasUnread: chatViewModel.hasUnreadMessages(for: group.peerID)
)
}
}
private func buildGeohashPeople() -> [GeohashPersonRow] { private func buildGeohashPeople() -> [GeohashPersonRow] {
let myHex = currentGeohashIdentityHex() let myHex = currentGeohashIdentityHex()
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() }) let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
+5 -38
View File
@@ -108,13 +108,7 @@ struct PrivateConversationHeaderState: Equatable {
let encryptionStatus: EncryptionStatus? let encryptionStatus: EncryptionStatus?
var supportsFavoriteToggle: Bool { var supportsFavoriteToggle: Bool {
!conversationPeerID.isGeoDM && !conversationPeerID.isGroup !conversationPeerID.isGeoDM
}
/// Group chats have no single peer identity behind the header: no
/// fingerprint screen, no per-peer encryption badge.
var isGroupConversation: Bool {
conversationPeerID.isGroup
} }
} }
@@ -212,13 +206,6 @@ final class PrivateConversationModel: ObservableObject {
} }
.store(in: &cancellables) .store(in: &cancellables)
chatViewModel.groupStore.$groups
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
}
.store(in: &cancellables)
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated")) NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [weak self] _ in .sink { [weak self] _ in
@@ -242,33 +229,13 @@ final class PrivateConversationModel: ObservableObject {
} }
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState { private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
// Group chats: the "peer" is the whole crew. Name + member count in
// the header; availability reads as mesh since group traffic floods
// the local mesh, and the per-peer encryption badge does not apply.
if conversationPeerID.isGroup {
let displayName: String
if let group = chatViewModel.groupStore.group(for: conversationPeerID) {
displayName = "#\(group.name) (\(group.members.count))"
} else {
displayName = String(localized: "common.unknown", comment: "Fallback label for unknown peer")
}
return PrivateConversationHeaderState(
conversationPeerID: conversationPeerID,
headerPeerID: conversationPeerID,
displayName: displayName,
availability: .meshReachable,
isFavorite: false,
encryptionStatus: nil
)
}
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID) let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
let peer = chatViewModel.getPeer(byID: headerPeerID) let peer = chatViewModel.getPeer(byID: headerPeerID)
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer) let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
// Geo DMs are always routed through BitChat private envelopes over // Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
// Nostr; their nostr_ keys never resolve to a reachable mesh peer, so // never resolve to a reachable mesh peer, so resolveAvailability would
// resolveAvailability would report .offline. Report .nostrAvailable // report .offline. Report .nostrAvailable so the header shows the
// so the header shows the globe instead of a misleading "offline" tag. // globe instead of a misleading "offline" tag.
let availability = conversationPeerID.isGeoDM let availability = conversationPeerID.isGeoDM
? .nostrAvailable ? .nostrAvailable
: resolveAvailability(for: headerPeerID, peer: peer) : resolveAvailability(for: headerPeerID, peer: peer)
-119
View File
@@ -1,119 +0,0 @@
import BitFoundation
import Combine
import Foundation
enum SharedContentDestination: Sendable, Equatable {
case mesh
case geohash(String)
case privateConversation(peerID: PeerID, displayName: String)
static func resolve(
selectedPrivatePeerID: PeerID?,
privateDisplayName: String?,
activeChannel: ChannelID
) -> SharedContentDestination {
if let selectedPrivatePeerID {
let fallback = String(selectedPrivatePeerID.id.prefix(12))
return .privateConversation(
peerID: selectedPrivatePeerID,
displayName: privateDisplayName?.trimmedOrNilIfEmpty ?? fallback
)
}
switch activeChannel {
case .mesh:
return .mesh
case .location(let channel):
return .geohash(channel.geohash.lowercased())
}
}
var displayName: String {
switch self {
case .mesh:
return "#mesh"
case .geohash(let geohash):
return "#\(geohash)"
case .privateConversation(_, let displayName):
return displayName
}
}
}
struct SharedContentOffer: Identifiable, Sendable, Equatable {
let payload: SharedContentPayload
let destination: SharedContentDestination
var id: UUID { payload.id }
}
/// Holds a pending extension handoff until the user chooses a destination and
/// explicitly adds it to the composer. This type has no send dependency by
/// design: confirming an import can never transmit a message.
@MainActor
final class SharedContentImportModel: ObservableObject {
@Published private(set) var offer: SharedContentOffer?
private let store: SharedContentStore?
init(store: SharedContentStore?) {
self.store = store
}
@discardableResult
func refresh(
destination: SharedContentDestination,
now: Date = Date()
) -> SharedContentPayload? {
guard let payload = store?.pending(now: now) else {
offer = nil
return nil
}
let nextOffer = SharedContentOffer(payload: payload, destination: destination)
if offer != nextOffer {
offer = nextOffer
}
return payload
}
func updateDestination(_ destination: SharedContentDestination) {
guard let offer, offer.destination != destination else { return }
self.offer = SharedContentOffer(payload: offer.payload, destination: destination)
}
/// Returns composer text only when the currently displayed destination is
/// still current and the reviewed envelope is still the stored envelope.
/// A destination change updates the prompt and requires another tap.
func confirm(
destination: SharedContentDestination,
now: Date = Date()
) -> String? {
guard let offer else { return nil }
guard offer.destination == destination else {
updateDestination(destination)
return nil
}
guard let payload = store?.consume(id: offer.id, now: now) else {
_ = refresh(destination: destination, now: now)
return nil
}
self.offer = nil
return payload.composerText
}
func cancel(destination: SharedContentDestination, now: Date = Date()) {
guard let offer else { return }
store?.discard(id: offer.id)
self.offer = nil
// If a newer share replaced the reviewed envelope, surface it rather
// than losing it with the older cancellation.
_ = refresh(destination: destination, now: now)
}
func discardAll() {
store?.discardAll()
offer = nil
}
}
+7 -40
View File
@@ -3,19 +3,12 @@ import Combine
import Foundation import Foundation
struct FingerprintPresentationState: Equatable { struct FingerprintPresentationState: Equatable {
let statusPeerID: PeerID
let peerNickname: String let peerNickname: String
let encryptionStatus: EncryptionStatus let encryptionStatus: EncryptionStatus
let theirFingerprint: String? let theirFingerprint: String?
let myFingerprint: String let myFingerprint: String
let isVerified: Bool let isVerified: Bool
/// Number of currently-valid vouches from peers the user verified
/// (0 when the peer is explicitly verified the stronger badge wins).
let voucherCount: Int
/// Display names of the (verified) vouchers, where known.
let voucherNames: [String]
/// Vouched for by 1 peer the user verified (and not explicitly verified).
var isVouched: Bool { voucherCount > 0 }
var canToggleVerification: Bool { var canToggleVerification: Bool {
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
@@ -55,6 +48,10 @@ final class VerificationModel: ObservableObject {
return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? "" return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? ""
} }
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
chatViewModel.beginQRVerification(with: qr)
}
func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome { func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome {
guard let qr = VerificationService.shared.verifyScannedQR(payload) else { guard let qr = VerificationService.shared.verifyScannedQR(payload) else {
return .invalid return .invalid
@@ -85,33 +82,14 @@ final class VerificationModel: ObservableObject {
let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID) let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID)
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID) let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID) let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
let isVerified = theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
// Vouch state is recomputed on read: only vouchers still in the
// verified set count, so removing a verification silently retires the
// vouches that peer gave.
let vouchers: [VouchRecord]
if !isVerified, let theirFingerprint {
vouchers = chatViewModel.identityManager.validVouchers(for: theirFingerprint)
} else {
vouchers = []
}
let voucherNames = vouchers.compactMap { record -> String? in
guard let social = chatViewModel.identityManager.getSocialIdentity(for: record.voucherFingerprint) else {
return nil
}
if let petname = social.localPetname, !petname.isEmpty { return petname }
return social.claimedNickname.isEmpty ? nil : social.claimedNickname
}
return FingerprintPresentationState( return FingerprintPresentationState(
statusPeerID: statusPeerID,
peerNickname: peerNickname, peerNickname: peerNickname,
encryptionStatus: encryptionStatus, encryptionStatus: encryptionStatus,
theirFingerprint: theirFingerprint, theirFingerprint: theirFingerprint,
myFingerprint: chatViewModel.getMyFingerprint(), myFingerprint: chatViewModel.getMyFingerprint(),
isVerified: isVerified, isVerified: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
voucherCount: vouchers.count,
voucherNames: voucherNames
) )
} }
@@ -144,17 +122,6 @@ final class VerificationModel: ObservableObject {
self?.objectWillChange.send() self?.objectWillChange.send()
} }
.store(in: &cancellables) .store(in: &cancellables)
// Vouch state changes (ChatVouchCoordinator.notifyPeerTrustChanged)
// are signalled via this notification rather than a published
// property, so an open fingerprint sheet refreshes its vouched badge
// live when a vouch batch is accepted.
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.objectWillChange.send()
}
.store(in: &cancellables)
} }
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String { private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 848 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 460 B

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 833 B

After

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 833 B

After

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 597 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

@@ -27,66 +27,6 @@
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"
},
{
"filename" : "mac_16x16.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "mac_16x16@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "mac_32x32.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "mac_32x32@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "mac_128x128.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "mac_128x128@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "mac_256x256.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "mac_256x256@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "mac_512x512.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "mac_512x512@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
} }
], ],
"info" : { "info" : {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 505 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 930 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

+2 -12
View File
@@ -40,8 +40,6 @@ struct BitchatApp: App {
.environmentObject(runtime.locationChannelsModel) .environmentObject(runtime.locationChannelsModel)
.environmentObject(runtime.peerListModel) .environmentObject(runtime.peerListModel)
.environmentObject(runtime.appChromeModel) .environmentObject(runtime.appChromeModel)
.environmentObject(runtime.boardAlertsModel)
.environmentObject(runtime.sharedContentImportModel)
.onAppear { .onAppear {
appDelegate.runtime = runtime appDelegate.runtime = runtime
runtime.start() runtime.start()
@@ -105,20 +103,12 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
let identifier = response.notification.request.identifier let identifier = response.notification.request.identifier
let actionIdentifier = response.actionIdentifier
let userInfo = response.notification.request.content.userInfo let userInfo = response.notification.request.content.userInfo
// Complete only after the response is handled: for a background
// action (👋 wave) the system may suspend the app the moment the
// completion handler runs, which would drop the queued send.
Task { @MainActor in Task { @MainActor in
self.runtime?.handleNotificationResponse( self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo)
identifier: identifier,
actionIdentifier: actionIdentifier,
userInfo: userInfo
)
completionHandler()
} }
completionHandler()
} }
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
@@ -1,472 +0,0 @@
//
// AudioSessionCoordinator.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import AVFoundation
import BitLogger
import Foundation
/// The raw audio-session calls the coordinator makes, abstracted so the
/// state machine is unit-testable with a mock (and compiles on the macOS
/// test host, where `AVAudioSession` doesn't exist).
///
/// Calls arrive on the coordinator's private serial queue never the main
/// thread. `setCategory`/`setActive` block on IPC to the audio server
/// (observed >1 s under contention on device, tripping the system gesture
/// gate), and Apple explicitly recommends activating the session off the
/// main thread.
protocol SessionApplying: Sendable {
func setCategory(_ category: AudioSessionCoordinator.Category) throws
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws
}
/// Sole owner of `AVAudioSession` category/activation for voice features.
///
/// Talk-over means capture (push-to-talk) and playback (inbound bursts,
/// voice notes) can be live simultaneously; letting each engine configure
/// the shared session directly made them stomp each other's category and
/// route mid-flight (the AURemoteIO -10851 dead-input class). Instead every
/// client acquires a `Token` and the coordinator:
///
/// - reference-counts activation: `setActive(true)` only on the first
/// holder, `setActive(false, notifyOthersOnDeactivation:)` only when the
/// last one releases no client can deactivate another's session;
/// - keeps one escalating category: playback-only holders get `.playback`,
/// any capture holder escalates to `.playAndRecord`, and the category is
/// never downgraded while anyone still holds a token (capture ending must
/// not yank the route out from under live playback);
/// - fans out `onInterrupted` on system interruptions and when the active
/// route's device disappears (no auto-resume: bursts are transient, the
/// next press or burst simply re-acquires). The escalating category change
/// fans out separately as `onCategoryEscalated` the session stays live,
/// so holders that can rebuild their engine against the new configuration
/// keep playing (talk-over is bidirectional); holders that don't provide
/// it fall back to `onInterrupted`.
///
/// Threading: all state lives on a private serial queue, which both
/// serializes rapid acquire/release pairs and keeps the blocking session IPC
/// off the main thread (`acquire` is `async` for exactly that hop; `release`
/// is fire-and-forget onto the queue). Holder callbacks always run on the
/// main actor.
///
/// Microphone *permission* queries stay with their callers; this type owns
/// only category and activation.
///
/// `@unchecked Sendable`: every mutable property is confined to `queue`.
final class AudioSessionCoordinator: @unchecked Sendable {
enum Use {
case playback
case capture
}
/// The session category the coordinator has applied (the `SessionApplying`
/// adapter maps these to concrete `AVAudioSession` category/mode/options).
enum Category {
case playback
case playAndRecord
}
/// Opaque handle for one client's hold on the session. Release exactly
/// once when done (extra releases are ignored).
///
/// `@unchecked` because the stored callbacks are `@MainActor`-isolated
/// closures (non-Sendable as stored types). Lifecycle state is protected
/// by `stateLock`, and callbacks are only ever invoked on the main actor.
final class Token: @unchecked Sendable {
fileprivate enum CallbackKind: Sendable {
case interrupted
case categoryEscalated
}
/// A callback snapshot is only valid for the lifecycle epoch in which
/// it was captured. `release` advances the epoch synchronously before
/// its queue work, so a callback already headed to the main actor can't
/// reach a client that has since released this token and reacquired a
/// different one.
fileprivate struct CallbackTicket: Sendable {
let token: Token
let kind: CallbackKind
let lifecycleEpoch: UInt64
}
private enum Lifecycle {
/// Registered on the session queue, but `acquire` has not yet
/// returned into the client's main-actor call frame.
case acquiring
case ready
case released
}
fileprivate let onInterrupted: @MainActor () -> Void
fileprivate let onCategoryEscalated: (@MainActor () -> Void)?
private let stateLock = NSLock()
private var lifecycle = Lifecycle.acquiring
private var lifecycleEpoch: UInt64 = 0
/// A terminal event that lands while the token is registered but not
/// yet handed off invalidates the acquire before its caller can start.
private var terminalEventPendingHandoff = false
fileprivate init(
onInterrupted: @escaping @MainActor () -> Void,
onCategoryEscalated: (@MainActor () -> Void)?
) {
self.onInterrupted = onInterrupted
self.onCategoryEscalated = onCategoryEscalated
}
/// Records an event at the same linearization point at which the
/// coordinator snapshots its holders. An acquiring token cannot safely
/// receive a callback yet: terminal events invalidate the acquire,
/// while category escalation needs no callback because its engine will
/// start against the already-escalated configuration.
fileprivate func record(_ kind: CallbackKind) -> CallbackTicket? {
stateLock.withLock {
switch lifecycle {
case .acquiring:
switch kind {
case .interrupted:
terminalEventPendingHandoff = true
case .categoryEscalated:
break
}
return nil
case .ready:
return CallbackTicket(token: self, kind: kind, lifecycleEpoch: lifecycleEpoch)
case .released:
return nil
}
}
}
/// Completes the main-actor ownership handoff if no terminal event
/// invalidated it. Because `acquire` itself is main-actor isolated, a
/// successful handoff returns directly into the caller without another
/// actor hop; no callback can interleave before the caller stores the
/// returned token.
fileprivate func completeHandoff() -> Bool {
stateLock.withLock {
guard lifecycle == .acquiring,
!terminalEventPendingHandoff
else { return false }
lifecycle = .ready
return true
}
}
/// Marks the token dead synchronously, before the asynchronous holder
/// removal. Returns false for an already-released token.
fileprivate func markReleased() -> Bool {
stateLock.withLock {
guard lifecycle != .released else { return false }
lifecycle = .released
lifecycleEpoch &+= 1
terminalEventPendingHandoff = false
return true
}
}
/// Revalidates a queue snapshot at the main-actor delivery boundary.
/// The lock is deliberately released before invoking client code: real
/// callbacks commonly call `release` on this same token.
@MainActor
fileprivate func deliver(_ ticket: CallbackTicket) {
let isLive = stateLock.withLock {
lifecycle == .ready && lifecycleEpoch == ticket.lifecycleEpoch
}
guard isLive else { return }
switch ticket.kind {
case .interrupted:
onInterrupted()
case .categoryEscalated:
(onCategoryEscalated ?? onInterrupted)()
}
}
}
/// Deterministic suspension points for lifecycle race tests. Production
/// instances use the nil defaults; the hooks never move session calls off
/// the coordinator queue or callback execution off the main actor.
struct TestingHooks: Sendable {
let beforeAcquireHandoff: (@Sendable () async -> Void)?
let beforeCallbackDelivery: (@Sendable () async -> Void)?
init(
beforeAcquireHandoff: (@Sendable () async -> Void)? = nil,
beforeCallbackDelivery: (@Sendable () async -> Void)? = nil
) {
self.beforeAcquireHandoff = beforeAcquireHandoff
self.beforeCallbackDelivery = beforeCallbackDelivery
}
}
static let shared = AudioSessionCoordinator(session: SystemAudioSession())
private let session: SessionApplying
private let testingHooks: TestingHooks
/// Confines all mutable state, serializes whole acquire/release
/// operations (two rapid presses can't interleave their category and
/// activation calls), and hosts the blocking session IPC off main.
private let queue = DispatchQueue(label: "chat.bitchat.audio-session", qos: .userInitiated)
// Queue-confined state.
private var holders: [ObjectIdentifier: Token] = [:]
private var currentCategory: Category?
private var sessionActive = false
/// Written once in init, read in deinit never touched concurrently.
private var observers: [NSObjectProtocol] = []
init(session: SessionApplying, testingHooks: TestingHooks = TestingHooks()) {
self.session = session
self.testingHooks = testingHooks
observeSystemNotifications()
}
deinit {
for observer in observers {
NotificationCenter.default.removeObserver(observer)
}
}
/// Configures + activates the session for `use` and registers the caller
/// as a holder. The blocking `AVAudioSession` calls run on the session
/// queue the caller suspends instead of stalling its thread (a PTT
/// press used to block main >1 s in `setActive`, tripping the system
/// gesture gate). `onInterrupted` fires (on the main actor) when the
/// client must stop using the session: a system interruption began or
/// its route's device went away. The client should stop its engine,
/// finalize any artifacts, and release resuming means acquiring again.
///
/// `onCategoryEscalated` fires instead when the session category
/// escalated underneath the holder (a capture client joined): the session
/// stays active, so a holder that can rebuild its engine against the new
/// configuration should restart and keep going. Holders that pass `nil`
/// get `onInterrupted` for escalation too. Escalation is delivered before
/// `acquire` returns, so the new holder starts its engine strictly after
/// existing ones were told to rebuild. Main-actor isolation is also the
/// ownership handoff boundary: if interruption or route loss lands after
/// queue registration but before that boundary, the provisional holder is
/// removed and `acquire` throws `CancellationError` instead of returning a
/// token whose callback already fired.
@MainActor
func acquire(
_ use: Use,
onInterrupted: @escaping @MainActor () -> Void,
onCategoryEscalated: (@MainActor () -> Void)? = nil
) async throws -> Token {
let token = Token(onInterrupted: onInterrupted, onCategoryEscalated: onCategoryEscalated)
let reconfigured: [Token.CallbackTicket] = try await withCheckedThrowingContinuation { continuation in
queue.async {
do {
continuation.resume(returning: try self.activateOnQueue(use, registering: token))
} catch {
continuation.resume(throwing: error)
}
}
}
// Escalating playback -> playAndRecord reconfigures the hardware
// route; engines started against the old configuration must restart.
if !reconfigured.isEmpty {
SecureLogger.info("AudioSession: category escalated to playAndRecord with \(reconfigured.count) live holder(s)", category: .session)
await deliver(reconfigured)
}
if let beforeAcquireHandoff = testingHooks.beforeAcquireHandoff {
await beforeAcquireHandoff()
}
guard token.completeHandoff() else {
// A call/Siri interruption or route loss landed after registration
// but before ownership handoff. Remove the provisional holder and
// fail instead of starting a client engine after the stop event.
release(token)
throw CancellationError()
}
return token
}
/// Drops one holder. Deactivates the session (notifying other apps) only
/// when the last holder releases. Safe to call more than once, from any
/// thread (including `deinit` paths): the work is fire-and-forget onto
/// the session queue, so the blocking deactivation IPC never runs on the
/// caller.
func release(_ token: Token) {
guard token.markReleased() else { return }
queue.async {
self.releaseOnQueue(token)
}
}
// MARK: - Queue-confined core
/// Returns callback tickets for pre-existing live holders whose engines
/// must restart because this acquire escalated the category.
private func activateOnQueue(_ use: Use, registering token: Token) throws -> [Token.CallbackTicket] {
let target: Category = (use == .capture || currentCategory == .playAndRecord) ? .playAndRecord : .playback
let categoryChanged = target != currentCategory
let previousCategory = currentCategory
if categoryChanged {
try session.setCategory(target)
currentCategory = target
}
if !sessionActive {
do {
try session.setActive(true, notifyOthersOnDeactivation: false)
} catch {
// Activation failed (e.g. a phone call owns the hardware):
// with no holder registered, an escalated category recorded
// here would stick and pin later playback-only acquires to
// .playAndRecord. Existing holders keep the category the
// hardware really has.
if categoryChanged, holders.isEmpty {
currentCategory = previousCategory
}
throw error
}
sessionActive = true
}
let reconfigured = categoryChanged
? holders.values.compactMap { $0.record(.categoryEscalated) }
: []
holders[ObjectIdentifier(token)] = token
return reconfigured
}
private func releaseOnQueue(_ token: Token) {
guard holders.removeValue(forKey: ObjectIdentifier(token)) != nil else { return }
guard holders.isEmpty else { return }
currentCategory = nil
guard sessionActive else { return }
sessionActive = false
do {
try session.setActive(false, notifyOthersOnDeactivation: true)
} catch {
SecureLogger.error("AudioSession: deactivation failed: \(error)", category: .session)
}
}
private func onQueue<T: Sendable>(_ body: @escaping @Sendable () -> T) async -> T {
await withCheckedContinuation { continuation in
queue.async {
continuation.resume(returning: body())
}
}
}
@MainActor
private func deliver(_ tickets: [Token.CallbackTicket]) async {
guard !tickets.isEmpty else { return }
if let beforeCallbackDelivery = testingHooks.beforeCallbackDelivery {
await beforeCallbackDelivery()
}
for ticket in tickets {
ticket.token.deliver(ticket)
}
}
// MARK: - System events (internal so tests can drive them directly)
/// A system interruption began: the session is already deactivated by the
/// OS, so just mark it inactive and tell every ready holder (on the main
/// actor) to stop. A provisional acquiring holder is invalidated instead.
/// No auto-resume the next acquire re-activates.
func handleInterruptionBegan() async {
let tickets = await onQueue { () -> [Token.CallbackTicket] in
self.sessionActive = false
return self.holders.values.compactMap { $0.record(.interrupted) }
}
await deliver(tickets)
}
/// The active route's input/output device disappeared (e.g. BT headset
/// off): ready holders' engines are wedged against a dead route stop
/// them; invalidate a holder whose acquire has not returned yet.
func handleRouteDeviceUnavailable() async {
let tickets = await onQueue {
self.holders.values.compactMap { $0.record(.interrupted) }
}
await deliver(tickets)
}
/// Test hook: suspends until every session operation enqueued before this
/// call including fire-and-forget `release`s has completed.
func drain() async {
await onQueue {}
}
private func observeSystemNotifications() {
#if os(iOS)
let center = NotificationCenter.default
observers.append(center.addObserver(
forName: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance(),
queue: .main
) { [weak self] note in
guard let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
AVAudioSession.InterruptionType(rawValue: raw) == .began,
let self
else { return }
SecureLogger.info("AudioSession: interruption began", category: .session)
Task { await self.handleInterruptionBegan() }
})
observers.append(center.addObserver(
forName: AVAudioSession.routeChangeNotification,
object: AVAudioSession.sharedInstance(),
queue: .main
) { [weak self] note in
guard let raw = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
AVAudioSession.RouteChangeReason(rawValue: raw) == .oldDeviceUnavailable,
let self
else { return }
SecureLogger.info("AudioSession: route device became unavailable", category: .session)
Task { await self.handleRouteDeviceUnavailable() }
})
#endif
}
}
// MARK: - Production adapter
#if os(iOS)
private struct SystemAudioSession: SessionApplying {
func setCategory(_ category: AudioSessionCoordinator.Category) throws {
let session = AVAudioSession.sharedInstance()
switch category {
case .playback:
try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
case .playAndRecord:
// allowBluetoothHFP is not available on iOS Simulator
#if targetEnvironment(simulator)
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .mixWithOthers]
)
#else
try session.setCategory(
.playAndRecord,
mode: .default,
options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP, .mixWithOthers]
)
#endif
}
}
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {
try AVAudioSession.sharedInstance().setActive(
active,
options: notifyOthersOnDeactivation ? [.notifyOthersOnDeactivation] : []
)
}
}
#else
/// macOS has no app-level audio session; the coordinator still runs its
/// bookkeeping so client code is identical across platforms.
private struct SystemAudioSession: SessionApplying {
func setCategory(_ category: AudioSessionCoordinator.Category) throws {}
func setActive(_ active: Bool, notifyOthersOnDeactivation: Bool) throws {}
}
#endif
-175
View File
@@ -1,175 +0,0 @@
//
// PTTAudioCodec.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import AVFoundation
import BitLogger
import Foundation
/// Streaming PCM -> AAC-LC encoder for live voice. Stateful (the AAC encoder
/// carries a bit reservoir across frames); one instance per burst.
/// Not thread-safe confine to one queue.
final class PTTFrameEncoder {
private let converter: AVAudioConverter
private var pendingInput: [AVAudioPCMBuffer] = []
init?() {
guard let pcm = PTTAudioFormat.pcmFormat,
let aac = PTTAudioFormat.aacFormat,
let converter = AVAudioConverter(from: pcm, to: aac)
else { return nil }
converter.bitRate = PTTAudioFormat.bitRate
self.converter = converter
}
/// Feeds PCM (16 kHz mono float) and returns every complete AAC frame the
/// encoder produced. Frames come out ~130 bytes each at 16 kbps.
func encode(_ buffer: AVAudioPCMBuffer) -> [Data] {
pendingInput.append(buffer)
return drainConverter()
}
private func drainConverter() -> [Data] {
var frames: [Data] = []
while true {
let output = AVAudioCompressedBuffer(
format: converter.outputFormat,
packetCapacity: 8,
maximumPacketSize: max(converter.maximumOutputPacketSize, 1)
)
var error: NSError?
let status = converter.convert(to: output, error: &error) { [weak self] _, outStatus in
guard let self, let next = self.pendingInput.first else {
outStatus.pointee = .noDataNow
return nil
}
self.pendingInput.removeFirst()
outStatus.pointee = .haveData
return next
}
if status == .error {
SecureLogger.error("PTT encode failed: \(error?.localizedDescription ?? "unknown")", category: .session)
return frames
}
frames.append(contentsOf: Self.extractPackets(from: output))
// .haveData means the output buffer filled and more may be ready;
// anything else means the converter wants more input.
if status != .haveData { return frames }
}
}
private static func extractPackets(from buffer: AVAudioCompressedBuffer) -> [Data] {
guard buffer.packetCount > 0, let descriptions = buffer.packetDescriptions else { return [] }
var frames: [Data] = []
frames.reserveCapacity(Int(buffer.packetCount))
for index in 0..<Int(buffer.packetCount) {
let description = descriptions[index]
guard description.mDataByteSize > 0 else { continue }
let start = buffer.data.advanced(by: Int(description.mStartOffset))
frames.append(Data(bytes: start, count: Int(description.mDataByteSize)))
}
return frames
}
}
/// Streaming AAC-LC -> PCM decoder for live voice. Stateful; one instance per
/// inbound burst. Not thread-safe confine to one queue/actor.
final class PTTFrameDecoder {
private let converter: AVAudioConverter
private let pcmFormat: AVAudioFormat
private let aacFormat: AVAudioFormat
init?() {
guard let pcm = PTTAudioFormat.pcmFormat,
let aac = PTTAudioFormat.aacFormat,
let converter = AVAudioConverter(from: aac, to: pcm)
else { return nil }
self.converter = converter
self.pcmFormat = pcm
self.aacFormat = aac
}
/// Decodes one raw AAC frame to PCM. Returns nil for malformed input or
/// while the decoder is still priming (the first frame of a stream).
func decode(_ frame: Data) -> AVAudioPCMBuffer? {
guard !frame.isEmpty, frame.count <= 8 * 1024 else { return nil }
let input = AVAudioCompressedBuffer(format: aacFormat, packetCapacity: 1, maximumPacketSize: frame.count)
frame.withUnsafeBytes { raw in
guard let base = raw.baseAddress else { return }
input.data.copyMemory(from: base, byteCount: frame.count)
}
input.byteLength = UInt32(frame.count)
input.packetCount = 1
input.packetDescriptions?.pointee = AudioStreamPacketDescription(
mStartOffset: 0,
mVariableFramesInPacket: 0,
mDataByteSize: UInt32(frame.count)
)
guard let output = AVAudioPCMBuffer(
pcmFormat: pcmFormat,
frameCapacity: PTTAudioFormat.samplesPerFrame * 2
) else { return nil }
var consumed = false
var error: NSError?
let status = converter.convert(to: output, error: &error) { _, outStatus in
if consumed {
outStatus.pointee = .noDataNow
return nil
}
consumed = true
outStatus.pointee = .haveData
return input
}
guard status != .error else {
SecureLogger.debug("PTT decode failed: \(error?.localizedDescription ?? "unknown")", category: .session)
return nil
}
return output.frameLength > 0 ? output : nil
}
}
/// Sample-rate/channel converter from the microphone's native format to the
/// 16 kHz mono processing format. Stateful; not thread-safe.
final class PTTInputResampler {
private let converter: AVAudioConverter
private let outputFormat: AVAudioFormat
private let ratio: Double
init?(inputFormat: AVAudioFormat) {
guard let pcm = PTTAudioFormat.pcmFormat,
let converter = AVAudioConverter(from: inputFormat, to: pcm)
else { return nil }
self.converter = converter
self.outputFormat = pcm
self.ratio = PTTAudioFormat.sampleRate / inputFormat.sampleRate
}
func resample(_ buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 64
guard let output = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: capacity) else { return nil }
var consumed = false
var error: NSError?
let status = converter.convert(to: output, error: &error) { _, outStatus in
if consumed {
outStatus.pointee = .noDataNow
return nil
}
consumed = true
outStatus.pointee = .haveData
return buffer
}
guard status != .error else {
SecureLogger.debug("PTT resample failed: \(error?.localizedDescription ?? "unknown")", category: .session)
return nil
}
return output.frameLength > 0 ? output : nil
}
}
@@ -1,84 +0,0 @@
//
// PTTAudioFormat.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import AVFoundation
import Foundation
/// Shared audio parameters for live push-to-talk: AAC-LC, 16 kHz, mono,
/// ~16 kbps deliberately identical to `VoiceRecorder`'s voice-note settings
/// so a burst's finalized `.m4a` and its live frames sound the same.
enum PTTAudioFormat {
static let sampleRate: Double = 16_000
static let channelCount: AVAudioChannelCount = 1
static let bitRate = 16_000
/// AAC-LC frame size is fixed by the codec: 1024 samples = 64 ms at 16 kHz.
static let samplesPerFrame: AVAudioFrameCount = 1024
static var frameDuration: TimeInterval { Double(samplesPerFrame) / sampleRate }
/// Uncompressed processing format (deinterleaved float PCM).
static var pcmFormat: AVAudioFormat? {
AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: channelCount)
}
/// Compressed wire format.
static var aacFormat: AVAudioFormat? {
var description = AudioStreamBasicDescription(
mSampleRate: sampleRate,
mFormatID: kAudioFormatMPEG4AAC,
mFormatFlags: 0,
mBytesPerPacket: 0,
mFramesPerPacket: samplesPerFrame,
mBytesPerFrame: 0,
mChannelsPerFrame: channelCount,
mBitsPerChannel: 0,
mReserved: 0
)
return AVAudioFormat(streamDescription: &description)
}
/// Voice-note container settings for the finalized `.m4a`, mirroring
/// `VoiceRecorder.startRecording()`.
static var voiceNoteFileSettings: [String: Any] {
[
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: sampleRate,
AVNumberOfChannelsKey: Int(channelCount),
AVEncoderBitRateKey: bitRate
]
}
}
/// Builds ADTS-framed AAC so a receiver can persist a burst progressively:
/// unlike `.m4a` (whose moov atom only exists after close), an ADTS `.aac`
/// stream is playable at any prefix a partially received burst is still a
/// replayable voice note.
enum ADTSFramer {
private static let headerSize = 7
/// MPEG-4 sampling frequency index for 16 kHz.
private static let samplingFrequencyIndex: UInt8 = 8
private static let channelConfiguration: UInt8 = 1
/// Wraps one raw AAC-LC frame in an ADTS header.
static func frame(_ aacFrame: Data) -> Data {
let frameLength = aacFrame.count + headerSize
var data = Data(capacity: frameLength)
// Syncword 0xFFF, MPEG-4, layer 00, no CRC.
data.append(0xFF)
data.append(0xF1)
// Profile AAC-LC (audio object type 2 -> bits 01), frequency index,
// private bit 0, channel config high bit.
data.append((0b01 << 6) | (samplingFrequencyIndex << 2) | ((channelConfiguration >> 2) & 0x1))
data.append(((channelConfiguration & 0x3) << 6) | UInt8((frameLength >> 11) & 0x3))
data.append(UInt8((frameLength >> 3) & 0xFF))
data.append(UInt8((frameLength & 0x7) << 5) | 0x1F)
// Buffer fullness 0x7FF (VBR), one AAC frame per ADTS frame.
data.append(0xFC)
data.append(aacFrame)
return data
}
}
-509
View File
@@ -1,509 +0,0 @@
//
// PTTBurstPlayer.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
@preconcurrency import AVFoundation
import BitLogger
import Foundation
/// The engine operations behind live-burst playback, abstracted so the
/// player's lifecycle (jitter start, category-escalation restart, stop) is
/// unit-testable without real audio hardware.
@MainActor
protocol PTTPlaybackEngine: AnyObject {
/// The object `AVAudioEngineConfigurationChange` notifications are posted
/// for (nil for mocks no observer is registered).
var configChangeObject: AnyObject? { get }
func start() throws
func play()
func stop()
func schedule(
_ buffer: AVAudioPCMBuffer,
completionType: PTTPlaybackCompletionType,
completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void
)
}
/// The lifecycle point requested from `AVAudioPlayerNode` for a scheduled
/// buffer. `dataConsumed` only means the node no longer needs the bytes; it
/// may arrive before the render pipeline has made the audio audible.
enum PTTPlaybackCompletionType: Equatable, Sendable {
case dataConsumed
case dataPlayedBack
}
enum PTTPlaybackCompletionEvent: Equatable, Sendable {
case dataConsumed
case dataPlayedBack
/// AVAudioPlayerNode invokes the requested callback when the node is
/// stopped too. That is not audible completion and must remain replayable.
case playbackStopped
}
/// One `AVAudioEngine` + `AVAudioPlayerNode` pair. Created fresh per (re)start:
/// an engine instantiated against an earlier audio-session configuration keeps
/// rendering to the stale route (same class of failure as the capture side's
/// fresh-engine-per-press rule).
@MainActor
private final class SystemPTTPlaybackEngine: PTTPlaybackEngine {
private let engine = AVAudioEngine()
private let node = AVAudioPlayerNode()
init(format: AVAudioFormat) {
engine.attach(node)
engine.connect(node, to: engine.mainMixerNode, format: format)
}
var configChangeObject: AnyObject? { engine }
func start() throws {
engine.prepare()
try engine.start()
}
func play() {
node.play()
}
func stop() {
node.stop()
engine.stop()
}
func schedule(
_ buffer: AVAudioPCMBuffer,
completionType: PTTPlaybackCompletionType,
completionHandler: @escaping @Sendable (PTTPlaybackCompletionEvent) -> Void
) {
let callbackType: AVAudioPlayerNodeCompletionCallbackType = switch completionType {
case .dataConsumed: .dataConsumed
case .dataPlayedBack: .dataPlayedBack
}
let scheduledEngine = engine
node.scheduleBuffer(buffer, completionCallbackType: callbackType) { [weak scheduledEngine] callbackType in
// The API invokes this callback when the player is stopped as
// well. A configuration change can stop the engine before its
// notification reaches MainActor, so do not misclassify that
// flushed tail as audible playback.
guard scheduledEngine?.isRunning == true else {
completionHandler(.playbackStopped)
return
}
switch callbackType {
case .dataConsumed:
completionHandler(.dataConsumed)
case .dataRendered:
completionHandler(.dataConsumed)
case .dataPlayedBack:
completionHandler(.dataPlayedBack)
@unknown default:
completionHandler(.playbackStopped)
}
}
}
}
/// Completion callbacks arrive off the main actor, while engine rebuilds are
/// serialized on it. This small lock-backed latch lets a rebuild atomically
/// claim only buffers whose completion has not already fired even when the
/// callback's hop back to the main actor is still queued.
private final class PTTPlaybackCompletionState: @unchecked Sendable {
private enum State {
case scheduled
case completed
case retired
}
private let lock = NSLock()
private var state: State = .scheduled
/// Returns true exactly once when playback completion wins the race with
/// an engine rebuild or stop.
func complete() -> Bool {
lock.withLock {
guard case .scheduled = state else { return false }
state = .completed
return true
}
}
/// Returns true exactly once when a rebuild or stop claims this
/// still-pending schedule. Later callbacks from that engine are stale.
func retireIfPending() -> Bool {
lock.withLock {
guard case .scheduled = state else { return false }
state = .retired
return true
}
}
}
/// Plays one inbound live voice burst with a small jitter buffer.
///
/// Frames are decoded and scheduled back-to-back on an `AVAudioPlayerNode`;
/// an underrun (missing/late packets) simply pauses output until the next
/// buffer arrives, which self-heals timing without explicit silence
/// insertion. Playback starts once `TransportConfig.pttJitterBufferSeconds`
/// of audio is queued or `pttJitterDeadlineSeconds` has elapsed.
///
/// Talk-over is bidirectional: when push-to-talk capture starts while this
/// burst plays, the session category escalates underneath the engine the
/// player rebuilds a fresh engine against the new configuration and keeps
/// streaming instead of dying. Real interruptions (phone call, route device
/// gone) still stop it; the burst keeps assembling to file either way.
@MainActor
final class PTTBurstPlayer {
/// Restart-on-reconfigure ceiling: a burst is at most ~2 minutes, so a
/// handful of category/route changes is plenty beyond it something is
/// thrashing and stopping cleanly beats an engine-rebuild loop.
private static let maxEngineRestarts = 8
private let makeEngine: @MainActor () -> PTTPlaybackEngine
private var engine: PTTPlaybackEngine
private let decoder: PTTFrameDecoder
private let coordinator: AudioSessionCoordinator
/// Injectable so tests don't fight over the app-wide exclusive-playback
/// slot (a parallel test's `play()` would stop this player mid-test).
private let exclusivity: VoiceNotePlaybackCoordinator
private var queuedBuffers: [AVAudioPCMBuffer] = []
private var queuedDuration: TimeInterval = 0
private struct ScheduledBuffer {
let id: UInt64
let buffer: AVAudioPCMBuffer
let completionState: PTTPlaybackCompletionState
}
/// Buffers handed to the current engine whose completion has not yet
/// been processed on the main actor. Keeping the buffers themselves lets
/// a category-escalation rebuild replay the unfinished tail in order.
private var scheduledBuffers: [ScheduledBuffer] = []
private var nextScheduledBufferID: UInt64 = 0
/// Bumped on every engine rebuild or stop so completion tasks from a
/// torn-down engine cannot mutate the current generation's pending list.
private var engineGeneration = 0
private var engineRestarts = 0
private var engineStarted = false
private var finished = false
/// Latched off (internal read so tests can await the async failure path).
private(set) var stopped = false
/// A session acquire is in flight (it suspends off-main for the blocking
/// session IPC); gates `startIfReady` against double acquisition.
private var acquiringSession = false
private var deadlineTask: Task<Void, Never>?
private var sessionToken: AudioSessionCoordinator.Token?
/// Reserved before the session acquire suspends. Activation succeeds only
/// if no newer playback request claimed the floor in the meantime.
private var playbackReservation: VoiceNotePlaybackCoordinator.Reservation?
private var configChangeObserver: NSObjectProtocol?
private(set) var isPlaying = false
/// Fires exactly once when the player stops for good (drain-out, cancel,
/// interruption, failure). `ChatLiveVoiceCoordinator` uses it to unpark
/// the draining player it keeps alive after the assembly the player's
/// only long-lived owner is discarded on burst END.
var onStopped: (() -> Void)?
init?(
coordinator: AudioSessionCoordinator? = nil,
exclusivity: VoiceNotePlaybackCoordinator? = nil,
makeEngine: (@MainActor () -> PTTPlaybackEngine)? = nil
) {
guard let format = PTTAudioFormat.pcmFormat, let decoder = PTTFrameDecoder() else { return nil }
self.decoder = decoder
self.coordinator = coordinator ?? .shared
self.exclusivity = exclusivity ?? .shared
let factory = makeEngine ?? { SystemPTTPlaybackEngine(format: format) }
self.makeEngine = factory
self.engine = factory()
deadlineTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttJitterDeadlineSeconds * 1_000_000_000))
self?.startIfReady(force: true)
}
}
deinit {
// Backstop for an owner dropping the player before it stopped: the
// session coordinator retains registered tokens strongly, so a token
// leaked here would keep the session active (and pin any escalated
// category) for the app's lifetime. `release` is fire-and-forget
// onto the coordinator's queue, so it is deinit-safe.
if let token = sessionToken {
coordinator.release(token)
}
if let observer = configChangeObserver {
NotificationCenter.default.removeObserver(observer)
}
deadlineTask?.cancel()
}
/// Decodes and queues frames (in burst order). Starts playback when the
/// jitter buffer fills.
func enqueue(_ frames: [Data]) {
guard !stopped else { return }
for frame in frames {
guard let pcm = decoder.decode(frame) else { continue }
if engineStarted {
schedule(pcm)
} else {
queuedBuffers.append(pcm)
queuedDuration += Double(pcm.frameLength) / PTTAudioFormat.sampleRate
}
}
startIfReady(force: false)
}
/// The burst ended: stop once everything scheduled has played out.
func finishAfterDrain() {
finished = true
// The complete burst is queued no jitter left to wait for. This
// also matters when END lands while the async session acquire is
// still in flight: the queued audio must play out, not be treated
// as already drained.
startIfReady(force: true)
stopIfDrained()
}
/// Immediate stop (cancel, another playback taking over, interruption,
/// teardown).
func stop() {
guard !stopped else { return }
stopped = true
deadlineTask?.cancel()
removeConfigObserver()
queuedBuffers = []
retireScheduledBuffers()
if engineStarted {
engine.stop()
}
isPlaying = false
releaseSessionToken()
exclusivity.deactivate(self)
onStopped?()
}
private func startIfReady(force: Bool) {
guard !engineStarted, !acquiringSession, !stopped, !queuedBuffers.isEmpty else { return }
guard force || queuedDuration >= TransportConfig.pttJitterBufferSeconds else { return }
// Acquiring the session suspends for its blocking IPC (off the main
// actor); frames arriving meanwhile keep queueing and are flushed
// onto the engine once it starts.
acquiringSession = true
playbackReservation = exclusivity.reserve(self)
Task { [weak self] in
await self?.acquireSessionAndStart()
}
}
private func acquireSessionAndStart() async {
let token: AudioSessionCoordinator.Token
do {
token = try await coordinator.acquire(
.playback,
onInterrupted: { [weak self] in self?.stop() },
onCategoryEscalated: { [weak self] in self?.restartEngine() }
)
} catch {
acquiringSession = false
SecureLogger.error("PTT playback session activation failed: \(error)", category: .session)
// Playing unregistered would leave the engine exposed: another
// holder's last release deactivates the session mid-play, and no
// interruption/escalation fan-out ever reaches us. Bail like the
// engine-start failure below; the burst still assembles to file.
// (stop() also fires onStopped so a parked draining player is
// unparked instead of leaking.)
stop()
return
}
acquiringSession = false
// stop() (cancel, exclusivity, teardown) may have landed while the
// session was activating: hand the token straight back.
guard !stopped else {
coordinator.release(token)
return
}
sessionToken = token
guard let playbackReservation,
exclusivity.isCurrent(playbackReservation, for: self)
else {
// The request was superseded while audio-session activation was
// suspended. Do not even start the retired engine.
stop()
return
}
// Observe reconfiguration before starting so nothing lands between.
registerConfigObserver()
do {
try engine.start()
} catch {
// A capture racing this start can reconfigure the session while
// the engine spins up (its escalation fan-out no-ops on a player
// that never started): rebuild once against the settled
// configuration counted against the restart cap before
// giving up.
SecureLogger.warning("PTT playback engine failed to start (\(error)) — rebuilding once", category: .session)
removeConfigObserver()
engineRestarts += 1
engine = makeEngine()
registerConfigObserver()
do {
try engine.start()
} catch {
SecureLogger.error("PTT playback engine failed to start: \(error)", category: .session)
// stop() removes the observer, hands the token back, and
// fires onStopped for any parked draining owner.
stop()
return
}
}
engineStarted = true
guard exclusivity.activate(self, reservation: playbackReservation)
else {
// A newer user-initiated playback reserved the floor while this
// older PTT request was suspended in audio-session activation.
// Never let the late completion steal playback back.
stop()
return
}
isPlaying = true
engine.play()
let buffered = queuedBuffers
queuedBuffers = []
queuedDuration = 0
for buffer in buffered {
schedule(buffer)
}
}
/// The audio session was reconfigured underneath the running engine
/// (category escalation for talk-over, or an engine configuration
/// change): rebuild a fresh engine against the new configuration and
/// keep streaming. Buffers already completed stay completed; the
/// unfinished scheduled tail is replayed in order on the fresh engine,
/// and frames still arriving continue scheduling after it.
private func restartEngine() {
guard engineStarted, !stopped else { return }
engineRestarts += 1
guard engineRestarts <= Self.maxEngineRestarts else {
SecureLogger.warning("PTT playback: engine reconfigured \(engineRestarts) times in one burst — stopping", category: .session)
stop()
return
}
removeConfigObserver()
// Claim the unfinished tail before stopping the old engine. Stopping
// a player node may itself invoke its completion handlers; retiring
// the claimed entries first makes those callbacks unambiguously stale.
// A completion that fired just before this rebuild wins the latch and
// is excluded even if its MainActor task has not run yet.
let buffersToReplay = scheduledBuffers.compactMap { scheduled in
scheduled.completionState.retireIfPending() ? scheduled.buffer : nil
}
scheduledBuffers = []
engineGeneration += 1
engine.stop()
engine = makeEngine()
registerConfigObserver()
do {
try engine.start()
} catch {
SecureLogger.error("PTT playback engine failed to restart after session reconfigure: \(error)", category: .session)
stop()
return
}
engine.play()
for buffer in buffersToReplay {
schedule(buffer)
}
SecureLogger.info("PTT playback: engine restarted after session reconfigure", category: .session)
// If every old buffer completed before the rebuild, a finished burst
// can stop now. Otherwise the replayed tail keeps it alive until its
// new-generation completions arrive.
stopIfDrained()
}
private func registerConfigObserver() {
guard let object = engine.configChangeObject else { return }
configChangeObserver = NotificationCenter.default.addObserver(
forName: .AVAudioEngineConfigurationChange,
object: object,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.restartEngine()
}
}
}
private func removeConfigObserver() {
if let observer = configChangeObserver {
NotificationCenter.default.removeObserver(observer)
configChangeObserver = nil
}
}
private func schedule(_ buffer: AVAudioPCMBuffer) {
let id = nextScheduledBufferID
nextScheduledBufferID &+= 1
let completionState = PTTPlaybackCompletionState()
scheduledBuffers.append(ScheduledBuffer(
id: id,
buffer: buffer,
completionState: completionState
))
let generation = engineGeneration
engine.schedule(buffer, completionType: .dataPlayedBack) { [weak self, completionState] event in
guard event == .dataPlayedBack else { return }
// Mark completion before hopping to MainActor. A rebuild can then
// distinguish already-completed audio from an unfinished tail
// even when this task has not run yet.
guard completionState.complete() else { return }
Task { @MainActor [weak self] in
guard let self, self.engineGeneration == generation else { return }
self.scheduledBuffers.removeAll { $0.id == id }
self.stopIfDrained()
}
}
}
private func retireScheduledBuffers() {
engineGeneration += 1
for scheduled in scheduledBuffers {
_ = scheduled.completionState.retireIfPending()
}
scheduledBuffers = []
}
private func stopIfDrained() {
guard finished, scheduledBuffers.isEmpty else { return }
// Started: everything scheduled has played out. Never started with
// nothing queued or in flight (e.g. no decodable frames): nothing
// will ever play. Otherwise the engine start is still pending (the
// async session acquire) and the queued audio must play out first.
guard engineStarted || (!acquiringSession && queuedBuffers.isEmpty) else { return }
stop()
}
private func releaseSessionToken() {
sessionToken.map(coordinator.release)
sessionToken = nil
}
}
extension PTTBurstPlayer: ExclusivePlayback {
/// A live stream can't meaningfully pause; yielding the floor stops it.
/// The burst keeps assembling to file, so nothing is lost.
nonisolated func pauseForExclusivity() {
Task { @MainActor [weak self] in
self?.stop()
}
}
}
@@ -1,326 +0,0 @@
//
// PTTCaptureEngine.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import AVFoundation
import BitLogger
import Foundation
/// Owns one capture token and returns it even when the capture engine's owner
/// disappears without reaching its normal stop/cancel path. The coordinator
/// retains registered tokens strongly, so relying on `Token.deinit` cannot
/// reclaim an abandoned hold.
final class PTTCaptureSessionLease: @unchecked Sendable {
private let coordinator: AudioSessionCoordinator
private let lock = NSLock()
private var token: AudioSessionCoordinator.Token?
init(coordinator: AudioSessionCoordinator) {
self.coordinator = coordinator
}
func install(_ token: AudioSessionCoordinator.Token) {
let previous = lock.withLock {
let previous = self.token
self.token = token
return previous
}
previous.map(coordinator.release)
}
func release() {
let token = lock.withLock {
let token = self.token
self.token = nil
return token
}
token.map(coordinator.release)
}
deinit {
release()
}
}
/// Monotonic capture identity shared by main-actor lifecycle code and queued
/// engine callbacks. Removing a notification observer does not cancel a block
/// already enqueued on the main queue, so every callback must also prove it
/// still belongs to the current hold before mutating capture state.
final class PTTCaptureGeneration: @unchecked Sendable {
private let lock = NSLock()
private var value: UInt = 0
func begin() -> UInt {
lock.withLock {
value &+= 1
return value
}
}
func invalidate() {
lock.withLock { value &+= 1 }
}
func invalidate(ifCurrent generation: UInt) -> Bool {
lock.withLock {
guard value == generation else { return false }
value &+= 1
return true
}
}
func isCurrent(_ generation: UInt) -> Bool {
lock.withLock { value == generation }
}
}
/// Captures microphone audio for a live push-to-talk burst, producing both:
/// - live AAC frames via `onFrames` (called on the capture queue), and
/// - a finalized `.m4a` voice note on `stop()` the same artifact
/// `VoiceRecorder` produces, so the existing voice-note send pipeline
/// handles delivery to receivers that missed the live stream.
/// `@unchecked Sendable`: every mutable property is confined to one executor
/// the capture `queue` (resampler/encoder/file/counters) or the main actor
/// (`engine`, `engineStarted`, `sessionLease`, `configChangeObserver`) so
/// weak references may cross the `@Sendable` tap/notification closures, which
/// immediately hop back to the owning executor.
final class PTTCaptureEngine: @unchecked Sendable {
/// Hard cap matching `VoiceRecorder.maxRecordingDuration`: past it the
/// engine keeps running (the UI owns the gesture) but stops encoding.
private static let maxCaptureDuration: TimeInterval = 120
/// Recreated on every `start()`: an engine whose input unit was
/// instantiated against an earlier (playback-only or inactive) audio
/// session keeps reporting a dead 0 Hz / 2 ch input format and fails to
/// enable the mic (AURemoteIO -10851, observed on iPhone field tests).
private var engine = AVAudioEngine()
private let queue = DispatchQueue(label: "chat.bitchat.ptt.capture", qos: .userInitiated)
private let coordinator: AudioSessionCoordinator
private let sessionLease: PTTCaptureSessionLease
private let captureGeneration = PTTCaptureGeneration()
// Capture-queue-confined state.
private var resampler: PTTInputResampler?
private var encoder: PTTFrameEncoder?
private var file: AVAudioFile?
private var fileURL: URL?
private var encodedFrameCount = 0
private var running = false
private var captureStart = Date()
/// Whether `engine.start()` succeeded for the current capture
/// (see `stopEngineIfStarted`).
@MainActor private var engineStarted = false
@MainActor private var configChangeObserver: NSObjectProtocol?
/// Called on the capture queue with each batch of encoded AAC frames.
var onFrames: (([Data]) -> Void)?
enum CaptureError: Error {
case inputUnavailable
case audioSetupFailed
}
init(coordinator: AudioSessionCoordinator = .shared) {
self.coordinator = coordinator
self.sessionLease = PTTCaptureSessionLease(coordinator: coordinator)
}
deinit {
sessionLease.release()
}
/// Async because acquiring the session hops its blocking IPC off the main
/// actor (a PTT press used to stall main >1 s in `setActive`); the engine
/// itself still starts back on main once the session is configured.
@MainActor
func start(outputURL: URL) async throws {
let generation = captureGeneration.begin()
let token = try await coordinator.acquire(.capture) { [weak self] in
self?.handleInterruption(for: generation)
}
// The hold ended (stop/cancel) while the session was activating:
// starting the engine now would leave a hot mic after release.
guard captureGeneration.isCurrent(generation) else {
coordinator.release(token)
throw CancellationError()
}
sessionLease.install(token)
do {
try beginCapture(outputURL: outputURL, generation: generation)
} catch {
releaseSessionToken()
throw error
}
}
@MainActor
private func beginCapture(outputURL: URL, generation: UInt) throws {
// Fresh engine per capture so its input unit binds to the session
// that is active *now* (see `engine` doc comment).
engine = AVAudioEngine()
let inputFormat = engine.inputNode.outputFormat(forBus: 0)
guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else {
SecureLogger.error("PTT: capture input unavailable (input reports \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session)
throw CaptureError.inputUnavailable
}
guard let resampler = PTTInputResampler(inputFormat: inputFormat),
let encoder = PTTFrameEncoder(),
let pcmFormat = PTTAudioFormat.pcmFormat
else { throw CaptureError.audioSetupFailed }
let file = try AVAudioFile(
forWriting: outputURL,
settings: PTTAudioFormat.voiceNoteFileSettings,
commonFormat: pcmFormat.commonFormat,
interleaved: pcmFormat.isInterleaved
)
queue.sync {
self.resampler = resampler
self.encoder = encoder
self.file = file
self.fileURL = outputURL
self.encodedFrameCount = 0
self.captureStart = Date()
self.running = true
}
engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buffer, _ in
self?.queue.async { self?.process(buffer, generation: generation) }
}
// Route/category changes reconfigure the engine underneath the tap;
// stop and finalize cleanly the .m4a captured so far still sends.
// Registered before start() so no reconfigure lands unobserved
// (handleInterruption also validates this capture generation).
configChangeObserver = NotificationCenter.default.addObserver(
forName: .AVAudioEngineConfigurationChange,
object: engine,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.handleInterruption(for: generation)
}
}
engine.prepare()
do {
try engine.start()
} catch {
SecureLogger.error("PTT: capture engine failed to start (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch): \(error)", category: .session)
if let observer = configChangeObserver {
NotificationCenter.default.removeObserver(observer)
configChangeObserver = nil
}
engine.inputNode.removeTap(onBus: 0)
queue.sync { self.teardown(deleteFile: true) }
throw error
}
engineStarted = true
SecureLogger.info("PTT: capture engine running (input: \(Int(inputFormat.sampleRate)) Hz, \(inputFormat.channelCount) ch)", category: .session)
}
/// Stops capture and finalizes the `.m4a`. Returns the file URL and the
/// number of encoded AAC frames (each `PTTAudioFormat.frameDuration` long).
@MainActor
func stop() -> (url: URL?, encodedFrames: Int) {
captureGeneration.invalidate()
stopEngineIfStarted()
let result: (URL?, Int) = queue.sync {
let url = fileURL
let frames = encodedFrameCount
teardown(deleteFile: false)
return (url, frames)
}
releaseSessionToken()
return result
}
@MainActor
func cancel() {
captureGeneration.invalidate()
stopEngineIfStarted()
queue.sync { teardown(deleteFile: true) }
releaseSessionToken()
}
/// Audio session interrupted (call, Siri) or the engine was reconfigured
/// mid-capture: behave like `stop()` finalize the `.m4a` container but
/// keep `fileURL`/`encodedFrameCount` so the caller's pending `stop()`
/// still returns the note for delivery.
@MainActor
private func handleInterruption(for generation: UInt) {
// Also invalidate a start whose acquire has registered its token but
// has not returned to this actor yet. Without this bump the callback
// is lost while `engineStarted == false`, and the resumed start can
// open the mic after the stop signal.
guard captureGeneration.invalidate(ifCurrent: generation) else { return }
guard engineStarted else {
releaseSessionToken()
return
}
stopEngineIfStarted()
queue.sync {
running = false
// Releasing the AVAudioFile finalizes the .m4a container.
file = nil
encoder = nil
resampler = nil
}
releaseSessionToken()
SecureLogger.info("PTT: capture interrupted — burst finalized early", category: .session)
}
/// Touching `inputNode` on an engine that never started instantiates its
/// input unit against whatever session is active and spams AURemoteIO
/// errors a canceled-before-start hold must not touch the engine.
@MainActor
private func stopEngineIfStarted() {
if let observer = configChangeObserver {
NotificationCenter.default.removeObserver(observer)
configChangeObserver = nil
}
guard engineStarted else { return }
engineStarted = false
engine.inputNode.removeTap(onBus: 0)
engine.stop()
}
@MainActor
private func releaseSessionToken() {
sessionLease.release()
}
// MARK: - Capture queue
private func process(_ buffer: AVAudioPCMBuffer, generation: UInt) {
guard captureGeneration.isCurrent(generation),
running,
Date().timeIntervalSince(captureStart) < Self.maxCaptureDuration,
let resampled = resampler?.resample(buffer)
else { return }
do {
try file?.write(from: resampled)
} catch {
SecureLogger.error("PTT capture file write failed: \(error)", category: .session)
}
guard let frames = encoder?.encode(resampled), !frames.isEmpty else { return }
encodedFrameCount += frames.count
onFrames?(frames)
}
private func teardown(deleteFile: Bool) {
running = false
// Releasing the AVAudioFile finalizes the .m4a container.
file = nil
encoder = nil
resampler = nil
if deleteFile, let url = fileURL {
try? FileManager.default.removeItem(at: url)
}
fileURL = nil
}
}
-39
View File
@@ -1,39 +0,0 @@
//
// PTTSettings.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
/// User preference for live push-to-talk voice. One switch controls both
/// directions: streaming your holds live, and auto-playing inbound bursts.
/// Off means voice messages behave exactly like classic voice notes.
enum PTTSettings {
private static let liveVoiceEnabledKey = "ptt.liveVoiceEnabled"
static var liveVoiceEnabled: Bool {
get { UserDefaults.standard.object(forKey: liveVoiceEnabledKey) as? Bool ?? true }
set { UserDefaults.standard.set(newValue, forKey: liveVoiceEnabledKey) }
}
/// Autoplay is foreground-only: audio must never start from the
/// background.
@MainActor
static var isAppActive: Bool {
#if os(iOS)
return UIApplication.shared.applicationState == .active
#elseif os(macOS)
return NSApplication.shared.isActive
#else
return true
#endif
}
}
@@ -1,250 +0,0 @@
//
// VoiceCaptureSession.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import BitLogger
import Foundation
/// Capture backend behind the composer's hold-to-record gesture.
/// `VoiceRecordingViewModel` drives one session per press; the concrete type
/// decides *how* audio leaves the device: `VoiceNoteCaptureSession` records a
/// note delivered on release (today's behavior), `PTTLiveVoiceSession`
/// additionally streams frames live while the button is held.
@MainActor
protocol VoiceCaptureSession: AnyObject {
/// Whether audio is leaving the device in real time while recording
/// drives the composer's LIVE treatment.
var isLive: Bool { get }
func requestPermission() async -> Bool
func start() async throws
/// Stops capture and returns the finalized voice-note file, or nil when
/// nothing valid was captured.
func finish() async -> URL?
func cancel() async
/// Stops capture and suppresses every later send before returning.
func panicCancelSynchronously()
}
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
@MainActor
final class VoiceNoteCaptureSession: VoiceCaptureSession {
private let recorder: VoiceRecorder
private let owner = VoiceRecorder.RecordingOwner()
var isLive: Bool { false }
init(recorder: VoiceRecorder = .shared) {
self.recorder = recorder
}
func requestPermission() async -> Bool {
await recorder.requestPermission()
}
func start() async throws {
try await recorder.startRecording(owner: owner)
}
func finish() async -> URL? {
await recorder.stopRecording(owner: owner)
}
func cancel() async {
await recorder.cancelRecording(owner: owner)
}
func panicCancelSynchronously() {
recorder.panicCancelSynchronously(owner: owner)
}
}
/// Testable surface of the live capture engine. Production uses
/// `PTTCaptureEngine`; tests can supply captured-frame counts without opening
/// real audio hardware.
@MainActor
protocol PTTCapturing: AnyObject {
var onFrames: (([Data]) -> Void)? { get set }
func start(outputURL: URL) async throws
func stop() -> (url: URL?, encodedFrames: Int)
func cancel()
}
extension PTTCaptureEngine: PTTCapturing {}
/// Live push-to-talk backend: streams `VoiceBurstPacket`s to one peer while
/// recording, then finalizes the same audio as a standard voice note whose
/// file name carries the burst ID (`voice_<burstID>.m4a`) so receivers that
/// heard the live stream absorb the note silently instead of seeing a
/// duplicate.
@MainActor
final class PTTLiveVoiceSession: VoiceCaptureSession {
let burstID: Data
private let sendPacket: (Data) -> Void
private let capture: any PTTCapturing
private let now: () -> Date
/// Capture-queue-confined stream state: packetizes frames and lazily
/// emits START so packet order is guaranteed by queue serialization.
private final class StreamState {
var packetizer: VoiceBurstPacketizer
var sentStart = false
init(burstID: Data) {
packetizer = VoiceBurstPacketizer(burstID: burstID)
}
}
private let stream: StreamState
private var startDate: Date?
private var completed = false
var isLive: Bool { true }
/// - Parameter sendPacket: delivers one encoded `VoiceBurstPacket` to the
/// target peer; must be safe to call from any queue (BLEService hops to
/// its own message queue internally).
init(
sendPacket: @escaping (Data) -> Void,
capture: (any PTTCapturing)? = nil,
now: @escaping () -> Date = Date.init,
burstID: Data? = nil
) {
self.burstID = burstID ?? VoiceBurstPacket.makeBurstID()
self.sendPacket = sendPacket
self.capture = capture ?? PTTCaptureEngine()
self.now = now
self.stream = StreamState(burstID: self.burstID)
}
func requestPermission() async -> Bool {
await VoiceRecorder.shared.requestPermission()
}
func start() async throws {
let outputURL = try Self.makeOutputURL(burstID: burstID)
let sendPacket = sendPacket
let stream = stream
capture.onFrames = { frames in
if !stream.sentStart {
stream.sentStart = true
if let start = VoiceBurstPacket(
burstID: stream.packetizer.burstID,
seq: 0,
kind: .start(codec: .aacLC16kMono)
) {
sendPacket(start.encode())
}
}
for frame in frames {
for packet in stream.packetizer.add(frame) {
sendPacket(packet)
}
}
// Flush per callback batch: at ~130-byte frames the budget fits
// one frame per packet anyway, and holding residue would add
// ~100 ms of avoidable latency.
for packet in stream.packetizer.flush() {
sendPacket(packet)
}
}
do {
try await capture.start(outputURL: outputURL)
} catch is CancellationError {
// The hold was released/canceled while the session acquire was
// in flight: the engine never started and the capture already
// handed its token back nothing to retry. A coordinator-side
// interruption during handoff also cancels acquire, but that is
// not a successful start and must propagate to the view model.
guard completed else { throw CancellationError() }
return
} catch {
// The HAL can briefly report a dead input right after the audio
// session (re)activates while the route settles; one retry after
// a short pause covers it (observed on iPhone field tests).
SecureLogger.warning("PTT: capture start failed (\(error)) — retrying once after route settle", category: .session)
try? await Task.sleep(nanoseconds: 150_000_000)
// The hold may have been released/canceled during the retry pause.
// Starting the mic now would leave it live and streaming after the
// user let go, so bail instead of opening a hot mic.
guard !completed else {
capture.cancel()
return
}
try await capture.start(outputURL: outputURL)
}
startDate = now()
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) capture started", category: .session)
}
func finish() async -> URL? {
guard !completed else { return nil }
completed = true
let elapsed = startDate.map { now().timeIntervalSince($0) } ?? 0
let (url, encodedFrames) = capture.stop()
// stop() drained the capture queue, so touching `stream` is safe now.
let capturedDuration = Double(encodedFrames) * PTTAudioFormat.frameDuration
guard elapsed >= VoiceRecorder.minRecordingDuration,
capturedDuration >= VoiceRecorder.minRecordingDuration,
let url
else {
sendControlPacket(.canceled)
if let url {
try? FileManager.default.removeItem(at: url)
}
return nil
}
for packet in stream.packetizer.flush() {
sendPacket(packet)
}
let durationMs = UInt32((capturedDuration * 1000).rounded())
sendControlPacket(.end(totalDataPackets: stream.packetizer.dataPacketCount, durationMs: durationMs))
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) finished — \(stream.packetizer.dataPacketCount) data packets, \(encodedFrames) frames, \(durationMs) ms", category: .session)
return url
}
func cancel() async {
let alreadyCompleted = completed
completed = true
// Always tear down the capture, even if a quick-release already marked
// us completed: the engine can start late (during start()'s retry
// pause), and only capture.cancel() stops the mic and deactivates the
// session. It is idempotent, so a redundant call is harmless.
capture.cancel()
if !alreadyCompleted {
sendControlPacket(.canceled)
}
}
func panicCancelSynchronously() {
// Do not emit a canceled packet: it would itself be pre-panic
// conversation data racing the emergency transport reset.
completed = true
capture.cancel()
}
private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) {
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
sendPacket(packet.encode())
}
private static func makeOutputURL(burstID: Data) throws -> URL {
let base = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let directory = base
.appendingPathComponent("files", isDirectory: true)
.appendingPathComponent("voicenotes/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
return directory.appendingPathComponent("voice_\(burstID.hexEncodedString()).m4a")
}
}
@@ -9,9 +9,6 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
@Published private(set) var duration: TimeInterval = 0 @Published private(set) var duration: TimeInterval = 0
@Published private(set) var progress: Double = 0 @Published private(set) var progress: Double = 0
/// Internal lifecycle visibility for deterministic acquisition tests.
var isPlaybackStartPending: Bool { sessionAcquireInFlight }
/// rounded so 4.9s shows "00:05" /// rounded so 4.9s shows "00:05"
var roundedDuration: Int { var roundedDuration: Int {
guard duration.isFinite else { return 0 } guard duration.isFinite else { return 0 }
@@ -27,24 +24,9 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
private var player: AVAudioPlayer? private var player: AVAudioPlayer?
private var timer: Timer? private var timer: Timer?
private var url: URL private var url: URL
/// Test seam; `AudioSessionCoordinator.shared` when nil.
private let sessionCoordinatorOverride: AudioSessionCoordinator?
/// Injectable so tests don't fight over the app-wide exclusive-playback
/// slot (a parallel test's `play()` would pause this controller mid-test).
private let exclusivity: VoiceNotePlaybackCoordinator
private var sessionToken: AudioSessionCoordinator.Token?
/// A session acquire is in flight (it suspends off-main for the blocking
/// session IPC); gates against double acquisition on rapid play taps.
private var sessionAcquireInFlight = false
init( init(url: URL) {
url: URL,
sessionCoordinator: AudioSessionCoordinator? = nil,
exclusivity: VoiceNotePlaybackCoordinator? = nil
) {
self.url = url self.url = url
self.sessionCoordinatorOverride = sessionCoordinator
self.exclusivity = exclusivity ?? .shared
super.init() super.init()
// Don't load anything eagerly - wait until user interaction or view is fully displayed // Don't load anything eagerly - wait until user interaction or view is fully displayed
} }
@@ -69,16 +51,6 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
deinit { deinit {
timer?.invalidate() timer?.invalidate()
player?.stop()
// A per-row @StateObject can be discarded mid-playback (navigating
// away). Leaking the token here would hold the session forever
// never deactivating it, and pinning any escalated category for the
// app's lifetime. `release` is fire-and-forget onto the coordinator's
// queue, so it is deinit-safe: only the Sendable token crosses.
if let token = sessionToken {
sessionToken = nil
(sessionCoordinatorOverride ?? .shared).release(token)
}
} }
func replaceURL(_ url: URL) { func replaceURL(_ url: URL) {
@@ -96,15 +68,11 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
func play() { func play() {
guard ensurePlayerReady() else { return } guard ensurePlayerReady() else { return }
exclusivity.activate(self) VoiceNotePlaybackCoordinator.shared.activate(self)
isPlaying = true player?.play()
startTimer() startTimer()
updateProgress() updateProgress()
// Acquired here (not in ensurePlayerReady): scrubbing a paused note isPlaying = true
// must not hold the session while nothing is audible. The session
// calls block on audio-server IPC, so they run off the main thread;
// the player starts once the session is configured.
startPlayerAfterAcquiringSession()
} }
func pause() { func pause() {
@@ -112,7 +80,6 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
stopTimer() stopTimer()
updateProgress() updateProgress()
isPlaying = false isPlaying = false
releaseSession()
} }
func stop() { func stop() {
@@ -121,8 +88,7 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
stopTimer() stopTimer()
updateProgress() updateProgress()
isPlaying = false isPlaying = false
releaseSession() VoiceNotePlaybackCoordinator.shared.deactivate(self)
exclusivity.deactivate(self)
} }
func seek(to fraction: Double) { func seek(to fraction: Double) {
@@ -130,11 +96,8 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
let clamped = max(0, min(1, fraction)) let clamped = max(0, min(1, fraction))
if let player = player { if let player = player {
player.currentTime = clamped * player.duration player.currentTime = clamped * player.duration
// While the session acquire is still in flight, don't start if isPlaying {
// audio pre-activation the pending acquire's completion starts player.play()
// playback (from the new position) once the session resolves.
if isPlaying, !sessionAcquireInFlight {
startPreparedPlayer()
} }
updateProgress() updateProgress()
} }
@@ -149,20 +112,18 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
self.stopTimer() self.stopTimer()
self.updateProgress() self.updateProgress()
self.isPlaying = false self.isPlaying = false
self.releaseSession() VoiceNotePlaybackCoordinator.shared.deactivate(self)
self.exclusivity.deactivate(self)
} }
} }
// MARK: - Private Helpers // MARK: - Private Helpers
private func preparePlayer(for url: URL) { private func preparePlayer(for url: URL) {
// Load metadata synchronously, but do not call prepareToPlay here: // Prepare player synchronously (only called when playback is requested)
// paused scrubbing reaches this path and must not acquire playback
// hardware outside the AudioSessionCoordinator token lifetime.
do { do {
let player = try AVAudioPlayer(contentsOf: url) let player = try AVAudioPlayer(contentsOf: url)
player.delegate = self player.delegate = self
player.prepareToPlay()
self.player = player self.player = player
duration = player.duration duration = player.duration
currentTime = player.currentTime currentTime = player.currentTime
@@ -180,79 +141,16 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
if player == nil { if player == nil {
preparePlayer(for: url) preparePlayer(for: url)
} }
return player != nil #if os(iOS)
} let session = AVAudioSession.sharedInstance()
/// All entry points (SwiftUI actions, `pauseForExclusivity`, the
/// delegate's main-queue hop) run on the main thread; the acquire itself
/// suspends while the blocking session IPC runs on the coordinator's
/// queue, and the player starts when it resolves. An acquire failure
/// leaves playback stopped: starting without a registered token would
/// bypass interruption fan-out and the coordinator's refcount. A
/// pause/stop landing mid-acquire hands the token straight back.
private func startPlayerAfterAcquiringSession() {
if sessionToken != nil {
startPreparedPlayer()
return
}
guard !sessionAcquireInFlight else { return }
sessionAcquireInFlight = true
let coordinator = sessionCoordinatorOverride ?? AudioSessionCoordinator.shared
Task { @MainActor [weak self] in
var token: AudioSessionCoordinator.Token?
do { do {
token = try await coordinator.acquire(.playback) { [weak self] in try session.setCategory(.playback, mode: .spokenAudio, options: [.mixWithOthers])
self?.pause() try session.setActive(true, options: [])
}
} catch { } catch {
SecureLogger.error("Failed to activate audio session: \(error)", category: .session) SecureLogger.error("Failed to activate audio session: \(error)", category: .session)
} }
guard let self else { #endif
// The row was discarded while acquiring; deinit had no token return player != nil
// to release yet.
token.map(coordinator.release)
return
}
self.sessionAcquireInFlight = false
guard self.isPlaying else {
// Paused/stopped while the session was activating.
token.map(coordinator.release)
return
}
guard let token else {
self.failPlaybackStart()
return
}
self.sessionToken = token
self.startPreparedPlayer()
}
}
@discardableResult
private func startPreparedPlayer() -> Bool {
guard let player,
player.prepareToPlay(),
player.play()
else {
SecureLogger.error("Voice note player refused to start " + url.lastPathComponent, category: .session)
failPlaybackStart()
return false
}
return true
}
private func failPlaybackStart() {
player?.pause()
stopTimer()
updateProgress()
isPlaying = false
releaseSession()
exclusivity.deactivate(self)
}
private func releaseSession() {
sessionToken.map((sessionCoordinatorOverride ?? .shared).release)
sessionToken = nil
} }
private func startTimer() { private func startTimer() {
@@ -283,75 +181,25 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay
} }
} }
/// Something that can hold the app's single audio-playback slot and yield it /// Ensures only one voice note plays at a time.
/// when another playback starts (voice notes pause; live bursts stop).
protocol ExclusivePlayback: AnyObject {
func pauseForExclusivity()
}
extension VoiceNotePlaybackController: ExclusivePlayback {
func pauseForExclusivity() {
pause()
}
}
/// Ensures only one voice playback (note or live burst) runs at a time.
final class VoiceNotePlaybackCoordinator { final class VoiceNotePlaybackCoordinator {
static let shared = VoiceNotePlaybackCoordinator() static let shared = VoiceNotePlaybackCoordinator()
struct Reservation: Equatable { private weak var activeController: VoiceNotePlaybackController?
fileprivate let generation: UInt64
}
private weak var activeController: (any ExclusivePlayback)? private init() {}
private weak var latestReservedController: (any ExclusivePlayback)?
private var latestReservation = Reservation(generation: 0)
/// Internal so tests can isolate their own exclusivity slot; the app func activate(_ controller: VoiceNotePlaybackController) {
/// uses `shared`.
init() {}
/// Records playback intent without interrupting audio that is already
/// audible. Async starters reserve before suspension, then activate only
/// after their audio resource is ready.
func reserve(_ controller: any ExclusivePlayback) -> Reservation {
latestReservation = Reservation(generation: latestReservation.generation &+ 1)
latestReservedController = controller
return latestReservation
}
/// Immediate activation for synchronous/user-initiated playback.
@discardableResult
func activate(_ controller: any ExclusivePlayback) -> Reservation {
let reservation = reserve(controller)
_ = activate(controller, reservation: reservation)
return reservation
}
/// Commits an earlier reservation only when it is still the newest
/// playback request. This prevents an older async acquire from stealing
/// the floor after a newer play gesture.
@discardableResult
func activate(_ controller: any ExclusivePlayback, reservation: Reservation) -> Bool {
guard isCurrent(reservation, for: controller) else { return false }
if activeController === controller { if activeController === controller {
return true return
} }
activeController?.pauseForExclusivity() activeController?.pause()
activeController = controller activeController = controller
return true
} }
func isCurrent(_ reservation: Reservation, for controller: any ExclusivePlayback) -> Bool { func deactivate(_ controller: VoiceNotePlaybackController) {
latestReservation == reservation && latestReservedController === controller
}
func deactivate(_ controller: any ExclusivePlayback) {
if activeController === controller { if activeController === controller {
activeController = nil activeController = nil
} }
if latestReservedController === controller {
latestReservedController = nil
}
} }
} }
+58 -227
View File
@@ -1,95 +1,22 @@
import Foundation import Foundation
import AVFoundation import AVFoundation
/// The small surface of `AVAudioRecorder` that `VoiceRecorder` owns. Keeping
/// it behind a protocol lets lifecycle races be tested without opening the
/// microphone on the test host.
protocol VoiceAudioRecording: AnyObject {
var isRecording: Bool { get }
var isMeteringEnabled: Bool { get set }
func prepareToRecord() -> Bool
func record(forDuration duration: TimeInterval) -> Bool
func stop()
}
extension AVAudioRecorder: VoiceAudioRecording {}
protocol VoiceAudioRecorderCreating {
func makeRecorder(url: URL) throws -> any VoiceAudioRecording
}
private struct SystemVoiceAudioRecorderFactory: VoiceAudioRecorderCreating {
func makeRecorder(url: URL) throws -> any VoiceAudioRecording {
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 16_000,
AVNumberOfChannelsKey: 1,
AVEncoderBitRateKey: 16_000
]
return try AVAudioRecorder(url: url, settings: settings)
}
}
/// Manages audio capture for mesh voice notes with predictable encoding settings. /// Manages audio capture for mesh voice notes with predictable encoding settings.
actor VoiceRecorder { actor VoiceRecorder {
enum RecorderError: Error, Equatable { enum RecorderError: Error {
case microphoneAccessDenied case microphoneAccessDenied
case recorderInitializationFailed
case recordingInProgress case recordingInProgress
case failedToStartRecording
} }
static let shared = VoiceRecorder() static let shared = VoiceRecorder()
private let paddingInterval: TimeInterval = 0.5
private let maxRecordingDuration: TimeInterval = 120
static let minRecordingDuration: TimeInterval = 1 static let minRecordingDuration: TimeInterval = 1
/// Identity of one press/hold. Every lifecycle mutation must present the private var recorder: AVAudioRecorder?
/// same owner that started the recorder, so a stale finish or cancel from
/// another hold cannot stop or delete the current recording.
final class RecordingOwner: @unchecked Sendable {}
/// Test-only scheduling seams for lifecycle boundaries that otherwise rely
/// on wall-clock sleeps. Production uses the real padding delay.
struct TestingHooks: Sendable {
let waitForStopPadding: (@Sendable (TimeInterval) async -> Void)?
init(waitForStopPadding: (@Sendable (TimeInterval) async -> Void)? = nil) {
self.waitForStopPadding = waitForStopPadding
}
}
private let sessionCoordinator: AudioSessionCoordinator
private let recorderFactory: any VoiceAudioRecorderCreating
private let permissionGranted: () -> Bool
private let paddingInterval: TimeInterval
private let maxRecordingDuration: TimeInterval
private let outputDirectory: URL?
private let testingHooks: TestingHooks
private var recorder: (any VoiceAudioRecording)?
private var currentURL: URL? private var currentURL: URL?
private var sessionToken: AudioSessionCoordinator.Token?
private var activeOwner: RecordingOwner?
/// True only while `startRecording()` is suspended in session acquire.
/// A second start is rejected instead of superseding the first one.
private var startInFlight = false
init(
sessionCoordinator: AudioSessionCoordinator = .shared,
recorderFactory: any VoiceAudioRecorderCreating = SystemVoiceAudioRecorderFactory(),
permissionGranted: (() -> Bool)? = nil,
paddingInterval: TimeInterval = 0.5,
maxRecordingDuration: TimeInterval = 120,
outputDirectory: URL? = nil,
testingHooks: TestingHooks = TestingHooks()
) {
self.sessionCoordinator = sessionCoordinator
self.recorderFactory = recorderFactory
self.permissionGranted = permissionGranted ?? Self.hasSystemPermission
self.paddingInterval = paddingInterval
self.maxRecordingDuration = maxRecordingDuration
self.outputDirectory = outputDirectory
self.testingHooks = testingHooks
}
// MARK: - Permissions // MARK: - Permissions
@@ -115,130 +42,82 @@ actor VoiceRecorder {
// MARK: - Recording Lifecycle // MARK: - Recording Lifecycle
@discardableResult @discardableResult
func startRecording(owner: RecordingOwner) async throws -> URL { func startRecording() throws -> URL {
if activeOwner != nil { if recorder?.isRecording == true {
throw RecorderError.recordingInProgress throw RecorderError.recordingInProgress
} }
guard permissionGranted() else { #if os(iOS)
let session = AVAudioSession.sharedInstance()
guard session.recordPermission == .granted else {
throw RecorderError.microphoneAccessDenied throw RecorderError.microphoneAccessDenied
} }
#if targetEnvironment(simulator)
activeOwner = owner // allowBluetoothHFP is not available on iOS Simulator
startInFlight = true try session.setCategory(
.playAndRecord,
// The acquire suspends while the blocking session IPC runs on the mode: .default,
// coordinator's queue (never this actor's thread or main). options: [.defaultToSpeaker, .allowBluetoothA2DP]
let token: AudioSessionCoordinator.Token )
do { #else
token = try await sessionCoordinator.acquire(.capture) { [weak self] in try session.setCategory(
Task { await self?.handleSessionInterruption(for: owner) } .playAndRecord,
} mode: .default,
} catch { options: [.defaultToSpeaker, .allowBluetoothA2DP, .allowBluetoothHFP]
guard activeOwner === owner else { )
throw CancellationError() #endif
} try session.setActive(true, options: .notifyOthersOnDeactivation)
startInFlight = false #endif
activeOwner = nil #if os(macOS)
throw error guard AVCaptureDevice.authorizationStatus(for: .audio) == .authorized else {
throw RecorderError.microphoneAccessDenied
} }
#endif
// Actor reentrancy: release/cancel may have ended this hold while the let outputURL = try makeOutputURL()
// blocking session activation was still in progress. let settings: [String: Any] = [
guard activeOwner === owner, startInFlight else { AVFormatIDKey: kAudioFormatMPEG4AAC,
sessionCoordinator.release(token) AVSampleRateKey: 16_000,
throw CancellationError() AVNumberOfChannelsKey: 1,
} AVEncoderBitRateKey: 16_000
startInFlight = false ]
sessionToken = token
var outputURL: URL? let audioRecorder = try AVAudioRecorder(url: outputURL, settings: settings)
do {
let newURL = try makeOutputURL()
outputURL = newURL
let audioRecorder = try recorderFactory.makeRecorder(url: newURL)
audioRecorder.isMeteringEnabled = true audioRecorder.isMeteringEnabled = true
guard audioRecorder.prepareToRecord() else { audioRecorder.prepareToRecord()
throw RecorderError.failedToStartRecording audioRecorder.record(forDuration: maxRecordingDuration)
}
guard audioRecorder.record(forDuration: maxRecordingDuration) else {
throw RecorderError.failedToStartRecording
}
recorder = audioRecorder recorder = audioRecorder
currentURL = newURL currentURL = outputURL
return newURL return outputURL
} catch {
releaseSessionToken()
recorder = nil
currentURL = nil
activeOwner = nil
if let outputURL {
try? FileManager.default.removeItem(at: outputURL)
}
throw error
}
} }
func stopRecording(owner: RecordingOwner) async -> URL? { func stopRecording() async -> URL? {
guard activeOwner === owner else { return nil } guard let recorder, recorder.isRecording else {
return currentURL
// `finish()` can race a still-suspended start on a direct caller even
// though the UI normally routes quick releases through cancel().
if startInFlight {
activeOwner = nil
startInFlight = false
return nil
}
guard let activeRecorder = recorder else {
let sessionURL = currentURL
releaseSessionToken()
currentURL = nil
activeOwner = nil
return sessionURL
} }
let sessionURL = currentURL let sessionURL = currentURL
if activeRecorder.isRecording, paddingInterval > 0 {
if let waitForStopPadding = testingHooks.waitForStopPadding {
await waitForStopPadding(paddingInterval)
} else {
try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000)) try? await Task.sleep(nanoseconds: UInt64(paddingInterval * 1_000_000_000))
}
}
// Cancellation or interruption may have run during the padding sleep. recorder.stop()
// Only the recorder whose stop began here may be finalized by it.
guard activeOwner === owner,
let recorder = self.recorder,
recorder === activeRecorder
else { return nil }
if activeRecorder.isRecording { // A new session may have started during the sleep don't touch its state
activeRecorder.stop() if self.recorder === recorder {
} cleanupSession()
releaseSessionToken()
self.recorder = nil self.recorder = nil
currentURL = nil currentURL = nil
activeOwner = nil }
return sessionURL return sessionURL
} }
func cancelRecording(owner: RecordingOwner) async { func cancelRecording() {
guard activeOwner === owner else { return }
// Invalidate ownership before cleanup. An actor-reentrant start whose
// session acquire resumes later will observe the mismatch and release
// its token without opening the microphone.
activeOwner = nil
startInFlight = false
if let recorder, recorder.isRecording { if let recorder, recorder.isRecording {
recorder.stop() recorder.stop()
} }
releaseSessionToken() cleanupSession()
if let currentURL { if let currentURL {
try? FileManager.default.removeItem(at: currentURL) try? FileManager.default.removeItem(at: currentURL)
} }
@@ -246,60 +125,14 @@ actor VoiceRecorder {
currentURL = nil currentURL = nil
} }
/// Panic is a synchronous security boundary: the caller must know the
/// microphone, audio-session lease, and partial file are gone before it
/// rotates identities or deletes the media tree. VoiceRecorder is an
/// independent actor and this cleanup path never hops to MainActor, so a
/// short semaphore join is safe even when invoked by the UI actor.
nonisolated
func panicCancelSynchronously(owner: RecordingOwner) {
let finished = DispatchSemaphore(value: 0)
Task {
await cancelRecording(owner: owner)
finished.signal()
}
finished.wait()
}
/// The audio session was interrupted (call, Siri) or reconfigured: stop
/// the recorder but keep `recorder`/`currentURL` so the caller's pending
/// `stopRecording()` still returns the partial note.
private func handleSessionInterruption(for owner: RecordingOwner) async {
// A callback captured for a released token must never stop a newer
// recording. Conversely, an interruption delivered while acquire is
// still suspended invalidates that acquire before it can open the mic.
guard activeOwner === owner else { return }
if startInFlight {
activeOwner = nil
startInFlight = false
return
}
startInFlight = false
if let recorder, recorder.isRecording {
recorder.stop()
}
releaseSessionToken()
}
// MARK: - Helpers // MARK: - Helpers
private static func hasSystemPermission() -> Bool {
#if os(iOS)
AVAudioSession.sharedInstance().recordPermission == .granted
#elseif os(macOS)
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
#else
true
#endif
}
private func makeOutputURL() throws -> URL { private func makeOutputURL() throws -> URL {
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd_HHmmss" formatter.dateFormat = "yyyyMMdd_HHmmss"
let fileName = "voice_\(formatter.string(from: Date()))_\(UUID().uuidString).m4a" let fileName = "voice_\(formatter.string(from: Date())).m4a"
let baseDirectory = try outputDirectory let baseDirectory = try applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
?? applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil) try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: nil)
return baseDirectory.appendingPathComponent(fileName) return baseDirectory.appendingPathComponent(fileName)
} }
@@ -314,11 +147,9 @@ actor VoiceRecorder {
#endif #endif
} }
/// Fire-and-forget: the coordinator hops the blocking deactivation IPC private func cleanupSession() {
/// onto its own queue. #if os(iOS)
private func releaseSessionToken() { try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
guard let token = sessionToken else { return } #endif
sessionToken = nil
sessionCoordinator.release(token)
} }
} }
+6
View File
@@ -56,6 +56,12 @@ final class WaveformCache {
} }
} }
func purgeAll() {
queue.async(flags: .barrier) { [weak self] in
self?.cache.removeAll()
}
}
private func computeWaveform(url: URL, bins: Int) -> [Float]? { private func computeWaveform(url: URL, bins: Int) -> [Float]? {
guard bins > 0 else { return nil } guard bins > 0 else { return nil }
// Use autoreleasepool to manage memory from audio buffer allocations // Use autoreleasepool to manage memory from audio buffer allocations
+4 -80
View File
@@ -88,6 +88,8 @@ import BitFoundation
/// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy. /// Represents the ephemeral layer of identity - short-lived peer IDs that provide network privacy.
/// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships. /// These IDs rotate periodically to prevent tracking while maintaining cryptographic relationships.
struct EphemeralIdentity { struct EphemeralIdentity {
let peerID: PeerID // 8 random bytes
let sessionStart: Date
var handshakeState: HandshakeState var handshakeState: HandshakeState
} }
@@ -96,6 +98,7 @@ enum HandshakeState {
case initiated case initiated
case inProgress case inProgress
case completed(fingerprint: String) case completed(fingerprint: String)
case failed(reason: String)
} }
/// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair. /// Represents the cryptographic layer of identity - the stable Noise Protocol static key pair.
@@ -107,6 +110,7 @@ struct CryptographicIdentity: Codable {
// Optional Ed25519 signing public key (used to authenticate public messages) // Optional Ed25519 signing public key (used to authenticate public messages)
var signingPublicKey: Data? = nil var signingPublicKey: Data? = nil
let firstSeen: Date let firstSeen: Date
let lastHandshake: Date?
} }
/// Represents the social layer of identity - user-assigned names and trust relationships. /// Represents the social layer of identity - user-assigned names and trust relationships.
@@ -122,37 +126,13 @@ struct SocialIdentity: Codable {
var notes: String? var notes: String?
} }
/// Trust ladder: unknown casual vouched trusted verified.
///
/// Persistence compatibility: `TrustLevel` is stored by its *String* raw
/// value ("unknown", "casual", ), not by ordinal position, so inserting
/// `vouched` mid-ladder cannot corrupt previously persisted values every
/// pre-existing case keeps the exact raw value it was written with. The
/// `vouched` tier is additionally never persisted into `SocialIdentity`
/// (it's recomputed on read from stored vouches), so downgraded builds never
/// encounter the unfamiliar raw value.
enum TrustLevel: String, Codable { enum TrustLevel: String, Codable {
case unknown case unknown
case casual case casual
/// Transitively trusted: vouched for by at least one peer *I* verified.
/// Derived at read time never written to persistent storage.
case vouched
case trusted case trusted
case verified case verified
} }
// MARK: - Vouching (transitive verification)
/// One accepted vouch: a peer I verified (the voucher) attested that they
/// verified the vouchee. Validity is recomputed on read a record only
/// counts while its voucher remains in `verifiedFingerprints` and its
/// timestamp is within `VouchAttestation.maxAge` so unverifying a voucher
/// silently invalidates the vouches they gave without a cascade delete.
struct VouchRecord: Codable, Equatable {
let voucherFingerprint: String
let timestamp: Date
}
// MARK: - Identity Cache // MARK: - Identity Cache
/// Persistent storage for identity mappings and relationships. /// Persistent storage for identity mappings and relationships.
@@ -175,64 +155,8 @@ struct IdentityCache: Codable {
// Blocked Nostr pubkeys (lowercased hex) for geohash chats // Blocked Nostr pubkeys (lowercased hex) for geohash chats
var blockedNostrPubkeys: Set<String> = [] var blockedNostrPubkeys: Set<String> = []
// Vouching (transitive verification). All three fields are Optional so
// caches persisted before this feature decode cleanly decodeIfPresent
// is used below, 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
// Stable Noise fingerprints that proved encrypted private-media support
// inside an authenticated Noise session. Optional for decoding caches
// written before this migration. Entries are monotonic until a panic wipe
// so an old/replayed announce cannot silently downgrade a peer.
var privateMediaCapableFingerprints: Set<String>? = nil
// Noise-fingerprint -> Ed25519 announcement key, learned only from the
// authenticated peer-state payload. This prevents a self-signed announce
// containing a copied public Noise key from replacing a previously bound
// public-message signing identity. Optional for old cache compatibility.
var authenticatedSigningKeysByFingerprint: [String: Data]? = nil
// Fingerprint -> Cryptographic identity (noise + pinned signing key).
// Persisting the signing-key pin is security-critical: it must survive
// app restarts so an attacker cannot replay a known peer's
// noiseKey/peerID with their own signing key and be treated as first
// contact (TOFU downgrade).
var cryptographicIdentities: [String: CryptographicIdentity] = [:]
// Schema version for future migrations // Schema version for future migrations
var version: Int = 1 var version: Int = 1
init() {}
// Custom decoding so caches written by older builds (missing newer keys
// such as `cryptographicIdentities` or the vouching fields) still load
// instead of being discarded. Every field uses decodeIfPresent so a
// missing key falls back to its default rather than throwing.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
socialIdentities = try container.decodeIfPresent([String: SocialIdentity].self, forKey: .socialIdentities) ?? [:]
nicknameIndex = try container.decodeIfPresent([String: Set<String>].self, forKey: .nicknameIndex) ?? [:]
verifiedFingerprints = try container.decodeIfPresent(Set<String>.self, forKey: .verifiedFingerprints) ?? []
lastInteractions = try container.decodeIfPresent([String: Date].self, forKey: .lastInteractions) ?? [:]
blockedNostrPubkeys = try container.decodeIfPresent(Set<String>.self, forKey: .blockedNostrPubkeys) ?? []
vouchesByVouchee = try container.decodeIfPresent([String: [VouchRecord]].self, forKey: .vouchesByVouchee)
vouchBatchSentAt = try container.decodeIfPresent([String: Date].self, forKey: .vouchBatchSentAt)
verifiedAt = try container.decodeIfPresent([String: Date].self, forKey: .verifiedAt)
privateMediaCapableFingerprints = try container.decodeIfPresent(Set<String>.self, forKey: .privateMediaCapableFingerprints)
authenticatedSigningKeysByFingerprint = try container.decodeIfPresent([String: Data].self, forKey: .authenticatedSigningKeysByFingerprint)
cryptographicIdentities = try container.decodeIfPresent([String: CryptographicIdentity].self, forKey: .cryptographicIdentities) ?? [:]
version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
}
} }
// //
+61 -343
View File
@@ -108,6 +108,8 @@ protocol SecureIdentityStateManagerProtocol {
func updateSocialIdentity(_ identity: SocialIdentity) func updateSocialIdentity(_ identity: SocialIdentity)
// MARK: Favorites Management // MARK: Favorites Management
func getFavorites() -> Set<String>
func setFavorite(_ fingerprint: String, isFavorite: Bool)
func isFavorite(fingerprint: String) -> Bool func isFavorite(fingerprint: String) -> Bool
// MARK: Blocked Users Management // MARK: Blocked Users Management
@@ -121,6 +123,7 @@ protocol SecureIdentityStateManagerProtocol {
// MARK: Ephemeral Session Management // MARK: Ephemeral Session Management
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState)
func updateHandshakeState(peerID: PeerID, state: HandshakeState)
// MARK: Cleanup // MARK: Cleanup
func clearAllIdentityData() func clearAllIdentityData()
@@ -130,24 +133,6 @@ protocol SecureIdentityStateManagerProtocol {
func setVerified(fingerprint: String, verified: Bool) func setVerified(fingerprint: String, verified: Bool)
func isVerified(fingerprint: String) -> Bool func isVerified(fingerprint: String) -> Bool
func getVerifiedFingerprints() -> Set<String> func getVerifiedFingerprints() -> Set<String>
// MARK: Vouching (transitive verification)
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool
func validVouchers(for fingerprint: String) -> [VouchRecord]
func isVouched(fingerprint: String) -> Bool
func lastVouchBatchSent(to fingerprint: String) -> Date?
func markVouchBatchSent(to fingerprint: String, at date: Date)
func signingPublicKey(forFingerprint fingerprint: String) -> Data?
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String]
// MARK: Noise-authenticated announcement identity
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String)
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data?
// MARK: Private-media downgrade protection
func markPrivateMediaCapable(fingerprint: String)
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool
} }
/// Singleton manager for secure identity state persistence and retrieval. /// Singleton manager for secure identity state persistence and retrieval.
@@ -160,30 +145,18 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// In-memory state // In-memory state
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:] private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
// Cryptographic identities (including pinned signing keys) live inside private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
// `cache` so they persist across app restarts; see IdentityCache.
private var cache: IdentityCache = IdentityCache() private var cache: IdentityCache = IdentityCache()
// Thread safety // Thread safety
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent) private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
private let queueSpecificKey = DispatchSpecificKey<UInt8>()
// Pending-save coalescing flag. Reads/writes are serialized on `queue`. // Pending-save coalescing flag. Reads/writes are serialized on `queue`.
// // Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
// Persistence is SYNCHRONOUS: every mutating API runs its mutate + encrypt // than a retained DispatchSourceTimer: a lingering, never-cancelled timer
// + keychain write inside `queue.sync(flags: .barrier)`, so when the call // keeps the dispatch machinery alive and prevents the unit-test process from
// returns the write is already complete and NOTHING is left scheduled on // exiting. (The original code used Timer.scheduledTimer on a GCD queue with
// the queue. This is deliberate a retained DispatchSourceTimer (the // no run loop, so saves never actually fired.)
// original design) kept the dispatch machinery alive and prevented the
// unit-test process from exiting, and fire-and-forget `queue.async(.barrier)`
// (a later design) left a backlog of instrumented barrier saves still
// draining when LLVM's `--enable-code-coverage` `atexit` handler dumped
// `.profraw`, deadlocking the process at teardown on the constrained CI
// runner. Synchronous persistence has zero outstanding dispatch at exit, so
// neither failure mode is possible. `pendingSave` is now effectively always
// false after any mutation (saveIdentityCache persists inline and clears
// it); it remains only as a belt-and-suspenders flag read by `forceSave`
// and `deinit`.
private var pendingSave = false private var pendingSave = false
// Encryption key // Encryption key
@@ -234,7 +207,6 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
self.encryptionKey = loadedKey self.encryptionKey = loadedKey
self.encryptionKeyIsEphemeral = keyIsEphemeral self.encryptionKeyIsEphemeral = keyIsEphemeral
queue.setSpecific(key: queueSpecificKey, value: 1)
// Only read the persisted cache when we hold the real key; with an // Only read the persisted cache when we hold the real key; with an
// ephemeral key the decrypt would fail and discard the real cache. // ephemeral key the decrypt would fail and discard the real cache.
@@ -244,22 +216,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
deinit { deinit {
// Do NOT dispatch onto `queue` here. `deinit` can run on any thread forceSave()
// (including one draining `queue`), and the object is being
// deallocated: a `queue.sync` risks a re-entrant same-queue wait
// (deadlock) and a `queue.async` schedules work that resurrects `self`
// and may not drain before process exit.
//
// A flush here is redundant anyway: every mutating API already
// persists inline within its own barrier, so the keychain is already
// up to date. As a queue-free best-effort belt-and-suspenders, only
// flush if something is still pending. This is a direct read of
// in-hand state safe because a deallocating object has no other
// live references, so nothing can be mutating `cache` concurrently.
if pendingSave {
pendingSave = false
persist(snapshot: cache)
}
} }
// MARK: - Secure Loading/Saving // MARK: - Secure Loading/Saving
@@ -284,27 +241,21 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
} }
/// Persists the cache. Always invoked on `queue` under a barrier (its /// Persists the cache. Always invoked on `queue` under a barrier (its callers
/// callers run inside `queue.sync(flags: .barrier)`), so `cache` is read /// run inside `queue.async(.barrier)`), so it simply marks the cache dirty
/// while serialized. The encode + keychain write are done here (already on /// and persists it on the same serialized context no timer, nothing left
/// the exclusive barrier context), synchronously, so no separate hop is /// scheduled to keep the process alive.
/// scheduled and nothing is left to keep the process alive.
private func saveIdentityCache() { private func saveIdentityCache() {
pendingSave = true pendingSave = true
// On the barrier context already: snapshot is trivially consistent. performSave()
persist(snapshot: cache)
pendingSave = false
} }
/// Encodes, seals, and writes a *snapshot* of the cache to the keychain. /// Writes the cache to the keychain. Must run on `queue` with exclusive
/// /// (barrier) access.
/// Takes the cache by value so callers can capture a consistent snapshot private func performSave() {
/// under `queue` and then encode without holding it. Reading `cache` guard pendingSave else { return }
/// concurrently with a barrier writer would be a data race on the pendingSave = false
/// dictionary storage, which because `JSONEncoder` walks that storage
/// can spin forever (observed as a CI test-suite hang), so the snapshot
/// must be taken on `queue`, never off it.
private func persist(snapshot: IdentityCache) {
// Never persist under an ephemeral key it would overwrite the real // Never persist under an ephemeral key it would overwrite the real
// cache with data the next launch cannot decrypt. // cache with data the next launch cannot decrypt.
guard !encryptionKeyIsEphemeral else { guard !encryptionKeyIsEphemeral else {
@@ -313,7 +264,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
do { do {
let data = try JSONEncoder().encode(snapshot) let data = try JSONEncoder().encode(cache)
let sealedBox = try AES.GCM.seal(data, using: encryptionKey) let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey) let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
if saved { if saved {
@@ -324,26 +275,14 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
} }
// Force a flush (for app-termination / lifecycle events NOT from // Force immediate save (for app termination / lifecycle events). Mutations
// `deinit`, which persists inline; see the deinit note). Every mutating // already persist synchronously via saveIdentityCache, so this is normally a
// API already persists inline inside its own barrier via // no-op (performSave early-returns when nothing is pending). Runs directly on
// `saveIdentityCache`, so by the time this is called the keychain is // the caller's thread deliberately NOT a `queue.sync(barrier)`, which is
// already up to date and this is normally a no-op; it exists as a // reachable from `deinit` and from async tests on the swift-concurrency
// belt-and-suspenders flush of any `pendingSave` left set. // cooperative pool where a blocking barrier-sync can starve/deadlock it.
//
// Runs synchronously inside a `queue.sync(flags: .barrier)`: the barrier
// makes the `cache` read race-free (a plain off-queue read races in-flight
// barrier writers JSONEncoder walking a concurrently-mutated dictionary
// can spin forever, which surfaced as a CI hang), and being synchronous it
// leaves nothing scheduled to keep the process alive at teardown. Safe
// against re-entrant deadlock because this is never invoked from `deinit`
// (the only path that can run *on* `queue`).
func forceSave() { func forceSave() {
queue.sync(flags: .barrier) { performSave()
guard pendingSave else { return }
pendingSave = false
persist(snapshot: cache)
}
} }
// MARK: - Social Identity Management // MARK: - Social Identity Management
@@ -357,46 +296,36 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// MARK: - Cryptographic Identities // MARK: - Cryptographic Identities
/// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname. /// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.
///
/// TOFU signing-key pinning: once a signing key has been persisted for a
/// fingerprint, an update carrying a *different* signing key is refused in
/// full (including the claimed-nickname update) and security-logged. This
/// mirrors `BLEPeerRegistry.upsertVerifiedAnnounce` without it, an
/// attacker replaying a victim's noiseKey/peerID with their own signing
/// key could overwrite the victim's persisted identity while the victim is
/// offline or after an app restart. The refusal is permanent: there is
/// currently no targeted in-app way to reset the pin (`setVerified` does
/// not touch it). Recovering from a legitimate signing re-key requires the
/// peer to establish a new noise identity (new peerID) or the local user
/// to wipe all identity data (`clearAllIdentityData`, e.g. panic wipe).
/// - Parameters: /// - Parameters:
/// - fingerprint: SHA-256 hex of the Noise static public key /// - fingerprint: SHA-256 hex of the Noise static public key
/// - noisePublicKey: Noise static public key data /// - noisePublicKey: Noise static public key data
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages /// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
/// - claimedNickname: Optional latest claimed nickname to persist into social identity /// - claimedNickname: Optional latest claimed nickname to persist into social identity
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) { func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
let now = Date() let now = Date()
if var existing = self.cache.cryptographicIdentities[fingerprint] { if var existing = self.cryptographicIdentities[fingerprint] {
if let pinnedSigningKey = existing.signingPublicKey,
let announcedSigningKey = signingPublicKey,
pinnedSigningKey != announcedSigningKey {
SecureLogger.warning("🚨 Refusing to replace pinned signing key for \(fingerprint.prefix(8))… (possible impersonation attempt)", category: .security)
return
}
// Update keys if changed // Update keys if changed
if existing.publicKey != noisePublicKey { if existing.publicKey != noisePublicKey {
existing = CryptographicIdentity( existing = CryptographicIdentity(
fingerprint: fingerprint, fingerprint: fingerprint,
publicKey: noisePublicKey, publicKey: noisePublicKey,
signingPublicKey: signingPublicKey ?? existing.signingPublicKey, signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
firstSeen: existing.firstSeen firstSeen: existing.firstSeen,
lastHandshake: now
) )
self.cache.cryptographicIdentities[fingerprint] = existing self.cryptographicIdentities[fingerprint] = existing
} else { } else {
// Update signing key // Update signing key and lastHandshake
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
self.cache.cryptographicIdentities[fingerprint] = existing let updated = CryptographicIdentity(
fingerprint: existing.fingerprint,
publicKey: existing.publicKey,
signingPublicKey: existing.signingPublicKey,
firstSeen: existing.firstSeen,
lastHandshake: now
)
self.cryptographicIdentities[fingerprint] = updated
} }
// Persist updated state (already assigned in branches above) // Persist updated state (already assigned in branches above)
} else { } else {
@@ -405,9 +334,10 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
fingerprint: fingerprint, fingerprint: fingerprint,
publicKey: noisePublicKey, publicKey: noisePublicKey,
signingPublicKey: signingPublicKey, signingPublicKey: signingPublicKey,
firstSeen: now firstSeen: now,
lastHandshake: now
) )
self.cache.cryptographicIdentities[fingerprint] = entry self.cryptographicIdentities[fingerprint] = entry
} }
// Optionally persist claimed nickname into social identity // Optionally persist claimed nickname into social identity
@@ -439,72 +369,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
queue.sync { queue.sync {
// Defensive: ensure hex and correct length // Defensive: ensure hex and correct length
guard peerID.isShort else { return [] } guard peerID.isShort else { return [] }
return cache.cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) } return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
}
}
// MARK: - Private-media downgrade protection
func markPrivateMediaCapable(fingerprint: String) {
guard !fingerprint.isEmpty else { return }
let insertAndPersist = {
var pinned = self.cache.privateMediaCapableFingerprints ?? []
guard pinned.insert(fingerprint).inserted else { return }
self.cache.privateMediaCapableFingerprints = pinned
self.saveIdentityCache()
}
// Downgrade decisions can run immediately after an authenticated
// announce. Make the pin visible before returning; merely enqueueing a
// barrier leaves a cross-queue window where a replay can look legacy.
// The queue-specific fast path prevents self-deadlock if a future
// identity-state mutation records the capability from inside `queue`.
if DispatchQueue.getSpecific(key: queueSpecificKey) != nil {
insertAndPersist()
} else {
queue.sync(flags: .barrier, execute: insertAndPersist)
}
}
func hasObservedPrivateMediaCapability(fingerprint: String) -> Bool {
guard !fingerprint.isEmpty else { return false }
return queue.sync {
cache.privateMediaCapableFingerprints?.contains(fingerprint) == true
}
}
// MARK: - Noise-authenticated announcement identity
func bindAuthenticatedSigningPublicKey(_ signingPublicKey: Data, fingerprint: String) {
guard signingPublicKey.count == AuthenticatedPeerStatePacket.signingPublicKeyLength,
!fingerprint.isEmpty else { return }
let bindAndPersist = {
var bindings = self.cache.authenticatedSigningKeysByFingerprint ?? [:]
let bindingChanged = bindings[fingerprint] != signingPublicKey
bindings[fingerprint] = signingPublicKey
self.cache.authenticatedSigningKeysByFingerprint = bindings
if var cryptoIdentity = self.cache.cryptographicIdentities[fingerprint] {
cryptoIdentity.signingPublicKey = signingPublicKey
self.cache.cryptographicIdentities[fingerprint] = cryptoIdentity
}
guard bindingChanged else { return }
self.saveIdentityCache()
}
if DispatchQueue.getSpecific(key: queueSpecificKey) != nil {
bindAndPersist()
} else {
queue.sync(flags: .barrier, execute: bindAndPersist)
}
}
func authenticatedSigningPublicKey(forFingerprint fingerprint: String) -> Data? {
guard !fingerprint.isEmpty else { return nil }
return queue.sync {
cache.authenticatedSigningKeysByFingerprint?[fingerprint]
} }
} }
func updateSocialIdentity(_ identity: SocialIdentity) { func updateSocialIdentity(_ identity: SocialIdentity) {
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
self.cache.socialIdentities[identity.fingerprint] = identity self.cache.socialIdentities[identity.fingerprint] = identity
@@ -540,7 +410,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
func setFavorite(_ fingerprint: String, isFavorite: Bool) { func setFavorite(_ fingerprint: String, isFavorite: Bool) {
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
if var identity = self.cache.socialIdentities[fingerprint] { if var identity = self.cache.socialIdentities[fingerprint] {
identity.isFavorite = isFavorite identity.isFavorite = isFavorite
self.cache.socialIdentities[fingerprint] = identity self.cache.socialIdentities[fingerprint] = identity
@@ -578,7 +448,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func setBlocked(_ fingerprint: String, isBlocked: Bool) { func setBlocked(_ fingerprint: String, isBlocked: Bool) {
SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security) SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
if var identity = self.cache.socialIdentities[fingerprint] { if var identity = self.cache.socialIdentities[fingerprint] {
identity.isBlocked = isBlocked identity.isBlocked = isBlocked
if isBlocked { if isBlocked {
@@ -612,7 +482,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) { func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
let key = pubkeyHexLowercased.lowercased() let key = pubkeyHexLowercased.lowercased()
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
if isBlocked { if isBlocked {
self.cache.blockedNostrPubkeys.insert(key) self.cache.blockedNostrPubkeys.insert(key)
} else { } else {
@@ -630,12 +500,16 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) { func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState = .none) {
queue.async(flags: .barrier) { queue.async(flags: .barrier) {
self.ephemeralSessions[peerID] = EphemeralIdentity(handshakeState: handshakeState) self.ephemeralSessions[peerID] = EphemeralIdentity(
peerID: peerID,
sessionStart: Date(),
handshakeState: handshakeState
)
} }
} }
func updateHandshakeState(peerID: PeerID, state: HandshakeState) { func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
self.ephemeralSessions[peerID]?.handshakeState = state self.ephemeralSessions[peerID]?.handshakeState = state
// If handshake completed, update last interaction // If handshake completed, update last interaction
@@ -651,9 +525,10 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func clearAllIdentityData() { func clearAllIdentityData() {
SecureLogger.warning("Clearing all identity data", category: .security) SecureLogger.warning("Clearing all identity data", category: .security)
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
self.cache = IdentityCache() self.cache = IdentityCache()
self.ephemeralSessions.removeAll() self.ephemeralSessions.removeAll()
self.cryptographicIdentities.removeAll()
// Delete from keychain // Delete from keychain
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey) let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
@@ -662,7 +537,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
func removeEphemeralSession(peerID: PeerID) { func removeEphemeralSession(peerID: PeerID) {
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
self.ephemeralSessions.removeValue(forKey: peerID) self.ephemeralSessions.removeValue(forKey: peerID)
} }
} }
@@ -672,15 +547,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func setVerified(fingerprint: String, verified: Bool) { func setVerified(fingerprint: String, verified: Bool) {
SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security) SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
if verified { if verified {
self.cache.verifiedFingerprints.insert(fingerprint) self.cache.verifiedFingerprints.insert(fingerprint)
var verifiedAt = self.cache.verifiedAt ?? [:]
verifiedAt[fingerprint] = Date()
self.cache.verifiedAt = verifiedAt
} else { } else {
self.cache.verifiedFingerprints.remove(fingerprint) self.cache.verifiedFingerprints.remove(fingerprint)
self.cache.verifiedAt?.removeValue(forKey: fingerprint)
} }
// Update trust level if social identity exists // Update trust level if social identity exists
@@ -705,159 +576,6 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
} }
// MARK: - Vouching (transitive verification)
/// Maximum vouchers retained per vouchee (most recent kept).
static let maxVouchersPerVouchee = 8
/// Records an accepted vouch, enforcing every accept-policy gate that can
/// be evaluated against stored state (signature verification is the
/// caller's job it needs the sender's announce-bound signing key):
/// - the voucher must be a fingerprint *I* verified
/// - self-vouches are ignored
/// - vouches for peers I already verified are ignored (nothing to add)
/// - attestations outside the validity window are ignored
/// - at most `maxVouchersPerVouchee` vouchers are kept per vouchee
///
/// Returns true when the vouch was stored (or refreshed).
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date) -> Bool {
recordVouch(
voucheeFingerprint: voucheeFingerprint,
voucherFingerprint: voucherFingerprint,
timestamp: timestamp,
now: Date()
)
}
@discardableResult
func recordVouch(voucheeFingerprint: String, voucherFingerprint: String, timestamp: Date, now: Date) -> Bool {
queue.sync(flags: .barrier) {
guard voucheeFingerprint != voucherFingerprint,
self.cache.verifiedFingerprints.contains(voucherFingerprint),
!self.cache.verifiedFingerprints.contains(voucheeFingerprint) else {
return false
}
let age = now.timeIntervalSince(timestamp)
guard age <= VouchAttestation.maxAge, age >= -VouchAttestation.maxClockSkew else {
return false
}
var records = self.cache.vouchesByVouchee?[voucheeFingerprint] ?? []
if let index = records.firstIndex(where: { $0.voucherFingerprint == voucherFingerprint }) {
let newest = max(records[index].timestamp, timestamp)
records[index] = VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: newest)
} else {
records.append(VouchRecord(voucherFingerprint: voucherFingerprint, timestamp: timestamp))
}
// Keep the most recent vouchers up to the cap.
records.sort { $0.timestamp > $1.timestamp }
let capped = Array(records.prefix(Self.maxVouchersPerVouchee))
guard capped.contains(where: { $0.voucherFingerprint == voucherFingerprint }) else {
return false // Full of fresher vouches; nothing changed.
}
var vouches = self.cache.vouchesByVouchee ?? [:]
vouches[voucheeFingerprint] = capped
self.cache.vouchesByVouchee = vouches
self.saveIdentityCache()
return true
}
}
/// The vouches that currently count for `fingerprint`. Validity is
/// recomputed here rather than maintained by cascade deletes: a record
/// only counts while its voucher is still verified-by-me and its
/// timestamp is within the expiry window.
func validVouchers(for fingerprint: String) -> [VouchRecord] {
validVouchers(for: fingerprint, now: Date())
}
func validVouchers(for fingerprint: String, now: Date) -> [VouchRecord] {
queue.sync {
self.validVouchersLocked(for: fingerprint, now: now)
}
}
/// Requires `queue`.
private func validVouchersLocked(for fingerprint: String, now: Date) -> [VouchRecord] {
guard let records = cache.vouchesByVouchee?[fingerprint] else { return [] }
return records.filter { record in
record.voucherFingerprint != fingerprint
&& cache.verifiedFingerprints.contains(record.voucherFingerprint)
&& now.timeIntervalSince(record.timestamp) <= VouchAttestation.maxAge
}
}
/// True when the peer has at least one valid vouch and no explicit
/// verification of ours.
func isVouched(fingerprint: String) -> Bool {
isVouched(fingerprint: fingerprint, now: Date())
}
func isVouched(fingerprint: String, now: Date) -> Bool {
queue.sync {
guard !self.cache.verifiedFingerprints.contains(fingerprint) else { return false }
return !self.validVouchersLocked(for: fingerprint, now: now).isEmpty
}
}
/// The trust level to display: explicit verification wins, then the
/// persisted level, with `vouched` layered in (derived, never persisted)
/// between `casual` and `trusted`.
func effectiveTrustLevel(for fingerprint: String) -> TrustLevel {
effectiveTrustLevel(for: fingerprint, now: Date())
}
func effectiveTrustLevel(for fingerprint: String, now: Date) -> TrustLevel {
queue.sync {
if self.cache.verifiedFingerprints.contains(fingerprint) { return .verified }
let stored = self.cache.socialIdentities[fingerprint]?.trustLevel ?? .unknown
let vouched = !self.validVouchersLocked(for: fingerprint, now: now).isEmpty
switch stored {
case .verified, .trusted:
return stored
case .vouched, .casual, .unknown:
if vouched { return .vouched }
// `.vouched` should never be persisted; degrade defensively.
return stored == .vouched ? .casual : stored
}
}
}
func lastVouchBatchSent(to fingerprint: String) -> Date? {
queue.sync { cache.vouchBatchSentAt?[fingerprint] }
}
func markVouchBatchSent(to fingerprint: String, at date: Date) {
queue.async(flags: .barrier) {
var sentAt = self.cache.vouchBatchSentAt ?? [:]
sentAt[fingerprint] = date
self.cache.vouchBatchSentAt = sentAt
self.saveIdentityCache()
}
}
/// The peer's announce-bound Ed25519 signing key, if seen this session.
func signingPublicKey(forFingerprint fingerprint: String) -> Data? {
queue.sync { cache.cryptographicIdentities[fingerprint]?.signingPublicKey }
}
/// Verified fingerprints ordered most recently verified first (entries
/// without a recorded verification time sort last), excluding the given
/// fingerprint. Feeds the outgoing vouch batch.
func mostRecentlyVerifiedFingerprints(limit: Int, excluding fingerprint: String) -> [String] {
queue.sync {
let verifiedAt = cache.verifiedAt ?? [:]
let ordered = cache.verifiedFingerprints
.filter { $0 != fingerprint }
.sorted {
(verifiedAt[$0] ?? .distantPast, $0) > (verifiedAt[$1] ?? .distantPast, $1)
}
return Array(ordered.prefix(limit))
}
}
var debugNicknameIndex: [String: Set<String>] { var debugNicknameIndex: [String: Set<String>] {
queue.sync { cache.nicknameIndex } queue.sync { cache.nicknameIndex }
} }
+2 -4
View File
@@ -31,8 +31,6 @@
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.social-networking</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string> <string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSBluetoothAlwaysUsageDescription</key> <key>NSBluetoothAlwaysUsageDescription</key>
@@ -42,9 +40,9 @@
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string> <string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your location to compute optional geohash channels, bridge cells, and nearby place labels. Exact coordinates are not included in bitchat messages.</string> <string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>bitchat uses the microphone while you record voice notes or hold live push-to-talk, then sends that audio to your selected mesh conversation.</string> <string>bitchat uses the microphone to record voice notes that relay across the mesh.</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>bitchat lets you pick images from your photo library to share with nearby peers.</string> <string>bitchat lets you pick images from your photo library to share with nearby peers.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
+2673 -38318
View File
File diff suppressed because it is too large Load Diff
@@ -13,6 +13,13 @@ extension BitchatMessage {
enum Media { enum Media {
case voice(URL) case voice(URL)
case image(URL) case image(URL)
var url: URL {
switch self {
case .voice(let url), .image(let url):
return url
}
}
} }
// Cache the directory lookup to avoid repeated FileManager calls during view rendering // Cache the directory lookup to avoid repeated FileManager calls during view rendering
+3 -1
View File
@@ -7,6 +7,7 @@ struct BitchatPeer: Equatable {
let peerID: PeerID // Hex-encoded peer ID let peerID: PeerID // Hex-encoded peer ID
let noisePublicKey: Data let noisePublicKey: Data
let nickname: String let nickname: String
let lastSeen: Date
let isConnected: Bool let isConnected: Bool
let isReachable: Bool let isReachable: Bool
@@ -76,13 +77,14 @@ struct BitchatPeer: Equatable {
peerID: PeerID, peerID: PeerID,
noisePublicKey: Data, noisePublicKey: Data,
nickname: String, nickname: String,
lastSeen _: Date = Date(), lastSeen: Date = Date(),
isConnected: Bool = false, isConnected: Bool = false,
isReachable: Bool = false isReachable: Bool = false
) { ) {
self.peerID = peerID self.peerID = peerID
self.noisePublicKey = noisePublicKey self.noisePublicKey = noisePublicKey
self.nickname = nickname self.nickname = nickname
self.lastSeen = lastSeen
self.isConnected = isConnected self.isConnected = isConnected
self.isReachable = isReachable self.isReachable = isReachable
+6 -29
View File
@@ -16,19 +16,14 @@ enum CommandInfo: String, Identifiable {
// suggesting a spelling the processor rejects teaches users dead ends. // suggesting a spelling the processor rejects teaches users dead ends.
case block case block
case clear case clear
case group
case help case help
case hug case hug
case message = "msg" case message = "msg"
case slap case slap
case pay
case unblock case unblock
case who case who
case favorite = "fav" case favorite = "fav"
case unfavorite = "unfav" case unfavorite = "unfav"
case ping
case trace
case drop
var id: String { rawValue } var id: String { rawValue }
@@ -36,14 +31,8 @@ enum CommandInfo: String, Identifiable {
var placeholder: String? { var placeholder: String? {
switch self { switch self {
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite, .ping, .trace: case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
return "<" + String(localized: "content.input.nickname_placeholder") + ">" return "<" + String(localized: "content.input.nickname_placeholder") + ">"
case .group:
return "<" + String(localized: "content.input.group_placeholder") + ">"
case .pay:
return "<" + String(localized: "content.input.token_placeholder") + ">"
case .drop:
return "<" + String(localized: "content.input.note_placeholder") + ">"
case .clear, .help, .who: case .clear, .help, .who:
return nil return nil
} }
@@ -53,36 +42,24 @@ enum CommandInfo: String, Identifiable {
switch self { switch self {
case .block: String(localized: "content.commands.block") case .block: String(localized: "content.commands.block")
case .clear: String(localized: "content.commands.clear") case .clear: String(localized: "content.commands.clear")
case .group: String(localized: "content.commands.group")
case .help: String(localized: "content.commands.help") case .help: String(localized: "content.commands.help")
case .hug: String(localized: "content.commands.hug") case .hug: String(localized: "content.commands.hug")
case .message: String(localized: "content.commands.message") case .message: String(localized: "content.commands.message")
case .pay: String(localized: "content.commands.pay")
case .slap: String(localized: "content.commands.slap") case .slap: String(localized: "content.commands.slap")
case .unblock: String(localized: "content.commands.unblock") case .unblock: String(localized: "content.commands.unblock")
case .who: String(localized: "content.commands.who") case .who: String(localized: "content.commands.who")
case .favorite: String(localized: "content.commands.favorite") case .favorite: String(localized: "content.commands.favorite")
case .unfavorite: String(localized: "content.commands.unfavorite") case .unfavorite: String(localized: "content.commands.unfavorite")
case .ping: String(localized: "content.commands.ping")
case .trace: String(localized: "content.commands.trace")
case .drop: String(localized: "content.commands.drop")
} }
} }
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] { static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
var commands: [CommandInfo] = [.block, .unblock, .clear, .drop, .help, .hug, .message, .slap, .who] let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
// Cashu tokens are bearer instruments: in a public geohash any nearby // The processor rejects favorites in geohash contexts, so only
// stranger can redeem one, so don't *suggest* /pay there (the // suggest them where they actually work: mesh.
// processor still allows it behind an explicit "public" confirm).
// Payments make sense in every DM and in mesh public.
if !isGeoPublic {
commands.append(.pay)
}
// The processor rejects favorites, groups, and mesh diagnostics in
// geohash contexts, so only suggest them where they work: mesh.
if isGeoPublic || isGeoDM { if isGeoPublic || isGeoDM {
return commands return baseCommands
} }
return commands + [.favorite, .unfavorite, .ping, .trace, .group] return baseCommands + [.favorite, .unfavorite]
} }
} }
+1 -1
View File
@@ -30,7 +30,7 @@ struct NoisePayload {
// Safely get the first byte // Safely get the first byte
let firstByte = data[data.startIndex] let firstByte = data[data.startIndex]
guard let type = NoisePayloadType.decoded(rawValue: firstByte) else { guard let type = NoisePayloadType(rawValue: firstByte) else {
return nil return nil
} }
+1 -37
View File
@@ -1,21 +1,10 @@
import BitFoundation
import Foundation import Foundation
// REQUEST_SYNC payload TLV (type, length16, value) // REQUEST_SYNC payload TLV (type, length16, value)
// - 0x01: P (uint8) Golomb-Rice parameter // - 0x01: P (uint8) Golomb-Rice parameter
// - 0x02: M (uint32, big-endian) hash range (N * 2^P) // - 0x02: M (uint32, big-endian) hash range (N * 2^P)
// - 0x03: data (opaque) GR bitstream bytes (MSB-first) // - 0x03: data (opaque) GR bitstream bytes (MSB-first)
// - 0x04: types (SyncTypeFlags) packet types the filter covers
// - 0x05: sinceTimestamp (uint64, big-endian) filter coverage cursor
// - 0x06: fragmentIdFilter (UTF-8) comma-separated 16-hex-char (8-byte)
// fragment stream IDs; restricts the fragment diff to exactly those
// streams (targeted resync for stalled reassemblies)
struct RequestSyncPacket { struct RequestSyncPacket {
/// Maximum fragment IDs one 0x06 filter may carry. Each ID encodes as
/// 16 hex chars plus a comma separator, so the largest encoded value is
/// 60 * 17 - 1 = 1019 bytes, which fits the 1024-byte decoder cap.
static let maxFragmentIdFilterCount = 60
let p: Int let p: Int
let m: UInt32 let m: UInt32
let data: Data let data: Data
@@ -23,29 +12,6 @@ struct RequestSyncPacket {
let sinceTimestamp: UInt64? let sinceTimestamp: UInt64?
let fragmentIdFilter: String? let fragmentIdFilter: String?
/// Encodes 8-byte fragment stream IDs as the 0x06 filter string,
/// dropping malformed IDs and capping at `maxFragmentIdFilterCount`.
static func encodeFragmentIdFilter(_ fragmentIDs: [Data]) -> String? {
let tokens = fragmentIDs
.filter { $0.count == 8 }
.prefix(maxFragmentIdFilterCount)
.map { $0.hexEncodedString() }
guard !tokens.isEmpty else { return nil }
return tokens.joined(separator: ",")
}
/// Decodes a 0x06 filter string back into 8-byte fragment stream IDs,
/// ignoring malformed tokens and capping at `maxFragmentIdFilterCount`.
static func decodeFragmentIdFilter(_ filter: String?) -> Set<Data>? {
guard let filter else { return nil }
var ids: Set<Data> = []
for token in filter.split(separator: ",").prefix(maxFragmentIdFilterCount) {
guard token.count == 16, let id = Data(hexString: String(token)) else { continue }
ids.insert(id)
}
return ids.isEmpty ? nil : ids
}
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) { init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
self.p = p self.p = p
self.m = m self.m = m
@@ -122,9 +88,7 @@ struct RequestSyncPacket {
sinceTimestamp = ts sinceTimestamp = ts
} }
case 0x06: case 0x06:
// Same acceptance cap as the GCS payload; an oversized filter if let fid = String(data: v, encoding: .utf8) {
// is ignored rather than failing the whole request.
if v.count <= maxAcceptBytes, let fid = String(data: v, encoding: .utf8) {
fragmentIdFilter = fid fragmentIdFilter = fid
} }
default: default:
+1 -1
View File
@@ -723,7 +723,7 @@ final class NoiseHandshakeState {
return messageBuffer return messageBuffer
} }
func readMessage(_ message: Data, expectedPayloadLength _: Int = 0) throws -> Data { func readMessage(_ message: Data, expectedPayloadLength: Int = 0) throws -> Data {
guard currentPattern < messagePatterns.count else { guard currentPattern < messagePatterns.count else {
throw NoiseError.handshakeComplete throw NoiseError.handshakeComplete
+6 -46
View File
@@ -6,67 +6,27 @@
// For more information, see <https://unlicense.org> // For more information, see <https://unlicense.org>
// //
import BitFoundation
import Foundation import Foundation
enum NoiseSecurityConstants { enum NoiseSecurityConstants {
// Maximum message size to prevent memory exhaustion // Maximum message size to prevent memory exhaustion
static let maxMessageSize = 65535 // 64KB as per Noise spec static let maxMessageSize = 65535 // 64KB as per Noise spec
/// The extracted transport nonce (4 bytes) and Poly1305 tag (16 bytes)
/// added by `NoiseCipherState` around every transport plaintext.
static let transportCiphertextOverhead = 20
/// Private files are an explicit BitChat extension to the ordinary Noise
/// message-size ceiling. They remain bounded by the same framed-file cap
/// used by the binary and fragment decoders. Only the `.privateFile`
/// typed-payload path is allowed to use this larger budget.
private static let privateFileOuterPacketOverhead =
(BinaryProtocol.v1HeaderSize + 2) // v2 adds two length bytes
+ BinaryProtocol.senderIDSize
+ BinaryProtocol.recipientIDSize
static let maxPrivateFilePlaintextSize = FileTransferLimits.maxFramedFileBytes
- privateFileOuterPacketOverhead
- transportCiphertextOverhead
static let maxPrivateFileCiphertextSize =
maxPrivateFilePlaintextSize + transportCiphertextOverhead
// Maximum handshake message size // Maximum handshake message size
static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern static let maxHandshakeMessageSize = 2048 // 2KB to accommodate XX pattern
// Noise XX message 1 contains only the initiator's 32-byte ephemeral key.
static let xxInitialMessageSize = 32
// Bounds an ordinary initiator whose message 1 or 2 is lost.
static let ordinaryHandshakeTimeout: TimeInterval = 10
// Bounds the receive-only rollback quarantine created by an unauthenticated
// inbound message 1. A lost message 3 must not strand outbound traffic.
static let ordinaryResponderHandshakeTimeout: TimeInterval = 20
// A released client may immediately retry after both crossed initiators
// yielded. Give that unilateral retry a brief head start before the
// patched side spends its one bounded recovery.
static let handshakeCollisionRecoveryDelay: TimeInterval = 0.2
// Rate-limited recovery remains actionable without spinning.
static let handshakeRateLimitRecoveryDelay: TimeInterval = 60
// Covers only reordering between a winning message 3 and the losing
// crossed message 1.
static let recentInitiatorCompletionGracePeriod: TimeInterval = 1
// After unauthenticated responder rollback, reject another attempt long
// enough that paced message 1 traffic cannot keep outbound paused. A
// legitimate peer converges through the one manager-owned local retry.
static let ordinaryReconnectRollbackCooldown: TimeInterval = 60
// Session timeout - sessions older than this should be renegotiated // Session timeout - sessions older than this should be renegotiated
static let sessionTimeout: TimeInterval = 86400 // 24 hours static let sessionTimeout: TimeInterval = 86400 // 24 hours
// Maximum number of messages before rekey (2^64 - 1 is the nonce limit) // Maximum number of messages before rekey (2^64 - 1 is the nonce limit)
static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages static let maxMessagesPerSession: UInt64 = 1_000_000_000 // 1 billion messages
// Handshake timeout - abandon incomplete handshakes
static let handshakeTimeout: TimeInterval = 60 // 1 minute
// Maximum concurrent sessions per peer
static let maxSessionsPerPeer = 3
// Rate limiting // Rate limiting
static let maxHandshakesPerMinute = 10 static let maxHandshakesPerMinute = 10
static let maxMessagesPerSecond = 100 static let maxMessagesPerSecond = 100
+1
View File
@@ -14,4 +14,5 @@ enum NoiseSecurityError: Error {
case messageTooLarge case messageTooLarge
case invalidPeerID case invalidPeerID
case rateLimitExceeded case rateLimitExceeded
case handshakeTimeout
} }
@@ -15,19 +15,6 @@ struct NoiseSecurityValidator {
return data.count <= NoiseSecurityConstants.maxMessageSize return data.count <= NoiseSecurityConstants.maxMessageSize
} }
static func validateCiphertextSize(_ data: Data) -> Bool {
data.count <= NoiseSecurityConstants.maxMessageSize
+ NoiseSecurityConstants.transportCiphertextOverhead
}
static func validatePrivateFileMessageSize(_ data: Data) -> Bool {
data.count <= NoiseSecurityConstants.maxPrivateFilePlaintextSize
}
static func validatePrivateFileCiphertextSize(_ data: Data) -> Bool {
data.count <= NoiseSecurityConstants.maxPrivateFileCiphertextSize
}
/// Validate handshake message size /// Validate handshake message size
static func validateHandshakeMessageSize(_ data: Data) -> Bool { static func validateHandshakeMessageSize(_ data: Data) -> Bool {
return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize return data.count <= NoiseSecurityConstants.maxHandshakeMessageSize
+1 -4
View File
@@ -66,10 +66,7 @@ class NoiseSession {
// Only initiator writes the first message // Only initiator writes the first message
if role == .initiator { if role == .initiator {
guard let handshake = handshakeState else { let message = try handshakeState!.writeMessage()
throw NoiseSessionError.invalidState
}
let message = try handshake.writeMessage()
sentHandshakeMessages.append(message) sentHandshakeMessages.append(message)
return message return message
} else { } else {
-7
View File
@@ -11,11 +11,4 @@ enum NoiseSessionError: Error, Equatable {
case notEstablished case notEstablished
case sessionNotFound case sessionNotFound
case alreadyEstablished case alreadyEstablished
case peerIdentityMismatch
}
/// The manager owns the exact attempt's one bounded recovery. Packet handling
/// must not launch its historical second, immediate restart for this failure.
struct NoiseManagedHandshakeFailure: Error {
let underlying: Error
} }
File diff suppressed because it is too large Load Diff
+4 -11
View File
@@ -24,12 +24,8 @@ final class SecureNoiseSession: NoiseSession {
throw NoiseSecurityError.sessionExhausted throw NoiseSecurityError.sessionExhausted
} }
// Ordinary Noise messages keep the protocol ceiling. Finalized media // Validate message size
// is the sole typed-payload extension and remains under the framed-file guard NoiseSecurityValidator.validateMessageSize(plaintext) else {
// cap enforced again at the service and file-decoder layers.
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: plaintext.first)
&& NoiseSecurityValidator.validatePrivateFileMessageSize(plaintext)
guard NoiseSecurityValidator.validateMessageSize(plaintext) || isPrivateFile else {
throw NoiseSecurityError.messageTooLarge throw NoiseSecurityError.messageTooLarge
} }
@@ -46,11 +42,8 @@ final class SecureNoiseSession: NoiseSession {
throw NoiseSecurityError.sessionExpired throw NoiseSecurityError.sessionExpired
} }
// The payload type is encrypted, so a large candidate can only be // Validate message size
// bounded here; `NoiseEncryptionService.decrypt` authenticates it and guard NoiseSecurityValidator.validateMessageSize(ciphertext) else {
// then requires the resulting type to be `.privateFile`.
guard NoiseSecurityValidator.validateCiphertextSize(ciphertext)
|| NoiseSecurityValidator.validatePrivateFileCiphertextSize(ciphertext) else {
throw NoiseSecurityError.messageTooLarge throw NoiseSecurityError.messageTooLarge
} }
+36 -211
View File
@@ -32,23 +32,6 @@ struct GeoRelayDirectoryDependencies {
var retrySleep: (TimeInterval) async -> Void var retrySleep: (TimeInterval) async -> Void
var activeNotificationName: Notification.Name? var activeNotificationName: Notification.Name?
var autoStart: Bool var autoStart: Bool
var validationPolicy: GeoRelayDirectoryValidationPolicy
}
struct GeoRelayDirectoryValidationPolicy: Sendable {
let maximumBytes: Int
let maximumRows: Int
let maximumEntries: Int
let minimumRemoteEntries: Int
let minimumRetainedFraction: Double
static let live = GeoRelayDirectoryValidationPolicy(
maximumBytes: 512 * 1024,
maximumRows: 5_000,
maximumEntries: 5_000,
minimumRemoteEntries: 50,
minimumRetainedFraction: 0.5
)
} }
private extension GeoRelayDirectoryDependencies { private extension GeoRelayDirectoryDependencies {
@@ -61,16 +44,12 @@ private extension GeoRelayDirectoryDependencies {
#else #else
let activeNotificationName: Notification.Name? = nil let activeNotificationName: Notification.Name? = nil
#endif #endif
let validationPolicy = GeoRelayDirectoryValidationPolicy.live
return Self( return Self(
userDefaults: .standard, userDefaults: .standard,
notificationCenter: .default, notificationCenter: .default,
now: Date.init, now: Date.init,
// Runtime refreshes only from bitchat's reviewed copy. Upstream remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
// georelays/main is imported by a validator-backed pull request,
// so an upstream mutation cannot immediately retarget clients.
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv")!,
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds, fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds, refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds, retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
@@ -79,27 +58,7 @@ private extension GeoRelayDirectoryDependencies {
makeFetchData: { makeFetchData: {
let session = TorURLSession.shared.session let session = TorURLSession.shared.session
return { request in return { request in
let (bytes, response) = try await session.bytes(for: request) let (data, _) = try await session.data(for: request)
guard let response = response as? HTTPURLResponse,
(200...299).contains(response.statusCode),
response.url == request.url else {
throw URLError(.badServerResponse)
}
let maximumBytes = validationPolicy.maximumBytes
guard response.expectedContentLength <= Int64(maximumBytes) else {
throw URLError(.dataLengthExceedsMaximum)
}
var data = Data()
if response.expectedContentLength > 0 {
data.reserveCapacity(Int(response.expectedContentLength))
}
for try await byte in bytes {
guard data.count < maximumBytes else {
throw URLError(.dataLengthExceedsMaximum)
}
data.append(byte)
}
return data return data
} }
}, },
@@ -117,11 +76,7 @@ private extension GeoRelayDirectoryDependencies {
) )
let dir = base.appendingPathComponent("bitchat", isDirectory: true) let dir = base.appendingPathComponent("bitchat", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
// v2 ignores caches populated from the old direct-upstream return dir.appendingPathComponent("georelays_cache.csv")
// trust path and subjects every load to strict validation.
let legacyCache = dir.appendingPathComponent("georelays_cache.csv")
try? FileManager.default.removeItem(at: legacyCache)
return dir.appendingPathComponent("georelays_cache_v2.csv")
} catch { } catch {
return nil return nil
} }
@@ -139,8 +94,7 @@ private extension GeoRelayDirectoryDependencies {
try? await Task.sleep(nanoseconds: nanoseconds) try? await Task.sleep(nanoseconds: nanoseconds)
}, },
activeNotificationName: activeNotificationName, activeNotificationName: activeNotificationName,
autoStart: true, autoStart: true
validationPolicy: validationPolicy
) )
} }
} }
@@ -171,7 +125,7 @@ final class GeoRelayDirectory {
} }
private enum DetachedFetchOutcome: Sendable { private enum DetachedFetchOutcome: Sendable {
case success(entries: [Entry], csv: Data) case success(entries: [Entry], csv: String)
case torNotReady case torNotReady
case invalidData case invalidData
case network(String) case network(String)
@@ -258,8 +212,6 @@ final class GeoRelayDirectory {
) )
let awaitTorReady = dependencies.awaitTorReady let awaitTorReady = dependencies.awaitTorReady
let fetchData = dependencies.makeFetchData() let fetchData = dependencies.makeFetchData()
let validationPolicy = dependencies.validationPolicy
let baselineEntries = Set(entries)
Task { [weak self] in Task { [weak self] in
guard let self else { return } guard let self else { return }
@@ -267,9 +219,7 @@ final class GeoRelayDirectory {
let outcome = await Self.fetchRemoteOutcome( let outcome = await Self.fetchRemoteOutcome(
request: request, request: request,
awaitTorReady: awaitTorReady, awaitTorReady: awaitTorReady,
fetchData: fetchData, fetchData: fetchData
validationPolicy: validationPolicy,
baselineEntries: baselineEntries
) )
switch outcome { switch outcome {
@@ -288,9 +238,7 @@ final class GeoRelayDirectory {
nonisolated private static func fetchRemoteOutcome( nonisolated private static func fetchRemoteOutcome(
request: URLRequest, request: URLRequest,
awaitTorReady: @escaping @Sendable () async -> Bool, awaitTorReady: @escaping @Sendable () async -> Bool,
fetchData: @escaping @Sendable (URLRequest) async throws -> Data, fetchData: @escaping @Sendable (URLRequest) async throws -> Data
validationPolicy: GeoRelayDirectoryValidationPolicy,
baselineEntries: Set<Entry>
) async -> DetachedFetchOutcome { ) async -> DetachedFetchOutcome {
await Task.detached(priority: .utility) { await Task.detached(priority: .utility) {
let ready = await awaitTorReady() let ready = await awaitTorReady()
@@ -298,16 +246,16 @@ final class GeoRelayDirectory {
do { do {
let data = try await fetchData(request) let data = try await fetchData(request)
guard let parsed = Self.validatedEntries( guard let text = String(data: data, encoding: .utf8) else {
from: data,
policy: validationPolicy,
minimumEntries: validationPolicy.minimumRemoteEntries,
baselineEntries: baselineEntries
) else {
return .invalidData return .invalidData
} }
return .success(entries: parsed, csv: data) let parsed = Self.parseCSV(text)
guard !parsed.isEmpty else {
return .invalidData
}
return .success(entries: parsed, csv: text)
} catch { } catch {
return .network(error.localizedDescription) return .network(error.localizedDescription)
} }
@@ -321,7 +269,7 @@ final class GeoRelayDirectory {
} }
@MainActor @MainActor
private func handleFetchSuccess(entries parsed: [Entry], csv: Data) { private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
entries = parsed entries = parsed
persistCache(csv) persistCache(csv)
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey) dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
@@ -373,8 +321,9 @@ final class GeoRelayDirectory {
cleanupState.retryTask = nil cleanupState.retryTask = nil
} }
private func persistCache(_ data: Data) { private func persistCache(_ text: String) {
guard let url = dependencies.cacheURL() else { return } guard let url = dependencies.cacheURL() else { return }
guard let data = text.data(using: .utf8) else { return }
do { do {
try dependencies.writeData(data, url) try dependencies.writeData(data, url)
} catch { } catch {
@@ -387,12 +336,9 @@ final class GeoRelayDirectory {
// Prefer cached file if present // Prefer cached file if present
if let cache = dependencies.cacheURL(), if let cache = dependencies.cacheURL(),
let data = dependencies.readData(cache), let data = dependencies.readData(cache),
let entries = Self.validatedEntries( let text = String(data: data, encoding: .utf8) {
from: data, let arr = Self.parseCSV(text)
policy: dependencies.validationPolicy, if !arr.isEmpty { return arr }
minimumEntries: 1
) {
return entries
} }
// Try bundled resource(s) // Try bundled resource(s)
@@ -400,157 +346,36 @@ final class GeoRelayDirectory {
for url in bundleCandidates { for url in bundleCandidates {
if let data = dependencies.readData(url), if let data = dependencies.readData(url),
let entries = Self.validatedEntries( let text = String(data: data, encoding: .utf8) {
from: data, let arr = Self.parseCSV(text)
policy: dependencies.validationPolicy, if !arr.isEmpty { return arr }
minimumEntries: 1
) {
return entries
} }
} }
// Try filesystem path (development/test) // Try filesystem path (development/test)
if let cwd = dependencies.currentDirectoryPath(), if let cwd = dependencies.currentDirectoryPath(),
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")), let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
let entries = Self.validatedEntries( let text = String(data: data, encoding: .utf8) {
from: data, return Self.parseCSV(text)
policy: dependencies.validationPolicy,
minimumEntries: 1
) {
return entries
} }
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session) SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
return [] return []
} }
/// Parses the fixed three-column format as an all-or-nothing trust unit. nonisolated static func parseCSV(_ text: String) -> [Entry] {
/// One malformed or conflicting row rejects the complete dataset rather var result: Set<Entry> = []
/// than silently shrinking or partially replacing the current directory.
nonisolated static func validatedEntries(
from data: Data,
policy: GeoRelayDirectoryValidationPolicy,
minimumEntries: Int,
baselineEntries: Set<Entry>? = nil
) -> [Entry]? {
guard !data.isEmpty, data.count <= policy.maximumBytes,
let text = String(data: data, encoding: .utf8),
!text.hasPrefix("\u{feff}") else {
return nil
}
let lines = text.split(whereSeparator: { $0.isNewline }) let lines = text.split(whereSeparator: { $0.isNewline })
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } for (idx, raw) in lines.enumerated() {
.filter { !$0.isEmpty } guard let line = raw.trimmedOrNilIfEmpty else { continue }
guard let header = lines.first, if idx == 0 && line.lowercased().contains("relay url") { continue }
lines.count - 1 <= policy.maximumRows else { let parts = line.split(separator: ",").map { $0.trimmed }
return nil guard parts.count >= 3 else { continue }
guard let host = NostrRelayURL.directoryAddress(parts[0]) else { continue }
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
result.insert(Entry(host: host, lat: lat, lon: lon))
} }
return Array(result)
let headerParts = header
.split(separator: ",", omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
let supportedHeaders = [
["relay url", "latitude", "longitude"],
["relay url", "lat", "lon"]
]
guard supportedHeaders.contains(headerParts) else {
return nil
}
var entriesByHost: [String: Entry] = [:]
for line in lines.dropFirst() {
let parts = line
.split(separator: ",", omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard parts.count == 3,
let host = validatedDirectoryAddress(parts[0]),
let latitude = Double(parts[1]), latitude.isFinite,
(-90.0...90.0).contains(latitude),
let longitude = Double(parts[2]), longitude.isFinite,
(-180.0...180.0).contains(longitude) else {
return nil
}
let entry = Entry(host: host, lat: latitude, lon: longitude)
if let existing = entriesByHost[host], existing != entry {
// One endpoint cannot truthfully occupy two coordinates. Do
// not let row ordering choose which location clients trust.
return nil
}
entriesByHost[host] = entry
guard entriesByHost.count <= policy.maximumEntries else { return nil }
}
let parsedEntries = Set(entriesByHost.values)
guard parsedEntries.count >= minimumEntries else { return nil }
if let baselineEntries {
guard (0...1).contains(policy.minimumRetainedFraction) else { return nil }
let requiredOverlap = Int(
ceil(Double(baselineEntries.count) * policy.minimumRetainedFraction)
)
guard parsedEntries.intersection(baselineEntries).count >= requiredOverlap else {
return nil
}
}
return parsedEntries.sorted {
($0.host, $0.lat, $0.lon) < ($1.host, $1.lat, $1.lon)
}
}
nonisolated private static func validatedDirectoryAddress(_ rawValue: String) -> String? {
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty,
value.unicodeScalars.allSatisfy({
$0.isASCII && !CharacterSet.controlCharacters.contains($0)
}) else {
return nil
}
let candidate = value.contains("://") ? value : "wss://\(value)"
guard let components = URLComponents(string: candidate),
let scheme = components.scheme?.lowercased(),
scheme == "wss" || scheme == "https",
components.user == nil,
components.password == nil,
components.query == nil,
components.fragment == nil,
components.path.isEmpty || components.path == "/",
let rawHost = components.host else {
return nil
}
let host = rawHost.lowercased()
guard !host.isEmpty, host.count <= 253,
host.unicodeScalars.allSatisfy({ $0.isASCII }),
!host.hasSuffix("."),
host != "localhost",
!host.hasSuffix(".localhost"),
!host.hasSuffix(".local"),
!host.hasSuffix(".internal") else {
return nil
}
let labels = host.split(separator: ".", omittingEmptySubsequences: false)
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-")
guard labels.count >= 2,
!labels.allSatisfy({ $0.allSatisfy(\.isNumber) }),
labels.allSatisfy({ label in
(1...63).contains(label.count) &&
label.first != "-" &&
label.last != "-" &&
label.unicodeScalars.allSatisfy { allowed.contains($0) }
}) else {
return nil
}
if let port = components.port {
guard (1...65_535).contains(port) else { return nil }
if port != 443 { return "\(host):\(port)" }
}
return host
} }
// MARK: - Observers & Timers // MARK: - Observers & Timers
+10 -3
View File
@@ -1,18 +1,19 @@
import Foundation import Foundation
import P256K import P256K
/// Manages the secp256k1 identity used by BitChat's Nostr relay features, /// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
/// including the proprietary private-envelope transport.
struct NostrIdentity: Codable { struct NostrIdentity: Codable {
let privateKey: Data let privateKey: Data
let publicKey: Data let publicKey: Data
let npub: String // Bech32-encoded public key let npub: String // Bech32-encoded public key
let createdAt: Date
/// Memberwise initializer /// Memberwise initializer
init(privateKey: Data, publicKey: Data, npub: String, createdAt _: Date) { init(privateKey: Data, publicKey: Data, npub: String, createdAt: Date) {
self.privateKey = privateKey self.privateKey = privateKey
self.publicKey = publicKey self.publicKey = publicKey
self.npub = npub self.npub = npub
self.createdAt = createdAt
} }
/// Generate a new Nostr identity /// Generate a new Nostr identity
@@ -38,6 +39,12 @@ struct NostrIdentity: Codable {
self.privateKey = privateKeyData self.privateKey = privateKeyData
self.publicKey = xOnlyPubkey self.publicKey = xOnlyPubkey
self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey) self.npub = try Bech32.encode(hrp: "npub", data: xOnlyPubkey)
self.createdAt = Date()
}
/// Get signing key for event signatures
func signingKey() throws -> P256K.Signing.PrivateKey {
try P256K.Signing.PrivateKey(dataRepresentation: privateKey)
} }
/// Get Schnorr signing key for Nostr event signatures /// Get Schnorr signing key for Nostr event signatures
+32 -12
View File
@@ -15,7 +15,7 @@ final class NostrIdentityBridge {
private let keychain: KeychainManagerProtocol private let keychain: KeychainManagerProtocol
init(keychain: KeychainManagerProtocol = KeychainManager.makeDefault()) { init(keychain: KeychainManagerProtocol = KeychainManager()) {
self.keychain = keychain self.keychain = keychain
} }
@@ -37,6 +37,14 @@ final class NostrIdentityBridge {
return nostrIdentity return nostrIdentity
} }
/// Associate a Nostr identity with a Noise public key (for favorites)
func associateNostrIdentity(_ nostrPubkey: String, with noisePublicKey: Data) {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
if let data = nostrPubkey.data(using: .utf8) {
keychain.save(key: key, data: data, service: keychainService, accessible: nil)
}
}
/// Get Nostr public key associated with a Noise public key /// Get Nostr public key associated with a Noise public key
func getNostrPublicKey(for noisePublicKey: Data) -> String? { func getNostrPublicKey(for noisePublicKey: Data) -> String? {
let key = "nostr-noise-\(noisePublicKey.base64EncodedString())" let key = "nostr-noise-\(noisePublicKey.base64EncodedString())"
@@ -49,10 +57,29 @@ final class NostrIdentityBridge {
/// Clear all Nostr identity associations and current identity /// Clear all Nostr identity associations and current identity
func clearAllAssociations() { func clearAllAssociations() {
// Must go through the injected keychain, not raw SecItem calls: let query: [String: Any] = [
// under test that keychain is in-memory, and a direct delete here kSecClass as String: kSecClassGenericPassword,
// would wipe the developer's real Nostr identity on every test run. kSecAttrService as String: keychainService,
keychain.deleteAll(service: keychainService) kSecMatchLimit as String: kSecMatchLimitAll,
kSecReturnAttributes as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecSuccess, let items = result as? [[String: Any]] {
for item in items {
var deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: keychainService
]
if let account = item[kSecAttrAccount as String] as? String {
deleteQuery[kSecAttrAccount as String] = account
}
SecItemDelete(deleteQuery as CFDictionary)
}
} else if status == errSecItemNotFound {
// nothing persisted; no action needed
}
deviceSeedCache = nil deviceSeedCache = nil
// Also drop the in-memory derived per-geohash identities. These hold the // Also drop the in-memory derived per-geohash identities. These hold the
@@ -86,13 +113,6 @@ final class NostrIdentityBridge {
return seed return seed
} }
/// Derive a deterministic, unlinkable Nostr identity for a mesh-bridge
/// rendezvous cell. Distinct HMAC label keeps it unlinkable from the
/// geohash-chat identity for the same cell string.
func deriveIdentity(forBridgeRendezvous cell: String) throws -> NostrIdentity {
try deriveIdentity(forGeohash: "bridge|" + cell)
}
/// Derive a deterministic, unlinkable Nostr identity for a given geohash. /// Derive a deterministic, unlinkable Nostr identity for a given geohash.
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing /// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
/// if the candidate is not a valid secp256k1 private key. /// if the candidate is not a valid secp256k1 private key.
-215
View File
@@ -1,215 +0,0 @@
import BitFoundation
import CryptoKit
import Foundation
/// NIP-13 proof-of-work for Nostr events.
///
/// Outgoing kind-20000 geohash messages mine a `["nonce", "<value>", "<target>"]`
/// tag so the event ID carries at least `target` leading zero bits. Inbound
/// events are scored (never hard-rejected the network has clients that do
/// not mine): validated PoW at or above `rateLimitBypassBits` relaxes the
/// per-sender public rate limit, everything else keeps the strict limits.
enum NostrPoW {
// MARK: - Tuning
/// Difficulty (leading zero bits of the event ID) mined onto outgoing
/// geohash messages. 8 bits is ~256 hash attempts typically well under
/// 100 ms on any supported device.
static let targetBits = 8
/// Inbound events whose validated NIP-13 difficulty is at least this many
/// bits skip the per-sender rate-limit bucket (the content-flood bucket
/// still applies). See `MessageRateLimiter.allow`.
static let rateLimitBypassBits = 8
/// Hard cap on mining wall-clock time. When it hits, the committed target
/// steps down until a difficulty reachable in a small extra budget is
/// found and the message is sent anyway mining never blocks sending.
static let miningTimeCap: TimeInterval = 2.0
/// Budget for each stepped-down attempt after the main cap (or a task
/// cancellation) hits.
private static let fallbackTimeCap: TimeInterval = 0.15
/// The hot loop checks the deadline and task cancellation every this many
/// hash attempts.
private static let checkInterval: UInt64 = 1024
/// The nonce value is a fixed-width hex counter so the serialized event
/// template can be mutated in place without reallocation.
private static let nonceLength = 16
// MARK: - Scoring
/// Number of leading zero bits in a byte sequence (NIP-13 difficulty of
/// an event-ID hash).
static func leadingZeroBits<Bytes: Sequence<UInt8>>(_ bytes: Bytes) -> Int {
var total = 0
for byte in bytes {
if byte == 0 {
total += 8
} else {
total += byte.leadingZeroBitCount
break
}
}
return total
}
/// Validated NIP-13 difficulty of an inbound event.
///
/// The committed target in the nonce tag is what counts: the actual
/// leading zero bits of the ID must meet it (otherwise the claim is void
/// and the event scores 0), and work beyond the commitment earns no extra
/// credit this stops spammers who mine a low target from getting lucky
/// high scores. Events without a well-formed commitment score 0.
static func validatedDifficulty(idHex: String, tags: [[String]]) -> Int {
guard let nonceTag = tags.last(where: { $0.first == "nonce" }),
nonceTag.count >= 3,
let committed = Int(nonceTag[2]),
committed > 0, committed <= 256,
let idData = Data(hexString: idHex)
else {
return 0
}
return leadingZeroBits(idData) >= committed ? committed : 0
}
// MARK: - Mining
/// Mine a `["nonce", value, target]` tag for the given unsigned-event
/// fields. Nonisolated async: runs off the calling actor.
///
/// Bounded by `miningTimeCap`: when the cap hits or the surrounding
/// task is cancelled the committed target steps down (halving to 0,
/// which any hash satisfies) so the event still ships promptly with an
/// honest commitment at the difficulty actually reached. Returns nil only
/// if canonical serialization fails; the caller then sends unmined.
static func mineNonceTag(
pubkey: String,
createdAt: Int,
kind: Int,
tags: [[String]],
content: String,
targetBits: Int = NostrPoW.targetBits
) async -> [String]? {
var target = min(max(targetBits, 0), 256)
var budget = miningTimeCap
while true {
if let tag = mineAttempt(
pubkey: pubkey,
createdAt: createdAt,
kind: kind,
baseTags: tags,
content: content,
targetBits: target,
budget: budget
) {
return tag
}
// Target 0 succeeds on the first hash, so reaching it with nil
// means serialization itself failed give up on mining.
if target == 0 { return nil }
target /= 2
budget = fallbackTimeCap
}
}
/// One bounded mining pass at a fixed committed target. Allocation-light:
/// the canonical serialization is built once and only the fixed-width
/// nonce bytes are rewritten per attempt (the event ID is recomputed for
/// every attempt, per NIP-13). Returns nil on timeout/cancellation or if
/// the template could not be built.
private static func mineAttempt(
pubkey: String,
createdAt: Int,
kind: Int,
baseTags: [[String]],
content: String,
targetBits: Int,
budget: TimeInterval
) -> [String]? {
let targetString = String(targetBits)
guard let template = serializedTemplate(
pubkey: pubkey,
createdAt: createdAt,
kind: kind,
baseTags: baseTags,
content: content,
targetString: targetString
) else {
return nil
}
var buffer = template.buffer
let nonceRange = template.nonceRange
let deadline = DispatchTime.now().uptimeNanoseconds &+ UInt64(budget * 1_000_000_000)
let hexDigits = [UInt8]("0123456789abcdef".utf8)
var nonce = UInt64.random(in: .min ... .max)
var attempts: UInt64 = 0
while true {
// Write the nonce as 16 lowercase hex chars, in place.
var value = nonce
var index = nonceRange.upperBound
while index > nonceRange.lowerBound {
index -= 1
buffer[index] = hexDigits[Int(value & 0xF)]
value >>= 4
}
if leadingZeroBits(SHA256.hash(data: buffer)) >= targetBits {
// Identical to the bytes just written into the buffer.
return ["nonce", String(format: "%016llx", nonce), targetString]
}
nonce &+= 1
attempts &+= 1
if attempts % checkInterval == 0,
Task.isCancelled || DispatchTime.now().uptimeNanoseconds >= deadline {
return nil
}
}
}
/// Canonical NIP-01 serialization of the event with a placeholder nonce,
/// plus the byte range of the nonce value inside it.
///
/// The range is located by serializing twice with two same-length
/// placeholders and diffing the buffers the only differing bytes are
/// the nonce value, so this stays correct however `JSONSerialization`
/// escapes the surrounding fields (and even if the content contains the
/// placeholder text itself).
private static func serializedTemplate(
pubkey: String,
createdAt: Int,
kind: Int,
baseTags: [[String]],
content: String,
targetString: String
) -> (buffer: Data, nonceRange: Range<Int>)? {
func serialize(noncePlaceholder: String) -> Data? {
var tags = baseTags
tags.append(["nonce", noncePlaceholder, targetString])
let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content]
return try? JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
}
guard let zeros = serialize(noncePlaceholder: String(repeating: "0", count: nonceLength)),
let effs = serialize(noncePlaceholder: String(repeating: "f", count: nonceLength)),
zeros.count == effs.count
else {
return nil
}
var firstDiff = -1
var lastDiff = -1
for index in 0..<zeros.count where zeros[index] != effs[index] {
if firstDiff < 0 { firstDiff = index }
lastDiff = index
}
guard firstDiff >= 0, lastDiff - firstDiff + 1 == nonceLength else { return nil }
return (zeros, firstDiff..<(firstDiff + nonceLength))
}
}
+90 -351
View File
@@ -7,74 +7,42 @@ import Security
// Note: This file depends on Data extension from BinaryEncodingUtils.swift // Note: This file depends on Data extension from BinaryEncodingUtils.swift
// Make sure BinaryEncodingUtils.swift is included in the target // Make sure BinaryEncodingUtils.swift is included in the target
/// BitChat's private-envelope protocol transported over Nostr relays. /// NIP-17 Protocol Implementation for Private Direct Messages
///
/// This construction is deliberately BitChat-specific and is **not** NIP-17,
/// NIP-44, or NIP-59 compatible, even though it historically reuses those
/// NIPs' kind numbers (1059/13/14) and a `v2:` content prefix. It uses Nostr
/// events and secp256k1 identities, but the XChaCha20-Poly1305 payload layout
/// and key derivation are proprietary and interoperate only with BitChat
/// clients.
struct NostrProtocol { struct NostrProtocol {
/// Nostr event kinds /// Nostr event kinds
enum EventKind: Int { enum EventKind: Int {
case metadata = 0 case metadata = 0
case textNote = 1 case textNote = 1
// BitChat's proprietary private-envelope layers. These reuse the case dm = 14 // NIP-17 DM rumor kind
// NIP-17/NIP-59 kind numbers (14/13/1059) for historical reasons, but case seal = 13 // NIP-17 sealed event
// the encrypted payloads are BitChat-specific and not NIP-compatible. case giftWrap = 1059 // NIP-59 gift wrap
case dm = 14 // unsigned inner message (inside ciphertext)
case seal = 13 // sender-signed seal (inside ciphertext)
case giftWrap = 1059 // public outer envelope (one-time key)
case ephemeralEvent = 20000 case ephemeralEvent = 20000
case geohashPresence = 20001 case geohashPresence = 20001
case deletion = 5 // NIP-09 event deletion request
/// Sealed courier envelope parked on relays under its rotating
/// recipient tag (`#x`). Regular (stored) kind so it survives until
/// its NIP-40 expiration the whole point is store-and-forward.
case courierDrop = 1401
} }
/// Bound work before Base64-decoding either encrypted layer of an inbound /// Create a NIP-17 private message
/// private envelope, and before parsing each decrypted nested JSON layer.
/// Real envelopes are normally a few KiB; 64 KiB leaves ample headroom
/// without letting an addressed relay event drive unbounded allocation.
static let maximumPrivateEnvelopeCiphertextBytes = 64 * 1024
/// Create a BitChat private envelope for relay transport (outer kind 1059).
static func createPrivateMessage( static func createPrivateMessage(
content: String, content: String,
recipientPubkey: String, recipientPubkey: String,
senderIdentity: NostrIdentity senderIdentity: NostrIdentity
) throws -> NostrEvent { ) throws -> NostrEvent {
try createPrivateMessage(
content: content,
recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity,
messageTags: []
)
}
private static func createPrivateMessage( // Creating private message
content: String,
recipientPubkey: String, // 1. Create the rumor (unsigned event)
senderIdentity: NostrIdentity,
messageTags: [[String]]
) throws -> NostrEvent {
// 1. Create the rumor (unsigned inner event)
let rumor = NostrEvent( let rumor = NostrEvent(
pubkey: senderIdentity.publicKeyHex, pubkey: senderIdentity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .dm, kind: .dm, // NIP-17: DM rumor kind 14
tags: messageTags, tags: [],
content: content content: content
) )
// 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S // 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S
// real identity key so the recipient can authenticate who sent the // real identity key. NIP-17 requires the seal be signed by the sender
// message; signing with a throwaway key leaves DMs // so the recipient can authenticate who sent the message; signing with
// forgeable/impersonatable. // a throwaway key leaves DMs forgeable/impersonatable.
let senderKey = try senderIdentity.schnorrSigningKey() let senderKey = try senderIdentity.schnorrSigningKey()
let sealedEvent = try createSeal( let sealedEvent = try createSeal(
rumor: rumor, rumor: rumor,
@@ -82,7 +50,7 @@ struct NostrProtocol {
senderKey: senderKey senderKey: senderKey
) )
// 3. Wrap the sealed event with a throwaway ephemeral key (the wrap // 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap
// layer hides the sender's identity from relays; createGiftWrap mints // layer hides the sender's identity from relays; createGiftWrap mints
// its own ephemeral key internally). // its own ephemeral key internally).
let giftWrap = try createGiftWrap( let giftWrap = try createGiftWrap(
@@ -90,30 +58,19 @@ struct NostrProtocol {
recipientPubkey: recipientPubkey recipientPubkey: recipientPubkey
) )
// Created gift wrap
return giftWrap return giftWrap
} }
/// Decrypt a received BitChat private envelope. /// Decrypt a received NIP-17 message
/// Returns the content, sender pubkey, and the actual message timestamp (not the randomized outer timestamp) /// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp)
static func decryptPrivateMessage( static func decryptPrivateMessage(
giftWrap: NostrEvent, giftWrap: NostrEvent,
recipientIdentity: NostrIdentity recipientIdentity: NostrIdentity
) throws -> (content: String, senderPubkey: String, timestamp: Int) { ) throws -> (content: String, senderPubkey: String, timestamp: Int) {
// 0. Validate the untrusted outer envelope before any decryption work. // Starting decryption
// Every BitChat client (released iOS and current Android) publishes
// exactly one outer recipient `p` tag on a validly signed kind-1059
// wrap; anything else is malformed or misbound.
guard giftWrap.content.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else {
SecureLogger.error("❌ Rejecting DM: oversized outer envelope ciphertext", category: .session)
throw NostrError.invalidCiphertext
}
guard giftWrap.kind == EventKind.giftWrap.rawValue,
giftWrap.tags == [["p", recipientIdentity.publicKeyHex]],
giftWrap.isValidSignature() else {
SecureLogger.error("❌ Rejecting DM: malformed or misbound outer envelope", category: .session)
throw NostrError.invalidEvent
}
// 1. Unwrap the gift wrap // 1. Unwrap the gift wrap
let seal: NostrEvent let seal: NostrEvent
@@ -129,13 +86,10 @@ struct NostrProtocol {
} }
// 2. Authenticate the seal. The seal MUST be signed by the sender's real // 2. Authenticate the seal. The seal MUST be signed by the sender's real
// identity key; without this check a DM is forgeable by anyone who // identity key (NIP-17); without this check a DM is forgeable by anyone
// knows the recipient's npub. Every BitChat sender emits a tagless // who knows the recipient's npub. Verify the seal's own signature.
// kind-13 seal, so bind the decrypted layer to that exact shape. guard seal.isValidSignature() else {
guard seal.kind == EventKind.seal.rawValue, SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session)
seal.tags.isEmpty,
seal.isValidSignature() else {
SecureLogger.error("❌ Rejecting DM: seal is malformed or its signature is missing/invalid", category: .session)
throw NostrError.invalidEvent throw NostrError.invalidEvent
} }
@@ -152,16 +106,11 @@ struct NostrProtocol {
throw error throw error
} }
// 4. The rumor is intentionally unsigned; sender authentication comes // 4. The sender claimed inside the rumor must match the key that actually
// from the seal. The sender claimed inside the rumor must match the // signed the seal, otherwise the sender field is unauthenticated and
// key that actually signed the seal, otherwise the sender field is // spoofable.
// unauthenticated and spoofable. Also bind the inner kind and tag guard seal.pubkey == rumor.pubkey else {
// shape to what BitChat clients actually emit. SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session)
guard rumor.kind == EventKind.dm.rawValue,
validInnerMessageTags(rumor.tags, recipientPubkey: recipientIdentity.publicKeyHex),
rumor.sig == nil,
seal.pubkey == rumor.pubkey else {
SecureLogger.error("❌ Rejecting DM: rumor is malformed or does not match seal signer", category: .session)
throw NostrError.invalidEvent throw NostrError.invalidEvent
} }
@@ -169,17 +118,6 @@ struct NostrProtocol {
return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at) return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at)
} }
/// Released iOS envelopes use no inner tags, while current Android
/// envelopes place exactly the authenticated recipient's `p` tag on the
/// unsigned inner event. Accept only those two historical shapes;
/// alternate recipients, duplicate tags, and extra tags are rejected.
private static func validInnerMessageTags(
_ tags: [[String]],
recipientPubkey: String
) -> Bool {
tags.isEmpty || tags == [["p", recipientPubkey]]
}
#if DEBUG #if DEBUG
static func createPrivateMessageWithInvalidSealSignatureForTesting( static func createPrivateMessageWithInvalidSealSignatureForTesting(
content: String, content: String,
@@ -222,23 +160,6 @@ struct NostrProtocol {
) )
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey) return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
} }
/// Reproduces historical wire shapes (current Android places exactly one
/// recipient `p` tag on the unsigned inner event) without making the
/// production encoder depend on that quirk.
static func createPrivateMessageWithInnerTagsForTesting(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity,
innerMessageTags: [[String]]
) throws -> NostrEvent {
try createPrivateMessage(
content: content,
recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity,
messageTags: innerMessageTags
)
}
#endif #endif
/// Create a geohash-scoped ephemeral public message (kind 20000) /// Create a geohash-scoped ephemeral public message (kind 20000)
@@ -249,63 +170,6 @@ struct NostrProtocol {
nickname: String? = nil, nickname: String? = nil,
teleported: Bool = false teleported: Bool = false
) throws -> NostrEvent { ) throws -> NostrEvent {
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported),
content: content
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a kind-20000 geohash message carrying a NIP-13 proof-of-work
/// nonce tag (see `NostrPoW`). Mining runs off the calling actor and is
/// bounded by `NostrPoW.miningTimeCap`; when the cap hits (or the
/// surrounding task is cancelled) the event ships at the highest
/// committed difficulty still met, and if mining is impossible it ships
/// unmined sending is never blocked.
static func createMinedEphemeralGeohashEvent(
content: String,
geohash: String,
senderIdentity: NostrIdentity,
nickname: String? = nil,
teleported: Bool = false,
powTargetBits: Int = NostrPoW.targetBits
) async throws -> NostrEvent {
var tags = ephemeralGeohashTags(geohash: geohash, nickname: nickname, teleported: teleported)
// Fix created_at up front: the mined nonce commits to the full
// serialized event, so the signed event must reuse the exact value.
let createdAt = Int(Date().timeIntervalSince1970)
if let nonceTag = await NostrPoW.mineNonceTag(
pubkey: senderIdentity.publicKeyHex,
createdAt: createdAt,
kind: EventKind.ephemeralEvent.rawValue,
tags: tags,
content: content,
targetBits: powTargetBits
) {
tags.append(nonceTag)
}
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(timeIntervalSince1970: TimeInterval(createdAt)),
kind: .ephemeralEvent,
tags: tags,
content: content
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Tags for a kind-20000 geohash message (shared by the plain and mined
/// variants).
private static func ephemeralGeohashTags(
geohash: String,
nickname: String?,
teleported: Bool
) -> [[String]] {
var tags = [["g", geohash]] var tags = [["g", geohash]]
if let nickname = nickname?.trimmedOrNilIfEmpty { if let nickname = nickname?.trimmedOrNilIfEmpty {
tags.append(["n", nickname]) tags.append(["n", nickname])
@@ -313,7 +177,15 @@ struct NostrProtocol {
if teleported { if teleported {
tags.append(["t", "teleport"]) tags.append(["t", "teleport"])
} }
return tags let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: tags,
content: content
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
} }
/// Create a geohash presence heartbeat (kind 20001) /// Create a geohash presence heartbeat (kind 20001)
@@ -334,115 +206,17 @@ struct NostrProtocol {
return try event.sign(with: schnorrKey) return try event.sign(with: schnorrKey)
} }
// MARK: - Mesh bridge (rendezvous) events
/// Create a mesh-bridge public message (kind 20000) for a geohash-cell
/// rendezvous. The distinct `r` tag keeps bridge traffic out of geohash
/// channel subscriptions (which filter on `#g`); `m` is
/// `[stable ID, mesh sender ID, wire timestamp in ms]`. Element 1 is the
/// content-stable mesh message ID (`MeshMessageIdentity`) for v1.7.0
/// parsers, which key their dedup on `m[1]` unconditionally and need it
/// per-message-unique. Current parsers key bridge rows by the authenticated
/// event ID and recompute elements 2-3 only as a radio-copy hint; the mesh
/// coordinates are public and cannot authenticate the Nostr signer.
static func createBridgeMeshEvent(
content: String,
cell: String,
senderIdentity: NostrIdentity,
nickname: String? = nil,
meshSenderID: String? = nil,
meshTimestampMs: UInt64? = nil
) throws -> NostrEvent {
var tags = [["r", cell]]
if let nickname = nickname?.trimmedOrNilIfEmpty {
tags.append(["n", nickname])
}
if let meshSenderID = meshSenderID?.trimmedOrNilIfEmpty, let meshTimestampMs {
let stableID = MeshMessageIdentity.stableID(
senderIDHex: meshSenderID,
timestampMs: meshTimestampMs,
content: content
)
tags.append(["m", stableID, meshSenderID, String(meshTimestampMs)])
}
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: tags,
content: content
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a mesh-bridge presence heartbeat (kind 20001) on a rendezvous
/// cell: empty content, `r` tag only the bridge analogue of geohash
/// presence, counted into "people across the bridge".
static func createBridgePresenceEvent(
cell: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .geohashPresence,
tags: [["r", cell]],
content: ""
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a courier drop (kind 1401): an opaque sealed courier envelope
/// parked on relays. `x` is the hex recipient tag the recipient (or a
/// gateway acting for them) subscribes for; the NIP-40 expiration tracks
/// the envelope expiry so honoring relays garbage-collect the drop. The
/// signing identity should be a throwaway the envelope authenticates
/// its sender internally via Noise-X, and linking drops to a stable
/// publisher key would leak courier traffic patterns.
static func createCourierDropEvent(
envelope: Data,
recipientTagHex: String,
expiresAt: Date,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
let tags = [
["x", recipientTagHex],
["expiration", String(Int(expiresAt.timeIntervalSince1970))]
]
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .courierDrop,
tags: tags,
content: envelope.base64EncodedString()
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash. /// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
/// An optional `expiresAt` adds a NIP-40 expiration tag so honoring relays
/// drop the note in step with a bridged board post's expiry.
static func createGeohashTextNote( static func createGeohashTextNote(
content: String, content: String,
geohash: String, geohash: String,
senderIdentity: NostrIdentity, senderIdentity: NostrIdentity,
nickname: String? = nil, nickname: String? = nil
expiresAt: Date? = nil,
urgent: Bool = false
) throws -> NostrEvent { ) throws -> NostrEvent {
var tags = [["g", geohash]] var tags = [["g", geohash]]
if let nickname = nickname?.trimmedOrNilIfEmpty { if let nickname = nickname?.trimmedOrNilIfEmpty {
tags.append(["n", nickname]) tags.append(["n", nickname])
} }
if let expiresAt {
tags.append(["expiration", String(Int(expiresAt.timeIntervalSince1970))])
}
if urgent {
tags.append(["t", "urgent"])
}
let event = NostrEvent( let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex, pubkey: senderIdentity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
@@ -454,24 +228,6 @@ struct NostrProtocol {
return try event.sign(with: schnorrKey) return try event.sign(with: schnorrKey)
} }
/// Create a NIP-09 deletion request for one of our own events. Relays that
/// honor NIP-09 drop the referenced event; it must be signed by the same
/// key that signed the original.
static func createDeleteEvent(
ofEventID eventID: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .deletion,
tags: [["e", eventID]],
content: ""
)
let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: schnorrKey)
}
// MARK: - Private Methods // MARK: - Private Methods
private static func createSeal( private static func createSeal(
@@ -542,11 +298,6 @@ struct NostrProtocol {
recipientKey: recipientKey recipientKey: recipientKey
) )
// Check UTF-8 size before allocating Data or invoking the general
// JSON parser on attacker-influenced plaintext.
guard decrypted.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else {
throw NostrError.invalidCiphertext
}
guard let data = decrypted.data(using: .utf8), guard let data = decrypted.data(using: .utf8),
let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw NostrError.invalidEvent throw NostrError.invalidEvent
@@ -569,9 +320,6 @@ struct NostrProtocol {
recipientKey: recipientKey recipientKey: recipientKey
) )
guard decrypted.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else {
throw NostrError.invalidCiphertext
}
guard let data = decrypted.data(using: .utf8), guard let data = decrypted.data(using: .utf8),
let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw NostrError.invalidEvent throw NostrError.invalidEvent
@@ -580,11 +328,7 @@ struct NostrProtocol {
return try NostrEvent(from: rumorDict) return try NostrEvent(from: rumorDict)
} }
// MARK: - BitChat private-envelope encryption // MARK: - Encryption (NIP-44 v2)
//
// Not NIP-44: the `v2:` prefix, base64url(nonce24 || ciphertext || tag)
// layout, XChaCha20-Poly1305 cipher, and HKDF parameters are all
// BitChat-specific.
private static func encrypt( private static func encrypt(
plaintext: String, plaintext: String,
@@ -596,24 +340,21 @@ struct NostrProtocol {
throw NostrError.invalidPublicKey throw NostrError.invalidPublicKey
} }
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
// Derive shared secret // Derive shared secret
let sharedSecret = try deriveSharedSecret( let sharedSecret = try deriveSharedSecret(
privateKey: senderKey, privateKey: senderKey,
publicKey: recipientPubkeyData publicKey: recipientPubkeyData
) )
// Derive the BitChat private-envelope symmetric key (HKDF-SHA256) // Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
let key = try derivePrivateEnvelopeKey(from: sharedSecret) let key = try deriveNIP44V2Key(from: sharedSecret)
// 24-byte random nonce for XChaCha20-Poly1305 // 24-byte random nonce for XChaCha20-Poly1305
var nonce24 = Data(count: 24) var nonce24 = Data(count: 24)
let randomStatus = nonce24.withUnsafeMutableBytes { ptr in _ = nonce24.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!) SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
} }
// Never encrypt with an unrandomized nonce: nonce reuse under the same
// key breaks XChaCha20-Poly1305 confidentiality and authenticity.
guard randomStatus == errSecSuccess else {
throw NostrError.cryptographicFailure
}
let pt = Data(plaintext.utf8) let pt = Data(plaintext.utf8)
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24) let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
@@ -631,12 +372,8 @@ struct NostrProtocol {
senderPubkey: String, senderPubkey: String,
recipientKey: P256K.Schnorr.PrivateKey recipientKey: P256K.Schnorr.PrivateKey
) throws -> String { ) throws -> String {
// Expect BitChat's historical `v2:` private-envelope framing, and // Expect NIP-44 v2 format
// bound work before Base64 decoding attacker-sized input. guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
guard ciphertext.utf8.count <= maximumPrivateEnvelopeCiphertextBytes,
ciphertext.hasPrefix("v2:") else {
throw NostrError.invalidCiphertext
}
let encoded = String(ciphertext.dropFirst(3)) let encoded = String(ciphertext.dropFirst(3))
guard let data = Base64URLCoding.decode(encoded), guard let data = Base64URLCoding.decode(encoded),
data.count > (24 + 16), data.count > (24 + 16),
@@ -652,7 +389,7 @@ struct NostrProtocol {
// Try decryption with even-Y then odd-Y when sender pubkey is x-only // Try decryption with even-Y then odd-Y when sender pubkey is x-only
func attemptDecrypt(using pubKeyData: Data) throws -> Data { func attemptDecrypt(using pubKeyData: Data) throws -> Data {
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData) let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
let key = try derivePrivateEnvelopeKey(from: ss) let key = try deriveNIP44V2Key(from: ss)
return try XChaCha20Poly1305Compat.open( return try XChaCha20Poly1305Compat.open(
ciphertext: Data(ct), ciphertext: Data(ct),
tag: Data(tag), tag: Data(tag),
@@ -662,25 +399,18 @@ struct NostrProtocol {
} }
// If 32 bytes (x-only) try both parities, otherwise single try // If 32 bytes (x-only) try both parities, otherwise single try
let plaintext: Data
if senderPubkeyData.count == 32 { if senderPubkeyData.count == 32 {
let even = Data([0x02]) + senderPubkeyData let even = Data([0x02]) + senderPubkeyData
if let pt = try? attemptDecrypt(using: even) { if let pt = try? attemptDecrypt(using: even) {
plaintext = pt return String(data: pt, encoding: .utf8) ?? ""
} else { }
let odd = Data([0x03]) + senderPubkeyData let odd = Data([0x03]) + senderPubkeyData
plaintext = try attemptDecrypt(using: odd) let pt = try attemptDecrypt(using: odd)
} return String(data: pt, encoding: .utf8) ?? ""
} else { } else {
plaintext = try attemptDecrypt(using: senderPubkeyData) let pt = try attemptDecrypt(using: senderPubkeyData)
return String(data: pt, encoding: .utf8) ?? ""
} }
// Authenticated plaintext that is not valid UTF-8 is a malformed
// envelope, not an empty message.
guard let decoded = String(data: plaintext, encoding: .utf8) else {
throw NostrError.invalidCiphertext
}
return decoded
} }
private static func deriveSharedSecret( private static func deriveSharedSecret(
@@ -740,8 +470,38 @@ struct NostrProtocol {
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// ECDH shared secret derived // ECDH shared secret derived
// Return raw ECDH shared secret; HKDF is applied by // Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
// derivePrivateEnvelopeKey return sharedSecretData
}
// Direct version that doesn't try to add prefixes
private static func deriveSharedSecretDirect(
privateKey: P256K.Schnorr.PrivateKey,
publicKey: Data
) throws -> Data {
// Direct shared secret calculation
// Convert Schnorr private key to KeyAgreement private key
let keyAgreementPrivateKey = try P256K.KeyAgreement.PrivateKey(
dataRepresentation: privateKey.dataRepresentation
)
// Use the public key as-is (should already have prefix)
let keyAgreementPublicKey = try P256K.KeyAgreement.PublicKey(
dataRepresentation: publicKey,
format: .compressed
)
// Perform ECDH
let sharedSecret = try keyAgreementPrivateKey.sharedSecretFromKeyAgreement(
with: keyAgreementPublicKey,
format: .compressed
)
// Convert SharedSecret to Data
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
return sharedSecretData return sharedSecretData
} }
@@ -802,10 +562,6 @@ struct NostrEvent: Codable {
throw NostrError.invalidEvent throw NostrError.invalidEvent
} }
guard Self.isWithinInboundTagLimits(tags) else {
throw NostrError.invalidEvent
}
self.id = dict["id"] as? String ?? "" self.id = dict["id"] as? String ?? ""
self.pubkey = pubkey self.pubkey = pubkey
self.created_at = createdAt self.created_at = createdAt
@@ -815,21 +571,6 @@ struct NostrEvent: Codable {
self.sig = dict["sig"] as? String self.sig = dict["sig"] as? String
} }
/// Bounds untrusted relay tag arrays so attackers cannot force large
/// allocations or expensive joins on the inbound hot path.
static func isWithinInboundTagLimits(_ tags: [[String]]) -> Bool {
guard tags.count <= TransportConfig.nostrMaxEventTags else { return false }
for tag in tags {
guard tag.count <= TransportConfig.nostrMaxEventTagValues else { return false }
guard tag.allSatisfy({ $0.utf8.count <= TransportConfig.nostrMaxEventTagValueBytes }) else {
return false
}
}
return true
}
func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent { func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
let (eventId, eventIdHash) = try calculateEventId() let (eventId, eventIdHash) = try calculateEventId()
@@ -893,19 +634,17 @@ struct NostrEvent: Codable {
enum NostrError: Error { enum NostrError: Error {
case invalidPublicKey case invalidPublicKey
case invalidPrivateKey
case invalidEvent case invalidEvent
case invalidCiphertext case invalidCiphertext
case cryptographicFailure case signingFailed
case encryptionFailed
} }
// MARK: - BitChat private-envelope key derivation // MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
private extension NostrProtocol { private extension NostrProtocol {
/// The HKDF info string retains the historical "nip44-v2" label for wire static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
/// compatibility with deployed clients, but this is not the NIP-44 key
/// schedule: NIP-44 derives a conversation key via HKDF-extract with that
/// label as the *salt* and uses ChaCha20 with per-message expanded keys.
static func derivePrivateEnvelopeKey(from sharedSecretData: Data) throws -> Data {
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey( let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData), inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: Data(), salt: Data(),
+42 -457
View File
@@ -48,14 +48,7 @@ private struct URLSessionAdapter: NostrRelaySessionProtocol {
let base: URLSession let base: URLSession
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol { func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
let task = base.webSocketTask(with: url) URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url))
// Byte bound per inbound frame; without it the per-relay buffer cap
// (nostrInboundPerRelayBufferCap) bounds FRAMES but not BYTES, and a
// hostile relay could pile up cap × 1 MiB (URLSession default) per
// connection. See TransportConfig.nostrInboundMaxFrameBytes for the
// sizing rationale. Oversized frames fail the receive with an error.
task.maximumMessageSize = TransportConfig.nostrInboundMaxFrameBytes
return URLSessionWebSocketTaskAdapter(base: task)
} }
} }
@@ -113,10 +106,7 @@ private extension NostrRelayManagerDependencies {
@MainActor @MainActor
final class NostrRelayManager: ObservableObject { final class NostrRelayManager: ObservableObject {
static let shared = NostrRelayManager() static let shared = NostrRelayManager()
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info. // Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
// Entries are removed only on OK acks (or panic wipe); relays that never
// ack leave entries behind for the process lifetime. Observability-only
// state, bounded in practice by outbound DM volume.
private(set) static var pendingGiftWrapIDs = Set<String>() private(set) static var pendingGiftWrapIDs = Set<String>()
static func registerPendingGiftWrap(id: String) { static func registerPendingGiftWrap(id: String) {
pendingGiftWrapIDs.insert(id) pendingGiftWrapIDs.insert(id)
@@ -127,6 +117,7 @@ final class NostrRelayManager: ObservableObject {
let url: String let url: String
var isConnected: Bool = false var isConnected: Bool = false
var lastError: Error? var lastError: Error?
var lastConnectedAt: Date?
var messagesSent: Int = 0 var messagesSent: Int = 0
var messagesReceived: Int = 0 var messagesReceived: Int = 0
var reconnectAttempts: Int = 0 var reconnectAttempts: Int = 0
@@ -146,10 +137,6 @@ final class NostrRelayManager: ObservableObject {
@Published private(set) var relays: [Relay] = [] @Published private(set) var relays: [Relay] = []
@Published private(set) var isConnected = false @Published private(set) var isConnected = false
/// Whether a relay that carries private messages is connected. DMs
/// target the default (gift-wrap-capable) relay set, so a connected
/// geohash/custom relay alone must not count sends would still queue.
@Published private(set) var isDMRelayConnected = false
private let dependencies: NostrRelayManagerDependencies private let dependencies: NostrRelayManagerDependencies
private var allowDefaultRelays: Bool = false private var allowDefaultRelays: Bool = false
@@ -193,26 +180,11 @@ final class NostrRelayManager: ObservableObject {
} }
private var subscriptionRequestState: [String: SubscriptionRequestState] = [:] private var subscriptionRequestState: [String: SubscriptionRequestState] = [:]
// Track EOSE per subscription to signal when initial stored events are // Track EOSE per subscription to signal when initial stored events are done
// done. Completion is scoped to relays the REQ actually reached: targets
// still mid-connect must not hold the callback hostage until the fallback
// timer (a dead relay of five used to pin "loading" for the full 10s).
private struct EOSETracker { private struct EOSETracker {
/// Targets the REQ has not been delivered to yet (still connecting). var pendingRelays: Set<String>
var awaitingSend: Set<String>
/// Relays that received the REQ and have not sent EOSE yet.
var awaitingEOSE: Set<String>
/// True once any relay received the REQ (or answered with EOSE)
/// completion with zero sends would mean "done" without ever asking.
var didSend = false
var callback: () -> Void var callback: () -> Void
let epoch: Int let epoch: Int
/// Done when every relay that got the REQ has resolved, provided at
/// least one did or when every target dropped out entirely.
var isComplete: Bool {
(didSend && awaitingEOSE.isEmpty) || (awaitingSend.isEmpty && awaitingEOSE.isEmpty)
}
} }
private var eoseTrackers: [String: EOSETracker] = [:] private var eoseTrackers: [String: EOSETracker] = [:]
private var eoseTrackerEpoch = 0 private var eoseTrackerEpoch = 0
@@ -226,15 +198,6 @@ final class NostrRelayManager: ObservableObject {
} }
private var messageQueue: [PendingSend] = [] private var messageQueue: [PendingSend] = []
private let messageQueueLock = NSLock() private let messageQueueLock = NSLock()
/// Non-queued sends whose callers require relay durability. A WebSocket
/// write only proves bytes left this process; NIP-01 `OK` is the relay's
/// accept/reject acknowledgment.
private struct ConfirmedSendState {
let token: UUID
var awaitingRelays: Set<String>
let completion: (Bool) -> Void
}
private var confirmedSends: [String: ConfirmedSendState] = [:]
// Total pending sends dropped at the queue cap; drives the sampled // Total pending sends dropped at the queue cap; drives the sampled
// overflow warning (first + every Nth drop). // overflow warning (first + every Nth drop).
private var pendingSendDropCount = 0 private var pendingSendDropCount = 0
@@ -250,32 +213,6 @@ final class NostrRelayManager: ObservableObject {
// Bump generation to invalidate scheduled reconnects when we reset/disconnect // Bump generation to invalidate scheduled reconnects when we reset/disconnect
private var connectionGeneration: Int = 0 private var connectionGeneration: Int = 0
// Per-relay off-main inbound pipeline: raw socket frames are parsed and
// Schnorr-verified in arrival order OFF the main actor (this is the single
// signature verification for the whole inbound path downstream handlers
// receive only verified events), then hop back to the main actor for dedup
// recording and handler dispatch.
//
// Each relay connection owns its OWN AsyncStream + consumer task, so N
// relays verify in parallel while every relay's frames stay in arrival
// order (a single subscription's events for a relay all arrive on that
// relay's socket, so per-relay ordering preserves per-subscription
// ordering). A burst of EVENT frames from one busy/malicious relay only
// blocks that relay's own verification backlog DMs, OKs, EOSEs, and
// events from every other relay keep flowing on their own pipelines.
//
// Each stream is bounded (`.bufferingNewest`) so a relay flooding faster
// than its verification drains sheds its own oldest frames instead of
// growing memory without bound; it can never starve other relays.
//
// Continuations live in a lock-guarded, `Sendable` router (see
// `InboundFrameRouter` at file scope) so the raw socket receive callback
// (which is NOT main-actor isolated) can route a frame to the right relay
// stream without a per-frame main hop, while the main actor owns pipeline
// creation/teardown. The expensive work (Schnorr verify) is what runs
// off-main; the yield stays cheap.
private let inboundRouter = InboundFrameRouter()
init() { init() {
self.dependencies = .live() self.dependencies = .live()
hasMutualFavorites = dependencies.hasMutualFavorites() hasMutualFavorites = dependencies.hasMutualFavorites()
@@ -330,69 +267,6 @@ final class NostrRelayManager: ObservableObject {
.store(in: &cancellables) .store(in: &cancellables)
} }
deinit {
inboundRouter.finishAll()
}
/// Ensure a serial off-main consumer pipeline exists for a relay. Called on
/// the main actor when a socket is (re)armed for receiving. Idempotent.
///
/// Ordering within the relay is deliberate and security/performance-critical:
/// 1. `precheckInboundEvent` (main hop): per-relay stats plus a cheap
/// duplicate LOOKUP duplicate fan-in from multiple relays dominates
/// real traffic and must never pay for Schnorr verification.
/// 2. `isValidSignature()` runs here, off the main actor the ONLY
/// signature verification on the inbound path (JSON re-serialization +
/// SHA-256 + secp256k1 Schnorr per event).
/// 3. `deliverVerifiedInboundEvent` (main hop): authoritative
/// check-and-RECORD plus handler dispatch. Recording only after
/// verification means a forged-signature copy can never poison the
/// dedup cache and suppress the genuine event.
private func ensureRelayInboundPipeline(for relayUrl: String) {
let started = inboundRouter.startPipeline(for: relayUrl) { [weak self] stream in
Task.detached(priority: .userInitiated) {
for await frame in stream {
guard let parsed = ParsedInbound(frame.message) else { continue }
guard let self else { return }
switch parsed {
case .event(let subId, let event):
guard await self.precheckInboundEvent(
subscriptionID: subId,
eventID: event.id,
relayUrl: relayUrl
) else {
continue
}
guard event.isValidSignature() else {
SecureLogger.warning(
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
category: .session
)
continue
}
await self.deliverVerifiedInboundEvent(subscriptionID: subId, event: event, from: relayUrl)
case .eose, .ok, .notice:
await self.handleParsedMessage(parsed, from: relayUrl)
}
}
}
}
if started {
SecureLogger.debug("🧵 Started inbound verify pipeline for \(relayUrl)", category: .session)
}
}
/// Tear down a relay's inbound pipeline (socket gone or state wiped). The
/// consumer drains any already-buffered frames before finishing, so
/// in-flight verified events are still delivered.
private func teardownRelayInboundPipeline(for relayUrl: String) {
inboundRouter.finishPipeline(for: relayUrl)
}
private func teardownAllRelayInboundPipelines() {
inboundRouter.finishAll()
}
/// Connect to all configured relays /// Connect to all configured relays
func connect() { func connect() {
// Global network policy gate // Global network policy gate
@@ -407,8 +281,6 @@ final class NostrRelayManager: ObservableObject {
task.cancel(with: .goingAway, reason: nil) task.cancel(with: .goingAway, reason: nil)
} }
connections.removeAll() connections.removeAll()
// Sockets are gone; drop every relay's inbound verify pipeline.
teardownAllRelayInboundPipelines()
markRelaySocketsClosed(resetState: false) markRelaySocketsClosed(resetState: false)
// Sockets are gone, so per-relay subscription state is cleared but // Sockets are gone, so per-relay subscription state is cleared but
// durable intent (subscriptionRequestState, messageHandlers, parked // durable intent (subscriptionRequestState, messageHandlers, parked
@@ -422,9 +294,6 @@ final class NostrRelayManager: ObservableObject {
for (_, tracker) in trackers { for (_, tracker) in trackers {
tracker.callback() tracker.callback()
} }
let confirmed = confirmedSends.values.map(\.completion)
confirmedSends.removeAll()
confirmed.forEach { $0(false) }
pendingTorConnectionURLs.removeAll() pendingTorConnectionURLs.removeAll()
awaitingTorForConnections = false awaitingTorForConnections = false
torReadyWaitAttempts = 0 torReadyWaitAttempts = 0
@@ -441,7 +310,6 @@ final class NostrRelayManager: ObservableObject {
task.cancel(with: .goingAway, reason: nil) task.cancel(with: .goingAway, reason: nil)
} }
connections.removeAll() connections.removeAll()
teardownAllRelayInboundPipelines()
markRelaySocketsClosed(resetState: true) markRelaySocketsClosed(resetState: true)
subscriptions.removeAll() subscriptions.removeAll()
pendingSubscriptions.removeAll() pendingSubscriptions.removeAll()
@@ -459,7 +327,6 @@ final class NostrRelayManager: ObservableObject {
duplicateInboundEventDropCountBySubscription.removeAll() duplicateInboundEventDropCountBySubscription.removeAll()
inboundEventLogCount = 0 inboundEventLogCount = 0
Self.pendingGiftWrapIDs.removeAll() Self.pendingGiftWrapIDs.removeAll()
confirmedSends.removeAll()
messageQueueLock.lock() messageQueueLock.lock()
messageQueue.removeAll() messageQueue.removeAll()
@@ -476,6 +343,7 @@ final class NostrRelayManager: ObservableObject {
relays[index].nextReconnectTime = nil relays[index].nextReconnectTime = nil
if resetState { if resetState {
relays[index].lastError = nil relays[index].lastError = nil
relays[index].lastConnectedAt = nil
relays[index].lastDisconnectedAt = nil relays[index].lastDisconnectedAt = nil
relays[index].messagesSent = 0 relays[index].messagesSent = 0
relays[index].messagesReceived = 0 relays[index].messagesReceived = 0
@@ -534,97 +402,6 @@ final class NostrRelayManager: ObservableObject {
} }
} }
/// Attempts an event only on currently connected target relays and
/// reports whether at least one relay explicitly accepted it via NIP-01
/// `OK`. A successful WebSocket write alone is not durable acceptance.
/// Unlike `sendEvent`, this never enters the process-local pending queue;
/// callers use it when success unlocks durable state or user-visible
/// delivery progress.
func sendEventImmediately(
_ event: NostrEvent,
to relayUrls: [String]? = nil,
completion: @escaping (Bool) -> Void
) {
guard dependencies.activationAllowed() else {
completion(false)
return
}
guard !(shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady()) else {
completion(false)
return
}
let requestedRelays = relayUrls ?? Self.defaultRelays
let targetRelays = allowedRelayList(from: requestedRelays)
let connectedTargets = targetRelays.compactMap { relayUrl -> (String, NostrRelayConnectionProtocol)? in
guard let connection = connectedConnection(for: relayUrl) else { return nil }
return (relayUrl, connection)
}
guard !connectedTargets.isEmpty else {
completion(false)
return
}
let token = UUID()
let eventID = event.id
if let replaced = confirmedSends.removeValue(forKey: eventID) {
replaced.completion(false)
}
confirmedSends[eventID] = ConfirmedSendState(
token: token,
awaitingRelays: Set(connectedTargets.map(\.0)),
completion: completion
)
dependencies.scheduleAfter(TransportConfig.nostrConfirmedSendAckTimeoutSeconds) { [weak self] in
Task { @MainActor [weak self] in
self?.timeoutConfirmedSend(eventID: eventID, token: token)
}
}
for (relayUrl, connection) in connectedTargets {
sendToRelay(event: event, connection: connection, relayUrl: relayUrl) { [weak self] succeeded in
guard let self else { return }
// Success only means the bytes reached the socket; wait for
// the matching relay OK. A failed write settles this target
// as rejected because no OK can arrive for it.
if !succeeded {
self.resolveConfirmedSend(
eventID: eventID,
relayURL: relayUrl,
accepted: false,
token: token
)
}
}
}
}
private func resolveConfirmedSend(
eventID: String,
relayURL: String,
accepted: Bool,
token: UUID? = nil
) {
guard var state = confirmedSends[eventID],
token == nil || state.token == token,
state.awaitingRelays.remove(relayURL) != nil else { return }
if accepted {
confirmedSends.removeValue(forKey: eventID)
state.completion(true)
} else if state.awaitingRelays.isEmpty {
confirmedSends.removeValue(forKey: eventID)
state.completion(false)
} else {
confirmedSends[eventID] = state
}
}
private func timeoutConfirmedSend(eventID: String, token: UUID) {
guard let state = confirmedSends[eventID], state.token == token else { return }
confirmedSends.removeValue(forKey: eventID)
state.completion(false)
}
private func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set<String>) { private func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set<String>) {
messageQueueLock.lock() messageQueueLock.lock()
messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays)) messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays))
@@ -783,7 +560,6 @@ final class NostrRelayManager: ObservableObject {
connection.cancel(with: .goingAway, reason: nil) connection.cancel(with: .goingAway, reason: nil)
} }
connections.removeValue(forKey: url) connections.removeValue(forKey: url)
teardownRelayInboundPipeline(for: url)
subscriptions.removeValue(forKey: url) subscriptions.removeValue(forKey: url)
pendingSubscriptions.removeValue(forKey: url) pendingSubscriptions.removeValue(forKey: url)
} }
@@ -1015,7 +791,7 @@ final class NostrRelayManager: ObservableObject {
private func startEOSETracking(id: String, relayURLs: Set<String>, callback: @escaping () -> Void) { private func startEOSETracking(id: String, relayURLs: Set<String>, callback: @escaping () -> Void) {
eoseTrackerEpoch += 1 eoseTrackerEpoch += 1
let epoch = eoseTrackerEpoch let epoch = eoseTrackerEpoch
eoseTrackers[id] = EOSETracker(awaitingSend: relayURLs, awaitingEOSE: [], callback: callback, epoch: epoch) eoseTrackers[id] = EOSETracker(pendingRelays: relayURLs, callback: callback, epoch: epoch)
// Fallback timeout to avoid hanging if a relay never sends EOSE. // Fallback timeout to avoid hanging if a relay never sends EOSE.
dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
@@ -1124,17 +900,12 @@ final class NostrRelayManager: ObservableObject {
connections[urlString] = task connections[urlString] = task
task.resume() task.resume()
// Bring up this relay's own serial verify pipeline before arming the
// socket, so inbound frames have somewhere to land.
ensureRelayInboundPipeline(for: urlString)
// Start receiving messages // Start receiving messages
receiveMessage(from: task, relayUrl: urlString) receiveMessage(from: task, relayUrl: urlString)
// Send initial ping to verify connection // Send initial ping to verify connection
task.sendPing { [weak self] error in task.sendPing { [weak self] error in
DispatchQueue.main.async { DispatchQueue.main.async {
guard self?.connections[urlString] === task else { return }
if error == nil { if error == nil {
SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session) SecureLogger.debug("✅ Connected to Nostr relay: \(urlString)", category: .session)
self?.updateRelayStatus(urlString, isConnected: true) self?.updateRelayStatus(urlString, isConnected: true)
@@ -1144,11 +915,7 @@ final class NostrRelayManager: ObservableObject {
SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session) SecureLogger.error("❌ Failed to connect to Nostr relay \(urlString): \(error?.localizedDescription ?? "Unknown error")", category: .session)
self?.updateRelayStatus(urlString, isConnected: false, error: error) self?.updateRelayStatus(urlString, isConnected: false, error: error)
// Trigger disconnection handler for proper backoff // Trigger disconnection handler for proper backoff
self?.handleDisconnection( self?.handleDisconnection(relayUrl: urlString, error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil))
relayUrl: urlString,
error: error ?? NSError(domain: "NostrRelay", code: -1, userInfo: nil),
connection: task
)
} }
} }
} }
@@ -1164,19 +931,8 @@ final class NostrRelayManager: ObservableObject {
toSend[id] = state.messageString toSend[id] = state.messageString
} }
for (id, messageString) in toSend { for (id, messageString) in toSend {
if self.subscriptions[relayUrl]?.contains(id) == true { if self.subscriptions[relayUrl]?.contains(id) == true { continue }
// Already subscribed on this relay (e.g. a tracker promoted
// after an earlier flush): its EOSE is coming, count it.
markEOSESubscribed(id: id, relayUrl: relayUrl)
continue
}
startPendingEOSETrackingIfNeeded(id: id) startPendingEOSETrackingIfNeeded(id: id)
// Mark at send *initiation*, not in the async completion: a fast
// relay's EOSE could otherwise complete the tracker while this
// relay REQ already on the wire still sat in awaitingSend.
// If the send fails the socket is going down with it, and the
// disconnect settle (or the fallback timer) releases the wait.
markEOSESubscribed(id: id, relayUrl: relayUrl)
connection.send(.string(messageString)) { [weak self, weak connection] error in connection.send(.string(messageString)) { [weak self, weak connection] error in
Task { @MainActor [weak self] in Task { @MainActor [weak self] in
guard let self else { return } guard let self else { return }
@@ -1202,23 +958,22 @@ final class NostrRelayManager: ObservableObject {
switch result { switch result {
case .success(let message): case .success(let message):
// Hand the raw frame to this relay's serial inbound pipeline: // Parse off-main to reduce UI jank, then hop back for state updates
// parsing and signature verification run off-main, in arrival Task.detached(priority: .utility) {
// order, independently of every other relay's pipeline. Routing guard let parsed = ParsedInbound(message) else { return }
// through the lock-guarded router keeps this off the main actor await MainActor.run {
// (no per-frame main hop). self.handleParsedMessage(parsed, from: relayUrl)
self.inboundRouter.yield(InboundFrame(message: message), to: relayUrl) }
}
// Continue receiving // Continue receiving
Task { @MainActor in Task { @MainActor in
guard self.connections[relayUrl] === task else { return }
self.receiveMessage(from: task, relayUrl: relayUrl) self.receiveMessage(from: task, relayUrl: relayUrl)
} }
case .failure(let error): case .failure(let error):
DispatchQueue.main.async { DispatchQueue.main.async {
self.handleDisconnection(relayUrl: relayUrl, error: error, connection: task) self.handleDisconnection(relayUrl: relayUrl, error: error)
} }
} }
} }
@@ -1228,30 +983,20 @@ final class NostrRelayManager: ObservableObject {
// Note: declared at file scope below to avoid MainActor isolation inside this class // Note: declared at file scope below to avoid MainActor isolation inside this class
// and keep parsing off the main actor. // and keep parsing off the main actor.
/// First main-actor hop for an inbound EVENT: per-relay stats plus a cheap // Handle parsed message on MainActor (state updates and handlers)
/// duplicate LOOKUP (no recording) so duplicate fan-in from multiple private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
/// relays never pays for Schnorr verification. Recording happens only switch parsed {
/// after the signature verifies (`deliverVerifiedInboundEvent`), so a case .event(let subId, let event):
/// forged-signature copy can never poison the dedup cache and suppress if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
/// the genuine event. self.relays[index].messagesReceived += 1
private func precheckInboundEvent(subscriptionID: String, eventID: String, relayUrl: String) -> Bool {
if let index = relays.firstIndex(where: { $0.url == relayUrl }) {
relays[index].messagesReceived += 1
} }
guard !eventID.isEmpty else { return true } guard event.isValidSignature() else {
let key = InboundEventKey(subscriptionID: subscriptionID, eventID: eventID) SecureLogger.warning(
if recentInboundEventKeys.contains(key) { "⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
recordDuplicateInboundEventDrop(subscriptionID: subscriptionID) category: .session
return false )
return
} }
return true
}
/// Second main-actor hop, after off-main signature verification:
/// authoritative check-and-record (the serial pipeline means the same
/// event is never in flight twice, but the record must stay atomic with
/// delivery) and handler dispatch.
private func deliverVerifiedInboundEvent(subscriptionID subId: String, event: NostrEvent, from relayUrl: String) {
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else { guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
return return
} }
@@ -1267,24 +1012,10 @@ final class NostrRelayManager: ObservableObject {
} else { } else {
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session) SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
} }
}
// Handle parsed non-EVENT messages on MainActor (state updates and handlers)
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
switch parsed {
case .event:
// Events flow through the serial inbound pipeline (precheck
// off-main signature verification deliverVerifiedInboundEvent)
// and never reach this fallback.
assertionFailure("inbound EVENT bypassed the verified pipeline")
case .eose(let subId): case .eose(let subId):
if var tracker = eoseTrackers[subId] { if var tracker = eoseTrackers[subId] {
// An EOSE proves the relay received the REQ even if the local tracker.pendingRelays.remove(relayUrl)
// send completion hasn't run yet. if tracker.pendingRelays.isEmpty {
tracker.awaitingSend.remove(relayUrl)
tracker.awaitingEOSE.remove(relayUrl)
tracker.didSend = true
if tracker.isComplete {
eoseTrackers.removeValue(forKey: subId) eoseTrackers.removeValue(forKey: subId)
tracker.callback() tracker.callback()
} else { } else {
@@ -1292,7 +1023,6 @@ final class NostrRelayManager: ObservableObject {
} }
} }
case .ok(let eventId, let success, let reason): case .ok(let eventId, let success, let reason):
resolveConfirmedSend(eventID: eventId, relayURL: relayUrl, accepted: success)
if success { if success {
_ = Self.pendingGiftWrapIDs.remove(eventId) _ = Self.pendingGiftWrapIDs.remove(eventId)
SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session) SecureLogger.debug("✅ Accepted id=\(eventId.prefix(16))… relay=\(relayUrl)", category: .session)
@@ -1309,12 +1039,7 @@ final class NostrRelayManager: ObservableObject {
} }
} }
private func sendToRelay( private func sendToRelay(event: NostrEvent, connection: NostrRelayConnectionProtocol, relayUrl: String) {
event: NostrEvent,
connection: NostrRelayConnectionProtocol,
relayUrl: String,
completion: ((Bool) -> Void)? = nil
) {
let req = NostrRequest.event(event) let req = NostrRequest.event(event)
do { do {
@@ -1327,20 +1052,17 @@ final class NostrRelayManager: ObservableObject {
DispatchQueue.main.async { DispatchQueue.main.async {
if let error = error { if let error = error {
SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session) SecureLogger.error("❌ Failed to send event to \(relayUrl): \(error)", category: .session)
completion?(false)
} else { } else {
// SecureLogger.debug(" Event sent to relay: \(relayUrl)", category: .session) // SecureLogger.debug(" Event sent to relay: \(relayUrl)", category: .session)
// Update relay stats // Update relay stats
if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) { if let index = self?.relays.firstIndex(where: { $0.url == relayUrl }) {
self?.relays[index].messagesSent += 1 self?.relays[index].messagesSent += 1
} }
completion?(true)
} }
} }
} }
} catch { } catch {
SecureLogger.error("Failed to encode event: \(error)", category: .session) SecureLogger.error("Failed to encode event: \(error)", category: .session)
completion?(false)
} }
} }
@@ -1349,6 +1071,7 @@ final class NostrRelayManager: ObservableObject {
relays[index].isConnected = isConnected relays[index].isConnected = isConnected
relays[index].lastError = error relays[index].lastError = error
if isConnected { if isConnected {
relays[index].lastConnectedAt = dependencies.now()
relays[index].reconnectAttempts = 0 // Reset on successful connection relays[index].reconnectAttempts = 0 // Reset on successful connection
relays[index].nextReconnectTime = nil relays[index].nextReconnectTime = nil
} else { } else {
@@ -1364,20 +1087,15 @@ final class NostrRelayManager: ObservableObject {
private func updateConnectionStatus() { private func updateConnectionStatus() {
isConnected = relays.contains { $0.isConnected } isConnected = relays.contains { $0.isConnected }
// Relay URLs are normalized before entries are created, so direct
// set membership is sound.
isDMRelayConnected = relays.contains { $0.isConnected && Self.defaultRelaySet.contains($0.url) }
} }
/// A relay that drops before sending EOSE must not stall initial-load /// A relay that drops before sending EOSE must not stall initial-load
/// callbacks; treat it as done and let the remaining relays (or the /// callbacks; treat it as done and let the remaining relays (or the
/// fallback timeout) drive completion. /// fallback timeout) drive completion.
private func settleEOSETrackers(droppingRelay relayUrl: String) { private func settleEOSETrackers(droppingRelay relayUrl: String) {
for (id, var tracker) in eoseTrackers for (id, var tracker) in eoseTrackers where tracker.pendingRelays.contains(relayUrl) {
where tracker.awaitingSend.contains(relayUrl) || tracker.awaitingEOSE.contains(relayUrl) { tracker.pendingRelays.remove(relayUrl)
tracker.awaitingSend.remove(relayUrl) if tracker.pendingRelays.isEmpty {
tracker.awaitingEOSE.remove(relayUrl)
if tracker.isComplete {
eoseTrackers.removeValue(forKey: id) eoseTrackers.removeValue(forKey: id)
tracker.callback() tracker.callback()
} else { } else {
@@ -1386,39 +1104,9 @@ final class NostrRelayManager: ObservableObject {
} }
} }
/// Whether any of `relayUrls` currently holds a live connection. Lets private func handleDisconnection(relayUrl: String, error: Error) {
/// subscribers distinguish "loaded, empty" from "never reached a relay"
/// when an EOSE fallback fires.
func isAnyRelayConnected(among relayUrls: [String]) -> Bool {
let targets = Set(relayUrls)
return relays.contains { targets.contains($0.url) && $0.isConnected }
}
/// Marks the REQ as delivered to `relayUrl`: EOSE completion now waits on
/// this relay instead of the never-connected remainder.
private func markEOSESubscribed(id: String, relayUrl: String) {
guard var tracker = eoseTrackers[id],
tracker.awaitingSend.remove(relayUrl) != nil else { return }
tracker.awaitingEOSE.insert(relayUrl)
tracker.didSend = true
eoseTrackers[id] = tracker
}
private func handleDisconnection(
relayUrl: String,
error: Error,
connection: NostrRelayConnectionProtocol? = nil
) {
if let connection, connections[relayUrl] !== connection { return }
connections.removeValue(forKey: relayUrl) connections.removeValue(forKey: relayUrl)
teardownRelayInboundPipeline(for: relayUrl)
subscriptions.removeValue(forKey: relayUrl) subscriptions.removeValue(forKey: relayUrl)
let awaitingConfirmation = confirmedSends.compactMap { eventID, state in
state.awaitingRelays.contains(relayUrl) ? eventID : nil
}
for eventID in awaitingConfirmation {
resolveConfirmedSend(eventID: eventID, relayURL: relayUrl, accepted: false)
}
updateRelayStatus(relayUrl, isConnected: false, error: error) updateRelayStatus(relayUrl, isConnected: false, error: error)
settleEOSETrackers(droppingRelay: relayUrl) settleEOSETrackers(droppingRelay: relayUrl)
// If networking is disallowed, do not schedule reconnection // If networking is disallowed, do not schedule reconnection
@@ -1506,7 +1194,6 @@ final class NostrRelayManager: ObservableObject {
if let connection = connections[normalizedRelayUrl] { if let connection = connections[normalizedRelayUrl] {
connection.cancel(with: .goingAway, reason: nil) connection.cancel(with: .goingAway, reason: nil)
connections.removeValue(forKey: normalizedRelayUrl) connections.removeValue(forKey: normalizedRelayUrl)
teardownRelayInboundPipeline(for: normalizedRelayUrl)
} }
// Attempt immediate reconnection // Attempt immediate reconnection
@@ -1601,77 +1288,6 @@ final class NostrRelayManager: ObservableObject {
// MARK: - Off-main inbound parsing helpers (file scope, non-isolated) // MARK: - Off-main inbound parsing helpers (file scope, non-isolated)
/// A single raw socket frame awaiting off-main parse + Schnorr verification.
private struct InboundFrame: Sendable {
let message: URLSessionWebSocketTask.Message
}
/// Lock-guarded registry of per-relay inbound streams.
///
/// The raw WebSocket receive callback is not main-actor isolated, so it needs a
/// `Sendable` path to route a frame to the correct relay's stream without a
/// per-frame hop onto the main actor. Pipeline lifecycle (start/finish) is
/// driven from the main actor; frame delivery (`yield`) can come from any
/// thread. All access is serialized by a single lock contention is negligible
/// because the guarded critical section is only a dictionary lookup + yield.
private final class InboundFrameRouter: @unchecked Sendable {
private let lock = NSLock()
private var continuations: [String: AsyncStream<InboundFrame>.Continuation] = [:]
private var tasks: [String: Task<Void, Never>] = [:]
/// Start a relay's stream + consumer if one does not already exist.
/// Returns true when a new pipeline was created. The bounded
/// `.bufferingNewest` policy makes a single relay shed its OWN oldest
/// frames under a flood, never other relays' frames. Buffered memory per
/// relay is bounded (not eliminated) at the frame cap times the per-frame
/// byte cap (`maximumMessageSize`) see TransportConfig.
func startPipeline(
for relayUrl: String,
makeConsumer: (AsyncStream<InboundFrame>) -> Task<Void, Never>
) -> Bool {
lock.lock()
defer { lock.unlock() }
if continuations[relayUrl] != nil { return false }
let (stream, continuation) = AsyncStream<InboundFrame>.makeStream(
bufferingPolicy: .bufferingNewest(TransportConfig.nostrInboundPerRelayBufferCap)
)
continuations[relayUrl] = continuation
tasks[relayUrl] = makeConsumer(stream)
return true
}
/// Route a frame to a relay's stream. No-op if the relay has no live
/// pipeline (socket already torn down) the frame is simply dropped, which
/// is safe for best-effort Nostr inbound.
func yield(_ frame: InboundFrame, to relayUrl: String) {
lock.lock()
let continuation = continuations[relayUrl]
lock.unlock()
continuation?.yield(frame)
}
/// Finish a relay's stream. The consumer drains any already-buffered frames
/// before exiting, so in-flight verified events are still delivered.
func finishPipeline(for relayUrl: String) {
lock.lock()
let continuation = continuations.removeValue(forKey: relayUrl)
tasks.removeValue(forKey: relayUrl)
lock.unlock()
continuation?.finish()
}
func finishAll() {
lock.lock()
let allContinuations = continuations
continuations.removeAll()
tasks.removeAll()
lock.unlock()
for continuation in allContinuations.values {
continuation.finish()
}
}
}
private enum ParsedInbound { private enum ParsedInbound {
case event(subId: String, event: NostrEvent) case event(subId: String, event: NostrEvent)
case ok(eventId: String, success: Bool, reason: String) case ok(eventId: String, success: Bool, reason: String)
@@ -1679,7 +1295,7 @@ private enum ParsedInbound {
case notice(String) case notice(String)
init?(_ message: URLSessionWebSocketTask.Message) { init?(_ message: URLSessionWebSocketTask.Message) {
guard let data = message.dataWithinInboundLimit, guard let data = message.data,
let array = try? JSONSerialization.jsonObject(with: data) as? [Any], let array = try? JSONSerialization.jsonObject(with: data) as? [Any],
array.count >= 2, array.count >= 2,
let type = array[0] as? String else { let type = array[0] as? String else {
@@ -1724,19 +1340,11 @@ private enum ParsedInbound {
} }
private extension URLSessionWebSocketTask.Message { private extension URLSessionWebSocketTask.Message {
/// Prefer rejecting oversized frames before UTF-8/Data materialization var data: Data? {
/// where we can (string length), and always before JSON parse.
var dataWithinInboundLimit: Data? {
let maxBytes = TransportConfig.nostrMaxInboundMessageBytes
switch self { switch self {
case .string(let text): case .string(let text): text.data(using: .utf8)
guard text.utf8.count <= maxBytes else { return nil } case .data(let data): data
return text.data(using: .utf8) @unknown default: nil
case .data(let data):
guard data.count <= maxBytes else { return nil }
return data
@unknown default:
return nil
} }
} }
} }
@@ -1848,29 +1456,6 @@ struct NostrFilter: Encodable {
filter.limit = limit filter.limit = limit
return filter return filter
} }
// For the mesh bridge: rendezvous messages (kind 20000) and presence
// (kind 20001) tagged `#r` with one or more cells (own + neighbors).
static func bridgeRendezvous(_ cells: [String], since: Date? = nil, limit: Int = 200) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [20000, 20001]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["r": cells]
filter.limit = limit
return filter
}
// For courier drops: sealed envelopes (kind 1401) parked under rotating
// recipient tags (`#x`, hex). Callers pass every candidate tag (adjacent
// UTC days x recipients) in one filter.
static func courierDrops(recipientTagsHex: [String], since: Date? = nil, limit: Int = 100) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [NostrProtocol.EventKind.courierDrop.rawValue]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["x": recipientTagsHex]
filter.limit = limit
return filter
}
} }
// Dynamic coding key for tag filters // Dynamic coding key for tag filters
+9
View File
@@ -39,4 +39,13 @@ enum NostrRelayURL {
return components.string return components.string
} }
static func directoryAddress(_ rawValue: String) -> String? {
guard var normalized = normalized(rawValue, defaultScheme: "wss") else { return nil }
for prefix in ["wss://", "ws://"] where normalized.hasPrefix(prefix) {
normalized.removeFirst(prefix.count)
break
}
return normalized
}
} }
-41
View File
@@ -1,41 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>C617.1</string>
<string>3B52.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>35F9.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
<string>1C8F.1</string>
</array>
</dict>
</array>
</dict>
</plist>
-87
View File
@@ -154,90 +154,3 @@ struct BitchatFilePacket {
) )
} }
} }
/// Wire-compatible identity for private media exchanged by clients using the
/// current iOS entropy-bearing filenames, without extending the deployed file
/// TLV. Android clients reject unknown file tags, so eligible senders and
/// receivers derive the receipt key from fields already on the wire.
///
/// Locally-created image and voice-note filenames contain a UUID or live-voice
/// burst ID. Including the normalized direction keeps a reused filename
/// distinct across chats while allowing short and full Noise-key peer IDs to
/// converge. Android and older-iOS timestamp-only names remain ineligible and
/// retain their legacy random local IDs (transfer-compatible, no receipts).
enum PrivateMediaMessageIdentity {
private static let domain = Data("bitchat-private-media-message-v1".utf8)
private static let idPrefix = "media-"
private static let digestHexLength = 32
static func isStableID(_ candidate: String) -> Bool {
guard candidate.hasPrefix(idPrefix) else { return false }
let digest = candidate.dropFirst(idPrefix.count)
guard digest.utf8.count == digestHexLength else { return false }
return digest.utf8.allSatisfy { byte in
(UInt8(ascii: "0")...UInt8(ascii: "9")).contains(byte)
|| (UInt8(ascii: "a")...UInt8(ascii: "f")).contains(byte)
}
}
static func stableID(
senderPeerID: PeerID,
recipientPeerID: PeerID,
fileName: String?
) -> String? {
guard let fileName, !fileName.isEmpty else { return nil }
let leafName = (fileName as NSString).lastPathComponent
guard leafName == fileName else { return nil }
let path = leafName as NSString
let stem = path.deletingPathExtension
let fileExtension = path.pathExtension.lowercased()
switch true {
case stem.hasPrefix("img_"):
guard fileExtension == "jpg" || fileExtension == "jpeg" else { return nil }
case stem.hasPrefix("voice_"):
guard fileExtension == "m4a" else { return nil }
default:
return nil
}
let entropyToken = stem.split(separator: "_").last.map(String.init)
let hasUUIDEntropy = entropyToken.flatMap(UUID.init(uuidString:)) != nil
let voiceBurstID = stem.hasPrefix("voice_")
? String(stem.dropFirst("voice_".count))
: ""
let hasBurstEntropy = voiceBurstID.count == 16
&& voiceBurstID.allSatisfy(\.isHexDigit)
guard hasUUIDEntropy || hasBurstEntropy else {
return nil
}
let fields = [
Data(senderPeerID.toShort().bare.utf8),
Data(recipientPeerID.toShort().bare.utf8),
Data(leafName.utf8)
]
var input = domain
for field in fields {
guard let length = UInt32(exactly: field.count) else { return nil }
var bigEndianLength = length.bigEndian
withUnsafeBytes(of: &bigEndianLength) {
input.append(contentsOf: $0)
}
input.append(field)
}
return "\(idPrefix)\(input.sha256Hex().prefix(digestHexLength))"
}
static func stableID(
for packet: BitchatFilePacket,
senderPeerID: PeerID,
recipientPeerID: PeerID
) -> String? {
stableID(
senderPeerID: senderPeerID,
recipientPeerID: recipientPeerID,
fileName: packet.fileName
)
}
}
+7 -59
View File
@@ -18,7 +18,7 @@
/// - Efficient binary message encoding /// - Efficient binary message encoding
/// - Message fragmentation for large payloads /// - Message fragmentation for large payloads
/// - TTL-based routing for mesh networks /// - TTL-based routing for mesh networks
/// - Privacy features: message padding and randomized relay jitter /// - Privacy features like padding and timing obfuscation
/// - Integration points for end-to-end encryption /// - Integration points for end-to-end encryption
/// ///
/// ## Protocol Design /// ## Protocol Design
@@ -38,20 +38,18 @@
/// 7. **Decoding**: Binary data parsed back to message objects /// 7. **Decoding**: Binary data parsed back to message objects
/// ///
/// ## Security Considerations /// ## Security Considerations
/// - Message padding (to 256/512/1024/2048-byte blocks) obscures actual content length /// - Message padding obscures actual content length
/// - Randomized relay jitter reduces the traffic-analysis signal; there is no /// - Timing obfuscation prevents traffic analysis
/// cover traffic or per-message timing obfuscation
/// - Integration with Noise Protocol for E2E encryption /// - Integration with Noise Protocol for E2E encryption
/// - No persistent identifiers in protocol headers /// - No persistent identifiers in protocol headers
/// ///
/// ## Message Types /// ## Message Types
/// - **Announce/Leave**: Peer presence notifications /// - **Announce/Leave**: Peer presence notifications
/// - **Message**: Public chat messages /// - **Message**: User chat messages (broadcast or directed)
/// - **Fragment**: Multi-part message handling /// - **Fragment**: Multi-part message handling
/// - **NoiseHandshake/NoiseEncrypted**: Encrypted channel establishment and /// - **Delivery/Read**: Message acknowledgments
/// all private payloads (messages, delivery acks, read receipts) /// - **Noise**: Encrypted channel establishment
/// - **CourierEnvelope**: Sealed store-and-forward mail /// - **Version**: Protocol version negotiation
/// - **RequestSync/FileTransfer**: Gossip history sync and media transfer
/// ///
/// ## Future Extensions /// ## Future Extensions
/// The protocol is designed to be extensible: /// The protocol is designed to be extensible:
@@ -74,53 +72,17 @@ enum NoisePayloadType: UInt8 {
case privateMessage = 0x01 // Private chat message case privateMessage = 0x01 // Private chat message
case readReceipt = 0x02 // Message was read case readReceipt = 0x02 // Message was read
case delivered = 0x03 // Message was delivered case delivered = 0x03 // Message was delivered
// Private groups (0x04/0x05 reserved by other features)
case groupInvite = 0x06 // Creator-signed group state (invite)
case groupKeyUpdate = 0x07 // Creator-signed group state (key rotation / roster update)
// Live voice (push-to-talk)
case voiceFrame = 0x08 // One live voice-burst packet (see VoiceBurstPacket)
// Finalized private media. `0x20` is the value already deployed by the
// Android client. The complete BitchatFilePacket is encrypted inside
// Noise before the outer noiseEncrypted packet is fragmented.
case privateFile = 0x20
// Versioned peer state authenticated by the surrounding Noise session.
// This is intentionally distinct from the public announce: announce
// capabilities are discovery hints, while this payload proves possession
// of the advertised Noise static key before downgrade state is pinned.
case authenticatedPeerState = 0x21
// Verification (QR-based OOB binding) // Verification (QR-based OOB binding)
case verifyChallenge = 0x10 // Verification challenge case verifyChallenge = 0x10 // Verification challenge
case verifyResponse = 0x11 // Verification response case verifyResponse = 0x11 // Verification response
// Transitive verification (web of trust)
case vouch = 0x12 // Batch of vouch attestations
/// #1434 briefly used 0x09 before release. Accept it while prerelease
/// builds age out, but never emit it. Decoders canonicalize both values to
/// `.privateFile` so the compatibility alias cannot leak into app logic.
static let prereleasePrivateFileRawValue: UInt8 = 0x09
static func decoded(rawValue: UInt8) -> NoisePayloadType? {
rawValue == prereleasePrivateFileRawValue ? .privateFile : Self(rawValue: rawValue)
}
static func isPrivateFile(rawValue: UInt8?) -> Bool {
guard let rawValue else { return false }
return rawValue == privateFile.rawValue || rawValue == prereleasePrivateFileRawValue
}
var description: String { var description: String {
switch self { switch self {
case .privateMessage: return "privateMessage" case .privateMessage: return "privateMessage"
case .readReceipt: return "readReceipt" case .readReceipt: return "readReceipt"
case .delivered: return "delivered" case .delivered: return "delivered"
case .groupInvite: return "groupInvite"
case .groupKeyUpdate: return "groupKeyUpdate"
case .voiceFrame: return "voiceFrame"
case .privateFile: return "privateFile"
case .authenticatedPeerState: return "authenticatedPeerState"
case .verifyChallenge: return "verifyChallenge" case .verifyChallenge: return "verifyChallenge"
case .verifyResponse: return "verifyResponse" case .verifyResponse: return "verifyResponse"
case .vouch: return "vouch"
} }
} }
} }
@@ -152,12 +114,6 @@ protocol BitchatDelegate: AnyObject {
// Low-level events for better separation of concerns // Low-level events for better separation of concerns
func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date) func didReceiveNoisePayload(from peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
// Encrypted group broadcast (opaque envelope; decrypted by the group coordinator)
func didReceiveGroupMessage(payload: Data, timestamp: Date)
// Public live-voice burst packet (signature-verified by the transport)
func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date)
// Bluetooth state updates for user notifications // Bluetooth state updates for user notifications
func didUpdateBluetoothState(_ state: CBManagerState) func didUpdateBluetoothState(_ state: CBManagerState)
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
@@ -177,14 +133,6 @@ extension BitchatDelegate {
// Default empty implementation // Default empty implementation
} }
func didReceiveGroupMessage(payload: Data, timestamp: Date) {
// Default empty implementation
}
func didReceivePublicVoiceFrame(from peerID: PeerID, nickname: String, payload: Data, timestamp: Date) {
// Default empty implementation
}
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) { func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
// Default empty implementation // Default empty implementation
} }
-348
View File
@@ -1,348 +0,0 @@
//
// BoardPackets.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import CryptoKit
import Foundation
// MARK: - Board wire format (MessageType.boardPost payloads)
//
// TLV layout (type u8, length u16 big-endian, value), matching REQUEST_SYNC:
// - 0x01: kind (u8) 0x01 post, 0x02 tombstone
// - 0x02: postID (16B random)
// - 0x03: geohash (UTF-8, empty = mesh-local board, max 12 chars)
// - 0x04: content (UTF-8, 1...512 bytes) [post]
// - 0x05: authorSigningKey (32B Ed25519 public key)
// - 0x06: authorNickname (UTF-8, max 64 bytes)
// - 0x07: createdAt (u64 big-endian, ms) [post]
// - 0x08: expiresAt (u64 big-endian, ms, max 7 days after createdAt) [post]
// - 0x09: flags (u8, bit0 = urgent) [post]
// - 0x0A: signature (64B Ed25519)
// - 0x0B: deletedAt (u64 big-endian, ms) [tombstone]
// Unknown TLVs are skipped for forward compatibility.
enum BoardWireConstants {
static let postIDLength = 16
static let signingKeyLength = 32
static let signatureLength = 64
static let contentMaxBytes = 512
static let nicknameMaxBytes = 64
static let geohashMaxLength = 12
/// Posts may live at most 7 days past their creation timestamp.
static let maxLifetimeMs: UInt64 = 7 * 24 * 60 * 60 * 1000
static let postSigningContext = "bitchat-board-v1"
static let tombstoneSigningContext = "bitchat-board-del-v1"
static let geohashAlphabet = Set("0123456789bcdefghjkmnpqrstuvwxyz")
}
private enum BoardTLVType: UInt8 {
case kind = 0x01
case postID = 0x02
case geohash = 0x03
case content = 0x04
case authorSigningKey = 0x05
case authorNickname = 0x06
case createdAt = 0x07
case expiresAt = 0x08
case flags = 0x09
case signature = 0x0A
case deletedAt = 0x0B
}
private enum BoardWireKind: UInt8 {
case post = 0x01
case tombstone = 0x02
}
/// A signed, persistent bulletin-board notice.
struct BoardPostPacket: Equatable {
let postID: Data
/// Empty string scopes the post to the mesh-local board.
let geohash: String
let content: String
let authorSigningKey: Data
let authorNickname: String
let createdAt: UInt64
let expiresAt: UInt64
let flags: UInt8
let signature: Data
static let urgentFlag: UInt8 = 0x01
var isUrgent: Bool { flags & Self.urgentFlag != 0 }
/// Canonical bytes covered by the Ed25519 signature. Variable-length
/// fields are length-prefixed so no two field combinations can collide.
static func signingBytes(
postID: Data,
geohash: String,
content: String,
authorSigningKey: Data,
authorNickname: String,
createdAt: UInt64,
expiresAt: UInt64,
flags: UInt8
) -> Data {
var out = Data()
BoardWireEncoding.appendContext(BoardWireConstants.postSigningContext, to: &out)
out.append(postID)
BoardWireEncoding.appendLengthPrefixed(Data(geohash.utf8), to: &out)
BoardWireEncoding.appendLengthPrefixed(Data(content.utf8), to: &out)
out.append(authorSigningKey)
BoardWireEncoding.appendLengthPrefixed(Data(authorNickname.utf8), to: &out)
BoardWireEncoding.appendUInt64(createdAt, to: &out)
BoardWireEncoding.appendUInt64(expiresAt, to: &out)
out.append(flags)
return out
}
var signingBytes: Data {
Self.signingBytes(
postID: postID,
geohash: geohash,
content: content,
authorSigningKey: authorSigningKey,
authorNickname: authorNickname,
createdAt: createdAt,
expiresAt: expiresAt,
flags: flags
)
}
func verifySignature() -> Bool {
BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey)
}
}
/// A signed deletion marker. Only the author's key can produce one; receivers
/// keep it until the post's original expiry so the delete outruns the post.
struct BoardTombstonePacket: Equatable {
let postID: Data
let authorSigningKey: Data
let deletedAt: UInt64
let signature: Data
static func signingBytes(postID: Data, deletedAt: UInt64) -> Data {
var out = Data()
BoardWireEncoding.appendContext(BoardWireConstants.tombstoneSigningContext, to: &out)
out.append(postID)
BoardWireEncoding.appendUInt64(deletedAt, to: &out)
return out
}
var signingBytes: Data {
Self.signingBytes(postID: postID, deletedAt: deletedAt)
}
func verifySignature() -> Bool {
BoardWireEncoding.verify(signature: signature, over: signingBytes, publicKey: authorSigningKey)
}
}
/// Decoded board payload: either a live post or a tombstone.
enum BoardWire: Equatable {
case post(BoardPostPacket)
case tombstone(BoardTombstonePacket)
func encode() -> Data {
var out = Data()
func putTLV(_ t: BoardTLVType, _ v: Data) {
out.append(t.rawValue)
let len = UInt16(v.count)
out.append(UInt8((len >> 8) & 0xFF))
out.append(UInt8(len & 0xFF))
out.append(v)
}
switch self {
case .post(let post):
putTLV(.kind, Data([BoardWireKind.post.rawValue]))
putTLV(.postID, post.postID)
putTLV(.geohash, Data(post.geohash.utf8))
putTLV(.content, Data(post.content.utf8))
putTLV(.authorSigningKey, post.authorSigningKey)
putTLV(.authorNickname, Data(post.authorNickname.utf8))
putTLV(.createdAt, BoardWireEncoding.uint64Data(post.createdAt))
putTLV(.expiresAt, BoardWireEncoding.uint64Data(post.expiresAt))
putTLV(.flags, Data([post.flags]))
putTLV(.signature, post.signature)
case .tombstone(let tombstone):
putTLV(.kind, Data([BoardWireKind.tombstone.rawValue]))
putTLV(.postID, tombstone.postID)
putTLV(.authorSigningKey, tombstone.authorSigningKey)
putTLV(.deletedAt, BoardWireEncoding.uint64Data(tombstone.deletedAt))
putTLV(.signature, tombstone.signature)
}
return out
}
/// Structural decode; the caller must still verify the signature before
/// ingesting (`verifySignature()`).
static func decode(from data: Data) -> BoardWire? {
var off = data.startIndex
var kind: BoardWireKind?
var postID: Data?
var geohash: String?
var content: String?
var contentBytes = 0
var authorSigningKey: Data?
var authorNickname: String?
var nicknameBytes = 0
var createdAt: UInt64?
var expiresAt: UInt64?
var flags: UInt8?
var signature: Data?
var deletedAt: UInt64?
while off + 3 <= data.endIndex {
let t = data[off]; off += 1
let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2
guard off + len <= data.endIndex else { return nil }
let v = data.subdata(in: off..<(off + len)); off += len
switch BoardTLVType(rawValue: t) {
case .kind:
guard v.count == 1 else { return nil }
kind = BoardWireKind(rawValue: v[v.startIndex])
case .postID:
guard v.count == BoardWireConstants.postIDLength else { return nil }
postID = v
case .geohash:
guard v.count <= BoardWireConstants.geohashMaxLength else { return nil }
geohash = String(data: v, encoding: .utf8)
case .content:
guard v.count <= BoardWireConstants.contentMaxBytes else { return nil }
contentBytes = v.count
content = String(data: v, encoding: .utf8)
case .authorSigningKey:
guard v.count == BoardWireConstants.signingKeyLength else { return nil }
authorSigningKey = v
case .authorNickname:
guard v.count <= BoardWireConstants.nicknameMaxBytes else { return nil }
nicknameBytes = v.count
authorNickname = String(data: v, encoding: .utf8)
case .createdAt:
createdAt = BoardWireEncoding.uint64(from: v)
case .expiresAt:
expiresAt = BoardWireEncoding.uint64(from: v)
case .flags:
guard v.count == 1 else { return nil }
flags = v[v.startIndex]
case .signature:
guard v.count == BoardWireConstants.signatureLength else { return nil }
signature = v
case .deletedAt:
deletedAt = BoardWireEncoding.uint64(from: v)
case nil:
continue // forward compatible; ignore unknown TLVs
}
}
guard let postID, let authorSigningKey, let signature else { return nil }
switch kind {
case .post:
guard let geohash, let content, let authorNickname,
let createdAt, let expiresAt, let flags,
contentBytes >= 1,
nicknameBytes <= BoardWireConstants.nicknameMaxBytes,
isValidGeohashField(geohash),
expiresAt > createdAt,
expiresAt - createdAt <= BoardWireConstants.maxLifetimeMs else {
return nil
}
return .post(BoardPostPacket(
postID: postID,
geohash: geohash,
content: content,
authorSigningKey: authorSigningKey,
authorNickname: authorNickname,
createdAt: createdAt,
expiresAt: expiresAt,
flags: flags,
signature: signature
))
case .tombstone:
guard let deletedAt else { return nil }
return .tombstone(BoardTombstonePacket(
postID: postID,
authorSigningKey: authorSigningKey,
deletedAt: deletedAt,
signature: signature
))
case nil:
return nil
}
}
func verifySignature() -> Bool {
switch self {
case .post(let post): return post.verifySignature()
case .tombstone(let tombstone): return tombstone.verifySignature()
}
}
/// Cheap TLV peek for relay policy: is this payload an urgent post?
/// Avoids a full decode on the hot relay path.
static func urgentFlag(in data: Data) -> Bool {
var off = data.startIndex
while off + 3 <= data.endIndex {
let t = data[off]; off += 1
let len = (Int(data[off]) << 8) | Int(data[off + 1]); off += 2
guard off + len <= data.endIndex else { return false }
if t == BoardTLVType.flags.rawValue, len == 1 {
return data[off] & BoardPostPacket.urgentFlag != 0
}
off += len
}
return false
}
/// Empty geohash = mesh-local board; otherwise 1-12 chars of the geohash
/// base32 alphabet.
private static func isValidGeohashField(_ geohash: String) -> Bool {
geohash.isEmpty || geohash.allSatisfy { BoardWireConstants.geohashAlphabet.contains($0) }
}
}
enum BoardWireEncoding {
static func appendContext(_ context: String, to out: inout Data) {
let bytes = Data(context.utf8)
out.append(UInt8(min(bytes.count, 255)))
out.append(bytes.prefix(255))
}
static func appendLengthPrefixed(_ value: Data, to out: inout Data) {
let len = UInt16(min(value.count, Int(UInt16.max)))
out.append(UInt8((len >> 8) & 0xFF))
out.append(UInt8(len & 0xFF))
out.append(value.prefix(Int(UInt16.max)))
}
static func appendUInt64(_ value: UInt64, to out: inout Data) {
var be = value.bigEndian
withUnsafeBytes(of: &be) { out.append(contentsOf: $0) }
}
static func uint64Data(_ value: UInt64) -> Data {
var out = Data()
appendUInt64(value, to: &out)
return out
}
static func uint64(from data: Data) -> UInt64? {
guard data.count == 8 else { return nil }
var value: UInt64 = 0
for byte in data { value = (value << 8) | UInt64(byte) }
return value
}
static func verify(signature: Data, over message: Data, publicKey: Data) -> Bool {
guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: publicKey) else {
return false
}
return key.isValidSignature(signature, for: message)
}
}
+4 -4
View File
@@ -10,11 +10,11 @@ enum Geohash {
return map return map
}() }()
/// Validates a geohash string at any channel precision (1-12 characters). /// Validates a geohash string for building-level precision (8 characters).
/// - Parameter geohash: The geohash string to validate /// - Parameter geohash: The geohash string to validate
/// - Returns: true if a non-empty base32 geohash of at most 12 characters /// - Returns: true if valid 8-character base32 geohash, false otherwise
static func isValidGeohash(_ geohash: String) -> Bool { static func isValidBuildingGeohash(_ geohash: String) -> Bool {
guard (1...12).contains(geohash.count) else { return false } guard geohash.count == 8 else { return false }
return geohash.lowercased().allSatisfy { base32Map[$0] != nil } return geohash.lowercased().allSatisfy { base32Map[$0] != nil }
} }
@@ -1,32 +0,0 @@
//
// MeshMessageIdentity.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
/// Content-derived identity for public mesh messages.
///
/// The BLE wire carries no message ID for public broadcasts, so every device
/// recomputes the same stable ID from the signed wire fields (sender ID,
/// millisecond timestamp, content). That gives the mesh bridge a
/// cross-device-consistent radio identity with zero wire change. Bridge events
/// carry this value only as a hint for detecting a radio copy that is already
/// present: sender/timestamp/content are public, so a different Nostr signer
/// can copy them and must never be allowed to reserve the genuine event's
/// authenticated dedup slot.
enum MeshMessageIdentity {
/// Matches the wire truncation in `BLEService.sendMessage`.
static func millisecondTimestamp(_ date: Date) -> UInt64 {
UInt64(date.timeIntervalSince1970 * 1000)
}
static func stableID(senderIDHex: String, timestampMs: UInt64, content: String) -> String {
let input = senderIDHex.lowercased() + "|" + String(timestampMs) + "|" + content.trimmed
return String(Data(input.utf8).sha256Hex().prefix(32))
}
}
-144
View File
@@ -1,144 +0,0 @@
//
// NostrCarrierPacket.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
/// Wire payload for `MessageType.nostrCarrier` (0x28): a complete, signed
/// Nostr event ferried over the mesh between a mesh-only peer and an
/// internet gateway peer.
///
/// - `toGateway` rides a DIRECTED packet (recipientID = the gateway peer):
/// a mesh-only sender asks the gateway to publish its locally signed
/// geohash event to Nostr relays.
/// - `fromGateway` rides a BROADCAST packet (default TTL): the gateway
/// rebroadcasts inbound relay events so mesh-only peers see the channel.
///
/// The carried event is public geohash chat already plaintext on Nostr
/// so the carrier adds no encryption. It IS signed by the originator's
/// per-geohash identity, so neither the gateway nor any mesh relay can forge
/// or alter it undetected: gateways and receivers verify the Schnorr
/// signature before acting on it.
///
/// TLV encoding with 2-byte big-endian lengths (the event JSON exceeds the
/// 1-byte TLV range used by smaller packets). Unknown TLV types are skipped
/// for forward compatibility.
struct NostrCarrierPacket: Equatable {
enum Direction: UInt8 {
case toGateway = 0x01
case fromGateway = 0x02
/// Mesh-bridge uplink: a mesh-only peer asks a bridge gateway to
/// publish its signed rendezvous event. Directed, like `toGateway`.
case toBridge = 0x03
/// Mesh-bridge downlink: a bridge gateway rebroadcasts a rendezvous
/// event from a remote island. Broadcast, like `fromGateway`.
/// Old clients fail the Direction decode on 0x03/0x04 and drop the
/// carrier quietly bridge traffic degrades to invisible, not junk.
case fromBridge = 0x04
}
let direction: Direction
let geohash: String
/// Complete signed Nostr event JSON (id, pubkey, created_at, kind, tags,
/// content, sig).
let eventJSON: Data
/// BLE airtime cap for a carried event.
static let maxEventJSONBytes = 16 * 1024
static let maxGeohashLength = 12
private enum TLVType: UInt8 {
case direction = 0x01
case geohash = 0x02
case eventJSON = 0x03
}
init?(direction: Direction, geohash: String, eventJSON: Data) {
let geohashBytes = Data(geohash.utf8)
guard !geohashBytes.isEmpty,
geohashBytes.count <= Self.maxGeohashLength,
!eventJSON.isEmpty,
eventJSON.count <= Self.maxEventJSONBytes else {
return nil
}
self.direction = direction
self.geohash = geohash
self.eventJSON = eventJSON
}
init?(direction: Direction, geohash: String, event: NostrEvent) {
guard let json = try? event.jsonString(), !json.isEmpty else { return nil }
self.init(direction: direction, geohash: geohash, eventJSON: Data(json.utf8))
}
/// Decodes the carried event. Callers MUST still verify
/// `event.isValidSignature()` before publishing or displaying it.
func event() -> NostrEvent? {
guard let dict = try? JSONSerialization.jsonObject(with: eventJSON) as? [String: Any] else {
return nil
}
return try? NostrEvent(from: dict)
}
func encode() -> Data? {
var data = Data()
data.reserveCapacity(eventJSON.count + geohash.utf8.count + 12)
func appendTLV(_ type: TLVType, _ value: Data) {
data.append(type.rawValue)
data.append(UInt8((value.count >> 8) & 0xFF))
data.append(UInt8(value.count & 0xFF))
data.append(value)
}
appendTLV(.direction, Data([direction.rawValue]))
appendTLV(.geohash, Data(geohash.utf8))
appendTLV(.eventJSON, eventJSON)
return data
}
static func decode(_ data: Data) -> NostrCarrierPacket? {
// Defensive slice re-base (Data slices keep parent indices).
let data = Data(data)
var offset = 0
var direction: Direction?
var geohash: String?
var eventJSON: Data?
while offset + 3 <= data.count {
let typeRaw = data[offset]
let length = (Int(data[offset + 1]) << 8) | Int(data[offset + 2])
offset += 3
guard offset + length <= data.count else { return nil }
let value = data.subdata(in: offset..<offset + length)
offset += length
switch TLVType(rawValue: typeRaw) {
case .direction:
guard value.count == 1, let parsed = Direction(rawValue: value[0]) else { return nil }
direction = parsed
case .geohash:
guard let parsed = String(data: value, encoding: .utf8) else { return nil }
geohash = parsed
case .eventJSON:
eventJSON = value
case nil:
// Unknown TLV; skip (tolerant decoder for forward compatibility).
continue
}
}
guard offset == data.count,
let direction,
let geohash,
let eventJSON else {
return nil
}
return NostrCarrierPacket(direction: direction, geohash: geohash, eventJSON: eventJSON)
}
}
+1 -136
View File
@@ -1,4 +1,3 @@
import BitFoundation
import Foundation import Foundation
// MARK: - Protocol TLV Packets // MARK: - Protocol TLV Packets
@@ -8,35 +7,12 @@ struct AnnouncementPacket {
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement) let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
let signingPublicKey: Data // Ed25519 public key for signing let signingPublicKey: Data // Ed25519 public key for signing
let directNeighbors: [Data]? // 8-byte peer IDs let directNeighbors: [Data]? // 8-byte peer IDs
let capabilities: PeerCapabilities? // advertised feature bits; nil when absent (old clients)
/// Rendezvous geohash cell this peer bridges, when advertising `.bridge`.
/// Coarse (cell-level) by design; lets mesh-only peers compose correctly
/// tagged rendezvous events without their own location fix.
let bridgeGeohash: String?
init(
nickname: String,
noisePublicKey: Data,
signingPublicKey: Data,
directNeighbors: [Data]?,
capabilities: PeerCapabilities? = nil,
bridgeGeohash: String? = nil
) {
self.nickname = nickname
self.noisePublicKey = noisePublicKey
self.signingPublicKey = signingPublicKey
self.directNeighbors = directNeighbors
self.capabilities = capabilities
self.bridgeGeohash = bridgeGeohash
}
private enum TLVType: UInt8 { private enum TLVType: UInt8 {
case nickname = 0x01 case nickname = 0x01
case noisePublicKey = 0x02 case noisePublicKey = 0x02
case signingPublicKey = 0x03 case signingPublicKey = 0x03
case directNeighbors = 0x04 case directNeighbors = 0x04
case capabilities = 0x05
case bridgeGeohash = 0x06
} }
func encode() -> Data? { func encode() -> Data? {
@@ -72,24 +48,6 @@ struct AnnouncementPacket {
} }
} }
// TLV for capabilities (optional)
if let capabilities = capabilities {
let capabilityBytes = capabilities.encoded()
guard capabilityBytes.count <= 255 else { return nil }
data.append(TLVType.capabilities.rawValue)
data.append(UInt8(capabilityBytes.count))
data.append(capabilityBytes)
}
// TLV for bridge rendezvous cell (optional; old clients skip it)
if let bridgeGeohash = bridgeGeohash,
let cellData = bridgeGeohash.data(using: .utf8),
!cellData.isEmpty, cellData.count <= 12 {
data.append(TLVType.bridgeGeohash.rawValue)
data.append(UInt8(cellData.count))
data.append(cellData)
}
return data return data
} }
@@ -99,8 +57,6 @@ struct AnnouncementPacket {
var noisePublicKey: Data? var noisePublicKey: Data?
var signingPublicKey: Data? var signingPublicKey: Data?
var directNeighbors: [Data]? var directNeighbors: [Data]?
var capabilities: PeerCapabilities?
var bridgeGeohash: String?
while offset + 2 <= data.count { while offset + 2 <= data.count {
let typeRaw = data[offset] let typeRaw = data[offset]
@@ -131,12 +87,6 @@ struct AnnouncementPacket {
} }
directNeighbors = neighbors directNeighbors = neighbors
} }
case .capabilities:
capabilities = PeerCapabilities(encoded: Data(value))
case .bridgeGeohash:
if length <= 12 {
bridgeGeohash = String(data: value, encoding: .utf8)
}
} }
} else { } else {
// Unknown TLV; skip (tolerant decoder for forward compatibility) // Unknown TLV; skip (tolerant decoder for forward compatibility)
@@ -149,92 +99,7 @@ struct AnnouncementPacket {
nickname: nickname, nickname: nickname,
noisePublicKey: noisePublicKey, noisePublicKey: noisePublicKey,
signingPublicKey: signingPublicKey, signingPublicKey: signingPublicKey,
directNeighbors: directNeighbors, directNeighbors: directNeighbors
capabilities: capabilities,
bridgeGeohash: bridgeGeohash
)
}
}
/// State that is authoritative only because it is carried inside an
/// established Noise session. The public announce remains useful for
/// discovery, but its self-signature cannot prove possession of the copied
/// Noise public key it contains.
///
/// Wire format (v1):
/// `[version=0x01][type][length][value]...`
/// - TLV `0x01`: canonical minimal little-endian `PeerCapabilities`
/// - TLV `0x02`: 32-byte Ed25519 signing public key
///
/// Unknown TLVs are skipped for forward compatibility. Unknown versions,
/// duplicates, non-canonical capability fields, and malformed lengths are
/// rejected without changing authenticated state.
struct AuthenticatedPeerStatePacket: Equatable {
static let currentVersion: UInt8 = 1
static let signingPublicKeyLength = 32
let capabilities: PeerCapabilities
let signingPublicKey: Data
private enum TLVType: UInt8 {
case capabilities = 0x01
case signingPublicKey = 0x02
}
func encode() -> Data? {
guard signingPublicKey.count == Self.signingPublicKeyLength else { return nil }
let capabilityBytes = capabilities.encoded()
guard !capabilityBytes.isEmpty, capabilityBytes.count <= 8 else { return nil }
var data = Data([Self.currentVersion])
data.append(TLVType.capabilities.rawValue)
data.append(UInt8(capabilityBytes.count))
data.append(capabilityBytes)
data.append(TLVType.signingPublicKey.rawValue)
data.append(UInt8(signingPublicKey.count))
data.append(signingPublicKey)
return data
}
static func decode(from data: Data) -> AuthenticatedPeerStatePacket? {
guard data.first == Self.currentVersion else { return nil }
var offset = 1
var capabilities: PeerCapabilities?
var signingPublicKey: Data?
while offset < data.count {
guard offset + 2 <= data.count else { return nil }
let typeRaw = data[offset]
let length = Int(data[offset + 1])
offset += 2
guard offset + length <= data.count else { return nil }
let value = Data(data[offset..<(offset + length)])
offset += length
guard let type = TLVType(rawValue: typeRaw) else {
continue
}
switch type {
case .capabilities:
guard capabilities == nil,
!value.isEmpty,
value.count <= 8 else { return nil }
let decoded = PeerCapabilities(encoded: value)
guard decoded.encoded() == value else { return nil }
capabilities = decoded
case .signingPublicKey:
guard signingPublicKey == nil,
value.count == Self.signingPublicKeyLength else { return nil }
signingPublicKey = value
}
}
guard let capabilities, let signingPublicKey else { return nil }
return AuthenticatedPeerStatePacket(
capabilities: capabilities,
signingPublicKey: signingPublicKey
) )
} }
} }
@@ -1,13 +0,0 @@
import BitFoundation
extension PeerCapabilities {
/// Capabilities this build advertises in its announce packets.
/// Each feature adds its bit here when it ships.
static let localSupported: PeerCapabilities = [
.vouch,
.prekeys,
.groups,
.privateMedia,
.privateMediaReceipts
]
}
-220
View File
@@ -1,220 +0,0 @@
//
// VoiceBurstPacket.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import Security
/// Audio codec of a live voice burst. START packets carry it so receivers can
/// reject bursts they can't decode instead of feeding garbage to the decoder.
enum VoiceBurstCodec: UInt8 {
/// AAC-LC, 16 kHz, mono, ~16 kbps matches the voice-note recorder, so
/// the finalized `.m4a` and the live frames come from the same encoder
/// settings.
case aacLC16kMono = 0x01
}
/// One packet of a live push-to-talk voice burst (the inner payload of
/// `NoisePayloadType.voiceFrame`, and for public mesh bursts the payload
/// of `MessageType.voiceFrame`).
///
/// Wire format:
/// ```
/// [burstID: 8][seq: UInt16 BE][flags: UInt8][payload]
/// ```
/// - flags 0x01 (START): payload = [codec: UInt8]
/// - flags 0x02 (END): payload = [totalDataPackets: UInt16 BE][durationMs: UInt32 BE]
/// - flags 0x04 (CANCELED): empty payload; receivers discard the burst
/// - flags 0x00 (data): payload = repeated [length: UInt16 BE][AAC frame]
struct VoiceBurstPacket: Equatable {
enum Kind: Equatable {
case start(codec: VoiceBurstCodec)
case frames([Data])
case end(totalDataPackets: UInt16, durationMs: UInt32)
case canceled
}
static let burstIDSize = 8
private static let headerSize = burstIDSize + 2 + 1
/// Sanity cap on frames per packet; real packets carry 1-2 frames.
static let maxFramesPerPacket = 8
private enum Flags {
static let start: UInt8 = 0x01
static let end: UInt8 = 0x02
static let canceled: UInt8 = 0x04
}
let burstID: Data
let seq: UInt16
let kind: Kind
init?(burstID: Data, seq: UInt16, kind: Kind) {
guard burstID.count == Self.burstIDSize else { return nil }
if case .frames(let frames) = kind {
guard !frames.isEmpty,
frames.count <= Self.maxFramesPerPacket,
frames.allSatisfy({ !$0.isEmpty && $0.count <= Int(UInt16.max) })
else { return nil }
}
self.burstID = burstID
self.seq = seq
self.kind = kind
}
func encode() -> Data {
var data = Data(capacity: Self.headerSize + payloadSize)
data.append(burstID)
data.append(UInt8((seq >> 8) & 0xFF))
data.append(UInt8(seq & 0xFF))
switch kind {
case .start(let codec):
data.append(Flags.start)
data.append(codec.rawValue)
case .frames(let frames):
data.append(0)
for frame in frames {
let length = UInt16(frame.count)
data.append(UInt8((length >> 8) & 0xFF))
data.append(UInt8(length & 0xFF))
data.append(frame)
}
case .end(let totalDataPackets, let durationMs):
data.append(Flags.end)
data.append(UInt8((totalDataPackets >> 8) & 0xFF))
data.append(UInt8(totalDataPackets & 0xFF))
for shift in stride(from: 24, through: 0, by: -8) {
data.append(UInt8((durationMs >> UInt32(shift)) & 0xFF))
}
case .canceled:
data.append(Flags.canceled)
}
return data
}
static func decode(_ data: Data) -> VoiceBurstPacket? {
// Work on a re-based copy so subscripting is offset-safe.
let data = Data(data)
guard data.count >= headerSize else { return nil }
let burstID = data.prefix(burstIDSize)
let seq = (UInt16(data[burstIDSize]) << 8) | UInt16(data[burstIDSize + 1])
let flags = data[burstIDSize + 2]
let payload = data.dropFirst(headerSize)
let kind: Kind
switch flags {
case Flags.start:
guard let codecByte = payload.first,
let codec = VoiceBurstCodec(rawValue: codecByte)
else { return nil }
kind = .start(codec: codec)
case Flags.end:
guard payload.count >= 6 else { return nil }
let bytes = Array(payload)
let total = (UInt16(bytes[0]) << 8) | UInt16(bytes[1])
let duration = bytes[2...5].reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
kind = .end(totalDataPackets: total, durationMs: duration)
case Flags.canceled:
kind = .canceled
case 0:
var frames: [Data] = []
var offset = payload.startIndex
while offset < payload.endIndex {
guard payload.distance(from: offset, to: payload.endIndex) >= 2 else { return nil }
let length = (Int(payload[offset]) << 8) | Int(payload[payload.index(after: offset)])
offset = payload.index(offset, offsetBy: 2)
guard length > 0,
payload.distance(from: offset, to: payload.endIndex) >= length,
frames.count < maxFramesPerPacket
else { return nil }
let end = payload.index(offset, offsetBy: length)
frames.append(Data(payload[offset..<end]))
offset = end
}
guard !frames.isEmpty else { return nil }
kind = .frames(frames)
default:
return nil
}
return VoiceBurstPacket(burstID: Data(burstID), seq: seq, kind: kind)
}
static func makeBurstID() -> Data {
var bytes = Data(count: burstIDSize)
let result = bytes.withUnsafeMutableBytes {
SecRandomCopyBytes(kSecRandomDefault, burstIDSize, $0.baseAddress!)
}
guard result == errSecSuccess else {
return Data((0..<burstIDSize).map { _ in UInt8.random(in: .min ... .max) })
}
return bytes
}
private var payloadSize: Int {
switch kind {
case .start: return 1
case .frames(let frames): return frames.reduce(0) { $0 + 2 + $1.count }
case .end: return 6
case .canceled: return 0
}
}
}
/// Greedy packetizer for outgoing bursts: batches encoded frames into
/// `VoiceBurstPacket`s without exceeding the byte budget that keeps each
/// packet in a single BLE frame after Noise encryption and padding.
/// Not thread-safe confine to one queue.
struct VoiceBurstPacketizer {
let burstID: Data
private let budget: Int
private var pendingFrames: [Data] = []
private var pendingSize = 0
/// seq 0 is reserved for START; data packets start at 1.
private(set) var nextSeq: UInt16 = 1
private(set) var dataPacketCount: UInt16 = 0
init(burstID: Data, budget: Int = TransportConfig.pttMaxBurstContentBytes) {
self.burstID = burstID
self.budget = budget
}
/// Adds one encoded frame, returning any packets that became full.
/// Frames larger than the budget are dropped (the encoder's ~130-byte
/// frames never hit this; it guards against misconfiguration looping).
mutating func add(_ frame: Data) -> [Data] {
let frameCost = 2 + frame.count
guard VoiceBurstPacket.burstIDSize + 3 + frameCost <= budget else { return [] }
var packets: [Data] = []
if !pendingFrames.isEmpty,
VoiceBurstPacket.burstIDSize + 3 + pendingSize + frameCost > budget
|| pendingFrames.count >= VoiceBurstPacket.maxFramesPerPacket {
packets.append(contentsOf: flush())
}
pendingFrames.append(frame)
pendingSize += frameCost
return packets
}
/// Emits any buffered frames as a final data packet.
mutating func flush() -> [Data] {
guard !pendingFrames.isEmpty,
let packet = VoiceBurstPacket(burstID: burstID, seq: nextSeq, kind: .frames(pendingFrames))
else {
pendingFrames = []
pendingSize = 0
return []
}
pendingFrames = []
pendingSize = 0
nextSeq &+= 1
dataPacketCount &+= 1
return [packet.encode()]
}
}
-225
View File
@@ -1,225 +0,0 @@
//
// VouchAttestation.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import CryptoKit
import Foundation
/// A signed statement that the *sender of the enclosing Noise payload* has
/// verified the identity described here ("transitive verification").
///
/// The voucher's identity is deliberately implicit: attestations only travel
/// inside an authenticated Noise session (`NoisePayloadType.vouch`), so the
/// receiver verifies the Ed25519 signature against the session peer's
/// announce-bound signing key and stores the vouch keyed by that peer's
/// fingerprint. Nothing in the attestation names the voucher, so a captured
/// attestation cannot be replayed by a third party whose signing key doesn't
/// match.
///
/// Wire format single attestation (TLV, 1-byte type + 1-byte length):
/// - `0x01` voucheeFingerprint: 32 bytes, SHA-256 of the vouchee's Noise static key
/// - `0x02` voucheeSigningKey: 32 bytes, Ed25519; anchors the vouch to a concrete identity
/// - `0x03` timestamp: 8 bytes big-endian, milliseconds since 1970
/// - `0x04` signature: 64 bytes, Ed25519 by the VOUCHER's signing key over
/// `"bitchat-vouch-v1" | voucheeFingerprint | voucheeSigningKey | timestamp`
///
/// Unknown TLV types are skipped for forward compatibility.
///
/// Batch format (the `vouch` Noise payload body):
/// `[count: UInt8]` then per attestation `[length: UInt16 BE][attestation TLV]`.
struct VouchAttestation: Equatable {
static let signingContext = "bitchat-vouch-v1"
/// Receiver-side expiry for attestations.
static let maxAge: TimeInterval = 30 * 24 * 60 * 60
/// Tolerated clock skew for attestations timestamped in the future.
static let maxClockSkew: TimeInterval = 60 * 60
/// Upper bound of attestations carried/accepted in one batch payload.
static let maxBatchCount = 16
static let fingerprintSize = 32
static let signingKeySize = 32
static let signatureSize = 64
let voucheeFingerprint: Data // 32 bytes
let voucheeSigningKey: Data // 32 bytes
let timestampMs: UInt64
let signature: Data // 64 bytes
private enum TLVType: UInt8 {
case voucheeFingerprint = 0x01
case voucheeSigningKey = 0x02
case timestamp = 0x03
case signature = 0x04
}
var voucheeFingerprintHex: String { voucheeFingerprint.hexEncodedString() }
var timestamp: Date { Date(timeIntervalSince1970: TimeInterval(timestampMs) / 1000) }
/// The exact bytes the voucher signs.
static func signableBytes(
voucheeFingerprint: Data,
voucheeSigningKey: Data,
timestampMs: UInt64
) -> Data {
var message = Data(signingContext.utf8)
message.append(voucheeFingerprint)
message.append(voucheeSigningKey)
var timestampBE = timestampMs.bigEndian
withUnsafeBytes(of: &timestampBE) { 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: &timestampBE) { Data($0) })
appendTLV(.signature, signature)
return data
}
static func decode(from data: Data) -> VouchAttestation? {
var fingerprint: Data?
var signingKey: Data?
var timestampMs: UInt64?
var signature: Data?
var offset = data.startIndex
while offset < data.endIndex {
guard data.index(offset, offsetBy: 2, limitedBy: data.endIndex) != nil,
offset + 1 < data.endIndex else { return nil }
let type = data[offset]
let length = Int(data[offset + 1])
let valueStart = offset + 2
guard let valueEnd = data.index(valueStart, offsetBy: length, limitedBy: data.endIndex) else {
return nil
}
let value = Data(data[valueStart..<valueEnd])
switch TLVType(rawValue: type) {
case .voucheeFingerprint:
guard value.count == fingerprintSize else { return nil }
fingerprint = value
case .voucheeSigningKey:
guard value.count == signingKeySize else { return nil }
signingKey = value
case .timestamp:
guard value.count == 8 else { return nil }
timestampMs = value.reduce(UInt64(0)) { ($0 << 8) | UInt64($1) }
case .signature:
guard value.count == signatureSize else { return nil }
signature = value
case nil:
break // Unknown TLV: skip for forward compatibility.
}
offset = valueEnd
}
guard let fingerprint, let signingKey, let timestampMs, let signature else { return nil }
return VouchAttestation(
voucheeFingerprint: fingerprint,
voucheeSigningKey: signingKey,
timestampMs: timestampMs,
signature: signature
)
}
// MARK: - Batch encoding
/// Encodes up to `maxBatchCount` attestations into one payload body.
static func encodeList(_ attestations: [VouchAttestation]) -> Data? {
guard !attestations.isEmpty, attestations.count <= maxBatchCount else { return nil }
var data = Data()
data.append(UInt8(attestations.count))
for attestation in attestations {
guard let encoded = attestation.encode(), encoded.count <= Int(UInt16.max) else { return nil }
var lengthBE = UInt16(encoded.count).bigEndian
withUnsafeBytes(of: &lengthBE) { data.append(contentsOf: $0) }
data.append(encoded)
}
return data
}
/// Decodes a batch payload, dropping malformed entries and ignoring
/// anything beyond `maxBatchCount` (sender-declared count is not trusted).
static func decodeList(from data: Data) -> [VouchAttestation] {
guard data.count > 1 else { return [] }
let declaredCount = Int(data[data.startIndex])
let limit = min(declaredCount, maxBatchCount)
var attestations: [VouchAttestation] = []
var offset = data.startIndex + 1
while attestations.count < limit, offset < data.endIndex {
guard let lengthEnd = data.index(offset, offsetBy: 2, limitedBy: data.endIndex) else { break }
let length = Int(data[offset]) << 8 | Int(data[offset + 1])
guard let entryEnd = data.index(lengthEnd, offsetBy: length, limitedBy: data.endIndex) else { break }
if let attestation = decode(from: Data(data[lengthEnd..<entryEnd])) {
attestations.append(attestation)
}
offset = entryEnd
}
return attestations
}
}
@@ -11,6 +11,13 @@ import Foundation
/// Manages autocomplete functionality for chat /// Manages autocomplete functionality for chat
final class AutocompleteService { final class AutocompleteService {
private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: []) private let mentionRegex = try? NSRegularExpression(pattern: "@([\\p{L}0-9_]*)$", options: [])
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
private let commands = [
"/msg", "/who", "/clear",
"/hug", "/slap", "/fav", "/unfav",
"/block", "/unblock"
]
/// Get autocomplete suggestions for current text /// Get autocomplete suggestions for current text
func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) { func getSuggestions(for text: String, peers: [String], cursorPosition: Int) -> (suggestions: [String], range: NSRange?) {
@@ -66,6 +73,26 @@ final class AutocompleteService {
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange) return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
} }
private func getCommandSuggestions(_ text: String) -> ([String], NSRange)? {
guard let regex = commandRegex else { return nil }
let nsText = text as NSString
let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsText.length))
guard let match = matches.last else { return nil }
let fullRange = match.range(at: 0)
let captureRange = match.range(at: 1)
let prefix = nsText.substring(with: captureRange).lowercased()
let suggestions = commands
.filter { $0.hasPrefix("/\(prefix)") }
.sorted()
.prefix(5)
return suggestions.isEmpty ? nil : (Array(suggestions), fullRange)
}
private func needsArgument(command: String) -> Bool { private func needsArgument(command: String) -> Bool {
switch command { switch command {
case "/who", "/clear": case "/who", "/clear":
+10 -77
View File
@@ -14,39 +14,22 @@ struct BLEAnnounceHandlerEnvironment {
let messageTTL: UInt8 let messageTTL: UInt8
/// Current time source. /// Current time source.
let now: () -> Date let now: () -> Date
/// Noise and signing public keys already recorded for the peer, if any /// Noise public key already recorded for the peer, if any (registry read).
/// (single registry read so both come from one consistent snapshot). let existingNoisePublicKey: (PeerID) -> Data?
let existingPeerKeys: (PeerID) -> (noisePublicKey: Data?, signingPublicKey: Data?)
/// Signing key from the persisted cryptographic identity for the peer, if
/// any. Registry pins do not survive app restarts or offline-peer
/// eviction; this fallback keeps the TOFU signing-key pin effective for
/// returning peers.
let persistedSigningPublicKey: (PeerID) -> Data?
/// Ed25519 key previously bound to this Noise identity by an authenticated
/// peer-state payload, if any (persistent identity-state read).
let authenticatedSigningPublicKey: (_ noisePublicKey: Data) -> Data?
/// Verifies the packet signature against the announced signing key. /// Verifies the packet signature against the announced signing key.
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
/// Direct link state for the peer (BLE-queue read). /// Direct link state for the peer (BLE-queue read).
let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool) let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool)
/// Whether the link this packet arrived on is already bound to a
/// different peer ID (ingress-registry + BLE-queue read). Directness
/// rides on the unsigned TTL, so a replayed announce can look "direct"
/// on the replayer's link; that link must not shortcut an absent peer
/// into "connected".
let linkBoundToOtherPeer: (_ packet: BitchatPacket, _ peerID: PeerID) -> Bool
/// Runs the registry mutation phase under the collections barrier. /// Runs the registry mutation phase under the collections barrier.
let withRegistryBarrier: (() -> Void) -> Void let withRegistryBarrier: (() -> Void) -> Void
/// Upserts the verified announce into the peer registry. /// Upserts the verified announce into the peer registry.
/// Returns `nil` when the registry refuses the announce because it carries
/// a signing key different from the one already pinned for this peer.
/// Must only be called from inside `withRegistryBarrier`. /// Must only be called from inside `withRegistryBarrier`.
let upsertVerifiedAnnounce: ( let upsertVerifiedAnnounce: (
_ peerID: PeerID, _ peerID: PeerID,
_ announcement: AnnouncementPacket, _ announcement: AnnouncementPacket,
_ isConnected: Bool, _ isConnected: Bool,
_ now: Date _ now: Date
) -> BLEPeerAnnounceUpdate? ) -> BLEPeerAnnounceUpdate
/// Debounced reconnect-log decision. /// Debounced reconnect-log decision.
/// Must only be called from inside `withRegistryBarrier`. /// Must only be called from inside `withRegistryBarrier`.
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
@@ -126,16 +109,7 @@ final class BLEAnnounceHandler {
// Suppress announce logs to reduce noise // Suppress announce logs to reduce noise
// Precompute signature verification outside barrier to reduce contention // Precompute signature verification outside barrier to reduce contention
var existingPeerKeys = env.existingPeerKeys(peerID) let existingNoisePublicKey = env.existingNoisePublicKey(peerID)
if existingPeerKeys.signingPublicKey == nil {
// The registry entry (and its signing-key pin) is dropped on app
// restart and offline-peer eviction, but the persisted
// cryptographic identity survives both. Fall back to it so a
// returning peer is not treated as first contact otherwise an
// attacker could replay the peer's noiseKey/peerID with their own
// signing key and re-pin the identity (TOFU downgrade).
existingPeerKeys.signingPublicKey = env.persistedSigningPublicKey(peerID)
}
let hasSignature = packet.signature != nil let hasSignature = packet.signature != nil
let signatureValid: Bool let signatureValid: Bool
if hasSignature { if hasSignature {
@@ -149,49 +123,18 @@ final class BLEAnnounceHandler {
let trustDecision = BLEAnnounceTrustPolicy.evaluate( let trustDecision = BLEAnnounceTrustPolicy.evaluate(
hasSignature: hasSignature, hasSignature: hasSignature,
signatureValid: signatureValid, signatureValid: signatureValid,
existingNoisePublicKey: existingPeerKeys.noisePublicKey, existingNoisePublicKey: existingNoisePublicKey,
announcedNoisePublicKey: announcement.noisePublicKey, announcedNoisePublicKey: announcement.noisePublicKey
existingSigningPublicKey: existingPeerKeys.signingPublicKey,
authenticatedSigningPublicKey: env.authenticatedSigningPublicKey(
announcement.noisePublicKey
),
announcedSigningPublicKey: announcement.signingPublicKey
) )
if case .reject(.keyMismatch) = trustDecision { if case .reject(.keyMismatch) = trustDecision {
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security) SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
} }
if case .reject(.signingKeyMismatch) = trustDecision { let verifiedAnnounce = trustDecision.isVerified
SecureLogger.warning("🚨 Announce signing-key mismatch for \(peerID.id.prefix(8))… — refusing to replace pinned signing key (possible impersonation attempt)", category: .security)
}
if case .reject(.authenticatedSigningKeyMismatch) = trustDecision {
SecureLogger.warning(
"⚠️ Announce signing-key replacement rejected for Noise-authenticated peer \(peerID.id.prefix(8))",
category: .security
)
}
var verifiedAnnounce = trustDecision.isVerified
var isNewPeer = false var isNewPeer = false
var isReconnectedPeer = false var isReconnectedPeer = false
let directLinkState = env.linkState(peerID) let directLinkState = env.linkState(peerID)
let isDirectAnnounce = packet.ttl == env.messageTTL let isDirectAnnounce = packet.ttl == env.messageTTL
// A "direct" announce arriving on a link that another peer already
// owns is either a rotation heal or a replay with its TTL restored;
// both are ambiguous, so only the rebind (which containment-checks
// the claimed identity) may promote it never this shortcut.
//
// Known limitation: denying the shortcut cannot prevent forged
// presence outright. A rebind that passes the containment checks
// promotes the claimed peer to connected it must, or a legitimate
// rotation on an open link would read as disconnected so a replay
// that wins the rebind (absent victim, cooldown clear) still forges
// presence. That residue is presence display only: DMs stay gated on
// canDeliverSecurely (no Noise session means retain + courier, see
// MessageRouter.sendPrivate). What this check buys: the ambiguous
// announce alone never flips presence forging requires winning the
// containment-checked rebind (never steals an identity that owns a
// live link; at most one rebind per link per cooldown window).
let linkBoundToOtherPeer = isDirectAnnounce && env.linkBoundToOtherPeer(packet, peerID)
env.withRegistryBarrier { env.withRegistryBarrier {
let hasPeripheralConnection = directLinkState.hasPeripheral let hasPeripheralConnection = directLinkState.hasPeripheral
@@ -206,22 +149,12 @@ final class BLEAnnounceHandler {
return return
} }
// The registry re-checks the signing-key pin inside the barrier. let update = env.upsertVerifiedAnnounce(
// The pre-barrier trust check reads the registry outside the
// barrier, so this closes the race where two announces for the
// same peer are evaluated concurrently.
guard let update = env.upsertVerifiedAnnounce(
peerID, peerID,
announcement, announcement,
hasPeripheralConnection || hasCentralSubscription || (isDirectAnnounce && !linkBoundToOtherPeer), isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
now now
) else { )
SecureLogger.warning("🚨 Registry refused announce for \(peerID.id.prefix(8))… — signing key differs from pinned key", category: .security)
verifiedAnnounce = false
isNewPeer = false
isReconnectedPeer = false
return
}
isNewPeer = update.isNewPeer isNewPeer = update.isNewPeer
isReconnectedPeer = update.wasDisconnected isReconnectedPeer = update.wasDisconnected
@@ -56,8 +56,6 @@ enum BLEAnnounceTrustRejection: Equatable {
case missingSignature case missingSignature
case invalidSignature case invalidSignature
case keyMismatch case keyMismatch
case signingKeyMismatch
case authenticatedSigningKeyMismatch
} }
enum BLEAnnounceTrustDecision: Equatable { enum BLEAnnounceTrustDecision: Equatable {
@@ -74,34 +72,12 @@ enum BLEAnnounceTrustPolicy {
hasSignature: Bool, hasSignature: Bool,
signatureValid: Bool, signatureValid: Bool,
existingNoisePublicKey: Data?, existingNoisePublicKey: Data?,
announcedNoisePublicKey: Data, announcedNoisePublicKey: Data
existingSigningPublicKey: Data? = nil,
authenticatedSigningPublicKey: Data? = nil,
announcedSigningPublicKey: Data
) -> BLEAnnounceTrustDecision { ) -> BLEAnnounceTrustDecision {
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey { if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
return .reject(.keyMismatch) return .reject(.keyMismatch)
} }
// Strongest binding first: an Ed25519 key bound to this Noise identity
// inside an authenticated Noise session can never be replaced by a
// merely self-signed announce.
if let authenticatedSigningPublicKey,
announcedSigningPublicKey != authenticatedSigningPublicKey {
return .reject(.authenticatedSigningKeyMismatch)
}
// TOFU signing-key pinning. The packet signature only proves the
// announce is self-consistent it is verified against the Ed25519 key
// carried *inside the same announce*. Since peerIDs derive from the
// broadcast (public) noise key, an attacker can replay a victim's
// peerID+noiseKey with their own signing key and a valid
// self-signature. Once we have bound a signing key to this peer,
// refuse to silently replace it.
if let existingSigningPublicKey, existingSigningPublicKey != announcedSigningPublicKey {
return .reject(.signingKeyMismatch)
}
guard hasSignature else { guard hasSignature else {
return .reject(.missingSignature) return .reject(.missingSignature)
} }
+4 -13
View File
@@ -1,13 +1,6 @@
import Foundation import Foundation
/// Thread-safe announce admission state. struct BLEAnnounceThrottle {
///
/// Announce requests originate from the Bluetooth delegate queue, the
/// concurrent message queue, and the maintenance timer. Keeping the timestamp
/// behind a lock makes admission and maintenance snapshots atomic when those
/// request sources race.
final class BLEAnnounceThrottle: @unchecked Sendable {
private let lock = NSLock()
private var lastSent: Date private var lastSent: Date
private let normalMinimumInterval: TimeInterval private let normalMinimumInterval: TimeInterval
private let forcedMinimumInterval: TimeInterval private let forcedMinimumInterval: TimeInterval
@@ -23,13 +16,12 @@ final class BLEAnnounceThrottle: @unchecked Sendable {
} }
func elapsed(since now: Date) -> TimeInterval { func elapsed(since now: Date) -> TimeInterval {
lock.withLock { now.timeIntervalSince(lastSent) } now.timeIntervalSince(lastSent)
} }
func shouldSend(force: Bool, now: Date) -> Bool { mutating func shouldSend(force: Bool, now: Date) -> Bool {
lock.withLock {
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
guard now.timeIntervalSince(lastSent) >= minimumInterval else { guard elapsed(since: now) >= minimumInterval else {
return false return false
} }
@@ -37,4 +29,3 @@ final class BLEAnnounceThrottle: @unchecked Sendable {
return true return true
} }
} }
}
+8 -44
View File
@@ -15,10 +15,7 @@ enum BLEFanoutSelector {
excludedLinks: Set<BLEIngressLinkID> = [], excludedLinks: Set<BLEIngressLinkID> = [],
peripheralPeerBindings: [String: PeerID] = [:], peripheralPeerBindings: [String: PeerID] = [:],
centralPeerBindings: [String: PeerID] = [:], centralPeerBindings: [String: PeerID] = [:],
preferredPeripheralPerPeer: [PeerID: String] = [:],
collapseDuplicatePeerLinks: Bool = true,
directedPeerHint: PeerID?, directedPeerHint: PeerID?,
requireDirectPeerLink: Bool = false,
packetType: UInt8, packetType: UInt8,
messageID: String messageID: String
) -> BLEFanoutSelection { ) -> BLEFanoutSelection {
@@ -34,14 +31,10 @@ enum BLEFanoutSelector {
to: directedPeerHint, to: directedPeerHint,
links: rawAllowed, links: rawAllowed,
peripheralPeerBindings: peripheralPeerBindings, peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings, centralPeerBindings: centralPeerBindings
preferredPeripheralPerPeer: preferredPeripheralPerPeer
) { ) {
return directedSelection return directedSelection
} }
if directedPeerHint != nil, requireDirectPeerLink {
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
}
if let directedPeerHint, if let directedPeerHint,
hasBoundLink( hasBoundLink(
to: directedPeerHint, to: directedPeerHint,
@@ -53,20 +46,11 @@ enum BLEFanoutSelector {
return BLEFanoutSelection(peripheralIDs: [], centralIDs: []) return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
} }
// Direct announces are the packet that binds a link to its peer let allowed = collapseDuplicateLinksPerPeer(
// (BLEService's raw bind and verified rebind). Collapsing them per
// peer starves duplicate same-peer links of the announce they need to
// become bound the duplicates then look "pre-announce" forever and
// every broadcast sprays down all of them. Announces are small and
// throttled, so they go on every live link.
let allowed = collapseDuplicatePeerLinks
? collapseDuplicateLinksPerPeer(
rawAllowed, rawAllowed,
peripheralPeerBindings: peripheralPeerBindings, peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings, centralPeerBindings: centralPeerBindings
preferredPeripheralPerPeer: preferredPeripheralPerPeer
) )
: rawAllowed
guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else { guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else {
return BLEFanoutSelection( return BLEFanoutSelection(
@@ -113,8 +97,7 @@ enum BLEFanoutSelector {
to peerID: PeerID, to peerID: PeerID,
links: (peripheralIDs: [String], centralIDs: [String]), links: (peripheralIDs: [String], centralIDs: [String]),
peripheralPeerBindings: [String: PeerID], peripheralPeerBindings: [String: PeerID],
centralPeerBindings: [String: PeerID], centralPeerBindings: [String: PeerID]
preferredPeripheralPerPeer: [PeerID: String]
) -> BLEFanoutSelection? { ) -> BLEFanoutSelection? {
let directLinks = collapseDuplicateLinksPerPeer( let directLinks = collapseDuplicateLinksPerPeer(
( (
@@ -122,8 +105,7 @@ enum BLEFanoutSelector {
centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID } centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID }
), ),
peripheralPeerBindings: peripheralPeerBindings, peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings, centralPeerBindings: centralPeerBindings
preferredPeripheralPerPeer: preferredPeripheralPerPeer
) )
guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else { guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else {
@@ -158,8 +140,7 @@ enum BLEFanoutSelector {
private static func collapseDuplicateLinksPerPeer( private static func collapseDuplicateLinksPerPeer(
_ links: (peripheralIDs: [String], centralIDs: [String]), _ links: (peripheralIDs: [String], centralIDs: [String]),
peripheralPeerBindings: [String: PeerID], peripheralPeerBindings: [String: PeerID],
centralPeerBindings: [String: PeerID], centralPeerBindings: [String: PeerID]
preferredPeripheralPerPeer: [PeerID: String]
) -> (peripheralIDs: [String], centralIDs: [String]) { ) -> (peripheralIDs: [String], centralIDs: [String]) {
guard !peripheralPeerBindings.isEmpty || !centralPeerBindings.isEmpty else { guard !peripheralPeerBindings.isEmpty || !centralPeerBindings.isEmpty else {
return links return links
@@ -167,30 +148,13 @@ enum BLEFanoutSelector {
var seenPeers = Set<PeerID>() var seenPeers = Set<PeerID>()
var keptPeripheralIDs: [String] = [] var keptPeripheralIDs: [String] = []
// When a peer has several bound peripheral links (duplicate
// connections after a restore), collapse onto its preferred one (the
// most recently bound) instead of dictionary order an arbitrary
// pick could route a peer's single collapsed copy down a stale link.
for id in links.peripheralIDs { for id in links.peripheralIDs {
guard let peer = peripheralPeerBindings[id], if let peer = peripheralPeerBindings[id], !seenPeers.insert(peer).inserted {
preferredPeripheralPerPeer[peer] == id, continue
seenPeers.insert(peer).inserted else { continue }
keptPeripheralIDs.append(id)
}
for id in links.peripheralIDs {
if let peer = peripheralPeerBindings[id] {
if preferredPeripheralPerPeer[peer] == id { continue }
if !seenPeers.insert(peer).inserted { continue }
} }
keptPeripheralIDs.append(id) keptPeripheralIDs.append(id)
} }
// Known limitation: centrals collapse in subscription order (oldest
// first) there is no recency signal like the peripheral reverse
// map. A central-only peer with duplicate subscriptions rides the
// oldest one until the remote side (which owns those connections)
// consolidates on its next verified announce (bounded by its
// retirement cooldown).
var keptCentralIDs: [String] = [] var keptCentralIDs: [String] = []
for id in links.centralIDs { for id in links.centralIDs {
if let peer = centralPeerBindings[id], !seenPeers.insert(peer).inserted { if let peer = centralPeerBindings[id], !seenPeers.insert(peer).inserted {

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