Compare commits

..
Author SHA1 Message Date
jack db7bc29d67 Surface terminal Nostr DM failures 2026-07-26 07:17:21 +02:00
jack f17d20def3 Reject unsendable Nostr DMs visibly 2026-07-26 07:17:21 +02:00
jackandjack 2fc2747349 Harden Nostr envelope migration compatibility 2026-07-26 07:17:21 +02:00
jackandjack 8ddde7ea8b Relabel private Nostr envelopes honestly 2026-07-26 07:17:21 +02:00
jack 11afe9652e Invalidate media deletion callbacks during panic 2026-07-26 07:17:21 +02:00
jack 9fe188d6b9 Make private media deletion transactional 2026-07-26 07:17:21 +02:00
jack a5043dec2d Retry confirmed private media after reconnect 2026-07-26 07:17:21 +02:00
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
98 changed files with 10894 additions and 6736 deletions
+22 -208
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"
git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
- name: Validate candidate against reviewed baseline
id: validation
- 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"
+3 -3
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.
@@ -73,7 +73,7 @@ Private group members receive the group's name, roster, key epoch, and encrypted
Internet-backed features are optional. When enabled or used:
- Private fallback messages use encrypted NIP-17 gift wraps. Relays can observe event and network metadata but not the message plaintext.
- Private fallback messages use BitChat's app-specific encrypted envelopes. This format is not NIP-17, NIP-44, or NIP-59 compatible. Relays can observe the recipient public-key tag, event timing and size, and network metadata, but not the message plaintext or stable sender identity.
- Public location-channel messages, notes, notices, and presence include a geohash tag, event kind, timestamp, and a public key. A geohash reveals an approximate area; finer precision reveals a smaller area.
- The optional mesh bridge publishes bridge-enabled public mesh messages and presence to a neighborhood rendezvous cell. Those messages are public to participants and relays for that cell. A per-message “nearby only” choice prevents that message from crossing the bridge.
- Bridge courier drops contain opaque end-to-end encrypted envelopes and a rotating recipient tag. Relays still observe timing and network metadata.
@@ -103,7 +103,7 @@ Private and public features use different protections:
- Mesh private sessions use Noise XX with X25519, ChaCha20-Poly1305, and SHA-256.
- Private group messages use ChaCha20-Poly1305; group state and relevant mesh packets use Ed25519 signatures.
- Nostr events use secp256k1 Schnorr signatures. NIP-44 v2 private payloads use secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305.
- Nostr events use secp256k1 Schnorr signatures. BitChat private envelopes use secp256k1 key agreement, HKDF-SHA256 with a BitChat-specific domain separator, and XChaCha20-Poly1305. The envelope format is proprietary, only interoperates with BitChat clients, and does not provide forward secrecy against later compromise of the recipient's static Nostr private key.
- The persistent private-message outbox uses ChaCha20-Poly1305 with a key held in the keychain. Some other protected local identity state uses AES-GCM.
- Public mesh, bridge, geohash, and board content is signed or authenticated as appropriate but is intentionally not confidential.
+5 -1
View File
@@ -63,7 +63,11 @@ let package = Package(
// Only the vector fixture: declaring the whole "Noise"
// directory would claim its .swift test files as resources
// and silently drop them from compilation.
.process("Noise/NoiseTestVectors.json")
.process("Noise/NoiseTestVectors.json"),
// Frozen output produced by the released 733098bb private-DM
// implementation; proves receive compatibility independently
// of the refactored legacy generator.
.process("Nostr/Fixtures")
]
)
]
+61 -52
View File
@@ -19,7 +19,7 @@ This project is released into the public domain. See the [LICENSE](LICENSE) file
- **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, NIP-17 for Nostr
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, BitChat private envelopes for Nostr fallback
- **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
- **Universal App**: Native support for iOS and macOS
- **Emergency Wipe**: Triple-tap to instantly clear all data
@@ -44,9 +44,50 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor
- **Global Reach**: Connect with users worldwide via internet relays
- **Location Channels**: Geographic chat rooms using geohash coordinates
- **290+ Relay Network**: Distributed across the globe for reliability
- **NIP-17 Encryption**: Gift-wrapped private messages for internet privacy
- **BitChat Private Envelopes**: App-specific encrypted private messages over Nostr relays
- **Ephemeral Keys**: Fresh cryptographic identity per geohash area
BitChat's private-envelope format is proprietary and is **not** NIP-17,
NIP-44, or NIP-59 compatible. It uses Nostr as a relay transport but only
interoperates with BitChat clients. New envelopes use provisional,
BitChat-specific public kind 1402 (not a formally reserved Nostr kind),
encrypted inner kinds 1403/1404, and the `bitchat-pm-v1:` content prefix.
For mixed-version delivery, clients publish both the primary kind-1402
envelope and a compatibility kind-1059 copy. There is no date-based cutoff:
kind 1059 must remain enabled until a coordinated iOS/Android release confirms
that supported older clients have migrated. Receivers subscribe to both kinds
and deduplicate the authenticated embedded BitChat payload.
Private-envelope migration compatibility:
| Sender | Receiver | Delivery path |
| --- | --- | --- |
| New iOS | New iOS | Kind 1402 is primary; the kind-1059 twin is deduplicated |
| New iOS | Released iOS | Compatibility kind 1059 |
| New iOS | Current Android | Compatibility kind 1059 |
| Released iOS | New iOS | Kind 1059 with the released empty inner-tag shape |
| Current Android | New iOS | Kind 1059 with exactly the authenticated recipient `p` tag |
New kind-1402 envelopes require an empty inner tag list. The Android recipient
tag exception is intentionally confined to legacy kind 1059 and accepts only
the exact addressed recipient. Mailbox subscriptions cover the 24-hour
delivery window plus Android's full 48-hour timestamp randomization and 15
minutes of clock skew. Recovery uses
one independent 500-event relay filter per wire kind so either format cannot
consume the other's result budget.
The two outbound migration copies are admitted to the relay queue as one
protected batch. Queue pressure evicts ephemeral traffic first, never one copy
of a private pair; if protected capacity is exhausted, the entire new pair is
rejected as a whole. User-message rejection becomes a visible failed delivery;
acknowledgements and favorite notifications retain the exact pair in a
process-wide 256-entry bounded retry queue. A sustained outage beyond that
bound evicts the oldest whole control pair with an explicit warning, never half
a pair.
If either socket write fails, the same queued pair remains pending and both
copies are replayed on the replacement connection. A terminal relay target is
pruned after bounded retries so one dead relay cannot wedge healthy delivery.
### Channel Types
#### `mesh #bluetooth`
@@ -80,7 +121,7 @@ Private messages use **intelligent transport selection**:
2. **Nostr Fallback** (when Bluetooth unavailable)
- Uses recipient's Nostr public key
- NIP-17 gift-wrapping for privacy
- BitChat's app-specific private-envelope encryption
- Routes through global relay network
3. **Smart Queuing** (when neither available)
@@ -93,62 +134,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.
+10 -4
View File
@@ -25,7 +25,7 @@ bitchat is a decentralized, peer-to-peer messaging application for secure, priva
Two transports implement a common `Transport` interface and are coordinated by a `MessageRouter`:
* **BLE mesh** — every device is simultaneously a GATT central and peripheral, relaying packets in a controlled flood. No infrastructure, pairing, or accounts.
* **Nostr** — private messages to mutual favorites travel as NIP-17 gift-wrapped events over public relays (over Tor where enabled), bridging separate meshes through the internet.
* **Nostr** — private messages to mutual favorites travel in BitChat's app-specific encrypted envelopes over public relays (over Tor where enabled), bridging separate meshes through the internet.
The router prefers a live mesh link, falls back to Nostr, and engages the courier system when neither can deliver promptly.
@@ -78,7 +78,9 @@ Courier envelopes are sealed to the recipient's *static* key with the one-way No
### 5.3 Nostr Path
Private messages to mutual favorites are wrapped per NIP-17/NIP-59: a rumor (kind 14) sealed (kind 13) and gift-wrapped (kind 1059) under a throwaway ephemeral key, so relays learn neither sender nor content.
Private messages to mutual favorites use BitChat's proprietary private-envelope protocol. An unsigned inner message (kind 1404) is encrypted and placed in a sender-signed seal (kind 1403); that seal is encrypted again inside a public envelope (kind 1402) signed by a one-time key. Kind 1402 is a provisional BitChat-specific assignment, not a formally reserved Nostr kind. Each encrypted content field is `bitchat-pm-v1:` followed by base64url of a 24-byte nonce, XChaCha20-Poly1305 ciphertext, and its 16-byte tag. Keys come from secp256k1 ECDH and HKDF-SHA256 with a BitChat-specific domain separator.
This format is **not NIP-17, NIP-44, or NIP-59 compatible** and interoperates only with BitChat clients. The outer `p` tag exposes the recipient's Nostr public key to relays; the stable sender identity and plaintext remain inside authenticated ciphertext. New-format seal and envelope timestamps are randomized up to 15 minutes into the past, while the actual message timestamp is encrypted. Legacy Android envelopes can carry public-layer timestamps randomized across the preceding 48 hours. The protocol does not provide forward secrecy: compromise of the recipient's static Nostr private key can expose stored envelopes.
## 6. Store and Forward
@@ -105,7 +107,11 @@ Public broadcast messages are cached (1000 packets) and reconciled between peers
### 6.4 Nostr Mailboxes
Gift-wrapped messages rest on Nostr relays; clients re-subscribe with a 24-hour lookback on reconnect, covering the both-devices-offline case for mutual favorites whenever either side touches the internet.
BitChat private envelopes rest on Nostr relays; clients re-subscribe across the 24-hour delivery window plus the full 48-hour timestamp randomization used by deployed Android clients and 15 minutes of clock skew (72 hours 15 minutes total). During the rolling format migration, clients subscribe to both the provisional BitChat-specific kind 1402 and historical kind 1059. Recovery places the kinds in separate filters within one REQ, each with an independent 500-event limit, so traffic in one format cannot starve the other. Each logical payload is published first in the primary kind-1402 format and then as a compatibility legacy copy for older iOS and current Android clients. There is no calendar cutoff: legacy publication and reception remain until a coordinated cross-platform release confirms supported clients have migrated. Bounded dedup of the authenticated embedded payload collapses the migration pair at receivers.
The relay send queue treats those two events as one protected batch. Capacity pressure removes ephemeral events before regular traffic and never evicts only one private-envelope copy. A queue containing only protected batches rejects a new pair as a whole: user messages surface a failed delivery, while acknowledgements and favorite notifications retain the exact pair in one process-wide 256-entry bounded-backoff retry queue shared by account and short-lived geohash transports. Sustained control-payload overflow evicts the oldest whole pair with an explicit warning rather than growing memory or splitting formats. After either socket write fails, the same pair remains pending and both copies are replayed on the replacement connection. Terminal relay targets are pruned after bounded connection retries; a batch that succeeded elsewhere retires normally, while an all-target failure returns to the transport's failure/retry policy. Receive-side dedup suppresses the replay if one copy had already reached the relay.
Released iOS legacy envelopes use an empty inner tag list. Current Android legacy envelopes use exactly one inner `p` tag naming the recipient. The kind-1059 decoder accepts only those two shapes after authenticating the outer recipient and sender-signed seal; alternate recipients, duplicate tags, and extra tags are rejected. Kind 1402 remains strict and permits no inner tags.
### 6.5 Delivery Metrics
@@ -127,7 +133,7 @@ Bare local counters (deposits, handovers, sprays, opens, outbox flushes and drop
* **Flooding abuse** is bounded by TTL clamps, deduplication, per-depositor quotas, connect-rate limits, and announce-rate limiting.
* **Replay** of public broadcasts is bounded by the 6-hour acceptance window plus deduplication; private payloads are protected by Noise nonces.
* **Metadata.** BLE proximity is inherently observable; ephemeral IDs and daily-rotating courier tags limit long-term correlation. Nostr traffic can ride Tor.
* **No forward secrecy for sealed mail** (§5.2) is the main cryptographic trade-off of the offline path.
* **No forward secrecy for sealed mail or Nostr envelopes** (§5.25.3) means compromise of a recipient's static key can expose retained ciphertext addressed to that key.
## 9. Future Work
-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
+4 -4
View File
@@ -265,10 +265,10 @@ final class PrivateConversationModel: ObservableObject {
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
let peer = chatViewModel.getPeer(byID: headerPeerID)
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
// never resolve to a reachable mesh peer, so resolveAvailability would
// report .offline. Report .nostrAvailable so the header shows the
// globe instead of a misleading "offline" tag.
// Geo DMs are always routed through BitChat private envelopes over
// Nostr; their nostr_ keys never resolve to a reachable mesh peer, so
// resolveAvailability would report .offline. Report .nostrAvailable
// so the header shows the globe instead of a misleading "offline" tag.
let availability = conversationPeerID.isGeoDM
? .nostrAvailable
: resolveAvailability(for: headerPeerID, peer: peer)
-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()
+3 -35
View File
@@ -176,9 +176,9 @@ struct IdentityCache: Codable {
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
}
}
//
+44 -105
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,9 +589,10 @@ 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)
@@ -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
-480
View File
@@ -30626,378 +30626,6 @@
}
}
},
"content.delivery.reason.private_media_capability_unresolved" : {
"comment" : "Failure reason shown when the peer's support for encrypted media could not be confirmed before sending",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "تعذّر تأكيد دعم الوسائط المشفّرة"
}
},
"bn" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "এনক্রিপ্ট করা মিডিয়া সমর্থন নিশ্চিত করা যায়নি"
}
},
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Unterstützung für verschlüsselte Medien konnte nicht bestätigt werden"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Could not confirm encrypted media support"
}
},
"es" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "No se pudo confirmar la compatibilidad con multimedia cifrada"
}
},
"fa" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "پشتیبانی از رسانه رمزنگاری‌شده تأیید نشد"
}
},
"fil" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Hindi makumpirma ang suporta sa naka-encrypt na media"
}
},
"fr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Impossible de confirmer la prise en charge des médias chiffrés"
}
},
"he" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "לא ניתן לאמת תמיכה במדיה מוצפנת"
}
},
"hi" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "एन्क्रिप्टेड मीडिया समर्थन की पुष्टि नहीं हो सकी"
}
},
"id" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Tidak dapat memastikan dukungan media terenkripsi"
}
},
"it" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Impossibile confermare il supporto dei contenuti multimediali cifrati"
}
},
"ja" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "暗号化メディアの対応を確認できませんでした"
}
},
"ko" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "암호화된 미디어 지원을 확인할 수 없습니다"
}
},
"ms" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Tidak dapat mengesahkan sokongan media tersulit"
}
},
"ne" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "एन्क्रिप्टेड मिडिया समर्थन पुष्टि गर्न सकिएन"
}
},
"nl" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Ondersteuning voor versleutelde media kon niet worden bevestigd"
}
},
"pl" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Nie udało się potwierdzić obsługi zaszyfrowanych multimediów"
}
},
"pt" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Não foi possível confirmar o suporte a multimédia cifrada"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Não foi possível confirmar o suporte a mídia criptografada"
}
},
"ru" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Не удалось подтвердить поддержку зашифрованных медиа"
}
},
"sv" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Det gick inte att bekräfta stöd för krypterade medier"
}
},
"ta" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "மறைகுறியாக்கப்பட்ட மீடியா ஆதரவை உறுதிப்படுத்த முடியவில்லை"
}
},
"th" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "ไม่สามารถยืนยันการรองรับสื่อที่เข้ารหัสได้"
}
},
"tr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Şifreli medya desteği doğrulanamadı"
}
},
"uk" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Не вдалося підтвердити підтримку зашифрованих медіа"
}
},
"ur" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "خفیہ کردہ میڈیا کی معاونت کی تصدیق نہیں ہو سکی"
}
},
"vi" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Không thể xác nhận hỗ trợ phương tiện được mã hóa"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "无法确认加密媒体支持"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "無法確認加密媒體支援"
}
}
}
},
"content.delivery.reason.private_media_delivery_unconfirmed" : {
"comment" : "Failure reason shown when an encrypted media message was sent but its delivery was never confirmed",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "تعذّر تأكيد التسليم"
}
},
"bn" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "ডেলিভারি নিশ্চিত করা যায়নি"
}
},
"de" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Zustellung konnte nicht bestätigt werden"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Delivery could not be confirmed"
}
},
"es" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "No se pudo confirmar la entrega"
}
},
"fa" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "تحویل تأیید نشد"
}
},
"fil" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Hindi makumpirma ang paghahatid"
}
},
"fr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Impossible de confirmer la remise"
}
},
"he" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "לא ניתן לאמת את המסירה"
}
},
"hi" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "डिलीवरी की पुष्टि नहीं हो सकी"
}
},
"id" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Pengiriman tidak dapat dipastikan"
}
},
"it" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Impossibile confermare la consegna"
}
},
"ja" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "配信を確認できませんでした"
}
},
"ko" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "전달을 확인할 수 없습니다"
}
},
"ms" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Penghantaran tidak dapat disahkan"
}
},
"ne" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "डेलिभरी पुष्टि गर्न सकिएन"
}
},
"nl" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Bezorging kon niet worden bevestigd"
}
},
"pl" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Nie udało się potwierdzić dostarczenia"
}
},
"pt" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Não foi possível confirmar a entrega"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Não foi possível confirmar a entrega"
}
},
"ru" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Не удалось подтвердить доставку"
}
},
"sv" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Leveransen kunde inte bekräftas"
}
},
"ta" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "விநியோகத்தை உறுதிப்படுத்த முடியவில்லை"
}
},
"th" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "ไม่สามารถยืนยันการส่งได้"
}
},
"tr" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Teslimat doğrulanamadı"
}
},
"uk" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Не вдалося підтвердити доставлення"
}
},
"ur" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "ترسیل کی تصدیق نہیں ہو سکی"
}
},
"vi" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "Không thể xác nhận việc gửi"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "无法确认送达"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "needs_review",
"value" : "無法確認送達"
}
}
}
},
"content.delivery.reason.not_delivered" : {
"comment" : "Failure reason shown when the router gave up delivering a message",
"extractionState" : "manual",
@@ -73034,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"
+6 -41
View File
@@ -31,20 +31,6 @@ enum NoiseHandshakeRecoveryPreparation {
case transferred
}
/// Why a quarantined transport became the active session again.
enum NoiseSessionRestoreReason: Equatable, Sendable {
/// The replacement attempt failed terminally (claimed-identity mismatch,
/// or a failure that owns no convergence retry). The counterpart never
/// finished replacement keys, so the restored generation is immediately
/// valid for outbound traffic.
case terminal
/// The responder window expired or a recoverable failure occurred
/// and this manager owns one mandatory convergence retry. The counterpart
/// may already hold replacement keys that discarded the restored ones, so
/// outbound queue drains must wait for the retry to conclude.
case pendingConvergence
}
final class NoiseSessionManager {
private var sessions: [PeerID: NoiseSession] = [:]
/// Opaque identity for each exact entry in `sessions`. The generation is
@@ -89,7 +75,7 @@ final class NoiseSessionManager {
// Callbacks
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey, UUID) -> Void)?
var onSessionRestored: ((PeerID, UUID, NoiseSessionRestoreReason) -> Void)?
var onSessionRestored: ((PeerID, UUID) -> Void)?
var onSessionFailed: ((PeerID, Error) -> Void)?
var onHandshakeRecoveryRequired: ((NoiseHandshakeRecoveryRequest) -> Void)?
@@ -767,9 +753,6 @@ final class NoiseSessionManager {
recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID)
session.reset()
let isIdentityMismatch =
(error as? NoiseSessionError) == .peerIdentityMismatch
let restoredGeneration: UUID?
if let quarantined = quarantinedTransports.removeValue(forKey: peerID) {
sessions[peerID] = quarantined.session
@@ -780,26 +763,16 @@ final class NoiseSessionManager {
restoredGeneration = nil
}
// An identity mismatch is terminal: the counterpart failed to
// prove the claimed static key, so it never finished keys that
// could have replaced the restored ones. Every other restore
// that owns (or joins) a convergence retry must keep transport
// queues parked until that retry concludes the counterpart
// may already have discarded the restored sending keys.
let restoreReason: NoiseSessionRestoreReason =
!isIdentityMismatch
&& (shouldRequestRecovery
|| pendingHandshakeRecoveryIDs[peerID] != nil)
? .pendingConvergence
: .terminal
// Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in
if let restoredGeneration {
self?.onSessionRestored?(peerID, restoredGeneration, restoreReason)
self?.onSessionRestored?(peerID, restoredGeneration)
}
self?.onSessionFailed?(peerID, error)
}
let isIdentityMismatch =
(error as? NoiseSessionError) == .peerIdentityMismatch
if pendingHandshakeRecoveryIDs[peerID] != nil {
shouldSuppressImmediateHandlerRestart = true
}
@@ -949,16 +922,8 @@ final class NoiseSessionManager {
category: .session
)
if let restored {
// The mandatory convergence retry below owns the outbound
// resume: the timed-out counterpart may hold replacement keys
// that already discarded the restored generation's, so queue
// drains under it would be silently undecryptable.
DispatchQueue.global().async { [weak self] in
self?.onSessionRestored?(
peerID,
restored.generation,
.pendingConvergence
)
self?.onSessionRestored?(peerID, restored.generation)
}
}
+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
+2 -1
View File
@@ -1,7 +1,8 @@
import Foundation
import P256K
/// Manages Nostr identity (secp256k1 keypair) for NIP-17 private messaging
/// Manages the secp256k1 identity used by BitChat's Nostr relay features,
/// including the proprietary private-envelope transport.
struct NostrIdentity: Codable {
let privateKey: Data
let publicKey: Data
+427 -213
View File
@@ -1,4 +1,3 @@
import BitLogger
import Foundation
import CryptoKit
import P256K
@@ -7,16 +6,31 @@ import Security
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
// Make sure BinaryEncodingUtils.swift is included in the target
/// NIP-17 Protocol Implementation for Private Direct Messages
/// BitChat's private-envelope protocol transported over Nostr relays.
///
/// This is deliberately BitChat-specific and is not NIP-17, NIP-44, or NIP-59.
/// It uses Nostr events and secp256k1 identities, but its XChaCha20-Poly1305
/// payload layout is proprietary and interoperates only with BitChat clients.
struct NostrProtocol {
/// Nostr event kinds
enum EventKind: Int {
case metadata = 0
case textNote = 1
case dm = 14 // NIP-17 DM rumor kind
case seal = 13 // NIP-17 sealed event
case giftWrap = 1059 // NIP-59 gift wrap
// Compatibility for BitChat releases that incorrectly emitted the
// proprietary payload under standard NIP kinds. Kind 1059 continues
// to be published and read until a coordinated cross-platform release
// explicitly removes it; all three legacy layers remain readable.
case legacyNIP59Seal = 13
case legacyNIP17DirectMessage = 14
case legacyNIP59GiftWrap = 1059
// Provisional BitChat-specific regular event kinds. These are not
// formally reserved by the Nostr kind registry. Only
// `privateEnvelope` is published; message and seal exist solely
// inside ciphertext.
case privateEnvelope = 1402
case privateSeal = 1403
case privateMessage = 1404
case ephemeralEvent = 20000
case geohashPresence = 20001
case deletion = 5 // NIP-09 event deletion request
@@ -26,144 +40,289 @@ struct NostrProtocol {
case courierDrop = 1401
}
/// Create a NIP-17 private message
static func createPrivateMessage(
/// Prefix for BitChat private-envelope ciphertext. The suffix is
/// base64url(nonce24 || ciphertext || poly1305Tag).
static let privateEnvelopeContentPrefix = "bitchat-pm-v1:"
/// Bound work before Base64 decoding either encrypted layer. Current
/// private messages are normally only a few KiB; 64 KiB leaves ample
/// migration headroom without allowing an addressed relay event to drive
/// unbounded allocation.
static let maximumPrivateEnvelopeCiphertextBytes = 64 * 1024
/// Bound the inner authenticated message JSON before allocation/parsing.
/// This is intentionally an envelope limit, not the generic composer
/// limit. Raising it alone would not make larger private-message packets
/// compatible with released readers; callers must surface a rejected
/// packet or envelope as a failed send.
static let maximumPrivateEnvelopePlaintextBytes = 32 * 1024
/// The outer authenticated seal JSON contains a Base64-encoded encrypted
/// copy of the inner JSON, so it needs expansion headroom of its own. Keep
/// the layer-specific cap below the public ciphertext ceiling.
private static let maximumPrivateEnvelopeSealPlaintextBytes = 48 * 1024
/// New clients subscribe to the provisional BitChat-specific kind and the
/// compatibility legacy kind so both sides of a rolling rollout can
/// recover stored messages. Do not remove kind 1059 here until all
/// supported iOS and Android releases have migrated.
static let acceptedPrivateEnvelopeKinds = [
EventKind.privateEnvelope.rawValue,
EventKind.legacyNIP59GiftWrap.rawValue
]
private enum PrivateEnvelopeWireFormat {
case bitchatV1
case legacyMislabelledV2
init?(outerKind: Int) {
switch outerKind {
case EventKind.privateEnvelope.rawValue:
self = .bitchatV1
case EventKind.legacyNIP59GiftWrap.rawValue:
self = .legacyMislabelledV2
default:
return nil
}
}
var messageKind: EventKind {
switch self {
case .bitchatV1: .privateMessage
case .legacyMislabelledV2: .legacyNIP17DirectMessage
}
}
var sealKind: EventKind {
switch self {
case .bitchatV1: .privateSeal
case .legacyMislabelledV2: .legacyNIP59Seal
}
}
var envelopeKind: EventKind {
switch self {
case .bitchatV1: .privateEnvelope
case .legacyMislabelledV2: .legacyNIP59GiftWrap
}
}
var contentPrefix: String {
switch self {
case .bitchatV1: NostrProtocol.privateEnvelopeContentPrefix
case .legacyMislabelledV2: "v2:"
}
}
var hkdfSalt: Data {
switch self {
case .bitchatV1: Data("bitchat-private-envelope-v1".utf8)
case .legacyMislabelledV2: Data()
}
}
var hkdfInfo: Data {
switch self {
case .bitchatV1: Data()
case .legacyMislabelledV2: Data("nip44-v2".utf8)
}
}
}
/// Create a BitChat private envelope for relay transport.
static func createPrivateEnvelope(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
try createPrivateEnvelope(
content: content,
recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity,
format: .bitchatV1
)
}
// Creating private message
/// Events to publish for one logical private payload. The primary
/// BitChat-specific format is always first and a legacy copy follows for
/// clients that still subscribe only to kind 1059. There is deliberately
/// no date-based cutoff: removal requires a coordinated iOS/Android
/// release after supported old clients have migrated. Both encrypt the
/// exact same embedded BitChat payload, so receive-side logical-payload
/// dedup collapses the pair.
static func createPrivateEnvelopePublicationBatch(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity
) throws -> [NostrEvent] {
let primary = try createPrivateEnvelope(
content: content,
recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity
)
let compatibilityCopy = try createPrivateEnvelope(
content: content,
recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity,
format: .legacyMislabelledV2
)
return [primary, compatibilityCopy]
}
// 1. Create the rumor (unsigned event)
let rumor = NostrEvent(
private static func createPrivateEnvelope(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity,
format: PrivateEnvelopeWireFormat,
messageTags: [[String]] = []
) throws -> NostrEvent {
// 1. Create the unsigned inner BitChat message.
let message = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .dm, // NIP-17: DM rumor kind 14
tags: [],
kind: format.messageKind,
tags: messageTags,
content: content
)
// 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S
// real identity key. NIP-17 requires the seal be signed by the sender
// so the recipient can authenticate who sent the message; signing with
// a throwaway key leaves DMs forgeable/impersonatable.
// 2. Encrypt the message to the recipient and sign the private seal
// with the sender's stable Nostr identity for sender authentication.
let senderKey = try senderIdentity.schnorrSigningKey()
let sealedEvent = try createSeal(
rumor: rumor,
let sealedEvent = try createPrivateSeal(
message: message,
recipientPubkey: recipientPubkey,
senderKey: senderKey
senderKey: senderKey,
format: format
)
// 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap
// layer hides the sender's identity from relays; createGiftWrap mints
// its own ephemeral key internally).
let giftWrap = try createGiftWrap(
// 3. Encrypt the seal under a one-time key so the public envelope does
// not reveal the stable sender identity.
return try createPrivateEnvelopeEvent(
seal: sealedEvent,
recipientPubkey: recipientPubkey
recipientPubkey: recipientPubkey,
format: format
)
// Created gift wrap
return giftWrap
}
/// Decrypt a received NIP-17 message
/// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp)
static func decryptPrivateMessage(
giftWrap: NostrEvent,
/// Decrypt a BitChat private envelope. Legacy proprietary envelopes that
/// older BitChat releases placed under kinds 1059/13/14 are accepted only
/// through the format-isolated receive path.
static func decryptPrivateEnvelope(
envelope: NostrEvent,
recipientIdentity: NostrIdentity
) throws -> (content: String, senderPubkey: String, timestamp: Int) {
// Starting decryption
// 1. Unwrap the gift wrap
let seal: NostrEvent
do {
seal = try unwrapGiftWrap(
giftWrap: giftWrap,
recipientKey: recipientIdentity.schnorrSigningKey()
)
// Successfully unwrapped gift wrap
} catch {
SecureLogger.error("❌ Failed to unwrap gift wrap: \(error)", category: .session)
throw error
}
// 2. Authenticate the seal. The seal MUST be signed by the sender's real
// identity key (NIP-17); without this check a DM is forgeable by anyone
// who knows the recipient's npub. Verify the seal's own signature.
guard seal.isValidSignature() else {
SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session)
throw NostrError.invalidEvent
}
// 3. Open the seal
let rumor: NostrEvent
do {
rumor = try openSeal(
seal: seal,
recipientKey: recipientIdentity.schnorrSigningKey()
)
// Successfully opened seal
} catch {
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
throw error
}
// 4. The sender claimed inside the rumor must match the key that actually
// signed the seal, otherwise the sender field is unauthenticated and
// spoofable.
guard seal.pubkey == rumor.pubkey else {
SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session)
throw NostrError.invalidEvent
}
// Return the seal signer's pubkey as the authenticated sender.
return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at)
let layers = try decodePrivateEnvelopeLayers(
envelope: envelope,
recipientIdentity: recipientIdentity
)
return (
content: layers.message.content,
senderPubkey: layers.seal.pubkey,
timestamp: layers.message.created_at
)
}
#if DEBUG
static func createPrivateMessageWithInvalidSealSignatureForTesting(
static func createPrivateEnvelopeWithInvalidSealSignatureForTesting(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
let rumor = NostrEvent(
let format = PrivateEnvelopeWireFormat.bitchatV1
let message = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .dm,
kind: format.messageKind,
tags: [],
content: content
)
var seal = try createSeal(
rumor: rumor,
var seal = try createPrivateSeal(
message: message,
recipientPubkey: recipientPubkey,
senderKey: senderIdentity.schnorrSigningKey()
senderKey: senderIdentity.schnorrSigningKey(),
format: format
)
seal.sig = String(repeating: "0", count: 128)
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
return try createPrivateEnvelopeEvent(
seal: seal,
recipientPubkey: recipientPubkey,
format: format
)
}
static func createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
static func createPrivateEnvelopeWithMismatchedSealMessagePubkeyForTesting(
content: String,
recipientPubkey: String,
rumorIdentity: NostrIdentity,
messageIdentity: NostrIdentity,
sealSignerIdentity: NostrIdentity
) throws -> NostrEvent {
let rumor = NostrEvent(
pubkey: rumorIdentity.publicKeyHex,
let format = PrivateEnvelopeWireFormat.bitchatV1
let message = NostrEvent(
pubkey: messageIdentity.publicKeyHex,
createdAt: Date(),
kind: .dm,
kind: format.messageKind,
tags: [],
content: content
)
let seal = try createSeal(
rumor: rumor,
let seal = try createPrivateSeal(
message: message,
recipientPubkey: recipientPubkey,
senderKey: sealSignerIdentity.schnorrSigningKey()
senderKey: sealSignerIdentity.schnorrSigningKey(),
format: format
)
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
return try createPrivateEnvelopeEvent(
seal: seal,
recipientPubkey: recipientPubkey,
format: format
)
}
static func createPrivateEnvelopeWithInnerTagsForTesting(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity,
innerMessageTags: [[String]]
) throws -> NostrEvent {
try createPrivateEnvelope(
content: content,
recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity,
format: .bitchatV1,
messageTags: innerMessageTags
)
}
static func createLegacyPrivateEnvelopeForTesting(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity,
innerMessageTags: [[String]] = []
) throws -> NostrEvent {
// Current Android legacy envelopes use exactly one recipient `p` tag
// on the unsigned inner kind-14 event; released iOS envelopes use no
// inner tags. Tests pass the Android shape explicitly so this helper
// cannot silently make the production encoder depend on that quirk.
try createPrivateEnvelope(
content: content,
recipientPubkey: recipientPubkey,
senderIdentity: senderIdentity,
format: .legacyMislabelledV2,
messageTags: innerMessageTags
)
}
static func decodePrivateEnvelopeLayersForTesting(
envelope: NostrEvent,
recipientIdentity: NostrIdentity
) throws -> (seal: NostrEvent, message: NostrEvent) {
try decodePrivateEnvelopeLayers(
envelope: envelope,
recipientIdentity: recipientIdentity
)
}
static func decodePrivateEnvelopeEventJSONForTesting(_ json: String) throws -> NostrEvent {
try decodePrivateEnvelopeEventJSON(json)
}
#endif
@@ -400,151 +559,207 @@ struct NostrProtocol {
// MARK: - Private Methods
private static func createSeal(
rumor: NostrEvent,
private static func createPrivateSeal(
message: NostrEvent,
recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey
senderKey: P256K.Schnorr.PrivateKey,
format: PrivateEnvelopeWireFormat
) throws -> NostrEvent {
let rumorJSON = try rumor.jsonString()
let encrypted = try encrypt(
plaintext: rumorJSON,
plaintext: message.jsonString(),
recipientPubkey: recipientPubkey,
senderKey: senderKey
senderKey: senderKey,
format: format,
maximumPlaintextBytes: maximumPrivateEnvelopePlaintextBytes
)
let seal = NostrEvent(
pubkey: Data(senderKey.xonly.bytes).hexEncodedString(),
createdAt: randomizedTimestamp(),
kind: .seal,
createdAt: randomizedPastTimestamp(),
kind: format.sealKind,
tags: [],
content: encrypted
)
// Sign the seal with the sender's Schnorr private key
return try seal.sign(with: senderKey)
}
private static func createGiftWrap(
private static func createPrivateEnvelopeEvent(
seal: NostrEvent,
recipientPubkey: String
recipientPubkey: String,
format: PrivateEnvelopeWireFormat
) throws -> NostrEvent {
let sealJSON = try seal.jsonString()
// Create new ephemeral key for gift wrap
let wrapKey = try P256K.Schnorr.PrivateKey()
// Creating gift wrap with ephemeral key
// Encrypt the seal with the new ephemeral key (not the seal's key)
// A fresh signing/encryption key for every public envelope keeps the
// stable sender identity inside ciphertext.
let envelopeKey = try P256K.Schnorr.PrivateKey()
let encrypted = try encrypt(
plaintext: sealJSON,
plaintext: seal.jsonString(),
recipientPubkey: recipientPubkey,
senderKey: wrapKey // Use the gift wrap ephemeral key
senderKey: envelopeKey,
format: format,
maximumPlaintextBytes: maximumPrivateEnvelopeSealPlaintextBytes
)
let giftWrap = NostrEvent(
pubkey: Data(wrapKey.xonly.bytes).hexEncodedString(),
createdAt: randomizedTimestamp(),
kind: .giftWrap,
tags: [["p", recipientPubkey]], // Tag recipient
let envelope = NostrEvent(
pubkey: Data(envelopeKey.xonly.bytes).hexEncodedString(),
createdAt: randomizedPastTimestamp(),
kind: format.envelopeKind,
tags: [["p", recipientPubkey]],
content: encrypted
)
// Sign the gift wrap with the wrap Schnorr private key
return try giftWrap.sign(with: wrapKey)
return try envelope.sign(with: envelopeKey)
}
private static func unwrapGiftWrap(
giftWrap: NostrEvent,
recipientKey: P256K.Schnorr.PrivateKey
) throws -> NostrEvent {
// Unwrapping gift wrap
let decrypted = try decrypt(
ciphertext: giftWrap.content,
senderPubkey: giftWrap.pubkey,
recipientKey: recipientKey
)
guard let data = decrypted.data(using: .utf8),
let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
private static func decodePrivateEnvelopeLayers(
envelope: NostrEvent,
recipientIdentity: NostrIdentity
) throws -> (seal: NostrEvent, message: NostrEvent) {
guard envelope.content.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else {
throw NostrError.invalidCiphertext
}
guard let format = PrivateEnvelopeWireFormat(outerKind: envelope.kind),
envelope.tags == [["p", recipientIdentity.publicKeyHex]],
envelope.isValidSignature() else {
throw NostrError.invalidEvent
}
let seal = try NostrEvent(from: sealDict)
// Unwrapped seal
let recipientKey = try recipientIdentity.schnorrSigningKey()
let sealJSON = try decrypt(
ciphertext: envelope.content,
senderPubkey: envelope.pubkey,
recipientKey: recipientKey,
format: format,
maximumPlaintextBytes: maximumPrivateEnvelopeSealPlaintextBytes
)
let seal = try decodePrivateEnvelopeEventJSON(
sealJSON,
maximumBytes: maximumPrivateEnvelopeSealPlaintextBytes
)
guard seal.kind == format.sealKind.rawValue,
seal.tags.isEmpty,
seal.isValidSignature() else {
throw NostrError.invalidEvent
}
return seal
}
private static func openSeal(
seal: NostrEvent,
recipientKey: P256K.Schnorr.PrivateKey
) throws -> NostrEvent {
let decrypted = try decrypt(
let messageJSON = try decrypt(
ciphertext: seal.content,
senderPubkey: seal.pubkey,
recipientKey: recipientKey
recipientKey: recipientKey,
format: format,
maximumPlaintextBytes: maximumPrivateEnvelopePlaintextBytes
)
let message = try decodePrivateEnvelopeEventJSON(
messageJSON,
maximumBytes: maximumPrivateEnvelopePlaintextBytes
)
guard let data = decrypted.data(using: .utf8),
let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
// The inner message is intentionally unsigned; sender authentication
// comes from the seal. Bind its claimed sender and custom kind to that
// authenticated layer before exposing content.
guard message.kind == format.messageKind.rawValue,
validInnerMessageTags(
message.tags,
format: format,
recipientPubkey: recipientIdentity.publicKeyHex
),
message.sig == nil,
seal.pubkey == message.pubkey else {
throw NostrError.invalidEvent
}
return try NostrEvent(from: rumorDict)
return (seal, message)
}
// MARK: - Encryption (NIP-44 v2)
/// Released iOS legacy envelopes used no inner tags, while current
/// Android legacy envelopes use exactly the authenticated recipient tag.
/// Accept only those two historical shapes for kind 1059. The new kind
/// 1402 format remains strict and rejects every inner tag.
private static func validInnerMessageTags(
_ tags: [[String]],
format: PrivateEnvelopeWireFormat,
recipientPubkey: String
) -> Bool {
switch format {
case .bitchatV1:
return tags.isEmpty
case .legacyMislabelledV2:
return tags.isEmpty || tags == [["p", recipientPubkey]]
}
}
private static func decodePrivateEnvelopeEventJSON(
_ json: String,
maximumBytes: Int = maximumPrivateEnvelopePlaintextBytes
) throws -> NostrEvent {
// Check UTF-8 size before allocating Data or invoking the general JSON
// parser. `decrypt` enforces the same cap on authenticated bytes; this
// local guard keeps the parser boundary explicit and independently
// testable.
guard json.utf8.count <= maximumBytes else {
throw NostrError.invalidCiphertext
}
guard let data = json.data(using: .utf8),
let dictionary = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw NostrError.invalidEvent
}
return try NostrEvent(from: dictionary)
}
// MARK: - BitChat private-envelope encryption
private static func encrypt(
plaintext: String,
recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey
senderKey: P256K.Schnorr.PrivateKey,
format: PrivateEnvelopeWireFormat,
maximumPlaintextBytes: Int
) throws -> String {
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
throw NostrError.invalidPublicKey
}
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
// Derive shared secret
let sharedSecret = try deriveSharedSecret(
privateKey: senderKey,
publicKey: recipientPubkeyData
)
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
let key = try deriveNIP44V2Key(from: sharedSecret)
let key = derivePrivateEnvelopeKey(from: sharedSecret, format: format)
// 24-byte random nonce for XChaCha20-Poly1305
var nonce24 = Data(count: 24)
_ = nonce24.withUnsafeMutableBytes { ptr in
let randomStatus = nonce24.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
}
guard randomStatus == errSecSuccess else {
throw NostrError.cryptographicFailure
}
let pt = Data(plaintext.utf8)
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
let plaintextData = Data(plaintext.utf8)
guard plaintextData.count <= maximumPlaintextBytes else {
throw NostrError.invalidCiphertext
}
let sealed = try XChaCha20Poly1305Compat.seal(
plaintext: plaintextData,
key: key,
nonce24: nonce24
)
// v2: base64url(nonce24 || ciphertext || tag)
var combined = Data()
combined.append(nonce24)
combined.append(sealed.ciphertext)
combined.append(sealed.tag)
return "v2:" + Base64URLCoding.encode(combined)
return format.contentPrefix + Base64URLCoding.encode(combined)
}
private static func decrypt(
ciphertext: String,
senderPubkey: String,
recipientKey: P256K.Schnorr.PrivateKey
recipientKey: P256K.Schnorr.PrivateKey,
format: PrivateEnvelopeWireFormat,
maximumPlaintextBytes: Int
) throws -> String {
// Expect NIP-44 v2 format
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
let encoded = String(ciphertext.dropFirst(3))
guard ciphertext.utf8.count <= maximumPrivateEnvelopeCiphertextBytes,
ciphertext.hasPrefix(format.contentPrefix) else {
throw NostrError.invalidCiphertext
}
let encoded = String(ciphertext.dropFirst(format.contentPrefix.count))
guard let data = Base64URLCoding.decode(encoded),
data.count > (24 + 16),
let senderPubkeyData = Data(hexString: senderPubkey) else {
@@ -554,33 +769,40 @@ struct NostrProtocol {
let nonce24 = data.prefix(24)
let rest = data.dropFirst(24)
let tag = rest.suffix(16)
let ct = rest.dropLast(16)
let ciphertextBytes = rest.dropLast(16)
// Try decryption with even-Y then odd-Y when sender pubkey is x-only
func attemptDecrypt(using pubKeyData: Data) throws -> Data {
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
let key = try deriveNIP44V2Key(from: ss)
func attemptDecrypt(using publicKeyData: Data) throws -> Data {
let sharedSecret = try deriveSharedSecret(
privateKey: recipientKey,
publicKey: publicKeyData
)
let key = derivePrivateEnvelopeKey(from: sharedSecret, format: format)
return try XChaCha20Poly1305Compat.open(
ciphertext: Data(ct),
ciphertext: Data(ciphertextBytes),
tag: Data(tag),
key: key,
nonce24: Data(nonce24)
)
}
// If 32 bytes (x-only) try both parities, otherwise single try
let plaintext: Data
if senderPubkeyData.count == 32 {
let even = Data([0x02]) + senderPubkeyData
if let pt = try? attemptDecrypt(using: even) {
return String(data: pt, encoding: .utf8) ?? ""
let evenKey = Data([0x02]) + senderPubkeyData
if let opened = try? attemptDecrypt(using: evenKey) {
plaintext = opened
} else {
let oddKey = Data([0x03]) + senderPubkeyData
plaintext = try attemptDecrypt(using: oddKey)
}
let odd = Data([0x03]) + senderPubkeyData
let pt = try attemptDecrypt(using: odd)
return String(data: pt, encoding: .utf8) ?? ""
} else {
let pt = try attemptDecrypt(using: senderPubkeyData)
return String(data: pt, encoding: .utf8) ?? ""
plaintext = try attemptDecrypt(using: senderPubkeyData)
}
guard plaintext.count <= maximumPlaintextBytes,
let decoded = String(data: plaintext, encoding: .utf8) else {
throw NostrError.invalidCiphertext
}
return decoded
}
private static func deriveSharedSecret(
@@ -640,29 +862,17 @@ struct NostrProtocol {
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// ECDH shared secret derived
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
// Return raw ECDH shared secret; the wire-format-specific HKDF is
// applied by derivePrivateEnvelopeKey.
return sharedSecretData
}
private static func randomizedTimestamp() -> Date {
// Add random offset to current time for privacy
// This prevents timing correlation attacks while the actual message timestamp
// is preserved in the encrypted rumor
let offset = TimeInterval.random(in: -900...900) // +/- 15 minutes
let now = Date()
let randomized = now.addingTimeInterval(offset)
// Log with explicit UTC and local time for debugging
let formatter = DateFormatter()
//
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
formatter.timeZone = TimeZone(abbreviation: "UTC")
formatter.timeZone = TimeZone.current
// Timestamp randomized for privacy
return randomized
private static func randomizedPastTimestamp() -> Date {
// Keep public timestamps in the past: future-dated events are rejected
// by some relays. The actual message timestamp remains encrypted.
Date().addingTimeInterval(
-TimeInterval.random(in: 0...TransportConfig.nostrPrivateEnvelopeTimestampFuzzSeconds)
)
}
}
@@ -794,16 +1004,20 @@ enum NostrError: Error {
case invalidPublicKey
case invalidEvent
case invalidCiphertext
case cryptographicFailure
}
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
// MARK: - BitChat private-envelope key derivation
private extension NostrProtocol {
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
private static func derivePrivateEnvelopeKey(
from sharedSecretData: Data,
format: PrivateEnvelopeWireFormat
) -> Data {
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: Data(),
info: Data("nip44-v2".utf8),
salt: format.hkdfSalt,
info: format.hkdfInfo,
outputByteCount: 32
)
return derivedKey.withUnsafeBytes { Data($0) }
File diff suppressed because it is too large Load Diff
+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
}
}
+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
}
}
@@ -40,6 +40,9 @@ struct BLEFileTransferHandlerEnvironment {
let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool
/// Rolls back a saved payload when its durable receipt commit fails.
let removeIncomingFile: (_ storedURL: URL) -> Void
/// Releases the allocator's save-to-UI ownership guard after synchronous
/// conversation insertion has completed.
let finishIncomingFileDelivery: (_ 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).
@@ -49,11 +52,14 @@ struct BLEFileTransferHandlerEnvironment {
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.
/// The completion authorizes the stable-media ACK. Finalization runs after
/// every delivery attempt, including rejection, so allocator ownership
/// cannot leak indefinitely.
let deliverMessage: (
_ message: BitchatMessage,
_ shouldDeliver: @escaping () -> Bool,
_ completion: @escaping () -> Void
_ completion: @escaping () -> Void,
_ finalization: @escaping (TransportEventDeliveryOutcome) -> Void
) -> Void
}
@@ -295,10 +301,8 @@ final class BLEFileTransferHandler {
)
return true
case .unavailable:
// Never turn an unreadable ledger into an empty ledger. A
// directory-level failure clears on retry; a quarantined
// record keeps exactly this ID fail-closed while every other
// payload still flows.
// 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
@@ -359,7 +363,24 @@ final class BLEFileTransferHandler {
env: env
)
} else {
env.deliverMessage(message, { true }, {})
env.deliverMessage(
message,
{ true },
{},
{ outcome in
if outcome == .rejected {
// Raw media has no durable receipt that can redeliver
// it later. Do not leave a newly saved, UI-unowned file
// available for a stale fallback path to misidentify.
env.removeIncomingFile(destination)
} else {
// Plain delegates are invoked without synchronous
// insertion confirmation. Preserve the payload for
// that supported delivery path.
env.finishIncomingFileDelivery(destination)
}
}
)
}
return true
}
@@ -383,6 +404,9 @@ final class BLEFileTransferHandler {
},
{
env.acknowledgePrivateMedia(messageID, peerID)
},
{ _ in
env.finishIncomingFileDelivery(expectedURL)
}
)
}
+160 -26
View File
@@ -93,7 +93,7 @@ struct PanicRecoveryOperations {
}
}
struct BLEIncomingFileStore {
struct BLEIncomingFileStore: @unchecked Sendable {
enum PanicRecoveryError: Error {
case externalMarkerCommitFailed
case markerWriteFailed(Error)
@@ -103,7 +103,17 @@ struct BLEIncomingFileStore {
)
}
private static let quotaBytes: Int64 = 100 * 1024 * 1024
struct PrivateMediaDeletionReservation: Sendable {
fileprivate let id: UUID
}
private final class PayloadCoordination: @unchecked Sendable {
let lock = NSLock()
var pendingDeliveryPaths: Set<String> = []
var deletionReservations: [UUID: Set<String>] = [:]
}
private static let defaultQuotaBytes: Int64 = 100 * 1024 * 1024
/// Kept outside `files/` so deleting the media tree cannot erase the
/// fail-closed startup decision before the full panic has committed.
private static let panicRecoveryPendingMarkerFileName =
@@ -120,7 +130,6 @@ struct BLEIncomingFileStore {
"files/incoming",
"files/outgoing"
]
/// Name prefix of in-flight live voice captures (progressively written by
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern
/// deleting one mid-stream unlinks the inode under an open `FileHandle`
@@ -134,7 +143,9 @@ struct BLEIncomingFileStore {
private let baseDirectory: URL?
private let dateProvider: () -> Date
private let panicMarkerWriter: (Data, URL) throws -> Void
private let quotaBytes: Int64
private let privateMediaReceipts: BLEPrivateMediaReceiptStore
private let payloadCoordination: PayloadCoordination
init(
fileManager: FileManager = .default,
@@ -142,17 +153,20 @@ struct BLEIncomingFileStore {
dateProvider: @escaping () -> Date = Date.init,
panicMarkerWriter: @escaping (Data, URL) throws -> Void = {
try $0.write(to: $1, options: .atomic)
}
},
quotaBytes: Int64 = Self.defaultQuotaBytes
) {
self.fileManager = fileManager
self.baseDirectory = baseDirectory
self.dateProvider = dateProvider
self.panicMarkerWriter = panicMarkerWriter
self.quotaBytes = max(0, quotaBytes)
self.privateMediaReceipts = BLEPrivateMediaReceiptStore(
fileManager: fileManager,
baseDirectory: baseDirectory,
now: dateProvider
)
self.payloadCoordination = PayloadCoordination()
}
/// Panic-wipe every managed incoming and outgoing media artifact before
@@ -165,10 +179,21 @@ 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() }
// The receipt index caches tombstones as well as accepted payloads,
// while payload coordination retains save/delete reservations. Always
// invalidate both on return, including partial-failure paths, so no
// pre-panic receiver decision survives after identity reset.
defer {
privateMediaReceipts.resetForPanic()
payloadCoordination.lock.lock()
payloadCoordination.pendingDeliveryPaths.removeAll(
keepingCapacity: false
)
payloadCoordination.deletionReservations.removeAll(
keepingCapacity: false
)
payloadCoordination.lock.unlock()
}
let markerError: Error?
do {
@@ -251,6 +276,9 @@ struct BLEIncomingFileStore {
fallbackExtension: String?,
defaultPrefix: String
) -> URL? {
payloadCoordination.lock.lock()
defer { payloadCoordination.lock.unlock() }
do {
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
@@ -259,8 +287,26 @@ struct BLEIncomingFileStore {
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
fallbackExtension: fallbackExtension
)
let destination = uniqueFileURL(in: base, fileName: sanitized)
let reservedPaths = privateMediaReceipts.reservedPayloadPaths()
let deletionPaths = payloadCoordination
.deletionReservations.values.reduce(into: Set<String>()) {
$0.formUnion($1)
}
let allocationReservations = deletionPaths.union(
payloadCoordination.pendingDeliveryPaths
)
let destination = uniqueFileURL(
in: base,
fileName: sanitized,
reservedPaths: (reservedPaths ?? []).union(
allocationReservations
),
forceRandomizedName: reservedPaths == nil
)
try data.write(to: destination, options: .atomic)
payloadCoordination.pendingDeliveryPaths.insert(
destination.standardizedFileURL.path
)
return destination
} catch {
SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session)
@@ -268,17 +314,6 @@ struct BLEIncomingFileStore {
}
}
/// Drops THIS instance's in-memory receipt index after a panic wipe.
///
/// `panicWipe` already resets the receipt store it runs on, but the
/// production wipe runs on the `PanicRecoveryOperations.live()` file
/// store while receipt lookups are served by `BLEService`'s own
/// `incomingFileStore`. The service's panic path must invalidate its own
/// cache explicitly or pre-panic decisions survive in memory.
func resetPrivateMediaReceiptsForPanic() {
privateMediaReceipts.resetForPanic()
}
func privateMediaReceiptState(
messageID: String
) -> BLEPrivateMediaReceiptState {
@@ -295,8 +330,75 @@ struct BLEIncomingFileStore {
)
}
/// Reserves every receipt/UI path before the asynchronous deletion
/// barrier. Allocation and reservation share one lock, so either an
/// in-flight raw arrival is observed and deletion fails closed, or the
/// arrival is forced onto a different filename.
func reservePrivateMediaDeletion(
messageIDs: [String],
payloadRelativePaths: [String: String]
) -> PrivateMediaDeletionReservation? {
payloadCoordination.lock.lock()
defer { payloadCoordination.lock.unlock() }
guard let paths = privateMediaReceipts
.prospectiveDeletionPayloadPaths(
messageIDs: messageIDs,
payloadRelativePaths: payloadRelativePaths
),
paths.isDisjoint(
with: payloadCoordination.pendingDeliveryPaths
) else {
return nil
}
let reservation = PrivateMediaDeletionReservation(id: UUID())
payloadCoordination.deletionReservations[reservation.id] = paths
return reservation
}
func commitPrivateMediaDeletion(
reservation: PrivateMediaDeletionReservation,
messageIDs: [String],
payloadRelativePaths: [String: String],
protectedPayloadRelativePaths: Set<String>
) -> Bool {
payloadCoordination.lock.lock()
defer {
payloadCoordination.deletionReservations.removeValue(
forKey: reservation.id
)
payloadCoordination.lock.unlock()
}
guard payloadCoordination.deletionReservations[reservation.id] != nil
else {
return false
}
return privateMediaReceipts.recordDeleted(
messageIDs: messageIDs,
payloadRelativePaths: payloadRelativePaths,
protectedPayloadRelativePaths: protectedPayloadRelativePaths
)
}
/// Releases the short window between disk save and synchronous
/// conversation insertion. Before this callback, a deletion transaction
/// may not infer ownership from a stale bubble that names the same path.
func finishIncomingFileDelivery(at storedURL: URL) {
payloadCoordination.lock.lock()
defer { payloadCoordination.lock.unlock() }
payloadCoordination.pendingDeliveryPaths.remove(
storedURL.standardizedFileURL.path
)
}
/// Best-effort rollback for a payload whose durable receipt commit failed.
func removeIncomingFile(at storedURL: URL) {
payloadCoordination.lock.lock()
defer { payloadCoordination.lock.unlock() }
payloadCoordination.pendingDeliveryPaths.remove(
storedURL.standardizedFileURL.path
)
guard isURLInsideFilesDirectory(storedURL) else { return }
do {
try fileManager.removeItem(at: storedURL)
@@ -314,6 +416,9 @@ struct BLEIncomingFileStore {
/// a finalized transfer can arrive at quota while a burst is still
/// streaming but they still count toward usage.
func enforceQuota(reservingBytes: Int) {
payloadCoordination.lock.lock()
defer { payloadCoordination.lock.unlock() }
do {
let base = try filesDirectory()
let incomingDirs = [
@@ -339,14 +444,26 @@ struct BLEIncomingFileStore {
}
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
let targetUsage = Self.quotaBytes - Int64(reservingBytes)
let targetUsage = quotaBytes - Int64(reservingBytes)
guard currentUsage > targetUsage else { return }
let needToFree = currentUsage - targetUsage
let activeDeletionPaths = payloadCoordination
.deletionReservations.values.reduce(into: Set<String>()) {
$0.formUnion($1)
}
let protectedPaths = activeDeletionPaths.union(
payloadCoordination.pendingDeliveryPaths
)
var freedSpace: Int64 = 0
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
guard freedSpace < needToFree else { break }
guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
guard !protectedPaths.contains(
file.url.standardizedFileURL.path
) else {
continue
}
do {
try fileManager.removeItem(at: file.url)
freedSpace += file.size
@@ -434,11 +551,20 @@ struct BLEIncomingFileStore {
return candidate.isEmpty ? defaultName : candidate
}
private func uniqueFileURL(in directory: URL, fileName: String) -> URL {
private func uniqueFileURL(
in directory: URL,
fileName: String,
reservedPaths: Set<String>,
forceRandomizedName: Bool
) -> URL {
let directoryPath = directory.standardizedFileURL.path
func isInsideDirectory(_ url: URL) -> Bool {
url.standardizedFileURL.path.hasPrefix(directoryPath + "/")
}
func isAvailable(_ url: URL) -> Bool {
!reservedPaths.contains(url.standardizedFileURL.path)
&& !fileManager.fileExists(atPath: url.path)
}
var candidate = directory.appendingPathComponent(fileName)
guard isInsideDirectory(candidate) else {
@@ -446,19 +572,27 @@ struct BLEIncomingFileStore {
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
}
if !fileManager.fileExists(atPath: candidate.path) {
let baseName = (fileName as NSString).deletingPathExtension
let ext = (fileName as NSString).pathExtension
if forceRandomizedName {
let suffix = UUID().uuidString
let randomizedName = ext.isEmpty
? "\(baseName)_\(suffix)"
: "\(baseName)_\(suffix).\(ext)"
return directory.appendingPathComponent(randomizedName)
}
if isAvailable(candidate) {
return candidate
}
let baseName = (fileName as NSString).deletingPathExtension
let ext = (fileName as NSString).pathExtension
for counter in 1..<100 {
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
candidate = directory.appendingPathComponent(newName)
guard isInsideDirectory(candidate) else {
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
}
if !fileManager.fileExists(atPath: candidate.path) {
if isAvailable(candidate) {
return candidate
}
}
@@ -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
)
}
}
}
+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 ?? [],
File diff suppressed because it is too large Load Diff
+293 -210
View File
@@ -315,10 +315,6 @@ final class BLEService: NSObject {
/// 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)?
/// May block the convergence-recovery callback on its global-queue thread
/// before it enqueues onto `messageQueue`. Tests use this boundary to
/// force the quarantine-restore handler to win the dispatch race.
var _test_beforeHandshakeRecoveryEnqueued: ((PeerID) -> Void)?
#endif
private var selfBroadcastTracker = BLESelfBroadcastTracker()
private let meshTopology = MeshTopologyTracker()
@@ -362,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)
@@ -391,13 +387,12 @@ final class BLEService: NSObject {
// MARK: - Identity
private var noiseService: NoiseEncryptionService
/// Injected so tests can compress the quarantine/rollback window;
/// production always passes the security-constant default.
private let noiseResponderHandshakeTimeout: TimeInterval
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.
@@ -510,20 +505,14 @@ final class BLEService: NSObject {
identityManager: SecureIdentityStateManagerProtocol,
initializeBluetoothManagers: Bool = true,
incomingFileStore: BLEIncomingFileStore = BLEIncomingFileStore(),
startSuspendedForPanicRecovery: Bool = false,
noiseResponderHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
startSuspendedForPanicRecovery: Bool = false
) {
self.keychain = keychain
self.idBridge = idBridge
self.incomingFileStore = incomingFileStore
self.shouldInitializeBluetoothManagers = initializeBluetoothManagers
self._isPanicSuspended = startSuspendedForPanicRecovery
self.noiseResponderHandshakeTimeout = noiseResponderHandshakeTimeout
noiseService = NoiseEncryptionService(
keychain: keychain,
ordinaryResponderHandshakeTimeout: noiseResponderHandshakeTimeout
)
noiseService = NoiseEncryptionService(keychain: keychain)
self.identityManager = identityManager
super.init()
@@ -739,12 +728,6 @@ final class BLEService: NSObject {
/// Reopen the radio only after media deletion and recovery-marker commit.
func completePanicReset(restartServices: Bool) {
// The media wipe ran on the recovery operations' own file store; this
// service's store still caches pre-panic receipt decisions (and a
// callback drained during suspension may have re-read the pre-wipe
// ledger). Drop the cache before admission reopens so the next lookup
// rebuilds from the wiped directory.
incomingFileStore.resetPrivateMediaReceiptsForPanic()
setPanicSuspended(false)
guard restartServices else { return }
startServices()
@@ -757,14 +740,8 @@ final class BLEService: NSObject {
) {
gossipSyncManager?.stop()
gossipSyncManager = nil
// Discard deferred pre-panic ciphertext behind any in-flight receive
// handlers so none can repopulate the handler's bounded queue.
messageQueue.sync(flags: .barrier) {
noisePacketHandler.resetForPanic()
}
// pendingNoiseSessionQueues is owned by collectionsQueue everywhere
// else, so clear it there too rather than on messageQueue.
collectionsQueue.sync(flags: .barrier) {
pendingNoiseSessionQueues.removeAll()
}
@@ -812,19 +789,14 @@ final class BLEService: NSObject {
noiseService.clearEphemeralStateForPanic()
noiseService.clearPersistentIdentity()
let newNoise = NoiseEncryptionService(
keychain: keychain,
ordinaryResponderHandshakeTimeout: noiseResponderHandshakeTimeout
)
let newNoise = NoiseEncryptionService(keychain: keychain)
noiseService = newNoise
configureNoiseServiceCallbacks(for: newNoise)
refreshPeerIdentity()
}
// 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()
@@ -903,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)
}
@@ -971,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(),
@@ -1145,6 +1119,29 @@ final class BLEService: NSObject {
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
}
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID? {
let normalizedPeerID = peerID.toShort()
let currentNoiseGeneration =
noiseService.sessionGeneration(for: normalizedPeerID)
return collectionsQueue.sync {
guard let generation =
privateMediaSessionGenerations[normalizedPeerID],
generation == currentNoiseGeneration,
let authenticated =
authenticatedPeerStates[normalizedPeerID],
authenticated.sessionGeneration == generation,
authenticated.capabilities.contains(.privateMedia),
authenticated.capabilities.contains(
.privateMediaReceipts
) else {
return nil
}
return generation
}
}
private func privateMediaPolicyFingerprint(
for peerID: PeerID,
expectedSessionGeneration: UUID?
@@ -1629,6 +1626,36 @@ final class BLEService: NSObject {
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool
) {
sendFilePrivate(
filePacket,
to: peerID,
transferId: transferId,
allowLegacyFallback: allowLegacyFallback,
requiresAuthenticatedPrivateMediaReceipts: false
)
}
func sendFilePrivateReceiptRetry(
_ filePacket: BitchatFilePacket,
to peerID: PeerID,
transferId: String
) {
sendFilePrivate(
filePacket,
to: peerID,
transferId: transferId,
allowLegacyFallback: false,
requiresAuthenticatedPrivateMediaReceipts: true
)
}
private func sendFilePrivate(
_ filePacket: BitchatFilePacket,
to peerID: PeerID,
transferId: String,
allowLegacyFallback: Bool,
requiresAuthenticatedPrivateMediaReceipts: Bool
) {
// Register before enqueueing onto messageQueue. This closes the window
// where cancel/delete could run first, observe no scheduler state, and
@@ -1741,6 +1768,25 @@ final class BLEService: NSObject {
self.privateMediaTransferAdmissions.finish(transferId)
return
}
if requiresAuthenticatedPrivateMediaReceipts,
self.authenticatedPrivateMediaReceiptSessionGeneration(
to: targetID
) == nil {
SecureLogger.warning(
"Private media retry blocked without current authenticated receipt support for \(targetID.id.prefix(8))",
category: .security
)
TransferProgressManager.shared.rejectBeforeStart(
id: transferId,
reason: String(
localized: "content.delivery.reason.private_media_capability_unresolved",
defaultValue: "Could not confirm encrypted media support",
comment: "Failure reason when private-media capability negotiation did not resolve"
)
)
self.privateMediaTransferAdmissions.finish(transferId)
return
}
guard let typedPayload = BLENoisePayloadFactory.privateFile(filePacket) else {
SecureLogger.error("❌ Failed to encode file packet for private send", category: .session)
TransferProgressManager.shared.rejectBeforeStart(
@@ -1751,6 +1797,21 @@ final class BLEService: NSObject {
return
}
guard self.noiseService.hasEstablishedSession(with: targetID) else {
if requiresAuthenticatedPrivateMediaReceipts {
// A retry belongs to one exact authenticated generation.
// Never let it enter the ordinary pending queue where a
// bit-8-only replacement session could later flush it.
TransferProgressManager.shared.rejectBeforeStart(
id: transferId,
reason: String(
localized: "content.delivery.reason.private_media_capability_unresolved",
defaultValue: "Could not confirm encrypted media support",
comment: "Failure reason when private-media capability negotiation did not resolve"
)
)
self.privateMediaTransferAdmissions.finish(transferId)
return
}
let queued = self.collectionsQueue.sync(flags: .barrier) {
self.privateMediaTransferAdmissions.withActive(transferId) {
self.pendingNoiseSessionQueues.appendTypedPayload(
@@ -1782,7 +1843,12 @@ final class BLEService: NSObject {
self.privateMediaTransferAdmissions.finish(transferId)
return
}
let packet = try self.makeEncryptedNoisePacket(typedPayload, to: targetID)
let packet = try self.makeEncryptedNoisePacket(
typedPayload,
to: targetID,
requiresAuthenticatedPrivateMediaReceipts:
requiresAuthenticatedPrivateMediaReceipts
)
guard self.privateMediaTransferAdmissions.isActive(transferId) else {
self.privateMediaTransferAdmissions.finish(transferId)
return
@@ -2016,26 +2082,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,
@@ -2605,6 +2660,18 @@ final class BLEService: NSObject {
removeIncomingFile: { [weak self] storedURL in
self?.incomingFileStore.removeIncomingFile(at: storedURL)
},
finishIncomingFileDelivery: { [weak self] storedURL in
// Serialize pending-owner release behind deletion barriers.
// If /clear snapshots before this UI insertion, its already
// queued barrier must still observe the path as pending. If
// insertion wins first, the next MainActor snapshot sees the
// new bubble and protects the path explicitly.
self?.messageQueue.async(flags: .barrier) {
self?.incomingFileStore.finishIncomingFileDelivery(
at: storedURL
)
}
},
isPrivateMediaSenderBlocked: { [weak self] peerID in
guard let self else { return false }
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID)
@@ -2629,11 +2696,12 @@ final class BLEService: NSObject {
}
self.sendDeliveryAck(for: messageID, to: peerID)
},
deliverMessage: { [weak self] message, shouldDeliver, completion in
deliverMessage: { [weak self] message, shouldDeliver, completion, finalization in
self?.emitTransportEvent(
.messageReceived(message),
shouldDeliver: shouldDeliver,
completion: completion
completion: completion,
finalization: finalization
)
}
)
@@ -2754,19 +2822,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()) {
@@ -2787,9 +2842,8 @@ final class BLEService: NSObject {
)
}
let localIdentity = localIdentityState.snapshot()
let announcement = AnnouncementPacket(
nickname: localIdentity.nickname,
nickname: myNickname,
noisePublicKey: noisePub,
signingPublicKey: signingPub,
directNeighbors: connectedPeerIDs,
@@ -2805,7 +2859,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,
@@ -2819,7 +2873,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)
@@ -3419,15 +3480,6 @@ extension BLEService {
capturePanicLifecycleGeneration() != nil
}
/// Queries the receipt store of the service's OWN incoming-file store
/// the instance production lookups run against so panic tests exercise
/// the real wiring instead of a same-instance shortcut.
func _test_privateMediaReceiptState(
messageID: String
) -> BLEPrivateMediaReceiptState {
incomingFileStore.privateMediaReceiptState(messageID: messageID)
}
/// Models a CoreBluetooth delegate callback without requiring a physical
/// peripheral. The callback itself runs on `bleQueue`, exactly where the
/// panic radio-stop barrier must linearize it.
@@ -3440,6 +3492,18 @@ extension BLEService {
}
}
func _test_emitTransportEvent(
_ event: TransportEvent,
completion: @escaping () -> Void,
finalization: @escaping (TransportEventDeliveryOutcome) -> Void
) {
emitTransportEvent(
event,
completion: completion,
finalization: finalization
)
}
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: PeerID, preseedPeer: Bool = true, signingPublicKey: Data? = nil) {
if preseedPeer {
// Ensure the synthetic peer is known and marked verified for public-message tests
@@ -3558,20 +3622,6 @@ extension BLEService {
try noiseService.processHandshakeMessage(from: peerID, message: message)
}
func _test_enqueuePendingPrivateMessage(
content: String,
messageID: String,
for peerID: PeerID
) {
collectionsQueue.sync(flags: .barrier) {
pendingNoiseSessionQueues.appendPrivateMessage(
content: content,
messageID: messageID,
for: peerID
)
}
}
func _test_enqueuePendingNoisePayload(
_ payload: Data,
transferId: String,
@@ -4359,8 +4409,87 @@ extension BLEService {
// No alias rotation or advertising restarts required.
}
// MARK: - Private Media Deletion
extension BLEService: PrivateMediaDeletionPersisting {
@MainActor
func persistDeletedPrivateMedia(
messageIDs: [String],
payloadRelativePaths: [String: String],
protectedPayloadRelativePaths: Set<String>,
completion: @escaping @MainActor (Bool) -> Void
) {
let fileStore = incomingFileStore
messageQueue.async(flags: .barrier) {
guard let reservation = fileStore
.reservePrivateMediaDeletion(
messageIDs: messageIDs,
payloadRelativePaths: payloadRelativePaths
) else {
Task { @MainActor in
completion(false)
}
return
}
let persisted = fileStore
.commitPrivateMediaDeletion(
reservation: reservation,
messageIDs: messageIDs,
payloadRelativePaths: payloadRelativePaths,
protectedPayloadRelativePaths:
protectedPayloadRelativePaths
)
Task { @MainActor in
completion(persisted)
}
}
}
}
// MARK: - Private Helpers
enum TransportEventDeliveryOutcome: Equatable {
/// A synchronous sink inserted the message and revalidation succeeded.
case accepted
/// A supported plain delegate was invoked, but insertion cannot be
/// confirmed synchronously.
case invokedUnconfirmed
/// No sink accepted the event, or receipt revalidation rejected it.
case rejected
}
enum TransportEventDeliveryGate {
/// Runs finalization exactly once for every attempted main-actor delivery,
/// including pre-insertion rejection, a missing/rejecting sink, and
/// post-insertion revalidation failure. Only a fully accepted delivery
/// runs `completion` (for example, a stable-media ACK).
@MainActor
static func attempt(
shouldDeliver: () -> Bool,
deliver: () -> TransportEventDeliveryOutcome,
completion: () -> Void,
finalization: (TransportEventDeliveryOutcome) -> Void
) {
var outcome = TransportEventDeliveryOutcome.rejected
defer { finalization(outcome) }
guard shouldDeliver() else {
return
}
switch deliver() {
case .rejected:
return
case .invokedUnconfirmed:
outcome = .invokedUnconfirmed
return
case .accepted:
break
}
guard shouldDeliver() else { return }
outcome = .accepted
completion()
}
}
extension BLEService {
/// Notify UI on the MainActor to satisfy Swift concurrency isolation
@@ -4384,66 +4513,71 @@ extension BLEService {
private func emitTransportEvent(
_ event: TransportEvent,
shouldDeliver: (() -> Bool)? = nil,
completion: (() -> Void)? = nil
completion: (() -> Void)? = nil,
finalization: ((TransportEventDeliveryOutcome) -> Void)? = nil
) {
notifyUI { [weak self] in
guard let generation = capturePanicLifecycleGeneration() else {
Task { @MainActor in
finalization?(.rejected)
}
return
}
Task { @MainActor [weak self] in
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 {
self.isCurrentPanicLifecycleGeneration(generation) else {
finalization?(.rejected)
return
}
completion?()
TransportEventDeliveryGate.attempt(
shouldDeliver: {
self.isCurrentPanicLifecycleGeneration(generation)
&& (shouldDeliver?() ?? true)
},
deliver: {
return self.deliverTransportEvent(event)
},
completion: { completion?() },
finalization: { finalization?($0) }
)
}
}
/// Delivers a transport event to the installed delegates and reports
/// whether acceptance was confirmed.
///
/// For `.messageReceived`, returns `true` only when a
/// `SynchronousMessageTransportEventDelegate` synchronously confirmed
/// acceptance of the message (duplicates count as accepted). Returns
/// `false` when acceptance cannot be confirmed: the sink blocked the
/// message, the content was empty, or only a non-synchronous delegate is
/// installed so delivery happens without confirmation. Downstream logic
/// MUST NOT treat `false` as safe to acknowledge a `false` return
/// means do not ACK.
///
/// For all other events, returns `true` when any delegate received the
/// event and `false` when no delegate is installed.
@MainActor
@discardableResult
private func deliverTransportEvent(_ event: TransportEvent) -> Bool {
private func deliverTransportEvent(
_ event: TransportEvent
) -> TransportEventDeliveryOutcome {
if case .messageReceived(let message) = event {
if let synchronousDelegate =
eventDelegate as? SynchronousMessageTransportEventDelegate {
return synchronousDelegate
.didReceiveTransportMessageSynchronously(message)
? .accepted
: .rejected
}
if let eventDelegate {
eventDelegate.didReceiveTransportEvent(event)
return false
return .invokedUnconfirmed
}
if let synchronousDelegate =
delegate as? SynchronousMessageTransportEventDelegate {
return synchronousDelegate
.didReceiveTransportMessageSynchronously(message)
? .accepted
: .rejected
}
}
if let eventDelegate {
eventDelegate.didReceiveTransportEvent(event)
return true
return .accepted
} else {
guard let delegate else { return false }
guard let delegate else { return .rejected }
delegate.receiveTransportEvent(event)
if case .messageReceived = event {
return false
return .invokedUnconfirmed
}
return true
return .accepted
}
}
@@ -4893,9 +5027,6 @@ extension BLEService {
service.onHandshakeRecoveryRequired = {
[weak self, weak service] request in
guard let self, let service else { return }
#if DEBUG
self._test_beforeHandshakeRecoveryEnqueued?(request.peerID)
#endif
self.messageQueue.async(flags: .barrier) {
[weak self, weak service] in
guard let self,
@@ -4939,7 +5070,7 @@ extension BLEService {
}
}
}
service.onSessionRestoredWithGeneration = { [weak self, weak service] peerID, generation, reason in
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.
@@ -4955,19 +5086,12 @@ extension BLEService {
category: .session
)
// Re-enter the same generation-bound transition used after a
// successful handshake to restore authenticated protocol
// state. Only a terminal restore may also drain the PM and
// typed-payload queues: after a responder timeout the
// counterpart may have completed the replacement handshake
// and discarded the restored keys, so encrypting the queues
// under them would lose every message silently. The mandatory
// convergence retry that accompanies the restore drains them
// under the new session instead (any establishment does).
// successful handshake. This restores authenticated protocol
// state and drains both PM and typed-payload queues.
self.handleNoisePeerAuthenticated(
peerID: peerID,
fingerprint: fingerprint,
sessionGeneration: generation,
deferOutboundUntilConvergence: reason == .pendingConvergence
sessionGeneration: generation
)
}
}
@@ -4976,8 +5100,7 @@ extension BLEService {
private func handleNoisePeerAuthenticated(
peerID: PeerID,
fingerprint: String,
sessionGeneration generation: UUID,
deferOutboundUntilConvergence: Bool = false
sessionGeneration generation: UUID
) {
let normalizedPeerID = peerID.toShort()
guard let transition = noiseService.withCurrentSessionGeneration(
@@ -5023,23 +5146,8 @@ extension BLEService {
// 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. Retrying the bounded
// early-ciphertext queue is receive-side and therefore always
// safe under the restored keys.
// needs one idempotent ready transition.
noisePacketHandler.handleSessionAuthenticated(normalizedPeerID)
#if DEBUG
_test_onPrivateMediaSessionReconciled?(normalizedPeerID)
#endif
if deferOutboundUntilConvergence {
// Timeout-restore: the counterpart may have completed the
// replacement handshake and discarded these keys, so
// encrypting the parked queues here would lose them silently.
// The restore's mandatory convergence retry or any later
// handshake the reconnect policy initiates re-enters this
// transition with a fresh generation and drains them under
// keys both sides hold.
return
}
sendPendingMessagesAfterHandshake(for: normalizedPeerID)
sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID)
return
@@ -5057,21 +5165,6 @@ extension BLEService {
// after this generation's transport state has been fully installed.
noisePacketHandler.handleSessionAuthenticated(normalizedPeerID)
if deferOutboundUntilConvergence {
// Timeout-restore: the session is back for receive purposes and
// the generation-bound protocol state above is rebuilt, but the
// counterpart may already hold replacement keys that discarded
// this generation's. Encrypting the pending queues here would
// lose them silently, so leave them parked: the restore's
// mandatory convergence retry or any later handshake the
// reconnect policy initiates re-enters this transition with a
// fresh generation and drains them under keys both sides hold.
#if DEBUG
_test_onPrivateMediaSessionReconciled?(normalizedPeerID)
#endif
return
}
// `onPeerAuthenticated` can fire while the initiator is returning XX
// message 3. This callback is queued behind the handshake handler, so
// message 3 is broadcast first. Both peers also send one idempotent
@@ -5245,9 +5338,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 {
@@ -5287,7 +5379,11 @@ extension BLEService {
}
}
private func makeEncryptedNoisePacket(_ typedPayload: Data, to peerID: PeerID) throws -> BitchatPacket {
private func makeEncryptedNoisePacket(
_ typedPayload: Data,
to peerID: PeerID,
requiresAuthenticatedPrivateMediaReceipts: Bool = false
) throws -> BitchatPacket {
let encrypted: Data
let isPrivateFile = NoisePayloadType.isPrivateFile(rawValue: typedPayload.first)
if isPrivateFile {
@@ -5297,6 +5393,13 @@ extension BLEService {
let authenticated = authenticatedPeerStates[peerID],
authenticated.sessionGeneration == generation,
authenticated.capabilities.contains(.privateMedia) else { return nil }
if requiresAuthenticatedPrivateMediaReceipts {
guard authenticated.capabilities.contains(
.privateMediaReceipts
) else {
return nil
}
}
return generation
}
guard let provenGeneration else {
@@ -7200,21 +7303,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(
@@ -7251,24 +7342,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.
+1 -1
View File
@@ -322,7 +322,7 @@ final class CourierStore {
/// Envelopes eligible to park on relays as bridge courier drops. Merely
/// offering one does not start its cooldown: the caller commits that only
/// after a relay explicitly accepts the event via NIP-20 OK.
/// after a relay explicitly accepts the event via NIP-01 `OK`.
func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
let date = now()
return queue.sync {
@@ -67,7 +67,7 @@ final class BridgeCourierService: ObservableObject {
var relaysConnected: (@MainActor () -> Bool)?
/// Publishes a signed drop event directly to connected default (DM)
/// relays. Completion is true only after at least one relay explicitly
/// accepts the event via NIP-20 OK; this must never mean "queued in RAM"
/// accepts the event via NIP-01 `OK`; this must never mean "queued in RAM"
/// or merely "written to a socket".
var publishEvent: (@MainActor (NostrEvent, @escaping @MainActor (Bool) -> Void) -> Void)?
/// (Re)opens the drop subscription for the given hex tags.
@@ -129,7 +129,7 @@ final class BridgeCourierService: ObservableObject {
/// a newer attempt for the same message.
private var activeDropOperations: [String: ActiveDropOperation] = [:]
/// Held-envelope publishes have no sender message ID, but still need an
/// in-flight identity: repeated refreshes inside the NIP-20 wait window
/// in-flight identity: repeated refreshes inside the relay-OK wait window
/// must not mint duplicate relay events for the same opaque envelope.
private var heldDropOperations: [Data: UUID] = [:]
/// Deterministically invalid envelopes are suppressed for this process,
@@ -275,7 +275,7 @@ final class BridgeCourierService: ObservableObject {
/// Publishes a drop, or queues it when relays are down. `messageID` is the
/// sender-side dedup key (nil for held/relayed envelopes we don't track);
/// it rides the pending queue so an evicted or failed drop can release its
/// in-flight slot. Completion reports actual NIP-20 relay acceptance.
/// in-flight slot. Completion reports actual NIP-01 relay acceptance.
private func publishDrop(
_ envelope: CourierEnvelope,
messageID: String? = nil,
@@ -172,11 +172,11 @@ final class MessageDeduplicationService {
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
private let nostrAckCache: LRUDeduplicationCache<Bool>
/// Optional cross-launch persistence for the Nostr event cache. NIP-59
/// randomizes gift-wrap timestamps, so DM subscriptions look back 24h and
/// relays redeliver the same events on every launch; without this record
/// each relaunch reprocesses old PMs and acks. Nil (tests, macOS callers
/// that don't opt in) keeps the cache purely in-memory.
/// Optional cross-launch persistence for the Nostr event cache. BitChat
/// randomizes private-envelope timestamps, so DM subscriptions look back
/// 24h and relays redeliver the same events on every launch; without this
/// record each relaunch reprocesses old PMs and acks. Nil (tests, macOS
/// callers that don't opt in) keeps the cache purely in-memory.
private let nostrEventStore: NostrProcessedEventStore?
private let nostrEventCapacity: Int
private var persistScheduled = false
@@ -314,7 +314,7 @@ final class MessageDeduplicationService {
// MARK: - Clear
/// Clears all caches. This is the wipe/panic path: the persisted
/// gift-wrap record goes with everything else.
/// private-envelope record goes with everything else.
func clearAll() {
contentCache.clear()
nostrEventCache.clear()
@@ -325,7 +325,7 @@ final class MessageDeduplicationService {
/// Clears only the in-memory Nostr caches (events and ACKs). Runs on
/// every geohash channel switch, so the disk record deliberately
/// survives wiping it here would forfeit cross-launch gift-wrap dedup
/// survives wiping it here would forfeit cross-launch private-envelope dedup
/// each time the user changes channels (flagged by Codex on #1398).
func clearNostrCaches() {
nostrEventCache.clear()
+10
View File
@@ -158,6 +158,16 @@ final class MessageRouter {
}
}
/// Wire one typed event sink to every route, including the queue-backed
/// Nostr transport. Keeping this at the router boundary prevents a relay
/// admission failure from disappearing merely because only the primary
/// mesh transport was assigned a UI delegate.
func setEventDelegate(_ delegate: TransportEventDelegate?) {
for transport in transports {
transport.eventDelegate = delegate
}
}
// MARK: - Transport Selection
private func reachableTransport(for peerID: PeerID) -> Transport? {
@@ -193,11 +193,8 @@ final class NoiseEncryptionService {
((_ request: NoiseHandshakeRecoveryRequest) -> Void)?
/// An unauthenticated reconnect attempt failed or timed out and the
/// receive-only rollback session became the active transport again.
/// Transport queues may only be drained for this exact restored
/// generation when the reason is terminal; a restore that owns a pending
/// convergence retry must keep them parked until the retry concludes.
var onSessionRestoredWithGeneration:
((_ peerID: PeerID, _ generation: UUID, _ reason: NoiseSessionRestoreReason) -> Void)?
/// 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) {
@@ -348,8 +345,8 @@ final class NoiseEncryptionService {
sessionGeneration: generation
)
}
sessionManager.onSessionRestored = { [weak self] peerID, generation, reason in
self?.onSessionRestoredWithGeneration?(peerID, generation, reason)
sessionManager.onSessionRestored = { [weak self] peerID, generation in
self?.onSessionRestoredWithGeneration?(peerID, generation)
}
sessionManager.onHandshakeRecoveryRequired = { [weak self] request in
self?.onHandshakeRecoveryRequired?(request)
@@ -9,10 +9,10 @@
import BitLogger
import Foundation
/// Disk persistence for processed gift-wrap event IDs. NIP-59 randomizes
/// gift-wrap timestamps, so DM subscriptions must look back generously (24h)
/// and relays redeliver the same events on every launch without a
/// cross-launch record, each relaunch reprocesses old PMs and acks
/// Disk persistence for processed private-envelope event IDs. BitChat
/// randomizes envelope timestamps, so DM subscriptions must look back
/// generously (24h) and relays redeliver the same events on every launch
/// without a cross-launch record, each relaunch reprocesses old PMs and acks
/// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise).
///
/// Contents are event IDs already visible to every relay, so
+418 -37
View File
@@ -11,8 +11,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
let favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
let currentIdentity: @MainActor () throws -> NostrIdentity?
let registerPendingGiftWrap: @MainActor (String) -> Void
let sendEvent: @MainActor (NostrEvent) -> Void
let registerPendingPrivateEnvelope: @MainActor (String) -> Void
let sendPrivateEnvelopeBatch: @MainActor (
[NostrEvent],
@escaping @MainActor () -> Void
) -> Bool
let envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue
/// Emits whether a relay that carries private messages is up
/// (fail-closed behind Tor). A connected geohash/custom relay alone
/// doesn't count: DM sends target the default relay set and would
@@ -22,25 +26,35 @@ final class NostrTransport: Transport, @unchecked Sendable {
/// serialize behind each other; `live` passes the process-wide one.
let ackPacer: AckPacer
@MainActor
init(
notificationCenter: NotificationCenter,
loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship],
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?,
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?,
currentIdentity: @escaping @MainActor () throws -> NostrIdentity?,
registerPendingGiftWrap: @escaping @MainActor (String) -> Void,
sendEvent: @escaping @MainActor (NostrEvent) -> Void,
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void,
sendPrivateEnvelopeBatch: @escaping @MainActor (
[NostrEvent],
@escaping @MainActor () -> Void
) -> Bool,
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void,
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never>,
ackPacer: AckPacer? = nil
ackPacer: AckPacer? = nil,
envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue? = nil
) {
self.notificationCenter = notificationCenter
self.loadFavorites = loadFavorites
self.favoriteStatusForNoiseKey = favoriteStatusForNoiseKey
self.favoriteStatusForPeerID = favoriteStatusForPeerID
self.currentIdentity = currentIdentity
self.registerPendingGiftWrap = registerPendingGiftWrap
self.sendEvent = sendEvent
self.registerPendingPrivateEnvelope = registerPendingPrivateEnvelope
self.sendPrivateEnvelopeBatch = sendPrivateEnvelopeBatch
self.envelopeRetryQueue = envelopeRetryQueue ?? NostrPrivateEnvelopeRetryQueue(
sendPrivateEnvelopeBatch: sendPrivateEnvelopeBatch,
registerPendingPrivateEnvelope: registerPendingPrivateEnvelope,
scheduleAfter: scheduleAfter
)
self.relayConnectivity = relayConnectivity
// Default pacer drives its throttle through the same injected
// scheduler, so tests that step scheduleAfter manually keep
@@ -56,13 +70,19 @@ final class NostrTransport: Transport, @unchecked Sendable {
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
currentIdentity: { try idBridge.getCurrentNostrIdentity() },
registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) },
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
registerPendingPrivateEnvelope: { NostrRelayManager.registerPendingPrivateEnvelope(id: $0) },
sendPrivateEnvelopeBatch: { events, terminalFailure in
NostrRelayManager.shared.sendPrivateEnvelopeBatch(
events,
terminalFailure: terminalFailure
)
},
scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
},
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() },
ackPacer: NostrTransport.sharedAckPacer
ackPacer: NostrTransport.sharedAckPacer,
envelopeRetryQueue: NostrTransport.sharedEnvelopeRetryQueue
)
}
}
@@ -128,7 +148,37 @@ final class NostrTransport: Transport, @unchecked Sendable {
}
}
static let sharedAckPacer = AckPacer()
// Geohash acknowledgements use short-lived NostrTransport instances, so
// the retry owner must be process-wide. A per-transport cap would still be
// globally unbounded under outage as throwaway instances accumulated.
@MainActor
private static let sharedEnvelopeRetryQueue = NostrPrivateEnvelopeRetryQueue(
sendPrivateEnvelopeBatch: { events, terminalFailure in
NostrRelayManager.shared.sendPrivateEnvelopeBatch(
events,
terminalFailure: terminalFailure
)
},
registerPendingPrivateEnvelope: {
NostrRelayManager.registerPendingPrivateEnvelope(id: $0)
},
scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
}
)
@MainActor
static func resetControlRetriesForPanicWipe() {
sharedEnvelopeRetryQueue.removeAll()
}
private enum PrivateEnvelopeFailurePolicy {
case userMessage(messageID: String)
case retry(retryKey: String)
}
private let dependencies: Dependencies
private let envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue
private var favoriteStatusObserver: NSObjectProtocol?
// Reachability Cache (thread-safe)
@@ -145,7 +195,9 @@ final class NostrTransport: Transport, @unchecked Sendable {
idBridge: NostrIdentityBridge,
dependencies: Dependencies? = nil
) {
self.dependencies = dependencies ?? .live(idBridge: idBridge)
let resolvedDependencies = dependencies ?? .live(idBridge: idBridge)
self.dependencies = resolvedDependencies
self.envelopeRetryQueue = resolvedDependencies.envelopeRetryQueue
setupObservers()
@@ -172,6 +224,18 @@ final class NostrTransport: Transport, @unchecked Sendable {
}
}
#if DEBUG
@MainActor
func debugEnqueueControlRetry(key: String, events: [NostrEvent]) {
envelopeRetryQueue.enqueue(key: key, events: events, registerPending: false)
}
@MainActor
var debugControlRetryCount: Int {
envelopeRetryQueue.debugPendingCount
}
#endif
private func setupObservers() {
favoriteStatusObserver = dependencies.notificationCenter.addObserver(
forName: .favoriteStatusChanged,
@@ -253,15 +317,49 @@ final class NostrTransport: Transport, @unchecked Sendable {
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID),
let recipientHex = npubToHex(recipientNpub),
let senderIdentity = try? dependencies.currentIdentity() else { return }
let failurePolicy = PrivateEnvelopeFailurePolicy.userMessage(
messageID: messageID
)
guard let recipientNpub = resolveRecipientNpub(for: peerID) else {
handlePrivateEnvelopeFailure(
events: [],
registerPending: false,
policy: failurePolicy
)
return
}
guard let recipientHex = npubToHex(recipientNpub) else {
handlePrivateEnvelopeFailure(
events: [],
registerPending: false,
policy: failurePolicy
)
return
}
guard let senderIdentity = try? dependencies.currentIdentity() else {
handlePrivateEnvelopeFailure(
events: [],
registerPending: false,
policy: failurePolicy
)
return
}
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
handlePrivateEnvelopeFailure(
events: [],
registerPending: false,
policy: failurePolicy
)
return
}
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
sendPrivateEnvelope(
content: embedded,
recipientHex: recipientHex,
senderIdentity: senderIdentity,
failurePolicy: failurePolicy
)
}
}
@@ -287,7 +385,14 @@ final class NostrTransport: Transport, @unchecked Sendable {
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
return
}
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
sendPrivateEnvelope(
content: embedded,
recipientHex: recipientHex,
senderIdentity: senderIdentity,
failurePolicy: .retry(
retryKey: privateEnvelopeRetryKey(content: embedded, recipientHex: recipientHex)
)
)
}
}
@@ -311,16 +416,48 @@ extension NostrTransport {
}
// MARK: Geohash DMs (per-geohash identity)
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
Task { @MainActor in
guard !recipientHex.isEmpty else { return }
SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
return
}
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
/// Returns true only when the complete migration pair entered the relay
/// delivery queue. GeoDM callers use this synchronous admission result
/// before showing "sent"; deterministic packet/envelope failures must not
/// be hidden behind an unobserved MainActor task.
@MainActor
@discardableResult
func sendPrivateMessageGeohash(
content: String,
toRecipientHex recipientHex: String,
from identity: NostrIdentity,
messageID: String
) -> Bool {
let failurePolicy = PrivateEnvelopeFailurePolicy.userMessage(messageID: messageID)
guard !recipientHex.isEmpty else {
handlePrivateEnvelopeFailure(
events: [],
registerPending: false,
policy: failurePolicy
)
return false
}
SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content: content,
messageID: messageID,
senderPeerID: senderPeerID
) else {
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
handlePrivateEnvelopeFailure(
events: [],
registerPending: false,
policy: failurePolicy
)
return false
}
return sendPrivateEnvelope(
content: embedded,
recipientHex: recipientHex,
senderIdentity: identity,
registerPending: true,
failurePolicy: failurePolicy
)
}
}
@@ -340,17 +477,112 @@ extension NostrTransport {
}
}
/// Creates and sends a gift-wrapped private message event
/// Creates and sends a BitChat private-envelope event over Nostr.
@MainActor
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session)
return
@discardableResult
private func sendPrivateEnvelope(
content: String,
recipientHex: String,
senderIdentity: NostrIdentity,
registerPending: Bool = false,
failurePolicy: PrivateEnvelopeFailurePolicy
) -> Bool {
let events: [NostrEvent]
do {
events = try NostrProtocol.createPrivateEnvelopePublicationBatch(
content: content,
recipientPubkey: recipientHex,
senderIdentity: senderIdentity
)
} catch {
SecureLogger.error(
"NostrTransport: failed to build Nostr private-envelope batch: \(error)",
category: .session
)
// Construction failures are deterministic. User-authored messages
// must become visibly failed; control payloads have no valid event
// pair to retain and retry.
handlePrivateEnvelopeFailure(
events: [],
registerPending: false,
policy: failurePolicy
)
return false
}
if registerPending {
dependencies.registerPendingGiftWrap(event.id)
let accepted = dependencies.sendPrivateEnvelopeBatch(events) { [self] in
handlePrivateEnvelopeFailure(
events: events,
registerPending: registerPending,
policy: failurePolicy
)
}
guard accepted else {
SecureLogger.error(
"NostrTransport: private-envelope migration pair was not accepted for relay delivery",
category: .session
)
handlePrivateEnvelopeFailure(
events: events,
registerPending: registerPending,
policy: failurePolicy
)
return false
}
registerPendingPrivateEnvelopesIfNeeded(events, registerPending: registerPending)
return true
}
@MainActor
private func handlePrivateEnvelopeFailure(
events: [NostrEvent],
registerPending: Bool,
policy: PrivateEnvelopeFailurePolicy
) {
switch policy {
case .userMessage(let messageID):
deliverTransportEvent(.messageDeliveryStatusUpdated(
messageID: messageID,
status: .failed(reason: String(
localized: "content.delivery.reason.not_delivered",
comment: "Failure reason shown when a private message could not enter the relay delivery queue"
))
))
case .retry(let retryKey):
// A deterministic packet/envelope construction failure has no
// events to retry. Only relay admission/delivery failures reach
// this branch with the complete atomic pair.
guard !events.isEmpty else { return }
envelopeRetryQueue.enqueue(
key: retryKey,
events: events,
registerPending: registerPending
)
}
}
@MainActor
private func registerPendingPrivateEnvelopesIfNeeded(
_ events: [NostrEvent],
registerPending: Bool
) {
guard registerPending else { return }
for event in events {
dependencies.registerPendingPrivateEnvelope(event.id)
}
}
@MainActor
private func privateEnvelopeRetryKey(content: String, recipientHex: String) -> String {
"\(recipientHex.lowercased()):\(Data(content.utf8).sha256Fingerprint())"
}
@MainActor
private func deliverTransportEvent(_ event: TransportEvent) {
if let eventDelegate {
eventDelegate.didReceiveTransportEvent(event)
} else {
delegate?.receiveTransportEvent(event)
}
dependencies.sendEvent(event)
}
@@ -367,7 +599,14 @@ extension NostrTransport {
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
return
}
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
sendPrivateEnvelope(
content: ack,
recipientHex: recipientHex,
senderIdentity: senderIdentity,
failurePolicy: .retry(
retryKey: privateEnvelopeRetryKey(content: ack, recipientHex: recipientHex)
)
)
case .deliveredDirect(let messageID, let peerID):
guard let recipientNpub = resolveRecipientNpub(for: peerID),
@@ -378,17 +617,40 @@ extension NostrTransport {
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
return
}
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
sendPrivateEnvelope(
content: ack,
recipientHex: recipientHex,
senderIdentity: senderIdentity,
failurePolicy: .retry(
retryKey: privateEnvelopeRetryKey(content: ack, recipientHex: recipientHex)
)
)
case .deliveredGeohash(let messageID, let recipientHex, let identity):
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
sendPrivateEnvelope(
content: embedded,
recipientHex: recipientHex,
senderIdentity: identity,
registerPending: true,
failurePolicy: .retry(
retryKey: privateEnvelopeRetryKey(content: embedded, recipientHex: recipientHex)
)
)
case .readGeohash(let messageID, let recipientHex, let identity):
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
sendPrivateEnvelope(
content: embedded,
recipientHex: recipientHex,
senderIdentity: identity,
registerPending: true,
failurePolicy: .retry(
retryKey: privateEnvelopeRetryKey(content: embedded, recipientHex: recipientHex)
)
)
}
}
}
@@ -408,3 +670,122 @@ extension NostrTransport {
return nil
}
}
/// Bounded retry owner for non-user private control payloads. It is separate
/// from `NostrTransport` so a scheduled retry from a short-lived geohash
/// transport remains valid after that transport deinitializes. Scheduler
/// callbacks retain this queue, never the transport; an evicted key simply
/// becomes a harmless no-op when its already-scheduled callback fires.
@MainActor
final class NostrPrivateEnvelopeRetryQueue {
private struct PendingRetry {
let events: [NostrEvent]
let registerPending: Bool
var attempt: Int
var isScheduled: Bool
}
private let sendPrivateEnvelopeBatch: @MainActor (
[NostrEvent],
@escaping @MainActor () -> Void
) -> Bool
private let registerPendingPrivateEnvelope: @MainActor (String) -> Void
private let scheduleAfter: @Sendable (
TimeInterval,
@escaping @Sendable () -> Void
) -> Void
private var pending: [String: PendingRetry] = [:]
private var insertionOrder: [String] = []
init(
sendPrivateEnvelopeBatch: @escaping @MainActor (
[NostrEvent],
@escaping @MainActor () -> Void
) -> Bool,
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void,
scheduleAfter: @escaping @Sendable (
TimeInterval,
@escaping @Sendable () -> Void
) -> Void
) {
self.sendPrivateEnvelopeBatch = sendPrivateEnvelopeBatch
self.registerPendingPrivateEnvelope = registerPendingPrivateEnvelope
self.scheduleAfter = scheduleAfter
}
func enqueue(key: String, events: [NostrEvent], registerPending: Bool) {
guard pending[key] == nil else { return }
if pending.count >= TransportConfig.nostrPrivateEnvelopeRetryQueueCap,
let evictedKey = insertionOrder.first {
insertionOrder.removeFirst()
pending.removeValue(forKey: evictedKey)
// These are control payloads, never user-authored messages. Keep
// the bounded-loss decision explicit rather than silently growing
// memory during a prolonged outage.
SecureLogger.warning(
"📮 Private control retry queue full — evicted oldest whole migration pair",
category: .session
)
}
pending[key] = PendingRetry(
events: events,
registerPending: registerPending,
attempt: 0,
isScheduled: false
)
insertionOrder.append(key)
schedule(key: key)
}
private func schedule(key: String) {
guard var item = pending[key], !item.isScheduled else { return }
item.isScheduled = true
pending[key] = item
let exponent = min(item.attempt, 5)
let delay = min(2.0 * pow(2.0, Double(exponent)), 60.0)
scheduleAfter(delay) { [self] in
Task { @MainActor [self] in
self.retry(key: key)
}
}
}
private func retry(key: String) {
guard var item = pending[key] else { return }
item.isScheduled = false
pending[key] = item
let accepted = sendPrivateEnvelopeBatch(item.events) { [self] in
self.enqueue(
key: key,
events: item.events,
registerPending: item.registerPending
)
}
if accepted {
remove(key: key)
if item.registerPending {
for event in item.events {
registerPendingPrivateEnvelope(event.id)
}
}
} else {
item.attempt += 1
pending[key] = item
schedule(key: key)
}
}
private func remove(key: String) {
pending.removeValue(forKey: key)
insertionOrder.removeAll { $0 == key }
}
func removeAll() {
pending.removeAll()
insertionOrder.removeAll()
}
var debugPendingCount: Int { pending.count }
func debugContains(key: String) -> Bool { pending[key] != nil }
}
-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)
}
}
}
+36
View File
@@ -97,6 +97,19 @@ enum PrivateMediaSendPolicy: Equatable {
case blockedDowngrade
}
/// Receiver-only persistence surface for explicit private-media deletion.
/// Kept separate from `Transport` so sender retry branches can rebase without
/// inheriting or implementing receiver storage concerns.
protocol PrivateMediaDeletionPersisting: AnyObject {
@MainActor
func persistDeletedPrivateMedia(
messageIDs: [String],
payloadRelativePaths: [String: String],
protectedPayloadRelativePaths: Set<String>,
completion: @escaping @MainActor (Bool) -> Void
)
}
protocol TransportEventDelegate: AnyObject {
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
}
@@ -190,6 +203,14 @@ protocol Transport: AnyObject {
transferId: String,
allowLegacyFallback: Bool
)
/// Automatic whole-file retry is admitted only while this exact Noise
/// generation authenticates bit 9. It must never queue across a session
/// replacement or enter the signed raw legacy path.
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
)
func cancelTransfer(_ transferId: String)
// Live voice / push-to-talk (mesh transports only): one encoded
@@ -236,6 +257,11 @@ protocol Transport: AnyObject {
/// empty for peers that predate the capabilities TLV.
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy
/// The exact current Noise generation that authenticated both encrypted
/// private media (bit 8) and durable receipts/retry (bit 9).
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID?
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
@@ -311,6 +337,11 @@ extension Transport {
func broadcastGroupMessage(_ envelope: Data) {}
func peerCapabilities(_ peerID: PeerID) -> PeerCapabilities { [] }
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy { .blockedDowngrade }
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID? {
nil
}
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
@@ -345,6 +376,11 @@ extension Transport {
guard !allowLegacyFallback else { return }
sendFilePrivate(packet, to: peerID, transferId: transferId)
}
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
) {}
func cancelTransfer(_ transferId: String) {}
func sendMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
+28 -23
View File
@@ -100,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
@@ -215,7 +195,22 @@ enum TransportConfig {
static let nostrGeoRelayCount: Int = 5
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
static let nostrGeohashSampleLimit: Int = 100
static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400
/// New iOS public-envelope timestamps are deliberately shifted into the
/// past for privacy and relay compatibility.
static let nostrPrivateEnvelopeTimestampFuzzSeconds: TimeInterval = 15 * 60
/// Deployed Android clients can shift legacy kind-1059 timestamps by the
/// full preceding 48 hours.
static let nostrLegacyAndroidTimestampFuzzSeconds: TimeInterval = 48 * 60 * 60
/// Private mail remains eligible for delivery for the same 24-hour window
/// as the persistent sender outbox. The relay query must add timestamp
/// randomization to that window rather than replacing it: an Android event
/// sent at t0 can legitimately be stamped t0-48h and fetched at t0+24h.
static let nostrPrivateEnvelopeDeliveryWindowSeconds: TimeInterval = 24 * 60 * 60
static let nostrDMSubscribeClockSkewSeconds: TimeInterval = 15 * 60
static let nostrDMSubscribeLookbackSeconds: TimeInterval =
nostrPrivateEnvelopeDeliveryWindowSeconds
+ nostrLegacyAndroidTimestampFuzzSeconds
+ nostrDMSubscribeClockSkewSeconds
// A sampled chat message this recent means "a conversation is happening
// there" for the empty-timeline nearby-activity hint.
static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900
@@ -243,11 +238,20 @@ enum TransportConfig {
// Reconnect delays get ±20% random jitter so relays that dropped together
// (e.g. a network blip) don't thundering-herd the same reconnect instant.
static let nostrRelayBackoffJitterRatio: Double = 0.2
static let nostrRelayDefaultFetchLimit: Int = 100
/// Migration recovery uses one independent relay filter per wire kind so
/// primary traffic cannot consume the legacy result budget (or vice
/// versa). Five hundred per kind bounds startup work while preserving a
/// materially deeper offline mailbox than the generic feed default.
static let nostrPrivateEnvelopeFetchLimitPerKind: Int = 500
// How many consecutive Tor-readiness waits (each bounded by TorManager's
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
static let nostrTorReadyMaxWaitAttempts: Int = 3
static let nostrPendingSendQueueCap: Int = 200
/// Control-payload pairs (delivery/read acknowledgements and favorite
/// notifications) that could not enter the relay queue. User messages do
/// not use this queue: they fail visibly, while direct messages also
/// remain in the router outbox.
static let nostrPrivateEnvelopeRetryQueueCap: Int = 256
// Sample interval for the send-queue overflow warning (first + every Nth
// dropped event). Drops are ephemeral presence/geo traffic log-only.
static let nostrPendingSendDropLogInterval: Int = 10
@@ -259,7 +263,7 @@ enum TransportConfig {
// Fallback deadline for treating a subscription's initial fetch as complete
// when a relay never sends EOSE (generous to cover Tor circuit setup).
static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0
// A bridge drop is durable only after NIP-20 OK. Relays that omit OK must
// A bridge drop is durable only after NIP-01 `OK`. Relays that omit `OK` must
// not pin the router's in-flight state indefinitely.
static let nostrConfirmedSendAckTimeoutSeconds: TimeInterval = 10.0
// After this long, a relay marked permanently failed gets another chance.
@@ -358,6 +362,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
@@ -55,6 +55,9 @@ extension ChatViewModel: ChatDeliveryContext {
func markMessageDelivered(_ messageID: String) {
messageRouter.markDelivered(messageID)
mediaTransferCoordinator.confirmPrivateMediaDelivery(
messageID: messageID
)
}
}
File diff suppressed because it is too large Load Diff
@@ -86,7 +86,13 @@ protocol ChatPrivateConversationContext: AnyObject {
@discardableResult
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) -> Bool
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String)
@discardableResult
func sendGeohashPrivateMessage(
_ content: String,
toRecipientHex recipientHex: String,
from identity: NostrIdentity,
messageID: String
) -> Bool
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
@@ -174,7 +180,13 @@ extension ChatViewModel: ChatPrivateConversationContext {
meshService.sendReadReceipt(receipt, to: peerID)
}
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
@discardableResult
func sendGeohashPrivateMessage(
_ content: String,
toRecipientHex recipientHex: String,
from identity: NostrIdentity,
messageID: String
) -> Bool {
makeGeohashNostrTransport().sendPrivateMessageGeohash(
content: content,
toRecipientHex: recipientHex,
@@ -216,9 +228,16 @@ extension ChatViewModel: ChatPrivateConversationContext {
NotificationService.shared.sendPrivateMessageNotification(from: senderName, message: message, peerID: peerID)
}
private func makeGeohashNostrTransport() -> NostrTransport {
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
func makeGeohashNostrTransport(
dependencies: NostrTransport.Dependencies? = nil
) -> NostrTransport {
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: dependencies
)
transport.senderPeerID = meshService.myPeerID
transport.eventDelegate = self
return transport
}
}
@@ -227,7 +246,7 @@ extension ChatViewModel: ChatPrivateConversationContext {
final class ChatPrivateConversationCoordinator {
private unowned let context: any ChatPrivateConversationContext
// Outbox retries re-wrap the same message in fresh gift-wrap events, so
// Outbox retries re-envelope the same message in fresh private events, so
// relay-level event-ID dedup can't catch them; track inbound GeoDM
// message IDs so each copy past the first costs one (already-deduped)
// ack check and nothing else.
@@ -399,13 +418,21 @@ final class ChatPrivateConversationCoordinator {
"GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)",
category: .session
)
context.sendGeohashPrivateMessage(
let accepted = context.sendGeohashPrivateMessage(
content,
toRecipientHex: recipientHex,
from: identity,
messageID: messageID
)
context.setPrivateDeliveryStatus(.sent, forMessageID: messageID, peerID: peerID)
let status: DeliveryStatus = accepted
? .sent
: .failed(
reason: String(
localized: "content.delivery.reason.not_delivered",
comment: "Failure reason shown when a private message could not enter the relay delivery queue"
)
)
context.setPrivateDeliveryStatus(status, forMessageID: messageID, peerID: peerID)
} catch {
context.setPrivateDeliveryStatus(
.failed(
@@ -58,6 +58,7 @@ protocol ChatVerificationContext: AnyObject {
func noiseStaticPublicKeyData() -> Data
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool
func triggerHandshake(with peerID: PeerID)
func privateMediaPeerDidAuthenticate(_ peerID: PeerID)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
@@ -116,6 +117,10 @@ extension ChatViewModel: ChatVerificationContext {
meshService.noiseStaticPublicKeyData()
}
func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {
mediaTransferCoordinator.peerDidAuthenticate(peerID.toShort())
}
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
}
@@ -129,6 +134,10 @@ extension ChatViewModel: ChatVerificationContext {
}
}
extension ChatVerificationContext {
func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {}
}
@MainActor
final class ChatVerificationCoordinator {
struct PendingVerification {
@@ -197,6 +206,7 @@ final class ChatVerificationCoordinator {
guard let self else { return }
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
self.context.privateMediaPeerDidAuthenticate(peerID)
if self.context.isVerifiedFingerprint(fingerprint) {
self.context.setEncryptionStatus(.noiseVerified, for: peerID)
+288 -15
View File
@@ -109,6 +109,16 @@ struct PanicNetworkLifecycle {
}
}
private struct PendingPrivateChatClear {
let peerID: PeerID
let sourceConversationID: ConversationID
let messages: [BitchatMessage]
let otherMessageIDs: Set<String>
let localPeerID: PeerID
let nickname: String
let outgoingMedia: [BitchatMessage]
}
/// 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.
@@ -376,6 +386,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
@Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
@MainActor private var queuedPrivateChatClears: [
PendingPrivateChatClear
] = []
@MainActor private var privateChatClearInFlight = false
@MainActor private var privateChatClearGeneration: UInt64 = 0
private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = []
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
@@ -643,7 +658,271 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
/// Empties the peer's chat but keeps the conversation alive (`/clear`).
@MainActor
func clearPrivateChat(_ peerID: PeerID) {
conversations.clear(.directPeer(peerID))
let sourceConversationID = ConversationID.directPeer(peerID)
// An active live-voice row owns an open FileHandle and may be
// republished as frames/final media arrive. Treat it like an in-flight
// arrival rather than unlinking its capture or removing its bubble.
let messages = privateMessages(for: peerID).filter {
!liveVoiceCoordinator.isLiveVoiceMessage($0)
}
let localPeerID = meshService.myPeerID.toShort()
let currentNickname = nickname
let mediaPrefixes = [
MimeType.Category.audio.messagePrefix,
MimeType.Category.image.messagePrefix,
MimeType.Category.file.messagePrefix
]
let outgoingMedia = messages.filter { message in
guard mediaPrefixes.contains(where: {
message.content.hasPrefix($0)
}) else {
return false
}
if let senderPeerID = message.senderPeerID {
return senderPeerID.toShort() == localPeerID
}
return message.sender == currentNickname
|| message.sender.hasPrefix(currentNickname + "#")
}
// Send ownership is canceled at command time even when another clear
// transaction is ahead in the queue. UI and files remain untouched
// until this request's receiver journal commit succeeds.
for message in outgoingMedia {
mediaTransferCoordinator
.cancelMediaTransferForConversationClear(
messageID: message.id
)
}
queuedPrivateChatClears.append(PendingPrivateChatClear(
peerID: peerID,
sourceConversationID: sourceConversationID,
messages: messages,
otherMessageIDs: Set(
privateChats
.filter { $0.key != peerID }
.flatMap { $0.value.map(\.id) }
),
localPeerID: localPeerID,
nickname: currentNickname,
outgoingMedia: outgoingMedia
))
startNextPrivateChatClearIfNeeded()
}
@MainActor
private func startNextPrivateChatClearIfNeeded() {
guard !privateChatClearInFlight,
!queuedPrivateChatClears.isEmpty else {
return
}
privateChatClearInFlight = true
let request = queuedPrivateChatClears.removeFirst()
let generation = privateChatClearGeneration
performPrivateChatClear(
request,
generation: generation
) { [weak self] in
guard let self,
self.privateChatClearGeneration == generation else {
return
}
self.privateChatClearInFlight = false
self.startNextPrivateChatClearIfNeeded()
}
}
@MainActor
private func performPrivateChatClear(
_ request: PendingPrivateChatClear,
generation: UInt64,
completion: @escaping @MainActor () -> Void
) {
guard privateChatClearGeneration == generation else {
completion()
return
}
let peerID = request.peerID
let selectedConversationID = request.sourceConversationID
let messagesToClear = request.messages
guard !messagesToClear.isEmpty else {
completion()
return
}
// Capture the transaction's exact UI set before any off-main receipt
// I/O. Messages arriving while the journal is written are not part of
// this command and must remain visible.
let capturedMessageIDs = Set(messagesToClear.map(\.id))
let survivingMessageIDs = request.otherMessageIDs
let mediaPrefixes = [
MimeType.Category.audio.messagePrefix,
MimeType.Category.image.messagePrefix,
MimeType.Category.file.messagePrefix
]
let localPeerID = request.localPeerID
let isMedia: (BitchatMessage) -> Bool = { message in
mediaPrefixes.contains(where: message.content.hasPrefix)
}
let isFromMe: (BitchatMessage) -> Bool = { [nickname = request.nickname] message in
if let senderPeerID = message.senderPeerID {
return senderPeerID.toShort() == localPeerID
}
return message.sender == nickname
|| message.sender.hasPrefix(nickname + "#")
}
let outgoingMedia = request.outgoingMedia
let outgoingMediaIDs = Set(outgoingMedia.map(\.id))
let capturedExclusiveIDs =
capturedMessageIDs.subtracting(survivingMessageIDs)
let capturedIncomingMedia = messagesToClear.filter {
isMedia($0) && !isFromMe($0)
}
let capturedStableMediaIDs = Set(
capturedIncomingMedia.compactMap { message in
PrivateMediaMessageIdentity.isStableID(message.id)
? message.id
: nil
}
)
func currentRemovalPlan() -> [ConversationID: Set<String>] {
// Identity handoff removes the source conversation and inserts its
// rows elsewhere. The old source may then be recreated by a new
// arrival before journal I/O finishes, so always scan all direct
// conversations. Only IDs exclusive at command time may follow a
// migration; shared aliases remain outside the source.
var plan: [ConversationID: Set<String>] = [:]
for (conversationID, conversation) in
conversations.conversationsByID {
guard case .direct = conversationID else { continue }
let eligibleIDs = conversationID == selectedConversationID
? capturedMessageIDs
: capturedExclusiveIDs
let matchingIDs = Set(conversation.messages.map(\.id))
.intersection(eligibleIDs)
if !matchingIDs.isEmpty {
plan[conversationID] = matchingIDs
}
}
return plan
}
func hasRemainingCopy(
of messageID: String,
after plan: [ConversationID: Set<String>]
) -> Bool {
conversations.conversationsByID.contains { conversationID, conversation in
guard case .direct = conversationID else { return false }
return conversation.messages.contains { message in
message.id == messageID
&& plan[conversationID]?.contains(messageID) != true
}
}
}
@MainActor
func continueClear(
persisted: Bool,
durableStableIDs: Set<String>
) {
guard privateChatClearGeneration == generation else {
completion()
return
}
guard persisted else {
SecureLogger.error(
"Refusing to clear private chat without durable media tombstones peer=\(peerID.id.prefix(8))",
category: .session
)
completion()
return
}
let plan = currentRemovalPlan()
let newlyLastStableIDs = Set(
capturedStableMediaIDs.filter {
!durableStableIDs.contains($0)
&& !hasRemainingCopy(of: $0, after: plan)
}
)
if !newlyLastStableIDs.isEmpty {
persistDeletedPrivateMedia(
messageIDs: Array(newlyLastStableIDs).sorted()
) { persisted in
continueClear(
persisted: persisted,
durableStableIDs:
durableStableIDs.union(newlyLastStableIDs)
)
}
return
}
// A stable receiver tombstone is global for that message ID.
// Remove any alias that arrived while journal I/O was in flight.
if !durableStableIDs.isEmpty {
let directConversationIDs = conversations
.conversationsByID.keys.filter {
if case .direct = $0 { return true }
return false
}
for conversationID in directConversationIDs {
conversations.removeMessages(from: conversationID) {
durableStableIDs.contains($0.id)
}
}
}
for message in outgoingMedia {
mediaTransferCoordinator.cleanupOutgoingLocalFile(
forMessage: message
)
}
if !outgoingMediaIDs.isEmpty {
let directConversationIDs = conversations
.conversationsByID.keys.filter {
if case .direct = $0 { return true }
return false
}
for conversationID in directConversationIDs {
conversations.removeMessages(from: conversationID) {
outgoingMediaIDs.contains($0.id)
}
}
}
// Stable payload cleanup belongs entirely to the durable receiver
// journal. Legacy/raw incoming payloads have no durable identity,
// so their basenames may already belong to a pending new arrival;
// leave those files for bounded quota cleanup.
let finalPlan = currentRemovalPlan()
for (conversationID, messageIDs) in finalPlan {
conversations.removeMessages(from: conversationID) {
messageIDs.contains($0.id)
}
}
completion()
}
let initialPlan = currentRemovalPlan()
let initialStableIDs = Set(
capturedStableMediaIDs.filter {
!hasRemainingCopy(of: $0, after: initialPlan)
}
)
persistDeletedPrivateMedia(
messageIDs: Array(initialStableIDs).sorted()
) { persisted in
continueClear(
persisted: persisted,
durableStableIDs: initialStableIDs
)
}
}
/// Removes the peer's chat entirely, including unread state.
@@ -1275,6 +1554,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
// handles before clearing state or removing the media directory.
mediaTransferCoordinator.resetForPanic()
liveVoiceCoordinator.resetForPanic()
privateChatClearGeneration &+= 1
queuedPrivateChatClears.removeAll(keepingCapacity: false)
privateChatClearInFlight = false
// Deny and release any clear-media confirmations before identities,
// message state, and local files are wiped.
@@ -1343,15 +1625,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
// 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
@@ -1381,12 +1654,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
// Drop relay subscriptions, handlers, pending sends, and replay state.
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
// disconnect is not enough here.
NostrTransport.resetControlRetriesForPanicWipe()
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
@@ -1769,6 +2038,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
case .peerConnected(let peerID):
transportEventCoordinator.didConnectToPeerSynchronously(peerID)
mediaTransferCoordinator.peerDidReconnect(peerID)
case .peerDisconnected(let peerID):
transportEventCoordinator.didDisconnectFromPeerSynchronously(peerID)
@@ -1858,6 +2128,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
func didConnectToPeer(_ peerID: PeerID) {
transportEventCoordinator.didConnectToPeer(peerID)
Task { @MainActor [weak self] in
self?.mediaTransferCoordinator.peerDidReconnect(peerID)
}
}
func didDisconnectFromPeer(_ peerID: PeerID) {
@@ -41,10 +41,11 @@ struct ChatViewModelServiceBundle {
self.privateChatManager = privateChatManager
self.unifiedPeerService = unifiedPeerService
self.autocompleteService = AutocompleteService()
// Persist processed gift-wrap event IDs: NIP-59 randomizes their
// timestamps, so the 24h-lookback DM subscriptions redeliver the same
// events on every launch and only a cross-launch record stops the
// reprocessing (re-sent DELIVERED bursts, phantom-ack noise).
// Persist processed private-envelope event IDs: legacy Android can
// randomize timestamps across the full 72h15m mailbox lookback, so DM
// subscriptions redeliver the same events on every launch and only a
// cross-launch record stops the reprocessing (re-sent DELIVERED
// bursts, phantom-ack noise).
self.deduplicationService = MessageDeduplicationService(nostrEventStore: NostrProcessedEventStore())
self.publicMessagePipeline = PublicMessagePipeline()
}
@@ -176,7 +177,7 @@ private extension ChatViewModelBootstrapper {
func configureTransport() {
viewModel.meshService.delegate = viewModel
viewModel.meshService.eventDelegate = viewModel
viewModel.messageRouter.setEventDelegate(viewModel)
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak viewModel] in
guard let viewModel else { return }
@@ -558,7 +559,7 @@ private extension ChatViewModelBootstrapper {
// Default (DM) relays: drops need the standing global relay set,
// not geo relays sender and recipient share no cell.
// This confirmed path never falls back to the volatile relay
// queue; bridge dedup is committed only after NIP-20 OK.
// queue; bridge dedup is committed only after NIP-01 `OK`.
NostrRelayManager.shared.sendEventImmediately(event, completion: completion)
}
courier.openSubscription = { tagsHex in
@@ -21,8 +21,8 @@ extension ChatViewModel {
}
@MainActor
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
nostrCoordinator.inbound.subscribeGiftWrap(giftWrap, id: id)
func subscribePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
nostrCoordinator.inbound.subscribePrivateEnvelope(envelope, id: id)
}
@MainActor
@@ -36,8 +36,8 @@ extension ChatViewModel {
}
@MainActor
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
nostrCoordinator.inbound.handleGiftWrap(giftWrap, id: id)
func handlePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
nostrCoordinator.inbound.handlePrivateEnvelope(envelope, id: id)
}
@MainActor
+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)
@@ -108,7 +108,7 @@ extension ChatViewModel: GeohashSubscriptionContext {
}
/// Owns subscription IDs and relay lifecycle for geohash channels, geohash
/// DMs, the account gift-wrap mailbox, and background geohash sampling. The
/// DMs, the account private-envelope mailbox, and background geohash sampling. The
/// only component that talks to `NostrRelayManager`; inbound events are
/// forwarded to `NostrInboundPipeline` / `GeoPresenceTracker`.
final class GeohashSubscriptionManager {
@@ -162,13 +162,13 @@ final class GeohashSubscriptionManager {
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
let dmSub = "geo-dm-\(channel.geohash)"
context.setGeoDmSubscriptionID(dmSub)
let dmFilter = NostrFilter.giftWrapsFor(
let dmFilters = NostrFilter.privateEnvelopeFiltersFor(
pubkey: identity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
)
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
NostrRelayManager.shared.subscribe(filters: dmFilters, id: dmSub) { [weak self] envelope in
Task { @MainActor [weak self] in
self?.inbound.subscribeGiftWrap(giftWrap, id: identity)
self?.inbound.subscribePrivateEnvelope(envelope, id: identity)
}
}
}
@@ -260,13 +260,13 @@ final class GeohashSubscriptionManager {
if TorManager.shared.isReady {
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
}
let dmFilter = NostrFilter.giftWrapsFor(
let dmFilters = NostrFilter.privateEnvelopeFiltersFor(
pubkey: identity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
)
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
NostrRelayManager.shared.subscribe(filters: dmFilters, id: dmSub) { [weak self] envelope in
Task { @MainActor [weak self] in
self?.inbound.handleGiftWrap(giftWrap, id: identity)
self?.inbound.handlePrivateEnvelope(envelope, id: identity)
}
}
}
@@ -388,14 +388,14 @@ final class GeohashSubscriptionManager {
category: .session
)
let filter = NostrFilter.giftWrapsFor(
let filters = NostrFilter.privateEnvelopeFiltersFor(
pubkey: currentIdentity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
)
context.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
context.nostrRelayManager?.subscribe(filters: filters, id: "chat-messages") { [weak self] event in
Task { @MainActor [weak self] in
self?.inbound.handleNostrMessage(event)
self?.inbound.handleAccountPrivateEnvelope(event)
}
}
}
+182 -152
View File
@@ -78,37 +78,27 @@ 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; private-envelope
/// 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
}
// During the coordinated wire-format migration, one logical private
// payload is published under both primary and compatibility formats. Outer
// event IDs differ, so collapse the authenticated embedded payload before
// invoking message/ack side effects. Keep this bounded like the outer-ID
// caches; the recipient and authenticated sender are part of the key.
private var recentPrivatePayloadFormats: [String: UInt8] = [:]
private var recentPrivatePayloadKeyOrder: [String] = []
private static let privatePayloadDedupCapacity = 2_048
init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) {
self.context = context
@@ -118,15 +108,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 +188,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)
@@ -286,162 +279,155 @@ final class NostrInboundPipeline {
}
@MainActor
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
func subscribePrivateEnvelope(_ envelope: 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.
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
// Dedup lookup before Schnorr verification; record only after it passes.
guard !context.hasProcessedNostrEvent(envelope.id) else { return }
guard envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes else { return }
guard envelope.isValidSignature() else { return }
context.recordProcessedNostrEvent(envelope.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, messageTs) = try? NostrProtocol.decryptPrivateEnvelope(
envelope: envelope,
recipientIdentity: id
),
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let noisePayload = NoisePayload.decode(packet.payload)
else {
return
}
guard shouldProcessPrivatePayload(
noisePayload,
senderPubkey: senderPubkey,
recipientPubkey: id.publicKeyHex,
envelopeKind: envelope.kind
) else { return }
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(messageTs))
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) {
func handlePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
guard let context else { return }
// Cheap dedup pre-check only; see subscribeGiftWrap.
if context.hasProcessedNostrEvent(giftWrap.id) {
// Dedup lookup before Schnorr verification; record only after it passes.
if context.hasProcessedNostrEvent(envelope.id) {
return
}
guard envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes else { return }
guard envelope.isValidSignature() else { return }
context.recordProcessedNostrEvent(envelope.id)
// 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 let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
guard let (content, senderPubkey, messageTs) = try? NostrProtocol.decryptPrivateEnvelope(
envelope: envelope,
recipientIdentity: id
) else {
if verbose {
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))", category: .session)
}
SecureLogger.warning("GeoDM: failed decrypt private envelope id=\(envelope.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 private envelope id=\(envelope.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
}
guard shouldProcessPrivatePayload(
payload,
senderPubkey: senderPubkey,
recipientPubkey: id.publicKeyHex,
envelopeKind: envelope.kind
) 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(messageTs))
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) {
func handleAccountPrivateEnvelope(_ envelope: 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.
if context.hasProcessedNostrEvent(giftWrap.id) { return }
// Cheap dedup pre-check only; Schnorr verification runs off-main in
// processAccountPrivateEnvelope, 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(envelope.id) { return }
Task.detached(priority: .userInitiated) { [weak self] in
await self?.processNostrMessage(giftWrap)
await self?.processAccountPrivateEnvelope(envelope)
}
}
func processNostrMessage(_ giftWrap: NostrEvent) async {
func processAccountPrivateEnvelope(_ envelope: NostrEvent) async {
guard envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes else { return }
guard envelope.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.
let alreadyProcessed: Bool = await MainActor.run {
if context.hasProcessedNostrEvent(giftWrap.id) { return true }
context.recordProcessedNostrEvent(giftWrap.id)
if context.hasProcessedNostrEvent(envelope.id) { return true }
context.recordProcessedNostrEvent(envelope.id)
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 }
do {
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
let (content, senderPubkey, messageTimestampSeconds) = try NostrProtocol.decryptPrivateEnvelope(
envelope: envelope,
recipientIdentity: currentIdentity
)
@@ -465,11 +451,14 @@ final class NostrInboundPipeline {
if packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(messageTimestampSeconds))
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 }
guard self.shouldProcessPrivatePayload(
payload,
senderPubkey: senderPubkey,
recipientPubkey: currentIdentity.publicKeyHex,
envelopeKind: envelope.kind
) else { return }
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
switch payload.type {
@@ -543,6 +532,47 @@ final class NostrInboundPipeline {
}
private extension NostrInboundPipeline {
@MainActor
func shouldProcessPrivatePayload(
_ payload: NoisePayload,
senderPubkey: String,
recipientPubkey: String,
envelopeKind: Int
) -> Bool {
let digest = payload.encode().sha256Fingerprint()
let key = "\(recipientPubkey.lowercased()):\(senderPubkey.lowercased()):\(digest)"
let formatBit: UInt8
switch envelopeKind {
case NostrProtocol.EventKind.privateEnvelope.rawValue:
formatBit = 1 << 0
case NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue:
formatBit = 1 << 1
default:
return true
}
if let observedFormats = recentPrivatePayloadFormats[key] {
if observedFormats & formatBit != 0 {
// A same-format re-envelope is a delivery retry. Let it reach
// the coordinator so a lost DELIVERED acknowledgement can be
// sent again; downstream message-ID dedup prevents rerendering.
return true
}
// The same authenticated payload under the other migration format
// is the compatibility twin, not a new message or acknowledgement.
recentPrivatePayloadFormats[key] = observedFormats | formatBit
return false
}
recentPrivatePayloadFormats[key] = formatBit
recentPrivatePayloadKeyOrder.append(key)
if recentPrivatePayloadKeyOrder.count > Self.privatePayloadDedupCapacity {
let evicted = recentPrivatePayloadKeyOrder.removeFirst()
recentPrivatePayloadFormats.removeValue(forKey: evicted)
}
return true
}
@MainActor
static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
guard content.hasPrefix("bitchat1:") else { return nil }
+2 -1
View File
@@ -504,7 +504,8 @@ private struct ContentPrivateChatSheetView: View {
if privateConversationModel.selectedPeerID?.isGroup == true {
return String(localized: "content.private.caption_group", comment: "Caption above the group chat composer noting messages are encrypted to group members")
}
// Geohash DMs are NIP-17 gift-wrapped always end-to-end encrypted,
// Geohash DMs use BitChat's private-envelope transport over Nostr
// always end-to-end encrypted,
// even though they carry no Noise session status. Mesh DMs earn the
// "encrypted" claim only once the Noise handshake has secured.
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
-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) {
+40 -245
View File
@@ -666,10 +666,6 @@ struct BLEServiceCoreTests {
)
await ble._test_drainNoiseMessagePipeline()
#expect(ble.canDeliverSecurely(to: alicePeerID))
// The establishment transition enqueues its forced announce as a
// separate serialized phase; drain again so it lands before the tap
// and cannot masquerade as restore-driven announce traffic below.
await ble._test_drainNoiseMessagePipeline()
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = outbound.record
@@ -800,202 +796,6 @@ struct BLEServiceCoreTests {
#expect(outbound.count(ofType: .announce) == 0)
}
/// The message-loss interleaving behind a legitimate peer restart: the
/// remote completes a replacement handshake (discarding the old keys)
/// but its completion never arrives, so the local responder timeout
/// restores the quarantined OLD generation and requests one convergence
/// retry both dispatched unordered. When the restore handler wins the
/// race, it must NOT drain the pending private-message/typed-payload
/// queues under the restored keys the remote no longer holds; the drain
/// has to wait for the convergence handshake and use its new session.
@Test
func timeoutRestoredSessionDefersQueueDrainUntilConvergence() async throws {
let ble = makeService(noiseResponderHandshakeTimeout: 0.3)
let alice = NoiseEncryptionService(keychain: MockKeychain())
let mallory = NoiseEncryptionService(keychain: MockKeychain())
let alicePeerID = PeerID(publicKey: alice.getStaticPublicKeyData())
// Establish BLE as responder so the inbound reconnect below is not
// 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))
// The convergence retry only prepares for reachable peers.
ble._test_seedConnectedPeer(alicePeerID, nickname: "Alice")
let reconciled = SessionReconcileCounter()
ble._test_onPrivateMediaSessionReconciled = reconciled.record
// Park the convergence-recovery callback on its global-queue thread
// before it can enqueue onto messageQueue: the restore handler
// deterministically wins the dispatch race this test exercises.
let recoveryGate = HandshakeRecoveryEnqueueGate()
defer { recoveryGate.release() }
ble._test_beforeHandshakeRecoveryEnqueued = { _ in recoveryGate.pause() }
let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = outbound.record
// Park the traffic the race would lose directly in the pending
// queues the same place live sends land during quarantine so no
// assertion below depends on outrunning the responder deadline under
// parallel test load. Nothing drains these queues while the current
// session stays untouched.
ble._test_enqueuePendingPrivateMessage(
content: "deferred private message",
messageID: "deferred-pm-\(UUID().uuidString)",
for: alicePeerID
)
ble._test_enqueuePendingNoisePayload(
NoisePayload(
type: .groupInvite,
data: Data("queued-during-quarantine".utf8)
).encode(),
transferId: "deferred-invite-\(UUID().uuidString)",
for: alicePeerID
)
// An unauthenticated message 1 quarantines the established transport.
// Its message 3 never arrives, modeling the restarted peer whose
// completion was lost after it already discarded the old keys.
let reconnectMessage1 = try mallory.initiateHandshake(with: ble.myPeerID)
let reconnectPacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: Data(hexString: alicePeerID.id) ?? Data(),
recipientID: Data(hexString: ble.myPeerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000),
payload: reconnectMessage1,
signature: nil,
ttl: 7
)
ble._test_handlePacket(reconnectPacket, fromPeerID: alicePeerID)
// The responder's message 2 is a monotonic quarantine signal; the
// secure-delivery dip itself only lasts until the responder deadline,
// which parallel test load can outrun. (The recovery gate keeps the
// convergence retry's message 1 out of the tap until released.)
let responderReady = await TestHelpers.waitUntil(
{ outbound.count(ofType: .noiseHandshake) >= 1 },
timeout: TestConstants.longTimeout
)
try #require(responderReady)
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
// The responder timeout restores the quarantined generation; the
// gate guarantees its handler runs before the convergence retry.
let restoreRan = await TestHelpers.waitUntil(
{ reconciled.count(for: alicePeerID) == 1 },
timeout: TestConstants.longTimeout
)
try #require(restoreRan)
#expect(ble.canDeliverSecurely(to: alicePeerID))
await ble._test_drainNoiseMessagePipeline()
// The parked queues must not have been encrypted under the restored
// old generation the remote may no longer be able to read.
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
// Release the mandatory convergence retry: it retires the restored
// session and starts a fresh XX exchange with the live peer.
recoveryGate.release()
let retryStarted = 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(retryStarted)
#expect(outbound.count(ofType: .noiseEncrypted) == 0)
let retryMessage1 = try #require(
outbound.snapshot().last {
$0.type == MessageType.noiseHandshake.rawValue
&& PeerID(hexData: $0.senderID) == ble.myPeerID
&& $0.payload.count
== NoiseSecurityConstants.xxInitialMessageSize
}?.payload
)
// Alice answers the retry as the restarted peer she models: her old
// session is gone, so the retry is a fresh responder exchange (and
// never the initiator-completion grace deferral, whose lower-peerID
// arm would otherwise coalesce the retry for random key orderings).
alice.clearSession(for: ble.myPeerID)
let retryMessage2 = try #require(
try alice.processHandshakeMessage(
from: ble.myPeerID,
message: retryMessage1
)
)
let retryPacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: Data(hexString: alicePeerID.id) ?? Data(),
recipientID: Data(hexString: ble.myPeerID.id),
timestamp: UInt64(Date().timeIntervalSince1970 * 1_000) + 1,
payload: retryMessage2,
signature: nil,
ttl: 7
)
ble._test_handlePacket(retryPacket, fromPeerID: alicePeerID)
let drained = await TestHelpers.waitUntil(
{ outbound.count(ofType: .noiseEncrypted) >= 3 },
timeout: TestConstants.longTimeout
)
try #require(drained)
await ble._test_drainNoiseMessagePipeline()
// Alice completes with message 3, then must be able to decrypt every
// drained payload proving nothing left under the old generation.
let retryMessage3 = try #require(
outbound.snapshot().last {
$0.type == MessageType.noiseHandshake.rawValue
&& PeerID(hexData: $0.senderID) == ble.myPeerID
&& $0.payload.count
!= NoiseSecurityConstants.xxInitialMessageSize
}?.payload
)
_ = try alice.processHandshakeMessage(
from: ble.myPeerID,
message: retryMessage3
)
let plaintexts = try outbound.snapshot()
.filter { $0.type == MessageType.noiseEncrypted.rawValue }
.map { try alice.decrypt($0.payload, from: ble.myPeerID) }
#expect(plaintexts.count == 3)
#expect(
plaintexts.filter {
$0.first == NoisePayloadType.authenticatedPeerState.rawValue
}.count == 1
)
#expect(
plaintexts.filter {
$0.first == NoisePayloadType.privateMessage.rawValue
}.count == 1
)
#expect(
plaintexts.filter {
$0.first == NoisePayloadType.groupInvite.rawValue
}.count == 1
)
}
/// A legitimate rotation announce necessarily arrives on a link still
/// bound to the OLD ID, so its registry upsert stores the new peer
/// disconnected. The successful rebind must promote it: a healed
@@ -1230,6 +1030,44 @@ struct BLEServiceCoreTests {
))
}
@Test @MainActor
func panicSuspension_finalizesStaleTransportEventsAsRejected() async {
let ble = makeService()
let delegate = TransportEventCaptureDelegate()
ble.eventDelegate = delegate
let message = BitchatMessage(
id: "pre-panic-finalization",
sender: "Peer",
content: "must be rejected",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: PeerID(str: "1122334455667788")
)
var completions = 0
var outcomes: [TransportEventDeliveryOutcome] = []
ble._test_emitTransportEvent(
.messageReceived(message),
completion: { completions += 1 },
finalization: { outcomes.append($0) }
)
ble.suspendForPanicReset()
ble._test_emitTransportEvent(
.messageReceived(message),
completion: { completions += 1 },
finalization: { outcomes.append($0) }
)
for _ in 0..<4 {
await Task.yield()
}
#expect(delegate.messageIDs.isEmpty)
#expect(completions == 0)
#expect(outcomes == [.rejected, .rejected])
}
@Test
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
@@ -1329,45 +1167,6 @@ private final class OutboundPacketTap {
}
}
/// Blocks the convergence-recovery callback on its global-queue thread so a
/// test can prove the quarantine-restore handler wins the messageQueue race.
private final class HandshakeRecoveryEnqueueGate: @unchecked Sendable {
private let condition = NSCondition()
private var released = false
func pause() {
condition.lock()
while !released {
condition.wait()
}
condition.unlock()
}
func release() {
condition.lock()
released = true
condition.broadcast()
condition.unlock()
}
}
/// Thread-safe counter for `_test_onPrivateMediaSessionReconciled` firings.
private final class SessionReconcileCounter: @unchecked Sendable {
private let lock = NSLock()
private var reconciles: [PeerID] = []
func record(_ peerID: PeerID) {
lock.lock()
reconciles.append(peerID)
lock.unlock()
}
func count(for peerID: PeerID) -> Int {
lock.lock(); defer { lock.unlock() }
return reconciles.filter { $0 == peerID }.count
}
}
private final class VerifiedDirectRebindGate: @unchecked Sendable {
private let condition = NSCondition()
private var paused = false
@@ -1459,10 +1258,7 @@ private final class PanicIngressObserver: @unchecked Sendable {
}
}
private func makeService(
noiseResponderHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
) -> BLEService {
private func makeService() -> BLEService {
let keychain = MockKeychain()
let identityManager = MockIdentityManager(keychain)
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
@@ -1470,8 +1266,7 @@ private func makeService(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
initializeBluetoothManagers: false,
noiseResponderHandshakeTimeout: noiseResponderHandshakeTimeout
initializeBluetoothManagers: false
)
}
@@ -58,6 +58,8 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
private(set) var appendedPublicMessages: [(message: BitchatMessage, conversationID: ConversationID)] = []
private(set) var removedMessages: [(messageID: String, cleanupFile: Bool)] = []
private(set) var untombstonedMediaRemovals: [String] = []
private(set) var outgoingMediaRemovals: [String] = []
private(set) var systemMessages: [String] = []
private(set) var notifyUIChangedCount = 0
@@ -71,6 +73,16 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
removedMessages.append((messageID, cleanupFile))
}
func removeUntombstonedMediaMessage(withID messageID: String) {
untombstonedMediaRemovals.append(messageID)
removedMessages.append((messageID, false))
}
func removeOutgoingMediaMessage(withID messageID: String) {
outgoingMediaRemovals.append(messageID)
removedMessages.append((messageID, false))
}
func addSystemMessage(_ content: String) { systemMessages.append(content) }
func notifyUIChanged() { notifyUIChangedCount += 1 }
@@ -95,10 +107,27 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
transferId: String
)] = []
private(set) var privateFileLegacyAllowances: [Bool] = []
private(set) var privateFileReceiptRetryTransferIDs: [String] = []
private(set) var broadcastFileSends: [String] = []
private(set) var cancelledTransfers: [String] = []
private(set) var privateMediaPolicyResolutionRequests: [PeerID] = []
private(set) var persistedDeletionBatches: [[String]] = []
var requiredTombstoneIDs: Set<String> = []
var deletedMediaPersistenceResult = true
var deferDeletedMediaPersistence = false
private var pendingDeletionCompletions: [
@MainActor (Bool) -> Void
] = []
var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted
var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy?
var resolvesPrivateMediaPolicyImmediately = true
var supportsAuthenticatedPrivateMediaReceipts = false
var authenticatedPrivateMediaReceiptGeneration = UUID(
uuidString: "00000000-0000-0000-0000-000000000001"
)!
private var pendingPrivateMediaPolicyResolutions: [
@MainActor (PrivateMediaSendPolicy) -> Void
] = []
private(set) var legacyConsentRequests: [(
id: UUID,
peerID: PeerID,
@@ -113,11 +142,40 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
privateMediaPolicy
}
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID? {
supportsAuthenticatedPrivateMediaReceipts
? authenticatedPrivateMediaReceiptGeneration
: nil
}
func resolvePrivateMediaSendPolicy(
to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
) {
completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy)
privateMediaPolicyResolutionRequests.append(peerID)
if resolvesPrivateMediaPolicyImmediately {
completion(resolvedPrivateMediaPolicy ?? privateMediaPolicy)
} else {
pendingPrivateMediaPolicyResolutions.append(completion)
}
}
var pendingPrivateMediaPolicyResolutionCount: Int {
pendingPrivateMediaPolicyResolutions.count
}
func resolveNextPrivateMediaPolicy(
_ policy: PrivateMediaSendPolicy? = nil
) {
guard !pendingPrivateMediaPolicyResolutions.isEmpty else { return }
let completion = pendingPrivateMediaPolicyResolutions.removeFirst()
completion(
policy
?? resolvedPrivateMediaPolicy
?? privateMediaPolicy
)
}
func requestLegacyPrivateMediaConsent(
@@ -162,6 +220,16 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
privateFileLegacyAllowances.append(allowLegacyFallback)
}
func sendFilePrivateReceiptRetry(
_ packet: BitchatFilePacket,
to peerID: PeerID,
transferId: String
) {
privateFileSends.append((packet, peerID, transferId))
privateFileLegacyAllowances.append(false)
privateFileReceiptRetryTransferIDs.append(transferId)
}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
broadcastFileSends.append(transferId)
}
@@ -169,6 +237,28 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
func cancelTransfer(_ transferId: String) {
cancelledTransfers.append(transferId)
}
func persistDeletedPrivateMedia(
messageIDs: [String],
completion: @escaping @MainActor (Bool) -> Void
) {
persistedDeletionBatches.append(messageIDs)
if deferDeletedMediaPersistence {
pendingDeletionCompletions.append(completion)
} else {
completion(deletedMediaPersistenceResult)
}
}
func requiresPrivateMediaTombstone(messageID: String) -> Bool {
requiredTombstoneIDs.contains(messageID)
}
func resolveNextDeletionPersistence(_ result: Bool? = nil) {
guard !pendingDeletionCompletions.isEmpty else { return }
let completion = pendingDeletionCompletions.removeFirst()
completion(result ?? deletedMediaPersistenceResult)
}
}
private final class PausedVoiceNotePreparer: @unchecked Sendable {
@@ -216,6 +306,59 @@ private final class PausedVoiceNotePreparer: @unchecked Sendable {
}
}
private final class StaticVoiceNotePreparer: @unchecked Sendable {
private let packet: BitchatFilePacket
init(fileName: String, content: Data = Data("voice".utf8)) {
packet = BitchatFilePacket(
fileName: fileName,
fileSize: UInt64(content.count),
mimeType: "audio/mp4",
content: content
)
}
func prepare(_ _: URL) throws -> BitchatFilePacket {
packet
}
}
private final class DeterministicMediaTransferIDFactory:
@unchecked Sendable {
private let lock = NSLock()
private var nextOrdinal = 0
func make(messageID: String) -> String {
lock.lock()
defer {
nextOrdinal += 1
lock.unlock()
}
return "\(messageID)-attempt-\(nextOrdinal)"
}
}
private final class MutableMediaRetryClock: @unchecked Sendable {
private let lock = NSLock()
private var value: Date
init(_ value: Date) {
self.value = value
}
func now() -> Date {
lock.lock()
defer { lock.unlock() }
return value
}
func advance(by interval: TimeInterval) {
lock.lock()
value = value.addingTimeInterval(interval)
lock.unlock()
}
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatMediaTransferCoordinator` against
@@ -279,7 +422,8 @@ struct ChatMediaTransferCoordinatorContextTests {
coordinator.handleTransferEvent(.cancelled(id: "t2", sentFragments: 1, totalFragments: 5))
#expect(context.removedMessages.count == 1)
#expect(context.removedMessages.first?.messageID == "m2")
#expect(context.removedMessages.first?.cleanupFile == true)
#expect(context.removedMessages.first?.cleanupFile == false)
#expect(context.outgoingMediaRemovals == ["m2"])
// A pre-start rejection keeps the placeholder visible and failed,
// including queued post-handshake encryption failures.
@@ -429,7 +573,172 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(context.cancelledTransfers == ["approved-delete"])
#expect(coordinator.messageIDToTransferId["message-delete"] == nil)
#expect(context.removedMessages.map(\.messageID) == ["message-delete"])
#expect(context.removedMessages.first?.cleanupFile == true)
#expect(context.removedMessages.first?.cleanupFile == false)
#expect(
context.untombstonedMediaRemovals == ["message-delete"]
)
}
@Test @MainActor
func deleteIncomingStableMediaWaitsForDurableTombstone() {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let messageID = "media-00112233445566778899aabbccddeeff"
context.requiredTombstoneIDs = [messageID]
context.deferDeletedMediaPersistence = true
coordinator.registerTransfer(
transferId: "incoming-delete",
messageID: messageID
)
coordinator.deleteMediaMessage(messageID: messageID)
#expect(context.persistedDeletionBatches == [[messageID]])
#expect(context.removedMessages.isEmpty)
#expect(context.cancelledTransfers == ["incoming-delete"])
#expect(coordinator.messageIDToTransferId[messageID] == nil)
context.resolveNextDeletionPersistence(true)
#expect(context.cancelledTransfers == ["incoming-delete"])
#expect(context.removedMessages.map(\.messageID) == [messageID])
#expect(context.removedMessages.first?.cleanupFile == false)
#expect(context.untombstonedMediaRemovals.isEmpty)
#expect(coordinator.messageIDToTransferId[messageID] == nil)
}
@Test @MainActor
func deleteIncomingStableMediaCompletionAfterPanicIsIgnored() {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let messageID = "media-11223344556677889900aabbccddeeff"
context.requiredTombstoneIDs = [messageID]
context.deferDeletedMediaPersistence = true
coordinator.deleteMediaMessage(messageID: messageID)
#expect(context.persistedDeletionBatches == [[messageID]])
coordinator.resetForPanic()
context.resolveNextDeletionPersistence(true)
#expect(context.removedMessages.isEmpty)
#expect(context.untombstonedMediaRemovals.isEmpty)
}
@Test @MainActor
func deleteIncomingStableMediaPreservesStateWhenTombstoneFails() {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let messageID = "media-ffeeddccbbaa99887766554433221100"
context.requiredTombstoneIDs = [messageID]
context.deletedMediaPersistenceResult = false
coordinator.registerTransfer(
transferId: "failed-delete",
messageID: messageID
)
coordinator.deleteMediaMessage(messageID: messageID)
#expect(context.persistedDeletionBatches == [[messageID]])
#expect(context.removedMessages.isEmpty)
#expect(context.cancelledTransfers == ["failed-delete"])
#expect(coordinator.messageIDToTransferId[messageID] == nil)
}
@Test @MainActor
func deleteStableMediaReleasesRetainedRetryBeforeTombstoneCommit()
async throws
{
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.supportsAuthenticatedPrivateMediaReceipts = true
context.deferDeletedMediaPersistence = true
let fileName = "voice_deadbeefdeadbeef.m4a"
let preparer = StaticVoiceNotePreparer(fileName: fileName)
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in
try preparer.prepare(url)
}
)
let url = try makeCoordinatorVoiceURL(fileName: fileName)
defer {
try? FileManager.default.removeItem(
at: url.deletingLastPathComponent()
)
}
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil(
{ context.privateFileSends.count == 1 },
timeout: TestConstants.longTimeout
))
let messageID = try #require(
context.privateChats[peerID]?.first?.id
)
let transferID = try #require(
context.privateFileSends.first?.transferId
)
context.requiredTombstoneIDs = [messageID]
#expect(coordinator.retainedReconnectRetryCount == 1)
coordinator.deleteMediaMessage(messageID: messageID)
#expect(context.persistedDeletionBatches == [[messageID]])
#expect(coordinator.retainedReconnectRetryCount == 0)
#expect(coordinator.retainedReconnectRetryBytes == 0)
#expect(context.cancelledTransfers == [transferID])
#expect(context.removedMessages.isEmpty)
coordinator.peerDidReconnect(peerID)
#expect(context.privateFileSends.count == 1)
context.resolveNextDeletionPersistence(true)
#expect(context.removedMessages.map(\.messageID) == [messageID])
}
@Test @MainActor
func legacyCleanupNeverRecursivelyDeletesDirectoryTarget() throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let incoming = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
.appendingPathComponent(
"files/images/incoming",
isDirectory: true
)
let directoryName = "cleanup-dir-\(UUID().uuidString)"
let directory = incoming.appendingPathComponent(
directoryName,
isDirectory: true
)
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
let child = directory.appendingPathComponent("child.jpg")
try Data([0x01]).write(to: child)
defer { try? FileManager.default.removeItem(at: directory) }
let message = BitchatMessage(
id: UUID().uuidString,
sender: "Peer",
content:
"\(MimeType.Category.image.messagePrefix)\(directoryName)",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Me"
)
coordinator.cleanupIncomingLocalFile(forMessage: message)
#expect(FileManager.default.fileExists(atPath: directory.path))
#expect(FileManager.default.fileExists(atPath: child.path))
}
@Test @MainActor
@@ -798,6 +1107,548 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(context.legacyConsentRequests.isEmpty)
#expect(context.privateFileSends.isEmpty)
}
@Test @MainActor
func receiptCapableEncryptedMediaRetriesExactPacketAfterReconnect() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.supportsAuthenticatedPrivateMediaReceipts = true
let fileName = "voice_0011223344556677.m4a"
let preparer = StaticVoiceNotePreparer(fileName: fileName)
let transferIDs = DeterministicMediaTransferIDFactory()
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in
try preparer.prepare(url)
},
transferIDFactory: transferIDs.make
)
let url = try makeCoordinatorVoiceURL(fileName: fileName)
defer {
try? FileManager.default.removeItem(
at: url.deletingLastPathComponent()
)
}
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil(
{ context.privateFileSends.count == 1 },
timeout: TestConstants.longTimeout
))
let messageID = try #require(
context.privateChats[peerID]?.first?.id
)
let initial = try #require(
context.privateFileSends.first
)
#expect(PrivateMediaMessageIdentity.isStableID(messageID))
#expect(coordinator.retainedReconnectRetryCount == 1)
coordinator.handleTransferEvent(.completed(
id: initial.transferId,
totalFragments: 1
))
#expect(coordinator.retainedReconnectRetryCount == 1)
#expect(context.deliveryStatusUpdates.last?.status == .sent)
coordinator.peerDidReconnect(peerID)
#expect(context.privateFileSends.count == 2)
let retry = try #require(context.privateFileSends.last)
#expect(retry.packet.encode() == initial.packet.encode())
#expect(retry.peerID == peerID)
#expect(retry.transferId != initial.transferId)
#expect(context.privateFileReceiptRetryTransferIDs == [
retry.transferId
])
#expect(context.privateFileLegacyAllowances == [false, false])
coordinator.confirmPrivateMediaDelivery(messageID: messageID)
#expect(coordinator.retainedReconnectRetryCount == 0)
#expect(context.cancelledTransfers == [retry.transferId])
#expect(context.removedMessages.isEmpty)
#expect(!context.deliveryStatusUpdates.contains {
if case .failed = $0.status { return true }
return false
})
// Receipt confirmation removes retry ownership before transport
// cancellation, so its late callback cannot delete the delivered row
// or re-arm another reconnect retry.
coordinator.handleTransferEvent(.cancelled(
id: retry.transferId,
sentFragments: 1,
totalFragments: 2
))
coordinator.peerDidReconnect(peerID)
#expect(context.privateFileSends.count == 2)
#expect(context.removedMessages.isEmpty)
#expect(coordinator.messageIDToTransferId[messageID] == nil)
}
@Test @MainActor
func bit8OnlyEncryptedMediaNeverRetainsOrAutomaticallyRetries() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.privateMediaPolicy = .encrypted
context.supportsAuthenticatedPrivateMediaReceipts = false
let fileName = "voice_1111222233334444.m4a"
let preparer = StaticVoiceNotePreparer(fileName: fileName)
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in
try preparer.prepare(url)
}
)
let url = try makeCoordinatorVoiceURL(fileName: fileName)
defer {
try? FileManager.default.removeItem(
at: url.deletingLastPathComponent()
)
}
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil(
{ context.privateFileSends.count == 1 },
timeout: TestConstants.longTimeout
))
let initial = try #require(context.privateFileSends.first)
coordinator.handleTransferEvent(.completed(
id: initial.transferId,
totalFragments: 1
))
coordinator.peerDidReconnect(peerID)
coordinator.peerDidAuthenticate(peerID)
#expect(coordinator.retainedReconnectRetryCount == 0)
#expect(context.privateFileSends.count == 1)
#expect(context.privateFileReceiptRetryTransferIDs.isEmpty)
#expect(context.privateMediaPolicyResolutionRequests.isEmpty)
}
@Test @MainActor
func consentedRawLegacyMediaNeverEntersAutomaticRetry() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.privateMediaPolicy = .legacyRequiresConsent
// Even a contradictory stale bit-9 observation must not retain an
// invocation that actually selected the explicit raw path.
context.supportsAuthenticatedPrivateMediaReceipts = true
let fileName = "voice_2222333344445555.m4a"
let preparer = StaticVoiceNotePreparer(fileName: fileName)
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in
try preparer.prepare(url)
}
)
let url = try makeCoordinatorVoiceURL(fileName: fileName)
defer {
try? FileManager.default.removeItem(
at: url.deletingLastPathComponent()
)
}
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil(
{ context.legacyConsentRequests.count == 1 },
timeout: TestConstants.longTimeout
))
context.resolveNextLegacyConsent(true)
let initial = try #require(context.privateFileSends.first)
#expect(context.privateFileLegacyAllowances == [true])
#expect(coordinator.retainedReconnectRetryCount == 0)
coordinator.handleTransferEvent(.completed(
id: initial.transferId,
totalFragments: 1
))
context.privateMediaPolicy = .encrypted
coordinator.peerDidReconnect(peerID)
coordinator.peerDidAuthenticate(peerID)
#expect(context.privateFileSends.count == 1)
#expect(context.privateFileReceiptRetryTransferIDs.isEmpty)
#expect(coordinator.retainedReconnectRetryCount == 0)
}
@Test @MainActor
func authenticatedGenerationSupersedesStaleReconnectResolution() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.supportsAuthenticatedPrivateMediaReceipts = true
context.resolvesPrivateMediaPolicyImmediately = false
let oldGeneration = context
.authenticatedPrivateMediaReceiptGeneration
let newGeneration = UUID(
uuidString: "00000000-0000-0000-0000-000000000002"
)!
let fileName = "voice_3333444455556666.m4a"
let preparer = StaticVoiceNotePreparer(fileName: fileName)
let transferIDs = DeterministicMediaTransferIDFactory()
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in
try preparer.prepare(url)
},
transferIDFactory: transferIDs.make
)
let url = try makeCoordinatorVoiceURL(fileName: fileName)
defer {
try? FileManager.default.removeItem(
at: url.deletingLastPathComponent()
)
}
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil(
{ context.privateFileSends.count == 1 },
timeout: TestConstants.longTimeout
))
let initial = try #require(context.privateFileSends.first)
coordinator.peerDidReconnect(peerID)
#expect(context.pendingPrivateMediaPolicyResolutionCount == 1)
context.authenticatedPrivateMediaReceiptGeneration = newGeneration
coordinator.peerDidAuthenticate(peerID)
#expect(context.pendingPrivateMediaPolicyResolutionCount == 2)
// The old-generation completion lost ownership and is inert.
context.resolveNextPrivateMediaPolicy(.encrypted)
#expect(context.privateFileReceiptRetryTransferIDs.isEmpty)
#expect(context.cancelledTransfers.isEmpty)
context.resolveNextPrivateMediaPolicy(.encrypted)
#expect(context.cancelledTransfers == [initial.transferId])
#expect(context.privateFileReceiptRetryTransferIDs.count == 1)
#expect(
context.authenticatedPrivateMediaReceiptGeneration
== newGeneration
)
#expect(oldGeneration != newGeneration)
}
@Test @MainActor
func panicClearsRetainedRetryPendingResolutionAndExpiry() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.supportsAuthenticatedPrivateMediaReceipts = true
context.resolvesPrivateMediaPolicyImmediately = false
let fileName = "voice_3333444455556666.m4a"
let preparer = StaticVoiceNotePreparer(fileName: fileName)
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in
try preparer.prepare(url)
},
reconnectRetryLimits: PrivateMediaReconnectRetryLimits(
maxRetainedPackets: 1,
maxRetainedBytes: 1_024,
maxRetriesPerMessage: 1,
retentionSeconds: 0.1,
maxRetriesPerReconnect: 1
)
)
let url = try makeCoordinatorVoiceURL(fileName: fileName)
defer {
try? FileManager.default.removeItem(
at: url.deletingLastPathComponent()
)
}
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil(
{ context.privateFileSends.count == 1 },
timeout: TestConstants.longTimeout
))
let initial = try #require(context.privateFileSends.first)
coordinator.handleTransferEvent(.completed(
id: initial.transferId,
totalFragments: 1
))
#expect(coordinator.retainedReconnectRetryCount == 1)
coordinator.peerDidReconnect(peerID)
#expect(context.pendingPrivateMediaPolicyResolutionCount == 1)
let failedBeforePanic = context.deliveryStatusUpdates.filter {
if case .failed = $0.status { return true }
return false
}.count
coordinator.resetForPanic()
context.resolveNextPrivateMediaPolicy(.encrypted)
try await Task.sleep(nanoseconds: 250_000_000)
coordinator._test_expireReconnectRetries()
#expect(coordinator.retainedReconnectRetryCount == 0)
#expect(coordinator.retainedReconnectRetryBytes == 0)
#expect(coordinator.transferIdToMessageIDs.isEmpty)
#expect(coordinator.messageIDToTransferId.isEmpty)
#expect(context.privateFileSends.count == 1)
#expect(context.privateFileReceiptRetryTransferIDs.isEmpty)
#expect(context.deliveryStatusUpdates.filter {
if case .failed = $0.status { return true }
return false
}.count == failedBeforePanic)
}
@Test @MainActor
func retryCountAndRetentionTimeEndInVisibleFailure() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.supportsAuthenticatedPrivateMediaReceipts = true
let clock = MutableMediaRetryClock(
Date(timeIntervalSince1970: 4_000)
)
let limits = PrivateMediaReconnectRetryLimits(
maxRetainedPackets: 2,
maxRetainedBytes: 1_024,
maxRetriesPerMessage: 1,
retentionSeconds: 10,
maxRetriesPerReconnect: 1
)
let fileName = "voice_4444555566667777.m4a"
let preparer = StaticVoiceNotePreparer(fileName: fileName)
let transferIDs = DeterministicMediaTransferIDFactory()
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in
try preparer.prepare(url)
},
reconnectRetryLimits: limits,
now: clock.now,
transferIDFactory: transferIDs.make
)
let url = try makeCoordinatorVoiceURL(fileName: fileName)
defer {
try? FileManager.default.removeItem(
at: url.deletingLastPathComponent()
)
}
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil(
{ context.privateFileSends.count == 1 },
timeout: TestConstants.longTimeout
))
let initial = try #require(context.privateFileSends.first)
coordinator.handleTransferEvent(.completed(
id: initial.transferId,
totalFragments: 1
))
coordinator.peerDidReconnect(peerID)
let retryID = try #require(
context.privateFileReceiptRetryTransferIDs.first
)
coordinator.handleTransferEvent(.cancelled(
id: retryID,
sentFragments: 0,
totalFragments: 1
))
#expect(coordinator.retainedReconnectRetryCount == 0)
#expect(context.removedMessages.isEmpty)
#expect(context.deliveryStatusUpdates.contains {
$0.messageID.hasPrefix("media-")
&& $0.status == .failed(reason: String(
localized: "content.delivery.reason.not_delivered",
defaultValue: "Not delivered",
comment: "Failure reason shown when a private media transfer could not finish"
))
})
// A separate retained row that locally completed but never received a
// remote receipt expires to a distinct visible failure.
let ttlFileName = "voice_5555666677778888.m4a"
let ttlPreparer = StaticVoiceNotePreparer(
fileName: ttlFileName
)
let ttlCoordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in
try ttlPreparer.prepare(url)
},
reconnectRetryLimits: limits,
now: clock.now,
transferIDFactory: transferIDs.make
)
let ttlURL = try makeCoordinatorVoiceURL(
fileName: ttlFileName
)
defer {
try? FileManager.default.removeItem(
at: ttlURL.deletingLastPathComponent()
)
}
let sendsBeforeTTL = context.privateFileSends.count
ttlCoordinator.sendVoiceNote(at: ttlURL)
#expect(await TestHelpers.waitUntil(
{
context.privateFileSends.count
== sendsBeforeTTL + 1
},
timeout: TestConstants.longTimeout
))
let ttlInitial = try #require(context.privateFileSends.last)
ttlCoordinator.handleTransferEvent(.completed(
id: ttlInitial.transferId,
totalFragments: 1
))
clock.advance(by: 10)
ttlCoordinator._test_expireReconnectRetries()
#expect(ttlCoordinator.retainedReconnectRetryCount == 0)
#expect(context.deliveryStatusUpdates.contains {
$0.status == .failed(
reason: String(
localized:
"content.delivery.reason.private_media_delivery_unconfirmed",
defaultValue: "Delivery could not be confirmed"
)
)
})
}
@Test @MainActor
func retentionAndPerReconnectWorkAreBounded() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.supportsAuthenticatedPrivateMediaReceipts = true
let limits = PrivateMediaReconnectRetryLimits(
maxRetainedPackets: 2,
maxRetainedBytes: 10,
maxRetriesPerMessage: 2,
retentionSeconds: 120,
maxRetriesPerReconnect: 1
)
let transferIDs = DeterministicMediaTransferIDFactory()
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in
let content = Data("voice".utf8)
return BitchatFilePacket(
fileName: url.lastPathComponent,
fileSize: UInt64(content.count),
mimeType: "audio/mp4",
content: content
)
},
reconnectRetryLimits: limits,
transferIDFactory: transferIDs.make
)
let fileNames = [
"voice_6666777788889999.m4a",
"voice_777788889999aaaa.m4a",
"voice_88889999aaaabbbb.m4a"
]
var roots: [URL] = []
defer {
for root in roots {
try? FileManager.default.removeItem(at: root)
}
}
for fileName in fileNames {
let url = try makeCoordinatorVoiceURL(
fileName: fileName,
bytes: Data("voice".utf8)
)
roots.append(url.deletingLastPathComponent())
// The production preparer preserves this stable filename.
coordinator.sendVoiceNote(at: url)
}
#expect(await TestHelpers.waitUntil(
{ context.privateFileSends.count == 3 },
timeout: TestConstants.longTimeout
))
#expect(coordinator.retainedReconnectRetryCount == 2)
#expect(coordinator.retainedReconnectRetryBytes <= 10)
for send in context.privateFileSends {
coordinator.handleTransferEvent(.completed(
id: send.transferId,
totalFragments: 1
))
}
coordinator.peerDidReconnect(peerID)
#expect(context.privateFileReceiptRetryTransferIDs.count == 1)
}
@Test @MainActor
func userCancellationReleasesRetainedBytesAndIgnoresLateEvent() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.supportsAuthenticatedPrivateMediaReceipts = true
let fileName = "voice_9999aaaabbbbcccc.m4a"
let preparer = StaticVoiceNotePreparer(fileName: fileName)
let coordinator = ChatMediaTransferCoordinator(
context: context,
prepareVoiceNotePacket: { url in
try preparer.prepare(url)
}
)
let url = try makeCoordinatorVoiceURL(fileName: fileName)
defer {
try? FileManager.default.removeItem(
at: url.deletingLastPathComponent()
)
}
coordinator.sendVoiceNote(at: url)
#expect(await TestHelpers.waitUntil(
{ context.privateFileSends.count == 1 },
timeout: TestConstants.longTimeout
))
let messageID = try #require(
context.privateChats[peerID]?.first?.id
)
let transferID = try #require(
context.privateFileSends.first?.transferId
)
coordinator.cancelMediaSend(messageID: messageID)
coordinator.handleTransferEvent(.cancelled(
id: transferID,
sentFragments: 0,
totalFragments: 1
))
#expect(coordinator.retainedReconnectRetryCount == 0)
#expect(coordinator.retainedReconnectRetryBytes == 0)
#expect(context.cancelledTransfers == [transferID])
#expect(context.removedMessages.map(\.messageID) == [
messageID
])
#expect(!context.deliveryStatusUpdates.contains {
if case .failed = $0.status { return true }
return false
})
}
}
private func makeCoordinatorVoiceURL(
fileName: String,
bytes: Data = Data("voice".utf8)
) throws -> URL {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent(
"media-retry-\(UUID().uuidString)",
isDirectory: true
)
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
let url = directory.appendingPathComponent(fileName)
try bytes.write(to: url)
return url
}
private final class PausedImagePreparer: @unchecked Sendable {
@@ -243,7 +243,7 @@ private func drainMainQueue() async {
/// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no
/// `ChatViewModel`. Scoped to the inbound event pipeline (dedup, presence,
/// public-message ingest), gift-wrap DM ingest, key mapping, channel-switch
/// public-message ingest), private-envelope ingest, key mapping, channel-switch
/// teardown, embedded ack flows, and now that favorites and notifications
/// are injected through the context the favorite-notification ingest and
/// the sampled-geohash notification cooldown. Flows that hit live singletons
@@ -335,7 +335,7 @@ struct ChatNostrCoordinatorContextTests {
}
@Test @MainActor
func handleGiftWrap_routesEmbeddedPrivateMessageAndDeduplicates() async throws {
func handlePrivateEnvelope_routesEmbeddedPrivateMessageAndDeduplicates() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
@@ -346,20 +346,22 @@ struct ChatNostrCoordinatorContextTests {
messageID: "gm-1",
senderPeerID: PeerID(str: "aabbccddeeff0011")
))
let giftWrap = try NostrProtocol.createPrivateMessage(
let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
content: embedded,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
for envelope in envelopes {
coordinator.inbound.handlePrivateEnvelope(envelope, 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.recordedNostrEventIDs == envelopes.map(\.id))
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
// The primary and compatibility envelopes carry the same authenticated
// embedded payload and must invoke message side effects only once.
#expect(context.handledPrivateMessages.count == 1)
#expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex)
#expect(context.handledPrivateMessages.first?.convKey == convKey)
@@ -370,80 +372,84 @@ struct ChatNostrCoordinatorContextTests {
#expect(pm.messageID == "gm-1")
#expect(pm.content == "psst")
// The same gift wrap is dropped on replay.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
await drainMainQueue()
#expect(context.recordedNostrEventIDs == [giftWrap.id])
// The same private envelope is dropped on replay.
coordinator.inbound.handlePrivateEnvelope(envelopes[0], id: recipient)
#expect(context.recordedNostrEventIDs == envelopes.map(\.id))
#expect(context.handledPrivateMessages.count == 1)
}
@Test @MainActor
func handleGiftWrap_panicWipeAfterSpawnDropsDecryptedResult() async throws {
func migrationEnvelopePairs_processEachMessageAndAckOnlyOnce() 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",
let messageContent = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content: "migration message",
messageID: "migration-message-id",
senderPeerID: PeerID(str: "aabbccddeeff0011")
))
let giftWrap = try NostrProtocol.createPrivateMessage(
content: embedded,
let deliveredContent = try #require(NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
type: .delivered,
messageID: "migration-ack-id",
senderPeerID: PeerID(str: "aabbccddeeff0011")
))
let readContent = try #require(NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
type: .readReceipt,
messageID: "migration-ack-id",
senderPeerID: PeerID(str: "aabbccddeeff0011")
))
for content in [messageContent, deliveredContent, readContent] {
let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
#expect(envelopes.count == 2)
for envelope in envelopes {
coordinator.inbound.handlePrivateEnvelope(envelope, id: recipient)
}
}
#expect(context.handledPrivateMessages.count == 1)
#expect(context.handledDelivered.count == 1)
#expect(context.handledReadReceipts.count == 1)
// A later same-format re-envelope is a delivery retry, not the
// migration twin, so it must still reach downstream message-ID dedup
// and acknowledgement resend logic.
let primaryRetry = try NostrProtocol.createPrivateEnvelope(
content: messageContent,
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)
coordinator.inbound.handlePrivateEnvelope(primaryRetry, id: recipient)
#expect(context.handledPrivateMessages.count == 2)
}
// 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 {
func processAccountPrivateEnvelope_invalidSignatureDoesNotPoisonDedup() 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(
let envelope = try NostrProtocol.createPrivateEnvelope(
content: "verify:noop",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
var invalidEnvelope = envelope
invalidEnvelope.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.processAccountPrivateEnvelope(invalidEnvelope)
#expect(context.recordedNostrEventIDs.isEmpty)
await coordinator.inbound.processNostrMessage(giftWrap)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
// ...so the genuine event with the same ID still processes and records.
await coordinator.inbound.processAccountPrivateEnvelope(envelope)
#expect(context.recordedNostrEventIDs == [envelope.id])
}
@Test @MainActor
@@ -176,6 +176,7 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
private(set) var geoPrivateMessages: [(content: String, recipientHex: String, messageID: String)] = []
private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = []
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
var geohashPrivateMessageAccepted = true
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
routedPrivateMessages.append((content, peerID, messageID))
@@ -191,8 +192,14 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
meshReadReceipts.append((receipt.originalMessageID, peerID))
}
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
func sendGeohashPrivateMessage(
_ content: String,
toRecipientHex recipientHex: String,
from identity: NostrIdentity,
messageID: String
) -> Bool {
geoPrivateMessages.append((content, recipientHex, messageID))
return geohashPrivateMessageAccepted
}
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
@@ -279,6 +286,11 @@ private func isRead(_ status: DeliveryStatus?, by expected: String) -> Bool {
return false
}
private func isFailed(_ status: DeliveryStatus?) -> Bool {
if case .failed = status { return true }
return false
}
private func makeFavoriteRelationship(
noiseKey: Data,
nostrPublicKey: String? = nil,
@@ -412,6 +424,25 @@ struct ChatPrivateConversationCoordinatorContextTests {
#expect(context.privateChats[convKey]?.count == 1)
}
@Test @MainActor
func sendGeohashDM_rejectedAdmissionNeverBecomesSent() async {
let context = MockChatPrivateConversationContext()
let coordinator = ChatPrivateConversationCoordinator(context: context)
let recipientHex = String(repeating: "33", count: 32)
let peerID = PeerID(nostr_: recipientHex)
context.activeChannel = .location(
GeohashChannel(level: .city, geohash: "u4pruy")
)
context.nostrKeyMapping[peerID] = recipientHex
context.geohashPrivateMessageAccepted = false
coordinator.sendGeohashDM("rejected", to: peerID)
#expect(context.geoPrivateMessages.map(\.content) == ["rejected"])
#expect(context.privateChats[peerID]?.count == 1)
#expect(isFailed(context.privateChats[peerID]?.first?.deliveryStatus))
}
@Test @MainActor
func handleViewingThisChat_clearsUnreadAndSendsRoutedReadReceiptOnce() async {
let context = MockChatPrivateConversationContext()
@@ -97,6 +97,7 @@ private final class MockChatVerificationContext: ChatVerificationContext {
var noiseSessionKeysByPeerID: [PeerID: Data] = [:]
private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)?
private(set) var triggeredHandshakes: [PeerID] = []
private(set) var privateMediaAuthenticatedPeers: [PeerID] = []
private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
private(set) var sentResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
@@ -113,6 +114,9 @@ private final class MockChatVerificationContext: ChatVerificationContext {
establishedNoiseSessions.contains(peerID)
}
func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) }
func privateMediaPeerDidAuthenticate(_ peerID: PeerID) {
privateMediaAuthenticatedPeers.append(peerID)
}
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
sentChallenges.append((peerID, noiseKeyHex, nonceA))
@@ -277,6 +281,7 @@ struct ChatVerificationCoordinatorContextTests {
#expect(context.encryptionStatuses[peerID] == .noiseVerified)
#expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey))
#expect(context.invalidatedEncryptionCachePeers.contains(peerID))
#expect(context.privateMediaAuthenticatedPeers == [peerID])
// Handshake required -> handshaking status.
callbacks?.onHandshakeRequired(peerID)
+117 -40
View File
@@ -366,27 +366,52 @@ 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 {
func privateEnvelope_rejectsOversizedEmbeddedPacketBeforePublication() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let oversized = Data(repeating: 0x41, count: FileTransferLimits.maxFramedFileBytes + 1)
let content = "bitchat1:" + base64URLEncode(oversized)
let giftWrap = try NostrProtocol.createPrivateMessage(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
do {
_ = try NostrProtocol.createPrivateEnvelope(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
Issue.record("Expected oversized private-envelope plaintext to be rejected")
} catch NostrError.invalidCiphertext {
// Rejected before encryption/publication, as intended.
} catch {
Issue.record("Expected NostrError.invalidCiphertext, got \(error)")
}
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(viewModel.privateChats.isEmpty)
@@ -431,7 +456,7 @@ struct ChatViewModelNostrExtensionTests {
}
@Test @MainActor
func subscribeGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
func subscribePrivateEnvelope_deliveredAckUpdatesExistingMessage() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
@@ -453,13 +478,13 @@ struct ChatViewModelNostrExtensionTests {
], for: convKey)
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
let giftWrap = try NostrProtocol.createPrivateMessage(
let envelope = try NostrProtocol.createPrivateEnvelope(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
viewModel.subscribePrivateEnvelope(envelope, id: recipient)
let didUpdate = await TestHelpers.waitUntil(
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
@@ -469,7 +494,7 @@ struct ChatViewModelNostrExtensionTests {
}
@Test @MainActor
func subscribeGiftWrap_readAckUpdatesExistingMessage() async throws {
func subscribePrivateEnvelope_readAckUpdatesExistingMessage() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
@@ -491,13 +516,13 @@ struct ChatViewModelNostrExtensionTests {
], for: convKey)
let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
let giftWrap = try NostrProtocol.createPrivateMessage(
let envelope = try NostrProtocol.createPrivateEnvelope(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.subscribeGiftWrap(giftWrap, id: recipient)
viewModel.subscribePrivateEnvelope(envelope, id: recipient)
let didUpdate = await TestHelpers.waitUntil(
{ isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
@@ -507,28 +532,28 @@ struct ChatViewModelNostrExtensionTests {
}
@Test @MainActor
func handleGiftWrap_privateMessageStoresConversationAndMapping() async throws {
func handlePrivateEnvelope_privateMessageStoresConversationAndMapping() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let messageID = "gift-private"
let messageID = "envelope-private"
let convKey = PeerID(nostr_: sender.publicKeyHex)
let content = try privateMessageContent(
text: "Hello from gift wrap",
text: "Hello from private envelope",
messageID: messageID,
senderPeerID: PeerID(str: "0123456789abcdef")
)
let giftWrap = try NostrProtocol.createPrivateMessage(
let envelope = try NostrProtocol.createPrivateEnvelope(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.handleGiftWrap(giftWrap, id: recipient)
viewModel.handlePrivateEnvelope(envelope, id: recipient)
let didStore = await TestHelpers.waitUntil(
{ viewModel.privateChats[convKey]?.first?.content == "Hello from gift wrap" },
{ viewModel.privateChats[convKey]?.first?.content == "Hello from private envelope" },
timeout: 5.0
)
#expect(didStore)
@@ -537,11 +562,11 @@ struct ChatViewModelNostrExtensionTests {
}
@Test @MainActor
func handleGiftWrap_blockedSenderSkipsMessageStorage() async throws {
func handlePrivateEnvelope_blockedSenderSkipsMessageStorage() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let messageID = "gift-blocked"
let messageID = "envelope-blocked"
let convKey = PeerID(nostr_: sender.publicKeyHex)
viewModel.identityManager.setNostrBlocked(sender.publicKeyHex, isBlocked: true)
@@ -551,31 +576,26 @@ struct ChatViewModelNostrExtensionTests {
messageID: messageID,
senderPeerID: PeerID(str: "0123456789abcdef")
)
let giftWrap = try NostrProtocol.createPrivateMessage(
let envelope = try NostrProtocol.createPrivateEnvelope(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.handleGiftWrap(giftWrap, id: recipient)
viewModel.handlePrivateEnvelope(envelope, 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
func handleGiftWrap_deliveredAckUpdatesExistingMessage() async throws {
func handlePrivateEnvelope_deliveredAckUpdatesExistingMessage() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let convKey = PeerID(nostr_: sender.publicKeyHex)
let messageID = "gift-delivered"
let messageID = "envelope-delivered"
viewModel.seedPrivateChat([
BitchatMessage(
@@ -592,13 +612,13 @@ struct ChatViewModelNostrExtensionTests {
], for: convKey)
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
let giftWrap = try NostrProtocol.createPrivateMessage(
let envelope = try NostrProtocol.createPrivateEnvelope(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
viewModel.handleGiftWrap(giftWrap, id: recipient)
viewModel.handlePrivateEnvelope(envelope, id: recipient)
let didUpdate = await TestHelpers.waitUntil(
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
@@ -766,6 +786,63 @@ struct ChatViewModelGeoDMTests {
#expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus))
}
@Test @MainActor
func geohashTerminalRelayFailure_transitionsSentMessageToFailed() async throws {
let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let messageID = "geo-terminal-failure"
let convKey = PeerID(nostr_: recipient.publicKeyHex)
let message = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "queued geohash message",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "recipient",
senderPeerID: viewModel.myPeerID,
deliveryStatus: .sent
)
viewModel.seedPrivateChat([message], for: convKey)
var terminalFailure: (@MainActor () -> Void)?
let dependencies = NostrTransport.Dependencies(
notificationCenter: NotificationCenter(),
loadFavorites: { [:] },
favoriteStatusForNoiseKey: { _ in nil },
favoriteStatusForPeerID: { _ in nil },
currentIdentity: { sender },
registerPendingPrivateEnvelope: { _ in },
sendPrivateEnvelopeBatch: { _, failure in
terminalFailure = failure
return true
},
scheduleAfter: { _, _ in },
relayConnectivity: {
Just(false).eraseToAnyPublisher()
}
)
let transport = viewModel.makeGeohashNostrTransport(
dependencies: dependencies
)
let accepted = transport.sendPrivateMessageGeohash(
content: message.content,
toRecipientHex: recipient.publicKeyHex,
from: sender,
messageID: messageID
)
#expect(accepted)
#expect(viewModel.privateChats[convKey]?.first?.deliveryStatus == .sent)
let fail = try #require(terminalFailure)
fail()
#expect(isFailed(
status: viewModel.privateChats[convKey]?.first?.deliveryStatus
))
}
/// The blocked notice belongs in the DM thread the person is typing in,
/// not on the active location-channel timeline.
@Test @MainActor
+753
View File
@@ -1194,6 +1194,759 @@ struct ChatViewModelBluetoothTests {
}
}
// MARK: - Private Media Deletion Tests
struct ChatViewModelPrivateMediaDeletionTests {
@Test @MainActor
func deleteMediaMessageTombstonesIncomingButNotOutgoingStableMedia() {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: String(repeating: "8", count: 64))
let incomingID = "media-\(String(repeating: "e", count: 32))"
let outgoingID = "media-\(String(repeating: "f", count: 32))"
viewModel.seedPrivateChat([
privateMediaMessage(
id: incomingID,
sender: "Peer",
senderPeerID: peerID,
recipient: viewModel.nickname,
filename: "incoming.jpg"
),
privateMediaMessage(
id: outgoingID,
sender: viewModel.nickname,
senderPeerID: transport.myPeerID,
recipient: "Peer",
filename: "outgoing.jpg"
)
], for: peerID)
viewModel.deleteMediaMessage(messageID: outgoingID)
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
#expect(viewModel.privateChats[peerID]?.map(\.id) == [incomingID])
viewModel.deleteMediaMessage(messageID: incomingID)
#expect(
transport.deletedPrivateMediaMessageIDBatches == [[incomingID]]
)
#expect(
transport.deletedPrivateMediaRelativePaths
== [[incomingID: "images/incoming/incoming.jpg"]]
)
#expect((viewModel.privateChats[peerID] ?? []).isEmpty)
}
@Test @MainActor
func stableDeleteProtectsPathSharedWithLegacyBubble() {
let (viewModel, transport) = makeTestableViewModel()
transport.persistDeletedPrivateMediaResult = false
let peerID = PeerID(str: String(repeating: "6", count: 64))
let stableID = "media-\(String(repeating: "5", count: 32))"
let legacyID = UUID().uuidString
let filename = "shared-migration.jpg"
viewModel.seedPrivateChat([
privateMediaMessage(
id: stableID,
sender: "Peer",
senderPeerID: peerID,
recipient: viewModel.nickname,
filename: filename
),
privateMediaMessage(
id: legacyID,
sender: "Old client",
senderPeerID: peerID,
recipient: viewModel.nickname,
filename: filename
)
], for: peerID)
viewModel.deleteMediaMessage(messageID: stableID)
#expect(
transport.deletedPrivateMediaMessageIDBatches == [[stableID]]
)
#expect(transport.deletedPrivateMediaRelativePaths == [[:]])
#expect(
transport.protectedPrivateMediaRelativePaths == [[
"images/incoming/\(filename)"
]]
)
#expect(
viewModel.privateChats[peerID]?.map(\.id)
== [stableID, legacyID]
)
}
@Test @MainActor
func legacyIncomingDeleteLeavesAmbiguousPayloadForQuota() throws {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: String(repeating: "3", count: 64))
let incoming = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
.appendingPathComponent(
"files/images/incoming",
isDirectory: true
)
try FileManager.default.createDirectory(
at: incoming,
withIntermediateDirectories: true
)
let payload = incoming.appendingPathComponent(
"legacy-delete-\(UUID().uuidString).jpg"
)
try Data([0x01]).write(to: payload)
defer { try? FileManager.default.removeItem(at: payload) }
let legacyID = UUID().uuidString
viewModel.seedPrivateChat([
privateMediaMessage(
id: legacyID,
sender: "Old client",
senderPeerID: peerID,
recipient: viewModel.nickname,
filename: payload.lastPathComponent
)
], for: peerID)
viewModel.deleteMediaMessage(messageID: legacyID)
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
#expect((viewModel.privateChats[peerID] ?? []).isEmpty)
#expect(FileManager.default.fileExists(atPath: payload.path))
}
@Test @MainActor
func clearPrivateChatTombstonesIncomingAndCancelsOutgoingBeforeClear() {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: String(repeating: "1", count: 64))
let incomingID = "media-\(String(repeating: "a", count: 32))"
let outgoingID = "media-\(String(repeating: "b", count: 32))"
viewModel.seedPrivateChat([
privateMediaMessage(
id: incomingID,
sender: "Peer",
senderPeerID: peerID,
recipient: viewModel.nickname,
filename: "incoming.jpg"
),
privateMediaMessage(
id: outgoingID,
sender: viewModel.nickname,
senderPeerID: transport.myPeerID,
recipient: "Peer",
filename: "outgoing.jpg"
),
BitchatMessage(
id: "ordinary-message",
sender: "Peer",
content: "hello",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: viewModel.nickname,
senderPeerID: peerID
)
], for: peerID)
viewModel.registerTransfer(
transferId: "outgoing-clear-transfer",
messageID: outgoingID
)
viewModel.clearPrivateChat(peerID)
#expect(
transport.deletedPrivateMediaMessageIDBatches == [[incomingID]]
)
#expect(
transport.deletedPrivateMediaRelativePaths
== [[incomingID: "images/incoming/incoming.jpg"]]
)
#expect(
transport.cancelledTransfers == ["outgoing-clear-transfer"]
)
#expect(viewModel.messageIDToTransferId[outgoingID] == nil)
#expect(viewModel.privateChats[peerID]?.isEmpty == true)
}
@Test @MainActor
func clearPrivateChatPreservesCapturedMessagesWhenTombstoneFails() {
let (viewModel, transport) = makeTestableViewModel()
transport.persistDeletedPrivateMediaResult = false
let peerID = PeerID(str: String(repeating: "2", count: 64))
let incomingID = "media-\(String(repeating: "c", count: 32))"
let outgoingID = "media-\(String(repeating: "7", count: 32))"
viewModel.seedPrivateChat([
privateMediaMessage(
id: incomingID,
sender: "Peer",
senderPeerID: peerID,
recipient: viewModel.nickname,
filename: "incoming.jpg"
),
privateMediaMessage(
id: outgoingID,
sender: viewModel.nickname,
senderPeerID: transport.myPeerID,
recipient: "Peer",
filename: "outgoing.jpg"
),
BitchatMessage(
id: "ordinary-message",
sender: "Peer",
content: "keep me on failure",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: viewModel.nickname,
senderPeerID: peerID
)
], for: peerID)
viewModel.registerTransfer(
transferId: "failed-clear-outgoing",
messageID: outgoingID
)
viewModel.clearPrivateChat(peerID)
#expect(
transport.deletedPrivateMediaMessageIDBatches == [[incomingID]]
)
#expect(
viewModel.privateChats[peerID]?.map(\.id)
== [incomingID, outgoingID, "ordinary-message"]
)
#expect(transport.cancelledTransfers == ["failed-clear-outgoing"])
#expect(viewModel.messageIDToTransferId[outgoingID] == nil)
}
@Test @MainActor
func clearFailurePreservesSameNameIncomingPayload() throws {
let (viewModel, transport) = makeTestableViewModel()
transport.persistDeletedPrivateMediaResult = false
let peerID = PeerID(str: String(repeating: "7", count: 64))
let incomingID = "media-\(String(repeating: "8", count: 32))"
let outgoingID = "media-\(String(repeating: "9", count: 32))"
let filename = "clear-collision-\(UUID().uuidString).jpg"
let filesDirectory = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
.appendingPathComponent("files/images", isDirectory: true)
let incomingDirectory = filesDirectory.appendingPathComponent(
"incoming",
isDirectory: true
)
let outgoingDirectory = filesDirectory.appendingPathComponent(
"outgoing",
isDirectory: true
)
try FileManager.default.createDirectory(
at: incomingDirectory,
withIntermediateDirectories: true
)
try FileManager.default.createDirectory(
at: outgoingDirectory,
withIntermediateDirectories: true
)
let incomingURL = incomingDirectory.appendingPathComponent(filename)
let outgoingURL = outgoingDirectory.appendingPathComponent(filename)
try Data("incoming".utf8).write(to: incomingURL, options: .atomic)
try Data("outgoing".utf8).write(to: outgoingURL, options: .atomic)
defer {
try? FileManager.default.removeItem(at: incomingURL)
try? FileManager.default.removeItem(at: outgoingURL)
}
viewModel.seedPrivateChat([
privateMediaMessage(
id: incomingID,
sender: "Peer",
senderPeerID: peerID,
recipient: viewModel.nickname,
filename: filename
),
privateMediaMessage(
id: outgoingID,
sender: viewModel.nickname,
senderPeerID: transport.myPeerID,
recipient: "Peer",
filename: filename
)
], for: peerID)
viewModel.clearPrivateChat(peerID)
#expect(FileManager.default.fileExists(atPath: incomingURL.path))
#expect(FileManager.default.fileExists(atPath: outgoingURL.path))
#expect(
transport.deletedPrivateMediaRelativePaths
== [[incomingID: "images/incoming/\(filename)"]]
)
#expect(
viewModel.privateChats[peerID]?.map(\.id)
== [incomingID, outgoingID]
)
}
@Test @MainActor
func clearPrivateChatPreservesArrivalDuringTombstoneIO() {
let (viewModel, transport) = makeTestableViewModel()
transport.deferDeletedPrivateMediaPersistence = true
let peerID = PeerID(str: String(repeating: "3", count: 64))
let incomingID = "media-\(String(repeating: "d", count: 32))"
viewModel.seedPrivateChat([
privateMediaMessage(
id: incomingID,
sender: "Peer",
senderPeerID: peerID,
recipient: viewModel.nickname,
filename: "incoming.jpg"
),
BitchatMessage(
id: "captured-text",
sender: "Peer",
content: "old",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: viewModel.nickname,
senderPeerID: peerID
)
], for: peerID)
viewModel.clearPrivateChat(peerID)
let arrival = BitchatMessage(
id: "concurrent-arrival",
sender: "Peer",
content: "new",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: viewModel.nickname,
senderPeerID: peerID
)
#expect(viewModel.appendPrivateMessage(arrival, to: peerID))
transport.resolveNextDeletedPrivateMediaPersistence(true)
#expect(
viewModel.privateChats[peerID]?.map(\.id)
== ["concurrent-arrival"]
)
}
@Test @MainActor
func clearPrivateChatPreservesActiveLiveVoiceAssembly() throws {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: String(repeating: "7", count: 64))
viewModel.selectedPrivateChatPeer = peerID
let burstID = Data(
repeating: 0xE1,
count: VoiceBurstPacket.burstIDSize
)
let start = try #require(VoiceBurstPacket(
burstID: burstID,
seq: 0,
kind: .start(codec: .aacLC16kMono)
))
let cancel = try #require(VoiceBurstPacket(
burstID: burstID,
seq: 1,
kind: .canceled
))
let coordinator = viewModel.liveVoiceCoordinator
defer {
coordinator.handleVoiceFramePayload(
from: peerID,
payload: cancel.encode(),
timestamp: Date()
)
}
coordinator.handleVoiceFramePayload(
from: peerID,
payload: start.encode(),
timestamp: Date()
)
let liveMessage = try #require(
viewModel.privateChats[peerID]?.first
)
#expect(coordinator.isLiveVoiceMessage(liveMessage))
let ordinary = BitchatMessage(
id: "clear-around-live-voice",
sender: "Peer",
content: "old text",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: viewModel.nickname,
senderPeerID: peerID
)
#expect(viewModel.appendPrivateMessage(ordinary, to: peerID))
viewModel.clearPrivateChat(peerID)
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
#expect(
viewModel.privateChats[peerID]?.map(\.id)
== [liveMessage.id]
)
#expect(coordinator.isLiveVoiceMessage(liveMessage))
}
@Test @MainActor
func overlappingClearsTombstoneTheLastMirroredStableAlias() {
let (viewModel, transport) = makeTestableViewModel()
transport.deferDeletedPrivateMediaPersistence = true
let firstPeerID = PeerID(str: String(repeating: "b", count: 64))
let secondPeerID = PeerID(str: String(repeating: "c", count: 64))
let sharedID = "media-\(String(repeating: "1", count: 32))"
let firstUniqueID = "media-\(String(repeating: "2", count: 32))"
let secondUniqueID = "media-\(String(repeating: "3", count: 32))"
viewModel.seedPrivateChat([
privateMediaMessage(
id: sharedID,
sender: "Peer",
senderPeerID: firstPeerID,
recipient: viewModel.nickname,
filename: "shared.jpg"
),
privateMediaMessage(
id: firstUniqueID,
sender: "Peer",
senderPeerID: firstPeerID,
recipient: viewModel.nickname,
filename: "first.jpg"
)
], for: firstPeerID)
viewModel.seedPrivateChat([
privateMediaMessage(
id: sharedID,
sender: "Peer",
senderPeerID: secondPeerID,
recipient: viewModel.nickname,
filename: "shared.jpg"
),
privateMediaMessage(
id: secondUniqueID,
sender: "Peer",
senderPeerID: secondPeerID,
recipient: viewModel.nickname,
filename: "second.jpg"
)
], for: secondPeerID)
viewModel.clearPrivateChat(firstPeerID)
viewModel.clearPrivateChat(secondPeerID)
let queuedArrival = BitchatMessage(
id: "arrival-after-queued-clear",
sender: "Peer",
content: "new",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: viewModel.nickname,
senderPeerID: secondPeerID
)
#expect(viewModel.appendPrivateMessage(
queuedArrival,
to: secondPeerID
))
#expect(
transport.deletedPrivateMediaMessageIDBatches
== [[firstUniqueID]]
)
transport.resolveNextDeletedPrivateMediaPersistence(true)
#expect(
transport.deletedPrivateMediaMessageIDBatches == [
[firstUniqueID],
[secondUniqueID, sharedID].sorted()
]
)
transport.resolveNextDeletedPrivateMediaPersistence(true)
#expect((viewModel.privateChats[firstPeerID] ?? []).isEmpty)
#expect(
viewModel.privateChats[secondPeerID]?.map(\.id)
== ["arrival-after-queued-clear"]
)
}
@Test @MainActor
func clearFollowsCapturedRowsAcrossPeerIdentityMigration() {
let (viewModel, transport) = makeTestableViewModel()
transport.deferDeletedPrivateMediaPersistence = true
let sourcePeerID = PeerID(str: String(repeating: "d", count: 64))
let destinationPeerID = PeerID(str: String(repeating: "e", count: 64))
let thirdPeerID = PeerID(str: String(repeating: "f", count: 64))
let stableID = "media-\(String(repeating: "4", count: 32))"
viewModel.selectedPrivateChatPeer = sourcePeerID
viewModel.seedPrivateChat([
privateMediaMessage(
id: stableID,
sender: "Peer",
senderPeerID: sourcePeerID,
recipient: viewModel.nickname,
filename: "migrated.jpg"
),
BitchatMessage(
id: "captured-before-migration",
sender: "Peer",
content: "old",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: viewModel.nickname,
senderPeerID: sourcePeerID
)
], for: sourcePeerID)
viewModel.clearPrivateChat(sourcePeerID)
viewModel.migratePrivateChat(
from: sourcePeerID,
to: destinationPeerID
)
viewModel.selectedPrivateChatPeer = thirdPeerID
let arrival = BitchatMessage(
id: "arrival-after-migration",
sender: "Peer",
content: "new",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: viewModel.nickname,
senderPeerID: destinationPeerID
)
#expect(viewModel.appendPrivateMessage(
arrival,
to: destinationPeerID
))
transport.resolveNextDeletedPrivateMediaPersistence(true)
#expect(
viewModel.privateChats[destinationPeerID]?.map(\.id)
== ["arrival-after-migration"]
)
}
@Test @MainActor
func clearFollowsMigrationWhenOldSourceIsRecreated() {
let (viewModel, transport) = makeTestableViewModel()
transport.deferDeletedPrivateMediaPersistence = true
let sourcePeerID = PeerID(str: String(repeating: "1", count: 64))
let destinationPeerID = PeerID(str: String(repeating: "2", count: 64))
let stableID = "media-\(String(repeating: "6", count: 32))"
viewModel.seedPrivateChat([
privateMediaMessage(
id: stableID,
sender: "Peer",
senderPeerID: sourcePeerID,
recipient: viewModel.nickname,
filename: "migrated-recreated.jpg"
),
BitchatMessage(
id: "captured-before-recreation",
sender: "Peer",
content: "old",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: viewModel.nickname,
senderPeerID: sourcePeerID
)
], for: sourcePeerID)
viewModel.clearPrivateChat(sourcePeerID)
viewModel.migratePrivateChat(
from: sourcePeerID,
to: destinationPeerID
)
let recreatedArrival = BitchatMessage(
id: "arrival-recreating-source",
sender: "Peer",
content: "new",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: viewModel.nickname,
senderPeerID: sourcePeerID
)
#expect(viewModel.appendPrivateMessage(
recreatedArrival,
to: sourcePeerID
))
transport.resolveNextDeletedPrivateMediaPersistence(true)
#expect(
(viewModel.privateChats[destinationPeerID] ?? []).isEmpty
)
#expect(
viewModel.privateChats[sourcePeerID]?.map(\.id)
== ["arrival-recreating-source"]
)
}
@Test @MainActor
func clearPrivateChatKeepsMediaReferencedByAnotherConversation() {
let (viewModel, transport) = makeTestableViewModel()
let firstPeerID = PeerID(str: String(repeating: "4", count: 64))
let aliasPeerID = PeerID(str: String(repeating: "5", count: 64))
let messageID = "media-\(String(repeating: "6", count: 32))"
let message = privateMediaMessage(
id: messageID,
sender: "Peer",
senderPeerID: firstPeerID,
recipient: viewModel.nickname,
filename: "mirrored.jpg"
)
viewModel.seedPrivateChat([message], for: firstPeerID)
viewModel.seedPrivateChat([message], for: aliasPeerID)
viewModel.clearPrivateChat(firstPeerID)
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
#expect(viewModel.privateChats[firstPeerID]?.isEmpty == true)
#expect(
viewModel.privateChats[aliasPeerID]?.map(\.id) == [messageID]
)
}
@Test @MainActor
func clearPrivateChatLeavesAmbiguousLegacyFileForQuotaCleanup()
throws {
let (viewModel, transport) = makeTestableViewModel()
let firstPeerID = PeerID(str: String(repeating: "9", count: 64))
let aliasPeerID = PeerID(str: String(repeating: "a", count: 64))
let incomingDirectory = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
.appendingPathComponent("files/images/incoming", isDirectory: true)
try FileManager.default.createDirectory(
at: incomingDirectory,
withIntermediateDirectories: true
)
let fileURL = incomingDirectory.appendingPathComponent(
"legacy-clear-\(UUID().uuidString).jpg"
)
try Data("legacy-image".utf8).write(to: fileURL, options: .atomic)
defer { try? FileManager.default.removeItem(at: fileURL) }
let message = privateMediaMessage(
id: UUID().uuidString,
sender: "Old client",
senderPeerID: firstPeerID,
recipient: viewModel.nickname,
filename: fileURL.lastPathComponent
)
viewModel.seedPrivateChat([message], for: firstPeerID)
viewModel.seedPrivateChat([message], for: aliasPeerID)
viewModel.clearPrivateChat(firstPeerID)
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
#expect(FileManager.default.fileExists(atPath: fileURL.path))
#expect(viewModel.privateChats[aliasPeerID]?.map(\.id) == [message.id])
viewModel.clearPrivateChat(aliasPeerID)
#expect(FileManager.default.fileExists(atPath: fileURL.path))
#expect((viewModel.privateChats[aliasPeerID] ?? []).isEmpty)
}
@Test @MainActor
func panicInvalidatesActiveAndQueuedPrivateChatClears() {
let (viewModel, transport) = makeTestableViewModel()
transport.deferDeletedPrivateMediaPersistence = true
let firstPeerID = PeerID(str: String(repeating: "4", count: 64))
let secondPeerID = PeerID(str: String(repeating: "5", count: 64))
let firstID = "media-\(String(repeating: "6", count: 32))"
let secondID = "media-\(String(repeating: "7", count: 32))"
viewModel.seedPrivateChat([
privateMediaMessage(
id: firstID,
sender: "First",
senderPeerID: firstPeerID,
recipient: viewModel.nickname,
filename: "first-pre-panic.jpg"
)
], for: firstPeerID)
viewModel.seedPrivateChat([
privateMediaMessage(
id: secondID,
sender: "Second",
senderPeerID: secondPeerID,
recipient: viewModel.nickname,
filename: "second-pre-panic.jpg"
)
], for: secondPeerID)
viewModel.clearPrivateChat(firstPeerID)
viewModel.clearPrivateChat(secondPeerID)
#expect(
transport.deletedPrivateMediaMessageIDBatches == [[firstID]]
)
_ = viewModel.panicClearAllData(restartServices: false)
viewModel.seedPrivateChat([
privateMediaMessage(
id: firstID,
sender: "First",
senderPeerID: firstPeerID,
recipient: viewModel.nickname,
filename: "first-post-panic.jpg"
)
], for: firstPeerID)
viewModel.seedPrivateChat([
privateMediaMessage(
id: secondID,
sender: "Second",
senderPeerID: secondPeerID,
recipient: viewModel.nickname,
filename: "second-post-panic.jpg"
)
], for: secondPeerID)
transport.resolveNextDeletedPrivateMediaPersistence(true)
#expect(
transport.deletedPrivateMediaMessageIDBatches == [[firstID]]
)
#expect(viewModel.privateChats[firstPeerID]?.map(\.id) == [firstID])
#expect(viewModel.privateChats[secondPeerID]?.map(\.id) == [secondID])
}
private func privateMediaMessage(
id: String,
sender: String,
senderPeerID: PeerID,
recipient: String,
filename: String
) -> BitchatMessage {
BitchatMessage(
id: id,
sender: sender,
content: "\(MimeType.Category.image.messagePrefix)\(filename)",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: recipient,
senderPeerID: senderPeerID
)
}
}
// MARK: - Panic Clear Tests
struct ChatViewModelPanicTests {
-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")
@@ -364,6 +364,217 @@ struct PrivateMediaEndToEndTests {
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent)
}
@Test
func privateMediaRetryRequiresExactAuthenticatedBit9Proof() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent(
"private-media-receipt-proof-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(
baseDirectory: root.appendingPathComponent(
"alice",
isDirectory: true
)
)
let bob = makeService(
baseDirectory: root.appendingPathComponent(
"bob",
isDirectory: true
)
)
let bothCapabilities: PeerCapabilities = [
.privateMedia,
.privateMediaReceipts
]
// A public bit-9 announce is discovery only.
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Bob",
capabilities: bothCapabilities,
noisePublicKey: bob.noiseStaticPublicKeyData()
)
#expect(
alice.authenticatedPrivateMediaReceiptSessionGeneration(
to: bob.myPeerID
) == nil
)
let proofs = try await establishSessionCapturingPeerState(
alice: alice,
bob: bob
)
#expect(
alice.authenticatedPrivateMediaReceiptSessionGeneration(
to: bob.myPeerID
) == nil
)
// Bit 8 alone preserves encrypted transfer compatibility but cannot
// authorize automatic resend.
let privateMediaOnly = try authenticatedPeerStatePacket(
from: bob,
to: alice,
capabilities: .privateMedia
)
alice._test_handlePacket(
privateMediaOnly,
fromPeerID: bob.myPeerID
)
#expect(await TestHelpers.waitUntil(
{
alice.privateMediaSendPolicy(to: bob.myPeerID)
== .encrypted
},
timeout: TestConstants.longTimeout
))
#expect(
alice.authenticatedPrivateMediaReceiptSessionGeneration(
to: bob.myPeerID
) == nil
)
let receiptCapable = try authenticatedPeerStatePacket(
from: bob,
to: alice,
capabilities: bothCapabilities
)
alice._test_handlePacket(
receiptCapable,
fromPeerID: bob.myPeerID
)
#expect(await TestHelpers.waitUntil(
{
alice.authenticatedPrivateMediaReceiptSessionGeneration(
to: bob.myPeerID
) != nil
},
timeout: TestConstants.longTimeout
))
bob._test_handlePacket(
proofs.alice,
fromPeerID: alice.myPeerID
)
alice._test_onOutboundPacket = nil
bob._test_onOutboundPacket = nil
}
@Test
func receiptRetryRechecksBit9AtDeferredTransportBoundary() async throws {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent(
"private-media-retry-proof-race-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: root) }
let alice = makeService(
baseDirectory: root.appendingPathComponent(
"alice",
isDirectory: true
)
)
let bob = makeService(
baseDirectory: root.appendingPathComponent(
"bob",
isDirectory: true
)
)
let receiptCapabilities: PeerCapabilities = [
.privateMedia,
.privateMediaReceipts
]
alice._test_seedConnectedPeer(
bob.myPeerID,
nickname: "Bob",
capabilities: receiptCapabilities,
noisePublicKey: bob.noiseStaticPublicKeyData()
)
bob._test_seedConnectedPeer(
alice.myPeerID,
nickname: "Alice",
capabilities: receiptCapabilities,
noisePublicKey: alice.noiseStaticPublicKeyData()
)
try await establishSession(alice: alice, bob: bob)
#expect(
alice.authenticatedPrivateMediaReceiptSessionGeneration(
to: bob.myPeerID
) != nil
)
let privateMediaOnly = try authenticatedPeerStatePacket(
from: bob,
to: alice,
capabilities: .privateMedia
)
let transferID =
"receipt-proof-race-\(UUID().uuidString)"
let tap = PacketTap()
let boundaryProofs = ReceiptCapabilityRecorder()
let rejections = TransferCancellationRecorder()
let cancellable = TransferProgressManager.shared.publisher.sink {
rejections.record($0)
}
alice._test_onOutboundPacket = tap.record
alice._test_beforePrivateMediaDeferredSend = { id in
guard id == transferID else { return }
boundaryProofs.record(
alice
.authenticatedPrivateMediaReceiptSessionGeneration(
to: bob.myPeerID
) != nil
)
}
defer {
alice._test_beforePrivateMediaDeferredSend = nil
alice._test_onOutboundPacket = nil
}
// Rotate authenticated state before the deferred retry reaches its
// admission boundary.
alice._test_handlePacket(
privateMediaOnly,
fromPeerID: bob.myPeerID
)
let content = Data("%PDF-1.7\nreceipt-proof-race".utf8)
alice.sendFilePrivateReceiptRetry(
BitchatFilePacket(
fileName: "receipt-proof-race.pdf",
fileSize: UInt64(content.count),
mimeType: "application/pdf",
content: content
),
to: bob.myPeerID,
transferId: transferID
)
#expect(await TestHelpers.waitUntil(
{ boundaryProofs.snapshot() == [false] },
timeout: TestConstants.longTimeout
))
await alice._test_drainPrivateMediaSendPipeline()
#expect(await TestHelpers.waitUntil(
{ rejections.contains(transferID) },
timeout: TestConstants.longTimeout
))
#expect(tap.snapshot().allSatisfy {
$0.type != MessageType.fileTransfer.rawValue
&& !(
$0.type == MessageType.noiseEncrypted.rawValue
&& $0.version == 2
)
})
let state = alice._test_privateMediaTransferState(
transferId: transferID
)
#expect(!state.admissionActive)
#expect(!state.pendingNoise)
_ = cancellable
}
@Test
func capabilityAnnounceCannotPoisonPinWithoutMatchingNoiseAuthentication() async throws {
let root = FileManager.default.temporaryDirectory
@@ -746,7 +957,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) }
@@ -795,24 +1006,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
}
@@ -1407,6 +1608,23 @@ private final class PrivateMediaPolicyRecorder: @unchecked Sendable {
}
}
private final class ReceiptCapabilityRecorder: @unchecked Sendable {
private let lock = NSLock()
private var values: [Bool] = []
func record(_ value: Bool) {
lock.lock()
values.append(value)
lock.unlock()
}
func snapshot() -> [Bool] {
lock.lock()
defer { lock.unlock() }
return values
}
}
private final class MessageCaptureDelegate: BitchatDelegate, @unchecked Sendable {
private let lock = NSLock()
private var messages: [BitchatMessage] = []
+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
+42 -1
View File
@@ -14,7 +14,7 @@ import BitFoundation
/// Mock Transport implementation for testing ChatViewModel in isolation.
/// Records all method calls and allows test code to verify interactions.
final class MockTransport: Transport {
final class MockTransport: Transport, PrivateMediaDeletionPersisting {
// MARK: - Protocol Properties
@@ -38,6 +38,11 @@ final class MockTransport: Transport {
private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = []
private(set) var sentPrivateFileLegacyAllowances: [Bool] = []
private(set) var cancelledTransfers: [String] = []
private(set) var deletedPrivateMediaMessageIDBatches: [[String]] = []
private(set) var deletedPrivateMediaRelativePaths: [
[String: String]
] = []
private(set) var protectedPrivateMediaRelativePaths: [Set<String>] = []
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
private(set) var sentCourierMessages: [(content: String, messageID: String, recipientNoiseKey: Data, couriers: [PeerID])] = []
@@ -60,6 +65,11 @@ final class MockTransport: Transport {
var peerFingerprints: [PeerID: String] = [:]
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:]
var persistDeletedPrivateMediaResult = true
var deferDeletedPrivateMediaPersistence = false
private var pendingDeletedPrivateMediaCompletions: [
@MainActor (Bool) -> Void
] = []
private let mockKeychain = MockKeychain()
// MARK: - Transport Protocol Implementation
@@ -217,6 +227,34 @@ final class MockTransport: Transport {
cancelledTransfers.append(transferId)
}
@MainActor
func persistDeletedPrivateMedia(
messageIDs: [String],
payloadRelativePaths: [String: String],
protectedPayloadRelativePaths: Set<String>,
completion: @escaping @MainActor (Bool) -> Void
) {
deletedPrivateMediaMessageIDBatches.append(messageIDs)
deletedPrivateMediaRelativePaths.append(payloadRelativePaths)
protectedPrivateMediaRelativePaths.append(
protectedPayloadRelativePaths
)
if deferDeletedPrivateMediaPersistence {
pendingDeletedPrivateMediaCompletions.append(completion)
} else {
completion(persistDeletedPrivateMediaResult)
}
}
@MainActor
func resolveNextDeletedPrivateMediaPersistence(
_ result: Bool? = nil
) {
guard !pendingDeletedPrivateMediaCompletions.isEmpty else { return }
let completion = pendingDeletedPrivateMediaCompletions.removeFirst()
completion(result ?? persistDeletedPrivateMediaResult)
}
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
sentVerifyChallenges.append((peerID, noiseKeyHex, nonceA))
}
@@ -264,6 +302,9 @@ final class MockTransport: Transport {
sentBroadcastFiles.removeAll()
sentPrivateFiles.removeAll()
cancelledTransfers.removeAll()
deletedPrivateMediaMessageIDBatches.removeAll()
deletedPrivateMediaRelativePaths.removeAll()
protectedPrivateMediaRelativePaths.removeAll()
sentVerifyChallenges.removeAll()
sentVerifyResponses.removeAll()
startServicesCallCount = 0
@@ -0,0 +1 @@
{"id":"8f4ac4680de5f972a586267bc7b6b5102ba548a34f618bdee47edd08929ee1e8","pubkey":"6981231b5745520fd982f66f485fa1b42f8f91ad25ab32242d4afb839d696b0a","created_at":1783727308,"kind":1059,"tags":[["p","c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"]],"content":"v2:ETvtGOrkDQ2JIiiMFMdxBlrYNLh07-QKmLds0xsjkgs-v54CP4E4uoKl1L_VvKvaREg-EviPQmksd3DOYHrBVAd5E6xuRn1o9vkoss4MYV7I71mu4RfADRt77ohZaNbc-KhrmgyRTgE-WnEsqErax8LUN6GUukjkKncVA9MAmC1WUB1MI1AR8c4BQ0IOc_ubrEqi640a8AeBZaCZOYutCb3ttqNSPBxR63XErE761KNg1uiq5pgBVo8iKvLO-N2ei7IWvQhmDTapBaEU7LexeIdHDEwMdXDoAtr5Nmd54H1VN12tBvk1wXvHnENgZCOLOR5J2E1eSwrFXXBto-ohrNpBaLKIXBTPGEoepa7x0gC0Vrh1OTf4tCI7JJ5UWnkUAQFGUgPGlTRYC8MdESgEthqmdgKT3Jc0N6sylTmv6zSVx5dXqO4fvSLHC6_7it8F_V_8-uAxUYstJz65oK4F9CwOEVglUuUdfn2mN_3cMBzASLOlKvL8jbkwwo5aMBVrShGiEwDix02hfGMNMKf7OKsLlfNiBAVSPh6MQSvAWhxbDCDnWN-yHKWF4TYxbnH70X2KGl_ZD9pXTphKVIpFuRmP7UNM-01oG53NKcv9puBSxAkLbTZd122uFL_zQebxid1ukOXT8WgSB_WYJYoAlQekeu4ITryB4I60vAAzMAa4AppCUNnf6T8jwWxuC-8G0TPf18MTxpeNkzcSpPVyVE0jtLaOwsJDXs_Pg82Id03Qc-b_fWMm9V07UzGmEnMqQ1gBMLmEXHb_4Ebg5X7TdzuXy87O36CJzau7Dm5ZfoalryF-16Z4MxzQOyXb1G61yFthRsGCT6sYi-68YkhPScMf7u_BobtMuGWiMiWoBqN_IrQ_ecMHVfaeEYvpCz5NYlrE26iAktNmzCBUDNcIr6P_nHdb6I3Q1rOOmWwEF7jsLbvnU_w_82_nXE_yfdGsoly24A2wB0L0SpdnyyEWgvKWjjfS3J3vkIVW4_iM0FT0jNelANc2X_ryb3EPTmGenlqm_qRGh86PYk_R07hKYu3ULNEgLzDTijeZ9-bP23tsMXUDdJS2VR7LP5063ygVuSC0J-GL1FmQ2c-DmJaWQSeqp0NN3sCND3pSIRzwQRwChnMNjVB65mJj","sig":"c53f339b19b9de021765588b0ee4f5f1e0c2423a34d35fe2cbcc6ce89e12e2fedbdf88a0b6a9d719c2c8580002c7574f1ace93aedeab1e255b6231d4ebb6f9b2"}
@@ -0,0 +1,29 @@
diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt
index a5bd956..544a29b 100644
--- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt
+++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrProtocolTest.kt
@@ -10,0 +11,21 @@ class NostrProtocolTest {
+ @Test
+ fun emitCrossPlatformFixtures() {
+ val sender = NostrIdentity.fromPrivateKey(
+ "0000000000000000000000000000000000000000000000000000000000000001"
+ )
+ val recipient = NostrIdentity.fromPrivateKey(
+ "0000000000000000000000000000000000000000000000000000000000000002"
+ )
+ val giftWrap = NostrProtocol.createPrivateMessage(
+ content = "legacy fixture from Android b7f0b33d",
+ recipientPubkey = recipient.publicKeyHex,
+ senderIdentity = sender
+ ).single()
+
+ assertEquals(NostrKind.GIFT_WRAP, giftWrap.kind)
+ assertEquals(listOf(listOf("p", recipient.publicKeyHex)), giftWrap.tags)
+ println("BITCHAT_FIXTURE_EVENT=${gson.toJson(giftWrap)}")
+ println("BITCHAT_FIXTURE_RECIPIENT_PRIVATE_KEY=${recipient.privateKeyHex}")
+ println("BITCHAT_FIXTURE_SENDER_PUBLIC_KEY=${sender.publicKeyHex}")
+ }
+
@Test
fun decryptPrivateMessage_acceptsAuthenticatedSeal() {
val sender = NostrIdentity.generate()
@@ -0,0 +1,10 @@
{
"android_commit": "b7f0b33d3a267c770d3d5a65ee2d8c7e755450db",
"generator": "NostrProtocol.createPrivateMessage",
"generator_patch": "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch",
"generator_patch_sha256": "e7ad29d6a247638cc16fb2ec06937266e6a03e7a115ae00913330a86a88fa56a",
"gradle_test": "ANDROID_HOME=/opt/homebrew/share/android-commandlinetools ANDROID_SDK_ROOT=/opt/homebrew/share/android-commandlinetools JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home ./gradlew testDebugUnitTest --tests com.bitchat.android.nostr.NostrProtocolTest.emitCrossPlatformFixtures --info",
"fixture_sha256": "d2df3d0b7ffd84c5cb25e0b3f4a89ad14f72a105bddadab77081f98c661b080e",
"recipient_private_key": "0000000000000000000000000000000000000000000000000000000000000002",
"sender_public_key": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
}
@@ -0,0 +1 @@
{"content":"v2:SorfnTaoQ_Rv0XLKA8b3FZojgaFvnWgx66JR6Gj20ztnorQyL-hUYBZwAa5ohFC6ioR9hlJARJm0cgNTvWgSZuNTcfSAvoMdSG6mXK73kzsp99x351zUB_lc1_5ZXm0qqePAEz3Dl5jj5EsfAb8ZzsCd-BZENCsTwqjqC_hCFl7RitlRfIL2Tq_n4TUFEFandDCplNz3W4L7V07V89aGEoUlhzcwPU44BcxfvQjeqVKq0IRf2AetypoOduPrjSqkv674ubWaRcHtw3Asrqgo5XSYbpOlSk1PF_TttrQSPZGViNe5MuiK2P-7d-XqKXuDf_bUgAzW884KXogbct-wtIJZJbVM-utMd-dHrpC1mY81lgpS4_kPuhj0Z6Ro9hU5nCcEk2K4_vNoSM9m9QbcXP34h47qSPsw9ikmz8UoD00-1fXQVB4YJcBVUSVI06IzbZEWulo8SPXvQ4pJjV3nwPgYqRgwnrNWMNfeuKojlF8yA17UNOWvD2U7r1cs84HL8dxztzX9NdN0DGxvjMILvt3D4eWrMbcSrsIkBgyvV-uskEPd4eX3fc9GmX8MPkMgxRErcxbuq6JBUNXikOJXNH_qspOt4UIw5dCIajrHsKycKd8A_3rSgLEQteirOWMGaD2gOJEzpbe4iT72dmPkvRq7k-wDjLbO5dOSuUvpFM-ipkB07ATJz_1uUcRpl8fD_oSlcdAzdPjGKG5Y-tNv3AcUstkqCi52E5x-aO8EDUNBFi_OCbD86nkTUv1jOpAQwejowSiiOnCZ91zUEg5pRQyhBV0Ozib-j0Wf8S8VAz9M8bqQQaKedPCEowDn6csOKbFdBtqSeROf1XWKCkm1mSJGHWW9V44MiMiHebVrRUpbP0PqvxiIeJE0","created_at":1783710443,"id":"46425f8d4e8007af43abb67b3668b59604bd34c86dee1b1c3702559086927cb9","kind":1059,"pubkey":"960e391e314a7fb00bbdd85eccb0a93c17e981b6fed38487cf891f1ed6b66aeb","sig":"975c88c1c4d11f0b623603f8ecc7c181fea7385e8feacdb4a64a6b4f7536b1337ff556aee165ca337c9ec501cfab3e9703026c6df2b12bb8c702378875f00722","tags":[["p","1c108500bf53da288d30530718bac4bf80d661d1fe38854060c5b5c79eb77755"]]}
@@ -0,0 +1 @@
{"recipient_private_key":"8355a5c110cdfef2e644f4ad5d51c39f253b2c2c80ebb6856379fb16531dc1fa"}
+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)
+486 -58
View File
@@ -2,18 +2,17 @@
// NostrProtocolTests.swift
// bitchatTests
//
// Tests for NIP-17 gift-wrapped private messages
// Tests for BitChat's proprietary private-envelope transport over Nostr.
//
import Testing
import CryptoKit
import Foundation
import BitFoundation
@testable import bitchat
struct NostrProtocolTests {
@Test func nip17MessageRoundTrip() throws {
@Test func privateEnvelopeRoundTrip() throws {
// Create sender and recipient identities
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
@@ -22,21 +21,19 @@ struct NostrProtocolTests {
print("Recipient pubkey: \(recipient.publicKeyHex)")
// Create a test message
let originalContent = "Hello from NIP-17 test!"
let originalContent = "Hello from BitChat private-envelope test!"
// Create encrypted gift wrap
let giftWrap = try NostrProtocol.createPrivateMessage(
let envelope = try NostrProtocol.createPrivateEnvelope(
content: originalContent,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
print("Gift wrap created with ID: \(giftWrap.id)")
print("Gift wrap pubkey: \(giftWrap.pubkey)")
print("Private envelope created with ID: \(envelope.id)")
print("Private envelope pubkey: \(envelope.pubkey)")
// Decrypt the gift wrap
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateEnvelope(
envelope: envelope,
recipientIdentity: recipient
)
@@ -52,37 +49,37 @@ struct NostrProtocolTests {
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
}
@Test func giftWrapUsesUniqueEphemeralKeys() throws {
@Test func privateEnvelopesUseUniqueEphemeralKeys() throws {
// Create identities
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
// Create two messages
let message1 = try NostrProtocol.createPrivateMessage(
let message1 = try NostrProtocol.createPrivateEnvelope(
content: "Message 1",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
let message2 = try NostrProtocol.createPrivateMessage(
let message2 = try NostrProtocol.createPrivateEnvelope(
content: "Message 2",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
// Gift wrap pubkeys should be different (unique ephemeral keys)
// Public envelope keys must be one-time use.
#expect(message1.pubkey != message2.pubkey)
print("Message 1 gift wrap pubkey: \(message1.pubkey)")
print("Message 2 gift wrap pubkey: \(message2.pubkey)")
print("Message 1 envelope pubkey: \(message1.pubkey)")
print("Message 2 envelope pubkey: \(message2.pubkey)")
// Both should decrypt successfully
let (content1, _, _) = try NostrProtocol.decryptPrivateMessage(
giftWrap: message1,
let (content1, _, _) = try NostrProtocol.decryptPrivateEnvelope(
envelope: message1,
recipientIdentity: recipient
)
let (content2, _, _) = try NostrProtocol.decryptPrivateMessage(
giftWrap: message2,
let (content2, _, _) = try NostrProtocol.decryptPrivateEnvelope(
envelope: message2,
recipientIdentity: recipient
)
@@ -90,74 +87,431 @@ struct NostrProtocolTests {
#expect(content2 == "Message 2")
}
@Test func privateEnvelopeUsesBitChatWireFormatAtEveryLayer() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let envelope = try NostrProtocol.createPrivateEnvelope(
content: "bitchat-specific",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
#expect(envelope.kind == NostrProtocol.EventKind.privateEnvelope.rawValue)
#expect(envelope.kind != NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue)
#expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
#expect(!envelope.content.hasPrefix("v2:"))
#expect(envelope.tags == [["p", recipient.publicKeyHex]])
#expect(envelope.created_at <= Int(Date().timeIntervalSince1970))
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
envelope: envelope,
recipientIdentity: recipient
)
#expect(layers.seal.kind == NostrProtocol.EventKind.privateSeal.rawValue)
#expect(layers.seal.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
#expect(layers.seal.tags.isEmpty)
#expect(layers.message.kind == NostrProtocol.EventKind.privateMessage.rawValue)
#expect(layers.message.tags.isEmpty)
#expect(layers.message.sig == nil)
}
@Test func decryptAcceptsReceiveOnlyLegacyBitChatEnvelope() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let envelope = try NostrProtocol.createLegacyPrivateEnvelopeForTesting(
content: "legacy in-flight message",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
#expect(envelope.kind == NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue)
#expect(envelope.content.hasPrefix("v2:"))
let result = try NostrProtocol.decryptPrivateEnvelope(
envelope: envelope,
recipientIdentity: recipient
)
#expect(result.content == "legacy in-flight message")
#expect(result.senderPubkey == sender.publicKeyHex)
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
envelope: envelope,
recipientIdentity: recipient
)
#expect(layers.seal.kind == NostrProtocol.EventKind.legacyNIP59Seal.rawValue)
#expect(layers.message.kind == NostrProtocol.EventKind.legacyNIP17DirectMessage.rawValue)
#expect(layers.message.tags.isEmpty)
}
@Test func decryptAcceptsCurrentAndroidLegacyInnerRecipientTag() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
// Current Android's `createPrivateMessage` emits an unsigned kind-14
// inner event with exactly [["p", recipient]], while its outer/seal
// layers use the deployed BitChat legacy v2 crypto. This isolated
// generator reproduces that cross-platform wire shape without making
// the production encoder depend on Android's historical tag choice.
let envelope = try NostrProtocol.createLegacyPrivateEnvelopeForTesting(
content: "legacy message from Android",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender,
innerMessageTags: [["p", recipient.publicKeyHex]]
)
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
envelope: envelope,
recipientIdentity: recipient
)
#expect(layers.message.tags == [["p", recipient.publicKeyHex]])
let result = try NostrProtocol.decryptPrivateEnvelope(
envelope: envelope,
recipientIdentity: recipient
)
#expect(result.content == "legacy message from Android")
#expect(result.senderPubkey == sender.publicKeyHex)
}
@Test func decryptsFrozenLegacyEnvelopeProducedByAndroidB7f0b33d() throws {
let eventData = try Data(contentsOf: fixtureURL(
name: "AndroidLegacyPrivateEnvelopeB7f0b33d"
))
let metadataData = try Data(contentsOf: fixtureURL(
name: "AndroidLegacyPrivateEnvelopeB7f0b33dMetadata"
))
let envelope = try JSONDecoder().decode(NostrEvent.self, from: eventData)
let metadata = try JSONDecoder().decode(AndroidLegacyEnvelopeFixture.self, from: metadataData)
let recipientKey = try #require(Data(hexString: metadata.recipientPrivateKey))
let recipient = try NostrIdentity(privateKeyData: recipientKey)
#expect(metadata.androidCommit == "b7f0b33d3a267c770d3d5a65ee2d8c7e755450db")
#expect(metadata.generator == "NostrProtocol.createPrivateMessage")
#expect(metadata.generatorPatch == "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator.patch")
#expect(metadata.fixtureSHA256 == eventData.sha256Fingerprint())
#expect(metadata.gradleTest.contains("NostrProtocolTest.emitCrossPlatformFixtures"))
let generatorPatch = try String(contentsOf: fixtureURL(
name: "AndroidLegacyPrivateEnvelopeB7f0b33dGenerator",
extension: "patch"
))
#expect(metadata.generatorPatchSHA256 == Data(generatorPatch.utf8).sha256Fingerprint())
#expect(generatorPatch.contains("fun emitCrossPlatformFixtures()"))
#expect(generatorPatch.contains(metadata.recipientPrivateKey))
#expect(envelope.isValidSignature())
#expect(envelope.kind == NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue)
#expect(envelope.tags == [["p", recipient.publicKeyHex]])
let layers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
envelope: envelope,
recipientIdentity: recipient
)
#expect(layers.message.tags == [["p", recipient.publicKeyHex]])
let result = try NostrProtocol.decryptPrivateEnvelope(
envelope: envelope,
recipientIdentity: recipient
)
#expect(result.content == "legacy fixture from Android b7f0b33d")
#expect(result.senderPubkey == metadata.senderPublicKey)
}
@Test func decryptRejectsAlternateLegacyInnerTags() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let otherRecipient = try NostrIdentity.generate()
let invalidTagShapes = [
[["p", otherRecipient.publicKeyHex]],
[["p", recipient.publicKeyHex], ["p", recipient.publicKeyHex]],
[["p", recipient.publicKeyHex, "unexpected"]],
[["x", recipient.publicKeyHex]]
]
for tags in invalidTagShapes {
let envelope = try NostrProtocol.createLegacyPrivateEnvelopeForTesting(
content: "invalid Android-shaped tags",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender,
innerMessageTags: tags
)
expectInvalidEvent {
_ = try NostrProtocol.decryptPrivateEnvelope(
envelope: envelope,
recipientIdentity: recipient
)
}
}
}
@Test func newPrivateEnvelopeRejectsInnerRecipientTag() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let envelope = try NostrProtocol.createPrivateEnvelopeWithInnerTagsForTesting(
content: "new format stays strict",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender,
innerMessageTags: [["p", recipient.publicKeyHex]]
)
expectInvalidEvent {
_ = try NostrProtocol.decryptPrivateEnvelope(
envelope: envelope,
recipientIdentity: recipient
)
}
}
@Test func decryptsFrozenLegacyEnvelopeProducedByRelease733098bb() throws {
let eventData = try Data(contentsOf: fixtureURL(
name: "LegacyPrivateEnvelope733098bb"
))
let keyData = try Data(contentsOf: fixtureURL(
name: "LegacyPrivateEnvelope733098bbRecipientKey"
))
let envelope = try JSONDecoder().decode(NostrEvent.self, from: eventData)
let keyFixture = try JSONDecoder().decode(LegacyRecipientKeyFixture.self, from: keyData)
let recipientKey = try #require(Data(hexString: keyFixture.recipientPrivateKey))
let recipient = try NostrIdentity(privateKeyData: recipientKey)
#expect(envelope.isValidSignature())
let result = try NostrProtocol.decryptPrivateEnvelope(
envelope: envelope,
recipientIdentity: recipient
)
#expect(result.content == "legacy fixture from 733098bb")
#expect(result.senderPubkey == "2e3d79df7047204f02b726c574e256f8de1dd80510f7dcb8b0d12df13acb87e6")
}
@Test func publicationBatchAlwaysDualPublishesForCoordinatedMigration() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let migrationBatch = try NostrProtocol.createPrivateEnvelopePublicationBatch(
content: "mixed-version",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
#expect(migrationBatch.map(\.kind) == [
NostrProtocol.EventKind.privateEnvelope.rawValue,
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
])
for envelope in migrationBatch {
let result = try NostrProtocol.decryptPrivateEnvelope(
envelope: envelope,
recipientIdentity: recipient
)
#expect(result.content == "mixed-version")
}
// The compatibility copy retains the exact released-iOS legacy shape,
// which current Android also accepts: kinds 1059/13/14, v2 prefix, and
// no inner tags. There is no wall-clock branch that can silently stop
// old-client delivery.
let compatibilityLayers = try NostrProtocol.decodePrivateEnvelopeLayersForTesting(
envelope: migrationBatch[1],
recipientIdentity: recipient
)
#expect(migrationBatch[1].content.hasPrefix("v2:"))
#expect(compatibilityLayers.seal.kind == NostrProtocol.EventKind.legacyNIP59Seal.rawValue)
#expect(compatibilityLayers.message.kind == NostrProtocol.EventKind.legacyNIP17DirectMessage.rawValue)
#expect(compatibilityLayers.message.tags.isEmpty)
}
@Test func mailboxLookbackCoversDeliveryWindowAndroidFuzzAndClockSkew() {
let sentAt = Date(timeIntervalSince1970: 1_800_000_000)
let earliestAndroidTimestamp = sentAt.addingTimeInterval(
-TransportConfig.nostrLegacyAndroidTimestampFuzzSeconds
)
let subscribeAtDeliveryBoundary = sentAt.addingTimeInterval(
TransportConfig.nostrPrivateEnvelopeDeliveryWindowSeconds
)
let filterSince = subscribeAtDeliveryBoundary.addingTimeInterval(
-TransportConfig.nostrDMSubscribeLookbackSeconds
)
#expect(TransportConfig.nostrPrivateEnvelopeDeliveryWindowSeconds == 24 * 60 * 60)
#expect(TransportConfig.nostrLegacyAndroidTimestampFuzzSeconds == 48 * 60 * 60)
#expect(TransportConfig.nostrDMSubscribeClockSkewSeconds == 15 * 60)
let expectedLookback: TimeInterval = 72 * 60 * 60 + 15 * 60
#expect(TransportConfig.nostrDMSubscribeLookbackSeconds == expectedLookback)
#expect(filterSince == earliestAndroidTimestamp.addingTimeInterval(-(15 * 60)))
}
@Test func largePrivateEnvelopeFitsLayerSpecificExpansionLimits() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
// Large enough that the nested Base64 seal exceeds the inner 32 KiB
// cap, while the inner message JSON itself remains below that cap.
let content = String(repeating: "A", count: 30 * 1024)
let envelope = try NostrProtocol.createPrivateEnvelope(
content: content,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
let decrypted = try NostrProtocol.decryptPrivateEnvelope(
envelope: envelope,
recipientIdentity: recipient
)
#expect(decrypted.content == content)
#expect(envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes)
}
@Test func privateEnvelopeRejectsOversizedCiphertextBeforeDecoding() throws {
let recipient = try NostrIdentity.generate()
let wrapper = try NostrIdentity.generate()
let oversizedContent = NostrProtocol.privateEnvelopeContentPrefix
+ String(
repeating: "A",
count: NostrProtocol.maximumPrivateEnvelopeCiphertextBytes
)
let event = NostrEvent(
pubkey: wrapper.publicKeyHex,
createdAt: Date(),
kind: .privateEnvelope,
tags: [["p", recipient.publicKeyHex]],
content: oversizedContent
)
let signed = try event.sign(with: wrapper.schnorrSigningKey())
expectInvalidCiphertext {
_ = try NostrProtocol.decryptPrivateEnvelope(
envelope: signed,
recipientIdentity: recipient
)
}
}
@Test func privateEnvelopeRejectsOversizedPlaintextBeforeEncryption() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let oversizedPlaintext = String(
repeating: "x",
count: NostrProtocol.maximumPrivateEnvelopePlaintextBytes + 1
)
expectInvalidCiphertext {
_ = try NostrProtocol.createPrivateEnvelope(
content: oversizedPlaintext,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
}
}
@Test func privateEnvelopePublicationBatchRejectsMaximumComposerPayload() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let composerMaximum = String(
repeating: "x",
count: InputValidator.Limits.maxMessageLength
)
#expect(
composerMaximum.utf8.count
> NostrProtocol.maximumPrivateEnvelopePlaintextBytes
)
expectInvalidCiphertext {
_ = try NostrProtocol.createPrivateEnvelopePublicationBatch(
content: composerMaximum,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
}
}
@Test func privateEnvelopeRejectsOversizedNestedJSONBeforeParsing() {
let oversizedJSON = String(
repeating: "{",
count: NostrProtocol.maximumPrivateEnvelopePlaintextBytes + 1
)
expectInvalidCiphertext {
_ = try NostrProtocol.decodePrivateEnvelopeEventJSONForTesting(oversizedJSON)
}
}
@Test func decryptDoesNotMisinterpretStandardNIP44PayloadAsLegacyBitChat() throws {
let recipient = try NostrIdentity.generate()
let wrapper = try NostrIdentity.generate()
// A valid NIP-44 v2 payload from the official test vectors. Its wire
// format starts with a version byte in standard Base64, not BitChat's
// historical `v2:` prefix.
let standardPayload = "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"
let event = NostrEvent(
pubkey: wrapper.publicKeyHex,
createdAt: Date(),
kind: .legacyNIP59GiftWrap,
tags: [["p", recipient.publicKeyHex]],
content: standardPayload
)
let signed = try event.sign(with: wrapper.schnorrSigningKey())
expectInvalidCiphertext {
_ = try NostrProtocol.decryptPrivateEnvelope(
envelope: signed,
recipientIdentity: recipient
)
}
}
@Test func decryptionFailsWithWrongRecipient() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let wrongRecipient = try NostrIdentity.generate()
// Create message for recipient
let giftWrap = try NostrProtocol.createPrivateMessage(
let envelope = try NostrProtocol.createPrivateEnvelope(
content: "Secret message",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
// Try to decrypt with wrong recipient
if #available(macOS 14.4, iOS 17.4, *) {
#expect(throws: CryptoKitError.authenticationFailure) {
try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: wrongRecipient
)
}
} else {
#expect(throws: (any Error).self) {
try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: wrongRecipient
)
}
expectInvalidEvent {
_ = try NostrProtocol.decryptPrivateEnvelope(
envelope: envelope,
recipientIdentity: wrongRecipient
)
}
}
@Test func decryptRejectsInvalidSealSignature() throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let giftWrap = try NostrProtocol.createPrivateMessageWithInvalidSealSignatureForTesting(
let envelope = try NostrProtocol.createPrivateEnvelopeWithInvalidSealSignatureForTesting(
content: "forged signature",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
expectInvalidEvent {
_ = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
_ = try NostrProtocol.decryptPrivateEnvelope(
envelope: envelope,
recipientIdentity: recipient
)
}
}
@Test func decryptRejectsSealRumorPubkeyMismatch() throws {
@Test func decryptRejectsSealMessagePubkeyMismatch() throws {
let claimedSender = try NostrIdentity.generate()
let sealSigner = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let giftWrap = try NostrProtocol.createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
let envelope = try NostrProtocol.createPrivateEnvelopeWithMismatchedSealMessagePubkeyForTesting(
content: "spoofed sender",
recipientPubkey: recipient.publicKeyHex,
rumorIdentity: claimedSender,
messageIdentity: claimedSender,
sealSignerIdentity: sealSigner
)
expectInvalidEvent {
_ = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
_ = try NostrProtocol.decryptPrivateEnvelope(
envelope: envelope,
recipientIdentity: recipient
)
}
}
@Test
func testAckRoundTripNIP44V2_Delivered() throws {
func deliveredAckRoundTripsInsidePrivateEnvelope() throws {
// Identities
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
@@ -171,19 +525,17 @@ struct NostrProtocolTests {
"Failed to embed delivered ack"
)
// Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally)
let giftWrap = try NostrProtocol.createPrivateMessage(
let envelope = try NostrProtocol.createPrivateEnvelope(
content: embedded,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
// Ensure v2 format was used for ciphertext
#expect(giftWrap.content.hasPrefix("v2:"))
#expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
// Decrypt as recipient
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
envelope: envelope,
recipientIdentity: recipient
)
@@ -208,7 +560,7 @@ struct NostrProtocolTests {
}
}
@Test func ackRoundTripNIP44V2_ReadReceipt() throws {
@Test func readReceiptRoundTripsInsidePrivateEnvelope() throws {
// Identities
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
@@ -220,16 +572,16 @@ struct NostrProtocolTests {
"Failed to embed read ack"
)
let giftWrap = try NostrProtocol.createPrivateMessage(
let envelope = try NostrProtocol.createPrivateEnvelope(
content: embedded,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
#expect(giftWrap.content.hasPrefix("v2:"))
#expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
envelope: envelope,
recipientIdentity: recipient
)
#expect(senderPubkey == sender.publicKeyHex)
@@ -290,7 +642,6 @@ struct NostrProtocolTests {
#expect(object["limit"] as? Int == 42)
}
@Test func inboundNostrEventRejectsTooManyTags() throws {
var eventDict = Self.validInboundEventDict()
eventDict["tags"] = Array(
@@ -336,6 +687,33 @@ struct NostrProtocolTests {
#expect(event.tags.count == 2)
}
@Test func privateEnvelopeFiltersGiveEachMigrationKindAnIndependentRecoveryBudget() throws {
let since = Date(timeIntervalSince1970: 1_234_567)
let filters = NostrFilter.privateEnvelopeFiltersFor(
pubkey: "recipient",
since: since
)
#expect(filters.count == 2)
let objects = try filters.map { filter in
let data = try JSONEncoder().encode(filter)
return try #require(
try JSONSerialization.jsonObject(with: data) as? [String: Any]
)
}
#expect(objects.compactMap { $0["kinds"] as? [Int] } == [
[NostrProtocol.EventKind.privateEnvelope.rawValue],
[NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue]
])
for object in objects {
#expect(object["#p"] as? [String] == ["recipient"])
#expect(object["since"] as? Int == 1_234_567)
#expect(object["limit"] as? Int ==
TransportConfig.nostrPrivateEnvelopeFetchLimitPerKind
)
}
}
// MARK: - Helpers
private static func validInboundEventDict() -> [String: Any] {
[
@@ -349,6 +727,45 @@ struct NostrProtocolTests {
]
}
private struct LegacyRecipientKeyFixture: Decodable {
let recipientPrivateKey: String
enum CodingKeys: String, CodingKey {
case recipientPrivateKey = "recipient_private_key"
}
}
private struct AndroidLegacyEnvelopeFixture: Decodable {
let androidCommit: String
let generator: String
let generatorPatch: String
let generatorPatchSHA256: String
let gradleTest: String
let fixtureSHA256: String
let recipientPrivateKey: String
let senderPublicKey: String
enum CodingKeys: String, CodingKey {
case androidCommit = "android_commit"
case generator
case generatorPatch = "generator_patch"
case generatorPatchSHA256 = "generator_patch_sha256"
case gradleTest = "gradle_test"
case fixtureSHA256 = "fixture_sha256"
case recipientPrivateKey = "recipient_private_key"
case senderPublicKey = "sender_public_key"
}
}
private func fixtureURL(name: String, extension fileExtension: String = "json") throws -> URL {
#if SWIFT_PACKAGE
let bundle = Bundle.module
#else
let bundle = Bundle(for: MockKeychain.self)
#endif
return try #require(bundle.url(forResource: name, withExtension: fileExtension))
}
private static func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
let rem = str.count % 4
@@ -366,4 +783,15 @@ struct NostrProtocolTests {
Issue.record("Expected NostrError.invalidEvent, got \(error)")
}
}
private func expectInvalidCiphertext(_ operation: () throws -> Void) {
do {
try operation()
Issue.record("Expected NostrError.invalidCiphertext")
} catch NostrError.invalidCiphertext {
return
} catch {
Issue.record("Expected NostrError.invalidCiphertext, got \(error)")
}
}
}
@@ -77,10 +77,8 @@ final class PerformanceBaselineTests: XCTestCase {
// MARK: - 1a. Nostr inbound event handling (fresh events)
/// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events
/// (kind 20000): dedup record, presence/nickname bookkeeping, and
/// public-message ingest scheduling. Schnorr signature verification is
/// NOT part of this path anymore it runs exactly once, off the main
/// actor, in `NostrRelayManager` before delivery.
/// (kind 20000): signature verification, dedup record, presence/nickname
/// bookkeeping, and public-message ingest scheduling.
func testNostrInboundEventHandling_freshEvents() throws {
let events = try Self.makeSignedGeohashEvents(count: 500)
// A fresh context per measure pass so every event takes the
@@ -108,9 +106,8 @@ final class PerformanceBaselineTests: XCTestCase {
/// The dedup-hit path: identical events replayed. Duplicates dominate
/// real relay traffic (the same event arrives from several relays), so
/// this path runs hundreds of times a minute in busy geohashes. It is a
/// pure dedup lookup: no crypto (verification happens upstream in
/// `NostrRelayManager`, and only for the first-seen copy).
/// this path runs hundreds of times a minute in busy geohashes. Note it
/// still pays full Schnorr signature verification before the dedup check.
func testNostrInboundEventHandling_duplicateEvents() throws {
let events = try Self.makeSignedGeohashEvents(count: 500)
let context = PerfNostrContext()
@@ -504,62 +501,6 @@ final class PerformanceBaselineTests: XCTestCase {
reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages")
}
// MARK: - 7b. ConversationStore append at the retention cap
/// Steady-state public timeline traffic after the 1337-message retention
/// cap has been reached. Every tail append evicts the oldest row, which is
/// the long-lived workload the cold `store.append` benchmark does not
/// exercise.
func testConversationStoreSteadyStateAppend() {
let store = ConversationStore()
let cap = TransportConfig.meshTimelineCap
let messagesPerPass = 500
let base = Date(timeIntervalSince1970: 1_700_000_000)
for i in 0..<cap {
store.append(
BitchatMessage(
id: "perf-steady-seed-\(i)",
sender: "perfsender",
content: "steady-state seed \(i)",
timestamp: base.addingTimeInterval(Double(i)),
isRelay: false
),
to: .mesh
)
}
var pass = 0
var samples: [TimeInterval] = []
measure {
let startIndex = cap + pass * messagesPerPass
let start = Date()
for offset in 0..<messagesPerPass {
let i = startIndex + offset
store.append(
BitchatMessage(
id: "perf-steady-\(i)",
sender: "perfsender",
content: "steady-state message \(i)",
timestamp: base.addingTimeInterval(Double(i)),
isRelay: false
),
to: .mesh
)
}
samples.append(Date().timeIntervalSince(start))
pass += 1
XCTAssertEqual(store.conversation(for: .mesh).messages.count, cap)
}
reportThroughput(
"store.steadyStateAppend",
samples: samples,
operations: messagesPerPass,
unit: "messages"
)
}
// MARK: - 8. ConversationStore invariant audit (field observability)
/// `ConversationStore.auditInvariants()` over a realistic 5k-message
@@ -30,10 +30,6 @@
"store.append": 213201,
"store.audit": 362
},
"_reference_local_numbers_2026_07": {
"store.steadyStateAppend_before": 2315,
"store.steadyStateAppend": 53976
},
"floors": {
"nostrInbound.fresh": 450,
"nostrInbound.duplicate": 250000,
@@ -45,7 +41,6 @@
"pipeline.privateIngest": 3000,
"pipeline.publicIngest": 2400,
"store.append": 48000,
"store.steadyStateAppend": 10000,
"store.audit": 70
},
"_slowest_observed_ci_numbers_2026_06": {
@@ -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 }
}
}
@@ -21,9 +21,12 @@ struct BLEFileTransferHandlerTests {
var receiptCommits: [(messageID: String, storedURL: URL)] = []
var receiptCommitSucceeds = true
var removedIncomingFiles: [URL] = []
var finishedIncomingFileDeliveries: [URL] = []
var lastSeenUpdates: [PeerID] = []
var deliveryAcks: [(messageID: String, peerID: PeerID)] = []
var deliveredMessages: [BitchatMessage] = []
var shouldAcceptDelivery = true
var deliveryOutcome = TransportEventDeliveryOutcome.accepted
var saveOverride: ((
_ data: Data,
_ preferredName: String?,
@@ -34,12 +37,85 @@ struct BLEFileTransferHandlerTests {
var receiptStateOverride: ((String) -> BLEPrivateMediaReceiptState)?
var receiptCommitOverride: ((String, URL) -> Bool)?
var removeIncomingFileOverride: ((URL) -> Void)?
var finishIncomingFileDeliveryOverride: ((URL) -> Void)?
}
private let localPeerID = PeerID(str: "0102030405060708")
private let remotePeerID = PeerID(str: "1122334455667788")
private let sampleSigningKey = Data(repeating: 0xAB, count: 32)
@Test @MainActor
func deliveryGateFinalizesInitialRejection() {
var completions = 0
var finalizations = 0
TransportEventDeliveryGate.attempt(
shouldDeliver: { false },
deliver: {
Issue.record("delivery sink must not run")
return .accepted
},
completion: { completions += 1 },
finalization: { _ in finalizations += 1 }
)
#expect(completions == 0)
#expect(finalizations == 1)
}
@Test @MainActor
func deliveryGateFinalizesMissingOrRejectingSink() {
var completions = 0
var finalizations = 0
TransportEventDeliveryGate.attempt(
shouldDeliver: { true },
deliver: { .rejected },
completion: { completions += 1 },
finalization: { _ in finalizations += 1 }
)
#expect(completions == 0)
#expect(finalizations == 1)
}
@Test @MainActor
func deliveryGateFinalizesPostInsertionRejection() {
var deliveryChecks = 0
var completions = 0
var finalizations = 0
TransportEventDeliveryGate.attempt(
shouldDeliver: {
deliveryChecks += 1
return deliveryChecks == 1
},
deliver: { .accepted },
completion: { completions += 1 },
finalization: { _ in finalizations += 1 }
)
#expect(deliveryChecks == 2)
#expect(completions == 0)
#expect(finalizations == 1)
}
@Test @MainActor
func deliveryGatePreservesInvokedUnconfirmedOutcomeWithoutAck() {
var completions = 0
var outcomes: [TransportEventDeliveryOutcome] = []
TransportEventDeliveryGate.attempt(
shouldDeliver: { true },
deliver: { .invokedUnconfirmed },
completion: { completions += 1 },
finalization: { outcomes.append($0) }
)
#expect(completions == 0)
#expect(outcomes == [.invokedUnconfirmed])
}
private func makeHandler(recorder: Recorder) -> BLEFileTransferHandler {
let environment = BLEFileTransferHandlerEnvironment(
localPeerID: { [localPeerID] in localPeerID },
@@ -86,6 +162,10 @@ struct BLEFileTransferHandlerTests {
recorder.removedIncomingFiles.append(storedURL)
recorder.removeIncomingFileOverride?(storedURL)
},
finishIncomingFileDelivery: { storedURL in
recorder.finishedIncomingFileDeliveries.append(storedURL)
recorder.finishIncomingFileDeliveryOverride?(storedURL)
},
isPrivateMediaSenderBlocked: { peerID in
recorder.blockedPeers.contains(peerID)
},
@@ -95,10 +175,18 @@ struct BLEFileTransferHandlerTests {
acknowledgePrivateMedia: { messageID, peerID in
recorder.deliveryAcks.append((messageID, peerID))
},
deliverMessage: { message, shouldDeliver, completion in
deliverMessage: { message, shouldDeliver, completion, finalization in
var outcome = TransportEventDeliveryOutcome.rejected
defer { finalization(outcome) }
guard recorder.shouldAcceptDelivery else { return }
guard shouldDeliver() else { return }
recorder.deliveredMessages.append(message)
guard shouldDeliver() else { return }
if recorder.deliveryOutcome == .invokedUnconfirmed {
outcome = .invokedUnconfirmed
return
}
outcome = .accepted
completion()
}
)
@@ -375,6 +463,73 @@ struct BLEFileTransferHandlerTests {
#expect(recorder.deliveryAcks.first?.messageID == recorder.deliveredMessages.first?.id)
}
@Test
func rejectedStableDeliveryReleasesPendingPayloadOwnership() throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
"private-media-handler-rejected-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: root) }
let store = BLEIncomingFileStore(baseDirectory: root)
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(
remotePeerID,
nickname: "Alice",
isVerified: true
)]
recorder.shouldAcceptDelivery = false
recorder.saveOverride = {
store.save(
data: $0,
preferredName: $1,
subdirectory: $2,
fallbackExtension: $3,
defaultPrefix: $4
)
}
recorder.receiptStateOverride = {
store.privateMediaReceiptState(messageID: $0)
}
recorder.receiptCommitOverride = {
store.commitPrivateMediaFile(messageID: $0, storedURL: $1)
}
recorder.removeIncomingFileOverride = {
store.removeIncomingFile(at: $0)
}
recorder.finishIncomingFileDeliveryOverride = {
store.finishIncomingFileDelivery(at: $0)
}
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
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 stableID = try #require(PrivateMediaMessageIdentity.stableID(
senderPeerID: remotePeerID,
recipientPeerID: localPeerID,
fileName: fileName
))
#expect(makeHandler(recorder: recorder).handlePrivatePayload(
payload,
from: remotePeerID,
timestamp: Date(timeIntervalSince1970: 1_234)
))
#expect(recorder.deliveredMessages.isEmpty)
#expect(recorder.deliveryAcks.isEmpty)
#expect(recorder.finishedIncomingFileDeliveries.count == 1)
#expect(store.reservePrivateMediaDeletion(
messageIDs: [stableID],
payloadRelativePaths: [:]
) != nil)
}
@Test
func rawLegacyPrivateFileWithRetryShapedNameNeverUsesReceiptLedger() throws {
let recorder = Recorder()
@@ -402,6 +557,64 @@ struct BLEFileTransferHandlerTests {
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
}
@Test
func rejectedRawDeliveryRemovesUIUnownedPayload() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(
remotePeerID,
nickname: "Alice",
isVerified: true,
signingPublicKey: sampleSigningKey
)]
recorder.signatureVerifies = true
recorder.shouldAcceptDelivery = false
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(
sender: remotePeerID,
mimeType: "image/jpeg",
content: Data([0xFF, 0xD8, 0xFF, 0xD9]),
recipientID: Data(hexString: localPeerID.id),
fileName: "raw-rejected.jpg"
)
#expect(handler.handle(packet, from: remotePeerID))
#expect(recorder.deliveredMessages.isEmpty)
#expect(recorder.removedIncomingFiles.count == 1)
#expect(recorder.removedIncomingFiles.first == recorder.saveResult)
#expect(recorder.finishedIncomingFileDeliveries.isEmpty)
}
@Test
func plainDelegateRawDeliveryPreservesPayloadWithoutSynchronousAck() throws {
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(
remotePeerID,
nickname: "Alice",
isVerified: true,
signingPublicKey: sampleSigningKey
)]
recorder.signatureVerifies = true
recorder.deliveryOutcome = .invokedUnconfirmed
let handler = makeHandler(recorder: recorder)
let packet = try makeFileTransferPacket(
sender: remotePeerID,
mimeType: "image/jpeg",
content: Data([0xFF, 0xD8, 0xFF, 0xD9]),
recipientID: Data(hexString: localPeerID.id),
fileName: "plain-delegate.jpg"
)
#expect(handler.handle(packet, from: remotePeerID))
#expect(recorder.deliveredMessages.count == 1)
#expect(recorder.deliveryAcks.isEmpty)
#expect(recorder.removedIncomingFiles.isEmpty)
#expect(recorder.finishedIncomingFileDeliveries.count == 1)
#expect(
recorder.finishedIncomingFileDeliveries.first
== recorder.saveResult
)
}
@Test
func repeatedLegacyPrivateImageNamesKeepDistinctRandomMessageIDs() throws {
let recorder = Recorder()
@@ -502,12 +715,7 @@ struct BLEFileTransferHandlerTests {
nickname: "Alice",
isVerified: true
)]
recorder.saveOverride = {
data,
preferredName,
subdirectory,
fallbackExtension,
defaultPrefix in
recorder.saveOverride = { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
store.save(
data: data,
preferredName: preferredName,
@@ -525,6 +733,9 @@ struct BLEFileTransferHandlerTests {
recorder.removeIncomingFileOverride = {
store.removeIncomingFile(at: $0)
}
recorder.finishIncomingFileDeliveryOverride = {
store.finishIncomingFileDelivery(at: $0)
}
}
let first = Recorder()
@@ -880,46 +1091,83 @@ struct BLEFileTransferHandlerTests {
let messageID = "media-00112233445566778899aabbccddeeff"
let seed = BLEPrivateMediaReceiptStore(baseDirectory: base)
let payload = base
.appendingPathComponent(
"files/images/incoming",
isDirectory: true
)
.appendingPathComponent("panic-receipt.jpg")
try FileManager.default.createDirectory(
at: payload.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try Data("secret".utf8).write(to: payload, options: .atomic)
#expect(seed.commitAccepted(
messageID: messageID,
storedURL: payload
))
#expect(seed.recordDeleted(messageID: messageID))
// Production wiring: receipt lookups run against the service's OWN
// incoming-file store while the panic wipe runs on the separate store
// `PanicRecoveryOperations.live()` constructs. The test must reset
// the instance BLEService uses, not a same-instance shortcut.
let keychain = MockKeychain()
let identityManager = MockIdentityManager(keychain)
let service = BLEService(
keychain: keychain,
idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()),
identityManager: identityManager,
initializeBluetoothManagers: false,
incomingFileStore: BLEIncomingFileStore(baseDirectory: base)
)
let store = BLEIncomingFileStore(baseDirectory: base)
#expect(
service._test_privateMediaReceiptState(messageID: messageID)
store.privateMediaReceiptState(messageID: messageID)
== .tombstoned
)
service.suspendForPanicReset()
// A receive callback drained during suspension can still consult the
// ledger and re-cache the pre-wipe decision before media deletion.
#expect(
service._test_privateMediaReceiptState(messageID: messageID)
== .tombstoned
)
// The wipe itself runs on the recovery operations' distinct store,
// exactly like ChatViewModel's panic transaction.
let recoveryStore = BLEIncomingFileStore(baseDirectory: base)
try recoveryStore.panicWipe()
service.completePanicReset(restartServices: false)
try store.panicWipe()
#expect(
service._test_privateMediaReceiptState(messageID: messageID)
store.privateMediaReceiptState(messageID: messageID)
== .absent
)
}
@Test
func panicWipeInvalidatesPayloadCoordinationReservations() throws {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent(
"panic-payload-coordination-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: base) }
let store = BLEIncomingFileStore(baseDirectory: base)
let pendingName = "pending-before-panic.jpg"
let pendingURL = try #require(store.save(
data: Data("old".utf8),
preferredName: pendingName,
subdirectory: "images/incoming",
fallbackExtension: "jpg",
defaultPrefix: "image"
))
let messageID = "media-aabbccddeeff00112233445566778899"
let reservation = try #require(store.reservePrivateMediaDeletion(
messageIDs: [messageID],
payloadRelativePaths: [
messageID: "images/incoming/delete-before-panic.jpg"
]
))
try store.panicWipe()
#expect(!store.commitPrivateMediaDeletion(
reservation: reservation,
messageIDs: [messageID],
payloadRelativePaths: [
messageID: "images/incoming/delete-before-panic.jpg"
],
protectedPayloadRelativePaths: []
))
let postPanicURL = try #require(store.save(
data: Data("new".utf8),
preferredName: pendingName,
subdirectory: "images/incoming",
fallbackExtension: "jpg",
defaultPrefix: "image"
))
#expect(pendingURL.lastPathComponent == pendingName)
#expect(postPanicURL.lastPathComponent == pendingName)
}
@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 }
}
}
@@ -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()
@@ -4,8 +4,22 @@ import Testing
struct BLEPrivateMediaReceiptStoreTests {
private struct TestError: Error {}
private struct ReceiptFixture: Codable {
let kind: String
let relativePath: String?
let recordedAt: Date
}
private struct JournalEntryFixture: Codable {
let relativePaths: [String]
let recordedAt: Date
}
private struct JournalFixture: Codable {
let version: Int
let entries: [String: JournalEntryFixture]
}
private let messageID = "media-00112233445566778899aabbccddeeff"
private let secondMessageID = "media-ffeeddccbbaa99887766554433221100"
@Test
func acceptedReceiptPersistsAcrossStoreInstances() throws {
@@ -21,6 +35,215 @@ struct BLEPrivateMediaReceiptStoreTests {
#expect(relaunched.state(for: messageID) == .accepted(payload))
}
@Test
func acceptedReceiptRejectsOutgoingPayloadAndCrossIDPathReuse() throws {
let root = makeRoot("accepted-ownership")
defer { try? FileManager.default.removeItem(at: root) }
let incoming = try makePayload(in: root)
let outgoingDirectory = root.appendingPathComponent(
"files/images/outgoing",
isDirectory: true
)
try FileManager.default.createDirectory(
at: outgoingDirectory,
withIntermediateDirectories: true
)
let outgoing = outgoingDirectory.appendingPathComponent("image.jpg")
try Data([0x01]).write(to: outgoing)
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(!store.commitAccepted(
messageID: messageID,
storedURL: outgoing
))
#expect(store.commitAccepted(
messageID: messageID,
storedURL: incoming
))
#expect(!store.commitAccepted(
messageID: secondMessageID,
storedURL: incoming
))
#expect(FileManager.default.fileExists(atPath: incoming.path))
#expect(FileManager.default.fileExists(atPath: outgoing.path))
}
@Test
func liveAcceptedPathSurvivesTTLAndCapacityPressure() throws {
let root = makeRoot("accepted-retention")
defer { try? FileManager.default.removeItem(at: root) }
let firstPayload = try makePayload(in: root, name: "first.jpg")
let secondPayload = try makePayload(in: root, name: "second.jpg")
let recordedAt = Date(timeIntervalSince1970: 2_000)
let seed = BLEPrivateMediaReceiptStore(
baseDirectory: root,
capacity: 1,
ttl: 1,
now: { recordedAt }
)
#expect(seed.commitAccepted(
messageID: messageID,
storedURL: firstPayload
))
let relaunched = BLEPrivateMediaReceiptStore(
baseDirectory: root,
capacity: 1,
ttl: 1,
now: { recordedAt.addingTimeInterval(2) }
)
#expect(relaunched.state(for: messageID) == .accepted(firstPayload))
#expect(!relaunched.commitAccepted(
messageID: secondMessageID,
storedURL: secondPayload
))
#expect(relaunched.state(for: messageID) == .accepted(firstPayload))
}
@Test
func incomingAllocationDoesNotReuseReceiptOwnedMissingPath() throws {
let root = makeRoot("accepted-reservation")
defer { try? FileManager.default.removeItem(at: root) }
let original = try makePayload(in: root)
#expect(BLEPrivateMediaReceiptStore(
baseDirectory: root
).commitAccepted(
messageID: messageID,
storedURL: original
))
try FileManager.default.removeItem(at: original)
let stored = BLEIncomingFileStore(baseDirectory: root).save(
data: Data([0x03]),
preferredName: "image.jpg",
subdirectory: "images/incoming",
fallbackExtension: "jpg",
defaultPrefix: "image"
)
#expect(stored?.lastPathComponent != "image.jpg")
#expect(stored.map {
FileManager.default.fileExists(atPath: $0.path)
} == true)
}
@Test
func pendingRawArrivalBlocksDeletionFallbackPathReuse() throws {
let root = makeRoot("pending-raw-arrival")
defer { try? FileManager.default.removeItem(at: root) }
let incoming = BLEIncomingFileStore(baseDirectory: root)
// The old stable bubble names image.jpg, but its receipt and payload
// have already been pruned. A raw arrival saves that basename before
// its main-actor bubble is inserted.
let rawArrival = incoming.save(
data: Data([0x03]),
preferredName: "image.jpg",
subdirectory: "images/incoming",
fallbackExtension: "jpg",
defaultPrefix: "image"
)
#expect(rawArrival?.lastPathComponent == "image.jpg")
let reservation = incoming.reservePrivateMediaDeletion(
messageIDs: [messageID],
payloadRelativePaths: [
messageID: "images/incoming/image.jpg"
]
)
#expect(reservation == nil)
#expect(rawArrival.map {
FileManager.default.fileExists(atPath: $0.path)
} == true)
}
@Test
func quotaDoesNotEvictOrReusePendingDeliveryPath() throws {
let root = makeRoot("pending-quota")
defer { try? FileManager.default.removeItem(at: root) }
let incoming = BLEIncomingFileStore(
baseDirectory: root,
quotaBytes: 1
)
let first = try #require(incoming.save(
data: Data([0x01, 0x02]),
preferredName: "image.jpg",
subdirectory: "images/incoming",
fallbackExtension: "jpg",
defaultPrefix: "image"
))
incoming.enforceQuota(reservingBytes: 1)
#expect(FileManager.default.fileExists(atPath: first.path))
let second = try #require(incoming.save(
data: Data([0x03]),
preferredName: "image.jpg",
subdirectory: "images/incoming",
fallbackExtension: "jpg",
defaultPrefix: "image"
))
#expect(second != first)
#expect(second.lastPathComponent == "image (1).jpg")
#expect(FileManager.default.fileExists(atPath: first.path))
#expect(FileManager.default.fileExists(atPath: second.path))
}
@Test
func invalidReceiptAndJournalCannotTargetOutgoingPayload() throws {
let root = makeRoot("invalid-owned-path")
defer { try? FileManager.default.removeItem(at: root) }
let outgoingDirectory = root.appendingPathComponent(
"files/images/outgoing",
isDirectory: true
)
try FileManager.default.createDirectory(
at: outgoingDirectory,
withIntermediateDirectories: true
)
let victim = outgoingDirectory.appendingPathComponent("victim.jpg")
try Data([0x02]).write(to: victim)
let receiptURL = receiptRecord(in: root)
try FileManager.default.createDirectory(
at: receiptURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let fixture = ReceiptFixture(
kind: "tombstone",
relativePath: "images/outgoing/victim.jpg",
recordedAt: Date()
)
try JSONEncoder().encode(fixture).write(
to: receiptURL,
options: .atomic
)
#expect(
BLEPrivateMediaReceiptStore(baseDirectory: root)
.state(for: messageID) == .unavailable
)
#expect(FileManager.default.fileExists(atPath: victim.path))
try FileManager.default.removeItem(at: receiptURL)
let journal = JournalFixture(
version: 1,
entries: [messageID: JournalEntryFixture(
relativePaths: ["images/outgoing/victim.jpg"],
recordedAt: fixture.recordedAt
)]
)
try JSONEncoder().encode(journal).write(
to: deletionJournal(in: root),
options: .atomic
)
#expect(
BLEPrivateMediaReceiptStore(baseDirectory: root)
.state(for: messageID) == .unavailable
)
#expect(FileManager.default.fileExists(atPath: victim.path))
}
@Test
func directoryEnumerationFailureIsUnavailableAndRetriesWithoutCachingEmpty() throws {
let root = makeRoot("list-failure")
@@ -54,7 +277,7 @@ struct BLEPrivateMediaReceiptStoreTests {
}
@Test
func recordReadFailureQuarantinesOnlyThatRecord() throws {
func recordReadFailureIsUnavailableAndPreservesReceiptForRetry() throws {
let root = makeRoot("read-failure")
defer { try? FileManager.default.removeItem(at: root) }
let payload = try makePayload(in: root)
@@ -63,12 +286,13 @@ struct BLEPrivateMediaReceiptStoreTests {
storedURL: payload
))
let record = receiptRecord(in: root)
let durableBytes = try Data(contentsOf: record)
var shouldFail = true
let store = BLEPrivateMediaReceiptStore(
baseDirectory: root,
dataReader: { url in
if url.lastPathComponent.hasPrefix(self.messageID) {
if shouldFail {
shouldFail = false
throw TestError()
}
return try Data(contentsOf: url)
@@ -76,17 +300,12 @@ struct BLEPrivateMediaReceiptStoreTests {
)
#expect(store.state(for: messageID) == .unavailable)
// The record was moved aside, bytes intact, not deleted.
#expect(!FileManager.default.fileExists(atPath: record.path))
let quarantined = quarantinedRecord(in: root)
#expect(FileManager.default.fileExists(atPath: quarantined.path))
#expect(try Data(contentsOf: quarantined) == durableBytes)
// The quarantine is sticky for this ID; no absent/accepted flapping.
#expect(store.state(for: messageID) == .unavailable)
#expect(FileManager.default.fileExists(atPath: record.path))
#expect(store.state(for: messageID) == .accepted(payload))
}
@Test
func decodeFailureQuarantinesRecordWithoutRetryOrDeletion() throws {
func decodeFailureIsUnavailableAndDoesNotDeleteOrCachePastRepair() throws {
let root = makeRoot("decode-failure")
defer { try? FileManager.default.removeItem(at: root) }
let payload = try makePayload(in: root)
@@ -95,74 +314,18 @@ struct BLEPrivateMediaReceiptStoreTests {
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)
// Only this ID fails closed; it cannot be re-recorded past the
// quarantine either.
#expect(store.state(for: messageID) == .unavailable)
#expect(!store.commitAccepted(messageID: messageID, storedURL: payload))
#expect(!store.recordDeleted(messageID: messageID))
#expect(store.state(for: messageID) == .unavailable)
#expect(FileManager.default.fileExists(atPath: record.path))
#expect(try Data(contentsOf: record) == corruptBytes)
// The unreadable bytes were preserved at the quarantine name.
let quarantined = quarantinedRecord(in: root)
#expect(!FileManager.default.fileExists(atPath: record.path))
#expect(FileManager.default.fileExists(atPath: quarantined.path))
#expect(try Data(contentsOf: quarantined) == corruptBytes)
// A relaunch stays fail-closed for this ID without ever re-reading
// the quarantined file.
let readURLs = ReadTracker()
let relaunched = BLEPrivateMediaReceiptStore(
baseDirectory: root,
dataReader: { url in
readURLs.append(url)
return try Data(contentsOf: url)
}
)
#expect(relaunched.state(for: messageID) == .unavailable)
#expect(!readURLs.urls.contains {
$0.lastPathComponent == quarantined.lastPathComponent
})
}
@Test
func corruptRecordDoesNotBlockAnotherSendersMedia() throws {
let root = makeRoot("quarantine-isolation")
defer { try? FileManager.default.removeItem(at: root) }
let otherMessageID = "media-ffeeddccbbaa99887766554433221100"
let corruptPayload = try makePayload(in: root, name: "corrupt.jpg")
let healthyPayload = try makePayload(in: root, name: "healthy.jpg")
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(seed.commitAccepted(
messageID: messageID,
storedURL: corruptPayload
))
#expect(seed.commitAccepted(
messageID: otherMessageID,
storedURL: healthyPayload
))
try Data("{not-json".utf8).write(
to: receiptRecord(in: root),
options: .atomic
)
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
// Only the damaged record fails closed; the other sender's ledger
// entry keeps serving and new decisions still commit durably.
#expect(store.state(for: messageID) == .unavailable)
#expect(store.state(for: otherMessageID) == .accepted(healthyPayload))
let freshMessageID = "media-0102030405060708090a0b0c0d0e0f10"
let freshPayload = try makePayload(in: root, name: "fresh.jpg")
#expect(store.commitAccepted(
messageID: freshMessageID,
storedURL: freshPayload
))
#expect(store.state(for: freshMessageID) == .accepted(freshPayload))
try durableBytes.write(to: record, options: .atomic)
#expect(store.state(for: messageID) == .accepted(payload))
}
@Test
@@ -176,48 +339,459 @@ struct BLEPrivateMediaReceiptStoreTests {
#expect(!FileManager.default.fileExists(atPath: payload.path))
let record = receiptRecord(in: root)
let corruptBytes = Data([0xFF, 0x00, 0x7B])
try corruptBytes.write(to: record, options: .atomic)
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)
// The quarantined tombstone can never flip to absent a sender retry
// must not resurrect explicitly deleted media even when the payload
// bytes arrive again.
try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload)
#expect(!relaunched.commitAccepted(
#expect(FileManager.default.fileExists(atPath: record.path))
try durableBytes.write(to: record, options: .atomic)
#expect(relaunched.state(for: messageID) == .tombstoned)
}
@Test
func deletionBatchCommitsEveryIDBeforeRemovingPayloads() throws {
let root = makeRoot("batch")
defer { try? FileManager.default.removeItem(at: root) }
let firstPayload = try makePayload(in: root, name: "first.jpg")
let secondPayload = try makePayload(in: root, name: "second.jpg")
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(store.commitAccepted(
messageID: messageID,
storedURL: firstPayload
))
#expect(store.commitAccepted(
messageID: secondMessageID,
storedURL: secondPayload
))
#expect(store.recordDeleted(
messageIDs: [secondMessageID, messageID]
))
#expect(store.state(for: messageID) == .tombstoned)
#expect(store.state(for: secondMessageID) == .tombstoned)
#expect(!FileManager.default.fileExists(atPath: firstPayload.path))
#expect(!FileManager.default.fileExists(atPath: secondPayload.path))
#expect(!FileManager.default.fileExists(
atPath: deletionJournal(in: root).path
))
}
@Test
func failedJournalCommitPreservesAcceptedStateAndPayloads() throws {
let root = makeRoot("journal-failure")
defer { try? FileManager.default.removeItem(at: root) }
let firstPayload = try makePayload(in: root, name: "first.jpg")
let secondPayload = try makePayload(in: root, name: "second.jpg")
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(seed.commitAccepted(
messageID: messageID,
storedURL: firstPayload
))
#expect(seed.commitAccepted(
messageID: secondMessageID,
storedURL: secondPayload
))
let failing = BLEPrivateMediaReceiptStore(
baseDirectory: root,
dataWriter: { _, _, _ in throw TestError() }
)
#expect(!failing.recordDeleted(
messageIDs: [messageID, secondMessageID]
))
// A failed commit must not install a volatile tombstone. Otherwise a
// sender retry could be ACKed although the caller kept both bubbles.
#expect(failing.state(for: messageID) == .accepted(firstPayload))
#expect(
failing.state(for: secondMessageID) == .accepted(secondPayload)
)
#expect(FileManager.default.fileExists(atPath: firstPayload.path))
#expect(FileManager.default.fileExists(atPath: secondPayload.path))
#expect(!FileManager.default.fileExists(
atPath: deletionJournal(in: root).path
))
}
@Test
func journalRecoversBatchAfterCrashDuringMaterialization() throws {
let root = makeRoot("crash-recovery")
defer { try? FileManager.default.removeItem(at: root) }
let firstPayload = try makePayload(in: root, name: "first.jpg")
let secondPayload = try makePayload(in: root, name: "second.jpg")
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(seed.commitAccepted(
messageID: messageID,
storedURL: firstPayload
))
#expect(seed.commitAccepted(
messageID: secondMessageID,
storedURL: secondPayload
))
let interrupted = BLEPrivateMediaReceiptStore(
baseDirectory: root,
dataWriter: { data, url, options in
let isJournal =
url.lastPathComponent == ".deletion-journal.json"
let isFirstRecord =
url.deletingPathExtension().lastPathComponent == messageID
guard isJournal || isFirstRecord else {
throw TestError()
}
try data.write(to: url, options: options)
}
)
#expect(interrupted.recordDeleted(
messageIDs: [messageID, secondMessageID]
))
#expect(FileManager.default.fileExists(
atPath: deletionJournal(in: root).path
))
#expect(!FileManager.default.fileExists(atPath: firstPayload.path))
#expect(FileManager.default.fileExists(atPath: secondPayload.path))
#expect(interrupted.state(for: messageID) == .tombstoned)
#expect(interrupted.state(for: secondMessageID) == .tombstoned)
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(relaunched.state(for: messageID) == .tombstoned)
#expect(relaunched.state(for: secondMessageID) == .tombstoned)
#expect(!FileManager.default.fileExists(atPath: firstPayload.path))
#expect(!FileManager.default.fileExists(atPath: secondPayload.path))
#expect(!FileManager.default.fileExists(
atPath: deletionJournal(in: root).path
))
}
@Test
func journalRetriesPayloadUnlinkAfterRelaunch() throws {
let root = makeRoot("unlink-recovery")
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
))
let quarantined = quarantinedRecord(in: root)
#expect(FileManager.default.fileExists(atPath: quarantined.path))
#expect(try Data(contentsOf: quarantined) == corruptBytes)
let unlinkFailure = BLEPrivateMediaReceiptStore(
baseDirectory: root,
payloadRemover: { _ in throw TestError() }
)
#expect(unlinkFailure.recordDeleted(messageID: messageID))
#expect(unlinkFailure.state(for: messageID) == .tombstoned)
#expect(FileManager.default.fileExists(atPath: payload.path))
#expect(FileManager.default.fileExists(
atPath: deletionJournal(in: root).path
))
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(relaunched.state(for: messageID) == .tombstoned)
#expect(!FileManager.default.fileExists(atPath: payload.path))
#expect(!FileManager.default.fileExists(
atPath: deletionJournal(in: root).path
))
}
@Test
func expiredReceiptUsesFallbackPathForCrashSafeCleanup() throws {
let root = makeRoot("expired-fallback")
defer { try? FileManager.default.removeItem(at: root) }
let payload = try makePayload(in: root)
let recordedAt = Date(timeIntervalSince1970: 1_000)
let seed = BLEPrivateMediaReceiptStore(
baseDirectory: root,
ttl: 10,
now: { recordedAt }
)
#expect(seed.commitAccepted(
messageID: messageID,
storedURL: payload
))
// Simulate a receipt pruned by an older app version while its bubble
// and payload remain. Current code retains live accepted-path owners.
try FileManager.default.removeItem(at: receiptRecord(in: root))
let afterExpiry = recordedAt.addingTimeInterval(11)
let interrupted = BLEPrivateMediaReceiptStore(
baseDirectory: root,
ttl: 10,
now: { afterExpiry },
payloadRemover: { _ in throw TestError() }
)
#expect(interrupted.recordDeleted(
messageIDs: [messageID],
payloadRelativePaths: [
messageID: "images/incoming/image.jpg"
]
))
#expect(interrupted.state(for: messageID) == .tombstoned)
#expect(FileManager.default.fileExists(atPath: payload.path))
#expect(FileManager.default.fileExists(
atPath: deletionJournal(in: root).path
))
let relaunched = BLEPrivateMediaReceiptStore(
baseDirectory: root,
ttl: 10,
now: { afterExpiry }
)
#expect(relaunched.state(for: messageID) == .tombstoned)
#expect(!FileManager.default.fileExists(atPath: payload.path))
#expect(!FileManager.default.fileExists(
atPath: deletionJournal(in: root).path
))
}
@Test
func retryAtSuffixedPathDeletesReceiptAndBubblePayloads() throws {
let root = makeRoot("retry-suffix")
defer { try? FileManager.default.removeItem(at: root) }
let original = try makePayload(in: root)
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(seed.commitAccepted(
messageID: messageID,
storedURL: original
))
// Simulate an older build pruning only the receipt. A retry must use a
// suffixed filename while the old bubble still references image.jpg.
try FileManager.default.removeItem(at: receiptRecord(in: root))
let retry = try makePayload(in: root, name: "image (1).jpg")
let retried = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(retried.commitAccepted(
messageID: messageID,
storedURL: retry
))
#expect(retried.recordDeleted(
messageIDs: [messageID],
payloadRelativePaths: [
messageID: "images/incoming/image.jpg"
]
))
#expect(retried.state(for: messageID) == .tombstoned)
#expect(!FileManager.default.fileExists(atPath: original.path))
#expect(!FileManager.default.fileExists(atPath: retry.path))
#expect(!FileManager.default.fileExists(
atPath: deletionJournal(in: root).path
))
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(relaunched.state(for: messageID) == .tombstoned)
#expect(!FileManager.default.fileExists(atPath: original.path))
#expect(!FileManager.default.fileExists(atPath: retry.path))
}
@Test
func protectedUIPathRejectsAcceptedReceiptDeletion() throws {
let root = makeRoot("protected-ui-owner")
defer { try? FileManager.default.removeItem(at: root) }
let payload = try makePayload(in: root)
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(store.commitAccepted(
messageID: messageID,
storedURL: payload
))
#expect(!store.recordDeleted(
messageIDs: [messageID],
protectedPayloadRelativePaths: [
"images/incoming/image.jpg"
]
))
#expect(store.state(for: messageID) == .accepted(payload))
#expect(FileManager.default.fileExists(atPath: payload.path))
}
@Test
func pathlessNewDeletionFailsWithoutChangingReceiverState() {
let root = makeRoot("pathless-delete")
defer { try? FileManager.default.removeItem(at: root) }
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(!store.recordDeleted(messageID: messageID))
#expect(store.state(for: messageID) == .absent)
#expect(!FileManager.default.fileExists(
atPath: deletionJournal(in: root).path
))
}
@Test
func completedTombstoneNeverDeletesReusedPayloadPath() throws {
let root = makeRoot("path-reuse")
defer { try? FileManager.default.removeItem(at: root) }
let original = try makePayload(in: root)
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(store.commitAccepted(
messageID: messageID,
storedURL: original
))
#expect(store.recordDeleted(messageID: messageID))
#expect(!FileManager.default.fileExists(atPath: original.path))
let reused = try makePayload(in: root)
#expect(store.commitAccepted(
messageID: secondMessageID,
storedURL: reused
))
#expect(store.state(for: messageID) == .tombstoned)
#expect(FileManager.default.fileExists(atPath: reused.path))
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(relaunched.state(for: messageID) == .tombstoned)
#expect(FileManager.default.fileExists(atPath: reused.path))
#expect(
BLEPrivateMediaReceiptStore(baseDirectory: root)
.state(for: messageID) == .unavailable
relaunched.state(for: secondMessageID) == .accepted(reused)
)
}
@Test
func failedTombstonePersistenceDoesNotPoisonVolatileState() throws {
let root = makeRoot("failed-tombstone-write")
func expiredLegacyPathfulTombstoneDoesNotDeleteAcceptedOwner() throws {
let root = makeRoot("legacy-path-conflict")
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)
let payload = try makePayload(in: root)
let receiptDirectory = receiptRecord(in: root)
.deletingLastPathComponent()
try FileManager.default.createDirectory(
at: record,
at: receiptDirectory,
withIntermediateDirectories: true
)
#expect(!store.recordDeleted(messageID: messageID))
try FileManager.default.removeItem(at: record)
let recordedAt = Date(timeIntervalSince1970: 3_000)
try JSONEncoder().encode(ReceiptFixture(
kind: "tombstone",
relativePath: "images/incoming/image.jpg",
recordedAt: recordedAt
)).write(
to: receiptRecord(in: root),
options: .atomic
)
try JSONEncoder().encode(ReceiptFixture(
kind: "accepted",
relativePath: "images/incoming/image.jpg",
recordedAt: recordedAt
)).write(
to: receiptRecord(
in: root,
messageID: secondMessageID
),
options: .atomic
)
// The UI must be able to report the deletion failure without a
// process-lifetime tombstone silently hiding a later retry.
let store = BLEPrivateMediaReceiptStore(
baseDirectory: root,
ttl: 1,
now: { recordedAt.addingTimeInterval(2) }
)
#expect(store.state(for: messageID) == .absent)
#expect(FileManager.default.fileExists(atPath: payload.path))
#expect(
store.state(for: secondMessageID) == .accepted(payload)
)
#expect(FileManager.default.fileExists(atPath: payload.path))
}
@Test
func expiredLegacyPathfulTombstonePreservesAmbiguousPayload() throws {
let root = makeRoot("legacy-expired-cleanup")
defer { try? FileManager.default.removeItem(at: root) }
let payload = try makePayload(in: root)
let receiptURL = receiptRecord(in: root)
try FileManager.default.createDirectory(
at: receiptURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let recordedAt = Date(timeIntervalSince1970: 3_000)
try JSONEncoder().encode(ReceiptFixture(
kind: "tombstone",
relativePath: "images/incoming/image.jpg",
recordedAt: recordedAt
)).write(to: receiptURL, options: .atomic)
let store = BLEPrivateMediaReceiptStore(
baseDirectory: root,
ttl: 1,
now: { recordedAt.addingTimeInterval(2) }
)
#expect(store.state(for: messageID) == .absent)
#expect(FileManager.default.fileExists(atPath: payload.path))
#expect(!FileManager.default.fileExists(atPath: receiptURL.path))
}
@Test
func deletionJournalNeverRecursivelyRemovesDirectoryTarget() throws {
let root = makeRoot("journal-directory")
defer { try? FileManager.default.removeItem(at: root) }
let directory = root.appendingPathComponent(
"files/images/incoming/archive",
isDirectory: true
)
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
let child = directory.appendingPathComponent("child.jpg")
try Data([0x01]).write(to: child)
let receiptDirectory = receiptRecord(in: root)
.deletingLastPathComponent()
try FileManager.default.createDirectory(
at: receiptDirectory,
withIntermediateDirectories: true
)
#expect(!BLEPrivateMediaReceiptStore(
baseDirectory: root
).recordDeleted(
messageIDs: [messageID],
payloadRelativePaths: [
messageID: "images/incoming/archive"
]
))
#expect(!FileManager.default.fileExists(
atPath: deletionJournal(in: root).path
))
try JSONEncoder().encode(JournalFixture(
version: 1,
entries: [messageID: JournalEntryFixture(
relativePaths: ["images/incoming/archive"],
recordedAt: Date()
)]
)).write(to: deletionJournal(in: root), options: .atomic)
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(store.state(for: messageID) == .tombstoned)
#expect(FileManager.default.fileExists(atPath: directory.path))
#expect(FileManager.default.fileExists(atPath: child.path))
#expect(FileManager.default.fileExists(
atPath: deletionJournal(in: root).path
))
}
@Test
func corruptDeletionJournalFailsClosedWithoutRemovingPayload() throws {
let root = makeRoot("corrupt-journal")
defer { try? FileManager.default.removeItem(at: root) }
let payload = try makePayload(in: root)
#expect(BLEPrivateMediaReceiptStore(
baseDirectory: root
).commitAccepted(
messageID: messageID,
storedURL: payload
))
let journal = deletionJournal(in: root)
try Data("{not-json".utf8).write(to: journal, options: .atomic)
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(relaunched.state(for: messageID) == .unavailable)
#expect(!relaunched.commitAccepted(
messageID: messageID,
storedURL: payload
))
#expect(FileManager.default.fileExists(atPath: payload.path))
#expect(try Data(contentsOf: journal) == Data("{not-json".utf8))
}
@Test
@@ -268,24 +842,25 @@ struct BLEPrivateMediaReceiptStoreTests {
return payload
}
private func receiptRecord(in root: URL) -> URL {
private func receiptRecord(
in root: URL,
messageID requestedMessageID: String? = nil
) -> URL {
root
.appendingPathComponent(
"files/.private-media-receipts",
isDirectory: true
)
.appendingPathComponent(messageID)
.appendingPathComponent(requestedMessageID ?? messageID)
.appendingPathExtension("json")
}
private func quarantinedRecord(in root: URL) -> URL {
receiptRecord(in: root).appendingPathExtension("corrupt")
private func deletionJournal(in root: URL) -> URL {
root
.appendingPathComponent(
"files/.private-media-receipts",
isDirectory: true
)
.appendingPathComponent(".deletion-journal.json")
}
}
/// Collects the URLs a store's data reader touched. A reference type so the
/// `@Sendable`-shaped reader closure can record without mutating captures.
private final class ReadTracker: @unchecked Sendable {
private(set) var urls: [URL] = []
func append(_ url: URL) { urls.append(url) }
}
+678 -188
View File
@@ -267,6 +267,588 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, TransportConfig.nostrPendingSendQueueCap)
}
func test_privateEnvelopeBatchEvictsEphemeralTrafficAndRemainsAtomic() async throws {
let relayURL = "wss://private-batch-priority.example"
let context = makeContext(
permission: .denied,
userTorEnabled: true,
torEnforced: true,
torIsReady: false
)
var ephemeralIDs: [String] = []
for i in 0..<(TransportConfig.nostrPendingSendQueueCap - 1) {
let event = try makeSignedEvent(
content: "ephemeral-\(i)",
kind: .ephemeralEvent
)
ephemeralIDs.append(event.id)
context.manager.sendEvent(event, to: [relayURL])
}
let regular = try makeSignedEvent(content: "regular survives")
context.manager.sendEvent(regular, to: [relayURL])
XCTAssertEqual(
context.manager.debugPendingMessageQueueCount,
TransportConfig.nostrPendingSendQueueCap
)
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
let batches = context.manager.debugPendingMessageQueueEventIDsByBatch
let allIDs = Set(batches.flatMap { $0 })
XCTAssertEqual(
context.manager.debugPendingMessageQueueCount,
TransportConfig.nostrPendingSendQueueCap
)
XCTAssertTrue(batches.contains([primary.id, legacy.id]))
XCTAssertTrue(allIDs.contains(regular.id))
XCTAssertFalse(allIDs.contains(ephemeralIDs[0]))
XCTAssertFalse(allIDs.contains(ephemeralIDs[1]))
// Later low-priority traffic may evict another ephemeral event, but
// never one half (or both halves) of the protected private batch.
let lateEphemeral = try makeSignedEvent(content: "late", kind: .geohashPresence)
context.manager.sendEvent(lateEphemeral, to: [relayURL])
XCTAssertTrue(
context.manager.debugPendingMessageQueueEventIDsByBatch
.contains([primary.id, legacy.id])
)
}
func test_privateEnvelopeBatchDoesNotWriteStaleSocketUntilTorRecovers() async throws {
let relayURL = "wss://private-batch-tor-gate.example"
let context = makeContext(
permission: .denied,
userTorEnabled: true,
torEnforced: true,
torIsReady: true
)
context.manager.ensureConnections(to: [relayURL])
let connected = await waitUntil {
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected == true
}
XCTAssertTrue(connected)
let connection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL))
context.torWaiter.isReady = false
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertTrue(connection.sentStrings.isEmpty)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
XCTAssertEqual(context.torWaiter.awaitCallCount, 1)
context.torWaiter.resolve(true)
let flushed = await waitUntil {
connection.sentStrings.count == 2
}
XCTAssertTrue(flushed)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
try await emitAcceptedPair([primary, legacy], on: connection)
let acknowledged = await waitUntil {
context.manager.debugPendingMessageQueueCount == 0
}
XCTAssertTrue(acknowledged)
}
func test_privateEnvelopeBatchPrunesTerminalRelayAfterHealthyDelivery() async throws {
let healthyURL = "wss://private-batch-healthy.example"
let deadURL = "wss://private-batch-dead.example"
let context = makeContext(permission: .denied)
context.sessionFactory.pingErrorByURL[deadURL] = NSError(
domain: NSURLErrorDomain,
code: NSURLErrorCannotFindHost
)
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
var terminalFailureCount = 0
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
[primary, legacy],
to: [healthyURL, deadURL],
terminalFailure: { terminalFailureCount += 1 }
))
let healthyConnection = try XCTUnwrap(
context.sessionFactory.latestConnection(for: healthyURL)
)
let written = await waitUntil {
healthyConnection.sentStrings.count == 2
}
XCTAssertTrue(written)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
try await emitAcceptedPair([primary, legacy], on: healthyConnection)
let initiallyAcknowledged = await waitUntil {
context.manager.debugPendingMessageQueueCount == 0
}
XCTAssertTrue(initiallyAcknowledged)
XCTAssertEqual(terminalFailureCount, 0)
// The terminal target is excluded during cooldown. More than one full
// protected-capacity worth of subsequent logical sends must continue
// to drain through the healthy relay instead of wedging globally.
for _ in 0...100 {
let sentCountBefore = healthyConnection.sentStrings.count
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
[primary, legacy],
to: [healthyURL, deadURL]
))
let written = await waitUntil {
healthyConnection.sentStrings.count == sentCountBefore + 2
}
XCTAssertTrue(written)
try await emitAcceptedPair([primary, legacy], on: healthyConnection)
let acknowledged = await waitUntil {
context.manager.debugPendingMessageQueueCount == 0
}
XCTAssertTrue(acknowledged)
}
}
func test_privateEnvelopeBatchAllTerminalTargetsReportsWholeBatchFailure() async throws {
let deadURL = "wss://private-batch-all-dead.example"
let context = makeContext(permission: .denied)
context.sessionFactory.pingErrorByURL[deadURL] = NSError(
domain: NSURLErrorDomain,
code: NSURLErrorCannotFindHost
)
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
var terminalFailureCount = 0
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
[primary, legacy],
to: [deadURL],
terminalFailure: { terminalFailureCount += 1 }
))
let failed = await waitUntil {
terminalFailureCount == 1 &&
context.manager.debugPendingMessageQueueCount == 0
}
XCTAssertTrue(failed)
}
func test_privateEnvelopeBatchRejectsBeforeWritingWhenProtectedCapacityIsFull() async throws {
let stalledRelayURL = "wss://private-batch-full.example"
let connectedRelayURL = "wss://private-batch-connected.example"
let context = makeContext(
permission: .denied,
userTorEnabled: true,
torEnforced: true,
torIsReady: true,
torIsForeground: true
)
context.manager.ensureConnections(to: [connectedRelayURL])
let connected = await waitUntil {
context.manager.relays.first(where: { $0.url == connectedRelayURL })?.isConnected == true
}
XCTAssertTrue(connected)
context.torForeground.value = false
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
let pair = [primary, legacy]
for _ in 0..<(TransportConfig.nostrPendingSendQueueCap / pair.count) {
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(pair, to: [stalledRelayURL]))
}
XCTAssertEqual(
context.manager.debugPendingMessageQueueCount,
TransportConfig.nostrPendingSendQueueCap
)
let rejectedPrimary = try makeSignedEvent(content: "rejected-primary", kind: .privateEnvelope)
let rejectedLegacy = try makeSignedEvent(content: "rejected-legacy", kind: .legacyNIP59GiftWrap)
XCTAssertFalse(
context.manager.sendPrivateEnvelopeBatch(
[rejectedPrimary, rejectedLegacy],
to: [connectedRelayURL]
)
)
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertTrue(
context.sessionFactory.latestConnection(for: connectedRelayURL)?.sentStrings.isEmpty == true
)
let queuedIDs = Set(
context.manager.debugPendingMessageQueueEventIDsByBatch.flatMap { $0 }
)
XCTAssertFalse(queuedIDs.contains(rejectedPrimary.id))
XCTAssertFalse(queuedIDs.contains(rejectedLegacy.id))
}
func test_privateEnvelopeBatchStaysQueuedAndRetriesAfterFirstWriteFails() async throws {
try await assertPrivateEnvelopeBatchWriteFailure(
failureSequence: [NSError(domain: "send", code: 1)],
expectedFirstConnectionWrites: 1
)
}
func test_privateEnvelopeBatchStaysQueuedAndRetriesAfterSecondWriteFails() async throws {
try await assertPrivateEnvelopeBatchWriteFailure(
failureSequence: [nil, NSError(domain: "send", code: 2)],
expectedFirstConnectionWrites: 2
)
}
func test_privateEnvelopeBatchRemainsPendingUntilBothRelayOKsArrive() async throws {
let relayURL = "wss://private-batch-two-write-commit.example"
let context = makeContext(permission: .denied)
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
let connection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL))
connection.deferSendCompletions = true
let firstWriteStarted = await waitUntil { connection.sentStrings.count == 1 }
XCTAssertTrue(firstWriteStarted)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
connection.flushDeferredSendCompletions()
let secondWriteStarted = await waitUntil { connection.sentStrings.count == 2 }
XCTAssertTrue(secondWriteStarted)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
connection.flushDeferredSendCompletions()
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
try connection.emitOK(eventID: primary.id, success: true, reason: "accepted")
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
try connection.emitOK(eventID: legacy.id, success: true, reason: "accepted")
let committed = await waitUntil {
context.manager.debugPendingMessageQueueCount == 0
}
XCTAssertTrue(committed)
}
func test_privateEnvelopeBatchRejectedHalfReportsWholeBatchFailure() async throws {
let relayURL = "wss://private-batch-rejected-half.example"
let context = makeContext(permission: .denied)
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
var terminalFailureCount = 0
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
[primary, legacy],
to: [relayURL],
terminalFailure: { terminalFailureCount += 1 }
))
let connection = try XCTUnwrap(
context.sessionFactory.latestConnection(for: relayURL)
)
let written = await waitUntil { connection.sentStrings.count == 2 }
XCTAssertTrue(written)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
try connection.emitOK(eventID: primary.id, success: true, reason: "accepted")
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
XCTAssertEqual(terminalFailureCount, 0)
try connection.emitOK(eventID: legacy.id, success: false, reason: "blocked: policy")
let failed = await waitUntil {
context.manager.debugPendingMessageQueueCount == 0 &&
terminalFailureCount == 1
}
XCTAssertTrue(failed)
}
func test_privateEnvelopeBatchDuplicateOKsCountAsDurableAcceptance() async throws {
let relayURL = "wss://private-batch-duplicate.example"
let context = makeContext(permission: .denied)
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
var terminalFailureCount = 0
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
[primary, legacy],
to: [relayURL],
terminalFailure: { terminalFailureCount += 1 }
))
let connection = try XCTUnwrap(
context.sessionFactory.latestConnection(for: relayURL)
)
let written = await waitUntil { connection.sentStrings.count == 2 }
XCTAssertTrue(written)
for event in [primary, legacy] {
try connection.emitOK(
eventID: event.id,
success: false,
reason: " DuPlIcAtE: already have this event"
)
try? await Task.sleep(nanoseconds: 20_000_000)
}
let acknowledged = await waitUntil {
context.manager.debugPendingMessageQueueCount == 0
}
XCTAssertTrue(acknowledged)
XCTAssertEqual(terminalFailureCount, 0)
}
func test_privateEnvelopeBatchAcknowledgementTimeoutReportsWholeBatchFailure() async throws {
let relayURL = "wss://private-batch-ok-timeout.example"
let context = makeContext(permission: .denied)
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
var terminalFailureCount = 0
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
[primary, legacy],
to: [relayURL],
terminalFailure: { terminalFailureCount += 1 }
))
let connection = try XCTUnwrap(
context.sessionFactory.latestConnection(for: relayURL)
)
let timeoutScheduled = await waitUntil {
connection.sentStrings.count == 2 &&
context.scheduler.scheduled.contains {
$0.delay == TransportConfig.nostrConfirmedSendAckTimeoutSeconds
}
}
XCTAssertTrue(timeoutScheduled)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
NostrRelayManager.registerPendingPrivateEnvelope(id: primary.id)
NostrRelayManager.registerPendingPrivateEnvelope(id: legacy.id)
context.scheduler.runNext()
let failed = await waitUntil {
context.manager.debugPendingMessageQueueCount == 0 &&
terminalFailureCount == 1
}
XCTAssertTrue(failed)
try connection.emitOK(eventID: primary.id, success: true, reason: "late")
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(terminalFailureCount, 1)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0)
XCTAssertFalse(NostrRelayManager.pendingPrivateEnvelopeIDs.contains(primary.id))
XCTAssertFalse(NostrRelayManager.pendingPrivateEnvelopeIDs.contains(legacy.id))
}
func test_privateEnvelopeBatchFastPrimaryRejectionStillWritesCompatibilityHalf() async throws {
let relayURL = "wss://private-batch-early-reject.example"
let context = makeContext(permission: .denied)
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
var terminalFailureCount = 0
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
[primary, legacy],
to: [relayURL],
terminalFailure: { terminalFailureCount += 1 }
))
let connection = try XCTUnwrap(
context.sessionFactory.latestConnection(for: relayURL)
)
connection.deferSendCompletions = true
let firstWriteStarted = await waitUntil { connection.sentStrings.count == 1 }
XCTAssertTrue(firstWriteStarted)
NostrRelayManager.registerPendingPrivateEnvelope(id: primary.id)
NostrRelayManager.registerPendingPrivateEnvelope(id: legacy.id)
try connection.emitOK(eventID: primary.id, success: false, reason: "invalid: rejected")
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
XCTAssertEqual(terminalFailureCount, 0)
XCTAssertEqual(connection.sentStrings.count, 1)
// Completing the primary write must attempt the legacy 1059 even
// though the relay has already rejected the provisional 1402.
connection.flushDeferredSendCompletions()
let compatibilityWriteStarted = await waitUntil {
connection.sentStrings.count == 2
}
XCTAssertTrue(compatibilityWriteStarted)
let compatibilityRequest = try XCTUnwrap(connection.sentStrings.last)
let compatibilityJSON = try XCTUnwrap(
JSONSerialization.jsonObject(
with: Data(compatibilityRequest.utf8)
) as? [Any]
)
let compatibilityEvent = try XCTUnwrap(
compatibilityJSON.dropFirst().first as? [String: Any]
)
XCTAssertEqual(
compatibilityEvent["kind"] as? Int,
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
)
XCTAssertEqual(compatibilityEvent["id"] as? String, legacy.id)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
XCTAssertEqual(terminalFailureCount, 0)
connection.flushDeferredSendCompletions()
let failed = await waitUntil {
context.manager.debugPendingMessageQueueCount == 0 &&
terminalFailureCount == 1
}
XCTAssertTrue(failed)
XCTAssertEqual(connection.cancelCallCount, 0)
XCTAssertEqual(
context.manager.relays.first(where: { $0.url == relayURL })?.isConnected,
true
)
XCTAssertEqual(terminalFailureCount, 1)
XCTAssertFalse(NostrRelayManager.pendingPrivateEnvelopeIDs.contains(primary.id))
XCTAssertFalse(NostrRelayManager.pendingPrivateEnvelopeIDs.contains(legacy.id))
}
func test_privateEnvelopeBatchFastRejectionThenDisconnectReplaysWholePair() async throws {
let relayURL = "wss://private-batch-early-reject-replay.example"
let context = makeContext(permission: .denied)
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
var terminalFailureCount = 0
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch(
[primary, legacy],
to: [relayURL],
terminalFailure: { terminalFailureCount += 1 }
))
let firstConnection = try XCTUnwrap(
context.sessionFactory.latestConnection(for: relayURL)
)
firstConnection.deferSendCompletions = true
let firstWriteStarted = await waitUntil {
firstConnection.sentStrings.count == 1
}
XCTAssertTrue(firstWriteStarted)
try firstConnection.emitOK(
eventID: primary.id,
success: false,
reason: "invalid: rejected"
)
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
XCTAssertEqual(terminalFailureCount, 0)
// Replacing the connection before the sibling write clears the
// attempt-scoped rejection. The durable pair must replay in full.
context.manager.disconnect()
context.manager.connect()
let replacementReady = await waitUntil {
let connections = context.sessionFactory.connectionsByURL[relayURL] ?? []
guard connections.count == 2, let replacement = connections.last else {
return false
}
return replacement.sentStrings.count == 2
}
XCTAssertTrue(replacementReady)
let replacement = try XCTUnwrap(
context.sessionFactory.latestConnection(for: relayURL)
)
try await emitAcceptedPair([primary, legacy], on: replacement)
let acknowledged = await waitUntil {
context.manager.debugPendingMessageQueueCount == 0
}
XCTAssertTrue(acknowledged)
XCTAssertEqual(terminalFailureCount, 0)
// The canceled connection's late completion cannot send its sibling
// or mutate the replacement attempt.
firstConnection.flushDeferredSendCompletions()
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(firstConnection.sentStrings.count, 1)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0)
XCTAssertEqual(terminalFailureCount, 0)
}
func test_privateEnvelopeBatchDisconnectDuringWriteReplaysOnReplacementConnection() async throws {
let relayURL = "wss://private-batch-background-reconnect.example"
let context = makeContext(permission: .denied)
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
let firstConnection = try XCTUnwrap(
context.sessionFactory.latestConnection(for: relayURL)
)
firstConnection.deferSendCompletions = true
let firstWriteStarted = await waitUntil { firstConnection.sentStrings.count == 1 }
XCTAssertTrue(firstWriteStarted)
// Backgrounding cancels the socket while its first write callback is
// still outstanding. The pair must remain replayable in the queue.
context.manager.disconnect()
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
context.manager.connect()
let replacementReady = await waitUntil {
let connections = context.sessionFactory.connectionsByURL[relayURL] ?? []
guard connections.count == 2, let replacement = connections.last else {
return false
}
return replacement.sentStrings.count == 2
}
XCTAssertTrue(replacementReady)
let replacement = try XCTUnwrap(
context.sessionFactory.latestConnection(for: relayURL)
)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
try await emitAcceptedPair([primary, legacy], on: replacement)
let acknowledged = await waitUntil {
context.manager.debugPendingMessageQueueCount == 0
}
XCTAssertTrue(acknowledged)
// A late success callback from the canceled socket must neither send
// the legacy half on that stale socket nor disturb the completed pair.
firstConnection.flushDeferredSendCompletions()
try? await Task.sleep(nanoseconds: 20_000_000)
XCTAssertEqual(firstConnection.sentStrings.count, 1)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 0)
}
private func assertPrivateEnvelopeBatchWriteFailure(
failureSequence: [Error?],
expectedFirstConnectionWrites: Int
) async throws {
let relayURL = "wss://private-batch-write-failure.example"
let context = makeContext(permission: .denied)
let primary = try makeSignedEvent(content: "primary", kind: .privateEnvelope)
let legacy = try makeSignedEvent(content: "legacy", kind: .legacyNIP59GiftWrap)
XCTAssertTrue(context.manager.sendPrivateEnvelopeBatch([primary, legacy], to: [relayURL]))
let firstConnection = try XCTUnwrap(context.sessionFactory.latestConnection(for: relayURL))
firstConnection.sendErrorSequence = failureSequence
let stayedQueued = await waitUntil {
firstConnection.sentStrings.count == expectedFirstConnectionWrites &&
context.manager.debugPendingMessageQueueEventIDsByBatch.contains([primary.id, legacy.id])
}
XCTAssertTrue(stayedQueued)
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, 2)
// The failed socket is disconnected with bounded backoff. Its queue
// item is reusednot re-admittedand the replacement retries both
// copies before the pair is finally removed.
XCTAssertFalse(context.scheduler.scheduled.isEmpty)
context.scheduler.runNext()
let replacementReady = await waitUntil {
guard let replacement = context.sessionFactory.latestConnection(for: relayURL),
replacement !== firstConnection else { return false }
return replacement.sentStrings.count == 2
}
XCTAssertTrue(replacementReady)
let replacement = try XCTUnwrap(
context.sessionFactory.latestConnection(for: relayURL)
)
try await emitAcceptedPair([primary, legacy], on: replacement)
let acknowledged = await waitUntil {
context.manager.debugPendingMessageQueueCount == 0
}
XCTAssertTrue(acknowledged)
}
func test_sendEvent_waitsForTorReadinessBeforeSending() async throws {
let relayURL = "wss://tor-ready.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
@@ -548,6 +1130,52 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertEqual(context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count, 1)
}
func test_subscribe_multiplePrivateEnvelopeFiltersEncodesIndependentLimits() async throws {
let relayURL = "wss://private-recovery-filters.example"
let context = makeContext(permission: .denied)
let filters = NostrFilter.privateEnvelopeFiltersFor(
pubkey: "recipient",
since: Date(timeIntervalSince1970: 1_234_567)
)
context.manager.subscribe(
filters: filters,
id: "private-recovery",
relayUrls: [relayURL],
handler: { _ in }
)
let sent = await waitUntil {
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.count == 1
}
XCTAssertTrue(sent)
let request = try XCTUnwrap(
context.sessionFactory.latestConnection(for: relayURL)?.sentStrings.first
)
let data = try XCTUnwrap(request.data(using: .utf8))
let array = try XCTUnwrap(
try JSONSerialization.jsonObject(with: data) as? [Any]
)
XCTAssertEqual(array.count, 4)
XCTAssertEqual(array[0] as? String, "REQ")
XCTAssertEqual(array[1] as? String, "private-recovery")
let encodedFilters = try [array[2], array[3]].map {
try XCTUnwrap($0 as? [String: Any])
}
XCTAssertEqual(encodedFilters.compactMap { $0["kinds"] as? [Int] }, [
[NostrProtocol.EventKind.privateEnvelope.rawValue],
[NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue]
])
for filter in encodedFilters {
XCTAssertEqual(
filter["limit"] as? Int,
TransportConfig.nostrPrivateEnvelopeFetchLimitPerKind
)
XCTAssertEqual(filter["#p"] as? [String], ["recipient"])
XCTAssertEqual(filter["since"] as? Int, 1_234_567)
}
}
func test_subscribe_coalescesDuplicateRequestsBeforeTorReadyAndDefersEOSE() async throws {
let relayURL = "wss://tor-subscribe-coalesce.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false)
@@ -754,15 +1382,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 +1419,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 +1457,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)
@@ -1039,11 +1521,11 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertTrue(secondDelivered)
}
func test_okMessages_clearPendingGiftWrapIDs() async throws {
func test_okMessages_clearPendingPrivateEnvelopeIDs() async throws {
let relayURL = "wss://ok.example"
let context = makeContext(permission: .denied)
let successID = "gift-wrap-success"
let failureID = "gift-wrap-failure"
let successID = "private-envelope-success"
let failureID = "private-envelope-failure"
context.manager.ensureConnections(to: [relayURL])
let connected = await waitUntil {
@@ -1052,17 +1534,17 @@ final class NostrRelayManagerTests: XCTestCase {
}
XCTAssertTrue(connected)
NostrRelayManager.registerPendingGiftWrap(id: successID)
NostrRelayManager.registerPendingPrivateEnvelope(id: successID)
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: successID, success: true, reason: "ok")
let successCleared = await waitUntil {
!NostrRelayManager.pendingGiftWrapIDs.contains(successID)
!NostrRelayManager.pendingPrivateEnvelopeIDs.contains(successID)
}
XCTAssertTrue(successCleared)
NostrRelayManager.registerPendingGiftWrap(id: failureID)
NostrRelayManager.registerPendingPrivateEnvelope(id: failureID)
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: failureID, success: false, reason: "rejected")
let failureCleared = await waitUntil {
!NostrRelayManager.pendingGiftWrapIDs.contains(failureID)
!NostrRelayManager.pendingPrivateEnvelopeIDs.contains(failureID)
}
XCTAssertTrue(failureCleared)
}
@@ -1792,12 +2274,31 @@ final class NostrRelayManagerTests: XCTestCase {
return filter
}
private func makeSignedEvent(content: String) throws -> NostrEvent {
private func emitAcceptedPair(
_ events: [NostrEvent],
on connection: MockRelayConnection
) async throws {
for event in events {
try connection.emitOK(
eventID: event.id,
success: true,
reason: "accepted"
)
// Each inbound frame consumes the mock receive handler. Let the
// manager re-arm it before delivering the next relay response.
try? await Task.sleep(nanoseconds: 20_000_000)
}
}
private func makeSignedEvent(
content: String,
kind: NostrProtocol.EventKind = .textNote
) throws -> NostrEvent {
let identity = try NostrIdentity.generate()
let event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .textNote,
kind: kind,
tags: [],
content: content
)
@@ -1937,6 +2438,7 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
private final class MockRelayConnection: NostrRelayConnectionProtocol {
private let pingError: Error?
var sendError: Error?
var sendErrorSequence: [Error?] = []
private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
private(set) var resumeCallCount = 0
private(set) var cancelCallCount = 0
@@ -1966,14 +2468,15 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
}
var deferSendCompletions = false
private var deferredSendCompletions: [(Error?) -> Void] = []
private var deferredSendCompletions: [(error: Error?, completion: (Error?) -> Void)] = []
func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
sentMessages.append(message)
let error = sendErrorSequence.isEmpty ? sendError : sendErrorSequence.removeFirst()
if deferSendCompletions {
deferredSendCompletions.append(completionHandler)
deferredSendCompletions.append((error, completionHandler))
} else {
completionHandler(sendError)
completionHandler(error)
}
}
@@ -1981,16 +2484,12 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
let pending = deferredSendCompletions
deferredSendCompletions = []
pending.forEach {
$0(sendError)
$0.completion($0.error)
}
}
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 +2522,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)))
}
}
+637 -40
View File
@@ -131,7 +131,7 @@ struct NostrTransportTests {
#expect(undeliverable)
}
@Test("Private message resolves short peer ID and emits decryptable packet")
@Test("Private message resolves short peer ID and emits both migration formats")
@MainActor
func sendPrivateMessageResolvesShortPeerID() async throws {
let keychain = MockKeychain()
@@ -153,8 +153,8 @@ struct NostrTransportTests {
favoriteStatusForNoiseKey: { _ in nil },
favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },
currentIdentity: { sender },
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
sendEvent: probe.record(event:),
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
@@ -164,8 +164,12 @@ struct NostrTransportTests {
transport.sendPrivateMessage("hello over nostr", to: shortPeerID, recipientNickname: "Carol", messageID: "pm-1")
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
#expect(didSend)
#expect(probe.sentEvents.map(\.kind) == [
NostrProtocol.EventKind.privateEnvelope.rawValue,
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
])
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
let privateMessage = try decodePrivateMessage(from: result.payload)
@@ -173,7 +177,583 @@ struct NostrTransportTests {
#expect(privateMessage.messageID == "pm-1")
#expect(privateMessage.content == "hello over nostr")
#expect(result.packet.recipientID == shortPeerID.routingData)
#expect(probe.pendingGiftWrapIDs.isEmpty)
#expect(probe.pendingPrivateEnvelopeIDs.isEmpty)
}
@Test("Coordinated migration always publishes primary and compatibility envelopes")
@MainActor
func migrationAlwaysDualPublishes() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let noiseKey = Data((192..<224).map(UInt8.init))
let peerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Migration peer"
)
let migrationProbe = NostrTransportProbe()
let migrationTransport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
currentIdentity: { sender },
sendPrivateEnvelopeBatch: { events, _ in migrationProbe.record(batch: events) }
)
)
migrationTransport.senderPeerID = PeerID(str: "0123456789abcdef")
migrationTransport.sendPrivateMessage(
"migration payload",
to: peerID,
recipientNickname: "Migration peer",
messageID: "migration-pm"
)
let sentPair = await TestHelpers.waitUntil(
{ migrationProbe.sentEvents.count == 2 },
timeout: 5.0
)
#expect(sentPair)
#expect(migrationProbe.sentEvents.map(\.kind) == [
NostrProtocol.EventKind.privateEnvelope.rawValue,
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
])
for event in migrationProbe.sentEvents {
let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
let message = try decodePrivateMessage(from: result.payload)
#expect(message.messageID == "migration-pm")
#expect(message.content == "migration payload")
}
}
@Test("Rejected migration batch does not register half-delivery state")
@MainActor
func rejectedMigrationBatchRegistersNothing() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let probe = NostrTransportProbe()
var rejectedKinds: [Int] = []
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
currentIdentity: { sender },
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
sendPrivateEnvelopeBatch: { events, _ in
rejectedKinds = events.map(\.kind)
return false
}
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
transport.sendPrivateMessageGeohash(
content: "must stay atomic",
toRecipientHex: recipient.publicKeyHex,
from: sender,
messageID: "atomic-reject"
)
let attempted = await TestHelpers.waitUntil({ rejectedKinds.count == 2 }, timeout: 5.0)
#expect(attempted)
#expect(rejectedKinds == [
NostrProtocol.EventKind.privateEnvelope.rawValue,
NostrProtocol.EventKind.legacyNIP59GiftWrap.rawValue
])
#expect(probe.pendingPrivateEnvelopeIDs.isEmpty)
}
@Test("Rejected user message emits a visible failed-delivery event")
@MainActor
func rejectedUserMessageEmitsFailureEvent() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let eventProbe = NostrTransportEventProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
currentIdentity: { sender },
sendPrivateEnvelopeBatch: { _, _ in false }
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
transport.eventDelegate = eventProbe
transport.sendPrivateMessageGeohash(
content: "must fail visibly",
toRecipientHex: recipient.publicKeyHex,
from: sender,
messageID: "visible-reject"
)
let reported = await TestHelpers.waitUntil(
{ eventProbe.failedMessageIDs == ["visible-reject"] },
timeout: 5.0
)
#expect(reported)
}
@Test("Envelope construction failure rejects visibly without publishing")
@MainActor
func envelopeConstructionFailureRejectsVisibly() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let eventProbe = NostrTransportEventProbe()
let publicationProbe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
currentIdentity: { sender },
sendPrivateEnvelopeBatch: { events, _ in
publicationProbe.record(batch: events)
}
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
transport.eventDelegate = eventProbe
// Non-empty but invalid hex reaches envelope construction after the
// embedded BitChat packet succeeds, reproducing the throwing batch
// builder path without allocating an oversized test fixture.
let accepted = transport.sendPrivateMessageGeohash(
content: "must fail before sent",
toRecipientHex: "not-a-hex-public-key",
from: sender,
messageID: "build-reject"
)
#expect(!accepted)
#expect(publicationProbe.sentEvents.isEmpty)
#expect(eventProbe.failedMessageIDs == ["build-reject"])
}
@Test("Unencodable user message rejects visibly without publishing")
@MainActor
func unencodableUserMessageRejectsVisibly() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let eventProbe = NostrTransportEventProbe()
let publicationProbe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
currentIdentity: { sender },
sendPrivateEnvelopeBatch: { events, _ in
publicationProbe.record(batch: events)
}
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
transport.eventDelegate = eventProbe
// PrivateMessagePacket uses the deployed UInt8 content length. Do not
// create a larger wire shape that released clients cannot decode.
let accepted = transport.sendPrivateMessageGeohash(
content: String(repeating: "x", count: 256),
toRecipientHex: recipient.publicKeyHex,
from: sender,
messageID: "packet-reject"
)
#expect(!accepted)
#expect(publicationProbe.sentEvents.isEmpty)
#expect(eventProbe.failedMessageIDs == ["packet-reject"])
}
@Test("Unencodable direct message emits a visible failure")
@MainActor
func unencodableDirectMessageEmitsVisibleFailure() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let noiseKey = Data((224..<256).map(UInt8.init))
let peerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Oversized peer"
)
let eventProbe = NostrTransportEventProbe()
let publicationProbe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: {
$0 == noiseKey ? relationship : nil
},
currentIdentity: { sender },
sendPrivateEnvelopeBatch: { events, _ in
publicationProbe.record(batch: events)
}
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
transport.eventDelegate = eventProbe
transport.sendPrivateMessage(
String(repeating: "x", count: 256),
to: peerID,
recipientNickname: "Oversized peer",
messageID: "direct-packet-reject"
)
let failed = await TestHelpers.waitUntil(
{
eventProbe.failedMessageIDs
== ["direct-packet-reject"]
},
timeout: 5.0
)
#expect(failed)
#expect(publicationProbe.sentEvents.isEmpty)
}
@Test("Direct message with an invalid npub emits a visible failure")
@MainActor
func invalidDirectRecipientNpubEmitsVisibleFailure() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let noiseKey = Data(repeating: 0xA5, count: 32)
let peerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: "not-a-valid-npub",
peerNickname: "Invalid npub peer"
)
let eventProbe = NostrTransportEventProbe()
let publicationProbe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: {
$0 == noiseKey ? relationship : nil
},
currentIdentity: { sender },
sendPrivateEnvelopeBatch: { events, _ in
publicationProbe.record(batch: events)
}
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
transport.eventDelegate = eventProbe
transport.sendPrivateMessage(
"must fail before publication",
to: peerID,
recipientNickname: "Invalid npub peer",
messageID: "invalid-npub"
)
let failed = await TestHelpers.waitUntil(
{ eventProbe.failedMessageIDs == ["invalid-npub"] },
timeout: 5.0
)
#expect(failed)
#expect(publicationProbe.sentEvents.isEmpty)
}
@Test("Direct message without a resolvable recipient emits a visible failure")
@MainActor
func missingDirectRecipientEmitsVisibleFailure() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let peerID = PeerID(hexData: Data(repeating: 0xC3, count: 32))
let eventProbe = NostrTransportEventProbe()
let publicationProbe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
currentIdentity: { sender },
sendPrivateEnvelopeBatch: { events, _ in
publicationProbe.record(batch: events)
}
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
transport.eventDelegate = eventProbe
transport.sendPrivateMessage(
"must fail without recipient",
to: peerID,
recipientNickname: "Unknown peer",
messageID: "missing-recipient"
)
let failed = await TestHelpers.waitUntil(
{ eventProbe.failedMessageIDs == ["missing-recipient"] },
timeout: 5.0
)
#expect(failed)
#expect(publicationProbe.sentEvents.isEmpty)
}
@Test("Direct message without a current identity emits a visible failure")
@MainActor
func missingDirectSenderIdentityEmitsVisibleFailure() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let recipient = try NostrIdentity.generate()
let noiseKey = Data(repeating: 0x5A, count: 32)
let peerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Missing identity peer"
)
let eventProbe = NostrTransportEventProbe()
let publicationProbe = NostrTransportProbe()
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: {
$0 == noiseKey ? relationship : nil
},
currentIdentity: { nil },
sendPrivateEnvelopeBatch: { events, _ in
publicationProbe.record(batch: events)
}
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
transport.eventDelegate = eventProbe
transport.sendPrivateMessage(
"must fail without identity",
to: peerID,
recipientNickname: "Missing identity peer",
messageID: "missing-identity"
)
let failed = await TestHelpers.waitUntil(
{ eventProbe.failedMessageIDs == ["missing-identity"] },
timeout: 5.0
)
#expect(failed)
#expect(publicationProbe.sentEvents.isEmpty)
}
@Test("Rejected favorite notification retains and retries the exact pair")
@MainActor
func rejectedFavoriteNotificationRetriesExactPair() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let noiseKey = Data((96..<128).map(UInt8.init))
let fullPeerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Retry favorite"
)
let probe = NostrTransportProbe()
var attempts: [[String]] = []
weak var releasedTransport: NostrTransport?
do {
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
currentIdentity: { sender },
sendPrivateEnvelopeBatch: { events, _ in
attempts.append(events.map(\.id))
return attempts.count > 1
},
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
releasedTransport = transport
transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true)
let retryScheduled = await TestHelpers.waitUntil(
{ attempts.count == 1 && probe.scheduledActionCount == 1 },
timeout: 5.0
)
#expect(retryScheduled)
}
#expect(releasedTransport == nil)
#expect(probe.runNextScheduledAction())
let retried = await TestHelpers.waitUntil({ attempts.count == 2 }, timeout: 5.0)
#expect(retried)
#expect(attempts[0] == attempts[1])
}
@Test("Rejected delivery acknowledgement remains queued for retry")
@MainActor
func rejectedDeliveryAckRetries() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let noiseKey = Data((128..<160).map(UInt8.init))
let fullPeerID = PeerID(hexData: noiseKey)
let relationship = makeRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: recipient.npub,
peerNickname: "Retry ack"
)
let probe = NostrTransportProbe()
var attempts: [[String]] = []
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
currentIdentity: { sender },
sendPrivateEnvelopeBatch: { events, _ in
attempts.append(events.map(\.id))
return attempts.count > 1
},
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
)
)
transport.senderPeerID = PeerID(str: "0123456789abcdef")
transport.sendDeliveryAck(for: "retry-ack", to: fullPeerID)
let firstAttempt = await TestHelpers.waitUntil(
{ attempts.count == 1 && probe.scheduledActionCount >= 1 },
timeout: 5.0
)
#expect(firstAttempt)
for _ in 0..<3 where attempts.count < 2 {
_ = probe.runNextScheduledAction()
_ = await TestHelpers.waitUntil(
{ attempts.count == 2 || probe.scheduledActionCount > 0 },
timeout: 1.0
)
}
#expect(attempts.count == 2)
#expect(attempts[0] == attempts[1])
}
@Test("Control retry queue is bounded and evicted callbacks are harmless")
@MainActor
func controlRetryQueueIsBounded() async throws {
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let events = try NostrProtocol.createPrivateEnvelopePublicationBatch(
content: "bounded retry fixture",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
let probe = NostrTransportProbe()
var retryAttempts = 0
let queue = NostrPrivateEnvelopeRetryQueue(
sendPrivateEnvelopeBatch: { _, _ in
retryAttempts += 1
return false
},
registerPendingPrivateEnvelope: { _ in },
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
)
for index in 0...TransportConfig.nostrPrivateEnvelopeRetryQueueCap {
queue.enqueue(
key: "control-\(index)",
events: events,
registerPending: false
)
}
#expect(queue.debugPendingCount == TransportConfig.nostrPrivateEnvelopeRetryQueueCap)
#expect(!queue.debugContains(key: "control-0"))
#expect(queue.debugContains(key: "control-1"))
// The oldest callback was scheduled before eviction. Running it must
// observe the missing key and return without touching dependencies.
#expect(probe.runNextScheduledAction())
try? await Task.sleep(nanoseconds: 20_000_000)
#expect(retryAttempts == 0)
queue.removeAll()
#expect(queue.debugPendingCount == 0)
#expect(probe.runNextScheduledAction())
try? await Task.sleep(nanoseconds: 20_000_000)
#expect(retryAttempts == 0)
}
@Test("Multiple transports share one globally bounded control retry owner")
@MainActor
func multipleTransportsShareControlRetryQueue() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let events = try NostrProtocol.createPrivateEnvelopePublicationBatch(
content: "shared retry fixture",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
let probe = NostrTransportProbe()
var retryAttempts = 0
let sharedQueue = NostrPrivateEnvelopeRetryQueue(
sendPrivateEnvelopeBatch: { _, _ in
retryAttempts += 1
return false
},
registerPendingPrivateEnvelope: { _ in },
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
)
let first = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(envelopeRetryQueue: sharedQueue)
)
let second = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: makeDependencies(envelopeRetryQueue: sharedQueue)
)
first.debugEnqueueControlRetry(key: "shared", events: events)
second.debugEnqueueControlRetry(key: "shared", events: events)
#expect(first.debugControlRetryCount == 1)
#expect(second.debugControlRetryCount == 1)
for index in 0...TransportConfig.nostrPrivateEnvelopeRetryQueueCap {
let transport = index.isMultiple(of: 2) ? first : second
transport.debugEnqueueControlRetry(key: "global-\(index)", events: events)
}
#expect(first.debugControlRetryCount == TransportConfig.nostrPrivateEnvelopeRetryQueueCap)
#expect(second.debugControlRetryCount == TransportConfig.nostrPrivateEnvelopeRetryQueueCap)
// The first scheduled callback belongs to the now-evicted shared key.
#expect(probe.runNextScheduledAction())
try? await Task.sleep(nanoseconds: 20_000_000)
#expect(retryAttempts == 0)
}
@Test("Favorite notification embeds current npub")
@@ -198,8 +778,8 @@ struct NostrTransportTests {
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
favoriteStatusForPeerID: { _ in nil },
currentIdentity: { sender },
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
sendEvent: probe.record(event:),
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
@@ -209,7 +789,7 @@ struct NostrTransportTests {
transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true)
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
#expect(didSend)
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
let privateMessage = try decodePrivateMessage(from: result.payload)
@@ -239,8 +819,8 @@ struct NostrTransportTests {
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
favoriteStatusForPeerID: { _ in nil },
currentIdentity: { sender },
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
sendEvent: probe.record(event:),
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
@@ -250,7 +830,7 @@ struct NostrTransportTests {
transport.sendDeliveryAck(for: "ack-1", to: fullPeerID)
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
#expect(didSend)
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
@@ -259,9 +839,9 @@ struct NostrTransportTests {
#expect(result.packet.recipientID == fullPeerID.toShort().routingData)
}
@Test("Geohash private message registers pending gift wrap")
@Test("Geohash private message registers pending private envelope")
@MainActor
func sendPrivateMessageGeohashRegistersPendingGiftWrap() async throws {
func sendPrivateMessageGeohashRegistersPendingPrivateEnvelope() async throws {
let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate()
@@ -272,8 +852,8 @@ struct NostrTransportTests {
idBridge: idBridge,
dependencies: makeDependencies(
currentIdentity: { sender },
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
sendEvent: probe.record(event:),
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
@@ -288,7 +868,7 @@ struct NostrTransportTests {
messageID: "geo-1"
)
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 1 }, timeout: 5.0)
let didSend = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: 5.0)
#expect(didSend)
let event = probe.sentEvents[0]
let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
@@ -297,7 +877,7 @@ struct NostrTransportTests {
#expect(privateMessage.messageID == "geo-1")
#expect(privateMessage.content == "geo hello")
#expect(result.packet.recipientID == nil)
#expect(probe.pendingGiftWrapIDs == [event.id])
#expect(probe.pendingPrivateEnvelopeIDs == probe.sentEvents.map(\.id))
}
@Test("Read receipt queue sends in order and waits for scheduler")
@@ -322,8 +902,8 @@ struct NostrTransportTests {
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
favoriteStatusForPeerID: { _ in nil },
currentIdentity: { sender },
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:),
sendEvent: probe.record(event:),
registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action)
}
@@ -338,20 +918,20 @@ struct NostrTransportTests {
transport.sendReadReceipt(second, to: fullPeerID)
let readReceiptTimeout: TimeInterval = 5.0
let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count >= 1 }, timeout: readReceiptTimeout)
try #require(sentFirst, "Expected first queued read receipt event")
let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: readReceiptTimeout)
try #require(sentFirst, "Expected first queued read receipt pair")
let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: readReceiptTimeout)
try #require(scheduledThrottle, "Expected queued throttle action after first read receipt")
let firstEvent = try #require(probe.sentEvents.first, "Expected first queued read receipt event")
let firstEvent = try #require(probe.sentEvents.first, "Expected first queued read receipt pair")
let firstPayload = try decodeEmbeddedPayload(from: firstEvent, recipient: recipient).payload
#expect(firstPayload.type == .readReceipt)
#expect(String(data: firstPayload.data, encoding: .utf8) == "read-1")
try #require(probe.runNextScheduledAction(), "Expected queued throttle action after first read receipt")
let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count >= 2 }, timeout: readReceiptTimeout)
try #require(sentSecond, "Expected second read receipt after running throttle action")
let secondEvent = try #require(probe.sentEvents.last, "Expected second queued read receipt event")
let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count == 4 }, timeout: readReceiptTimeout)
try #require(sentSecond, "Expected second read receipt pair after running throttle action")
let secondEvent = probe.sentEvents[2]
let secondPayload = try decodeEmbeddedPayload(from: secondEvent, recipient: recipient).payload
#expect(secondPayload.type == .readReceipt)
#expect(String(data: secondPayload.data, encoding: .utf8) == "read-2")
@@ -419,10 +999,14 @@ struct NostrTransportTests {
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoriteRelationship? = { _ in nil },
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil },
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in },
sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in },
registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void = { _ in },
sendPrivateEnvelopeBatch: @escaping @MainActor (
[NostrEvent],
@escaping @MainActor () -> Void
) -> Bool = { _, _ in true },
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in },
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never> = { Just(false).eraseToAnyPublisher() }
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never> = { Just(false).eraseToAnyPublisher() },
envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue? = nil
) -> NostrTransport.Dependencies {
NostrTransport.Dependencies(
notificationCenter: notificationCenter,
@@ -430,10 +1014,11 @@ struct NostrTransportTests {
favoriteStatusForNoiseKey: favoriteStatusForNoiseKey,
favoriteStatusForPeerID: favoriteStatusForPeerID,
currentIdentity: currentIdentity,
registerPendingGiftWrap: registerPendingGiftWrap,
sendEvent: sendEvent,
registerPendingPrivateEnvelope: registerPendingPrivateEnvelope,
sendPrivateEnvelopeBatch: sendPrivateEnvelopeBatch,
scheduleAfter: scheduleAfter,
relayConnectivity: relayConnectivity
relayConnectivity: relayConnectivity,
envelopeRetryQueue: envelopeRetryQueue
)
}
@@ -457,8 +1042,8 @@ struct NostrTransportTests {
from event: NostrEvent,
recipient: NostrIdentity
) throws -> (packet: BitchatPacket, payload: NoisePayload, senderPubkey: String) {
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
giftWrap: event,
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
envelope: event,
recipientIdentity: recipient
)
guard content.hasPrefix("bitchat1:") else {
@@ -488,6 +1073,17 @@ private enum NostrTransportTestError: Error {
case invalidPrivateMessage
}
@MainActor
private final class NostrTransportEventProbe: TransportEventDelegate {
private(set) var failedMessageIDs: [String] = []
func didReceiveTransportEvent(_ event: TransportEvent) {
guard case .messageDeliveryStatusUpdated(let messageID, let status) = event,
case .failed = status else { return }
failedMessageIDs.append(messageID)
}
}
private func base64URLDecode(_ string: String) -> Data? {
var candidate = string
let padding = (4 - (candidate.count % 4)) % 4
@@ -503,7 +1099,7 @@ private func base64URLDecode(_ string: String) -> Data? {
private final class NostrTransportProbe: @unchecked Sendable {
private let lock = NSLock()
private var sentEventsStorage: [NostrEvent] = []
private var pendingGiftWrapIDsStorage: [String] = []
private var pendingPrivateEnvelopeIDsStorage: [String] = []
private var scheduledActionsStorage: [(@Sendable () -> Void)] = []
var sentEvents: [NostrEvent] {
@@ -512,10 +1108,10 @@ private final class NostrTransportProbe: @unchecked Sendable {
return sentEventsStorage
}
var pendingGiftWrapIDs: [String] {
var pendingPrivateEnvelopeIDs: [String] {
lock.lock()
defer { lock.unlock() }
return pendingGiftWrapIDsStorage
return pendingPrivateEnvelopeIDsStorage
}
var scheduledActionCount: Int {
@@ -524,15 +1120,16 @@ private final class NostrTransportProbe: @unchecked Sendable {
return scheduledActionsStorage.count
}
func record(event: NostrEvent) {
func record(batch: [NostrEvent]) -> Bool {
lock.lock()
sentEventsStorage.append(event)
sentEventsStorage.append(contentsOf: batch)
lock.unlock()
return true
}
func recordPendingGiftWrap(id: String) {
func recordPendingPrivateEnvelope(id: String) {
lock.lock()
pendingGiftWrapIDsStorage.append(id)
pendingPrivateEnvelopeIDsStorage.append(id)
lock.unlock()
}
@@ -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
+5 -13
View File
@@ -107,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.
+2 -2
View File
@@ -54,9 +54,9 @@ Public archives contain content already intended for public mesh/board distribut
## Nostr and Mesh Bridge
- NIP-17/NIP-44 v2 private fallback protects plaintext with secp256k1 key agreement, HKDF-SHA256, and XChaCha20-Poly1305. Relays still see event and network metadata.
- BitChat's proprietary private-envelope fallback protects plaintext with secp256k1 key agreement, HKDF-SHA256 with a BitChat-specific domain separator, and XChaCha20-Poly1305. It is not NIP-17, NIP-44, or NIP-59 compatible and does not provide forward secrecy against later compromise of the recipient's static Nostr private key. Relays still see the recipient public-key tag, event timing and size, and network metadata.
- Bridge courier drops use a throwaway publisher key, an opaque Noise-sealed envelope, and a day-rotating recipient tag. Only a party already holding the recipient's Noise static key can compute candidate tags.
- Relay publication is considered successful only after an explicit NIP-20 `OK true` from at least one target relay. Rejected, disconnected, timed-out, or merely socket-written events stay retryable.
- Relay publication is considered successful only after an explicit NIP-01 `OK true` from at least one target relay. Rejected, disconnected, timed-out, or merely socket-written events stay retryable.
- When mesh bridge is enabled, public mesh messages not marked “nearby only” are signed under a per-cell Nostr identity and published to a neighborhood rendezvous geohash. Presence and public bridge traffic therefore expose a coarse area to relays and participants.
- A bridge gateway can carry signed bridge/location events and opaque courier drops for nearby mesh-only peers. It cannot validly publish a neighbor's radio-only message because the author must first sign the bridge event.
+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())