mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 09:25:20 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f53afe330c | ||
|
|
58ccb30575 | ||
|
|
400ac4c904 | ||
|
|
565d2b7773 | ||
|
|
6b71ad2a64 | ||
|
|
16324c819f | ||
|
|
cd727c6867 | ||
|
|
fb8fe39713 |
@@ -1,42 +1,228 @@
|
|||||||
name: Fetch GeoRelays Data
|
name: Propose GeoRelay Data Update
|
||||||
|
|
||||||
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:
|
permissions:
|
||||||
contents: write
|
contents: read
|
||||||
pull-requests: write
|
|
||||||
|
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:
|
jobs:
|
||||||
update-relay-data:
|
propose-relay-data:
|
||||||
|
name: Validate and propose relay data
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout reviewed base
|
||||||
uses: actions/checkout@v4
|
# Pinned actions/checkout v5 so a mutable action tag cannot change the
|
||||||
|
# code that receives this job's write-capable token.
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
ref: main
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
# Do not expose the write token to fetch/validation subprocesses.
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Fetch GeoRelays
|
- name: Test GeoRelay validator
|
||||||
run: |
|
run: |
|
||||||
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
set -euo pipefail
|
||||||
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
python3 -m unittest discover -s scripts/tests -p "test_*.py" -v
|
||||||
|
|
||||||
- name: Check for changes
|
- name: Fetch candidate over pinned HTTPS policy
|
||||||
id: git-check
|
id: upstream
|
||||||
run: |
|
run: |
|
||||||
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
set -euo pipefail
|
||||||
|
source_commit=$(git ls-remote --refs "$SOURCE_REPOSITORY" refs/heads/main | awk 'NR == 1 { print $1 }')
|
||||||
- name: Commit and push changes
|
if [[ ! "$source_commit" =~ ^[0-9a-f]{40}$ ]]; then
|
||||||
if: steps.git-check.outputs.changes == 'true'
|
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
|
||||||
|
id: validation
|
||||||
run: |
|
run: |
|
||||||
git config --local user.email "action@github.com"
|
set -euo pipefail
|
||||||
git config --local user.name "GitHub Action"
|
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 add relays/online_relays_gps.csv
|
|
||||||
git commit -m "Automated update of relay data - $(date -u)"
|
- name: Check for a reviewed-file change
|
||||||
git push
|
id: changes
|
||||||
|
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:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ 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
|
||||||
|
|||||||
@@ -94,6 +94,24 @@ 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)
|
||||||
@@ -115,22 +133,6 @@ 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
|
||||||
|
|
||||||
# Informational only: surfaces per-file and total line coverage in the
|
|
||||||
# job log so coverage trends are visible on every PR. No thresholds —
|
|
||||||
# this must never be the reason a build goes red.
|
|
||||||
- name: Coverage summary
|
|
||||||
run: |
|
|
||||||
BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }})
|
|
||||||
PROF="$BIN_PATH/codecov/default.profdata"
|
|
||||||
XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1)
|
|
||||||
BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)"
|
|
||||||
if [ -f "$PROF" ] && [ -f "$BINARY" ]; then
|
|
||||||
xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \
|
|
||||||
-ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)' || true
|
|
||||||
else
|
|
||||||
echo "No coverage data found; skipping summary."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# SPM tests do not link the shipping app targets. This job covers the
|
# SPM tests do not link the shipping app targets. This job covers the
|
||||||
# iOS-conditional paths and both universal Release link configurations.
|
# iOS-conditional paths and both universal Release link configurations.
|
||||||
ios-build:
|
ios-build:
|
||||||
@@ -142,6 +144,9 @@ 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
|
# Build both simulator architectures so CI validates every vendored
|
||||||
# Arti simulator slice and the configuration that ships.
|
# Arti simulator slice and the configuration that ships.
|
||||||
@@ -169,6 +174,52 @@ jobs:
|
|||||||
CODE_SIGNING_ALLOWED=NO \
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
build
|
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.
|
||||||
|
|||||||
@@ -3,3 +3,6 @@ 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,107 +1,66 @@
|
|||||||
# BitChat macOS Build Justfile
|
# BitChat developer commands
|
||||||
# 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 macOS Build Commands:"
|
@echo "BitChat developer 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 only"
|
@echo " just build Build the macOS app without signing"
|
||||||
@echo " just clean - Clean build artifacts and restore original files"
|
@echo " just test Run the SwiftPM test suite"
|
||||||
@echo " just check - Check prerequisites"
|
@echo " just test-ios Run tests on the iPhone 17 simulator"
|
||||||
@echo ""
|
@echo " just clean Remove repo-local build artifacts only"
|
||||||
@echo "Original files are preserved - modifications are temporary for builds only"
|
@echo " just nuke Also remove nested package build caches"
|
||||||
|
@echo " just check Validate the development environment"
|
||||||
|
|
||||||
# Check prerequisites
|
# Static guard against reintroducing source-restoring or source-deleting clean
|
||||||
check:
|
# behavior. CI runs the same script directly.
|
||||||
|
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 Xcode from App Store" && exit 1)
|
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install full Xcode." && exit 1)
|
||||||
@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)
|
@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
|
||||||
@test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1)
|
@xcodebuild -version
|
||||||
@xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
@echo "✅ Development environment ready (a signing identity is not required for just build)"
|
||||||
@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"
|
|
||||||
|
|
||||||
# Backup original files
|
build: check
|
||||||
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 bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
@xcodebuild -project "{{project}}" -scheme "{{macos_scheme}}" -configuration Debug -derivedDataPath "{{derived_data}}" CODE_SIGNING_ALLOWED=NO build
|
||||||
|
|
||||||
# Run the macOS app
|
|
||||||
run: build
|
run: build
|
||||||
@echo "Launching BitChat..."
|
@app="{{derived_data}}/Build/Products/Debug/bitchat.app"; test -d "$$app" || (echo "❌ Built app not found at $$app" && exit 1); open "$$app"
|
||||||
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
|
|
||||||
|
|
||||||
# Clean build artifacts and restore original files
|
# Backward-compatible alias for the old quick-run recipe.
|
||||||
clean: restore
|
dev-run: run
|
||||||
@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"
|
|
||||||
|
|
||||||
# Quick run without cleaning (for development)
|
test:
|
||||||
dev-run: check
|
@swift test
|
||||||
@echo "Quick development build..."
|
|
||||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
test-ios: check
|
||||||
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
|
@xcodebuild -project "{{project}}" -scheme "{{ios_scheme}}" -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -derivedDataPath "{{derived_data}}" test
|
||||||
|
|
||||||
|
# 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 "======================================"
|
@echo "macOS 13+ and iOS 16+"
|
||||||
@echo "• Native macOS SwiftUI app"
|
@echo "Bluetooth mesh behavior requires physical Bluetooth-capable devices"
|
||||||
@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"
|
|
||||||
|
|||||||
+3
-3
@@ -17,8 +17,8 @@ bitchat is designed for private, account-free communication. This policy describ
|
|||||||
|
|
||||||
1. **Identity and cryptographic keys**
|
1. **Identity and cryptographic keys**
|
||||||
- Noise, signing, group, prekey, and optional Nostr identity material is generated locally.
|
- Noise, signing, group, prekey, and optional Nostr identity material is generated locally.
|
||||||
- Secret keys are stored in the system keychain. Public keys are shared when required for messaging, verification, groups, or Nostr events.
|
- 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, erased with panic wipe, or removed with the app.
|
- 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**
|
2. **Nickname, preferences, and relationships**
|
||||||
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
|
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
|
||||||
@@ -121,7 +121,7 @@ No cryptographic system can protect content after a recipient reads, copies, scr
|
|||||||
|
|
||||||
## Your Controls
|
## Your Controls
|
||||||
|
|
||||||
- **Panic wipe:** Triple-tap the logo to clear local keys, sessions, preferences, groups, queues, carried mail, public archives, board data, and media managed by the app.
|
- **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.
|
- **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.
|
- **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.
|
- **No account:** The project operates no account record for you to request or export.
|
||||||
|
|||||||
@@ -93,30 +93,62 @@ 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
|
```
|
||||||
```
|
|
||||||
|
|
||||||
To run on a device there're a few steps to prepare the code:
|
For a signed device build, create your ignored local configuration and replace
|
||||||
- Clone the local configs: `cp Configs/Local.xcconfig.example Configs/Local.xcconfig`
|
the example team ID with your Apple Developer Team ID:
|
||||||
- Add your Developer Team ID into the newly created `Configs/Local.xcconfig`
|
|
||||||
- Bundle ID would be set to `chat.bitchat.<team_id>` (unless you set to something else)
|
```bash
|
||||||
- Entitlements need to be updated manually (TODO: Automate):
|
cp Configs/Local.xcconfig.example Configs/Local.xcconfig
|
||||||
- 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
|
||||||
|
```
|
||||||
|
|
||||||
Want to try this on macos: `just run` will set it up and run from source.
|
`just build` and `just run` use the current `bitchat (macOS)` scheme and keep
|
||||||
Run `just clean` afterwards to restore things to original state for mobile app building and development.
|
Xcode output in the ignored `.DerivedData/` directory. They never patch source,
|
||||||
|
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
|
||||||
|
|
||||||
- Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`.
|
- App localizations live in `bitchat/Localizable.xcstrings`.
|
||||||
- Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`.
|
- Share extension strings are separate in `bitchatShareExtension/Localization/Localizable.xcstrings`.
|
||||||
- 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.
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ final class AppChromeModel: ObservableObject {
|
|||||||
|
|
||||||
private let chatViewModel: ChatViewModel
|
private let chatViewModel: ChatViewModel
|
||||||
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.
|
/// Bulletin-board coordinator, created on first use of the board sheet.
|
||||||
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
||||||
@@ -97,7 +100,12 @@ final class AppChromeModel: ObservableObject {
|
|||||||
showScreenshotPrivacyWarning = true
|
showScreenshotPrivacyWarning = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setPanicPreparation(_ preparation: (@MainActor () -> Void)?) {
|
||||||
|
prepareForPanic = preparation
|
||||||
|
}
|
||||||
|
|
||||||
func panicClearAllData() {
|
func panicClearAllData() {
|
||||||
|
prepareForPanic?()
|
||||||
chatViewModel.panicClearAllData()
|
chatViewModel.panicClearAllData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,12 +107,15 @@ final class AppRuntime: ObservableObject {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
if chatViewModel.networkActivationAllowed {
|
||||||
|
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
|
||||||
@@ -151,12 +154,14 @@ 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()
|
||||||
@@ -175,6 +180,7 @@ 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)
|
||||||
@@ -222,6 +228,7 @@ final class AppRuntime: ObservableObject {
|
|||||||
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
|
actionIdentifier: String = UNNotificationDefaultActionIdentifier,
|
||||||
userInfo: [AnyHashable: Any]
|
userInfo: [AnyHashable: Any]
|
||||||
) {
|
) {
|
||||||
|
guard chatViewModel.networkActivationAllowed else { return }
|
||||||
if actionIdentifier == NotificationService.waveActionID {
|
if actionIdentifier == NotificationService.waveActionID {
|
||||||
chatViewModel.sendMeshWave()
|
chatViewModel.sendMeshWave()
|
||||||
return
|
return
|
||||||
@@ -273,6 +280,8 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -281,6 +290,8 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -289,6 +300,8 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -297,6 +310,8 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -313,6 +328,7 @@ private extension AppRuntime {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkForSharedContent() {
|
func checkForSharedContent() {
|
||||||
|
guard chatViewModel.networkActivationAllowed else { return }
|
||||||
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
|
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
|
||||||
let clearSharedContent = {
|
let clearSharedContent = {
|
||||||
userDefaults.removeObject(forKey: "sharedContent")
|
userDefaults.removeObject(forKey: "sharedContent")
|
||||||
@@ -359,7 +375,9 @@ private extension AppRuntime {
|
|||||||
let becameConnected = isConnected && !lastNostrRelayConnectedState
|
let becameConnected = isConnected && !lastNostrRelayConnectedState
|
||||||
lastNostrRelayConnectedState = isConnected
|
lastNostrRelayConnectedState = isConnected
|
||||||
|
|
||||||
guard started, becameConnected else { return }
|
guard chatViewModel.networkActivationAllowed,
|
||||||
|
started,
|
||||||
|
becameConnected else { return }
|
||||||
|
|
||||||
let isInitialConnection = !didHandleInitialNostrConnection
|
let isInitialConnection = !didHandleInitialNostrConnection
|
||||||
didHandleInitialNostrConnection = true
|
didHandleInitialNostrConnection = true
|
||||||
|
|||||||
@@ -39,15 +39,17 @@ 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 → index map for O(1) dedup and
|
/// Incrementally-maintained message-ID → logical-index map for O(1)
|
||||||
/// delivery-status lookup. Kept in sync on every mutation:
|
/// dedup and delivery-status lookup. Logical indexes are physical array
|
||||||
/// - tail append: single insert
|
/// indexes plus `indexOffset`; trimming from the head advances the offset
|
||||||
/// - out-of-order insert: suffix reindex from the insertion point
|
/// instead of rewriting every surviving dictionary entry. This matters
|
||||||
/// - trim: full rebuild — `removeFirst(k)` is already O(n), so the
|
/// after the 1337-message cap is reached, when every steady-state tail
|
||||||
/// rebuild does not change the asymptotics, and trim only happens once
|
/// append evicts one old row.
|
||||||
/// the cap (1337) is reached. Simple and correct beats the
|
///
|
||||||
/// offset-tracking alternative here.
|
/// Out-of-order inserts and middle removals still reindex only the
|
||||||
|
/// 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
|
||||||
@@ -61,7 +63,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func message(withID messageID: String) -> BitchatMessage? {
|
func message(withID messageID: String) -> BitchatMessage? {
|
||||||
guard let index = indexByMessageID[messageID] else { return nil }
|
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
||||||
return messages[index]
|
return messages[index]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +103,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
reindex(from: index)
|
reindex(from: index)
|
||||||
} else {
|
} else {
|
||||||
messages.append(message)
|
messages.append(message)
|
||||||
indexByMessageID[message.id] = messages.count - 1
|
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
||||||
@@ -111,7 +113,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 = indexByMessageID[message.id] {
|
if let index = physicalIndex(forMessageID: message.id) {
|
||||||
messages[index] = message
|
messages[index] = message
|
||||||
return .updated
|
return .updated
|
||||||
}
|
}
|
||||||
@@ -125,7 +127,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 = indexByMessageID[messageID] else { return false }
|
guard let index = physicalIndex(forMessageID: 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 }
|
||||||
|
|
||||||
@@ -142,7 +144,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 = indexByMessageID[messageID] else { return false }
|
guard let index = physicalIndex(forMessageID: messageID) else { return false }
|
||||||
messages[index] = messages[index]
|
messages[index] = messages[index]
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -157,10 +159,14 @@ 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 = indexByMessageID[messageID] else { return nil }
|
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
||||||
let removed = messages.remove(at: index)
|
let removed = messages.remove(at: index)
|
||||||
indexByMessageID.removeValue(forKey: messageID)
|
indexByMessageID.removeValue(forKey: messageID)
|
||||||
reindex(from: index)
|
if index == 0 {
|
||||||
|
indexOffset += 1
|
||||||
|
} else {
|
||||||
|
reindex(from: index)
|
||||||
|
}
|
||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +183,7 @@ 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
|
||||||
}
|
}
|
||||||
@@ -184,6 +191,7 @@ 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
|
||||||
@@ -205,9 +213,10 @@ 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 index = indexByMessageID[message.id] {
|
if let logicalIndex = indexByMessageID[message.id] {
|
||||||
if index != position {
|
let expectedIndex = indexOffset + position
|
||||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(index)")
|
if logicalIndex != expectedIndex {
|
||||||
|
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")
|
||||||
@@ -269,10 +278,17 @@ 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] = index
|
indexByMessageID[messages[index].id] = indexOffset + 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 [] }
|
||||||
@@ -282,7 +298,7 @@ final class Conversation: ObservableObject, Identifiable {
|
|||||||
indexByMessageID.removeValue(forKey: id)
|
indexByMessageID.removeValue(forKey: id)
|
||||||
}
|
}
|
||||||
messages.removeFirst(overflow)
|
messages.removeFirst(overflow)
|
||||||
reindex(from: 0)
|
indexOffset += overflow
|
||||||
return trimmedIDs
|
return trimmedIDs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -844,8 +860,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] = 1
|
indexByMessageID[messages[0].id] = indexOffset + 1
|
||||||
indexByMessageID[messages[1].id] = 0
|
indexByMessageID[messages[1].id] = indexOffset
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drops a message's index entry entirely (count mismatch + missing).
|
/// Drops a message's index entry entirely (count mismatch + missing).
|
||||||
@@ -859,8 +875,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] = 0
|
indexByMessageID[messages[0].id] = indexOffset
|
||||||
indexByMessageID[messages[messages.count - 1].id] = messages.count - 1
|
indexByMessageID[messages[messages.count - 1].id] = indexOffset + messages.count - 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -900,7 +916,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] = messages.count - 1
|
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ protocol VoiceCaptureSession: AnyObject {
|
|||||||
/// nothing valid was captured.
|
/// nothing valid was captured.
|
||||||
func finish() async -> URL?
|
func finish() async -> URL?
|
||||||
func cancel() async
|
func cancel() async
|
||||||
|
/// Stops capture and suppresses every later send before returning.
|
||||||
|
func panicCancelSynchronously()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
|
/// The classic record-then-send backend, wrapping the shared `VoiceRecorder`.
|
||||||
@@ -55,6 +57,10 @@ final class VoiceNoteCaptureSession: VoiceCaptureSession {
|
|||||||
func cancel() async {
|
func cancel() async {
|
||||||
await recorder.cancelRecording(owner: owner)
|
await recorder.cancelRecording(owner: owner)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func panicCancelSynchronously() {
|
||||||
|
recorder.panicCancelSynchronously(owner: owner)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Testable surface of the live capture engine. Production uses
|
/// Testable surface of the live capture engine. Production uses
|
||||||
@@ -216,6 +222,13 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) {
|
||||||
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
|
guard let packet = VoiceBurstPacket(burstID: burstID, seq: stream.packetizer.nextSeq, kind: kind) else { return }
|
||||||
sendPacket(packet.encode())
|
sendPacket(packet.encode())
|
||||||
|
|||||||
@@ -246,6 +246,21 @@ 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 audio session was interrupted (call, Siri) or reconfigured: stop
|
||||||
/// the recorder but keep `recorder`/`currentURL` so the caller's pending
|
/// the recorder but keep `recorder`/`currentURL` so the caller's pending
|
||||||
/// `stopRecording()` still returns the partial note.
|
/// `stopRecording()` still returns the partial note.
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ enum NoiseSecurityConstants {
|
|||||||
|
|
||||||
// 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
|
||||||
|
|
||||||
// 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
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ enum NoiseSessionError: Error, Equatable {
|
|||||||
case notEstablished
|
case notEstablished
|
||||||
case sessionNotFound
|
case sessionNotFound
|
||||||
case alreadyEstablished
|
case alreadyEstablished
|
||||||
|
case peerIdentityMismatch
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,18 @@ import CryptoKit
|
|||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
|
||||||
|
struct NoiseHandshakeProcessingResult {
|
||||||
|
let response: Data?
|
||||||
|
let didEstablishAuthenticatedSession: Bool
|
||||||
|
}
|
||||||
|
|
||||||
final class NoiseSessionManager {
|
final class NoiseSessionManager {
|
||||||
private var sessions: [PeerID: NoiseSession] = [:]
|
private var sessions: [PeerID: NoiseSession] = [:]
|
||||||
|
/// A responder rehandshake must not evict a working transport session
|
||||||
|
/// before the candidate proves that its authenticated static key belongs
|
||||||
|
/// to the claimed wire ID. Candidates therefore live outside `sessions`
|
||||||
|
/// until the XX handshake completes and the binding is validated.
|
||||||
|
private var responderCandidates: [PeerID: NoiseSession] = [:]
|
||||||
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
private let sessionFactory: (PeerID, NoiseRole) -> NoiseSession
|
||||||
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
private let managerQueue = DispatchQueue(label: "chat.bitchat.noise.manager", attributes: .concurrent)
|
||||||
|
|
||||||
@@ -54,6 +64,9 @@ final class NoiseSessionManager {
|
|||||||
if let session = sessions.removeValue(forKey: peerID) {
|
if let session = sessions.removeValue(forKey: peerID) {
|
||||||
session.reset() // Clear sensitive data before removing
|
session.reset() // Clear sensitive data before removing
|
||||||
}
|
}
|
||||||
|
if let candidate = responderCandidates.removeValue(forKey: peerID) {
|
||||||
|
candidate.reset()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +75,11 @@ final class NoiseSessionManager {
|
|||||||
for (_, session) in sessions {
|
for (_, session) in sessions {
|
||||||
session.reset()
|
session.reset()
|
||||||
}
|
}
|
||||||
|
for (_, candidate) in responderCandidates {
|
||||||
|
candidate.reset()
|
||||||
|
}
|
||||||
sessions.removeAll()
|
sessions.removeAll()
|
||||||
|
responderCandidates.removeAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +96,7 @@ final class NoiseSessionManager {
|
|||||||
// Remove any existing non-established session
|
// Remove any existing non-established session
|
||||||
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
if let existingSession = sessions[peerID], !existingSession.isEstablished() {
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
existingSession.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new initiator session
|
// Create new initiator session
|
||||||
@@ -91,6 +109,7 @@ final class NoiseSessionManager {
|
|||||||
} catch {
|
} catch {
|
||||||
// Clean up failed session
|
// Clean up failed session
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
session.reset()
|
||||||
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
SecureLogger.error(.handshakeFailed(peerID: peerID.id, error: error.localizedDescription))
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -98,61 +117,116 @@ final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
func handleIncomingHandshake(from peerID: PeerID, message: Data) throws -> Data? {
|
||||||
|
try handleIncomingHandshakeWithResult(
|
||||||
|
from: peerID,
|
||||||
|
message: message
|
||||||
|
).response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Processes one exact handshake candidate and reports whether that
|
||||||
|
/// candidate completed authenticated establishment. The peer's retained
|
||||||
|
/// session may already be established while a replacement is only on
|
||||||
|
/// message one, so callers must not infer candidate completion from the
|
||||||
|
/// peer-level session table.
|
||||||
|
func handleIncomingHandshakeWithResult(
|
||||||
|
from peerID: PeerID,
|
||||||
|
message: Data
|
||||||
|
) throws -> NoiseHandshakeProcessingResult {
|
||||||
// Process everything within the synchronized block to prevent race conditions
|
// Process everything within the synchronized block to prevent race conditions
|
||||||
return try managerQueue.sync(flags: .barrier) {
|
return try managerQueue.sync(flags: .barrier) {
|
||||||
var shouldCreateNew = false
|
let session: NoiseSession
|
||||||
var existingSession: NoiseSession? = nil
|
let isReplacementCandidate: Bool
|
||||||
|
|
||||||
if let existing = sessions[peerID] {
|
if let candidate = responderCandidates[peerID] {
|
||||||
// If we have an established session, the peer must have cleared their session
|
// A fresh XX message 1 supersedes an incomplete candidate,
|
||||||
// for a good reason (e.g., decryption failure, restart, etc.)
|
// but never the established session it is trying to replace.
|
||||||
// We should accept the new handshake to re-establish encryption
|
if message.count == NoiseSecurityConstants.xxInitialMessageSize {
|
||||||
if existing.isEstablished() {
|
candidate.reset()
|
||||||
SecureLogger.info("Accepting handshake from \(peerID) despite existing session - peer likely cleared their session", category: .session)
|
let replacement = sessionFactory(peerID, .responder)
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
responderCandidates[peerID] = replacement
|
||||||
shouldCreateNew = true
|
session = replacement
|
||||||
} else {
|
} else {
|
||||||
// If we're in the middle of a handshake and receive a new initiation,
|
session = candidate
|
||||||
// reset and start fresh (the other side may have restarted)
|
}
|
||||||
if existing.getState() == .handshaking && message.count == 32 {
|
isReplacementCandidate = true
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
} else if let existing = sessions[peerID] {
|
||||||
shouldCreateNew = true
|
if existing.isEstablished() {
|
||||||
} else {
|
SecureLogger.info(
|
||||||
existingSession = existing
|
"Validating replacement handshake from \(peerID) while preserving the established session",
|
||||||
}
|
category: .session
|
||||||
|
)
|
||||||
|
let candidate = sessionFactory(peerID, .responder)
|
||||||
|
responderCandidates[peerID] = candidate
|
||||||
|
session = candidate
|
||||||
|
isReplacementCandidate = true
|
||||||
|
} else if existing.getState() == .handshaking,
|
||||||
|
message.count == NoiseSecurityConstants.xxInitialMessageSize {
|
||||||
|
// No established transport state exists to preserve. A
|
||||||
|
// fresh initiation replaces the incomplete handshake.
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
existing.reset()
|
||||||
|
let replacement = sessionFactory(peerID, .responder)
|
||||||
|
sessions[peerID] = replacement
|
||||||
|
session = replacement
|
||||||
|
isReplacementCandidate = false
|
||||||
|
} else {
|
||||||
|
session = existing
|
||||||
|
isReplacementCandidate = false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
shouldCreateNew = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get or create session
|
|
||||||
let session: NoiseSession
|
|
||||||
if shouldCreateNew {
|
|
||||||
let newSession = sessionFactory(peerID, .responder)
|
let newSession = sessionFactory(peerID, .responder)
|
||||||
sessions[peerID] = newSession
|
sessions[peerID] = newSession
|
||||||
session = newSession
|
session = newSession
|
||||||
} else {
|
isReplacementCandidate = false
|
||||||
session = existingSession!
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the handshake message within the synchronized block
|
// Process the handshake message within the synchronized block
|
||||||
do {
|
do {
|
||||||
let response = try session.processHandshakeMessage(message)
|
let response = try session.processHandshakeMessage(message)
|
||||||
|
|
||||||
// Check if session is established after processing
|
// Check the exact session that processed this message. A
|
||||||
if session.isEstablished() {
|
// preserved peer-level session can remain established while a
|
||||||
if let remoteKey = session.getRemoteStaticPublicKey() {
|
// replacement candidate is still unauthenticated.
|
||||||
// Schedule callback outside the synchronized block to prevent deadlock
|
let didEstablishAuthenticatedSession = session.isEstablished()
|
||||||
DispatchQueue.global().async { [weak self] in
|
if didEstablishAuthenticatedSession {
|
||||||
self?.onSessionEstablished?(peerID, remoteKey)
|
guard let remoteKey = session.getRemoteStaticPublicKey(),
|
||||||
|
authenticatedRemoteKey(remoteKey, matches: peerID) else {
|
||||||
|
throw NoiseSessionError.peerIdentityMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
if isReplacementCandidate {
|
||||||
|
_ = responderCandidates.removeValue(forKey: peerID)
|
||||||
|
let previous = sessions.updateValue(session, forKey: peerID)
|
||||||
|
if let previous, previous !== session {
|
||||||
|
previous.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schedule callback outside the synchronized block to prevent deadlock
|
||||||
|
DispatchQueue.global().async { [weak self] in
|
||||||
|
self?.onSessionEstablished?(peerID, remoteKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response
|
return NoiseHandshakeProcessingResult(
|
||||||
|
response: response,
|
||||||
|
didEstablishAuthenticatedSession:
|
||||||
|
didEstablishAuthenticatedSession
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
// Reset the session on handshake failure so next attempt can start fresh
|
// A failed candidate is discarded without touching the
|
||||||
_ = sessions.removeValue(forKey: peerID)
|
// established session. Ordinary failed handshakes retain the
|
||||||
|
// historical cleanup behavior.
|
||||||
|
if isReplacementCandidate {
|
||||||
|
if let storedCandidate = responderCandidates[peerID],
|
||||||
|
storedCandidate === session {
|
||||||
|
_ = responderCandidates.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
} else if let storedSession = sessions[peerID],
|
||||||
|
storedSession === session {
|
||||||
|
_ = sessions.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
session.reset()
|
||||||
|
|
||||||
// Schedule callback outside the synchronized block to prevent deadlock
|
// Schedule callback outside the synchronized block to prevent deadlock
|
||||||
DispatchQueue.global().async { [weak self] in
|
DispatchQueue.global().async { [weak self] in
|
||||||
@@ -164,6 +238,24 @@ final class NoiseSessionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mesh handshakes normally use a 16-hex wire ID. Full Noise-key IDs are
|
||||||
|
/// also accepted by internal callers when they exactly match the static
|
||||||
|
/// key. Non-wire identifiers remain available to protocol test harnesses;
|
||||||
|
/// BLE packet ingress always supplies a short hexadecimal ID.
|
||||||
|
private func authenticatedRemoteKey(
|
||||||
|
_ remoteKey: Curve25519.KeyAgreement.PublicKey,
|
||||||
|
matches claimedPeerID: PeerID
|
||||||
|
) -> Bool {
|
||||||
|
let rawKey = remoteKey.rawRepresentation
|
||||||
|
if claimedPeerID.isShort {
|
||||||
|
return PeerID(publicKey: rawKey) == claimedPeerID
|
||||||
|
}
|
||||||
|
if let claimedNoiseKey = claimedPeerID.noiseKey {
|
||||||
|
return claimedNoiseKey == rawKey
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Encryption/Decryption
|
// MARK: - Encryption/Decryption
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,23 @@ 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 {
|
||||||
@@ -44,12 +61,16 @@ 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,
|
||||||
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
|
// Runtime refreshes only from bitchat's reviewed copy. Upstream
|
||||||
|
// 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,
|
||||||
@@ -58,7 +79,27 @@ private extension GeoRelayDirectoryDependencies {
|
|||||||
makeFetchData: {
|
makeFetchData: {
|
||||||
let session = TorURLSession.shared.session
|
let session = TorURLSession.shared.session
|
||||||
return { request in
|
return { request in
|
||||||
let (data, _) = try await session.data(for: request)
|
let (bytes, response) = try await session.bytes(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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -76,7 +117,11 @@ 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)
|
||||||
return dir.appendingPathComponent("georelays_cache.csv")
|
// v2 ignores caches populated from the old direct-upstream
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
@@ -94,7 +139,8 @@ 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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,7 +171,7 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private enum DetachedFetchOutcome: Sendable {
|
private enum DetachedFetchOutcome: Sendable {
|
||||||
case success(entries: [Entry], csv: String)
|
case success(entries: [Entry], csv: Data)
|
||||||
case torNotReady
|
case torNotReady
|
||||||
case invalidData
|
case invalidData
|
||||||
case network(String)
|
case network(String)
|
||||||
@@ -212,6 +258,8 @@ 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 }
|
||||||
@@ -219,7 +267,9 @@ 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 {
|
||||||
@@ -238,7 +288,9 @@ 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()
|
||||||
@@ -246,16 +298,16 @@ final class GeoRelayDirectory {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try await fetchData(request)
|
let data = try await fetchData(request)
|
||||||
guard let text = String(data: data, encoding: .utf8) else {
|
guard let parsed = Self.validatedEntries(
|
||||||
|
from: data,
|
||||||
|
policy: validationPolicy,
|
||||||
|
minimumEntries: validationPolicy.minimumRemoteEntries,
|
||||||
|
baselineEntries: baselineEntries
|
||||||
|
) else {
|
||||||
return .invalidData
|
return .invalidData
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsed = Self.parseCSV(text)
|
return .success(entries: parsed, csv: data)
|
||||||
guard !parsed.isEmpty else {
|
|
||||||
return .invalidData
|
|
||||||
}
|
|
||||||
|
|
||||||
return .success(entries: parsed, csv: text)
|
|
||||||
} catch {
|
} catch {
|
||||||
return .network(error.localizedDescription)
|
return .network(error.localizedDescription)
|
||||||
}
|
}
|
||||||
@@ -269,7 +321,7 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
private func handleFetchSuccess(entries parsed: [Entry], csv: Data) {
|
||||||
entries = parsed
|
entries = parsed
|
||||||
persistCache(csv)
|
persistCache(csv)
|
||||||
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
|
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
|
||||||
@@ -321,9 +373,8 @@ final class GeoRelayDirectory {
|
|||||||
cleanupState.retryTask = nil
|
cleanupState.retryTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func persistCache(_ text: String) {
|
private func persistCache(_ data: Data) {
|
||||||
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 {
|
||||||
@@ -336,9 +387,12 @@ 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 text = String(data: data, encoding: .utf8) {
|
let entries = Self.validatedEntries(
|
||||||
let arr = Self.parseCSV(text)
|
from: data,
|
||||||
if !arr.isEmpty { return arr }
|
policy: dependencies.validationPolicy,
|
||||||
|
minimumEntries: 1
|
||||||
|
) {
|
||||||
|
return entries
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try bundled resource(s)
|
// Try bundled resource(s)
|
||||||
@@ -346,36 +400,157 @@ final class GeoRelayDirectory {
|
|||||||
|
|
||||||
for url in bundleCandidates {
|
for url in bundleCandidates {
|
||||||
if let data = dependencies.readData(url),
|
if let data = dependencies.readData(url),
|
||||||
let text = String(data: data, encoding: .utf8) {
|
let entries = Self.validatedEntries(
|
||||||
let arr = Self.parseCSV(text)
|
from: data,
|
||||||
if !arr.isEmpty { return arr }
|
policy: dependencies.validationPolicy,
|
||||||
|
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 text = String(data: data, encoding: .utf8) {
|
let entries = Self.validatedEntries(
|
||||||
return Self.parseCSV(text)
|
from: data,
|
||||||
|
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 []
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
/// Parses the fixed three-column format as an all-or-nothing trust unit.
|
||||||
var result: Set<Entry> = []
|
/// One malformed or conflicting row rejects the complete dataset rather
|
||||||
let lines = text.split(whereSeparator: { $0.isNewline })
|
/// than silently shrinking or partially replacing the current directory.
|
||||||
for (idx, raw) in lines.enumerated() {
|
nonisolated static func validatedEntries(
|
||||||
guard let line = raw.trimmedOrNilIfEmpty else { continue }
|
from data: Data,
|
||||||
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
policy: GeoRelayDirectoryValidationPolicy,
|
||||||
let parts = line.split(separator: ",").map { $0.trimmed }
|
minimumEntries: Int,
|
||||||
guard parts.count >= 3 else { continue }
|
baselineEntries: Set<Entry>? = nil
|
||||||
guard let host = NostrRelayURL.directoryAddress(parts[0]) else { continue }
|
) -> [Entry]? {
|
||||||
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
guard !data.isEmpty, data.count <= policy.maximumBytes,
|
||||||
result.insert(Entry(host: host, lat: lat, lon: lon))
|
let text = String(data: data, encoding: .utf8),
|
||||||
|
!text.hasPrefix("\u{feff}") else {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return Array(result)
|
|
||||||
|
let lines = text.split(whereSeparator: { $0.isNewline })
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
guard let header = lines.first,
|
||||||
|
lines.count - 1 <= policy.maximumRows else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -39,13 +39,4 @@ 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct BLEAnnounceThrottle {
|
/// Thread-safe announce admission state.
|
||||||
|
///
|
||||||
|
/// 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
|
||||||
@@ -16,16 +23,18 @@ struct BLEAnnounceThrottle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func elapsed(since now: Date) -> TimeInterval {
|
func elapsed(since now: Date) -> TimeInterval {
|
||||||
now.timeIntervalSince(lastSent)
|
lock.withLock { now.timeIntervalSince(lastSent) }
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func shouldSend(force: Bool, now: Date) -> Bool {
|
func shouldSend(force: Bool, now: Date) -> Bool {
|
||||||
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
|
lock.withLock {
|
||||||
guard elapsed(since: now) >= minimumInterval else {
|
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
|
||||||
return false
|
guard now.timeIntervalSince(lastSent) >= minimumInterval else {
|
||||||
}
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
lastSent = now
|
lastSent = now
|
||||||
return true
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,124 @@ import BitLogger
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
struct PanicRecoveryIntent {
|
||||||
|
let fileMarkerEstablished: Bool
|
||||||
|
let externalMarkerEstablished: Bool
|
||||||
|
|
||||||
|
var hasDurableMarker: Bool {
|
||||||
|
fileMarkerEstablished || externalMarkerEstablished
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Small, dependency-injectable transaction surface used by ChatViewModel.
|
||||||
|
/// Production persists the same intent in two independent locations before
|
||||||
|
/// any application state is erased. Tests can inject an ephemeral operation
|
||||||
|
/// set without touching the developer's Application Support directory.
|
||||||
|
struct PanicRecoveryOperations {
|
||||||
|
let isPending: () throws -> Bool
|
||||||
|
let begin: () -> PanicRecoveryIntent
|
||||||
|
let wipeMedia: (PanicRecoveryIntent) throws -> Void
|
||||||
|
let complete: () throws -> Void
|
||||||
|
|
||||||
|
static func ephemeral(
|
||||||
|
wipeMedia: @escaping () throws -> Void = {}
|
||||||
|
) -> PanicRecoveryOperations {
|
||||||
|
PanicRecoveryOperations(
|
||||||
|
isPending: { false },
|
||||||
|
begin: {
|
||||||
|
PanicRecoveryIntent(
|
||||||
|
fileMarkerEstablished: false,
|
||||||
|
externalMarkerEstablished: false
|
||||||
|
)
|
||||||
|
},
|
||||||
|
wipeMedia: { _ in try wipeMedia() },
|
||||||
|
complete: {}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func live(
|
||||||
|
fileStore: BLEIncomingFileStore = BLEIncomingFileStore(),
|
||||||
|
defaults: UserDefaults = .standard
|
||||||
|
) -> PanicRecoveryOperations {
|
||||||
|
let defaultsKey = "bitchat.panicResetPending"
|
||||||
|
return PanicRecoveryOperations(
|
||||||
|
isPending: {
|
||||||
|
if defaults.bool(forKey: defaultsKey) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return try fileStore.isPanicRecoveryPending()
|
||||||
|
},
|
||||||
|
begin: {
|
||||||
|
defaults.set(true, forKey: defaultsKey)
|
||||||
|
let externalMarkerEstablished =
|
||||||
|
defaults.synchronize()
|
||||||
|
&& defaults.bool(forKey: defaultsKey)
|
||||||
|
|
||||||
|
let fileMarkerEstablished: Bool
|
||||||
|
do {
|
||||||
|
try fileStore.markPanicRecoveryPending()
|
||||||
|
fileMarkerEstablished = true
|
||||||
|
} catch {
|
||||||
|
fileMarkerEstablished = false
|
||||||
|
SecureLogger.error(
|
||||||
|
"Failed to persist file panic-recovery marker: \(error)",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return PanicRecoveryIntent(
|
||||||
|
fileMarkerEstablished: fileMarkerEstablished,
|
||||||
|
externalMarkerEstablished: externalMarkerEstablished
|
||||||
|
)
|
||||||
|
},
|
||||||
|
wipeMedia: { intent in
|
||||||
|
try fileStore.panicWipe(
|
||||||
|
hasDurablePendingMarker: intent.hasDurableMarker
|
||||||
|
)
|
||||||
|
},
|
||||||
|
complete: {
|
||||||
|
// Keep the independent defaults latch until the file marker
|
||||||
|
// has definitely cleared. Any failure therefore remains
|
||||||
|
// visible to the next launch.
|
||||||
|
try fileStore.completePanicRecovery()
|
||||||
|
defaults.removeObject(forKey: defaultsKey)
|
||||||
|
guard defaults.synchronize(),
|
||||||
|
!defaults.bool(forKey: defaultsKey) else {
|
||||||
|
throw BLEIncomingFileStore.PanicRecoveryError
|
||||||
|
.externalMarkerCommitFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct BLEIncomingFileStore {
|
struct BLEIncomingFileStore {
|
||||||
|
enum PanicRecoveryError: Error {
|
||||||
|
case externalMarkerCommitFailed
|
||||||
|
case markerWriteFailed(Error)
|
||||||
|
case markerWriteAndMediaWipeFailed(
|
||||||
|
markerError: Error,
|
||||||
|
mediaError: Error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
||||||
|
/// Kept outside `files/` so deleting the media tree cannot erase the
|
||||||
|
/// fail-closed startup decision before the full panic has committed.
|
||||||
|
private static let panicRecoveryPendingMarkerFileName =
|
||||||
|
".panic-recovery-pending"
|
||||||
|
/// Compatibility with a short-lived development build that used the
|
||||||
|
/// media-specific name for the same full-transaction latch.
|
||||||
|
private static let legacyPanicRecoveryPendingMarkerFileName =
|
||||||
|
".panic-media-wipe-pending"
|
||||||
|
private static let mediaSubdirectories = [
|
||||||
|
"voicenotes/incoming",
|
||||||
|
"voicenotes/outgoing",
|
||||||
|
"images/incoming",
|
||||||
|
"images/outgoing",
|
||||||
|
"files/incoming",
|
||||||
|
"files/outgoing"
|
||||||
|
]
|
||||||
|
|
||||||
/// Name prefix of in-flight live voice captures (progressively written by
|
/// Name prefix of in-flight live voice captures (progressively written by
|
||||||
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
||||||
@@ -17,11 +133,96 @@ struct BLEIncomingFileStore {
|
|||||||
let fileManager: FileManager
|
let fileManager: FileManager
|
||||||
private let baseDirectory: URL?
|
private let baseDirectory: URL?
|
||||||
private let dateProvider: () -> Date
|
private let dateProvider: () -> Date
|
||||||
|
private let panicMarkerWriter: (Data, URL) throws -> Void
|
||||||
|
|
||||||
init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) {
|
init(
|
||||||
|
fileManager: FileManager = .default,
|
||||||
|
baseDirectory: URL? = nil,
|
||||||
|
dateProvider: @escaping () -> Date = Date.init,
|
||||||
|
panicMarkerWriter: @escaping (Data, URL) throws -> Void = {
|
||||||
|
try $0.write(to: $1, options: .atomic)
|
||||||
|
}
|
||||||
|
) {
|
||||||
self.fileManager = fileManager
|
self.fileManager = fileManager
|
||||||
self.baseDirectory = baseDirectory
|
self.baseDirectory = baseDirectory
|
||||||
self.dateProvider = dateProvider
|
self.dateProvider = dateProvider
|
||||||
|
self.panicMarkerWriter = panicMarkerWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Panic-wipe every managed incoming and outgoing media artifact before
|
||||||
|
/// returning. Recreating the directory tree keeps later capture/receive
|
||||||
|
/// paths usable without allowing a detached cleanup task to race them.
|
||||||
|
///
|
||||||
|
/// Marker persistence and deletion are deliberately separate error
|
||||||
|
/// domains: even when both durable marker channels fail, deletion is
|
||||||
|
/// still attempted before this method reports the marker failure.
|
||||||
|
func panicWipe(
|
||||||
|
hasDurablePendingMarker: Bool = false
|
||||||
|
) throws {
|
||||||
|
let markerError: Error?
|
||||||
|
do {
|
||||||
|
try markPanicRecoveryPending()
|
||||||
|
markerError = nil
|
||||||
|
} catch {
|
||||||
|
markerError = error
|
||||||
|
SecureLogger.error(
|
||||||
|
"Could not persist file panic-recovery marker; attempting media deletion anyway: \(error)",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let filesDirectory = try rootDirectory()
|
||||||
|
.appendingPathComponent("files", isDirectory: true)
|
||||||
|
if fileManager.fileExists(atPath: filesDirectory.path) {
|
||||||
|
try fileManager.removeItem(at: filesDirectory)
|
||||||
|
}
|
||||||
|
for subdirectory in Self.mediaSubdirectories {
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: filesDirectory.appendingPathComponent(
|
||||||
|
subdirectory,
|
||||||
|
isDirectory: true
|
||||||
|
),
|
||||||
|
withIntermediateDirectories: true,
|
||||||
|
attributes: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if let markerError {
|
||||||
|
throw PanicRecoveryError.markerWriteAndMediaWipeFailed(
|
||||||
|
markerError: markerError,
|
||||||
|
mediaError: error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if let markerError, !hasDurablePendingMarker {
|
||||||
|
throw PanicRecoveryError.markerWriteFailed(markerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func markPanicRecoveryPending() throws {
|
||||||
|
let markerURL = try panicRecoveryPendingMarkerURL()
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: markerURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true,
|
||||||
|
attributes: nil
|
||||||
|
)
|
||||||
|
try panicMarkerWriter(Data([1]), markerURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPanicRecoveryPending() throws -> Bool {
|
||||||
|
try panicRecoveryMarkerURLs().contains {
|
||||||
|
fileManager.fileExists(atPath: $0.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func completePanicRecovery() throws {
|
||||||
|
for markerURL in try panicRecoveryMarkerURLs()
|
||||||
|
where fileManager.fileExists(atPath: markerURL.path) {
|
||||||
|
try fileManager.removeItem(at: markerURL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves (and creates) an incoming-media directory for callers that
|
/// Resolves (and creates) an incoming-media directory for callers that
|
||||||
@@ -113,15 +314,39 @@ struct BLEIncomingFileStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func filesDirectory() throws -> URL {
|
private func filesDirectory() throws -> URL {
|
||||||
let root = try baseDirectory ?? fileManager.url(
|
let filesDir = try rootDirectory().appendingPathComponent("files", isDirectory: true)
|
||||||
|
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
return filesDir
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rootDirectory() throws -> URL {
|
||||||
|
try baseDirectory ?? fileManager.url(
|
||||||
for: .applicationSupportDirectory,
|
for: .applicationSupportDirectory,
|
||||||
in: .userDomainMask,
|
in: .userDomainMask,
|
||||||
appropriateFor: nil,
|
appropriateFor: nil,
|
||||||
create: true
|
create: true
|
||||||
)
|
)
|
||||||
let filesDir = root.appendingPathComponent("files", isDirectory: true)
|
}
|
||||||
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
|
||||||
return filesDir
|
private func panicRecoveryPendingMarkerURL() throws -> URL {
|
||||||
|
try rootDirectory().appendingPathComponent(
|
||||||
|
Self.panicRecoveryPendingMarkerFileName,
|
||||||
|
isDirectory: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func panicRecoveryMarkerURLs() throws -> [URL] {
|
||||||
|
let root = try rootDirectory()
|
||||||
|
return [
|
||||||
|
root.appendingPathComponent(
|
||||||
|
Self.panicRecoveryPendingMarkerFileName,
|
||||||
|
isDirectory: false
|
||||||
|
),
|
||||||
|
root.appendingPathComponent(
|
||||||
|
Self.legacyPanicRecoveryPendingMarkerFileName,
|
||||||
|
isDirectory: false
|
||||||
|
)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLELocalIdentitySnapshot: Equatable, Sendable {
|
||||||
|
let peerID: PeerID
|
||||||
|
let peerIDData: Data
|
||||||
|
let nickname: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock-backed local identity state shared by the transport's message,
|
||||||
|
/// Bluetooth, maintenance, and main-actor entry points.
|
||||||
|
///
|
||||||
|
/// `peerID` and its binary wire representation must change as one unit during
|
||||||
|
/// panic rotation. A snapshot also gives announce construction one consistent
|
||||||
|
/// view of the nickname and identity instead of reading three independently
|
||||||
|
/// mutable properties across queues.
|
||||||
|
final class BLELocalIdentityStateStore: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var state: BLELocalIdentitySnapshot
|
||||||
|
|
||||||
|
init(
|
||||||
|
peerID: PeerID = PeerID(str: ""),
|
||||||
|
nickname: String = "anon"
|
||||||
|
) {
|
||||||
|
state = BLELocalIdentitySnapshot(
|
||||||
|
peerID: peerID,
|
||||||
|
peerIDData: Data(hexString: peerID.id) ?? Data(),
|
||||||
|
nickname: nickname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot() -> BLELocalIdentitySnapshot {
|
||||||
|
lock.withLock { state }
|
||||||
|
}
|
||||||
|
|
||||||
|
func setNickname(_ nickname: String) {
|
||||||
|
lock.withLock {
|
||||||
|
state = BLELocalIdentitySnapshot(
|
||||||
|
peerID: state.peerID,
|
||||||
|
peerIDData: state.peerIDData,
|
||||||
|
nickname: nickname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func replacePeerIdentity(with peerID: PeerID) {
|
||||||
|
lock.withLock {
|
||||||
|
state = BLELocalIdentitySnapshot(
|
||||||
|
peerID: peerID,
|
||||||
|
peerIDData: Data(hexString: peerID.id) ?? Data(),
|
||||||
|
nickname: state.nickname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,11 @@ import BitFoundation
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
struct BLENoiseHandshakeHandlingResult {
|
||||||
|
let processed: Bool
|
||||||
|
let didEstablishAuthenticatedSession: Bool
|
||||||
|
}
|
||||||
|
|
||||||
/// Narrow environment for `BLENoisePacketHandler`.
|
/// Narrow environment for `BLENoisePacketHandler`.
|
||||||
///
|
///
|
||||||
/// All queue hops (collections barrier writes, main-actor UI notification)
|
/// All queue hops (collections barrier writes, main-actor UI notification)
|
||||||
@@ -16,8 +21,11 @@ struct BLENoisePacketHandlerEnvironment {
|
|||||||
let messageTTL: UInt8
|
let messageTTL: UInt8
|
||||||
/// Current time source.
|
/// Current time source.
|
||||||
let now: () -> Date
|
let now: () -> Date
|
||||||
/// Processes an inbound handshake message, returning an optional response payload (crypto).
|
/// Processes an inbound handshake message, returning its optional response
|
||||||
let processHandshakeMessage: (_ peerID: PeerID, _ message: Data) throws -> Data?
|
/// and whether that exact candidate authenticated (crypto).
|
||||||
|
let processHandshakeMessage:
|
||||||
|
(_ peerID: PeerID, _ message: Data) throws
|
||||||
|
-> NoiseHandshakeProcessingResult
|
||||||
/// Whether any Noise session (established or pending) exists for the peer (crypto).
|
/// Whether any Noise session (established or pending) exists for the peer (crypto).
|
||||||
let hasNoiseSession: (PeerID) -> Bool
|
let hasNoiseSession: (PeerID) -> Bool
|
||||||
/// Initiates a fresh Noise handshake with the peer (crypto + send).
|
/// Initiates a fresh Noise handshake with the peer (crypto + send).
|
||||||
@@ -49,13 +57,28 @@ final class BLENoisePacketHandler {
|
|||||||
self.environment = environment
|
self.environment = environment
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
/// Returns true when the handshake message was processed successfully.
|
||||||
|
/// Callers use this to distinguish an authenticated replacement completion
|
||||||
|
/// from a rejected candidate while an older session remains established.
|
||||||
|
@discardableResult
|
||||||
|
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||||
|
handleHandshakeWithResult(packet, from: peerID).processed
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleHandshakeWithResult(
|
||||||
|
_ packet: BitchatPacket,
|
||||||
|
from peerID: PeerID
|
||||||
|
) -> BLENoiseHandshakeHandlingResult {
|
||||||
let env = environment
|
let env = environment
|
||||||
// Use NoiseEncryptionService for handshake processing
|
// Use NoiseEncryptionService for handshake processing
|
||||||
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
|
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
|
||||||
// Handshake is for us
|
// Handshake is for us
|
||||||
do {
|
do {
|
||||||
if let response = try env.processHandshakeMessage(peerID, packet.payload) {
|
let result = try env.processHandshakeMessage(
|
||||||
|
peerID,
|
||||||
|
packet.payload
|
||||||
|
)
|
||||||
|
if let response = result.response {
|
||||||
// Send response
|
// Send response
|
||||||
let responsePacket = BitchatPacket(
|
let responsePacket = BitchatPacket(
|
||||||
type: MessageType.noiseHandshake.rawValue,
|
type: MessageType.noiseHandshake.rawValue,
|
||||||
@@ -72,14 +95,39 @@ final class BLENoisePacketHandler {
|
|||||||
|
|
||||||
// Session establishment will trigger onPeerAuthenticated callback
|
// Session establishment will trigger onPeerAuthenticated callback
|
||||||
// which will send any pending messages at the right time
|
// which will send any pending messages at the right time
|
||||||
|
return BLENoiseHandshakeHandlingResult(
|
||||||
|
processed: true,
|
||||||
|
didEstablishAuthenticatedSession:
|
||||||
|
result.didEstablishAuthenticatedSession
|
||||||
|
)
|
||||||
|
} catch NoiseSessionError.peerIdentityMismatch {
|
||||||
|
// The candidate was already discarded by the session manager.
|
||||||
|
// Do not let a spoofed claimed ID trigger a fresh outbound
|
||||||
|
// handshake or recreate state for the attacker-selected ID.
|
||||||
|
SecureLogger.warning(
|
||||||
|
"Rejected Noise handshake whose static key does not match \(peerID.id.prefix(8))…",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
return BLENoiseHandshakeHandlingResult(
|
||||||
|
processed: false,
|
||||||
|
didEstablishAuthenticatedSession: false
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Failed to process handshake: \(error)")
|
SecureLogger.error("Failed to process handshake: \(error)")
|
||||||
// Try initiating a new handshake
|
// Try initiating a new handshake
|
||||||
if !env.hasNoiseSession(peerID) {
|
if !env.hasNoiseSession(peerID) {
|
||||||
env.initiateHandshake(peerID)
|
env.initiateHandshake(peerID)
|
||||||
}
|
}
|
||||||
|
return BLENoiseHandshakeHandlingResult(
|
||||||
|
processed: false,
|
||||||
|
didEstablishAuthenticatedSession: false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return BLENoiseHandshakeHandlingResult(
|
||||||
|
processed: false,
|
||||||
|
didEstablishAuthenticatedSession: false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ final class BLEService: NSObject {
|
|||||||
// Test-only tap on the outbound pipeline so multi-node tests can ferry
|
// Test-only tap on the outbound pipeline so multi-node tests can ferry
|
||||||
// packets between in-process service instances.
|
// packets between in-process service instances.
|
||||||
var _test_onOutboundPacket: ((BitchatPacket) -> Void)?
|
var _test_onOutboundPacket: ((BitchatPacket) -> Void)?
|
||||||
|
/// May block a synthetic CoreBluetooth receive callback immediately
|
||||||
|
/// before it hands a packet to `messageQueue`.
|
||||||
|
var _test_beforeReceivePacketHandoff: (() -> Void)?
|
||||||
|
var _test_onReceivePacketHandoff: (() -> Void)?
|
||||||
#endif
|
#endif
|
||||||
private var selfBroadcastTracker = BLESelfBroadcastTracker()
|
private var selfBroadcastTracker = BLESelfBroadcastTracker()
|
||||||
private let meshTopology = MeshTopologyTracker()
|
private let meshTopology = MeshTopologyTracker()
|
||||||
@@ -119,6 +123,7 @@ final class BLEService: NSObject {
|
|||||||
private struct PendingMeshPing {
|
private struct PendingMeshPing {
|
||||||
let peerID: PeerID
|
let peerID: PeerID
|
||||||
let sentAt: Date
|
let sentAt: Date
|
||||||
|
let lifecycleGeneration: UInt64
|
||||||
let completion: @MainActor (MeshPingResult?) -> Void
|
let completion: @MainActor (MeshPingResult?) -> Void
|
||||||
let timeout: DispatchWorkItem
|
let timeout: DispatchWorkItem
|
||||||
}
|
}
|
||||||
@@ -134,7 +139,7 @@ final class BLEService: NSObject {
|
|||||||
private let incomingFileStore = BLEIncomingFileStore()
|
private let incomingFileStore = BLEIncomingFileStore()
|
||||||
|
|
||||||
// Simple announce throttling
|
// Simple announce throttling
|
||||||
private var announceThrottle = BLEAnnounceThrottle()
|
private let announceThrottle = BLEAnnounceThrottle()
|
||||||
|
|
||||||
// Application state tracking (thread-safe)
|
// Application state tracking (thread-safe)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -155,6 +160,10 @@ final class BLEService: NSObject {
|
|||||||
private var centralManager: CBCentralManager?
|
private var centralManager: CBCentralManager?
|
||||||
private var peripheralManager: CBPeripheralManager?
|
private var peripheralManager: CBPeripheralManager?
|
||||||
private var characteristic: CBMutableCharacteristic?
|
private var characteristic: CBMutableCharacteristic?
|
||||||
|
private let shouldInitializeBluetoothManagers: Bool
|
||||||
|
private let panicLifecycleLock = NSLock()
|
||||||
|
private var _isPanicSuspended: Bool
|
||||||
|
private var panicLifecycleGeneration: UInt64 = 0
|
||||||
|
|
||||||
// MARK: - Identity
|
// MARK: - Identity
|
||||||
|
|
||||||
@@ -162,9 +171,7 @@ final class BLEService: NSObject {
|
|||||||
private let identityManager: SecureIdentityStateManagerProtocol
|
private let identityManager: SecureIdentityStateManagerProtocol
|
||||||
private let keychain: KeychainManagerProtocol
|
private let keychain: KeychainManagerProtocol
|
||||||
private let idBridge: NostrIdentityBridge
|
private let idBridge: NostrIdentityBridge
|
||||||
/// Binary form of `myPeerID`; same contract — mutated only inside a
|
private let localIdentityState = BLELocalIdentityStateStore()
|
||||||
/// `messageQueue` barrier via `refreshPeerIdentity()`.
|
|
||||||
private var myPeerIDData: Data = Data()
|
|
||||||
|
|
||||||
// MARK: - Advertising Privacy
|
// MARK: - Advertising Privacy
|
||||||
// No Local Name by default for maximum privacy. No rotating alias.
|
// No Local Name by default for maximum privacy. No rotating alias.
|
||||||
@@ -275,10 +282,13 @@ final class BLEService: NSObject {
|
|||||||
keychain: KeychainManagerProtocol,
|
keychain: KeychainManagerProtocol,
|
||||||
idBridge: NostrIdentityBridge,
|
idBridge: NostrIdentityBridge,
|
||||||
identityManager: SecureIdentityStateManagerProtocol,
|
identityManager: SecureIdentityStateManagerProtocol,
|
||||||
initializeBluetoothManagers: Bool = true
|
initializeBluetoothManagers: Bool = true,
|
||||||
|
startSuspendedForPanicRecovery: Bool = false
|
||||||
) {
|
) {
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
|
self.shouldInitializeBluetoothManagers = initializeBluetoothManagers
|
||||||
|
self._isPanicSuspended = startSuspendedForPanicRecovery
|
||||||
noiseService = NoiseEncryptionService(keychain: keychain)
|
noiseService = NoiseEncryptionService(keychain: keychain)
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
super.init()
|
super.init()
|
||||||
@@ -327,37 +337,90 @@ final class BLEService: NSObject {
|
|||||||
// any access from another queue (cross-queue reads use readLinkState).
|
// any access from another queue (cross-queue reads use readLinkState).
|
||||||
linkStateStore.assumeOwnership(of: bleQueue)
|
linkStateStore.assumeOwnership(of: bleQueue)
|
||||||
|
|
||||||
if initializeBluetoothManagers {
|
if !startSuspendedForPanicRecovery {
|
||||||
// Initialize BLE on background queue to prevent main thread blocking.
|
initializeBluetoothManagersIfNeeded()
|
||||||
#if os(iOS)
|
|
||||||
let centralOptions: [String: Any] = [
|
|
||||||
CBCentralManagerOptionRestoreIdentifierKey: BLEService.centralRestorationID
|
|
||||||
]
|
|
||||||
centralManager = CBCentralManager(delegate: self, queue: bleQueue, options: centralOptions)
|
|
||||||
|
|
||||||
let peripheralOptions: [String: Any] = [
|
|
||||||
CBPeripheralManagerOptionRestoreIdentifierKey: BLEService.peripheralRestorationID
|
|
||||||
]
|
|
||||||
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue, options: peripheralOptions)
|
|
||||||
#else
|
|
||||||
centralManager = CBCentralManager(delegate: self, queue: bleQueue)
|
|
||||||
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue)
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single maintenance timer for all periodic tasks (dispatch-based for
|
// Single maintenance timer for all periodic tasks (dispatch-based for
|
||||||
// determinism). Only run it when real Bluetooth managers exist.
|
// determinism). Only run it when real Bluetooth managers exist.
|
||||||
meshBackgroundEnabled = initializeBluetoothManagers
|
meshBackgroundEnabled = initializeBluetoothManagers
|
||||||
startMaintenanceTimer()
|
if !startSuspendedForPanicRecovery {
|
||||||
|
startMaintenanceTimer()
|
||||||
|
}
|
||||||
|
|
||||||
// Publish initial empty state
|
// Publish initial empty state
|
||||||
requestPeerDataPublish()
|
requestPeerDataPublish()
|
||||||
|
|
||||||
// Initialize gossip sync manager
|
// Initialize gossip sync manager
|
||||||
restartGossipManager()
|
if !startSuspendedForPanicRecovery {
|
||||||
|
restartGossipManager()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isPanicSuspended: Bool {
|
||||||
|
panicLifecycleLock.lock()
|
||||||
|
defer { panicLifecycleLock.unlock() }
|
||||||
|
return _isPanicSuspended
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setPanicSuspended(_ suspended: Bool) {
|
||||||
|
panicLifecycleLock.lock()
|
||||||
|
if suspended {
|
||||||
|
panicLifecycleGeneration &+= 1
|
||||||
|
}
|
||||||
|
_isPanicSuspended = suspended
|
||||||
|
panicLifecycleLock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func capturePanicLifecycleGeneration() -> UInt64? {
|
||||||
|
panicLifecycleLock.lock()
|
||||||
|
defer { panicLifecycleLock.unlock() }
|
||||||
|
return _isPanicSuspended ? nil : panicLifecycleGeneration
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isCurrentPanicLifecycleGeneration(_ generation: UInt64) -> Bool {
|
||||||
|
panicLifecycleLock.lock()
|
||||||
|
defer { panicLifecycleLock.unlock() }
|
||||||
|
return !_isPanicSuspended && panicLifecycleGeneration == generation
|
||||||
|
}
|
||||||
|
|
||||||
|
private func initializeBluetoothManagersIfNeeded() {
|
||||||
|
guard shouldInitializeBluetoothManagers,
|
||||||
|
centralManager == nil,
|
||||||
|
peripheralManager == nil,
|
||||||
|
!isPanicSuspended else { return }
|
||||||
|
|
||||||
|
// Initialize BLE on its dedicated delegate queue. On iOS, retain the
|
||||||
|
// restoration identifiers even when construction was deferred by a
|
||||||
|
// pending panic-recovery latch.
|
||||||
|
#if os(iOS)
|
||||||
|
let centralOptions: [String: Any] = [
|
||||||
|
CBCentralManagerOptionRestoreIdentifierKey:
|
||||||
|
BLEService.centralRestorationID
|
||||||
|
]
|
||||||
|
centralManager = CBCentralManager(
|
||||||
|
delegate: self,
|
||||||
|
queue: bleQueue,
|
||||||
|
options: centralOptions
|
||||||
|
)
|
||||||
|
|
||||||
|
let peripheralOptions: [String: Any] = [
|
||||||
|
CBPeripheralManagerOptionRestoreIdentifierKey:
|
||||||
|
BLEService.peripheralRestorationID
|
||||||
|
]
|
||||||
|
peripheralManager = CBPeripheralManager(
|
||||||
|
delegate: self,
|
||||||
|
queue: bleQueue,
|
||||||
|
options: peripheralOptions
|
||||||
|
)
|
||||||
|
#else
|
||||||
|
centralManager = CBCentralManager(delegate: self, queue: bleQueue)
|
||||||
|
peripheralManager = CBPeripheralManager(delegate: self, queue: bleQueue)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func restartGossipManager() {
|
private func restartGossipManager() {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
// Stop existing
|
// Stop existing
|
||||||
gossipSyncManager?.stop()
|
gossipSyncManager?.stop()
|
||||||
|
|
||||||
@@ -416,8 +479,42 @@ final class BLEService: NSObject {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
func resetIdentityForPanic(currentNickname: String) {
|
/// Close radio admission before application state starts disappearing.
|
||||||
messageQueue.sync(flags: .barrier) {
|
/// CoreBluetooth callbacks consult the same gate and cannot restart scan
|
||||||
|
/// or advertising while the full panic transaction is incomplete.
|
||||||
|
func suspendForPanicReset() {
|
||||||
|
setPanicSuspended(true)
|
||||||
|
gossipSyncManager?.stop()
|
||||||
|
gossipSyncManager = nil
|
||||||
|
// Stop the radio and drain CoreBluetooth's delegate queue first. A
|
||||||
|
// callback may already have passed its initial suspension check; the
|
||||||
|
// bleQueue drain forces its final messageQueue handoff to happen
|
||||||
|
// before the receive barrier below.
|
||||||
|
stopServicesImmediatelyForPanic()
|
||||||
|
// Drain every receive/send submitted by callbacks that finished ahead
|
||||||
|
// of the radio stop. Later callbacks observe the closed lifecycle, and
|
||||||
|
// generation-bound handoffs that raced this barrier reject themselves.
|
||||||
|
messageQueue.sync(flags: .barrier) {}
|
||||||
|
clearEmergencySessionState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reopen the radio only after media deletion and recovery-marker commit.
|
||||||
|
func completePanicReset(restartServices: Bool) {
|
||||||
|
setPanicSuspended(false)
|
||||||
|
guard restartServices else { return }
|
||||||
|
startServices()
|
||||||
|
sendAnnounce(forceSend: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetIdentityForPanic(
|
||||||
|
currentNickname: String,
|
||||||
|
restartServices: Bool = true
|
||||||
|
) {
|
||||||
|
gossipSyncManager?.stop()
|
||||||
|
gossipSyncManager = nil
|
||||||
|
// pendingNoiseSessionQueues is owned by collectionsQueue everywhere
|
||||||
|
// else, so clear it there too rather than on messageQueue.
|
||||||
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
pendingNoiseSessionQueues.removeAll()
|
pendingNoiseSessionQueues.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,16 +557,21 @@ final class BLEService: NSObject {
|
|||||||
configureNoiseServiceCallbacks(for: newNoise)
|
configureNoiseServiceCallbacks(for: newNoise)
|
||||||
refreshPeerIdentity()
|
refreshPeerIdentity()
|
||||||
}
|
}
|
||||||
restartGossipManager()
|
// Keep the transport silent until the application-level transaction
|
||||||
|
// has also removed its media and committed both recovery markers.
|
||||||
setNickname(currentNickname)
|
// Set through the identity store directly (not setNickname(_:), which
|
||||||
|
// would force-send an announce and break that silence).
|
||||||
|
localIdentityState.setNickname(currentNickname)
|
||||||
messageDeduplicator.reset()
|
messageDeduplicator.reset()
|
||||||
messageQueue.async(flags: .barrier) { [weak self] in
|
messageQueue.async(flags: .barrier) { [weak self] in
|
||||||
self?.selfBroadcastTracker.removeAll()
|
self?.selfBroadcastTracker.removeAll()
|
||||||
}
|
}
|
||||||
requestPeerDataPublish()
|
requestPeerDataPublish()
|
||||||
startServices()
|
if restartServices {
|
||||||
|
restartGossipManager()
|
||||||
|
startServices()
|
||||||
|
sendAnnounce(forceSend: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure this runs on message queue to avoid main thread blocking
|
// Ensure this runs on message queue to avoid main thread blocking
|
||||||
@@ -481,6 +583,7 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
|
|
||||||
guard content.count <= maxMessageLength else {
|
guard content.count <= maxMessageLength else {
|
||||||
SecureLogger.error("Message too long: \(content.count) chars", category: .session)
|
SecureLogger.error("Message too long: \(content.count) chars", category: .session)
|
||||||
@@ -537,20 +640,17 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// MARK: Identity
|
// MARK: Identity
|
||||||
|
|
||||||
/// Derived from the Noise identity fingerprint; rotated only via
|
/// Derived from the Noise identity fingerprint. Reads can originate from
|
||||||
/// `refreshPeerIdentity()` (e.g. panic reset), which performs the swap
|
/// the main actor, message queue, Bluetooth queue, and maintenance timer,
|
||||||
/// inside a `messageQueue` barrier so concurrent queue work never sees a
|
/// so all three local identity fields live in one lock-backed snapshot.
|
||||||
/// half-updated identity. Externally read-only — no out-of-band mutation
|
var myPeerID: PeerID { localIdentityState.snapshot().peerID }
|
||||||
/// may bypass that derivation.
|
var myNickname: String { localIdentityState.snapshot().nickname }
|
||||||
private(set) var myPeerID = PeerID(str: "")
|
private var myPeerIDData: Data { localIdentityState.snapshot().peerIDData }
|
||||||
/// Externally read-only; mutate via `setNickname(_:)`, which also
|
|
||||||
/// broadcasts the change to peers.
|
|
||||||
private(set) var myNickname: String = "anon"
|
|
||||||
|
|
||||||
/// Sole mutator for `myNickname`: updates the stored value and force-sends
|
/// Sole mutator for `myNickname`: updates the stored value and force-sends
|
||||||
/// an announce so peers learn the new name.
|
/// an announce so peers learn the new name.
|
||||||
func setNickname(_ nickname: String) {
|
func setNickname(_ nickname: String) {
|
||||||
self.myNickname = nickname
|
localIdentityState.setNickname(nickname)
|
||||||
// Send announce to notify peers of nickname change (force send)
|
// Send announce to notify peers of nickname change (force send)
|
||||||
sendAnnounce(forceSend: true)
|
sendAnnounce(forceSend: true)
|
||||||
}
|
}
|
||||||
@@ -562,7 +662,9 @@ final class BLEService: NSObject {
|
|||||||
/// `startServices()` — the latter matters after a panic reset, where
|
/// `startServices()` — the latter matters after a panic reset, where
|
||||||
/// `stopServices()` cancels and nils the timer.
|
/// `stopServices()` cancels and nils the timer.
|
||||||
private func startMaintenanceTimer() {
|
private func startMaintenanceTimer() {
|
||||||
guard meshBackgroundEnabled, maintenanceTimer == nil else { return }
|
guard !isPanicSuspended,
|
||||||
|
meshBackgroundEnabled,
|
||||||
|
maintenanceTimer == nil else { return }
|
||||||
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
|
let timer = DispatchSource.makeTimerSource(queue: bleQueue)
|
||||||
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
|
timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
|
||||||
repeating: TransportConfig.bleMaintenanceInterval,
|
repeating: TransportConfig.bleMaintenanceInterval,
|
||||||
@@ -575,6 +677,12 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startServices() {
|
func startServices() {
|
||||||
|
guard let lifecycleGeneration =
|
||||||
|
capturePanicLifecycleGeneration() else { return }
|
||||||
|
initializeBluetoothManagersIfNeeded()
|
||||||
|
if gossipSyncManager == nil {
|
||||||
|
restartGossipManager()
|
||||||
|
}
|
||||||
// Restart the maintenance timer if a prior stopServices() cancelled it
|
// Restart the maintenance timer if a prior stopServices() cancelled it
|
||||||
// (e.g. the panic flow), otherwise periodic announces, peer reconciliation
|
// (e.g. the panic flow), otherwise periodic announces, peer reconciliation
|
||||||
// and cache cleanup would never resume until app restart.
|
// and cache cleanup would never resume until app restart.
|
||||||
@@ -591,15 +699,20 @@ final class BLEService: NSObject {
|
|||||||
// Send initial announce after services are ready
|
// Send initial announce after services are ready
|
||||||
// Use longer delay to avoid conflicts with other announces
|
// Use longer delay to avoid conflicts with other announces
|
||||||
messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in
|
messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in
|
||||||
self?.sendAnnounce(forceSend: true)
|
guard let self,
|
||||||
|
self.isCurrentPanicLifecycleGeneration(
|
||||||
|
lifecycleGeneration
|
||||||
|
) else { return }
|
||||||
|
self.sendAnnounce(forceSend: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopServices() {
|
func stopServices() {
|
||||||
|
let localIdentity = localIdentityState.snapshot()
|
||||||
// Send leave message synchronously to ensure delivery
|
// Send leave message synchronously to ensure delivery
|
||||||
var leavePacket = BitchatPacket(
|
var leavePacket = BitchatPacket(
|
||||||
type: MessageType.leave.rawValue,
|
type: MessageType.leave.rawValue,
|
||||||
senderID: myPeerIDData,
|
senderID: localIdentity.peerIDData,
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: Data(),
|
payload: Data(),
|
||||||
@@ -659,26 +772,62 @@ final class BLEService: NSObject {
|
|||||||
centralManager?.cancelPeripheralConnection(state.peripheral)
|
centralManager?.cancelPeripheralConnection(state.peripheral)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Panic cannot spend its security boundary sending a signed LEAVE or
|
||||||
|
/// pumping the main run loop. Close the radio and timers immediately;
|
||||||
|
/// the identity/session cleanup follows synchronously.
|
||||||
|
private func stopServicesImmediatelyForPanic() {
|
||||||
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
|
pendingNotifications.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
maintenanceTimer?.cancel()
|
||||||
|
maintenanceTimer = nil
|
||||||
|
scanDutyTimer?.cancel()
|
||||||
|
scanDutyTimer = nil
|
||||||
|
|
||||||
|
centralManager?.stopScan()
|
||||||
|
peripheralManager?.stopAdvertising()
|
||||||
|
|
||||||
|
let peripheralsToDisconnect = bleQueue.sync {
|
||||||
|
linkStateStore.peripheralStates
|
||||||
|
}
|
||||||
|
for state in peripheralsToDisconnect {
|
||||||
|
centralManager?.cancelPeripheralConnection(state.peripheral)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func emergencyDisconnectAll() {
|
func emergencyDisconnectAll() {
|
||||||
stopServices()
|
stopServices()
|
||||||
|
clearEmergencySessionState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearEmergencySessionState() {
|
||||||
// Clear all sessions and peers
|
// Clear all sessions and peers
|
||||||
let cancelledTransfers: [(id: String, items: [DispatchWorkItem])] = collectionsQueue.sync(flags: .barrier) {
|
let cancelled = collectionsQueue.sync(flags: .barrier) {
|
||||||
let entries = outboundFragmentTransfers.removeAll().map { ($0.id, $0.workItems) }
|
let entries = outboundFragmentTransfers.removeAll().map {
|
||||||
|
(id: $0.id, items: $0.workItems)
|
||||||
|
}
|
||||||
|
let pingTimeouts = pendingMeshPings.values.map(\.timeout)
|
||||||
|
pendingMeshPings.removeAll()
|
||||||
|
meshPingResponseLimiter = SyncResponseRateLimiter(
|
||||||
|
maxResponses: TransportConfig.meshPingInboundMaxPerLink,
|
||||||
|
window: TransportConfig.meshPingInboundWindowSeconds
|
||||||
|
)
|
||||||
peerRegistry.removeAll()
|
peerRegistry.removeAll()
|
||||||
fragmentAssemblyBuffer.removeAll()
|
fragmentAssemblyBuffer.removeAll()
|
||||||
sourceRouteFailures = BLESourceRouteFailureCache()
|
sourceRouteFailures = BLESourceRouteFailureCache()
|
||||||
// Also clear pending message queues to avoid stale state across sessions
|
// Also clear pending message queues to avoid stale state across sessions
|
||||||
pendingNoiseSessionQueues.removeAll()
|
pendingNoiseSessionQueues.removeAll()
|
||||||
pendingDirectedRelays.removeAll()
|
pendingDirectedRelays.removeAll()
|
||||||
return entries
|
return (transfers: entries, pingTimeouts: pingTimeouts)
|
||||||
}
|
}
|
||||||
|
|
||||||
for entry in cancelledTransfers {
|
for entry in cancelled.transfers {
|
||||||
entry.items.forEach { $0.cancel() }
|
entry.items.forEach { $0.cancel() }
|
||||||
TransferProgressManager.shared.cancel(id: entry.id)
|
TransferProgressManager.shared.cancel(id: entry.id)
|
||||||
}
|
}
|
||||||
|
cancelled.pingTimeouts.forEach { $0.cancel() }
|
||||||
|
|
||||||
// Clear processed messages
|
// Clear processed messages
|
||||||
messageDeduplicator.reset()
|
messageDeduplicator.reset()
|
||||||
@@ -899,6 +1048,7 @@ final class BLEService: NSObject {
|
|||||||
func sendFileBroadcast(_ filePacket: BitchatFilePacket, transferId: String) {
|
func sendFileBroadcast(_ filePacket: BitchatFilePacket, transferId: String) {
|
||||||
messageQueue.async { [weak self] in
|
messageQueue.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
guard !self.isPanicSuspended else { return }
|
||||||
guard let payload = filePacket.encode() else {
|
guard let payload = filePacket.encode() else {
|
||||||
SecureLogger.error("❌ Failed to encode file packet for broadcast", category: .session)
|
SecureLogger.error("❌ Failed to encode file packet for broadcast", category: .session)
|
||||||
return
|
return
|
||||||
@@ -935,6 +1085,7 @@ final class BLEService: NSObject {
|
|||||||
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
func sendFilePrivate(_ filePacket: BitchatFilePacket, to peerID: PeerID, transferId: String) {
|
||||||
messageQueue.async { [weak self] in
|
messageQueue.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
guard !self.isPanicSuspended else { return }
|
||||||
guard let payload = filePacket.encode() else {
|
guard let payload = filePacket.encode() else {
|
||||||
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
|
||||||
return
|
return
|
||||||
@@ -1088,6 +1239,7 @@ final class BLEService: NSObject {
|
|||||||
// MARK: - Packet Broadcasting
|
// MARK: - Packet Broadcasting
|
||||||
|
|
||||||
private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) {
|
private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
// Apply route if recipient exists (centralized route application)
|
// Apply route if recipient exists (centralized route application)
|
||||||
let packetToSend: BitchatPacket
|
let packetToSend: BitchatPacket
|
||||||
if let recipientPeerID = PeerID(hexData: packet.recipientID) {
|
if let recipientPeerID = PeerID(hexData: packet.recipientID) {
|
||||||
@@ -1169,8 +1321,10 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func enqueuePendingNotification(data: Data, centrals: [CBCentral]?, context: String, attempt: Int = 0) {
|
private func enqueuePendingNotification(data: Data, centrals: [CBCentral]?, context: String, attempt: Int = 0) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
collectionsQueue.async(flags: .barrier) { [weak self] in
|
collectionsQueue.async(flags: .barrier) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
guard !self.isPanicSuspended else { return }
|
||||||
let result = self.pendingNotifications.enqueue(
|
let result = self.pendingNotifications.enqueue(
|
||||||
data: data,
|
data: data,
|
||||||
targets: centrals,
|
targets: centrals,
|
||||||
@@ -1271,6 +1425,7 @@ final class BLEService: NSObject {
|
|||||||
requireDirectPeerLink: Bool = false,
|
requireDirectPeerLink: Bool = false,
|
||||||
requireNoiseAuthenticatedPeerLink: Bool = false
|
requireNoiseAuthenticatedPeerLink: Bool = false
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
guard !isPanicSuspended else { return false }
|
||||||
let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) }
|
let ingressRecord = collectionsQueue.sync { ingressLinks.record(for: packet) }
|
||||||
var excludedPeerLinks = links(to: ingressRecord?.peerID)
|
var excludedPeerLinks = links(to: ingressRecord?.peerID)
|
||||||
if requireNoiseAuthenticatedPeerLink {
|
if requireNoiseAuthenticatedPeerLink {
|
||||||
@@ -1421,6 +1576,7 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func flushDirectedSpool() {
|
private func flushDirectedSpool() {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
// Move items out and attempt broadcast; if still no links, they'll be re-spooled
|
// Move items out and attempt broadcast; if still no links, they'll be re-spooled
|
||||||
let toSend = collectionsQueue.sync(flags: .barrier) {
|
let toSend = collectionsQueue.sync(flags: .barrier) {
|
||||||
pendingDirectedRelays.drainUnexpired(
|
pendingDirectedRelays.drainUnexpired(
|
||||||
@@ -1464,22 +1620,40 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) {
|
func collectArchivedPublicMessages(completion: @escaping @MainActor ([ArchivedPublicMessage]) -> Void) {
|
||||||
|
guard let generation = capturePanicLifecycleGeneration() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
guard let sync = gossipSyncManager else {
|
guard let sync = gossipSyncManager else {
|
||||||
Task { @MainActor in completion([]) }
|
notifyUI { [weak self] in
|
||||||
|
guard let self,
|
||||||
|
self.isCurrentPanicLifecycleGeneration(generation) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completion([])
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sync.collectPublicMessagePackets { [weak self] packets in
|
sync.collectPublicMessagePackets { [weak self] packets in
|
||||||
guard let self = self else {
|
guard let self,
|
||||||
Task { @MainActor in completion([]) }
|
self.isCurrentPanicLifecycleGeneration(generation) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Signature verification and registry lookups run on messageQueue
|
// Signature verification and registry lookups run on messageQueue
|
||||||
// like the live receive path.
|
// like the live receive path.
|
||||||
self.messageQueue.async {
|
self.messageQueue.async {
|
||||||
|
guard self.isCurrentPanicLifecycleGeneration(generation) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
let decoded = packets
|
let decoded = packets
|
||||||
.compactMap { self.decodeArchivedPublicMessage($0) }
|
.compactMap { self.decodeArchivedPublicMessage($0) }
|
||||||
.sorted { $0.timestamp < $1.timestamp }
|
.sorted { $0.timestamp < $1.timestamp }
|
||||||
Task { @MainActor in completion(decoded) }
|
self.notifyUI { [weak self] in
|
||||||
|
guard let self,
|
||||||
|
self.isCurrentPanicLifecycleGeneration(generation) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completion(decoded)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1618,7 +1792,44 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleLeave(_: BitchatPacket, from peerID: PeerID) {
|
/// Accept a leave only when the claimed sender proves possession of the
|
||||||
|
/// signing key bound by a verified announce. The persisted identity cache
|
||||||
|
/// keeps delayed/relayed leaves verifiable after the live registry entry
|
||||||
|
/// has aged out.
|
||||||
|
private func handleLeave(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||||
|
let registrySigningKey = collectionsQueue.sync {
|
||||||
|
peerRegistry.info(for: peerID)?.signingPublicKey
|
||||||
|
}
|
||||||
|
let verifiedViaRegistry = registrySigningKey.map {
|
||||||
|
noiseService.verifyPacketSignature(packet, publicKey: $0)
|
||||||
|
} ?? false
|
||||||
|
let verifiedViaPersistedIdentity = !verifiedViaRegistry
|
||||||
|
&& identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID).contains { identity in
|
||||||
|
PeerID(publicKey: identity.publicKey) == peerID
|
||||||
|
&& identity.signingPublicKey.map {
|
||||||
|
noiseService.verifyPacketSignature(packet, publicKey: $0)
|
||||||
|
} == true
|
||||||
|
}
|
||||||
|
|
||||||
|
guard verifiedViaRegistry || verifiedViaPersistedIdentity else {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"🚫 Dropping leave with missing/invalid signature for claimed sender \(peerID.id.prefix(8))…",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// A valid departure retires transport state too; otherwise
|
||||||
|
// canDeliverSecurely could remain true for a peer we just removed.
|
||||||
|
noiseService.clearSession(for: peerID)
|
||||||
|
readLinkState { _ in
|
||||||
|
let departedLinks = noiseAuthenticatedLinkOwners.compactMap { link, owner in
|
||||||
|
owner == peerID ? link : nil
|
||||||
|
}
|
||||||
|
for link in departedLinks {
|
||||||
|
noiseAuthenticatedLinkOwners.removeValue(forKey: link)
|
||||||
|
}
|
||||||
|
}
|
||||||
_ = collectionsQueue.sync(flags: .barrier) {
|
_ = collectionsQueue.sync(flags: .barrier) {
|
||||||
// Remove the peer when they leave
|
// Remove the peer when they leave
|
||||||
peerRegistry.remove(peerID)
|
peerRegistry.remove(peerID)
|
||||||
@@ -1635,8 +1846,23 @@ final class BLEService: NSObject {
|
|||||||
self.deliverTransportEvent(.peerDisconnected(peerID))
|
self.deliverTransportEvent(.peerDisconnected(peerID))
|
||||||
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
|
self.deliverTransportEvent(.peerListUpdated(currentPeerIDs))
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
private func sendAnnounce(forceSend: Bool = false) {
|
private func sendAnnounce(forceSend: Bool = false) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
|
// Announce construction reads the replaceable Noise service and several
|
||||||
|
// related state snapshots. Serialize the whole operation with identity
|
||||||
|
// rotation instead of letting CoreBluetooth and maintenance callbacks
|
||||||
|
// execute it directly on their own queues.
|
||||||
|
messageQueue.async(flags: .barrier) { [weak self] in
|
||||||
|
self?.sendAnnounceNow(forceSend: forceSend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendAnnounceNow(forceSend: Bool) {
|
||||||
|
// Re-check on the serialized queue: a panic suspend may have started
|
||||||
|
// after this announce was scheduled but before it runs.
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
// Throttle announces to prevent flooding
|
// Throttle announces to prevent flooding
|
||||||
if !announceThrottle.shouldSend(force: forceSend, now: Date()) {
|
if !announceThrottle.shouldSend(force: forceSend, now: Date()) {
|
||||||
return
|
return
|
||||||
@@ -1656,8 +1882,9 @@ final class BLEService: NSObject {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let localIdentity = localIdentityState.snapshot()
|
||||||
let announcement = AnnouncementPacket(
|
let announcement = AnnouncementPacket(
|
||||||
nickname: myNickname,
|
nickname: localIdentity.nickname,
|
||||||
noisePublicKey: noisePub,
|
noisePublicKey: noisePub,
|
||||||
signingPublicKey: signingPub,
|
signingPublicKey: signingPub,
|
||||||
directNeighbors: connectedPeerIDs,
|
directNeighbors: connectedPeerIDs,
|
||||||
@@ -1673,7 +1900,7 @@ final class BLEService: NSObject {
|
|||||||
// Create packet with signature using the noise private key
|
// Create packet with signature using the noise private key
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.announce.rawValue,
|
type: MessageType.announce.rawValue,
|
||||||
senderID: myPeerIDData,
|
senderID: localIdentity.peerIDData,
|
||||||
recipientID: nil,
|
recipientID: nil,
|
||||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
payload: payload,
|
payload: payload,
|
||||||
@@ -1687,14 +1914,7 @@ final class BLEService: NSObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call directly if on messageQueue, otherwise dispatch
|
broadcastPacket(signedPacket)
|
||||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
|
||||||
broadcastPacket(signedPacket)
|
|
||||||
} else {
|
|
||||||
messageQueue.async { [weak self] in
|
|
||||||
self?.broadcastPacket(signedPacket)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Ensure our own announce is included in sync state
|
// Ensure our own announce is included in sync state
|
||||||
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||||||
|
|
||||||
@@ -1846,6 +2066,13 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
|
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
|
||||||
let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
|
let restoredPeripherals = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral]) ?? []
|
||||||
|
guard !isPanicSuspended else {
|
||||||
|
central.stopScan()
|
||||||
|
restoredPeripherals.forEach {
|
||||||
|
central.cancelPeripheralConnection($0)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
|
let restoredServices = (dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]) ?? []
|
||||||
let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
|
let restoredOptions = (dict[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]) ?? [:]
|
||||||
let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool
|
let allowDuplicates = restoredOptions[CBCentralManagerScanOptionAllowDuplicatesKey] as? Bool
|
||||||
@@ -1901,6 +2128,10 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
|
|
||||||
switch central.state {
|
switch central.state {
|
||||||
case .poweredOn:
|
case .poweredOn:
|
||||||
|
guard !isPanicSuspended else {
|
||||||
|
central.stopScan()
|
||||||
|
return
|
||||||
|
}
|
||||||
// Links restored as connected have no characteristic in the new
|
// Links restored as connected have no characteristic in the new
|
||||||
// process; without rediscovery they sit connected-but-unusable
|
// process; without rediscovery they sit connected-but-unusable
|
||||||
// until the peer disconnects. Runs here (not willRestoreState)
|
// until the peer disconnects. Runs here (not willRestoreState)
|
||||||
@@ -1957,7 +2188,8 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startScanning() {
|
private func startScanning() {
|
||||||
guard let central = centralManager,
|
guard !isPanicSuspended,
|
||||||
|
let central = centralManager,
|
||||||
central.state == .poweredOn,
|
central.state == .poweredOn,
|
||||||
!central.isScanning else { return }
|
!central.isScanning else { return }
|
||||||
|
|
||||||
@@ -1978,6 +2210,7 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
let peripheralID = peripheral.identifier.uuidString
|
let peripheralID = peripheral.identifier.uuidString
|
||||||
let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "…")
|
let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "…")
|
||||||
let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true
|
let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true
|
||||||
@@ -2019,6 +2252,10 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||||
|
guard !isPanicSuspended else {
|
||||||
|
central.cancelPeripheralConnection(peripheral)
|
||||||
|
return
|
||||||
|
}
|
||||||
let peripheralID = peripheral.identifier.uuidString
|
let peripheralID = peripheral.identifier.uuidString
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@@ -2169,7 +2406,9 @@ private extension CBPeripheralState {
|
|||||||
|
|
||||||
extension BLEService {
|
extension BLEService {
|
||||||
private func tryConnectFromQueue() {
|
private func tryConnectFromQueue() {
|
||||||
guard let central = centralManager, central.state == .poweredOn else { return }
|
guard !isPanicSuspended,
|
||||||
|
let central = centralManager,
|
||||||
|
central.state == .poweredOn else { return }
|
||||||
|
|
||||||
let decision = connectionScheduler.nextCandidate(
|
let decision = connectionScheduler.nextCandidate(
|
||||||
connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount,
|
connectedOrConnectingCount: linkStateStore.connectedOrConnectingPeripheralCount,
|
||||||
@@ -2195,6 +2434,7 @@ extension BLEService {
|
|||||||
using central: CBCentralManager,
|
using central: CBCentralManager,
|
||||||
logPrefix: String
|
logPrefix: String
|
||||||
) {
|
) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
let peripheral = candidate.peripheral
|
let peripheral = candidate.peripheral
|
||||||
let peripheralID = candidate.peripheralID
|
let peripheralID = candidate.peripheralID
|
||||||
linkStateStore.beginConnecting(to: peripheral, at: Date())
|
linkStateStore.beginConnecting(to: peripheral, at: Date())
|
||||||
@@ -2255,6 +2495,28 @@ private extension BLEService {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
// Test-only helper to inject packets into the receive pipeline
|
// Test-only helper to inject packets into the receive pipeline
|
||||||
extension BLEService {
|
extension BLEService {
|
||||||
|
/// Queues an event through the same MainActor hop as production receive
|
||||||
|
/// handlers so panic-boundary tests can deterministically invalidate it.
|
||||||
|
func _test_emitTransportEvent(_ event: TransportEvent) {
|
||||||
|
emitTransportEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _test_isPanicIngressOpen: Bool {
|
||||||
|
capturePanicLifecycleGeneration() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Models a CoreBluetooth delegate callback without requiring a physical
|
||||||
|
/// peripheral. The callback itself runs on `bleQueue`, exactly where the
|
||||||
|
/// panic radio-stop barrier must linearize it.
|
||||||
|
func _test_handlePacketFromBLEQueue(
|
||||||
|
_ packet: BitchatPacket,
|
||||||
|
fromPeerID: PeerID
|
||||||
|
) {
|
||||||
|
bleQueue.async { [weak self] in
|
||||||
|
self?.handleReceivedPacket(packet, from: fromPeerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) {
|
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) {
|
||||||
if preseedPeer {
|
if preseedPeer {
|
||||||
// Ensure the synthetic peer is known and marked verified for public-message tests
|
// Ensure the synthetic peer is known and marked verified for public-message tests
|
||||||
@@ -2336,6 +2598,12 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _test_isNoiseAuthenticatedCentral(_ centralUUID: String, for peerID: PeerID) -> Bool {
|
||||||
|
bleQueue.sync {
|
||||||
|
noiseAuthenticatedLinkOwners[.central(centralUUID)] == peerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) {
|
func _test_seedConnectedPeer(_ peerID: PeerID, nickname: String) {
|
||||||
collectionsQueue.sync(flags: .barrier) {
|
collectionsQueue.sync(flags: .barrier) {
|
||||||
peerRegistry.upsert(BLEPeerInfo(
|
peerRegistry.upsert(BLEPeerInfo(
|
||||||
@@ -2376,6 +2644,7 @@ extension BLEService {
|
|||||||
|
|
||||||
extension BLEService: CBPeripheralDelegate {
|
extension BLEService: CBPeripheralDelegate {
|
||||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
|
SecureLogger.error("❌ Error discovering services for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
|
||||||
// Retry service discovery after a delay
|
// Retry service discovery after a delay
|
||||||
@@ -2402,6 +2671,7 @@ extension BLEService: CBPeripheralDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
|
SecureLogger.error("❌ Error discovering characteristics for \(peripheral.name ?? "Unknown"): \(error.localizedDescription)", category: .session)
|
||||||
return
|
return
|
||||||
@@ -2449,6 +2719,7 @@ extension BLEService: CBPeripheralDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session)
|
SecureLogger.error("❌ Error receiving notification: \(error.localizedDescription)", category: .session)
|
||||||
return
|
return
|
||||||
@@ -2566,6 +2837,7 @@ extension BLEService: CBPeripheralDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
|
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
// Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again
|
// Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again
|
||||||
if logRateLimiter.shouldLog(key: "peripheral-ready:\(peripheral.identifier.uuidString)") {
|
if logRateLimiter.shouldLog(key: "peripheral-ready:\(peripheral.identifier.uuidString)") {
|
||||||
SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session)
|
SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session)
|
||||||
@@ -2574,6 +2846,7 @@ extension BLEService: CBPeripheralDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
|
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
|
SecureLogger.warning("⚠️ Services modified for \(peripheral.name ?? peripheral.identifier.uuidString)", category: .session)
|
||||||
|
|
||||||
let shouldRediscover = BLEService.shouldRediscoverBitChatService(
|
let shouldRediscover = BLEService.shouldRediscoverBitChatService(
|
||||||
@@ -2594,6 +2867,7 @@ extension BLEService: CBPeripheralDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
|
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session)
|
SecureLogger.error("❌ Error updating notification state: \(error.localizedDescription)", category: .session)
|
||||||
} else {
|
} else {
|
||||||
@@ -2617,6 +2891,12 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
|
|
||||||
switch peripheral.state {
|
switch peripheral.state {
|
||||||
case .poweredOn:
|
case .poweredOn:
|
||||||
|
guard !isPanicSuspended else {
|
||||||
|
peripheral.stopAdvertising()
|
||||||
|
peripheral.removeAllServices()
|
||||||
|
characteristic = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
// Remove all services first to ensure clean state
|
// Remove all services first to ensure clean state
|
||||||
peripheral.removeAllServices()
|
peripheral.removeAllServices()
|
||||||
|
|
||||||
@@ -2677,6 +2957,12 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
|
func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String: Any]) {
|
||||||
|
guard !isPanicSuspended else {
|
||||||
|
peripheral.stopAdvertising()
|
||||||
|
peripheral.removeAllServices()
|
||||||
|
characteristic = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? []
|
let restoredServices = (dict[CBPeripheralManagerRestoredStateServicesKey] as? [CBMutableService]) ?? []
|
||||||
let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]
|
let restoredAdvertisement = (dict[CBPeripheralManagerRestoredStateAdvertisementDataKey] as? [String: Any]) ?? [:]
|
||||||
|
|
||||||
@@ -2703,6 +2989,10 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
|
func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
|
||||||
|
guard !isPanicSuspended else {
|
||||||
|
peripheral.stopAdvertising()
|
||||||
|
return
|
||||||
|
}
|
||||||
if let error = error {
|
if let error = error {
|
||||||
SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session)
|
SecureLogger.error("❌ Failed to add service: \(error.localizedDescription)", category: .session)
|
||||||
return
|
return
|
||||||
@@ -2718,6 +3008,7 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
|
func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
let centralUUID = central.identifier.uuidString
|
let centralUUID = central.identifier.uuidString
|
||||||
SecureLogger.debug("📥 Central subscribed: \(centralUUID.prefix(8))…", category: .session)
|
SecureLogger.debug("📥 Central subscribed: \(centralUUID.prefix(8))…", category: .session)
|
||||||
linkStateStore.addSubscribedCentral(central)
|
linkStateStore.addSubscribedCentral(central)
|
||||||
@@ -2759,7 +3050,7 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
|
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
|
||||||
|
|
||||||
// Ensure we're still advertising for other devices to find us
|
// Ensure we're still advertising for other devices to find us
|
||||||
if peripheral.isAdvertising == false {
|
if !isPanicSuspended, peripheral.isAdvertising == false {
|
||||||
SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session)
|
SecureLogger.debug("📡 Restarting advertising after central unsubscribed", category: .session)
|
||||||
peripheral.startAdvertising(buildAdvertisementData())
|
peripheral.startAdvertising(buildAdvertisementData())
|
||||||
}
|
}
|
||||||
@@ -2796,6 +3087,7 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
|
func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
drainPendingNotifications(logPrefix: "✅ Sent")
|
drainPendingNotifications(logPrefix: "✅ Sent")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2856,6 +3148,7 @@ extension BLEService: CBPeripheralManagerDelegate {
|
|||||||
for request in requests {
|
for request in requests {
|
||||||
peripheral.respond(to: request, withResult: .success)
|
peripheral.respond(to: request, withResult: .success)
|
||||||
}
|
}
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
|
|
||||||
// Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets.
|
// Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets.
|
||||||
// Combine per-central request values by offset before decoding.
|
// Combine per-central request values by offset before decoding.
|
||||||
@@ -2965,8 +3258,18 @@ extension BLEService {
|
|||||||
|
|
||||||
/// Notify UI on the MainActor to satisfy Swift concurrency isolation
|
/// Notify UI on the MainActor to satisfy Swift concurrency isolation
|
||||||
private func notifyUI(_ block: @escaping @MainActor () -> Void) {
|
private func notifyUI(_ block: @escaping @MainActor () -> Void) {
|
||||||
// Always hop onto the MainActor so calls to @MainActor delegates are safe
|
// Capture the panic lifecycle before queueing the MainActor hop. A
|
||||||
Task { @MainActor in
|
// receive callback can enqueue UI delivery immediately before panic
|
||||||
|
// clears application state; rechecking here prevents that stale work
|
||||||
|
// from repopulating the wiped conversation store afterward.
|
||||||
|
guard let generation = capturePanicLifecycleGeneration() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self,
|
||||||
|
self.isCurrentPanicLifecycleGeneration(generation) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
block()
|
block()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3131,14 +3434,24 @@ extension BLEService {
|
|||||||
/// The completion fires exactly once on the main actor: with RTT/hops
|
/// The completion fires exactly once on the main actor: with RTT/hops
|
||||||
/// when the matching pong returns, or nil after the timeout window.
|
/// when the matching pong returns, or nil after the timeout window.
|
||||||
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
|
func sendMeshPing(to peerID: PeerID, completion: @escaping @MainActor (MeshPingResult?) -> Void) {
|
||||||
|
guard let generation = capturePanicLifecycleGeneration() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
messageQueue.async { [weak self] in
|
messageQueue.async { [weak self] in
|
||||||
guard let self,
|
guard let self,
|
||||||
|
self.isCurrentPanicLifecycleGeneration(generation),
|
||||||
let recipientData = peerID.toShort().routingData,
|
let recipientData = peerID.toShort().routingData,
|
||||||
let payload = MeshPingPayload(
|
let payload = MeshPingPayload(
|
||||||
nonce: Data((0..<MeshPingPayload.nonceLength).map { _ in UInt8.random(in: .min ... .max) }),
|
nonce: Data((0..<MeshPingPayload.nonceLength).map { _ in UInt8.random(in: .min ... .max) }),
|
||||||
originTTL: self.messageTTL
|
originTTL: self.messageTTL
|
||||||
) else {
|
) else {
|
||||||
Task { @MainActor in completion(nil) }
|
self?.notifyUI { [weak self] in
|
||||||
|
guard let self,
|
||||||
|
self.isCurrentPanicLifecycleGeneration(generation) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completion(nil)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let nonce = payload.nonce
|
let nonce = payload.nonce
|
||||||
@@ -3157,12 +3470,21 @@ extension BLEService {
|
|||||||
self.pendingMeshPings.removeValue(forKey: nonce)
|
self.pendingMeshPings.removeValue(forKey: nonce)
|
||||||
}
|
}
|
||||||
guard let expired else { return }
|
guard let expired else { return }
|
||||||
Task { @MainActor in expired.completion(nil) }
|
self.notifyUI { [weak self] in
|
||||||
|
guard let self,
|
||||||
|
self.isCurrentPanicLifecycleGeneration(
|
||||||
|
expired.lifecycleGeneration
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expired.completion(nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.collectionsQueue.sync(flags: .barrier) {
|
self.collectionsQueue.sync(flags: .barrier) {
|
||||||
self.pendingMeshPings[nonce] = PendingMeshPing(
|
self.pendingMeshPings[nonce] = PendingMeshPing(
|
||||||
peerID: PeerID(hexData: recipientData),
|
peerID: PeerID(hexData: recipientData),
|
||||||
sentAt: Date(),
|
sentAt: Date(),
|
||||||
|
lifecycleGeneration: generation,
|
||||||
completion: completion,
|
completion: completion,
|
||||||
timeout: timeout
|
timeout: timeout
|
||||||
)
|
)
|
||||||
@@ -3229,7 +3551,15 @@ extension BLEService {
|
|||||||
rttMs: max(0, rttMs),
|
rttMs: max(0, rttMs),
|
||||||
hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl)
|
hops: MeshPingPayload.hopCount(originTTL: pong.originTTL, receivedTTL: packet.ttl)
|
||||||
)
|
)
|
||||||
Task { @MainActor in pending.completion(result) }
|
notifyUI { [weak self] in
|
||||||
|
guard let self,
|
||||||
|
self.isCurrentPanicLifecycleGeneration(
|
||||||
|
pending.lifecycleGeneration
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pending.completion(result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Estimated intermediate hops toward `peerID`, BFS over gossiped
|
/// Estimated intermediate hops toward `peerID`, BFS over gossiped
|
||||||
@@ -3358,8 +3688,9 @@ extension BLEService {
|
|||||||
private func refreshPeerIdentity() {
|
private func refreshPeerIdentity() {
|
||||||
let swap = {
|
let swap = {
|
||||||
let fingerprint = self.noiseService.getIdentityFingerprint()
|
let fingerprint = self.noiseService.getIdentityFingerprint()
|
||||||
self.myPeerID = PeerID(str: fingerprint.prefix(16))
|
self.localIdentityState.replacePeerIdentity(
|
||||||
self.myPeerIDData = Data(hexString: self.myPeerID.id) ?? Data()
|
with: PeerID(str: fingerprint.prefix(16))
|
||||||
|
)
|
||||||
self.meshTopology.reset()
|
self.meshTopology.reset()
|
||||||
}
|
}
|
||||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||||
@@ -3713,7 +4044,7 @@ extension BLEService {
|
|||||||
let store = courierStore
|
let store = courierStore
|
||||||
let policy = courierDepositPolicy
|
let policy = courierDepositPolicy
|
||||||
let metrics = sfMetrics
|
let metrics = sfMetrics
|
||||||
Task { @MainActor in
|
notifyUI {
|
||||||
guard let tier = policy(depositorKey, isVerifiedPeer) else {
|
guard let tier = policy(depositorKey, isVerifiedPeer) else {
|
||||||
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (neither favorite nor verified)", category: .session)
|
SecureLogger.debug("📦 Courier deposit from \(peerID.id.prefix(8))… rejected (neither favorite nor verified)", category: .session)
|
||||||
return
|
return
|
||||||
@@ -3793,7 +4124,7 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let policy = courierDepositPolicy
|
let policy = courierDepositPolicy
|
||||||
Task { @MainActor in
|
notifyUI {
|
||||||
// Same trust gate as deposits: don't hand mail to a peer who
|
// Same trust gate as deposits: don't hand mail to a peer who
|
||||||
// would reject it from us.
|
// would reject it from us.
|
||||||
guard policy(noiseKey, isVerifiedPeer) != nil else { return }
|
guard policy(noiseKey, isVerifiedPeer) != nil else { return }
|
||||||
@@ -4138,6 +4469,7 @@ extension BLEService {
|
|||||||
let uuid = peripheral.identifier.uuidString
|
let uuid = peripheral.identifier.uuidString
|
||||||
bleQueue.async { [weak self] in
|
bleQueue.async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
guard !self.isPanicSuspended else { return }
|
||||||
guard let state = self.linkStateStore.state(forPeripheralID: uuid), let ch = state.characteristic else { return }
|
guard let state = self.linkStateStore.state(forPeripheralID: uuid), let ch = state.characteristic else { return }
|
||||||
|
|
||||||
// Atomically take all pending items from the queue to avoid race conditions
|
// Atomically take all pending items from the queue to avoid race conditions
|
||||||
@@ -4228,7 +4560,10 @@ extension BLEService {
|
|||||||
slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve
|
slotReserve: Int = TransportConfig.bleBackgroundPendingConnectSlotReserve
|
||||||
) {
|
) {
|
||||||
bleQueue.async { [weak self] in
|
bleQueue.async { [weak self] in
|
||||||
guard let self, let central = self.centralManager, central.state == .poweredOn else { return }
|
guard let self,
|
||||||
|
!self.isPanicSuspended,
|
||||||
|
let central = self.centralManager,
|
||||||
|
central.state == .poweredOn else { return }
|
||||||
let budget = TransportConfig.bleMaxCentralLinks
|
let budget = TransportConfig.bleMaxCentralLinks
|
||||||
- slotReserve
|
- slotReserve
|
||||||
- self.linkStateStore.connectedOrConnectingPeripheralCount
|
- self.linkStateStore.connectedOrConnectingPeripheralCount
|
||||||
@@ -4681,8 +5016,24 @@ extension BLEService {
|
|||||||
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
// Call directly if already on messageQueue, otherwise dispatch
|
// Call directly if already on messageQueue, otherwise dispatch
|
||||||
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
|
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
|
||||||
|
guard let lifecycleGeneration =
|
||||||
|
capturePanicLifecycleGeneration() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#if DEBUG
|
||||||
|
_test_beforeReceivePacketHandoff?()
|
||||||
|
#endif
|
||||||
messageQueue.async { [weak self] in
|
messageQueue.async { [weak self] in
|
||||||
self?.handleReceivedPacket(packet, from: peerID)
|
guard let self,
|
||||||
|
self.isCurrentPanicLifecycleGeneration(
|
||||||
|
lifecycleGeneration
|
||||||
|
) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#if DEBUG
|
||||||
|
self._test_onReceivePacketHandoff?()
|
||||||
|
#endif
|
||||||
|
self.handleReceivedPacket(packet, from: peerID)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -4785,7 +5136,9 @@ extension BLEService {
|
|||||||
handleMeshPong(packet, from: senderID)
|
handleMeshPong(packet, from: senderID)
|
||||||
|
|
||||||
case .leave:
|
case .leave:
|
||||||
handleLeave(packet, from: senderID)
|
// A forged leave must neither evict the claimed peer nor spread
|
||||||
|
// to downstream nodes.
|
||||||
|
guard handleLeave(packet, from: senderID) else { return }
|
||||||
|
|
||||||
case .none:
|
case .none:
|
||||||
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
|
SecureLogger.warning("⚠️ Unknown message type: \(packet.type)", category: .session)
|
||||||
@@ -5425,9 +5778,11 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
private func handleNoiseHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
let wasEstablished = noiseService.hasEstablishedSession(with: peerID)
|
let result = noisePacketHandler.handleHandshakeWithResult(
|
||||||
noisePacketHandler.handleHandshake(packet, from: peerID)
|
packet,
|
||||||
if !wasEstablished, noiseService.hasEstablishedSession(with: peerID) {
|
from: peerID
|
||||||
|
)
|
||||||
|
if result.didEstablishAuthenticatedSession {
|
||||||
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
|
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5450,7 +5805,16 @@ extension BLEService {
|
|||||||
messageTTL: messageTTL,
|
messageTTL: messageTTL,
|
||||||
now: { Date() },
|
now: { Date() },
|
||||||
processHandshakeMessage: { [weak self] peerID, message in
|
processHandshakeMessage: { [weak self] peerID, message in
|
||||||
try self?.noiseService.processHandshakeMessage(from: peerID, message: message)
|
guard let self else {
|
||||||
|
return NoiseHandshakeProcessingResult(
|
||||||
|
response: nil,
|
||||||
|
didEstablishAuthenticatedSession: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return try self.noiseService.processHandshakeMessageWithResult(
|
||||||
|
from: peerID,
|
||||||
|
message: message
|
||||||
|
)
|
||||||
},
|
},
|
||||||
hasNoiseSession: { [weak self] peerID in
|
hasNoiseSession: { [weak self] peerID in
|
||||||
self?.noiseService.hasSession(with: peerID) ?? false
|
self?.noiseService.hasSession(with: peerID) ?? false
|
||||||
@@ -5526,8 +5890,7 @@ extension BLEService {
|
|||||||
let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync {
|
let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync {
|
||||||
peerRegistry.transportSnapshots(selfNickname: myNickname)
|
peerRegistry.transportSnapshots(selfNickname: myNickname)
|
||||||
}
|
}
|
||||||
// Notify UI on MainActor via delegate
|
notifyUI { [weak self] in
|
||||||
Task { @MainActor [weak self] in
|
|
||||||
self?.peerEventsDelegate?.didUpdatePeerSnapshots(transportPeers)
|
self?.peerEventsDelegate?.didUpdatePeerSnapshots(transportPeers)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5535,6 +5898,7 @@ extension BLEService {
|
|||||||
// MARK: Consolidated Maintenance
|
// MARK: Consolidated Maintenance
|
||||||
|
|
||||||
private func performMaintenance() {
|
private func performMaintenance() {
|
||||||
|
guard !isPanicSuspended else { return }
|
||||||
maintenanceCounter += 1
|
maintenanceCounter += 1
|
||||||
lastMaintenanceAt = Date()
|
lastMaintenanceAt = Date()
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
|
|
||||||
private var subscriptions = Set<AnyCancellable>()
|
private var subscriptions = Set<AnyCancellable>()
|
||||||
private var heartbeatTimer: GeohashPresenceTimerProtocol?
|
private var heartbeatTimer: GeohashPresenceTimerProtocol?
|
||||||
|
private var pendingBroadcastTasks: [UUID: Task<Void, Never>] = [:]
|
||||||
|
private var heartbeatGeneration: UInt64 = 0
|
||||||
|
private var started = false
|
||||||
private let availableChannelsProvider: () -> [GeohashChannel]
|
private let availableChannelsProvider: () -> [GeohashChannel]
|
||||||
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
|
private let locationChanges: AnyPublisher<[GeohashChannel], Never>
|
||||||
private let torReadyPublisher: AnyPublisher<Void, Never>
|
private let torReadyPublisher: AnyPublisher<Void, Never>
|
||||||
@@ -147,10 +150,25 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
|
|
||||||
/// Start the service (safe to call multiple times)
|
/// Start the service (safe to call multiple times)
|
||||||
func start() {
|
func start() {
|
||||||
|
guard !started else { return }
|
||||||
|
started = true
|
||||||
|
heartbeatGeneration &+= 1
|
||||||
SecureLogger.info("Presence: service starting...", category: .session)
|
SecureLogger.info("Presence: service starting...", category: .session)
|
||||||
scheduleNextHeartbeat()
|
scheduleNextHeartbeat()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stops the timer and every decorrelation task synchronously at the panic
|
||||||
|
/// boundary. Generation checks also protect against custom sleepers that
|
||||||
|
/// ignore task cancellation and return later.
|
||||||
|
func stopForPanic() {
|
||||||
|
started = false
|
||||||
|
heartbeatGeneration &+= 1
|
||||||
|
heartbeatTimer?.invalidate()
|
||||||
|
heartbeatTimer = nil
|
||||||
|
pendingBroadcastTasks.values.forEach { $0.cancel() }
|
||||||
|
pendingBroadcastTasks.removeAll(keepingCapacity: false)
|
||||||
|
}
|
||||||
|
|
||||||
private func setupObservers() {
|
private func setupObservers() {
|
||||||
// Monitor location channel changes
|
// Monitor location channel changes
|
||||||
locationChanges
|
locationChanges
|
||||||
@@ -169,20 +187,26 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handleLocationChange() {
|
func handleLocationChange() {
|
||||||
|
guard started else { return }
|
||||||
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
|
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
|
||||||
// to announce presence in the new zone, then reset the loop.
|
// to announce presence in the new zone, then reset the loop.
|
||||||
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
|
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
|
||||||
heartbeatTimer?.invalidate()
|
heartbeatTimer?.invalidate()
|
||||||
|
|
||||||
// Small delay to allow location state to settle
|
// Small delay to allow location state to settle
|
||||||
|
let generation = heartbeatGeneration
|
||||||
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
|
heartbeatTimer = scheduleTimer(5.0) { [weak self] in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.performHeartbeat()
|
guard let self,
|
||||||
|
self.started,
|
||||||
|
self.heartbeatGeneration == generation else { return }
|
||||||
|
self.performHeartbeat()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleConnectivityChange() {
|
func handleConnectivityChange() {
|
||||||
|
guard started else { return }
|
||||||
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
|
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
|
||||||
// If we were waiting for network, do it now
|
// If we were waiting for network, do it now
|
||||||
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
|
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
|
||||||
@@ -191,18 +215,29 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func scheduleNextHeartbeat() {
|
func scheduleNextHeartbeat() {
|
||||||
|
guard started else { return }
|
||||||
heartbeatTimer?.invalidate()
|
heartbeatTimer?.invalidate()
|
||||||
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
||||||
|
let generation = heartbeatGeneration
|
||||||
heartbeatTimer = scheduleTimer(interval) { [weak self] in
|
heartbeatTimer = scheduleTimer(interval) { [weak self] in
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
self?.performHeartbeat()
|
guard let self,
|
||||||
|
self.started,
|
||||||
|
self.heartbeatGeneration == generation else { return }
|
||||||
|
self.performHeartbeat()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func performHeartbeat() {
|
func performHeartbeat() {
|
||||||
|
guard started else { return }
|
||||||
|
let generation = heartbeatGeneration
|
||||||
// Always schedule next loop first ensures continuity even if this one fails/skips
|
// Always schedule next loop first ensures continuity even if this one fails/skips
|
||||||
defer { scheduleNextHeartbeat() }
|
defer {
|
||||||
|
if started, heartbeatGeneration == generation {
|
||||||
|
scheduleNextHeartbeat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Check preconditions
|
// 1. Check preconditions
|
||||||
guard torIsReady() else {
|
guard torIsReady() else {
|
||||||
@@ -228,14 +263,27 @@ final class GeohashPresenceService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Launch independent task for each channel's delay
|
// Launch independent task for each channel's delay
|
||||||
Task { @MainActor in
|
let taskID = UUID()
|
||||||
|
let sleeper = self.sleeper
|
||||||
|
let delay = TimeInterval.random(
|
||||||
|
in: burstMinDelay...burstMaxDelay
|
||||||
|
)
|
||||||
|
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||||
|
let task = Task { @MainActor [weak self] in
|
||||||
// Random delay for decorrelation
|
// Random delay for decorrelation
|
||||||
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
|
await sleeper(nanoseconds)
|
||||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
|
||||||
await self.sleeper(nanoseconds)
|
guard let self else { return }
|
||||||
|
guard !Task.isCancelled,
|
||||||
|
self.started,
|
||||||
|
self.heartbeatGeneration == generation else {
|
||||||
|
self.pendingBroadcastTasks.removeValue(forKey: taskID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.pendingBroadcastTasks.removeValue(forKey: taskID)
|
||||||
self.broadcastPresence(for: channel.geohash)
|
self.broadcastPresence(for: channel.geohash)
|
||||||
}
|
}
|
||||||
|
pendingBroadcastTasks[taskID] = task
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,54 @@ import BitFoundation
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Security
|
import Security
|
||||||
|
|
||||||
|
enum KeychainInstallLifecycleAction: Equatable {
|
||||||
|
case markerPresent
|
||||||
|
case bootstrapMarker
|
||||||
|
case clearStaleKeys
|
||||||
|
case retryLater
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process-local fail-closed gate for an unresolved install lifecycle.
|
||||||
|
///
|
||||||
|
/// A blocked caller may perform one synchronous reconciliation attempt.
|
||||||
|
/// Concurrent callers fail closed instead of reading while that cleanup is
|
||||||
|
/// in flight. Once reconciliation succeeds, access remains open.
|
||||||
|
final class KeychainInstallAccessGate: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var blocked = false
|
||||||
|
private var reconciliationInProgress = false
|
||||||
|
|
||||||
|
func block() {
|
||||||
|
lock.lock()
|
||||||
|
blocked = true
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func allowsAccess(reconcile: () -> Bool) -> Bool {
|
||||||
|
lock.lock()
|
||||||
|
if !blocked {
|
||||||
|
lock.unlock()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
guard !reconciliationInProgress else {
|
||||||
|
lock.unlock()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
reconciliationInProgress = true
|
||||||
|
lock.unlock()
|
||||||
|
|
||||||
|
let completed = reconcile()
|
||||||
|
|
||||||
|
lock.lock()
|
||||||
|
if completed {
|
||||||
|
blocked = false
|
||||||
|
}
|
||||||
|
reconciliationInProgress = false
|
||||||
|
lock.unlock()
|
||||||
|
return completed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final class KeychainManager: KeychainManagerProtocol {
|
final class KeychainManager: KeychainManagerProtocol {
|
||||||
/// Default keychain for components that construct their own rather than
|
/// Default keychain for components that construct their own rather than
|
||||||
/// having one injected. Under test this is an in-memory keychain: the
|
/// having one injected. Under test this is an in-memory keychain: the
|
||||||
@@ -41,53 +89,281 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
// Use consistent service name for all keychain items
|
// Use consistent service name for all keychain items
|
||||||
private let service = BitchatApp.bundleID
|
private let service = BitchatApp.bundleID
|
||||||
private let appGroup = "group.\(BitchatApp.bundleID)"
|
private let appGroup = "group.\(BitchatApp.bundleID)"
|
||||||
|
#if os(iOS)
|
||||||
|
private let installAccessGate = KeychainInstallAccessGate()
|
||||||
|
#endif
|
||||||
|
/// Every generic-password service owned by this app, including names used
|
||||||
|
/// by older releases. Keep custom services here so one-time security
|
||||||
|
/// migrations and panic deletion cannot silently miss them.
|
||||||
|
private static let additionalApplicationOwnedServices = [
|
||||||
|
"chat.bitchat.nostr",
|
||||||
|
"chat.bitchat.favorites",
|
||||||
|
"chat.bitchat.outbox",
|
||||||
|
"com.bitchat.passwords",
|
||||||
|
"com.bitchat.deviceidentity",
|
||||||
|
"com.bitchat.noise.identity",
|
||||||
|
"chat.bitchat.passwords",
|
||||||
|
"bitchat.keychain",
|
||||||
|
"bitchat",
|
||||||
|
"com.bitchat"
|
||||||
|
]
|
||||||
// AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the
|
// AfterFirstUnlock, not WhenUnlocked: the mesh keeps running with the
|
||||||
// device locked (identity-cache saves failed with -25308 throughout
|
// device locked (identity-cache saves failed with -25308 throughout
|
||||||
// locked-phone testing), and a wake-on-proximity relaunch via BLE state
|
// locked-phone testing), and a wake-on-proximity relaunch via BLE state
|
||||||
// restoration must be able to read the noise keys before the user
|
// restoration must be able to read the noise keys before the user
|
||||||
// unlocks. Backup/sync semantics are unchanged (not ThisDeviceOnly).
|
// unlocks. ThisDeviceOnly prevents private identities and group keys from
|
||||||
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlock
|
// migrating through device backups onto a second device.
|
||||||
|
private static let itemAccessibility = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
migrateAccessibilityIfNeeded()
|
if reconcileInstallLifecycle() {
|
||||||
|
migrateAccessibilityIfNeeded()
|
||||||
|
} else {
|
||||||
|
installAccessGate.block()
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func installLifecycleAction(
|
||||||
|
containerKnowsMarker: Bool,
|
||||||
|
cleanupPending: Bool = false,
|
||||||
|
markerRead: KeychainReadResult
|
||||||
|
) -> KeychainInstallLifecycleAction {
|
||||||
|
// Once a reinstall cleanup has started, its container-local latch
|
||||||
|
// must win even if the keychain marker was deleted before a later
|
||||||
|
// keychain operation failed. Otherwise the next launch could mistake
|
||||||
|
// a partial cleanup for a fresh bootstrap and preserve stale secrets.
|
||||||
|
if cleanupPending {
|
||||||
|
return .clearStaleKeys
|
||||||
|
}
|
||||||
|
|
||||||
|
switch markerRead {
|
||||||
|
case .success:
|
||||||
|
return containerKnowsMarker ? .markerPresent : .clearStaleKeys
|
||||||
|
case .itemNotFound:
|
||||||
|
return .bootstrapMarker
|
||||||
|
case .accessDenied, .deviceLocked, .authenticationFailed, .otherError:
|
||||||
|
return .retryLater
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func applicationOwnedKeychainServices(primaryService: String) -> [String] {
|
||||||
|
var seen = Set<String>()
|
||||||
|
return ([primaryService] + additionalApplicationOwnedServices).filter {
|
||||||
|
seen.insert($0).inserted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs every service update even after one failure. Successful updates
|
||||||
|
/// are idempotent, while returning false keeps the one-time flag unset so
|
||||||
|
/// a later unlocked launch retries the incomplete migration.
|
||||||
|
static func migrateAccessibilityForApplicationOwnedServices(
|
||||||
|
primaryService: String,
|
||||||
|
updateService: (String) -> OSStatus
|
||||||
|
) -> Bool {
|
||||||
|
var completed = true
|
||||||
|
for serviceName in applicationOwnedKeychainServices(
|
||||||
|
primaryService: primaryService
|
||||||
|
) {
|
||||||
|
let status = updateService(serviceName)
|
||||||
|
if status != errSecSuccess && status != errSecItemNotFound {
|
||||||
|
completed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return completed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes every declared service even after one failure. An empty scope
|
||||||
|
/// is already clean, while any other status leaves the cleanup
|
||||||
|
/// incomplete so its durable retry marker remains set.
|
||||||
|
static func deleteApplicationOwnedKeychainServices(
|
||||||
|
primaryService: String,
|
||||||
|
deleteService: (String) -> OSStatus
|
||||||
|
) -> Bool {
|
||||||
|
var completed = true
|
||||||
|
for serviceName in applicationOwnedKeychainServices(
|
||||||
|
primaryService: primaryService
|
||||||
|
) {
|
||||||
|
let status = deleteService(serviceName)
|
||||||
|
if status != errSecSuccess && status != errSecItemNotFound {
|
||||||
|
completed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return completed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app currently has an application-group entitlement, not a
|
||||||
|
/// keychain-access-group entitlement. Keep the historical group cleanup
|
||||||
|
/// probe as best effort without making its expected -34018 response block
|
||||||
|
/// panic recovery forever.
|
||||||
|
static func completedApplicationGroupDelete(status: OSStatus) -> Bool {
|
||||||
|
status == errSecSuccess
|
||||||
|
|| status == errSecItemNotFound
|
||||||
|
|| status == -34018
|
||||||
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
|
|
||||||
|
private static let installMarkerAccount = "install_lifecycle_marker"
|
||||||
|
private static let installMarkerDefaultsKey = "keychain.installLifecycleMarker.present"
|
||||||
|
private static let installCleanupPendingDefaultsKey =
|
||||||
|
"keychain.installLifecycleCleanup.pending"
|
||||||
|
|
||||||
|
/// Keychain items can survive app removal while the app container and its
|
||||||
|
/// UserDefaults do not. The first version carrying this marker bootstraps
|
||||||
|
/// without deleting existing users' identities. On a later reinstall, a
|
||||||
|
/// surviving keychain marker plus a missing defaults marker proves the app
|
||||||
|
/// container was replaced, so stale secrets are removed before use.
|
||||||
|
@discardableResult
|
||||||
|
private func reconcileInstallLifecycle() -> Bool {
|
||||||
|
let defaults = UserDefaults.standard
|
||||||
|
let containerKnowsMarker = defaults.bool(forKey: Self.installMarkerDefaultsKey)
|
||||||
|
let cleanupPending = defaults.bool(
|
||||||
|
forKey: Self.installCleanupPendingDefaultsKey
|
||||||
|
)
|
||||||
|
|
||||||
|
let markerRead = retrieveDataWithResult(forKey: Self.installMarkerAccount)
|
||||||
|
switch Self.installLifecycleAction(
|
||||||
|
containerKnowsMarker: containerKnowsMarker,
|
||||||
|
cleanupPending: cleanupPending,
|
||||||
|
markerRead: markerRead
|
||||||
|
) {
|
||||||
|
case .markerPresent:
|
||||||
|
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
|
||||||
|
return true
|
||||||
|
|
||||||
|
case .bootstrapMarker:
|
||||||
|
if case .success = saveDataWithResult(Data([1]), forKey: Self.installMarkerAccount) {
|
||||||
|
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
|
||||||
|
}
|
||||||
|
// A missing marker is the intentional bootstrap path for both a
|
||||||
|
// fresh install and the first marker-carrying upgrade. Preserve
|
||||||
|
// existing users' identities even if marker creation must retry
|
||||||
|
// on a later construction.
|
||||||
|
return true
|
||||||
|
|
||||||
|
case .clearStaleKeys:
|
||||||
|
// Establish a container-local retry latch before deleting the
|
||||||
|
// surviving keychain marker. If the process exits or any keychain
|
||||||
|
// operation fails, the next launch retries even when that marker
|
||||||
|
// can no longer be read.
|
||||||
|
defaults.set(true, forKey: Self.installCleanupPendingDefaultsKey)
|
||||||
|
guard defaults.synchronize(),
|
||||||
|
defaults.bool(forKey: Self.installCleanupPendingDefaultsKey)
|
||||||
|
else {
|
||||||
|
SecureLogger.error(
|
||||||
|
"Could not persist reinstall keychain-cleanup intent",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
guard deleteAllKeychainData() else {
|
||||||
|
SecureLogger.error(
|
||||||
|
"Reinstall keychain cleanup incomplete; retry remains pending",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
defaults.set(true, forKey: Self.installMarkerDefaultsKey)
|
||||||
|
defaults.removeObject(
|
||||||
|
forKey: Self.installCleanupPendingDefaultsKey
|
||||||
|
)
|
||||||
|
guard defaults.synchronize(),
|
||||||
|
defaults.bool(forKey: Self.installMarkerDefaultsKey),
|
||||||
|
!defaults.bool(
|
||||||
|
forKey: Self.installCleanupPendingDefaultsKey
|
||||||
|
)
|
||||||
|
else {
|
||||||
|
// Preserve the fail-closed state in memory and make one more
|
||||||
|
// best-effort persistence attempt before startup continues.
|
||||||
|
defaults.set(
|
||||||
|
true,
|
||||||
|
forKey: Self.installCleanupPendingDefaultsKey
|
||||||
|
)
|
||||||
|
_ = defaults.synchronize()
|
||||||
|
SecureLogger.error(
|
||||||
|
"Could not commit reinstall keychain-cleanup state; retry remains pending",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
|
||||||
|
case .retryLater:
|
||||||
|
// Do not guess that a temporarily unreadable marker is absent.
|
||||||
|
// An established container may keep using ordinary protected-data
|
||||||
|
// semantics: reads fail while locked and recover after unlock. A
|
||||||
|
// container that has not committed the marker must stay blocked
|
||||||
|
// until the marker becomes readable and this state machine can
|
||||||
|
// distinguish bootstrap from reinstall.
|
||||||
|
return containerKnowsMarker
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One-time upgrade of items created under WhenUnlocked. New saves get
|
/// One-time upgrade of items created under WhenUnlocked. New saves get
|
||||||
/// the right class on their own (saves are delete-then-add), but the
|
/// the right class on their own (saves are delete-then-add), but the
|
||||||
/// long-lived identity keys are written once and would otherwise stay
|
/// long-lived identity keys are written once and would otherwise stay
|
||||||
/// unreadable while the device is locked.
|
/// unreadable while the device is locked.
|
||||||
private func migrateAccessibilityIfNeeded() {
|
private func migrateAccessibilityIfNeeded() {
|
||||||
let flag = "keychain.accessibility.afterFirstUnlock.migrated"
|
let flag = "keychain.accessibility.afterFirstUnlockThisDeviceOnly.migrated"
|
||||||
guard !UserDefaults.standard.bool(forKey: flag) else { return }
|
guard !UserDefaults.standard.bool(forKey: flag) else { return }
|
||||||
|
|
||||||
let query: [String: Any] = [
|
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
|
||||||
kSecAttrService as String: service
|
|
||||||
]
|
|
||||||
let update: [String: Any] = [
|
let update: [String: Any] = [
|
||||||
kSecAttrAccessible as String: Self.itemAccessibility
|
kSecAttrAccessible as String: Self.itemAccessibility
|
||||||
]
|
]
|
||||||
let status = SecItemUpdate(query as CFDictionary, update as CFDictionary)
|
let completed = Self.migrateAccessibilityForApplicationOwnedServices(
|
||||||
switch status {
|
primaryService: service
|
||||||
case errSecSuccess, errSecItemNotFound:
|
) { serviceName in
|
||||||
// Nothing to migrate on a fresh install; both are terminal.
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: serviceName
|
||||||
|
]
|
||||||
|
return SecItemUpdate(
|
||||||
|
query as CFDictionary,
|
||||||
|
update as CFDictionary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if completed {
|
||||||
|
// Missing services on a fresh install are terminal, but the flag is
|
||||||
|
// set only after every application-owned service was considered.
|
||||||
UserDefaults.standard.set(true, forKey: flag)
|
UserDefaults.standard.set(true, forKey: flag)
|
||||||
SecureLogger.info("Keychain accessibility migrated to AfterFirstUnlock (status \(status))", category: .keychain)
|
SecureLogger.info(
|
||||||
default:
|
"Keychain accessibility migrated to AfterFirstUnlockThisDeviceOnly",
|
||||||
|
category: .keychain
|
||||||
|
)
|
||||||
|
} else {
|
||||||
// Likely errSecInteractionNotAllowed (relaunched while locked) —
|
// Likely errSecInteractionNotAllowed (relaunched while locked) —
|
||||||
// leave the flag unset so the next launch retries.
|
// leave the flag unset so the next launch retries.
|
||||||
SecureLogger.warning("Keychain accessibility migration deferred (status \(status))", category: .keychain)
|
SecureLogger.warning(
|
||||||
|
"Keychain accessibility migration deferred for at least one application-owned service",
|
||||||
|
category: .keychain
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
private func installAccessAllowed() -> Bool {
|
||||||
|
#if os(iOS)
|
||||||
|
return installAccessGate.allowsAccess { [self] in
|
||||||
|
guard reconcileInstallLifecycle() else { return false }
|
||||||
|
migrateAccessibilityIfNeeded()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
return true
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Identity Keys
|
// MARK: - Identity Keys
|
||||||
|
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||||
|
guard installAccessAllowed() else {
|
||||||
|
SecureLogger.logKeyOperation(.save, keyType: key, success: false)
|
||||||
|
return false
|
||||||
|
}
|
||||||
let fullKey = "identity_\(key)"
|
let fullKey = "identity_\(key)"
|
||||||
let result = saveData(keyData, forKey: fullKey)
|
let result = saveData(keyData, forKey: fullKey)
|
||||||
SecureLogger.logKeyOperation(.save, keyType: key, success: result)
|
SecureLogger.logKeyOperation(.save, keyType: key, success: result)
|
||||||
@@ -95,11 +371,16 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getIdentityKey(forKey key: String) -> Data? {
|
func getIdentityKey(forKey key: String) -> Data? {
|
||||||
|
guard installAccessAllowed() else { return nil }
|
||||||
let fullKey = "identity_\(key)"
|
let fullKey = "identity_\(key)"
|
||||||
return retrieveData(forKey: fullKey)
|
return retrieveData(forKey: fullKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteIdentityKey(forKey key: String) -> Bool {
|
func deleteIdentityKey(forKey key: String) -> Bool {
|
||||||
|
guard installAccessAllowed() else {
|
||||||
|
SecureLogger.logKeyOperation(.delete, keyType: key, success: false)
|
||||||
|
return false
|
||||||
|
}
|
||||||
let result = delete(forKey: "identity_\(key)")
|
let result = delete(forKey: "identity_\(key)")
|
||||||
SecureLogger.logKeyOperation(.delete, keyType: key, success: result)
|
SecureLogger.logKeyOperation(.delete, keyType: key, success: result)
|
||||||
return result
|
return result
|
||||||
@@ -110,12 +391,14 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
/// Get identity key with detailed result for proper error handling
|
/// Get identity key with detailed result for proper error handling
|
||||||
/// Distinguishes between missing keys (expected) and critical failures
|
/// Distinguishes between missing keys (expected) and critical failures
|
||||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
||||||
|
guard installAccessAllowed() else { return .accessDenied }
|
||||||
let fullKey = "identity_\(key)"
|
let fullKey = "identity_\(key)"
|
||||||
return retrieveDataWithResult(forKey: fullKey)
|
return retrieveDataWithResult(forKey: fullKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save identity key with detailed result and retry logic for transient errors
|
/// Save identity key with detailed result and retry logic for transient errors
|
||||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
||||||
|
guard installAccessAllowed() else { return .accessDenied }
|
||||||
let fullKey = "identity_\(key)"
|
let fullKey = "identity_\(key)"
|
||||||
return saveDataWithResult(keyData, forKey: fullKey)
|
return saveDataWithResult(keyData, forKey: fullKey)
|
||||||
}
|
}
|
||||||
@@ -385,114 +668,165 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
// Delete ALL keychain data for panic mode
|
// Delete ALL keychain data for panic mode
|
||||||
func deleteAllKeychainData() -> Bool {
|
func deleteAllKeychainData() -> Bool {
|
||||||
SecureLogger.warning("Panic mode - deleting all keychain data", category: .security)
|
SecureLogger.warning("Panic mode - deleting all keychain data", category: .security)
|
||||||
|
|
||||||
var totalDeleted = 0
|
let ownedServices = Set(
|
||||||
|
Self.applicationOwnedKeychainServices(
|
||||||
// Search without service restriction to catch all items
|
primaryService: service
|
||||||
|
)
|
||||||
|
)
|
||||||
|
var enumerationCompleted = true
|
||||||
let searchQuery: [String: Any] = [
|
let searchQuery: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||||
kSecReturnAttributes as String: true
|
kSecReturnAttributes as String: true
|
||||||
]
|
]
|
||||||
|
|
||||||
var result: AnyObject?
|
var result: AnyObject?
|
||||||
let searchStatus = SecItemCopyMatching(searchQuery as CFDictionary, &result)
|
let searchStatus = SecItemCopyMatching(
|
||||||
|
searchQuery as CFDictionary,
|
||||||
if searchStatus == errSecSuccess, let items = result as? [[String: Any]] {
|
&result
|
||||||
|
)
|
||||||
|
switch searchStatus {
|
||||||
|
case errSecSuccess:
|
||||||
|
guard let items = result as? [[String: Any]] else {
|
||||||
|
enumerationCompleted = false
|
||||||
|
SecureLogger.error(
|
||||||
|
"Unable to decode application-owned keychain inventory",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve the access-group sweep for custom services that are
|
||||||
|
// not yet in the declared legacy-service list.
|
||||||
for item in items {
|
for item in items {
|
||||||
var shouldDelete = false
|
let account =
|
||||||
let account = item[kSecAttrAccount as String] as? String ?? ""
|
item[kSecAttrAccount as String] as? String ?? ""
|
||||||
let service = item[kSecAttrService as String] as? String ?? ""
|
let itemService =
|
||||||
let accessGroup = item[kSecAttrAccessGroup as String] as? String
|
item[kSecAttrService as String] as? String ?? ""
|
||||||
|
let accessGroup =
|
||||||
// More precise deletion criteria:
|
item[kSecAttrAccessGroup as String] as? String
|
||||||
// 1. Check for our specific app group
|
guard accessGroup == appGroup
|
||||||
// 2. OR check for our exact service name
|
|| ownedServices.contains(itemService)
|
||||||
// 3. OR check for known legacy service names
|
else {
|
||||||
if accessGroup == appGroup {
|
continue
|
||||||
shouldDelete = true
|
|
||||||
} else if service == self.service {
|
|
||||||
shouldDelete = true
|
|
||||||
} else if [
|
|
||||||
"com.bitchat.passwords",
|
|
||||||
"com.bitchat.deviceidentity",
|
|
||||||
"com.bitchat.noise.identity",
|
|
||||||
"chat.bitchat.passwords",
|
|
||||||
"bitchat.keychain",
|
|
||||||
"bitchat",
|
|
||||||
"com.bitchat"
|
|
||||||
].contains(service) {
|
|
||||||
shouldDelete = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if shouldDelete {
|
var deleteQuery: [String: Any] = [
|
||||||
// Build delete query with all available attributes for precise deletion
|
kSecClass as String: kSecClassGenericPassword
|
||||||
var deleteQuery: [String: Any] = [
|
]
|
||||||
kSecClass as String: kSecClassGenericPassword
|
if !account.isEmpty {
|
||||||
]
|
deleteQuery[kSecAttrAccount as String] = account
|
||||||
|
}
|
||||||
if !account.isEmpty {
|
if !itemService.isEmpty {
|
||||||
deleteQuery[kSecAttrAccount as String] = account
|
deleteQuery[kSecAttrService as String] = itemService
|
||||||
}
|
}
|
||||||
if !service.isEmpty {
|
if let accessGroup,
|
||||||
deleteQuery[kSecAttrService as String] = service
|
!accessGroup.isEmpty,
|
||||||
}
|
accessGroup != "test" {
|
||||||
|
deleteQuery[kSecAttrAccessGroup as String] = accessGroup
|
||||||
// Add access group if present
|
}
|
||||||
if let accessGroup = item[kSecAttrAccessGroup as String] as? String,
|
|
||||||
!accessGroup.isEmpty && accessGroup != "test" {
|
let status = SecItemDelete(deleteQuery as CFDictionary)
|
||||||
deleteQuery[kSecAttrAccessGroup as String] = accessGroup
|
if status != errSecSuccess && status != errSecItemNotFound {
|
||||||
}
|
enumerationCompleted = false
|
||||||
|
SecureLogger.error(
|
||||||
let deleteStatus = SecItemDelete(deleteQuery as CFDictionary)
|
NSError(domain: "Keychain", code: Int(status)),
|
||||||
if deleteStatus == errSecSuccess {
|
context: "Unable to delete enumerated application-owned keychain item",
|
||||||
totalDeleted += 1
|
category: .keychain
|
||||||
SecureLogger.info("Deleted keychain item: \(account) from \(service)", category: .keychain)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case errSecItemNotFound:
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
enumerationCompleted = false
|
||||||
|
SecureLogger.error(
|
||||||
|
NSError(domain: "Keychain", code: Int(searchStatus)),
|
||||||
|
context: "Unable to enumerate application-owned keychain items",
|
||||||
|
category: .keychain
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also try to delete by known service names and app group
|
// Bulk deletion by every application-owned service is authoritative
|
||||||
// This catches any items that might have been missed above
|
// and idempotent. It also verifies that every known service scope is
|
||||||
let knownServices = [
|
// empty even when the inventory pass found no items.
|
||||||
self.service, // Current service name
|
let servicesCompleted =
|
||||||
"com.bitchat.passwords",
|
Self.deleteApplicationOwnedKeychainServices(
|
||||||
"com.bitchat.deviceidentity",
|
primaryService: service
|
||||||
"com.bitchat.noise.identity",
|
) { serviceName in
|
||||||
"chat.bitchat.passwords",
|
let query: [String: Any] = [
|
||||||
"chat.bitchat.nostr",
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
"bitchat.keychain",
|
kSecAttrService as String: serviceName
|
||||||
"bitchat",
|
]
|
||||||
"com.bitchat"
|
let status = SecItemDelete(query as CFDictionary)
|
||||||
]
|
if status != errSecSuccess && status != errSecItemNotFound {
|
||||||
|
SecureLogger.error(
|
||||||
for serviceName in knownServices {
|
NSError(domain: "Keychain", code: Int(status)),
|
||||||
let query: [String: Any] = [
|
context: "Unable to delete application-owned keychain service \(serviceName)",
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
category: .keychain
|
||||||
kSecAttrService as String: serviceName
|
)
|
||||||
]
|
}
|
||||||
|
return status
|
||||||
let status = SecItemDelete(query as CFDictionary)
|
|
||||||
if status == errSecSuccess {
|
|
||||||
totalDeleted += 1
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// Historical builds attempted this application-group identifier as a
|
||||||
// Also delete by app group to ensure complete cleanup
|
// keychain access group. It is not currently entitled, so -34018
|
||||||
|
// means the scope is inapplicable rather than partially deleted.
|
||||||
let groupQuery: [String: Any] = [
|
let groupQuery: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrAccessGroup as String: appGroup
|
kSecAttrAccessGroup as String: appGroup
|
||||||
]
|
]
|
||||||
|
|
||||||
let groupStatus = SecItemDelete(groupQuery as CFDictionary)
|
let groupStatus = SecItemDelete(groupQuery as CFDictionary)
|
||||||
if groupStatus == errSecSuccess {
|
let groupCompleted = Self.completedApplicationGroupDelete(
|
||||||
totalDeleted += 1
|
status: groupStatus
|
||||||
|
)
|
||||||
|
if !groupCompleted {
|
||||||
|
SecureLogger.error(
|
||||||
|
NSError(domain: "Keychain", code: Int(groupStatus)),
|
||||||
|
context: "Unable to delete historical application-group keychain items",
|
||||||
|
category: .keychain
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
SecureLogger.warning("Panic mode cleanup completed. Total items deleted: \(totalDeleted)", category: .keychain)
|
var markerCompleted = true
|
||||||
|
#if os(iOS)
|
||||||
return totalDeleted > 0
|
// The non-secret marker is intentionally recreated after a panic so a
|
||||||
|
// later uninstall/reinstall can still be distinguished from an in-place
|
||||||
|
// upgrade. Do not commit the container-side marker here: reinstall
|
||||||
|
// reconciliation may still need to retry an incomplete cleanup.
|
||||||
|
if case .success = saveDataWithResult(
|
||||||
|
Data([1]),
|
||||||
|
forKey: Self.installMarkerAccount
|
||||||
|
) {
|
||||||
|
markerCompleted = true
|
||||||
|
} else {
|
||||||
|
markerCompleted = false
|
||||||
|
SecureLogger.error(
|
||||||
|
"Unable to restore install-lifecycle keychain marker",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
let completed =
|
||||||
|
enumerationCompleted
|
||||||
|
&& servicesCompleted
|
||||||
|
&& groupCompleted
|
||||||
|
&& markerCompleted
|
||||||
|
if completed {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"Panic mode keychain cleanup completed",
|
||||||
|
category: .keychain
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
SecureLogger.error(
|
||||||
|
"Panic mode keychain cleanup incomplete",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return completed
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Security Utilities
|
// MARK: - Security Utilities
|
||||||
@@ -518,6 +852,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
// MARK: - Debug
|
// MARK: - Debug
|
||||||
|
|
||||||
func verifyIdentityKeyExists() -> Bool {
|
func verifyIdentityKeyExists() -> Bool {
|
||||||
|
guard installAccessAllowed() else { return false }
|
||||||
let key = "identity_noiseStaticKey"
|
let key = "identity_noiseStaticKey"
|
||||||
return retrieveData(forKey: key) != nil
|
return retrieveData(forKey: key) != nil
|
||||||
}
|
}
|
||||||
@@ -526,18 +861,40 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
/// Save data with a custom service name
|
/// Save data with a custom service name
|
||||||
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
|
func save(key: String, data: Data, service customService: String, accessible: CFString?) {
|
||||||
var query: [String: Any] = [
|
guard installAccessAllowed() else { return }
|
||||||
|
let primaryKeyQuery: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: customService,
|
kSecAttrService as String: customService,
|
||||||
kSecAttrAccount as String: key,
|
kSecAttrAccount as String: key
|
||||||
kSecValueData as String: data
|
|
||||||
]
|
]
|
||||||
if let accessible = accessible {
|
var addQuery = primaryKeyQuery
|
||||||
query[kSecAttrAccessible as String] = accessible
|
addQuery.merge([
|
||||||
}
|
kSecValueData as String: data,
|
||||||
|
kSecAttrAccessible as String: accessible ?? Self.itemAccessibility,
|
||||||
|
kSecAttrSynchronizable as String: false
|
||||||
|
]) { _, new in new }
|
||||||
|
|
||||||
SecItemDelete(query as CFDictionary)
|
// Delete by the item's primary key only. Value/accessibility fields
|
||||||
SecItemAdd(query as CFDictionary, nil)
|
// are add attributes, not valid selectors for replacing an existing
|
||||||
|
// item; including them can leave the old item in place and make the
|
||||||
|
// subsequent add fail as a duplicate.
|
||||||
|
let deleteStatus = SecItemDelete(primaryKeyQuery as CFDictionary)
|
||||||
|
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
|
||||||
|
SecureLogger.error(
|
||||||
|
NSError(domain: "Keychain", code: Int(deleteStatus)),
|
||||||
|
context: "Unable to replace custom-service keychain item",
|
||||||
|
category: .keychain
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
||||||
|
if addStatus != errSecSuccess {
|
||||||
|
SecureLogger.error(
|
||||||
|
NSError(domain: "Keychain", code: Int(addStatus)),
|
||||||
|
context: "Unable to save custom-service keychain item",
|
||||||
|
category: .keychain
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load data from a custom service
|
/// Load data from a custom service
|
||||||
@@ -551,6 +908,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
/// Load custom-service data without collapsing `itemNotFound` and
|
/// Load custom-service data without collapsing `itemNotFound` and
|
||||||
/// protected-data/keychain failures into the same nil result.
|
/// protected-data/keychain failures into the same nil result.
|
||||||
func loadWithResult(key: String, service customService: String) -> KeychainReadResult {
|
func loadWithResult(key: String, service customService: String) -> KeychainReadResult {
|
||||||
|
guard installAccessAllowed() else { return .accessDenied }
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: customService,
|
kSecAttrService as String: customService,
|
||||||
@@ -565,6 +923,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
/// Delete data from a custom service
|
/// Delete data from a custom service
|
||||||
func delete(key: String, service customService: String) {
|
func delete(key: String, service customService: String) {
|
||||||
|
guard installAccessAllowed() else { return }
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: customService,
|
kSecAttrService as String: customService,
|
||||||
@@ -576,6 +935,7 @@ final class KeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
/// Delete every item stored under a custom service
|
/// Delete every item stored under a custom service
|
||||||
func deleteAll(service customService: String) {
|
func deleteAll(service customService: String) {
|
||||||
|
guard installAccessAllowed() else { return }
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String: kSecClassGenericPassword,
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
kSecAttrService as String: customService,
|
kSecAttrService as String: customService,
|
||||||
|
|||||||
@@ -154,6 +154,19 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stops all internet-facing work at the synchronous panic boundary.
|
||||||
|
/// `start()` may be called again only after the full wipe commits.
|
||||||
|
func stopForPanic() {
|
||||||
|
cancellables.removeAll()
|
||||||
|
started = false
|
||||||
|
reachabilityMonitor.stop()
|
||||||
|
activationAllowed = false
|
||||||
|
torAutoStartDesired = false
|
||||||
|
relayController.disconnect()
|
||||||
|
torController.setAutoStartAllowed(false)
|
||||||
|
applyTorState(torDesired: false)
|
||||||
|
}
|
||||||
|
|
||||||
func setUserTorEnabled(_ enabled: Bool) {
|
func setUserTorEnabled(_ enabled: Bool) {
|
||||||
guard enabled != userTorEnabled else { return }
|
guard enabled != userTorEnabled else { return }
|
||||||
userTorEnabled = enabled
|
userTorEnabled = enabled
|
||||||
@@ -167,6 +180,7 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func reevaluate() {
|
private func reevaluate() {
|
||||||
|
guard started else { return }
|
||||||
let allowed = effectiveAllowed()
|
let allowed = effectiveAllowed()
|
||||||
let torDesired = allowed && userTorEnabled
|
let torDesired = allowed && userTorEnabled
|
||||||
let statusChanged = allowed != activationAllowed
|
let statusChanged = allowed != activationAllowed
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ protocol NetworkReachabilityMonitoring: AnyObject {
|
|||||||
var reachabilityPublisher: AnyPublisher<Bool, Never> { get }
|
var reachabilityPublisher: AnyPublisher<Bool, Never> { get }
|
||||||
/// Begin monitoring. Idempotent.
|
/// Begin monitoring. Idempotent.
|
||||||
func start()
|
func start()
|
||||||
|
/// Stop monitoring and discard pending debounce work. Idempotent.
|
||||||
|
func stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure debounce/decision logic for reachability, split out so it can be
|
/// Pure debounce/decision logic for reachability, split out so it can be
|
||||||
@@ -88,18 +90,6 @@ struct ReachabilityDebounce {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Always-reachable stub. Used as the default in tests and as the fallback on
|
|
||||||
/// platforms without the Network framework, so reachability never suppresses
|
|
||||||
/// startup by itself.
|
|
||||||
@MainActor
|
|
||||||
final class AlwaysReachableMonitor: NetworkReachabilityMonitoring {
|
|
||||||
var isReachable: Bool { true }
|
|
||||||
var reachabilityPublisher: AnyPublisher<Bool, Never> {
|
|
||||||
Empty(completeImmediately: false).eraseToAnyPublisher()
|
|
||||||
}
|
|
||||||
func start() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `NWPathMonitor`-backed reachability. All state lives on the main actor; the
|
/// `NWPathMonitor`-backed reachability. All state lives on the main actor; the
|
||||||
/// background path callback hops here before touching the debounce.
|
/// background path callback hops here before touching the debounce.
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -146,6 +136,18 @@ final class NWPathReachabilityMonitor: NetworkReachabilityMonitoring {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
guard started else { return }
|
||||||
|
started = false
|
||||||
|
flushWorkItem?.cancel()
|
||||||
|
flushWorkItem = nil
|
||||||
|
#if canImport(Network)
|
||||||
|
monitor?.pathUpdateHandler = nil
|
||||||
|
monitor?.cancel()
|
||||||
|
monitor = nil
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
/// Feed an observation into the debounce and publish committed changes.
|
/// Feed an observation into the debounce and publish committed changes.
|
||||||
/// Exposed internally so higher layers/tests could drive it if needed.
|
/// Exposed internally so higher layers/tests could drive it if needed.
|
||||||
func ingest(reachable: Bool) {
|
func ingest(reachable: Bool) {
|
||||||
|
|||||||
@@ -664,6 +664,18 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
/// Process an incoming handshake message
|
/// Process an incoming handshake message
|
||||||
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
|
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
|
||||||
|
try processHandshakeMessageWithResult(
|
||||||
|
from: peerID,
|
||||||
|
message: message
|
||||||
|
).response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process an incoming handshake message and report whether the exact
|
||||||
|
/// session that consumed it completed authenticated establishment.
|
||||||
|
func processHandshakeMessageWithResult(
|
||||||
|
from peerID: PeerID,
|
||||||
|
message: Data
|
||||||
|
) throws -> NoiseHandshakeProcessingResult {
|
||||||
|
|
||||||
// Validate peer ID
|
// Validate peer ID
|
||||||
guard peerID.isValid else {
|
guard peerID.isValid else {
|
||||||
@@ -685,11 +697,14 @@ final class NoiseEncryptionService {
|
|||||||
|
|
||||||
// For handshakes, we process the raw data directly without NoiseMessage wrapper
|
// For handshakes, we process the raw data directly without NoiseMessage wrapper
|
||||||
// The Noise protocol handles its own message format
|
// The Noise protocol handles its own message format
|
||||||
let responsePayload = try sessionManager.handleIncomingHandshake(from: peerID, message: message)
|
let result = try sessionManager.handleIncomingHandshakeWithResult(
|
||||||
|
from: peerID,
|
||||||
|
message: message
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
// Return raw response without wrapper
|
// Return raw response without wrapper
|
||||||
return responsePayload
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if we have an established session with a peer
|
/// Check if we have an established session with a peer
|
||||||
|
|||||||
@@ -114,7 +114,6 @@ enum TransportConfig {
|
|||||||
// UI sleeps/delays
|
// UI sleeps/delays
|
||||||
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
|
||||||
static let uiStartupPhaseDurationSeconds: TimeInterval = 2.0
|
static let uiStartupPhaseDurationSeconds: TimeInterval = 2.0
|
||||||
static let uiAsyncShortSleepNs: UInt64 = 100_000_000
|
|
||||||
static let uiReadReceiptRetryShortSeconds: TimeInterval = 0.1
|
static let uiReadReceiptRetryShortSeconds: TimeInterval = 0.1
|
||||||
static let uiReadReceiptRetryLongSeconds: TimeInterval = 0.5
|
static let uiReadReceiptRetryLongSeconds: TimeInterval = 0.5
|
||||||
static let uiBatchDispatchStaggerSeconds: TimeInterval = 0.15
|
static let uiBatchDispatchStaggerSeconds: TimeInterval = 0.15
|
||||||
|
|||||||
@@ -226,6 +226,21 @@ final class ChatLiveVoiceCoordinator {
|
|||||||
assemblies.values.contains { $0.messageID == message.id }
|
assemblies.values.contains { $0.messageID == message.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stop every live file handle/player before the panic media directory is
|
||||||
|
/// removed. This prevents an in-flight assembly from continuing to write
|
||||||
|
/// through an unlinked file after the wipe returns.
|
||||||
|
func resetForPanic() {
|
||||||
|
for assembly in Array(assemblies.values) {
|
||||||
|
cancelAssembly(assembly)
|
||||||
|
}
|
||||||
|
for player in drainingPlayers.values {
|
||||||
|
player.stop()
|
||||||
|
}
|
||||||
|
drainingPlayers.removeAll(keepingCapacity: false)
|
||||||
|
finishedBursts.removeAll(keepingCapacity: false)
|
||||||
|
updatePublicTalkerIndicator()
|
||||||
|
}
|
||||||
|
|
||||||
/// Called for every inbound private message: when it is the finalized
|
/// Called for every inbound private message: when it is the finalized
|
||||||
/// voice note of a burst we assembled (matched by burst ID in the file
|
/// voice note of a burst we assembled (matched by burst ID in the file
|
||||||
/// name), swap it into the existing live bubble and report `true` so the
|
/// name), swap it into the existing live bubble and report `true` so the
|
||||||
|
|||||||
@@ -72,15 +72,98 @@ extension ChatViewModel: ChatMediaTransferContext {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Synchronous boundary between detached image writers and panic deletion.
|
||||||
|
///
|
||||||
|
/// Invalidation closes admission before waiting for writers that already
|
||||||
|
/// entered. Those writers never need the main actor while inside the boundary,
|
||||||
|
/// so a synchronous panic transaction can safely join them and then delete
|
||||||
|
/// every output before reporting completion.
|
||||||
|
private final class ImagePreparationBarrier: @unchecked Sendable {
|
||||||
|
private let condition = NSCondition()
|
||||||
|
private var generation: UInt64 = 0
|
||||||
|
private var activeOperations = 0
|
||||||
|
|
||||||
|
var currentGeneration: UInt64 {
|
||||||
|
condition.lock()
|
||||||
|
defer { condition.unlock() }
|
||||||
|
return generation
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCurrent(_ candidate: UInt64) -> Bool {
|
||||||
|
condition.lock()
|
||||||
|
defer { condition.unlock() }
|
||||||
|
return generation == candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
func performIfCurrent<T>(
|
||||||
|
generation candidate: UInt64,
|
||||||
|
operation: () throws -> T
|
||||||
|
) rethrows -> T? {
|
||||||
|
condition.lock()
|
||||||
|
guard generation == candidate else {
|
||||||
|
condition.unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
activeOperations += 1
|
||||||
|
condition.unlock()
|
||||||
|
|
||||||
|
defer {
|
||||||
|
condition.lock()
|
||||||
|
activeOperations -= 1
|
||||||
|
if activeOperations == 0 {
|
||||||
|
condition.broadcast()
|
||||||
|
}
|
||||||
|
condition.unlock()
|
||||||
|
}
|
||||||
|
return try operation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidateAndWait() {
|
||||||
|
condition.lock()
|
||||||
|
generation &+= 1
|
||||||
|
while activeOperations > 0 {
|
||||||
|
condition.wait()
|
||||||
|
}
|
||||||
|
condition.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs synchronous media encoding and file I/O on a dispatch worker.
|
||||||
|
///
|
||||||
|
/// These operations can legitimately block. Keeping them off Swift's
|
||||||
|
/// cooperative executor preserves forward progress when several transfers or
|
||||||
|
/// test doubles wait at the same time.
|
||||||
|
private func runBlockingMediaPreparation<T>(
|
||||||
|
_ operation: @escaping @Sendable () throws -> T
|
||||||
|
) async throws -> T {
|
||||||
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
do {
|
||||||
|
continuation.resume(returning: try operation())
|
||||||
|
} catch {
|
||||||
|
continuation.resume(throwing: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class ChatMediaTransferCoordinator {
|
final class ChatMediaTransferCoordinator {
|
||||||
private unowned let context: any ChatMediaTransferContext
|
private unowned let context: any ChatMediaTransferContext
|
||||||
|
private let prepareImagePacket: @Sendable (URL) throws -> ChatPreparedImage
|
||||||
|
private let imagePreparationBarrier = ImagePreparationBarrier()
|
||||||
|
|
||||||
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
||||||
private(set) var messageIDToTransferId: [String: String] = [:]
|
private(set) var messageIDToTransferId: [String: String] = [:]
|
||||||
|
|
||||||
init(context: any ChatMediaTransferContext) {
|
init(
|
||||||
|
context: any ChatMediaTransferContext,
|
||||||
|
prepareImagePacket: @escaping @Sendable (URL) throws -> ChatPreparedImage = {
|
||||||
|
try ChatMediaPreparation.prepareImagePacket(from: $0)
|
||||||
|
}
|
||||||
|
) {
|
||||||
self.context = context
|
self.context = context
|
||||||
|
self.prepareImagePacket = prepareImagePacket
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendVoiceNote(at url: URL) {
|
func sendVoiceNote(at url: URL) {
|
||||||
@@ -98,13 +181,19 @@ final class ChatMediaTransferCoordinator {
|
|||||||
)
|
)
|
||||||
let messageID = message.id
|
let messageID = message.id
|
||||||
let transferId = makeTransferID(messageID: messageID)
|
let transferId = makeTransferID(messageID: messageID)
|
||||||
|
let generation = imagePreparationBarrier.currentGeneration
|
||||||
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
do {
|
do {
|
||||||
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
|
let packet = try await runBlockingMediaPreparation {
|
||||||
|
try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
|
||||||
|
}
|
||||||
|
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self else { return }
|
guard let self,
|
||||||
|
self.imagePreparationBarrier.isCurrent(generation) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
self.registerTransfer(transferId: transferId, messageID: messageID)
|
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||||
if let peerID = targetPeer {
|
if let peerID = targetPeer {
|
||||||
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||||
@@ -116,13 +205,19 @@ final class ChatMediaTransferCoordinator {
|
|||||||
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
|
||||||
try? FileManager.default.removeItem(at: url)
|
try? FileManager.default.removeItem(at: url)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self else { return }
|
guard let self,
|
||||||
|
self.imagePreparationBarrier.isCurrent(generation) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
|
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self] in
|
||||||
guard let self else { return }
|
guard let self,
|
||||||
|
self.imagePreparationBarrier.isCurrent(generation) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
|
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,11 +227,27 @@ final class ChatMediaTransferCoordinator {
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
func processThenSendImage(_ image: UIImage?) {
|
func processThenSendImage(_ image: UIImage?) {
|
||||||
guard let image else { return }
|
guard let image else { return }
|
||||||
Task.detached { [weak self] in
|
let generation = imagePreparationBarrier.currentGeneration
|
||||||
|
let barrier = imagePreparationBarrier
|
||||||
|
Task.detached(priority: .userInitiated) { [weak self, barrier] in
|
||||||
do {
|
do {
|
||||||
let processedURL = try ImageUtils.processImage(image)
|
let processedURL = try await runBlockingMediaPreparation {
|
||||||
await MainActor.run { [weak self] in
|
try barrier.performIfCurrent(
|
||||||
guard let self else { return }
|
generation: generation,
|
||||||
|
operation: {
|
||||||
|
try ImageUtils.processImage(image)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard let processedURL else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await MainActor.run { [weak self, barrier] in
|
||||||
|
guard let self,
|
||||||
|
barrier.isCurrent(generation) else {
|
||||||
|
try? FileManager.default.removeItem(at: processedURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
self.sendImage(from: processedURL)
|
self.sendImage(from: processedURL)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -147,11 +258,27 @@ final class ChatMediaTransferCoordinator {
|
|||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
func processThenSendImage(from url: URL?) {
|
func processThenSendImage(from url: URL?) {
|
||||||
guard let url else { return }
|
guard let url else { return }
|
||||||
Task.detached { [weak self] in
|
let generation = imagePreparationBarrier.currentGeneration
|
||||||
|
let barrier = imagePreparationBarrier
|
||||||
|
Task.detached(priority: .userInitiated) { [weak self, barrier] in
|
||||||
do {
|
do {
|
||||||
let processedURL = try ImageUtils.processImage(at: url)
|
let processedURL = try await runBlockingMediaPreparation {
|
||||||
await MainActor.run { [weak self] in
|
try barrier.performIfCurrent(
|
||||||
guard let self else { return }
|
generation: generation,
|
||||||
|
operation: {
|
||||||
|
try ImageUtils.processImage(at: url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard let processedURL else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await MainActor.run { [weak self, barrier] in
|
||||||
|
guard let self,
|
||||||
|
barrier.isCurrent(generation) else {
|
||||||
|
try? FileManager.default.removeItem(at: processedURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
self.sendImage(from: processedURL)
|
self.sendImage(from: processedURL)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -170,6 +297,7 @@ final class ChatMediaTransferCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let targetPeer = context.selectedPrivateChatPeer
|
let targetPeer = context.selectedPrivateChatPeer
|
||||||
|
let generation = imagePreparationBarrier.currentGeneration
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try ImageUtils.validateImageSource(at: sourceURL)
|
try ImageUtils.validateImageSource(at: sourceURL)
|
||||||
@@ -179,12 +307,28 @@ final class ChatMediaTransferCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Task.detached(priority: .userInitiated) { [weak self] in
|
let prepareImagePacket = self.prepareImagePacket
|
||||||
|
let barrier = imagePreparationBarrier
|
||||||
|
Task.detached(priority: .userInitiated) { [weak self, barrier] in
|
||||||
do {
|
do {
|
||||||
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
|
let prepared = try await runBlockingMediaPreparation {
|
||||||
|
try barrier.performIfCurrent(
|
||||||
|
generation: generation,
|
||||||
|
operation: {
|
||||||
|
try prepareImagePacket(sourceURL)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
guard let prepared else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self, barrier] in
|
||||||
guard let self else { return }
|
guard let self,
|
||||||
|
barrier.isCurrent(generation) else {
|
||||||
|
try? FileManager.default.removeItem(at: prepared.outputURL)
|
||||||
|
return
|
||||||
|
}
|
||||||
let message = self.enqueueMediaMessage(
|
let message = self.enqueueMediaMessage(
|
||||||
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
||||||
targetPeer: targetPeer
|
targetPeer: targetPeer
|
||||||
@@ -200,14 +344,20 @@ final class ChatMediaTransferCoordinator {
|
|||||||
}
|
}
|
||||||
} catch ChatMediaPreparationError.imageTooLarge(let size) {
|
} catch ChatMediaPreparationError.imageTooLarge(let size) {
|
||||||
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
|
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self, barrier] in
|
||||||
guard let self else { return }
|
guard let self,
|
||||||
|
barrier.isCurrent(generation) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
self.context.addSystemMessage("Image is too large to send.")
|
self.context.addSystemMessage("Image is too large to send.")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
||||||
await MainActor.run { [weak self] in
|
await MainActor.run { [weak self, barrier] in
|
||||||
guard let self else { return }
|
guard let self,
|
||||||
|
barrier.isCurrent(generation) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
self.context.addSystemMessage("Failed to prepare image for sending.")
|
self.context.addSystemMessage("Failed to prepare image for sending.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -341,6 +491,20 @@ final class ChatMediaTransferCoordinator {
|
|||||||
clearTransferMapping(for: messageID)
|
clearTransferMapping(for: messageID)
|
||||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Invalidates detached preparation work and cancels every transfer that
|
||||||
|
/// reached the transport. Closing image-preparation admission and joining
|
||||||
|
/// active synchronous writers ensures the following panic media deletion
|
||||||
|
/// is the last filesystem mutation before the transaction can complete.
|
||||||
|
func resetForPanic() {
|
||||||
|
imagePreparationBarrier.invalidateAndWait()
|
||||||
|
let transferIDs = Set(transferIdToMessageIDs.keys)
|
||||||
|
transferIdToMessageIDs.removeAll(keepingCapacity: false)
|
||||||
|
messageIDToTransferId.removeAll(keepingCapacity: false)
|
||||||
|
for transferID in transferIDs {
|
||||||
|
context.cancelTransfer(transferID)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private extension ChatMediaTransferCoordinator {
|
private extension ChatMediaTransferCoordinator {
|
||||||
|
|||||||
@@ -89,6 +89,26 @@ import UIKit
|
|||||||
#endif
|
#endif
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
struct PanicNetworkLifecycle {
|
||||||
|
let stop: @MainActor () -> Void
|
||||||
|
let restart: @MainActor () -> Void
|
||||||
|
|
||||||
|
static let noop = PanicNetworkLifecycle(stop: {}, restart: {})
|
||||||
|
|
||||||
|
static var live: PanicNetworkLifecycle {
|
||||||
|
PanicNetworkLifecycle(
|
||||||
|
stop: {
|
||||||
|
GeohashPresenceService.shared.stopForPanic()
|
||||||
|
NetworkActivationService.shared.stopForPanic()
|
||||||
|
},
|
||||||
|
restart: {
|
||||||
|
NetworkActivationService.shared.start()
|
||||||
|
GeohashPresenceService.shared.start()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Manages the application state and business logic for BitChat.
|
/// Manages the application state and business logic for BitChat.
|
||||||
/// Acts as the primary coordinator between UI components and backend services,
|
/// Acts as the primary coordinator between UI components and backend services,
|
||||||
/// implementing the BitchatDelegate protocol to handle network events.
|
/// implementing the BitchatDelegate protocol to handle network events.
|
||||||
@@ -142,6 +162,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
@Published var currentColorScheme: ColorScheme = .light
|
@Published var currentColorScheme: ColorScheme = .light
|
||||||
@Published var currentTheme: AppTheme = .matrix
|
@Published var currentTheme: AppTheme = .matrix
|
||||||
@Published var isConnected = false
|
@Published var isConnected = false
|
||||||
|
@Published private(set) var panicRecoveryBlocked = false
|
||||||
|
var networkActivationAllowed: Bool { !panicRecoveryBlocked }
|
||||||
@Published var nickname: String = "" {
|
@Published var nickname: String = "" {
|
||||||
didSet {
|
didSet {
|
||||||
// Trim whitespace whenever nickname is set; whitespace-only becomes ""
|
// Trim whitespace whenever nickname is set; whitespace-only becomes ""
|
||||||
@@ -151,7 +173,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Update mesh service nickname if it's initialized
|
// Update mesh service nickname if it's initialized
|
||||||
if !meshService.myPeerID.isEmpty {
|
if !isPanicResetting, !meshService.myPeerID.isEmpty {
|
||||||
meshService.setNickname(nickname)
|
meshService.setNickname(nickname)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,7 +199,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
|
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
|
||||||
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
|
||||||
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
|
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
|
||||||
lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator(context: self)
|
lazy var liveVoiceCoordinator = ChatLiveVoiceCoordinator(
|
||||||
|
context: self,
|
||||||
|
sweepsOnInit: !TestEnvironment.isRunningTests
|
||||||
|
)
|
||||||
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
|
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
|
||||||
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
|
lazy var groupCoordinator = ChatGroupCoordinator(context: self)
|
||||||
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
|
lazy var vouchCoordinator = ChatVouchCoordinator(context: self)
|
||||||
@@ -292,6 +317,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
var nostrRelayManager: NostrRelayManager?
|
var nostrRelayManager: NostrRelayManager?
|
||||||
private let userDefaults = UserDefaults.standard
|
private let userDefaults = UserDefaults.standard
|
||||||
let keychain: KeychainManagerProtocol
|
let keychain: KeychainManagerProtocol
|
||||||
|
private let panicRecoveryOperations: PanicRecoveryOperations
|
||||||
|
private let panicNetworkLifecycle: PanicNetworkLifecycle
|
||||||
|
private var isPanicResetting = false
|
||||||
/// Private group membership: keys in the keychain, metadata on disk.
|
/// Private group membership: keys in the keychain, metadata on disk.
|
||||||
let groupStore: GroupStore
|
let groupStore: GroupStore
|
||||||
private let nicknameKey = "bitchat.nickname"
|
private let nicknameKey = "bitchat.nickname"
|
||||||
@@ -769,7 +797,34 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
locationPresenceStore: LocationPresenceStore? = nil,
|
locationPresenceStore: LocationPresenceStore? = nil,
|
||||||
locationManager: LocationChannelManager = .shared
|
locationManager: LocationChannelManager = .shared
|
||||||
) {
|
) {
|
||||||
let meshService = BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
|
let livePanicRecoveryOperations = PanicRecoveryOperations.live()
|
||||||
|
let startSuspendedForRecovery: Bool
|
||||||
|
do {
|
||||||
|
startSuspendedForRecovery =
|
||||||
|
try livePanicRecoveryOperations.isPending()
|
||||||
|
} catch {
|
||||||
|
startSuspendedForRecovery = true
|
||||||
|
}
|
||||||
|
// Preserve the preflight decision used to defer CoreBluetooth. A
|
||||||
|
// transiently successful second read must not skip recovery and leave
|
||||||
|
// the service permanently suspended without running the wipe.
|
||||||
|
let panicRecoveryOperations = PanicRecoveryOperations(
|
||||||
|
isPending: {
|
||||||
|
if startSuspendedForRecovery {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return try livePanicRecoveryOperations.isPending()
|
||||||
|
},
|
||||||
|
begin: livePanicRecoveryOperations.begin,
|
||||||
|
wipeMedia: livePanicRecoveryOperations.wipeMedia,
|
||||||
|
complete: livePanicRecoveryOperations.complete
|
||||||
|
)
|
||||||
|
let meshService = BLEService(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
identityManager: identityManager,
|
||||||
|
startSuspendedForPanicRecovery: startSuspendedForRecovery
|
||||||
|
)
|
||||||
meshService.sfMetrics = .shared
|
meshService.sfMetrics = .shared
|
||||||
self.init(
|
self.init(
|
||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
@@ -781,7 +836,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
|
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
|
||||||
locationManager: locationManager,
|
locationManager: locationManager,
|
||||||
outboxStore: MessageOutboxStore(keychain: keychain),
|
outboxStore: MessageOutboxStore(keychain: keychain),
|
||||||
sfMetrics: .shared
|
sfMetrics: .shared,
|
||||||
|
panicRecoveryOperations: panicRecoveryOperations,
|
||||||
|
panicNetworkLifecycle: .live
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -799,7 +856,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
locationManager: LocationChannelManager = .shared,
|
locationManager: LocationChannelManager = .shared,
|
||||||
readReceiptsDefaults: UserDefaults? = nil,
|
readReceiptsDefaults: UserDefaults? = nil,
|
||||||
outboxStore: MessageOutboxStore? = nil,
|
outboxStore: MessageOutboxStore? = nil,
|
||||||
sfMetrics: StoreAndForwardMetrics? = nil
|
sfMetrics: StoreAndForwardMetrics? = nil,
|
||||||
|
panicMediaWipe: (() throws -> Void)? = nil,
|
||||||
|
panicRecoveryOperations: PanicRecoveryOperations? = nil,
|
||||||
|
panicNetworkLifecycle: PanicNetworkLifecycle = .noop
|
||||||
) {
|
) {
|
||||||
let conversations = conversations ?? ConversationStore()
|
let conversations = conversations ?? ConversationStore()
|
||||||
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
|
||||||
@@ -814,6 +874,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.keychain = keychain
|
self.keychain = keychain
|
||||||
|
self.panicRecoveryOperations = panicRecoveryOperations
|
||||||
|
?? .ephemeral(wipeMedia: panicMediaWipe ?? {})
|
||||||
|
self.panicNetworkLifecycle = panicNetworkLifecycle
|
||||||
self.groupStore = GroupStore(keychain: keychain)
|
self.groupStore = GroupStore(keychain: keychain)
|
||||||
self.idBridge = idBridge
|
self.idBridge = idBridge
|
||||||
self.identityManager = identityManager
|
self.identityManager = identityManager
|
||||||
@@ -849,7 +912,31 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
ChatViewModelBootstrapper(viewModel: self).configure()
|
let recoveryRequired: Bool
|
||||||
|
do {
|
||||||
|
recoveryRequired = try self.panicRecoveryOperations.isPending()
|
||||||
|
} catch {
|
||||||
|
// Failure to read the latch cannot fail open. Re-run the complete
|
||||||
|
// transaction; a persistent storage failure leaves services
|
||||||
|
// blocked below.
|
||||||
|
recoveryRequired = true
|
||||||
|
SecureLogger.error(
|
||||||
|
"Could not read panic-recovery state; retrying the full wipe before startup: \(error)",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if recoveryRequired {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"Pending panic recovery detected; wiping before runtime services start",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
_ = panicClearAllData(restartServices: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if networkActivationAllowed {
|
||||||
|
ChatViewModelBootstrapper(viewModel: self).configure()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Deinitialization
|
// MARK: - Deinitialization
|
||||||
@@ -1153,8 +1240,33 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
// PANIC: Emergency data clearing for activist safety
|
// PANIC: Emergency data clearing for activist safety
|
||||||
@MainActor
|
@MainActor
|
||||||
func panicClearAllData() {
|
@discardableResult
|
||||||
// Messages are processed immediately - nothing to flush
|
func panicClearAllData(restartServices: Bool = true) -> Bool {
|
||||||
|
panicRecoveryBlocked = true
|
||||||
|
isPanicResetting = true
|
||||||
|
defer { isPanicResetting = false }
|
||||||
|
|
||||||
|
// Stop internet and location-presence work before clearing identity or
|
||||||
|
// state. These services cancel their subscriptions and delayed tasks,
|
||||||
|
// so old callbacks cannot reconnect during the transaction.
|
||||||
|
panicNetworkLifecycle.stop()
|
||||||
|
|
||||||
|
// Establish both independent durable intents before erasing anything.
|
||||||
|
// `wipeMedia` will still attempt deletion if neither write succeeds.
|
||||||
|
let recoveryIntent = panicRecoveryOperations.begin()
|
||||||
|
|
||||||
|
// Quiesce the mesh before clearing stores. Identity replacement below
|
||||||
|
// deliberately stays stopped until media deletion and marker commit.
|
||||||
|
if let bleService = meshService as? BLEService {
|
||||||
|
bleService.suspendForPanicReset()
|
||||||
|
} else {
|
||||||
|
meshService.emergencyDisconnectAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate detached media preparation and close live capture file
|
||||||
|
// handles before clearing state or removing the media directory.
|
||||||
|
mediaTransferCoordinator.resetForPanic()
|
||||||
|
liveVoiceCoordinator.resetForPanic()
|
||||||
|
|
||||||
// Clear all messages (public timelines and private chats live in the
|
// Clear all messages (public timelines and private chats live in the
|
||||||
// single-writer ConversationStore; the derived `messages` view and
|
// single-writer ConversationStore; the derived `messages` view and
|
||||||
@@ -1163,7 +1275,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
pendingGeohashSystemMessages.removeAll()
|
pendingGeohashSystemMessages.removeAll()
|
||||||
|
|
||||||
// Delete all keychain data (including Noise and Nostr keys)
|
// Delete all keychain data (including Noise and Nostr keys)
|
||||||
_ = keychain.deleteAllKeychainData()
|
let keychainWipeCompleted = keychain.deleteAllKeychainData()
|
||||||
|
if !keychainWipeCompleted {
|
||||||
|
SecureLogger.error(
|
||||||
|
"Panic keychain cleanup incomplete; recovery remains pending",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Clear UserDefaults identity data
|
// Clear UserDefaults identity data
|
||||||
userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey")
|
userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey")
|
||||||
@@ -1176,7 +1294,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
|
|
||||||
// Reset nickname to anonymous
|
// Reset nickname to anonymous
|
||||||
nickname = "anon\(Int.random(in: 1000...9999))"
|
nickname = "anon\(Int.random(in: 1000...9999))"
|
||||||
saveNickname()
|
userDefaults.set(nickname, forKey: nicknameKey)
|
||||||
|
|
||||||
// Clear favorites and peer mappings
|
// Clear favorites and peer mappings
|
||||||
// Clear through SecureIdentityStateManager instead of directly
|
// Clear through SecureIdentityStateManager instead of directly
|
||||||
@@ -1248,78 +1366,77 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
|||||||
// Clear Nostr identity associations
|
// Clear Nostr identity associations
|
||||||
idBridge.clearAllAssociations()
|
idBridge.clearAllAssociations()
|
||||||
|
|
||||||
// Disconnect from all peers and clear persistent identity
|
// Replace the BLE identity while keeping the radio stopped. It may
|
||||||
// This will force creation of a new identity (new fingerprint) on next launch
|
// reopen only after the durable panic transaction commits.
|
||||||
meshService.emergencyDisconnectAll()
|
|
||||||
if let bleService = meshService as? BLEService {
|
if let bleService = meshService as? BLEService {
|
||||||
bleService.resetIdentityForPanic(currentNickname: nickname)
|
bleService.resetIdentityForPanic(
|
||||||
|
currentNickname: nickname,
|
||||||
|
restartServices: false
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
meshService.setNickname(nickname)
|
||||||
}
|
}
|
||||||
|
|
||||||
// No need to force UserDefaults synchronization
|
// The wipe must finish before this security action returns. A detached
|
||||||
|
// task could otherwise lose a race with a new capture or app exit and
|
||||||
|
// leave pre-panic media behind.
|
||||||
|
let panicCompleted: Bool
|
||||||
|
do {
|
||||||
|
try panicRecoveryOperations.wipeMedia(recoveryIntent)
|
||||||
|
if keychainWipeCompleted {
|
||||||
|
try panicRecoveryOperations.complete()
|
||||||
|
panicCompleted = true
|
||||||
|
SecureLogger.info(
|
||||||
|
"🗑️ Deleted all media files during panic clear",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Do not clear either durable recovery marker. Startup must
|
||||||
|
// retry the entire transaction before any transport restarts.
|
||||||
|
panicCompleted = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
panicCompleted = false
|
||||||
|
SecureLogger.error(
|
||||||
|
"Panic transaction did not commit; services remain stopped: \(error)",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
}
|
||||||
|
panicRecoveryBlocked = !panicCompleted
|
||||||
|
|
||||||
// Reinitialize Nostr with new identity
|
// BCH-01-013: Clear iOS app switcher snapshots. Keep tests away from
|
||||||
// This will generate new Nostr keys derived from new Noise keys.
|
// the host user's real cache tree just as the default media wipe does.
|
||||||
// Skipped under tests: connecting the shared relay singleton starts
|
#if os(iOS)
|
||||||
// real network/reconnect work that never completes and would keep the
|
|
||||||
// test process alive (the singleton, unlike a discardable instance, is
|
|
||||||
// never deallocated to cancel it).
|
|
||||||
if !TestEnvironment.isRunningTests {
|
if !TestEnvironment.isRunningTests {
|
||||||
Task { @MainActor in
|
Self.clearAppSwitcherSnapshots()
|
||||||
// Small delay to ensure cleanup completes
|
}
|
||||||
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
|
#endif
|
||||||
|
|
||||||
// Reinitialize Nostr relay manager with new identity. Reuse the
|
guard panicCompleted else { return false }
|
||||||
// shared singleton — every other component (NostrTransport, geohash
|
|
||||||
// subscriptions, AppRuntime observers) is bound to `.shared`, so
|
if let bleService = meshService as? BLEService {
|
||||||
// creating a fresh instance here would split relay state and leave
|
// Startup recovery reopens admission but leaves actual service
|
||||||
// sends running against a disconnected manager.
|
// start to the bootstrapper immediately after this method.
|
||||||
|
bleService.completePanicReset(
|
||||||
|
restartServices: restartServices
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if restartServices {
|
||||||
|
// All persistent state and media are gone. Bring each service back
|
||||||
|
// only now, under the new identity.
|
||||||
|
if !(meshService is BLEService) {
|
||||||
|
meshService.startServices()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !TestEnvironment.isRunningTests {
|
||||||
nostrRelayManager = NostrRelayManager.shared
|
nostrRelayManager = NostrRelayManager.shared
|
||||||
setupNostrMessageHandling()
|
setupNostrMessageHandling()
|
||||||
nostrRelayManager?.connect()
|
|
||||||
}
|
}
|
||||||
|
panicNetworkLifecycle.restart()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete ALL media files (incoming and outgoing) in background
|
return true
|
||||||
Task.detached(priority: .utility) {
|
|
||||||
// Skipped under tests: the test process shares the user's real
|
|
||||||
// ~/Library/Application Support/files tree, and this detached
|
|
||||||
// utility-priority wipe fires at a nondeterministic time —
|
|
||||||
// deleting media that concurrently running tests (e.g. the
|
|
||||||
// sendImage flow) just wrote there, and the developer's real
|
|
||||||
// app data with it.
|
|
||||||
guard !TestEnvironment.isRunningTests else { return }
|
|
||||||
do {
|
|
||||||
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
|
|
||||||
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
|
||||||
|
|
||||||
// Delete the entire files directory and recreate it
|
|
||||||
if FileManager.default.fileExists(atPath: filesDir.path) {
|
|
||||||
try FileManager.default.removeItem(at: filesDir)
|
|
||||||
SecureLogger.info("🗑️ Deleted all media files during panic clear", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recreate empty directory structure
|
|
||||||
try FileManager.default.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
|
||||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
|
||||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
|
||||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
|
||||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("images/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
|
||||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/incoming", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
|
||||||
try FileManager.default.createDirectory(at: filesDir.appendingPathComponent("files/outgoing", isDirectory: true), withIntermediateDirectories: true, attributes: nil)
|
|
||||||
} catch {
|
|
||||||
SecureLogger.error("Failed to clear media files during panic: \(error)", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BCH-01-013: Clear iOS app switcher snapshots
|
|
||||||
// These are stored in Library/Caches/Snapshots/<bundle_id>/
|
|
||||||
#if os(iOS)
|
|
||||||
Self.clearAppSwitcherSnapshots()
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force immediate UI update for panic mode
|
|
||||||
// UI updates immediately - no flushing needed
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// BCH-01-013: Clear iOS app switcher snapshots during panic mode
|
/// BCH-01-013: Clear iOS app switcher snapshots during panic mode
|
||||||
|
|||||||
@@ -188,8 +188,25 @@ final class VoiceRecordingViewModel: ObservableObject {
|
|||||||
|
|
||||||
Task {
|
Task {
|
||||||
let finalDuration = Date().timeIntervalSince(startDate)
|
let finalDuration = Date().timeIntervalSince(startDate)
|
||||||
if let url = await session.finish(),
|
if let url = await session.finish() {
|
||||||
isValidRecording(at: url, duration: finalDuration) {
|
// Panic and a newer hold both invalidate this completion.
|
||||||
|
// Never route an old recording using a post-panic target.
|
||||||
|
guard generation == holdGeneration else {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard isValidRecording(
|
||||||
|
at: url,
|
||||||
|
duration: finalDuration
|
||||||
|
) else {
|
||||||
|
guard state == .idle else { return }
|
||||||
|
state = .error(
|
||||||
|
message: finalDuration < VoiceRecorder.minRecordingDuration
|
||||||
|
? "Recording is too short."
|
||||||
|
: "Recording failed to save."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
completion(url)
|
completion(url)
|
||||||
} else {
|
} else {
|
||||||
guard generation == holdGeneration, state == .idle else { return }
|
guard generation == holdGeneration, state == .idle else { return }
|
||||||
@@ -206,6 +223,17 @@ final class VoiceRecordingViewModel: ObservableObject {
|
|||||||
finish(completion: nil)
|
finish(completion: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Invalidates in-flight permission/start/finalize callbacks and tears
|
||||||
|
/// down an active microphone before the panic transaction continues.
|
||||||
|
func panicWipe() {
|
||||||
|
holdGeneration &+= 1
|
||||||
|
let session = activeSession
|
||||||
|
activeSession = nil
|
||||||
|
state = .idle
|
||||||
|
isLiveStreaming = false
|
||||||
|
session?.panicCancelSynchronously()
|
||||||
|
}
|
||||||
|
|
||||||
private func isValidRecording(at url: URL, duration: TimeInterval) -> Bool {
|
private func isValidRecording(at url: URL, duration: TimeInterval) -> Bool {
|
||||||
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
if let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
|
||||||
let fileSize = attributes[.size] as? NSNumber,
|
let fileSize = attributes[.size] as? NSNumber,
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ struct ContentView: View {
|
|||||||
voiceRecordingVM.sessionProvider = { [weak conversationUIModel] in
|
voiceRecordingVM.sessionProvider = { [weak conversationUIModel] in
|
||||||
conversationUIModel?.makeVoiceCaptureSession() ?? VoiceNoteCaptureSession()
|
conversationUIModel?.makeVoiceCaptureSession() ?? VoiceNoteCaptureSession()
|
||||||
}
|
}
|
||||||
|
appChromeModel.setPanicPreparation { [weak voiceRecordingVM] in
|
||||||
|
voiceRecordingVM?.panicWipe()
|
||||||
|
}
|
||||||
#if os(macOS)
|
#if os(macOS)
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
isNicknameFieldFocused = false
|
isNicknameFieldFocused = false
|
||||||
@@ -229,6 +232,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
autocompleteDebounceTimer?.invalidate()
|
autocompleteDebounceTimer?.invalidate()
|
||||||
|
appChromeModel.setPanicPreparation(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,11 +14,25 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
|
|||||||
// every default-constructed component under test, which access it from
|
// every default-constructed component under test, which access it from
|
||||||
// arbitrary threads.
|
// arbitrary threads.
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
|
private let installAccessGate: KeychainInstallAccessGate
|
||||||
|
private let reconcileInstallAccess: () -> Bool
|
||||||
private var storage: [String: Data] = [:]
|
private var storage: [String: Data] = [:]
|
||||||
private var serviceStorage: [String: [String: Data]] = [:]
|
private var serviceStorage: [String: [String: Data]] = [:]
|
||||||
init() {}
|
|
||||||
|
init(
|
||||||
|
installAccessGate: KeychainInstallAccessGate = KeychainInstallAccessGate(),
|
||||||
|
reconcileInstallAccess: @escaping () -> Bool = { true }
|
||||||
|
) {
|
||||||
|
self.installAccessGate = installAccessGate
|
||||||
|
self.reconcileInstallAccess = reconcileInstallAccess
|
||||||
|
}
|
||||||
|
|
||||||
|
private func installAccessAllowed() -> Bool {
|
||||||
|
installAccessGate.allowsAccess(reconcile: reconcileInstallAccess)
|
||||||
|
}
|
||||||
|
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||||
|
guard installAccessAllowed() else { return false }
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
storage[key] = keyData
|
storage[key] = keyData
|
||||||
@@ -26,12 +40,14 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getIdentityKey(forKey key: String) -> Data? {
|
func getIdentityKey(forKey key: String) -> Data? {
|
||||||
|
guard installAccessAllowed() else { return nil }
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
return storage[key]
|
return storage[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteIdentityKey(forKey key: String) -> Bool {
|
func deleteIdentityKey(forKey key: String) -> Bool {
|
||||||
|
guard installAccessAllowed() else { return false }
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
storage.removeValue(forKey: key)
|
storage.removeValue(forKey: key)
|
||||||
@@ -51,6 +67,7 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
|
|||||||
func secureClear(_ string: inout String) {}
|
func secureClear(_ string: inout String) {}
|
||||||
|
|
||||||
func verifyIdentityKeyExists() -> Bool {
|
func verifyIdentityKeyExists() -> Bool {
|
||||||
|
guard installAccessAllowed() else { return false }
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
return storage["identity_noiseStaticKey"] != nil
|
return storage["identity_noiseStaticKey"] != nil
|
||||||
@@ -58,6 +75,7 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
|
|||||||
|
|
||||||
// BCH-01-009: New methods with proper error classification
|
// BCH-01-009: New methods with proper error classification
|
||||||
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
func getIdentityKeyWithResult(forKey key: String) -> KeychainReadResult {
|
||||||
|
guard installAccessAllowed() else { return .accessDenied }
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
if let data = storage[key] {
|
if let data = storage[key] {
|
||||||
@@ -67,6 +85,7 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
func saveIdentityKeyWithResult(_ keyData: Data, forKey key: String) -> KeychainSaveResult {
|
||||||
|
guard installAccessAllowed() else { return .accessDenied }
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
storage[key] = keyData
|
storage[key] = keyData
|
||||||
@@ -76,24 +95,38 @@ final class PreviewKeychainManager: KeychainManagerProtocol {
|
|||||||
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
// MARK: - Generic Data Storage (consolidated from KeychainHelper)
|
||||||
|
|
||||||
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
func save(key: String, data: Data, service: String, accessible: CFString?) {
|
||||||
|
guard installAccessAllowed() else { return }
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
serviceStorage[service, default: [:]][key] = data
|
serviceStorage[service, default: [:]][key] = data
|
||||||
}
|
}
|
||||||
|
|
||||||
func load(key: String, service: String) -> Data? {
|
func load(key: String, service: String) -> Data? {
|
||||||
|
guard case .success(let data) = loadWithResult(key: key, service: service) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadWithResult(key: String, service: String) -> KeychainReadResult {
|
||||||
|
guard installAccessAllowed() else { return .accessDenied }
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
return serviceStorage[service]?[key]
|
guard let data = serviceStorage[service]?[key] else {
|
||||||
|
return .itemNotFound
|
||||||
|
}
|
||||||
|
return .success(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func delete(key: String, service: String) {
|
func delete(key: String, service: String) {
|
||||||
|
guard installAccessAllowed() else { return }
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
serviceStorage[service]?.removeValue(forKey: key)
|
serviceStorage[service]?.removeValue(forKey: key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteAll(service: String) {
|
func deleteAll(service: String) {
|
||||||
|
guard installAccessAllowed() else { return }
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
serviceStorage.removeValue(forKey: service)
|
serviceStorage.removeValue(forKey: service)
|
||||||
|
|||||||
@@ -99,6 +99,95 @@ struct BLEServiceCoreTests {
|
|||||||
#expect(ble.currentPeerSnapshots().isEmpty)
|
#expect(ble.currentPeerSnapshots().isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func unsignedAndBadSignatureLeaveDoNotEvictOrRelayClaimedPeer() async throws {
|
||||||
|
let ble = makeService()
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
let outbound = OutboundPacketTap()
|
||||||
|
ble._test_onOutboundPacket = outbound.record
|
||||||
|
|
||||||
|
let unsigned = makeLeavePacket(sender: alicePeerID, marker: "unsigned")
|
||||||
|
ble._test_handlePacket(
|
||||||
|
unsigned,
|
||||||
|
fromPeerID: alicePeerID,
|
||||||
|
signingPublicKey: alice.getSigningPublicKeyData()
|
||||||
|
)
|
||||||
|
|
||||||
|
let unsignedRelayed = await TestHelpers.waitUntil(
|
||||||
|
{ outbound.count(ofType: .leave) > 0 },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!unsignedRelayed)
|
||||||
|
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
|
||||||
|
|
||||||
|
let badSignature = try #require(
|
||||||
|
mallory.signPacket(makeLeavePacket(sender: alicePeerID, marker: "bad-signature"))
|
||||||
|
)
|
||||||
|
ble._test_handlePacket(
|
||||||
|
badSignature,
|
||||||
|
fromPeerID: alicePeerID,
|
||||||
|
signingPublicKey: alice.getSigningPublicKeyData()
|
||||||
|
)
|
||||||
|
|
||||||
|
let badSignatureRelayed = await TestHelpers.waitUntil(
|
||||||
|
{ outbound.count(ofType: .leave) > 0 },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!badSignatureRelayed)
|
||||||
|
#expect(ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func validSignedLeaveEvictsSessionAndRelays() async throws {
|
||||||
|
let ble = makeService()
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
|
||||||
|
// Establish a real session so the leave regression also verifies that
|
||||||
|
// stale secure-delivery state is retired, not just the peer-list row.
|
||||||
|
let message1 = try ble._test_noiseInitiateHandshake(with: alicePeerID)
|
||||||
|
let message2 = try #require(
|
||||||
|
try alice.processHandshakeMessage(from: ble.myPeerID, message: message1)
|
||||||
|
)
|
||||||
|
let message3 = try #require(
|
||||||
|
try ble._test_noiseProcessHandshakeMessage(from: alicePeerID, message: message2)
|
||||||
|
)
|
||||||
|
_ = try alice.processHandshakeMessage(from: ble.myPeerID, message: message3)
|
||||||
|
#expect(ble.canDeliverSecurely(to: alicePeerID))
|
||||||
|
let centralUUID = "central-valid-leave"
|
||||||
|
ble._test_bindCentral(centralUUID, to: alicePeerID)
|
||||||
|
ble._test_markNoiseAuthenticatedCentral(centralUUID, to: alicePeerID)
|
||||||
|
#expect(ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID))
|
||||||
|
|
||||||
|
let outbound = OutboundPacketTap()
|
||||||
|
ble._test_onOutboundPacket = outbound.record
|
||||||
|
let signedLeave = try #require(
|
||||||
|
alice.signPacket(makeLeavePacket(sender: alicePeerID, marker: "valid"))
|
||||||
|
)
|
||||||
|
ble._test_handlePacket(
|
||||||
|
signedLeave,
|
||||||
|
fromPeerID: alicePeerID,
|
||||||
|
signingPublicKey: alice.getSigningPublicKeyData()
|
||||||
|
)
|
||||||
|
|
||||||
|
let evicted = await TestHelpers.waitUntil(
|
||||||
|
{
|
||||||
|
!ble.currentPeerSnapshots().contains { $0.peerID == alicePeerID }
|
||||||
|
&& !ble.canDeliverSecurely(to: alicePeerID)
|
||||||
|
&& !ble._test_isNoiseAuthenticatedCentral(centralUUID, for: alicePeerID)
|
||||||
|
},
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(evicted)
|
||||||
|
let relayed = await TestHelpers.waitUntil(
|
||||||
|
{ outbound.count(ofType: .leave) == 1 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(relayed)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func ingressAllowsRelayedSenderOnBoundLink() async throws {
|
func ingressAllowsRelayedSenderOnBoundLink() async throws {
|
||||||
let ble = makeService()
|
let ble = makeService()
|
||||||
@@ -440,6 +529,94 @@ struct BLEServiceCoreTests {
|
|||||||
#expect(outbound.count(ofType: .courierEnvelope) == 0)
|
#expect(outbound.count(ofType: .courierEnvelope) == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func replacementXXMessageOneWithPayloadCannotAuthenticateIngressLink() async throws {
|
||||||
|
let ble = makeService()
|
||||||
|
let victim = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let victimPeerID = PeerID(publicKey: victim.getStaticPublicKeyData())
|
||||||
|
|
||||||
|
// Preserve a working victim session while an unauthenticated
|
||||||
|
// replacement candidate arrives on a newly bound physical link.
|
||||||
|
let message1 = try ble._test_noiseInitiateHandshake(with: victimPeerID)
|
||||||
|
let message2 = try #require(
|
||||||
|
try victim.processHandshakeMessage(from: ble.myPeerID, message: message1)
|
||||||
|
)
|
||||||
|
let message3 = try #require(
|
||||||
|
try ble._test_noiseProcessHandshakeMessage(
|
||||||
|
from: victimPeerID,
|
||||||
|
message: message2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_ = try victim.processHandshakeMessage(
|
||||||
|
from: ble.myPeerID,
|
||||||
|
message: message3
|
||||||
|
)
|
||||||
|
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
||||||
|
|
||||||
|
let centralUUID = "central-replacement-xx-message-one"
|
||||||
|
ble._test_bindCentral(centralUUID, to: victimPeerID)
|
||||||
|
#expect(
|
||||||
|
!ble._test_isNoiseAuthenticatedCentral(
|
||||||
|
centralUUID,
|
||||||
|
for: victimPeerID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// XX message one may legally carry a payload, so its length is not a
|
||||||
|
// reliable signal that the replacement handshake completed.
|
||||||
|
let unauthenticatedInitiator = NoiseHandshakeState(
|
||||||
|
role: .initiator,
|
||||||
|
pattern: .XX,
|
||||||
|
keychain: MockKeychain()
|
||||||
|
)
|
||||||
|
let replacementMessage1 = try unauthenticatedInitiator.writeMessage(
|
||||||
|
payload: Data([0xA5])
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
replacementMessage1.count
|
||||||
|
> NoiseSecurityConstants.xxInitialMessageSize
|
||||||
|
)
|
||||||
|
|
||||||
|
let packet = BitchatPacket(
|
||||||
|
type: MessageType.noiseHandshake.rawValue,
|
||||||
|
senderID: Data(hexString: victimPeerID.id) ?? Data(),
|
||||||
|
recipientID: Data(hexString: ble.myPeerID.id),
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: replacementMessage1,
|
||||||
|
signature: nil,
|
||||||
|
ttl: TransportConfig.messageTTLDefault
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
ble._test_recordIngressIfNew(
|
||||||
|
packet: packet,
|
||||||
|
linkID: centralUUID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
let outbound = OutboundPacketTap()
|
||||||
|
ble._test_onOutboundPacket = outbound.record
|
||||||
|
ble._test_handlePacket(
|
||||||
|
packet,
|
||||||
|
fromPeerID: victimPeerID,
|
||||||
|
preseedPeer: false
|
||||||
|
)
|
||||||
|
|
||||||
|
// Waiting for the responder's message two proves the candidate was
|
||||||
|
// processed before checking its exact authentication result.
|
||||||
|
let candidateProcessed = await TestHelpers.waitUntil(
|
||||||
|
{ outbound.count(ofType: .noiseHandshake) == 1 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(candidateProcessed)
|
||||||
|
#expect(
|
||||||
|
!ble._test_isNoiseAuthenticatedCentral(
|
||||||
|
centralUUID,
|
||||||
|
for: victimPeerID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
||||||
|
}
|
||||||
|
|
||||||
/// A legitimate rotation announce necessarily arrives on a link still
|
/// A legitimate rotation announce necessarily arrives on a link still
|
||||||
/// bound to the OLD ID, so its registry upsert stores the new peer
|
/// bound to the OLD ID, so its registry upsert stores the new peer
|
||||||
/// disconnected. The successful rebind must promote it: a healed
|
/// disconnected. The successful rebind must promote it: a healed
|
||||||
@@ -572,6 +749,108 @@ struct BLEServiceCoreTests {
|
|||||||
#expect(ble.myPeerID == PeerID(str: newFingerprint.prefix(16)))
|
#expect(ble.myPeerID == PeerID(str: newFingerprint.prefix(16)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func panicSuspension_dropsLateOutboundWorkUntilCommit() async {
|
||||||
|
let ble = makeService()
|
||||||
|
let outbound = OutboundPacketTap()
|
||||||
|
ble._test_onOutboundPacket = outbound.record
|
||||||
|
let packet = makePublicPacket(
|
||||||
|
content: "late callback",
|
||||||
|
sender: ble.myPeerID,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
)
|
||||||
|
|
||||||
|
ble.suspendForPanicReset()
|
||||||
|
ble.sendPacket(packet)
|
||||||
|
#expect(outbound.count(ofType: .message) == 0)
|
||||||
|
|
||||||
|
ble.completePanicReset(restartServices: false)
|
||||||
|
ble.sendPacket(packet)
|
||||||
|
#expect(outbound.count(ofType: .message) == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func panicSuspension_invalidatesQueuedMainActorIngress() async {
|
||||||
|
let ble = makeService()
|
||||||
|
let delegate = TransportEventCaptureDelegate()
|
||||||
|
ble.eventDelegate = delegate
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: "pre-panic-ingress",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "must not survive panic",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: "Me",
|
||||||
|
senderPeerID: PeerID(str: "1122334455667788")
|
||||||
|
)
|
||||||
|
|
||||||
|
// The test already owns MainActor, so this task cannot run until the
|
||||||
|
// synchronous panic boundary below has invalidated its generation.
|
||||||
|
ble._test_emitTransportEvent(.messageReceived(message))
|
||||||
|
ble.suspendForPanicReset()
|
||||||
|
await Task.yield()
|
||||||
|
#expect(delegate.messageIDs.isEmpty)
|
||||||
|
|
||||||
|
ble.completePanicReset(restartServices: false)
|
||||||
|
ble._test_emitTransportEvent(.messageReceived(message))
|
||||||
|
await Task.yield()
|
||||||
|
#expect(delegate.messageIDs == [message.id])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func panicSuspension_rejectsPausedBLEReceiveBeforeMessageQueueHandoff() async {
|
||||||
|
let ble = makeService()
|
||||||
|
let gate = ReceivePacketHandoffGate()
|
||||||
|
ble._test_beforeReceivePacketHandoff = gate.pause
|
||||||
|
ble._test_onReceivePacketHandoff = gate.recordHandoff
|
||||||
|
defer {
|
||||||
|
gate.release()
|
||||||
|
ble._test_beforeReceivePacketHandoff = nil
|
||||||
|
ble._test_onReceivePacketHandoff = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let sender = PeerID(str: "1122334455667788")
|
||||||
|
let packet = makePublicPacket(
|
||||||
|
content: "must not cross panic",
|
||||||
|
sender: sender,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000)
|
||||||
|
)
|
||||||
|
ble._test_handlePacketFromBLEQueue(packet, fromPeerID: sender)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ gate.hasPaused },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
|
||||||
|
// Panic closes the lifecycle before waiting for the paused bleQueue
|
||||||
|
// callback. Releasing it afterward lets the callback enqueue its
|
||||||
|
// messageQueue handoff, where the captured generation must be rejected
|
||||||
|
// before packet processing starts.
|
||||||
|
let panicIngressObserver = PanicIngressObserver(service: ble)
|
||||||
|
let didObservePanicClosure = await withCheckedContinuation { continuation in
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
let didObserveClosure = panicIngressObserver.waitUntilClosed(
|
||||||
|
timeout: TestConstants.defaultTimeout
|
||||||
|
)
|
||||||
|
gate.release()
|
||||||
|
continuation.resume(returning: didObserveClosure)
|
||||||
|
}
|
||||||
|
ble.suspendForPanicReset()
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(didObservePanicClosure)
|
||||||
|
#expect(gate.handoffCount == 0)
|
||||||
|
|
||||||
|
// A packet captured under the reopened lifecycle still crosses the
|
||||||
|
// same handoff, proving the test did not merely disable the hook.
|
||||||
|
ble.completePanicReset(restartServices: false)
|
||||||
|
ble._test_handlePacketFromBLEQueue(packet, fromPeerID: sender)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ gate.handoffCount == 1 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
|
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
|
||||||
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
|
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
|
||||||
@@ -666,6 +945,68 @@ private final class OutboundPacketTap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class ReceivePacketHandoffGate: @unchecked Sendable {
|
||||||
|
private let condition = NSCondition()
|
||||||
|
private var paused = false
|
||||||
|
private var released = false
|
||||||
|
private var recordedHandoffCount = 0
|
||||||
|
|
||||||
|
var hasPaused: Bool {
|
||||||
|
condition.lock()
|
||||||
|
defer { condition.unlock() }
|
||||||
|
return paused
|
||||||
|
}
|
||||||
|
|
||||||
|
var handoffCount: Int {
|
||||||
|
condition.lock()
|
||||||
|
defer { condition.unlock() }
|
||||||
|
return recordedHandoffCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func pause() {
|
||||||
|
condition.lock()
|
||||||
|
paused = true
|
||||||
|
condition.broadcast()
|
||||||
|
while !released {
|
||||||
|
condition.wait()
|
||||||
|
}
|
||||||
|
condition.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func release() {
|
||||||
|
condition.lock()
|
||||||
|
released = true
|
||||||
|
condition.broadcast()
|
||||||
|
condition.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordHandoff() {
|
||||||
|
condition.lock()
|
||||||
|
recordedHandoffCount += 1
|
||||||
|
condition.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lets a dedicated dispatch worker observe the lock-protected panic gate
|
||||||
|
/// without treating the full BLE service as generally Sendable.
|
||||||
|
private final class PanicIngressObserver: @unchecked Sendable {
|
||||||
|
private let service: BLEService
|
||||||
|
|
||||||
|
init(service: BLEService) {
|
||||||
|
self.service = service
|
||||||
|
}
|
||||||
|
|
||||||
|
func waitUntilClosed(timeout: TimeInterval) -> Bool {
|
||||||
|
let deadline = DispatchTime.now().uptimeNanoseconds
|
||||||
|
+ UInt64(timeout * 1_000_000_000)
|
||||||
|
while service._test_isPanicIngressOpen,
|
||||||
|
DispatchTime.now().uptimeNanoseconds < deadline {
|
||||||
|
Thread.sleep(forTimeInterval: 0.001)
|
||||||
|
}
|
||||||
|
return !service._test_isPanicIngressOpen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func makeService() -> BLEService {
|
private func makeService() -> BLEService {
|
||||||
let keychain = MockKeychain()
|
let keychain = MockKeychain()
|
||||||
let identityManager = MockIdentityManager(keychain)
|
let identityManager = MockIdentityManager(keychain)
|
||||||
@@ -690,6 +1031,18 @@ private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makeLeavePacket(sender: PeerID, marker: String) -> BitchatPacket {
|
||||||
|
BitchatPacket(
|
||||||
|
type: MessageType.leave.rawValue,
|
||||||
|
senderID: Data(hexString: sender.id) ?? Data(),
|
||||||
|
recipientID: nil,
|
||||||
|
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
payload: Data(marker.utf8),
|
||||||
|
signature: nil,
|
||||||
|
ttl: TransportConfig.messageTTLDefault
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private final class PublicCaptureDelegate: BitchatDelegate {
|
private final class PublicCaptureDelegate: BitchatDelegate {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private(set) var publicMessages: [BitchatMessage] = []
|
private(set) var publicMessages: [BitchatMessage] = []
|
||||||
@@ -724,3 +1077,13 @@ private final class PublicCaptureDelegate: BitchatDelegate {
|
|||||||
return publicMessages
|
return publicMessages
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class TransportEventCaptureDelegate: TransportEventDelegate {
|
||||||
|
private(set) var messageIDs: [String] = []
|
||||||
|
|
||||||
|
func didReceiveTransportEvent(_ event: TransportEvent) {
|
||||||
|
guard case .messageReceived(let message) = event else { return }
|
||||||
|
messageIDs.append(message.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
import Testing
|
import Testing
|
||||||
import Foundation
|
import Foundation
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#else
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
// MARK: - Mock Context
|
// MARK: - Mock Context
|
||||||
@@ -188,6 +193,114 @@ struct ChatMediaTransferCoordinatorContextTests {
|
|||||||
#expect(coordinator.messageIDToTransferId.isEmpty)
|
#expect(coordinator.messageIDToTransferId.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func resetForPanic_cancelsEveryTransportTransferAndClearsMappings() {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
coordinator.registerTransfer(transferId: "t1", messageID: "m1")
|
||||||
|
coordinator.registerTransfer(transferId: "t1", messageID: "m2")
|
||||||
|
coordinator.registerTransfer(transferId: "t2", messageID: "m3")
|
||||||
|
|
||||||
|
coordinator.resetForPanic()
|
||||||
|
|
||||||
|
#expect(Set(context.cancelledTransfers) == Set(["t1", "t2"]))
|
||||||
|
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||||
|
#expect(coordinator.messageIDToTransferId.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func resetForPanic_waitsForActiveImageWriterBeforeReturning() async throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let sourceURL = try makeCoordinatorTestImageURL()
|
||||||
|
let outputURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("panic-prepared-\(UUID().uuidString).jpg")
|
||||||
|
let preparer = PausedImagePreparer(outputURL: outputURL)
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(
|
||||||
|
context: context,
|
||||||
|
prepareImagePacket: { sourceURL in
|
||||||
|
try preparer.prepare(sourceURL)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
defer {
|
||||||
|
preparer.release()
|
||||||
|
try? FileManager.default.removeItem(at: sourceURL)
|
||||||
|
try? FileManager.default.removeItem(at: outputURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
coordinator.sendImage(from: sourceURL)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ preparer.hasStarted },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInitiated).asyncAfter(
|
||||||
|
deadline: .now() + .milliseconds(100)
|
||||||
|
) {
|
||||||
|
preparer.release()
|
||||||
|
}
|
||||||
|
coordinator.resetForPanic()
|
||||||
|
|
||||||
|
// The synchronous reset boundary cannot return while a pre-panic
|
||||||
|
// writer can still create output. The real panic path deletes media
|
||||||
|
// immediately after this method returns.
|
||||||
|
#expect(preparer.hasFinished)
|
||||||
|
|
||||||
|
try? FileManager.default.removeItem(at: outputURL)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ !FileManager.default.fileExists(atPath: outputURL.path) },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
await Task.yield()
|
||||||
|
#expect(context.privateFileSends.isEmpty)
|
||||||
|
#expect(context.broadcastFileSends.isEmpty)
|
||||||
|
#expect(context.systemMessages.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func imagePreparation_doesNotRetainCoordinatorOrDeallocatedContext() async throws {
|
||||||
|
let sourceURL = try makeCoordinatorTestImageURL()
|
||||||
|
let outputURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("released-context-\(UUID().uuidString).jpg")
|
||||||
|
let preparer = PausedImagePreparer(outputURL: outputURL)
|
||||||
|
var context: MockChatMediaTransferContext? = MockChatMediaTransferContext()
|
||||||
|
var coordinator: ChatMediaTransferCoordinator? = ChatMediaTransferCoordinator(
|
||||||
|
context: context!,
|
||||||
|
prepareImagePacket: { sourceURL in
|
||||||
|
try preparer.prepare(sourceURL)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
weak var weakContext: MockChatMediaTransferContext?
|
||||||
|
weak var weakCoordinator: ChatMediaTransferCoordinator?
|
||||||
|
weakContext = context
|
||||||
|
weakCoordinator = coordinator
|
||||||
|
defer {
|
||||||
|
preparer.release()
|
||||||
|
try? FileManager.default.removeItem(at: sourceURL)
|
||||||
|
try? FileManager.default.removeItem(at: outputURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
coordinator?.sendImage(from: sourceURL)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ preparer.hasStarted },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
|
||||||
|
coordinator = nil
|
||||||
|
context = nil
|
||||||
|
#expect(weakCoordinator == nil)
|
||||||
|
#expect(weakContext == nil)
|
||||||
|
|
||||||
|
preparer.release()
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ preparer.hasFinished },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ !FileManager.default.fileExists(atPath: outputURL.path) },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
|
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
|
||||||
let context = MockChatMediaTransferContext()
|
let context = MockChatMediaTransferContext()
|
||||||
@@ -207,3 +320,91 @@ struct ChatMediaTransferCoordinatorContextTests {
|
|||||||
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class PausedImagePreparer: @unchecked Sendable {
|
||||||
|
private let condition = NSCondition()
|
||||||
|
private let outputURL: URL
|
||||||
|
private var started = false
|
||||||
|
private var released = false
|
||||||
|
private var finished = false
|
||||||
|
|
||||||
|
init(outputURL: URL) {
|
||||||
|
self.outputURL = outputURL
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasStarted: Bool {
|
||||||
|
condition.lock()
|
||||||
|
defer { condition.unlock() }
|
||||||
|
return started
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasFinished: Bool {
|
||||||
|
condition.lock()
|
||||||
|
defer { condition.unlock() }
|
||||||
|
return finished
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepare(_ _: URL) throws -> ChatPreparedImage {
|
||||||
|
condition.lock()
|
||||||
|
started = true
|
||||||
|
condition.broadcast()
|
||||||
|
while !released {
|
||||||
|
condition.wait()
|
||||||
|
}
|
||||||
|
condition.unlock()
|
||||||
|
|
||||||
|
let data = Data("prepared image".utf8)
|
||||||
|
try data.write(to: outputURL, options: .atomic)
|
||||||
|
let packet = BitchatFilePacket(
|
||||||
|
fileName: outputURL.lastPathComponent,
|
||||||
|
fileSize: UInt64(data.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: data
|
||||||
|
)
|
||||||
|
|
||||||
|
condition.lock()
|
||||||
|
finished = true
|
||||||
|
condition.broadcast()
|
||||||
|
condition.unlock()
|
||||||
|
return ChatPreparedImage(outputURL: outputURL, packet: packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
func release() {
|
||||||
|
condition.lock()
|
||||||
|
released = true
|
||||||
|
condition.broadcast()
|
||||||
|
condition.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeCoordinatorTestImageURL() throws -> URL {
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("coordinator-image-\(UUID().uuidString).png")
|
||||||
|
#if os(iOS)
|
||||||
|
let image = UIGraphicsImageRenderer(size: CGSize(width: 16, height: 16))
|
||||||
|
.image { context in
|
||||||
|
UIColor.systemBlue.setFill()
|
||||||
|
context.fill(CGRect(x: 0, y: 0, width: 16, height: 16))
|
||||||
|
}
|
||||||
|
guard let data = image.pngData() else {
|
||||||
|
throw CoordinatorImageTestError.encodingFailed
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
let image = NSImage(size: NSSize(width: 16, height: 16))
|
||||||
|
image.lockFocus()
|
||||||
|
NSColor.systemBlue.setFill()
|
||||||
|
NSRect(x: 0, y: 0, width: 16, height: 16).fill()
|
||||||
|
image.unlockFocus()
|
||||||
|
guard let tiff = image.tiffRepresentation,
|
||||||
|
let bitmap = NSBitmapImageRep(data: tiff),
|
||||||
|
let data = bitmap.representation(using: .png, properties: [:]) else {
|
||||||
|
throw CoordinatorImageTestError.encodingFailed
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
try data.write(to: url, options: .atomic)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CoordinatorImageTestError: Error {
|
||||||
|
case encodingFailed
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,8 +15,13 @@ import BitFoundation
|
|||||||
|
|
||||||
/// Creates a ChatViewModel with mock dependencies for testing
|
/// Creates a ChatViewModel with mock dependencies for testing
|
||||||
@MainActor
|
@MainActor
|
||||||
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
|
private func makeTestableViewModel(
|
||||||
let keychain = MockKeychain()
|
keychain injectedKeychain: MockKeychain? = nil,
|
||||||
|
panicMediaWipe: (() throws -> Void)? = nil,
|
||||||
|
panicRecoveryOperations: PanicRecoveryOperations? = nil,
|
||||||
|
panicNetworkLifecycle: PanicNetworkLifecycle = .noop
|
||||||
|
) -> (viewModel: ChatViewModel, transport: MockTransport) {
|
||||||
|
let keychain = injectedKeychain ?? MockKeychain()
|
||||||
let keychainHelper = MockKeychainHelper()
|
let keychainHelper = MockKeychainHelper()
|
||||||
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
|
||||||
let identityManager = MockIdentityManager(keychain)
|
let identityManager = MockIdentityManager(keychain)
|
||||||
@@ -26,7 +31,10 @@ private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: Mo
|
|||||||
keychain: keychain,
|
keychain: keychain,
|
||||||
idBridge: idBridge,
|
idBridge: idBridge,
|
||||||
identityManager: identityManager,
|
identityManager: identityManager,
|
||||||
transport: transport
|
transport: transport,
|
||||||
|
panicMediaWipe: panicMediaWipe,
|
||||||
|
panicRecoveryOperations: panicRecoveryOperations,
|
||||||
|
panicNetworkLifecycle: panicNetworkLifecycle
|
||||||
)
|
)
|
||||||
|
|
||||||
return (viewModel, transport)
|
return (viewModel, transport)
|
||||||
@@ -1116,6 +1124,159 @@ struct ChatViewModelBluetoothTests {
|
|||||||
|
|
||||||
struct ChatViewModelPanicTests {
|
struct ChatViewModelPanicTests {
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func panicClearAllData_finishesMediaWipeBeforeReturning() {
|
||||||
|
var wipeFinished = false
|
||||||
|
let (viewModel, _) = makeTestableViewModel(panicMediaWipe: {
|
||||||
|
wipeFinished = true
|
||||||
|
})
|
||||||
|
|
||||||
|
viewModel.panicClearAllData()
|
||||||
|
|
||||||
|
#expect(wipeFinished)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func panicClearAllData_stopsNetworkBeforeWipeAndRestartsAfterCommit() {
|
||||||
|
var events: [String] = []
|
||||||
|
let lifecycle = PanicNetworkLifecycle(
|
||||||
|
stop: { events.append("stop") },
|
||||||
|
restart: { events.append("restart") }
|
||||||
|
)
|
||||||
|
let (viewModel, _) = makeTestableViewModel(
|
||||||
|
panicMediaWipe: { events.append("wipe") },
|
||||||
|
panicNetworkLifecycle: lifecycle
|
||||||
|
)
|
||||||
|
|
||||||
|
let completed = viewModel.panicClearAllData()
|
||||||
|
|
||||||
|
#expect(completed)
|
||||||
|
#expect(events == ["stop", "wipe", "restart"])
|
||||||
|
#expect(viewModel.networkActivationAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func panicKeychainFailureKeepsRecoveryPendingAndServicesStopped() {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
keychain.simulatedDeleteAllResult = false
|
||||||
|
var events: [String] = []
|
||||||
|
let operations = PanicRecoveryOperations(
|
||||||
|
isPending: { false },
|
||||||
|
begin: {
|
||||||
|
events.append("begin")
|
||||||
|
return PanicRecoveryIntent(
|
||||||
|
fileMarkerEstablished: true,
|
||||||
|
externalMarkerEstablished: false
|
||||||
|
)
|
||||||
|
},
|
||||||
|
wipeMedia: { _ in events.append("wipe") },
|
||||||
|
complete: { events.append("complete") }
|
||||||
|
)
|
||||||
|
let lifecycle = PanicNetworkLifecycle(
|
||||||
|
stop: { events.append("stop") },
|
||||||
|
restart: { events.append("restart") }
|
||||||
|
)
|
||||||
|
let (viewModel, transport) = makeTestableViewModel(
|
||||||
|
keychain: keychain,
|
||||||
|
panicRecoveryOperations: operations,
|
||||||
|
panicNetworkLifecycle: lifecycle
|
||||||
|
)
|
||||||
|
let startsBeforePanic = transport.startServicesCallCount
|
||||||
|
|
||||||
|
let completed = viewModel.panicClearAllData()
|
||||||
|
|
||||||
|
#expect(!completed)
|
||||||
|
#expect(events == ["stop", "begin", "wipe"])
|
||||||
|
#expect(keychain.deleteAllCallCount == 1)
|
||||||
|
#expect(transport.startServicesCallCount == startsBeforePanic)
|
||||||
|
#expect(!viewModel.networkActivationAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func pendingPanicRecoveryCompletesBeforeTransportBootstrap() {
|
||||||
|
var events: [String] = []
|
||||||
|
let operations = PanicRecoveryOperations(
|
||||||
|
isPending: {
|
||||||
|
events.append("read")
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
begin: {
|
||||||
|
events.append("begin")
|
||||||
|
return PanicRecoveryIntent(
|
||||||
|
fileMarkerEstablished: true,
|
||||||
|
externalMarkerEstablished: false
|
||||||
|
)
|
||||||
|
},
|
||||||
|
wipeMedia: { _ in events.append("wipe") },
|
||||||
|
complete: { events.append("complete") }
|
||||||
|
)
|
||||||
|
|
||||||
|
let (viewModel, transport) = makeTestableViewModel(
|
||||||
|
panicRecoveryOperations: operations
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(events == ["read", "begin", "wipe", "complete"])
|
||||||
|
#expect(transport.emergencyDisconnectCallCount == 1)
|
||||||
|
#expect(transport.startServicesCallCount == 1)
|
||||||
|
#expect(viewModel.networkActivationAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func failedStartupRecoveryLeavesTransportAndNetworkBlocked() {
|
||||||
|
enum WipeFailure: Error { case failed }
|
||||||
|
var completedMarker = false
|
||||||
|
let operations = PanicRecoveryOperations(
|
||||||
|
isPending: { true },
|
||||||
|
begin: {
|
||||||
|
PanicRecoveryIntent(
|
||||||
|
fileMarkerEstablished: true,
|
||||||
|
externalMarkerEstablished: false
|
||||||
|
)
|
||||||
|
},
|
||||||
|
wipeMedia: { _ in throw WipeFailure.failed },
|
||||||
|
complete: { completedMarker = true }
|
||||||
|
)
|
||||||
|
|
||||||
|
let (viewModel, transport) = makeTestableViewModel(
|
||||||
|
panicRecoveryOperations: operations
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(!completedMarker)
|
||||||
|
#expect(transport.emergencyDisconnectCallCount == 1)
|
||||||
|
#expect(transport.startServicesCallCount == 0)
|
||||||
|
#expect(!viewModel.networkActivationAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func failedStartupKeychainRecoveryLeavesIntentAndTransportBlocked() {
|
||||||
|
let keychain = MockKeychain()
|
||||||
|
keychain.simulatedDeleteAllResult = false
|
||||||
|
var events: [String] = []
|
||||||
|
let operations = PanicRecoveryOperations(
|
||||||
|
isPending: { true },
|
||||||
|
begin: {
|
||||||
|
events.append("begin")
|
||||||
|
return PanicRecoveryIntent(
|
||||||
|
fileMarkerEstablished: true,
|
||||||
|
externalMarkerEstablished: true
|
||||||
|
)
|
||||||
|
},
|
||||||
|
wipeMedia: { _ in events.append("wipe") },
|
||||||
|
complete: { events.append("complete") }
|
||||||
|
)
|
||||||
|
|
||||||
|
let (viewModel, transport) = makeTestableViewModel(
|
||||||
|
keychain: keychain,
|
||||||
|
panicRecoveryOperations: operations
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(events == ["begin", "wipe"])
|
||||||
|
#expect(keychain.deleteAllCallCount == 1)
|
||||||
|
#expect(transport.emergencyDisconnectCallCount == 1)
|
||||||
|
#expect(transport.startServicesCallCount == 0)
|
||||||
|
#expect(!viewModel.networkActivationAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func panicClearAllData_delegatesToTransport() async {
|
func panicClearAllData_delegatesToTransport() async {
|
||||||
let (viewModel, transport) = makeTestableViewModel()
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
|||||||
@@ -45,6 +45,154 @@ private func makeDirectConversationID(_ suffix: String) -> ConversationID {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deliberately simple O(n) model used to differentially test the store's
|
||||||
|
/// optimized logical-index bookkeeping. It models observable behavior only;
|
||||||
|
/// it has no offset or ID index and therefore cannot reproduce the same bug.
|
||||||
|
private struct ReferenceConversationTimeline {
|
||||||
|
struct Message: Equatable {
|
||||||
|
let id: String
|
||||||
|
let timestamp: Date
|
||||||
|
let content: String
|
||||||
|
var deliveryStatus: DeliveryStatus?
|
||||||
|
|
||||||
|
init(_ message: BitchatMessage) {
|
||||||
|
id = message.id
|
||||||
|
timestamp = message.timestamp
|
||||||
|
content = message.content
|
||||||
|
deliveryStatus = message.deliveryStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppendResult {
|
||||||
|
let inserted: Bool
|
||||||
|
let trimmedCount: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
let cap: Int
|
||||||
|
private(set) var messages: [Message] = []
|
||||||
|
|
||||||
|
func contains(_ id: String) -> Bool {
|
||||||
|
messages.contains { $0.id == id }
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func append(_ message: BitchatMessage) -> AppendResult {
|
||||||
|
guard !contains(message.id) else {
|
||||||
|
return AppendResult(inserted: false, trimmedCount: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot = Message(message)
|
||||||
|
var low = 0
|
||||||
|
var high = messages.count
|
||||||
|
while low < high {
|
||||||
|
let mid = (low + high) / 2
|
||||||
|
if messages[mid].timestamp <= snapshot.timestamp {
|
||||||
|
low = mid + 1
|
||||||
|
} else {
|
||||||
|
high = mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messages.insert(snapshot, at: low)
|
||||||
|
|
||||||
|
let overflow = max(0, messages.count - cap)
|
||||||
|
if overflow > 0 {
|
||||||
|
messages.removeFirst(overflow)
|
||||||
|
}
|
||||||
|
return AppendResult(inserted: true, trimmedCount: overflow)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func upsert(_ message: BitchatMessage) -> Int {
|
||||||
|
if let index = messages.firstIndex(where: { $0.id == message.id }) {
|
||||||
|
messages[index] = Message(message)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return append(message).trimmedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func applyDeliveryStatus(_ status: DeliveryStatus, to id: String) -> Bool {
|
||||||
|
guard let index = messages.firstIndex(where: { $0.id == id }),
|
||||||
|
messages[index].deliveryStatus != status else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// The differential stream uses only unique `.delivered` values (or
|
||||||
|
// an exact repeat), so no-downgrade policy is intentionally outside
|
||||||
|
// this index-focused reference model.
|
||||||
|
messages[index].deliveryStatus = status
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func remove(at index: Int) -> Message {
|
||||||
|
messages.remove(at: index)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll(where predicate: (Message) -> Bool) {
|
||||||
|
messages.removeAll(where: predicate)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func clear() {
|
||||||
|
messages.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ConversationStoreDifferentialRNG {
|
||||||
|
private var state: UInt64
|
||||||
|
|
||||||
|
init(seed: UInt64) {
|
||||||
|
state = seed
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func next() -> UInt64 {
|
||||||
|
state &+= 0x9E37_79B9_7F4A_7C15
|
||||||
|
var value = state
|
||||||
|
value = (value ^ (value >> 30)) &* 0xBF58_476D_1CE4_E5B9
|
||||||
|
value = (value ^ (value >> 27)) &* 0x94D0_49BB_1331_11EB
|
||||||
|
return value ^ (value >> 31)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func index(upperBound: Int) -> Int {
|
||||||
|
precondition(upperBound > 0)
|
||||||
|
return Int(next() % UInt64(upperBound))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func expectStore(
|
||||||
|
_ store: ConversationStore,
|
||||||
|
matches reference: ReferenceConversationTimeline,
|
||||||
|
issuedIDs: [String],
|
||||||
|
checkpoint: String
|
||||||
|
) {
|
||||||
|
let conversation = store.conversation(for: .mesh)
|
||||||
|
let actual = conversation.messages.map(ReferenceConversationTimeline.Message.init)
|
||||||
|
#expect(actual == reference.messages, "timeline mismatch at \(checkpoint)")
|
||||||
|
|
||||||
|
let lookupSnapshot = reference.messages.compactMap { expected in
|
||||||
|
conversation.message(withID: expected.id).map(ReferenceConversationTimeline.Message.init)
|
||||||
|
}
|
||||||
|
#expect(lookupSnapshot == reference.messages, "ID lookup mismatch at \(checkpoint)")
|
||||||
|
#expect(
|
||||||
|
Set(conversation.messageIDs) == Set(reference.messages.map(\.id)),
|
||||||
|
"per-conversation ID set mismatch at \(checkpoint)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if !reference.messages.isEmpty {
|
||||||
|
for index in Set([0, reference.messages.count / 2, reference.messages.count - 1]) {
|
||||||
|
let id = reference.messages[index].id
|
||||||
|
#expect(store.conversationIDs(forMessageID: id) == [.mesh], "store ID map mismatch at \(checkpoint)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let activeIDs = Set(reference.messages.map(\.id))
|
||||||
|
var checkedStaleIDs = 0
|
||||||
|
for id in issuedIDs.reversed() where !activeIDs.contains(id) {
|
||||||
|
#expect(conversation.message(withID: id) == nil, "stale conversation index entry at \(checkpoint)")
|
||||||
|
#expect(store.conversationIDs(forMessageID: id).isEmpty, "stale store ID map entry at \(checkpoint)")
|
||||||
|
checkedStaleIDs += 1
|
||||||
|
if checkedStaleIDs == 16 { break }
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(store.auditInvariants().isEmpty, "invariant audit failed at \(checkpoint)")
|
||||||
|
}
|
||||||
|
|
||||||
@Suite("ConversationStore")
|
@Suite("ConversationStore")
|
||||||
struct ConversationStoreTests {
|
struct ConversationStoreTests {
|
||||||
|
|
||||||
@@ -140,6 +288,282 @@ struct ConversationStoreTests {
|
|||||||
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("steady-state cap trimming keeps lookups exact across mixed mutations")
|
||||||
|
@MainActor
|
||||||
|
func steadyStateCapTrimmingKeepsLogicalIndexExact() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let conversation = store.conversation(for: .mesh)
|
||||||
|
let overflow = 64
|
||||||
|
|
||||||
|
for i in 0..<(conversation.cap + overflow) {
|
||||||
|
store.append(makeMessage(id: "m\(i)", timestamp: TimeInterval(i)), to: .mesh)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(conversation.messages.first?.id == "m\(overflow)")
|
||||||
|
#expect(conversation.message(withID: "m\(overflow)")?.id == "m\(overflow)")
|
||||||
|
|
||||||
|
// Exercise a suffix reindex after the head offset has advanced, then
|
||||||
|
// trim the old head. The late row becomes the new first element.
|
||||||
|
let late = makeMessage(id: "late", timestamp: TimeInterval(overflow) + 0.5)
|
||||||
|
#expect(store.append(late, to: .mesh))
|
||||||
|
#expect(conversation.messages.first?.id == "late")
|
||||||
|
#expect(conversation.message(withID: "m\(overflow + 1)")?.id == "m\(overflow + 1)")
|
||||||
|
|
||||||
|
// Head and middle removals, an in-place upsert, and a status update
|
||||||
|
// must all resolve through the same logical index representation.
|
||||||
|
#expect(store.removeMessage(withID: "late", from: .mesh)?.id == "late")
|
||||||
|
let middleID = "m\(overflow + conversation.cap / 2)"
|
||||||
|
#expect(store.removeMessage(withID: middleID, from: .mesh)?.id == middleID)
|
||||||
|
|
||||||
|
let probeID = "m\(overflow + 10)"
|
||||||
|
store.upsertByID(
|
||||||
|
makeMessage(id: probeID, timestamp: TimeInterval(overflow + 10), content: "edited"),
|
||||||
|
in: .mesh
|
||||||
|
)
|
||||||
|
#expect(conversation.message(withID: probeID)?.content == "edited")
|
||||||
|
#expect(store.setDeliveryStatus(.sent, forMessageID: probeID, in: .mesh))
|
||||||
|
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
|
||||||
|
#expect(store.auditInvariants().isEmpty)
|
||||||
|
|
||||||
|
// Clearing resets the logical offset as well as the maps.
|
||||||
|
store.clear(.mesh)
|
||||||
|
#expect(store.append(makeMessage(id: "after-clear", timestamp: 10_000), to: .mesh))
|
||||||
|
#expect(conversation.message(withID: "after-clear")?.id == "after-clear")
|
||||||
|
#expect(store.auditInvariants().isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("logical index offset matches a reference model under adversarial mutations")
|
||||||
|
@MainActor
|
||||||
|
func logicalIndexOffsetDifferentialStress() async {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let cap = store.conversation(for: .mesh).cap
|
||||||
|
var reference = ReferenceConversationTimeline(cap: cap)
|
||||||
|
var rng = ConversationStoreDifferentialRNG(seed: 0xC0FF_EE13_37CA_FE42)
|
||||||
|
var issuedIDs: [String] = []
|
||||||
|
var nextID = 0
|
||||||
|
var nextTailTimestamp: TimeInterval = 1_700_000_000
|
||||||
|
var trimmedCount = 0
|
||||||
|
|
||||||
|
var tailAppendCount = 0
|
||||||
|
var outOfOrderCount = 0
|
||||||
|
var duplicateOrReuseCount = 0
|
||||||
|
var headRemovalCount = 0
|
||||||
|
var middleRemovalCount = 0
|
||||||
|
var upsertCount = 0
|
||||||
|
var deliveryUpdateCount = 0
|
||||||
|
var filterCount = 0
|
||||||
|
var clearCount = 0
|
||||||
|
|
||||||
|
func issueMessage(timestamp: TimeInterval? = nil, tag: String) -> BitchatMessage {
|
||||||
|
let number = nextID
|
||||||
|
nextID += 1
|
||||||
|
let id = "diff-\(number)"
|
||||||
|
issuedIDs.append(id)
|
||||||
|
let resolvedTimestamp: TimeInterval
|
||||||
|
if let timestamp {
|
||||||
|
resolvedTimestamp = timestamp
|
||||||
|
} else {
|
||||||
|
resolvedTimestamp = nextTailTimestamp
|
||||||
|
nextTailTimestamp += 1
|
||||||
|
}
|
||||||
|
let dropMarker = number.isMultiple(of: 11) ? " [drop]" : ""
|
||||||
|
return makeMessage(
|
||||||
|
id: id,
|
||||||
|
timestamp: resolvedTimestamp,
|
||||||
|
content: "\(tag) \(number)\(dropMarker)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func appendAndCompare(_ message: BitchatMessage, checkpoint: String) -> ReferenceConversationTimeline.AppendResult {
|
||||||
|
let expected = reference.append(message)
|
||||||
|
let actual = store.append(message, to: .mesh)
|
||||||
|
#expect(actual == expected.inserted, "append result mismatch at \(checkpoint)")
|
||||||
|
trimmedCount += expected.trimmedCount
|
||||||
|
return expected
|
||||||
|
}
|
||||||
|
|
||||||
|
func refill(extra: Int, checkpoint: String) async {
|
||||||
|
let appendCount = max(0, cap - reference.messages.count) + extra
|
||||||
|
for index in 0..<appendCount {
|
||||||
|
appendAndCompare(
|
||||||
|
issueMessage(tag: "refill"),
|
||||||
|
checkpoint: "\(checkpoint)-\(index)"
|
||||||
|
)
|
||||||
|
if index.isMultiple(of: 64) {
|
||||||
|
await Task.yield()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expectStore(store, matches: reference, issuedIDs: issuedIDs, checkpoint: checkpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start well into steady state so the offset is already non-zero
|
||||||
|
// before any mixed operations begin.
|
||||||
|
await refill(extra: 384, checkpoint: "initial steady-state fill")
|
||||||
|
|
||||||
|
for step in 0..<1_200 {
|
||||||
|
if step == 300 || step == 900 {
|
||||||
|
store.removeMessages(from: .mesh) { $0.content.contains("[drop]") }
|
||||||
|
reference.removeAll { $0.content.contains("[drop]") }
|
||||||
|
filterCount += 1
|
||||||
|
expectStore(
|
||||||
|
store,
|
||||||
|
matches: reference,
|
||||||
|
issuedIDs: issuedIDs,
|
||||||
|
checkpoint: "filter at step \(step)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if step == 600 {
|
||||||
|
store.clear(.mesh)
|
||||||
|
reference.clear()
|
||||||
|
clearCount += 1
|
||||||
|
expectStore(
|
||||||
|
store,
|
||||||
|
matches: reference,
|
||||||
|
issuedIDs: issuedIDs,
|
||||||
|
checkpoint: "clear at step \(step)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch rng.index(upperBound: 100) {
|
||||||
|
case 0..<35:
|
||||||
|
appendAndCompare(issueMessage(tag: "tail"), checkpoint: "tail append \(step)")
|
||||||
|
tailAppendCount += 1
|
||||||
|
|
||||||
|
case 35..<55:
|
||||||
|
if reference.messages.isEmpty {
|
||||||
|
appendAndCompare(issueMessage(tag: "tail-fallback"), checkpoint: "OOO fallback \(step)")
|
||||||
|
} else {
|
||||||
|
let target = reference.messages[rng.index(upperBound: reference.messages.count)]
|
||||||
|
let jitter = [-0.25, 0.0, 0.25][rng.index(upperBound: 3)]
|
||||||
|
let timestamp = target.timestamp.timeIntervalSince1970 + jitter
|
||||||
|
appendAndCompare(
|
||||||
|
issueMessage(timestamp: timestamp, tag: "out-of-order"),
|
||||||
|
checkpoint: "out-of-order append \(step)"
|
||||||
|
)
|
||||||
|
outOfOrderCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case 55..<65:
|
||||||
|
if issuedIDs.isEmpty {
|
||||||
|
appendAndCompare(issueMessage(tag: "reuse-fallback"), checkpoint: "reuse fallback \(step)")
|
||||||
|
} else {
|
||||||
|
let reusedID = issuedIDs[rng.index(upperBound: issuedIDs.count)]
|
||||||
|
let message = makeMessage(
|
||||||
|
id: reusedID,
|
||||||
|
timestamp: nextTailTimestamp,
|
||||||
|
content: "duplicate-or-trimmed-reuse \(step)"
|
||||||
|
)
|
||||||
|
nextTailTimestamp += 1
|
||||||
|
appendAndCompare(message, checkpoint: "duplicate or reuse \(step)")
|
||||||
|
duplicateOrReuseCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case 65..<73:
|
||||||
|
if !reference.messages.isEmpty {
|
||||||
|
let expected = reference.remove(at: 0)
|
||||||
|
let actual = store.removeMessage(withID: expected.id, from: .mesh)
|
||||||
|
.map(ReferenceConversationTimeline.Message.init)
|
||||||
|
#expect(actual == expected, "head removal mismatch at step \(step)")
|
||||||
|
headRemovalCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case 73..<81:
|
||||||
|
if !reference.messages.isEmpty {
|
||||||
|
let middleStart = reference.messages.count / 4
|
||||||
|
let middleWidth = max(1, reference.messages.count / 2)
|
||||||
|
let index = min(
|
||||||
|
reference.messages.count - 1,
|
||||||
|
middleStart + rng.index(upperBound: middleWidth)
|
||||||
|
)
|
||||||
|
let expected = reference.remove(at: index)
|
||||||
|
let actual = store.removeMessage(withID: expected.id, from: .mesh)
|
||||||
|
.map(ReferenceConversationTimeline.Message.init)
|
||||||
|
#expect(actual == expected, "middle removal mismatch at step \(step)")
|
||||||
|
middleRemovalCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
case 81..<90:
|
||||||
|
let message: BitchatMessage
|
||||||
|
if step.isMultiple(of: 4) || reference.messages.isEmpty {
|
||||||
|
let timestamp = reference.messages.isEmpty
|
||||||
|
? nil
|
||||||
|
: reference.messages[rng.index(upperBound: reference.messages.count)]
|
||||||
|
.timestamp.timeIntervalSince1970
|
||||||
|
message = issueMessage(timestamp: timestamp, tag: "upsert-new")
|
||||||
|
} else {
|
||||||
|
let current = reference.messages[rng.index(upperBound: reference.messages.count)]
|
||||||
|
message = makeMessage(
|
||||||
|
id: current.id,
|
||||||
|
timestamp: current.timestamp.timeIntervalSince1970,
|
||||||
|
content: "upsert-existing \(step)",
|
||||||
|
deliveryStatus: current.deliveryStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
trimmedCount += reference.upsert(message)
|
||||||
|
store.upsertByID(message, in: .mesh)
|
||||||
|
upsertCount += 1
|
||||||
|
|
||||||
|
default:
|
||||||
|
let id: String
|
||||||
|
let repeatedStatus: DeliveryStatus?
|
||||||
|
if step.isMultiple(of: 6) || reference.messages.isEmpty {
|
||||||
|
id = "missing-\(step)"
|
||||||
|
repeatedStatus = nil
|
||||||
|
} else {
|
||||||
|
let current = reference.messages[rng.index(upperBound: reference.messages.count)]
|
||||||
|
id = current.id
|
||||||
|
repeatedStatus = current.deliveryStatus
|
||||||
|
}
|
||||||
|
let status: DeliveryStatus
|
||||||
|
if step.isMultiple(of: 4), let repeatedStatus {
|
||||||
|
status = repeatedStatus
|
||||||
|
} else {
|
||||||
|
status = .delivered(
|
||||||
|
to: "peer",
|
||||||
|
at: Date(timeIntervalSince1970: 2_000_000_000 + Double(step))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let expected = reference.applyDeliveryStatus(status, to: id)
|
||||||
|
let actual = store.setDeliveryStatus(status, forMessageID: id, in: .mesh)
|
||||||
|
#expect(actual == expected, "delivery update mismatch at step \(step)")
|
||||||
|
deliveryUpdateCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
expectStore(
|
||||||
|
store,
|
||||||
|
matches: reference,
|
||||||
|
issuedIDs: issuedIDs,
|
||||||
|
checkpoint: "mixed operation \(step)"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This intentionally expensive MainActor stress test runs beside
|
||||||
|
// async audio/UI tests in SwiftPM's parallel phase. Cooperatively
|
||||||
|
// release the actor so their bounded waits can make progress.
|
||||||
|
await Task.yield()
|
||||||
|
|
||||||
|
if (step + 1).isMultiple(of: 100) {
|
||||||
|
await refill(extra: 32, checkpoint: "periodic refill after step \(step)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guarantee another long run of one-row evictions after every other
|
||||||
|
// mutation family has perturbed and rebuilt the offset/index state.
|
||||||
|
await refill(extra: 512, checkpoint: "final steady-state trim run")
|
||||||
|
|
||||||
|
#expect(trimmedCount > 1_200)
|
||||||
|
#expect(tailAppendCount > 300)
|
||||||
|
#expect(outOfOrderCount > 150)
|
||||||
|
#expect(duplicateOrReuseCount > 75)
|
||||||
|
#expect(headRemovalCount > 50)
|
||||||
|
#expect(middleRemovalCount > 50)
|
||||||
|
#expect(upsertCount > 75)
|
||||||
|
#expect(deliveryUpdateCount > 75)
|
||||||
|
#expect(filterCount == 2)
|
||||||
|
#expect(clearCount == 1)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Upsert
|
// MARK: - Upsert
|
||||||
|
|
||||||
@Test("upsertByID replaces in place and appends when absent")
|
@Test("upsertByID replaces in place and appends when absent")
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ final class MockKeychain: KeychainManagerProtocol {
|
|||||||
var simulatedReadError: KeychainReadResult?
|
var simulatedReadError: KeychainReadResult?
|
||||||
var simulatedSaveError: KeychainSaveResult?
|
var simulatedSaveError: KeychainSaveResult?
|
||||||
var simulatedGenericReadError: KeychainReadResult?
|
var simulatedGenericReadError: KeychainReadResult?
|
||||||
|
var simulatedDeleteAllResult = true
|
||||||
|
private(set) var deleteAllCallCount = 0
|
||||||
|
|
||||||
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
func saveIdentityKey(_ keyData: Data, forKey key: String) -> Bool {
|
||||||
storage[key] = keyData
|
storage[key] = keyData
|
||||||
@@ -34,6 +36,8 @@ final class MockKeychain: KeychainManagerProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deleteAllKeychainData() -> Bool {
|
func deleteAllKeychainData() -> Bool {
|
||||||
|
deleteAllCallCount += 1
|
||||||
|
guard simulatedDeleteAllResult else { return false }
|
||||||
storage.removeAll()
|
storage.removeAll()
|
||||||
serviceStorage.removeAll()
|
serviceStorage.removeAll()
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -12,8 +12,15 @@ struct NoiseCoverageTests {
|
|||||||
private let bobStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
private let bobStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
private let charlieStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
private let charlieStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
||||||
|
|
||||||
private let alicePeerID = PeerID(str: "0011223344556677")
|
// Manager test dictionaries are keyed by the remote peer. Keep the
|
||||||
private let bobPeerID = PeerID(str: "8899aabbccddeeff")
|
// historical names, but derive each wire ID from the static key that the
|
||||||
|
// corresponding manager authenticates during the handshake.
|
||||||
|
private var alicePeerID: PeerID {
|
||||||
|
PeerID(publicKey: bobStaticKey.publicKey.rawRepresentation)
|
||||||
|
}
|
||||||
|
private var bobPeerID: PeerID {
|
||||||
|
PeerID(publicKey: aliceStaticKey.publicKey.rawRepresentation)
|
||||||
|
}
|
||||||
private let charliePeerID = PeerID(str: "fedcba9876543210")
|
private let charliePeerID = PeerID(str: "fedcba9876543210")
|
||||||
|
|
||||||
@Test("Protocol metadata and handshake patterns expose expected values")
|
@Test("Protocol metadata and handshake patterns expose expected values")
|
||||||
|
|||||||
@@ -5,19 +5,25 @@ import XCTest
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class GeoRelayDirectoryTests: XCTestCase {
|
final class GeoRelayDirectoryTests: XCTestCase {
|
||||||
func test_parseCSV_normalizesRelaySchemesAndDeduplicatesEntries() {
|
private func parse(_ csv: String) -> [GeoRelayDirectory.Entry] {
|
||||||
|
GeoRelayDirectory.validatedEntries(
|
||||||
|
from: Data(csv.utf8),
|
||||||
|
policy: .live,
|
||||||
|
minimumEntries: 1
|
||||||
|
) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_parseCSV_normalizesSecureRelaySchemesAndDeduplicatesEntries() {
|
||||||
let csv = """
|
let csv = """
|
||||||
relay url,lat,lon
|
relay url,lat,lon
|
||||||
wss://one.example/,10,20
|
wss://one.example/,10,20
|
||||||
https://one.example,10,20
|
https://one.example,10,20
|
||||||
wss://one.example:443/,10,20
|
wss://one.example:443/,10,20
|
||||||
http://two.example/,11,21
|
two.example,11,21
|
||||||
wss://two.example:443,11,21
|
wss://two.example:443,11,21
|
||||||
invalid row
|
|
||||||
ws://three.example,not-a-lat,22
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
let parsed = Set(GeoRelayDirectory.parseCSV(csv))
|
let parsed = Set(parse(csv))
|
||||||
|
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(
|
||||||
parsed,
|
parsed,
|
||||||
@@ -28,6 +34,136 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func test_parseCSV_rejectsWholeDatasetWhenAnyRowOrHeaderIsUnsafe() {
|
||||||
|
let invalidCSVs = [
|
||||||
|
"relay,lat,lon\nrelay.example,1,2\n",
|
||||||
|
"relay url,lat,lon\nrelay.example,1\n",
|
||||||
|
"relay url,lat,lon\nhttp://relay.example,1,2\n",
|
||||||
|
"relay url,lat,lon\nwss://user@relay.example,1,2\n",
|
||||||
|
"relay url,lat,lon\nwss://relay.example/path,1,2\n",
|
||||||
|
"relay url,lat,lon\nwss://relay.example?,1,2\n",
|
||||||
|
"relay url,lat,lon\nwss://relay.example#,1,2\n",
|
||||||
|
"relay url,lat,lon\nrelay.example:0,1,2\n",
|
||||||
|
"relay url,lat,lon\nrelay.example:99999,1,2\n",
|
||||||
|
"relay url,lat,lon\nlocalhost,1,2\n",
|
||||||
|
"relay url,lat,lon\nr\u{00e9}lay.example,1,2\n",
|
||||||
|
"relay url,lat,lon\nrelay\u{202e}.example,1,2\n",
|
||||||
|
"relay url,lat,lon\nrelay.example,NaN,2\n",
|
||||||
|
"relay url,lat,lon\nrelay.example,1_0,2\n",
|
||||||
|
"relay url,lat,lon\nrelay.example,\u{0661}\u{0660},2\n",
|
||||||
|
"relay url,lat,lon\nrelay.example,\u{ff11}\u{ff10},2\n",
|
||||||
|
"relay url,lat,lon\nrelay.example,91,2\n",
|
||||||
|
"relay url,lat,lon\nrelay.example,1,181\n",
|
||||||
|
"relay url,lat,lon\nrelay.example,1,2\nrelay.example,3,4\n"
|
||||||
|
]
|
||||||
|
|
||||||
|
for csv in invalidCSVs {
|
||||||
|
XCTAssertTrue(parse(csv).isEmpty, csv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_validatedEntries_enforcesByteRowEntryAndRetentionLimits() {
|
||||||
|
let restrictive = GeoRelayDirectoryValidationPolicy(
|
||||||
|
maximumBytes: 100,
|
||||||
|
maximumRows: 2,
|
||||||
|
maximumEntries: 2,
|
||||||
|
minimumRemoteEntries: 1,
|
||||||
|
minimumRetainedFraction: 0.5
|
||||||
|
)
|
||||||
|
let one = Data("relay url,lat,lon\none.example,1,2\n".utf8)
|
||||||
|
let three = Data("relay url,lat,lon\none.example,1,2\ntwo.example,3,4\nthree.example,5,6\n".utf8)
|
||||||
|
|
||||||
|
XCTAssertNil(GeoRelayDirectory.validatedEntries(
|
||||||
|
from: one,
|
||||||
|
policy: restrictive,
|
||||||
|
minimumEntries: 2
|
||||||
|
))
|
||||||
|
XCTAssertNil(GeoRelayDirectory.validatedEntries(
|
||||||
|
from: Data(repeating: 0x41, count: 101),
|
||||||
|
policy: restrictive,
|
||||||
|
minimumEntries: 1
|
||||||
|
))
|
||||||
|
XCTAssertNil(GeoRelayDirectory.validatedEntries(
|
||||||
|
from: three,
|
||||||
|
policy: restrictive,
|
||||||
|
minimumEntries: 1
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_validatedEntries_requiresExactBaselineEntryOverlap() throws {
|
||||||
|
let policy = GeoRelayDirectoryValidationPolicy(
|
||||||
|
maximumBytes: 1_000,
|
||||||
|
maximumRows: 10,
|
||||||
|
maximumEntries: 10,
|
||||||
|
minimumRemoteEntries: 1,
|
||||||
|
minimumRetainedFraction: 0.5
|
||||||
|
)
|
||||||
|
let baseline = Set(try XCTUnwrap(GeoRelayDirectory.validatedEntries(
|
||||||
|
from: Data("""
|
||||||
|
relay url,lat,lon
|
||||||
|
one.example,1,1
|
||||||
|
two.example,2,2
|
||||||
|
three.example,3,3
|
||||||
|
""".utf8),
|
||||||
|
policy: policy,
|
||||||
|
minimumEntries: 1
|
||||||
|
)))
|
||||||
|
let disjoint = Data("""
|
||||||
|
relay url,lat,lon
|
||||||
|
four.example,1,1
|
||||||
|
five.example,2,2
|
||||||
|
six.example,3,3
|
||||||
|
""".utf8)
|
||||||
|
let rewrittenCoordinates = Data("""
|
||||||
|
relay url,lat,lon
|
||||||
|
one.example,11,11
|
||||||
|
two.example,12,12
|
||||||
|
three.example,13,13
|
||||||
|
""".utf8)
|
||||||
|
let halfRetained = Data("""
|
||||||
|
relay url,lat,lon
|
||||||
|
wss://one.example:443/,1,1
|
||||||
|
https://two.example/,2,2
|
||||||
|
replacement.example,4,4
|
||||||
|
""".utf8)
|
||||||
|
|
||||||
|
XCTAssertNil(GeoRelayDirectory.validatedEntries(
|
||||||
|
from: disjoint,
|
||||||
|
policy: policy,
|
||||||
|
minimumEntries: 1,
|
||||||
|
baselineEntries: baseline
|
||||||
|
))
|
||||||
|
XCTAssertNil(GeoRelayDirectory.validatedEntries(
|
||||||
|
from: rewrittenCoordinates,
|
||||||
|
policy: policy,
|
||||||
|
minimumEntries: 1,
|
||||||
|
baselineEntries: baseline
|
||||||
|
))
|
||||||
|
XCTAssertNotNil(GeoRelayDirectory.validatedEntries(
|
||||||
|
from: halfRetained,
|
||||||
|
policy: policy,
|
||||||
|
minimumEntries: 1,
|
||||||
|
baselineEntries: baseline
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_bundledReviewedCSV_passesStrictProductionValidation() throws {
|
||||||
|
let repositoryRoot = URL(fileURLWithPath: #filePath)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
let data = try Data(
|
||||||
|
contentsOf: repositoryRoot.appendingPathComponent("relays/online_relays_gps.csv")
|
||||||
|
)
|
||||||
|
|
||||||
|
let entries = try XCTUnwrap(GeoRelayDirectory.validatedEntries(
|
||||||
|
from: data,
|
||||||
|
policy: .live,
|
||||||
|
minimumEntries: GeoRelayDirectoryValidationPolicy.live.minimumRemoteEntries
|
||||||
|
))
|
||||||
|
XCTAssertGreaterThan(entries.count, 250)
|
||||||
|
}
|
||||||
|
|
||||||
func test_closestRelays_sortsByDistanceForLatLonAndGeohash() {
|
func test_closestRelays_sortsByDistanceForLatLonAndGeohash() {
|
||||||
let harness = makeHarness(
|
let harness = makeHarness(
|
||||||
cacheCSV: """
|
cacheCSV: """
|
||||||
@@ -243,6 +379,53 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
|||||||
XCTAssertFalse(directory.debugHasRetryTask)
|
XCTAssertFalse(directory.debugHasRetryTask)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func test_prefetchIfNeeded_rejectsSharpValidLookingTruncationBeforeCaching() async {
|
||||||
|
let cached = """
|
||||||
|
relay url,lat,lon
|
||||||
|
old-one.example,1,1
|
||||||
|
old-two.example,2,2
|
||||||
|
old-three.example,3,3
|
||||||
|
"""
|
||||||
|
let truncated = """
|
||||||
|
relay url,lat,lon
|
||||||
|
attacker.example,9,9
|
||||||
|
"""
|
||||||
|
let recovered = """
|
||||||
|
relay url,lat,lon
|
||||||
|
old-one.example,1,1
|
||||||
|
old-two.example,2,2
|
||||||
|
new-three.example,6,6
|
||||||
|
"""
|
||||||
|
let harness = makeHarness(
|
||||||
|
cacheCSV: cached,
|
||||||
|
fetchResults: [
|
||||||
|
.success(Data(truncated.utf8)),
|
||||||
|
.success(Data(recovered.utf8))
|
||||||
|
],
|
||||||
|
validationPolicy: GeoRelayDirectoryValidationPolicy(
|
||||||
|
maximumBytes: 64 * 1024,
|
||||||
|
maximumRows: 1_000,
|
||||||
|
maximumEntries: 1_000,
|
||||||
|
minimumRemoteEntries: 1,
|
||||||
|
minimumRetainedFraction: 0.5
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
|
||||||
|
|
||||||
|
directory.prefetchIfNeeded()
|
||||||
|
|
||||||
|
let refreshed = await waitUntil {
|
||||||
|
directory.entries.contains(where: { $0.host == "new-three.example" })
|
||||||
|
}
|
||||||
|
XCTAssertTrue(refreshed)
|
||||||
|
XCTAssertFalse(directory.entries.contains(where: { $0.host == "attacker.example" }))
|
||||||
|
let requestCount = await harness.fetcher.recordedRequestCount()
|
||||||
|
let retryDelays = await harness.retryRecorder.recordedDelays()
|
||||||
|
XCTAssertEqual(requestCount, 2)
|
||||||
|
XCTAssertEqual(retryDelays, [5])
|
||||||
|
XCTAssertEqual(harness.fileStore.dataByURL[harness.cacheURL], Data(recovered.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
func test_observers_triggerPrefetchesForTorReadyAndAppActivation() async {
|
func test_observers_triggerPrefetchesForTorReadyAndAppActivation() async {
|
||||||
let activeNotification = Notification.Name("GeoRelayDirectoryTests.didBecomeActive")
|
let activeNotification = Notification.Name("GeoRelayDirectoryTests.didBecomeActive")
|
||||||
let harness = makeHarness(
|
let harness = makeHarness(
|
||||||
@@ -289,7 +472,14 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
|||||||
fetchFactoryObserver: (@MainActor @Sendable () -> Void)? = nil,
|
fetchFactoryObserver: (@MainActor @Sendable () -> Void)? = nil,
|
||||||
fetchObserver: (@Sendable () async -> Void)? = nil,
|
fetchObserver: (@Sendable () async -> Void)? = nil,
|
||||||
autoStart: Bool = false,
|
autoStart: Bool = false,
|
||||||
activeNotificationName: Notification.Name? = nil
|
activeNotificationName: Notification.Name? = nil,
|
||||||
|
validationPolicy: GeoRelayDirectoryValidationPolicy = GeoRelayDirectoryValidationPolicy(
|
||||||
|
maximumBytes: 64 * 1024,
|
||||||
|
maximumRows: 1_000,
|
||||||
|
maximumEntries: 1_000,
|
||||||
|
minimumRemoteEntries: 1,
|
||||||
|
minimumRetainedFraction: 0
|
||||||
|
)
|
||||||
) -> GeoRelayHarness {
|
) -> GeoRelayHarness {
|
||||||
let userDefaultsSuite = "GeoRelayDirectoryTests.\(UUID().uuidString)"
|
let userDefaultsSuite = "GeoRelayDirectoryTests.\(UUID().uuidString)"
|
||||||
let userDefaults = UserDefaults(suiteName: userDefaultsSuite)!
|
let userDefaults = UserDefaults(suiteName: userDefaultsSuite)!
|
||||||
@@ -347,7 +537,8 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
|||||||
await retryRecorder.record(delay)
|
await retryRecorder.record(delay)
|
||||||
},
|
},
|
||||||
activeNotificationName: activeNotificationName,
|
activeNotificationName: activeNotificationName,
|
||||||
autoStart: autoStart
|
autoStart: autoStart,
|
||||||
|
validationPolicy: validationPolicy
|
||||||
)
|
)
|
||||||
|
|
||||||
return GeoRelayHarness(
|
return GeoRelayHarness(
|
||||||
|
|||||||
@@ -501,6 +501,62 @@ final class PerformanceBaselineTests: XCTestCase {
|
|||||||
reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages")
|
reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - 7b. ConversationStore append at the retention cap
|
||||||
|
|
||||||
|
/// Steady-state public timeline traffic after the 1337-message retention
|
||||||
|
/// cap has been reached. Every tail append evicts the oldest row, which is
|
||||||
|
/// the long-lived workload the cold `store.append` benchmark does not
|
||||||
|
/// exercise.
|
||||||
|
func testConversationStoreSteadyStateAppend() {
|
||||||
|
let store = ConversationStore()
|
||||||
|
let cap = TransportConfig.meshTimelineCap
|
||||||
|
let messagesPerPass = 500
|
||||||
|
let base = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
|
|
||||||
|
for i in 0..<cap {
|
||||||
|
store.append(
|
||||||
|
BitchatMessage(
|
||||||
|
id: "perf-steady-seed-\(i)",
|
||||||
|
sender: "perfsender",
|
||||||
|
content: "steady-state seed \(i)",
|
||||||
|
timestamp: base.addingTimeInterval(Double(i)),
|
||||||
|
isRelay: false
|
||||||
|
),
|
||||||
|
to: .mesh
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pass = 0
|
||||||
|
var samples: [TimeInterval] = []
|
||||||
|
measure {
|
||||||
|
let startIndex = cap + pass * messagesPerPass
|
||||||
|
let start = Date()
|
||||||
|
for offset in 0..<messagesPerPass {
|
||||||
|
let i = startIndex + offset
|
||||||
|
store.append(
|
||||||
|
BitchatMessage(
|
||||||
|
id: "perf-steady-\(i)",
|
||||||
|
sender: "perfsender",
|
||||||
|
content: "steady-state message \(i)",
|
||||||
|
timestamp: base.addingTimeInterval(Double(i)),
|
||||||
|
isRelay: false
|
||||||
|
),
|
||||||
|
to: .mesh
|
||||||
|
)
|
||||||
|
}
|
||||||
|
samples.append(Date().timeIntervalSince(start))
|
||||||
|
pass += 1
|
||||||
|
XCTAssertEqual(store.conversation(for: .mesh).messages.count, cap)
|
||||||
|
}
|
||||||
|
|
||||||
|
reportThroughput(
|
||||||
|
"store.steadyStateAppend",
|
||||||
|
samples: samples,
|
||||||
|
operations: messagesPerPass,
|
||||||
|
unit: "messages"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - 8. ConversationStore invariant audit (field observability)
|
// MARK: - 8. ConversationStore invariant audit (field observability)
|
||||||
|
|
||||||
/// `ConversationStore.auditInvariants()` over a realistic 5k-message
|
/// `ConversationStore.auditInvariants()` over a realistic 5k-message
|
||||||
|
|||||||
@@ -30,6 +30,10 @@
|
|||||||
"store.append": 213201,
|
"store.append": 213201,
|
||||||
"store.audit": 362
|
"store.audit": 362
|
||||||
},
|
},
|
||||||
|
"_reference_local_numbers_2026_07": {
|
||||||
|
"store.steadyStateAppend_before": 2315,
|
||||||
|
"store.steadyStateAppend": 53976
|
||||||
|
},
|
||||||
"floors": {
|
"floors": {
|
||||||
"nostrInbound.fresh": 450,
|
"nostrInbound.fresh": 450,
|
||||||
"nostrInbound.duplicate": 250000,
|
"nostrInbound.duplicate": 250000,
|
||||||
@@ -41,6 +45,7 @@
|
|||||||
"pipeline.privateIngest": 3000,
|
"pipeline.privateIngest": 3000,
|
||||||
"pipeline.publicIngest": 2400,
|
"pipeline.publicIngest": 2400,
|
||||||
"store.append": 48000,
|
"store.append": 48000,
|
||||||
|
"store.steadyStateAppend": 10000,
|
||||||
"store.audit": 70
|
"store.audit": 70
|
||||||
},
|
},
|
||||||
"_slowest_observed_ci_numbers_2026_06": {
|
"_slowest_observed_ci_numbers_2026_06": {
|
||||||
@@ -56,4 +61,4 @@
|
|||||||
"store.append": 97423,
|
"store.append": 97423,
|
||||||
"store.audit": 140
|
"store.audit": 140
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,103 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import Security
|
||||||
import Testing
|
import Testing
|
||||||
|
import BitFoundation
|
||||||
@testable import bitchat
|
@testable import bitchat
|
||||||
|
|
||||||
@Suite("PreviewKeychainManager Tests")
|
@Suite("PreviewKeychainManager Tests")
|
||||||
struct PreviewKeychainManagerTests {
|
struct PreviewKeychainManagerTests {
|
||||||
|
|
||||||
|
@Test("Install lifecycle distinguishes upgrade, reinstall, bootstrap, and unreadable keychain")
|
||||||
|
func installLifecycleDecision() {
|
||||||
|
#expect(KeychainManager.installLifecycleAction(
|
||||||
|
containerKnowsMarker: true,
|
||||||
|
markerRead: .success(Data([1]))
|
||||||
|
) == .markerPresent)
|
||||||
|
#expect(KeychainManager.installLifecycleAction(
|
||||||
|
containerKnowsMarker: false,
|
||||||
|
markerRead: .success(Data([1]))
|
||||||
|
) == .clearStaleKeys)
|
||||||
|
#expect(KeychainManager.installLifecycleAction(
|
||||||
|
containerKnowsMarker: false,
|
||||||
|
markerRead: .itemNotFound
|
||||||
|
) == .bootstrapMarker)
|
||||||
|
#expect(KeychainManager.installLifecycleAction(
|
||||||
|
containerKnowsMarker: false,
|
||||||
|
markerRead: .deviceLocked
|
||||||
|
) == .retryLater)
|
||||||
|
#expect(KeychainManager.installLifecycleAction(
|
||||||
|
containerKnowsMarker: false,
|
||||||
|
cleanupPending: true,
|
||||||
|
markerRead: .itemNotFound
|
||||||
|
) == .clearStaleKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Accessibility migration covers custom services and retries after any incomplete update")
|
||||||
|
func accessibilityMigrationCoversEveryApplicationOwnedService() {
|
||||||
|
let primaryService = "chat.bitchat.test-primary"
|
||||||
|
var visitedServices: [String] = []
|
||||||
|
|
||||||
|
let completed = KeychainManager
|
||||||
|
.migrateAccessibilityForApplicationOwnedServices(
|
||||||
|
primaryService: primaryService
|
||||||
|
) { service in
|
||||||
|
visitedServices.append(service)
|
||||||
|
return service == "chat.bitchat.favorites"
|
||||||
|
? errSecInteractionNotAllowed
|
||||||
|
: errSecItemNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(!completed)
|
||||||
|
#expect(visitedServices.first == primaryService)
|
||||||
|
#expect(Set(visitedServices).isSuperset(of: [
|
||||||
|
"chat.bitchat.nostr",
|
||||||
|
"chat.bitchat.favorites",
|
||||||
|
"chat.bitchat.outbox"
|
||||||
|
]))
|
||||||
|
#expect(Set(visitedServices).count == visitedServices.count)
|
||||||
|
|
||||||
|
let retryCompleted = KeychainManager
|
||||||
|
.migrateAccessibilityForApplicationOwnedServices(
|
||||||
|
primaryService: primaryService
|
||||||
|
) { _ in errSecSuccess }
|
||||||
|
#expect(retryCompleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Keychain cleanup is complete only when every owned scope is clean")
|
||||||
|
func keychainCleanupRequiresEveryApplicationOwnedService() {
|
||||||
|
let primaryService = "chat.bitchat.test-primary"
|
||||||
|
var visitedServices: [String] = []
|
||||||
|
|
||||||
|
let partialCleanup = KeychainManager
|
||||||
|
.deleteApplicationOwnedKeychainServices(
|
||||||
|
primaryService: primaryService
|
||||||
|
) { service in
|
||||||
|
visitedServices.append(service)
|
||||||
|
return service == "chat.bitchat.outbox"
|
||||||
|
? errSecInteractionNotAllowed
|
||||||
|
: errSecSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(!partialCleanup)
|
||||||
|
#expect(visitedServices.first == primaryService)
|
||||||
|
#expect(Set(visitedServices).isSuperset(of: [
|
||||||
|
"chat.bitchat.nostr",
|
||||||
|
"chat.bitchat.favorites",
|
||||||
|
"chat.bitchat.outbox"
|
||||||
|
]))
|
||||||
|
#expect(Set(visitedServices).count == visitedServices.count)
|
||||||
|
|
||||||
|
let emptyCleanup = KeychainManager
|
||||||
|
.deleteApplicationOwnedKeychainServices(
|
||||||
|
primaryService: primaryService
|
||||||
|
) { _ in errSecItemNotFound }
|
||||||
|
#expect(emptyCleanup)
|
||||||
|
#expect(KeychainManager.completedApplicationGroupDelete(status: -34018))
|
||||||
|
#expect(!KeychainManager.completedApplicationGroupDelete(
|
||||||
|
status: errSecInteractionNotAllowed
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
@Test("Preview keychain manager stores identity and service-scoped data in memory")
|
@Test("Preview keychain manager stores identity and service-scoped data in memory")
|
||||||
func previewKeychainManagerRoundTripsData() {
|
func previewKeychainManagerRoundTripsData() {
|
||||||
let manager = PreviewKeychainManager()
|
let manager = PreviewKeychainManager()
|
||||||
@@ -51,4 +144,132 @@ struct PreviewKeychainManagerTests {
|
|||||||
Issue.record("Expected preview keychain to be empty after deleteAllKeychainData")
|
Issue.record("Expected preview keychain to be empty after deleteAllKeychainData")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("Failed reinstall cleanup blocks stale data until a successful retry")
|
||||||
|
func failedReinstallCleanupBlocksEveryNamespaceUntilSuccessfulRetry() {
|
||||||
|
let gate = KeychainInstallAccessGate()
|
||||||
|
var cleanupCanComplete = false
|
||||||
|
var reconciliationAttempts = 0
|
||||||
|
var manager: PreviewKeychainManager!
|
||||||
|
manager = PreviewKeychainManager(
|
||||||
|
installAccessGate: gate
|
||||||
|
) {
|
||||||
|
reconciliationAttempts += 1
|
||||||
|
guard cleanupCanComplete else { return false }
|
||||||
|
return manager.deleteAllKeychainData()
|
||||||
|
}
|
||||||
|
|
||||||
|
let staleIdentity = Data([1, 2, 3])
|
||||||
|
let staleFavorite = Data([4, 5, 6])
|
||||||
|
let staleOutbox = Data([7, 8, 9])
|
||||||
|
let staleCustom = Data([10, 11, 12])
|
||||||
|
#expect(manager.saveIdentityKey(
|
||||||
|
staleIdentity,
|
||||||
|
forKey: "noiseStaticKey"
|
||||||
|
))
|
||||||
|
#expect(manager.saveIdentityKey(
|
||||||
|
staleIdentity,
|
||||||
|
forKey: "identity_noiseStaticKey"
|
||||||
|
))
|
||||||
|
#expect(manager.verifyIdentityKeyExists())
|
||||||
|
manager.save(
|
||||||
|
key: "favorite",
|
||||||
|
data: staleFavorite,
|
||||||
|
service: "chat.bitchat.favorites",
|
||||||
|
accessible: nil
|
||||||
|
)
|
||||||
|
manager.save(
|
||||||
|
key: "outbox",
|
||||||
|
data: staleOutbox,
|
||||||
|
service: "chat.bitchat.outbox",
|
||||||
|
accessible: nil
|
||||||
|
)
|
||||||
|
manager.save(
|
||||||
|
key: "custom",
|
||||||
|
data: staleCustom,
|
||||||
|
service: "chat.bitchat.future-custom",
|
||||||
|
accessible: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
gate.block()
|
||||||
|
|
||||||
|
#expect(manager.getIdentityKey(forKey: "noiseStaticKey") == nil)
|
||||||
|
#expect(!manager.verifyIdentityKeyExists())
|
||||||
|
if case .accessDenied = manager.getIdentityKeyWithResult(
|
||||||
|
forKey: "noiseStaticKey"
|
||||||
|
) {
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected blocked identity read to fail closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
for (key, service) in [
|
||||||
|
("favorite", "chat.bitchat.favorites"),
|
||||||
|
("outbox", "chat.bitchat.outbox"),
|
||||||
|
("custom", "chat.bitchat.future-custom")
|
||||||
|
] {
|
||||||
|
#expect(manager.load(key: key, service: service) == nil)
|
||||||
|
if case .accessDenied = manager.loadWithResult(
|
||||||
|
key: key,
|
||||||
|
service: service
|
||||||
|
) {
|
||||||
|
} else {
|
||||||
|
Issue.record(
|
||||||
|
"Expected blocked \(service) read to fail closed"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(!manager.saveIdentityKey(
|
||||||
|
Data([13]),
|
||||||
|
forKey: "replacement"
|
||||||
|
))
|
||||||
|
if case .accessDenied = manager.saveIdentityKeyWithResult(
|
||||||
|
Data([14]),
|
||||||
|
forKey: "replacement"
|
||||||
|
) {
|
||||||
|
} else {
|
||||||
|
Issue.record("Expected blocked identity save to fail closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
let failedAttempts = reconciliationAttempts
|
||||||
|
#expect(failedAttempts > 0)
|
||||||
|
cleanupCanComplete = true
|
||||||
|
|
||||||
|
// The first access retries cleanup synchronously. It must not return
|
||||||
|
// any surviving value from before the reinstall.
|
||||||
|
#expect(manager.getIdentityKey(forKey: "noiseStaticKey") == nil)
|
||||||
|
#expect(reconciliationAttempts == failedAttempts + 1)
|
||||||
|
#expect(manager.load(
|
||||||
|
key: "favorite",
|
||||||
|
service: "chat.bitchat.favorites"
|
||||||
|
) == nil)
|
||||||
|
#expect(manager.load(
|
||||||
|
key: "outbox",
|
||||||
|
service: "chat.bitchat.outbox"
|
||||||
|
) == nil)
|
||||||
|
#expect(manager.load(
|
||||||
|
key: "custom",
|
||||||
|
service: "chat.bitchat.future-custom"
|
||||||
|
) == nil)
|
||||||
|
|
||||||
|
let replacementIdentity = Data([21, 22, 23])
|
||||||
|
let replacementCustom = Data([24, 25, 26])
|
||||||
|
#expect(manager.saveIdentityKey(
|
||||||
|
replacementIdentity,
|
||||||
|
forKey: "noiseStaticKey"
|
||||||
|
))
|
||||||
|
#expect(manager.getIdentityKey(
|
||||||
|
forKey: "noiseStaticKey"
|
||||||
|
) == replacementIdentity)
|
||||||
|
manager.save(
|
||||||
|
key: "custom",
|
||||||
|
data: replacementCustom,
|
||||||
|
service: "chat.bitchat.future-custom",
|
||||||
|
accessible: nil
|
||||||
|
)
|
||||||
|
#expect(manager.load(
|
||||||
|
key: "custom",
|
||||||
|
service: "chat.bitchat.future-custom"
|
||||||
|
) == replacementCustom)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Testing
|
|||||||
struct BLEAnnounceThrottleTests {
|
struct BLEAnnounceThrottleTests {
|
||||||
@Test
|
@Test
|
||||||
func firstAnnounceIsAllowed() {
|
func firstAnnounceIsAllowed() {
|
||||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||||
|
|
||||||
let shouldSend = throttle.shouldSend(force: false, now: Date(timeIntervalSince1970: 100))
|
let shouldSend = throttle.shouldSend(force: false, now: Date(timeIntervalSince1970: 100))
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ struct BLEAnnounceThrottleTests {
|
|||||||
@Test
|
@Test
|
||||||
func regularAnnounceUsesNormalMinimumInterval() {
|
func regularAnnounceUsesNormalMinimumInterval() {
|
||||||
let now = Date(timeIntervalSince1970: 100)
|
let now = Date(timeIntervalSince1970: 100)
|
||||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||||
|
|
||||||
let first = throttle.shouldSend(force: false, now: now)
|
let first = throttle.shouldSend(force: false, now: now)
|
||||||
let suppressed = throttle.shouldSend(force: false, now: now.addingTimeInterval(9.9))
|
let suppressed = throttle.shouldSend(force: false, now: now.addingTimeInterval(9.9))
|
||||||
@@ -29,7 +29,7 @@ struct BLEAnnounceThrottleTests {
|
|||||||
@Test
|
@Test
|
||||||
func forcedAnnounceUsesShorterMinimumInterval() {
|
func forcedAnnounceUsesShorterMinimumInterval() {
|
||||||
let now = Date(timeIntervalSince1970: 100)
|
let now = Date(timeIntervalSince1970: 100)
|
||||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||||
|
|
||||||
let first = throttle.shouldSend(force: false, now: now)
|
let first = throttle.shouldSend(force: false, now: now)
|
||||||
let suppressed = throttle.shouldSend(force: true, now: now.addingTimeInterval(1.9))
|
let suppressed = throttle.shouldSend(force: true, now: now.addingTimeInterval(1.9))
|
||||||
@@ -43,10 +43,40 @@ struct BLEAnnounceThrottleTests {
|
|||||||
@Test
|
@Test
|
||||||
func elapsedReportsTimeSinceAcceptedSend() {
|
func elapsedReportsTimeSinceAcceptedSend() {
|
||||||
let now = Date(timeIntervalSince1970: 100)
|
let now = Date(timeIntervalSince1970: 100)
|
||||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||||
|
|
||||||
_ = throttle.shouldSend(force: false, now: now)
|
_ = throttle.shouldSend(force: false, now: now)
|
||||||
|
|
||||||
#expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3)
|
#expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func concurrentRequestsAdmitOnlyOneAnnounce() {
|
||||||
|
let now = Date(timeIntervalSince1970: 100)
|
||||||
|
let throttle = BLEAnnounceThrottle(
|
||||||
|
normalMinimumInterval: 10,
|
||||||
|
forcedMinimumInterval: 2
|
||||||
|
)
|
||||||
|
let accepted = LockedCounter()
|
||||||
|
|
||||||
|
DispatchQueue.concurrentPerform(iterations: 1_000) { _ in
|
||||||
|
if throttle.shouldSend(force: false, now: now) {
|
||||||
|
accepted.increment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(accepted.value == 1)
|
||||||
|
#expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class LockedCounter: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var count = 0
|
||||||
|
|
||||||
|
var value: Int { lock.withLock { count } }
|
||||||
|
|
||||||
|
func increment() {
|
||||||
|
lock.withLock { count += 1 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -370,6 +370,132 @@ struct BLEFileTransferHandlerTests {
|
|||||||
#expect(!FileManager.default.fileExists(atPath: evictable.path))
|
#expect(!FileManager.default.fileExists(atPath: evictable.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func panicWipeDeletesEveryManagedMediaFileAndRecreatesEmptyDirectories() throws {
|
||||||
|
let base = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("panic-media-wipe-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
defer { try? FileManager.default.removeItem(at: base) }
|
||||||
|
let store = BLEIncomingFileStore(baseDirectory: base)
|
||||||
|
let subdirectories = [
|
||||||
|
"voicenotes/incoming",
|
||||||
|
"voicenotes/outgoing",
|
||||||
|
"images/incoming",
|
||||||
|
"images/outgoing",
|
||||||
|
"files/incoming",
|
||||||
|
"files/outgoing"
|
||||||
|
]
|
||||||
|
|
||||||
|
for subdirectory in subdirectories {
|
||||||
|
let directory = base
|
||||||
|
.appendingPathComponent("files", isDirectory: true)
|
||||||
|
.appendingPathComponent(subdirectory, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
try Data("secret".utf8).write(to: directory.appendingPathComponent("artifact.bin"))
|
||||||
|
}
|
||||||
|
let unmanaged = base.appendingPathComponent("files/legacy/secret.bin")
|
||||||
|
try FileManager.default.createDirectory(at: unmanaged.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
try Data("legacy".utf8).write(to: unmanaged)
|
||||||
|
|
||||||
|
try store.panicWipe()
|
||||||
|
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: unmanaged.path))
|
||||||
|
for subdirectory in subdirectories {
|
||||||
|
let directory = base
|
||||||
|
.appendingPathComponent("files", isDirectory: true)
|
||||||
|
.appendingPathComponent(subdirectory, isDirectory: true)
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
#expect(FileManager.default.fileExists(atPath: directory.path, isDirectory: &isDirectory))
|
||||||
|
#expect(isDirectory.boolValue)
|
||||||
|
#expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
|
||||||
|
enum MarkerFailure: Error { case unavailable }
|
||||||
|
|
||||||
|
let base = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"panic-marker-failure-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: base) }
|
||||||
|
let secret = base
|
||||||
|
.appendingPathComponent("files/images/outgoing", isDirectory: true)
|
||||||
|
.appendingPathComponent("secret.jpg")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: secret.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try Data("secret".utf8).write(to: secret)
|
||||||
|
let store = BLEIncomingFileStore(
|
||||||
|
baseDirectory: base,
|
||||||
|
panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable }
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try store.panicWipe(hasDurablePendingMarker: false)
|
||||||
|
Issue.record("Expected the missing durable marker to fail closed")
|
||||||
|
} catch {
|
||||||
|
// The marker error is reported only after the deletion attempt.
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: secret.path))
|
||||||
|
#expect(
|
||||||
|
FileManager.default.fileExists(
|
||||||
|
atPath: secret.deletingLastPathComponent().path
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func externalMarkerAllowsDeletionToCommitWhenFileMarkerFails() throws {
|
||||||
|
enum MarkerFailure: Error { case unavailable }
|
||||||
|
|
||||||
|
let base = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"panic-external-marker-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: base) }
|
||||||
|
let secret = base
|
||||||
|
.appendingPathComponent("files/voicenotes/incoming", isDirectory: true)
|
||||||
|
.appendingPathComponent("secret.m4a")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: secret.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try Data("secret".utf8).write(to: secret)
|
||||||
|
let store = BLEIncomingFileStore(
|
||||||
|
baseDirectory: base,
|
||||||
|
panicMarkerWriter: { _, _ in throw MarkerFailure.unavailable }
|
||||||
|
)
|
||||||
|
|
||||||
|
try store.panicWipe(hasDurablePendingMarker: true)
|
||||||
|
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: secret.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func panicRecoveryMarkerPersistsUntilExplicitCommit() throws {
|
||||||
|
let base = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"panic-recovery-marker-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: base) }
|
||||||
|
let store = BLEIncomingFileStore(baseDirectory: base)
|
||||||
|
|
||||||
|
try store.markPanicRecoveryPending()
|
||||||
|
#expect(try store.isPanicRecoveryPending())
|
||||||
|
try store.panicWipe(hasDurablePendingMarker: true)
|
||||||
|
#expect(try store.isPanicRecoveryPending())
|
||||||
|
|
||||||
|
try store.completePanicRecovery()
|
||||||
|
|
||||||
|
#expect(try !store.isPanicRecoveryPending())
|
||||||
|
}
|
||||||
|
|
||||||
private func expectNoSideEffects(_ recorder: Recorder) {
|
private func expectNoSideEffects(_ recorder: Recorder) {
|
||||||
#expect(recorder.signedNameQueries.isEmpty)
|
#expect(recorder.signedNameQueries.isEmpty)
|
||||||
#expect(recorder.trackedPackets.isEmpty)
|
#expect(recorder.trackedPackets.isEmpty)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import bitchat
|
||||||
|
|
||||||
|
struct BLELocalIdentityStateStoreTests {
|
||||||
|
@Test
|
||||||
|
func identityReplacementUpdatesWireBytesAtomically() throws {
|
||||||
|
let initial = PeerID(str: "0011223344556677")
|
||||||
|
let replacement = PeerID(str: "8899aabbccddeeff")
|
||||||
|
let store = BLELocalIdentityStateStore(peerID: initial, nickname: "alice")
|
||||||
|
|
||||||
|
store.replacePeerIdentity(with: replacement)
|
||||||
|
|
||||||
|
let snapshot = store.snapshot()
|
||||||
|
#expect(snapshot.peerID == replacement)
|
||||||
|
#expect(snapshot.peerIDData == Data(hexString: replacement.id))
|
||||||
|
#expect(snapshot.nickname == "alice")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func concurrentReadsNeverObserveSplitIdentityState() {
|
||||||
|
let peerIDs = [
|
||||||
|
PeerID(str: "0011223344556677"),
|
||||||
|
PeerID(str: "8899aabbccddeeff")
|
||||||
|
]
|
||||||
|
let store = BLELocalIdentityStateStore(peerID: peerIDs[0], nickname: "alice")
|
||||||
|
let failures = LockedFailureRecorder()
|
||||||
|
|
||||||
|
DispatchQueue.concurrentPerform(iterations: 2_000) { index in
|
||||||
|
if index.isMultiple(of: 2) {
|
||||||
|
store.replacePeerIdentity(with: peerIDs[index % peerIDs.count])
|
||||||
|
} else {
|
||||||
|
store.setNickname(index.isMultiple(of: 3) ? "alice" : "bob")
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot = store.snapshot()
|
||||||
|
let expectedWireID = Data(hexString: snapshot.peerID.id) ?? Data()
|
||||||
|
if snapshot.peerIDData != expectedWireID {
|
||||||
|
failures.record()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(!failures.hasFailure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class LockedFailureRecorder: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var failed = false
|
||||||
|
|
||||||
|
var hasFailure: Bool { lock.withLock { failed } }
|
||||||
|
|
||||||
|
func record() {
|
||||||
|
lock.withLock { failed = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ struct BLENoisePacketHandlerTests {
|
|||||||
|
|
||||||
private final class Recorder {
|
private final class Recorder {
|
||||||
var handshakeResult: Result<Data?, Error> = .success(nil)
|
var handshakeResult: Result<Data?, Error> = .success(nil)
|
||||||
|
var handshakeAuthenticated = false
|
||||||
var hasSession = false
|
var hasSession = false
|
||||||
var decryptResult: Result<Data, Error> = .success(Data())
|
var decryptResult: Result<Data, Error> = .success(Data())
|
||||||
|
|
||||||
@@ -38,7 +39,11 @@ struct BLENoisePacketHandlerTests {
|
|||||||
now: { now },
|
now: { now },
|
||||||
processHandshakeMessage: { peerID, message in
|
processHandshakeMessage: { peerID, message in
|
||||||
recorder.processedHandshakes.append((peerID, message))
|
recorder.processedHandshakes.append((peerID, message))
|
||||||
return try recorder.handshakeResult.get()
|
return NoiseHandshakeProcessingResult(
|
||||||
|
response: try recorder.handshakeResult.get(),
|
||||||
|
didEstablishAuthenticatedSession:
|
||||||
|
recorder.handshakeAuthenticated
|
||||||
|
)
|
||||||
},
|
},
|
||||||
hasNoiseSession: { peerID in
|
hasNoiseSession: { peerID in
|
||||||
recorder.hasSessionQueries.append(peerID)
|
recorder.hasSessionQueries.append(peerID)
|
||||||
@@ -110,6 +115,24 @@ struct BLENoisePacketHandlerTests {
|
|||||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func handshakeResultPreservesExactCandidateAuthentication() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.handshakeAuthenticated = true
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let packet = makeHandshakePacket(
|
||||||
|
recipientID: Data(hexString: localPeerID.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = handler.handleHandshakeWithResult(
|
||||||
|
packet,
|
||||||
|
from: remotePeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(result.processed)
|
||||||
|
#expect(result.didEstablishAuthenticatedSession)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func handshakeForAnotherPeerIsIgnored() {
|
func handshakeForAnotherPeerIsIgnored() {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
@@ -152,6 +175,21 @@ struct BLENoisePacketHandlerTests {
|
|||||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func peerIdentityMismatchDoesNotRecreateHandshakeState() {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.handshakeResult = .failure(NoiseSessionError.peerIdentityMismatch)
|
||||||
|
recorder.hasSession = false
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let packet = makeHandshakePacket(recipientID: Data(hexString: localPeerID.id))
|
||||||
|
|
||||||
|
#expect(!handler.handleHandshake(packet, from: remotePeerID))
|
||||||
|
|
||||||
|
#expect(recorder.hasSessionQueries.isEmpty)
|
||||||
|
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||||
|
#expect(recorder.broadcastPackets.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Encrypted
|
// MARK: Encrypted
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
|||||||
burstMaxDelay: 0
|
burstMaxDelay: 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
service.start()
|
||||||
service.performHeartbeat()
|
service.performHeartbeat()
|
||||||
|
|
||||||
let sentAllAllowedChannels = await waitUntil { sentGeohashes.count == 3 }
|
let sentAllAllowedChannels = await waitUntil { sentGeohashes.count == 3 }
|
||||||
@@ -83,7 +84,7 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
|||||||
XCTAssertEqual(Set(sentGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
XCTAssertEqual(Set(sentGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
||||||
XCTAssertEqual(Set(lookedUpGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
XCTAssertEqual(Set(lookedUpGeohashes), Set(["9q", "9q8y", "9q8yy"]))
|
||||||
XCTAssertEqual(sleptNanoseconds.count, 3)
|
XCTAssertEqual(sleptNanoseconds.count, 3)
|
||||||
XCTAssertEqual(scheduler.intervals, [17])
|
XCTAssertEqual(scheduler.intervals, [17, 17])
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_performHeartbeat_skipsBroadcastWhenTorIsNotReady() async {
|
func test_performHeartbeat_skipsBroadcastWhenTorIsNotReady() async {
|
||||||
@@ -97,11 +98,12 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
|||||||
loopMaxInterval: 21
|
loopMaxInterval: 21
|
||||||
)
|
)
|
||||||
|
|
||||||
|
service.start()
|
||||||
service.performHeartbeat()
|
service.performHeartbeat()
|
||||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
|
||||||
XCTAssertEqual(sendCount, 0)
|
XCTAssertEqual(sendCount, 0)
|
||||||
XCTAssertEqual(scheduler.intervals, [21])
|
XCTAssertEqual(scheduler.intervals, [21, 21])
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_performHeartbeat_skipsBroadcastWhenAppIsBackgrounded() async {
|
func test_performHeartbeat_skipsBroadcastWhenAppIsBackgrounded() async {
|
||||||
@@ -115,11 +117,45 @@ final class GeohashPresenceServiceTests: XCTestCase {
|
|||||||
loopMaxInterval: 22
|
loopMaxInterval: 22
|
||||||
)
|
)
|
||||||
|
|
||||||
|
service.start()
|
||||||
service.performHeartbeat()
|
service.performHeartbeat()
|
||||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
|
||||||
XCTAssertEqual(sendCount, 0)
|
XCTAssertEqual(sendCount, 0)
|
||||||
XCTAssertEqual(scheduler.intervals, [22])
|
XCTAssertEqual(scheduler.intervals, [22, 22])
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_stopForPanic_cancelsTimerAndSuppressesDelayedBroadcast() async throws {
|
||||||
|
let identity = try NostrIdentity.generate()
|
||||||
|
let scheduler = MockGeohashPresenceScheduler()
|
||||||
|
var sleeperContinuation: CheckedContinuation<Void, Never>?
|
||||||
|
var sendCount = 0
|
||||||
|
let service = makeService(
|
||||||
|
scheduler: scheduler,
|
||||||
|
deriveIdentity: { _ in identity },
|
||||||
|
relaySender: { _, _ in sendCount += 1 },
|
||||||
|
sleeper: { _ in
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
|
sleeperContinuation = continuation
|
||||||
|
}
|
||||||
|
},
|
||||||
|
burstMinDelay: 1,
|
||||||
|
burstMaxDelay: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
service.start()
|
||||||
|
service.performHeartbeat()
|
||||||
|
let delayStarted = await waitUntil {
|
||||||
|
sleeperContinuation != nil
|
||||||
|
}
|
||||||
|
XCTAssertTrue(delayStarted)
|
||||||
|
|
||||||
|
service.stopForPanic()
|
||||||
|
sleeperContinuation?.resume()
|
||||||
|
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||||
|
|
||||||
|
XCTAssertEqual(sendCount, 0)
|
||||||
|
XCTAssertEqual(scheduler.timers.first?.invalidateCallCount, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_broadcastPresence_skipsSendWhenNoRelaysAreAvailable() async throws {
|
func test_broadcastPresence_skipsSendWhenNoRelaysAreAvailable() async throws {
|
||||||
|
|||||||
@@ -91,6 +91,53 @@ final class NetworkActivationServiceTests: XCTestCase {
|
|||||||
XCTAssertGreaterThanOrEqual(context.relayController.connectCallCount, 1)
|
XCTAssertGreaterThanOrEqual(context.relayController.connectCallCount, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func test_stopForPanic_synchronouslyStopsAndIgnoresPublisherUpdates() async {
|
||||||
|
let context = makeService(permission: .authorized, favorites: [])
|
||||||
|
|
||||||
|
context.service.start()
|
||||||
|
context.service.stopForPanic()
|
||||||
|
let connectCountAfterStop = context.relayController.connectCallCount
|
||||||
|
let startCountAfterStop = context.torController.startIfNeededCallCount
|
||||||
|
|
||||||
|
context.favoritesSubject.send([Data([0x01])])
|
||||||
|
context.reachability.set(false)
|
||||||
|
context.reachability.set(true)
|
||||||
|
try? await Task.sleep(nanoseconds: 30_000_000)
|
||||||
|
|
||||||
|
XCTAssertFalse(context.service.activationAllowed)
|
||||||
|
XCTAssertEqual(context.reachability.stopCallCount, 1)
|
||||||
|
XCTAssertEqual(context.torController.autoStartAllowedValues.last, false)
|
||||||
|
XCTAssertEqual(context.proxyController.proxyModes.last, false)
|
||||||
|
XCTAssertGreaterThanOrEqual(
|
||||||
|
context.torController.shutdownCompletelyCallCount,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
XCTAssertGreaterThanOrEqual(
|
||||||
|
context.relayController.disconnectCallCount,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
context.relayController.connectCallCount,
|
||||||
|
connectCountAfterStop
|
||||||
|
)
|
||||||
|
XCTAssertEqual(
|
||||||
|
context.torController.startIfNeededCallCount,
|
||||||
|
startCountAfterStop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func test_start_afterPanicStop_reestablishesSubscriptions() {
|
||||||
|
let context = makeService(permission: .authorized, favorites: [])
|
||||||
|
|
||||||
|
context.service.start()
|
||||||
|
context.service.stopForPanic()
|
||||||
|
context.service.start()
|
||||||
|
|
||||||
|
XCTAssertTrue(context.service.activationAllowed)
|
||||||
|
XCTAssertEqual(context.reachability.startCallCount, 2)
|
||||||
|
XCTAssertEqual(context.relayController.connectCallCount, 2)
|
||||||
|
}
|
||||||
|
|
||||||
private func makeService(
|
private func makeService(
|
||||||
permission: LocationChannelManager.PermissionState,
|
permission: LocationChannelManager.PermissionState,
|
||||||
favorites: Set<Data>
|
favorites: Set<Data>
|
||||||
@@ -104,6 +151,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
|||||||
let torController = MockNetworkActivationTorController()
|
let torController = MockNetworkActivationTorController()
|
||||||
let relayController = MockNetworkActivationRelayController()
|
let relayController = MockNetworkActivationRelayController()
|
||||||
let proxyController = MockNetworkActivationProxyController()
|
let proxyController = MockNetworkActivationProxyController()
|
||||||
|
let reachability = MockNetworkActivationReachability()
|
||||||
let notificationCenter = NotificationCenter()
|
let notificationCenter = NotificationCenter()
|
||||||
let service = NetworkActivationService(
|
let service = NetworkActivationService(
|
||||||
storage: storage,
|
storage: storage,
|
||||||
@@ -111,7 +159,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
|||||||
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
|
mutualFavoritesPublisher: favoritesSubject.eraseToAnyPublisher(),
|
||||||
permissionProvider: { permissionSubject.value },
|
permissionProvider: { permissionSubject.value },
|
||||||
mutualFavoritesProvider: { favoritesSubject.value },
|
mutualFavoritesProvider: { favoritesSubject.value },
|
||||||
reachabilityMonitor: AlwaysReachableMonitor(),
|
reachabilityMonitor: reachability,
|
||||||
torController: torController,
|
torController: torController,
|
||||||
relayController: relayController,
|
relayController: relayController,
|
||||||
proxyController: proxyController,
|
proxyController: proxyController,
|
||||||
@@ -121,6 +169,7 @@ final class NetworkActivationServiceTests: XCTestCase {
|
|||||||
service: service,
|
service: service,
|
||||||
storage: storage,
|
storage: storage,
|
||||||
favoritesSubject: favoritesSubject,
|
favoritesSubject: favoritesSubject,
|
||||||
|
reachability: reachability,
|
||||||
torController: torController,
|
torController: torController,
|
||||||
relayController: relayController,
|
relayController: relayController,
|
||||||
proxyController: proxyController,
|
proxyController: proxyController,
|
||||||
@@ -148,12 +197,38 @@ private struct NetworkActivationTestContext {
|
|||||||
let service: NetworkActivationService
|
let service: NetworkActivationService
|
||||||
let storage: UserDefaults
|
let storage: UserDefaults
|
||||||
let favoritesSubject: CurrentValueSubject<Set<Data>, Never>
|
let favoritesSubject: CurrentValueSubject<Set<Data>, Never>
|
||||||
|
let reachability: MockNetworkActivationReachability
|
||||||
let torController: MockNetworkActivationTorController
|
let torController: MockNetworkActivationTorController
|
||||||
let relayController: MockNetworkActivationRelayController
|
let relayController: MockNetworkActivationRelayController
|
||||||
let proxyController: MockNetworkActivationProxyController
|
let proxyController: MockNetworkActivationProxyController
|
||||||
let notificationCenter: NotificationCenter
|
let notificationCenter: NotificationCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class MockNetworkActivationReachability:
|
||||||
|
NetworkReachabilityMonitoring {
|
||||||
|
private let subject = CurrentValueSubject<Bool, Never>(true)
|
||||||
|
private(set) var startCallCount = 0
|
||||||
|
private(set) var stopCallCount = 0
|
||||||
|
|
||||||
|
var isReachable: Bool { subject.value }
|
||||||
|
var reachabilityPublisher: AnyPublisher<Bool, Never> {
|
||||||
|
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
startCallCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
stopCallCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(_ reachable: Bool) {
|
||||||
|
subject.send(reachable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private final class MockNetworkActivationTorController: NetworkActivationTorControlling {
|
private final class MockNetworkActivationTorController: NetworkActivationTorControlling {
|
||||||
private(set) var autoStartAllowedValues: [Bool] = []
|
private(set) var autoStartAllowedValues: [Bool] = []
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ private final class ControllableReachabilityMonitor: NetworkReachabilityMonitori
|
|||||||
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
|
subject.removeDuplicates().dropFirst().eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
func start() { startCalled = true }
|
func start() { startCalled = true }
|
||||||
|
func stop() { startCalled = false }
|
||||||
func set(_ reachable: Bool) { subject.send(reachable) }
|
func set(_ reachable: Bool) { subject.send(reachable) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,39 +91,150 @@ struct NoiseEncryptionServiceTests {
|
|||||||
func handshakeEncryptionAndFingerprintLifecycle() async throws {
|
func handshakeEncryptionAndFingerprintLifecycle() async throws {
|
||||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
let alicePeerID = PeerID(str: "0011223344556677")
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
let bobPeerID = PeerID(str: "8899aabbccddeeff")
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
let recorder = AuthenticationRecorder()
|
let recorder = AuthenticationRecorder()
|
||||||
|
|
||||||
#expect(alice.onPeerAuthenticated == nil)
|
#expect(alice.onPeerAuthenticated == nil)
|
||||||
alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
alice.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
||||||
bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:)
|
bob.onPeerAuthenticated = recorder.record(peerID:fingerprint:)
|
||||||
|
|
||||||
try establishSessions(alice: alice, bob: bob, alicePeerID: alicePeerID, bobPeerID: bobPeerID)
|
try establishSessions(alice: alice, bob: bob)
|
||||||
|
|
||||||
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0)
|
let authenticated = await TestHelpers.waitUntil({ recorder.count >= 2 }, timeout: 5.0)
|
||||||
#expect(authenticated)
|
#expect(authenticated)
|
||||||
#expect(alice.hasEstablishedSession(with: alicePeerID))
|
#expect(alice.hasEstablishedSession(with: bobPeerID))
|
||||||
#expect(bob.hasEstablishedSession(with: bobPeerID))
|
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
||||||
#expect(alice.hasSession(with: alicePeerID))
|
#expect(alice.hasSession(with: bobPeerID))
|
||||||
#expect(bob.hasSession(with: bobPeerID))
|
#expect(bob.hasSession(with: alicePeerID))
|
||||||
#expect(alice.getPeerPublicKeyData(alicePeerID)?.count == 32)
|
#expect(alice.getPeerPublicKeyData(bobPeerID)?.count == 32)
|
||||||
#expect(bob.getPeerPublicKeyData(bobPeerID)?.count == 32)
|
#expect(bob.getPeerPublicKeyData(alicePeerID)?.count == 32)
|
||||||
#expect(alice.getPeerFingerprint(alicePeerID) != nil)
|
#expect(alice.getPeerFingerprint(bobPeerID) != nil)
|
||||||
#expect(bob.getPeerFingerprint(bobPeerID) != nil)
|
#expect(bob.getPeerFingerprint(alicePeerID) != nil)
|
||||||
|
|
||||||
let plaintext = Data("secret payload".utf8)
|
let plaintext = Data("secret payload".utf8)
|
||||||
let ciphertext = try alice.encrypt(plaintext, for: alicePeerID)
|
let ciphertext = try alice.encrypt(plaintext, for: bobPeerID)
|
||||||
let decrypted = try bob.decrypt(ciphertext, from: bobPeerID)
|
let decrypted = try bob.decrypt(ciphertext, from: alicePeerID)
|
||||||
#expect(decrypted == plaintext)
|
#expect(decrypted == plaintext)
|
||||||
|
|
||||||
alice.clearSession(for: alicePeerID)
|
alice.clearSession(for: bobPeerID)
|
||||||
#expect(!alice.hasSession(with: alicePeerID))
|
#expect(!alice.hasSession(with: bobPeerID))
|
||||||
#expect(alice.getPeerFingerprint(alicePeerID) == nil)
|
#expect(alice.getPeerFingerprint(bobPeerID) == nil)
|
||||||
|
|
||||||
bob.clearEphemeralStateForPanic()
|
bob.clearEphemeralStateForPanic()
|
||||||
#expect(!bob.hasSession(with: bobPeerID))
|
#expect(!bob.hasSession(with: alicePeerID))
|
||||||
#expect(bob.getPeerFingerprint(bobPeerID) == nil)
|
#expect(bob.getPeerFingerprint(alicePeerID) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Handshake rejects a claimed peer ID that does not match the authenticated static key")
|
||||||
|
func handshakeRejectsClaimedPeerIDStaticKeyMismatch() async throws {
|
||||||
|
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let claimedAlice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData())
|
||||||
|
let claimedAlicePeerID = PeerID(publicKey: claimedAlice.getStaticPublicKeyData())
|
||||||
|
let recorder = AuthenticationRecorder()
|
||||||
|
receiver.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
||||||
|
|
||||||
|
let message1 = try mallory.initiateHandshake(with: receiverPeerID)
|
||||||
|
let message2 = try #require(
|
||||||
|
try receiver.processHandshakeMessage(from: claimedAlicePeerID, message: message1)
|
||||||
|
)
|
||||||
|
let message3 = try #require(
|
||||||
|
try mallory.processHandshakeMessage(from: receiverPeerID, message: message2)
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try receiver.processHandshakeMessage(from: claimedAlicePeerID, message: message3)
|
||||||
|
Issue.record("Expected the authenticated Mallory key to be rejected for Alice's peer ID")
|
||||||
|
} catch let error as NoiseSessionError {
|
||||||
|
#expect(error == .peerIdentityMismatch)
|
||||||
|
} catch {
|
||||||
|
Issue.record("Unexpected mismatch error: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(!receiver.hasSession(with: claimedAlicePeerID))
|
||||||
|
let emittedAuthentication = await TestHelpers.waitUntil(
|
||||||
|
{ recorder.count > 0 },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!emittedAuthentication)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Failed forged replacement preserves the established peer session")
|
||||||
|
func forgedReplacementPreservesEstablishedSession() async throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData())
|
||||||
|
let recorder = AuthenticationRecorder()
|
||||||
|
receiver.addOnPeerAuthenticatedHandler(recorder.record(peerID:fingerprint:))
|
||||||
|
|
||||||
|
try establishSessions(alice: alice, bob: receiver)
|
||||||
|
let initialAuthentication = await TestHelpers.waitUntil(
|
||||||
|
{ recorder.count == 1 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
)
|
||||||
|
#expect(initialAuthentication)
|
||||||
|
|
||||||
|
let before = try alice.encrypt(Data("before".utf8), for: receiverPeerID)
|
||||||
|
#expect(try receiver.decrypt(before, from: alicePeerID) == Data("before".utf8))
|
||||||
|
|
||||||
|
let forgedMessage1 = try mallory.initiateHandshake(with: receiverPeerID)
|
||||||
|
let forgedMessage2 = try #require(
|
||||||
|
try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage1)
|
||||||
|
)
|
||||||
|
// The replacement has not authenticated yet; the working Alice
|
||||||
|
// transport session must remain available throughout the candidate.
|
||||||
|
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||||
|
let forgedMessage3 = try #require(
|
||||||
|
try mallory.processHandshakeMessage(from: receiverPeerID, message: forgedMessage2)
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try receiver.processHandshakeMessage(from: alicePeerID, message: forgedMessage3)
|
||||||
|
Issue.record("Expected forged replacement to fail peer binding")
|
||||||
|
} catch let error as NoiseSessionError {
|
||||||
|
#expect(error == .peerIdentityMismatch)
|
||||||
|
} catch {
|
||||||
|
Issue.record("Unexpected replacement error: \(error)")
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||||
|
let after = try alice.encrypt(Data("after".utf8), for: receiverPeerID)
|
||||||
|
#expect(try receiver.decrypt(after, from: alicePeerID) == Data("after".utf8))
|
||||||
|
let emittedReplacementAuthentication = await TestHelpers.waitUntil(
|
||||||
|
{ recorder.count > 1 },
|
||||||
|
timeout: TestConstants.shortTimeout
|
||||||
|
)
|
||||||
|
#expect(!emittedReplacementAuthentication)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Valid rehandshake atomically replaces the established session")
|
||||||
|
func validRehandshakeReplacesEstablishedSession() throws {
|
||||||
|
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||||
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
|
let receiverPeerID = PeerID(publicKey: receiver.getStaticPublicKeyData())
|
||||||
|
|
||||||
|
try establishSessions(alice: alice, bob: receiver)
|
||||||
|
alice.clearSession(for: receiverPeerID)
|
||||||
|
|
||||||
|
let message1 = try alice.initiateHandshake(with: receiverPeerID)
|
||||||
|
let message2 = try #require(
|
||||||
|
try receiver.processHandshakeMessage(from: alicePeerID, message: message1)
|
||||||
|
)
|
||||||
|
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||||
|
let message3 = try #require(
|
||||||
|
try alice.processHandshakeMessage(from: receiverPeerID, message: message2)
|
||||||
|
)
|
||||||
|
_ = try receiver.processHandshakeMessage(from: alicePeerID, message: message3)
|
||||||
|
|
||||||
|
#expect(alice.hasEstablishedSession(with: receiverPeerID))
|
||||||
|
#expect(receiver.hasEstablishedSession(with: alicePeerID))
|
||||||
|
let ciphertext = try alice.encrypt(Data("new session".utf8), for: receiverPeerID)
|
||||||
|
#expect(try receiver.decrypt(ciphertext, from: alicePeerID) == Data("new session".utf8))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Encrypt without a session requests handshake and decrypt without session fails")
|
@Test("Encrypt without a session requests handshake and decrypt without session fails")
|
||||||
@@ -200,16 +311,16 @@ struct NoiseEncryptionServiceTests {
|
|||||||
|
|
||||||
private func establishSessions(
|
private func establishSessions(
|
||||||
alice: NoiseEncryptionService,
|
alice: NoiseEncryptionService,
|
||||||
bob: NoiseEncryptionService,
|
bob: NoiseEncryptionService
|
||||||
alicePeerID: PeerID,
|
|
||||||
bobPeerID: PeerID
|
|
||||||
) throws {
|
) throws {
|
||||||
let message1 = try alice.initiateHandshake(with: alicePeerID)
|
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||||
let response = try bob.processHandshakeMessage(from: bobPeerID, message: message1)
|
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||||
|
let message1 = try alice.initiateHandshake(with: bobPeerID)
|
||||||
|
let response = try bob.processHandshakeMessage(from: alicePeerID, message: message1)
|
||||||
let message2 = try #require(response, "Expected handshake response")
|
let message2 = try #require(response, "Expected handshake response")
|
||||||
let final = try alice.processHandshakeMessage(from: alicePeerID, message: message2)
|
let final = try alice.processHandshakeMessage(from: bobPeerID, message: message2)
|
||||||
let message3 = try #require(final, "Expected handshake final")
|
let message3 = try #require(final, "Expected handshake final")
|
||||||
let finalMessage = try bob.processHandshakeMessage(from: bobPeerID, message: message3)
|
let finalMessage = try bob.processHandshakeMessage(from: alicePeerID, message: message3)
|
||||||
#expect(finalMessage == nil)
|
#expect(finalMessage == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ private final class GatedVoiceCaptureSession: VoiceCaptureSession {
|
|||||||
private let startError: Error?
|
private let startError: Error?
|
||||||
private(set) var finishStarted = false
|
private(set) var finishStarted = false
|
||||||
private(set) var cancelCount = 0
|
private(set) var cancelCount = 0
|
||||||
|
private(set) var panicCancelCount = 0
|
||||||
private var finishContinuation: CheckedContinuation<URL?, Never>?
|
private var finishContinuation: CheckedContinuation<URL?, Never>?
|
||||||
|
|
||||||
init(startError: Error? = nil) {
|
init(startError: Error? = nil) {
|
||||||
@@ -83,6 +84,10 @@ private final class GatedVoiceCaptureSession: VoiceCaptureSession {
|
|||||||
cancelCount += 1
|
cancelCount += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func panicCancelSynchronously() {
|
||||||
|
panicCancelCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
func resolveFinish(with url: URL?) {
|
func resolveFinish(with url: URL?) {
|
||||||
let continuation = finishContinuation
|
let continuation = finishContinuation
|
||||||
finishContinuation = nil
|
finishContinuation = nil
|
||||||
@@ -204,4 +209,57 @@ struct VoiceCaptureSessionTests {
|
|||||||
}
|
}
|
||||||
#expect(viewModel.state == .idle)
|
#expect(viewModel.state == .idle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func panicSynchronouslyCancelsActiveCaptureAndResetsUI() async {
|
||||||
|
let session = GatedVoiceCaptureSession()
|
||||||
|
let viewModel = VoiceRecordingViewModel()
|
||||||
|
viewModel.sessionProvider = { session }
|
||||||
|
|
||||||
|
viewModel.start(shouldShow: true)
|
||||||
|
await waitUntil { self.isRecording(viewModel.state) }
|
||||||
|
|
||||||
|
viewModel.panicWipe()
|
||||||
|
|
||||||
|
#expect(session.panicCancelCount == 1)
|
||||||
|
#expect(viewModel.state == .idle)
|
||||||
|
#expect(!viewModel.isLiveStreaming)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func panicInvalidatesARecordingAlreadyFinalizing() async throws {
|
||||||
|
let session = GatedVoiceCaptureSession()
|
||||||
|
let viewModel = VoiceRecordingViewModel()
|
||||||
|
viewModel.sessionProvider = { session }
|
||||||
|
let url = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("voice-panic-\(UUID().uuidString).m4a")
|
||||||
|
try Data([0x01]).write(to: url)
|
||||||
|
var delivered = false
|
||||||
|
|
||||||
|
viewModel.start(shouldShow: true)
|
||||||
|
await waitUntil { self.isRecording(viewModel.state) }
|
||||||
|
viewModel.finish { _ in delivered = true }
|
||||||
|
await waitUntil { session.finishStarted }
|
||||||
|
|
||||||
|
viewModel.panicWipe()
|
||||||
|
session.resolveFinish(with: url)
|
||||||
|
await waitUntil {
|
||||||
|
!FileManager.default.fileExists(atPath: url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(!delivered)
|
||||||
|
#expect(viewModel.state == .idle)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func liveSessionPanicStopsCaptureWithoutSendingControl() {
|
||||||
|
let capture = StubPTTCapture(stopResult: (nil, 0))
|
||||||
|
var sentPackets: [Data] = []
|
||||||
|
let session = PTTLiveVoiceSession(
|
||||||
|
sendPacket: { sentPackets.append($0) },
|
||||||
|
capture: capture
|
||||||
|
)
|
||||||
|
|
||||||
|
session.panicCancelSynchronously()
|
||||||
|
|
||||||
|
#expect(capture.cancelCount == 1)
|
||||||
|
#expect(sentPackets.isEmpty)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,6 +360,35 @@ struct VoiceRecorderTests {
|
|||||||
#expect(FileManager.default.fileExists(atPath: secondURL.path))
|
#expect(FileManager.default.fileExists(atPath: secondURL.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func classicSessionPanicStopsRecorderAndDeletesFileBeforeReturning() async throws {
|
||||||
|
let directory = try makeTemporaryDirectory()
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
|
||||||
|
let rawSession = VoiceRecorderTestSession()
|
||||||
|
let coordinator = AudioSessionCoordinator(session: rawSession)
|
||||||
|
let factory = TestVoiceAudioRecorderFactory(plans: [.success])
|
||||||
|
let voiceRecorder = VoiceRecorder(
|
||||||
|
sessionCoordinator: coordinator,
|
||||||
|
recorderFactory: factory,
|
||||||
|
permissionGranted: { true },
|
||||||
|
paddingInterval: 0,
|
||||||
|
outputDirectory: directory
|
||||||
|
)
|
||||||
|
let capture = VoiceNoteCaptureSession(recorder: voiceRecorder)
|
||||||
|
|
||||||
|
try await capture.start()
|
||||||
|
let url = try #require(factory.urls.first)
|
||||||
|
let recorder = try #require(factory.recorders.first)
|
||||||
|
|
||||||
|
capture.panicCancelSynchronously()
|
||||||
|
|
||||||
|
#expect(recorder.stopCallCount == 1)
|
||||||
|
#expect(!recorder.isRecording)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||||
|
await coordinator.drain()
|
||||||
|
#expect(rawSession.activationCalls == [true, false])
|
||||||
|
}
|
||||||
|
|
||||||
private func verifyFailedStart(
|
private func verifyFailedStart(
|
||||||
firstPlan: TestVoiceAudioRecorderFactory.Plan,
|
firstPlan: TestVoiceAudioRecorderFactory.Plan,
|
||||||
expectedPrepareCalls: Int,
|
expectedPrepareCalls: Int,
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ Residual risk: private-message metadata such as timing, radio adjacency, ciphert
|
|||||||
- Recent signed public mesh messages are archived in Application Support for up to 15 minutes so gossip sync survives a relaunch and can cross mesh partitions.
|
- Recent signed public mesh messages are archived in Application Support for up to 15 minutes so gossip sync survives a relaunch and can cross mesh partitions.
|
||||||
- Signed public board posts and tombstones persist until author-selected expiry, at most seven days. Stores are bounded by global and per-author quotas.
|
- Signed public board posts and tombstones persist until author-selected expiry, at most seven days. Stores are bounded by global and per-author quotas.
|
||||||
- Group metadata (name, roster, creator, epoch) persists as protected JSON; group keys live in the keychain until leave/removal/wipe.
|
- Group metadata (name, roster, creator, epoch) persists as protected JSON; group keys live in the keychain until leave/removal/wipe.
|
||||||
- Voice notes and images are stored in Application Support. Incoming media has a 100 MB oldest-first quota; outgoing media does not have an equivalent automatic lifetime and remains until cleanup, panic wipe, or app removal.
|
- Voice notes and images are stored in Application Support. Incoming media has a 100 MB oldest-first quota; outgoing media does not have an equivalent automatic lifetime and remains until cleanup, panic wipe, or app removal. Panic wipe invalidates detached preparation work, cancels active transfers, closes live capture files, and removes the managed media tree before returning.
|
||||||
|
|
||||||
Public archives contain content already intended for public mesh/board distribution, but a seized unlocked device can reveal it. Group metadata and media can reveal relationships or content even when the in-memory chat timeline has gone away.
|
Public archives contain content already intended for public mesh/board distribution, but a seized unlocked device can reveal it. Group metadata and media can reveal relationships or content even when the in-memory chat timeline has gone away.
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ Residual risk: Nostr relay retention and logging are outside project control. Pu
|
|||||||
|
|
||||||
## Panic Wipe Coverage
|
## Panic Wipe Coverage
|
||||||
|
|
||||||
The panic action clears identity/session state, preferences, location state, groups, prekeys, outbox mail, courier mail, bridge dedup state, gossip archive, board data, managed media, and active subscriptions/transports. New persistent stores must add an explicit wipe hook and a regression test.
|
The panic action clears identity/session state, preferences, location state, groups, prekeys, outbox mail, courier mail, bridge dedup state, gossip archive, board data, managed media, and active subscriptions/transports. Managed media deletion completes synchronously, after active media work has been invalidated. Keychain secrets use device-only accessibility, and an install marker detects and clears app keys that survive uninstall before a later reinstall can use them. New persistent stores must add an explicit wipe hook and a regression test.
|
||||||
|
|
||||||
## Release Review Checklist
|
## Release Review Checklist
|
||||||
|
|
||||||
|
|||||||
Vendored
+433
-407
@@ -1,416 +1,442 @@
|
|||||||
Relay URL,Latitude,Longitude
|
Relay URL,Latitude,Longitude
|
||||||
relay.lab.rytswd.com,49.4543,11.0746
|
|
||||||
relay.paulstephenborile.com:443,49.4543,11.0746
|
|
||||||
relay.binaryrobot.com,43.6532,-79.3832
|
|
||||||
nostr-2.21crypto.ch,47.5356,8.73209
|
|
||||||
spookstr2.nostr1.com:443,40.7057,-74.0136
|
|
||||||
fanfares.nostr1.com:443,40.7057,-74.0136
|
|
||||||
x.kojira.io,43.6532,-79.3832
|
|
||||||
freelay.sovbit.host,60.1699,24.9384
|
|
||||||
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
|
|
||||||
testnet.samt.st,43.6532,-79.3832
|
|
||||||
relay.angor.io,48.1046,11.6002
|
|
||||||
relay-arg.zombi.cloudrodion.com,1.35208,103.82
|
|
||||||
nostr-01.yakihonne.com,1.32123,103.695
|
|
||||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
|
||||||
relay.guggero.org,46.5971,9.59652
|
|
||||||
nostr.snowbla.de,60.1699,24.9384
|
|
||||||
relay.zone667.com,60.1699,24.9384
|
|
||||||
nexus.libernet.app:443,43.6532,-79.3832
|
|
||||||
relay.islandbitcoin.com,12.8498,77.6545
|
|
||||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
|
||||||
nostr-relay.xbytez.io,50.6924,3.20113
|
|
||||||
kasztanowa.bieda.it,43.6532,-79.3832
|
|
||||||
nostrcity-club.fly.dev,37.7648,-122.432
|
|
||||||
relay.typedcypher.com,51.5072,-0.127586
|
|
||||||
nostr.na.social:443,43.6532,-79.3832
|
|
||||||
relay.laantungir.net,-19.4692,-42.5315
|
|
||||||
relay-dev.satlantis.io:443,40.8302,-74.1299
|
|
||||||
rilo.nostria.app,43.6532,-79.3832
|
|
||||||
nostr.hekster.org:443,37.3986,-121.964
|
|
||||||
nostr-relay.amethyst.name:443,39.0067,-77.4291
|
|
||||||
chat-relay.zap-work.com:443,43.6532,-79.3832
|
|
||||||
relay.edufeed.org,49.4521,11.0767
|
|
||||||
syb.lol:443,43.6532,-79.3832
|
|
||||||
relay.sigit.io,50.4754,12.3683
|
|
||||||
nostr-relay.xbytez.io:443,50.6924,3.20113
|
|
||||||
relay.wavefunc.live,41.8781,-87.6298
|
|
||||||
nostr.sathoarder.com,48.5734,7.75211
|
|
||||||
myvoiceourstory.org,37.3598,-121.981
|
|
||||||
relay.underorion.se,50.1109,8.68213
|
|
||||||
nostr.data.haus,50.4754,12.3683
|
|
||||||
relay.erybody.com,41.4513,-81.7021
|
|
||||||
espelho.girino.org,43.6532,-79.3832
|
|
||||||
nostr.pbfs.io:443,50.4754,12.3683
|
|
||||||
wot.dergigi.com,64.1476,-21.9392
|
|
||||||
nostr.bitcoiner.social:443,47.6743,-117.112
|
|
||||||
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
|
|
||||||
relay.gulugulu.moe,43.6532,-79.3832
|
|
||||||
nostr.spicyz.io,43.6532,-79.3832
|
|
||||||
relay.cypherflow.ai,48.8575,2.35138
|
|
||||||
treuzkas.branruz.com,48.8575,2.35138
|
|
||||||
relay1.nostrchat.io,60.1699,24.9384
|
|
||||||
kotukonostr.onrender.com,37.7775,-122.397
|
|
||||||
nostr.plantroon.com,50.1013,8.62643
|
|
||||||
nostr.davenov.com,50.1109,8.68213
|
|
||||||
node.kommonzenze.de,49.4521,11.0767
|
|
||||||
relay2.veganostr.com,60.1699,24.9384
|
|
||||||
armada.sharegap.net,43.6532,-79.3832
|
|
||||||
wot.makenomistakes.ca,43.7064,-79.3986
|
|
||||||
nostr.2b9t.xyz:443,34.0549,-118.243
|
|
||||||
relay.libernet.app:443,43.6532,-79.3832
|
|
||||||
relay.dreamith.to:443,43.6532,-79.3832
|
|
||||||
relay.lightning.pub:443,39.0438,-77.4874
|
|
||||||
nostr.rtvslawenia.com,49.4543,11.0746
|
|
||||||
nostr.21crypto.ch,47.5356,8.73209
|
|
||||||
relay.ditto.pub:443,43.6532,-79.3832
|
|
||||||
relay.plebchain.club,43.6532,-79.3832
|
|
||||||
memlay.v0l.io,53.3498,-6.26031
|
|
||||||
nostr.chaima.info:443,50.1109,8.68213
|
|
||||||
relay.wavlake.com:443,41.2619,-95.8608
|
|
||||||
nostr.thalheim.io:443,60.1699,24.9384
|
|
||||||
relay.lightning.pub,39.0438,-77.4874
|
|
||||||
dev.relay.edufeed.org:443,49.4521,11.0767
|
|
||||||
nostr.myshosholoza.co.za:443,52.3913,4.66545
|
|
||||||
relay.binaryrobot.com:443,43.6532,-79.3832
|
|
||||||
wot.nostr.place,43.6532,-79.3832
|
|
||||||
nostr.sathoarder.com:443,48.5734,7.75211
|
|
||||||
thecitadel.nostr1.com,40.7057,-74.0136
|
|
||||||
relay.artx.market,43.6548,-79.3885
|
|
||||||
nos.lol,50.4754,12.3683
|
|
||||||
nostr.plantroon.com:443,50.1013,8.62643
|
|
||||||
premium.primal.net,43.6532,-79.3832
|
|
||||||
nas01xanthosnet.synology.me:7778,47.1285,8.74735
|
|
||||||
nostrja-kari.heguro.com,43.6532,-79.3832
|
|
||||||
relay.mrmave.work,43.6532,-79.3832
|
|
||||||
nostrelay.circum.space,52.6907,4.8181
|
|
||||||
mostro-p2p.tech,50.1109,8.68213
|
|
||||||
wot.shaving.kiwi,43.6532,-79.3832
|
|
||||||
relay.fundstr.me,42.3601,-71.0589
|
|
||||||
nostrelay.circum.space:443,52.6907,4.8181
|
|
||||||
relay.nostrdice.com,-33.8688,151.209
|
|
||||||
relay.getvia.xyz,60.1699,24.9384
|
|
||||||
strfry.shock.network:443,39.0438,-77.4874
|
|
||||||
relay.nostrmap.net:443,60.1699,24.9384
|
|
||||||
relay.nearhood.co.uk,51.5072,-0.127586
|
|
||||||
no.str.cr,10.6352,-85.4378
|
|
||||||
relay.getsafebox.app:443,43.6532,-79.3832
|
|
||||||
relay0.gfcom.info,13.6992,100.694
|
|
||||||
nostr.ps1829.com,33.8851,130.883
|
|
||||||
relay2.angor.io,48.1046,11.6002
|
|
||||||
relay.stickeroo.is-cool.dev,37.3387,-121.885
|
|
||||||
ricardo-oem.tailb5546.ts.net,40.7128,-74.006
|
|
||||||
relay.typedcypher.com:443,51.5072,-0.127586
|
|
||||||
relay.paulstephenborile.com,49.4543,11.0746
|
|
||||||
nittom.nostr1.com,40.7057,-74.0136
|
|
||||||
conduitl2.fly.dev,37.7648,-122.432
|
|
||||||
nostr.rikmeijer.nl,51.7111,5.36809
|
|
||||||
relay.thecryptosquid.com,50.4754,12.3683
|
|
||||||
spookstr2.nostr1.com,40.7057,-74.0136
|
|
||||||
offchain.bostr.online,43.6532,-79.3832
|
|
||||||
nostr.planix.org,43.6532,-79.3832
|
|
||||||
relay.mccormick.cx,52.3563,4.95714
|
|
||||||
0x-nostr-relay.fly.dev,37.7648,-122.432
|
|
||||||
nostr.wecsats.io,43.6532,-79.3832
|
|
||||||
schnorr.me,43.6532,-79.3832
|
|
||||||
relay.satmaxt.xyz,43.6532,-79.3832
|
|
||||||
relay.bornheimer.app,51.5072,-0.127586
|
|
||||||
relay.nostrhub.fr,48.1045,11.6004
|
|
||||||
blossom.gnostr.cloud:443,43.6532,-79.3832
|
|
||||||
nostr-02.yakihonne.com:443,1.32123,103.695
|
|
||||||
dev.relay.stream,43.6532,-79.3832
|
|
||||||
ithurtswhenip.ee,51.5072,-0.127586
|
|
||||||
nostr.myshosholoza.co.za,52.3913,4.66545
|
|
||||||
relayrs.notoshi.win:443,43.6532,-79.3832
|
|
||||||
relay-rpi.edufeed.org:443,49.4521,11.0767
|
|
||||||
relay.olas.app:443,60.1699,24.9384
|
|
||||||
nostr.unkn0wn.world,46.8499,9.53287
|
|
||||||
relay.mitchelltribe.com,39.0438,-77.4874
|
|
||||||
yabu.me,35.6092,139.73
|
|
||||||
nostr.nodesmap.com,59.3327,18.0656
|
|
||||||
dm-test-strfry-generic.samt.st,43.6532,-79.3832
|
|
||||||
nostr2.girino.org:443,43.6532,-79.3832
|
|
||||||
wot.brightbolt.net,47.6735,-116.781
|
|
||||||
strfry.shock.network,39.0438,-77.4874
|
|
||||||
relay.kilombino.com,43.6532,-79.3832
|
|
||||||
relay.nostr.blockhenge.com,39.0438,-77.4874
|
|
||||||
shu04.shugur.net,25.2048,55.2708
|
|
||||||
relay-rpi.edufeed.org,49.4521,11.0767
|
|
||||||
relay.bullishbounty.com:443,43.6532,-79.3832
|
|
||||||
vault.iris.to:443,43.6532,-79.3832
|
|
||||||
relay.mostro.network:443,40.8302,-74.1299
|
|
||||||
offchain.pub:443,39.1585,-94.5728
|
|
||||||
soloco.nl,43.6532,-79.3832
|
|
||||||
relay.nostu.be,40.4167,-3.70329
|
|
||||||
nostr.pbfs.io,50.4754,12.3683
|
|
||||||
relay.directsponsor.net,42.8864,-78.8784
|
|
||||||
relay.decentralia.fr,49.4282,10.9796
|
|
||||||
relayrs.notoshi.win,43.6532,-79.3832
|
|
||||||
nostr-relay.amethyst.name,39.0067,-77.4291
|
|
||||||
relay.arx-ccn.com,50.4754,12.3683
|
|
||||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
|
||||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
|
||||||
rilo.nostria.app:443,43.6532,-79.3832
|
|
||||||
relay.trotters.cc:443,43.6532,-79.3832
|
|
||||||
nostr.overmind.lol:443,43.6532,-79.3832
|
|
||||||
nostr.girino.org:443,43.6532,-79.3832
|
|
||||||
bitsat.molonlabe.holdings,51.4012,-1.3147
|
|
||||||
nostr.azzamo.net,52.2633,21.0283
|
|
||||||
insta-relay.apps3.slidestr.net,40.4167,-3.70329
|
|
||||||
bridge.tagomago.me,42.3601,-71.0589
|
|
||||||
nostr.thalheim.io,60.1699,24.9384
|
|
||||||
relay.artx.market:443,43.6548,-79.3885
|
|
||||||
nostr.openhoofd.nl,51.5717,3.70417
|
|
||||||
nostr.bond,50.1109,8.68213
|
|
||||||
relay.earthly.city,34.1749,-118.54
|
|
||||||
nexus.libernet.app,43.6532,-79.3832
|
|
||||||
relay.plebeian.market,50.1109,8.68213
|
|
||||||
relay.nostr.net,43.6532,-79.3832
|
|
||||||
nostr.overmind.lol,43.6532,-79.3832
|
|
||||||
relay.ohstr.com,43.6532,-79.3832
|
|
||||||
testnet-relay.samt.st:443,40.8302,-74.1299
|
|
||||||
relay01.lnfi.network,35.6764,139.65
|
|
||||||
relay.mostr.pub:443,43.6532,-79.3832
|
|
||||||
wot.nostr.party,36.1659,-86.7844
|
|
||||||
relayone.soundhsa.com,39.1008,-94.5811
|
|
||||||
relay.mostro.network,40.8302,-74.1299
|
|
||||||
ribo.eu.nostria.app,43.6532,-79.3832
|
|
||||||
chat-relay.zap-work.com,43.6532,-79.3832
|
|
||||||
relay.nostreon.com,60.1699,24.9384
|
|
||||||
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
|
|
||||||
nostr.quali.chat:443,60.1699,24.9384
|
|
||||||
relay.internationalright-wing.org:443,-22.5022,-48.7114
|
|
||||||
relay.mitchelltribe.com:443,39.0438,-77.4874
|
|
||||||
relay.satlantis.io,40.8054,-74.0241
|
|
||||||
nittom.nostr1.com:443,40.7057,-74.0136
|
|
||||||
nostr.janx.com,43.6532,-79.3832
|
|
||||||
nostr.carroarmato0.be:443,50.914,3.21378
|
|
||||||
relay.mmwaves.de:443,48.8575,2.35138
|
|
||||||
relay.chorus.community:443,48.5333,10.7
|
|
||||||
wot.utxo.one,43.6532,-79.3832
|
|
||||||
relay.plebeian.market:443,50.1109,8.68213
|
|
||||||
relay.cosmicbolt.net,37.3986,-121.964
|
|
||||||
x.kojira.io:443,43.6532,-79.3832
|
|
||||||
top.testrelay.top,43.6532,-79.3832
|
|
||||||
nos.lol:443,50.4754,12.3683
|
|
||||||
dev.relay.edufeed.org,49.4521,11.0767
|
|
||||||
relayone.geektank.ai:443,39.1008,-94.5811
|
|
||||||
relay.nostar.org,43.6532,-79.3832
|
|
||||||
nostr.oxtr.dev:443,50.4754,12.3683
|
|
||||||
nostr.88mph.life,52.1941,-2.21905
|
|
||||||
relay.staging.commonshub.brussels,49.4543,11.0746
|
|
||||||
weboftrust.libretechsystems.xyz,55.4724,9.87335
|
|
||||||
relay.openfarmtools.org,60.1699,24.9384
|
|
||||||
cs-relay.nostrdev.com,50.4754,12.3683
|
|
||||||
relay.inforsupports.com,43.6532,-79.3832
|
|
||||||
nostr-verified.wellorder.net,45.5201,-122.99
|
|
||||||
nostr.hekster.org,37.3986,-121.964
|
|
||||||
relay.gulugulu.moe:443,43.6532,-79.3832
|
|
||||||
relay.mwaters.net,50.9871,2.12554
|
|
||||||
nostrcity-club.fly.dev:443,37.7648,-122.432
|
|
||||||
relay.vrtmrz.net:443,43.6532,-79.3832
|
|
||||||
relay.nostr.place,43.6532,-79.3832
|
|
||||||
relay.wavefunc.live:443,41.8781,-87.6298
|
|
||||||
nostr.islandarea.net,35.4669,-97.6473
|
|
||||||
purplerelay.com:443,43.6532,-79.3832
|
|
||||||
nostr-relay.psfoundation.info:443,39.0438,-77.4874
|
|
||||||
r.0kb.io,32.789,-96.7989
|
|
||||||
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
|
|
||||||
relay.mulatta.io,37.5665,126.978
|
|
||||||
strfry.bonsai.com:443,39.0438,-77.4874
|
|
||||||
bendernostur.duckdns.org:8443,50.1109,8.68213
|
|
||||||
vault.iris.to,43.6532,-79.3832
|
|
||||||
ec2.f7z.io,60.1699,24.9384
|
|
||||||
nostr.debate.report,50.1109,8.68213
|
|
||||||
wot.codingarena.top,50.4754,12.3683
|
|
||||||
relay.layer.systems:443,49.0291,8.35695
|
|
||||||
relay.degmods.com,50.4754,12.3683
|
|
||||||
nostr.mom,50.4754,12.3683
|
|
||||||
ribo.us.nostria.app:443,43.6532,-79.3832
|
|
||||||
adre.su,59.9311,30.3609
|
|
||||||
wot.sudocarlos.com,43.6532,-79.3832
|
|
||||||
relay.nostrian-conquest.com,41.223,-111.974
|
|
||||||
nostr-relay.nextblockvending.com,47.2343,-119.853
|
|
||||||
relay.endfiat.money:443,59.3327,18.0656
|
|
||||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
|
||||||
nostr.carroarmato0.be,50.914,3.21378
|
|
||||||
relay.cypherflow.ai:443,48.8575,2.35138
|
|
||||||
nostr.girino.org,43.6532,-79.3832
|
|
||||||
nostr.thebiglake.org,32.71,-96.6745
|
|
||||||
strfry.ymir.cloud,43.6532,-79.3832
|
|
||||||
relay.mypathtofire.de,42.8864,-78.8784
|
|
||||||
relay.lanacoin-eternity.com,40.8302,-74.1299
|
|
||||||
nostr.snowbla.de:443,60.1699,24.9384
|
|
||||||
relay.ditto.pub,43.6532,-79.3832
|
|
||||||
relay.damus.io,43.6532,-79.3832
|
|
||||||
relay.ru.ac.th,13.7607,100.627
|
|
||||||
nrs-01.darkcloudarcade.com,39.1008,-94.5811
|
|
||||||
testnet-relay.samt.st,40.8302,-74.1299
|
|
||||||
antiprimal.net,43.6532,-79.3832
|
|
||||||
bitchat.nostr1.com,40.7057,-74.0136
|
bitchat.nostr1.com,40.7057,-74.0136
|
||||||
relay.snort.social,53.3498,-6.26031
|
relay.fundstr.me,42.3601,-71.0589
|
||||||
relay.mccormick.cx:443,52.3563,4.95714
|
|
||||||
relay02.lnfi.network,35.6764,139.65
|
|
||||||
srtrelay.c-stellar.net,43.6532,-79.3832
|
|
||||||
relay.minibolt.info,43.6532,-79.3832
|
|
||||||
nostrride.io,37.3986,-121.964
|
|
||||||
articles.layer3.news:443,37.3387,-121.885
|
|
||||||
rele.speyhard.fi,51.5072,-0.127586
|
|
||||||
relay.aarpia.com,37.3986,-121.964
|
|
||||||
nostr.chaima.info,50.1109,8.68213
|
|
||||||
relay.wisp.talk:443,49.4543,11.0746
|
|
||||||
relay.agorist.space:443,52.3734,4.89406
|
|
||||||
strfry.bonsai.com,39.0438,-77.4874
|
|
||||||
nostr.hifish.org,47.4244,8.57658
|
|
||||||
offchain.pub,39.1585,-94.5728
|
|
||||||
nostr.spicyz.io:443,43.6532,-79.3832
|
|
||||||
relay.beginningend.com,35.2227,-97.4786
|
|
||||||
relay.sharegap.net,43.6532,-79.3832
|
|
||||||
nostr.purpura.cloud,43.6532,-79.3832
|
|
||||||
nrs-01.darkcloudarcade.com:443,39.1008,-94.5811
|
|
||||||
relay.fountain.fm:443,43.6532,-79.3832
|
|
||||||
relay.olas.app,60.1699,24.9384
|
|
||||||
relay.mmwaves.de,48.8575,2.35138
|
|
||||||
relay.openresist.com:443,43.6532,-79.3832
|
|
||||||
relay.homeinhk.xyz,35.694,139.754
|
|
||||||
relay.libernet.app,43.6532,-79.3832
|
|
||||||
relay.comcomponent.com,43.6532,-79.3832
|
|
||||||
nostr.tac.lol,47.4748,-122.273
|
|
||||||
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
|
||||||
relay.nostriot.com:443,41.5695,-83.9786
|
|
||||||
bcast.girino.org,43.6532,-79.3832
|
|
||||||
nostr.azzamo.net:443,52.2633,21.0283
|
|
||||||
relay.islandbitcoin.com:443,12.8498,77.6545
|
|
||||||
pool.libernet.app,43.6532,-79.3832
|
|
||||||
test.thedude.cloud,50.1109,8.68213
|
|
||||||
nostrelites.org,41.8781,-87.6298
|
|
||||||
nostr.infero.net,35.6764,139.65
|
|
||||||
relay.primal.net,43.6532,-79.3832
|
|
||||||
ribo.nostria.app,43.6532,-79.3832
|
|
||||||
relay.chorus.community,48.5333,10.7
|
|
||||||
bitcoiner.social:443,47.6743,-117.112
|
|
||||||
relay.wisp.talk,49.4543,11.0746
|
|
||||||
relay.layer.systems,49.0291,8.35695
|
|
||||||
relay-dev.satlantis.io,40.8302,-74.1299
|
|
||||||
nostr.bitcoiner.social,47.6743,-117.112
|
|
||||||
relay.lanavault.space:443,60.1699,24.9384
|
|
||||||
relay.staging.plebeian.market,51.5072,-0.127586
|
|
||||||
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
|
|
||||||
relay.fountain.fm,43.6532,-79.3832
|
|
||||||
nostr.middling.mydns.jp,35.8099,140.12
|
|
||||||
relay.dreamith.to,43.6532,-79.3832
|
|
||||||
relay.satmaxt.xyz:443,43.6532,-79.3832
|
|
||||||
shu03.shugur.net,25.2048,55.2708
|
|
||||||
zealand-charts-craig-thru.trycloudflare.com,43.6532,-79.3832
|
|
||||||
nostr.computingcache.com,34.0356,-118.442
|
|
||||||
ribo.us.nostria.app,43.6532,-79.3832
|
|
||||||
relay.agentry.com,42.8864,-78.8784
|
|
||||||
nostr.hifish.org:443,47.4244,8.57658
|
|
||||||
nostr.vulpem.com,49.4543,11.0746
|
|
||||||
relay.cosmicbolt.net:443,37.3986,-121.964
|
|
||||||
nostr-02.yakihonne.com,1.32123,103.695
|
|
||||||
r.0kb.io:443,32.789,-96.7989
|
|
||||||
nostr-relay.corb.net,38.8353,-104.822
|
|
||||||
ribo.eu.nostria.app:443,43.6532,-79.3832
|
|
||||||
nostr-relay.psfoundation.info,39.0438,-77.4874
|
|
||||||
relay.wellorder.net,45.5201,-122.99
|
|
||||||
relay.novospes.com,43.6532,-79.3832
|
|
||||||
nostr-dev.wellorder.net,45.5201,-122.99
|
|
||||||
relay.endfiat.money,59.3327,18.0656
|
|
||||||
relay.angor.io:443,48.1046,11.6002
|
|
||||||
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
|
|
||||||
strfry.openhoofd.nl,51.5717,3.70417
|
|
||||||
relay.getsafebox.app,43.6532,-79.3832
|
|
||||||
relay.openresist.com,43.6532,-79.3832
|
|
||||||
relay5.bitransfer.org,43.6532,-79.3832
|
|
||||||
nostr.na.social,43.6532,-79.3832
|
|
||||||
portal-relay.pareto.space,49.0291,8.35696
|
|
||||||
nostr.notribe.net:443,40.8302,-74.1299
|
|
||||||
relay.bitmacro.cloud,43.6532,-79.3832
|
|
||||||
no.str.cr:443,10.6352,-85.4378
|
|
||||||
relay.klabo.world,47.2343,-119.853
|
|
||||||
nostr.notribe.net,40.8302,-74.1299
|
|
||||||
relay.staging.plebeian.market:443,51.5072,-0.127586
|
|
||||||
relay.nostrmap.net,60.1699,24.9384
|
|
||||||
temp.iris.to,43.6532,-79.3832
|
|
||||||
nostr.sovereignservices.xyz,43.6532,-79.3832
|
|
||||||
nostr.liberty.fans,36.9104,-89.5875
|
|
||||||
relay.nostrian-conquest.com:443,41.223,-111.974
|
|
||||||
relay.nostriot.com,41.5695,-83.9786
|
|
||||||
nostrbtc.com,43.6532,-79.3832
|
|
||||||
shu02.shugur.net,21.4902,39.2246
|
|
||||||
relay.kalcafe.xyz,37.3986,-121.964
|
|
||||||
relay.illuminodes.com,43.6532,-79.3832
|
|
||||||
relay.wavlake.com,41.2619,-95.8608
|
|
||||||
nostr.ps1829.com:443,33.8851,130.883
|
|
||||||
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
|
|
||||||
nostr.wecsats.io:443,43.6532,-79.3832
|
|
||||||
nostr-pub.wellorder.net,45.5201,-122.99
|
|
||||||
nostr.dlcdevkit.com:443,40.0992,-83.1141
|
|
||||||
nostr.mom:443,50.4754,12.3683
|
|
||||||
ribo.nostria.app:443,43.6532,-79.3832
|
|
||||||
nostr.2b9t.xyz,34.0549,-118.243
|
nostr.2b9t.xyz,34.0549,-118.243
|
||||||
nostr.data.haus:443,50.4754,12.3683
|
armada.sharegap.net,43.6532,-79.3832
|
||||||
|
nostr.chaima.info,51.5072,-0.127586
|
||||||
|
nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832
|
||||||
|
ribo.eu.nostria.app:443,43.6532,-79.3832
|
||||||
|
relay.lightning.pub,39.0438,-77.4874
|
||||||
|
relay.nostu.be,40.4167,-3.70329
|
||||||
|
nostr.whitenode45.ddns.net,40.55,-74.4758
|
||||||
|
nostr.carroarmato0.be:443,50.914,3.21378
|
||||||
|
cdn.satellite.earth,40.8302,-74.1299
|
||||||
|
relay2.veganostr.com,60.1699,24.9384
|
||||||
|
relay.layer.systems:443,49.0291,8.35695
|
||||||
|
relay0.gfcom.info,13.7653,100.647
|
||||||
|
relay.mmwaves.de:443,48.8575,2.35138
|
||||||
|
offchain.pub,39.1585,-94.5728
|
||||||
|
bcast.girino.org,43.6532,-79.3832
|
||||||
staging.yabu.me,35.6092,139.73
|
staging.yabu.me,35.6092,139.73
|
||||||
relay.sigit.io:443,50.4754,12.3683
|
nostr.overpay.com,29.7449,-95.5343
|
||||||
relay.edufeed.org:443,49.4521,11.0767
|
bridge.tagomago.me,42.3601,-71.0589
|
||||||
nostr-01.yakihonne.com:443,1.32123,103.695
|
nostr-01.yakihonne.com,1.32123,103.695
|
||||||
reraw.pbla2fish.cc,43.6532,-79.3832
|
strfry.bonsai.com,39.0438,-77.4874
|
||||||
cs-relay.nostrdev.com:443,50.4754,12.3683
|
relay.sharegap.net,43.6532,-79.3832
|
||||||
herbstmeister.com,34.0549,-118.243
|
nostr.islandarea.net,35.4669,-97.6473
|
||||||
|
dm-test-strfry-generic.samt.st,43.6532,-79.3832
|
||||||
|
treuzkas.branruz.com,48.8575,2.35138
|
||||||
|
relay-rpi.edufeed.org:443,49.4521,11.0767
|
||||||
|
vault.iris.to:443,43.6532,-79.3832
|
||||||
|
node.kommonzenze.de,49.4521,11.0767
|
||||||
|
nostr.thalheim.io:443,60.1699,24.9384
|
||||||
|
soloco.nl,43.6532,-79.3832
|
||||||
|
strfry.shock.network,39.0438,-77.4874
|
||||||
|
nostr-relay.zimage.com,34.0549,-118.243
|
||||||
|
public.crostr.com:443,43.6532,-79.3832
|
||||||
|
nostr.sathoarder.com:443,48.5734,7.75211
|
||||||
|
relay.angor.io,48.1046,11.6002
|
||||||
|
relay.wellorder.net,45.5201,-122.99
|
||||||
|
relay.mwaters.net,50.9871,2.12554
|
||||||
|
relay.staging.commonshub.brussels,49.4543,11.0746
|
||||||
|
nostr-verified.wellorder.net,45.5201,-122.99
|
||||||
|
nostr-pub.wellorder.net,45.5201,-122.99
|
||||||
|
nostr-2.21crypto.ch,47.5356,8.73209
|
||||||
|
relay.kaleidoswap.com,50.8476,4.35717
|
||||||
|
relay.libernet.app:443,43.6532,-79.3832
|
||||||
|
relay.homeinhk.xyz,35.694,139.754
|
||||||
|
relay.manneken.brussels,49.4543,11.0746
|
||||||
|
nostr.spicyz.io:443,43.6532,-79.3832
|
||||||
|
relay.lanacoin-eternity.com:443,40.8302,-74.1299
|
||||||
|
ribo.us.nostria.app:443,43.6532,-79.3832
|
||||||
|
relay.loveisbitcoin.com,43.6532,-79.3832
|
||||||
|
relay.angor.io:443,48.1046,11.6002
|
||||||
|
relay02.lnfi.network,35.6764,139.65
|
||||||
|
relay.cosmicbolt.net:443,37.3986,-121.964
|
||||||
|
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
|
||||||
|
nrs-01.darkcloudarcade.com,39.0997,-94.5786
|
||||||
|
relay.endfiat.money:443,59.3327,18.0656
|
||||||
|
relay.paulstephenborile.com,49.4543,11.0746
|
||||||
|
rele.speyhard.fi,51.5072,-0.127586
|
||||||
|
relay.froth.zone,60.1699,24.9384
|
||||||
|
relay.nostr.blockhenge.com,39.0438,-77.4874
|
||||||
|
nrl.ceskar.xyz,50.5145,16.0119
|
||||||
|
rilo.nostria.app,43.6532,-79.3832
|
||||||
|
nostr.overmind.lol:443,43.6532,-79.3832
|
||||||
|
nostr.snowbla.de:443,50.4754,12.3683
|
||||||
|
nostrrelay.taylorperron.com,45.5029,-73.5723
|
||||||
|
chorus.pjv.me,45.5201,-122.99
|
||||||
|
relay.nostr.place,43.6532,-79.3832
|
||||||
|
bucket.coracle.social,37.7775,-122.397
|
||||||
|
nostr.girino.org:443,43.6532,-79.3832
|
||||||
|
relay.aarpia.com,37.3986,-121.964
|
||||||
|
nostr.thalheim.io,60.1699,24.9384
|
||||||
|
ec2.f7z.io,60.1699,24.9384
|
||||||
|
relay.trotters.cc,43.6532,-79.3832
|
||||||
|
relay.mccormick.cx:443,52.3563,4.95714
|
||||||
|
relay.momostr.pink,43.6532,-79.3832
|
||||||
|
relay.nostr.net,43.6532,-79.3832
|
||||||
|
conduitl2.fly.dev,37.7648,-122.432
|
||||||
|
chat-relay.zap-work.com,43.6532,-79.3832
|
||||||
|
relay.ditto.pub,43.6532,-79.3832
|
||||||
|
relay.veganostr.com,60.1699,24.9384
|
||||||
relay.minibolt.info:443,43.6532,-79.3832
|
relay.minibolt.info:443,43.6532,-79.3832
|
||||||
relay2.angor.io:443,48.1046,11.6002
|
adre.su,59.9311,30.3609
|
||||||
social.amanah.eblessing.co,48.1046,11.6002
|
bitcoinostr.duckdns.org,41.1976,1.11167
|
||||||
nostr.stakey.net,52.3676,4.90414
|
|
||||||
nostr.computingcache.com:443,34.0356,-118.442
|
nostr.computingcache.com:443,34.0356,-118.442
|
||||||
slick.mjex.me,39.0418,-77.4744
|
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
||||||
fanfares.nostr1.com,40.7057,-74.0136
|
nostr.hekster.org:443,37.3986,-121.964
|
||||||
bitcoinostr.duckdns.org,43.3434,-3.99532
|
nostr.88mph.life,52.1941,-2.21905
|
||||||
nostr.oxtr.dev,50.4754,12.3683
|
wot.dergigi.com,64.1476,-21.9392
|
||||||
cache.trustr.ing,43.6548,-79.3885
|
nostr.planix.org,43.6532,-79.3832
|
||||||
purplerelay.com,43.6532,-79.3832
|
relay.satsmarkt.club,52.6907,4.8181
|
||||||
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
|
nostrcity-club.fly.dev:443,37.7648,-122.432
|
||||||
nostr-relay.corb.net:443,38.8353,-104.822
|
aeon.libretechsystems.xyz,55.486,9.86577
|
||||||
relay-dev.gulugulu.moe,43.6532,-79.3832
|
testnet.samt.st,43.6532,-79.3832
|
||||||
prl.plus,55.7628,37.5983
|
nostr.data.haus,50.4754,12.3683
|
||||||
nostr.tac.lol:443,47.4748,-122.273
|
wot.sudocarlos.com,43.6532,-79.3832
|
||||||
relay.mostr.pub,43.6532,-79.3832
|
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
|
||||||
schnorr.me:443,43.6532,-79.3832
|
shu01.shugur.net,21.4902,39.2246
|
||||||
|
relay.gulugulu.moe:443,43.6532,-79.3832
|
||||||
|
relay2.angor.io:443,48.1046,11.6002
|
||||||
|
relay.libernet.app,43.6532,-79.3832
|
||||||
|
directories-safe-motherboard-recipients.trycloudflare.com,43.6532,-79.3832
|
||||||
|
wot.nostr.party,36.1659,-86.7844
|
||||||
|
relay.zone667.com,60.1699,24.9384
|
||||||
|
nostr.wild-vibes.ts.net,48.8566,2.35222
|
||||||
|
relay.nostr.com,50.1109,8.68213
|
||||||
|
nostr.iskarion.ddns.net,43.3076,-2.95421
|
||||||
|
relay-dev.satlantis.io,39.0438,-77.4874
|
||||||
|
relay.sovereignresonance.org,48.9006,2.25929
|
||||||
|
relay.nostrian-conquest.com,41.223,-111.974
|
||||||
|
relay.aidatanorge.no,43.6532,-79.3832
|
||||||
|
strfry.apps3.slidestr.net,40.4167,-3.70329
|
||||||
|
relay.klabo.world,47.2343,-119.853
|
||||||
|
nostr.data.haus:443,50.4754,12.3683
|
||||||
|
testr.nymble.world,40.8054,-74.0241
|
||||||
|
relay.inforsupports.com,43.6532,-79.3832
|
||||||
|
relay.nostrmap.net:443,60.1699,24.9384
|
||||||
|
nostr.stakey.net:443,52.3676,4.90414
|
||||||
dev-relay.nostreon.com,60.1699,24.9384
|
dev-relay.nostreon.com,60.1699,24.9384
|
||||||
nostr.islandarea.net:443,35.4669,-97.6473
|
nostr.islandarea.net:443,35.4669,-97.6473
|
||||||
bucket.coracle.social,37.7775,-122.397
|
nostr.rtvslawenia.com,49.4543,11.0746
|
||||||
blossom.gnostr.cloud,43.6532,-79.3832
|
relay.bowlafterbowl.com,32.9483,-96.7299
|
||||||
relay.solife.me,43.6532,-79.3832
|
nostr.quali.chat:443,60.1699,24.9384
|
||||||
nostr.quali.chat,60.1699,24.9384
|
relay.plebeian.market,50.1109,8.68213
|
||||||
relay.vrtmrz.net,43.6532,-79.3832
|
relay-rpi.edufeed.org,49.4521,11.0767
|
||||||
relay-dev.gulugulu.moe:443,43.6532,-79.3832
|
r.0kb.io,32.789,-96.7989
|
||||||
relay.bullishbounty.com,43.6532,-79.3832
|
nostr.notribe.net:443,40.8302,-74.1299
|
||||||
relay.fckstate.net,59.3293,18.0686
|
relay.getsafebox.app:443,43.6532,-79.3832
|
||||||
nostr.rtvslawenia.com:443,49.4543,11.0746
|
nostr.dlcdevkit.com:443,40.0992,-83.1141
|
||||||
relay.nostx.io,43.6532,-79.3832
|
nostrelites.org,34.9582,-81.9907
|
||||||
relay.agorist.space,52.3734,4.89406
|
nostr.hoppe-relay.it.com,42.8864,-78.8784
|
||||||
relay.notoshi.win,13.7829,100.546
|
nostr.thebiglake.org,32.71,-96.6745
|
||||||
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
|
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
|
||||||
relay.trotters.cc,43.6532,-79.3832
|
relay.nostriot.com,41.5695,-83.9786
|
||||||
relay.lanavault.space,60.1699,24.9384
|
nostr.christiansass.de,51.7634,7.8887
|
||||||
public.crostr.com:443,43.6532,-79.3832
|
relay.btcforplebs.com,43.6532,-79.3832
|
||||||
nostr.stakey.net:443,52.3676,4.90414
|
|
||||||
relay.nostr.place:443,43.6532,-79.3832
|
|
||||||
nostr.dlcdevkit.com,40.0992,-83.1141
|
|
||||||
nostr.aruku.ovh,1.27994,103.849
|
|
||||||
satsage.xyz,37.3986,-121.964
|
|
||||||
strfry.apps3.slidestr.net,40.4167,-3.70329
|
|
||||||
nostr2.girino.org,43.6532,-79.3832
|
|
||||||
relay.samt.st,40.8302,-74.1299
|
|
||||||
articles.layer3.news,37.3387,-121.885
|
|
||||||
aeon.libretechsystems.xyz,55.486,9.86577
|
|
||||||
relay.routstr.com,59.4016,17.9455
|
|
||||||
relay.ohstr.com:443,43.6532,-79.3832
|
|
||||||
relay.lanacoin-eternity.com:443,40.8302,-74.1299
|
|
||||||
strfry.openhoofd.nl:443,51.5717,3.70417
|
|
||||||
nostr.blankfors.se,60.1699,24.9384
|
|
||||||
nostr-2.21crypto.ch:443,47.5356,8.73209
|
|
||||||
relayone.soundhsa.com:443,39.1008,-94.5811
|
|
||||||
relay.lab.rytswd.com:443,49.4543,11.0746
|
|
||||||
nostr.tagomago.me,42.3601,-71.0589
|
nostr.tagomago.me,42.3601,-71.0589
|
||||||
relay.0xchat.com:443,43.6532,-79.3832
|
relayone.geektank.ai,39.0997,-94.5786
|
||||||
|
relay.dreamith.to:443,43.6532,-79.3832
|
||||||
|
nostr.liberty.fans,36.8767,-89.5879
|
||||||
|
wot.makenomistakes.ca,43.7064,-79.3986
|
||||||
|
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
||||||
|
relay.layer.systems,49.0291,8.35695
|
||||||
|
relay.paulstephenborile.com:443,49.4543,11.0746
|
||||||
|
relay.ohstr.com,43.6532,-79.3832
|
||||||
|
nostr-relay.xbytez.io:443,50.6924,3.20113
|
||||||
|
nostr.ac,38.958,-77.3592
|
||||||
|
ribo.us.nostria.app,43.6532,-79.3832
|
||||||
|
nostr.21crypto.ch,47.5356,8.73209
|
||||||
|
relay.chorus.community:443,48.5333,10.7
|
||||||
|
relay.cypherflow.ai,48.8575,2.35138
|
||||||
|
relay.agorist.space:443,52.3734,4.89406
|
||||||
|
relay.nostrian-conquest.com:443,41.223,-111.974
|
||||||
|
relay.keykeeper.world,40.7824,-74.0711
|
||||||
|
relay.getvia.xyz,60.1699,24.9384
|
||||||
|
relay.nuts.cash,52.3676,4.90414
|
||||||
|
kotukonostr.onrender.com,37.7775,-122.397
|
||||||
|
relay.minibolt.info,43.6532,-79.3832
|
||||||
|
relay.dwadziesciajeden.pl,52.2297,21.0122
|
||||||
|
relay.fountain.fm:443,43.6532,-79.3832
|
||||||
|
relay.fountain.fm,43.6532,-79.3832
|
||||||
|
nostr-02.uid.ovh,50.9871,2.12554
|
||||||
|
relay.lanavault.space:443,60.1699,24.9384
|
||||||
|
nostr.carroarmato0.be,50.914,3.21378
|
||||||
|
nexus.libernet.app:443,43.6532,-79.3832
|
||||||
|
relay.artio.inf.unibe.ch,46.9501,7.43678
|
||||||
|
blossom.gnostr.cloud,43.6532,-79.3832
|
||||||
|
relay.binaryrobot.com,43.6532,-79.3832
|
||||||
|
relay.earthly.city,34.1749,-118.54
|
||||||
|
nostr.hifish.org,47.4244,8.57658
|
||||||
|
offchain.pub:443,39.1585,-94.5728
|
||||||
|
relay.bullishbounty.com:443,43.6532,-79.3832
|
||||||
|
strfry.openhoofd.nl:443,51.5717,3.70417
|
||||||
|
cs-relay.nostrdev.com:443,50.4754,12.3683
|
||||||
|
strfry.ymir.cloud,43.6532,-79.3832
|
||||||
|
nostrbtc.com,43.6532,-79.3832
|
||||||
|
relay.directsponsor.net,42.8864,-78.8784
|
||||||
|
nostr2.girino.org,43.6532,-79.3832
|
||||||
|
relay.sigit.io:443,50.4754,12.3683
|
||||||
|
relay.getsafebox.app,43.6532,-79.3832
|
||||||
|
antiprimal.net,43.6532,-79.3832
|
||||||
|
nostr.sathoarder.com,48.5734,7.75211
|
||||||
|
inbox.scuba323.com,40.8218,-74.45
|
||||||
|
nrs-01.darkcloudarcade.com:443,39.0997,-94.5786
|
||||||
|
nostr.tac.lol,47.4748,-122.273
|
||||||
|
nostr.davenov.com,50.1109,8.68213
|
||||||
|
relay.trotters.cc:443,43.6532,-79.3832
|
||||||
|
nostr.plantroon.com:443,50.1013,8.62643
|
||||||
|
relay.nostreon.com,60.1699,24.9384
|
||||||
|
nostr.easycryptosend.it,43.6532,-79.3832
|
||||||
|
nostr-01.yakihonne.com:443,1.32123,103.695
|
||||||
|
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
||||||
|
nostr.purpura.cloud,43.6532,-79.3832
|
||||||
|
insta-relay.apps3.slidestr.net,40.4167,-3.70329
|
||||||
|
nostr.mifen.me,43.6532,-79.3832
|
||||||
|
testnet-relay.samt.st:443,40.8302,-74.1299
|
||||||
|
nostr.2b9t.xyz:443,34.0549,-118.243
|
||||||
|
relay.wavlake.com:443,41.2619,-95.8608
|
||||||
|
relay.wisp.talk:443,49.4543,11.0746
|
||||||
|
relay-dev.satlantis.io:443,39.0438,-77.4874
|
||||||
|
relay.satlantis.io,39.0438,-77.4874
|
||||||
|
relay.staging.plebeian.market,51.5072,-0.127586
|
||||||
|
relay.openfarmtools.org,60.1699,24.9384
|
||||||
|
relay.nostrhub.fr,48.1045,11.6004
|
||||||
|
nostr-relay.xbytez.io,50.6924,3.20113
|
||||||
|
relay.binaryrobot.com:443,43.6532,-79.3832
|
||||||
|
relay.samt.st,40.8302,-74.1299
|
||||||
|
relay.illuminodes.com,43.6532,-79.3832
|
||||||
|
relay.liberbitworld.org,43.6532,-79.3832
|
||||||
|
relay.olas.app:443,60.1699,24.9384
|
||||||
|
no.str.cr,8.96171,-83.5246
|
||||||
|
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
|
||||||
|
wot.rejecttheframe.xyz,43.6532,-79.3832
|
||||||
|
relay.nostriot.com:443,41.5695,-83.9786
|
||||||
|
nostr.plantroon.com,50.1013,8.62643
|
||||||
|
nostr-01.uid.ovh,50.9871,2.12554
|
||||||
|
relay.openresist.com:443,43.6532,-79.3832
|
||||||
|
nostr.overmind.lol,43.6532,-79.3832
|
||||||
|
relay.internationalright-wing.org,-22.5022,-48.7114
|
||||||
|
nostr.myshosholoza.co.za:443,52.3676,4.90414
|
||||||
|
nostr.pbfs.io:443,50.4754,12.3683
|
||||||
|
21milionidinostr.duckdns.org,41.8967,12.4822
|
||||||
|
nostr.4rs.nl,49.0291,8.35696
|
||||||
|
relay.lanavault.space,60.1699,24.9384
|
||||||
|
relay.mostr.pub,43.6532,-79.3832
|
||||||
|
relay.nostar.org,43.6532,-79.3832
|
||||||
|
nostr.mom,50.4754,12.3683
|
||||||
|
relay.decentralia.fr,48.122,11.589
|
||||||
|
relay.agentry.com,42.8864,-78.8784
|
||||||
|
relay2.angor.io,48.1046,11.6002
|
||||||
|
slick.mjex.me,39.0418,-77.4744
|
||||||
|
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
|
||||||
|
relay.vrtmrz.net:443,43.6532,-79.3832
|
||||||
|
relay.beginningend.com,35.2227,-97.4786
|
||||||
|
chat-relay.zap-work.com:443,43.6532,-79.3832
|
||||||
|
relay.underorion.se,50.1109,8.68213
|
||||||
|
relay.mitchelltribe.com,39.0438,-77.4874
|
||||||
|
relay.qstr.app,51.5072,-0.127586
|
||||||
|
relay.cyberguy.fyi,52.6907,4.8181
|
||||||
|
strfry.bonsai.com:443,39.0438,-77.4874
|
||||||
|
relayone.soundhsa.com:443,39.0997,-94.5786
|
||||||
|
relay.sigit.io,50.4754,12.3683
|
||||||
|
relay.npubhaus.com,43.6532,-79.3832
|
||||||
|
relayrs.notoshi.win,43.6532,-79.3832
|
||||||
|
relay.mitchelltribe.com:443,39.0438,-77.4874
|
||||||
|
relay.44billion.net,43.6532,-79.3832
|
||||||
|
reraw.pbla2fish.cc,43.6532,-79.3832
|
||||||
|
articles.layer3.news:443,37.3387,-121.885
|
||||||
|
nostr.sovereignservices.xyz,43.6532,-79.3832
|
||||||
|
relay.nostx.io,43.6532,-79.3832
|
||||||
|
nostr-relay.amethyst.name,39.0067,-77.4291
|
||||||
|
0x-nostr-relay.fly.dev,37.7648,-122.432
|
||||||
|
relay.ohstr.com:443,43.6532,-79.3832
|
||||||
|
00f2e774.relay.dev.thunderegg.us,39.0438,-77.4874
|
||||||
|
nostr-relay.cbrx.io,43.6532,-79.3832
|
||||||
|
relay.wavlake.com,41.2619,-95.8608
|
||||||
|
purplerelay.com:443,43.6532,-79.3832
|
||||||
|
nostr-pr02.redscrypt.org,52.3676,4.90414
|
||||||
|
fanfares.nostr1.com:443,40.7057,-74.0136
|
||||||
|
kasztanowa.bieda.it,43.6532,-79.3832
|
||||||
|
relay.flashapp.me,43.6548,-79.3885
|
||||||
|
relay.typedcypher.com,51.5072,-0.127586
|
||||||
|
nostr.bond,50.1109,8.68213
|
||||||
|
nostr.azzamo.net,52.2633,21.0283
|
||||||
|
nexus.libernet.app,43.6532,-79.3832
|
||||||
|
relay.cosmicbolt.net,37.3986,-121.964
|
||||||
|
schnorr.me,43.6532,-79.3832
|
||||||
|
relay.mostro.network:443,40.8302,-74.1299
|
||||||
|
relay-arg.zombi.cloudrodion.com,1.35208,103.82
|
||||||
|
relay.chorus.community,48.5333,10.7
|
||||||
|
blossom.gnostr.cloud:443,43.6532,-79.3832
|
||||||
|
syb.lol:443,34.0549,-118.243
|
||||||
|
relay.dyne.org,49.0291,8.35705
|
||||||
|
btc.klendazu.com,41.2861,1.24993
|
||||||
|
wot.nostr.place,43.6532,-79.3832
|
||||||
|
relay.openresist.com,43.6532,-79.3832
|
||||||
|
rilo.nostria.app:443,43.6532,-79.3832
|
||||||
|
no.str.cr:443,8.96171,-83.5246
|
||||||
|
relay.mostr.pub:443,43.6532,-79.3832
|
||||||
|
relay.edufeed.org:443,49.4521,11.0767
|
||||||
|
nostr.debate.report,50.1109,8.68213
|
||||||
|
relay.satmaxt.xyz:443,43.6532,-79.3832
|
||||||
|
relay.artx.market:443,43.6548,-79.3885
|
||||||
|
relay-dev.gulugulu.moe,43.6532,-79.3832
|
||||||
|
relay.novospes.com,43.6532,-79.3832
|
||||||
|
relay.nostr-check.me,43.6532,-79.3832
|
||||||
|
nostr.computingcache.com,34.0356,-118.442
|
||||||
|
nostr.oxtr.dev,50.4754,12.3683
|
||||||
|
relay.fckstate.net,59.3293,18.0686
|
||||||
|
relay.vrtmrz.net,43.6532,-79.3832
|
||||||
|
relay.bornheimer.app,51.5072,-0.127586
|
||||||
|
relay.guggero.org,46.5971,9.59652
|
||||||
|
relay01.lnfi.network,35.6764,139.65
|
||||||
|
wot.shaving.kiwi,43.6532,-79.3832
|
||||||
|
nostr.twinkle.lol,51.902,7.6657
|
||||||
|
relay.edufeed.org,49.4521,11.0767
|
||||||
|
relay.lanacoin-eternity.com,40.8302,-74.1299
|
||||||
|
relay.satmaxt.xyz,43.6532,-79.3832
|
||||||
|
nostr.hifish.org:443,47.4244,8.57658
|
||||||
|
relay.cypherflow.ai:443,48.8575,2.35138
|
||||||
|
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
|
||||||
|
nostr.na.social:443,43.6532,-79.3832
|
||||||
|
nostr.rtvslawenia.com:443,49.4543,11.0746
|
||||||
|
relay.mypathtofire.de,42.8864,-78.8784
|
||||||
|
public.crostr.com,43.6532,-79.3832
|
||||||
|
relay.olas.app,60.1699,24.9384
|
||||||
|
relay.agora.social,50.7383,15.0648
|
||||||
|
ribo.nostria.app,43.6532,-79.3832
|
||||||
|
relay.lab.rytswd.com,49.4543,11.0746
|
||||||
|
relay.ditto.pub:443,43.6532,-79.3832
|
||||||
|
porchlight.social,43.6532,-79.3832
|
||||||
|
nostr.notribe.net,40.8302,-74.1299
|
||||||
|
relay.endfiat.money,59.3327,18.0656
|
||||||
|
nostr.myshosholoza.co.za,52.3676,4.90414
|
||||||
|
relay.nearhood.co.uk,51.5134,-0.0890675
|
||||||
|
relay.degmods.com,50.4754,12.3683
|
||||||
|
nostr.novacisko.cz,52.2026,20.9397
|
||||||
|
prl.plus,55.7628,37.5983
|
||||||
|
bruh.samt.st,43.6532,-79.3832
|
||||||
|
strfry.openhoofd.nl,51.5717,3.70417
|
||||||
|
nostr.spicyz.io,43.6532,-79.3832
|
||||||
|
nostr.na.social,43.6532,-79.3832
|
||||||
|
nip85.nosfabrica.com,39.0997,-94.5786
|
||||||
|
premium.primal.net,43.6532,-79.3832
|
||||||
|
fanfares.nostr1.com,40.7057,-74.0136
|
||||||
|
relay.scuba323.com,40.8218,-74.45
|
||||||
|
nostr2.girino.org:443,43.6532,-79.3832
|
||||||
|
relay.mmwaves.de,48.8575,2.35138
|
||||||
|
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
|
||||||
|
strfry.shock.network:443,39.0438,-77.4874
|
||||||
|
nostr.snowbla.de,50.4754,12.3683
|
||||||
|
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||||
|
nostr.quali.chat,60.1699,24.9384
|
||||||
|
wot.utxo.one,43.6532,-79.3832
|
||||||
|
relay.mccormick.cx,52.3563,4.95714
|
||||||
|
mostro-p2p.tech,50.1109,8.68213
|
||||||
|
basspistol.org,49.0291,8.35696
|
||||||
|
ribo.nostria.app:443,43.6532,-79.3832
|
||||||
|
chorus.mikedilger.com:444,-36.8906,174.794
|
||||||
|
nostr.oxtr.dev:443,50.4754,12.3683
|
||||||
|
nostr.nodesmap.com,59.3327,18.0656
|
||||||
|
offchain.bostr.online,43.6532,-79.3832
|
||||||
|
purplerelay.com,43.6532,-79.3832
|
||||||
|
relayrs.notoshi.win:443,43.6532,-79.3832
|
||||||
|
relay.wavefunc.live,41.8781,-87.6298
|
||||||
|
relay.dreamith.to,43.6532,-79.3832
|
||||||
|
bendernostur.duckdns.org:8443,50.1109,8.68213
|
||||||
|
relay.nmail.li,50.9871,2.12554
|
||||||
|
nostr-relay.corb.net,39.6478,-104.988
|
||||||
|
relay.staging.plebeian.market:443,51.5072,-0.127586
|
||||||
|
spamspamspamspam.rest,43.6532,-79.3832
|
||||||
|
relay1.gfcom.info,13.9215,100.538
|
||||||
|
schnorr.me:443,43.6532,-79.3832
|
||||||
|
relay.lab.rytswd.com:443,49.4543,11.0746
|
||||||
|
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||||
|
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
|
||||||
|
relay.nostrmap.net,60.1699,24.9384
|
||||||
|
nostr.relay.hedwig.sh,60.1699,24.9384
|
||||||
|
relay.veganostr.com:443,60.1699,24.9384
|
||||||
|
relay.wavefunc.live:443,41.8781,-87.6298
|
||||||
|
nostr.mikoshi.de,52.52,13.405
|
||||||
|
syb.lol,34.0549,-118.243
|
||||||
|
relay1.nostrchat.io,60.1699,24.9384
|
||||||
|
nostr.wecsats.io:443,43.6532,-79.3832
|
||||||
|
nostr.chaima.info:443,51.5072,-0.127586
|
||||||
|
nostr.azzamo.net:443,52.2633,21.0283
|
||||||
|
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
|
||||||
|
nostr.unkn0wn.world,46.8499,9.53287
|
||||||
|
relayone.soundhsa.com,39.0997,-94.5786
|
||||||
|
x.kojira.io,43.6532,-79.3832
|
||||||
|
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
|
||||||
|
nostrelay.circum.space,52.6907,4.8181
|
||||||
|
relay.primal.net,43.6532,-79.3832
|
||||||
|
nostr.girino.org,43.6532,-79.3832
|
||||||
|
nostr.pbfs.io,50.4754,12.3683
|
||||||
|
relay.kalcafe.xyz,37.3986,-121.964
|
||||||
|
relay.gulugulu.moe,43.6532,-79.3832
|
||||||
|
top.testrelay.top,43.6532,-79.3832
|
||||||
|
relay.kilombino.com,43.6532,-79.3832
|
||||||
|
nos.lol:443,50.4754,12.3683
|
||||||
|
nos.lol,50.4754,12.3683
|
||||||
|
relay.nostr.place:443,43.6532,-79.3832
|
||||||
|
cache.trustr.ing,43.6548,-79.3885
|
||||||
|
relay.internationalright-wing.org:443,-22.5022,-48.7114
|
||||||
|
relay.laantungir.net,-19.4692,-42.5315
|
||||||
|
relay.lightning.pub:443,39.0438,-77.4874
|
||||||
|
nostr.stakey.net,52.3676,4.90414
|
||||||
|
articles.layer3.news,37.3387,-121.885
|
||||||
|
relay.wisp.talk,49.4543,11.0746
|
||||||
|
relay.pyramid.li,47.4093,8.46503
|
||||||
|
relay.typedcypher.com:443,51.5072,-0.127586
|
||||||
|
dev.relay.stream,43.6532,-79.3832
|
||||||
|
relay.bullishbounty.com,43.6532,-79.3832
|
||||||
|
nostr.mom:443,50.4754,12.3683
|
||||||
|
relay.plebeian.market:443,50.1109,8.68213
|
||||||
|
nostr.hekster.org,37.3986,-121.964
|
||||||
|
nostrcity-club.fly.dev,37.7648,-122.432
|
||||||
|
nostr.vulpem.com,49.4543,11.0746
|
||||||
|
relay-dev.gulugulu.moe:443,43.6532,-79.3832
|
||||||
|
weboftrust.libretechsystems.xyz,55.4724,9.87335
|
||||||
|
nostr-relay.corb.net:443,39.6478,-104.988
|
||||||
|
wheat.happytavern.co,43.6532,-79.3832
|
||||||
|
relay.mappingbitcoin.com,43.6532,-79.3832
|
||||||
|
testnet-relay.samt.st,40.8302,-74.1299
|
||||||
|
relay.bitmacro.cloud,43.6532,-79.3832
|
||||||
|
dev.relay.edufeed.org,49.4521,11.0767
|
||||||
|
myvoiceourstory.org,37.3598,-121.981
|
||||||
|
relay.stickeroo.is-cool.dev,37.3387,-121.885
|
||||||
|
relay.agorist.space,52.3734,4.89406
|
||||||
|
freelay.sovbit.host,60.1699,24.9384
|
||||||
|
nostr-dev.wellorder.net,45.5201,-122.99
|
||||||
|
nostr.middling.mydns.jp,35.8099,140.12
|
||||||
|
cs-relay.nostrdev.com,50.4754,12.3683
|
||||||
|
x.kojira.io:443,43.6532,-79.3832
|
||||||
|
nostrelay.circum.space:443,52.6907,4.8181
|
||||||
|
nostr.janx.com,43.6532,-79.3832
|
||||||
|
relay.mrmave.work,43.6532,-79.3832
|
||||||
|
espelho.girino.org,43.6532,-79.3832
|
||||||
|
hol.is,43.6532,-79.3832
|
||||||
|
ribo.eu.nostria.app,43.6532,-79.3832
|
||||||
|
nostr.yutakobayashi.com,43.6532,-79.3832
|
||||||
|
relay.mostro.network,40.8302,-74.1299
|
||||||
|
communities.nos.social,40.8302,-74.1299
|
||||||
|
relay.solife.me,43.6532,-79.3832
|
||||||
|
yabu.me,35.6092,139.73
|
||||||
|
relay.islandbitcoin.com,12.8498,77.6545
|
||||||
|
nostr.wecsats.io,43.6532,-79.3832
|
||||||
|
nostr.tac.lol:443,47.4748,-122.273
|
||||||
|
relay.arx-ccn.com,50.4754,12.3683
|
||||||
|
nostrride.io,37.3986,-121.964
|
||||||
|
r.0kb.io:443,32.789,-96.7989
|
||||||
|
herbstmeister.com,34.0549,-118.243
|
||||||
|
relay.artx.market,43.6548,-79.3885
|
||||||
|
vault.iris.to,43.6532,-79.3832
|
||||||
|
relay.ru.ac.th,13.7607,100.627
|
||||||
|
temp.iris.to,43.6532,-79.3832
|
||||||
|
social.amanah.eblessing.co,48.1046,11.6002
|
||||||
|
nostr-relay.nextblockvending.com,47.2343,-119.853
|
||||||
|
wot.codingarena.top,50.4754,12.3683
|
||||||
|
relay.sincensura.org,43.6532,-79.3832
|
||||||
|
nostr.dlcdevkit.com,40.0992,-83.1141
|
||||||
|
|||||||
|
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
tracked_justfiles="$(git ls-files | awk 'tolower($0) == "justfile"')"
|
||||||
|
tracked_justfile_count="$(printf '%s\n' "$tracked_justfiles" | awk 'NF { count++ } END { print count + 0 }')"
|
||||||
|
if [[ $tracked_justfile_count -ne 1 || $tracked_justfiles != "Justfile" ]]; then
|
||||||
|
echo "Expected exactly one tracked canonical Justfile; found: ${tracked_justfiles:-none}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fxq 'clean:' Justfile; then
|
||||||
|
echo "Clean recipe must not depend on another recipe" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
clean_recipe="$({
|
||||||
|
awk '
|
||||||
|
/^clean:/ { in_clean = 1; next }
|
||||||
|
in_clean && /^[^[:space:]]/ { exit }
|
||||||
|
in_clean { print }
|
||||||
|
' Justfile
|
||||||
|
})"
|
||||||
|
|
||||||
|
if [[ -z ${clean_recipe//[[:space:]]/} ]]; then
|
||||||
|
echo "Justfile clean recipe is missing or empty" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fxq 'derived_data := ".DerivedData"' Justfile; then
|
||||||
|
echo "Derived data path must remain the ignored repo-local .DerivedData directory" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
clean_forbidden='git[[:space:]]+(checkout|restore|reset|clean)|(^|[[:space:]])(cp|mv)([[:space:]]|$)|bitchat\.xcodeproj|project\.pbxproj|Info\.plist|LaunchScreen|project\.yml|Configs/'
|
||||||
|
if grep -Eiq "$clean_forbidden" <<<"$clean_recipe"; then
|
||||||
|
echo "Unsafe source/configuration mutation found in the clean recipe:" >&2
|
||||||
|
grep -Ein "$clean_forbidden" <<<"$clean_recipe" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq 'rm -rf -- "{{derived_data}}" ".build"' <<<"$clean_recipe"; then
|
||||||
|
echo "Clean recipe must remain limited to the declared repo-local artifact paths" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
clean_rm_count="$(grep -Ec '^[[:space:]]*@?rm[[:space:]]+-rf([[:space:]]|$)' <<<"$clean_recipe" || true)"
|
||||||
|
if [[ $clean_rm_count -ne 1 ]]; then
|
||||||
|
echo "Clean recipe must contain exactly one recursive removal command" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
expected_clean_recipe=' @echo "Cleaning repo-local build artifacts..."
|
||||||
|
@rm -rf -- "{{derived_data}}" ".build"
|
||||||
|
@echo "✅ Cleaned {{derived_data}} and .build; tracked files were untouched"'
|
||||||
|
if [[ $clean_recipe != "$expected_clean_recipe" ]]; then
|
||||||
|
echo "Clean recipe contains commands outside the reviewed artifact-only implementation" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
file_forbidden='git[[:space:]]+(checkout|restore|reset|clean)|rm[[:space:]]+-rf[^#]*(bitchat\.xcodeproj|bitchat/|Configs/)|LaunchScreen\.storyboard\.ios|project\.pbxproj\.backup|Info\.plist\.backup'
|
||||||
|
if grep -Ein "$file_forbidden" Justfile; then
|
||||||
|
echo "Unsafe tracked-file recovery/deletion logic found in Justfile" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Justfile clean safety check passed"
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
WORKFLOW_PATH = REPOSITORY_ROOT / ".github/workflows/fetch_georelays.yml"
|
||||||
|
|
||||||
|
|
||||||
|
class FetchGeoRelaysWorkflowTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
cls.workflow = WORKFLOW_PATH.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_write_capable_checkout_action_is_immutable(self) -> None:
|
||||||
|
checkout = re.search(r"uses: actions/checkout@([0-9a-f]+)", self.workflow)
|
||||||
|
self.assertIsNotNone(checkout)
|
||||||
|
self.assertRegex(checkout.group(1), r"^[0-9a-f]{40}$")
|
||||||
|
self.assertIn("persist-credentials: false", self.workflow)
|
||||||
|
|
||||||
|
def test_pr_failure_has_single_issue_fallback_with_review_metadata(self) -> None:
|
||||||
|
required_fragments = [
|
||||||
|
"issues: write",
|
||||||
|
"TRACKING_ISSUE_TITLE: GeoRelay update awaiting pull request",
|
||||||
|
"gh pr create",
|
||||||
|
"gh issue create",
|
||||||
|
"gh issue edit",
|
||||||
|
"compare/main...${UPDATE_BRANCH}?expand=1",
|
||||||
|
"Upstream commit: $SOURCE_COMMIT",
|
||||||
|
"Data rows: $DATA_ROWS",
|
||||||
|
"Unique normalized relays: $UNIQUE_RELAYS",
|
||||||
|
"SHA-256: $DATA_SHA256",
|
||||||
|
'[[ -n "$issue_url" ]]',
|
||||||
|
]
|
||||||
|
for fragment in required_fragments:
|
||||||
|
with self.subTest(fragment=fragment):
|
||||||
|
self.assertIn(fragment, self.workflow)
|
||||||
|
|
||||||
|
confirmed = self.workflow.index('[[ -n "$issue_url" ]]')
|
||||||
|
success_summary = self.workflow.index(
|
||||||
|
"Published GeoRelay tracking issue fallback: $issue_url"
|
||||||
|
)
|
||||||
|
self.assertLess(confirmed, success_summary)
|
||||||
|
|
||||||
|
def test_obsolete_review_state_is_cleaned_without_pushing_main(self) -> None:
|
||||||
|
self.assertIn("gh pr close", self.workflow)
|
||||||
|
self.assertIn("gh issue close", self.workflow)
|
||||||
|
self.assertIn('git push origin --delete "$UPDATE_BRANCH"', self.workflow)
|
||||||
|
self.assertIn('git switch -C "$UPDATE_BRANCH"', self.workflow)
|
||||||
|
self.assertNotIn("git push origin main", self.workflow)
|
||||||
|
self.assertNotIn("git push --force origin main", self.workflow)
|
||||||
|
|
||||||
|
def test_workflow_runs_all_validator_tests(self) -> None:
|
||||||
|
self.assertIn(
|
||||||
|
'python3 -m unittest discover -s scripts/tests -p "test_*.py" -v',
|
||||||
|
self.workflow,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
import validate_georelays as validator
|
||||||
|
|
||||||
|
|
||||||
|
def csv_bytes(rows: list[str]) -> bytes:
|
||||||
|
return ("Relay URL,Latitude,Longitude\n" + "\n".join(rows) + "\n").encode()
|
||||||
|
|
||||||
|
|
||||||
|
class ValidateGeoRelaysTests(unittest.TestCase):
|
||||||
|
def test_validates_and_deduplicates_secure_relay_addresses(self) -> None:
|
||||||
|
data = csv_bytes(
|
||||||
|
[
|
||||||
|
"relay.example.com,10,20",
|
||||||
|
"wss://relay.example.com:443/,10,20",
|
||||||
|
"https://second.example.org,11,21",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = validator.validate_bytes(data, minimum_unique_relays=2)
|
||||||
|
|
||||||
|
self.assertEqual(summary.data_rows, 3)
|
||||||
|
self.assertEqual(summary.unique_relays, 2)
|
||||||
|
|
||||||
|
def test_rejects_insecure_or_non_host_relay_urls(self) -> None:
|
||||||
|
bad_addresses = [
|
||||||
|
"http://relay.example.com",
|
||||||
|
"ws://relay.example.com",
|
||||||
|
"wss://user@relay.example.com",
|
||||||
|
"wss://relay.example.com/path",
|
||||||
|
"wss://relay.example.com?",
|
||||||
|
"wss://relay.example.com#",
|
||||||
|
"relay.example.com:0",
|
||||||
|
"relay.example.com:99999",
|
||||||
|
"localhost",
|
||||||
|
"127.0.0.1",
|
||||||
|
"relay_example.com",
|
||||||
|
"relay\u202e.example.com",
|
||||||
|
]
|
||||||
|
|
||||||
|
for address in bad_addresses:
|
||||||
|
with self.subTest(address=address):
|
||||||
|
with self.assertRaises(validator.ValidationError):
|
||||||
|
validator.validate_bytes(
|
||||||
|
csv_bytes([f"{address},10,20"]),
|
||||||
|
minimum_unique_relays=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_malformed_rows_and_unsafe_coordinates(self) -> None:
|
||||||
|
bad_rows = [
|
||||||
|
"relay.example.com,10",
|
||||||
|
"relay.example.com,NaN,20",
|
||||||
|
"relay.example.com,1_0,20",
|
||||||
|
"relay.example.com,\u0661\u0660,20",
|
||||||
|
"relay.example.com,\uff11\uff10,20",
|
||||||
|
"relay.example.com,91,20",
|
||||||
|
"relay.example.com,10,-181",
|
||||||
|
"relay.example.com,10,20,extra",
|
||||||
|
'"relay.example.com",10,20',
|
||||||
|
]
|
||||||
|
|
||||||
|
for row in bad_rows:
|
||||||
|
with self.subTest(row=row):
|
||||||
|
with self.assertRaises(validator.ValidationError):
|
||||||
|
validator.validate_bytes(csv_bytes([row]), minimum_unique_relays=1)
|
||||||
|
|
||||||
|
def test_accepts_ascii_coordinate_forms_supported_by_swift_double(self) -> None:
|
||||||
|
summary = validator.validate_bytes(
|
||||||
|
csv_bytes(
|
||||||
|
[
|
||||||
|
"one.example.com,+1,-.5",
|
||||||
|
"two.example.com,1.e1,2E+1",
|
||||||
|
"three.example.com,01,20.",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
minimum_unique_relays=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(summary.unique_relays, 3)
|
||||||
|
|
||||||
|
def test_rejects_conflicts_limits_and_large_baseline_deltas(self) -> None:
|
||||||
|
with self.assertRaises(validator.ValidationError):
|
||||||
|
validator.validate_bytes(
|
||||||
|
csv_bytes(["relay.example.com,10,20", "relay.example.com,11,21"]),
|
||||||
|
minimum_unique_relays=1,
|
||||||
|
)
|
||||||
|
with self.assertRaises(validator.ValidationError):
|
||||||
|
validator.validate_bytes(b"x" * 20, maximum_bytes=10, minimum_unique_relays=1)
|
||||||
|
with self.assertRaises(validator.ValidationError):
|
||||||
|
validator.validate_bytes(
|
||||||
|
csv_bytes(["one.example.com,1,1", "two.example.com,2,2"]),
|
||||||
|
minimum_unique_relays=3,
|
||||||
|
)
|
||||||
|
|
||||||
|
baseline = csv_bytes(
|
||||||
|
[f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(120)]
|
||||||
|
)
|
||||||
|
shrunken = csv_bytes(
|
||||||
|
[f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(59)]
|
||||||
|
)
|
||||||
|
with self.assertRaises(validator.ValidationError):
|
||||||
|
validator.validate_update(shrunken, baseline)
|
||||||
|
|
||||||
|
smaller_baseline = csv_bytes(
|
||||||
|
[f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(60)]
|
||||||
|
)
|
||||||
|
expanded = csv_bytes(
|
||||||
|
[f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(121)]
|
||||||
|
)
|
||||||
|
with self.assertRaises(validator.ValidationError):
|
||||||
|
validator.validate_update(expanded, smaller_baseline)
|
||||||
|
|
||||||
|
def test_update_requires_exact_normalized_baseline_entry_overlap(self) -> None:
|
||||||
|
baseline_rows = [
|
||||||
|
f"relay-{index}.example.com,{index % 80},{index % 170}"
|
||||||
|
for index in range(60)
|
||||||
|
]
|
||||||
|
baseline = csv_bytes(baseline_rows)
|
||||||
|
disjoint = csv_bytes(
|
||||||
|
[
|
||||||
|
f"attacker-{index}.example.com,{index % 80},{index % 170}"
|
||||||
|
for index in range(60)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
rewritten_coordinates = csv_bytes(
|
||||||
|
[
|
||||||
|
f"relay-{index}.example.com,{(index % 80) + 0.5},{index % 170}"
|
||||||
|
for index in range(60)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
for candidate in (disjoint, rewritten_coordinates):
|
||||||
|
with self.subTest(candidate=candidate[:80]):
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
validator.ValidationError,
|
||||||
|
"exact relay-coordinate entries",
|
||||||
|
):
|
||||||
|
validator.validate_update(candidate, baseline)
|
||||||
|
|
||||||
|
half_retained = csv_bytes(
|
||||||
|
[
|
||||||
|
f"wss://relay-{index}.example.com:443/,{index % 80},{index % 170}"
|
||||||
|
for index in range(30)
|
||||||
|
]
|
||||||
|
+ [
|
||||||
|
f"replacement-{index}.example.com,{index % 80},{index % 170}"
|
||||||
|
for index in range(30)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
summary = validator.validate_update(half_retained, baseline)
|
||||||
|
self.assertEqual(summary.unique_relays, 60)
|
||||||
|
|
||||||
|
def test_cli_copies_only_validated_data_and_emits_review_metadata(self) -> None:
|
||||||
|
rows = [f"relay-{index}.example.com,{index % 80},{index % 170}" for index in range(60)]
|
||||||
|
data = csv_bytes(rows)
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
candidate = root / "candidate.csv"
|
||||||
|
baseline = root / "baseline.csv"
|
||||||
|
output = root / "output.csv"
|
||||||
|
github_output = root / "github-output.txt"
|
||||||
|
candidate.write_bytes(data)
|
||||||
|
baseline.write_bytes(data)
|
||||||
|
|
||||||
|
result = validator.main(
|
||||||
|
[
|
||||||
|
"--input", str(candidate),
|
||||||
|
"--baseline", str(baseline),
|
||||||
|
"--output", str(output),
|
||||||
|
"--github-output", str(github_output),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
self.assertEqual(output.read_bytes(), data)
|
||||||
|
metadata = github_output.read_text()
|
||||||
|
self.assertIn("unique_relays=60", metadata)
|
||||||
|
self.assertIn("sha256=", metadata)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Strict validator for the reviewed georelay CSV update workflow."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import unicodedata
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
MAX_BYTES = 512 * 1024
|
||||||
|
MAX_ROWS = 5_000
|
||||||
|
MAX_UNIQUE_RELAYS = 5_000
|
||||||
|
MIN_UNIQUE_RELAYS = 50
|
||||||
|
MIN_BASELINE_FRACTION = 0.5
|
||||||
|
MAX_BASELINE_MULTIPLIER = 2.0
|
||||||
|
EXPECTED_HEADER = ("relay url", "latitude", "longitude")
|
||||||
|
ASCII_DECIMAL_PATTERN = re.compile(
|
||||||
|
r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?\Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ValidationSummary:
|
||||||
|
data_rows: int
|
||||||
|
unique_relays: int
|
||||||
|
sha256: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _ValidatedDataset:
|
||||||
|
summary: ValidationSummary
|
||||||
|
entries: frozenset[tuple[str, float, float]]
|
||||||
|
|
||||||
|
|
||||||
|
def _has_disallowed_control(value: str) -> bool:
|
||||||
|
return any(
|
||||||
|
unicodedata.category(character) in {"Cc", "Cf"}
|
||||||
|
and character not in {"\r", "\n", "\t"}
|
||||||
|
for character in value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_relay_address(raw_value: str) -> str:
|
||||||
|
value = raw_value.strip()
|
||||||
|
if not value or _has_disallowed_control(value):
|
||||||
|
raise ValidationError("relay address is empty or contains control characters")
|
||||||
|
# urlsplit cannot distinguish an absent query/fragment from an explicitly
|
||||||
|
# empty one. Reject the delimiters themselves so this validator matches
|
||||||
|
# URLComponents in the client and reviewed data cannot fail closed there.
|
||||||
|
if "?" in value or "#" in value:
|
||||||
|
raise ValidationError(f"relay query or fragment is not allowed: {value}")
|
||||||
|
|
||||||
|
candidate = value if "://" in value else f"wss://{value}"
|
||||||
|
try:
|
||||||
|
parsed = urlsplit(candidate)
|
||||||
|
port = parsed.port
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValidationError(f"invalid relay URL: {value}") from error
|
||||||
|
|
||||||
|
if parsed.scheme.lower() not in {"wss", "https"}:
|
||||||
|
raise ValidationError(f"relay must use wss/https or a bare hostname: {value}")
|
||||||
|
if parsed.username is not None or parsed.password is not None:
|
||||||
|
raise ValidationError(f"relay credentials are not allowed: {value}")
|
||||||
|
if parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
|
||||||
|
raise ValidationError(f"relay path, query, or fragment is not allowed: {value}")
|
||||||
|
|
||||||
|
host = (parsed.hostname or "").lower()
|
||||||
|
if not host or len(host) > 253 or not host.isascii():
|
||||||
|
raise ValidationError(f"relay hostname is missing or non-ASCII: {value}")
|
||||||
|
if host.endswith(".") or host == "localhost" or host.endswith((".localhost", ".local", ".internal")):
|
||||||
|
raise ValidationError(f"local or absolute relay hostname is not allowed: {value}")
|
||||||
|
|
||||||
|
labels = host.split(".")
|
||||||
|
if len(labels) < 2 or all(label.isdigit() for label in labels):
|
||||||
|
raise ValidationError(f"relay must use a public DNS hostname: {value}")
|
||||||
|
for label in labels:
|
||||||
|
if not 1 <= len(label) <= 63:
|
||||||
|
raise ValidationError(f"invalid DNS label length: {value}")
|
||||||
|
if label[0] == "-" or label[-1] == "-":
|
||||||
|
raise ValidationError(f"DNS labels cannot start or end with '-': {value}")
|
||||||
|
if any(character not in "abcdefghijklmnopqrstuvwxyz0123456789-" for character in label):
|
||||||
|
raise ValidationError(f"invalid DNS hostname character: {value}")
|
||||||
|
|
||||||
|
if port is not None and not 1 <= port <= 65_535:
|
||||||
|
raise ValidationError(f"invalid relay port: {value}")
|
||||||
|
if port in {None, 443}:
|
||||||
|
return host
|
||||||
|
return f"{host}:{port}"
|
||||||
|
|
||||||
|
|
||||||
|
def _validated_dataset(
|
||||||
|
data: bytes,
|
||||||
|
*,
|
||||||
|
minimum_unique_relays: int = MIN_UNIQUE_RELAYS,
|
||||||
|
maximum_bytes: int = MAX_BYTES,
|
||||||
|
maximum_rows: int = MAX_ROWS,
|
||||||
|
maximum_unique_relays: int = MAX_UNIQUE_RELAYS,
|
||||||
|
) -> _ValidatedDataset:
|
||||||
|
if not data or len(data) > maximum_bytes:
|
||||||
|
raise ValidationError(f"CSV must contain 1..{maximum_bytes} bytes")
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = data.decode("utf-8")
|
||||||
|
except UnicodeDecodeError as error:
|
||||||
|
raise ValidationError("CSV is not valid UTF-8") from error
|
||||||
|
if text.startswith("\ufeff"):
|
||||||
|
raise ValidationError("UTF-8 BOM is not allowed")
|
||||||
|
if _has_disallowed_control(text):
|
||||||
|
raise ValidationError("CSV contains disallowed control characters")
|
||||||
|
# Runtime intentionally implements the fixed three-field schema without
|
||||||
|
# general CSV quoting. Reject quoted variants here so reviewed workflow
|
||||||
|
# output and client-side validation cannot disagree.
|
||||||
|
if '"' in text:
|
||||||
|
raise ValidationError("quoted CSV fields are not allowed")
|
||||||
|
|
||||||
|
reader = csv.reader(io.StringIO(text, newline=""), strict=True)
|
||||||
|
try:
|
||||||
|
header = next(reader)
|
||||||
|
except (StopIteration, csv.Error) as error:
|
||||||
|
raise ValidationError("CSV header is missing") from error
|
||||||
|
normalized_header = tuple(field.strip().lower() for field in header)
|
||||||
|
if normalized_header != EXPECTED_HEADER:
|
||||||
|
raise ValidationError(f"unexpected CSV header: {header!r}")
|
||||||
|
|
||||||
|
data_rows = 0
|
||||||
|
relays: dict[str, tuple[float, float]] = {}
|
||||||
|
try:
|
||||||
|
for row in reader:
|
||||||
|
if not row or all(not field.strip() for field in row):
|
||||||
|
continue
|
||||||
|
data_rows += 1
|
||||||
|
if data_rows > maximum_rows:
|
||||||
|
raise ValidationError(f"CSV exceeds {maximum_rows} data rows")
|
||||||
|
if len(row) != 3:
|
||||||
|
raise ValidationError(f"row {reader.line_num} must contain exactly 3 columns")
|
||||||
|
|
||||||
|
address = normalize_relay_address(row[0])
|
||||||
|
latitude_text = row[1].strip()
|
||||||
|
longitude_text = row[2].strip()
|
||||||
|
if not ASCII_DECIMAL_PATTERN.fullmatch(latitude_text) or not ASCII_DECIMAL_PATTERN.fullmatch(longitude_text):
|
||||||
|
raise ValidationError(
|
||||||
|
f"row {reader.line_num} coordinates must be ASCII decimal numbers"
|
||||||
|
)
|
||||||
|
latitude = float(latitude_text)
|
||||||
|
longitude = float(longitude_text)
|
||||||
|
if not math.isfinite(latitude) or not -90 <= latitude <= 90:
|
||||||
|
raise ValidationError(f"row {reader.line_num} latitude is out of range")
|
||||||
|
if not math.isfinite(longitude) or not -180 <= longitude <= 180:
|
||||||
|
raise ValidationError(f"row {reader.line_num} longitude is out of range")
|
||||||
|
|
||||||
|
coordinates = (latitude, longitude)
|
||||||
|
previous = relays.get(address)
|
||||||
|
if previous is not None and previous != coordinates:
|
||||||
|
raise ValidationError(f"relay {address} has conflicting coordinates")
|
||||||
|
relays[address] = coordinates
|
||||||
|
if len(relays) > maximum_unique_relays:
|
||||||
|
raise ValidationError(f"CSV exceeds {maximum_unique_relays} unique relays")
|
||||||
|
except csv.Error as error:
|
||||||
|
raise ValidationError(f"malformed CSV near line {reader.line_num}") from error
|
||||||
|
|
||||||
|
if len(relays) < minimum_unique_relays:
|
||||||
|
raise ValidationError(
|
||||||
|
f"CSV has {len(relays)} unique relays; minimum is {minimum_unique_relays}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return _ValidatedDataset(
|
||||||
|
summary=ValidationSummary(
|
||||||
|
data_rows=data_rows,
|
||||||
|
unique_relays=len(relays),
|
||||||
|
sha256=hashlib.sha256(data).hexdigest(),
|
||||||
|
),
|
||||||
|
entries=frozenset(
|
||||||
|
(address, coordinates[0], coordinates[1])
|
||||||
|
for address, coordinates in relays.items()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_bytes(
|
||||||
|
data: bytes,
|
||||||
|
*,
|
||||||
|
minimum_unique_relays: int = MIN_UNIQUE_RELAYS,
|
||||||
|
maximum_bytes: int = MAX_BYTES,
|
||||||
|
maximum_rows: int = MAX_ROWS,
|
||||||
|
maximum_unique_relays: int = MAX_UNIQUE_RELAYS,
|
||||||
|
) -> ValidationSummary:
|
||||||
|
return _validated_dataset(
|
||||||
|
data,
|
||||||
|
minimum_unique_relays=minimum_unique_relays,
|
||||||
|
maximum_bytes=maximum_bytes,
|
||||||
|
maximum_rows=maximum_rows,
|
||||||
|
maximum_unique_relays=maximum_unique_relays,
|
||||||
|
).summary
|
||||||
|
|
||||||
|
|
||||||
|
def validate_update(candidate: bytes, baseline: bytes) -> ValidationSummary:
|
||||||
|
baseline_dataset = _validated_dataset(baseline, minimum_unique_relays=1)
|
||||||
|
candidate_dataset = _validated_dataset(candidate)
|
||||||
|
baseline_summary = baseline_dataset.summary
|
||||||
|
candidate_summary = candidate_dataset.summary
|
||||||
|
|
||||||
|
minimum_from_baseline = math.ceil(
|
||||||
|
baseline_summary.unique_relays * MIN_BASELINE_FRACTION
|
||||||
|
)
|
||||||
|
maximum_from_baseline = math.floor(
|
||||||
|
baseline_summary.unique_relays * MAX_BASELINE_MULTIPLIER
|
||||||
|
)
|
||||||
|
if candidate_summary.unique_relays < minimum_from_baseline:
|
||||||
|
raise ValidationError(
|
||||||
|
"candidate loses more than half of the baseline's unique relays "
|
||||||
|
f"({candidate_summary.unique_relays} < {minimum_from_baseline})"
|
||||||
|
)
|
||||||
|
if candidate_summary.unique_relays > maximum_from_baseline:
|
||||||
|
raise ValidationError(
|
||||||
|
"candidate more than doubles the baseline's unique relays "
|
||||||
|
f"({candidate_summary.unique_relays} > {maximum_from_baseline})"
|
||||||
|
)
|
||||||
|
|
||||||
|
retained_entries = len(baseline_dataset.entries & candidate_dataset.entries)
|
||||||
|
if retained_entries < minimum_from_baseline:
|
||||||
|
raise ValidationError(
|
||||||
|
"candidate retains fewer than half of the baseline's exact relay-coordinate entries "
|
||||||
|
f"({retained_entries} < {minimum_from_baseline})"
|
||||||
|
)
|
||||||
|
return candidate_summary
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--input", required=True, type=Path)
|
||||||
|
parser.add_argument("--baseline", required=True, type=Path)
|
||||||
|
parser.add_argument("--output", required=True, type=Path)
|
||||||
|
parser.add_argument("--github-output", type=Path)
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
try:
|
||||||
|
candidate = args.input.read_bytes()
|
||||||
|
baseline = args.baseline.read_bytes()
|
||||||
|
summary = validate_update(candidate, baseline)
|
||||||
|
args.output.write_bytes(candidate)
|
||||||
|
if args.github_output is not None:
|
||||||
|
with args.github_output.open("a", encoding="utf-8") as output:
|
||||||
|
output.write(f"data_rows={summary.data_rows}\n")
|
||||||
|
output.write(f"unique_relays={summary.unique_relays}\n")
|
||||||
|
output.write(f"sha256={summary.sha256}\n")
|
||||||
|
except (OSError, ValidationError) as error:
|
||||||
|
print(f"georelay validation failed: {error}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"validated {summary.unique_relays} unique relays across "
|
||||||
|
f"{summary.data_rows} rows (sha256 {summary.sha256})"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user