Compare commits

..
Author SHA1 Message Date
jack 2f8af04821 Persist private media delivery receipts 2026-07-26 07:17:21 +02:00
jack 78083bd773 Correlate private media delivery receipts 2026-07-26 07:17:21 +02:00
jack a8201b3958 Deliver live transport events synchronously 2026-07-26 07:17:21 +02:00
jack 01ce054238 Discard deferred Noise ciphertext during panic 2026-07-26 07:17:21 +02:00
jack b6c7c77080 Preserve early Noise ciphertext across reconnects 2026-07-26 07:17:21 +02:00
jack 55ee1df0bc Stabilize synchronous Noise restart tests 2026-07-26 07:17:21 +02:00
jack b255355fed Fix ordinary Noise handshake races 2026-07-26 02:09:31 +02:00
jack 6e026c2c22 Fix cached Noise reconnects atomically 2026-07-26 02:09:31 +02:00
jack c8e330777a Discard private-media callbacks during panic 2026-07-26 02:09:31 +02:00
jack 91aba8b597 Fix private media Noise round-trip fixture 2026-07-26 02:09:31 +02:00
jackandjack d9a6dbfca8 Make Noise generation race test deterministic 2026-07-26 02:09:31 +02:00
jackandjack 68f8f03ad8 Avoid authentication callback queue starvation 2026-07-26 02:09:31 +02:00
jackandjack 48026991b2 Bind capability state to Noise generations 2026-07-26 02:09:31 +02:00
jackandjack ec795520ee Authenticate private media capabilities in Noise 2026-07-26 02:09:31 +02:00
jackandjack 40238c5e43 Harden private media migration compatibility 2026-07-26 02:09:31 +02:00
jackandjack a31cd80027 Encrypt private media before fragmentation 2026-07-26 02:08:56 +02:00
jack 974510ad9e Authenticate only completed Noise candidates 2026-07-26 02:08:56 +02:00
jackandjack 5aee7f0f98 Bind Noise sessions to claimed peer identities 2026-07-26 02:08:56 +02:00
jack 6054248765 Harden panic keychain and media cleanup 2026-07-26 02:08:56 +02:00
jack 5f7df63238 Invalidate queued BLE ingress during panic 2026-07-26 00:12:26 +02:00
jack b081c98dba Harden panic recovery and service shutdown 2026-07-26 00:12:26 +02:00
jackandjack 76d3b0f1ed Scope install markers to iOS 2026-07-25 21:43:11 +02:00
jackandjack aa3021c9ca Make panic wipe deterministic and device-bound 2026-07-25 21:43:11 +02:00
93 changed files with 8531 additions and 5354 deletions
+23 -209
View File
@@ -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 }}
+16 -67
View File
@@ -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.
-1
View File
@@ -80,4 +80,3 @@ build.log
# Local configs
Local.xcconfig
*.profraw
-3
View File
@@ -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)
+95 -54
View File
@@ -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
View File
@@ -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.
+17 -49
View File
@@ -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.
-1
View File
@@ -70,7 +70,6 @@
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Services/SharedContentHandoff.swift,
Services/TransportConfig.swift,
);
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
+6 -1
View File
@@ -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)
+1 -8
View File
@@ -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()
}
+40 -29
View File
@@ -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) {
+25 -41
View File
@@ -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
+5 -14
View File
@@ -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
-119
View File
@@ -1,119 +0,0 @@
import BitFoundation
import Combine
import Foundation
enum SharedContentDestination: Sendable, Equatable {
case mesh
case geohash(String)
case privateConversation(peerID: PeerID, displayName: String)
static func resolve(
selectedPrivatePeerID: PeerID?,
privateDisplayName: String?,
activeChannel: ChannelID
) -> SharedContentDestination {
if let selectedPrivatePeerID {
let fallback = String(selectedPrivatePeerID.id.prefix(12))
return .privateConversation(
peerID: selectedPrivatePeerID,
displayName: privateDisplayName?.trimmedOrNilIfEmpty ?? fallback
)
}
switch activeChannel {
case .mesh:
return .mesh
case .location(let channel):
return .geohash(channel.geohash.lowercased())
}
}
var displayName: String {
switch self {
case .mesh:
return "#mesh"
case .geohash(let geohash):
return "#\(geohash)"
case .privateConversation(_, let displayName):
return displayName
}
}
}
struct SharedContentOffer: Identifiable, Sendable, Equatable {
let payload: SharedContentPayload
let destination: SharedContentDestination
var id: UUID { payload.id }
}
/// Holds a pending extension handoff until the user chooses a destination and
/// explicitly adds it to the composer. This type has no send dependency by
/// design: confirming an import can never transmit a message.
@MainActor
final class SharedContentImportModel: ObservableObject {
@Published private(set) var offer: SharedContentOffer?
private let store: SharedContentStore?
init(store: SharedContentStore?) {
self.store = store
}
@discardableResult
func refresh(
destination: SharedContentDestination,
now: Date = Date()
) -> SharedContentPayload? {
guard let payload = store?.pending(now: now) else {
offer = nil
return nil
}
let nextOffer = SharedContentOffer(payload: payload, destination: destination)
if offer != nextOffer {
offer = nextOffer
}
return payload
}
func updateDestination(_ destination: SharedContentDestination) {
guard let offer, offer.destination != destination else { return }
self.offer = SharedContentOffer(payload: offer.payload, destination: destination)
}
/// Returns composer text only when the currently displayed destination is
/// still current and the reviewed envelope is still the stored envelope.
/// A destination change updates the prompt and requires another tap.
func confirm(
destination: SharedContentDestination,
now: Date = Date()
) -> String? {
guard let offer else { return nil }
guard offer.destination == destination else {
updateDestination(destination)
return nil
}
guard let payload = store?.consume(id: offer.id, now: now) else {
_ = refresh(destination: destination, now: now)
return nil
}
self.offer = nil
return payload.composerText
}
func cancel(destination: SharedContentDestination, now: Date = Date()) {
guard let offer else { return }
store?.discard(id: offer.id)
self.offer = nil
// If a newer share replaced the reviewed envelope, surface it rather
// than losing it with the older cancellation.
_ = refresh(destination: destination, now: now)
}
func discardAll() {
store?.discardAll()
offer = nil
}
}
-1
View File
@@ -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()
+8 -40
View File
@@ -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
}
}
//
+45 -106
View File
@@ -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
-108
View File
@@ -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
+6
View File
@@ -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
+36 -211
View File
@@ -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
+42 -241
View File
@@ -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)
+9
View File
@@ -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
}
}
+87
View File
@@ -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
]
}
+8 -39
View File
@@ -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)
}
+9 -18
View File
@@ -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
}
}
+258 -10
View File
@@ -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()
}
}
+2 -18
View File
@@ -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
}
}
+490 -121
View File
@@ -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 iOSiOS photos in
// the ~120512 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,
+158 -22
View File
@@ -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
-212
View File
@@ -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)
}
}
}
+7
View File
@@ -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 }
+8 -20
View File
@@ -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 ~64256 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 }
+82 -16
View File
@@ -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) {
+1 -2
View File
@@ -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)
+97 -137
View File
@@ -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 {
-42
View File
@@ -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 denregistrer 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" : "bitchate 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 lapp 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 lapp 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" : "✓ bitchate 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"
}
}
}
}
},
+25 -25
View File
@@ -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) {
+225 -11
View File
@@ -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()
+27 -12
View File
@@ -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
+74
View File
@@ -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()
-424
View File
@@ -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
}
+20 -5
View File
@@ -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)
}
}
+25 -35
View File
@@ -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
+19 -4
View File
@@ -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,
+57 -18
View File
@@ -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)
+19 -237
View File
@@ -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
+1 -6
View File
@@ -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)
}
}
+1 -4
View File
@@ -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
+12 -13
View File
@@ -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()) == [])
+405 -431
View File
@@ -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 Relay URL Latitude Longitude
2 bitchat.nostr1.com relay.lab.rytswd.com 40.7057 49.4543 -74.0136 11.0746
3 relay.fundstr.me relay.paulstephenborile.com:443 42.3601 49.4543 -71.0589 11.0746
4 nostr.2b9t.xyz relay.binaryrobot.com 34.0549 43.6532 -118.243 -79.3832
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
5 nostr-2.21crypto.ch 47.5356 8.73209
6 relay.kaleidoswap.com spookstr2.nostr1.com:443 50.8476 40.7057 4.35717 -74.0136
7 relay.libernet.app:443 fanfares.nostr1.com:443 43.6532 40.7057 -79.3832 -74.0136
8 relay.homeinhk.xyz x.kojira.io 35.694 43.6532 139.754 -79.3832
9 relay.manneken.brussels freelay.sovbit.host 49.4543 60.1699 11.0746 24.9384
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
10 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
11 testnet.samt.st 43.6532 -79.3832
12 nostr.data.haus relay.angor.io 50.4754 48.1046 12.3683 11.6002
13 wot.sudocarlos.com relay-arg.zombi.cloudrodion.com 43.6532 1.35208 -79.3832 103.82
14 relay-fra.zombi.cloudrodion.com:443 nostr-01.yakihonne.com 48.8566 1.32123 2.35222 103.695
15 shu01.shugur.net nostr-relay.cbrx.io 21.4902 43.6532 39.2246 -79.3832
16 relay.gulugulu.moe:443 relay.guggero.org 43.6532 46.5971 -79.3832 9.59652
17 relay2.angor.io:443 nostr.snowbla.de 48.1046 60.1699 11.6002 24.9384
relay.libernet.app 43.6532 -79.3832
directories-safe-motherboard-recipients.trycloudflare.com 43.6532 -79.3832
wot.nostr.party 36.1659 -86.7844
18 relay.zone667.com 60.1699 24.9384
19 nostr.wild-vibes.ts.net nexus.libernet.app:443 48.8566 43.6532 2.35222 -79.3832
20 relay.nostr.com relay.islandbitcoin.com 50.1109 12.8498 8.68213 77.6545
21 nostr.iskarion.ddns.net relay-testnet.k8s.layer3.news 43.3076 37.3387 -2.95421 -121.885
22 relay-dev.satlantis.io nostr-relay.xbytez.io 39.0438 50.6924 -77.4874 3.20113
23 relay.sovereignresonance.org kasztanowa.bieda.it 48.9006 43.6532 2.25929 -79.3832
24 relay.nostrian-conquest.com nostrcity-club.fly.dev 41.223 37.7648 -111.974 -122.432
25 relay.aidatanorge.no relay.typedcypher.com 43.6532 51.5072 -79.3832 -0.127586
26 strfry.apps3.slidestr.net nostr.na.social:443 40.4167 43.6532 -3.70329 -79.3832
27 relay.klabo.world relay.laantungir.net 47.2343 -19.4692 -119.853 -42.5315
28 nostr.data.haus:443 relay-dev.satlantis.io:443 50.4754 40.8302 12.3683 -74.1299
29 testr.nymble.world rilo.nostria.app 40.8054 43.6532 -74.0241 -79.3832
30 relay.inforsupports.com nostr.hekster.org:443 43.6532 37.3986 -79.3832 -121.964
31 nostr-relay.amethyst.name:443 39.0067 -77.4291
32 chat-relay.zap-work.com:443 43.6532 -79.3832
33 relay.edufeed.org 49.4521 11.0767
34 syb.lol:443 43.6532 -79.3832
35 relay.sigit.io 50.4754 12.3683
36 nostr-relay.xbytez.io:443 50.6924 3.20113
37 relay.wavefunc.live 41.8781 -87.6298
38 nostr.sathoarder.com 48.5734 7.75211
39 myvoiceourstory.org 37.3598 -121.981
40 relay.underorion.se 50.1109 8.68213
41 nostr.data.haus 50.4754 12.3683
42 relay.erybody.com 41.4513 -81.7021
43 espelho.girino.org 43.6532 -79.3832
44 nostr.pbfs.io:443 50.4754 12.3683
45 wot.dergigi.com 64.1476 -21.9392
46 nostr.bitcoiner.social:443 47.6743 -117.112
47 dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
48 relay.gulugulu.moe 43.6532 -79.3832
49 nostr.spicyz.io 43.6532 -79.3832
50 relay.cypherflow.ai 48.8575 2.35138
51 treuzkas.branruz.com 48.8575 2.35138
52 relay1.nostrchat.io 60.1699 24.9384
53 kotukonostr.onrender.com 37.7775 -122.397
54 nostr.plantroon.com 50.1013 8.62643
55 nostr.davenov.com 50.1109 8.68213
56 node.kommonzenze.de 49.4521 11.0767
57 relay2.veganostr.com 60.1699 24.9384
58 armada.sharegap.net 43.6532 -79.3832
59 wot.makenomistakes.ca 43.7064 -79.3986
60 nostr.2b9t.xyz:443 34.0549 -118.243
61 relay.libernet.app:443 43.6532 -79.3832
62 relay.dreamith.to:443 43.6532 -79.3832
63 relay.lightning.pub:443 39.0438 -77.4874
64 nostr.rtvslawenia.com 49.4543 11.0746
65 nostr.21crypto.ch 47.5356 8.73209
66 relay.ditto.pub:443 43.6532 -79.3832
67 relay.plebchain.club 43.6532 -79.3832
68 memlay.v0l.io 53.3498 -6.26031
69 nostr.chaima.info:443 50.1109 8.68213
70 relay.wavlake.com:443 41.2619 -95.8608
71 nostr.thalheim.io:443 60.1699 24.9384
72 relay.lightning.pub 39.0438 -77.4874
73 dev.relay.edufeed.org:443 49.4521 11.0767
74 nostr.myshosholoza.co.za:443 52.3913 4.66545
75 relay.binaryrobot.com:443 43.6532 -79.3832
76 wot.nostr.place 43.6532 -79.3832
77 nostr.sathoarder.com:443 48.5734 7.75211
78 thecitadel.nostr1.com 40.7057 -74.0136
79 relay.artx.market 43.6548 -79.3885
80 nos.lol 50.4754 12.3683
81 nostr.plantroon.com:443 50.1013 8.62643
82 premium.primal.net 43.6532 -79.3832
83 nas01xanthosnet.synology.me:7778 47.1285 8.74735
84 nostrja-kari.heguro.com 43.6532 -79.3832
85 relay.mrmave.work 43.6532 -79.3832
86 nostrelay.circum.space 52.6907 4.8181
87 mostro-p2p.tech 50.1109 8.68213
88 wot.shaving.kiwi 43.6532 -79.3832
89 relay.fundstr.me 42.3601 -71.0589
90 nostrelay.circum.space:443 52.6907 4.8181
91 relay.nostrdice.com -33.8688 151.209
92 relay.getvia.xyz 60.1699 24.9384
93 strfry.shock.network:443 39.0438 -77.4874
94 relay.nostrmap.net:443 60.1699 24.9384
95 nostr.stakey.net:443 relay.nearhood.co.uk 52.3676 51.5072 4.90414 -0.127586
96 no.str.cr 10.6352 -85.4378
97 relay.getsafebox.app:443 43.6532 -79.3832
98 relay0.gfcom.info 13.6992 100.694
99 nostr.ps1829.com 33.8851 130.883
100 relay2.angor.io 48.1046 11.6002
101 relay.stickeroo.is-cool.dev 37.3387 -121.885
102 ricardo-oem.tailb5546.ts.net 40.7128 -74.006
103 relay.typedcypher.com:443 51.5072 -0.127586
104 relay.paulstephenborile.com 49.4543 11.0746
105 nittom.nostr1.com 40.7057 -74.0136
106 conduitl2.fly.dev 37.7648 -122.432
107 nostr.rikmeijer.nl 51.7111 5.36809
108 relay.thecryptosquid.com 50.4754 12.3683
109 spookstr2.nostr1.com 40.7057 -74.0136
110 offchain.bostr.online 43.6532 -79.3832
111 nostr.planix.org 43.6532 -79.3832
112 relay.mccormick.cx 52.3563 4.95714
113 0x-nostr-relay.fly.dev 37.7648 -122.432
114 nostr.wecsats.io 43.6532 -79.3832
115 schnorr.me 43.6532 -79.3832
116 relay.satmaxt.xyz 43.6532 -79.3832
117 relay.bornheimer.app 51.5072 -0.127586
118 relay.nostrhub.fr 48.1045 11.6004
119 blossom.gnostr.cloud:443 43.6532 -79.3832
120 nostr-02.yakihonne.com:443 1.32123 103.695
121 dev.relay.stream 43.6532 -79.3832
122 ithurtswhenip.ee 51.5072 -0.127586
123 nostr.myshosholoza.co.za 52.3913 4.66545
124 relayrs.notoshi.win:443 43.6532 -79.3832
125 relay-rpi.edufeed.org:443 49.4521 11.0767
126 relay.olas.app:443 60.1699 24.9384
127 nostr.unkn0wn.world 46.8499 9.53287
128 relay.mitchelltribe.com 39.0438 -77.4874
129 yabu.me 35.6092 139.73
130 nostr.nodesmap.com 59.3327 18.0656
131 dm-test-strfry-generic.samt.st 43.6532 -79.3832
132 nostr2.girino.org:443 43.6532 -79.3832
133 wot.brightbolt.net 47.6735 -116.781
134 strfry.shock.network 39.0438 -77.4874
135 relay.kilombino.com 43.6532 -79.3832
136 relay.nostr.blockhenge.com 39.0438 -77.4874
137 shu04.shugur.net 25.2048 55.2708
138 relay-rpi.edufeed.org 49.4521 11.0767
139 relay.bullishbounty.com:443 43.6532 -79.3832
140 vault.iris.to:443 43.6532 -79.3832
141 relay.mostro.network:443 40.8302 -74.1299
142 offchain.pub:443 39.1585 -94.5728
143 soloco.nl 43.6532 -79.3832
144 relay.nostu.be 40.4167 -3.70329
145 nostr.pbfs.io 50.4754 12.3683
146 relay.directsponsor.net 42.8864 -78.8784
147 relay.decentralia.fr 49.4282 10.9796
148 relayrs.notoshi.win 43.6532 -79.3832
149 nostr-relay.amethyst.name 39.0067 -77.4291
150 relay.arx-ccn.com 50.4754 12.3683
151 nostr.spaceshell.xyz 43.6532 -79.3832
152 relay-fra.zombi.cloudrodion.com 48.8566 2.35222
153 rilo.nostria.app:443 43.6532 -79.3832
154 relay.trotters.cc:443 43.6532 -79.3832
155 nostr.overmind.lol:443 43.6532 -79.3832
156 nostr.girino.org:443 43.6532 -79.3832
157 bitsat.molonlabe.holdings 51.4012 -1.3147
158 nostr.azzamo.net 52.2633 21.0283
159 insta-relay.apps3.slidestr.net 40.4167 -3.70329
160 bridge.tagomago.me 42.3601 -71.0589
161 nostr.thalheim.io 60.1699 24.9384
162 relay.artx.market:443 43.6548 -79.3885
163 nostr.openhoofd.nl 51.5717 3.70417
164 nostr.bond 50.1109 8.68213
165 relay.earthly.city 34.1749 -118.54
166 nexus.libernet.app 43.6532 -79.3832
167 relay.plebeian.market 50.1109 8.68213
168 relay.nostr.net 43.6532 -79.3832
169 nostr.overmind.lol 43.6532 -79.3832
170 relay.ohstr.com 43.6532 -79.3832
171 testnet-relay.samt.st:443 40.8302 -74.1299
172 relay01.lnfi.network 35.6764 139.65
173 relay.mostr.pub:443 43.6532 -79.3832
174 wot.nostr.party 36.1659 -86.7844
175 relayone.soundhsa.com 39.1008 -94.5811
176 relay.mostro.network 40.8302 -74.1299
177 ribo.eu.nostria.app 43.6532 -79.3832
178 chat-relay.zap-work.com 43.6532 -79.3832
179 relay.nostreon.com 60.1699 24.9384
180 nostr-rs-relay.dev.fedibtc.com:443 39.0438 -77.4874
181 nostr.quali.chat:443 60.1699 24.9384
182 relay.internationalright-wing.org:443 -22.5022 -48.7114
183 relay.mitchelltribe.com:443 39.0438 -77.4874
184 relay.satlantis.io 40.8054 -74.0241
185 nittom.nostr1.com:443 40.7057 -74.0136
186 nostr.janx.com 43.6532 -79.3832
187 nostr.carroarmato0.be:443 50.914 3.21378
188 relay.mmwaves.de:443 48.8575 2.35138
189 relay.chorus.community:443 48.5333 10.7
190 wot.utxo.one 43.6532 -79.3832
191 relay.plebeian.market:443 50.1109 8.68213
192 relay.cosmicbolt.net 37.3986 -121.964
193 x.kojira.io:443 43.6532 -79.3832
194 top.testrelay.top 43.6532 -79.3832
195 nos.lol:443 50.4754 12.3683
196 dev.relay.edufeed.org 49.4521 11.0767
197 relayone.geektank.ai:443 39.1008 -94.5811
198 relay.nostar.org 43.6532 -79.3832
199 nostr.oxtr.dev:443 50.4754 12.3683
200 nostr.88mph.life 52.1941 -2.21905
201 relay.staging.commonshub.brussels 49.4543 11.0746
202 weboftrust.libretechsystems.xyz 55.4724 9.87335
203 relay.openfarmtools.org 60.1699 24.9384
204 cs-relay.nostrdev.com 50.4754 12.3683
205 relay.inforsupports.com 43.6532 -79.3832
206 nostr-verified.wellorder.net 45.5201 -122.99
207 nostr.hekster.org 37.3986 -121.964
208 relay.gulugulu.moe:443 43.6532 -79.3832
209 relay.mwaters.net 50.9871 2.12554
210 nostrcity-club.fly.dev:443 37.7648 -122.432
211 relay.vrtmrz.net:443 43.6532 -79.3832
212 relay.nostr.place 43.6532 -79.3832
213 relay.wavefunc.live:443 41.8781 -87.6298
214 nostr.islandarea.net 35.4669 -97.6473
215 purplerelay.com:443 43.6532 -79.3832
216 nostr-relay.psfoundation.info:443 39.0438 -77.4874
217 r.0kb.io 32.789 -96.7989
218 relay-us.zombi.cloudrodion.com 40.7862 -74.0743
219 relay.mulatta.io 37.5665 126.978
220 strfry.bonsai.com:443 39.0438 -77.4874
221 bendernostur.duckdns.org:8443 50.1109 8.68213
222 vault.iris.to 43.6532 -79.3832
223 ec2.f7z.io 60.1699 24.9384
224 nostr.debate.report 50.1109 8.68213
225 wot.codingarena.top 50.4754 12.3683
226 relay.layer.systems:443 49.0291 8.35695
227 relay.degmods.com 50.4754 12.3683
228 nostr.mom 50.4754 12.3683
229 ribo.us.nostria.app:443 43.6532 -79.3832
230 adre.su 59.9311 30.3609
231 wot.sudocarlos.com 43.6532 -79.3832
232 relay.nostrian-conquest.com 41.223 -111.974
233 nostr-relay.nextblockvending.com 47.2343 -119.853
234 relay.endfiat.money:443 59.3327 18.0656
235 nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
236 nostr.carroarmato0.be 50.914 3.21378
237 relay.cypherflow.ai:443 48.8575 2.35138
238 nostr.girino.org 43.6532 -79.3832
239 nostr.thebiglake.org 32.71 -96.6745
240 strfry.ymir.cloud 43.6532 -79.3832
241 relay.mypathtofire.de 42.8864 -78.8784
242 relay.lanacoin-eternity.com 40.8302 -74.1299
243 nostr.snowbla.de:443 60.1699 24.9384
244 relay.ditto.pub 43.6532 -79.3832
245 relay.damus.io 43.6532 -79.3832
246 relay.ru.ac.th 13.7607 100.627
247 nrs-01.darkcloudarcade.com 39.1008 -94.5811
248 testnet-relay.samt.st 40.8302 -74.1299
249 antiprimal.net 43.6532 -79.3832
250 bitchat.nostr1.com 40.7057 -74.0136
251 relay.snort.social 53.3498 -6.26031
252 relay.mccormick.cx:443 52.3563 4.95714
253 relay02.lnfi.network 35.6764 139.65
254 srtrelay.c-stellar.net 43.6532 -79.3832
255 relay.minibolt.info 43.6532 -79.3832
256 nostrride.io 37.3986 -121.964
257 articles.layer3.news:443 37.3387 -121.885
258 rele.speyhard.fi 51.5072 -0.127586
259 relay.aarpia.com 37.3986 -121.964
260 nostr.chaima.info 50.1109 8.68213
261 relay.wisp.talk:443 49.4543 11.0746
262 relay.agorist.space:443 52.3734 4.89406
263 strfry.bonsai.com 39.0438 -77.4874
264 nostr.hifish.org 47.4244 8.57658
265 offchain.pub 39.1585 -94.5728
266 nostr.spicyz.io:443 43.6532 -79.3832
267 relay.beginningend.com 35.2227 -97.4786
268 relay.sharegap.net 43.6532 -79.3832
269 nostr.purpura.cloud 43.6532 -79.3832
270 nrs-01.darkcloudarcade.com:443 39.1008 -94.5811
271 relay.fountain.fm:443 43.6532 -79.3832
272 relay.olas.app 60.1699 24.9384
273 relay.mmwaves.de 48.8575 2.35138
274 relay.openresist.com:443 43.6532 -79.3832
275 relay.homeinhk.xyz 35.694 139.754
276 relay.libernet.app 43.6532 -79.3832
277 relay.comcomponent.com 43.6532 -79.3832
278 nostr.tac.lol 47.4748 -122.273
279 relay.goodmorningbitcoin.com 43.6532 -79.3832
280 relay.nostriot.com:443 41.5695 -83.9786
281 bcast.girino.org 43.6532 -79.3832
282 nostr.azzamo.net:443 52.2633 21.0283
283 relay.islandbitcoin.com:443 12.8498 77.6545
284 pool.libernet.app 43.6532 -79.3832
285 test.thedude.cloud 50.1109 8.68213
286 nostrelites.org 41.8781 -87.6298
287 nostr.infero.net 35.6764 139.65
288 relay.primal.net 43.6532 -79.3832
289 ribo.nostria.app 43.6532 -79.3832
290 relay.chorus.community 48.5333 10.7
291 bitcoiner.social:443 47.6743 -117.112
292 relay.wisp.talk 49.4543 11.0746
293 relay.layer.systems 49.0291 8.35695
294 relay-dev.satlantis.io 40.8302 -74.1299
295 nostr.bitcoiner.social 47.6743 -117.112
296 relay.lanavault.space:443 60.1699 24.9384
297 relay.staging.plebeian.market 51.5072 -0.127586
298 infinity-signal-relay.digitalforlifeagency.workers.dev 43.6532 -79.3832
299 relay.fountain.fm 43.6532 -79.3832
300 nostr.middling.mydns.jp 35.8099 140.12
301 relay.dreamith.to 43.6532 -79.3832
302 relay.satmaxt.xyz:443 43.6532 -79.3832
303 shu03.shugur.net 25.2048 55.2708
304 zealand-charts-craig-thru.trycloudflare.com 43.6532 -79.3832
305 nostr.computingcache.com 34.0356 -118.442
306 ribo.us.nostria.app 43.6532 -79.3832
307 relay.agentry.com 42.8864 -78.8784
308 nostr.hifish.org:443 47.4244 8.57658
309 nostr.vulpem.com 49.4543 11.0746
310 relay.cosmicbolt.net:443 37.3986 -121.964
311 nostr-02.yakihonne.com 1.32123 103.695
312 r.0kb.io:443 32.789 -96.7989
313 nostr-relay.corb.net 38.8353 -104.822
314 ribo.eu.nostria.app:443 43.6532 -79.3832
315 nostr-relay.psfoundation.info 39.0438 -77.4874
316 relay.wellorder.net 45.5201 -122.99
317 relay.novospes.com 43.6532 -79.3832
318 nostr-dev.wellorder.net 45.5201 -122.99
319 relay.endfiat.money 59.3327 18.0656
320 relay.angor.io:443 48.1046 11.6002
321 relay-fra.zombi.cloudrodion.com:443 48.8566 2.35222
322 strfry.openhoofd.nl 51.5717 3.70417
323 relay.getsafebox.app 43.6532 -79.3832
324 relay.openresist.com 43.6532 -79.3832
325 relay5.bitransfer.org 43.6532 -79.3832
326 nostr.na.social 43.6532 -79.3832
327 portal-relay.pareto.space 49.0291 8.35696
328 nostr.notribe.net:443 40.8302 -74.1299
329 relay.bitmacro.cloud 43.6532 -79.3832
330 no.str.cr:443 10.6352 -85.4378
331 relay.klabo.world 47.2343 -119.853
332 nostr.notribe.net 40.8302 -74.1299
333 relay.staging.plebeian.market:443 51.5072 -0.127586
334 relay.nostrmap.net 60.1699 24.9384
335 temp.iris.to 43.6532 -79.3832
336 nostr.sovereignservices.xyz 43.6532 -79.3832
337 nostr.liberty.fans 36.9104 -89.5875
338 relay.nostrian-conquest.com:443 41.223 -111.974
339 relay.nostriot.com 41.5695 -83.9786
340 nostrbtc.com 43.6532 -79.3832
341 shu02.shugur.net 21.4902 39.2246
342 relay.kalcafe.xyz 37.3986 -121.964
343 relay.illuminodes.com 43.6532 -79.3832
344 relay.wavlake.com 41.2619 -95.8608
345 nostr.ps1829.com:443 33.8851 130.883
346 dm-test-strfry-discovery.samt.st 43.6532 -79.3832
347 nostr.wecsats.io:443 43.6532 -79.3832
348 nostr-pub.wellorder.net 45.5201 -122.99
349 nostr.dlcdevkit.com:443 40.0992 -83.1141
350 nostr.mom:443 50.4754 12.3683
351 ribo.nostria.app:443 43.6532 -79.3832
352 nostr.2b9t.xyz 34.0549 -118.243
353 nostr.data.haus:443 50.4754 12.3683
354 staging.yabu.me 35.6092 139.73
355 relay.sigit.io:443 50.4754 12.3683
356 relay.edufeed.org:443 49.4521 11.0767
357 nostr-01.yakihonne.com:443 1.32123 103.695
358 reraw.pbla2fish.cc 43.6532 -79.3832
359 cs-relay.nostrdev.com:443 50.4754 12.3683
360 herbstmeister.com 34.0549 -118.243
361 relay.minibolt.info:443 43.6532 -79.3832
362 relay2.angor.io:443 48.1046 11.6002
363 social.amanah.eblessing.co 48.1046 11.6002
364 nostr.stakey.net 52.3676 4.90414
365 nostr.computingcache.com:443 34.0356 -118.442
366 slick.mjex.me 39.0418 -77.4744
367 fanfares.nostr1.com 40.7057 -74.0136
368 bitcoinostr.duckdns.org 43.3434 -3.99532
369 nostr.oxtr.dev 50.4754 12.3683
370 cache.trustr.ing 43.6548 -79.3885
371 purplerelay.com 43.6532 -79.3832
372 nostr-kyomu-haskell.onrender.com 37.7775 -122.397
373 nostr-relay.corb.net:443 38.8353 -104.822
374 relay-dev.gulugulu.moe 43.6532 -79.3832
375 prl.plus 55.7628 37.5983
376 nostr.tac.lol:443 47.4748 -122.273
377 relay.mostr.pub 43.6532 -79.3832
378 schnorr.me:443 43.6532 -79.3832
379 dev-relay.nostreon.com 60.1699 24.9384
380 nostr.islandarea.net:443 35.4669 -97.6473
381 nostr.rtvslawenia.com bucket.coracle.social 49.4543 37.7775 11.0746 -122.397
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
382 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
383 relay.solife.me 43.6532 -79.3832
384 yabu.me nostr.quali.chat 35.6092 60.1699 139.73 24.9384
385 relay.islandbitcoin.com relay.vrtmrz.net 12.8498 43.6532 77.6545 -79.3832
386 nostr.wecsats.io relay-dev.gulugulu.moe:443 43.6532 -79.3832
387 nostr.tac.lol:443 relay.bullishbounty.com 47.4748 43.6532 -122.273 -79.3832
388 relay.arx-ccn.com relay.fckstate.net 50.4754 59.3293 12.3683 18.0686
389 nostrride.io nostr.rtvslawenia.com:443 37.3986 49.4543 -121.964 11.0746
390 r.0kb.io:443 relay.nostx.io 32.789 43.6532 -96.7989 -79.3832
391 herbstmeister.com relay.agorist.space 34.0549 52.3734 -118.243 4.89406
392 relay.artx.market relay.notoshi.win 43.6548 13.7829 -79.3885 100.546
393 vault.iris.to dm-test-strfry-discovery.samt.st:443 43.6532 -79.3832
394 relay.ru.ac.th relay.trotters.cc 13.7607 43.6532 100.627 -79.3832
395 temp.iris.to relay.lanavault.space 43.6532 60.1699 -79.3832 24.9384
396 social.amanah.eblessing.co public.crostr.com:443 48.1046 43.6532 11.6002 -79.3832
397 nostr-relay.nextblockvending.com nostr.stakey.net:443 47.2343 52.3676 -119.853 4.90414
398 wot.codingarena.top relay.nostr.place:443 50.4754 43.6532 12.3683 -79.3832
relay.sincensura.org 43.6532 -79.3832
399 nostr.dlcdevkit.com 40.0992 -83.1141
400 nostr.aruku.ovh 1.27994 103.849
401 satsage.xyz 37.3986 -121.964
402 strfry.apps3.slidestr.net 40.4167 -3.70329
403 nostr2.girino.org 43.6532 -79.3832
404 relay.samt.st 40.8302 -74.1299
405 articles.layer3.news 37.3387 -121.885
406 aeon.libretechsystems.xyz 55.486 9.86577
407 relay.routstr.com 59.4016 17.9455
408 relay.ohstr.com:443 43.6532 -79.3832
409 relay.lanacoin-eternity.com:443 40.8302 -74.1299
410 strfry.openhoofd.nl:443 51.5717 3.70417
411 nostr.blankfors.se 60.1699 24.9384
412 nostr-2.21crypto.ch:443 47.5356 8.73209
413 relayone.soundhsa.com:443 39.1008 -94.5811
414 relay.lab.rytswd.com:443 49.4543 11.0746
415 nostr.tagomago.me 42.3601 -71.0589
416 relay.0xchat.com:443 43.6532 -79.3832
-69
View File
@@ -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()
-186
View File
@@ -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()
-271
View File
@@ -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())