mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 10:45:20 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f8af04821 | ||
|
|
78083bd773 | ||
|
|
a8201b3958 | ||
|
|
01ce054238 | ||
|
|
b6c7c77080 | ||
|
|
55ee1df0bc | ||
|
|
b255355fed | ||
|
|
6e026c2c22 | ||
|
|
c8e330777a | ||
|
|
91aba8b597 | ||
|
|
d9a6dbfca8 | ||
|
|
68f8f03ad8 | ||
|
|
48026991b2 | ||
|
|
ec795520ee | ||
|
|
40238c5e43 | ||
|
|
a31cd80027 | ||
|
|
974510ad9e | ||
|
|
5aee7f0f98 | ||
|
|
6054248765 | ||
|
|
5f7df63238 | ||
|
|
b081c98dba | ||
|
|
76d3b0f1ed | ||
|
|
aa3021c9ca |
@@ -1,228 +1,42 @@
|
||||
name: Propose GeoRelay Data Update
|
||||
name: Fetch GeoRelays Data
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 6 * * 0"
|
||||
- cron: '0 6 * * 0'
|
||||
workflow_dispatch:
|
||||
|
||||
# Default to read-only. The publishing job receives only the scopes required
|
||||
# to push its branch and publish either a PR or a tracking issue.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: georelay-data-update
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
SOURCE_REPOSITORY: https://github.com/permissionlesstech/georelays.git
|
||||
UPDATE_BRANCH: automation/georelay-data
|
||||
TRACKING_ISSUE_TITLE: GeoRelay update awaiting pull request
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
propose-relay-data:
|
||||
name: Validate and propose relay data
|
||||
update-relay-data:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Checkout reviewed base
|
||||
# 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
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
# Do not expose the write token to fetch/validation subprocesses.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Test GeoRelay validator
|
||||
- name: Fetch GeoRelays
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -m unittest discover -s scripts/tests -p "test_*.py" -v
|
||||
wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
|
||||
mv nostr_relays.csv ./relays/online_relays_gps.csv
|
||||
|
||||
- name: Fetch candidate over pinned HTTPS policy
|
||||
id: upstream
|
||||
- name: Check for changes
|
||||
id: git-check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source_commit=$(git ls-remote --refs "$SOURCE_REPOSITORY" refs/heads/main | awk 'NR == 1 { print $1 }')
|
||||
if [[ ! "$source_commit" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "::error::Could not resolve an immutable upstream commit"
|
||||
exit 1
|
||||
fi
|
||||
source_url="https://raw.githubusercontent.com/permissionlesstech/georelays/$source_commit/nostr_relays.csv"
|
||||
effective_url=$(curl --fail --show-error --silent --location --proto "=https" --proto-redir "=https" --tlsv1.2 --max-time 60 --retry 3 --retry-all-errors --output "$RUNNER_TEMP/georelays-candidate.csv" --write-out "%{url_effective}" "$source_url")
|
||||
if [[ "$effective_url" != "$source_url" ]]; then
|
||||
echo "::error::Unexpected GeoRelay redirect target: $effective_url"
|
||||
exit 1
|
||||
fi
|
||||
echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT"
|
||||
echo "source_url=$source_url" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate candidate against reviewed baseline
|
||||
id: validation
|
||||
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Commit and push changes
|
||||
if: steps.git-check.outputs.changes == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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"
|
||||
|
||||
- name: Check for a reviewed-file change
|
||||
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'
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add relays/online_relays_gps.csv
|
||||
git commit -m "Automated update of relay data - $(date -u)"
|
||||
git push
|
||||
env:
|
||||
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
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -94,24 +94,6 @@ jobs:
|
||||
kill "$watchdog_pid" 2>/dev/null || true
|
||||
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
|
||||
# numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate.
|
||||
- name: Run performance benchmarks (serial)
|
||||
@@ -133,6 +115,22 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
run: ./scripts/check-perf-floors.sh perf-output.log
|
||||
|
||||
# Informational only: surfaces per-file and total line coverage in the
|
||||
# job log so coverage trends are visible on every PR. No thresholds —
|
||||
# this must never be the reason a build goes red.
|
||||
- name: Coverage summary
|
||||
run: |
|
||||
BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }})
|
||||
PROF="$BIN_PATH/codecov/default.profdata"
|
||||
XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1)
|
||||
BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)"
|
||||
if [ -f "$PROF" ] && [ -f "$BINARY" ]; then
|
||||
xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \
|
||||
-ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)' || true
|
||||
else
|
||||
echo "No coverage data found; skipping summary."
|
||||
fi
|
||||
|
||||
# SPM tests do not link the shipping app targets. This job covers the
|
||||
# iOS-conditional paths and both universal Release link configurations.
|
||||
ios-build:
|
||||
@@ -144,9 +142,6 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Check clean recipe safety
|
||||
run: bash scripts/check-just-clean-safety.sh
|
||||
|
||||
- name: Build iOS (simulator, no signing)
|
||||
# Build both simulator architectures so CI validates every vendored
|
||||
# Arti simulator slice and the configuration that ships.
|
||||
@@ -174,52 +169,6 @@ jobs:
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
build
|
||||
|
||||
# The SwiftPM matrix runs on macOS and cannot execute UIKit/CoreBluetooth
|
||||
# conditional tests. Build the shared iOS test target and run it on the first
|
||||
# available iPhone simulator from the runner image instead of hard-coding a
|
||||
# model that changes when GitHub updates Xcode. The suite intentionally runs
|
||||
# in one test runner: a number of integration tests exercise process-global
|
||||
# stores and notification centers, so overlapping workers can corrupt each
|
||||
# other's fixtures and turn sub-second tests into multi-minute timeouts.
|
||||
ios-tests:
|
||||
name: Run iOS simulator tests
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Select available iPhone simulator
|
||||
id: destination
|
||||
run: |
|
||||
destinations=$(xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -showdestinations)
|
||||
destination_id=$(awk -F'id:' '
|
||||
/platform:iOS Simulator/ && /name:iPhone/ && !found {
|
||||
value=$2
|
||||
sub(/,.*/, "", value)
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
|
||||
print value
|
||||
found=1
|
||||
}
|
||||
' <<< "$destinations")
|
||||
if [ -z "$destination_id" ]; then
|
||||
echo "::error::No available iPhone simulator destination found"
|
||||
exit 1
|
||||
fi
|
||||
echo "id=$destination_id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run iOS tests
|
||||
run: |
|
||||
set -o pipefail
|
||||
xcodebuild -project bitchat.xcodeproj \
|
||||
-scheme "bitchat (iOS)" \
|
||||
-sdk iphonesimulator \
|
||||
-destination "platform=iOS Simulator,id=${{ steps.destination.outputs.id }}" \
|
||||
-parallel-testing-enabled NO \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
test
|
||||
|
||||
# Advisory only: SwiftLint reports style violations without ever failing the
|
||||
# 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.
|
||||
|
||||
@@ -80,4 +80,3 @@ build.log
|
||||
|
||||
# Local configs
|
||||
Local.xcconfig
|
||||
*.profraw
|
||||
|
||||
@@ -3,6 +3,3 @@ DEVELOPMENT_TEAM = ABC123
|
||||
|
||||
// Unique bundle id to be able to register and run locally
|
||||
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,66 +1,107 @@
|
||||
# BitChat developer commands
|
||||
#
|
||||
# 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"
|
||||
# BitChat macOS Build Justfile
|
||||
# Handles temporary modifications needed to build and run on macOS
|
||||
|
||||
# Default recipe - shows available commands
|
||||
default:
|
||||
@echo "BitChat developer commands:"
|
||||
@echo " just run Build and run the macOS app"
|
||||
@echo " just build Build the macOS app without signing"
|
||||
@echo " just test Run the SwiftPM test suite"
|
||||
@echo " just test-ios Run tests on the iPhone 17 simulator"
|
||||
@echo " just clean Remove repo-local build artifacts only"
|
||||
@echo " just nuke Also remove nested package build caches"
|
||||
@echo " just check Validate the development environment"
|
||||
@echo "BitChat macOS Build Commands:"
|
||||
@echo " just run - Build and run the macOS app"
|
||||
@echo " just build - Build the macOS app only"
|
||||
@echo " just clean - Clean build artifacts and restore original files"
|
||||
@echo " just check - Check prerequisites"
|
||||
@echo ""
|
||||
@echo "Original files are preserved - modifications are temporary for builds only"
|
||||
|
||||
# Static guard against reintroducing source-restoring or source-deleting clean
|
||||
# behavior. CI runs the same script directly.
|
||||
check-clean-safety:
|
||||
@bash scripts/check-just-clean-safety.sh
|
||||
|
||||
check: check-clean-safety
|
||||
# Check prerequisites
|
||||
check:
|
||||
@echo "Checking prerequisites..."
|
||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install full Xcode." && 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
|
||||
@xcodebuild -version
|
||||
@echo "✅ Development environment ready (a signing identity is not required for just build)"
|
||||
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && 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)
|
||||
@test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1)
|
||||
@xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
|
||||
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
|
||||
@echo "✅ All prerequisites met"
|
||||
|
||||
build: check
|
||||
# Backup original files
|
||||
backup:
|
||||
@echo "Backing up original project configuration..."
|
||||
@if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi
|
||||
@if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi
|
||||
|
||||
# Restore original files
|
||||
restore:
|
||||
@echo "Restoring original project configuration..."
|
||||
@if [ -f project.yml.backup ]; then mv project.yml.backup project.yml; fi
|
||||
@# Restore iOS-specific files
|
||||
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
|
||||
@# Use git to restore all modified files except Justfile
|
||||
@git checkout -- project.yml bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Could not restore some files with git"
|
||||
@# Remove any backup files
|
||||
@rm -f bitchat.xcodeproj/project.pbxproj.backup bitchat/Info.plist.backup 2>/dev/null || true
|
||||
|
||||
# Apply macOS-specific modifications
|
||||
patch-for-macos: backup
|
||||
@echo "Temporarily hiding iOS-specific files for macOS build..."
|
||||
@# Move iOS-specific files out of the way temporarily
|
||||
@if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi
|
||||
|
||||
# Build the macOS app
|
||||
build: #check generate
|
||||
@echo "Building BitChat for macOS..."
|
||||
@xcodebuild -project "{{project}}" -scheme "{{macos_scheme}}" -configuration Debug -derivedDataPath "{{derived_data}}" CODE_SIGNING_ALLOWED=NO build
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
|
||||
# Run the macOS app
|
||||
run: build
|
||||
@app="{{derived_data}}/Build/Products/Debug/bitchat.app"; test -d "$$app" || (echo "❌ Built app not found at $$app" && exit 1); open "$$app"
|
||||
@echo "Launching BitChat..."
|
||||
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
|
||||
|
||||
# Backward-compatible alias for the old quick-run recipe.
|
||||
dev-run: run
|
||||
# Clean build artifacts and restore original files
|
||||
clean: restore
|
||||
@echo "Cleaning build artifacts..."
|
||||
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
|
||||
@# Only remove the generated project if we have a backup, otherwise use git
|
||||
@if [ -f bitchat.xcodeproj/project.pbxproj.backup ]; then \
|
||||
rm -rf bitchat.xcodeproj; \
|
||||
else \
|
||||
git checkout -- bitchat.xcodeproj/project.pbxproj 2>/dev/null || echo "⚠️ Could not restore project.pbxproj"; \
|
||||
fi
|
||||
@rm -f project-macos.yml 2>/dev/null || true
|
||||
@echo "✅ Cleaned and restored original files"
|
||||
|
||||
test:
|
||||
@swift test
|
||||
|
||||
test-ios: check
|
||||
@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"
|
||||
# Quick run without cleaning (for development)
|
||||
dev-run: check
|
||||
@echo "Quick development build..."
|
||||
@xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
|
||||
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
|
||||
|
||||
# Show app info
|
||||
info:
|
||||
@echo "BitChat - decentralized mesh messaging"
|
||||
@echo "macOS 13+ and iOS 16+"
|
||||
@echo "Bluetooth mesh behavior requires physical Bluetooth-capable devices"
|
||||
@echo "BitChat - Decentralized Mesh Messaging"
|
||||
@echo "======================================"
|
||||
@echo "• Native macOS SwiftUI app"
|
||||
@echo "• Bluetooth LE mesh networking"
|
||||
@echo "• End-to-end encryption"
|
||||
@echo "• No internet required"
|
||||
@echo "• Works offline with nearby devices"
|
||||
@echo ""
|
||||
@echo "Requirements:"
|
||||
@echo "• macOS 13.0+ (Ventura)"
|
||||
@echo "• Bluetooth LE capable Mac"
|
||||
@echo "• Physical device (no simulator support)"
|
||||
@echo ""
|
||||
@echo "Usage:"
|
||||
@echo "• Set nickname and start chatting"
|
||||
@echo "• Use /join #channel for group chats"
|
||||
@echo "• Use /msg @user for private messages"
|
||||
@echo "• Triple-tap logo for emergency wipe"
|
||||
|
||||
# Force clean everything (nuclear option)
|
||||
nuke:
|
||||
@echo "🧨 Nuclear clean - removing all build artifacts and backups..."
|
||||
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
|
||||
@rm -rf bitchat.xcodeproj 2>/dev/null || true
|
||||
@rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true
|
||||
@rm -f bitchat/Info.plist.backup 2>/dev/null || true
|
||||
@# Restore iOS-specific files if they were moved
|
||||
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
|
||||
@git checkout bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
|
||||
@echo "✅ Nuclear clean complete"
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ bitchat is designed for private, account-free communication. This policy describ
|
||||
|
||||
2. **Nickname, preferences, and relationships**
|
||||
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
|
||||
- The share extension can retain one item you choose to share in the app-group preferences for up to 24 hours. The app shows the destination and a preview for review; it does not send the item automatically. The item is cleared when you add it to the composer, cancel, panic-wipe, or it expires.
|
||||
- The share extension briefly places content you choose to share in the app-group preferences so the main app can import it.
|
||||
|
||||
3. **Private group state**
|
||||
- Group names, rosters, creator identity, and key epoch are stored as protected files in Application Support.
|
||||
|
||||
@@ -93,62 +93,30 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
|
||||
|
||||
### Option 1: Using Xcode
|
||||
|
||||
```bash
|
||||
open bitchat.xcodeproj
|
||||
```
|
||||
```bash
|
||||
cd bitchat
|
||||
open bitchat.xcodeproj
|
||||
```
|
||||
|
||||
For a signed device build, create your ignored local configuration and replace
|
||||
the example team ID with your Apple Developer Team ID:
|
||||
|
||||
```bash
|
||||
cp Configs/Local.xcconfig.example Configs/Local.xcconfig
|
||||
```
|
||||
|
||||
`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)"
|
||||
```
|
||||
To run on a device there're a few steps to prepare the code:
|
||||
- Clone the local configs: `cp Configs/Local.xcconfig.example Configs/Local.xcconfig`
|
||||
- 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)
|
||||
- Entitlements need to be updated manually (TODO: Automate):
|
||||
- Search and replace `group.chat.bitchat` with `group.<your_bundle_id>` (e.g. `group.chat.bitchat.ABC123`)
|
||||
|
||||
### Option 2: Using `just`
|
||||
|
||||
```bash
|
||||
brew install just
|
||||
just check
|
||||
just run
|
||||
```
|
||||
```bash
|
||||
brew install just
|
||||
```
|
||||
|
||||
`just build` and `just run` use the current `bitchat (macOS)` scheme and keep
|
||||
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.
|
||||
Want to try this on macos: `just run` will set it up and run from source.
|
||||
Run `just clean` afterwards to restore things to original state for mobile app building and development.
|
||||
|
||||
## Localization
|
||||
|
||||
- App localizations live in `bitchat/Localizable.xcstrings`.
|
||||
- Share extension strings are separate in `bitchatShareExtension/Localization/Localizable.xcstrings`.
|
||||
- Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`.
|
||||
- Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`.
|
||||
- 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.
|
||||
|
||||
Generated
-1
@@ -70,7 +70,6 @@
|
||||
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Services/SharedContentHandoff.swift,
|
||||
Services/TransportConfig.swift,
|
||||
);
|
||||
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
|
||||
|
||||
@@ -2,6 +2,11 @@ import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum SharedContentKind: String, Sendable, Equatable {
|
||||
case text
|
||||
case url
|
||||
}
|
||||
|
||||
enum RuntimeScenePhase: String, Sendable, Equatable {
|
||||
case active
|
||||
case inactive
|
||||
@@ -20,7 +25,7 @@ enum AppEvent: Sendable, Equatable {
|
||||
case startupCompleted
|
||||
case scenePhaseChanged(RuntimeScenePhase)
|
||||
case openedURL(String)
|
||||
case sharedContentReadyForReview(SharedContentKind)
|
||||
case sharedContentAccepted(SharedContentKind)
|
||||
case notificationOpened(peerID: PeerID?)
|
||||
case deepLinkOpened(String)
|
||||
case torLifecycleChanged(TorLifecycleEvent)
|
||||
|
||||
@@ -20,7 +20,6 @@ final class AppChromeModel: ObservableObject {
|
||||
@Published var showScreenshotPrivacyWarning = false
|
||||
|
||||
private let chatViewModel: ChatViewModel
|
||||
private let onPanicWipe: () -> Void
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
/// The composer owns capture state above ChatViewModel. ContentView
|
||||
/// installs this hook so both panic entry points synchronously stop it.
|
||||
@@ -29,13 +28,8 @@ final class AppChromeModel: ObservableObject {
|
||||
/// Bulletin-board coordinator, created on first use of the board sheet.
|
||||
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
|
||||
|
||||
init(
|
||||
chatViewModel: ChatViewModel,
|
||||
privateInboxModel: PrivateInboxModel,
|
||||
onPanicWipe: @escaping () -> Void = {}
|
||||
) {
|
||||
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
|
||||
self.chatViewModel = chatViewModel
|
||||
self.onPanicWipe = onPanicWipe
|
||||
self.nickname = chatViewModel.nickname
|
||||
|
||||
bind(privateInboxModel: privateInboxModel)
|
||||
@@ -112,7 +106,6 @@ final class AppChromeModel: ObservableObject {
|
||||
|
||||
func panicClearAllData() {
|
||||
prepareForPanic?()
|
||||
onPanicWipe()
|
||||
chatViewModel.panicClearAllData()
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ final class AppRuntime: ObservableObject {
|
||||
let peerListModel: PeerListModel
|
||||
let appChromeModel: AppChromeModel
|
||||
let boardAlertsModel: BoardAlertsModel
|
||||
let sharedContentImportModel: SharedContentImportModel
|
||||
|
||||
private let idBridge: NostrIdentityBridge
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
@@ -42,8 +41,7 @@ final class AppRuntime: ObservableObject {
|
||||
|
||||
init(
|
||||
keychain: KeychainManagerProtocol = KeychainManager.makeDefault(),
|
||||
idBridge: NostrIdentityBridge = NostrIdentityBridge(),
|
||||
sharedContentStore: SharedContentStore? = nil
|
||||
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
||||
) {
|
||||
self.idBridge = idBridge
|
||||
let conversations = ConversationStore()
|
||||
@@ -86,20 +84,9 @@ final class AppRuntime: ObservableObject {
|
||||
peerIdentityStore: peerIdentityStore,
|
||||
locationPresenceStore: locationPresenceStore
|
||||
)
|
||||
let resolvedSharedContentStore: SharedContentStore?
|
||||
if let sharedContentStore {
|
||||
resolvedSharedContentStore = sharedContentStore
|
||||
} else if let sharedDefaults = UserDefaults(suiteName: BitchatApp.groupID) {
|
||||
resolvedSharedContentStore = SharedContentStore(defaults: sharedDefaults)
|
||||
} else {
|
||||
resolvedSharedContentStore = nil
|
||||
}
|
||||
let sharedContentImportModel = SharedContentImportModel(store: resolvedSharedContentStore)
|
||||
self.sharedContentImportModel = sharedContentImportModel
|
||||
self.appChromeModel = AppChromeModel(
|
||||
chatViewModel: self.chatViewModel,
|
||||
privateInboxModel: self.privateInboxModel,
|
||||
onPanicWipe: { sharedContentImportModel.discardAll() }
|
||||
privateInboxModel: self.privateInboxModel
|
||||
)
|
||||
let chatViewModel = self.chatViewModel
|
||||
self.boardAlertsModel = BoardAlertsModel(
|
||||
@@ -119,6 +106,7 @@ final class AppRuntime: ObservableObject {
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
if chatViewModel.networkActivationAllowed {
|
||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||
}
|
||||
@@ -340,22 +328,45 @@ private extension AppRuntime {
|
||||
}
|
||||
|
||||
func checkForSharedContent() {
|
||||
let previousID = sharedContentImportModel.offer?.id
|
||||
guard let payload = sharedContentImportModel.refresh(
|
||||
destination: currentSharedContentDestination
|
||||
) else { return }
|
||||
|
||||
if previousID != payload.id {
|
||||
record(.sharedContentReadyForReview(payload.kind))
|
||||
guard chatViewModel.networkActivationAllowed else { return }
|
||||
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
|
||||
let clearSharedContent = {
|
||||
userDefaults.removeObject(forKey: "sharedContent")
|
||||
userDefaults.removeObject(forKey: "sharedContentType")
|
||||
userDefaults.removeObject(forKey: "sharedContentDate")
|
||||
}
|
||||
}
|
||||
|
||||
var currentSharedContentDestination: SharedContentDestination {
|
||||
SharedContentDestination.resolve(
|
||||
selectedPrivatePeerID: privateConversationModel.selectedPeerID,
|
||||
privateDisplayName: privateConversationModel.selectedHeaderState?.displayName,
|
||||
activeChannel: locationChannelsModel.selectedChannel
|
||||
)
|
||||
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
||||
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
|
||||
// A partial or malformed handoff must not linger in the shared
|
||||
// app-group container indefinitely.
|
||||
clearSharedContent()
|
||||
return
|
||||
}
|
||||
|
||||
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
|
||||
clearSharedContent()
|
||||
return
|
||||
}
|
||||
|
||||
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
|
||||
|
||||
clearSharedContent()
|
||||
|
||||
switch contentKind {
|
||||
case .url:
|
||||
if let data = sharedContent.data(using: .utf8),
|
||||
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
|
||||
let url = urlData["url"] {
|
||||
chatViewModel.sendMessage(url)
|
||||
} else {
|
||||
chatViewModel.sendMessage(sharedContent)
|
||||
}
|
||||
case .text:
|
||||
chatViewModel.sendMessage(sharedContent)
|
||||
}
|
||||
|
||||
record(.sharedContentAccepted(contentKind))
|
||||
}
|
||||
|
||||
func handleNostrRelayConnectionChanged(_ isConnected: Bool) {
|
||||
|
||||
@@ -39,17 +39,15 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
@Published private(set) var messages: [BitchatMessage] = []
|
||||
@Published private(set) var isUnread: Bool = false
|
||||
|
||||
/// Incrementally-maintained message-ID → logical-index map for O(1)
|
||||
/// dedup and delivery-status lookup. Logical indexes are physical array
|
||||
/// indexes plus `indexOffset`; trimming from the head advances the offset
|
||||
/// instead of rewriting every surviving dictionary entry. This matters
|
||||
/// after the 1337-message cap is reached, when every steady-state tail
|
||||
/// append evicts one old row.
|
||||
///
|
||||
/// Out-of-order inserts and middle removals still reindex only the
|
||||
/// affected suffix. Full filtering resets the offset while rebuilding.
|
||||
/// Incrementally-maintained message-ID → index map for O(1) dedup and
|
||||
/// delivery-status lookup. Kept in sync on every mutation:
|
||||
/// - tail append: single insert
|
||||
/// - out-of-order insert: suffix reindex from the insertion point
|
||||
/// - trim: full rebuild — `removeFirst(k)` is already O(n), so the
|
||||
/// rebuild does not change the asymptotics, and trim only happens once
|
||||
/// the cap (1337) is reached. Simple and correct beats the
|
||||
/// offset-tracking alternative here.
|
||||
private var indexByMessageID: [String: Int] = [:]
|
||||
private var indexOffset = 0
|
||||
|
||||
fileprivate init(id: ConversationID, cap: Int) {
|
||||
self.id = id
|
||||
@@ -63,7 +61,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
}
|
||||
|
||||
func message(withID messageID: String) -> BitchatMessage? {
|
||||
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
||||
guard let index = indexByMessageID[messageID] else { return nil }
|
||||
return messages[index]
|
||||
}
|
||||
|
||||
@@ -103,7 +101,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
reindex(from: index)
|
||||
} else {
|
||||
messages.append(message)
|
||||
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
||||
indexByMessageID[message.id] = messages.count - 1
|
||||
}
|
||||
|
||||
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
|
||||
@@ -113,7 +111,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
/// timeline position (in-place updates like media progress reuse the
|
||||
/// original timestamp); a new message goes through ordered insertion.
|
||||
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
|
||||
if let index = physicalIndex(forMessageID: message.id) {
|
||||
if let index = indexByMessageID[message.id] {
|
||||
messages[index] = message
|
||||
return .updated
|
||||
}
|
||||
@@ -127,7 +125,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
/// `.read` is never downgraded to `.delivered` or `.sent`.
|
||||
/// Returns `true` when the status was applied.
|
||||
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
|
||||
guard let index = physicalIndex(forMessageID: messageID) else { return false }
|
||||
guard let index = indexByMessageID[messageID] else { return false }
|
||||
let message = messages[index]
|
||||
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
|
||||
|
||||
@@ -144,7 +142,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
/// observers still need an @Published emission to re-render.
|
||||
@discardableResult
|
||||
fileprivate func republishMessage(withID messageID: String) -> Bool {
|
||||
guard let index = physicalIndex(forMessageID: messageID) else { return false }
|
||||
guard let index = indexByMessageID[messageID] else { return false }
|
||||
messages[index] = messages[index]
|
||||
return true
|
||||
}
|
||||
@@ -159,14 +157,10 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
/// Removes a single message by ID. Returns the removed message, or
|
||||
/// `nil` when no message with that ID exists.
|
||||
fileprivate func remove(messageID: String) -> BitchatMessage? {
|
||||
guard let index = physicalIndex(forMessageID: messageID) else { return nil }
|
||||
guard let index = indexByMessageID[messageID] else { return nil }
|
||||
let removed = messages.remove(at: index)
|
||||
indexByMessageID.removeValue(forKey: messageID)
|
||||
if index == 0 {
|
||||
indexOffset += 1
|
||||
} else {
|
||||
reindex(from: index)
|
||||
}
|
||||
reindex(from: index)
|
||||
return removed
|
||||
}
|
||||
|
||||
@@ -183,7 +177,6 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
for id in removedIDs {
|
||||
indexByMessageID.removeValue(forKey: id)
|
||||
}
|
||||
indexOffset = 0
|
||||
reindex(from: 0)
|
||||
return removedIDs
|
||||
}
|
||||
@@ -191,7 +184,6 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
fileprivate func clearMessages() {
|
||||
messages.removeAll()
|
||||
indexByMessageID.removeAll()
|
||||
indexOffset = 0
|
||||
}
|
||||
|
||||
// MARK: Diagnostics
|
||||
@@ -213,10 +205,9 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
let message = messages[position]
|
||||
// Count equality + every message resolving to its own position
|
||||
// proves the index is exactly the inverse map (no stale extras).
|
||||
if let logicalIndex = indexByMessageID[message.id] {
|
||||
let expectedIndex = indexOffset + position
|
||||
if logicalIndex != expectedIndex {
|
||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(logicalIndex - indexOffset)")
|
||||
if let index = indexByMessageID[message.id] {
|
||||
if index != position {
|
||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(index)")
|
||||
}
|
||||
} else {
|
||||
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
|
||||
@@ -278,17 +269,10 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
|
||||
private func reindex(from start: Int) {
|
||||
for index in start..<messages.count {
|
||||
indexByMessageID[messages[index].id] = indexOffset + index
|
||||
indexByMessageID[messages[index].id] = index
|
||||
}
|
||||
}
|
||||
|
||||
private func physicalIndex(forMessageID messageID: String) -> Int? {
|
||||
guard let logicalIndex = indexByMessageID[messageID] else { return nil }
|
||||
let index = logicalIndex - indexOffset
|
||||
guard messages.indices.contains(index) else { return nil }
|
||||
return index
|
||||
}
|
||||
|
||||
/// Trims oldest messages over the cap; returns the trimmed message IDs.
|
||||
private func trimIfNeeded() -> [String] {
|
||||
guard messages.count > cap else { return [] }
|
||||
@@ -298,7 +282,7 @@ final class Conversation: ObservableObject, Identifiable {
|
||||
indexByMessageID.removeValue(forKey: id)
|
||||
}
|
||||
messages.removeFirst(overflow)
|
||||
indexOffset += overflow
|
||||
reindex(from: 0)
|
||||
return trimmedIDs
|
||||
}
|
||||
}
|
||||
@@ -860,8 +844,8 @@ extension Conversation {
|
||||
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
|
||||
func _testCorruptIndexEntries() {
|
||||
guard messages.count >= 2 else { return }
|
||||
indexByMessageID[messages[0].id] = indexOffset + 1
|
||||
indexByMessageID[messages[1].id] = indexOffset
|
||||
indexByMessageID[messages[0].id] = 1
|
||||
indexByMessageID[messages[1].id] = 0
|
||||
}
|
||||
|
||||
/// Drops a message's index entry entirely (count mismatch + missing).
|
||||
@@ -875,8 +859,8 @@ extension Conversation {
|
||||
func _testCorruptOrderingPreservingIndex() {
|
||||
guard messages.count >= 2 else { return }
|
||||
messages.swapAt(0, messages.count - 1)
|
||||
indexByMessageID[messages[0].id] = indexOffset
|
||||
indexByMessageID[messages[messages.count - 1].id] = indexOffset + messages.count - 1
|
||||
indexByMessageID[messages[0].id] = 0
|
||||
indexByMessageID[messages[messages.count - 1].id] = messages.count - 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -916,7 +900,7 @@ extension ConversationStore {
|
||||
extension Conversation {
|
||||
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
|
||||
messages.append(message)
|
||||
indexByMessageID[message.id] = indexOffset + messages.count - 1
|
||||
indexByMessageID[message.id] = messages.count - 1
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -33,25 +33,15 @@ final class NearbyNotesCounter: ObservableObject {
|
||||
private let locationManager: LocationChannelManager
|
||||
private let managerFactory: @MainActor (String) -> LocationNotesManager
|
||||
private let releaseManager: @MainActor (LocationNotesManager?) -> Void
|
||||
private let locationNotesEnabled: @MainActor () -> Bool
|
||||
private let locationNotesSettingsPublisher: AnyPublisher<Void, Never>
|
||||
|
||||
init(
|
||||
locationManager: LocationChannelManager = .shared,
|
||||
managerFactory: @escaping @MainActor (String) -> LocationNotesManager = { LocationNotesPool.shared.acquire($0) },
|
||||
releaseManager: @escaping @MainActor (LocationNotesManager?) -> Void = { LocationNotesPool.shared.release($0) },
|
||||
locationNotesEnabled: @escaping @MainActor () -> Bool = { LocationNotesSettings.enabled },
|
||||
locationNotesSettings: AnyPublisher<Void, Never>? = nil
|
||||
releaseManager: @escaping @MainActor (LocationNotesManager?) -> Void = { LocationNotesPool.shared.release($0) }
|
||||
) {
|
||||
self.locationManager = locationManager
|
||||
self.managerFactory = managerFactory
|
||||
self.releaseManager = releaseManager
|
||||
self.locationNotesEnabled = locationNotesEnabled
|
||||
self.locationNotesSettingsPublisher = locationNotesSettings
|
||||
?? NotificationCenter.default
|
||||
.publisher(for: LocationNotesSettings.didChangeNotification)
|
||||
.map { _ in () }
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
/// Whether the empty-timeline "check for notes" hint should render.
|
||||
@@ -63,7 +53,7 @@ final class NearbyNotesCounter: ObservableObject {
|
||||
/// passes its own observed permission state so the hint re-renders when
|
||||
/// authorization changes.
|
||||
func offersRevealHint(permissionState: LocationChannelManager.PermissionState) -> Bool {
|
||||
!revealed && locationNotesEnabled() && permissionState == .authorized
|
||||
!revealed && LocationNotesSettings.enabled && permissionState == .authorized
|
||||
}
|
||||
|
||||
/// Marks the one explicit act that lets the counter subscribe. Sticky for
|
||||
@@ -93,7 +83,8 @@ final class NearbyNotesCounter: ObservableObject {
|
||||
.sink { [weak self] _ in self?.retarget() }
|
||||
// The app-info kill switch must take effect immediately, not on the
|
||||
// next location change or remount.
|
||||
settingCancellable = locationNotesSettingsPublisher
|
||||
settingCancellable = NotificationCenter.default
|
||||
.publisher(for: LocationNotesSettings.didChangeNotification)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in self?.retarget() }
|
||||
retarget()
|
||||
@@ -114,7 +105,7 @@ final class NearbyNotesCounter: ObservableObject {
|
||||
private func retarget() {
|
||||
guard activeHolders > 0,
|
||||
revealed,
|
||||
locationNotesEnabled(),
|
||||
LocationNotesSettings.enabled,
|
||||
locationManager.permissionState == .authorized,
|
||||
let geohash = locationManager.availableChannels
|
||||
.first(where: { $0.level == .building })?.geohash
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
enum SharedContentDestination: Sendable, Equatable {
|
||||
case mesh
|
||||
case geohash(String)
|
||||
case privateConversation(peerID: PeerID, displayName: String)
|
||||
|
||||
static func resolve(
|
||||
selectedPrivatePeerID: PeerID?,
|
||||
privateDisplayName: String?,
|
||||
activeChannel: ChannelID
|
||||
) -> SharedContentDestination {
|
||||
if let selectedPrivatePeerID {
|
||||
let fallback = String(selectedPrivatePeerID.id.prefix(12))
|
||||
return .privateConversation(
|
||||
peerID: selectedPrivatePeerID,
|
||||
displayName: privateDisplayName?.trimmedOrNilIfEmpty ?? fallback
|
||||
)
|
||||
}
|
||||
|
||||
switch activeChannel {
|
||||
case .mesh:
|
||||
return .mesh
|
||||
case .location(let channel):
|
||||
return .geohash(channel.geohash.lowercased())
|
||||
}
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .mesh:
|
||||
return "#mesh"
|
||||
case .geohash(let geohash):
|
||||
return "#\(geohash)"
|
||||
case .privateConversation(_, let displayName):
|
||||
return displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SharedContentOffer: Identifiable, Sendable, Equatable {
|
||||
let payload: SharedContentPayload
|
||||
let destination: SharedContentDestination
|
||||
|
||||
var id: UUID { payload.id }
|
||||
}
|
||||
|
||||
/// Holds a pending extension handoff until the user chooses a destination and
|
||||
/// explicitly adds it to the composer. This type has no send dependency by
|
||||
/// design: confirming an import can never transmit a message.
|
||||
@MainActor
|
||||
final class SharedContentImportModel: ObservableObject {
|
||||
@Published private(set) var offer: SharedContentOffer?
|
||||
|
||||
private let store: SharedContentStore?
|
||||
|
||||
init(store: SharedContentStore?) {
|
||||
self.store = store
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func refresh(
|
||||
destination: SharedContentDestination,
|
||||
now: Date = Date()
|
||||
) -> SharedContentPayload? {
|
||||
guard let payload = store?.pending(now: now) else {
|
||||
offer = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
let nextOffer = SharedContentOffer(payload: payload, destination: destination)
|
||||
if offer != nextOffer {
|
||||
offer = nextOffer
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func updateDestination(_ destination: SharedContentDestination) {
|
||||
guard let offer, offer.destination != destination else { return }
|
||||
self.offer = SharedContentOffer(payload: offer.payload, destination: destination)
|
||||
}
|
||||
|
||||
/// Returns composer text only when the currently displayed destination is
|
||||
/// still current and the reviewed envelope is still the stored envelope.
|
||||
/// A destination change updates the prompt and requires another tap.
|
||||
func confirm(
|
||||
destination: SharedContentDestination,
|
||||
now: Date = Date()
|
||||
) -> String? {
|
||||
guard let offer else { return nil }
|
||||
guard offer.destination == destination else {
|
||||
updateDestination(destination)
|
||||
return nil
|
||||
}
|
||||
guard let payload = store?.consume(id: offer.id, now: now) else {
|
||||
_ = refresh(destination: destination, now: now)
|
||||
return nil
|
||||
}
|
||||
|
||||
self.offer = nil
|
||||
return payload.composerText
|
||||
}
|
||||
|
||||
func cancel(destination: SharedContentDestination, now: Date = Date()) {
|
||||
guard let offer else { return }
|
||||
store?.discard(id: offer.id)
|
||||
self.offer = nil
|
||||
// If a newer share replaced the reviewed envelope, surface it rather
|
||||
// than losing it with the older cancellation.
|
||||
_ = refresh(destination: destination, now: now)
|
||||
}
|
||||
|
||||
func discardAll() {
|
||||
store?.discardAll()
|
||||
offer = nil
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,6 @@ struct BitchatApp: App {
|
||||
.environmentObject(runtime.peerListModel)
|
||||
.environmentObject(runtime.appChromeModel)
|
||||
.environmentObject(runtime.boardAlertsModel)
|
||||
.environmentObject(runtime.sharedContentImportModel)
|
||||
.onAppear {
|
||||
appDelegate.runtime = runtime
|
||||
runtime.start()
|
||||
|
||||
@@ -161,24 +161,24 @@ struct VouchRecord: Codable, Equatable {
|
||||
struct IdentityCache: Codable {
|
||||
// Fingerprint -> Social mapping
|
||||
var socialIdentities: [String: SocialIdentity] = [:]
|
||||
|
||||
|
||||
// Nickname -> [Fingerprints] reverse index
|
||||
// Multiple fingerprints can claim same nickname
|
||||
var nicknameIndex: [String: Set<String>] = [:]
|
||||
|
||||
|
||||
// Verified fingerprints (cryptographic proof)
|
||||
var verifiedFingerprints: Set<String> = []
|
||||
|
||||
|
||||
// Last interaction timestamps (privacy: optional)
|
||||
var lastInteractions: [String: Date] = [:]
|
||||
|
||||
var lastInteractions: [String: Date] = [:]
|
||||
|
||||
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
|
||||
var blockedNostrPubkeys: Set<String> = []
|
||||
|
||||
// Vouching (transitive verification). All three fields are Optional so
|
||||
// caches persisted before this feature decode cleanly — decodeIfPresent
|
||||
// is used below, and a missing key must not trip the "unreadable cache"
|
||||
// recovery path that discards everything.
|
||||
// caches persisted before this feature decode cleanly — the synthesized
|
||||
// decoder uses decodeIfPresent for optionals, and a missing key must not
|
||||
// trip the "unreadable cache" recovery path that discards everything.
|
||||
|
||||
// Vouchee fingerprint -> accepted vouches (capped per vouchee)
|
||||
var vouchesByVouchee: [String: [VouchRecord]]? = nil
|
||||
@@ -201,38 +201,6 @@ struct IdentityCache: Codable {
|
||||
// containing a copied public Noise key from replacing a previously bound
|
||||
// public-message signing identity. Optional for old cache compatibility.
|
||||
var authenticatedSigningKeysByFingerprint: [String: Data]? = nil
|
||||
|
||||
// Fingerprint -> Cryptographic identity (noise + pinned signing key).
|
||||
// Persisting the signing-key pin is security-critical: it must survive
|
||||
// app restarts so an attacker cannot replay a known peer's
|
||||
// noiseKey/peerID with their own signing key and be treated as first
|
||||
// contact (TOFU downgrade).
|
||||
var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||
|
||||
// Schema version for future migrations
|
||||
var version: Int = 1
|
||||
|
||||
init() {}
|
||||
|
||||
// Custom decoding so caches written by older builds (missing newer keys
|
||||
// such as `cryptographicIdentities` or the vouching fields) still load
|
||||
// instead of being discarded. Every field uses decodeIfPresent so a
|
||||
// missing key falls back to its default rather than throwing.
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
socialIdentities = try container.decodeIfPresent([String: SocialIdentity].self, forKey: .socialIdentities) ?? [:]
|
||||
nicknameIndex = try container.decodeIfPresent([String: Set<String>].self, forKey: .nicknameIndex) ?? [:]
|
||||
verifiedFingerprints = try container.decodeIfPresent(Set<String>.self, forKey: .verifiedFingerprints) ?? []
|
||||
lastInteractions = try container.decodeIfPresent([String: Date].self, forKey: .lastInteractions) ?? [:]
|
||||
blockedNostrPubkeys = try container.decodeIfPresent(Set<String>.self, forKey: .blockedNostrPubkeys) ?? []
|
||||
vouchesByVouchee = try container.decodeIfPresent([String: [VouchRecord]].self, forKey: .vouchesByVouchee)
|
||||
vouchBatchSentAt = try container.decodeIfPresent([String: Date].self, forKey: .vouchBatchSentAt)
|
||||
verifiedAt = try container.decodeIfPresent([String: Date].self, forKey: .verifiedAt)
|
||||
privateMediaCapableFingerprints = try container.decodeIfPresent(Set<String>.self, forKey: .privateMediaCapableFingerprints)
|
||||
authenticatedSigningKeysByFingerprint = try container.decodeIfPresent([String: Data].self, forKey: .authenticatedSigningKeysByFingerprint)
|
||||
cryptographicIdentities = try container.decodeIfPresent([String: CryptographicIdentity].self, forKey: .cryptographicIdentities) ?? [:]
|
||||
version = try container.decodeIfPresent(Int.self, forKey: .version) ?? 1
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
@@ -160,8 +160,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
// In-memory state
|
||||
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
|
||||
// Cryptographic identities (including pinned signing keys) live inside
|
||||
// `cache` so they persist across app restarts; see IdentityCache.
|
||||
private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
|
||||
private var cache: IdentityCache = IdentityCache()
|
||||
|
||||
// Thread safety
|
||||
@@ -169,21 +168,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
private let queueSpecificKey = DispatchSpecificKey<UInt8>()
|
||||
|
||||
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
|
||||
//
|
||||
// Persistence is SYNCHRONOUS: every mutating API runs its mutate + encrypt
|
||||
// + keychain write inside `queue.sync(flags: .barrier)`, so when the call
|
||||
// returns the write is already complete and NOTHING is left scheduled on
|
||||
// the queue. This is deliberate — a retained DispatchSourceTimer (the
|
||||
// original design) kept the dispatch machinery alive and prevented the
|
||||
// unit-test process from exiting, and fire-and-forget `queue.async(.barrier)`
|
||||
// (a later design) left a backlog of instrumented barrier saves still
|
||||
// draining when LLVM's `--enable-code-coverage` `atexit` handler dumped
|
||||
// `.profraw`, deadlocking the process at teardown on the constrained CI
|
||||
// runner. Synchronous persistence has zero outstanding dispatch at exit, so
|
||||
// neither failure mode is possible. `pendingSave` is now effectively always
|
||||
// false after any mutation (saveIdentityCache persists inline and clears
|
||||
// it); it remains only as a belt-and-suspenders flag read by `forceSave`
|
||||
// and `deinit`.
|
||||
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
|
||||
// than a retained DispatchSourceTimer: a lingering, never-cancelled timer
|
||||
// keeps the dispatch machinery alive and prevents the unit-test process from
|
||||
// exiting. (The original code used Timer.scheduledTimer on a GCD queue with
|
||||
// no run loop, so saves never actually fired.)
|
||||
private var pendingSave = false
|
||||
|
||||
// Encryption key
|
||||
@@ -244,22 +233,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Do NOT dispatch onto `queue` here. `deinit` can run on any thread
|
||||
// (including one draining `queue`), and the object is being
|
||||
// deallocated: a `queue.sync` risks a re-entrant same-queue wait
|
||||
// (deadlock) and a `queue.async` schedules work that resurrects `self`
|
||||
// and may not drain before process exit.
|
||||
//
|
||||
// A flush here is redundant anyway: every mutating API already
|
||||
// persists inline within its own barrier, so the keychain is already
|
||||
// up to date. As a queue-free best-effort belt-and-suspenders, only
|
||||
// flush if something is still pending. This is a direct read of
|
||||
// in-hand state — safe because a deallocating object has no other
|
||||
// live references, so nothing can be mutating `cache` concurrently.
|
||||
if pendingSave {
|
||||
pendingSave = false
|
||||
persist(snapshot: cache)
|
||||
}
|
||||
forceSave()
|
||||
}
|
||||
|
||||
// MARK: - Secure Loading/Saving
|
||||
@@ -284,27 +258,21 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the cache. Always invoked on `queue` under a barrier (its
|
||||
/// callers run inside `queue.sync(flags: .barrier)`), so `cache` is read
|
||||
/// while serialized. The encode + keychain write are done here (already on
|
||||
/// the exclusive barrier context), synchronously, so no separate hop is
|
||||
/// scheduled and nothing is left to keep the process alive.
|
||||
/// Persists the cache. Always invoked on `queue` under a barrier (its callers
|
||||
/// run inside `queue.async(.barrier)`), so it simply marks the cache dirty
|
||||
/// and persists it on the same serialized context — no timer, nothing left
|
||||
/// scheduled to keep the process alive.
|
||||
private func saveIdentityCache() {
|
||||
pendingSave = true
|
||||
// On the barrier context already: snapshot is trivially consistent.
|
||||
persist(snapshot: cache)
|
||||
pendingSave = false
|
||||
performSave()
|
||||
}
|
||||
|
||||
/// Encodes, seals, and writes a *snapshot* of the cache to the keychain.
|
||||
///
|
||||
/// Takes the cache by value so callers can capture a consistent snapshot
|
||||
/// under `queue` and then encode without holding it. Reading `cache`
|
||||
/// concurrently with a barrier writer would be a data race on the
|
||||
/// dictionary storage, which — because `JSONEncoder` walks that storage —
|
||||
/// can spin forever (observed as a CI test-suite hang), so the snapshot
|
||||
/// must be taken on `queue`, never off it.
|
||||
private func persist(snapshot: IdentityCache) {
|
||||
/// Writes the cache to the keychain. Must run on `queue` with exclusive
|
||||
/// (barrier) access.
|
||||
private func performSave() {
|
||||
guard pendingSave else { return }
|
||||
pendingSave = false
|
||||
|
||||
// Never persist under an ephemeral key — it would overwrite the real
|
||||
// cache with data the next launch cannot decrypt.
|
||||
guard !encryptionKeyIsEphemeral else {
|
||||
@@ -313,7 +281,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try JSONEncoder().encode(snapshot)
|
||||
let data = try JSONEncoder().encode(cache)
|
||||
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
|
||||
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
|
||||
if saved {
|
||||
@@ -324,26 +292,14 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
// Force a flush (for app-termination / lifecycle events — NOT from
|
||||
// `deinit`, which persists inline; see the deinit note). Every mutating
|
||||
// API already persists inline inside its own barrier via
|
||||
// `saveIdentityCache`, so by the time this is called the keychain is
|
||||
// already up to date and this is normally a no-op; it exists as a
|
||||
// belt-and-suspenders flush of any `pendingSave` left set.
|
||||
//
|
||||
// Runs synchronously inside a `queue.sync(flags: .barrier)`: the barrier
|
||||
// makes the `cache` read race-free (a plain off-queue read races in-flight
|
||||
// barrier writers — JSONEncoder walking a concurrently-mutated dictionary
|
||||
// can spin forever, which surfaced as a CI hang), and being synchronous it
|
||||
// leaves nothing scheduled to keep the process alive at teardown. Safe
|
||||
// against re-entrant deadlock because this is never invoked from `deinit`
|
||||
// (the only path that can run *on* `queue`).
|
||||
// Force immediate save (for app termination / lifecycle events). Mutations
|
||||
// already persist synchronously via saveIdentityCache, so this is normally a
|
||||
// no-op (performSave early-returns when nothing is pending). Runs directly on
|
||||
// the caller's thread — deliberately NOT a `queue.sync(barrier)`, which is
|
||||
// reachable from `deinit` and from async tests on the swift-concurrency
|
||||
// cooperative pool where a blocking barrier-sync can starve/deadlock it.
|
||||
func forceSave() {
|
||||
queue.sync(flags: .barrier) {
|
||||
guard pendingSave else { return }
|
||||
pendingSave = false
|
||||
persist(snapshot: cache)
|
||||
}
|
||||
performSave()
|
||||
}
|
||||
|
||||
// MARK: - Social Identity Management
|
||||
@@ -357,33 +313,15 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
// MARK: - Cryptographic Identities
|
||||
|
||||
/// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.
|
||||
///
|
||||
/// TOFU signing-key pinning: once a signing key has been persisted for a
|
||||
/// fingerprint, an update carrying a *different* signing key is refused in
|
||||
/// full (including the claimed-nickname update) and security-logged. This
|
||||
/// mirrors `BLEPeerRegistry.upsertVerifiedAnnounce` — without it, an
|
||||
/// attacker replaying a victim's noiseKey/peerID with their own signing
|
||||
/// key could overwrite the victim's persisted identity while the victim is
|
||||
/// offline or after an app restart. The refusal is permanent: there is
|
||||
/// currently no targeted in-app way to reset the pin (`setVerified` does
|
||||
/// not touch it). Recovering from a legitimate signing re-key requires the
|
||||
/// peer to establish a new noise identity (new peerID) or the local user
|
||||
/// to wipe all identity data (`clearAllIdentityData`, e.g. panic wipe).
|
||||
/// - Parameters:
|
||||
/// - fingerprint: SHA-256 hex of the Noise static public key
|
||||
/// - noisePublicKey: Noise static public key data
|
||||
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
|
||||
/// - claimedNickname: Optional latest claimed nickname to persist into social identity
|
||||
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
|
||||
queue.sync(flags: .barrier) {
|
||||
queue.async(flags: .barrier) {
|
||||
let now = Date()
|
||||
if var existing = self.cache.cryptographicIdentities[fingerprint] {
|
||||
if let pinnedSigningKey = existing.signingPublicKey,
|
||||
let announcedSigningKey = signingPublicKey,
|
||||
pinnedSigningKey != announcedSigningKey {
|
||||
SecureLogger.warning("🚨 Refusing to replace pinned signing key for \(fingerprint.prefix(8))… (possible impersonation attempt)", category: .security)
|
||||
return
|
||||
}
|
||||
if var existing = self.cryptographicIdentities[fingerprint] {
|
||||
// Update keys if changed
|
||||
if existing.publicKey != noisePublicKey {
|
||||
existing = CryptographicIdentity(
|
||||
@@ -392,11 +330,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
|
||||
firstSeen: existing.firstSeen
|
||||
)
|
||||
self.cache.cryptographicIdentities[fingerprint] = existing
|
||||
self.cryptographicIdentities[fingerprint] = existing
|
||||
} else {
|
||||
// Update signing key
|
||||
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
|
||||
self.cache.cryptographicIdentities[fingerprint] = existing
|
||||
self.cryptographicIdentities[fingerprint] = existing
|
||||
}
|
||||
// Persist updated state (already assigned in branches above)
|
||||
} else {
|
||||
@@ -407,7 +345,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
signingPublicKey: signingPublicKey,
|
||||
firstSeen: now
|
||||
)
|
||||
self.cache.cryptographicIdentities[fingerprint] = entry
|
||||
self.cryptographicIdentities[fingerprint] = entry
|
||||
}
|
||||
|
||||
// Optionally persist claimed nickname into social identity
|
||||
@@ -439,7 +377,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
queue.sync {
|
||||
// Defensive: ensure hex and correct length
|
||||
guard peerID.isShort else { return [] }
|
||||
return cache.cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,9 +420,9 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
let bindingChanged = bindings[fingerprint] != signingPublicKey
|
||||
bindings[fingerprint] = signingPublicKey
|
||||
self.cache.authenticatedSigningKeysByFingerprint = bindings
|
||||
if var cryptoIdentity = self.cache.cryptographicIdentities[fingerprint] {
|
||||
if var cryptoIdentity = self.cryptographicIdentities[fingerprint] {
|
||||
cryptoIdentity.signingPublicKey = signingPublicKey
|
||||
self.cache.cryptographicIdentities[fingerprint] = cryptoIdentity
|
||||
self.cryptographicIdentities[fingerprint] = cryptoIdentity
|
||||
}
|
||||
guard bindingChanged else { return }
|
||||
self.saveIdentityCache()
|
||||
@@ -504,7 +442,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
func updateSocialIdentity(_ identity: SocialIdentity) {
|
||||
queue.sync(flags: .barrier) {
|
||||
queue.async(flags: .barrier) {
|
||||
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
|
||||
self.cache.socialIdentities[identity.fingerprint] = identity
|
||||
|
||||
@@ -540,7 +478,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
func setFavorite(_ fingerprint: String, isFavorite: Bool) {
|
||||
queue.sync(flags: .barrier) {
|
||||
queue.async(flags: .barrier) {
|
||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||
identity.isFavorite = isFavorite
|
||||
self.cache.socialIdentities[fingerprint] = identity
|
||||
@@ -578,7 +516,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
|
||||
SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
|
||||
|
||||
queue.sync(flags: .barrier) {
|
||||
queue.async(flags: .barrier) {
|
||||
if var identity = self.cache.socialIdentities[fingerprint] {
|
||||
identity.isBlocked = isBlocked
|
||||
if isBlocked {
|
||||
@@ -612,7 +550,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
|
||||
let key = pubkeyHexLowercased.lowercased()
|
||||
queue.sync(flags: .barrier) {
|
||||
queue.async(flags: .barrier) {
|
||||
if isBlocked {
|
||||
self.cache.blockedNostrPubkeys.insert(key)
|
||||
} else {
|
||||
@@ -635,7 +573,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
|
||||
queue.sync(flags: .barrier) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions[peerID]?.handshakeState = state
|
||||
|
||||
// If handshake completed, update last interaction
|
||||
@@ -651,10 +589,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
func clearAllIdentityData() {
|
||||
SecureLogger.warning("Clearing all identity data", category: .security)
|
||||
|
||||
queue.sync(flags: .barrier) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.cache = IdentityCache()
|
||||
self.ephemeralSessions.removeAll()
|
||||
|
||||
self.cryptographicIdentities.removeAll()
|
||||
|
||||
// Delete from keychain
|
||||
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
|
||||
SecureLogger.logKeyOperation(.delete, keyType: "identity cache", success: deleted)
|
||||
@@ -662,7 +601,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
}
|
||||
|
||||
func removeEphemeralSession(peerID: PeerID) {
|
||||
queue.sync(flags: .barrier) {
|
||||
queue.async(flags: .barrier) {
|
||||
self.ephemeralSessions.removeValue(forKey: peerID)
|
||||
}
|
||||
}
|
||||
@@ -672,7 +611,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
func setVerified(fingerprint: String, verified: Bool) {
|
||||
SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
|
||||
|
||||
queue.sync(flags: .barrier) {
|
||||
queue.async(flags: .barrier) {
|
||||
if verified {
|
||||
self.cache.verifiedFingerprints.insert(fingerprint)
|
||||
var verifiedAt = self.cache.verifiedAt ?? [:]
|
||||
@@ -840,7 +779,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
||||
|
||||
/// The peer's announce-bound Ed25519 signing key, if seen this session.
|
||||
func signingPublicKey(forFingerprint fingerprint: String) -> Data? {
|
||||
queue.sync { cache.cryptographicIdentities[fingerprint]?.signingPublicKey }
|
||||
queue.sync { cryptographicIdentities[fingerprint]?.signingPublicKey }
|
||||
}
|
||||
|
||||
/// Verified fingerprints ordered most recently verified first (entries
|
||||
|
||||
@@ -72662,114 +72662,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"share_import.review.message" : {
|
||||
"comment" : "Explains that shared content replaces the named destination's composer and is not sent automatically",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "هل تريد استبدال مسودة %@ بهذا المحتوى؟ لن يتم إرسال أي شيء تلقائيًا." } },
|
||||
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "%@-এর খসড়াটি এই কনটেন্ট দিয়ে বদলাবেন? কিছুই স্বয়ংক্রিয়ভাবে পাঠানো হবে না।" } },
|
||||
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "Entwurf für %@ durch diesen Inhalt ersetzen? Es wird nichts automatisch gesendet." } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "Replace the draft for %@ with this content? Nothing will be sent automatically." } },
|
||||
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "¿Reemplazar el borrador de %@ con este contenido? No se enviará nada automáticamente." } },
|
||||
"fa" : { "stringUnit" : { "state" : "needs_review", "value" : "پیشنویس %@ با این محتوا جایگزین شود؟ چیزی بهطور خودکار ارسال نمیشود." } },
|
||||
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Palitan ng content na ito ang draft para sa %@? Walang awtomatikong ipapadala." } },
|
||||
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Remplacer le brouillon pour %@ par ce contenu ? Rien ne sera envoyé automatiquement." } },
|
||||
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "להחליף את הטיוטה עבור %@ בתוכן הזה? שום דבר לא יישלח אוטומטית." } },
|
||||
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ का ड्राफ़्ट इस सामग्री से बदलें? कुछ भी अपने आप नहीं भेजा जाएगा।" } },
|
||||
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "Ganti draf untuk %@ dengan konten ini? Tidak ada yang akan dikirim otomatis." } },
|
||||
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "Sostituire la bozza per %@ con questo contenuto? Nulla verrà inviato automaticamente." } },
|
||||
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ の下書きをこの内容で置き換えますか?自動的には送信されません。" } },
|
||||
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "%@의 초안을 이 콘텐츠로 바꾸시겠습니까? 자동으로 전송되지 않습니다." } },
|
||||
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Gantikan draf untuk %@ dengan kandungan ini? Tiada apa-apa akan dihantar secara automatik." } },
|
||||
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ को मस्यौदा यो सामग्रीले बदल्ने? केही पनि स्वचालित रूपमा पठाइने छैन।" } },
|
||||
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Concept voor %@ vervangen door deze inhoud? Er wordt niets automatisch verzonden." } },
|
||||
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Zastąpić szkic dla %@ tą treścią? Nic nie zostanie wysłane automatycznie." } },
|
||||
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Substituir o rascunho para %@ por este conteúdo? Nada será enviado automaticamente." } },
|
||||
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Substituir o rascunho de %@ por este conteúdo? Nada será enviado automaticamente." } },
|
||||
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Заменить черновик для %@ этим содержимым? Ничего не будет отправлено автоматически." } },
|
||||
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Ersätta utkastet för %@ med detta innehåll? Inget skickas automatiskt." } },
|
||||
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ க்கான வரைவைக் இந்த உள்ளடக்கத்தால் மாற்றவா? எதுவும் தானாக அனுப்பப்படாது." } },
|
||||
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "แทนที่ฉบับร่างสำหรับ %@ ด้วยเนื้อหานี้หรือไม่? จะไม่มีการส่งอัตโนมัติ" } },
|
||||
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ taslağı bu içerikle değiştirilsin mi? Hiçbir şey otomatik olarak gönderilmez." } },
|
||||
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Замінити чернетку для %@ цим вмістом? Нічого не буде надіслано автоматично." } },
|
||||
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "%@ کا مسودہ اس مواد سے بدلیں؟ کچھ بھی خودکار طور پر نہیں بھیجا جائے گا۔" } },
|
||||
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Thay bản nháp cho %@ bằng nội dung này? Không có gì được tự động gửi." } },
|
||||
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "要用此内容替换 %@ 的草稿吗?内容不会自动发送。" } },
|
||||
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "要用此內容取代 %@ 的草稿嗎?內容不會自動傳送。" } }
|
||||
}
|
||||
},
|
||||
"share_import.review.title" : {
|
||||
"comment" : "Title for reviewing content received from the share extension",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "مراجعة المحتوى المشترك" } },
|
||||
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "শেয়ার করা কনটেন্ট পর্যালোচনা করুন" } },
|
||||
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "Geteilte Inhalte prüfen" } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "Review shared content" } },
|
||||
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "Revisar contenido compartido" } },
|
||||
"fa" : { "stringUnit" : { "state" : "needs_review", "value" : "بازبینی محتوای همرسانیشده" } },
|
||||
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Suriin ang ibinahaging content" } },
|
||||
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Vérifier le contenu partagé" } },
|
||||
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "בדיקת תוכן משותף" } },
|
||||
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "शेयर की गई सामग्री की समीक्षा करें" } },
|
||||
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "Tinjau konten yang dibagikan" } },
|
||||
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "Controlla il contenuto condiviso" } },
|
||||
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "共有コンテンツを確認" } },
|
||||
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "공유 콘텐츠 검토" } },
|
||||
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Semak kandungan yang dikongsi" } },
|
||||
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "साझा सामग्री समीक्षा गर्नुहोस्" } },
|
||||
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Gedeelde inhoud bekijken" } },
|
||||
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Sprawdź udostępnioną treść" } },
|
||||
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Rever conteúdo partilhado" } },
|
||||
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Revisar conteúdo compartilhado" } },
|
||||
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Проверить общий контент" } },
|
||||
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Granska delat innehåll" } },
|
||||
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "பகிரப்பட்ட உள்ளடக்கத்தை மதிப்பாய்வு செய்" } },
|
||||
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "ตรวจสอบเนื้อหาที่แชร์" } },
|
||||
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "Paylaşılan içeriği gözden geçir" } },
|
||||
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Переглянути спільний вміст" } },
|
||||
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "شیئر کردہ مواد کا جائزہ لیں" } },
|
||||
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Xem lại nội dung được chia sẻ" } },
|
||||
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "查看共享内容" } },
|
||||
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "查看分享內容" } }
|
||||
}
|
||||
},
|
||||
"share_import.review.use_in_composer" : {
|
||||
"comment" : "Action that places reviewed shared content in the composer without sending it",
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "استخدام في مسودة الرسالة" } },
|
||||
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "বার্তার খসড়ায় ব্যবহার করুন" } },
|
||||
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "Im Nachrichtenentwurf verwenden" } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "Use in composer" } },
|
||||
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "Usar en el borrador" } },
|
||||
"fa" : { "stringUnit" : { "state" : "needs_review", "value" : "استفاده در پیشنویس پیام" } },
|
||||
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Gamitin sa draft" } },
|
||||
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Utiliser dans le brouillon" } },
|
||||
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "שימוש בטיוטה" } },
|
||||
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "संदेश के ड्राफ़्ट में उपयोग करें" } },
|
||||
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "Gunakan di draf" } },
|
||||
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "Usa nella bozza" } },
|
||||
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "下書きで使用" } },
|
||||
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "초안에 사용" } },
|
||||
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Gunakan dalam draf" } },
|
||||
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "सन्देशको मस्यौदामा प्रयोग गर्नुहोस्" } },
|
||||
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "In concept gebruiken" } },
|
||||
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Użyj w szkicu" } },
|
||||
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Usar no rascunho" } },
|
||||
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Usar no rascunho" } },
|
||||
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Использовать в черновике" } },
|
||||
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Använd i utkast" } },
|
||||
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "செய்தி வரைவில் பயன்படுத்து" } },
|
||||
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "ใช้ในฉบับร่าง" } },
|
||||
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "Taslakta kullan" } },
|
||||
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Використати в чернетці" } },
|
||||
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "پیغام کے مسودے میں استعمال کریں" } },
|
||||
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Dùng trong bản nháp" } },
|
||||
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "用于草稿" } },
|
||||
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "用於草稿" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.1"
|
||||
|
||||
@@ -36,6 +36,30 @@ enum NoiseSecurityConstants {
|
||||
|
||||
// Noise XX message 1 contains only the initiator's 32-byte ephemeral key.
|
||||
static let xxInitialMessageSize = 32
|
||||
|
||||
// Bounds an ordinary initiator whose message 1 or 2 is lost.
|
||||
static let ordinaryHandshakeTimeout: TimeInterval = 10
|
||||
|
||||
// Bounds the receive-only rollback quarantine created by an unauthenticated
|
||||
// inbound message 1. A lost message 3 must not strand outbound traffic.
|
||||
static let ordinaryResponderHandshakeTimeout: TimeInterval = 20
|
||||
|
||||
// A released client may immediately retry after both crossed initiators
|
||||
// yielded. Give that unilateral retry a brief head start before the
|
||||
// patched side spends its one bounded recovery.
|
||||
static let handshakeCollisionRecoveryDelay: TimeInterval = 0.2
|
||||
|
||||
// Rate-limited recovery remains actionable without spinning.
|
||||
static let handshakeRateLimitRecoveryDelay: TimeInterval = 60
|
||||
|
||||
// Covers only reordering between a winning message 3 and the losing
|
||||
// crossed message 1.
|
||||
static let recentInitiatorCompletionGracePeriod: TimeInterval = 1
|
||||
|
||||
// After unauthenticated responder rollback, reject another attempt long
|
||||
// enough that paced message 1 traffic cannot keep outbound paused. A
|
||||
// legitimate peer converges through the one manager-owned local retry.
|
||||
static let ordinaryReconnectRollbackCooldown: TimeInterval = 60
|
||||
|
||||
// Session timeout - sessions older than this should be renegotiated
|
||||
static let sessionTimeout: TimeInterval = 86400 // 24 hours
|
||||
|
||||
@@ -13,3 +13,9 @@ enum NoiseSessionError: Error, Equatable {
|
||||
case alreadyEstablished
|
||||
case peerIdentityMismatch
|
||||
}
|
||||
|
||||
/// The manager owns the exact attempt's one bounded recovery. Packet handling
|
||||
/// must not launch its historical second, immediate restart for this failure.
|
||||
struct NoiseManagedHandshakeFailure: Error {
|
||||
let underlying: Error
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,23 +32,6 @@ struct GeoRelayDirectoryDependencies {
|
||||
var retrySleep: (TimeInterval) async -> Void
|
||||
var activeNotificationName: Notification.Name?
|
||||
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 {
|
||||
@@ -61,16 +44,12 @@ private extension GeoRelayDirectoryDependencies {
|
||||
#else
|
||||
let activeNotificationName: Notification.Name? = nil
|
||||
#endif
|
||||
let validationPolicy = GeoRelayDirectoryValidationPolicy.live
|
||||
|
||||
return Self(
|
||||
userDefaults: .standard,
|
||||
notificationCenter: .default,
|
||||
now: Date.init,
|
||||
// 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")!,
|
||||
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
|
||||
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
|
||||
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
|
||||
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
|
||||
@@ -79,27 +58,7 @@ private extension GeoRelayDirectoryDependencies {
|
||||
makeFetchData: {
|
||||
let session = TorURLSession.shared.session
|
||||
return { request in
|
||||
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)
|
||||
}
|
||||
let (data, _) = try await session.data(for: request)
|
||||
return data
|
||||
}
|
||||
},
|
||||
@@ -117,11 +76,7 @@ private extension GeoRelayDirectoryDependencies {
|
||||
)
|
||||
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
// 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")
|
||||
return dir.appendingPathComponent("georelays_cache.csv")
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
@@ -139,8 +94,7 @@ private extension GeoRelayDirectoryDependencies {
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
},
|
||||
activeNotificationName: activeNotificationName,
|
||||
autoStart: true,
|
||||
validationPolicy: validationPolicy
|
||||
autoStart: true
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -171,7 +125,7 @@ final class GeoRelayDirectory {
|
||||
}
|
||||
|
||||
private enum DetachedFetchOutcome: Sendable {
|
||||
case success(entries: [Entry], csv: Data)
|
||||
case success(entries: [Entry], csv: String)
|
||||
case torNotReady
|
||||
case invalidData
|
||||
case network(String)
|
||||
@@ -258,8 +212,6 @@ final class GeoRelayDirectory {
|
||||
)
|
||||
let awaitTorReady = dependencies.awaitTorReady
|
||||
let fetchData = dependencies.makeFetchData()
|
||||
let validationPolicy = dependencies.validationPolicy
|
||||
let baselineEntries = Set(entries)
|
||||
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -267,9 +219,7 @@ final class GeoRelayDirectory {
|
||||
let outcome = await Self.fetchRemoteOutcome(
|
||||
request: request,
|
||||
awaitTorReady: awaitTorReady,
|
||||
fetchData: fetchData,
|
||||
validationPolicy: validationPolicy,
|
||||
baselineEntries: baselineEntries
|
||||
fetchData: fetchData
|
||||
)
|
||||
|
||||
switch outcome {
|
||||
@@ -288,9 +238,7 @@ final class GeoRelayDirectory {
|
||||
nonisolated private static func fetchRemoteOutcome(
|
||||
request: URLRequest,
|
||||
awaitTorReady: @escaping @Sendable () async -> Bool,
|
||||
fetchData: @escaping @Sendable (URLRequest) async throws -> Data,
|
||||
validationPolicy: GeoRelayDirectoryValidationPolicy,
|
||||
baselineEntries: Set<Entry>
|
||||
fetchData: @escaping @Sendable (URLRequest) async throws -> Data
|
||||
) async -> DetachedFetchOutcome {
|
||||
await Task.detached(priority: .utility) {
|
||||
let ready = await awaitTorReady()
|
||||
@@ -298,16 +246,16 @@ final class GeoRelayDirectory {
|
||||
|
||||
do {
|
||||
let data = try await fetchData(request)
|
||||
guard let parsed = Self.validatedEntries(
|
||||
from: data,
|
||||
policy: validationPolicy,
|
||||
minimumEntries: validationPolicy.minimumRemoteEntries,
|
||||
baselineEntries: baselineEntries
|
||||
) else {
|
||||
guard let text = String(data: data, encoding: .utf8) else {
|
||||
return .invalidData
|
||||
}
|
||||
|
||||
return .success(entries: parsed, csv: data)
|
||||
let parsed = Self.parseCSV(text)
|
||||
guard !parsed.isEmpty else {
|
||||
return .invalidData
|
||||
}
|
||||
|
||||
return .success(entries: parsed, csv: text)
|
||||
} catch {
|
||||
return .network(error.localizedDescription)
|
||||
}
|
||||
@@ -321,7 +269,7 @@ final class GeoRelayDirectory {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func handleFetchSuccess(entries parsed: [Entry], csv: Data) {
|
||||
private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
|
||||
entries = parsed
|
||||
persistCache(csv)
|
||||
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
|
||||
@@ -373,8 +321,9 @@ final class GeoRelayDirectory {
|
||||
cleanupState.retryTask = nil
|
||||
}
|
||||
|
||||
private func persistCache(_ data: Data) {
|
||||
private func persistCache(_ text: String) {
|
||||
guard let url = dependencies.cacheURL() else { return }
|
||||
guard let data = text.data(using: .utf8) else { return }
|
||||
do {
|
||||
try dependencies.writeData(data, url)
|
||||
} catch {
|
||||
@@ -387,12 +336,9 @@ final class GeoRelayDirectory {
|
||||
// Prefer cached file if present
|
||||
if let cache = dependencies.cacheURL(),
|
||||
let data = dependencies.readData(cache),
|
||||
let entries = Self.validatedEntries(
|
||||
from: data,
|
||||
policy: dependencies.validationPolicy,
|
||||
minimumEntries: 1
|
||||
) {
|
||||
return entries
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
|
||||
// Try bundled resource(s)
|
||||
@@ -400,157 +346,36 @@ final class GeoRelayDirectory {
|
||||
|
||||
for url in bundleCandidates {
|
||||
if let data = dependencies.readData(url),
|
||||
let entries = Self.validatedEntries(
|
||||
from: data,
|
||||
policy: dependencies.validationPolicy,
|
||||
minimumEntries: 1
|
||||
) {
|
||||
return entries
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
let arr = Self.parseCSV(text)
|
||||
if !arr.isEmpty { return arr }
|
||||
}
|
||||
}
|
||||
|
||||
// Try filesystem path (development/test)
|
||||
if let cwd = dependencies.currentDirectoryPath(),
|
||||
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
|
||||
let entries = Self.validatedEntries(
|
||||
from: data,
|
||||
policy: dependencies.validationPolicy,
|
||||
minimumEntries: 1
|
||||
) {
|
||||
return entries
|
||||
let text = String(data: data, encoding: .utf8) {
|
||||
return Self.parseCSV(text)
|
||||
}
|
||||
|
||||
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
|
||||
return []
|
||||
}
|
||||
|
||||
/// Parses the fixed three-column format as an all-or-nothing trust unit.
|
||||
/// One malformed or conflicting row rejects the complete dataset rather
|
||||
/// than silently shrinking or partially replacing the current directory.
|
||||
nonisolated static func validatedEntries(
|
||||
from data: Data,
|
||||
policy: GeoRelayDirectoryValidationPolicy,
|
||||
minimumEntries: Int,
|
||||
baselineEntries: Set<Entry>? = nil
|
||||
) -> [Entry]? {
|
||||
guard !data.isEmpty, data.count <= policy.maximumBytes,
|
||||
let text = String(data: data, encoding: .utf8),
|
||||
!text.hasPrefix("\u{feff}") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
nonisolated static func parseCSV(_ text: String) -> [Entry] {
|
||||
var result: Set<Entry> = []
|
||||
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
|
||||
for (idx, raw) in lines.enumerated() {
|
||||
guard let line = raw.trimmedOrNilIfEmpty else { continue }
|
||||
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
||||
let parts = line.split(separator: ",").map { $0.trimmed }
|
||||
guard parts.count >= 3 else { continue }
|
||||
guard let host = NostrRelayURL.directoryAddress(parts[0]) else { continue }
|
||||
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
||||
result.insert(Entry(host: host, lat: lat, lon: lon))
|
||||
}
|
||||
|
||||
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
|
||||
return Array(result)
|
||||
}
|
||||
|
||||
// MARK: - Observers & Timers
|
||||
|
||||
@@ -48,14 +48,7 @@ private struct URLSessionAdapter: NostrRelaySessionProtocol {
|
||||
let base: URLSession
|
||||
|
||||
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
|
||||
let task = base.webSocketTask(with: url)
|
||||
// Byte bound per inbound frame; without it the per-relay buffer cap
|
||||
// (nostrInboundPerRelayBufferCap) bounds FRAMES but not BYTES, and a
|
||||
// hostile relay could pile up cap × 1 MiB (URLSession default) per
|
||||
// connection. See TransportConfig.nostrInboundMaxFrameBytes for the
|
||||
// sizing rationale. Oversized frames fail the receive with an error.
|
||||
task.maximumMessageSize = TransportConfig.nostrInboundMaxFrameBytes
|
||||
return URLSessionWebSocketTaskAdapter(base: task)
|
||||
URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,10 +106,7 @@ private extension NostrRelayManagerDependencies {
|
||||
@MainActor
|
||||
final class NostrRelayManager: ObservableObject {
|
||||
static let shared = NostrRelayManager()
|
||||
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info.
|
||||
// Entries are removed only on OK acks (or panic wipe); relays that never
|
||||
// ack leave entries behind for the process lifetime. Observability-only
|
||||
// state, bounded in practice by outbound DM volume.
|
||||
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
|
||||
private(set) static var pendingGiftWrapIDs = Set<String>()
|
||||
static func registerPendingGiftWrap(id: String) {
|
||||
pendingGiftWrapIDs.insert(id)
|
||||
@@ -249,33 +239,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
|
||||
private var connectionGeneration: Int = 0
|
||||
|
||||
// Per-relay off-main inbound pipeline: raw socket frames are parsed and
|
||||
// Schnorr-verified in arrival order OFF the main actor (this is the single
|
||||
// signature verification for the whole inbound path — downstream handlers
|
||||
// receive only verified events), then hop back to the main actor for dedup
|
||||
// recording and handler dispatch.
|
||||
//
|
||||
// Each relay connection owns its OWN AsyncStream + consumer task, so N
|
||||
// relays verify in parallel while every relay's frames stay in arrival
|
||||
// order (a single subscription's events for a relay all arrive on that
|
||||
// relay's socket, so per-relay ordering preserves per-subscription
|
||||
// ordering). A burst of EVENT frames from one busy/malicious relay only
|
||||
// blocks that relay's own verification backlog — DMs, OKs, EOSEs, and
|
||||
// events from every other relay keep flowing on their own pipelines.
|
||||
//
|
||||
// Each stream is bounded (`.bufferingNewest`) so a relay flooding faster
|
||||
// than its verification drains sheds its own oldest frames instead of
|
||||
// growing memory without bound; it can never starve other relays.
|
||||
//
|
||||
// Continuations live in a lock-guarded, `Sendable` router (see
|
||||
// `InboundFrameRouter` at file scope) so the raw socket receive callback
|
||||
// (which is NOT main-actor isolated) can route a frame to the right relay
|
||||
// stream without a per-frame main hop, while the main actor owns pipeline
|
||||
// creation/teardown. The expensive work (Schnorr verify) is what runs
|
||||
// off-main; the yield stays cheap.
|
||||
private let inboundRouter = InboundFrameRouter()
|
||||
|
||||
|
||||
init() {
|
||||
self.dependencies = .live()
|
||||
hasMutualFavorites = dependencies.hasMutualFavorites()
|
||||
@@ -329,70 +293,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
deinit {
|
||||
inboundRouter.finishAll()
|
||||
}
|
||||
|
||||
/// Ensure a serial off-main consumer pipeline exists for a relay. Called on
|
||||
/// the main actor when a socket is (re)armed for receiving. Idempotent.
|
||||
///
|
||||
/// Ordering within the relay is deliberate and security/performance-critical:
|
||||
/// 1. `precheckInboundEvent` (main hop): per-relay stats plus a cheap
|
||||
/// duplicate LOOKUP — duplicate fan-in from multiple relays dominates
|
||||
/// real traffic and must never pay for Schnorr verification.
|
||||
/// 2. `isValidSignature()` runs here, off the main actor — the ONLY
|
||||
/// signature verification on the inbound path (JSON re-serialization +
|
||||
/// SHA-256 + secp256k1 Schnorr per event).
|
||||
/// 3. `deliverVerifiedInboundEvent` (main hop): authoritative
|
||||
/// check-and-RECORD plus handler dispatch. Recording only after
|
||||
/// verification means a forged-signature copy can never poison the
|
||||
/// dedup cache and suppress the genuine event.
|
||||
private func ensureRelayInboundPipeline(for relayUrl: String) {
|
||||
let started = inboundRouter.startPipeline(for: relayUrl) { [weak self] stream in
|
||||
Task.detached(priority: .userInitiated) {
|
||||
for await frame in stream {
|
||||
guard let parsed = ParsedInbound(frame.message) else { continue }
|
||||
guard let self else { return }
|
||||
switch parsed {
|
||||
case .event(let subId, let event):
|
||||
guard await self.precheckInboundEvent(
|
||||
subscriptionID: subId,
|
||||
eventID: event.id,
|
||||
relayUrl: relayUrl
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
guard event.isValidSignature() else {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
|
||||
category: .session
|
||||
)
|
||||
continue
|
||||
}
|
||||
await self.deliverVerifiedInboundEvent(subscriptionID: subId, event: event, from: relayUrl)
|
||||
case .eose, .ok, .notice:
|
||||
await self.handleParsedMessage(parsed, from: relayUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if started {
|
||||
SecureLogger.debug("🧵 Started inbound verify pipeline for \(relayUrl)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tear down a relay's inbound pipeline (socket gone or state wiped). The
|
||||
/// consumer drains any already-buffered frames before finishing, so
|
||||
/// in-flight verified events are still delivered.
|
||||
private func teardownRelayInboundPipeline(for relayUrl: String) {
|
||||
inboundRouter.finishPipeline(for: relayUrl)
|
||||
}
|
||||
|
||||
private func teardownAllRelayInboundPipelines() {
|
||||
inboundRouter.finishAll()
|
||||
}
|
||||
|
||||
|
||||
/// Connect to all configured relays
|
||||
func connect() {
|
||||
// Global network policy gate
|
||||
@@ -407,8 +308,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeAll()
|
||||
// Sockets are gone; drop every relay's inbound verify pipeline.
|
||||
teardownAllRelayInboundPipelines()
|
||||
markRelaySocketsClosed(resetState: false)
|
||||
// Sockets are gone, so per-relay subscription state is cleared — but
|
||||
// durable intent (subscriptionRequestState, messageHandlers, parked
|
||||
@@ -441,7 +340,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeAll()
|
||||
teardownAllRelayInboundPipelines()
|
||||
markRelaySocketsClosed(resetState: true)
|
||||
subscriptions.removeAll()
|
||||
pendingSubscriptions.removeAll()
|
||||
@@ -783,7 +681,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
connection.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeValue(forKey: url)
|
||||
teardownRelayInboundPipeline(for: url)
|
||||
subscriptions.removeValue(forKey: url)
|
||||
pendingSubscriptions.removeValue(forKey: url)
|
||||
}
|
||||
@@ -1123,11 +1020,7 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
connections[urlString] = task
|
||||
task.resume()
|
||||
|
||||
// Bring up this relay's own serial verify pipeline before arming the
|
||||
// socket, so inbound frames have somewhere to land.
|
||||
ensureRelayInboundPipeline(for: urlString)
|
||||
|
||||
|
||||
// Start receiving messages
|
||||
receiveMessage(from: task, relayUrl: urlString)
|
||||
|
||||
@@ -1202,14 +1095,15 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
switch result {
|
||||
case .success(let message):
|
||||
// Hand the raw frame to this relay's serial inbound pipeline:
|
||||
// parsing and signature verification run off-main, in arrival
|
||||
// order, independently of every other relay's pipeline. Routing
|
||||
// through the lock-guarded router keeps this off the main actor
|
||||
// (no per-frame main hop).
|
||||
self.inboundRouter.yield(InboundFrame(message: message), to: relayUrl)
|
||||
|
||||
|
||||
// Parse off-main to reduce UI jank, then hop back for state updates
|
||||
Task.detached(priority: .utility) {
|
||||
guard let parsed = ParsedInbound(message) else { return }
|
||||
await MainActor.run {
|
||||
guard self.connections[relayUrl] === task else { return }
|
||||
self.handleParsedMessage(parsed, from: relayUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// Continue receiving
|
||||
Task { @MainActor in
|
||||
guard self.connections[relayUrl] === task else { return }
|
||||
@@ -1228,55 +1122,35 @@ final class NostrRelayManager: ObservableObject {
|
||||
// Note: declared at file scope below to avoid MainActor isolation inside this class
|
||||
// and keep parsing off the main actor.
|
||||
|
||||
/// First main-actor hop for an inbound EVENT: per-relay stats plus a cheap
|
||||
/// duplicate LOOKUP (no recording) so duplicate fan-in from multiple
|
||||
/// relays never pays for Schnorr verification. Recording happens only
|
||||
/// after the signature verifies (`deliverVerifiedInboundEvent`), so a
|
||||
/// forged-signature copy can never poison the dedup cache and suppress
|
||||
/// the genuine event.
|
||||
private func precheckInboundEvent(subscriptionID: String, eventID: String, relayUrl: String) -> Bool {
|
||||
if let index = relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||
relays[index].messagesReceived += 1
|
||||
}
|
||||
guard !eventID.isEmpty else { return true }
|
||||
let key = InboundEventKey(subscriptionID: subscriptionID, eventID: eventID)
|
||||
if recentInboundEventKeys.contains(key) {
|
||||
recordDuplicateInboundEventDrop(subscriptionID: subscriptionID)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Second main-actor hop, after off-main signature verification:
|
||||
/// authoritative check-and-record (the serial pipeline means the same
|
||||
/// event is never in flight twice, but the record must stay atomic with
|
||||
/// delivery) and handler dispatch.
|
||||
private func deliverVerifiedInboundEvent(subscriptionID subId: String, event: NostrEvent, from relayUrl: String) {
|
||||
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
|
||||
return
|
||||
}
|
||||
if event.kind != 1059 {
|
||||
// Per-event logging floods dev builds in busy geohashes; sample it.
|
||||
inboundEventLogCount += 1
|
||||
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
||||
SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
|
||||
}
|
||||
}
|
||||
if let handler = self.messageHandlers[subId] {
|
||||
handler(event)
|
||||
} else {
|
||||
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle parsed non-EVENT messages on MainActor (state updates and handlers)
|
||||
// Handle parsed message on MainActor (state updates and handlers)
|
||||
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
|
||||
switch parsed {
|
||||
case .event:
|
||||
// Events flow through the serial inbound pipeline (precheck →
|
||||
// off-main signature verification → deliverVerifiedInboundEvent)
|
||||
// and never reach this fallback.
|
||||
assertionFailure("inbound EVENT bypassed the verified pipeline")
|
||||
case .event(let subId, let event):
|
||||
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||
self.relays[index].messagesReceived += 1
|
||||
}
|
||||
guard event.isValidSignature() else {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
|
||||
category: .session
|
||||
)
|
||||
return
|
||||
}
|
||||
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
|
||||
return
|
||||
}
|
||||
if event.kind != 1059 {
|
||||
// Per-event logging floods dev builds in busy geohashes; sample it.
|
||||
inboundEventLogCount += 1
|
||||
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
||||
SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
|
||||
}
|
||||
}
|
||||
if let handler = self.messageHandlers[subId] {
|
||||
handler(event)
|
||||
} else {
|
||||
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
|
||||
}
|
||||
case .eose(let subId):
|
||||
if var tracker = eoseTrackers[subId] {
|
||||
// An EOSE proves the relay received the REQ even if the local
|
||||
@@ -1411,7 +1285,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
) {
|
||||
if let connection, connections[relayUrl] !== connection { return }
|
||||
connections.removeValue(forKey: relayUrl)
|
||||
teardownRelayInboundPipeline(for: relayUrl)
|
||||
subscriptions.removeValue(forKey: relayUrl)
|
||||
let awaitingConfirmation = confirmedSends.compactMap { eventID, state in
|
||||
state.awaitingRelays.contains(relayUrl) ? eventID : nil
|
||||
@@ -1506,9 +1379,8 @@ final class NostrRelayManager: ObservableObject {
|
||||
if let connection = connections[normalizedRelayUrl] {
|
||||
connection.cancel(with: .goingAway, reason: nil)
|
||||
connections.removeValue(forKey: normalizedRelayUrl)
|
||||
teardownRelayInboundPipeline(for: normalizedRelayUrl)
|
||||
}
|
||||
|
||||
|
||||
// Attempt immediate reconnection
|
||||
connectToRelay(normalizedRelayUrl)
|
||||
}
|
||||
@@ -1601,77 +1473,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
// MARK: - Off-main inbound parsing helpers (file scope, non-isolated)
|
||||
|
||||
/// A single raw socket frame awaiting off-main parse + Schnorr verification.
|
||||
private struct InboundFrame: Sendable {
|
||||
let message: URLSessionWebSocketTask.Message
|
||||
}
|
||||
|
||||
/// Lock-guarded registry of per-relay inbound streams.
|
||||
///
|
||||
/// The raw WebSocket receive callback is not main-actor isolated, so it needs a
|
||||
/// `Sendable` path to route a frame to the correct relay's stream without a
|
||||
/// per-frame hop onto the main actor. Pipeline lifecycle (start/finish) is
|
||||
/// driven from the main actor; frame delivery (`yield`) can come from any
|
||||
/// thread. All access is serialized by a single lock — contention is negligible
|
||||
/// because the guarded critical section is only a dictionary lookup + yield.
|
||||
private final class InboundFrameRouter: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var continuations: [String: AsyncStream<InboundFrame>.Continuation] = [:]
|
||||
private var tasks: [String: Task<Void, Never>] = [:]
|
||||
|
||||
/// Start a relay's stream + consumer if one does not already exist.
|
||||
/// Returns true when a new pipeline was created. The bounded
|
||||
/// `.bufferingNewest` policy makes a single relay shed its OWN oldest
|
||||
/// frames under a flood, never other relays' frames. Buffered memory per
|
||||
/// relay is bounded (not eliminated) at the frame cap times the per-frame
|
||||
/// byte cap (`maximumMessageSize`) — see TransportConfig.
|
||||
func startPipeline(
|
||||
for relayUrl: String,
|
||||
makeConsumer: (AsyncStream<InboundFrame>) -> Task<Void, Never>
|
||||
) -> Bool {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if continuations[relayUrl] != nil { return false }
|
||||
let (stream, continuation) = AsyncStream<InboundFrame>.makeStream(
|
||||
bufferingPolicy: .bufferingNewest(TransportConfig.nostrInboundPerRelayBufferCap)
|
||||
)
|
||||
continuations[relayUrl] = continuation
|
||||
tasks[relayUrl] = makeConsumer(stream)
|
||||
return true
|
||||
}
|
||||
|
||||
/// Route a frame to a relay's stream. No-op if the relay has no live
|
||||
/// pipeline (socket already torn down) — the frame is simply dropped, which
|
||||
/// is safe for best-effort Nostr inbound.
|
||||
func yield(_ frame: InboundFrame, to relayUrl: String) {
|
||||
lock.lock()
|
||||
let continuation = continuations[relayUrl]
|
||||
lock.unlock()
|
||||
continuation?.yield(frame)
|
||||
}
|
||||
|
||||
/// Finish a relay's stream. The consumer drains any already-buffered frames
|
||||
/// before exiting, so in-flight verified events are still delivered.
|
||||
func finishPipeline(for relayUrl: String) {
|
||||
lock.lock()
|
||||
let continuation = continuations.removeValue(forKey: relayUrl)
|
||||
tasks.removeValue(forKey: relayUrl)
|
||||
lock.unlock()
|
||||
continuation?.finish()
|
||||
}
|
||||
|
||||
func finishAll() {
|
||||
lock.lock()
|
||||
let allContinuations = continuations
|
||||
continuations.removeAll()
|
||||
tasks.removeAll()
|
||||
lock.unlock()
|
||||
for continuation in allContinuations.values {
|
||||
continuation.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum ParsedInbound {
|
||||
case event(subId: String, event: NostrEvent)
|
||||
case ok(eventId: String, success: Bool, reason: String)
|
||||
|
||||
@@ -39,4 +39,13 @@ enum NostrRelayURL {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,3 +154,90 @@ struct BitchatFilePacket {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire-compatible identity for private media exchanged by clients using the
|
||||
/// current iOS entropy-bearing filenames, without extending the deployed file
|
||||
/// TLV. Android clients reject unknown file tags, so eligible senders and
|
||||
/// receivers derive the receipt key from fields already on the wire.
|
||||
///
|
||||
/// Locally-created image and voice-note filenames contain a UUID or live-voice
|
||||
/// burst ID. Including the normalized direction keeps a reused filename
|
||||
/// distinct across chats while allowing short and full Noise-key peer IDs to
|
||||
/// converge. Android and older-iOS timestamp-only names remain ineligible and
|
||||
/// retain their legacy random local IDs (transfer-compatible, no receipts).
|
||||
enum PrivateMediaMessageIdentity {
|
||||
private static let domain = Data("bitchat-private-media-message-v1".utf8)
|
||||
private static let idPrefix = "media-"
|
||||
private static let digestHexLength = 32
|
||||
|
||||
static func isStableID(_ candidate: String) -> Bool {
|
||||
guard candidate.hasPrefix(idPrefix) else { return false }
|
||||
let digest = candidate.dropFirst(idPrefix.count)
|
||||
guard digest.utf8.count == digestHexLength else { return false }
|
||||
return digest.utf8.allSatisfy { byte in
|
||||
(UInt8(ascii: "0")...UInt8(ascii: "9")).contains(byte)
|
||||
|| (UInt8(ascii: "a")...UInt8(ascii: "f")).contains(byte)
|
||||
}
|
||||
}
|
||||
|
||||
static func stableID(
|
||||
senderPeerID: PeerID,
|
||||
recipientPeerID: PeerID,
|
||||
fileName: String?
|
||||
) -> String? {
|
||||
guard let fileName, !fileName.isEmpty else { return nil }
|
||||
let leafName = (fileName as NSString).lastPathComponent
|
||||
guard leafName == fileName else { return nil }
|
||||
|
||||
let path = leafName as NSString
|
||||
let stem = path.deletingPathExtension
|
||||
let fileExtension = path.pathExtension.lowercased()
|
||||
switch true {
|
||||
case stem.hasPrefix("img_"):
|
||||
guard fileExtension == "jpg" || fileExtension == "jpeg" else { return nil }
|
||||
case stem.hasPrefix("voice_"):
|
||||
guard fileExtension == "m4a" else { return nil }
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
let entropyToken = stem.split(separator: "_").last.map(String.init)
|
||||
let hasUUIDEntropy = entropyToken.flatMap(UUID.init(uuidString:)) != nil
|
||||
let voiceBurstID = stem.hasPrefix("voice_")
|
||||
? String(stem.dropFirst("voice_".count))
|
||||
: ""
|
||||
let hasBurstEntropy = voiceBurstID.count == 16
|
||||
&& voiceBurstID.allSatisfy(\.isHexDigit)
|
||||
guard hasUUIDEntropy || hasBurstEntropy else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let fields = [
|
||||
Data(senderPeerID.toShort().bare.utf8),
|
||||
Data(recipientPeerID.toShort().bare.utf8),
|
||||
Data(leafName.utf8)
|
||||
]
|
||||
var input = domain
|
||||
for field in fields {
|
||||
guard let length = UInt32(exactly: field.count) else { return nil }
|
||||
var bigEndianLength = length.bigEndian
|
||||
withUnsafeBytes(of: &bigEndianLength) {
|
||||
input.append(contentsOf: $0)
|
||||
}
|
||||
input.append(field)
|
||||
}
|
||||
|
||||
return "\(idPrefix)\(input.sha256Hex().prefix(digestHexLength))"
|
||||
}
|
||||
|
||||
static func stableID(
|
||||
for packet: BitchatFilePacket,
|
||||
senderPeerID: PeerID,
|
||||
recipientPeerID: PeerID
|
||||
) -> String? {
|
||||
stableID(
|
||||
senderPeerID: senderPeerID,
|
||||
recipientPeerID: recipientPeerID,
|
||||
fileName: packet.fileName
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,5 +3,11 @@ import BitFoundation
|
||||
extension PeerCapabilities {
|
||||
/// Capabilities this build advertises in its announce packets.
|
||||
/// Each feature adds its bit here when it ships.
|
||||
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups, .privateMedia]
|
||||
static let localSupported: PeerCapabilities = [
|
||||
.vouch,
|
||||
.prekeys,
|
||||
.groups,
|
||||
.privateMedia,
|
||||
.privateMediaReceipts
|
||||
]
|
||||
}
|
||||
|
||||
@@ -14,14 +14,8 @@ struct BLEAnnounceHandlerEnvironment {
|
||||
let messageTTL: UInt8
|
||||
/// Current time source.
|
||||
let now: () -> Date
|
||||
/// Noise and signing public keys already recorded for the peer, if any
|
||||
/// (single registry read so both come from one consistent snapshot).
|
||||
let existingPeerKeys: (PeerID) -> (noisePublicKey: Data?, signingPublicKey: Data?)
|
||||
/// Signing key from the persisted cryptographic identity for the peer, if
|
||||
/// any. Registry pins do not survive app restarts or offline-peer
|
||||
/// eviction; this fallback keeps the TOFU signing-key pin effective for
|
||||
/// returning peers.
|
||||
let persistedSigningPublicKey: (PeerID) -> Data?
|
||||
/// Noise public key already recorded for the peer, if any (registry read).
|
||||
let existingNoisePublicKey: (PeerID) -> Data?
|
||||
/// Ed25519 key previously bound to this Noise identity by an authenticated
|
||||
/// peer-state payload, if any (persistent identity-state read).
|
||||
let authenticatedSigningPublicKey: (_ noisePublicKey: Data) -> Data?
|
||||
@@ -38,15 +32,13 @@ struct BLEAnnounceHandlerEnvironment {
|
||||
/// Runs the registry mutation phase under the collections barrier.
|
||||
let withRegistryBarrier: (() -> Void) -> Void
|
||||
/// Upserts the verified announce into the peer registry.
|
||||
/// Returns `nil` when the registry refuses the announce because it carries
|
||||
/// a signing key different from the one already pinned for this peer.
|
||||
/// Must only be called from inside `withRegistryBarrier`.
|
||||
let upsertVerifiedAnnounce: (
|
||||
_ peerID: PeerID,
|
||||
_ announcement: AnnouncementPacket,
|
||||
_ isConnected: Bool,
|
||||
_ now: Date
|
||||
) -> BLEPeerAnnounceUpdate?
|
||||
) -> BLEPeerAnnounceUpdate
|
||||
/// Debounced reconnect-log decision.
|
||||
/// Must only be called from inside `withRegistryBarrier`.
|
||||
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
|
||||
@@ -126,16 +118,7 @@ final class BLEAnnounceHandler {
|
||||
// Suppress announce logs to reduce noise
|
||||
|
||||
// Precompute signature verification outside barrier to reduce contention
|
||||
var existingPeerKeys = env.existingPeerKeys(peerID)
|
||||
if existingPeerKeys.signingPublicKey == nil {
|
||||
// The registry entry (and its signing-key pin) is dropped on app
|
||||
// restart and offline-peer eviction, but the persisted
|
||||
// cryptographic identity survives both. Fall back to it so a
|
||||
// returning peer is not treated as first contact — otherwise an
|
||||
// attacker could replay the peer's noiseKey/peerID with their own
|
||||
// signing key and re-pin the identity (TOFU downgrade).
|
||||
existingPeerKeys.signingPublicKey = env.persistedSigningPublicKey(peerID)
|
||||
}
|
||||
let existingNoisePublicKey = env.existingNoisePublicKey(peerID)
|
||||
let hasSignature = packet.signature != nil
|
||||
let signatureValid: Bool
|
||||
if hasSignature {
|
||||
@@ -149,9 +132,8 @@ final class BLEAnnounceHandler {
|
||||
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
|
||||
hasSignature: hasSignature,
|
||||
signatureValid: signatureValid,
|
||||
existingNoisePublicKey: existingPeerKeys.noisePublicKey,
|
||||
existingNoisePublicKey: existingNoisePublicKey,
|
||||
announcedNoisePublicKey: announcement.noisePublicKey,
|
||||
existingSigningPublicKey: existingPeerKeys.signingPublicKey,
|
||||
authenticatedSigningPublicKey: env.authenticatedSigningPublicKey(
|
||||
announcement.noisePublicKey
|
||||
),
|
||||
@@ -160,16 +142,13 @@ final class BLEAnnounceHandler {
|
||||
if case .reject(.keyMismatch) = trustDecision {
|
||||
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
|
||||
}
|
||||
if case .reject(.signingKeyMismatch) = trustDecision {
|
||||
SecureLogger.warning("🚨 Announce signing-key mismatch for \(peerID.id.prefix(8))… — refusing to replace pinned signing key (possible impersonation attempt)", category: .security)
|
||||
}
|
||||
if case .reject(.authenticatedSigningKeyMismatch) = trustDecision {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Announce signing-key replacement rejected for Noise-authenticated peer \(peerID.id.prefix(8))…",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
var verifiedAnnounce = trustDecision.isVerified
|
||||
let verifiedAnnounce = trustDecision.isVerified
|
||||
|
||||
var isNewPeer = false
|
||||
var isReconnectedPeer = false
|
||||
@@ -206,22 +185,12 @@ final class BLEAnnounceHandler {
|
||||
return
|
||||
}
|
||||
|
||||
// The registry re-checks the signing-key pin inside the barrier.
|
||||
// The pre-barrier trust check reads the registry outside the
|
||||
// barrier, so this closes the race where two announces for the
|
||||
// same peer are evaluated concurrently.
|
||||
guard let update = env.upsertVerifiedAnnounce(
|
||||
let update = env.upsertVerifiedAnnounce(
|
||||
peerID,
|
||||
announcement,
|
||||
hasPeripheralConnection || hasCentralSubscription || (isDirectAnnounce && !linkBoundToOtherPeer),
|
||||
now
|
||||
) else {
|
||||
SecureLogger.warning("🚨 Registry refused announce for \(peerID.id.prefix(8))… — signing key differs from pinned key", category: .security)
|
||||
verifiedAnnounce = false
|
||||
isNewPeer = false
|
||||
isReconnectedPeer = false
|
||||
return
|
||||
}
|
||||
)
|
||||
isNewPeer = update.isNewPeer
|
||||
isReconnectedPeer = update.wasDisconnected
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@ enum BLEAnnounceTrustRejection: Equatable {
|
||||
case missingSignature
|
||||
case invalidSignature
|
||||
case keyMismatch
|
||||
case signingKeyMismatch
|
||||
case authenticatedSigningKeyMismatch
|
||||
}
|
||||
|
||||
@@ -75,33 +74,18 @@ enum BLEAnnounceTrustPolicy {
|
||||
signatureValid: Bool,
|
||||
existingNoisePublicKey: Data?,
|
||||
announcedNoisePublicKey: Data,
|
||||
existingSigningPublicKey: Data? = nil,
|
||||
authenticatedSigningPublicKey: Data? = nil,
|
||||
announcedSigningPublicKey: Data
|
||||
announcedSigningPublicKey: Data? = nil
|
||||
) -> BLEAnnounceTrustDecision {
|
||||
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
||||
return .reject(.keyMismatch)
|
||||
}
|
||||
|
||||
// Strongest binding first: an Ed25519 key bound to this Noise identity
|
||||
// inside an authenticated Noise session can never be replaced by a
|
||||
// merely self-signed announce.
|
||||
if let authenticatedSigningPublicKey,
|
||||
announcedSigningPublicKey != authenticatedSigningPublicKey {
|
||||
return .reject(.authenticatedSigningKeyMismatch)
|
||||
}
|
||||
|
||||
// TOFU signing-key pinning. The packet signature only proves the
|
||||
// announce is self-consistent — it is verified against the Ed25519 key
|
||||
// carried *inside the same announce*. Since peerIDs derive from the
|
||||
// broadcast (public) noise key, an attacker can replay a victim's
|
||||
// peerID+noiseKey with their own signing key and a valid
|
||||
// self-signature. Once we have bound a signing key to this peer,
|
||||
// refuse to silently replace it.
|
||||
if let existingSigningPublicKey, existingSigningPublicKey != announcedSigningPublicKey {
|
||||
return .reject(.signingKeyMismatch)
|
||||
}
|
||||
|
||||
guard hasSignature else {
|
||||
return .reject(.missingSignature)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
/// 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()
|
||||
struct BLEAnnounceThrottle {
|
||||
private var lastSent: Date
|
||||
private let normalMinimumInterval: TimeInterval
|
||||
private let forcedMinimumInterval: TimeInterval
|
||||
@@ -23,18 +16,16 @@ final class BLEAnnounceThrottle: @unchecked Sendable {
|
||||
}
|
||||
|
||||
func elapsed(since now: Date) -> TimeInterval {
|
||||
lock.withLock { now.timeIntervalSince(lastSent) }
|
||||
now.timeIntervalSince(lastSent)
|
||||
}
|
||||
|
||||
func shouldSend(force: Bool, now: Date) -> Bool {
|
||||
lock.withLock {
|
||||
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
|
||||
guard now.timeIntervalSince(lastSent) >= minimumInterval else {
|
||||
return false
|
||||
}
|
||||
|
||||
lastSent = now
|
||||
return true
|
||||
mutating func shouldSend(force: Bool, now: Date) -> Bool {
|
||||
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
|
||||
guard elapsed(since: now) >= minimumInterval else {
|
||||
return false
|
||||
}
|
||||
|
||||
lastSent = now
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,10 +32,79 @@ struct BLEFileTransferHandlerEnvironment {
|
||||
_ fallbackExtension: String?,
|
||||
_ defaultPrefix: String
|
||||
) -> URL?
|
||||
/// Resolves the durable receiver decision for a stable private-media ID.
|
||||
let privateMediaReceiptState: (
|
||||
_ messageID: String
|
||||
) -> BLEPrivateMediaReceiptState
|
||||
/// Atomically records a stable private-media ID after the payload save.
|
||||
let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool
|
||||
/// Rolls back a saved payload when its durable receipt commit fails.
|
||||
let removeIncomingFile: (_ storedURL: URL) -> Void
|
||||
/// Checks the authenticated sender before any private-media disk work.
|
||||
let isPrivateMediaSenderBlocked: (PeerID) -> Bool
|
||||
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
||||
let updatePeerLastSeen: (PeerID) -> Void
|
||||
/// Delivers `.messageReceived` to the UI as one main-actor hop.
|
||||
let deliverMessage: (BitchatMessage) -> Void
|
||||
/// Acknowledges stable private media only after its synchronous
|
||||
/// conversation delivery has completed.
|
||||
let acknowledgePrivateMedia: (_ messageID: String, _ peerID: PeerID) -> Void
|
||||
/// Delivers `.messageReceived` as one main-actor hop while
|
||||
/// `shouldDeliver` remains true before and after the synchronous sink.
|
||||
/// The completion authorizes the stable-media ACK.
|
||||
let deliverMessage: (
|
||||
_ message: BitchatMessage,
|
||||
_ shouldDeliver: @escaping () -> Bool,
|
||||
_ completion: @escaping () -> Void
|
||||
) -> Void
|
||||
}
|
||||
|
||||
/// Process-lifetime reservation cache for stable private-media IDs.
|
||||
///
|
||||
/// The first arrival reserves its ID before quota enforcement. Concurrent
|
||||
/// arrivals remain coalesced in memory, while accepted state is resolved from
|
||||
/// the durable ID-to-file ledger so it survives relaunch and becomes retryable
|
||||
/// if quota cleanup removed the file.
|
||||
private final class PrivateMediaArrivalDeduplicator {
|
||||
enum Reservation {
|
||||
case reserved
|
||||
case pending
|
||||
case accepted(URL)
|
||||
case tombstoned
|
||||
case unavailable
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private var pending: Set<String> = []
|
||||
|
||||
func reserve(
|
||||
_ messageID: String,
|
||||
receiptState: () -> BLEPrivateMediaReceiptState
|
||||
) -> Reservation {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if pending.contains(messageID) {
|
||||
return .pending
|
||||
}
|
||||
|
||||
switch receiptState() {
|
||||
case .accepted(let existingURL):
|
||||
return .accepted(existingURL)
|
||||
case .tombstoned:
|
||||
return .tombstoned
|
||||
case .unavailable:
|
||||
return .unavailable
|
||||
case .absent:
|
||||
break
|
||||
}
|
||||
|
||||
pending.insert(messageID)
|
||||
return .reserved
|
||||
}
|
||||
|
||||
func finish(_ messageID: String) {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
pending.remove(messageID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Orchestrates inbound file transfers: self-echo policy, sender display-name
|
||||
@@ -43,6 +112,7 @@ struct BLEFileTransferHandlerEnvironment {
|
||||
/// and UI delivery.
|
||||
final class BLEFileTransferHandler {
|
||||
private let environment: BLEFileTransferHandlerEnvironment
|
||||
private let privateMediaArrivals = PrivateMediaArrivalDeduplicator()
|
||||
|
||||
init(environment: BLEFileTransferHandlerEnvironment) {
|
||||
self.environment = environment
|
||||
@@ -86,6 +156,7 @@ final class BLEFileTransferHandler {
|
||||
senderNickname: senderNickname,
|
||||
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
|
||||
isPrivate: deliveryPlan.isPrivateMessage,
|
||||
usesDurableReceipts: false,
|
||||
env: env
|
||||
)
|
||||
// Once authenticated, a local decode/quota/save failure is not proof
|
||||
@@ -116,6 +187,11 @@ final class BLEFileTransferHandler {
|
||||
senderNickname: senderNickname,
|
||||
timestamp: timestamp,
|
||||
isPrivate: true,
|
||||
// Every authenticated Noise private-file keeps the stable ID/ACK
|
||||
// contract introduced with capability bit 8. Bit 9 advertises
|
||||
// sender-side automatic retry support; it must not downgrade
|
||||
// prior iOS clients to random IDs or single-check delivery.
|
||||
usesDurableReceipts: true,
|
||||
env: env
|
||||
)
|
||||
}
|
||||
@@ -126,9 +202,11 @@ final class BLEFileTransferHandler {
|
||||
senderNickname: String,
|
||||
timestamp: Date,
|
||||
isPrivate: Bool,
|
||||
usesDurableReceipts: Bool,
|
||||
env: BLEFileTransferHandlerEnvironment
|
||||
) -> Bool {
|
||||
|
||||
let localPeerID = env.localPeerID()
|
||||
let filePacket: BitchatFilePacket
|
||||
let mime: MimeType
|
||||
switch BLEIncomingFileValidator.validate(payload: payload) {
|
||||
@@ -149,6 +227,89 @@ final class BLEFileTransferHandler {
|
||||
return false
|
||||
}
|
||||
|
||||
if isPrivate, env.isPrivateMediaSenderBlocked(peerID) {
|
||||
SecureLogger.debug(
|
||||
"🚫 Dropping private media from blocked peer \(peerID.id.prefix(8))… before disk write",
|
||||
category: .security
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
let messageID = usesDurableReceipts
|
||||
? PrivateMediaMessageIdentity.stableID(
|
||||
for: filePacket,
|
||||
senderPeerID: peerID,
|
||||
recipientPeerID: localPeerID
|
||||
)
|
||||
: nil
|
||||
if let messageID {
|
||||
switch privateMediaArrivals.reserve(
|
||||
messageID,
|
||||
receiptState: { env.privateMediaReceiptState(messageID) }
|
||||
) {
|
||||
case .reserved:
|
||||
break
|
||||
case .pending:
|
||||
// The first arrival has not reached durable storage yet.
|
||||
// Coalesce this retry without ACKing so a failed first save
|
||||
// remains retryable by the sender.
|
||||
SecureLogger.debug(
|
||||
"📁 Coalesced in-flight private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
return true
|
||||
case .accepted(let existingFile):
|
||||
env.updatePeerLastSeen(peerID)
|
||||
let message = incomingMessage(
|
||||
messageID: messageID,
|
||||
senderNickname: senderNickname,
|
||||
timestamp: timestamp,
|
||||
isPrivate: true,
|
||||
peerID: peerID,
|
||||
destination: existingFile,
|
||||
category: storedMediaCategory(
|
||||
for: existingFile,
|
||||
fallback: mime.category
|
||||
),
|
||||
env: env
|
||||
)
|
||||
SecureLogger.debug(
|
||||
"📁 Restored durable private media duplicate id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))… -> \(existingFile.lastPathComponent)",
|
||||
category: .session
|
||||
)
|
||||
deliverStableMessage(
|
||||
message,
|
||||
messageID: messageID,
|
||||
peerID: peerID,
|
||||
expectedURL: existingFile,
|
||||
env: env
|
||||
)
|
||||
return true
|
||||
case .tombstoned:
|
||||
// Explicit deletion is a durable terminal receiver decision.
|
||||
env.updatePeerLastSeen(peerID)
|
||||
env.acknowledgePrivateMedia(messageID, peerID)
|
||||
SecureLogger.debug(
|
||||
"📁 Dropped explicitly deleted private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
return true
|
||||
case .unavailable:
|
||||
// Never turn an unreadable ledger into an empty ledger. The
|
||||
// sender can retry after the transient storage failure clears.
|
||||
SecureLogger.warning(
|
||||
"📁 Withholding private media id=\(messageID.prefix(12))… while durable receipt state is unavailable",
|
||||
category: .session
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
defer {
|
||||
if let messageID {
|
||||
privateMediaArrivals.finish(messageID)
|
||||
}
|
||||
}
|
||||
|
||||
// BCH-01-002: Enforce storage quota before saving
|
||||
env.enforceStorageQuota(filePacket.content.count)
|
||||
|
||||
@@ -162,13 +323,82 @@ final class BLEFileTransferHandler {
|
||||
return false
|
||||
}
|
||||
|
||||
if let messageID,
|
||||
!env.commitPrivateMediaFile(messageID, destination) {
|
||||
// A payload without its durable ID mapping cannot safely suppress
|
||||
// a retry after relaunch. Roll it back and withhold UI/ACK.
|
||||
env.removeIncomingFile(destination)
|
||||
return false
|
||||
}
|
||||
|
||||
if isPrivate {
|
||||
env.updatePeerLastSeen(peerID)
|
||||
}
|
||||
|
||||
let message = BitchatMessage(
|
||||
let message = incomingMessage(
|
||||
messageID: messageID,
|
||||
senderNickname: senderNickname,
|
||||
timestamp: timestamp,
|
||||
isPrivate: isPrivate,
|
||||
peerID: peerID,
|
||||
destination: destination,
|
||||
category: mime.category,
|
||||
env: env
|
||||
)
|
||||
|
||||
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
||||
|
||||
if let messageID {
|
||||
deliverStableMessage(
|
||||
message,
|
||||
messageID: messageID,
|
||||
peerID: peerID,
|
||||
expectedURL: destination,
|
||||
env: env
|
||||
)
|
||||
} else {
|
||||
env.deliverMessage(message, { true }, {})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func deliverStableMessage(
|
||||
_ message: BitchatMessage,
|
||||
messageID: String,
|
||||
peerID: PeerID,
|
||||
expectedURL: URL,
|
||||
env: BLEFileTransferHandlerEnvironment
|
||||
) {
|
||||
env.deliverMessage(
|
||||
message,
|
||||
{
|
||||
guard case .accepted(let resolvedURL) =
|
||||
env.privateMediaReceiptState(messageID) else {
|
||||
return false
|
||||
}
|
||||
return resolvedURL.standardizedFileURL
|
||||
== expectedURL.standardizedFileURL
|
||||
},
|
||||
{
|
||||
env.acknowledgePrivateMedia(messageID, peerID)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func incomingMessage(
|
||||
messageID: String?,
|
||||
senderNickname: String,
|
||||
timestamp: Date,
|
||||
isPrivate: Bool,
|
||||
peerID: PeerID,
|
||||
destination: URL,
|
||||
category: MimeType.Category,
|
||||
env: BLEFileTransferHandlerEnvironment
|
||||
) -> BitchatMessage {
|
||||
BitchatMessage(
|
||||
id: messageID,
|
||||
sender: senderNickname,
|
||||
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
|
||||
content: "\(category.messagePrefix)\(destination.lastPathComponent)",
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
@@ -176,17 +406,35 @@ final class BLEFileTransferHandler {
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID,
|
||||
// Received messages need an explicit status: BitchatMessage
|
||||
// defaults private messages to .sending, which the media views
|
||||
// render as an in-flight send (empty reveal mask, disabled tap).
|
||||
// defaults private messages to .sending, which media views render
|
||||
// as an in-flight send.
|
||||
deliveryStatus: isPrivate
|
||||
? .delivered(to: env.localNickname(), at: timestamp)
|
||||
: nil
|
||||
)
|
||||
}
|
||||
|
||||
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
||||
|
||||
env.deliverMessage(message)
|
||||
return true
|
||||
/// The durable URL is authoritative during reconstruction. A sender that
|
||||
/// reuses a stable filename with a different MIME type must not change how
|
||||
/// the already-stored payload renders.
|
||||
private func storedMediaCategory(
|
||||
for url: URL,
|
||||
fallback: MimeType.Category
|
||||
) -> MimeType.Category {
|
||||
let mediaDirectory = url
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.lastPathComponent
|
||||
switch mediaDirectory {
|
||||
case MimeType.Category.audio.mediaDir:
|
||||
return .audio
|
||||
case MimeType.Category.image.mediaDir:
|
||||
return .image
|
||||
case MimeType.Category.file.mediaDir:
|
||||
return .file
|
||||
default:
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
/// Every remaining raw file transfer is signed, regardless of whether it
|
||||
|
||||
@@ -134,6 +134,7 @@ struct BLEIncomingFileStore {
|
||||
private let baseDirectory: URL?
|
||||
private let dateProvider: () -> Date
|
||||
private let panicMarkerWriter: (Data, URL) throws -> Void
|
||||
private let privateMediaReceipts: BLEPrivateMediaReceiptStore
|
||||
|
||||
init(
|
||||
fileManager: FileManager = .default,
|
||||
@@ -147,6 +148,11 @@ struct BLEIncomingFileStore {
|
||||
self.baseDirectory = baseDirectory
|
||||
self.dateProvider = dateProvider
|
||||
self.panicMarkerWriter = panicMarkerWriter
|
||||
self.privateMediaReceipts = BLEPrivateMediaReceiptStore(
|
||||
fileManager: fileManager,
|
||||
baseDirectory: baseDirectory,
|
||||
now: dateProvider
|
||||
)
|
||||
}
|
||||
|
||||
/// Panic-wipe every managed incoming and outgoing media artifact before
|
||||
@@ -159,6 +165,11 @@ struct BLEIncomingFileStore {
|
||||
func panicWipe(
|
||||
hasDurablePendingMarker: Bool = false
|
||||
) throws {
|
||||
// The receipt index caches tombstones as well as accepted payloads.
|
||||
// Always invalidate it on return, including partial-failure paths, so
|
||||
// no pre-panic receiver decision survives after identity reset.
|
||||
defer { privateMediaReceipts.resetForPanic() }
|
||||
|
||||
let markerError: Error?
|
||||
do {
|
||||
try markPanicRecoveryPending()
|
||||
@@ -257,6 +268,35 @@ struct BLEIncomingFileStore {
|
||||
}
|
||||
}
|
||||
|
||||
func privateMediaReceiptState(
|
||||
messageID: String
|
||||
) -> BLEPrivateMediaReceiptState {
|
||||
privateMediaReceipts.state(for: messageID)
|
||||
}
|
||||
|
||||
func commitPrivateMediaFile(
|
||||
messageID: String,
|
||||
storedURL: URL
|
||||
) -> Bool {
|
||||
privateMediaReceipts.commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: storedURL
|
||||
)
|
||||
}
|
||||
|
||||
/// Best-effort rollback for a payload whose durable receipt commit failed.
|
||||
func removeIncomingFile(at storedURL: URL) {
|
||||
guard isURLInsideFilesDirectory(storedURL) else { return }
|
||||
do {
|
||||
try fileManager.removeItem(at: storedURL)
|
||||
} catch {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Failed to roll back uncommitted incoming media: \(error)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Frees least-recently-modified incoming files until `reservingBytes`
|
||||
/// fits under the quota. Files named `voice_live_*` (in-flight live
|
||||
/// captures) are never evicted regardless of who triggers enforcement —
|
||||
@@ -349,6 +389,13 @@ struct BLEIncomingFileStore {
|
||||
]
|
||||
}
|
||||
|
||||
private func isURLInsideFilesDirectory(_ url: URL) -> Bool {
|
||||
guard let filesDirectory = try? filesDirectory().standardizedFileURL else {
|
||||
return false
|
||||
}
|
||||
return url.standardizedFileURL.path.hasPrefix(filesDirectory.path + "/")
|
||||
}
|
||||
|
||||
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||
var candidate = (name ?? "")
|
||||
.replacingOccurrences(of: "\0", with: "")
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
struct BLENoiseHandshakeHandlingResult {
|
||||
@@ -33,6 +34,8 @@ struct BLENoisePacketHandlerEnvironment {
|
||||
-> NoiseHandshakeProcessingResult
|
||||
/// Whether any Noise session (established or pending) exists for the peer (crypto).
|
||||
let hasNoiseSession: (PeerID) -> Bool
|
||||
/// Whether an inbound ordinary XX responder is waiting for message 3.
|
||||
let isAwaitingResponderHandshakeCompletion: (PeerID) -> Bool
|
||||
/// Initiates a fresh Noise handshake with the peer (crypto + send).
|
||||
let initiateHandshake: (PeerID) -> Void
|
||||
/// Broadcasts a packet on the mesh (caller is already on the message queue).
|
||||
@@ -63,15 +66,36 @@ struct BLENoisePacketHandlerEnvironment {
|
||||
/// processing (with response), encrypted payload decryption and dispatch,
|
||||
/// and session recovery on decrypt failure.
|
||||
final class BLENoisePacketHandler {
|
||||
private struct DeferredCiphertext {
|
||||
let packet: BitchatPacket
|
||||
let receivedAt: Date
|
||||
}
|
||||
|
||||
/// Early post-handshake packets are normally tiny control messages or
|
||||
/// queued DMs. Keep the recovery surface deliberately small so an
|
||||
/// unauthenticated half-handshake cannot create an unbounded memory queue.
|
||||
private static let maxDeferredPacketsPerPeer = 4
|
||||
private static let maxDeferredPacketsGlobal = 32
|
||||
/// One legacy sender can immediately follow message 3 with the largest
|
||||
/// valid private-file ciphertext and has no application-level retry. Keep
|
||||
/// room for that packet plus a small control-message budget.
|
||||
private static let maxDeferredBytes =
|
||||
NoiseSecurityConstants.maxPrivateFileCiphertextSize + 256 * 1024
|
||||
private static let deferredLifetime =
|
||||
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
|
||||
|
||||
private let environment: BLENoisePacketHandlerEnvironment
|
||||
private let deferredLock = NSLock()
|
||||
private var deferredCiphertexts: [PeerID: [DeferredCiphertext]] = [:]
|
||||
private var deferredCiphertextBytes = 0
|
||||
|
||||
init(environment: BLENoisePacketHandlerEnvironment) {
|
||||
self.environment = environment
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Callers use this to distinguish an authenticated reconnect completion
|
||||
/// from a rejected ordinary responder while rollback state is restored.
|
||||
@discardableResult
|
||||
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) -> Bool {
|
||||
handleHandshakeWithResult(packet, from: peerID).processed
|
||||
@@ -105,15 +129,23 @@ final class BLENoisePacketHandler {
|
||||
env.broadcastPacket(responsePacket)
|
||||
}
|
||||
|
||||
// Session establishment will trigger onPeerAuthenticated callback
|
||||
// which will send any pending messages at the right time
|
||||
// The serialized authentication callback installs transport
|
||||
// state before it drains any bounded early ciphertext.
|
||||
return BLENoiseHandshakeHandlingResult(
|
||||
processed: true,
|
||||
didEstablishAuthenticatedSession:
|
||||
result.didEstablishAuthenticatedSession
|
||||
)
|
||||
} catch let managedFailure as NoiseManagedHandshakeFailure {
|
||||
SecureLogger.error(
|
||||
"Failed to process handshake; manager owns recovery: \(managedFailure.underlying)"
|
||||
)
|
||||
return BLENoiseHandshakeHandlingResult(
|
||||
processed: false,
|
||||
didEstablishAuthenticatedSession: false
|
||||
)
|
||||
} catch NoiseSessionError.peerIdentityMismatch {
|
||||
// The candidate was already discarded by the session manager.
|
||||
// The responder 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(
|
||||
@@ -143,6 +175,30 @@ final class BLENoisePacketHandler {
|
||||
}
|
||||
|
||||
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
handleEncrypted(packet, from: peerID, isDeferredRetry: false)
|
||||
}
|
||||
|
||||
/// Called by the transport's serialized authentication callback after it
|
||||
/// has installed state for the promoted or restored session generation.
|
||||
func handleSessionAuthenticated(_ peerID: PeerID) {
|
||||
drainDeferredCiphertextsIfReady(for: peerID)
|
||||
}
|
||||
|
||||
/// Synchronously discards ciphertext retained for a pre-panic Noise
|
||||
/// generation. The handler survives the service's identity replacement,
|
||||
/// so keeping this queue would replay old bytes after post-panic auth.
|
||||
func resetForPanic() {
|
||||
deferredLock.lock()
|
||||
deferredCiphertexts.removeAll(keepingCapacity: false)
|
||||
deferredCiphertextBytes = 0
|
||||
deferredLock.unlock()
|
||||
}
|
||||
|
||||
private func handleEncrypted(
|
||||
_ packet: BitchatPacket,
|
||||
from peerID: PeerID,
|
||||
isDeferredRetry: Bool
|
||||
) {
|
||||
let env = environment
|
||||
guard let recipientID = PeerID(hexData: packet.recipientID) else {
|
||||
SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
|
||||
@@ -184,19 +240,205 @@ final class BLENoisePacketHandler {
|
||||
|
||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
|
||||
} catch NoiseEncryptionError.transportGenerationNotReady {
|
||||
if isDeferredRetry {
|
||||
SecureLogger.warning(
|
||||
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because its authenticated transport generation changed again",
|
||||
category: .session
|
||||
)
|
||||
return
|
||||
}
|
||||
// The manager promoted or restored keys before BLE's serialized
|
||||
// callback installed generation-bound transport state. The
|
||||
// manager rejected this before decrypting, so replay is safe.
|
||||
deferCiphertext(packet, from: peerID)
|
||||
} catch NoiseEncryptionError.sessionNotEstablished {
|
||||
if isDeferredRetry {
|
||||
SecureLogger.warning(
|
||||
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… because the authenticated session is unavailable",
|
||||
category: .session
|
||||
)
|
||||
return
|
||||
}
|
||||
// We received an encrypted message before establishing a session with this peer.
|
||||
// Trigger a handshake so future messages can be decrypted.
|
||||
// An initiator may already have sent message 3 followed by this
|
||||
// ciphertext, with BLE delivering the ciphertext first.
|
||||
if env.isAwaitingResponderHandshakeCompletion(peerID) {
|
||||
deferCiphertext(packet, from: peerID)
|
||||
return
|
||||
}
|
||||
// Otherwise trigger a handshake so future messages can decrypt.
|
||||
SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake")
|
||||
if !env.hasNoiseSession(peerID) {
|
||||
env.initiateHandshake(peerID)
|
||||
}
|
||||
} catch {
|
||||
if isDeferredRetry {
|
||||
// An early packet cannot tear down the authenticated session
|
||||
// merely because its single bounded retry still fails.
|
||||
SecureLogger.warning(
|
||||
"Dropping deferred Noise ciphertext from \(peerID.id.prefix(8))… after retry failed: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return
|
||||
}
|
||||
// A responder may retain an older transport as receive-only
|
||||
// rollback state while ordinary XX waits for message 3. New-key
|
||||
// ciphertext can fail against those retained receive keys first.
|
||||
if env.isAwaitingResponderHandshakeCompletion(peerID) {
|
||||
if isDeferrableEarlyHandshakeFailure(error) {
|
||||
deferCiphertext(packet, from: peerID)
|
||||
} else {
|
||||
SecureLogger.warning(
|
||||
"Dropping invalid Noise ciphertext from \(peerID.id.prefix(8))… while responder handshake is completing: \(error)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if isDropOnlyCiphertextFailure(error) {
|
||||
// The packet is attacker-controlled and did not prove a
|
||||
// transport-state failure. Never let malformed, replayed,
|
||||
// forged, oversized, or rate-limited bytes evict working keys.
|
||||
SecureLogger.warning(
|
||||
"Dropping rejected Noise ciphertext from \(peerID.id.prefix(8))… without clearing its session: \(error)",
|
||||
category: .security
|
||||
)
|
||||
return
|
||||
}
|
||||
// Decryption failed - clear the corrupted session and re-initiate handshake
|
||||
// This handles cases where session state got out of sync (nonce mismatch, etc.)
|
||||
// Only local/session lifecycle failures reach this path.
|
||||
SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake")
|
||||
env.clearSession(peerID)
|
||||
env.initiateHandshake(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func isDeferrableEarlyHandshakeFailure(_ error: Error) -> Bool {
|
||||
if let noiseError = error as? NoiseError {
|
||||
switch noiseError {
|
||||
case .authenticationFailure, .replayDetected:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
if let cryptoError = error as? CryptoKitError,
|
||||
case .authenticationFailure = cryptoError {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func isDropOnlyCiphertextFailure(_ error: Error) -> Bool {
|
||||
if let securityError = error as? NoiseSecurityError {
|
||||
switch securityError {
|
||||
case .messageTooLarge, .rateLimitExceeded, .invalidPeerID:
|
||||
return true
|
||||
case .sessionExpired, .sessionExhausted:
|
||||
return false
|
||||
}
|
||||
}
|
||||
if let noiseError = error as? NoiseError {
|
||||
switch noiseError {
|
||||
case .invalidCiphertext, .authenticationFailure, .replayDetected:
|
||||
return true
|
||||
case .uninitializedCipher, .handshakeComplete,
|
||||
.handshakeNotComplete, .missingLocalStaticKey,
|
||||
.missingKeys, .invalidMessage, .invalidPublicKey,
|
||||
.nonceExceeded:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return error is CryptoKitError
|
||||
}
|
||||
|
||||
private func deferCiphertext(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
guard NoiseSecurityValidator.validatePrivateFileCiphertextSize(
|
||||
packet.payload
|
||||
) else {
|
||||
SecureLogger.warning(
|
||||
"Dropping oversized early Noise ciphertext from \(peerID.id.prefix(8))…",
|
||||
category: .security
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let now = environment.now()
|
||||
deferredLock.lock()
|
||||
defer { deferredLock.unlock() }
|
||||
purgeExpiredCiphertextsLocked(now: now)
|
||||
|
||||
let peerCount = deferredCiphertexts[peerID]?.count ?? 0
|
||||
let globalCount = deferredCiphertexts.values.reduce(0) {
|
||||
$0 + $1.count
|
||||
}
|
||||
guard peerCount < Self.maxDeferredPacketsPerPeer,
|
||||
globalCount < Self.maxDeferredPacketsGlobal,
|
||||
deferredCiphertextBytes + packet.payload.count
|
||||
<= Self.maxDeferredBytes else {
|
||||
SecureLogger.warning(
|
||||
"Dropping early Noise ciphertext from \(peerID.id.prefix(8))… because the handshake buffer is full",
|
||||
category: .security
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
deferredCiphertexts[peerID, default: []].append(
|
||||
DeferredCiphertext(packet: packet, receivedAt: now)
|
||||
)
|
||||
deferredCiphertextBytes += packet.payload.count
|
||||
SecureLogger.debug(
|
||||
"Deferring early Noise ciphertext from \(peerID.id.prefix(8))… until responder handshake completion",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
|
||||
private func drainDeferredCiphertextsIfReady(for peerID: PeerID) {
|
||||
let env = environment
|
||||
guard !env.isAwaitingResponderHandshakeCompletion(peerID),
|
||||
env.hasNoiseSession(peerID) else {
|
||||
return
|
||||
}
|
||||
|
||||
let now = env.now()
|
||||
deferredLock.lock()
|
||||
purgeExpiredCiphertextsLocked(now: now)
|
||||
let deferred = deferredCiphertexts.removeValue(forKey: peerID) ?? []
|
||||
deferredCiphertextBytes -= deferred.reduce(0) {
|
||||
$0 + $1.packet.payload.count
|
||||
}
|
||||
deferredLock.unlock()
|
||||
|
||||
guard !deferred.isEmpty else { return }
|
||||
SecureLogger.debug(
|
||||
"Retrying \(deferred.count) early Noise ciphertext packet(s) from \(peerID.id.prefix(8))… after handshake completion",
|
||||
category: .session
|
||||
)
|
||||
for item in deferred {
|
||||
handleEncrypted(item.packet, from: peerID, isDeferredRetry: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func purgeExpiredCiphertextsLocked(now: Date) {
|
||||
for peerID in Array(deferredCiphertexts.keys) {
|
||||
guard let items = deferredCiphertexts[peerID] else { continue }
|
||||
let retained = items.filter {
|
||||
now.timeIntervalSince($0.receivedAt) <= Self.deferredLifetime
|
||||
}
|
||||
guard retained.count != items.count else { continue }
|
||||
|
||||
deferredCiphertextBytes -= items.reduce(0) {
|
||||
$0 + $1.packet.payload.count
|
||||
}
|
||||
deferredCiphertextBytes += retained.reduce(0) {
|
||||
$0 + $1.packet.payload.count
|
||||
}
|
||||
if retained.isEmpty {
|
||||
deferredCiphertexts.removeValue(forKey: peerID)
|
||||
} else {
|
||||
deferredCiphertexts[peerID] = retained
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import Foundation
|
||||
|
||||
/// Bounds ordinary Noise revalidation to one attempt per physical-link epoch.
|
||||
/// A live epoch may retry after the cooldown so a lost handshake cannot leave
|
||||
/// the link permanently unauthenticated.
|
||||
struct BLENoiseReconnectPolicy {
|
||||
static let minimumRetryInterval: TimeInterval = 60
|
||||
|
||||
private var lastAttemptAt: [BLEIngressLinkID: Date] = [:]
|
||||
|
||||
mutating func shouldRevalidate(
|
||||
on link: BLEIngressLinkID,
|
||||
hasEstablishedSession: Bool,
|
||||
isNoiseAuthenticatedLink: Bool,
|
||||
hasAuthenticatedPeerLink: Bool,
|
||||
now: Date
|
||||
) -> Bool {
|
||||
guard hasEstablishedSession,
|
||||
!isNoiseAuthenticatedLink,
|
||||
!hasAuthenticatedPeerLink else {
|
||||
return false
|
||||
}
|
||||
if let previous = lastAttemptAt[link],
|
||||
now.timeIntervalSince(previous) < Self.minimumRetryInterval {
|
||||
return false
|
||||
}
|
||||
lastAttemptAt[link] = now
|
||||
return true
|
||||
}
|
||||
|
||||
/// Link identifiers can be stable across CoreBluetooth reconnects, so a
|
||||
/// disconnect explicitly starts a new epoch and permits one fresh attempt.
|
||||
mutating func endLinkEpoch(_ link: BLEIngressLinkID) {
|
||||
lastAttemptAt.removeValue(forKey: link)
|
||||
}
|
||||
|
||||
mutating func removeAll() {
|
||||
lastAttemptAt.removeAll()
|
||||
}
|
||||
}
|
||||
@@ -189,14 +189,6 @@ struct BLEPeerRegistry {
|
||||
peers[peer.peerID] = peer
|
||||
}
|
||||
|
||||
/// Applies a verified announce to the registry.
|
||||
///
|
||||
/// TOFU signing-key pinning: once a signing key has been bound to this
|
||||
/// peer entry, an announce carrying a *different* signing key is refused
|
||||
/// (returns `nil`) and the existing record is left untouched. PeerIDs are
|
||||
/// derived from the (public) noise key, so without pinning an attacker
|
||||
/// could replay a victim's noiseKey/peerID with their own signing key and
|
||||
/// silently take over the victim's mesh identity and nickname.
|
||||
mutating func upsertVerifiedAnnounce(
|
||||
peerID: PeerID,
|
||||
nickname: String,
|
||||
@@ -206,15 +198,8 @@ struct BLEPeerRegistry {
|
||||
now: Date,
|
||||
capabilities: PeerCapabilities? = nil,
|
||||
bridgeGeohash: String? = nil
|
||||
) -> BLEPeerAnnounceUpdate? {
|
||||
) -> BLEPeerAnnounceUpdate {
|
||||
let existing = peers[peerID]
|
||||
|
||||
if let pinnedSigningKey = existing?.signingPublicKey,
|
||||
let announcedSigningKey = signingPublicKey,
|
||||
pinnedSigningKey != announcedSigningKey {
|
||||
return nil
|
||||
}
|
||||
|
||||
let update = BLEPeerAnnounceUpdate(
|
||||
isNewPeer: existing == nil,
|
||||
wasDisconnected: existing?.isConnected == false,
|
||||
@@ -226,8 +211,7 @@ struct BLEPeerRegistry {
|
||||
nickname: nickname,
|
||||
isConnected: isConnected,
|
||||
noisePublicKey: noisePublicKey,
|
||||
// Never drop an already-pinned signing key.
|
||||
signingPublicKey: signingPublicKey ?? existing?.signingPublicKey,
|
||||
signingPublicKey: signingPublicKey,
|
||||
isVerifiedNickname: true,
|
||||
lastSeen: now,
|
||||
capabilities: capabilities ?? [],
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
import BitLogger
|
||||
import Foundation
|
||||
|
||||
enum BLEPrivateMediaReceiptState: Equatable {
|
||||
/// No durable receiver decision exists for this stable message ID.
|
||||
case absent
|
||||
/// The payload is durably mapped to a file that still exists.
|
||||
case accepted(URL)
|
||||
/// The user explicitly deleted the payload; retries must not resurrect it.
|
||||
case tombstoned
|
||||
/// Durable state could not be read safely. Callers must fail closed and
|
||||
/// must not save, deliver, or acknowledge the payload.
|
||||
case unavailable
|
||||
}
|
||||
|
||||
/// Durable, per-message receiver decisions for stable private media.
|
||||
///
|
||||
/// Each ID has its own atomic record so one hot lookup never rewrites or
|
||||
/// decodes the entire ledger. The process-lifetime index is installed only
|
||||
/// after a complete, successful directory scan. An enumeration, read, decode,
|
||||
/// or structural-validation failure therefore remains retryable and cannot be
|
||||
/// mistaken for an empty ledger.
|
||||
final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
typealias DirectoryReader = (_ directory: URL) throws -> [URL]
|
||||
typealias DataReader = (_ url: URL) throws -> Data
|
||||
private static let receiptDirectoryName = ".private-media-receipts"
|
||||
|
||||
private struct ReceiptRecord: Codable, Equatable {
|
||||
enum Kind: String, Codable {
|
||||
case accepted
|
||||
case tombstone
|
||||
}
|
||||
|
||||
let kind: Kind
|
||||
/// Path below the app's `files/` root. Absolute application-container
|
||||
/// prefixes are not stable across updates, restores, or reinstalls.
|
||||
let relativePath: String?
|
||||
let recordedAt: Date
|
||||
}
|
||||
|
||||
private final class Runtime: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
var records: [String: ReceiptRecord]?
|
||||
var volatileTombstones: [String: Date] = [:]
|
||||
}
|
||||
|
||||
private let fileManager: FileManager
|
||||
private let baseDirectory: URL?
|
||||
private let capacity: Int
|
||||
private let ttl: TimeInterval
|
||||
private let now: () -> Date
|
||||
private let directoryReader: DirectoryReader?
|
||||
private let dataReader: DataReader?
|
||||
private let runtime = Runtime()
|
||||
|
||||
init(
|
||||
fileManager: FileManager = .default,
|
||||
baseDirectory: URL? = nil,
|
||||
capacity: Int = TransportConfig.privateMediaReceivedLedgerCapacity,
|
||||
ttl: TimeInterval = TransportConfig.privateMediaReceivedLedgerTTLSeconds,
|
||||
now: @escaping () -> Date = Date.init,
|
||||
directoryReader: DirectoryReader? = nil,
|
||||
dataReader: DataReader? = nil
|
||||
) {
|
||||
self.fileManager = fileManager
|
||||
self.baseDirectory = baseDirectory
|
||||
self.capacity = max(1, capacity)
|
||||
self.ttl = max(0, ttl)
|
||||
self.now = now
|
||||
self.directoryReader = directoryReader
|
||||
self.dataReader = dataReader
|
||||
}
|
||||
|
||||
/// Drops process-lifetime decisions after the enclosing media directory
|
||||
/// has been panic-wiped. A later lookup must rebuild from the durable
|
||||
/// ledger instead of retaining an accepted receipt or tombstone whose
|
||||
/// backing files no longer exist.
|
||||
func resetForPanic() {
|
||||
runtime.lock.lock()
|
||||
runtime.records = nil
|
||||
runtime.volatileTombstones.removeAll(keepingCapacity: false)
|
||||
runtime.lock.unlock()
|
||||
}
|
||||
|
||||
func state(for messageID: String) -> BLEPrivateMediaReceiptState {
|
||||
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
|
||||
return .absent
|
||||
}
|
||||
|
||||
runtime.lock.lock()
|
||||
defer { runtime.lock.unlock() }
|
||||
|
||||
let date = now()
|
||||
if let tombstonedAt = runtime.volatileTombstones[messageID] {
|
||||
if !isExpired(tombstonedAt, at: date) {
|
||||
return .tombstoned
|
||||
}
|
||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||
}
|
||||
|
||||
guard let directory = resolvedReceiptDirectory(),
|
||||
var records = loadIndexIfNeeded(from: directory, at: date) else {
|
||||
return .unavailable
|
||||
}
|
||||
guard let record = records[messageID] else { return .absent }
|
||||
|
||||
if isExpired(record.recordedAt, at: date) {
|
||||
records.removeValue(forKey: messageID)
|
||||
runtime.records = records
|
||||
removeRecord(messageID: messageID, from: directory)
|
||||
return .absent
|
||||
}
|
||||
|
||||
switch record.kind {
|
||||
case .tombstone:
|
||||
removePayloadRecordedByTombstone(record)
|
||||
return .tombstoned
|
||||
|
||||
case .accepted:
|
||||
guard let relativePath = record.relativePath,
|
||||
let existingURL = existingPayload(relativePath: relativePath) else {
|
||||
// Quota cleanup is not explicit deletion. Remove the stale
|
||||
// receipt so a sender retry can restore the payload and bubble.
|
||||
records.removeValue(forKey: messageID)
|
||||
runtime.records = records
|
||||
removeRecord(messageID: messageID, from: directory)
|
||||
return .absent
|
||||
}
|
||||
return .accepted(existingURL)
|
||||
}
|
||||
}
|
||||
|
||||
/// Records an accepted ID only after the payload is on disk. Callers must
|
||||
/// roll the payload back and withhold UI delivery/ACK when this returns
|
||||
/// false.
|
||||
func commitAccepted(messageID: String, storedURL: URL) -> Bool {
|
||||
guard PrivateMediaMessageIdentity.isStableID(messageID),
|
||||
validExistingPayload(storedURL) != nil,
|
||||
let relativePath = relativePath(for: storedURL) else {
|
||||
return false
|
||||
}
|
||||
|
||||
runtime.lock.lock()
|
||||
defer { runtime.lock.unlock() }
|
||||
|
||||
let date = now()
|
||||
if let tombstonedAt = runtime.volatileTombstones[messageID],
|
||||
!isExpired(tombstonedAt, at: date) {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let directory = resolvedReceiptDirectory(),
|
||||
var records = loadIndexIfNeeded(from: directory, at: date) else {
|
||||
return false
|
||||
}
|
||||
if let existing = records[messageID],
|
||||
existing.kind == .tombstone,
|
||||
!isExpired(existing.recordedAt, at: date) {
|
||||
return false
|
||||
}
|
||||
|
||||
let victim = capacityVictim(
|
||||
for: .accepted,
|
||||
replacing: messageID,
|
||||
in: records
|
||||
)
|
||||
if records[messageID]?.kind != .accepted,
|
||||
records.values.lazy.filter({ $0.kind == .accepted }).count >= capacity,
|
||||
victim == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
let record = ReceiptRecord(
|
||||
kind: .accepted,
|
||||
relativePath: relativePath,
|
||||
recordedAt: date
|
||||
)
|
||||
guard persist(record, messageID: messageID, to: directory) else {
|
||||
return false
|
||||
}
|
||||
|
||||
records[messageID] = record
|
||||
if let victim, victim != messageID {
|
||||
records.removeValue(forKey: victim)
|
||||
removeRecord(messageID: victim, from: directory)
|
||||
}
|
||||
runtime.records = records
|
||||
return true
|
||||
}
|
||||
|
||||
/// Foundation for explicit media deletion. This branch does not wire the
|
||||
/// chat-clear UI; it only makes a tombstone durable and fail closed.
|
||||
func recordDeleted(messageID: String) -> Bool {
|
||||
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
|
||||
return false
|
||||
}
|
||||
|
||||
runtime.lock.lock()
|
||||
defer { runtime.lock.unlock() }
|
||||
|
||||
let date = now()
|
||||
addVolatileTombstone(messageID, at: date)
|
||||
|
||||
guard let directory = resolvedReceiptDirectory(),
|
||||
var records = loadIndexIfNeeded(from: directory, at: date) else {
|
||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||
return false
|
||||
}
|
||||
if let existing = records[messageID],
|
||||
existing.kind == .tombstone,
|
||||
!isExpired(existing.recordedAt, at: date) {
|
||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||
removePayloadRecordedByTombstone(existing)
|
||||
return true
|
||||
}
|
||||
|
||||
let victim = capacityVictim(
|
||||
for: .tombstone,
|
||||
replacing: messageID,
|
||||
in: records
|
||||
)
|
||||
if records[messageID]?.kind != .tombstone,
|
||||
records.values.lazy.filter({ $0.kind == .tombstone }).count >= capacity,
|
||||
victim == nil {
|
||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||
return false
|
||||
}
|
||||
|
||||
let tombstone = ReceiptRecord(
|
||||
kind: .tombstone,
|
||||
// Retain the accepted path so a crash between the atomic record
|
||||
// write and payload unlink can finish cleanup after relaunch.
|
||||
relativePath: records[messageID]?.relativePath,
|
||||
recordedAt: date
|
||||
)
|
||||
guard persist(tombstone, messageID: messageID, to: directory) else {
|
||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||
return false
|
||||
}
|
||||
|
||||
records[messageID] = tombstone
|
||||
if let victim, victim != messageID {
|
||||
records.removeValue(forKey: victim)
|
||||
removeRecord(messageID: victim, from: directory)
|
||||
}
|
||||
runtime.records = records
|
||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||
removePayloadRecordedByTombstone(tombstone)
|
||||
return true
|
||||
}
|
||||
|
||||
private func loadIndexIfNeeded(
|
||||
from directory: URL,
|
||||
at date: Date
|
||||
) -> [String: ReceiptRecord]? {
|
||||
if let records = runtime.records {
|
||||
return records
|
||||
}
|
||||
|
||||
do {
|
||||
try fileManager.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
)
|
||||
} catch {
|
||||
SecureLogger.error(
|
||||
"❌ Failed to create private-media receipt directory: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
let urls: [URL]
|
||||
do {
|
||||
if let directoryReader {
|
||||
urls = try directoryReader(directory)
|
||||
} else {
|
||||
urls = try fileManager.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: nil,
|
||||
options: []
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error(
|
||||
"❌ Failed to enumerate private-media receipts: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
var records: [String: ReceiptRecord] = [:]
|
||||
var expired: [String] = []
|
||||
var tombstones: [ReceiptRecord] = []
|
||||
for url in urls {
|
||||
guard url.pathExtension == "json" else { continue }
|
||||
let messageID = url.deletingPathExtension().lastPathComponent
|
||||
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let record: ReceiptRecord
|
||||
do {
|
||||
let data = try dataReader?(url) ?? Data(contentsOf: url)
|
||||
record = try JSONDecoder().decode(ReceiptRecord.self, from: data)
|
||||
} catch {
|
||||
// Never delete or skip an unreadable stable-ID record. Treating
|
||||
// it as absent could resurrect accepted or deleted media.
|
||||
SecureLogger.error(
|
||||
"❌ Failed to read private-media receipt \(messageID.prefix(12))…: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
guard isStructurallyValid(record) else {
|
||||
SecureLogger.error(
|
||||
"❌ Invalid private-media receipt \(messageID.prefix(12))…",
|
||||
category: .session
|
||||
)
|
||||
return nil
|
||||
}
|
||||
if isExpired(record.recordedAt, at: date) {
|
||||
expired.append(messageID)
|
||||
continue
|
||||
}
|
||||
records[messageID] = record
|
||||
if record.kind == .tombstone {
|
||||
tombstones.append(record)
|
||||
}
|
||||
}
|
||||
|
||||
let overflow = overflowVictims(in: records)
|
||||
for messageID in overflow {
|
||||
records.removeValue(forKey: messageID)
|
||||
}
|
||||
|
||||
// Install the index only after every stable-ID record was read and
|
||||
// validated successfully. Cleanup cannot influence a failed scan.
|
||||
runtime.records = records
|
||||
|
||||
for messageID in expired + overflow {
|
||||
removeRecord(messageID: messageID, from: directory)
|
||||
}
|
||||
for tombstone in tombstones {
|
||||
removePayloadRecordedByTombstone(tombstone)
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
private func isStructurallyValid(_ record: ReceiptRecord) -> Bool {
|
||||
switch record.kind {
|
||||
case .tombstone:
|
||||
guard let relativePath = record.relativePath else { return true }
|
||||
return candidatePayload(relativePath: relativePath) != nil
|
||||
case .accepted:
|
||||
guard let relativePath = record.relativePath else { return false }
|
||||
return candidatePayload(relativePath: relativePath) != nil
|
||||
}
|
||||
}
|
||||
|
||||
private func isExpired(_ recordedAt: Date, at date: Date) -> Bool {
|
||||
date.timeIntervalSince(recordedAt) > ttl
|
||||
}
|
||||
|
||||
private func overflowVictims(
|
||||
in records: [String: ReceiptRecord]
|
||||
) -> [String] {
|
||||
var victims: [String] = []
|
||||
for kind in [ReceiptRecord.Kind.accepted, .tombstone] {
|
||||
let matching = records.filter { $0.value.kind == kind }
|
||||
let overflow = matching.count - capacity
|
||||
guard overflow > 0 else { continue }
|
||||
victims.append(contentsOf: matching.sorted { lhs, rhs in
|
||||
if lhs.value.recordedAt == rhs.value.recordedAt {
|
||||
return lhs.key < rhs.key
|
||||
}
|
||||
return lhs.value.recordedAt < rhs.value.recordedAt
|
||||
}
|
||||
.prefix(overflow)
|
||||
.map(\.key))
|
||||
}
|
||||
return victims
|
||||
}
|
||||
|
||||
/// Accepted receipts and tombstones have independent capacity. High media
|
||||
/// volume cannot evict explicit deletion intent, and vice versa.
|
||||
private func capacityVictim(
|
||||
for incomingKind: ReceiptRecord.Kind,
|
||||
replacing messageID: String,
|
||||
in records: [String: ReceiptRecord]
|
||||
) -> String? {
|
||||
guard records[messageID]?.kind != incomingKind else { return nil }
|
||||
let matching = records.filter {
|
||||
$0.key != messageID && $0.value.kind == incomingKind
|
||||
}
|
||||
guard matching.count >= capacity else { return nil }
|
||||
return matching.min { lhs, rhs in
|
||||
if lhs.value.recordedAt == rhs.value.recordedAt {
|
||||
return lhs.key < rhs.key
|
||||
}
|
||||
return lhs.value.recordedAt < rhs.value.recordedAt
|
||||
}?.key
|
||||
}
|
||||
|
||||
private func persist(
|
||||
_ record: ReceiptRecord,
|
||||
messageID: String,
|
||||
to directory: URL
|
||||
) -> Bool {
|
||||
do {
|
||||
try fileManager.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
)
|
||||
let data = try JSONEncoder().encode(record)
|
||||
var options: Data.WritingOptions = [.atomic]
|
||||
#if os(iOS)
|
||||
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
|
||||
#endif
|
||||
let url = recordURL(messageID: messageID, in: directory)
|
||||
try data.write(to: url, options: options)
|
||||
return true
|
||||
} catch {
|
||||
SecureLogger.error(
|
||||
"❌ Failed to persist private-media receipt \(messageID.prefix(12))…: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func removeRecord(messageID: String, from directory: URL) {
|
||||
let url = recordURL(messageID: messageID, in: directory)
|
||||
guard fileManager.fileExists(atPath: url.path) else { return }
|
||||
do {
|
||||
try fileManager.removeItem(at: url)
|
||||
} catch {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Failed to prune private-media receipt \(messageID.prefix(12))…: \(error)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func recordURL(messageID: String, in directory: URL) -> URL {
|
||||
directory
|
||||
.appendingPathComponent(messageID, isDirectory: false)
|
||||
.appendingPathExtension("json")
|
||||
}
|
||||
|
||||
private func removePayloadRecordedByTombstone(_ record: ReceiptRecord) {
|
||||
guard record.kind == .tombstone,
|
||||
let relativePath = record.relativePath,
|
||||
let payload = candidatePayload(relativePath: relativePath),
|
||||
fileManager.fileExists(atPath: payload.path) else {
|
||||
return
|
||||
}
|
||||
do {
|
||||
try fileManager.removeItem(at: payload)
|
||||
} catch {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Failed to remove explicitly deleted private media: \(error)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func addVolatileTombstone(_ messageID: String, at date: Date) {
|
||||
runtime.volatileTombstones[messageID] = date
|
||||
let overflow = runtime.volatileTombstones.count - capacity
|
||||
guard overflow > 0 else { return }
|
||||
let oldest = runtime.volatileTombstones.sorted {
|
||||
if $0.value == $1.value { return $0.key < $1.key }
|
||||
return $0.value < $1.value
|
||||
}
|
||||
for (oldMessageID, _) in oldest.prefix(overflow) {
|
||||
runtime.volatileTombstones.removeValue(forKey: oldMessageID)
|
||||
}
|
||||
}
|
||||
|
||||
private func validExistingPayload(_ url: URL) -> URL? {
|
||||
let standardized = url.standardizedFileURL
|
||||
guard isInsideFilesDirectory(standardized) else { return nil }
|
||||
var isDirectory: ObjCBool = false
|
||||
guard fileManager.fileExists(
|
||||
atPath: standardized.path,
|
||||
isDirectory: &isDirectory
|
||||
), !isDirectory.boolValue else {
|
||||
return nil
|
||||
}
|
||||
return standardized
|
||||
}
|
||||
|
||||
private func relativePath(for url: URL) -> String? {
|
||||
guard let filesRoot = try? filesDirectory().standardizedFileURL else {
|
||||
return nil
|
||||
}
|
||||
let prefix = filesRoot.path + "/"
|
||||
let standardized = url.standardizedFileURL
|
||||
guard standardized.path.hasPrefix(prefix) else { return nil }
|
||||
let relativePath = String(standardized.path.dropFirst(prefix.count))
|
||||
return relativePath.isEmpty ? nil : relativePath
|
||||
}
|
||||
|
||||
private func existingPayload(relativePath: String) -> URL? {
|
||||
guard let candidate = candidatePayload(relativePath: relativePath) else {
|
||||
return nil
|
||||
}
|
||||
return validExistingPayload(candidate)
|
||||
}
|
||||
|
||||
private func candidatePayload(relativePath: String) -> URL? {
|
||||
guard !relativePath.isEmpty,
|
||||
let filesRoot = try? filesDirectory().standardizedFileURL else {
|
||||
return nil
|
||||
}
|
||||
let candidate = filesRoot
|
||||
.appendingPathComponent(relativePath, isDirectory: false)
|
||||
.standardizedFileURL
|
||||
guard candidate.path.hasPrefix(filesRoot.path + "/") else { return nil }
|
||||
return candidate
|
||||
}
|
||||
|
||||
private func isInsideFilesDirectory(_ url: URL) -> Bool {
|
||||
guard let filesRoot = try? filesDirectory().standardizedFileURL else {
|
||||
return false
|
||||
}
|
||||
return url.standardizedFileURL.path.hasPrefix(filesRoot.path + "/")
|
||||
}
|
||||
|
||||
private func resolvedReceiptDirectory() -> URL? {
|
||||
return try? filesDirectory().appendingPathComponent(
|
||||
Self.receiptDirectoryName,
|
||||
isDirectory: true
|
||||
)
|
||||
}
|
||||
|
||||
private func filesDirectory() throws -> URL {
|
||||
let root = try baseDirectory ?? fileManager.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let files = root.appendingPathComponent("files", isDirectory: true)
|
||||
try fileManager.createDirectory(
|
||||
at: files,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: nil
|
||||
)
|
||||
return files
|
||||
}
|
||||
}
|
||||
@@ -241,6 +241,7 @@ final class BLEService: NSObject {
|
||||
// that the session was established *on this current ingress link*, not
|
||||
// merely that some session exists for the claimed ID. bleQueue-owned.
|
||||
private var noiseAuthenticatedLinkOwners: [BLEIngressLinkID: PeerID] = [:]
|
||||
private var noiseReconnectPolicy = BLENoiseReconnectPolicy()
|
||||
|
||||
// Rotation-rebind cooldown per link UUID (bleQueue-owned, like the link
|
||||
// store): entries older than the cooldown are pruned on insert.
|
||||
@@ -311,6 +312,9 @@ final class BLEService: NSObject {
|
||||
/// May block in tests to hold the serial message queue immediately before
|
||||
/// the deferred private-media admission check.
|
||||
var _test_beforePrivateMediaDeferredSend: ((String) -> Void)?
|
||||
/// May block announce handling after verified-link rebind work is queued.
|
||||
/// Tests use this boundary to prove rebind and reconnect are serialized.
|
||||
var _test_afterVerifiedDirectRebindEnqueued: (() -> Void)?
|
||||
#endif
|
||||
private var selfBroadcastTracker = BLESelfBroadcastTracker()
|
||||
private let meshTopology = MeshTopologyTracker()
|
||||
@@ -354,7 +358,7 @@ final class BLEService: NSObject {
|
||||
private let incomingFileStore: BLEIncomingFileStore
|
||||
|
||||
// Simple announce throttling
|
||||
private let announceThrottle = BLEAnnounceThrottle()
|
||||
private var announceThrottle = BLEAnnounceThrottle()
|
||||
|
||||
// Application state tracking (thread-safe)
|
||||
#if os(iOS)
|
||||
@@ -386,7 +390,9 @@ final class BLEService: NSObject {
|
||||
private let identityManager: SecureIdentityStateManagerProtocol
|
||||
private let keychain: KeychainManagerProtocol
|
||||
private let idBridge: NostrIdentityBridge
|
||||
private let localIdentityState = BLELocalIdentityStateStore()
|
||||
/// Binary form of `myPeerID`; same contract — mutated only inside a
|
||||
/// `messageQueue` barrier via `refreshPeerIdentity()`.
|
||||
private var myPeerIDData: Data = Data()
|
||||
|
||||
// MARK: - Advertising Privacy
|
||||
// No Local Name by default for maximum privacy. No rotating alias.
|
||||
@@ -701,6 +707,7 @@ final class BLEService: NSObject {
|
||||
/// or advertising while the full panic transaction is incomplete.
|
||||
func suspendForPanicReset() {
|
||||
setPanicSuspended(true)
|
||||
noisePacketHandler.resetForPanic()
|
||||
gossipSyncManager?.stop()
|
||||
gossipSyncManager = nil
|
||||
// Stop the radio and drain CoreBluetooth's delegate queue first. A
|
||||
@@ -711,7 +718,11 @@ final class BLEService: NSObject {
|
||||
// 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) {}
|
||||
// Clear the old identity's bounded early-ciphertext queue again after
|
||||
// those callbacks drain so none can repopulate it after the first wipe.
|
||||
messageQueue.sync(flags: .barrier) {
|
||||
noisePacketHandler.resetForPanic()
|
||||
}
|
||||
clearEmergencySessionState()
|
||||
}
|
||||
|
||||
@@ -729,9 +740,8 @@ final class BLEService: NSObject {
|
||||
) {
|
||||
gossipSyncManager?.stop()
|
||||
gossipSyncManager = nil
|
||||
// pendingNoiseSessionQueues is owned by collectionsQueue everywhere
|
||||
// else, so clear it there too rather than on messageQueue.
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
messageQueue.sync(flags: .barrier) {
|
||||
noisePacketHandler.resetForPanic()
|
||||
pendingNoiseSessionQueues.removeAll()
|
||||
}
|
||||
|
||||
@@ -764,6 +774,8 @@ final class BLEService: NSObject {
|
||||
|
||||
bleQueue.sync {
|
||||
pendingWriteBuffers.removeAll()
|
||||
noiseAuthenticatedLinkOwners.removeAll()
|
||||
noiseReconnectPolicy.removeAll()
|
||||
connectionScheduler.reset()
|
||||
}
|
||||
disconnectNotifyDebouncer.removeAll()
|
||||
@@ -784,9 +796,7 @@ final class BLEService: NSObject {
|
||||
}
|
||||
// Keep the transport silent until the application-level transaction
|
||||
// has also removed its media and committed both recovery markers.
|
||||
// Set through the identity store directly (not setNickname(_:), which
|
||||
// would force-send an announce and break that silence).
|
||||
localIdentityState.setNickname(currentNickname)
|
||||
myNickname = currentNickname
|
||||
messageDeduplicator.reset()
|
||||
messageQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.selfBroadcastTracker.removeAll()
|
||||
@@ -865,17 +875,20 @@ final class BLEService: NSObject {
|
||||
|
||||
// MARK: Identity
|
||||
|
||||
/// Derived from the Noise identity fingerprint. Reads can originate from
|
||||
/// the main actor, message queue, Bluetooth queue, and maintenance timer,
|
||||
/// so all three local identity fields live in one lock-backed snapshot.
|
||||
var myPeerID: PeerID { localIdentityState.snapshot().peerID }
|
||||
var myNickname: String { localIdentityState.snapshot().nickname }
|
||||
private var myPeerIDData: Data { localIdentityState.snapshot().peerIDData }
|
||||
/// Derived from the Noise identity fingerprint; rotated only via
|
||||
/// `refreshPeerIdentity()` (e.g. panic reset), which performs the swap
|
||||
/// inside a `messageQueue` barrier so concurrent queue work never sees a
|
||||
/// half-updated identity. Externally read-only — no out-of-band mutation
|
||||
/// may bypass that derivation.
|
||||
private(set) var myPeerID = PeerID(str: "")
|
||||
/// 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
|
||||
/// an announce so peers learn the new name.
|
||||
func setNickname(_ nickname: String) {
|
||||
localIdentityState.setNickname(nickname)
|
||||
self.myNickname = nickname
|
||||
// Send announce to notify peers of nickname change (force send)
|
||||
sendAnnounce(forceSend: true)
|
||||
}
|
||||
@@ -933,11 +946,10 @@ final class BLEService: NSObject {
|
||||
}
|
||||
|
||||
func stopServices() {
|
||||
let localIdentity = localIdentityState.snapshot()
|
||||
// Send leave message synchronously to ensure delivery
|
||||
var leavePacket = BitchatPacket(
|
||||
type: MessageType.leave.rawValue,
|
||||
senderID: localIdentity.peerIDData,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: Data(),
|
||||
@@ -1061,6 +1073,7 @@ final class BLEService: NSObject {
|
||||
bleQueue.sync {
|
||||
linkStateStore.clearAll()
|
||||
noiseAuthenticatedLinkOwners.removeAll()
|
||||
noiseReconnectPolicy.removeAll()
|
||||
connectionScheduler.reset()
|
||||
subscriptionAnnounceLimiter.removeAll()
|
||||
}
|
||||
@@ -1106,6 +1119,28 @@ final class BLEService: NSObject {
|
||||
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
|
||||
}
|
||||
|
||||
private func privateMediaPolicyFingerprint(
|
||||
for peerID: PeerID,
|
||||
expectedSessionGeneration: UUID?
|
||||
) -> String? {
|
||||
let normalizedPeerID = peerID.toShort()
|
||||
if let expectedSessionGeneration,
|
||||
noiseService.sessionGeneration(for: normalizedPeerID)
|
||||
== expectedSessionGeneration,
|
||||
let fingerprint = noiseService.getPeerFingerprint(normalizedPeerID),
|
||||
noiseService.sessionGeneration(for: normalizedPeerID)
|
||||
== expectedSessionGeneration {
|
||||
// The exact authenticated Noise static key is stronger than a
|
||||
// registry entry populated by a public announce.
|
||||
return fingerprint
|
||||
}
|
||||
return collectionsQueue.sync {
|
||||
peerRegistry.info(for: normalizedPeerID)?
|
||||
.noisePublicKey?
|
||||
.sha256Fingerprint()
|
||||
}
|
||||
}
|
||||
|
||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||||
let normalizedPeerID = peerID.toShort()
|
||||
let state: (
|
||||
@@ -1133,7 +1168,10 @@ final class BLEService: NSObject {
|
||||
return .awaitingCapabilityProof
|
||||
}
|
||||
|
||||
guard let fingerprint = state.fingerprint else {
|
||||
guard let fingerprint = privateMediaPolicyFingerprint(
|
||||
for: normalizedPeerID,
|
||||
expectedSessionGeneration: state.sessionGeneration
|
||||
) ?? state.fingerprint else {
|
||||
// A raw fallback must be bound to the stable Noise key from a
|
||||
// verified registry entry; a routing ID alone can rotate or be
|
||||
// spoofed. Without that key neither proof nor safe migration state
|
||||
@@ -1187,11 +1225,13 @@ final class BLEService: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
let fingerprint: String? = self.collectionsQueue.sync {
|
||||
self.peerRegistry.info(for: normalizedPeerID)?
|
||||
.noisePublicKey?
|
||||
.sha256Fingerprint()
|
||||
let generation = self.collectionsQueue.sync {
|
||||
self.privateMediaSessionGenerations[normalizedPeerID]
|
||||
}
|
||||
let fingerprint = self.privateMediaPolicyFingerprint(
|
||||
for: normalizedPeerID,
|
||||
expectedSessionGeneration: generation
|
||||
)
|
||||
guard let fingerprint else {
|
||||
self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade)
|
||||
return
|
||||
@@ -1950,26 +1990,15 @@ final class BLEService: NSObject {
|
||||
// Encode once using a small per-type padding policy, then delegate by type
|
||||
let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
|
||||
|
||||
// The 256-fragment ceiling exists to protect *current Android*
|
||||
// receivers, which only ever receive private media over the directed
|
||||
// raw-file migration fallback (they do not implement the encrypted
|
||||
// 0x20 path). Encrypted private media (`noiseEncrypted`) is sent only to
|
||||
// peers that advertised the `.privateMedia` capability — modern clients
|
||||
// that assemble up to the full receiver ceiling (see
|
||||
// `BLEFragmentAssemblyBuffer`'s 10,000-fragment guard) — so forcing them
|
||||
// down to Android's 256 cap would needlessly reject iOS→iOS photos in
|
||||
// the ~120–512 KiB range that work today. Restrict the low cap to the
|
||||
// migration fallback (directed `fileTransfer`); public media is
|
||||
// unaffected. Run the same planner the scheduler will use, after route
|
||||
// application, and reject before reserving a transfer slot or writing
|
||||
// any fragment.
|
||||
// TODO(#1434): negotiate an explicit per-peer fragment limit so a future
|
||||
// Android client that adopts the encrypted 0x20 path but still caps its
|
||||
// reassembler can advertise its own ceiling instead of relying on the
|
||||
// capability/type proxy above.
|
||||
// Cross-platform private-media v1 is bounded by Android's deployed
|
||||
// 256-fragment receive cap. Run the same planner the scheduler will
|
||||
// use, after route application, for both encrypted and consented raw
|
||||
// migration sends. Reject before reserving a transfer slot or writing
|
||||
// any fragment; public media is intentionally unaffected.
|
||||
if let transferId,
|
||||
let recipientPeerID = PeerID(hexData: packetToSend.recipientID),
|
||||
packetToSend.type == MessageType.fileTransfer.rawValue {
|
||||
packetToSend.type == MessageType.noiseEncrypted.rawValue
|
||||
|| packetToSend.type == MessageType.fileTransfer.rawValue {
|
||||
let compatibilityRequest = BLEOutboundFragmentTransferRequest(
|
||||
packet: packetToSend,
|
||||
pad: padForBLE,
|
||||
@@ -2525,12 +2554,50 @@ final class BLEService: NSObject {
|
||||
defaultPrefix: defaultPrefix
|
||||
)
|
||||
},
|
||||
privateMediaReceiptState: { [weak self] messageID in
|
||||
self?.incomingFileStore.privateMediaReceiptState(
|
||||
messageID: messageID
|
||||
) ?? .unavailable
|
||||
},
|
||||
commitPrivateMediaFile: { [weak self] messageID, storedURL in
|
||||
self?.incomingFileStore.commitPrivateMediaFile(
|
||||
messageID: messageID,
|
||||
storedURL: storedURL
|
||||
) ?? false
|
||||
},
|
||||
removeIncomingFile: { [weak self] storedURL in
|
||||
self?.incomingFileStore.removeIncomingFile(at: storedURL)
|
||||
},
|
||||
isPrivateMediaSenderBlocked: { [weak self] peerID in
|
||||
guard let self else { return false }
|
||||
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID)
|
||||
?? self.collectionsQueue.sync {
|
||||
self.peerRegistry.info(for: peerID)?.noisePublicKey
|
||||
}
|
||||
guard let senderStaticKey else { return false }
|
||||
return self.identityManager.isBlocked(
|
||||
fingerprint: senderStaticKey.sha256Fingerprint()
|
||||
)
|
||||
},
|
||||
updatePeerLastSeen: { [weak self] peerID in
|
||||
self?.updatePeerLastSeen(peerID)
|
||||
},
|
||||
deliverMessage: { [weak self] message in
|
||||
// Single main-actor hop delivering `.messageReceived`.
|
||||
self?.emitTransportEvent(.messageReceived(message))
|
||||
acknowledgePrivateMedia: { [weak self] messageID, peerID in
|
||||
guard let self,
|
||||
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID),
|
||||
!self.identityManager.isBlocked(
|
||||
fingerprint: senderStaticKey.sha256Fingerprint()
|
||||
) else {
|
||||
return
|
||||
}
|
||||
self.sendDeliveryAck(for: messageID, to: peerID)
|
||||
},
|
||||
deliverMessage: { [weak self] message, shouldDeliver, completion in
|
||||
self?.emitTransportEvent(
|
||||
.messageReceived(message),
|
||||
shouldDeliver: shouldDeliver,
|
||||
completion: completion
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -2628,6 +2695,7 @@ final class BLEService: NSObject {
|
||||
}
|
||||
for link in departedLinks {
|
||||
noiseAuthenticatedLinkOwners.removeValue(forKey: link)
|
||||
noiseReconnectPolicy.endLinkEpoch(link)
|
||||
}
|
||||
}
|
||||
_ = collectionsQueue.sync(flags: .barrier) {
|
||||
@@ -2649,19 +2717,6 @@ final class BLEService: NSObject {
|
||||
return true
|
||||
}
|
||||
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
|
||||
if !announceThrottle.shouldSend(force: forceSend, now: Date()) {
|
||||
@@ -2682,9 +2737,8 @@ final class BLEService: NSObject {
|
||||
)
|
||||
}
|
||||
|
||||
let localIdentity = localIdentityState.snapshot()
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: localIdentity.nickname,
|
||||
nickname: myNickname,
|
||||
noisePublicKey: noisePub,
|
||||
signingPublicKey: signingPub,
|
||||
directNeighbors: connectedPeerIDs,
|
||||
@@ -2700,7 +2754,7 @@ final class BLEService: NSObject {
|
||||
// Create packet with signature using the noise private key
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: localIdentity.peerIDData,
|
||||
senderID: myPeerIDData,
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
@@ -2714,7 +2768,14 @@ final class BLEService: NSObject {
|
||||
return
|
||||
}
|
||||
|
||||
broadcastPacket(signedPacket)
|
||||
// Call directly if on messageQueue, otherwise dispatch
|
||||
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
|
||||
gossipSyncManager?.onPublicPacketSeen(signedPacket)
|
||||
|
||||
@@ -2947,14 +3008,21 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
startScanning()
|
||||
|
||||
case .poweredOff:
|
||||
// Bluetooth was turned off - stop scanning and clean up connection state
|
||||
// CoreBluetooth has already transitioned out of poweredOn. Do
|
||||
// not issue stop/cancel commands now; they are rejected as API
|
||||
// misuse. Retire our link state locally instead.
|
||||
SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session)
|
||||
central.stopScan()
|
||||
// Mark all peripheral connections as disconnected (they are now invalid)
|
||||
let peripheralStates = linkStateStore.peripheralStates
|
||||
let peerIDs: [PeerID] = peripheralStates.compactMap(\.peerID)
|
||||
for state in peripheralStates {
|
||||
central.cancelPeripheralConnection(state.peripheral)
|
||||
let peripheralID = state.peripheral.identifier.uuidString
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
pendingPeripheralWrites.discardAll(for: peripheralID)
|
||||
}
|
||||
noiseAuthenticatedLinkOwners.removeValue(
|
||||
forKey: .peripheral(peripheralID)
|
||||
)
|
||||
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
|
||||
}
|
||||
_ = linkStateStore.clearPeripherals()
|
||||
// Notify UI of disconnections
|
||||
@@ -2967,7 +3035,6 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
case .unauthorized:
|
||||
// User denied Bluetooth permission
|
||||
SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session)
|
||||
central.stopScan()
|
||||
_ = linkStateStore.clearPeripherals()
|
||||
|
||||
case .unsupported:
|
||||
@@ -3115,6 +3182,7 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
pendingPeripheralWrites.discardAll(for: peripheralID)
|
||||
}
|
||||
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
||||
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
|
||||
_ = linkStateStore.removePeripheral(peripheralID)
|
||||
// A duplicate link can drop while the peer stays live on another
|
||||
// (the dual-role central link, or a second bound link after a
|
||||
@@ -3169,6 +3237,7 @@ extension BLEService: CBCentralManagerDelegate {
|
||||
pendingPeripheralWrites.discardAll(for: peripheralID)
|
||||
}
|
||||
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
||||
noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
|
||||
_ = linkStateStore.removePeripheral(peripheralID)
|
||||
|
||||
SecureLogger.error("❌ Failed to connect to peripheral: \(peripheral.name ?? "Unknown") [\(peripheralID)] - Error: \(error?.localizedDescription ?? "Unknown")", category: .session)
|
||||
@@ -3276,6 +3345,7 @@ extension BLEService {
|
||||
self.pendingPeripheralWrites.discardAll(for: peripheralID)
|
||||
}
|
||||
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
||||
self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
|
||||
_ = self.linkStateStore.removePeripheral(peripheralID)
|
||||
self.connectionScheduler.recordConnectionTimeout(peripheralID: peripheralID, at: Date())
|
||||
self.tryConnectFromQueue()
|
||||
@@ -3557,6 +3627,28 @@ extension BLEService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replays the current generation's ready callback. Restore tests use
|
||||
/// this to prove same-generation reconciliation is idempotent.
|
||||
func _test_reconcileCurrentNoiseSession(for peerID: PeerID) {
|
||||
let normalizedPeerID = peerID.toShort()
|
||||
messageQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self,
|
||||
let generation = self.noiseService.sessionGeneration(
|
||||
for: normalizedPeerID
|
||||
),
|
||||
let fingerprint = self.noiseService.getPeerFingerprint(
|
||||
normalizedPeerID
|
||||
) else {
|
||||
return
|
||||
}
|
||||
self.handleNoisePeerAuthenticated(
|
||||
peerID: normalizedPeerID,
|
||||
fingerprint: fingerprint,
|
||||
sessionGeneration: generation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an authenticated-session packet from an exact typed plaintext.
|
||||
/// Compatibility tests use this to model Android's deployed 0x20 file
|
||||
/// payload and the short-lived 0x09 prerelease payload without exposing a
|
||||
@@ -3856,8 +3948,19 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
case .poweredOff:
|
||||
// Bluetooth was turned off - clean up peripheral state
|
||||
SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session)
|
||||
peripheral.stopAdvertising()
|
||||
// Clear subscribed centrals (they are now invalid)
|
||||
let centralSnapshot = linkStateStore.subscribedCentralSnapshot
|
||||
for central in centralSnapshot.centrals {
|
||||
let centralID = central.identifier.uuidString
|
||||
noiseAuthenticatedLinkOwners.removeValue(
|
||||
forKey: .central(centralID)
|
||||
)
|
||||
noiseReconnectPolicy.endLinkEpoch(.central(centralID))
|
||||
}
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
pendingNotifications.removeAll()
|
||||
pendingWriteBuffers.removeAll()
|
||||
}
|
||||
let centralPeerIDs = linkStateStore.clearCentrals()
|
||||
subscriptionAnnounceLimiter.removeAll()
|
||||
characteristic = nil
|
||||
@@ -3871,7 +3974,6 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
case .unauthorized:
|
||||
// User denied Bluetooth permission
|
||||
SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session)
|
||||
peripheral.stopAdvertising()
|
||||
_ = linkStateStore.clearCentrals()
|
||||
subscriptionAnnounceLimiter.removeAll()
|
||||
characteristic = nil
|
||||
@@ -3984,6 +4086,7 @@ extension BLEService: CBPeripheralManagerDelegate {
|
||||
pendingNotifications.removeTarget { $0.identifier.uuidString == centralID }
|
||||
}
|
||||
noiseAuthenticatedLinkOwners.removeValue(forKey: .central(centralID))
|
||||
noiseReconnectPolicy.endLinkEpoch(.central(centralID))
|
||||
let removedPeerID = linkStateStore.removeSubscribedCentral(central)
|
||||
|
||||
// Ensure we're still advertising for other devices to find us
|
||||
@@ -4211,18 +4314,55 @@ extension BLEService {
|
||||
}
|
||||
}
|
||||
|
||||
private func emitTransportEvent(_ event: TransportEvent) {
|
||||
private func emitTransportEvent(
|
||||
_ event: TransportEvent,
|
||||
shouldDeliver: (() -> Bool)? = nil,
|
||||
completion: (() -> Void)? = nil
|
||||
) {
|
||||
notifyUI { [weak self] in
|
||||
self?.deliverTransportEvent(event)
|
||||
guard let self,
|
||||
shouldDeliver?() ?? true,
|
||||
self.deliverTransportEvent(event),
|
||||
// Quota cleanup can race the asynchronous main-actor hop or
|
||||
// the synchronous ConversationStore upsert. ACK only while
|
||||
// the exact durable mapping and file still resolve.
|
||||
shouldDeliver?() ?? true else {
|
||||
return
|
||||
}
|
||||
completion?()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func deliverTransportEvent(_ event: TransportEvent) {
|
||||
@discardableResult
|
||||
private func deliverTransportEvent(_ event: TransportEvent) -> Bool {
|
||||
if case .messageReceived(let message) = event {
|
||||
if let synchronousDelegate =
|
||||
eventDelegate as? SynchronousMessageTransportEventDelegate {
|
||||
return synchronousDelegate
|
||||
.didReceiveTransportMessageSynchronously(message)
|
||||
}
|
||||
if let eventDelegate {
|
||||
eventDelegate.didReceiveTransportEvent(event)
|
||||
return false
|
||||
}
|
||||
if let synchronousDelegate =
|
||||
delegate as? SynchronousMessageTransportEventDelegate {
|
||||
return synchronousDelegate
|
||||
.didReceiveTransportMessageSynchronously(message)
|
||||
}
|
||||
}
|
||||
|
||||
if let eventDelegate {
|
||||
eventDelegate.didReceiveTransportEvent(event)
|
||||
return true
|
||||
} else {
|
||||
delegate?.receiveTransportEvent(event)
|
||||
guard let delegate else { return false }
|
||||
delegate.receiveTransportEvent(event)
|
||||
if case .messageReceived = event {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4601,11 +4741,48 @@ extension BLEService {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A peer-level session can outlive the physical link that established it.
|
||||
/// Revalidate a fresh direct link with an ordinary XX exchange, retiring
|
||||
/// cached sending keys atomically before message 1 can leave.
|
||||
private func refreshNoiseSessionForVerifiedDirectLink(
|
||||
_ packet: BitchatPacket,
|
||||
peerID: PeerID
|
||||
) {
|
||||
guard let link = collectionsQueue.sync(execute: { ingressLinks.link(for: packet) }) else {
|
||||
return
|
||||
}
|
||||
|
||||
let hasEstablishedSession = noiseService.hasEstablishedSession(with: peerID)
|
||||
let authenticatedPeerLinks = currentNoiseAuthenticatedLinks(to: peerID)
|
||||
let shouldRevalidate = readLinkState { store in
|
||||
guard boundPeerID(for: link, in: store) == peerID else {
|
||||
return false
|
||||
}
|
||||
return noiseReconnectPolicy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: hasEstablishedSession,
|
||||
isNoiseAuthenticatedLink: noiseAuthenticatedLinkOwners[link] == peerID,
|
||||
hasAuthenticatedPeerLink: !authenticatedPeerLinks.isEmpty,
|
||||
now: Date()
|
||||
)
|
||||
}
|
||||
guard shouldRevalidate else { return }
|
||||
|
||||
SecureLogger.info(
|
||||
"🔄 Revalidating cached Noise session on fresh direct link to \(peerID.id.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
initiateNoiseReconnectHandshake(with: peerID)
|
||||
}
|
||||
|
||||
private func configureNoiseServiceCallbacks(for service: NoiseEncryptionService) {
|
||||
service.onPeerAuthenticatedWithGeneration = { [weak self] peerID, fingerprint, generation in
|
||||
SecureLogger.debug("🔐 Noise session authenticated with \(peerID.id.prefix(8))…, fingerprint: \(fingerprint.prefix(16))…")
|
||||
self?.messageQueue.async { [weak self] in
|
||||
// Authentication can be reported while an initiator is still
|
||||
// returning XX message 3. Serialize generation-bound state and
|
||||
// every post-handshake drain behind the handshake packet handler.
|
||||
self?.messageQueue.async(flags: .barrier) { [weak self] in
|
||||
self?.handleNoisePeerAuthenticated(
|
||||
peerID: peerID,
|
||||
fingerprint: fingerprint,
|
||||
@@ -4613,13 +4790,96 @@ extension BLEService {
|
||||
)
|
||||
}
|
||||
}
|
||||
service.onRekeyHandshakeReady = { [weak self] peerID, message in
|
||||
self?.messageQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
service.onRekeyHandshakeReady = {
|
||||
[weak self, weak service] peerID, initiation in
|
||||
self?.messageQueue.async(flags: .barrier) {
|
||||
[weak self, weak service] in
|
||||
guard let self,
|
||||
let service,
|
||||
self.noiseService === service else {
|
||||
return
|
||||
}
|
||||
self.noteNoiseSessionCleared(for: peerID)
|
||||
guard let message = service.claimHandshakeInitiation(
|
||||
initiation,
|
||||
for: peerID
|
||||
) else {
|
||||
return
|
||||
}
|
||||
self.broadcastNoiseHandshake(message, to: peerID)
|
||||
}
|
||||
}
|
||||
service.onHandshakeRecoveryRequired = {
|
||||
[weak self, weak service] request in
|
||||
guard let self, let service else { return }
|
||||
self.messageQueue.async(flags: .barrier) {
|
||||
[weak self, weak service] in
|
||||
guard let self,
|
||||
let service,
|
||||
self.noiseService === service else {
|
||||
return
|
||||
}
|
||||
let peerID = request.peerID
|
||||
guard self.isPeerReachable(peerID) else {
|
||||
service.cancelHandshakeRecovery(request)
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
guard let preparation =
|
||||
try service.prepareHandshakeRecovery(request) else {
|
||||
return
|
||||
}
|
||||
switch preparation {
|
||||
case .ordinary(let initiation):
|
||||
self.noteNoiseSessionCleared(for: peerID)
|
||||
guard let handshakeData =
|
||||
service.claimHandshakeInitiation(
|
||||
initiation,
|
||||
for: peerID
|
||||
) else {
|
||||
return
|
||||
}
|
||||
self.broadcastNoiseHandshake(
|
||||
handshakeData,
|
||||
to: peerID
|
||||
)
|
||||
case .transferred:
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error(
|
||||
"Failed to prepare handshake recovery with \(peerID.id.prefix(8))…: \(error)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
service.onSessionRestoredWithGeneration = { [weak self, weak service] peerID, generation in
|
||||
guard let self, let service else { return }
|
||||
// The manager makes restored keys visible atomically. Reconcile
|
||||
// transport state and queued sends as the next serialized phase.
|
||||
self.messageQueue.async(flags: .barrier) { [weak self, weak service] in
|
||||
guard let self,
|
||||
let service,
|
||||
self.noiseService === service,
|
||||
let fingerprint = service.getPeerFingerprint(peerID) else {
|
||||
return
|
||||
}
|
||||
SecureLogger.debug(
|
||||
"🔐 Restored quarantined Noise session with \(peerID.id.prefix(8))…",
|
||||
category: .session
|
||||
)
|
||||
// Re-enter the same generation-bound transition used after a
|
||||
// successful handshake. This restores authenticated protocol
|
||||
// state and drains both PM and typed-payload queues.
|
||||
self.handleNoisePeerAuthenticated(
|
||||
peerID: peerID,
|
||||
fingerprint: fingerprint,
|
||||
sessionGeneration: generation
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleNoisePeerAuthenticated(
|
||||
@@ -4667,7 +4927,16 @@ extension BLEService {
|
||||
}
|
||||
) else { return }
|
||||
|
||||
guard let watchdog = transition.watchdog else { return }
|
||||
guard let watchdog = transition.watchdog else {
|
||||
// A quarantined transport restored the same cryptographic
|
||||
// generation. Its capability proof and announce state never
|
||||
// became stale; only work queued while outbound keys were paused
|
||||
// needs one idempotent ready transition.
|
||||
noisePacketHandler.handleSessionAuthenticated(normalizedPeerID)
|
||||
sendPendingMessagesAfterHandshake(for: normalizedPeerID)
|
||||
sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID)
|
||||
return
|
||||
}
|
||||
|
||||
completePrivateMediaPolicyResolution(transition.rejected, with: .blockedDowngrade)
|
||||
schedulePrivateMediaProofTimeout(
|
||||
@@ -4676,6 +4945,10 @@ extension BLEService {
|
||||
sessionGeneration: generation,
|
||||
nonce: watchdog.nonce
|
||||
)
|
||||
// Cross-link delivery can put ciphertext sent immediately after
|
||||
// message 3 ahead of message 3 itself. Retry the bounded queue only
|
||||
// after this generation's transport state has been fully installed.
|
||||
noisePacketHandler.handleSessionAuthenticated(normalizedPeerID)
|
||||
|
||||
// `onPeerAuthenticated` can fire while the initiator is returning XX
|
||||
// message 3. This callback is queued behind the handshake handler, so
|
||||
@@ -4850,9 +5123,8 @@ extension BLEService {
|
||||
private func refreshPeerIdentity() {
|
||||
let swap = {
|
||||
let fingerprint = self.noiseService.getIdentityFingerprint()
|
||||
self.localIdentityState.replacePeerIdentity(
|
||||
with: PeerID(str: fingerprint.prefix(16))
|
||||
)
|
||||
self.myPeerID = PeerID(str: fingerprint.prefix(16))
|
||||
self.myPeerIDData = Data(hexString: self.myPeerID.id) ?? Data()
|
||||
self.meshTopology.reset()
|
||||
}
|
||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||
@@ -4874,8 +5146,9 @@ extension BLEService {
|
||||
}
|
||||
return
|
||||
}
|
||||
guard noiseService.hasSession(with: peerID) else {
|
||||
// No session yet - queue the payload SYNCHRONOUSLY before initiating handshake
|
||||
guard noiseService.hasEstablishedSession(with: peerID) else {
|
||||
// No established session yet - queue the payload synchronously
|
||||
// before initiating a handshake
|
||||
// to prevent race where fast handshake completion drains empty queue
|
||||
collectionsQueue.sync(flags: .barrier) {
|
||||
self.pendingNoiseSessionQueues.appendTypedPayload(typedPayload, for: peerID)
|
||||
@@ -5805,6 +6078,7 @@ extension BLEService {
|
||||
self.pendingPeripheralWrites.discardAll(for: peripheralID)
|
||||
}
|
||||
self.noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(peripheralID))
|
||||
self.noiseReconnectPolicy.endLinkEpoch(.peripheral(peripheralID))
|
||||
_ = self.linkStateStore.removePeripheral(peripheralID)
|
||||
cancelled += 1
|
||||
}
|
||||
@@ -5870,12 +6144,27 @@ extension BLEService {
|
||||
}
|
||||
|
||||
private func initiateNoiseHandshake(with peerID: PeerID) {
|
||||
// Use NoiseEncryptionService for handshake
|
||||
guard !noiseService.hasSession(with: peerID) else { return }
|
||||
|
||||
let service = noiseService
|
||||
do {
|
||||
let handshakeData = try noiseService.initiateHandshake(with: peerID)
|
||||
broadcastNoiseHandshake(handshakeData, to: peerID)
|
||||
guard let initiation = try service.initiateHandshakeIfNeeded(
|
||||
with: peerID,
|
||||
retryOnTimeout: true
|
||||
) else {
|
||||
return
|
||||
}
|
||||
messageQueue.async(flags: .barrier) {
|
||||
[weak self, weak service] in
|
||||
guard let self,
|
||||
let service,
|
||||
self.noiseService === service,
|
||||
let handshakeData = service.claimHandshakeInitiation(
|
||||
initiation,
|
||||
for: peerID
|
||||
) else {
|
||||
return
|
||||
}
|
||||
self.broadcastNoiseHandshake(handshakeData, to: peerID)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Failed to initiate handshake: \(error)")
|
||||
}
|
||||
@@ -5893,6 +6182,42 @@ extension BLEService {
|
||||
)
|
||||
broadcastPacket(packet)
|
||||
}
|
||||
|
||||
/// Starts a wire-compatible ordinary XX reconnect. The manager prepares
|
||||
/// the initiator before atomically retiring the cached transport; the
|
||||
/// one-shot claim prevents a crossed inbound message from making a stale
|
||||
/// message 1 leave after this peer has already become responder.
|
||||
private func initiateNoiseReconnectHandshake(with peerID: PeerID) {
|
||||
let service = noiseService
|
||||
do {
|
||||
let initiation = try service.initiateReconnectHandshake(
|
||||
with: peerID,
|
||||
retryOnTimeout: true
|
||||
)
|
||||
messageQueue.async(flags: .barrier) { [weak self, weak service] in
|
||||
guard let self,
|
||||
let service,
|
||||
self.noiseService === service else {
|
||||
return
|
||||
}
|
||||
self.noteNoiseSessionCleared(for: peerID)
|
||||
guard let handshakeData = service.claimHandshakeInitiation(
|
||||
initiation,
|
||||
for: peerID
|
||||
) else {
|
||||
return
|
||||
}
|
||||
self.broadcastNoiseHandshake(handshakeData, to: peerID)
|
||||
}
|
||||
} catch NoiseSessionError.notEstablished {
|
||||
initiateNoiseHandshake(with: peerID)
|
||||
} catch {
|
||||
SecureLogger.error(
|
||||
"Failed to initiate ordinary reconnect: \(error)",
|
||||
category: .session
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendPendingMessagesAfterHandshake(for peerID: PeerID) {
|
||||
// Atomically take all pending messages to process (prevents concurrent modification)
|
||||
@@ -6238,7 +6563,12 @@ extension BLEService {
|
||||
// MARK: Packet Reception
|
||||
|
||||
private func handleReceivedPacket(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||
// Call directly if already on messageQueue, otherwise dispatch
|
||||
let isNoisePacket = packet.type == MessageType.noiseHandshake.rawValue
|
||||
|| packet.type == MessageType.noiseEncrypted.rawValue
|
||||
|
||||
// Capture the panic lifecycle at the first off-messageQueue handoff.
|
||||
// Noise packets still enter through a barrier so handshake promotion,
|
||||
// quarantine, and encrypted delivery share one ordered session.
|
||||
if DispatchQueue.getSpecific(key: messageQueueKey) == nil {
|
||||
guard let lifecycleGeneration =
|
||||
capturePanicLifecycleGeneration() else {
|
||||
@@ -6247,7 +6577,8 @@ extension BLEService {
|
||||
#if DEBUG
|
||||
_test_beforeReceivePacketHandoff?()
|
||||
#endif
|
||||
messageQueue.async { [weak self] in
|
||||
let flags: DispatchWorkItemFlags = isNoisePacket ? .barrier : []
|
||||
messageQueue.async(flags: flags) { [weak self] in
|
||||
guard let self,
|
||||
self.isCurrentPanicLifecycleGeneration(
|
||||
lifecycleGeneration
|
||||
@@ -6257,11 +6588,34 @@ extension BLEService {
|
||||
#if DEBUG
|
||||
self._test_onReceivePacketHandoff?()
|
||||
#endif
|
||||
self.handleReceivedPacket(packet, from: peerID)
|
||||
self.handleReceivedPacketOnQueue(packet, from: peerID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if isNoisePacket {
|
||||
guard let lifecycleGeneration =
|
||||
capturePanicLifecycleGeneration() else {
|
||||
return
|
||||
}
|
||||
messageQueue.async(flags: .barrier) { [weak self] in
|
||||
guard let self,
|
||||
self.isCurrentPanicLifecycleGeneration(
|
||||
lifecycleGeneration
|
||||
) else {
|
||||
return
|
||||
}
|
||||
self.handleReceivedPacketOnQueue(packet, from: peerID)
|
||||
}
|
||||
} else {
|
||||
handleReceivedPacketOnQueue(packet, from: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleReceivedPacketOnQueue(
|
||||
_ packet: BitchatPacket,
|
||||
from peerID: PeerID
|
||||
) {
|
||||
let context = BLEReceivePipeline.context(for: packet, localPeerID: myPeerID)
|
||||
let senderID = context.senderID
|
||||
let messageID = context.messageID
|
||||
@@ -6448,6 +6802,9 @@ extension BLEService {
|
||||
// consolidate duplicate same-role connections onto that link.
|
||||
if let result, result.isVerified, result.isDirectAnnounce {
|
||||
rebindLinkAfterVerifiedDirectAnnounce(packet, to: result.peerID)
|
||||
#if DEBUG
|
||||
_test_afterVerifiedDirectRebindEnqueued?()
|
||||
#endif
|
||||
retireRedundantPeripheralLinks(packet, to: result.peerID)
|
||||
}
|
||||
|
||||
@@ -6481,11 +6838,9 @@ extension BLEService {
|
||||
deliverCourierMailRemotely(to: result.peerID, noiseKey: noiseKey)
|
||||
if result.isDirectAnnounce,
|
||||
!hasCurrentNoiseAuthenticatedLink(to: result.peerID) {
|
||||
if noiseService.hasEstablishedSession(with: result.peerID) {
|
||||
// A session with no surviving authenticated link is stale;
|
||||
// force the current link to prove possession again.
|
||||
clearNoiseSession(for: result.peerID)
|
||||
}
|
||||
// A cached session may predate this physical link.
|
||||
// rebindLinkAfterVerifiedDirectAnnounce performs its atomic
|
||||
// ordinary reconnect after the binding is published.
|
||||
if !noiseService.hasSession(with: result.peerID) {
|
||||
initiateNoiseHandshake(with: result.peerID)
|
||||
}
|
||||
@@ -6514,7 +6869,14 @@ extension BLEService {
|
||||
linkUUID = centralUUID
|
||||
previousPeerID = self.linkStateStore.peerID(forCentralUUID: centralUUID)
|
||||
}
|
||||
guard let previousPeerID, previousPeerID != peerID else { return }
|
||||
guard let previousPeerID else { return }
|
||||
guard previousPeerID != peerID else {
|
||||
self.refreshNoiseSessionForVerifiedDirectLink(
|
||||
packet,
|
||||
peerID: peerID
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// The signature does not authenticate directness (TTL is excluded
|
||||
// from signing because relays mutate it), so a "verified direct"
|
||||
@@ -6541,12 +6903,20 @@ extension BLEService {
|
||||
// it across an announce-driven rebind, whose direct TTL is
|
||||
// replayable; the new owner must complete a fresh handshake.
|
||||
self.noiseAuthenticatedLinkOwners.removeValue(forKey: link)
|
||||
self.noiseReconnectPolicy.endLinkEpoch(link)
|
||||
switch link {
|
||||
case .peripheral(let peripheralUUID):
|
||||
self.linkStateStore.bindPeripheral(peripheralUUID, to: peerID)
|
||||
case .central(let centralUUID):
|
||||
self.linkStateStore.bindCentral(centralUUID, to: peerID)
|
||||
}
|
||||
// Keep the rebind and reconnect decision in one bleQueue critical
|
||||
// section. No observer may see the new binding while a cached
|
||||
// peer-level sender is still considered established.
|
||||
self.refreshNoiseSessionForVerifiedDirectLink(
|
||||
packet,
|
||||
peerID: peerID
|
||||
)
|
||||
SecureLogger.debug("🔄 Rebinding link after peer-ID rotation: \(previousPeerID.id.prefix(8))… → \(peerID.id.prefix(8))…", category: .session)
|
||||
self.refreshLocalTopology()
|
||||
// The announce that triggered this rebind was upserted as
|
||||
@@ -6638,6 +7008,7 @@ extension BLEService {
|
||||
pendingPeripheralWrites.discardAll(for: uuid)
|
||||
}
|
||||
noiseAuthenticatedLinkOwners.removeValue(forKey: .peripheral(uuid))
|
||||
noiseReconnectPolicy.endLinkEpoch(.peripheral(uuid))
|
||||
_ = linkStateStore.removePeripheral(uuid)
|
||||
SecureLogger.info(
|
||||
"🔗 Retiring redundant link \(uuid.prefix(8))… bound to \(peerID.id.prefix(8))…\(keptUUID.map { " (keeping \($0.prefix(8))…)" } ?? "")",
|
||||
@@ -6706,21 +7077,9 @@ extension BLEService {
|
||||
},
|
||||
messageTTL: messageTTL,
|
||||
now: { Date() },
|
||||
existingPeerKeys: { [weak self] peerID in
|
||||
guard let self = self else { return (nil, nil) }
|
||||
return self.collectionsQueue.sync {
|
||||
let info = self.peerRegistry.info(for: peerID)
|
||||
return (info?.noisePublicKey, info?.signingPublicKey)
|
||||
}
|
||||
},
|
||||
persistedSigningPublicKey: { [weak self] peerID in
|
||||
// Same synchronous identity-manager read pattern as
|
||||
// signedSenderDisplayName(for:from:); the manager serializes
|
||||
// access on its own internal queue.
|
||||
existingNoisePublicKey: { [weak self] peerID in
|
||||
guard let self = self else { return nil }
|
||||
return self.identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
|
||||
.compactMap { $0.signingPublicKey }
|
||||
.first
|
||||
return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey }
|
||||
},
|
||||
authenticatedSigningPublicKey: { [weak self] noisePublicKey in
|
||||
self?.identityManager.authenticatedSigningPublicKey(
|
||||
@@ -6757,24 +7116,16 @@ extension BLEService {
|
||||
},
|
||||
upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in
|
||||
// Called from inside withRegistryBarrier; access registry directly.
|
||||
guard let self = self else {
|
||||
return BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||
}
|
||||
return self.peerRegistry.upsertVerifiedAnnounce(
|
||||
self?.peerRegistry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: announcement.nickname,
|
||||
noisePublicKey: announcement.noisePublicKey,
|
||||
signingPublicKey: announcement.signingPublicKey,
|
||||
isConnected: isConnected,
|
||||
// Propagate `nil` (registry refused the announce because it
|
||||
// carries a signing key different from the pinned one) so
|
||||
// the handler's guard rejects it instead of overwriting the
|
||||
// pinned identity. Main's capabilities/bridgeGeohash are
|
||||
// preserved.
|
||||
now: now,
|
||||
capabilities: announcement.capabilities,
|
||||
bridgeGeohash: announcement.bridgeGeohash
|
||||
)
|
||||
) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||
},
|
||||
shouldEmitReconnectLog: { [weak self] peerID, now in
|
||||
// Called from inside withRegistryBarrier; access debouncer directly.
|
||||
@@ -7044,6 +7395,12 @@ extension BLEService {
|
||||
packet,
|
||||
from: peerID
|
||||
)
|
||||
// An inbound message 1 quarantines the old transport receive-only.
|
||||
// Keep its generation-bound BLE state intact: the manager's new
|
||||
// handshaking generation already gates every outbound policy, while
|
||||
// a rollback can become ready again without repeating capability
|
||||
// proof or announce side effects. Only the exact handshake candidate's
|
||||
// authenticated completion may promote the physical ingress link.
|
||||
if result.didEstablishAuthenticatedSession {
|
||||
markNoiseAuthenticatedIngressLink(for: packet, peerID: peerID)
|
||||
}
|
||||
@@ -7081,6 +7438,11 @@ extension BLEService {
|
||||
hasNoiseSession: { [weak self] peerID in
|
||||
self?.noiseService.hasSession(with: peerID) ?? false
|
||||
},
|
||||
isAwaitingResponderHandshakeCompletion: { [weak self] peerID in
|
||||
self?.noiseService.isAwaitingResponderHandshakeCompletion(
|
||||
with: peerID
|
||||
) ?? false
|
||||
},
|
||||
initiateHandshake: { [weak self] peerID in
|
||||
self?.initiateNoiseHandshake(with: peerID)
|
||||
},
|
||||
@@ -7094,7 +7456,14 @@ extension BLEService {
|
||||
guard let self = self else { throw NoiseEncryptionError.sessionNotEstablished }
|
||||
let result = try self.noiseService.decryptWithSessionGeneration(
|
||||
payload,
|
||||
from: peerID
|
||||
from: peerID,
|
||||
establishedGenerationIsReady: { generation in
|
||||
self.collectionsQueue.sync {
|
||||
self.privateMediaSessionGenerations[
|
||||
peerID.toShort()
|
||||
] == generation
|
||||
}
|
||||
}
|
||||
)
|
||||
return BLENoiseDecryptionResult(
|
||||
plaintext: result.plaintext,
|
||||
|
||||
@@ -184,11 +184,17 @@ final class NoiseEncryptionService {
|
||||
private var onPeerAuthenticatedHandlers: [((PeerID, String) -> Void)] = [] // Array of handlers for peer authentication
|
||||
private var onPeerAuthenticatedWithGenerationHandlers: [((PeerID, String, UUID) -> Void)] = []
|
||||
var onHandshakeRequired: ((PeerID) -> Void)? // peerID needs handshake
|
||||
/// Automatic rekey removed the old session and produced XX message 1.
|
||||
/// The transport must clear session-scoped state and put these exact bytes
|
||||
/// on the wire; merely reporting "handshake required" strands the partial
|
||||
/// initiator session because a second initiate call sees it already exists.
|
||||
var onRekeyHandshakeReady: ((_ peerID: PeerID, _ message: Data) -> Void)?
|
||||
/// Automatic rekey prepared XX message 1. The transport must claim the
|
||||
/// exact attempt at its actual BLE handoff; a crossed inbound initiation
|
||||
/// can invalidate the token before that point.
|
||||
var onRekeyHandshakeReady:
|
||||
((_ peerID: PeerID, _ initiation: NoiseHandshakeInitiation) -> Void)?
|
||||
var onHandshakeRecoveryRequired:
|
||||
((_ request: NoiseHandshakeRecoveryRequest) -> Void)?
|
||||
/// An unauthenticated reconnect attempt failed or timed out and the
|
||||
/// receive-only rollback session became the active transport again.
|
||||
/// Transport queues must be drained for this exact restored generation.
|
||||
var onSessionRestoredWithGeneration: ((_ peerID: PeerID, _ generation: UUID) -> Void)?
|
||||
|
||||
// Add a handler for peer authentication
|
||||
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
|
||||
@@ -219,7 +225,17 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
}
|
||||
|
||||
init(keychain: KeychainManagerProtocol) {
|
||||
init(
|
||||
keychain: KeychainManagerProtocol,
|
||||
ordinaryHandshakeTimeout: TimeInterval =
|
||||
NoiseSecurityConstants.ordinaryHandshakeTimeout,
|
||||
ordinaryResponderHandshakeTimeout: TimeInterval =
|
||||
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout,
|
||||
recentInitiatorCompletionGracePeriod: TimeInterval =
|
||||
NoiseSecurityConstants.recentInitiatorCompletionGracePeriod,
|
||||
ordinaryReconnectRollbackCooldown: TimeInterval =
|
||||
NoiseSecurityConstants.ordinaryReconnectRollbackCooldown
|
||||
) {
|
||||
self.keychain = keychain
|
||||
self.localPrekeys = LocalPrekeyStore(keychain: keychain)
|
||||
|
||||
@@ -309,7 +325,17 @@ final class NoiseEncryptionService {
|
||||
self.signingPublicKey = signingKey.publicKey
|
||||
|
||||
// Initialize session manager
|
||||
self.sessionManager = NoiseSessionManager(localStaticKey: staticIdentityKey, keychain: keychain)
|
||||
self.sessionManager = NoiseSessionManager(
|
||||
localStaticKey: staticIdentityKey,
|
||||
keychain: keychain,
|
||||
ordinaryHandshakeTimeout: ordinaryHandshakeTimeout,
|
||||
ordinaryResponderHandshakeTimeout:
|
||||
ordinaryResponderHandshakeTimeout,
|
||||
recentInitiatorCompletionGracePeriod:
|
||||
recentInitiatorCompletionGracePeriod,
|
||||
ordinaryReconnectRollbackCooldown:
|
||||
ordinaryReconnectRollbackCooldown
|
||||
)
|
||||
|
||||
// Set up session callbacks
|
||||
sessionManager.onSessionEstablished = { [weak self] peerID, remoteStaticKey, generation in
|
||||
@@ -319,6 +345,12 @@ final class NoiseEncryptionService {
|
||||
sessionGeneration: generation
|
||||
)
|
||||
}
|
||||
sessionManager.onSessionRestored = { [weak self] peerID, generation in
|
||||
self?.onSessionRestoredWithGeneration?(peerID, generation)
|
||||
}
|
||||
sessionManager.onHandshakeRecoveryRequired = { [weak self] request in
|
||||
self?.onHandshakeRecoveryRequired?(request)
|
||||
}
|
||||
|
||||
// Start session maintenance timer
|
||||
startRekeyTimer()
|
||||
@@ -682,6 +714,90 @@ final class NoiseEncryptionService {
|
||||
let handshakeData = try sessionManager.initiateHandshake(with: peerID)
|
||||
return handshakeData
|
||||
}
|
||||
|
||||
/// Atomically admits and prepares one initial ordinary handshake. Returns
|
||||
/// nil when another discovery callback already created a session.
|
||||
func initiateHandshakeIfNeeded(
|
||||
with peerID: PeerID,
|
||||
retryOnTimeout: Bool = false
|
||||
) throws -> NoiseHandshakeInitiation? {
|
||||
guard peerID.isValid else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||
throw NoiseSecurityError.invalidPeerID
|
||||
}
|
||||
|
||||
guard let initiation = try sessionManager.initiateHandshakeIfAbsent(
|
||||
with: peerID,
|
||||
notifyOnTimeout: retryOnTimeout,
|
||||
authorize: { [rateLimiter] in
|
||||
guard rateLimiter.allowHandshake(from: peerID) else {
|
||||
SecureLogger.warning(
|
||||
.authenticationFailed(peerID: "Rate limited: \(peerID)")
|
||||
)
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
}
|
||||
) else {
|
||||
return nil
|
||||
}
|
||||
SecureLogger.info(.handshakeStarted(peerID: peerID.id))
|
||||
return initiation
|
||||
}
|
||||
|
||||
/// Atomically prepares an ordinary reconnect for a peer whose cached
|
||||
/// transport belongs to an earlier physical link. Failed authorization or
|
||||
/// handshake setup preserves the established session.
|
||||
func initiateReconnectHandshake(
|
||||
with peerID: PeerID,
|
||||
retryOnTimeout: Bool = false
|
||||
) throws -> NoiseHandshakeInitiation {
|
||||
guard peerID.isValid else {
|
||||
SecureLogger.warning(.authenticationFailed(peerID: peerID.id))
|
||||
throw NoiseSecurityError.invalidPeerID
|
||||
}
|
||||
|
||||
return try sessionManager.initiateReconnectHandshake(
|
||||
with: peerID,
|
||||
notifyOnTimeout: retryOnTimeout,
|
||||
authorize: { [rateLimiter] in
|
||||
guard rateLimiter.allowHandshake(from: peerID) else {
|
||||
SecureLogger.warning(
|
||||
.authenticationFailed(peerID: "Rate limited: \(peerID)")
|
||||
)
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func prepareHandshakeRecovery(
|
||||
_ request: NoiseHandshakeRecoveryRequest
|
||||
) throws -> NoiseHandshakeRecoveryPreparation? {
|
||||
try sessionManager.prepareHandshakeRecovery(
|
||||
request,
|
||||
authorizeAttempt: { [rateLimiter] in
|
||||
guard rateLimiter.allowHandshake(from: request.peerID) else {
|
||||
SecureLogger.warning(
|
||||
.authenticationFailed(
|
||||
peerID: "Rate limited: \(request.peerID)"
|
||||
)
|
||||
)
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func cancelHandshakeRecovery(_ request: NoiseHandshakeRecoveryRequest) {
|
||||
sessionManager.cancelHandshakeRecovery(request)
|
||||
}
|
||||
|
||||
func claimHandshakeInitiation(
|
||||
_ initiation: NoiseHandshakeInitiation,
|
||||
for peerID: PeerID
|
||||
) -> Data? {
|
||||
sessionManager.claimHandshakeInitiation(initiation, for: peerID)
|
||||
}
|
||||
|
||||
/// Process an incoming handshake message
|
||||
func processHandshakeMessage(from peerID: PeerID, message: Data) throws -> Data? {
|
||||
@@ -737,6 +853,13 @@ final class NoiseEncryptionService {
|
||||
func hasSession(with peerID: PeerID) -> Bool {
|
||||
return sessionManager.getSession(for: peerID) != nil
|
||||
}
|
||||
|
||||
/// True while an inbound ordinary XX responder is waiting for message 3.
|
||||
/// A small amount of immediately-following ciphertext may arrive first
|
||||
/// over BLE and must be retried only after responder promotion.
|
||||
func isAwaitingResponderHandshakeCompletion(with peerID: PeerID) -> Bool {
|
||||
sessionManager.isAwaitingResponderHandshakeCompletion(for: peerID)
|
||||
}
|
||||
|
||||
// MARK: - Encryption/Decryption
|
||||
|
||||
@@ -804,27 +927,37 @@ final class NoiseEncryptionService {
|
||||
|
||||
func decryptWithSessionGeneration(
|
||||
_ data: Data,
|
||||
from peerID: PeerID
|
||||
from peerID: PeerID,
|
||||
establishedGenerationIsReady: (UUID) -> Bool = { _ in true }
|
||||
) throws -> (plaintext: Data, sessionGeneration: UUID) {
|
||||
// Standard transport ciphertext has 20 bytes of nonce/tag overhead.
|
||||
// A larger candidate is admitted only up to the framed-file ceiling;
|
||||
// A larger ciphertext is admitted only up to the framed-file ceiling;
|
||||
// after authenticated decryption it must prove it is `.privateFile`.
|
||||
let isStandardCiphertext = NoiseSecurityValidator.validateCiphertextSize(data)
|
||||
guard isStandardCiphertext || NoiseSecurityValidator.validatePrivateFileCiphertextSize(data) else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
let isAdmittedCiphertext = isStandardCiphertext
|
||||
|| NoiseSecurityValidator.validatePrivateFileCiphertextSize(data)
|
||||
|
||||
// Check rate limit
|
||||
guard rateLimiter.allowMessage(from: peerID) else {
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
|
||||
// Check if we have an established session
|
||||
guard hasEstablishedSession(with: peerID) else {
|
||||
// A quarantined transport is deliberately unavailable for outbound
|
||||
// state, but remains receive-only until the responder proves identity
|
||||
// or the bounded rollback restores it.
|
||||
guard sessionManager.hasReceiveSession(for: peerID) else {
|
||||
throw NoiseEncryptionError.sessionNotEstablished
|
||||
}
|
||||
|
||||
let result = try sessionManager.decryptWithSessionGeneration(data, from: peerID)
|
||||
let result = try sessionManager.decryptWithSessionGeneration(
|
||||
data,
|
||||
from: peerID,
|
||||
establishedGenerationIsReady:
|
||||
establishedGenerationIsReady,
|
||||
authorizeDecrypt: { [rateLimiter] in
|
||||
guard isAdmittedCiphertext else {
|
||||
throw NoiseSecurityError.messageTooLarge
|
||||
}
|
||||
guard rateLimiter.allowMessage(from: peerID) else {
|
||||
throw NoiseSecurityError.rateLimitExceeded
|
||||
}
|
||||
}
|
||||
)
|
||||
if !isStandardCiphertext {
|
||||
guard NoisePayloadType.isPrivateFile(rawValue: result.plaintext.first),
|
||||
NoiseSecurityValidator.validatePrivateFileMessageSize(result.plaintext) else {
|
||||
@@ -943,9 +1076,9 @@ final class NoiseEncryptionService {
|
||||
}
|
||||
|
||||
private func initiateAutomaticRekey(for peerID: PeerID) throws {
|
||||
let handshakeMessage = try sessionManager.initiateRekey(for: peerID)
|
||||
let initiation = try sessionManager.initiateRekey(for: peerID)
|
||||
SecureLogger.debug("Key rotation initiated for peer: \(peerID)", category: .security)
|
||||
onRekeyHandshakeReady?(peerID, handshakeMessage)
|
||||
onRekeyHandshakeReady?(peerID, initiation)
|
||||
onHandshakeRequired?(peerID)
|
||||
}
|
||||
|
||||
@@ -1041,6 +1174,9 @@ struct NoiseMessage: Codable {
|
||||
enum NoiseEncryptionError: Error {
|
||||
case handshakeRequired
|
||||
case sessionNotEstablished
|
||||
/// Manager keys are established or restored, but BLE has not installed
|
||||
/// generation-bound transport state. No receive nonce was consumed.
|
||||
case transportGenerationNotReady
|
||||
/// Envelope references a prekey ID we don't hold (never ours, already
|
||||
/// deleted after its grace window, or wiped in a panic).
|
||||
case unknownPrekey
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum SharedContentKind: String, Codable, Sendable, Equatable {
|
||||
case text
|
||||
case url
|
||||
}
|
||||
|
||||
/// The single, bounded payload handed from the share extension to the app.
|
||||
///
|
||||
/// The app-group store intentionally contains at most one envelope. A newer
|
||||
/// share replaces an older one, which prevents unbounded shared-container
|
||||
/// growth while still surviving suspension and a later app launch.
|
||||
struct SharedContentPayload: Codable, Sendable, Equatable, Identifiable {
|
||||
static let currentVersion = 1
|
||||
static let maxContentBytes = 16_000
|
||||
static let maxTitleBytes = 512
|
||||
static let maxEnvelopeBytes = 24_000
|
||||
static let retentionSeconds: TimeInterval = 24 * 60 * 60
|
||||
static let allowedFutureSkewSeconds: TimeInterval = 5 * 60
|
||||
|
||||
let version: Int
|
||||
let id: UUID
|
||||
let kind: SharedContentKind
|
||||
let content: String
|
||||
let title: String?
|
||||
let createdAt: Date
|
||||
|
||||
init(
|
||||
version: Int = Self.currentVersion,
|
||||
id: UUID = UUID(),
|
||||
kind: SharedContentKind,
|
||||
content: String,
|
||||
title: String? = nil,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.version = version
|
||||
self.id = id
|
||||
self.kind = kind
|
||||
self.content = content
|
||||
self.title = title
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
|
||||
static func text(_ content: String, createdAt: Date = Date()) -> SharedContentPayload {
|
||||
SharedContentPayload(kind: .text, content: content, createdAt: createdAt)
|
||||
}
|
||||
|
||||
var composerText: String { content }
|
||||
|
||||
var preview: String {
|
||||
let normalized = content
|
||||
.replacingOccurrences(of: "\r\n", with: "\n")
|
||||
.replacingOccurrences(of: "\r", with: "\n")
|
||||
guard normalized.count > 240 else { return normalized }
|
||||
return String(normalized.prefix(240)) + "…"
|
||||
}
|
||||
|
||||
func validate(now: Date = Date()) throws {
|
||||
guard version == Self.currentVersion else {
|
||||
throw SharedContentHandoffError.unsupportedVersion
|
||||
}
|
||||
|
||||
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
throw SharedContentHandoffError.emptyContent
|
||||
}
|
||||
guard content.utf8.count <= Self.maxContentBytes else {
|
||||
throw SharedContentHandoffError.contentTooLarge
|
||||
}
|
||||
if let title {
|
||||
guard title.utf8.count <= Self.maxTitleBytes else {
|
||||
throw SharedContentHandoffError.titleTooLarge
|
||||
}
|
||||
guard !Self.containsDisallowedControl(in: title, allowsTextLayout: false) else {
|
||||
throw SharedContentHandoffError.invalidCharacters
|
||||
}
|
||||
}
|
||||
|
||||
let age = now.timeIntervalSince(createdAt)
|
||||
guard age >= -Self.allowedFutureSkewSeconds,
|
||||
age <= Self.retentionSeconds else {
|
||||
throw SharedContentHandoffError.expired
|
||||
}
|
||||
|
||||
switch kind {
|
||||
case .text:
|
||||
guard !Self.containsDisallowedControl(in: content, allowsTextLayout: true) else {
|
||||
throw SharedContentHandoffError.invalidCharacters
|
||||
}
|
||||
case .url:
|
||||
guard !Self.containsDisallowedControl(in: content, allowsTextLayout: false),
|
||||
let components = URLComponents(string: content),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
scheme == "http" || scheme == "https",
|
||||
components.host?.isEmpty == false else {
|
||||
throw SharedContentHandoffError.unsupportedURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func containsDisallowedControl(
|
||||
in value: String,
|
||||
allowsTextLayout: Bool
|
||||
) -> Bool {
|
||||
value.unicodeScalars.contains { scalar in
|
||||
guard CharacterSet.controlCharacters.contains(scalar) else { return false }
|
||||
if allowsTextLayout, scalar == "\n" || scalar == "\r" || scalar == "\t" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SharedContentHandoffError: Error, Equatable {
|
||||
case unsupportedVersion
|
||||
case emptyContent
|
||||
case contentTooLarge
|
||||
case titleTooLarge
|
||||
case invalidCharacters
|
||||
case expired
|
||||
case unsupportedURL
|
||||
case envelopeTooLarge
|
||||
case encodingFailed
|
||||
}
|
||||
|
||||
/// Durable, single-item app-group storage used by both the extension and app.
|
||||
final class SharedContentStore {
|
||||
static let storageKey = "sharedContentEnvelopeV1"
|
||||
|
||||
private static let legacyKeys = [
|
||||
"sharedContent",
|
||||
"sharedContentType",
|
||||
"sharedContentDate"
|
||||
]
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let encoder: JSONEncoder
|
||||
private let decoder: JSONDecoder
|
||||
|
||||
init(defaults: UserDefaults) {
|
||||
self.defaults = defaults
|
||||
self.encoder = JSONEncoder()
|
||||
self.decoder = JSONDecoder()
|
||||
}
|
||||
|
||||
/// Replaces any older pending share with a validated, bounded envelope.
|
||||
func stage(_ payload: SharedContentPayload, now: Date = Date()) throws {
|
||||
try payload.validate(now: now)
|
||||
guard let encoded = try? encoder.encode(payload) else {
|
||||
throw SharedContentHandoffError.encodingFailed
|
||||
}
|
||||
guard encoded.count <= SharedContentPayload.maxEnvelopeBytes else {
|
||||
throw SharedContentHandoffError.envelopeTooLarge
|
||||
}
|
||||
|
||||
defaults.set(encoded, forKey: Self.storageKey)
|
||||
clearLegacyKeys()
|
||||
}
|
||||
|
||||
/// Reads the pending share without consuming it. Invalid and expired data
|
||||
/// is removed immediately so malformed app-group state cannot linger.
|
||||
func pending(now: Date = Date()) -> SharedContentPayload? {
|
||||
clearLegacyKeys()
|
||||
|
||||
guard let encoded = defaults.data(forKey: Self.storageKey) else { return nil }
|
||||
guard encoded.count <= SharedContentPayload.maxEnvelopeBytes,
|
||||
let payload = try? decoder.decode(SharedContentPayload.self, from: encoded) else {
|
||||
defaults.removeObject(forKey: Self.storageKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
try payload.validate(now: now)
|
||||
return payload
|
||||
} catch {
|
||||
defaults.removeObject(forKey: Self.storageKey)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Consumes only the envelope the user actually reviewed. If a newer share
|
||||
/// already replaced it, the newer content remains pending.
|
||||
func consume(id: UUID, now: Date = Date()) -> SharedContentPayload? {
|
||||
guard let payload = pending(now: now), payload.id == id else { return nil }
|
||||
defaults.removeObject(forKey: Self.storageKey)
|
||||
return payload
|
||||
}
|
||||
|
||||
/// Explicit cancellation has the same identity guard as consumption so it
|
||||
/// can never discard a newer share that arrived while a prompt was open.
|
||||
func discard(id: UUID) {
|
||||
guard let encoded = defaults.data(forKey: Self.storageKey),
|
||||
encoded.count <= SharedContentPayload.maxEnvelopeBytes,
|
||||
let payload = try? decoder.decode(SharedContentPayload.self, from: encoded),
|
||||
payload.id == id else {
|
||||
return
|
||||
}
|
||||
defaults.removeObject(forKey: Self.storageKey)
|
||||
}
|
||||
|
||||
func discardAll() {
|
||||
defaults.removeObject(forKey: Self.storageKey)
|
||||
clearLegacyKeys()
|
||||
}
|
||||
|
||||
private func clearLegacyKeys() {
|
||||
for key in Self.legacyKeys {
|
||||
defaults.removeObject(forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,13 @@ protocol TransportEventDelegate: AnyObject {
|
||||
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
||||
}
|
||||
|
||||
/// Optional typed-event contract for sinks that can synchronously decide
|
||||
/// whether an inbound message was accepted.
|
||||
protocol SynchronousMessageTransportEventDelegate: TransportEventDelegate {
|
||||
@MainActor
|
||||
func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool
|
||||
}
|
||||
|
||||
protocol Transport: AnyObject {
|
||||
// Event sink
|
||||
var delegate: BitchatDelegate? { get set }
|
||||
|
||||
@@ -15,6 +15,13 @@ enum TransportConfig {
|
||||
static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5
|
||||
static let privateMediaCapabilityProofPendingPeerCap: Int = 64
|
||||
static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16
|
||||
/// Accepted private-media receipts and explicit-deletion tombstones each
|
||||
/// receive this independent capacity.
|
||||
static let privateMediaReceivedLedgerCapacity: Int = 4_096
|
||||
/// A bounded retry horizon prevents stable receipt state from growing into
|
||||
/// permanent application history.
|
||||
static let privateMediaReceivedLedgerTTLSeconds: TimeInterval =
|
||||
7 * 24 * 60 * 60
|
||||
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
||||
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
||||
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
|
||||
@@ -93,26 +100,6 @@ enum TransportConfig {
|
||||
static let nostrMaxEventTags: Int = 64
|
||||
static let nostrMaxEventTagValues: Int = 16
|
||||
static let nostrMaxEventTagValueBytes: Int = 1024
|
||||
// Bounded per-relay inbound frame buffer. Each relay connection owns its
|
||||
// own serial verify pipeline; if a relay floods faster than its Schnorr
|
||||
// verification drains, the oldest buffered frames for THAT relay are
|
||||
// dropped (bufferingNewest) so one relay cannot stall other relays.
|
||||
// Nostr inbound is already best-effort (relays are redundant and events
|
||||
// replay), so dropping a flooding relay's backlog is safe. Together with
|
||||
// nostrInboundMaxFrameBytes this caps buffered inbound bytes at
|
||||
// cap × maxFrameBytes (128 MiB) per hostile relay — bounded, not zero.
|
||||
static let nostrInboundPerRelayBufferCap: Int = 256
|
||||
// Hard per-frame byte bound, applied as URLSessionWebSocketTask
|
||||
// .maximumMessageSize (oversized frames fail the receive instead of
|
||||
// buffering). BitChat's legitimate Nostr traffic is small: geohash chat /
|
||||
// presence events (kind 20000/20001), kind-1 notes, and NIP-17
|
||||
// gift-wrapped DMs carrying text payloads or receipts are all a few KiB,
|
||||
// and most public relays reject events beyond ~64–256 KiB anyway. 512 KiB
|
||||
// leaves an order-of-magnitude margin over anything we produce or expect
|
||||
// while halving the URLSession default (1 MiB), so a hostile relay's
|
||||
// worst-case buffered pile-up per connection is
|
||||
// nostrInboundPerRelayBufferCap × 512 KiB = 128 MiB instead of 256 MiB.
|
||||
static let nostrInboundMaxFrameBytes: Int = 512 * 1024
|
||||
|
||||
// Conversation store diagnostics (field observability)
|
||||
// Sample interval for the periodic store-audit "OK" heartbeat line
|
||||
@@ -351,6 +338,7 @@ enum TransportConfig {
|
||||
|
||||
// Share extension
|
||||
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
|
||||
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
|
||||
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
|
||||
|
||||
// Gossip Sync Configuration
|
||||
|
||||
@@ -10,6 +10,7 @@ import Foundation
|
||||
@MainActor
|
||||
protocol ChatLiveVoiceContext: AnyObject {
|
||||
var nickname: String { get }
|
||||
var myPeerID: PeerID { get }
|
||||
var selectedPrivateChatPeer: PeerID? { get }
|
||||
/// Whether the public mesh timeline is what's on screen (autoplay gate
|
||||
/// for public bursts).
|
||||
@@ -30,6 +31,12 @@ protocol ChatLiveVoiceContext: AnyObject {
|
||||
func upsertPublicMeshMessage(_ message: BitchatMessage)
|
||||
@discardableResult
|
||||
func removePrivateMessage(withID messageID: String) -> BitchatMessage?
|
||||
/// Records and sends the finalized note's read receipt after a live
|
||||
/// bubble adopts its wire-derivable message ID.
|
||||
func hasSentReadReceipt(_ messageID: String) -> Bool
|
||||
@discardableResult
|
||||
func markReadReceiptSent(_ messageID: String) -> Bool
|
||||
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||
/// Removes a message from whichever conversation holds it.
|
||||
func removeMessage(withID messageID: String, cleanupFile: Bool)
|
||||
/// Publishes who is currently talking live in the public mesh channel
|
||||
@@ -272,8 +279,16 @@ final class ChatLiveVoiceCoordinator {
|
||||
guard let entry = finishedBursts.first(where: { matches($0.key) }) else { return false }
|
||||
let finished = entry.value
|
||||
|
||||
// A DM live bubble starts before the finalized file exists and
|
||||
// therefore has a receiver-local random ID. Adopt the finalized
|
||||
// message's deterministic ID so delivery/read ACKs address the same
|
||||
// row as the sender's media placeholder. Public notes retain their
|
||||
// live-bubble ID because public transfers have no private receipts.
|
||||
let replacementID = finished.scope == .directMessage
|
||||
? message.id
|
||||
: finished.messageID
|
||||
let replacement = BitchatMessage(
|
||||
id: finished.messageID,
|
||||
id: replacementID,
|
||||
sender: message.sender,
|
||||
content: message.content,
|
||||
timestamp: finished.messageTimestamp,
|
||||
@@ -287,7 +302,31 @@ final class ChatLiveVoiceCoordinator {
|
||||
)
|
||||
switch finished.scope {
|
||||
case .directMessage:
|
||||
// Capture read state before rekeying. The user may have read the
|
||||
// live bubble and navigated away before the finalized .m4a lands.
|
||||
let shouldSendAdoptedReadReceipt =
|
||||
context.hasSentReadReceipt(finished.messageID)
|
||||
|| context.selectedPrivateChatPeer == finished.peerID
|
||||
|
||||
// Insert first so replacing the only row in a DM never
|
||||
// transiently deletes its conversation, unread state, or current
|
||||
// selection. Then remove the receiver-local live-bubble alias.
|
||||
context.upsertPrivateMessage(replacement, in: finished.peerID)
|
||||
if replacementID != finished.messageID {
|
||||
context.removePrivateMessage(withID: finished.messageID)
|
||||
}
|
||||
// The live bubble may already have emitted a receiver-local READ
|
||||
// before the sender created its finalized media row. Re-emit once
|
||||
// for the adopted stable ID now that the file has arrived.
|
||||
if shouldSendAdoptedReadReceipt,
|
||||
context.markReadReceiptSent(replacementID) {
|
||||
let receipt = ReadReceipt(
|
||||
originalMessageID: replacementID,
|
||||
readerID: context.myPeerID,
|
||||
readerNickname: context.nickname
|
||||
)
|
||||
context.sendMeshReadReceipt(receipt, to: finished.peerID)
|
||||
}
|
||||
case .publicMesh:
|
||||
context.upsertPublicMeshMessage(replacement)
|
||||
}
|
||||
|
||||
@@ -252,9 +252,17 @@ final class ChatMediaTransferCoordinator {
|
||||
}
|
||||
|
||||
let targetPeer = context.selectedPrivateChatPeer
|
||||
let privateMessageID = targetPeer.flatMap { peerID in
|
||||
PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: context.myPeerID,
|
||||
recipientPeerID: peerID,
|
||||
fileName: url.lastPathComponent
|
||||
)
|
||||
}
|
||||
let message = enqueueMediaMessage(
|
||||
content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)",
|
||||
targetPeer: targetPeer
|
||||
targetPeer: targetPeer,
|
||||
messageID: privateMessageID
|
||||
)
|
||||
let messageID = message.id
|
||||
let transferId = makeTransferID(messageID: messageID)
|
||||
@@ -419,9 +427,17 @@ final class ChatMediaTransferCoordinator {
|
||||
try? FileManager.default.removeItem(at: prepared.outputURL)
|
||||
return
|
||||
}
|
||||
let privateMessageID = targetPeer.flatMap { peerID in
|
||||
PrivateMediaMessageIdentity.stableID(
|
||||
for: prepared.packet,
|
||||
senderPeerID: self.context.myPeerID,
|
||||
recipientPeerID: peerID
|
||||
)
|
||||
}
|
||||
let message = self.enqueueMediaMessage(
|
||||
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
|
||||
targetPeer: targetPeer
|
||||
targetPeer: targetPeer,
|
||||
messageID: privateMessageID
|
||||
)
|
||||
let messageID = message.id
|
||||
let transferId = self.makeTransferID(messageID: messageID)
|
||||
@@ -459,12 +475,17 @@ final class ChatMediaTransferCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage {
|
||||
func enqueueMediaMessage(
|
||||
content: String,
|
||||
targetPeer: PeerID?,
|
||||
messageID: String? = nil
|
||||
) -> BitchatMessage {
|
||||
let timestamp = Date()
|
||||
let message: BitchatMessage
|
||||
|
||||
if let peerID = targetPeer {
|
||||
message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: context.nickname,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
|
||||
@@ -100,9 +100,14 @@ final class ChatPeerListCoordinator: @unchecked Sendable {
|
||||
|
||||
func didUpdatePeerList(_ peers: [PeerID]) {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handlePeerListUpdate(peers)
|
||||
self?.didUpdatePeerListSynchronously(peers)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func didUpdatePeerListSynchronously(_ peers: [PeerID]) {
|
||||
handlePeerListUpdate(peers)
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChatPeerListCoordinator {
|
||||
|
||||
@@ -163,21 +163,20 @@ final class ChatTransportEventCoordinator {
|
||||
}
|
||||
|
||||
func didReceiveMessage(_ message: BitchatMessage) {
|
||||
runOnMain { context in
|
||||
guard !context.isMessageBlocked(message) else { return }
|
||||
guard !message.content.trimmed.isEmpty || message.isPrivate else { return }
|
||||
|
||||
if message.isPrivate {
|
||||
context.handlePrivateMessage(message)
|
||||
} else {
|
||||
context.handlePublicMessage(message)
|
||||
}
|
||||
|
||||
context.checkForMentions(message)
|
||||
context.sendHapticFeedback(for: message)
|
||||
runOnMain { [self] context in
|
||||
handleReceivedMessage(message, in: context)
|
||||
}
|
||||
}
|
||||
|
||||
/// Typed transport events already arrive on the main actor. Handle them
|
||||
/// synchronously so observers see the ConversationStore mutation before
|
||||
/// the transport completes delivery.
|
||||
@MainActor
|
||||
@discardableResult
|
||||
func didReceiveMessageSynchronously(_ message: BitchatMessage) -> Bool {
|
||||
handleReceivedMessage(message, in: context)
|
||||
}
|
||||
|
||||
func didReceivePublicMessage(
|
||||
from peerID: PeerID,
|
||||
nickname: String,
|
||||
@@ -185,28 +184,36 @@ final class ChatTransportEventCoordinator {
|
||||
timestamp: Date,
|
||||
messageID: String?
|
||||
) {
|
||||
runOnMain { context in
|
||||
let normalized = content.trimmed
|
||||
let mentions = context.parseMentions(from: normalized)
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: nickname,
|
||||
content: normalized,
|
||||
runOnMain { [self] context in
|
||||
handlePublicMessage(
|
||||
from: peerID,
|
||||
nickname: nickname,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID,
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
messageID: messageID,
|
||||
in: context
|
||||
)
|
||||
|
||||
context.handlePublicMessage(message)
|
||||
context.checkForMentions(message)
|
||||
context.sendHapticFeedback(for: message)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func didReceivePublicMessageSynchronously(
|
||||
from peerID: PeerID,
|
||||
nickname: String,
|
||||
content: String,
|
||||
timestamp: Date,
|
||||
messageID: String?
|
||||
) {
|
||||
handlePublicMessage(
|
||||
from: peerID,
|
||||
nickname: nickname,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
messageID: messageID,
|
||||
in: context
|
||||
)
|
||||
}
|
||||
|
||||
func didReceiveNoisePayload(
|
||||
from peerID: PeerID,
|
||||
type: NoisePayloadType,
|
||||
@@ -224,59 +231,134 @@ final class ChatTransportEventCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func didReceiveNoisePayloadSynchronously(
|
||||
from peerID: PeerID,
|
||||
type: NoisePayloadType,
|
||||
payload: Data,
|
||||
timestamp: Date
|
||||
) {
|
||||
handleNoisePayload(
|
||||
from: peerID,
|
||||
type: type,
|
||||
payload: payload,
|
||||
timestamp: timestamp,
|
||||
in: context
|
||||
)
|
||||
}
|
||||
|
||||
func didConnectToPeer(_ peerID: PeerID) {
|
||||
SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
|
||||
|
||||
runOnMain { context in
|
||||
context.isConnected = true
|
||||
context.registerEphemeralSession(peerID: peerID)
|
||||
context.notifyUIChanged()
|
||||
|
||||
if let peer = context.unifiedPeer(for: peerID) {
|
||||
let stablePeerID = PeerID(hexData: peer.noisePublicKey)
|
||||
context.cacheStablePeerID(stablePeerID, for: peerID)
|
||||
}
|
||||
|
||||
context.flushRouterOutbox(for: peerID)
|
||||
context.retryCourierDeposits(via: peerID)
|
||||
runOnMain { [weak self] _ in
|
||||
self?.didConnectToPeerSynchronously(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func didConnectToPeerSynchronously(_ peerID: PeerID) {
|
||||
SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
|
||||
|
||||
context.isConnected = true
|
||||
context.registerEphemeralSession(peerID: peerID)
|
||||
context.notifyUIChanged()
|
||||
|
||||
if let peer = context.unifiedPeer(for: peerID) {
|
||||
let stablePeerID = PeerID(hexData: peer.noisePublicKey)
|
||||
context.cacheStablePeerID(stablePeerID, for: peerID)
|
||||
}
|
||||
|
||||
context.flushRouterOutbox(for: peerID)
|
||||
context.retryCourierDeposits(via: peerID)
|
||||
}
|
||||
|
||||
func didDisconnectFromPeer(_ peerID: PeerID) {
|
||||
runOnMain { [weak self] _ in
|
||||
self?.didDisconnectFromPeerSynchronously(peerID)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func didDisconnectFromPeerSynchronously(_ peerID: PeerID) {
|
||||
SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session)
|
||||
|
||||
runOnMain { context in
|
||||
context.removeEphemeralSession(peerID: peerID)
|
||||
context.removeEphemeralSession(peerID: peerID)
|
||||
|
||||
var stablePeerID = context.cachedStablePeerID(for: peerID)
|
||||
if stablePeerID == nil,
|
||||
let key = context.noiseSessionPublicKeyData(for: peerID) {
|
||||
let derivedPeerID = PeerID(hexData: key)
|
||||
context.cacheStablePeerID(derivedPeerID, for: peerID)
|
||||
stablePeerID = derivedPeerID
|
||||
}
|
||||
|
||||
if let currentPeerID = context.selectedPrivateChatPeer,
|
||||
currentPeerID == peerID,
|
||||
let stablePeerID {
|
||||
self.migrateSelectedConversationIfNeeded(
|
||||
from: peerID,
|
||||
to: stablePeerID,
|
||||
in: context
|
||||
)
|
||||
}
|
||||
|
||||
let receiptIDs = context.privateMessages(for: peerID)
|
||||
.filter { $0.senderPeerID == peerID }
|
||||
.map(\.id)
|
||||
context.unmarkReadReceiptsSent(receiptIDs)
|
||||
|
||||
context.notifyUIChanged()
|
||||
var stablePeerID = context.cachedStablePeerID(for: peerID)
|
||||
if stablePeerID == nil,
|
||||
let key = context.noiseSessionPublicKeyData(for: peerID) {
|
||||
let derivedPeerID = PeerID(hexData: key)
|
||||
context.cacheStablePeerID(derivedPeerID, for: peerID)
|
||||
stablePeerID = derivedPeerID
|
||||
}
|
||||
|
||||
if let currentPeerID = context.selectedPrivateChatPeer,
|
||||
currentPeerID == peerID,
|
||||
let stablePeerID {
|
||||
migrateSelectedConversationIfNeeded(
|
||||
from: peerID,
|
||||
to: stablePeerID,
|
||||
in: context
|
||||
)
|
||||
}
|
||||
|
||||
let receiptIDs = context.privateMessages(for: peerID)
|
||||
.filter { $0.senderPeerID == peerID }
|
||||
.map(\.id)
|
||||
context.unmarkReadReceiptsSent(receiptIDs)
|
||||
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
}
|
||||
|
||||
private extension ChatTransportEventCoordinator {
|
||||
@MainActor
|
||||
func handlePublicMessage(
|
||||
from peerID: PeerID,
|
||||
nickname: String,
|
||||
content: String,
|
||||
timestamp: Date,
|
||||
messageID: String?,
|
||||
in context: any ChatTransportEventContext
|
||||
) {
|
||||
let normalized = content.trimmed
|
||||
let mentions = context.parseMentions(from: normalized)
|
||||
let message = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: nickname,
|
||||
content: normalized,
|
||||
timestamp: timestamp,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: peerID,
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
|
||||
context.handlePublicMessage(message)
|
||||
context.checkForMentions(message)
|
||||
context.sendHapticFeedback(for: message)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@discardableResult
|
||||
func handleReceivedMessage(
|
||||
_ message: BitchatMessage,
|
||||
in context: any ChatTransportEventContext
|
||||
) -> Bool {
|
||||
guard !context.isMessageBlocked(message) else { return false }
|
||||
guard !message.content.trimmed.isEmpty || message.isPrivate else { return false }
|
||||
|
||||
if message.isPrivate {
|
||||
context.handlePrivateMessage(message)
|
||||
} else {
|
||||
context.handlePublicMessage(message)
|
||||
}
|
||||
|
||||
context.checkForMentions(message)
|
||||
context.sendHapticFeedback(for: message)
|
||||
return true
|
||||
}
|
||||
|
||||
func runOnMain(_ action: @escaping @MainActor (any ChatTransportEventContext) -> Void) {
|
||||
Task { @MainActor [weak context = self.context] in
|
||||
guard let context else { return }
|
||||
|
||||
@@ -112,7 +112,7 @@ struct PanicNetworkLifecycle {
|
||||
/// Manages the application state and business logic for BitChat.
|
||||
/// Acts as the primary coordinator between UI components and backend services,
|
||||
/// implementing the BitchatDelegate protocol to handle network events.
|
||||
final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext {
|
||||
final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessageTransportEventDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext {
|
||||
// Use MessageFormattingEngine.Patterns for regex matching (shared, precompiled)
|
||||
typealias Patterns = MessageFormattingEngine.Patterns
|
||||
|
||||
@@ -495,6 +495,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a read receipt has already been recorded for `messageID`.
|
||||
@MainActor
|
||||
func hasSentReadReceipt(_ messageID: String) -> Bool {
|
||||
sentReadReceipts.contains(messageID)
|
||||
}
|
||||
|
||||
/// Records that a read receipt is being sent for `messageID`.
|
||||
/// Returns `false` when one was already recorded — the caller must skip sending.
|
||||
@MainActor
|
||||
@@ -1337,15 +1343,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
// posts are signed with our identity key and persist for days.
|
||||
BoardStore.shared.wipe()
|
||||
|
||||
// Drop any share-extension handoff staged in the app group. The normal
|
||||
// panic path clears this through AppChromeModel.onPanicWipe, but the
|
||||
// crash-recovery replay calls this method directly and would otherwise
|
||||
// let a staged envelope survive the wipe. Clearing here is idempotent
|
||||
// (it only removes the app-group key), so the double-clear is harmless.
|
||||
if let sharedDefaults = UserDefaults(suiteName: BitchatApp.groupID) {
|
||||
SharedContentStore(defaults: sharedDefaults).discardAll()
|
||||
}
|
||||
|
||||
// Identity manager has cleared persisted identity data above
|
||||
|
||||
// Clear autocomplete state
|
||||
@@ -1376,11 +1373,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
|
||||
// disconnect is not enough here.
|
||||
NostrRelayManager.shared.resetForPanicWipe()
|
||||
// Clearing relay handlers stops NEW events, but a detached gift-wrap
|
||||
// decrypt spawned just before the wipe still holds a pre-wipe key and
|
||||
// ciphertext; bump the pipeline's wipe generation so its result is
|
||||
// dropped at the main-actor delivery hop instead of landing here.
|
||||
nostrCoordinator.inbound.invalidateInFlightDecrypts()
|
||||
nostrRelayManager = nil
|
||||
|
||||
// Clear Nostr identity associations
|
||||
@@ -1715,7 +1707,81 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
|
||||
@MainActor
|
||||
func didReceiveTransportEvent(_ event: TransportEvent) {
|
||||
receiveTransportEvent(event)
|
||||
switch event {
|
||||
case .messageReceived(let message):
|
||||
_ = didReceiveTransportMessageSynchronously(message)
|
||||
|
||||
case let .publicMessageReceived(
|
||||
peerID,
|
||||
nickname,
|
||||
content,
|
||||
timestamp,
|
||||
messageID
|
||||
):
|
||||
transportEventCoordinator.didReceivePublicMessageSynchronously(
|
||||
from: peerID,
|
||||
nickname: nickname,
|
||||
content: content,
|
||||
timestamp: timestamp,
|
||||
messageID: messageID
|
||||
)
|
||||
|
||||
case let .noisePayloadReceived(peerID, type, payload, timestamp):
|
||||
transportEventCoordinator.didReceiveNoisePayloadSynchronously(
|
||||
from: peerID,
|
||||
type: type,
|
||||
payload: payload,
|
||||
timestamp: timestamp
|
||||
)
|
||||
|
||||
case let .groupMessageReceived(payload, timestamp):
|
||||
groupCoordinator.handleGroupMessagePayload(
|
||||
payload,
|
||||
timestamp: timestamp
|
||||
)
|
||||
|
||||
case let .publicVoiceFrameReceived(
|
||||
peerID,
|
||||
nickname,
|
||||
payload,
|
||||
timestamp
|
||||
):
|
||||
liveVoiceCoordinator.handlePublicVoiceFramePayload(
|
||||
from: peerID,
|
||||
nickname: nickname,
|
||||
payload: payload,
|
||||
timestamp: timestamp
|
||||
)
|
||||
|
||||
case .peerConnected(let peerID):
|
||||
transportEventCoordinator.didConnectToPeerSynchronously(peerID)
|
||||
|
||||
case .peerDisconnected(let peerID):
|
||||
transportEventCoordinator.didDisconnectFromPeerSynchronously(peerID)
|
||||
|
||||
case .peerListUpdated(let peers):
|
||||
peerListCoordinator.didUpdatePeerListSynchronously(peers)
|
||||
// A peer-list update follows every verified announce, which is
|
||||
// where a peer's `.vouch` capability actually arrives.
|
||||
vouchCoordinator.peersUpdated(peers)
|
||||
|
||||
case .peerSnapshotsUpdated:
|
||||
break
|
||||
|
||||
case let .messageDeliveryStatusUpdated(messageID, status):
|
||||
deliveryCoordinator.didUpdateMessageDeliveryStatus(
|
||||
messageID,
|
||||
status: status
|
||||
)
|
||||
|
||||
case .bluetoothStateUpdated(let state):
|
||||
updateBluetoothState(state)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func didReceiveTransportMessageSynchronously(_ message: BitchatMessage) -> Bool {
|
||||
transportEventCoordinator.didReceiveMessageSynchronously(message)
|
||||
}
|
||||
|
||||
func didReceiveMessage(_ message: BitchatMessage) {
|
||||
|
||||
@@ -103,8 +103,7 @@ final class GeoPresenceTracker {
|
||||
else {
|
||||
return
|
||||
}
|
||||
// The signature was already verified (exactly once, off the main
|
||||
// actor) by NostrRelayManager before delivery.
|
||||
guard event.isValidSignature() else { return }
|
||||
guard shouldProcessGeoSamplingEvent(event.id) else { return }
|
||||
|
||||
let existingCount = context.geoParticipantCount(for: gh)
|
||||
|
||||
@@ -78,38 +78,20 @@ extension ChatViewModel: NostrInboundPipelineContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// The inbound Nostr hot path: verified relay events in, chat messages /
|
||||
/// Noise payloads out. Pure transformation plus dedup — no relay lifecycle.
|
||||
/// The inbound Nostr hot path: raw relay events in, chat messages / Noise
|
||||
/// payloads out. Pure transformation plus dedup — no relay lifecycle.
|
||||
///
|
||||
/// Every event arriving here already had its Schnorr signature verified
|
||||
/// exactly once, off the main actor, by `NostrRelayManager`'s serial inbound
|
||||
/// pipeline (which records events into its own dedup cache only AFTER
|
||||
/// verification, so forged copies can't suppress genuine events). This
|
||||
/// pipeline therefore never re-verifies; it keeps its own event-ID dedup
|
||||
/// (cheap main-actor lookups) and moves NIP-17 gift-wrap decryption — two
|
||||
/// ECDH+ChaCha layers — off the main actor with an atomic main-actor
|
||||
/// check-and-record.
|
||||
/// Ordering is deliberate and performance-critical: cheap rejects (kind,
|
||||
/// dedup lookup) run BEFORE Schnorr signature verification because duplicates
|
||||
/// dominate real relay traffic; events are recorded only AFTER verification so
|
||||
/// a forged-signature copy can never poison the dedup set; gift-wrap
|
||||
/// verification for the account mailbox runs off-main with an atomic
|
||||
/// main-actor check-and-record.
|
||||
final class NostrInboundPipeline {
|
||||
private weak var context: (any NostrInboundPipelineContext)?
|
||||
private let presence: GeoPresenceTracker
|
||||
private var geoEventLogCount = 0
|
||||
|
||||
/// Monotonic panic-wipe generation for this pipeline. A panic wipe clears
|
||||
/// relay handlers so no NEW events flow, but a detached decrypt task
|
||||
/// spawned just BEFORE the wipe — which strongly captures a pre-wipe Nostr
|
||||
/// private key and ciphertext — survives it. Spawn sites capture this
|
||||
/// value; the task compares it at its main-actor hops and drops its result
|
||||
/// (no delivery; the captured identity and plaintext die with the task)
|
||||
/// if `invalidateInFlightDecrypts()` bumped it in between.
|
||||
@MainActor private(set) var wipeGeneration: UInt64 = 0
|
||||
|
||||
/// Called from `ChatViewModel.panicClearAllData()` so plaintext decrypted
|
||||
/// with pre-wipe keys can never land in post-wipe state.
|
||||
@MainActor
|
||||
func invalidateInFlightDecrypts() {
|
||||
wipeGeneration &+= 1
|
||||
}
|
||||
|
||||
init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) {
|
||||
self.context = context
|
||||
self.presence = presence
|
||||
@@ -118,15 +100,17 @@ final class NostrInboundPipeline {
|
||||
@MainActor
|
||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
||||
guard let context else { return }
|
||||
// Cheap rejects (kind, dedup lookup) — duplicates dominate real
|
||||
// traffic. The signature was already verified (exactly once, off the
|
||||
// main actor) by NostrRelayManager before delivery.
|
||||
// Cheap rejects (kind, dedup lookup) before Schnorr verification —
|
||||
// duplicates dominate real traffic and must not pay for crypto.
|
||||
// Only verified events are recorded, so a forged-signature copy can
|
||||
// never poison the dedup set and suppress the genuine event.
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
||||
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
|
||||
!context.hasProcessedNostrEvent(event.id)
|
||||
else {
|
||||
return
|
||||
}
|
||||
guard event.isValidSignature() else { return }
|
||||
|
||||
context.recordProcessedNostrEvent(event.id)
|
||||
|
||||
@@ -196,14 +180,15 @@ final class NostrInboundPipeline {
|
||||
@MainActor
|
||||
func handleNostrEvent(_ event: NostrEvent) {
|
||||
guard let context else { return }
|
||||
// Cheap rejects (kind, dedup lookup) — the signature was already
|
||||
// verified (exactly once, off the main actor) by NostrRelayManager.
|
||||
// Cheap rejects (kind, dedup lookup) before Schnorr verification —
|
||||
// duplicates dominate real traffic and must not pay for crypto.
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
||||
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
|
||||
else {
|
||||
return
|
||||
}
|
||||
if context.hasProcessedNostrEvent(event.id) { return }
|
||||
guard event.isValidSignature() else { return }
|
||||
context.recordProcessedNostrEvent(event.id)
|
||||
|
||||
let powBits = NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags)
|
||||
@@ -288,130 +273,112 @@ final class NostrInboundPipeline {
|
||||
@MainActor
|
||||
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
guard let context else { return }
|
||||
// Cheap dedup pre-check only; processGeohashGiftWrap does the
|
||||
// authoritative main-actor check-and-record before the off-main
|
||||
// NIP-17 unwrap. The outer signature was already verified (exactly
|
||||
// once, off the main actor) by NostrRelayManager.
|
||||
// Dedup lookup before Schnorr verification; record only after it passes.
|
||||
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
context.recordProcessedNostrEvent(giftWrap.id)
|
||||
|
||||
// Capture the wipe generation at spawn, alongside the per-geohash
|
||||
// identity (private key) the detached task strongly captures. A panic
|
||||
// wipe between spawn and delivery bumps the generation, and the task
|
||||
// drops its result instead of delivering plaintext post-wipe.
|
||||
let wipeGeneration = self.wipeGeneration
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: false, wipeGeneration: wipeGeneration)
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: id
|
||||
),
|
||||
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let noisePayload = NoisePayload.decode(packet.payload)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
||||
let convKey = PeerID(nostr_: senderPubkey)
|
||||
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
||||
|
||||
switch noisePayload.type {
|
||||
case .privateMessage:
|
||||
context.handlePrivateMessage(
|
||||
noisePayload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: convKey,
|
||||
id: id,
|
||||
messageTimestamp: messageTimestamp
|
||||
)
|
||||
case .delivered:
|
||||
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .readReceipt:
|
||||
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
// Group state travels only over mesh Noise sessions in v1; anything
|
||||
// claiming to be group traffic over Nostr is ignored.
|
||||
// Live voice is mesh-only: latency and relay cost make it
|
||||
// meaningless over Nostr.
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
guard let context else { return }
|
||||
// Cheap dedup pre-check only; see subscribeGiftWrap.
|
||||
// Dedup lookup before Schnorr verification; record only after it passes.
|
||||
if context.hasProcessedNostrEvent(giftWrap.id) {
|
||||
return
|
||||
}
|
||||
|
||||
// Spawn-time wipe-generation capture; see subscribeGiftWrap.
|
||||
let wipeGeneration = self.wipeGeneration
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: true, wipeGeneration: wipeGeneration)
|
||||
}
|
||||
}
|
||||
|
||||
/// Geohash-DM gift wrap ingest. The NIP-17 unwrap (two ECDH+ChaCha
|
||||
/// layers) runs off the main actor; results hop back for state updates.
|
||||
/// `verbose` keeps `handleGiftWrap`'s decrypt logging without adding it
|
||||
/// to the sampling path.
|
||||
///
|
||||
/// `wipeGeneration` is this pipeline's generation captured at spawn (the
|
||||
/// moment the pre-wipe `id` was captured); a mismatch at either main-actor
|
||||
/// hop means a panic wipe happened in between, so the task bails without
|
||||
/// decrypting (first hop) or without delivering the plaintext (second
|
||||
/// hop) — the captured identity and any decrypted material are simply
|
||||
/// dropped with the task.
|
||||
private func processGeohashGiftWrap(
|
||||
_ giftWrap: NostrEvent,
|
||||
id: NostrIdentity,
|
||||
verbose: Bool,
|
||||
wipeGeneration: UInt64
|
||||
) async {
|
||||
guard let context else { return }
|
||||
// Authoritative check-and-record, atomic on the main actor so two
|
||||
// concurrent detached tasks can't both process the same event.
|
||||
let alreadyProcessed: Bool = await MainActor.run {
|
||||
guard self.wipeGeneration == wipeGeneration else { return true }
|
||||
if context.hasProcessedNostrEvent(giftWrap.id) { return true }
|
||||
context.recordProcessedNostrEvent(giftWrap.id)
|
||||
return false
|
||||
}
|
||||
if alreadyProcessed { return }
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
context.recordProcessedNostrEvent(giftWrap.id)
|
||||
|
||||
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
||||
giftWrap: giftWrap,
|
||||
recipientIdentity: id
|
||||
) else {
|
||||
if verbose {
|
||||
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session)
|
||||
}
|
||||
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
if verbose {
|
||||
SecureLogger.debug(
|
||||
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
|
||||
category: .session
|
||||
)
|
||||
SecureLogger.debug(
|
||||
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
|
||||
category: .session
|
||||
)
|
||||
|
||||
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let payload = NoisePayload.decode(packet.payload)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
// A panic wipe during the off-main decrypt must not let the
|
||||
// pre-wipe plaintext reach post-wipe state; drop it here, atomic
|
||||
// with the wipe on the main actor.
|
||||
guard self.wipeGeneration == wipeGeneration else { return }
|
||||
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
||||
packet.type == MessageType.noiseEncrypted.rawValue,
|
||||
let payload = NoisePayload.decode(packet.payload)
|
||||
else {
|
||||
return
|
||||
}
|
||||
let convKey = PeerID(nostr_: senderPubkey)
|
||||
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
||||
|
||||
let convKey = PeerID(nostr_: senderPubkey)
|
||||
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
||||
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
||||
context.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: convKey,
|
||||
id: id,
|
||||
messageTimestamp: messageTimestamp
|
||||
)
|
||||
case .delivered:
|
||||
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .readReceipt:
|
||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
// Group state travels only over mesh Noise sessions in v1; anything
|
||||
// claiming to be group traffic over Nostr is ignored.
|
||||
// Live voice is mesh-only: latency and relay cost make it
|
||||
// meaningless over Nostr.
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
|
||||
break
|
||||
}
|
||||
switch payload.type {
|
||||
case .privateMessage:
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
||||
context.handlePrivateMessage(
|
||||
payload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: convKey,
|
||||
id: id,
|
||||
messageTimestamp: messageTimestamp
|
||||
)
|
||||
case .delivered:
|
||||
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .readReceipt:
|
||||
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
// Group state travels only over mesh Noise sessions in v1; anything
|
||||
// claiming to be group traffic over Nostr is ignored.
|
||||
// Live voice is mesh-only: latency and relay cost make it
|
||||
// meaningless over Nostr.
|
||||
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
||||
guard let context else { return }
|
||||
// Cheap dedup pre-check only; processNostrMessage does the
|
||||
// authoritative check-and-record before the off-main NIP-17 unwrap.
|
||||
// The outer signature was already verified (exactly once, off the
|
||||
// main actor) by NostrRelayManager, and only verified events are
|
||||
// recorded, so a forged-signature copy can never poison the dedup
|
||||
// set and suppress the genuine event.
|
||||
// Cheap dedup pre-check only; Schnorr verification runs off-main in
|
||||
// processNostrMessage, which then does the authoritative
|
||||
// check-and-record. Recording stays after verification so a
|
||||
// forged-signature copy can never poison the dedup set and suppress
|
||||
// the genuine event.
|
||||
if context.hasProcessedNostrEvent(giftWrap.id) { return }
|
||||
|
||||
Task.detached(priority: .userInitiated) { [weak self] in
|
||||
@@ -420,6 +387,7 @@ final class NostrInboundPipeline {
|
||||
}
|
||||
|
||||
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
guard let context else { return }
|
||||
// Authoritative check-and-record, atomic on the main actor so two
|
||||
// concurrent detached tasks can't both process the same event.
|
||||
@@ -429,13 +397,8 @@ final class NostrInboundPipeline {
|
||||
return false
|
||||
}
|
||||
if alreadyProcessed { return }
|
||||
// Fetch the identity and the wipe generation in ONE main-actor hop:
|
||||
// the generation then vouches for exactly this identity. A wipe after
|
||||
// this point bumps the generation and the delivery hop below drops
|
||||
// the decrypted result (same guard as processGeohashGiftWrap; this
|
||||
// account-mailbox path had the identical hazard).
|
||||
let (currentIdentity, wipeGeneration): (NostrIdentity?, UInt64) = await MainActor.run {
|
||||
(context.currentNostrIdentity(), self.wipeGeneration)
|
||||
let currentIdentity: NostrIdentity? = await MainActor.run {
|
||||
context.currentNostrIdentity()
|
||||
}
|
||||
guard let currentIdentity else { return }
|
||||
|
||||
@@ -467,9 +430,6 @@ final class NostrInboundPipeline {
|
||||
let payload = NoisePayload.decode(packet.payload) {
|
||||
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
||||
await MainActor.run {
|
||||
// Drop pre-wipe plaintext if a panic wipe landed
|
||||
// during the off-main decrypt (see above).
|
||||
guard self.wipeGeneration == wipeGeneration else { return }
|
||||
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
|
||||
|
||||
switch payload.type {
|
||||
|
||||
@@ -36,7 +36,6 @@ struct ContentView: View {
|
||||
@EnvironmentObject private var verificationModel: VerificationModel
|
||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||
@EnvironmentObject private var sharedContentImportModel: SharedContentImportModel
|
||||
|
||||
@StateObject private var voiceRecordingVM = VoiceRecordingViewModel()
|
||||
@State private var messageText = ""
|
||||
@@ -70,14 +69,6 @@ struct ContentView: View {
|
||||
privateConversationModel.selectedPeerID
|
||||
}
|
||||
|
||||
private var sharedContentDestination: SharedContentDestination {
|
||||
SharedContentDestination.resolve(
|
||||
selectedPrivatePeerID: selectedPrivatePeerID,
|
||||
privateDisplayName: privateConversationModel.selectedHeaderState?.displayName,
|
||||
activeChannel: locationChannelsModel.selectedChannel
|
||||
)
|
||||
}
|
||||
|
||||
private var usesGlassLayout: Bool { appTheme.usesGlassChrome }
|
||||
|
||||
var body: some View {
|
||||
@@ -97,7 +88,6 @@ struct ContentView: View {
|
||||
isTextFieldFocused = true
|
||||
}
|
||||
#endif
|
||||
sharedContentImportModel.updateDestination(sharedContentDestination)
|
||||
}
|
||||
.onChange(of: colorScheme) { newValue in
|
||||
conversationUIModel.setCurrentColorScheme(newValue)
|
||||
@@ -114,10 +104,6 @@ struct ContentView: View {
|
||||
if newValue != nil {
|
||||
showSidebar = true
|
||||
}
|
||||
sharedContentImportModel.updateDestination(sharedContentDestination)
|
||||
}
|
||||
.onChange(of: locationChannelsModel.selectedChannel) { _ in
|
||||
sharedContentImportModel.updateDestination(sharedContentDestination)
|
||||
}
|
||||
.sheet(
|
||||
isPresented: Binding(
|
||||
@@ -244,34 +230,6 @@ struct ContentView: View {
|
||||
} message: {
|
||||
Text(appChromeModel.bluetoothAlertMessage)
|
||||
}
|
||||
.alert(
|
||||
String(localized: "share_import.review.title", comment: "Title for reviewing content received from the share extension"),
|
||||
isPresented: Binding(
|
||||
get: { sharedContentImportModel.offer != nil },
|
||||
set: { _ in }
|
||||
),
|
||||
presenting: sharedContentImportModel.offer
|
||||
) { _ in
|
||||
Button("common.cancel", role: .cancel) {
|
||||
sharedContentImportModel.cancel(destination: sharedContentDestination)
|
||||
}
|
||||
Button("share_import.review.use_in_composer") {
|
||||
guard let importedText = sharedContentImportModel.confirm(
|
||||
destination: sharedContentDestination
|
||||
) else { return }
|
||||
// Replacing is deliberate and called out in the prompt. It
|
||||
// avoids combining a stale draft from another conversation
|
||||
// with newly shared content.
|
||||
messageText = importedText
|
||||
isTextFieldFocused = true
|
||||
}
|
||||
} message: { offer in
|
||||
let format = String(
|
||||
localized: "share_import.review.message",
|
||||
comment: "Explains that shared content will replace the named destination's composer and will not be sent automatically"
|
||||
)
|
||||
Text(String(format: format, offer.destination.displayName) + "\n\n" + offer.payload.preview)
|
||||
}
|
||||
.onDisappear {
|
||||
autocompleteDebounceTimer?.invalidate()
|
||||
appChromeModel.setPanicPreparation(nil)
|
||||
|
||||
@@ -202,6 +202,207 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"share.status.failed_to_encode" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "تعذر ترميز الرابط",
|
||||
"comment" : "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"bn" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "লিঙ্ক এনকোড করা যায়নি"
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "link konnte nicht codiert werden",
|
||||
"comment" : "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "failed to encode link",
|
||||
"comment" : "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "no se pudo codificar el enlace",
|
||||
"comment" : "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"fa" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "کدگذاری پیوند ناموفق بود"
|
||||
}
|
||||
},
|
||||
"fil" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "hindi ma-encode ang link"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "échec de l'encodage du lien",
|
||||
"comment" : "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"he" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "לא ניתן לקודד את הקישור",
|
||||
"comment" : "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"hi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "लिंक एन्कोड नहीं हो सका"
|
||||
}
|
||||
},
|
||||
"id" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "gagal mengodekan tautan",
|
||||
"comment" : "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "impossibile codificare il link",
|
||||
"comment" : "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "リンクのエンコードに失敗しました",
|
||||
"comment" : "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "링크를 인코딩하는 데 실패했습니다",
|
||||
"comment" : "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"ms" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "gagal mengekod pautan"
|
||||
}
|
||||
},
|
||||
"ne" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "लिङ्क सङ्केत गर्न सकेन",
|
||||
"comment" : "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"nl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "link coderen mislukt"
|
||||
}
|
||||
},
|
||||
"pl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "nie udało się zakodować linku"
|
||||
}
|
||||
},
|
||||
"pt" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "falha ao codificar a ligação"
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "falha ao codificar link",
|
||||
"comment" : "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "не удалось закодировать ссылку",
|
||||
"comment" : "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"sv" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "kunde inte koda länken"
|
||||
}
|
||||
},
|
||||
"ta" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "இணைப்பை என்கோட் செய்ய முடியவில்லை"
|
||||
}
|
||||
},
|
||||
"th" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "เข้ารหัสลิงก์ไม่สำเร็จ"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "bağlantı kodlanamadı",
|
||||
"comment" : "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"uk" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "не вдалося закодувати посилання",
|
||||
"comment" : "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"ur" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "لنک انکوڈ نہیں ہو سکا"
|
||||
}
|
||||
},
|
||||
"vi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "không thể mã hóa liên kết"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "无法编码链接",
|
||||
"comment" : "Shown when the share payload cannot be encoded"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "無法編碼連結"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"share.status.no_shareable_content" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -604,76 +805,406 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"share.status.failed_to_save" : {
|
||||
"comment" : "Shown when content cannot be staged for the main app",
|
||||
"share.status.shared_link" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "تعذر الحفظ في bitchat" } },
|
||||
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat-এ সংরক্ষণ করা যায়নি" } },
|
||||
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "Konnte nicht in bitchat gespeichert werden" } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "Could not save to bitchat" } },
|
||||
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "No se pudo guardar en bitchat" } },
|
||||
"fa" : { "stringUnit" : { "state" : "needs_review", "value" : "ذخیره در bitchat ممکن نشد" } },
|
||||
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "Hindi ma-save sa bitchat" } },
|
||||
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "Impossible d’enregistrer dans bitchat" } },
|
||||
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "לא ניתן לשמור ב-bitchat" } },
|
||||
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat में सेव नहीं किया जा सका" } },
|
||||
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "Tidak dapat menyimpan ke bitchat" } },
|
||||
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "Impossibile salvare in bitchat" } },
|
||||
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat に保存できませんでした" } },
|
||||
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat에 저장할 수 없습니다" } },
|
||||
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Tidak dapat menyimpan ke bitchat" } },
|
||||
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat मा सुरक्षित गर्न सकिएन" } },
|
||||
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Kon niet opslaan in bitchat" } },
|
||||
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Nie udało się zapisać w bitchat" } },
|
||||
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "Não foi possível guardar no bitchat" } },
|
||||
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "Não foi possível salvar no bitchat" } },
|
||||
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "Не удалось сохранить в bitchat" } },
|
||||
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Kunde inte spara i bitchat" } },
|
||||
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat-இல் சேமிக்க முடியவில்லை" } },
|
||||
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "บันทึกไปยัง bitchat ไม่ได้" } },
|
||||
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat’e kaydedilemedi" } },
|
||||
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "Не вдалося зберегти в bitchat" } },
|
||||
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat میں محفوظ نہیں ہو سکا" } },
|
||||
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "Không thể lưu vào bitchat" } },
|
||||
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "无法保存到 bitchat" } },
|
||||
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "無法儲存到 bitchat" } }
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ تم إرسال الرابط إلى bitchat",
|
||||
"comment" : "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"bn" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ bitchat-এ লিঙ্ক শেয়ার করা হয়েছে"
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ link zu bitchat geteilt",
|
||||
"comment" : "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ shared link to bitchat",
|
||||
"comment" : "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ enlace compartido con bitchat",
|
||||
"comment" : "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"fa" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ پیوند در bitchat به اشتراک گذاشته شد"
|
||||
}
|
||||
},
|
||||
"fil" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ naibahagi ang link sa bitchat"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ lien partagé vers bitchat",
|
||||
"comment" : "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"he" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ הקישור נשלח אל bitchat",
|
||||
"comment" : "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"hi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ bitchat पर लिंक शेयर किया गया"
|
||||
}
|
||||
},
|
||||
"id" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ tautan dikirim ke bitchat",
|
||||
"comment" : "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ link inviato a bitchat",
|
||||
"comment" : "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ bitchatにリンクを共有",
|
||||
"comment" : "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ bitchat으로 링크를 공유했습니다",
|
||||
"comment" : "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"ms" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ pautan dikongsi ke bitchat"
|
||||
}
|
||||
},
|
||||
"ne" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ bitchat मा लिङ्क पठाइयो",
|
||||
"comment" : "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"nl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ link gedeeld met bitchat"
|
||||
}
|
||||
},
|
||||
"pl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ udostępniono link w bitchat"
|
||||
}
|
||||
},
|
||||
"pt" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ ligação enviada para o bitchat"
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ link enviado para bitchat",
|
||||
"comment" : "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ ссылка отправлена в bitchat",
|
||||
"comment" : "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"sv" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ länk delad till bitchat"
|
||||
}
|
||||
},
|
||||
"ta" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ bitchat-க்கு இணைப்பு பகிரப்பட்டது"
|
||||
}
|
||||
},
|
||||
"th" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ แชร์ลิงก์ไปยัง bitchat แล้ว"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ bitchat'e bağlantı paylaşıldı",
|
||||
"comment" : "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"uk" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ посилання надіслано в bitchat",
|
||||
"comment" : "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"ur" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ bitchat پر لنک شیئر کر دیا گیا"
|
||||
}
|
||||
},
|
||||
"vi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ đã chia sẻ liên kết tới bitchat"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ 已将链接分享至 bitchat",
|
||||
"comment" : "Confirmation after successfully sharing a link"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ 已將連結分享至 bitchat"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"share.status.saved_for_review" : {
|
||||
"comment" : "Shown after content is staged for review in the main app",
|
||||
"share.status.shared_text" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ حُفظ في bitchat — افتح التطبيق للمراجعة" } },
|
||||
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat-এ সংরক্ষিত — পর্যালোচনার জন্য অ্যাপটি খুলুন" } },
|
||||
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ In bitchat gespeichert — App zum Prüfen öffnen" } },
|
||||
"en" : { "stringUnit" : { "state" : "translated", "value" : "✓ Saved in bitchat — open the app to review" } },
|
||||
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Guardado en bitchat — abre la app para revisarlo" } },
|
||||
"fa" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ در bitchat ذخیره شد — برای بازبینی، برنامه را باز کنید" } },
|
||||
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Na-save sa bitchat — buksan ang app para suriin" } },
|
||||
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Enregistré dans bitchat — ouvrez l’app pour vérifier" } },
|
||||
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ נשמר ב-bitchat — יש לפתוח את האפליקציה לבדיקה" } },
|
||||
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat में सेव किया गया — समीक्षा के लिए ऐप खोलें" } },
|
||||
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Disimpan di bitchat — buka aplikasi untuk meninjau" } },
|
||||
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Salvato in bitchat — apri l’app per controllare" } },
|
||||
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat に保存しました — アプリを開いて確認してください" } },
|
||||
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat에 저장됨 — 앱을 열어 검토하세요" } },
|
||||
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Disimpan dalam bitchat — buka aplikasi untuk menyemak" } },
|
||||
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat मा सुरक्षित गरियो — समीक्षा गर्न एप खोल्नुहोस्" } },
|
||||
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Opgeslagen in bitchat — open de app om te bekijken" } },
|
||||
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Zapisano w bitchat — otwórz aplikację, aby sprawdzić" } },
|
||||
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Guardado no bitchat — abra a app para rever" } },
|
||||
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Salvo no bitchat — abra o app para revisar" } },
|
||||
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Сохранено в bitchat — откройте приложение для проверки" } },
|
||||
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Sparat i bitchat — öppna appen för att granska" } },
|
||||
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat-இல் சேமிக்கப்பட்டது — மதிப்பாய்வு செய்ய செயலியைத் திறக்கவும்" } },
|
||||
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ บันทึกใน bitchat แล้ว — เปิดแอปเพื่อตรวจสอบ" } },
|
||||
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat’e kaydedildi — incelemek için uygulamayı açın" } },
|
||||
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Збережено в bitchat — відкрийте застосунок для перегляду" } },
|
||||
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat میں محفوظ ہو گیا — جائزے کے لیے ایپ کھولیں" } },
|
||||
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Đã lưu trong bitchat — mở ứng dụng để xem lại" } },
|
||||
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ 已保存在 bitchat 中 — 打开应用查看" } },
|
||||
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ 已儲存在 bitchat 中 — 開啟 App 查看" } }
|
||||
"ar" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ تم إرسال النص إلى bitchat",
|
||||
"comment" : "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"bn" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ bitchat-এ টেক্সট শেয়ার করা হয়েছে"
|
||||
}
|
||||
},
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ text zu bitchat geteilt",
|
||||
"comment" : "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ shared text to bitchat",
|
||||
"comment" : "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"es" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ texto compartido con bitchat",
|
||||
"comment" : "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"fa" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ متن در bitchat به اشتراک گذاشته شد"
|
||||
}
|
||||
},
|
||||
"fil" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ naibahagi ang teksto sa bitchat"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ texte partagé vers bitchat",
|
||||
"comment" : "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"he" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ הטקסט נשלח אל bitchat",
|
||||
"comment" : "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"hi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ bitchat पर टेक्स्ट शेयर किया गया"
|
||||
}
|
||||
},
|
||||
"id" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ teks dikirim ke bitchat",
|
||||
"comment" : "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"it" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ testo inviato a bitchat",
|
||||
"comment" : "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ bitchatにテキストを共有",
|
||||
"comment" : "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ bitchat으로 텍스트를 공유했습니다",
|
||||
"comment" : "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"ms" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ teks dikongsi ke bitchat"
|
||||
}
|
||||
},
|
||||
"ne" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ bitchat मा पाठ पठाइयो",
|
||||
"comment" : "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"nl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ tekst gedeeld met bitchat"
|
||||
}
|
||||
},
|
||||
"pl" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ udostępniono tekst w bitchat"
|
||||
}
|
||||
},
|
||||
"pt" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ texto enviado para o bitchat"
|
||||
}
|
||||
},
|
||||
"pt-BR" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ texto enviado para bitchat",
|
||||
"comment" : "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"ru" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ текст отправлен в bitchat",
|
||||
"comment" : "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"sv" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ text delad till bitchat"
|
||||
}
|
||||
},
|
||||
"ta" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ bitchat-க்கு உரை பகிரப்பட்டது"
|
||||
}
|
||||
},
|
||||
"th" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ แชร์ข้อความไปยัง bitchat แล้ว"
|
||||
}
|
||||
},
|
||||
"tr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ bitchat'e metin paylaşıldı",
|
||||
"comment" : "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"uk" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ текст надіслано в bitchat",
|
||||
"comment" : "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"ur" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ bitchat پر متن شیئر کر دیا گیا"
|
||||
}
|
||||
},
|
||||
"vi" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ đã chia sẻ văn bản tới bitchat"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "✓ 已将文本分享至 bitchat",
|
||||
"comment" : "Confirmation after successfully sharing text"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "needs_review",
|
||||
"value" : "✓ 已將文字分享至 bitchat"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -19,8 +19,9 @@ final class ShareViewController: UIViewController {
|
||||
static let nothingToShare = String(localized: "share.status.nothing_to_share", comment: "Shown when the share extension receives no content")
|
||||
static let noShareableContent = String(localized: "share.status.no_shareable_content", comment: "Shown when provided content cannot be shared")
|
||||
static let sharedLinkTitleFallback = String(localized: "share.fallback.shared_link_title", comment: "Fallback title when saving a shared link")
|
||||
static let savedForReview = String(localized: "share.status.saved_for_review", comment: "Shown after content is staged for review in the main app")
|
||||
static let failedToSave = String(localized: "share.status.failed_to_save", comment: "Shown when content cannot be staged for the main app")
|
||||
static let sharedLinkConfirmation = String(localized: "share.status.shared_link", comment: "Confirmation after successfully sharing a link")
|
||||
static let sharedTextConfirmation = String(localized: "share.status.shared_text", comment: "Confirmation after successfully sharing text")
|
||||
static let failedToEncode = String(localized: "share.status.failed_to_encode", comment: "Shown when the share payload cannot be encoded")
|
||||
}
|
||||
|
||||
private let statusLabel: UILabel = {
|
||||
@@ -43,7 +44,9 @@ final class ShareViewController: UIViewController {
|
||||
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
|
||||
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
|
||||
])
|
||||
processShare()
|
||||
DispatchQueue.global().async {
|
||||
self.processShare()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Processing
|
||||
@@ -148,33 +151,30 @@ final class ShareViewController: UIViewController {
|
||||
|
||||
// MARK: - Save + Finish
|
||||
private func saveAndFinish(url: URL, title: String?) {
|
||||
let payload = SharedContentPayload(
|
||||
kind: .url,
|
||||
content: url.absoluteString,
|
||||
title: title ?? url.host ?? Strings.sharedLinkTitleFallback
|
||||
)
|
||||
stageAndFinish(payload)
|
||||
let payload: [String: String] = [
|
||||
"url": url.absoluteString,
|
||||
"title": title ?? url.host ?? Strings.sharedLinkTitleFallback
|
||||
]
|
||||
if let json = try? JSONSerialization.data(withJSONObject: payload),
|
||||
let s = String(data: json, encoding: .utf8) {
|
||||
saveToSharedDefaults(content: s, type: "url")
|
||||
finishWithMessage(Strings.sharedLinkConfirmation)
|
||||
} else {
|
||||
finishWithMessage(Strings.failedToEncode)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAndFinish(text: String) {
|
||||
stageAndFinish(.text(text))
|
||||
saveToSharedDefaults(content: text, type: "text")
|
||||
finishWithMessage(Strings.sharedTextConfirmation)
|
||||
}
|
||||
|
||||
private func stageAndFinish(_ payload: SharedContentPayload) {
|
||||
guard let defaults = UserDefaults(suiteName: Self.groupID) else {
|
||||
finishWithMessage(Strings.failedToSave)
|
||||
return
|
||||
}
|
||||
let store = SharedContentStore(defaults: defaults)
|
||||
|
||||
do {
|
||||
try store.stage(payload)
|
||||
// Staging is not sending. The main app will require a second,
|
||||
// destination-labelled confirmation before filling its composer.
|
||||
finishWithMessage(Strings.savedForReview)
|
||||
} catch {
|
||||
finishWithMessage(Strings.failedToSave)
|
||||
}
|
||||
private func saveToSharedDefaults(content: String, type: String) {
|
||||
guard let userDefaults = UserDefaults(suiteName: Self.groupID) else { return }
|
||||
userDefaults.set(content, forKey: "sharedContent")
|
||||
userDefaults.set(type, forKey: "sharedContentType")
|
||||
userDefaults.set(Date(), forKey: "sharedContentDate")
|
||||
// No need to force synchronize; the system persists changes
|
||||
}
|
||||
|
||||
private func finishWithMessage(_ msg: String) {
|
||||
|
||||
@@ -502,14 +502,26 @@ struct BLEServiceCoreTests {
|
||||
)
|
||||
let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce")
|
||||
#expect(ble._test_recordIngressIfNew(packet: replay, linkID: attackerLink))
|
||||
let rebindGate = VerifiedDirectRebindGate()
|
||||
ble._test_afterVerifiedDirectRebindEnqueued = rebindGate.pause
|
||||
defer {
|
||||
rebindGate.release()
|
||||
ble._test_afterVerifiedDirectRebindEnqueued = nil
|
||||
}
|
||||
ble._test_handlePacket(replay, fromPeerID: victimPeerID, preseedPeer: false)
|
||||
|
||||
let rebound = await TestHelpers.waitUntil(
|
||||
{ ble._test_centralBinding(attackerLink) == victimPeerID },
|
||||
let announcePaused = await TestHelpers.waitUntil(
|
||||
{ rebindGate.hasPaused },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(rebound)
|
||||
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
||||
try #require(announcePaused)
|
||||
|
||||
// Rebind and ordinary reconnect preparation are one bleQueue
|
||||
// critical section. Once the binding is visible, stale sending keys
|
||||
// must already be unavailable.
|
||||
#expect(ble._test_centralBinding(attackerLink) == victimPeerID)
|
||||
#expect(!ble.canDeliverSecurely(to: victimPeerID))
|
||||
rebindGate.release()
|
||||
|
||||
let outbound = OutboundPacketTap()
|
||||
ble._test_onOutboundPacket = { outbound.record($0) }
|
||||
@@ -537,20 +549,26 @@ struct BLEServiceCoreTests {
|
||||
|
||||
// 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)
|
||||
// Establish BLE as responder so the replacement candidate below is
|
||||
// not coalesced by the initiator-completion grace path.
|
||||
let message1 = try victim.initiateHandshake(with: ble.myPeerID)
|
||||
let message2 = try #require(
|
||||
try victim.processHandshakeMessage(from: ble.myPeerID, message: message1)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try ble._test_noiseProcessHandshakeMessage(
|
||||
from: victimPeerID,
|
||||
message: message1
|
||||
)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try victim.processHandshakeMessage(
|
||||
from: ble.myPeerID,
|
||||
message: message2
|
||||
)
|
||||
)
|
||||
_ = try victim.processHandshakeMessage(
|
||||
from: ble.myPeerID,
|
||||
_ = try ble._test_noiseProcessHandshakeMessage(
|
||||
from: victimPeerID,
|
||||
message: message3
|
||||
)
|
||||
await ble._test_drainNoiseMessagePipeline()
|
||||
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
||||
|
||||
let centralUUID = "central-replacement-xx-message-one"
|
||||
@@ -614,7 +632,168 @@ struct BLEServiceCoreTests {
|
||||
for: victimPeerID
|
||||
)
|
||||
)
|
||||
#expect(ble.canDeliverSecurely(to: victimPeerID))
|
||||
// Ordinary reconnect hardening quarantines the cached transport while
|
||||
// this candidate proves the claimed identity. It must be unavailable
|
||||
// for sending as well as unable to authenticate this ingress link.
|
||||
#expect(!ble.canDeliverSecurely(to: victimPeerID))
|
||||
}
|
||||
|
||||
@Test
|
||||
func failedInboundReconnectRestoresAndDrainsWaitingWorkOnce() async throws {
|
||||
let ble = makeService()
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
|
||||
|
||||
// Establish BLE as responder so the following inbound reconnect is
|
||||
// not intentionally coalesced by the initiator-completion grace path.
|
||||
let message1 = try alice.initiateHandshake(with: ble.myPeerID)
|
||||
let message2 = try #require(
|
||||
try ble._test_noiseProcessHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
message: message1
|
||||
)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try alice.processHandshakeMessage(
|
||||
from: ble.myPeerID,
|
||||
message: message2
|
||||
)
|
||||
)
|
||||
_ = try ble._test_noiseProcessHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
message: message3
|
||||
)
|
||||
await ble._test_drainNoiseMessagePipeline()
|
||||
#expect(ble.canDeliverSecurely(to: alicePeerID))
|
||||
|
||||
let outbound = OutboundPacketTap()
|
||||
ble._test_onOutboundPacket = outbound.record
|
||||
let forgedMessage1 = try mallory.initiateHandshake(with: ble.myPeerID)
|
||||
let firstPacket = BitchatPacket(
|
||||
type: MessageType.noiseHandshake.rawValue,
|
||||
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: ble.myPeerID.id),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000),
|
||||
payload: forgedMessage1,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
ble._test_handlePacket(firstPacket, fromPeerID: alicePeerID)
|
||||
|
||||
let responseReady = await TestHelpers.waitUntil(
|
||||
{
|
||||
outbound.snapshot().contains {
|
||||
$0.type == MessageType.noiseHandshake.rawValue
|
||||
&& PeerID(hexData: $0.senderID) == ble.myPeerID
|
||||
&& $0.payload.count
|
||||
!= NoiseSecurityConstants.xxInitialMessageSize
|
||||
}
|
||||
},
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
try #require(responseReady)
|
||||
let forgedMessage2 = try #require(
|
||||
outbound.snapshot().first {
|
||||
$0.type == MessageType.noiseHandshake.rawValue
|
||||
&& PeerID(hexData: $0.senderID) == ble.myPeerID
|
||||
&& $0.payload.count
|
||||
!= NoiseSecurityConstants.xxInitialMessageSize
|
||||
}?.payload
|
||||
)
|
||||
#expect(!ble.canDeliverSecurely(to: alicePeerID))
|
||||
|
||||
// Typed control traffic must queue behind the ordinary responder,
|
||||
// rather than attempting encryption and disappearing.
|
||||
let privateMessageID = "quarantine-pm-\(UUID().uuidString)"
|
||||
ble.sendPrivateMessage(
|
||||
"queued private message",
|
||||
to: alicePeerID,
|
||||
recipientNickname: "Alice",
|
||||
messageID: privateMessageID
|
||||
)
|
||||
ble.sendGroupInvite(Data("queued-during-quarantine".utf8), to: alicePeerID)
|
||||
await ble._test_drainNoiseMessagePipeline()
|
||||
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
|
||||
|
||||
let forgedMessage3 = try #require(
|
||||
try mallory.processHandshakeMessage(
|
||||
from: ble.myPeerID,
|
||||
message: forgedMessage2
|
||||
)
|
||||
)
|
||||
let forgedEarlyPayload = try #require(
|
||||
BLENoisePayloadFactory.privateMessage(
|
||||
content: "forged early message",
|
||||
messageID: "forged-early"
|
||||
)
|
||||
)
|
||||
try #require(
|
||||
mallory.hasEstablishedSession(with: ble.myPeerID),
|
||||
"forged initiator did not establish after producing message three"
|
||||
)
|
||||
let forgedEarlyCiphertext = try mallory.encrypt(
|
||||
forgedEarlyPayload,
|
||||
for: ble.myPeerID
|
||||
)
|
||||
let earlyPacket = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: ble.myPeerID.id),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 1,
|
||||
payload: forgedEarlyCiphertext,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
ble._test_handlePacket(earlyPacket, fromPeerID: alicePeerID)
|
||||
await ble._test_drainNoiseMessagePipeline()
|
||||
|
||||
let thirdPacket = BitchatPacket(
|
||||
type: MessageType.noiseHandshake.rawValue,
|
||||
senderID: Data(hexString: alicePeerID.id) ?? Data(),
|
||||
recipientID: Data(hexString: ble.myPeerID.id),
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 2,
|
||||
payload: forgedMessage3,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
ble._test_handlePacket(thirdPacket, fromPeerID: alicePeerID)
|
||||
|
||||
// Rollback restores the same generation. It retries the bounded early
|
||||
// ciphertext and drains both outbound queues, but must not repeat a
|
||||
// new-generation capability proof or forced announce.
|
||||
let drained = await TestHelpers.waitUntil(
|
||||
{ outbound.count(ofType: .noiseEncrypted) >= 2 },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
try #require(drained)
|
||||
await ble._test_drainNoiseMessagePipeline()
|
||||
let plaintexts = try outbound.snapshot()
|
||||
.filter { $0.type == MessageType.noiseEncrypted.rawValue }
|
||||
.map { try alice.decrypt($0.payload, from: ble.myPeerID) }
|
||||
#expect(plaintexts.count == 2)
|
||||
#expect(
|
||||
plaintexts.filter {
|
||||
$0.first == NoisePayloadType.authenticatedPeerState.rawValue
|
||||
}.isEmpty
|
||||
)
|
||||
#expect(
|
||||
plaintexts.filter {
|
||||
$0.first == NoisePayloadType.privateMessage.rawValue
|
||||
}.count == 1
|
||||
)
|
||||
#expect(
|
||||
plaintexts.filter {
|
||||
$0.first == NoisePayloadType.groupInvite.rawValue
|
||||
}.count == 1
|
||||
)
|
||||
#expect(outbound.count(ofType: .announce) == 0)
|
||||
|
||||
// A duplicate ready callback cannot replay either buffer.
|
||||
ble._test_reconcileCurrentNoiseSession(for: alicePeerID)
|
||||
await ble._test_drainNoiseMessagePipeline()
|
||||
#expect(outbound.count(ofType: .noiseEncrypted) == 2)
|
||||
#expect(outbound.count(ofType: .announce) == 0)
|
||||
}
|
||||
|
||||
/// A legitimate rotation announce necessarily arrives on a link still
|
||||
@@ -943,6 +1122,40 @@ private final class OutboundPacketTap {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return packets.filter { $0.type == type.rawValue }.count
|
||||
}
|
||||
|
||||
func snapshot() -> [BitchatPacket] {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return packets
|
||||
}
|
||||
}
|
||||
|
||||
private final class VerifiedDirectRebindGate: @unchecked Sendable {
|
||||
private let condition = NSCondition()
|
||||
private var paused = false
|
||||
private var released = false
|
||||
|
||||
var hasPaused: Bool {
|
||||
condition.lock()
|
||||
defer { condition.unlock() }
|
||||
return paused
|
||||
}
|
||||
|
||||
func pause() {
|
||||
condition.lock()
|
||||
paused = true
|
||||
condition.broadcast()
|
||||
while !released {
|
||||
condition.wait()
|
||||
}
|
||||
condition.unlock()
|
||||
}
|
||||
|
||||
func release() {
|
||||
condition.lock()
|
||||
released = true
|
||||
condition.broadcast()
|
||||
condition.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private final class ReceivePacketHandoffGate: @unchecked Sendable {
|
||||
@@ -1076,6 +1289,7 @@ private final class PublicCaptureDelegate: BitchatDelegate {
|
||||
defer { lock.unlock() }
|
||||
return publicMessages
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -14,6 +14,7 @@ import BitFoundation
|
||||
@MainActor
|
||||
private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
||||
var nickname = "me"
|
||||
var myPeerID = PeerID(str: "0102030405060708")
|
||||
var selectedPrivateChatPeer: PeerID?
|
||||
var isViewingPublicMeshTimeline = false
|
||||
var blockedPeers: Set<PeerID> = []
|
||||
@@ -23,7 +24,10 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
||||
private(set) var upsertedMessages: [(message: BitchatMessage, peerID: PeerID)] = []
|
||||
private(set) var upsertedPublicMessages: [BitchatMessage] = []
|
||||
private(set) var removedMessageIDs: [String] = []
|
||||
private(set) var sentReadReceipts: [(receipt: ReadReceipt, peerID: PeerID)] = []
|
||||
private(set) var talkerUpdates: [String?] = []
|
||||
private(set) var privateMutationLog: [String] = []
|
||||
private var readReceiptMessageIDs: Set<String> = []
|
||||
|
||||
func isPeerBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
|
||||
func resolveNickname(for peerID: PeerID) -> String { "alice" }
|
||||
@@ -31,6 +35,7 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
||||
func appendPublicMeshMessage(_ message: BitchatMessage) { appendedPublicMessages.append(message) }
|
||||
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) {
|
||||
upsertedMessages.append((message, peerID))
|
||||
privateMutationLog.append("upsert:\(message.id)")
|
||||
}
|
||||
func upsertPublicMeshMessage(_ message: BitchatMessage) {
|
||||
upsertedPublicMessages.append(message)
|
||||
@@ -38,8 +43,18 @@ private final class MockChatLiveVoiceContext: ChatLiveVoiceContext {
|
||||
@discardableResult
|
||||
func removePrivateMessage(withID messageID: String) -> BitchatMessage? {
|
||||
removedMessageIDs.append(messageID)
|
||||
privateMutationLog.append("remove:\(messageID)")
|
||||
return nil
|
||||
}
|
||||
func hasSentReadReceipt(_ messageID: String) -> Bool {
|
||||
readReceiptMessageIDs.contains(messageID)
|
||||
}
|
||||
func markReadReceiptSent(_ messageID: String) -> Bool {
|
||||
readReceiptMessageIDs.insert(messageID).inserted
|
||||
}
|
||||
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||
sentReadReceipts.append((receipt, peerID))
|
||||
}
|
||||
func removeMessage(withID messageID: String, cleanupFile: Bool) {
|
||||
removedMessageIDs.append(messageID)
|
||||
}
|
||||
@@ -151,17 +166,29 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
|
||||
@Test func absorbsFinalizedNoteIntoLiveBubble() throws {
|
||||
let context = MockChatLiveVoiceContext()
|
||||
context.selectedPrivateChatPeer = peer
|
||||
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
|
||||
let burstID = makeBurstID(0xB2)
|
||||
let hex = burstID.hexEncodedString()
|
||||
let fileName = "voice_\(hex).m4a"
|
||||
let stableMessageID = try #require(PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: peer,
|
||||
recipientPeerID: context.myPeerID,
|
||||
fileName: fileName
|
||||
))
|
||||
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 7, count: 50)]))), to: coordinator, from: peer)
|
||||
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
|
||||
let bubble = try #require(context.handledPrivateMessages.first)
|
||||
// The user read the live bubble, then left before the finalized file
|
||||
// arrived. Stable-ID adoption must preserve that read state.
|
||||
#expect(context.markReadReceiptSent(bubble.id))
|
||||
context.selectedPrivateChatPeer = nil
|
||||
|
||||
let note = BitchatMessage(
|
||||
id: stableMessageID,
|
||||
sender: "alice",
|
||||
content: "[voice] voice_\(hex).m4a",
|
||||
content: "[voice] \(fileName)",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
@@ -170,12 +197,21 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
)
|
||||
#expect(coordinator.absorbFinalizedVoiceNote(note))
|
||||
|
||||
// The note replaced the live bubble in place: same message ID, new
|
||||
// content, partial capture deleted.
|
||||
// The finalized note adopts the sender-correlatable ID, removes the
|
||||
// receiver-local live ID, and emits a fresh READ now that the sender
|
||||
// has created its finalized media row.
|
||||
let replacement = try #require(context.upsertedMessages.last)
|
||||
#expect(replacement.message.id == bubble.id)
|
||||
#expect(replacement.message.id == stableMessageID)
|
||||
#expect(replacement.message.content == note.content)
|
||||
#expect(replacement.peerID == peer)
|
||||
#expect(context.removedMessageIDs.contains(bubble.id))
|
||||
#expect(Array(context.privateMutationLog.suffix(2)) == [
|
||||
"upsert:\(stableMessageID)",
|
||||
"remove:\(bubble.id)"
|
||||
])
|
||||
#expect(context.sentReadReceipts.count == 1)
|
||||
#expect(context.sentReadReceipts.first?.receipt.originalMessageID == stableMessageID)
|
||||
#expect(context.sentReadReceipts.first?.peerID == peer)
|
||||
// The promoted partial capture is deleted in favor of the note.
|
||||
let url = try #require(fallbackFileURL(burstID: burstID, peerID: peer))
|
||||
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||
@@ -449,7 +485,8 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer
|
||||
)
|
||||
#expect(coordinator.absorbFinalizedVoiceNote(dmNote))
|
||||
#expect(try #require(context.upsertedMessages.last).message.id == dmBubble.id)
|
||||
#expect(try #require(context.upsertedMessages.last).message.id == dmNote.id)
|
||||
#expect(context.removedMessageIDs.contains(dmBubble.id))
|
||||
}
|
||||
|
||||
@Test func finalizedNoteBindsToItsAuthenticatedSender() throws {
|
||||
@@ -479,8 +516,9 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
)
|
||||
#expect(coordinator.absorbFinalizedVoiceNote(note))
|
||||
let replacement = try #require(context.upsertedMessages.last)
|
||||
#expect(replacement.message.id == victimBubble.id)
|
||||
#expect(replacement.message.id == note.id)
|
||||
#expect(replacement.peerID == peer)
|
||||
#expect(context.removedMessageIDs.contains(victimBubble.id))
|
||||
|
||||
// The attacker's note can only ever claim the attacker's own bubble.
|
||||
let attackerNote = BitchatMessage(
|
||||
@@ -489,8 +527,9 @@ struct ChatLiveVoiceCoordinatorTests {
|
||||
)
|
||||
#expect(coordinator.absorbFinalizedVoiceNote(attackerNote))
|
||||
let attackerReplacement = try #require(context.upsertedMessages.last)
|
||||
#expect(attackerReplacement.message.id == attackerBubble.id)
|
||||
#expect(attackerReplacement.message.id == attackerNote.id)
|
||||
#expect(attackerReplacement.peerID == attacker)
|
||||
#expect(context.removedMessageIDs.contains(attackerBubble.id))
|
||||
|
||||
// Both registry entries are consumed — nothing left to hijack.
|
||||
#expect(!coordinator.absorbFinalizedVoiceNote(note))
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
||||
//
|
||||
// Real file/codec work remains covered by `ChatMediaPreparationTests`. These
|
||||
// tests inject a paused voice-note preparer to exercise cancellation ownership
|
||||
// tests inject paused media preparers to exercise cancellation ownership
|
||||
// across the detached-preparation/MainActor boundary deterministically.
|
||||
//
|
||||
|
||||
@@ -89,7 +89,11 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
}
|
||||
|
||||
// Mesh file transfer
|
||||
private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = []
|
||||
private(set) var privateFileSends: [(
|
||||
packet: BitchatFilePacket,
|
||||
peerID: PeerID,
|
||||
transferId: String
|
||||
)] = []
|
||||
private(set) var privateFileLegacyAllowances: [Bool] = []
|
||||
private(set) var broadcastFileSends: [String] = []
|
||||
private(set) var cancelledTransfers: [String] = []
|
||||
@@ -154,7 +158,7 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
||||
transferId: String,
|
||||
allowLegacyFallback: Bool
|
||||
) {
|
||||
privateFileSends.append((peerID, transferId))
|
||||
privateFileSends.append((packet, peerID, transferId))
|
||||
privateFileLegacyAllowances.append(allowLegacyFallback)
|
||||
}
|
||||
|
||||
@@ -172,19 +176,8 @@ private final class PausedVoiceNotePreparer: @unchecked Sendable {
|
||||
private var started = false
|
||||
private var released = false
|
||||
private var finished = false
|
||||
private let packet: BitchatFilePacket
|
||||
|
||||
init() {
|
||||
let content = Data("voice".utf8)
|
||||
packet = BitchatFilePacket(
|
||||
fileName: "paused.m4a",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "audio/mp4",
|
||||
content: content
|
||||
)
|
||||
}
|
||||
|
||||
func prepare(_: URL) throws -> BitchatFilePacket {
|
||||
func prepare(_ url: URL) throws -> BitchatFilePacket {
|
||||
condition.lock()
|
||||
started = true
|
||||
condition.broadcast()
|
||||
@@ -194,7 +187,13 @@ private final class PausedVoiceNotePreparer: @unchecked Sendable {
|
||||
finished = true
|
||||
condition.broadcast()
|
||||
condition.unlock()
|
||||
return packet
|
||||
let content = Data("voice".utf8)
|
||||
return BitchatFilePacket(
|
||||
fileName: url.lastPathComponent,
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "audio/mp4",
|
||||
content: content
|
||||
)
|
||||
}
|
||||
|
||||
var hasStarted: Bool {
|
||||
@@ -452,6 +451,109 @@ struct ChatMediaTransferCoordinatorContextTests {
|
||||
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func privateVoiceNoteUsesWireDerivableMessageID() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("voice_receipt_\(UUID().uuidString).m4a")
|
||||
try Data("voice".utf8).write(to: url)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
|
||||
coordinator.sendVoiceNote(at: url)
|
||||
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let message = try #require(context.privateChats[peerID]?.first)
|
||||
let sentPacket = try #require(context.privateFileSends.first?.packet)
|
||||
#expect(message.id == PrivateMediaMessageIdentity.stableID(
|
||||
for: sentPacket,
|
||||
senderPeerID: context.myPeerID,
|
||||
recipientPeerID: peerID
|
||||
))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func privateImageUsesWireDerivableMessageID() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||
let peerID = PeerID(str: "99aabbccddeeff00")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
let sourceURL = try makeCoordinatorTestImageURL()
|
||||
defer { try? FileManager.default.removeItem(at: sourceURL) }
|
||||
|
||||
coordinator.sendImage(from: sourceURL)
|
||||
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ context.privateFileSends.count == 1 },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
let message = try #require(context.privateChats[peerID]?.first)
|
||||
let sentPacket = try #require(context.privateFileSends.first?.packet)
|
||||
#expect(message.id == PrivateMediaMessageIdentity.stableID(
|
||||
for: sentPacket,
|
||||
senderPeerID: context.myPeerID,
|
||||
recipientPeerID: peerID
|
||||
))
|
||||
coordinator.cleanupLocalFile(forMessage: message)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func panicDuringImagePreparationDeletesStaleOutputWithoutSideEffects() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
let peerID = PeerID(str: "99aabbccddeeff00")
|
||||
context.selectedPrivateChatPeer = peerID
|
||||
let sourceURL = try makeCoordinatorTestImageURL()
|
||||
let outputURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(
|
||||
"panic-stale-image-\(UUID().uuidString).jpg"
|
||||
)
|
||||
let preparer = PausedImagePreparer(outputURL: outputURL)
|
||||
let coordinator = ChatMediaTransferCoordinator(
|
||||
context: context,
|
||||
prepareImagePacket: { url in try preparer.prepare(url) }
|
||||
)
|
||||
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()
|
||||
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ preparer.hasFinished },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
#expect(await TestHelpers.waitUntil(
|
||||
{ !FileManager.default.fileExists(atPath: outputURL.path) },
|
||||
timeout: TestConstants.longTimeout
|
||||
))
|
||||
#expect(context.privateChats[peerID]?.isEmpty != false)
|
||||
#expect(context.appendedPublicMessages.isEmpty)
|
||||
#expect(context.privateFileSends.isEmpty)
|
||||
#expect(context.broadcastFileSends.isEmpty)
|
||||
#expect(context.systemMessages.isEmpty)
|
||||
#expect(context.deliveryStatusUpdates.isEmpty)
|
||||
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||
#expect(coordinator.messageIDToTransferId.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws {
|
||||
let context = MockChatMediaTransferContext()
|
||||
|
||||
@@ -354,12 +354,10 @@ struct ChatNostrCoordinatorContextTests {
|
||||
|
||||
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||
|
||||
// The NIP-17 unwrap runs off the main actor; wait for the hop back.
|
||||
let convKey = PeerID(nostr_: sender.publicKeyHex)
|
||||
let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
|
||||
#expect(routed)
|
||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
|
||||
#expect(context.handledPrivateMessages.count == 1)
|
||||
#expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex)
|
||||
#expect(context.handledPrivateMessages.first?.convKey == convKey)
|
||||
|
||||
@@ -372,76 +370,30 @@ struct ChatNostrCoordinatorContextTests {
|
||||
|
||||
// The same gift wrap is dropped on replay.
|
||||
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||
await drainMainQueue()
|
||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||
#expect(context.handledPrivateMessages.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleGiftWrap_panicWipeAfterSpawnDropsDecryptedResult() async throws {
|
||||
func processNostrMessage_invalidSignatureDoesNotPoisonDedup() async throws {
|
||||
let context = MockChatNostrContext()
|
||||
let coordinator = ChatNostrCoordinator(context: context)
|
||||
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let sender = try NostrIdentity.generate()
|
||||
let embedded = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
||||
content: "pre-wipe secret",
|
||||
messageID: "gm-wipe-1",
|
||||
senderPeerID: PeerID(str: "aabbccddeeff0011")
|
||||
))
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: embedded,
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
// Spawn the detached decrypt (it strongly captures the pre-wipe
|
||||
// identity), then panic-wipe in the SAME main-actor turn — guaranteed
|
||||
// to land before the task's first main-actor hop.
|
||||
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||
coordinator.inbound.invalidateInFlightDecrypts()
|
||||
|
||||
// Give the detached task ample time to have delivered if the wipe
|
||||
// guard were broken.
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
await drainMainQueue()
|
||||
|
||||
#expect(context.handledPrivateMessages.isEmpty)
|
||||
#expect(context.recordedNostrEventIDs.isEmpty)
|
||||
|
||||
// The pipeline itself stays usable: a gift wrap spawned AFTER the
|
||||
// wipe (new generation) still decrypts and delivers.
|
||||
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
|
||||
let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
|
||||
#expect(delivered)
|
||||
}
|
||||
|
||||
// NOTE: Inbound Schnorr signature verification (and the forged-copy
|
||||
// dedup-poisoning invariant) is enforced once, off the main actor, at the
|
||||
// relay boundary — see NostrRelayManagerTests
|
||||
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache` and
|
||||
// `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`.
|
||||
// The inbound pipeline only ever sees verified events.
|
||||
|
||||
@Test @MainActor
|
||||
func processNostrMessage_duplicateDeliveryProcessesOnce() async throws {
|
||||
let context = MockChatNostrContext()
|
||||
let coordinator = ChatNostrCoordinator(context: context)
|
||||
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let sender = try NostrIdentity.generate()
|
||||
context.nostrIdentity = recipient
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: "verify:noop",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
var invalidGiftWrap = giftWrap
|
||||
invalidGiftWrap.sig = String(repeating: "0", count: 128)
|
||||
|
||||
// Fan-in of the same (already verified) gift wrap from several relays
|
||||
// records and processes exactly once.
|
||||
await coordinator.inbound.processNostrMessage(giftWrap)
|
||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||
// A forged-signature copy is rejected WITHOUT entering the dedup set...
|
||||
await coordinator.inbound.processNostrMessage(invalidGiftWrap)
|
||||
#expect(context.recordedNostrEventIDs.isEmpty)
|
||||
|
||||
// ...so the genuine event with the same ID still processes and records.
|
||||
await coordinator.inbound.processNostrMessage(giftWrap)
|
||||
#expect(context.recordedNostrEventIDs == [giftWrap.id])
|
||||
}
|
||||
|
||||
@@ -104,6 +104,20 @@ private func makeMessage(id: String, senderPeerID: PeerID? = nil) -> BitchatMess
|
||||
/// no `ChatViewModel`.
|
||||
struct ChatPeerListCoordinatorContextTests {
|
||||
|
||||
@Test @MainActor
|
||||
func synchronousPeerListUpdate_appliesBeforeReturning() {
|
||||
let context = MockChatPeerListContext()
|
||||
let coordinator = ChatPeerListCoordinator(context: context)
|
||||
let peerID = PeerID(str: "0011223344556677")
|
||||
|
||||
coordinator.didUpdatePeerListSynchronously([peerID])
|
||||
|
||||
#expect(context.isConnected)
|
||||
#expect(context.registeredEphemeralSessions == [peerID])
|
||||
#expect(context.updateEncryptionStatusForPeersCount == 1)
|
||||
#expect(context.cleanupOldReadReceiptsCount == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async {
|
||||
let context = MockChatPeerListContext()
|
||||
|
||||
@@ -220,26 +220,85 @@ struct ChatTransportEventCoordinatorContextTests {
|
||||
func didReceiveMessage_routesPrivateAndPublic_skipsBlockedAndEmpty() async {
|
||||
let context = MockChatTransportEventContext()
|
||||
let coordinator = ChatTransportEventCoordinator(context: context)
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
|
||||
// Blocked messages are dropped before any handling.
|
||||
context.blockedMessageIDs = ["blocked"]
|
||||
context.blockedMessageIDs = ["blocked", "blocked-private"]
|
||||
coordinator.didReceiveMessage(makeMessage(id: "blocked"))
|
||||
coordinator.didReceiveMessage(makeMessage(
|
||||
id: "blocked-private",
|
||||
isPrivate: true,
|
||||
senderPeerID: peerID
|
||||
))
|
||||
// Empty public content is dropped too.
|
||||
coordinator.didReceiveMessage(makeMessage(id: "empty", content: " "))
|
||||
await drainMainActorTasks()
|
||||
#expect(context.handledPublicMessages.isEmpty)
|
||||
#expect(context.handledPrivateMessages.isEmpty)
|
||||
#expect(context.mentionCheckedMessageIDs.isEmpty)
|
||||
#expect(context.meshDeliveryAcks.isEmpty)
|
||||
|
||||
// Private goes to the private handler, public to the public handler;
|
||||
// both get mention checks and haptics.
|
||||
coordinator.didReceiveMessage(makeMessage(id: "pm", isPrivate: true))
|
||||
// both get mention checks and haptics. Stable-media ACK authorization
|
||||
// belongs to BLEFileTransferHandler after its durable commit and this
|
||||
// synchronous acceptance result, not to the generic UI coordinator.
|
||||
let stableMediaID = "media-\(String(repeating: "a", count: 32))"
|
||||
coordinator.didReceiveMessage(makeMessage(
|
||||
id: stableMediaID,
|
||||
isPrivate: true,
|
||||
senderPeerID: peerID
|
||||
))
|
||||
coordinator.didReceiveMessage(makeMessage(
|
||||
id: "legacy-media",
|
||||
isPrivate: true,
|
||||
senderPeerID: peerID
|
||||
))
|
||||
coordinator.didReceiveMessage(makeMessage(id: "pm-missing-sender", isPrivate: true))
|
||||
coordinator.didReceiveMessage(makeMessage(id: "pub"))
|
||||
await drainMainActorTasks()
|
||||
#expect(context.handledPrivateMessages.map(\.id) == ["pm"])
|
||||
#expect(context.handledPrivateMessages.map(\.id) == [
|
||||
stableMediaID,
|
||||
"legacy-media",
|
||||
"pm-missing-sender"
|
||||
])
|
||||
#expect(context.handledPublicMessages.map(\.id) == ["pub"])
|
||||
#expect(context.mentionCheckedMessageIDs == ["pm", "pub"])
|
||||
#expect(context.hapticMessageIDs == ["pm", "pub"])
|
||||
#expect(context.mentionCheckedMessageIDs == [
|
||||
stableMediaID,
|
||||
"legacy-media",
|
||||
"pm-missing-sender",
|
||||
"pub"
|
||||
])
|
||||
#expect(context.hapticMessageIDs == [
|
||||
stableMediaID,
|
||||
"legacy-media",
|
||||
"pm-missing-sender",
|
||||
"pub"
|
||||
])
|
||||
#expect(context.meshDeliveryAcks.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func synchronousMessageDeliveryReportsAcceptanceForAckGating() {
|
||||
let context = MockChatTransportEventContext()
|
||||
let coordinator = ChatTransportEventCoordinator(context: context)
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
let blocked = makeMessage(
|
||||
id: "blocked-private-media",
|
||||
isPrivate: true,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
context.blockedMessageIDs = [blocked.id]
|
||||
|
||||
#expect(coordinator.didReceiveMessageSynchronously(blocked) == false)
|
||||
#expect(context.handledPrivateMessages.isEmpty)
|
||||
|
||||
let accepted = makeMessage(
|
||||
id: "accepted-private-media",
|
||||
isPrivate: true,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
#expect(coordinator.didReceiveMessageSynchronously(accepted) == true)
|
||||
#expect(context.handledPrivateMessages.map(\.id) == [accepted.id])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
@@ -295,6 +354,32 @@ struct ChatTransportEventCoordinatorContextTests {
|
||||
#expect(context.notifyUIChangedCount == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func synchronousConnectAndDisconnect_applyBeforeReturning() {
|
||||
let context = MockChatTransportEventContext()
|
||||
let coordinator = ChatTransportEventCoordinator(context: context)
|
||||
let peerID = PeerID(str: "2233445566778899")
|
||||
let incoming = makeMessage(
|
||||
id: "incoming-receipt",
|
||||
isPrivate: true,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
context.privateChats[peerID] = [incoming]
|
||||
|
||||
coordinator.didConnectToPeerSynchronously(peerID)
|
||||
|
||||
#expect(context.isConnected)
|
||||
#expect(context.registeredEphemeralSessions == [peerID])
|
||||
#expect(context.flushedOutboxPeerIDs == [peerID])
|
||||
#expect(context.courierRetryPeerIDs == [peerID])
|
||||
|
||||
coordinator.didDisconnectFromPeerSynchronously(peerID)
|
||||
|
||||
#expect(context.removedEphemeralSessions == [peerID])
|
||||
#expect(context.unmarkedReadReceiptBatches == [[incoming.id]])
|
||||
#expect(context.notifyUIChangedCount == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func didDisconnect_whileViewingChat_migratesConversationToStablePeerID() async {
|
||||
let context = MockChatTransportEventContext()
|
||||
|
||||
@@ -366,11 +366,31 @@ struct ChatViewModelNostrExtensionTests {
|
||||
#expect(!viewModel.messages.contains { $0.content == "Blocked" })
|
||||
}
|
||||
|
||||
// NOTE: Tampered-signature rejection is enforced once, off the main
|
||||
// actor, at the relay boundary (events only reach the inbound pipeline
|
||||
// after verification) — see NostrRelayManagerTests
|
||||
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache` and
|
||||
// `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`.
|
||||
@Test @MainActor
|
||||
func handleNostrEvent_rejectsInvalidSignature() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let geohash = "u4pruydq"
|
||||
let identity = try NostrIdentity.generate()
|
||||
|
||||
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
|
||||
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash]],
|
||||
content: "Valid"
|
||||
)
|
||||
var signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
signed.id = "deadbeef"
|
||||
|
||||
viewModel.handleNostrEvent(signed)
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
viewModel.publicMessagePipeline.flushIfNeeded()
|
||||
|
||||
#expect(!viewModel.messages.contains { $0.content == "Tampered" })
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws {
|
||||
@@ -559,14 +579,9 @@ struct ChatViewModelNostrExtensionTests {
|
||||
|
||||
viewModel.handleGiftWrap(giftWrap, id: recipient)
|
||||
|
||||
// Gift-wrap decryption runs off the main actor; wait for the ack
|
||||
// (sent even for blocked senders) to know processing finished.
|
||||
let didAck = await TestHelpers.waitUntil(
|
||||
{ viewModel.sentGeoDeliveryAcks.contains(messageID) },
|
||||
timeout: 5.0
|
||||
)
|
||||
#expect(didAck)
|
||||
try? await Task.sleep(nanoseconds: 50_000_000)
|
||||
#expect(viewModel.privateChats[convKey] == nil)
|
||||
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
|
||||
@@ -864,6 +864,80 @@ struct ChatViewModelPublicConversationTests {
|
||||
|
||||
struct ChatViewModelPeerTests {
|
||||
|
||||
@Test @MainActor
|
||||
func typedPeerLifecycleEvents_applyBeforeReturning() {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
let incoming = BitchatMessage(
|
||||
id: "typed-peer-incoming",
|
||||
sender: "Alice",
|
||||
content: "Hello",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: viewModel.nickname,
|
||||
senderPeerID: peerID
|
||||
)
|
||||
viewModel.seedPrivateChat([incoming], for: peerID)
|
||||
viewModel.sentReadReceipts.insert(incoming.id)
|
||||
|
||||
viewModel.didReceiveTransportEvent(.peerConnected(peerID))
|
||||
|
||||
#expect(viewModel.isConnected)
|
||||
|
||||
viewModel.didReceiveTransportEvent(.peerDisconnected(peerID))
|
||||
|
||||
#expect(!viewModel.sentReadReceipts.contains(incoming.id))
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func typedPeerListDeliveryAndBluetoothEvents_applyBeforeReturning() {
|
||||
let (viewModel, transport) = makeTestableViewModel()
|
||||
let stalePeer = PeerID(str: "00000000000000a2")
|
||||
let deliveryPeer = PeerID(str: "0102030405060708")
|
||||
let messageID = "typed-delivery-status"
|
||||
let delivered = DeliveryStatus.delivered(
|
||||
to: "Alice",
|
||||
at: Date(timeIntervalSince1970: 1_234)
|
||||
)
|
||||
let outgoing = BitchatMessage(
|
||||
id: messageID,
|
||||
sender: viewModel.nickname,
|
||||
content: "On the way",
|
||||
timestamp: Date(),
|
||||
isRelay: false,
|
||||
isPrivate: true,
|
||||
recipientNickname: "Alice",
|
||||
senderPeerID: transport.myPeerID,
|
||||
deliveryStatus: .sent
|
||||
)
|
||||
viewModel.markPrivateChatUnread(stalePeer)
|
||||
viewModel.seedPrivateChat([outgoing], for: deliveryPeer)
|
||||
|
||||
viewModel.didReceiveTransportEvent(.peerListUpdated([]))
|
||||
#expect(!viewModel.unreadPrivateMessages.contains(stalePeer))
|
||||
|
||||
viewModel.didReceiveTransportEvent(
|
||||
.messageDeliveryStatusUpdated(
|
||||
messageID: messageID,
|
||||
status: delivered
|
||||
)
|
||||
)
|
||||
#expect(
|
||||
viewModel.privateMessages(for: deliveryPeer).first?.deliveryStatus
|
||||
== delivered
|
||||
)
|
||||
|
||||
viewModel.didReceiveTransportEvent(.bluetoothStateUpdated(.poweredOff))
|
||||
#expect(viewModel.bluetoothState == .poweredOff)
|
||||
#expect(viewModel.showBluetoothAlert)
|
||||
|
||||
// Snapshot events belong to TransportPeerEventsDelegate and are
|
||||
// intentionally ignored at this typed sink.
|
||||
viewModel.didReceiveTransportEvent(.peerSnapshotsUpdated([]))
|
||||
#expect(viewModel.bluetoothState == .poweredOff)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func didConnectToPeer_notifiesDelegate() async {
|
||||
let (_, transport) = makeTestableViewModel()
|
||||
|
||||
@@ -45,154 +45,6 @@ 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")
|
||||
struct ConversationStoreTests {
|
||||
|
||||
@@ -288,282 +140,6 @@ struct ConversationStoreTests {
|
||||
#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
|
||||
|
||||
@Test("upsertByID replaces in place and appends when absent")
|
||||
|
||||
@@ -682,13 +682,18 @@ struct PrivateMediaEndToEndTests {
|
||||
#expect(!identity.hasObservedPrivateMediaCapability(
|
||||
fingerprint: impostorKey.sha256Fingerprint()
|
||||
))
|
||||
#expect(identity.hasObservedPrivateMediaCapability(
|
||||
fingerprint: bob.noiseStaticPublicKeyData().sha256Fingerprint()
|
||||
))
|
||||
alice._test_seedConnectedPeer(
|
||||
bob.myPeerID,
|
||||
nickname: "Bob",
|
||||
capabilities: [],
|
||||
noisePublicKey: impostorKey
|
||||
)
|
||||
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent)
|
||||
// The exact live Noise identity remains authoritative over a later
|
||||
// impostor registry rewrite.
|
||||
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -741,7 +746,7 @@ struct PrivateMediaEndToEndTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func consentedLegacySendRejectsAboveAndroidFragmentCapButEncryptedDoesNot() async throws {
|
||||
func encryptedAndConsentedLegacySendsRejectAboveAndroidFragmentCap() async throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("private-media-fragment-cap-\(UUID().uuidString)", isDirectory: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
@@ -790,24 +795,14 @@ struct PrivateMediaEndToEndTests {
|
||||
allowLegacyFallback: true
|
||||
)
|
||||
|
||||
// The directed raw-file migration fallback (Android-style peer without
|
||||
// the .privateMedia capability) still honors the 256-fragment ceiling.
|
||||
let legacyRejected = await TestHelpers.waitUntil(
|
||||
{ rejections.contains(legacyID) },
|
||||
let bothRejected = await TestHelpers.waitUntil(
|
||||
{ rejections.contains(encryptedID) && rejections.contains(legacyID) },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(legacyRejected)
|
||||
#expect(bothRejected)
|
||||
#expect(rejections.reason(for: encryptedID)?.contains("256") == true)
|
||||
#expect(rejections.reason(for: legacyID)?.contains("256") == true)
|
||||
|
||||
// Encrypted private media to a .privateMedia-capable peer is NOT forced
|
||||
// down to Android's 256 cap: it uses the full receiver ceiling and
|
||||
// proceeds to fragment/emit (a 130 KiB file exceeds 256 fragments).
|
||||
let encryptedEmitted = await TestHelpers.waitUntil(
|
||||
{ !tap.snapshot().isEmpty },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
#expect(encryptedEmitted, "Encrypted send to a capable peer must not be blocked by the Android cap")
|
||||
#expect(!rejections.contains(encryptedID))
|
||||
#expect(tap.snapshot().isEmpty, "No outer packet or fragment may be exposed before size rejection")
|
||||
_ = cancellable
|
||||
}
|
||||
|
||||
@@ -876,7 +871,7 @@ struct PrivateMediaEndToEndTests {
|
||||
+ marker
|
||||
+ Data(repeating: 0x4A, count: 6 * 1024)
|
||||
try await assertPrivateMediaRoundTrip(
|
||||
fileName: "private.jpg",
|
||||
fileName: "img_20260725_120000_11111111-1111-1111-1111-111111111111.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
content: content,
|
||||
marker: marker,
|
||||
@@ -1147,6 +1142,17 @@ struct PrivateMediaEndToEndTests {
|
||||
#expect(message.isPrivate)
|
||||
#expect(message.senderPeerID == alice.myPeerID)
|
||||
#expect(message.content.hasPrefix(expectedMessagePrefix))
|
||||
if let stableMessageID = PrivateMediaMessageIdentity.stableID(
|
||||
for: file,
|
||||
senderPeerID: alice.myPeerID,
|
||||
recipientPeerID: bob.myPeerID
|
||||
) {
|
||||
#expect(message.id == stableMessageID)
|
||||
} else {
|
||||
// Generic/legacy filenames retain random per-arrival IDs so two
|
||||
// unrelated "photo.jpg" transfers are never deduplicated.
|
||||
#expect(!message.id.hasPrefix("media-"))
|
||||
}
|
||||
|
||||
let stored = recursivelyStoredFiles(under: bobRoot)
|
||||
#expect(stored.count == 1)
|
||||
@@ -1289,6 +1295,8 @@ struct PrivateMediaEndToEndTests {
|
||||
|
||||
return enumerator.compactMap { item in
|
||||
guard let url = item as? URL,
|
||||
!url.pathComponents.contains(".private-media-receipts"),
|
||||
url.lastPathComponent != ".private-media-receipts.json",
|
||||
(try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -326,11 +326,26 @@ struct ChatViewModelPresenceHandlingTests {
|
||||
#expect(viewModel.geohashParticipantCount(for: activeGeohash) >= 1)
|
||||
}
|
||||
|
||||
// NOTE: Tampered-signature rejection (and the forged-copy dedup-poisoning
|
||||
// invariant) is enforced once, off the main actor, at the relay boundary —
|
||||
// the sampling path only ever sees verified events. See
|
||||
// NostrRelayManagerTests
|
||||
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache`.
|
||||
@Test func subscribeNostrEvent_samplingInvalidSignatureDoesNotPoisonDedup() async throws {
|
||||
let (viewModel, _) = makeTestableViewModel()
|
||||
let sampleGeohash = "u4pru"
|
||||
let identity = try NostrIdentity.generate()
|
||||
let event = NostrEvent(
|
||||
pubkey: identity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: [["g", sampleGeohash]],
|
||||
content: ""
|
||||
)
|
||||
let signed = try event.sign(with: identity.schnorrSigningKey())
|
||||
var invalid = signed
|
||||
invalid.sig = String(repeating: "0", count: 128)
|
||||
|
||||
viewModel.subscribeNostrEvent(invalid, gh: sampleGeohash)
|
||||
viewModel.subscribeNostrEvent(signed, gh: sampleGeohash)
|
||||
|
||||
#expect(viewModel.geohashParticipantCount(for: sampleGeohash) == 1)
|
||||
}
|
||||
|
||||
// MARK: - Test Helper
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import Testing
|
||||
@testable import BitFoundation // to avoid unnecessary public's
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("Integration Tests", .serialized)
|
||||
struct IntegrationTests {
|
||||
|
||||
private var helper = TestNetworkHelper()
|
||||
@@ -272,8 +273,18 @@ struct IntegrationTests {
|
||||
// Re-establish Noise handshake explicitly via managers
|
||||
do {
|
||||
let m1 = try helper.noiseManagers["Bob"]!.initiateHandshake(with: helper.nodes["Alice"]!.peerID)
|
||||
let m2 = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m1)!
|
||||
let m3 = try helper.noiseManagers["Bob"]!.handleIncomingHandshake(from: helper.nodes["Alice"]!.peerID, message: m2)!
|
||||
let m2 = try #require(
|
||||
try helper.noiseManagers["Alice"]!.handleIncomingHandshake(
|
||||
from: helper.nodes["Bob"]!.peerID,
|
||||
message: m1
|
||||
)
|
||||
)
|
||||
let m3 = try #require(
|
||||
try helper.noiseManagers["Bob"]!.handleIncomingHandshake(
|
||||
from: helper.nodes["Alice"]!.peerID,
|
||||
message: m2
|
||||
)
|
||||
)
|
||||
_ = try helper.noiseManagers["Alice"]!.handleIncomingHandshake(from: helper.nodes["Bob"]!.peerID, message: m3)
|
||||
} catch {
|
||||
Issue.record("Failed to re-establish Noise session after restart: \(error)")
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import Testing
|
||||
@testable import BitFoundation // to avoid unnecessary public's
|
||||
@testable import bitchat
|
||||
|
||||
@@ -27,9 +28,14 @@ final class TestNetworkHelper {
|
||||
node.mockNickname = name
|
||||
nodes[name] = node
|
||||
|
||||
// Create/replace Noise manager for this node
|
||||
// This synchronous helper directly drives all three XX messages and
|
||||
// has no transport callback loop for delayed collision recovery.
|
||||
let key = Curve25519.KeyAgreement.PrivateKey()
|
||||
noiseManagers[name] = NoiseSessionManager(localStaticKey: key, keychain: mockKeychain)
|
||||
noiseManagers[name] = NoiseSessionManager(
|
||||
localStaticKey: key,
|
||||
keychain: mockKeychain,
|
||||
recentInitiatorCompletionGracePeriod: 0
|
||||
)
|
||||
return node
|
||||
}
|
||||
|
||||
@@ -108,8 +114,18 @@ final class TestNetworkHelper {
|
||||
let peer2ID = nodes[node2]?.peerID else { return }
|
||||
|
||||
let msg1 = try manager1.initiateHandshake(with: peer2ID)
|
||||
let msg2 = try manager2.handleIncomingHandshake(from: peer1ID, message: msg1)!
|
||||
let msg3 = try manager1.handleIncomingHandshake(from: peer2ID, message: msg2)!
|
||||
let msg2 = try #require(
|
||||
try manager2.handleIncomingHandshake(
|
||||
from: peer1ID,
|
||||
message: msg1
|
||||
)
|
||||
)
|
||||
let msg3 = try #require(
|
||||
try manager1.handleIncomingHandshake(
|
||||
from: peer2ID,
|
||||
message: msg2
|
||||
)
|
||||
)
|
||||
_ = try manager2.handleIncomingHandshake(from: peer1ID, message: msg3)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,25 @@ import XCTest
|
||||
/// the pooled subscription must come up exactly once and go down exactly once.
|
||||
@MainActor
|
||||
final class NearbyNotesCounterTests: XCTestCase {
|
||||
private var previousNotesEnabled: Any?
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
previousNotesEnabled = UserDefaults.standard.object(forKey: "locationNotes.enabled")
|
||||
UserDefaults.standard.set(true, forKey: "locationNotes.enabled")
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
if let previous = previousNotesEnabled as? Bool {
|
||||
UserDefaults.standard.set(previous, forKey: "locationNotes.enabled")
|
||||
} else {
|
||||
UserDefaults.standard.removeObject(forKey: "locationNotes.enabled")
|
||||
}
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func test_counterOnlySubscribesAfterReveal_countsUnexpiredNotes_andUnsubscribesOnDeactivate() async throws {
|
||||
let relays = SubscriptionRecorder()
|
||||
let settings = LocationNotesSettingsStub()
|
||||
let locationManager = try await makeAuthorizedLocationManager()
|
||||
let buildingGeohash = try XCTUnwrap(
|
||||
locationManager.availableChannels.first(where: { $0.level == .building })?.geohash
|
||||
@@ -19,9 +35,7 @@ final class NearbyNotesCounterTests: XCTestCase {
|
||||
let counter = NearbyNotesCounter(
|
||||
locationManager: locationManager,
|
||||
managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) },
|
||||
releaseManager: { $0?.cancel() },
|
||||
locationNotesEnabled: { settings.enabled },
|
||||
locationNotesSettings: settings.changes
|
||||
releaseManager: { $0?.cancel() }
|
||||
)
|
||||
|
||||
counter.activate()
|
||||
@@ -79,14 +93,11 @@ final class NearbyNotesCounterTests: XCTestCase {
|
||||
|
||||
func test_permissionRevocation_releasesBuildingSubscriptionDespiteCachedChannels() async throws {
|
||||
let relays = SubscriptionRecorder()
|
||||
let settings = LocationNotesSettingsStub()
|
||||
let locationManager = try await makeAuthorizedLocationManager()
|
||||
let counter = NearbyNotesCounter(
|
||||
locationManager: locationManager,
|
||||
managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) },
|
||||
releaseManager: { $0?.cancel() },
|
||||
locationNotesEnabled: { settings.enabled },
|
||||
locationNotesSettings: settings.changes
|
||||
releaseManager: { $0?.cancel() }
|
||||
)
|
||||
|
||||
counter.activate()
|
||||
@@ -111,27 +122,24 @@ final class NearbyNotesCounterTests: XCTestCase {
|
||||
|
||||
func test_locationNotesKillSwitch_releasesAndCanReacquireBuildingSubscription() async throws {
|
||||
let relays = SubscriptionRecorder()
|
||||
let settings = LocationNotesSettingsStub()
|
||||
let locationManager = try await makeAuthorizedLocationManager()
|
||||
let counter = NearbyNotesCounter(
|
||||
locationManager: locationManager,
|
||||
managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) },
|
||||
releaseManager: { $0?.cancel() },
|
||||
locationNotesEnabled: { settings.enabled },
|
||||
locationNotesSettings: settings.changes
|
||||
releaseManager: { $0?.cancel() }
|
||||
)
|
||||
|
||||
counter.activate()
|
||||
counter.reveal()
|
||||
XCTAssertEqual(relays.subscribeCount, 1)
|
||||
|
||||
settings.setEnabled(false)
|
||||
LocationNotesSettings.enabled = false
|
||||
|
||||
let released = await waitUntil { relays.unsubscribeCount == 1 }
|
||||
XCTAssertTrue(released)
|
||||
XCTAssertEqual(counter.noteCount, 0)
|
||||
|
||||
settings.setEnabled(true)
|
||||
LocationNotesSettings.enabled = true
|
||||
|
||||
let reacquired = await waitUntil { relays.subscribeCount == 2 }
|
||||
XCTAssertTrue(reacquired)
|
||||
@@ -141,13 +149,10 @@ final class NearbyNotesCounterTests: XCTestCase {
|
||||
|
||||
func test_checkNotesHint_requiresAuthorizedLocationPermission() {
|
||||
let relays = SubscriptionRecorder()
|
||||
let settings = LocationNotesSettingsStub()
|
||||
let counter = NearbyNotesCounter(
|
||||
locationManager: makeBareLocationManager(),
|
||||
managerFactory: { LocationNotesManager(geohash: $0, dependencies: relays.dependencies) },
|
||||
releaseManager: { $0?.cancel() },
|
||||
locationNotesEnabled: { settings.enabled },
|
||||
locationNotesSettings: settings.changes
|
||||
releaseManager: { $0?.cancel() }
|
||||
)
|
||||
|
||||
// An unauthorized install must never see the hint: the tap can't
|
||||
@@ -159,9 +164,9 @@ final class NearbyNotesCounterTests: XCTestCase {
|
||||
XCTAssertTrue(counter.offersRevealHint(permissionState: .authorized))
|
||||
|
||||
// The app-info kill switch hides it too.
|
||||
settings.setEnabled(false)
|
||||
LocationNotesSettings.enabled = false
|
||||
XCTAssertFalse(counter.offersRevealHint(permissionState: .authorized))
|
||||
settings.setEnabled(true)
|
||||
LocationNotesSettings.enabled = true
|
||||
|
||||
// Once revealed, the hint yields to the live strip and count.
|
||||
counter.reveal()
|
||||
@@ -406,21 +411,6 @@ final class NearbyNotesCounterTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class LocationNotesSettingsStub {
|
||||
private let changesSubject = PassthroughSubject<Void, Never>()
|
||||
private(set) var enabled = true
|
||||
|
||||
var changes: AnyPublisher<Void, Never> {
|
||||
changesSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool) {
|
||||
self.enabled = enabled
|
||||
changesSubject.send(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Stub relay layer: counts REQs, captures the last filter/handler, and never
|
||||
/// touches the network.
|
||||
@MainActor
|
||||
|
||||
@@ -5,7 +5,7 @@ import BitFoundation
|
||||
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("Noise Coverage Tests")
|
||||
@Suite("Noise Coverage Tests", .serialized)
|
||||
struct NoiseCoverageTests {
|
||||
private let keychain = MockKeychain()
|
||||
private let aliceStaticKey = Curve25519.KeyAgreement.PrivateKey()
|
||||
@@ -633,8 +633,16 @@ struct NoiseCoverageTests {
|
||||
)
|
||||
let replacementSession = try #require(manager.getSession(for: alicePeerID))
|
||||
|
||||
#expect(replacementResponse != nil)
|
||||
#expect(replacementSession !== restartedSession)
|
||||
let localPeerID = PeerID(
|
||||
publicKey: aliceStaticKey.publicKey.rawRepresentation
|
||||
)
|
||||
if localPeerID < alicePeerID {
|
||||
#expect(replacementResponse == nil)
|
||||
#expect(replacementSession === restartedSession)
|
||||
} else {
|
||||
#expect(replacementResponse != nil)
|
||||
#expect(replacementSession !== restartedSession)
|
||||
}
|
||||
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceStaticKey, keychain: keychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobStaticKey, keychain: keychain)
|
||||
@@ -654,7 +662,13 @@ struct NoiseCoverageTests {
|
||||
try aliceManager.initiateHandshake(with: alicePeerID)
|
||||
}
|
||||
|
||||
let rekeyHandshake = try aliceManager.initiateRekey(for: alicePeerID)
|
||||
let rekeyInitiation = try aliceManager.initiateRekey(for: alicePeerID)
|
||||
let rekeyHandshake = try #require(
|
||||
aliceManager.claimHandshakeInitiation(
|
||||
rekeyInitiation,
|
||||
for: alicePeerID
|
||||
)
|
||||
)
|
||||
#expect(!rekeyHandshake.isEmpty)
|
||||
let rekeyedSession = try #require(aliceManager.getSession(for: alicePeerID))
|
||||
|
||||
@@ -667,6 +681,7 @@ struct NoiseCoverageTests {
|
||||
let aliceManager = NoiseSessionManager(
|
||||
localStaticKey: aliceStaticKey,
|
||||
keychain: keychain,
|
||||
recentInitiatorCompletionGracePeriod: 0,
|
||||
sessionFactory: { peerID, role in
|
||||
BlockingDecryptNoiseSession(
|
||||
peerID: peerID,
|
||||
|
||||
@@ -357,8 +357,18 @@ struct NoiseProtocolTests {
|
||||
|
||||
@Test func peerRestartDetection() throws {
|
||||
// Establish initial sessions
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||
// This test explicitly drives the three synchronous XX messages and
|
||||
// does not exercise the transport's delayed collision recovery.
|
||||
let aliceManager = NoiseSessionManager(
|
||||
localStaticKey: aliceKey,
|
||||
keychain: mockKeychain,
|
||||
recentInitiatorCompletionGracePeriod: 0
|
||||
)
|
||||
let bobManager = NoiseSessionManager(
|
||||
localStaticKey: bobKey,
|
||||
keychain: mockKeychain,
|
||||
recentInitiatorCompletionGracePeriod: 0
|
||||
)
|
||||
|
||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||
|
||||
@@ -377,15 +387,24 @@ struct NoiseProtocolTests {
|
||||
let newHandshake1 = try bobManagerRestarted.initiateHandshake(with: bobPeerID)
|
||||
|
||||
// Alice should accept the new handshake (clearing old session)
|
||||
let newHandshake2 = try aliceManager.handleIncomingHandshake(
|
||||
from: alicePeerID, message: newHandshake1)
|
||||
#expect(newHandshake2 != nil)
|
||||
let newHandshake2 = try #require(
|
||||
try aliceManager.handleIncomingHandshake(
|
||||
from: alicePeerID,
|
||||
message: newHandshake1
|
||||
)
|
||||
)
|
||||
|
||||
// Complete the new handshake
|
||||
let newHandshake3 = try bobManagerRestarted.handleIncomingHandshake(
|
||||
from: bobPeerID, message: newHandshake2!)
|
||||
#expect(newHandshake3 != nil)
|
||||
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: newHandshake3!)
|
||||
let newHandshake3 = try #require(
|
||||
try bobManagerRestarted.handleIncomingHandshake(
|
||||
from: bobPeerID,
|
||||
message: newHandshake2
|
||||
)
|
||||
)
|
||||
_ = try aliceManager.handleIncomingHandshake(
|
||||
from: alicePeerID,
|
||||
message: newHandshake3
|
||||
)
|
||||
|
||||
// Should be able to exchange messages with new sessions
|
||||
let testMessage = Data("After restart".utf8)
|
||||
@@ -543,8 +562,18 @@ struct NoiseProtocolTests {
|
||||
|
||||
@Test func nonceDesynchronizationCausesRehandshake() throws {
|
||||
// Test that nonce desynchronization leads to proper re-handshake
|
||||
let aliceManager = NoiseSessionManager(localStaticKey: aliceKey, keychain: mockKeychain)
|
||||
let bobManager = NoiseSessionManager(localStaticKey: bobKey, keychain: mockKeychain)
|
||||
// This test explicitly drives the three synchronous XX messages and
|
||||
// does not exercise the transport's delayed collision recovery.
|
||||
let aliceManager = NoiseSessionManager(
|
||||
localStaticKey: aliceKey,
|
||||
keychain: mockKeychain,
|
||||
recentInitiatorCompletionGracePeriod: 0
|
||||
)
|
||||
let bobManager = NoiseSessionManager(
|
||||
localStaticKey: bobKey,
|
||||
keychain: mockKeychain,
|
||||
recentInitiatorCompletionGracePeriod: 0
|
||||
)
|
||||
|
||||
// Establish sessions
|
||||
try establishManagerSessions(aliceManager: aliceManager, bobManager: bobManager)
|
||||
@@ -572,15 +601,25 @@ struct NoiseProtocolTests {
|
||||
let rehandshake1 = try bobManager.initiateHandshake(with: bobPeerID)
|
||||
|
||||
// Alice should accept despite having a "valid" (but desynced) session
|
||||
let rehandshake2 = try aliceManager.handleIncomingHandshake(
|
||||
from: alicePeerID, message: rehandshake1)
|
||||
#expect(rehandshake2 != nil, "Alice should accept handshake to fix desync")
|
||||
let rehandshake2 = try #require(
|
||||
try aliceManager.handleIncomingHandshake(
|
||||
from: alicePeerID,
|
||||
message: rehandshake1
|
||||
),
|
||||
"Alice should accept handshake to fix desync"
|
||||
)
|
||||
|
||||
// Complete handshake
|
||||
let rehandshake3 = try bobManager.handleIncomingHandshake(
|
||||
from: bobPeerID, message: rehandshake2!)
|
||||
#expect(rehandshake3 != nil)
|
||||
_ = try aliceManager.handleIncomingHandshake(from: alicePeerID, message: rehandshake3!)
|
||||
let rehandshake3 = try #require(
|
||||
try bobManager.handleIncomingHandshake(
|
||||
from: bobPeerID,
|
||||
message: rehandshake2
|
||||
)
|
||||
)
|
||||
_ = try aliceManager.handleIncomingHandshake(
|
||||
from: alicePeerID,
|
||||
message: rehandshake3
|
||||
)
|
||||
|
||||
// Verify communication works again
|
||||
let testResynced = Data("Resynced".utf8)
|
||||
|
||||
@@ -5,25 +5,19 @@ import XCTest
|
||||
|
||||
@MainActor
|
||||
final class GeoRelayDirectoryTests: XCTestCase {
|
||||
private func parse(_ csv: String) -> [GeoRelayDirectory.Entry] {
|
||||
GeoRelayDirectory.validatedEntries(
|
||||
from: Data(csv.utf8),
|
||||
policy: .live,
|
||||
minimumEntries: 1
|
||||
) ?? []
|
||||
}
|
||||
|
||||
func test_parseCSV_normalizesSecureRelaySchemesAndDeduplicatesEntries() {
|
||||
func test_parseCSV_normalizesRelaySchemesAndDeduplicatesEntries() {
|
||||
let csv = """
|
||||
relay url,lat,lon
|
||||
wss://one.example/,10,20
|
||||
https://one.example,10,20
|
||||
wss://one.example:443/,10,20
|
||||
two.example,11,21
|
||||
http://two.example/,11,21
|
||||
wss://two.example:443,11,21
|
||||
invalid row
|
||||
ws://three.example,not-a-lat,22
|
||||
"""
|
||||
|
||||
let parsed = Set(parse(csv))
|
||||
let parsed = Set(GeoRelayDirectory.parseCSV(csv))
|
||||
|
||||
XCTAssertEqual(
|
||||
parsed,
|
||||
@@ -34,136 +28,6 @@ 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() {
|
||||
let harness = makeHarness(
|
||||
cacheCSV: """
|
||||
@@ -290,15 +154,11 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
||||
harness.userDefaults.set(harness.clock.now, forKey: "georelay.lastFetchAt")
|
||||
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
|
||||
|
||||
let baselineRequestCount = await harness.fetcher.recordedRequestCount()
|
||||
directory.prefetchIfNeeded()
|
||||
// Negative check: nothing should have been scheduled, so there is no
|
||||
// condition to wait on. A modest delay gives a wrongly spawned fetch
|
||||
// task a chance to run before we compare against the baseline.
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
|
||||
let requestCount = await harness.fetcher.recordedRequestCount()
|
||||
XCTAssertEqual(requestCount, baselineRequestCount)
|
||||
XCTAssertEqual(requestCount, 0)
|
||||
XCTAssertFalse(directory.debugHasRetryTask)
|
||||
}
|
||||
|
||||
@@ -323,11 +183,9 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
||||
XCTAssertFalse(directory.debugHasRetryTask)
|
||||
|
||||
directory.prefetchIfNeeded(force: true)
|
||||
// Negative check against the captured baseline: the forced refetch
|
||||
// must be skipped, so there is no condition to wait on.
|
||||
try? await Task.sleep(nanoseconds: 20_000_000)
|
||||
let forcedRequestCount = await harness.fetcher.recordedRequestCount()
|
||||
XCTAssertEqual(forcedRequestCount, requestCount)
|
||||
XCTAssertEqual(forcedRequestCount, 1)
|
||||
}
|
||||
|
||||
func test_prefetchIfNeeded_runsRemoteFetchOffMainThread() async {
|
||||
@@ -385,53 +243,6 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
||||
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 {
|
||||
let activeNotification = Notification.Name("GeoRelayDirectoryTests.didBecomeActive")
|
||||
let harness = makeHarness(
|
||||
@@ -442,43 +253,26 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
||||
autoStart: true,
|
||||
activeNotificationName: activeNotification
|
||||
)
|
||||
// Wait on the refresh notification rather than the raw request count:
|
||||
// the request count increments before the directory finishes handling
|
||||
// the response on the main actor (resetting `isFetching`, recording
|
||||
// `lastFetchAt`). Posting the next trigger inside that gap would get
|
||||
// swallowed by the in-flight guard. The notification is posted at the
|
||||
// end of the synchronous success handler, so once it fires the next
|
||||
// trigger is guaranteed to be accepted.
|
||||
var refreshCount = 0
|
||||
let refreshObserver = harness.notificationCenter.addObserver(
|
||||
forName: .geoRelayDirectoryDidRefresh,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
refreshCount += 1
|
||||
}
|
||||
defer { harness.notificationCenter.removeObserver(refreshObserver) }
|
||||
|
||||
var directory: GeoRelayDirectory? = GeoRelayDirectory(dependencies: harness.dependencies)
|
||||
let initialFetch = await waitUntil { refreshCount == 1 }
|
||||
let initialFetch = await waitUntil {
|
||||
await harness.fetcher.recordedRequestCount() == 1
|
||||
}
|
||||
XCTAssertTrue(initialFetch)
|
||||
var requestCount = await harness.fetcher.recordedRequestCount()
|
||||
XCTAssertEqual(requestCount, 1)
|
||||
XCTAssertEqual(directory?.debugObserverCount, 2)
|
||||
|
||||
harness.clock.now = harness.clock.now.addingTimeInterval(6)
|
||||
harness.notificationCenter.post(name: .TorDidBecomeReady, object: nil)
|
||||
let torTriggered = await waitUntil { refreshCount == 2 }
|
||||
let torTriggered = await waitUntil {
|
||||
await harness.fetcher.recordedRequestCount() == 2
|
||||
}
|
||||
XCTAssertTrue(torTriggered)
|
||||
requestCount = await harness.fetcher.recordedRequestCount()
|
||||
XCTAssertEqual(requestCount, 2)
|
||||
|
||||
harness.clock.now = harness.clock.now.addingTimeInterval(61)
|
||||
harness.notificationCenter.post(name: activeNotification, object: nil)
|
||||
let activeTriggered = await waitUntil { refreshCount == 3 }
|
||||
let activeTriggered = await waitUntil {
|
||||
await harness.fetcher.recordedRequestCount() == 3
|
||||
}
|
||||
XCTAssertTrue(activeTriggered)
|
||||
requestCount = await harness.fetcher.recordedRequestCount()
|
||||
XCTAssertEqual(requestCount, 3)
|
||||
|
||||
weak var weakDirectory: GeoRelayDirectory?
|
||||
weakDirectory = directory
|
||||
@@ -495,14 +289,7 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
||||
fetchFactoryObserver: (@MainActor @Sendable () -> Void)? = nil,
|
||||
fetchObserver: (@Sendable () async -> Void)? = nil,
|
||||
autoStart: Bool = false,
|
||||
activeNotificationName: Notification.Name? = nil,
|
||||
validationPolicy: GeoRelayDirectoryValidationPolicy = GeoRelayDirectoryValidationPolicy(
|
||||
maximumBytes: 64 * 1024,
|
||||
maximumRows: 1_000,
|
||||
maximumEntries: 1_000,
|
||||
minimumRemoteEntries: 1,
|
||||
minimumRetainedFraction: 0
|
||||
)
|
||||
activeNotificationName: Notification.Name? = nil
|
||||
) -> GeoRelayHarness {
|
||||
let userDefaultsSuite = "GeoRelayDirectoryTests.\(UUID().uuidString)"
|
||||
let userDefaults = UserDefaults(suiteName: userDefaultsSuite)!
|
||||
@@ -560,8 +347,7 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
||||
await retryRecorder.record(delay)
|
||||
},
|
||||
activeNotificationName: activeNotificationName,
|
||||
autoStart: autoStart,
|
||||
validationPolicy: validationPolicy
|
||||
autoStart: autoStart
|
||||
)
|
||||
|
||||
return GeoRelayHarness(
|
||||
@@ -576,12 +362,8 @@ final class GeoRelayDirectoryTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
/// Polls until `condition` holds. The timeout is deliberately generous:
|
||||
/// constrained CI runners (2-core, serialized testing) can starve the
|
||||
/// detached utility-priority fetch task for seconds before it runs, and
|
||||
/// a successful wait returns as soon as the condition becomes true.
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 10.0,
|
||||
timeout: TimeInterval = 1.0,
|
||||
condition: @escaping @MainActor () async -> Bool
|
||||
) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
|
||||
@@ -77,10 +77,8 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
// MARK: - 1a. Nostr inbound event handling (fresh events)
|
||||
|
||||
/// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events
|
||||
/// (kind 20000): dedup record, presence/nickname bookkeeping, and
|
||||
/// public-message ingest scheduling. Schnorr signature verification is
|
||||
/// NOT part of this path anymore — it runs exactly once, off the main
|
||||
/// actor, in `NostrRelayManager` before delivery.
|
||||
/// (kind 20000): signature verification, dedup record, presence/nickname
|
||||
/// bookkeeping, and public-message ingest scheduling.
|
||||
func testNostrInboundEventHandling_freshEvents() throws {
|
||||
let events = try Self.makeSignedGeohashEvents(count: 500)
|
||||
// A fresh context per measure pass so every event takes the
|
||||
@@ -108,9 +106,8 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
|
||||
/// The dedup-hit path: identical events replayed. Duplicates dominate
|
||||
/// real relay traffic (the same event arrives from several relays), so
|
||||
/// this path runs hundreds of times a minute in busy geohashes. It is a
|
||||
/// pure dedup lookup: no crypto (verification happens upstream in
|
||||
/// `NostrRelayManager`, and only for the first-seen copy).
|
||||
/// this path runs hundreds of times a minute in busy geohashes. Note it
|
||||
/// still pays full Schnorr signature verification before the dedup check.
|
||||
func testNostrInboundEventHandling_duplicateEvents() throws {
|
||||
let events = try Self.makeSignedGeohashEvents(count: 500)
|
||||
let context = PerfNostrContext()
|
||||
@@ -504,62 +501,6 @@ final class PerformanceBaselineTests: XCTestCase {
|
||||
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)
|
||||
|
||||
/// `ConversationStore.auditInvariants()` over a realistic 5k-message
|
||||
|
||||
@@ -30,10 +30,6 @@
|
||||
"store.append": 213201,
|
||||
"store.audit": 362
|
||||
},
|
||||
"_reference_local_numbers_2026_07": {
|
||||
"store.steadyStateAppend_before": 2315,
|
||||
"store.steadyStateAppend": 53976
|
||||
},
|
||||
"floors": {
|
||||
"nostrInbound.fresh": 450,
|
||||
"nostrInbound.duplicate": 250000,
|
||||
@@ -45,7 +41,6 @@
|
||||
"pipeline.privateIngest": 3000,
|
||||
"pipeline.publicIngest": 2400,
|
||||
"store.append": 48000,
|
||||
"store.steadyStateAppend": 10000,
|
||||
"store.audit": 70
|
||||
},
|
||||
"_slowest_observed_ci_numbers_2026_06": {
|
||||
@@ -61,4 +56,4 @@
|
||||
"store.append": 97423,
|
||||
"store.audit": 140
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import BitFoundation
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
@@ -73,4 +74,85 @@ final class BitchatFilePacketTests: XCTestCase {
|
||||
XCTAssertEqual(decoded.fileSize, UInt64(content.count))
|
||||
XCTAssertEqual(decoded.content, content)
|
||||
}
|
||||
|
||||
func testPrivateMediaMessageIdentityConvergesAcrossPeerIDAliases() throws {
|
||||
let senderKey = Data(repeating: 0x11, count: 32)
|
||||
let recipientKey = Data(repeating: 0x22, count: 32)
|
||||
let senderStable = PeerID(hexData: senderKey)
|
||||
let recipientStable = PeerID(hexData: recipientKey)
|
||||
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||
|
||||
let senderID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: senderStable.toShort(),
|
||||
recipientPeerID: PeerID(str: "mesh:\(recipientStable.toShort().bare)"),
|
||||
fileName: fileName
|
||||
))
|
||||
let receiverID = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: senderStable,
|
||||
recipientPeerID: recipientStable.toShort(),
|
||||
fileName: fileName
|
||||
))
|
||||
|
||||
XCTAssertEqual(senderID, receiverID)
|
||||
XCTAssertTrue(senderID.hasPrefix("media-"))
|
||||
XCTAssertEqual(senderID.count, 38)
|
||||
XCTAssertTrue(PrivateMediaMessageIdentity.isStableID(senderID))
|
||||
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "A", count: 32))"))
|
||||
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID("media-\(String(repeating: "a", count: 31))"))
|
||||
XCTAssertFalse(PrivateMediaMessageIdentity.isStableID(UUID().uuidString))
|
||||
}
|
||||
|
||||
func testPrivateMediaMessageIdentitySeparatesDirectionAndFilename() throws {
|
||||
let alice = PeerID(str: "0011223344556677")
|
||||
let bob = PeerID(str: "8899aabbccddeeff")
|
||||
let firstName = "voice_20260725_105708_11111111-1111-1111-1111-111111111111.m4a"
|
||||
let secondName = "voice_20260725_105709_22222222-2222-2222-2222-222222222222.m4a"
|
||||
let first = try XCTUnwrap(PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: alice,
|
||||
recipientPeerID: bob,
|
||||
fileName: firstName
|
||||
))
|
||||
|
||||
XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: bob,
|
||||
recipientPeerID: alice,
|
||||
fileName: firstName
|
||||
))
|
||||
XCTAssertNotEqual(first, PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: alice,
|
||||
recipientPeerID: bob,
|
||||
fileName: secondName
|
||||
))
|
||||
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: alice,
|
||||
recipientPeerID: bob,
|
||||
fileName: nil
|
||||
))
|
||||
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: alice,
|
||||
recipientPeerID: bob,
|
||||
fileName: "photo.jpg"
|
||||
))
|
||||
XCTAssertNil(PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: alice,
|
||||
recipientPeerID: bob,
|
||||
fileName: "img_11111111-1111-1111-1111-111111111111.pdf"
|
||||
))
|
||||
XCTAssertNotNil(PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: alice,
|
||||
recipientPeerID: bob,
|
||||
fileName: "voice_0011223344556677.m4a"
|
||||
))
|
||||
}
|
||||
|
||||
func testPrivateMediaMessageIdentityMatchesVersionOneGoldenVector() {
|
||||
XCTAssertEqual(
|
||||
PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: PeerID(str: "0011223344556677"),
|
||||
recipientPeerID: PeerID(str: "8899aabbccddeeff"),
|
||||
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||
),
|
||||
"media-910bd42c65060ab76bb6406f220c4516"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,11 @@ import Testing
|
||||
struct BLEAnnounceHandlerTests {
|
||||
private final class Recorder {
|
||||
var existingNoisePublicKey: Data?
|
||||
var existingSigningPublicKey: Data?
|
||||
var persistedSigningPublicKey: Data?
|
||||
var persistedSigningKeyQueries: [PeerID] = []
|
||||
var authenticatedSigningPublicKey: Data?
|
||||
var signatureValid = true
|
||||
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
||||
var linkBoundToOtherPeer = false
|
||||
var upsertResult: BLEPeerAnnounceUpdate? = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||
var upsertResult = BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
|
||||
var dedupSeenIDs: Set<String> = []
|
||||
var shouldEmitReconnectLogResult = true
|
||||
|
||||
@@ -40,11 +37,7 @@ struct BLEAnnounceHandlerTests {
|
||||
localPeerID: { localPeerID },
|
||||
messageTTL: TransportConfig.messageTTLDefault,
|
||||
now: { now },
|
||||
existingPeerKeys: { _ in (recorder.existingNoisePublicKey, recorder.existingSigningPublicKey) },
|
||||
persistedSigningPublicKey: { peerID in
|
||||
recorder.persistedSigningKeyQueries.append(peerID)
|
||||
return recorder.persistedSigningPublicKey
|
||||
},
|
||||
existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
|
||||
authenticatedSigningPublicKey: { _ in recorder.authenticatedSigningPublicKey },
|
||||
verifySignature: { packet, signingPublicKey in
|
||||
recorder.verifySignatureCalls.append((packet, signingPublicKey))
|
||||
@@ -491,168 +484,6 @@ struct BLEAnnounceHandlerTests {
|
||||
#expect(recorder.topologyUpdates.first?.neighbors == neighbors)
|
||||
}
|
||||
|
||||
@Test
|
||||
func matchingPinnedSigningKeyIsAccepted() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x9A, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: timestamp(now),
|
||||
signature: Data(repeating: 0xEE, count: 64)
|
||||
)
|
||||
|
||||
let recorder = Recorder()
|
||||
recorder.existingNoisePublicKey = noiseKey
|
||||
// Matches the signing key encoded by makeAnnouncePacket.
|
||||
recorder.existingSigningPublicKey = Data(repeating: 0x99, count: 32)
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(recorder.upsertCalls.count == 1)
|
||||
#expect(recorder.persistedIdentities.count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func signingKeyMismatchWithPinnedKeySkipsUpsertAndIdentityPersistence() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x9B, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
// Attacker announce: victim's noiseKey/peerID, attacker's signing key
|
||||
// (0x99 from makeAnnouncePacket) with a "valid" self-signature.
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: timestamp(now),
|
||||
signature: Data(repeating: 0xEE, count: 64)
|
||||
)
|
||||
|
||||
let recorder = Recorder()
|
||||
recorder.existingNoisePublicKey = noiseKey
|
||||
recorder.existingSigningPublicKey = Data(repeating: 0x42, count: 32) // victim's pinned key
|
||||
recorder.signatureValid = true
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(recorder.upsertCalls.isEmpty)
|
||||
#expect(recorder.persistedIdentities.isEmpty)
|
||||
#expect(recorder.topologyUpdates.isEmpty)
|
||||
#expect(recorder.uiEventDeliveries.count == 1)
|
||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func persistedSigningKeyMismatchWithoutRegistryEntryIsRejected() throws {
|
||||
// Registry has no entry (app restart or offline-peer eviction), but
|
||||
// the persisted cryptographic identity still pins the victim's
|
||||
// signing key. An attacker replaying the victim's noiseKey/peerID
|
||||
// with their own signing key must not be treated as first contact.
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x9D, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: timestamp(now),
|
||||
signature: Data(repeating: 0xEE, count: 64)
|
||||
)
|
||||
|
||||
let recorder = Recorder()
|
||||
recorder.existingNoisePublicKey = nil
|
||||
recorder.existingSigningPublicKey = nil
|
||||
recorder.persistedSigningPublicKey = Data(repeating: 0x42, count: 32) // victim's persisted pin
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(recorder.persistedSigningKeyQueries == [peerID])
|
||||
#expect(recorder.upsertCalls.isEmpty)
|
||||
#expect(recorder.persistedIdentities.isEmpty)
|
||||
#expect(recorder.topologyUpdates.isEmpty)
|
||||
#expect(recorder.uiEventDeliveries.count == 1)
|
||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func persistedSigningKeyMatchWithoutRegistryEntryIsAccepted() throws {
|
||||
// Legitimate returning peer: registry entry evicted, persisted pin
|
||||
// matches the announced signing key — accepted like a normal announce.
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x9E, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: timestamp(now),
|
||||
signature: Data(repeating: 0xEE, count: 64)
|
||||
)
|
||||
|
||||
let recorder = Recorder()
|
||||
// Matches the signing key encoded by makeAnnouncePacket.
|
||||
recorder.persistedSigningPublicKey = Data(repeating: 0x99, count: 32)
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(recorder.upsertCalls.count == 1)
|
||||
#expect(recorder.persistedIdentities.count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func registryPinnedSigningKeySkipsPersistedLookup() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x9F, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: timestamp(now),
|
||||
signature: Data(repeating: 0xEE, count: 64)
|
||||
)
|
||||
|
||||
let recorder = Recorder()
|
||||
recorder.existingNoisePublicKey = noiseKey
|
||||
recorder.existingSigningPublicKey = Data(repeating: 0x99, count: 32)
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(recorder.persistedSigningKeyQueries.isEmpty)
|
||||
#expect(recorder.upsertCalls.count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func registryPinRejectionSkipsTopologyAndIdentityPersistence() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
let noiseKey = Data(repeating: 0x9C, count: 32)
|
||||
let peerID = PeerID(publicKey: noiseKey)
|
||||
let packet = try makeAnnouncePacket(
|
||||
noisePublicKey: noiseKey,
|
||||
peerID: peerID,
|
||||
timestamp: timestamp(now),
|
||||
signature: Data(repeating: 0xEE, count: 64),
|
||||
directNeighbors: [Data(repeating: 0xAB, count: 8)]
|
||||
)
|
||||
|
||||
// Pre-barrier trust check sees no pinned key (e.g. concurrent race),
|
||||
// but the registry itself refuses to replace its pinned signing key.
|
||||
let recorder = Recorder()
|
||||
recorder.upsertResult = nil
|
||||
let handler = makeHandler(recorder: recorder, now: now)
|
||||
|
||||
handler.handle(packet, from: peerID)
|
||||
|
||||
#expect(recorder.upsertCalls.count == 1)
|
||||
#expect(recorder.persistedIdentities.isEmpty)
|
||||
#expect(recorder.topologyUpdates.isEmpty)
|
||||
#expect(recorder.uiEventDeliveries.count == 1)
|
||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||
#expect(recorder.afterglowDelays.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func keyMismatchWithExistingPeerKeepsAnnounceUnverified() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
@@ -677,255 +508,6 @@ struct BLEAnnounceHandlerTests {
|
||||
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func attackerReplayingVictimNoiseKeyWithOwnSigningKeyIsRejectedEndToEnd() throws {
|
||||
// Real crypto: the attacker crafts a fully self-consistent announce
|
||||
// (victim's noiseKey/peerID, attacker's signing key and nickname,
|
||||
// valid packet signature made with the attacker's key). Without
|
||||
// signing-key pinning this used to overwrite the victim's registry
|
||||
// entry and persisted identity.
|
||||
let victim = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let attacker = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let victimNoiseKey = victim.getStaticPublicKeyData()
|
||||
let peerID = PeerID(publicKey: victimNoiseKey)
|
||||
let now = Date()
|
||||
|
||||
final class RegistryBox {
|
||||
var registry = BLEPeerRegistry()
|
||||
var persistedIdentities: [AnnouncementPacket] = []
|
||||
}
|
||||
let box = RegistryBox()
|
||||
|
||||
let environment = BLEAnnounceHandlerEnvironment(
|
||||
localPeerID: { PeerID(str: "0102030405060708") },
|
||||
messageTTL: TransportConfig.messageTTLDefault,
|
||||
now: { now },
|
||||
existingPeerKeys: { peerID in
|
||||
let info = box.registry.info(for: peerID)
|
||||
return (info?.noisePublicKey, info?.signingPublicKey)
|
||||
},
|
||||
persistedSigningPublicKey: { _ in nil },
|
||||
authenticatedSigningPublicKey: { _ in nil },
|
||||
verifySignature: { packet, signingPublicKey in
|
||||
victim.verifyPacketSignature(packet, publicKey: signingPublicKey)
|
||||
},
|
||||
linkState: { _ in (hasPeripheral: true, hasCentral: false) },
|
||||
linkBoundToOtherPeer: { _, _ in false },
|
||||
withRegistryBarrier: { body in body() },
|
||||
upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in
|
||||
box.registry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: announcement.nickname,
|
||||
noisePublicKey: announcement.noisePublicKey,
|
||||
signingPublicKey: announcement.signingPublicKey,
|
||||
isConnected: isConnected,
|
||||
now: now
|
||||
)
|
||||
},
|
||||
shouldEmitReconnectLog: { _, _ in false },
|
||||
updateTopology: { _, _ in },
|
||||
persistIdentity: { announcement in
|
||||
box.persistedIdentities.append(announcement)
|
||||
},
|
||||
dedupContains: { _ in true },
|
||||
dedupMarkProcessed: { _ in },
|
||||
deliverAnnounceUIEvents: { _, _, _ in },
|
||||
trackPacketSeen: { _ in },
|
||||
sendAnnounceBack: {},
|
||||
scheduleAfterglow: { _ in }
|
||||
)
|
||||
let handler = BLEAnnounceHandler(environment: environment)
|
||||
|
||||
func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) throws -> BitchatPacket {
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: nickname,
|
||||
noisePublicKey: victimNoiseKey,
|
||||
signingPublicKey: signer.getSigningPublicKeyData(),
|
||||
directNeighbors: nil
|
||||
)
|
||||
let payload = try #require(announcement.encode())
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: Data(hexString: peerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
return try #require(signer.signPacket(packet))
|
||||
}
|
||||
|
||||
// Legitimate announce from the victim is accepted and pinned.
|
||||
let victimAnnounce = try makeSignedAnnounce(nickname: "victim", signer: victim)
|
||||
handler.handle(victimAnnounce, from: peerID)
|
||||
|
||||
#expect(box.registry.info(for: peerID)?.nickname == "victim")
|
||||
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
|
||||
#expect(box.persistedIdentities.count == 1)
|
||||
|
||||
// Attacker announce with a valid self-signature must be rejected.
|
||||
let attackerAnnounce = try makeSignedAnnounce(nickname: "attacker", signer: attacker)
|
||||
handler.handle(attackerAnnounce, from: peerID)
|
||||
|
||||
#expect(box.registry.info(for: peerID)?.nickname == "victim")
|
||||
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
|
||||
#expect(box.persistedIdentities.count == 1)
|
||||
|
||||
// The victim's subsequent announces (same pinned key) still work.
|
||||
let victimRename = try makeSignedAnnounce(nickname: "victim-renamed", signer: victim)
|
||||
handler.handle(victimRename, from: peerID)
|
||||
|
||||
#expect(box.registry.info(for: peerID)?.nickname == "victim-renamed")
|
||||
#expect(box.persistedIdentities.count == 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
func signingKeyPinSurvivesRegistryEvictionAndRestartEndToEnd() throws {
|
||||
// Real crypto + real persistence: the victim announces and gets
|
||||
// pinned, then the registry entry disappears (offline-peer eviction
|
||||
// via reconcileConnectivity, or app restart which starts with an
|
||||
// empty registry). The attacker replays the victim's
|
||||
// noiseKey/peerID with their own signing key and a valid
|
||||
// self-signature — the persisted identity must still block the
|
||||
// takeover, and must not be overwritten. The victim (same signing
|
||||
// key) must be re-accepted.
|
||||
let victim = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let attacker = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let victimNoiseKey = victim.getStaticPublicKeyData()
|
||||
let peerID = PeerID(publicKey: victimNoiseKey)
|
||||
let now = Date()
|
||||
|
||||
let identityKeychain = MockKeychain()
|
||||
let identityManager = SecureIdentityStateManager(identityKeychain)
|
||||
|
||||
final class RegistryBox {
|
||||
var registry = BLEPeerRegistry()
|
||||
}
|
||||
let box = RegistryBox()
|
||||
|
||||
func makeEnvironment(identityManager: SecureIdentityStateManager) -> BLEAnnounceHandlerEnvironment {
|
||||
BLEAnnounceHandlerEnvironment(
|
||||
localPeerID: { PeerID(str: "0102030405060708") },
|
||||
messageTTL: TransportConfig.messageTTLDefault,
|
||||
now: { now },
|
||||
existingPeerKeys: { peerID in
|
||||
let info = box.registry.info(for: peerID)
|
||||
return (info?.noisePublicKey, info?.signingPublicKey)
|
||||
},
|
||||
// Mirrors the BLEService wiring: fall back to the persisted
|
||||
// cryptographic identity.
|
||||
persistedSigningPublicKey: { peerID in
|
||||
identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID)
|
||||
.compactMap { $0.signingPublicKey }
|
||||
.first
|
||||
},
|
||||
authenticatedSigningPublicKey: { _ in nil },
|
||||
verifySignature: { packet, signingPublicKey in
|
||||
victim.verifyPacketSignature(packet, publicKey: signingPublicKey)
|
||||
},
|
||||
linkState: { _ in (hasPeripheral: true, hasCentral: false) },
|
||||
linkBoundToOtherPeer: { _, _ in false },
|
||||
withRegistryBarrier: { body in body() },
|
||||
upsertVerifiedAnnounce: { peerID, announcement, isConnected, now in
|
||||
box.registry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: announcement.nickname,
|
||||
noisePublicKey: announcement.noisePublicKey,
|
||||
signingPublicKey: announcement.signingPublicKey,
|
||||
isConnected: isConnected,
|
||||
now: now
|
||||
)
|
||||
},
|
||||
shouldEmitReconnectLog: { _, _ in false },
|
||||
updateTopology: { _, _ in },
|
||||
persistIdentity: { announcement in
|
||||
identityManager.upsertCryptographicIdentity(
|
||||
fingerprint: announcement.noisePublicKey.sha256Fingerprint(),
|
||||
noisePublicKey: announcement.noisePublicKey,
|
||||
signingPublicKey: announcement.signingPublicKey,
|
||||
claimedNickname: announcement.nickname
|
||||
)
|
||||
},
|
||||
dedupContains: { _ in true },
|
||||
dedupMarkProcessed: { _ in },
|
||||
deliverAnnounceUIEvents: { _, _, _ in },
|
||||
trackPacketSeen: { _ in },
|
||||
sendAnnounceBack: {},
|
||||
scheduleAfterglow: { _ in }
|
||||
)
|
||||
}
|
||||
let handler = BLEAnnounceHandler(environment: makeEnvironment(identityManager: identityManager))
|
||||
|
||||
func makeSignedAnnounce(nickname: String, signer: NoiseEncryptionService) throws -> BitchatPacket {
|
||||
let announcement = AnnouncementPacket(
|
||||
nickname: nickname,
|
||||
noisePublicKey: victimNoiseKey,
|
||||
signingPublicKey: signer.getSigningPublicKeyData(),
|
||||
directNeighbors: nil
|
||||
)
|
||||
let payload = try #require(announcement.encode())
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.announce.rawValue,
|
||||
senderID: Data(hexString: peerID.id) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(now.timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
return try #require(signer.signPacket(packet))
|
||||
}
|
||||
|
||||
func persistedIdentity() -> CryptographicIdentity? {
|
||||
// queue.sync read; fences the manager's pending barrier writes.
|
||||
identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first
|
||||
}
|
||||
|
||||
// 1. Victim announces: pinned in the registry and persisted.
|
||||
handler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
|
||||
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
|
||||
#expect(persistedIdentity()?.signingPublicKey == victim.getSigningPublicKeyData())
|
||||
|
||||
// 2. Registry entry disappears (eviction / restart).
|
||||
_ = box.registry.remove(peerID)
|
||||
#expect(box.registry.info(for: peerID) == nil)
|
||||
|
||||
// 3. Attacker replay with own signing key: rejected via the persisted
|
||||
// pin, and neither the registry nor the persisted identity change.
|
||||
handler.handle(try makeSignedAnnounce(nickname: "attacker", signer: attacker), from: peerID)
|
||||
#expect(box.registry.info(for: peerID) == nil)
|
||||
#expect(persistedIdentity()?.signingPublicKey == victim.getSigningPublicKeyData())
|
||||
#expect(identityManager.getSocialIdentity(for: victimNoiseKey.sha256Fingerprint())?.claimedNickname == "victim")
|
||||
|
||||
// 4. Victim re-announces with the same signing key: accepted again.
|
||||
handler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
|
||||
#expect(box.registry.info(for: peerID)?.nickname == "victim")
|
||||
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
|
||||
|
||||
// 5. Simulated app restart: a fresh identity manager reloads the pin
|
||||
// from the (mock) keychain, and a fresh registry starts empty. The
|
||||
// attacker replay is still rejected.
|
||||
identityManager.forceSave()
|
||||
let reloadedManager = SecureIdentityStateManager(identityKeychain)
|
||||
#expect(
|
||||
reloadedManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey
|
||||
== victim.getSigningPublicKeyData()
|
||||
)
|
||||
box.registry = BLEPeerRegistry()
|
||||
let restartedHandler = BLEAnnounceHandler(environment: makeEnvironment(identityManager: reloadedManager))
|
||||
restartedHandler.handle(try makeSignedAnnounce(nickname: "attacker", signer: attacker), from: peerID)
|
||||
#expect(box.registry.info(for: peerID) == nil)
|
||||
#expect(
|
||||
reloadedManager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey
|
||||
== victim.getSigningPublicKeyData()
|
||||
)
|
||||
|
||||
// ...while the victim is accepted after the restart.
|
||||
restartedHandler.handle(try makeSignedAnnounce(nickname: "victim", signer: victim), from: peerID)
|
||||
#expect(box.registry.info(for: peerID)?.signingPublicKey == victim.getSigningPublicKeyData())
|
||||
}
|
||||
|
||||
private func expectNoSideEffects(_ recorder: Recorder) {
|
||||
#expect(recorder.barrierCount == 0)
|
||||
#expect(recorder.upsertCalls.isEmpty)
|
||||
|
||||
@@ -123,9 +123,7 @@ struct BLEAnnounceHandlingPolicyTests {
|
||||
hasSignature: false,
|
||||
signatureValid: false,
|
||||
existingNoisePublicKey: nil,
|
||||
announcedNoisePublicKey: Data(repeating: 0x11, count: 32),
|
||||
existingSigningPublicKey: nil,
|
||||
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
|
||||
announcedNoisePublicKey: Data(repeating: 0x11, count: 32)
|
||||
)
|
||||
|
||||
#expect(decision == .reject(.missingSignature))
|
||||
@@ -138,9 +136,7 @@ struct BLEAnnounceHandlingPolicyTests {
|
||||
hasSignature: true,
|
||||
signatureValid: false,
|
||||
existingNoisePublicKey: nil,
|
||||
announcedNoisePublicKey: Data(repeating: 0x11, count: 32),
|
||||
existingSigningPublicKey: nil,
|
||||
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
|
||||
announcedNoisePublicKey: Data(repeating: 0x11, count: 32)
|
||||
)
|
||||
|
||||
#expect(decision == .reject(.invalidSignature))
|
||||
@@ -152,9 +148,7 @@ struct BLEAnnounceHandlingPolicyTests {
|
||||
hasSignature: true,
|
||||
signatureValid: true,
|
||||
existingNoisePublicKey: Data(repeating: 0xAA, count: 32),
|
||||
announcedNoisePublicKey: Data(repeating: 0xBB, count: 32),
|
||||
existingSigningPublicKey: nil,
|
||||
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
|
||||
announcedNoisePublicKey: Data(repeating: 0xBB, count: 32)
|
||||
)
|
||||
|
||||
#expect(decision == .reject(.keyMismatch))
|
||||
@@ -168,51 +162,13 @@ struct BLEAnnounceHandlingPolicyTests {
|
||||
hasSignature: true,
|
||||
signatureValid: true,
|
||||
existingNoisePublicKey: noiseKey,
|
||||
announcedNoisePublicKey: noiseKey,
|
||||
existingSigningPublicKey: nil,
|
||||
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
|
||||
announcedNoisePublicKey: noiseKey
|
||||
)
|
||||
|
||||
#expect(decision == .verified)
|
||||
#expect(decision.isVerified)
|
||||
}
|
||||
|
||||
@Test
|
||||
func trustPolicyRejectsPinnedSigningKeyMismatchEvenWithValidSignature() {
|
||||
let noiseKey = Data(repeating: 0xCC, count: 32)
|
||||
|
||||
// Attacker replays the victim's noiseKey/peerID with their own signing
|
||||
// key and a valid self-signature; the pinned key must win.
|
||||
let decision = BLEAnnounceTrustPolicy.evaluate(
|
||||
hasSignature: true,
|
||||
signatureValid: true,
|
||||
existingNoisePublicKey: noiseKey,
|
||||
announcedNoisePublicKey: noiseKey,
|
||||
existingSigningPublicKey: Data(repeating: 0x99, count: 32),
|
||||
announcedSigningPublicKey: Data(repeating: 0x66, count: 32)
|
||||
)
|
||||
|
||||
#expect(decision == .reject(.signingKeyMismatch))
|
||||
#expect(!decision.isVerified)
|
||||
}
|
||||
|
||||
@Test
|
||||
func trustPolicyAcceptsMatchingPinnedSigningKey() {
|
||||
let noiseKey = Data(repeating: 0xCC, count: 32)
|
||||
let signingKey = Data(repeating: 0x99, count: 32)
|
||||
|
||||
let decision = BLEAnnounceTrustPolicy.evaluate(
|
||||
hasSignature: true,
|
||||
signatureValid: true,
|
||||
existingNoisePublicKey: noiseKey,
|
||||
announcedNoisePublicKey: noiseKey,
|
||||
existingSigningPublicKey: signingKey,
|
||||
announcedSigningPublicKey: signingKey
|
||||
)
|
||||
|
||||
#expect(decision == .verified)
|
||||
}
|
||||
|
||||
@Test
|
||||
func trustPolicyRejectsSigningKeyReplacementAfterNoiseBinding() {
|
||||
let noiseKey = Data(repeating: 0xCC, count: 32)
|
||||
|
||||
@@ -5,7 +5,7 @@ import Testing
|
||||
struct BLEAnnounceThrottleTests {
|
||||
@Test
|
||||
func firstAnnounceIsAllowed() {
|
||||
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
|
||||
let shouldSend = throttle.shouldSend(force: false, now: Date(timeIntervalSince1970: 100))
|
||||
|
||||
@@ -15,7 +15,7 @@ struct BLEAnnounceThrottleTests {
|
||||
@Test
|
||||
func regularAnnounceUsesNormalMinimumInterval() {
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
|
||||
let first = throttle.shouldSend(force: false, now: now)
|
||||
let suppressed = throttle.shouldSend(force: false, now: now.addingTimeInterval(9.9))
|
||||
@@ -29,7 +29,7 @@ struct BLEAnnounceThrottleTests {
|
||||
@Test
|
||||
func forcedAnnounceUsesShorterMinimumInterval() {
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
|
||||
let first = throttle.shouldSend(force: false, now: now)
|
||||
let suppressed = throttle.shouldSend(force: true, now: now.addingTimeInterval(1.9))
|
||||
@@ -43,40 +43,10 @@ struct BLEAnnounceThrottleTests {
|
||||
@Test
|
||||
func elapsedReportsTimeSinceAcceptedSend() {
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
|
||||
|
||||
_ = throttle.shouldSend(force: false, now: now)
|
||||
|
||||
#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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,27 @@ struct BLEFileTransferHandlerTests {
|
||||
|
||||
var signatureVerifyCount = 0
|
||||
var signedNameQueries: [PeerID] = []
|
||||
var blockedPeers: Set<PeerID> = []
|
||||
var trackedPackets: [BitchatPacket] = []
|
||||
var quotaReservations: [Int] = []
|
||||
var saveCalls: [(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String)] = []
|
||||
var receiptStates: [String: BLEPrivateMediaReceiptState] = [:]
|
||||
var receiptCommits: [(messageID: String, storedURL: URL)] = []
|
||||
var receiptCommitSucceeds = true
|
||||
var removedIncomingFiles: [URL] = []
|
||||
var lastSeenUpdates: [PeerID] = []
|
||||
var deliveryAcks: [(messageID: String, peerID: PeerID)] = []
|
||||
var deliveredMessages: [BitchatMessage] = []
|
||||
var saveOverride: ((
|
||||
_ data: Data,
|
||||
_ preferredName: String?,
|
||||
_ subdirectory: String,
|
||||
_ fallbackExtension: String?,
|
||||
_ defaultPrefix: String
|
||||
) -> URL?)?
|
||||
var receiptStateOverride: ((String) -> BLEPrivateMediaReceiptState)?
|
||||
var receiptCommitOverride: ((String, URL) -> Bool)?
|
||||
var removeIncomingFileOverride: ((URL) -> Void)?
|
||||
}
|
||||
|
||||
private let localPeerID = PeerID(str: "0102030405060708")
|
||||
@@ -46,13 +62,44 @@ struct BLEFileTransferHandlerTests {
|
||||
},
|
||||
saveIncomingFile: { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
|
||||
recorder.saveCalls.append((data, preferredName, subdirectory, fallbackExtension, defaultPrefix))
|
||||
if let saveOverride = recorder.saveOverride {
|
||||
return saveOverride(data, preferredName, subdirectory, fallbackExtension, defaultPrefix)
|
||||
}
|
||||
return recorder.saveResult
|
||||
},
|
||||
privateMediaReceiptState: { messageID in
|
||||
if let receiptStateOverride = recorder.receiptStateOverride {
|
||||
return receiptStateOverride(messageID)
|
||||
}
|
||||
return recorder.receiptStates[messageID] ?? .absent
|
||||
},
|
||||
commitPrivateMediaFile: { messageID, storedURL in
|
||||
recorder.receiptCommits.append((messageID, storedURL))
|
||||
if let receiptCommitOverride = recorder.receiptCommitOverride {
|
||||
return receiptCommitOverride(messageID, storedURL)
|
||||
}
|
||||
guard recorder.receiptCommitSucceeds else { return false }
|
||||
recorder.receiptStates[messageID] = .accepted(storedURL)
|
||||
return true
|
||||
},
|
||||
removeIncomingFile: { storedURL in
|
||||
recorder.removedIncomingFiles.append(storedURL)
|
||||
recorder.removeIncomingFileOverride?(storedURL)
|
||||
},
|
||||
isPrivateMediaSenderBlocked: { peerID in
|
||||
recorder.blockedPeers.contains(peerID)
|
||||
},
|
||||
updatePeerLastSeen: { peerID in
|
||||
recorder.lastSeenUpdates.append(peerID)
|
||||
},
|
||||
deliverMessage: { message in
|
||||
acknowledgePrivateMedia: { messageID, peerID in
|
||||
recorder.deliveryAcks.append((messageID, peerID))
|
||||
},
|
||||
deliverMessage: { message, shouldDeliver, completion in
|
||||
guard shouldDeliver() else { return }
|
||||
recorder.deliveredMessages.append(message)
|
||||
guard shouldDeliver() else { return }
|
||||
completion()
|
||||
}
|
||||
)
|
||||
return BLEFileTransferHandler(environment: environment)
|
||||
@@ -284,6 +331,7 @@ struct BLEFileTransferHandlerTests {
|
||||
#expect(recorder.lastSeenUpdates == [remotePeerID])
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
||||
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
|
||||
// Must be explicit: BitchatMessage defaults private messages to
|
||||
// .sending, which the media views render as an in-flight send
|
||||
// (empty reveal mask, disabled reveal tap).
|
||||
@@ -291,13 +339,14 @@ struct BLEFileTransferHandlerTests {
|
||||
}
|
||||
|
||||
@Test
|
||||
func decryptedPrivateFileUsesValidationQuotaAndPrivateDeliveryWithoutRawSignature() throws {
|
||||
func bit8EncryptedPrivateFileKeepsStableIDAndAckWithoutBit9Proof() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "secret.jpg",
|
||||
fileName: fileName,
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
@@ -316,6 +365,357 @@ struct BLEFileTransferHandlerTests {
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveredMessages.first?.isPrivate == true)
|
||||
#expect(recorder.deliveredMessages.first?.timestamp == timestamp)
|
||||
#expect(recorder.deliveredMessages.first?.id == PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: remotePeerID,
|
||||
recipientPeerID: localPeerID,
|
||||
fileName: fileName
|
||||
))
|
||||
#expect(recorder.receiptCommits.count == 1)
|
||||
#expect(recorder.deliveryAcks.count == 1)
|
||||
#expect(recorder.deliveryAcks.first?.messageID == recorder.deliveredMessages.first?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rawLegacyPrivateFileWithRetryShapedNameNeverUsesReceiptLedger() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(
|
||||
remotePeerID,
|
||||
nickname: "Alice",
|
||||
isVerified: true,
|
||||
signingPublicKey: sampleSigningKey
|
||||
)]
|
||||
recorder.signatureVerifies = true
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||
let packet = try makeFileTransferPacket(
|
||||
sender: remotePeerID,
|
||||
mimeType: "image/jpeg",
|
||||
content: content,
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||
)
|
||||
|
||||
#expect(handler.handle(packet, from: remotePeerID))
|
||||
#expect(recorder.receiptCommits.isEmpty)
|
||||
#expect(recorder.deliveryAcks.isEmpty)
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
|
||||
}
|
||||
|
||||
@Test
|
||||
func repeatedLegacyPrivateImageNamesKeepDistinctRandomMessageIDs() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "photo.jpg",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
)
|
||||
let payload = try #require(file.encode())
|
||||
|
||||
#expect(handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||
))
|
||||
#expect(handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||
))
|
||||
|
||||
#expect(recorder.deliveredMessages.count == 2)
|
||||
#expect(recorder.deliveredMessages[0].id != recorder.deliveredMessages[1].id)
|
||||
#expect(recorder.deliveredMessages.allSatisfy { !$0.id.hasPrefix("media-") })
|
||||
}
|
||||
|
||||
@Test
|
||||
func lostCapabilityProofThenStableRetryReusesDurableIDWithoutSecondDiskWrite() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||
let fileName = "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||
let file = BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
)
|
||||
let payload = try #require(file.encode())
|
||||
let expectedID = try #require(PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: remotePeerID,
|
||||
recipientPeerID: localPeerID,
|
||||
fileName: fileName
|
||||
))
|
||||
|
||||
// First encrypted arrival may precede the sender's authenticated bit-9
|
||||
// proof. It still uses the bit-8 stable ID/ACK contract.
|
||||
#expect(handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||
))
|
||||
// A later automatic retry after proof must resolve the same durable ID
|
||||
// rather than create a legacy random-ID bubble.
|
||||
#expect(handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||
))
|
||||
|
||||
#expect(recorder.quotaReservations == [content.count])
|
||||
#expect(recorder.saveCalls.count == 1)
|
||||
// The handler re-offers a durable duplicate so a relaunched UI can
|
||||
// restore its bubble; the synchronous conversation sink deduplicates.
|
||||
#expect(recorder.deliveredMessages.count == 2)
|
||||
#expect(recorder.lastSeenUpdates == [remotePeerID, remotePeerID])
|
||||
#expect(recorder.deliveryAcks.count == 2)
|
||||
#expect(recorder.deliveryAcks.allSatisfy {
|
||||
$0.messageID == expectedID && $0.peerID == remotePeerID
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
func acceptedPrivateMediaAfterRelaunchRedeliversDurableURLBeforeAck() throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
"private-media-handler-relaunch-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let store = BLEIncomingFileStore(baseDirectory: root)
|
||||
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
)
|
||||
let payload = try #require(file.encode())
|
||||
|
||||
func configure(_ recorder: Recorder) {
|
||||
recorder.peers = [remotePeerID: makePeerInfo(
|
||||
remotePeerID,
|
||||
nickname: "Alice",
|
||||
isVerified: true
|
||||
)]
|
||||
recorder.saveOverride = {
|
||||
data,
|
||||
preferredName,
|
||||
subdirectory,
|
||||
fallbackExtension,
|
||||
defaultPrefix in
|
||||
store.save(
|
||||
data: data,
|
||||
preferredName: preferredName,
|
||||
subdirectory: subdirectory,
|
||||
fallbackExtension: fallbackExtension,
|
||||
defaultPrefix: defaultPrefix
|
||||
)
|
||||
}
|
||||
recorder.receiptStateOverride = {
|
||||
store.privateMediaReceiptState(messageID: $0)
|
||||
}
|
||||
recorder.receiptCommitOverride = {
|
||||
store.commitPrivateMediaFile(messageID: $0, storedURL: $1)
|
||||
}
|
||||
recorder.removeIncomingFileOverride = {
|
||||
store.removeIncomingFile(at: $0)
|
||||
}
|
||||
}
|
||||
|
||||
let first = Recorder()
|
||||
configure(first)
|
||||
#expect(makeHandler(recorder: first).handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||
))
|
||||
let originalMessage = try #require(first.deliveredMessages.first)
|
||||
#expect(first.deliveryAcks.count == 1)
|
||||
|
||||
// A fresh handler models process relaunch: its in-memory reservation
|
||||
// cache is empty, so only the durable receipt can suppress disk work.
|
||||
let relaunched = Recorder()
|
||||
configure(relaunched)
|
||||
#expect(makeHandler(recorder: relaunched).handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||
))
|
||||
|
||||
#expect(relaunched.quotaReservations.isEmpty)
|
||||
#expect(relaunched.saveCalls.isEmpty)
|
||||
#expect(relaunched.receiptCommits.isEmpty)
|
||||
#expect(relaunched.deliveredMessages.count == 1)
|
||||
#expect(relaunched.deliveredMessages.first?.id == originalMessage.id)
|
||||
#expect(relaunched.deliveredMessages.first?.content == originalMessage.content)
|
||||
#expect(relaunched.deliveryAcks.count == 1)
|
||||
#expect(relaunched.deliveryAcks.first?.messageID == originalMessage.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
func inFlightStableDuplicateIsNotAcknowledgedAndFailedSaveRemainsRetryable() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
)
|
||||
let payload = try #require(file.encode())
|
||||
var handler: BLEFileTransferHandler!
|
||||
var nestedResult: Bool?
|
||||
var failFirstSave = true
|
||||
recorder.saveOverride = { _, _, _, _, _ in
|
||||
if failFirstSave {
|
||||
failFirstSave = false
|
||||
nestedResult = handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: self.remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||
)
|
||||
return nil
|
||||
}
|
||||
return recorder.saveResult
|
||||
}
|
||||
handler = makeHandler(recorder: recorder)
|
||||
|
||||
// The nested arrival sees the first reservation as pending. It is
|
||||
// coalesced without an ACK; then the first durable save fails.
|
||||
#expect(!handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||
))
|
||||
#expect(nestedResult == true)
|
||||
#expect(recorder.saveCalls.count == 1)
|
||||
#expect(recorder.deliveryAcks.isEmpty)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
|
||||
// Failure released the reservation, so the sender's later retry can
|
||||
// persist and deliver normally.
|
||||
#expect(handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_236)
|
||||
))
|
||||
#expect(recorder.saveCalls.count == 2)
|
||||
#expect(recorder.deliveryAcks.count == 1)
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
func unavailableDurableReceiptStateWithholdsDiskDeliveryAndAck() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(
|
||||
remotePeerID,
|
||||
nickname: "Alice",
|
||||
isVerified: true
|
||||
)]
|
||||
let fileName =
|
||||
"img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||
let messageID = try #require(PrivateMediaMessageIdentity.stableID(
|
||||
senderPeerID: remotePeerID,
|
||||
recipientPeerID: localPeerID,
|
||||
fileName: fileName
|
||||
))
|
||||
recorder.receiptStates[messageID] = .unavailable
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||
let payload = try #require(BitchatFilePacket(
|
||||
fileName: fileName,
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
).encode())
|
||||
|
||||
#expect(handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||
))
|
||||
#expect(recorder.quotaReservations.isEmpty)
|
||||
#expect(recorder.saveCalls.isEmpty)
|
||||
#expect(recorder.receiptCommits.isEmpty)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
#expect(recorder.deliveryAcks.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func durableReceiptCommitFailureRollsBackAndWithholdsDeliveryAck() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.peers = [remotePeerID: makePeerInfo(
|
||||
remotePeerID,
|
||||
nickname: "Alice",
|
||||
isVerified: true
|
||||
)]
|
||||
recorder.receiptCommitSucceeds = false
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||
let payload = try #require(BitchatFilePacket(
|
||||
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
).encode())
|
||||
|
||||
#expect(!handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||
))
|
||||
#expect(recorder.saveCalls.count == 1)
|
||||
#expect(recorder.receiptCommits.count == 1)
|
||||
#expect(recorder.removedIncomingFiles.count == 1)
|
||||
#expect(recorder.removedIncomingFiles.first == recorder.saveResult)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
#expect(recorder.deliveryAcks.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func blockedPrivateMediaIsDroppedBeforeQuotaDiskAndDedupState() throws {
|
||||
let recorder = Recorder()
|
||||
recorder.blockedPeers = [remotePeerID]
|
||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let content = Data([0xFF, 0xD8, 0xFF]) + Data(repeating: 0x41, count: 128)
|
||||
let file = BitchatFilePacket(
|
||||
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
|
||||
fileSize: UInt64(content.count),
|
||||
mimeType: "image/jpeg",
|
||||
content: content
|
||||
)
|
||||
let payload = try #require(file.encode())
|
||||
|
||||
#expect(handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||
))
|
||||
|
||||
#expect(recorder.quotaReservations.isEmpty)
|
||||
#expect(recorder.saveCalls.isEmpty)
|
||||
#expect(recorder.lastSeenUpdates.isEmpty)
|
||||
#expect(recorder.deliveryAcks.isEmpty)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
|
||||
// Unblocking must allow a retry through; the blocked attempt cannot
|
||||
// poison the stable-ID dedup reservation.
|
||||
recorder.blockedPeers = []
|
||||
#expect(handler.handlePrivatePayload(
|
||||
payload,
|
||||
from: remotePeerID,
|
||||
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||
))
|
||||
#expect(recorder.saveCalls.count == 1)
|
||||
#expect(recorder.deliveredMessages.count == 1)
|
||||
#expect(recorder.deliveryAcks.count == 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -337,6 +737,7 @@ struct BLEFileTransferHandlerTests {
|
||||
#expect(recorder.quotaReservations.isEmpty)
|
||||
#expect(recorder.saveCalls.isEmpty)
|
||||
#expect(recorder.lastSeenUpdates.isEmpty)
|
||||
#expect(recorder.deliveryAcks.isEmpty)
|
||||
#expect(recorder.deliveredMessages.isEmpty)
|
||||
}
|
||||
|
||||
@@ -468,6 +869,33 @@ struct BLEFileTransferHandlerTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func panicWipeClearsCachedPrivateMediaReceiptDecisions() throws {
|
||||
let base = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(
|
||||
"panic-receipt-cache-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: base) }
|
||||
let messageID = "media-00112233445566778899aabbccddeeff"
|
||||
|
||||
let seed = BLEPrivateMediaReceiptStore(baseDirectory: base)
|
||||
#expect(seed.recordDeleted(messageID: messageID))
|
||||
|
||||
let store = BLEIncomingFileStore(baseDirectory: base)
|
||||
#expect(
|
||||
store.privateMediaReceiptState(messageID: messageID)
|
||||
== .tombstoned
|
||||
)
|
||||
|
||||
try store.panicWipe()
|
||||
|
||||
#expect(
|
||||
store.privateMediaReceiptState(messageID: messageID)
|
||||
== .absent
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
|
||||
enum MarkerFailure: Error { case unavailable }
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import BitFoundation
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
@@ -11,7 +12,11 @@ struct BLENoisePacketHandlerTests {
|
||||
var handshakeAuthenticated = false
|
||||
var hasSession = false
|
||||
let sessionGeneration = UUID()
|
||||
var awaitingResponderHandshake = false
|
||||
var decryptResult: Result<Data, Error> = .success(Data())
|
||||
var currentDate = Date(timeIntervalSince1970: 1_000)
|
||||
var transportGenerationReady = false
|
||||
var forcedServiceDecryptError: Error?
|
||||
|
||||
var processedHandshakes: [(peerID: PeerID, message: Data)] = []
|
||||
var hasSessionQueries: [PeerID] = []
|
||||
@@ -34,11 +39,12 @@ struct BLENoisePacketHandlerTests {
|
||||
recorder: Recorder,
|
||||
now: Date = Date(timeIntervalSince1970: 1_000)
|
||||
) -> BLENoisePacketHandler {
|
||||
recorder.currentDate = now
|
||||
let environment = BLENoisePacketHandlerEnvironment(
|
||||
localPeerID: { [localPeerID] in localPeerID },
|
||||
localPeerIDData: { [localPeerIDData] in localPeerIDData },
|
||||
messageTTL: TransportConfig.messageTTLDefault,
|
||||
now: { now },
|
||||
now: { recorder.currentDate },
|
||||
processHandshakeMessage: { peerID, message in
|
||||
recorder.processedHandshakes.append((peerID, message))
|
||||
return NoiseHandshakeProcessingResult(
|
||||
@@ -51,6 +57,9 @@ struct BLENoisePacketHandlerTests {
|
||||
recorder.hasSessionQueries.append(peerID)
|
||||
return recorder.hasSession
|
||||
},
|
||||
isAwaitingResponderHandshakeCompletion: { _ in
|
||||
recorder.awaitingResponderHandshake
|
||||
},
|
||||
initiateHandshake: { peerID in
|
||||
recorder.initiatedHandshakes.append(peerID)
|
||||
recorder.events.append("initiateHandshake")
|
||||
@@ -82,6 +91,120 @@ struct BLENoisePacketHandlerTests {
|
||||
return BLENoisePacketHandler(environment: environment)
|
||||
}
|
||||
|
||||
private func makeServiceBackedHandler(
|
||||
service: NoiseEncryptionService,
|
||||
localPeerID: PeerID,
|
||||
recorder: Recorder,
|
||||
transportGenerationIsReady:
|
||||
@escaping (UUID) -> Bool
|
||||
) -> BLENoisePacketHandler {
|
||||
BLENoisePacketHandler(
|
||||
environment: BLENoisePacketHandlerEnvironment(
|
||||
localPeerID: { localPeerID },
|
||||
localPeerIDData: {
|
||||
Data(hexString: localPeerID.id) ?? Data()
|
||||
},
|
||||
messageTTL: TransportConfig.messageTTLDefault,
|
||||
now: { recorder.currentDate },
|
||||
processHandshakeMessage: { peerID, message in
|
||||
try service.processHandshakeMessageWithResult(
|
||||
from: peerID,
|
||||
message: message
|
||||
)
|
||||
},
|
||||
hasNoiseSession: { peerID in
|
||||
service.hasSession(with: peerID)
|
||||
},
|
||||
isAwaitingResponderHandshakeCompletion: { peerID in
|
||||
service.isAwaitingResponderHandshakeCompletion(
|
||||
with: peerID
|
||||
)
|
||||
},
|
||||
initiateHandshake: { peerID in
|
||||
recorder.initiatedHandshakes.append(peerID)
|
||||
},
|
||||
broadcastPacket: { packet in
|
||||
recorder.broadcastPackets.append(packet)
|
||||
},
|
||||
updatePeerLastSeen: { peerID in
|
||||
recorder.lastSeenUpdates.append(peerID)
|
||||
},
|
||||
decrypt: { payload, peerID in
|
||||
recorder.decryptCalls.append((payload, peerID))
|
||||
if let error = recorder.forcedServiceDecryptError {
|
||||
throw error
|
||||
}
|
||||
let result =
|
||||
try service.decryptWithSessionGeneration(
|
||||
payload,
|
||||
from: peerID,
|
||||
establishedGenerationIsReady:
|
||||
transportGenerationIsReady
|
||||
)
|
||||
return BLENoiseDecryptionResult(
|
||||
plaintext: result.plaintext,
|
||||
sessionGeneration: result.sessionGeneration
|
||||
)
|
||||
},
|
||||
clearSession: { peerID in
|
||||
recorder.clearedSessions.append(peerID)
|
||||
service.clearSession(for: peerID)
|
||||
},
|
||||
handleAuthenticatedPeerState: {
|
||||
peerID, payload, generation in
|
||||
recorder.authenticatedPeerStates.append(
|
||||
(peerID, payload, generation)
|
||||
)
|
||||
},
|
||||
deliverNoisePayload: {
|
||||
peerID, type, payload, timestamp in
|
||||
recorder.deliveries.append(
|
||||
(peerID, type, payload, timestamp)
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func establishedServices() throws -> (
|
||||
sender: NoiseEncryptionService,
|
||||
receiver: NoiseEncryptionService,
|
||||
senderPeerID: PeerID,
|
||||
receiverPeerID: PeerID
|
||||
) {
|
||||
let sender = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let receiver = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let senderPeerID = PeerID(
|
||||
publicKey: sender.getStaticPublicKeyData()
|
||||
)
|
||||
let receiverPeerID = PeerID(
|
||||
publicKey: receiver.getStaticPublicKeyData()
|
||||
)
|
||||
let message1 = try sender.initiateHandshake(with: receiverPeerID)
|
||||
let message2 = try #require(
|
||||
try receiver.processHandshakeMessage(
|
||||
from: senderPeerID,
|
||||
message: message1
|
||||
)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try sender.processHandshakeMessage(
|
||||
from: receiverPeerID,
|
||||
message: message2
|
||||
)
|
||||
)
|
||||
_ = try receiver.processHandshakeMessage(
|
||||
from: senderPeerID,
|
||||
message: message3
|
||||
)
|
||||
return (
|
||||
sender,
|
||||
receiver,
|
||||
senderPeerID,
|
||||
receiverPeerID
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: Handshake
|
||||
|
||||
@Test
|
||||
@@ -198,6 +321,24 @@ struct BLENoisePacketHandlerTests {
|
||||
#expect(recorder.broadcastPackets.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func managedHandshakeFailureDoesNotStartASecondRecovery() {
|
||||
let recorder = Recorder()
|
||||
recorder.handshakeResult = .failure(
|
||||
NoiseManagedHandshakeFailure(underlying: TestError())
|
||||
)
|
||||
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
|
||||
|
||||
@Test
|
||||
@@ -346,6 +487,799 @@ struct BLENoisePacketHandlerTests {
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func earlyCiphertextIsRetriedAfterResponderHandshakeCompletes() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(
|
||||
CryptoKitError.authenticationFailure
|
||||
)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let encrypted = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
)
|
||||
|
||||
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 1)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.privateMessage.rawValue, 0xCA, 0xFE])
|
||||
)
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 2)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.type == .privateMessage)
|
||||
#expect(recorder.deliveries.first?.payload == Data([0xCA, 0xFE]))
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func panicResetDiscardsDeferredCiphertextBeforeFutureAuthentication() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(
|
||||
CryptoKitError.authenticationFailure
|
||||
)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let prePanicCiphertext = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
payload: Data(
|
||||
count: NoiseSecurityConstants.maxMessageSize
|
||||
+ NoiseSecurityConstants.transportCiphertextOverhead
|
||||
)
|
||||
)
|
||||
|
||||
handler.handleEncrypted(prePanicCiphertext, from: remotePeerID)
|
||||
#expect(recorder.decryptCalls.count == 1)
|
||||
|
||||
handler.resetForPanic()
|
||||
|
||||
// Three maximum-sized packets fit only when reset also zeroed the
|
||||
// global byte accounting. They model ciphertext received under the
|
||||
// replacement identity before that responder handshake completes.
|
||||
for index in 0..<3 {
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
timestamp: UInt64(901_000 + index),
|
||||
payload: Data(
|
||||
count: NoiseSecurityConstants.maxMessageSize
|
||||
+ NoiseSecurityConstants.transportCiphertextOverhead
|
||||
)
|
||||
),
|
||||
from: remotePeerID
|
||||
)
|
||||
}
|
||||
#expect(recorder.decryptCalls.count == 4)
|
||||
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.privateMessage.rawValue, 0xCA, 0xFE])
|
||||
)
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
|
||||
// Only the three post-reset packets replay; the pre-panic packet does
|
||||
// not survive into the replacement session.
|
||||
#expect(recorder.decryptCalls.count == 7)
|
||||
#expect(recorder.deliveries.count == 3)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func ciphertextQueuedAheadOfEstablishmentCallbackDoesNotConsumeNonce()
|
||||
throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(
|
||||
publicKey: alice.getStaticPublicKeyData()
|
||||
)
|
||||
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||
|
||||
let message1 = try alice.initiateHandshake(with: bobPeerID)
|
||||
let message2 = try #require(
|
||||
try bob.processHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
message: message1
|
||||
)
|
||||
)
|
||||
let message3 = try #require(
|
||||
try alice.processHandshakeMessage(
|
||||
from: bobPeerID,
|
||||
message: message2
|
||||
)
|
||||
)
|
||||
let typedPayload = Data([
|
||||
NoisePayloadType.privateMessage.rawValue,
|
||||
0xCA, 0xFE
|
||||
])
|
||||
let ciphertext = try alice.encrypt(
|
||||
typedPayload,
|
||||
for: bobPeerID
|
||||
)
|
||||
|
||||
// Manager promotion has completed, but the serialized BLE callback is
|
||||
// deliberately still behind this ciphertext.
|
||||
_ = try bob.processHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
message: message3
|
||||
)
|
||||
let recorder = Recorder()
|
||||
recorder.transportGenerationReady = false
|
||||
let handler = makeServiceBackedHandler(
|
||||
service: bob,
|
||||
localPeerID: bobPeerID,
|
||||
recorder: recorder,
|
||||
transportGenerationIsReady: { _ in
|
||||
recorder.transportGenerationReady
|
||||
}
|
||||
)
|
||||
let packet = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: bobPeerID.id),
|
||||
payload: ciphertext
|
||||
)
|
||||
|
||||
handler.handleEncrypted(packet, from: alicePeerID)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
|
||||
// The exact ciphertext must still authenticate, proving the readiness
|
||||
// rejection happened before the receive nonce was consumed.
|
||||
recorder.transportGenerationReady = true
|
||||
handler.handleSessionAuthenticated(alicePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 2)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.type == .privateMessage)
|
||||
#expect(recorder.deliveries.first?.payload == Data([0xCA, 0xFE]))
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func ciphertextQueuedAheadOfRestoreCallbackDoesNotConsumeNonce()
|
||||
throws {
|
||||
let alice = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let bob = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let mallory = NoiseEncryptionService(keychain: MockKeychain())
|
||||
let alicePeerID = PeerID(
|
||||
publicKey: alice.getStaticPublicKeyData()
|
||||
)
|
||||
let bobPeerID = PeerID(publicKey: bob.getStaticPublicKeyData())
|
||||
|
||||
let initial1 = try alice.initiateHandshake(with: bobPeerID)
|
||||
let initial2 = try #require(
|
||||
try bob.processHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
message: initial1
|
||||
)
|
||||
)
|
||||
let initial3 = try #require(
|
||||
try alice.processHandshakeMessage(
|
||||
from: bobPeerID,
|
||||
message: initial2
|
||||
)
|
||||
)
|
||||
_ = try bob.processHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
message: initial3
|
||||
)
|
||||
let typedPayload = Data([
|
||||
NoisePayloadType.privateMessage.rawValue,
|
||||
0xBE, 0xEF
|
||||
])
|
||||
let delayedCiphertext = try alice.encrypt(
|
||||
typedPayload,
|
||||
for: bobPeerID
|
||||
)
|
||||
|
||||
let forged1 = try mallory.initiateHandshake(with: bobPeerID)
|
||||
let forged2 = try #require(
|
||||
try bob.processHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
message: forged1
|
||||
)
|
||||
)
|
||||
let forged3 = try #require(
|
||||
try mallory.processHandshakeMessage(
|
||||
from: bobPeerID,
|
||||
message: forged2
|
||||
)
|
||||
)
|
||||
#expect(throws: NoiseSessionError.peerIdentityMismatch) {
|
||||
try bob.processHandshakeMessage(
|
||||
from: alicePeerID,
|
||||
message: forged3
|
||||
)
|
||||
}
|
||||
#expect(bob.hasEstablishedSession(with: alicePeerID))
|
||||
|
||||
let recorder = Recorder()
|
||||
recorder.transportGenerationReady = false
|
||||
let handler = makeServiceBackedHandler(
|
||||
service: bob,
|
||||
localPeerID: bobPeerID,
|
||||
recorder: recorder,
|
||||
transportGenerationIsReady: { _ in
|
||||
recorder.transportGenerationReady
|
||||
}
|
||||
)
|
||||
let packet = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: bobPeerID.id),
|
||||
payload: delayedCiphertext
|
||||
)
|
||||
|
||||
// Manager rollback is visible, while the BLE restore callback is
|
||||
// deliberately still queued behind this ciphertext.
|
||||
handler.handleEncrypted(packet, from: alicePeerID)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
|
||||
recorder.transportGenerationReady = true
|
||||
handler.handleSessionAuthenticated(alicePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 2)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.type == .privateMessage)
|
||||
#expect(recorder.deliveries.first?.payload == Data([0xBE, 0xEF]))
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func oversizedCiphertextCannotEvictEstablishedTransport() throws {
|
||||
let pair = try establishedServices()
|
||||
let recorder = Recorder()
|
||||
recorder.transportGenerationReady = true
|
||||
let handler = makeServiceBackedHandler(
|
||||
service: pair.receiver,
|
||||
localPeerID: pair.receiverPeerID,
|
||||
recorder: recorder,
|
||||
transportGenerationIsReady: { _ in
|
||||
recorder.transportGenerationReady
|
||||
}
|
||||
)
|
||||
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||
payload: Data(
|
||||
count:
|
||||
NoiseSecurityConstants
|
||||
.maxPrivateFileCiphertextSize + 1
|
||||
)
|
||||
),
|
||||
from: pair.senderPeerID
|
||||
)
|
||||
#expect(
|
||||
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
|
||||
)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
|
||||
let valid = try pair.sender.encrypt(
|
||||
Data([NoisePayloadType.privateMessage.rawValue, 0x01]),
|
||||
for: pair.receiverPeerID
|
||||
)
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||
payload: valid
|
||||
),
|
||||
from: pair.senderPeerID
|
||||
)
|
||||
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.payload == Data([0x01]))
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func forgedAuthenticationFailureCannotEvictEstablishedTransport()
|
||||
throws {
|
||||
let pair = try establishedServices()
|
||||
let recorder = Recorder()
|
||||
recorder.transportGenerationReady = true
|
||||
let handler = makeServiceBackedHandler(
|
||||
service: pair.receiver,
|
||||
localPeerID: pair.receiverPeerID,
|
||||
recorder: recorder,
|
||||
transportGenerationIsReady: { _ in
|
||||
recorder.transportGenerationReady
|
||||
}
|
||||
)
|
||||
let valid = try pair.sender.encrypt(
|
||||
Data([NoisePayloadType.privateMessage.rawValue, 0x02]),
|
||||
for: pair.receiverPeerID
|
||||
)
|
||||
var forged = valid
|
||||
forged[forged.index(before: forged.endIndex)] ^= 0xFF
|
||||
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||
payload: forged
|
||||
),
|
||||
from: pair.senderPeerID
|
||||
)
|
||||
#expect(
|
||||
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
|
||||
)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
|
||||
// Authentication failure leaves nonce state untouched, so the exact
|
||||
// original ciphertext remains valid.
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||
payload: valid
|
||||
),
|
||||
from: pair.senderPeerID
|
||||
)
|
||||
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.payload == Data([0x02]))
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func replayCannotEvictEstablishedTransportOrBlockNextNonce() throws {
|
||||
let pair = try establishedServices()
|
||||
let recorder = Recorder()
|
||||
recorder.transportGenerationReady = true
|
||||
let handler = makeServiceBackedHandler(
|
||||
service: pair.receiver,
|
||||
localPeerID: pair.receiverPeerID,
|
||||
recorder: recorder,
|
||||
transportGenerationIsReady: { _ in
|
||||
recorder.transportGenerationReady
|
||||
}
|
||||
)
|
||||
let first = try pair.sender.encrypt(
|
||||
Data([NoisePayloadType.privateMessage.rawValue, 0x03]),
|
||||
for: pair.receiverPeerID
|
||||
)
|
||||
let firstPacket = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||
payload: first
|
||||
)
|
||||
|
||||
handler.handleEncrypted(firstPacket, from: pair.senderPeerID)
|
||||
handler.handleEncrypted(firstPacket, from: pair.senderPeerID)
|
||||
#expect(
|
||||
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
|
||||
)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
|
||||
let next = try pair.sender.encrypt(
|
||||
Data([NoisePayloadType.privateMessage.rawValue, 0x04]),
|
||||
for: pair.receiverPeerID
|
||||
)
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||
payload: next
|
||||
),
|
||||
from: pair.senderPeerID
|
||||
)
|
||||
|
||||
#expect(recorder.deliveries.count == 2)
|
||||
#expect(recorder.deliveries.map { $0.payload } == [
|
||||
Data([0x03]), Data([0x04])
|
||||
])
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func rateLimitFailureCannotEvictEstablishedTransportOrConsumeNonce()
|
||||
throws {
|
||||
let pair = try establishedServices()
|
||||
let recorder = Recorder()
|
||||
recorder.transportGenerationReady = true
|
||||
recorder.forcedServiceDecryptError =
|
||||
NoiseSecurityError.rateLimitExceeded
|
||||
let handler = makeServiceBackedHandler(
|
||||
service: pair.receiver,
|
||||
localPeerID: pair.receiverPeerID,
|
||||
recorder: recorder,
|
||||
transportGenerationIsReady: { _ in
|
||||
recorder.transportGenerationReady
|
||||
}
|
||||
)
|
||||
let valid = try pair.sender.encrypt(
|
||||
Data([NoisePayloadType.privateMessage.rawValue, 0x05]),
|
||||
for: pair.receiverPeerID
|
||||
)
|
||||
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||
payload: Data(repeating: 0xA5, count: 20)
|
||||
),
|
||||
from: pair.senderPeerID
|
||||
)
|
||||
#expect(
|
||||
pair.receiver.hasEstablishedSession(with: pair.senderPeerID)
|
||||
)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
|
||||
recorder.forcedServiceDecryptError = nil
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: pair.receiverPeerID.id),
|
||||
payload: valid
|
||||
),
|
||||
from: pair.senderPeerID
|
||||
)
|
||||
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.payload == Data([0x05]))
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func maximumPrivateFileCiphertextIsEligibleForDeferredRetry() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(
|
||||
CryptoKitError.authenticationFailure
|
||||
)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let encrypted = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
payload: Data(
|
||||
count: NoiseSecurityConstants.maxPrivateFileCiphertextSize
|
||||
)
|
||||
)
|
||||
|
||||
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.privateFile.rawValue, 0x01])
|
||||
)
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 2)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.type == .privateFile)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func oversizedEarlyCiphertextIsNotDeferred() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(
|
||||
CryptoKitError.authenticationFailure
|
||||
)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let encrypted = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
payload: Data(
|
||||
count:
|
||||
NoiseSecurityConstants.maxPrivateFileCiphertextSize + 1
|
||||
)
|
||||
)
|
||||
|
||||
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||
)
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 1)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func missingSessionCiphertextIsRetriedAfterResponderHandshakeCompletes() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(
|
||||
NoiseEncryptionError.sessionNotEstablished
|
||||
)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let encrypted = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
)
|
||||
|
||||
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||
#expect(recorder.decryptCalls.count == 1)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||
)
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 2)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func lowNonceCiphertextIsRetriedAfterResponderHandshakeCompletes() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(NoiseError.replayDetected)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let encrypted = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
)
|
||||
|
||||
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.readReceipt.rawValue, 0x02])
|
||||
)
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 2)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.deliveries.first?.type == .readReceipt)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func invalidDeferredCiphertextDoesNotClearAuthenticatedSession() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(
|
||||
CryptoKitError.authenticationFailure
|
||||
)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let encrypted = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
)
|
||||
|
||||
handler.handleEncrypted(encrypted, from: remotePeerID)
|
||||
recorder.awaitingResponderHandshake = false
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 2)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func nonCipherFailureDuringResponderHandshakeIsDroppedNotDeferred() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(TestError())
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
),
|
||||
from: remotePeerID
|
||||
)
|
||||
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||
)
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 1)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func earlyCiphertextBufferIsBoundedPerPeer() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(
|
||||
CryptoKitError.authenticationFailure
|
||||
)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
|
||||
for index in 0..<5 {
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
timestamp: UInt64(900_000 + index)
|
||||
),
|
||||
from: remotePeerID
|
||||
)
|
||||
}
|
||||
#expect(recorder.decryptCalls.count == 5)
|
||||
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||
)
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 9)
|
||||
#expect(recorder.deliveries.count == 4)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func earlyCiphertextBufferIsBoundedGlobally() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(
|
||||
CryptoKitError.authenticationFailure
|
||||
)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let peers = (1...33).map {
|
||||
PeerID(str: String(format: "%016llx", UInt64($0)))
|
||||
}
|
||||
let packet = makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
)
|
||||
|
||||
for peerID in peers {
|
||||
handler.handleEncrypted(packet, from: peerID)
|
||||
}
|
||||
#expect(recorder.decryptCalls.count == 33)
|
||||
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||
)
|
||||
for peerID in peers {
|
||||
handler.handleSessionAuthenticated(peerID)
|
||||
}
|
||||
|
||||
#expect(recorder.decryptCalls.count == 65)
|
||||
#expect(recorder.deliveries.count == 32)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func earlyCiphertextBufferKeepsPrivateFileRoomAndByteBound() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(
|
||||
CryptoKitError.authenticationFailure
|
||||
)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
let peers = [
|
||||
PeerID(str: "0000000000000001"),
|
||||
PeerID(str: "0000000000000002"),
|
||||
PeerID(str: "0000000000000003")
|
||||
]
|
||||
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
payload: Data(
|
||||
count:
|
||||
NoiseSecurityConstants.maxPrivateFileCiphertextSize
|
||||
)
|
||||
),
|
||||
from: peers[0]
|
||||
)
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
payload: Data(count: 256 * 1024)
|
||||
),
|
||||
from: peers[1]
|
||||
)
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id),
|
||||
payload: Data([0x01])
|
||||
),
|
||||
from: peers[2]
|
||||
)
|
||||
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||
)
|
||||
for peerID in peers {
|
||||
handler.handleSessionAuthenticated(peerID)
|
||||
}
|
||||
|
||||
#expect(recorder.decryptCalls.count == 5)
|
||||
#expect(recorder.deliveries.count == 2)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func expiredEarlyCiphertextIsNotRetried() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(
|
||||
CryptoKitError.authenticationFailure
|
||||
)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
),
|
||||
from: remotePeerID
|
||||
)
|
||||
|
||||
recorder.currentDate =
|
||||
recorder.currentDate.addingTimeInterval(
|
||||
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
|
||||
+ 0.001
|
||||
)
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||
)
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 1)
|
||||
#expect(recorder.deliveries.isEmpty)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func earlyCiphertextSurvivesResponderHandshakeWindow() {
|
||||
let recorder = Recorder()
|
||||
recorder.hasSession = true
|
||||
recorder.awaitingResponderHandshake = true
|
||||
recorder.decryptResult = .failure(
|
||||
CryptoKitError.authenticationFailure
|
||||
)
|
||||
let handler = makeHandler(recorder: recorder)
|
||||
handler.handleEncrypted(
|
||||
makeEncryptedPacket(
|
||||
recipientID: Data(hexString: localPeerID.id)
|
||||
),
|
||||
from: remotePeerID
|
||||
)
|
||||
|
||||
recorder.currentDate =
|
||||
recorder.currentDate.addingTimeInterval(
|
||||
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
|
||||
- 0.001
|
||||
)
|
||||
recorder.awaitingResponderHandshake = false
|
||||
recorder.decryptResult = .success(
|
||||
Data([NoisePayloadType.delivered.rawValue, 0x01])
|
||||
)
|
||||
handler.handleSessionAuthenticated(remotePeerID)
|
||||
|
||||
#expect(recorder.decryptCalls.count == 2)
|
||||
#expect(recorder.deliveries.count == 1)
|
||||
#expect(recorder.clearedSessions.isEmpty)
|
||||
#expect(recorder.initiatedHandshakes.isEmpty)
|
||||
}
|
||||
|
||||
private func makeHandshakePacket(recipientID: Data?) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.noiseHandshake.rawValue,
|
||||
@@ -360,14 +1294,15 @@ struct BLENoisePacketHandlerTests {
|
||||
|
||||
private func makeEncryptedPacket(
|
||||
recipientID: Data?,
|
||||
timestamp: UInt64 = 900_000
|
||||
timestamp: UInt64 = 900_000,
|
||||
payload: Data = Data([0xC0, 0xFF, 0xEE])
|
||||
) -> BitchatPacket {
|
||||
BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: remotePeerID.id) ?? Data(),
|
||||
recipientID: recipientID,
|
||||
timestamp: timestamp,
|
||||
payload: Data([0xC0, 0xFF, 0xEE]),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: TransportConfig.messageTTLDefault
|
||||
)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("BLE Noise reconnect policy")
|
||||
struct BLENoiseReconnectPolicyTests {
|
||||
@Test("Revalidation requires a cached session and no authenticated link")
|
||||
func revalidationPreconditions() {
|
||||
var policy = BLENoiseReconnectPolicy()
|
||||
let link = BLEIngressLinkID.peripheral("peripheral-a")
|
||||
let now = Date(timeIntervalSince1970: 1_000)
|
||||
|
||||
let withoutSession = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: false,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: now
|
||||
)
|
||||
#expect(!withoutSession)
|
||||
let authenticated = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: true,
|
||||
hasAuthenticatedPeerLink: true,
|
||||
now: now
|
||||
)
|
||||
#expect(!authenticated)
|
||||
let eligible = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: now
|
||||
)
|
||||
#expect(eligible)
|
||||
}
|
||||
|
||||
@Test("Revalidation is once per link epoch or after sixty seconds")
|
||||
func revalidationIsBoundPerLinkEpoch() {
|
||||
var policy = BLENoiseReconnectPolicy()
|
||||
let link = BLEIngressLinkID.central("central-a")
|
||||
let start = Date(timeIntervalSince1970: 2_000)
|
||||
|
||||
let initial = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: start
|
||||
)
|
||||
#expect(initial)
|
||||
let duringCooldown = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: start.addingTimeInterval(59.999)
|
||||
)
|
||||
#expect(!duringCooldown)
|
||||
let afterCooldown = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: start.addingTimeInterval(60)
|
||||
)
|
||||
#expect(afterCooldown)
|
||||
|
||||
policy.endLinkEpoch(link)
|
||||
let nextEpoch = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: start.addingTimeInterval(60.001)
|
||||
)
|
||||
#expect(nextEpoch)
|
||||
}
|
||||
|
||||
@Test("An authenticated sibling suppresses redundant reconnect")
|
||||
func authenticatedSiblingSuppressesReconnect() {
|
||||
var policy = BLENoiseReconnectPolicy()
|
||||
let link = BLEIngressLinkID.peripheral("unproven-sibling")
|
||||
let start = Date(timeIntervalSince1970: 3_000)
|
||||
|
||||
let suppressed = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: true,
|
||||
now: start
|
||||
)
|
||||
#expect(!suppressed)
|
||||
let eligible = policy.shouldRevalidate(
|
||||
on: link,
|
||||
hasEstablishedSession: true,
|
||||
isNoiseAuthenticatedLink: false,
|
||||
hasAuthenticatedPeerLink: false,
|
||||
now: start
|
||||
)
|
||||
#expect(eligible)
|
||||
}
|
||||
|
||||
@Test("Reserved replacement bit is not advertised")
|
||||
func reservedReplacementBitIsNotAdvertised() {
|
||||
#expect(
|
||||
!PeerCapabilities.localSupported.contains(
|
||||
.nonDestructiveNoiseReplacement
|
||||
)
|
||||
)
|
||||
#expect(PeerCapabilities.localSupported.contains(.privateMedia))
|
||||
#expect(
|
||||
PeerCapabilities.localSupported.contains(.privateMediaReceipts)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,12 @@ import Testing
|
||||
@Suite("BLE peer registry tests")
|
||||
struct BLEPeerRegistryTests {
|
||||
@Test("upserted announces track new, reconnect, and rename transitions")
|
||||
func upsertVerifiedAnnounceTracksTransitions() throws {
|
||||
func upsertVerifiedAnnounceTracksTransitions() {
|
||||
var registry = BLEPeerRegistry()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
let firstSeen = Date(timeIntervalSince1970: 100)
|
||||
|
||||
let firstResult = registry.upsertVerifiedAnnounce(
|
||||
let first = registry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: "alice",
|
||||
noisePublicKey: Data([1, 2, 3]),
|
||||
@@ -19,7 +19,6 @@ struct BLEPeerRegistryTests {
|
||||
isConnected: true,
|
||||
now: firstSeen
|
||||
)
|
||||
let first = try #require(firstResult)
|
||||
|
||||
#expect(first.isNewPeer)
|
||||
#expect(!first.wasDisconnected)
|
||||
@@ -28,7 +27,7 @@ struct BLEPeerRegistryTests {
|
||||
#expect(registry.nickname(for: peerID, connectedOnly: true) == "alice")
|
||||
|
||||
registry.markDisconnected(peerID)
|
||||
let reconnectResult = registry.upsertVerifiedAnnounce(
|
||||
let reconnect = registry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: "alice-renamed",
|
||||
noisePublicKey: Data([1, 2, 3]),
|
||||
@@ -36,7 +35,6 @@ struct BLEPeerRegistryTests {
|
||||
isConnected: true,
|
||||
now: firstSeen.addingTimeInterval(1)
|
||||
)
|
||||
let reconnect = try #require(reconnectResult)
|
||||
|
||||
#expect(!reconnect.isNewPeer)
|
||||
#expect(reconnect.wasDisconnected)
|
||||
@@ -44,85 +42,6 @@ struct BLEPeerRegistryTests {
|
||||
#expect(registry.info(for: peerID)?.nickname == "alice-renamed")
|
||||
}
|
||||
|
||||
@Test("pinned signing key cannot be silently replaced by a later announce")
|
||||
func upsertVerifiedAnnounceRefusesToReplacePinnedSigningKey() throws {
|
||||
var registry = BLEPeerRegistry()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
let noiseKey = Data(repeating: 0x11, count: 32)
|
||||
let victimSigningKey = Data(repeating: 0x42, count: 32)
|
||||
let attackerSigningKey = Data(repeating: 0x66, count: 32)
|
||||
let firstSeen = Date(timeIntervalSince1970: 100)
|
||||
|
||||
let pinResult = registry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: "victim",
|
||||
noisePublicKey: noiseKey,
|
||||
signingPublicKey: victimSigningKey,
|
||||
isConnected: true,
|
||||
now: firstSeen
|
||||
)
|
||||
#expect(pinResult != nil)
|
||||
|
||||
// Attacker replays the victim's noiseKey/peerID with their own
|
||||
// signing key and nickname; the upsert must be refused wholesale.
|
||||
let attack = registry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: "attacker",
|
||||
noisePublicKey: noiseKey,
|
||||
signingPublicKey: attackerSigningKey,
|
||||
isConnected: true,
|
||||
now: firstSeen.addingTimeInterval(1)
|
||||
)
|
||||
|
||||
#expect(attack == nil)
|
||||
let info = try #require(registry.info(for: peerID))
|
||||
#expect(info.nickname == "victim")
|
||||
#expect(info.signingPublicKey == victimSigningKey)
|
||||
|
||||
// A legitimate re-announce with the pinned key is still accepted.
|
||||
let legit = registry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: "victim-renamed",
|
||||
noisePublicKey: noiseKey,
|
||||
signingPublicKey: victimSigningKey,
|
||||
isConnected: true,
|
||||
now: firstSeen.addingTimeInterval(2)
|
||||
)
|
||||
#expect(legit != nil)
|
||||
#expect(registry.info(for: peerID)?.nickname == "victim-renamed")
|
||||
}
|
||||
|
||||
@Test("announce without a signing key keeps the pinned key")
|
||||
func upsertVerifiedAnnounceKeepsPinnedSigningKeyWhenAnnounceOmitsIt() throws {
|
||||
var registry = BLEPeerRegistry()
|
||||
let peerID = PeerID(str: "1122334455667788")
|
||||
let noiseKey = Data(repeating: 0x11, count: 32)
|
||||
let signingKey = Data(repeating: 0x42, count: 32)
|
||||
let firstSeen = Date(timeIntervalSince1970: 100)
|
||||
|
||||
let initialResult = registry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: "alice",
|
||||
noisePublicKey: noiseKey,
|
||||
signingPublicKey: signingKey,
|
||||
isConnected: true,
|
||||
now: firstSeen
|
||||
)
|
||||
#expect(initialResult != nil)
|
||||
|
||||
let update = registry.upsertVerifiedAnnounce(
|
||||
peerID: peerID,
|
||||
nickname: "alice",
|
||||
noisePublicKey: noiseKey,
|
||||
signingPublicKey: nil,
|
||||
isConnected: true,
|
||||
now: firstSeen.addingTimeInterval(1)
|
||||
)
|
||||
|
||||
#expect(update != nil)
|
||||
#expect(registry.info(for: peerID)?.signingPublicKey == signingKey)
|
||||
}
|
||||
|
||||
@Test("registry preserves absent versus explicit empty capabilities")
|
||||
func capabilitiesPresenceIsPreserved() {
|
||||
var registry = BLEPeerRegistry()
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
struct BLEPrivateMediaReceiptStoreTests {
|
||||
private struct TestError: Error {}
|
||||
|
||||
private let messageID = "media-00112233445566778899aabbccddeeff"
|
||||
|
||||
@Test
|
||||
func acceptedReceiptPersistsAcrossStoreInstances() throws {
|
||||
let root = makeRoot("persist")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let payload = try makePayload(in: root)
|
||||
|
||||
let first = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(first.commitAccepted(messageID: messageID, storedURL: payload))
|
||||
#expect(first.state(for: messageID) == .accepted(payload))
|
||||
|
||||
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(relaunched.state(for: messageID) == .accepted(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
func directoryEnumerationFailureIsUnavailableAndRetriesWithoutCachingEmpty() throws {
|
||||
let root = makeRoot("list-failure")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let payload = try makePayload(in: root)
|
||||
#expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: payload
|
||||
))
|
||||
let record = receiptRecord(in: root)
|
||||
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||
|
||||
var shouldFail = true
|
||||
let store = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
directoryReader: { directory in
|
||||
if shouldFail {
|
||||
shouldFail = false
|
||||
throw TestError()
|
||||
}
|
||||
return try FileManager.default.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: nil
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
#expect(store.state(for: messageID) == .unavailable)
|
||||
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||
#expect(store.state(for: messageID) == .accepted(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
func recordReadFailureIsUnavailableAndPreservesReceiptForRetry() throws {
|
||||
let root = makeRoot("read-failure")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let payload = try makePayload(in: root)
|
||||
#expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: payload
|
||||
))
|
||||
let record = receiptRecord(in: root)
|
||||
|
||||
var shouldFail = true
|
||||
let store = BLEPrivateMediaReceiptStore(
|
||||
baseDirectory: root,
|
||||
dataReader: { url in
|
||||
if shouldFail {
|
||||
shouldFail = false
|
||||
throw TestError()
|
||||
}
|
||||
return try Data(contentsOf: url)
|
||||
}
|
||||
)
|
||||
|
||||
#expect(store.state(for: messageID) == .unavailable)
|
||||
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||
#expect(store.state(for: messageID) == .accepted(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
func decodeFailureIsUnavailableAndDoesNotDeleteOrCachePastRepair() throws {
|
||||
let root = makeRoot("decode-failure")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let payload = try makePayload(in: root)
|
||||
#expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted(
|
||||
messageID: messageID,
|
||||
storedURL: payload
|
||||
))
|
||||
let record = receiptRecord(in: root)
|
||||
let durableBytes = try Data(contentsOf: record)
|
||||
let corruptBytes = Data("{not-json".utf8)
|
||||
try corruptBytes.write(to: record, options: .atomic)
|
||||
|
||||
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(store.state(for: messageID) == .unavailable)
|
||||
#expect(!store.commitAccepted(messageID: messageID, storedURL: payload))
|
||||
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||
#expect(try Data(contentsOf: record) == corruptBytes)
|
||||
|
||||
try durableBytes.write(to: record, options: .atomic)
|
||||
#expect(store.state(for: messageID) == .accepted(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
func unreadableTombstoneNeverBecomesAbsentOrGetsDeleted() throws {
|
||||
let root = makeRoot("tombstone-decode")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let payload = try makePayload(in: root)
|
||||
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(seed.commitAccepted(messageID: messageID, storedURL: payload))
|
||||
#expect(seed.recordDeleted(messageID: messageID))
|
||||
#expect(!FileManager.default.fileExists(atPath: payload.path))
|
||||
|
||||
let record = receiptRecord(in: root)
|
||||
let durableBytes = try Data(contentsOf: record)
|
||||
try Data([0xFF, 0x00, 0x7B]).write(to: record, options: .atomic)
|
||||
|
||||
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(relaunched.state(for: messageID) == .unavailable)
|
||||
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||
|
||||
try durableBytes.write(to: record, options: .atomic)
|
||||
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||
}
|
||||
|
||||
@Test
|
||||
func failedTombstonePersistenceDoesNotPoisonVolatileState() throws {
|
||||
let root = makeRoot("failed-tombstone-write")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(store.state(for: messageID) == .absent)
|
||||
|
||||
// Force the atomic record write itself to fail after the store has
|
||||
// successfully loaded its empty index.
|
||||
let record = receiptRecord(in: root)
|
||||
try FileManager.default.createDirectory(
|
||||
at: record,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
#expect(!store.recordDeleted(messageID: messageID))
|
||||
try FileManager.default.removeItem(at: record)
|
||||
|
||||
// The UI must be able to report the deletion failure without a
|
||||
// process-lifetime tombstone silently hiding a later retry.
|
||||
#expect(store.state(for: messageID) == .absent)
|
||||
}
|
||||
|
||||
@Test
|
||||
func unreleasedAggregateLedgerIsIgnoredAndLeftUntouched() throws {
|
||||
let root = makeRoot("no-legacy-migration")
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let files = root.appendingPathComponent("files", isDirectory: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: files,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let legacy = files.appendingPathComponent(
|
||||
".private-media-receipts.json",
|
||||
isDirectory: false
|
||||
)
|
||||
let bytes = Data(
|
||||
#"{"entries":{"media-00112233445566778899aabbccddeeff":{"relativePath":"images/incoming/old.jpg","acceptedAt":0}}}"#
|
||||
.utf8
|
||||
)
|
||||
try bytes.write(to: legacy, options: .atomic)
|
||||
|
||||
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||
#expect(store.state(for: messageID) == .absent)
|
||||
#expect(try Data(contentsOf: legacy) == bytes)
|
||||
}
|
||||
|
||||
private func makeRoot(_ label: String) -> URL {
|
||||
FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||
"private-media-receipt-\(label)-\(UUID().uuidString)",
|
||||
isDirectory: true
|
||||
)
|
||||
}
|
||||
|
||||
private func makePayload(in root: URL) throws -> URL {
|
||||
let directory = root.appendingPathComponent(
|
||||
"files/images/incoming",
|
||||
isDirectory: true
|
||||
)
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let payload = directory.appendingPathComponent("image.jpg")
|
||||
try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload)
|
||||
return payload
|
||||
}
|
||||
|
||||
private func receiptRecord(in root: URL) -> URL {
|
||||
root
|
||||
.appendingPathComponent(
|
||||
"files/.private-media-receipts",
|
||||
isDirectory: true
|
||||
)
|
||||
.appendingPathComponent(messageID)
|
||||
.appendingPathExtension("json")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -754,15 +754,11 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
|
||||
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
|
||||
|
||||
// Wait on the DELIVERY-side state: handler dispatch and the duplicate
|
||||
// drop both land on the second main hop, after off-main verification.
|
||||
let settled = await waitUntil {
|
||||
let countedOnBothRelays = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
|
||||
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 &&
|
||||
receivedIDs == [event.id] &&
|
||||
context.manager.debugDuplicateInboundEventDropCount == 1
|
||||
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
|
||||
}
|
||||
XCTAssertTrue(settled)
|
||||
XCTAssertTrue(countedOnBothRelays)
|
||||
XCTAssertEqual(receivedIDs, [event.id])
|
||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 1)
|
||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 1)
|
||||
@@ -795,17 +791,12 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
// Wait on the DELIVERY-side state: the winner's handler dispatch and
|
||||
// the losers' duplicate drops both land on the second main hop, after
|
||||
// off-main verification — messagesReceived (first hop) settles sooner.
|
||||
let settled = await waitUntil {
|
||||
let countedOnEveryRelay = await waitUntil {
|
||||
relayURLs.allSatisfy { relayURL in
|
||||
context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1
|
||||
} &&
|
||||
receivedIDs == [event.id] &&
|
||||
context.manager.debugDuplicateInboundEventDropCount == relayURLs.count - 1
|
||||
}
|
||||
}
|
||||
XCTAssertTrue(settled)
|
||||
XCTAssertTrue(countedOnEveryRelay)
|
||||
XCTAssertEqual(receivedIDs, [event.id])
|
||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, relayURLs.count - 1)
|
||||
XCTAssertEqual(
|
||||
@@ -838,153 +829,16 @@ final class NostrRelayManagerTests: XCTestCase {
|
||||
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: invalidEvent)
|
||||
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
|
||||
|
||||
// Wait on the DELIVERY-side state (second main hop, after off-main
|
||||
// verification), not just messagesReceived (first main hop) — the
|
||||
// handler only fires after verify + a second hop.
|
||||
let genuineDelivered = await waitUntil {
|
||||
let countedOnBothRelays = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
|
||||
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 &&
|
||||
receivedIDs == [event.id]
|
||||
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
|
||||
}
|
||||
XCTAssertTrue(genuineDelivered)
|
||||
XCTAssertTrue(countedOnBothRelays)
|
||||
XCTAssertEqual(receivedIDs, [event.id])
|
||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0)
|
||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 0)
|
||||
}
|
||||
|
||||
/// The relay boundary is the single signature-verification point for the
|
||||
/// whole inbound path (downstream pipelines no longer re-verify), so a
|
||||
/// tampered gift wrap (kind 1059, the DM/mailbox path) must be dropped
|
||||
/// here — and must not poison the dedup cache against the genuine copy.
|
||||
func test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup() async throws {
|
||||
let firstRelayURL = "wss://giftwrap-one.example"
|
||||
let secondRelayURL = "wss://giftwrap-two.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let sender = try NostrIdentity.generate()
|
||||
let recipient = try NostrIdentity.generate()
|
||||
let giftWrap = try NostrProtocol.createPrivateMessage(
|
||||
content: "psst",
|
||||
recipientPubkey: recipient.publicKeyHex,
|
||||
senderIdentity: sender
|
||||
)
|
||||
let tampered = invalidSignatureCopy(of: giftWrap)
|
||||
var receivedIDs: [String] = []
|
||||
|
||||
context.manager.subscribe(
|
||||
filter: makeFilter(),
|
||||
id: "gift-wraps",
|
||||
relayUrls: [firstRelayURL, secondRelayURL]
|
||||
) { event in
|
||||
receivedIDs.append(event.id)
|
||||
}
|
||||
let subscriptionsSent = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: firstRelayURL)?.sentStrings.count == 1 &&
|
||||
context.sessionFactory.latestConnection(for: secondRelayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(subscriptionsSent)
|
||||
|
||||
try context.sessionFactory.latestConnection(for: firstRelayURL)?.emitEventMessage(subscriptionID: "gift-wraps", event: tampered)
|
||||
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "gift-wraps", event: giftWrap)
|
||||
|
||||
// Wait on the DELIVERY-side state (second main hop, after off-main
|
||||
// verification), not just messagesReceived (first main hop) — the
|
||||
// handler only fires after verify + a second hop.
|
||||
let genuineDelivered = await waitUntil {
|
||||
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
|
||||
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 &&
|
||||
receivedIDs == [giftWrap.id]
|
||||
}
|
||||
XCTAssertTrue(genuineDelivered)
|
||||
XCTAssertEqual(receivedIDs, [giftWrap.id])
|
||||
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0)
|
||||
}
|
||||
|
||||
/// Signature verification runs off-main in a per-relay serial consumer;
|
||||
/// several frames buffered on one socket must still be delivered to the
|
||||
/// handler in that relay's arrival order.
|
||||
func test_receiveEvent_deliversBackToBackEventsInArrivalOrder() async throws {
|
||||
let relayURL = "wss://ordered.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
let events = try (0..<12).map { try makeSignedEvent(content: "ordered-\($0)") }
|
||||
var receivedIDs: [String] = []
|
||||
|
||||
context.manager.subscribe(filter: makeFilter(), id: "ordered", relayUrls: [relayURL]) { event in
|
||||
receivedIDs.append(event.id)
|
||||
}
|
||||
let subscriptionSent = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(subscriptionSent)
|
||||
|
||||
for event in events {
|
||||
try context.sessionFactory.latestConnection(for: relayURL)?.emitEventMessage(subscriptionID: "ordered", event: event)
|
||||
}
|
||||
|
||||
let allDelivered = await waitUntil(timeout: 5.0) {
|
||||
receivedIDs.count == events.count
|
||||
}
|
||||
XCTAssertTrue(allDelivered)
|
||||
XCTAssertEqual(receivedIDs, events.map(\.id))
|
||||
}
|
||||
|
||||
/// Each relay owns its own off-main verify pipeline, so a large backlog of
|
||||
/// EVENT frames on one relay must NOT head-of-line-block a frame that
|
||||
/// arrives on a different relay. Under the previous single global consumer,
|
||||
/// relay B's single event (emitted after relay A's whole burst) could only
|
||||
/// be delivered once every frame in A's backlog had been Schnorr-verified;
|
||||
/// with per-relay pipelines B verifies concurrently and lands before A's
|
||||
/// backlog drains.
|
||||
func test_receiveEvent_busyRelayDoesNotBlockOtherRelayDelivery() async throws {
|
||||
let busyRelayURL = "wss://busy-relay.example"
|
||||
let quietRelayURL = "wss://quiet-relay.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
|
||||
// Distinct subscriptions per relay so dedup never coalesces A vs. B.
|
||||
let busyEvents = try (0..<200).map { try makeSignedEvent(content: "busy-\($0)") }
|
||||
let quietEvent = try makeSignedEvent(content: "quiet")
|
||||
|
||||
var busyDeliveredCount = 0
|
||||
var quietDeliveredAfterBusyCount = -1 // busy-count observed when B lands
|
||||
|
||||
context.manager.subscribe(filter: makeFilter(), id: "busy", relayUrls: [busyRelayURL]) { _ in
|
||||
busyDeliveredCount += 1
|
||||
}
|
||||
context.manager.subscribe(filter: makeFilter(), id: "quiet", relayUrls: [quietRelayURL]) { _ in
|
||||
if quietDeliveredAfterBusyCount < 0 {
|
||||
quietDeliveredAfterBusyCount = busyDeliveredCount
|
||||
}
|
||||
}
|
||||
let subscribed = await waitUntil {
|
||||
context.sessionFactory.latestConnection(for: busyRelayURL)?.sentStrings.count == 1 &&
|
||||
context.sessionFactory.latestConnection(for: quietRelayURL)?.sentStrings.count == 1
|
||||
}
|
||||
XCTAssertTrue(subscribed)
|
||||
|
||||
// Flood relay A first, then emit a single frame on relay B.
|
||||
for event in busyEvents {
|
||||
try context.sessionFactory.latestConnection(for: busyRelayURL)?.emitEventMessage(subscriptionID: "busy", event: event)
|
||||
}
|
||||
try context.sessionFactory.latestConnection(for: quietRelayURL)?.emitEventMessage(subscriptionID: "quiet", event: quietEvent)
|
||||
|
||||
let quietDelivered = await waitUntil(timeout: 5.0) { quietDeliveredAfterBusyCount >= 0 }
|
||||
XCTAssertTrue(quietDelivered, "relay B's event was never delivered")
|
||||
|
||||
// The signal: B did not have to wait for A's entire backlog. If the two
|
||||
// pipelines were globally serialized, B could only land after all 200 of
|
||||
// A's frames, so busyDeliveredCount would be 200 when B arrived.
|
||||
XCTAssertLessThan(
|
||||
quietDeliveredAfterBusyCount,
|
||||
busyEvents.count,
|
||||
"relay B was head-of-line blocked behind relay A's backlog"
|
||||
)
|
||||
|
||||
// Both relays still drain fully and in order.
|
||||
let allDelivered = await waitUntil(timeout: 5.0) {
|
||||
busyDeliveredCount == busyEvents.count
|
||||
}
|
||||
XCTAssertTrue(allDelivered)
|
||||
}
|
||||
|
||||
func test_receiveEvent_withoutHandlerStillTracksReceivedCount() async throws {
|
||||
let relayURL = "wss://missing-handler.example"
|
||||
let context = makeContext(permission: .denied)
|
||||
@@ -1986,11 +1840,7 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||
}
|
||||
|
||||
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
|
||||
if !pendingResults.isEmpty {
|
||||
completionHandler(pendingResults.removeFirst())
|
||||
} else {
|
||||
receiveHandler = completionHandler
|
||||
}
|
||||
receiveHandler = completionHandler
|
||||
}
|
||||
|
||||
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
|
||||
@@ -2023,24 +1873,15 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
|
||||
}
|
||||
|
||||
func emitRawString(_ string: String) throws {
|
||||
deliver(.success(.string(string)))
|
||||
let handler = receiveHandler
|
||||
receiveHandler = nil
|
||||
handler?(.success(.string(string)))
|
||||
}
|
||||
|
||||
private func emit(jsonObject: Any) throws {
|
||||
let data = try JSONSerialization.data(withJSONObject: jsonObject)
|
||||
deliver(.success(.data(data)))
|
||||
}
|
||||
|
||||
// Frames emitted before the manager re-arms `receive` are queued so
|
||||
// back-to-back emissions model a socket with several buffered frames.
|
||||
private var pendingResults: [Result<URLSessionWebSocketTask.Message, Error>] = []
|
||||
|
||||
private func deliver(_ result: Result<URLSessionWebSocketTask.Message, Error>) {
|
||||
if let handler = receiveHandler {
|
||||
receiveHandler = nil
|
||||
handler(result)
|
||||
} else {
|
||||
pendingResults.append(result)
|
||||
}
|
||||
let handler = receiveHandler
|
||||
receiveHandler = nil
|
||||
handler?(.success(.data(data)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,100 +79,6 @@ final class SecureIdentityStateManagerTests: XCTestCase {
|
||||
XCTAssertEqual(matches.first?.signingPublicKey, signingPublicKey)
|
||||
}
|
||||
|
||||
func test_upsertCryptographicIdentity_refusesToReplacePinnedSigningKey() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let noisePublicKey = Data(repeating: 0x11, count: 32)
|
||||
let fingerprint = noisePublicKey.sha256Fingerprint()
|
||||
let peerID = PeerID(publicKey: noisePublicKey)
|
||||
let victimSigningKey = Data(repeating: 0x22, count: 32)
|
||||
let attackerSigningKey = Data(repeating: 0x66, count: 32)
|
||||
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: victimSigningKey,
|
||||
claimedNickname: "victim"
|
||||
)
|
||||
let pinned = await waitUntil {
|
||||
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey == victimSigningKey
|
||||
}
|
||||
XCTAssertTrue(pinned)
|
||||
|
||||
// Attacker upsert with a different signing key must be refused in
|
||||
// full — signing key AND claimed nickname stay the victim's.
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: attackerSigningKey,
|
||||
claimedNickname: "attacker"
|
||||
)
|
||||
|
||||
// Synchronous reads fence the manager's pending barrier writes.
|
||||
XCTAssertEqual(
|
||||
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
|
||||
victimSigningKey
|
||||
)
|
||||
XCTAssertEqual(manager.getSocialIdentity(for: fingerprint)?.claimedNickname, "victim")
|
||||
|
||||
// The legitimate peer (same signing key) can still update.
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: victimSigningKey,
|
||||
claimedNickname: "victim-renamed"
|
||||
)
|
||||
let renamed = await waitUntil {
|
||||
manager.getSocialIdentity(for: fingerprint)?.claimedNickname == "victim-renamed"
|
||||
}
|
||||
XCTAssertTrue(renamed)
|
||||
XCTAssertEqual(
|
||||
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
|
||||
victimSigningKey
|
||||
)
|
||||
}
|
||||
|
||||
func test_cryptographicIdentity_persistsAcrossReinitAndKeepsSigningKeyPin() async {
|
||||
let keychain = MockKeychain()
|
||||
let manager = SecureIdentityStateManager(keychain)
|
||||
let noisePublicKey = Data(repeating: 0x13, count: 32)
|
||||
let fingerprint = noisePublicKey.sha256Fingerprint()
|
||||
let peerID = PeerID(publicKey: noisePublicKey)
|
||||
let victimSigningKey = Data(repeating: 0x24, count: 32)
|
||||
let attackerSigningKey = Data(repeating: 0x77, count: 32)
|
||||
|
||||
manager.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: victimSigningKey,
|
||||
claimedNickname: "victim"
|
||||
)
|
||||
let pinned = await waitUntil {
|
||||
manager.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey == victimSigningKey
|
||||
}
|
||||
XCTAssertTrue(pinned)
|
||||
manager.forceSave()
|
||||
|
||||
// Simulated app restart: the pin must survive and still refuse a
|
||||
// different signing key.
|
||||
let reloaded = SecureIdentityStateManager(keychain)
|
||||
XCTAssertEqual(
|
||||
reloaded.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
|
||||
victimSigningKey
|
||||
)
|
||||
|
||||
reloaded.upsertCryptographicIdentity(
|
||||
fingerprint: fingerprint,
|
||||
noisePublicKey: noisePublicKey,
|
||||
signingPublicKey: attackerSigningKey,
|
||||
claimedNickname: "attacker"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
reloaded.getCryptoIdentitiesByPeerIDPrefix(peerID).first?.signingPublicKey,
|
||||
victimSigningKey
|
||||
)
|
||||
XCTAssertEqual(reloaded.getSocialIdentity(for: fingerprint)?.claimedNickname, "victim")
|
||||
}
|
||||
|
||||
func test_setBlocked_clearsFavoriteState() async {
|
||||
let manager = SecureIdentityStateManager(MockKeychain())
|
||||
let fingerprint = String(repeating: "ab", count: 32)
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("Share extension handoff", .serialized)
|
||||
struct SharedContentHandoffTests {
|
||||
private func makeStore() -> (suite: String, defaults: UserDefaults, store: SharedContentStore) {
|
||||
let suite = "SharedContentHandoffTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
return (suite, defaults, SharedContentStore(defaults: defaults))
|
||||
}
|
||||
|
||||
@Test("A staged share survives an inactive app and a late open")
|
||||
func stagedShareSurvivesLateOpen() throws {
|
||||
let context = makeStore()
|
||||
defer { context.defaults.removePersistentDomain(forName: context.suite) }
|
||||
let stagedAt = Date(timeIntervalSince1970: 1_000_000)
|
||||
let payload = SharedContentPayload.text("review me later", createdAt: stagedAt)
|
||||
|
||||
try context.store.stage(payload, now: stagedAt)
|
||||
|
||||
#expect(context.store.pending(now: stagedAt.addingTimeInterval(60 * 60)) == payload)
|
||||
#expect(context.defaults.data(forKey: SharedContentStore.storageKey) != nil)
|
||||
}
|
||||
|
||||
@Test("Malformed, oversized, unsupported, and expired payloads are rejected and cleared")
|
||||
func invalidPayloadsAreRejectedAndCleared() throws {
|
||||
let context = makeStore()
|
||||
defer { context.defaults.removePersistentDomain(forName: context.suite) }
|
||||
let now = Date(timeIntervalSince1970: 2_000_000)
|
||||
|
||||
context.defaults.set(Data("not-json".utf8), forKey: SharedContentStore.storageKey)
|
||||
#expect(context.store.pending(now: now) == nil)
|
||||
#expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil)
|
||||
|
||||
context.defaults.set(
|
||||
Data(repeating: 0x41, count: SharedContentPayload.maxEnvelopeBytes + 1),
|
||||
forKey: SharedContentStore.storageKey
|
||||
)
|
||||
#expect(context.store.pending(now: now) == nil)
|
||||
#expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil)
|
||||
|
||||
let oversized = SharedContentPayload.text(
|
||||
String(repeating: "x", count: SharedContentPayload.maxContentBytes + 1),
|
||||
createdAt: now
|
||||
)
|
||||
#expect(throws: SharedContentHandoffError.contentTooLarge) {
|
||||
try context.store.stage(oversized, now: now)
|
||||
}
|
||||
#expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil)
|
||||
|
||||
let unsupportedURL = SharedContentPayload(
|
||||
kind: .url,
|
||||
content: "file:///private/tmp/secret.txt",
|
||||
createdAt: now
|
||||
)
|
||||
#expect(throws: SharedContentHandoffError.unsupportedURL) {
|
||||
try context.store.stage(unsupportedURL, now: now)
|
||||
}
|
||||
|
||||
let misleadingControl = SharedContentPayload.text("safe\u{202E}txt", createdAt: now)
|
||||
#expect(throws: SharedContentHandoffError.invalidCharacters) {
|
||||
try context.store.stage(misleadingControl, now: now)
|
||||
}
|
||||
|
||||
let expired = SharedContentPayload.text(
|
||||
"too old",
|
||||
createdAt: now.addingTimeInterval(-SharedContentPayload.retentionSeconds - 1)
|
||||
)
|
||||
context.defaults.set(try JSONEncoder().encode(expired), forKey: SharedContentStore.storageKey)
|
||||
#expect(context.store.pending(now: now) == nil)
|
||||
#expect(context.defaults.object(forKey: SharedContentStore.storageKey) == nil)
|
||||
}
|
||||
|
||||
@Test("Mesh, geohash, and stale private selections resolve to explicit destinations")
|
||||
func destinationsAreExplicit() {
|
||||
let geohashChannel = ChannelID.location(
|
||||
GeohashChannel(level: .city, geohash: "9Q8YY")
|
||||
)
|
||||
let stalePeer = PeerID(str: "0011223344556677")
|
||||
|
||||
#expect(SharedContentDestination.resolve(
|
||||
selectedPrivatePeerID: nil,
|
||||
privateDisplayName: nil,
|
||||
activeChannel: .mesh
|
||||
) == .mesh)
|
||||
#expect(SharedContentDestination.resolve(
|
||||
selectedPrivatePeerID: nil,
|
||||
privateDisplayName: nil,
|
||||
activeChannel: geohashChannel
|
||||
) == .geohash("9q8yy"))
|
||||
#expect(SharedContentDestination.resolve(
|
||||
selectedPrivatePeerID: stalePeer,
|
||||
privateDisplayName: "alice",
|
||||
activeChannel: geohashChannel
|
||||
) == .privateConversation(peerID: stalePeer, displayName: "alice"))
|
||||
}
|
||||
|
||||
@Test("A destination change requires a new confirmation and never consumes on the stale tap")
|
||||
@MainActor
|
||||
func staleDestinationCannotBeConfirmed() throws {
|
||||
let context = makeStore()
|
||||
defer { context.defaults.removePersistentDomain(forName: context.suite) }
|
||||
let now = Date(timeIntervalSince1970: 3_000_000)
|
||||
let payload = SharedContentPayload.text("do not auto-send", createdAt: now)
|
||||
let peer = PeerID(str: "8899aabbccddeeff")
|
||||
let privateDestination = SharedContentDestination.privateConversation(
|
||||
peerID: peer,
|
||||
displayName: "alice"
|
||||
)
|
||||
let model = SharedContentImportModel(store: context.store)
|
||||
try context.store.stage(payload, now: now)
|
||||
model.refresh(destination: privateDestination, now: now)
|
||||
|
||||
#expect(model.confirm(destination: .mesh, now: now) == nil)
|
||||
#expect(model.offer?.destination == .mesh)
|
||||
#expect(context.store.pending(now: now) == payload)
|
||||
|
||||
#expect(model.confirm(destination: .mesh, now: now) == payload.content)
|
||||
#expect(model.offer == nil)
|
||||
#expect(context.store.pending(now: now) == nil)
|
||||
}
|
||||
|
||||
@Test("Confirmation consumes once and cancellation explicitly clears without producing composer text")
|
||||
@MainActor
|
||||
func oneTimeConfirmationAndCancellation() throws {
|
||||
let context = makeStore()
|
||||
defer { context.defaults.removePersistentDomain(forName: context.suite) }
|
||||
let now = Date(timeIntervalSince1970: 4_000_000)
|
||||
let model = SharedContentImportModel(store: context.store)
|
||||
|
||||
let first = SharedContentPayload.text("confirmed", createdAt: now)
|
||||
try context.store.stage(first, now: now)
|
||||
model.refresh(destination: .geohash("u4pruy"), now: now)
|
||||
#expect(model.confirm(destination: .geohash("u4pruy"), now: now) == "confirmed")
|
||||
#expect(model.confirm(destination: .geohash("u4pruy"), now: now) == nil)
|
||||
|
||||
let second = SharedContentPayload.text("cancelled", createdAt: now)
|
||||
try context.store.stage(second, now: now)
|
||||
model.refresh(destination: .mesh, now: now)
|
||||
model.cancel(destination: .mesh, now: now)
|
||||
#expect(model.offer == nil)
|
||||
#expect(context.store.pending(now: now) == nil)
|
||||
|
||||
let third = SharedContentPayload.text("panic-wiped", createdAt: now)
|
||||
try context.store.stage(third, now: now)
|
||||
model.refresh(destination: .mesh, now: now)
|
||||
model.discardAll()
|
||||
#expect(model.offer == nil)
|
||||
#expect(context.store.pending(now: now) == nil)
|
||||
}
|
||||
|
||||
@Test("Cancelling an old review never deletes a newer staged share")
|
||||
@MainActor
|
||||
func cancellationPreservesNewerShare() throws {
|
||||
let context = makeStore()
|
||||
defer { context.defaults.removePersistentDomain(forName: context.suite) }
|
||||
let now = Date(timeIntervalSince1970: 5_000_000)
|
||||
let model = SharedContentImportModel(store: context.store)
|
||||
let old = SharedContentPayload.text("old", createdAt: now)
|
||||
let newer = SharedContentPayload.text("new", createdAt: now)
|
||||
|
||||
try context.store.stage(old, now: now)
|
||||
model.refresh(destination: .mesh, now: now)
|
||||
try context.store.stage(newer, now: now)
|
||||
model.cancel(destination: .mesh, now: now)
|
||||
|
||||
#expect(model.offer?.payload == newer)
|
||||
#expect(context.store.pending(now: now) == newer)
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,6 @@ private struct SmokeFeatureModels {
|
||||
let conversationUIModel: ConversationUIModel
|
||||
let peerListModel: PeerListModel
|
||||
let boardAlertsModel: BoardAlertsModel
|
||||
let sharedContentImportModel: SharedContentImportModel
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -100,8 +99,7 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur
|
||||
verificationModel: verificationModel,
|
||||
conversationUIModel: conversationUIModel,
|
||||
peerListModel: peerListModel,
|
||||
boardAlertsModel: boardAlertsModel,
|
||||
sharedContentImportModel: SharedContentImportModel(store: nil)
|
||||
boardAlertsModel: boardAlertsModel
|
||||
)
|
||||
}
|
||||
|
||||
@@ -120,7 +118,6 @@ private func installSmokeEnvironment<V: View>(
|
||||
.environmentObject(featureModels.conversationUIModel)
|
||||
.environmentObject(featureModels.peerListModel)
|
||||
.environmentObject(featureModels.boardAlertsModel)
|
||||
.environmentObject(featureModels.sharedContentImportModel)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
||||
@@ -28,6 +28,13 @@ peer's Noise session before BLE fragmentation.
|
||||
`0x21`. A valid bit-8 proof selects Noise `0x20`; a valid no-bit proof or a
|
||||
no-proof timeout reaches the explicit legacy-consent path for an unpinned
|
||||
peer. No timeout automatically sends raw bytes.
|
||||
- `PeerCapabilities.privateMediaReceipts` is bit 9. Its exact-session
|
||||
authenticated proof enables bounded sender-side automatic retry; a public
|
||||
announce never does. It does not replace bit 8: encrypted `0x20` media from
|
||||
bit-8-only prior iOS clients keeps the same deterministic stable ID and
|
||||
delivery ACK. Receivers durably commit that ID before UI delivery or ACK, so
|
||||
a lost proof followed by a later bit-9 retry cannot create a second,
|
||||
random-ID bubble.
|
||||
- An unpinned peer with a stable Noise key but without that capability is
|
||||
eligible for one signed, directed
|
||||
`fileTransfer`, matching the pre-migration wire form used by older iOS and
|
||||
@@ -100,16 +107,8 @@ temporary `0x09` alias. Ordinary Noise messages retain their 64 KiB limit.
|
||||
|
||||
Current Android builds cap each reassembly at 256 fragments. Depending on the
|
||||
negotiated BLE packet size and routing overhead, that is roughly 110-120 KiB,
|
||||
well below iOS's absolute inbound ceiling. That cap only applies to those
|
||||
receivers, which take private media exclusively over the directed raw-file
|
||||
migration fallback (they do not implement the encrypted `0x20` path).
|
||||
Private-media v1 therefore runs the actual route-aware BLE fragment planner
|
||||
before a consented legacy send and rejects any plan above 256 fragments with a
|
||||
visible failure. Encrypted sends go only to peers that advertised the
|
||||
`privateMedia` capability — modern clients that reassemble up to the full
|
||||
receiver ceiling (10,000 fragments) — so they are not held to Android's cap and
|
||||
iOS→iOS photos in the ~120-512 KiB range keep working. This fragment-count
|
||||
contract, rather than a guessed byte threshold, stays correct as route overhead
|
||||
changes. A future Android client that adopts `0x20` but still caps its
|
||||
reassembler would need to negotiate an explicit per-peer fragment limit
|
||||
(tracked as a #1434 follow-up).
|
||||
well below iOS's absolute inbound ceiling. Private-media v1 therefore runs the
|
||||
actual route-aware BLE fragment planner before both encrypted and consented
|
||||
legacy sends and rejects any plan above 256 fragments with a visible failure.
|
||||
This fragment-count contract, rather than a guessed byte threshold, stays
|
||||
correct as route overhead changes.
|
||||
|
||||
@@ -28,6 +28,18 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
|
||||
/// before outer BLE fragmentation. Peers that omit this bit require the
|
||||
/// signed directed raw-file migration fallback.
|
||||
public static let privateMedia = PeerCapabilities(rawValue: 1 << 8)
|
||||
/// Stable private-media IDs are durably deduplicated by the receiver and
|
||||
/// correlated delivery/read receipts permit bounded automatic resend.
|
||||
///
|
||||
/// Bit 8 remains the encrypted-media compatibility contract. Bit 9 only
|
||||
/// enables sender-side automatic retry after exact-session proof.
|
||||
public static let privateMediaReceipts =
|
||||
PeerCapabilities(rawValue: 1 << 9)
|
||||
/// Reserved for test builds that briefly advertised non-destructive Noise
|
||||
/// replacement. Current clients intentionally do not advertise or act on
|
||||
/// this bit; keep it decodable so the wire assignment is never reused.
|
||||
public static let nonDestructiveNoiseReplacement =
|
||||
PeerCapabilities(rawValue: 1 << 10)
|
||||
|
||||
/// Minimal little-endian byte encoding; always at least one byte so an
|
||||
/// empty set is distinguishable from an absent TLV.
|
||||
|
||||
@@ -18,10 +18,30 @@ struct PeerCapabilitiesTests {
|
||||
#expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40]))
|
||||
#expect(PeerCapabilities.privateMedia.encoded() == Data([0x00, 0x01]))
|
||||
|
||||
let high = PeerCapabilities(rawValue: 1 << 9)
|
||||
#expect(high.encoded() == Data([0x00, 0x02]))
|
||||
#expect(
|
||||
PeerCapabilities.privateMediaReceipts.encoded()
|
||||
== Data([0x00, 0x02])
|
||||
)
|
||||
#expect(
|
||||
PeerCapabilities.nonDestructiveNoiseReplacement.encoded()
|
||||
== Data([0x00, 0x04])
|
||||
)
|
||||
|
||||
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia]
|
||||
let high = PeerCapabilities(rawValue: 1 << 11)
|
||||
#expect(high.encoded() == Data([0x00, 0x08]))
|
||||
|
||||
let all: PeerCapabilities = [
|
||||
.prekeys,
|
||||
.wifiBulk,
|
||||
.gateway,
|
||||
.groups,
|
||||
.board,
|
||||
.vouch,
|
||||
.meshDiagnostics,
|
||||
.privateMedia,
|
||||
.privateMediaReceipts,
|
||||
.nonDestructiveNoiseReplacement
|
||||
]
|
||||
#expect(PeerCapabilities(encoded: all.encoded()) == all)
|
||||
#expect(PeerCapabilities(encoded: high.encoded()) == high)
|
||||
#expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])
|
||||
|
||||
Vendored
+405
-431
@@ -1,442 +1,416 @@
|
||||
Relay URL,Latitude,Longitude
|
||||
bitchat.nostr1.com,40.7057,-74.0136
|
||||
relay.fundstr.me,42.3601,-71.0589
|
||||
nostr.2b9t.xyz,34.0549,-118.243
|
||||
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
|
||||
nostr.overpay.com,29.7449,-95.5343
|
||||
bridge.tagomago.me,42.3601,-71.0589
|
||||
nostr-01.yakihonne.com,1.32123,103.695
|
||||
strfry.bonsai.com,39.0438,-77.4874
|
||||
relay.sharegap.net,43.6532,-79.3832
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
adre.su,59.9311,30.3609
|
||||
bitcoinostr.duckdns.org,41.1976,1.11167
|
||||
nostr.computingcache.com:443,34.0356,-118.442
|
||||
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
|
||||
nostr.hekster.org:443,37.3986,-121.964
|
||||
nostr.88mph.life,52.1941,-2.21905
|
||||
wot.dergigi.com,64.1476,-21.9392
|
||||
nostr.planix.org,43.6532,-79.3832
|
||||
relay.satsmarkt.club,52.6907,4.8181
|
||||
nostrcity-club.fly.dev:443,37.7648,-122.432
|
||||
aeon.libretechsystems.xyz,55.486,9.86577
|
||||
testnet.samt.st,43.6532,-79.3832
|
||||
nostr.data.haus,50.4754,12.3683
|
||||
wot.sudocarlos.com,43.6532,-79.3832
|
||||
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
|
||||
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.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
|
||||
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
|
||||
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
|
||||
nostr.stakey.net:443,52.3676,4.90414
|
||||
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
|
||||
relay.snort.social,53.3498,-6.26031
|
||||
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.data.haus:443,50.4754,12.3683
|
||||
staging.yabu.me,35.6092,139.73
|
||||
relay.sigit.io:443,50.4754,12.3683
|
||||
relay.edufeed.org:443,49.4521,11.0767
|
||||
nostr-01.yakihonne.com:443,1.32123,103.695
|
||||
reraw.pbla2fish.cc,43.6532,-79.3832
|
||||
cs-relay.nostrdev.com:443,50.4754,12.3683
|
||||
herbstmeister.com,34.0549,-118.243
|
||||
relay.minibolt.info:443,43.6532,-79.3832
|
||||
relay2.angor.io:443,48.1046,11.6002
|
||||
social.amanah.eblessing.co,48.1046,11.6002
|
||||
nostr.stakey.net,52.3676,4.90414
|
||||
nostr.computingcache.com:443,34.0356,-118.442
|
||||
slick.mjex.me,39.0418,-77.4744
|
||||
fanfares.nostr1.com,40.7057,-74.0136
|
||||
bitcoinostr.duckdns.org,43.3434,-3.99532
|
||||
nostr.oxtr.dev,50.4754,12.3683
|
||||
cache.trustr.ing,43.6548,-79.3885
|
||||
purplerelay.com,43.6532,-79.3832
|
||||
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
|
||||
nostr-relay.corb.net:443,38.8353,-104.822
|
||||
relay-dev.gulugulu.moe,43.6532,-79.3832
|
||||
prl.plus,55.7628,37.5983
|
||||
nostr.tac.lol:443,47.4748,-122.273
|
||||
relay.mostr.pub,43.6532,-79.3832
|
||||
schnorr.me:443,43.6532,-79.3832
|
||||
dev-relay.nostreon.com,60.1699,24.9384
|
||||
nostr.islandarea.net:443,35.4669,-97.6473
|
||||
nostr.rtvslawenia.com,49.4543,11.0746
|
||||
relay.bowlafterbowl.com,32.9483,-96.7299
|
||||
nostr.quali.chat:443,60.1699,24.9384
|
||||
relay.plebeian.market,50.1109,8.68213
|
||||
relay-rpi.edufeed.org,49.4521,11.0767
|
||||
r.0kb.io,32.789,-96.7989
|
||||
nostr.notribe.net:443,40.8302,-74.1299
|
||||
relay.getsafebox.app:443,43.6532,-79.3832
|
||||
nostr.dlcdevkit.com:443,40.0992,-83.1141
|
||||
nostrelites.org,34.9582,-81.9907
|
||||
nostr.hoppe-relay.it.com,42.8864,-78.8784
|
||||
nostr.thebiglake.org,32.71,-96.6745
|
||||
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
|
||||
relay.nostriot.com,41.5695,-83.9786
|
||||
nostr.christiansass.de,51.7634,7.8887
|
||||
relay.btcforplebs.com,43.6532,-79.3832
|
||||
nostr.tagomago.me,42.3601,-71.0589
|
||||
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
|
||||
bucket.coracle.social,37.7775,-122.397
|
||||
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.quali.chat,60.1699,24.9384
|
||||
relay.vrtmrz.net,43.6532,-79.3832
|
||||
relay-dev.gulugulu.moe:443,43.6532,-79.3832
|
||||
relay.bullishbounty.com,43.6532,-79.3832
|
||||
relay.fckstate.net,59.3293,18.0686
|
||||
nostr.rtvslawenia.com:443,49.4543,11.0746
|
||||
relay.nostx.io,43.6532,-79.3832
|
||||
relay.agorist.space,52.3734,4.89406
|
||||
relay.notoshi.win,13.7829,100.546
|
||||
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
|
||||
relay.trotters.cc,43.6532,-79.3832
|
||||
relay.lanavault.space,60.1699,24.9384
|
||||
public.crostr.com:443,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
|
||||
relay.0xchat.com:443,43.6532,-79.3832
|
||||
|
||||
|
@@ -1,69 +0,0 @@
|
||||
#!/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"
|
||||
@@ -1,61 +0,0 @@
|
||||
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()
|
||||
@@ -1,186 +0,0 @@
|
||||
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()
|
||||
@@ -1,271 +0,0 @@
|
||||
#!/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