Compare commits

..
Author SHA1 Message Date
jack 84bed52d08 Stabilize VoiceRecorder async gate tests 2026-07-26 08:30:37 +02:00
jack 5d92dca135 Scope authenticated delivery cleanup 2026-07-26 07:17:22 +02:00
jackandjack 41a6072b1b Retry private messages after Noise session replacement 2026-07-26 07:17:22 +02:00
jackandjack 7d8fdea3c8 Fix live DM handoff across transport aliases 2026-07-26 07:17:22 +02:00
jack f6d6797d80 Prevent Bluetooth alerts from interrupting modal warnings 2026-07-26 07:17:22 +02:00
jack 33d65b4129 Prevent Bluetooth alerts from competing with sheets 2026-07-26 07:17:22 +02:00
jackandjack 48e86b6148 Preserve open DMs across Bluetooth settings 2026-07-26 07:17:21 +02:00
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
105 changed files with 7109 additions and 7422 deletions
+22 -208
View File
@@ -1,228 +1,42 @@
name: Propose GeoRelay Data Update name: Fetch GeoRelays Data
on: on:
schedule: schedule:
- cron: "0 6 * * 0" - cron: '0 6 * * 0'
workflow_dispatch: workflow_dispatch:
# Default to read-only. The publishing job receives only the scopes required
# to push its branch and publish either a PR or a tracking issue.
permissions: permissions:
contents: read contents: write
pull-requests: write
concurrency:
group: georelay-data-update
cancel-in-progress: false
env:
SOURCE_REPOSITORY: https://github.com/permissionlesstech/georelays.git
UPDATE_BRANCH: automation/georelay-data
TRACKING_ISSUE_TITLE: GeoRelay update awaiting pull request
jobs: jobs:
propose-relay-data: update-relay-data:
name: Validate and propose relay data
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
issues: write
steps: steps:
- name: Checkout reviewed base - name: Checkout repository
# Pinned actions/checkout v5 so a mutable action tag cannot change the uses: actions/checkout@v4
# code that receives this job's write-capable token.
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd
with: with:
ref: main token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0 fetch-depth: 0
# Do not expose the write token to fetch/validation subprocesses.
persist-credentials: false
- name: Test GeoRelay validator - name: Fetch GeoRelays
run: | run: |
set -euo pipefail wget -q https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
python3 -m unittest discover -s scripts/tests -p "test_*.py" -v mv nostr_relays.csv ./relays/online_relays_gps.csv
- name: Fetch candidate over pinned HTTPS policy - name: Check for changes
id: upstream id: git-check
run: | run: |
set -euo pipefail git diff --exit-code || echo "changes=true" >> $GITHUB_OUTPUT
source_commit=$(git ls-remote --refs "$SOURCE_REPOSITORY" refs/heads/main | awk 'NR == 1 { print $1 }')
if [[ ! "$source_commit" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::Could not resolve an immutable upstream commit"
exit 1
fi
source_url="https://raw.githubusercontent.com/permissionlesstech/georelays/$source_commit/nostr_relays.csv"
effective_url=$(curl --fail --show-error --silent --location --proto "=https" --proto-redir "=https" --tlsv1.2 --max-time 60 --retry 3 --retry-all-errors --output "$RUNNER_TEMP/georelays-candidate.csv" --write-out "%{url_effective}" "$source_url")
if [[ "$effective_url" != "$source_url" ]]; then
echo "::error::Unexpected GeoRelay redirect target: $effective_url"
exit 1
fi
echo "source_commit=$source_commit" >> "$GITHUB_OUTPUT"
echo "source_url=$source_url" >> "$GITHUB_OUTPUT"
- name: Validate candidate against reviewed baseline - name: Commit and push changes
id: validation if: steps.git-check.outputs.changes == 'true'
run: | run: |
set -euo pipefail git config --local user.email "action@github.com"
python3 scripts/validate_georelays.py --input "$RUNNER_TEMP/georelays-candidate.csv" --baseline relays/online_relays_gps.csv --output relays/online_relays_gps.csv --github-output "$GITHUB_OUTPUT" git config --local user.name "GitHub Action"
git add relays/online_relays_gps.csv
- name: Check for a reviewed-file change git commit -m "Automated update of relay data - $(date -u)"
id: changes git push
run: |
set -euo pipefail
if git diff --quiet -- relays/online_relays_gps.csv; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Upstream GeoRelay data already matches main." >> "$GITHUB_STEP_SUMMARY"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
git diff --stat -- relays/online_relays_gps.csv
fi
- name: Push automation branch and publish review request
if: steps.changes.outputs.changed == 'true'
env: env:
GH_TOKEN: ${{ github.token }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SOURCE_COMMIT: ${{ steps.upstream.outputs.source_commit }}
SOURCE_URL: ${{ steps.upstream.outputs.source_url }}
DATA_ROWS: ${{ steps.validation.outputs.data_rows }}
UNIQUE_RELAYS: ${{ steps.validation.outputs.unique_relays }}
DATA_SHA256: ${{ steps.validation.outputs.sha256 }}
run: |
set -euo pipefail
# Scope credential exposure to this final publishing step.
gh auth setup-git
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git switch -C "$UPDATE_BRANCH"
git add -- relays/online_relays_gps.csv
git diff --cached --quiet && {
echo "::error::Expected a staged GeoRelay data change"
exit 1
}
git commit -m "Update reviewed georelay directory" -m "Upstream-commit: $SOURCE_COMMIT"
remote_ref="refs/remotes/origin/$UPDATE_BRANCH"
if git fetch --no-tags origin "+refs/heads/$UPDATE_BRANCH:$remote_ref" 2>/dev/null; then
remote_sha=$(git rev-parse "$remote_ref")
git push --force-with-lease="refs/heads/$UPDATE_BRANCH:$remote_sha" origin "HEAD:refs/heads/$UPDATE_BRANCH"
else
git push origin "HEAD:refs/heads/$UPDATE_BRANCH"
fi
body_file="$RUNNER_TEMP/georelay-pr-body.md"
{
echo "## Automated GeoRelay data proposal"
echo
echo "- Source: $SOURCE_URL"
echo "- Upstream commit: $SOURCE_COMMIT"
echo "- Data rows: $DATA_ROWS"
echo "- Unique normalized relays: $UNIQUE_RELAYS"
echo "- SHA-256: $DATA_SHA256"
echo
echo "The candidate passed strict UTF-8, schema, size, row-count, secure-host, coordinate, duplicate-conflict, and baseline-delta validation."
echo
echo "This PR is intentionally not auto-merged. Review the relay additions/removals before merging."
} > "$body_file"
existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$UPDATE_BRANCH" --json number --jq '.[0].number // empty')
pr_error="$RUNNER_TEMP/georelay-pr-error.txt"
pr_url=""
if [[ -n "$existing_pr" ]]; then
if gh pr edit "$existing_pr" --repo "$GITHUB_REPOSITORY" --title "Update reviewed GeoRelay directory" --body-file "$body_file" 2> "$pr_error"; then
pr_url=$(gh pr view "$existing_pr" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
fi
else
if created_pr_url=$(gh pr create --repo "$GITHUB_REPOSITORY" --base main --head "$UPDATE_BRANCH" --title "Update reviewed GeoRelay directory" --body-file "$body_file" 2> "$pr_error"); then
pr_url="$created_pr_url"
fi
fi
tracking_issue_numbers=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$TRACKING_ISSUE_TITLE\" in:title" --limit 100 --json number,title --jq ".[] | select(.title == \"$TRACKING_ISSUE_TITLE\") | .number")
tracking_issues=()
if [[ -n "$tracking_issue_numbers" ]]; then
mapfile -t tracking_issues <<< "$tracking_issue_numbers"
fi
if [[ -n "$pr_url" ]]; then
for issue_number in "${tracking_issues[@]}"; do
gh issue close "$issue_number" --repo "$GITHUB_REPOSITORY" --comment "A pull request is now available at $pr_url; closing this fallback tracking issue."
done
echo "Published GeoRelay review PR: $pr_url" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
echo "::warning::GITHUB_TOKEN could not create or update the GeoRelay pull request; publishing the issues-write fallback."
if [[ -s "$pr_error" ]]; then
cat "$pr_error" >&2
fi
compare_url="https://github.com/${GITHUB_REPOSITORY}/compare/main...${UPDATE_BRANCH}?expand=1"
issue_body_file="$RUNNER_TEMP/georelay-tracking-issue-body.md"
{
echo "## Validated GeoRelay update awaiting review"
echo
echo "The automation branch was updated, but this workflow token could not create or update the pull request. Use the compare link below to create it manually."
echo
echo "- Compare and create PR: $compare_url"
echo "- Automation branch: $UPDATE_BRANCH"
echo "- Source: $SOURCE_URL"
echo "- Upstream commit: $SOURCE_COMMIT"
echo "- Data rows: $DATA_ROWS"
echo "- Unique normalized relays: $UNIQUE_RELAYS"
echo "- SHA-256: $DATA_SHA256"
echo
echo "The snapshot passed the repository's strict validator before the branch was pushed."
} > "$issue_body_file"
if (( ${#tracking_issues[@]} > 0 )); then
primary_issue="${tracking_issues[0]}"
gh issue edit "$primary_issue" --repo "$GITHUB_REPOSITORY" --title "$TRACKING_ISSUE_TITLE" --body-file "$issue_body_file"
issue_url=$(gh issue view "$primary_issue" --repo "$GITHUB_REPOSITORY" --json url --jq .url)
for duplicate_issue in "${tracking_issues[@]:1}"; do
gh issue close "$duplicate_issue" --repo "$GITHUB_REPOSITORY" --comment "Closing duplicate GeoRelay automation tracking issue; #$primary_issue is canonical."
done
else
issue_url=$(gh issue create --repo "$GITHUB_REPOSITORY" --title "$TRACKING_ISSUE_TITLE" --body-file "$issue_body_file")
fi
# Do not claim success until the fallback issue was confirmed.
[[ -n "$issue_url" ]]
echo "Published GeoRelay tracking issue fallback: $issue_url" >> "$GITHUB_STEP_SUMMARY"
- name: Clean obsolete automation review state
if: steps.changes.outputs.changed == 'false'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh auth setup-git
existing_pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --state open --base main --head "$UPDATE_BRANCH" --json number --jq '.[0].number // empty')
if [[ -n "$existing_pr" ]]; then
gh pr close "$existing_pr" --repo "$GITHUB_REPOSITORY" --comment "Upstream now matches the reviewed file on main; closing this obsolete automation proposal."
echo "Closed obsolete PR #$existing_pr." >> "$GITHUB_STEP_SUMMARY"
fi
tracking_issue_numbers=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$TRACKING_ISSUE_TITLE\" in:title" --limit 100 --json number,title --jq ".[] | select(.title == \"$TRACKING_ISSUE_TITLE\") | .number")
if [[ -n "$tracking_issue_numbers" ]]; then
while IFS= read -r issue_number; do
gh issue close "$issue_number" --repo "$GITHUB_REPOSITORY" --comment "Upstream now matches the reviewed file on main; closing this obsolete automation tracker."
echo "Closed obsolete tracking issue #$issue_number." >> "$GITHUB_STEP_SUMMARY"
done <<< "$tracking_issue_numbers"
fi
if git ls-remote --exit-code --heads origin "refs/heads/$UPDATE_BRANCH" > /dev/null; then
git push origin --delete "$UPDATE_BRANCH"
echo "Deleted obsolete automation branch $UPDATE_BRANCH." >> "$GITHUB_STEP_SUMMARY"
else
ls_remote_status=$?
if (( ls_remote_status != 2 )); then
echo "::error::Could not inspect the obsolete automation branch"
exit "$ls_remote_status"
fi
fi
+16 -67
View File
@@ -94,24 +94,6 @@ jobs:
kill "$watchdog_pid" 2>/dev/null || true kill "$watchdog_pid" 2>/dev/null || true
exit "$status" exit "$status"
# Read coverage before the serial benchmark command below rebuilds the
# test binary without instrumentation. Reporting against that newer
# binary makes llvm-cov reject the profile as out of date.
# Informational only: there is deliberately no percentage threshold, but
# a broken/missing report is a CI configuration error and must be visible.
- name: Coverage summary
run: |
BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }})
PROF="$BIN_PATH/codecov/default.profdata"
XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1)
BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)"
if [ ! -f "$PROF" ] || [ ! -f "$BINARY" ]; then
echo "::error::Coverage profile or test binary is missing"
exit 1
fi
xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \
-ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)'
# Benchmarks run serially on an otherwise idle runner for stable # Benchmarks run serially on an otherwise idle runner for stable
# numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate. # numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate.
- name: Run performance benchmarks (serial) - name: Run performance benchmarks (serial)
@@ -133,6 +115,22 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
run: ./scripts/check-perf-floors.sh perf-output.log run: ./scripts/check-perf-floors.sh perf-output.log
# Informational only: surfaces per-file and total line coverage in the
# job log so coverage trends are visible on every PR. No thresholds —
# this must never be the reason a build goes red.
- name: Coverage summary
run: |
BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }})
PROF="$BIN_PATH/codecov/default.profdata"
XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1)
BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)"
if [ -f "$PROF" ] && [ -f "$BINARY" ]; then
xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \
-ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)' || true
else
echo "No coverage data found; skipping summary."
fi
# SPM tests do not link the shipping app targets. This job covers the # SPM tests do not link the shipping app targets. This job covers the
# iOS-conditional paths and both universal Release link configurations. # iOS-conditional paths and both universal Release link configurations.
ios-build: ios-build:
@@ -144,9 +142,6 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v5 uses: actions/checkout@v5
- name: Check clean recipe safety
run: bash scripts/check-just-clean-safety.sh
- name: Build iOS (simulator, no signing) - name: Build iOS (simulator, no signing)
# Build both simulator architectures so CI validates every vendored # Build both simulator architectures so CI validates every vendored
# Arti simulator slice and the configuration that ships. # Arti simulator slice and the configuration that ships.
@@ -174,52 +169,6 @@ jobs:
CODE_SIGNING_ALLOWED=NO \ CODE_SIGNING_ALLOWED=NO \
build build
# The SwiftPM matrix runs on macOS and cannot execute UIKit/CoreBluetooth
# conditional tests. Build the shared iOS test target and run it on the first
# available iPhone simulator from the runner image instead of hard-coding a
# model that changes when GitHub updates Xcode. The suite intentionally runs
# in one test runner: a number of integration tests exercise process-global
# stores and notification centers, so overlapping workers can corrupt each
# other's fixtures and turn sub-second tests into multi-minute timeouts.
ios-tests:
name: Run iOS simulator tests
runs-on: macos-latest
timeout-minutes: 20
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Select available iPhone simulator
id: destination
run: |
destinations=$(xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" -showdestinations)
destination_id=$(awk -F'id:' '
/platform:iOS Simulator/ && /name:iPhone/ && !found {
value=$2
sub(/,.*/, "", value)
gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
print value
found=1
}
' <<< "$destinations")
if [ -z "$destination_id" ]; then
echo "::error::No available iPhone simulator destination found"
exit 1
fi
echo "id=$destination_id" >> "$GITHUB_OUTPUT"
- name: Run iOS tests
run: |
set -o pipefail
xcodebuild -project bitchat.xcodeproj \
-scheme "bitchat (iOS)" \
-sdk iphonesimulator \
-destination "platform=iOS Simulator,id=${{ steps.destination.outputs.id }}" \
-parallel-testing-enabled NO \
CODE_SIGNING_ALLOWED=NO \
test
# Advisory only: SwiftLint reports style violations without ever failing the # Advisory only: SwiftLint reports style violations without ever failing the
# build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so # build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so
# it can never break the documented xcodebuild path or block a merge. # it can never break the documented xcodebuild path or block a merge.
-1
View File
@@ -80,4 +80,3 @@ build.log
# Local configs # Local configs
Local.xcconfig Local.xcconfig
*.profraw
-3
View File
@@ -3,6 +3,3 @@ DEVELOPMENT_TEAM = ABC123
// Unique bundle id to be able to register and run locally // Unique bundle id to be able to register and run locally
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.$(DEVELOPMENT_TEAM) PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.$(DEVELOPMENT_TEAM)
// App and share extension must use an App Group registered to your team.
APP_GROUP_ID = group.chat.bitchat.$(DEVELOPMENT_TEAM)
+95 -54
View File
@@ -1,66 +1,107 @@
# BitChat developer commands # BitChat macOS Build Justfile
# # Handles temporary modifications needed to build and run on macOS
# Builds use a repository-local, ignored DerivedData directory. No recipe
# patches, restores, or removes tracked project/configuration files.
project := "bitchat.xcodeproj"
macos_scheme := "bitchat (macOS)"
ios_scheme := "bitchat (iOS)"
derived_data := ".DerivedData"
# Default recipe - shows available commands
default: default:
@echo "BitChat developer commands:" @echo "BitChat macOS Build Commands:"
@echo " just run Build and run the macOS app" @echo " just run - Build and run the macOS app"
@echo " just build Build the macOS app without signing" @echo " just build - Build the macOS app only"
@echo " just test Run the SwiftPM test suite" @echo " just clean - Clean build artifacts and restore original files"
@echo " just test-ios Run tests on the iPhone 17 simulator" @echo " just check - Check prerequisites"
@echo " just clean Remove repo-local build artifacts only" @echo ""
@echo " just nuke Also remove nested package build caches" @echo "Original files are preserved - modifications are temporary for builds only"
@echo " just check Validate the development environment"
# Static guard against reintroducing source-restoring or source-deleting clean # Check prerequisites
# behavior. CI runs the same script directly. check:
check-clean-safety:
@bash scripts/check-just-clean-safety.sh
check: check-clean-safety
@echo "Checking prerequisites..." @echo "Checking prerequisites..."
@command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install full Xcode." && exit 1) @command -v xcodebuild >/dev/null 2>&1 || (echo "❌ xcodebuild not found. Install Xcode from App Store" && exit 1)
@developer_dir="$$(xcode-select -p 2>/dev/null)"; case "$$developer_dir" in *.app/Contents/Developer) ;; *) echo "❌ Full Xcode is not selected. Run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"; exit 1;; esac @xcode-select -p | grep -q "Xcode.app" || (echo "❌ Full Xcode required, not just command line tools. Install from App Store and run:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
@xcodebuild -version @test -d "/Applications/Xcode.app" || (echo "❌ Xcode.app not found in Applications folder. Install from App Store" && exit 1)
@echo "✅ Development environment ready (a signing identity is not required for just build)" @xcodebuild -version >/dev/null 2>&1 || (echo "❌ Xcode not properly configured. Try:\n sudo xcode-select -s /Applications/Xcode.app/Contents/Developer" && exit 1)
@security find-identity -v -p codesigning | grep -q "Apple Development\|Developer ID" || (echo "⚠️ No Developer ID found - code signing may fail" && exit 0)
@echo "✅ All prerequisites met"
build: check # Backup original files
backup:
@echo "Backing up original project configuration..."
@if [ -f bitchat.xcodeproj/project.pbxproj ]; then cp bitchat.xcodeproj/project.pbxproj bitchat.xcodeproj/project.pbxproj.backup; fi
@if [ -f bitchat/Info.plist ]; then cp bitchat/Info.plist bitchat/Info.plist.backup; fi
# Restore original files
restore:
@echo "Restoring original project configuration..."
@if [ -f project.yml.backup ]; then mv project.yml.backup project.yml; fi
@# Restore iOS-specific files
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
@# Use git to restore all modified files except Justfile
@git checkout -- project.yml bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Could not restore some files with git"
@# Remove any backup files
@rm -f bitchat.xcodeproj/project.pbxproj.backup bitchat/Info.plist.backup 2>/dev/null || true
# Apply macOS-specific modifications
patch-for-macos: backup
@echo "Temporarily hiding iOS-specific files for macOS build..."
@# Move iOS-specific files out of the way temporarily
@if [ -f bitchat/LaunchScreen.storyboard ]; then mv bitchat/LaunchScreen.storyboard bitchat/LaunchScreen.storyboard.ios; fi
# Build the macOS app
build: #check generate
@echo "Building BitChat for macOS..." @echo "Building BitChat for macOS..."
@xcodebuild -project "{{project}}" -scheme "{{macos_scheme}}" -configuration Debug -derivedDataPath "{{derived_data}}" CODE_SIGNING_ALLOWED=NO build @xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
# Run the macOS app
run: build run: build
@app="{{derived_data}}/Build/Products/Debug/bitchat.app"; test -d "$$app" || (echo "❌ Built app not found at $$app" && exit 1); open "$$app" @echo "Launching BitChat..."
@find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
# Backward-compatible alias for the old quick-run recipe. # Clean build artifacts and restore original files
dev-run: run clean: restore
@echo "Cleaning build artifacts..."
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
@# Only remove the generated project if we have a backup, otherwise use git
@if [ -f bitchat.xcodeproj/project.pbxproj.backup ]; then \
rm -rf bitchat.xcodeproj; \
else \
git checkout -- bitchat.xcodeproj/project.pbxproj 2>/dev/null || echo "⚠️ Could not restore project.pbxproj"; \
fi
@rm -f project-macos.yml 2>/dev/null || true
@echo "✅ Cleaned and restored original files"
test: # Quick run without cleaning (for development)
@swift test dev-run: check
@echo "Quick development build..."
test-ios: check @xcodebuild -project bitchat.xcodeproj -scheme "bitchat_macOS" -configuration Debug CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" build
@xcodebuild -project "{{project}}" -scheme "{{ios_scheme}}" -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 17' -derivedDataPath "{{derived_data}}" test @find ~/Library/Developer/Xcode/DerivedData -name "bitchat.app" -path "*/Debug/*" -not -path "*/Index.noindex/*" | head -1 | xargs -I {} open "{}"
# Artifact-only cleanup. In particular, this recipe never invokes Git and
# never writes, moves, restores, or removes source/configuration files.
clean:
@echo "Cleaning repo-local build artifacts..."
@rm -rf -- "{{derived_data}}" ".build"
@echo "✅ Cleaned {{derived_data}} and .build; tracked files were untouched"
# Retain the familiar command, but keep it artifact-only as well.
nuke: clean
@echo "Cleaning nested package build caches..."
@find localPackages -type d -name .build -prune -exec rm -rf -- {} +
@rm -rf -- ".cache"
@echo "✅ Removed repository build caches; tracked files were untouched"
# Show app info
info: info:
@echo "BitChat - decentralized mesh messaging" @echo "BitChat - Decentralized Mesh Messaging"
@echo "macOS 13+ and iOS 16+" @echo "======================================"
@echo "Bluetooth mesh behavior requires physical Bluetooth-capable devices" @echo "• Native macOS SwiftUI app"
@echo "• Bluetooth LE mesh networking"
@echo "• End-to-end encryption"
@echo "• No internet required"
@echo "• Works offline with nearby devices"
@echo ""
@echo "Requirements:"
@echo "• macOS 13.0+ (Ventura)"
@echo "• Bluetooth LE capable Mac"
@echo "• Physical device (no simulator support)"
@echo ""
@echo "Usage:"
@echo "• Set nickname and start chatting"
@echo "• Use /join #channel for group chats"
@echo "• Use /msg @user for private messages"
@echo "• Triple-tap logo for emergency wipe"
# Force clean everything (nuclear option)
nuke:
@echo "🧨 Nuclear clean - removing all build artifacts and backups..."
@rm -rf ~/Library/Developer/Xcode/DerivedData/bitchat-* 2>/dev/null || true
@rm -rf bitchat.xcodeproj 2>/dev/null || true
@rm -f bitchat.xcodeproj/project.pbxproj.backup 2>/dev/null || true
@rm -f bitchat/Info.plist.backup 2>/dev/null || true
@# Restore iOS-specific files if they were moved
@if [ -f bitchat/LaunchScreen.storyboard.ios ]; then mv bitchat/LaunchScreen.storyboard.ios bitchat/LaunchScreen.storyboard; fi
@git checkout bitchat.xcodeproj/project.pbxproj bitchat/Info.plist 2>/dev/null || echo "⚠️ Not a git repo or no changes to restore"
@echo "✅ Nuclear clean complete"
+3 -3
View File
@@ -22,7 +22,7 @@ bitchat is designed for private, account-free communication. This policy describ
2. **Nickname, preferences, and relationships** 2. **Nickname, preferences, and relationships**
- Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally. - Your nickname, settings, favorites, petnames, read-receipt identifiers, and bounded operational metadata are stored locally.
- 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** 3. **Private group state**
- Group names, rosters, creator identity, and key epoch are stored as protected files in Application Support. - 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: 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. - 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. - 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. - 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. - 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. - 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. - 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. - 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" // Only the vector fixture: declaring the whole "Noise"
// directory would claim its .swift test files as resources // directory would claim its .swift test files as resources
// and silently drop them from compilation. // and silently drop them from compilation.
.process("Noise/NoiseTestVectors.json") .process("Noise/NoiseTestVectors.json"),
// Frozen 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) - **Intelligent Message Routing**: Automatically chooses best transport (Bluetooth → Nostr fallback)
- **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE - **Decentralized Mesh Network**: Automatic peer discovery and multi-hop message relay over Bluetooth LE
- **Privacy First**: No accounts, no phone numbers, no persistent identifiers - **Privacy First**: No accounts, no phone numbers, no persistent identifiers
- **Private Message End-to-End Encryption**: [Noise Protocol](https://noiseprotocol.org) for mesh, 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 - **IRC-Style Commands**: Familiar `/slap`, `/msg`, `/who` style interface
- **Universal App**: Native support for iOS and macOS - **Universal App**: Native support for iOS and macOS
- **Emergency Wipe**: Triple-tap to instantly clear all data - **Emergency Wipe**: Triple-tap to instantly clear all data
@@ -44,9 +44,50 @@ BitChat uses a **hybrid messaging architecture** with two complementary transpor
- **Global Reach**: Connect with users worldwide via internet relays - **Global Reach**: Connect with users worldwide via internet relays
- **Location Channels**: Geographic chat rooms using geohash coordinates - **Location Channels**: Geographic chat rooms using geohash coordinates
- **290+ Relay Network**: Distributed across the globe for reliability - **290+ Relay Network**: Distributed across the globe for reliability
- **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 - **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 ### Channel Types
#### `mesh #bluetooth` #### `mesh #bluetooth`
@@ -80,7 +121,7 @@ Private messages use **intelligent transport selection**:
2. **Nostr Fallback** (when Bluetooth unavailable) 2. **Nostr Fallback** (when Bluetooth unavailable)
- Uses recipient's Nostr public key - Uses recipient's Nostr public key
- NIP-17 gift-wrapping for privacy - BitChat's app-specific private-envelope encryption
- Routes through global relay network - Routes through global relay network
3. **Smart Queuing** (when neither available) 3. **Smart Queuing** (when neither available)
@@ -93,62 +134,30 @@ For detailed protocol documentation, see the [Technical Whitepaper](WHITEPAPER.m
### Option 1: Using Xcode ### Option 1: Using Xcode
```bash ```bash
open bitchat.xcodeproj cd bitchat
``` open bitchat.xcodeproj
```
For a signed device build, create your ignored local configuration and replace To run on a device there're a few steps to prepare the code:
the example team ID with your Apple Developer Team ID: - Clone the local configs: `cp Configs/Local.xcconfig.example Configs/Local.xcconfig`
- Add your Developer Team ID into the newly created `Configs/Local.xcconfig`
```bash - Bundle ID would be set to `chat.bitchat.<team_id>` (unless you set to something else)
cp Configs/Local.xcconfig.example Configs/Local.xcconfig - Entitlements need to be updated manually (TODO: Automate):
``` - Search and replace `group.chat.bitchat` with `group.<your_bundle_id>` (e.g. `group.chat.bitchat.ABC123`)
`Local.xcconfig.example` derives unique app and App Group identifiers from that
team ID. The entitlement files already reference `$(APP_GROUP_ID)`, so tracked
project or entitlement files do not need to be edited.
Useful command-line checks from the repository root:
```bash
# macOS Debug build without signing
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" \
-configuration Debug CODE_SIGNING_ALLOWED=NO build
# Full SwiftPM test suite
swift test
# iOS simulator tests
xcodebuild -project bitchat.xcodeproj -scheme "bitchat (iOS)" \
-sdk iphonesimulator \
-destination 'platform=iOS Simulator,name=iPhone 17' test
```
If `iPhone 17` is unavailable, choose an installed simulator from:
```bash
xcodebuild -showdestinations -project bitchat.xcodeproj -scheme "bitchat (iOS)"
```
### Option 2: Using `just` ### Option 2: Using `just`
```bash ```bash
brew install just brew install just
just check ```
just run
```
`just build` and `just run` use the current `bitchat (macOS)` scheme and keep Want to try this on macos: `just run` will set it up and run from source.
Xcode output in the ignored `.DerivedData/` directory. They never patch source, Run `just clean` afterwards to restore things to original state for mobile app building and development.
project, configuration, or entitlement files.
`just clean` removes only `.DerivedData/` and `.build/`. It does not invoke Git
or restore tracked files, so uncommitted work is preserved. `just test` runs the
SwiftPM suite and `just test-ios` runs the iPhone 17 simulator suite.
## Localization ## Localization
- App localizations live in `bitchat/Localizable.xcstrings`. - Base app resources live under `bitchat/Localization/Base.lproj/`. Add new copy to `Localizable.strings` and plural rules to `Localizable.stringsdict`.
- Share extension strings are separate in `bitchatShareExtension/Localization/Localizable.xcstrings`. - Share extension strings are separate in `bitchatShareExtension/Localization/Base.lproj/Localizable.strings`.
- Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible. - Prefer keys that describe intent (`app_info.features.offline.title`) and reuse existing ones where possible.
- Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates. - Run `xcodebuild -project bitchat.xcodeproj -scheme "bitchat (macOS)" -configuration Debug CODE_SIGNING_ALLOWED=NO build` to compile-check any localization updates.
+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`: 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. * **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. 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 ### 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 ## 6. Store and Forward
@@ -105,7 +107,11 @@ Public broadcast messages are cached (1000 packets) and reconciled between peers
### 6.4 Nostr Mailboxes ### 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 ### 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. * **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. * **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. * **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 ## 9. Future Work
-1
View File
@@ -70,7 +70,6 @@
A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = { A6E32D1B2E762EA70032EA8A /* Exceptions for "bitchat" folder in "bitchatShareExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
Services/SharedContentHandoff.swift,
Services/TransportConfig.swift, Services/TransportConfig.swift,
); );
target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */; target = 57CA17A36A2532A6CFF367BB /* bitchatShareExtension */;
+6 -1
View File
@@ -2,6 +2,11 @@ import BitFoundation
import Combine import Combine
import Foundation import Foundation
enum SharedContentKind: String, Sendable, Equatable {
case text
case url
}
enum RuntimeScenePhase: String, Sendable, Equatable { enum RuntimeScenePhase: String, Sendable, Equatable {
case active case active
case inactive case inactive
@@ -20,7 +25,7 @@ enum AppEvent: Sendable, Equatable {
case startupCompleted case startupCompleted
case scenePhaseChanged(RuntimeScenePhase) case scenePhaseChanged(RuntimeScenePhase)
case openedURL(String) case openedURL(String)
case sharedContentReadyForReview(SharedContentKind) case sharedContentAccepted(SharedContentKind)
case notificationOpened(peerID: PeerID?) case notificationOpened(peerID: PeerID?)
case deepLinkOpened(String) case deepLinkOpened(String)
case torLifecycleChanged(TorLifecycleEvent) case torLifecycleChanged(TorLifecycleEvent)
+1 -8
View File
@@ -20,7 +20,6 @@ final class AppChromeModel: ObservableObject {
@Published var showScreenshotPrivacyWarning = false @Published var showScreenshotPrivacyWarning = false
private let chatViewModel: ChatViewModel private let chatViewModel: ChatViewModel
private let onPanicWipe: () -> Void
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
/// The composer owns capture state above ChatViewModel. ContentView /// The composer owns capture state above ChatViewModel. ContentView
/// installs this hook so both panic entry points synchronously stop it. /// 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. /// Bulletin-board coordinator, created on first use of the board sheet.
private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService) private(set) lazy var boardManager = BoardManager(transport: chatViewModel.meshService)
init( init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
chatViewModel: ChatViewModel,
privateInboxModel: PrivateInboxModel,
onPanicWipe: @escaping () -> Void = {}
) {
self.chatViewModel = chatViewModel self.chatViewModel = chatViewModel
self.onPanicWipe = onPanicWipe
self.nickname = chatViewModel.nickname self.nickname = chatViewModel.nickname
bind(privateInboxModel: privateInboxModel) bind(privateInboxModel: privateInboxModel)
@@ -112,7 +106,6 @@ final class AppChromeModel: ObservableObject {
func panicClearAllData() { func panicClearAllData() {
prepareForPanic?() prepareForPanic?()
onPanicWipe()
chatViewModel.panicClearAllData() chatViewModel.panicClearAllData()
} }
+40 -29
View File
@@ -27,7 +27,6 @@ final class AppRuntime: ObservableObject {
let peerListModel: PeerListModel let peerListModel: PeerListModel
let appChromeModel: AppChromeModel let appChromeModel: AppChromeModel
let boardAlertsModel: BoardAlertsModel let boardAlertsModel: BoardAlertsModel
let sharedContentImportModel: SharedContentImportModel
private let idBridge: NostrIdentityBridge private let idBridge: NostrIdentityBridge
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@@ -42,8 +41,7 @@ final class AppRuntime: ObservableObject {
init( init(
keychain: KeychainManagerProtocol = KeychainManager.makeDefault(), keychain: KeychainManagerProtocol = KeychainManager.makeDefault(),
idBridge: NostrIdentityBridge = NostrIdentityBridge(), idBridge: NostrIdentityBridge = NostrIdentityBridge()
sharedContentStore: SharedContentStore? = nil
) { ) {
self.idBridge = idBridge self.idBridge = idBridge
let conversations = ConversationStore() let conversations = ConversationStore()
@@ -86,20 +84,9 @@ final class AppRuntime: ObservableObject {
peerIdentityStore: peerIdentityStore, peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore locationPresenceStore: locationPresenceStore
) )
let resolvedSharedContentStore: SharedContentStore?
if let sharedContentStore {
resolvedSharedContentStore = sharedContentStore
} else if let sharedDefaults = UserDefaults(suiteName: BitchatApp.groupID) {
resolvedSharedContentStore = SharedContentStore(defaults: sharedDefaults)
} else {
resolvedSharedContentStore = nil
}
let sharedContentImportModel = SharedContentImportModel(store: resolvedSharedContentStore)
self.sharedContentImportModel = sharedContentImportModel
self.appChromeModel = AppChromeModel( self.appChromeModel = AppChromeModel(
chatViewModel: self.chatViewModel, chatViewModel: self.chatViewModel,
privateInboxModel: self.privateInboxModel, privateInboxModel: self.privateInboxModel
onPanicWipe: { sharedContentImportModel.discardAll() }
) )
let chatViewModel = self.chatViewModel let chatViewModel = self.chatViewModel
self.boardAlertsModel = BoardAlertsModel( self.boardAlertsModel = BoardAlertsModel(
@@ -119,6 +106,7 @@ final class AppRuntime: ObservableObject {
} }
) )
) )
if chatViewModel.networkActivationAllowed { if chatViewModel.networkActivationAllowed {
GeoRelayDirectory.shared.prefetchIfNeeded() GeoRelayDirectory.shared.prefetchIfNeeded()
} }
@@ -340,22 +328,45 @@ private extension AppRuntime {
} }
func checkForSharedContent() { func checkForSharedContent() {
let previousID = sharedContentImportModel.offer?.id guard chatViewModel.networkActivationAllowed else { return }
guard let payload = sharedContentImportModel.refresh( guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else { return }
destination: currentSharedContentDestination let clearSharedContent = {
) else { return } userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType")
if previousID != payload.id { userDefaults.removeObject(forKey: "sharedContentDate")
record(.sharedContentReadyForReview(payload.kind))
} }
}
var currentSharedContentDestination: SharedContentDestination { guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
SharedContentDestination.resolve( let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
selectedPrivatePeerID: privateConversationModel.selectedPeerID, // A partial or malformed handoff must not linger in the shared
privateDisplayName: privateConversationModel.selectedHeaderState?.displayName, // app-group container indefinitely.
activeChannel: locationChannelsModel.selectedChannel 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) { func handleNostrRelayConnectionChanged(_ isConnected: Bool) {
+59 -41
View File
@@ -39,17 +39,15 @@ final class Conversation: ObservableObject, Identifiable {
@Published private(set) var messages: [BitchatMessage] = [] @Published private(set) var messages: [BitchatMessage] = []
@Published private(set) var isUnread: Bool = false @Published private(set) var isUnread: Bool = false
/// Incrementally-maintained message-ID logical-index map for O(1) /// Incrementally-maintained message-ID index map for O(1) dedup and
/// dedup and delivery-status lookup. Logical indexes are physical array /// delivery-status lookup. Kept in sync on every mutation:
/// indexes plus `indexOffset`; trimming from the head advances the offset /// - tail append: single insert
/// instead of rewriting every surviving dictionary entry. This matters /// - out-of-order insert: suffix reindex from the insertion point
/// after the 1337-message cap is reached, when every steady-state tail /// - trim: full rebuild `removeFirst(k)` is already O(n), so the
/// append evicts one old row. /// rebuild does not change the asymptotics, and trim only happens once
/// /// the cap (1337) is reached. Simple and correct beats the
/// Out-of-order inserts and middle removals still reindex only the /// offset-tracking alternative here.
/// affected suffix. Full filtering resets the offset while rebuilding.
private var indexByMessageID: [String: Int] = [:] private var indexByMessageID: [String: Int] = [:]
private var indexOffset = 0
fileprivate init(id: ConversationID, cap: Int) { fileprivate init(id: ConversationID, cap: Int) {
self.id = id self.id = id
@@ -63,7 +61,7 @@ final class Conversation: ObservableObject, Identifiable {
} }
func message(withID messageID: String) -> BitchatMessage? { func message(withID messageID: String) -> BitchatMessage? {
guard let index = physicalIndex(forMessageID: messageID) else { return nil } guard let index = indexByMessageID[messageID] else { return nil }
return messages[index] return messages[index]
} }
@@ -103,7 +101,7 @@ final class Conversation: ObservableObject, Identifiable {
reindex(from: index) reindex(from: index)
} else { } else {
messages.append(message) messages.append(message)
indexByMessageID[message.id] = indexOffset + messages.count - 1 indexByMessageID[message.id] = messages.count - 1
} }
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded()) return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
@@ -113,7 +111,7 @@ final class Conversation: ObservableObject, Identifiable {
/// timeline position (in-place updates like media progress reuse the /// timeline position (in-place updates like media progress reuse the
/// original timestamp); a new message goes through ordered insertion. /// original timestamp); a new message goes through ordered insertion.
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome { fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
if let index = physicalIndex(forMessageID: message.id) { if let index = indexByMessageID[message.id] {
messages[index] = message messages[index] = message
return .updated return .updated
} }
@@ -127,7 +125,7 @@ final class Conversation: ObservableObject, Identifiable {
/// `.read` is never downgraded to `.delivered` or `.sent`. /// `.read` is never downgraded to `.delivered` or `.sent`.
/// Returns `true` when the status was applied. /// Returns `true` when the status was applied.
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
guard let index = physicalIndex(forMessageID: messageID) else { return false } guard let index = indexByMessageID[messageID] else { return false }
let message = messages[index] let message = messages[index]
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false } guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
@@ -144,7 +142,7 @@ final class Conversation: ObservableObject, Identifiable {
/// observers still need an @Published emission to re-render. /// observers still need an @Published emission to re-render.
@discardableResult @discardableResult
fileprivate func republishMessage(withID messageID: String) -> Bool { fileprivate func republishMessage(withID messageID: String) -> Bool {
guard let index = physicalIndex(forMessageID: messageID) else { return false } guard let index = indexByMessageID[messageID] else { return false }
messages[index] = messages[index] messages[index] = messages[index]
return true return true
} }
@@ -159,14 +157,10 @@ final class Conversation: ObservableObject, Identifiable {
/// Removes a single message by ID. Returns the removed message, or /// Removes a single message by ID. Returns the removed message, or
/// `nil` when no message with that ID exists. /// `nil` when no message with that ID exists.
fileprivate func remove(messageID: String) -> BitchatMessage? { fileprivate func remove(messageID: String) -> BitchatMessage? {
guard let index = physicalIndex(forMessageID: messageID) else { return nil } guard let index = indexByMessageID[messageID] else { return nil }
let removed = messages.remove(at: index) let removed = messages.remove(at: index)
indexByMessageID.removeValue(forKey: messageID) indexByMessageID.removeValue(forKey: messageID)
if index == 0 { reindex(from: index)
indexOffset += 1
} else {
reindex(from: index)
}
return removed return removed
} }
@@ -183,7 +177,6 @@ final class Conversation: ObservableObject, Identifiable {
for id in removedIDs { for id in removedIDs {
indexByMessageID.removeValue(forKey: id) indexByMessageID.removeValue(forKey: id)
} }
indexOffset = 0
reindex(from: 0) reindex(from: 0)
return removedIDs return removedIDs
} }
@@ -191,7 +184,6 @@ final class Conversation: ObservableObject, Identifiable {
fileprivate func clearMessages() { fileprivate func clearMessages() {
messages.removeAll() messages.removeAll()
indexByMessageID.removeAll() indexByMessageID.removeAll()
indexOffset = 0
} }
// MARK: Diagnostics // MARK: Diagnostics
@@ -213,10 +205,9 @@ final class Conversation: ObservableObject, Identifiable {
let message = messages[position] let message = messages[position]
// Count equality + every message resolving to its own position // Count equality + every message resolving to its own position
// proves the index is exactly the inverse map (no stale extras). // proves the index is exactly the inverse map (no stale extras).
if let logicalIndex = indexByMessageID[message.id] { if let index = indexByMessageID[message.id] {
let expectedIndex = indexOffset + position if index != position {
if logicalIndex != expectedIndex { violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(index)")
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(logicalIndex - indexOffset)")
} }
} else { } else {
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index") violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
@@ -278,17 +269,10 @@ final class Conversation: ObservableObject, Identifiable {
private func reindex(from start: Int) { private func reindex(from start: Int) {
for index in start..<messages.count { for index in start..<messages.count {
indexByMessageID[messages[index].id] = indexOffset + index indexByMessageID[messages[index].id] = index
} }
} }
private func physicalIndex(forMessageID messageID: String) -> Int? {
guard let logicalIndex = indexByMessageID[messageID] else { return nil }
let index = logicalIndex - indexOffset
guard messages.indices.contains(index) else { return nil }
return index
}
/// Trims oldest messages over the cap; returns the trimmed message IDs. /// Trims oldest messages over the cap; returns the trimmed message IDs.
private func trimIfNeeded() -> [String] { private func trimIfNeeded() -> [String] {
guard messages.count > cap else { return [] } guard messages.count > cap else { return [] }
@@ -298,7 +282,7 @@ final class Conversation: ObservableObject, Identifiable {
indexByMessageID.removeValue(forKey: id) indexByMessageID.removeValue(forKey: id)
} }
messages.removeFirst(overflow) messages.removeFirst(overflow)
indexOffset += overflow reindex(from: 0)
return trimmedIDs return trimmedIDs
} }
} }
@@ -442,6 +426,40 @@ final class ConversationStore: ObservableObject {
@discardableResult @discardableResult
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
guard let ids = conversationIDsByMessageID[messageID] else { return false } guard let ids = conversationIDsByMessageID[messageID] else { return false }
return applyDeliveryStatus(status, forMessageID: messageID, among: ids)
}
/// Applies an authenticated delivery/read receipt only to the supplied
/// direct-conversation aliases. A colliding message ID in another peer's
/// conversation (or a public timeline) must not inherit the receipt.
///
/// Stable and ephemeral aliases can temporarily hold the same message
/// instance during handoff. The shared helper republishes every targeted
/// alias even when the first mutation already changed that instance.
@discardableResult
func setDeliveryStatus(
_ status: DeliveryStatus,
forMessageID messageID: String,
inDirectPeerAliases peerIDs: Set<PeerID>
) -> Bool {
guard !peerIDs.isEmpty,
let indexedIDs = conversationIDsByMessageID[messageID] else {
return false
}
let allowedIDs = Set(peerIDs.map { ConversationID.directPeer($0) })
return applyDeliveryStatus(
status,
forMessageID: messageID,
among: indexedIDs.intersection(allowedIDs)
)
}
private func applyDeliveryStatus(
_ status: DeliveryStatus,
forMessageID messageID: String,
among ids: Set<ConversationID>
) -> Bool {
guard !ids.isEmpty else { return false }
var applied = false var applied = false
var skipped: [ConversationID] = [] var skipped: [ConversationID] = []
for id in ids { for id in ids {
@@ -860,8 +878,8 @@ extension Conversation {
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages. /// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
func _testCorruptIndexEntries() { func _testCorruptIndexEntries() {
guard messages.count >= 2 else { return } guard messages.count >= 2 else { return }
indexByMessageID[messages[0].id] = indexOffset + 1 indexByMessageID[messages[0].id] = 1
indexByMessageID[messages[1].id] = indexOffset indexByMessageID[messages[1].id] = 0
} }
/// Drops a message's index entry entirely (count mismatch + missing). /// Drops a message's index entry entirely (count mismatch + missing).
@@ -875,8 +893,8 @@ extension Conversation {
func _testCorruptOrderingPreservingIndex() { func _testCorruptOrderingPreservingIndex() {
guard messages.count >= 2 else { return } guard messages.count >= 2 else { return }
messages.swapAt(0, messages.count - 1) messages.swapAt(0, messages.count - 1)
indexByMessageID[messages[0].id] = indexOffset indexByMessageID[messages[0].id] = 0
indexByMessageID[messages[messages.count - 1].id] = indexOffset + messages.count - 1 indexByMessageID[messages[messages.count - 1].id] = messages.count - 1
} }
} }
@@ -916,7 +934,7 @@ extension ConversationStore {
extension Conversation { extension Conversation {
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) { fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
messages.append(message) messages.append(message)
indexByMessageID[message.id] = indexOffset + messages.count - 1 indexByMessageID[message.id] = messages.count - 1
} }
} }
#endif #endif
+4 -4
View File
@@ -265,10 +265,10 @@ final class PrivateConversationModel: ObservableObject {
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID) let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
let peer = chatViewModel.getPeer(byID: headerPeerID) let peer = chatViewModel.getPeer(byID: headerPeerID)
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer) let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys // Geo DMs are always routed through BitChat private envelopes over
// never resolve to a reachable mesh peer, so resolveAvailability would // Nostr; their nostr_ keys never resolve to a reachable mesh peer, so
// report .offline. Report .nostrAvailable so the header shows the // resolveAvailability would report .offline. Report .nostrAvailable
// globe instead of a misleading "offline" tag. // so the header shows the globe instead of a misleading "offline" tag.
let availability = conversationPeerID.isGeoDM let availability = conversationPeerID.isGeoDM
? .nostrAvailable ? .nostrAvailable
: resolveAvailability(for: headerPeerID, peer: peer) : resolveAvailability(for: headerPeerID, peer: peer)
-119
View File
@@ -1,119 +0,0 @@
import BitFoundation
import Combine
import Foundation
enum SharedContentDestination: Sendable, Equatable {
case mesh
case geohash(String)
case privateConversation(peerID: PeerID, displayName: String)
static func resolve(
selectedPrivatePeerID: PeerID?,
privateDisplayName: String?,
activeChannel: ChannelID
) -> SharedContentDestination {
if let selectedPrivatePeerID {
let fallback = String(selectedPrivatePeerID.id.prefix(12))
return .privateConversation(
peerID: selectedPrivatePeerID,
displayName: privateDisplayName?.trimmedOrNilIfEmpty ?? fallback
)
}
switch activeChannel {
case .mesh:
return .mesh
case .location(let channel):
return .geohash(channel.geohash.lowercased())
}
}
var displayName: String {
switch self {
case .mesh:
return "#mesh"
case .geohash(let geohash):
return "#\(geohash)"
case .privateConversation(_, let displayName):
return displayName
}
}
}
struct SharedContentOffer: Identifiable, Sendable, Equatable {
let payload: SharedContentPayload
let destination: SharedContentDestination
var id: UUID { payload.id }
}
/// Holds a pending extension handoff until the user chooses a destination and
/// explicitly adds it to the composer. This type has no send dependency by
/// design: confirming an import can never transmit a message.
@MainActor
final class SharedContentImportModel: ObservableObject {
@Published private(set) var offer: SharedContentOffer?
private let store: SharedContentStore?
init(store: SharedContentStore?) {
self.store = store
}
@discardableResult
func refresh(
destination: SharedContentDestination,
now: Date = Date()
) -> SharedContentPayload? {
guard let payload = store?.pending(now: now) else {
offer = nil
return nil
}
let nextOffer = SharedContentOffer(payload: payload, destination: destination)
if offer != nextOffer {
offer = nextOffer
}
return payload
}
func updateDestination(_ destination: SharedContentDestination) {
guard let offer, offer.destination != destination else { return }
self.offer = SharedContentOffer(payload: offer.payload, destination: destination)
}
/// Returns composer text only when the currently displayed destination is
/// still current and the reviewed envelope is still the stored envelope.
/// A destination change updates the prompt and requires another tap.
func confirm(
destination: SharedContentDestination,
now: Date = Date()
) -> String? {
guard let offer else { return nil }
guard offer.destination == destination else {
updateDestination(destination)
return nil
}
guard let payload = store?.consume(id: offer.id, now: now) else {
_ = refresh(destination: destination, now: now)
return nil
}
self.offer = nil
return payload.composerText
}
func cancel(destination: SharedContentDestination, now: Date = Date()) {
guard let offer else { return }
store?.discard(id: offer.id)
self.offer = nil
// If a newer share replaced the reviewed envelope, surface it rather
// than losing it with the older cancellation.
_ = refresh(destination: destination, now: now)
}
func discardAll() {
store?.discardAll()
offer = nil
}
}
-1
View File
@@ -41,7 +41,6 @@ struct BitchatApp: App {
.environmentObject(runtime.peerListModel) .environmentObject(runtime.peerListModel)
.environmentObject(runtime.appChromeModel) .environmentObject(runtime.appChromeModel)
.environmentObject(runtime.boardAlertsModel) .environmentObject(runtime.boardAlertsModel)
.environmentObject(runtime.sharedContentImportModel)
.onAppear { .onAppear {
appDelegate.runtime = runtime appDelegate.runtime = runtime
runtime.start() runtime.start()
+3 -35
View File
@@ -176,9 +176,9 @@ struct IdentityCache: Codable {
var blockedNostrPubkeys: Set<String> = [] var blockedNostrPubkeys: Set<String> = []
// Vouching (transitive verification). All three fields are Optional so // Vouching (transitive verification). All three fields are Optional so
// caches persisted before this feature decode cleanly decodeIfPresent // caches persisted before this feature decode cleanly the synthesized
// is used below, and a missing key must not trip the "unreadable cache" // decoder uses decodeIfPresent for optionals, and a missing key must not
// recovery path that discards everything. // trip the "unreadable cache" recovery path that discards everything.
// Vouchee fingerprint -> accepted vouches (capped per vouchee) // Vouchee fingerprint -> accepted vouches (capped per vouchee)
var vouchesByVouchee: [String: [VouchRecord]]? = nil var vouchesByVouchee: [String: [VouchRecord]]? = nil
@@ -201,38 +201,6 @@ struct IdentityCache: Codable {
// containing a copied public Noise key from replacing a previously bound // containing a copied public Noise key from replacing a previously bound
// public-message signing identity. Optional for old cache compatibility. // public-message signing identity. Optional for old cache compatibility.
var authenticatedSigningKeysByFingerprint: [String: Data]? = nil 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 // In-memory state
private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:] private var ephemeralSessions: [PeerID: EphemeralIdentity] = [:]
// Cryptographic identities (including pinned signing keys) live inside private var cryptographicIdentities: [String: CryptographicIdentity] = [:]
// `cache` so they persist across app restarts; see IdentityCache.
private var cache: IdentityCache = IdentityCache() private var cache: IdentityCache = IdentityCache()
// Thread safety // Thread safety
@@ -169,21 +168,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
private let queueSpecificKey = DispatchSpecificKey<UInt8>() private let queueSpecificKey = DispatchSpecificKey<UInt8>()
// Pending-save coalescing flag. Reads/writes are serialized on `queue`. // Pending-save coalescing flag. Reads/writes are serialized on `queue`.
// // Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
// Persistence is SYNCHRONOUS: every mutating API runs its mutate + encrypt // than a retained DispatchSourceTimer: a lingering, never-cancelled timer
// + keychain write inside `queue.sync(flags: .barrier)`, so when the call // keeps the dispatch machinery alive and prevents the unit-test process from
// returns the write is already complete and NOTHING is left scheduled on // exiting. (The original code used Timer.scheduledTimer on a GCD queue with
// the queue. This is deliberate a retained DispatchSourceTimer (the // no run loop, so saves never actually fired.)
// original design) kept the dispatch machinery alive and prevented the
// unit-test process from exiting, and fire-and-forget `queue.async(.barrier)`
// (a later design) left a backlog of instrumented barrier saves still
// draining when LLVM's `--enable-code-coverage` `atexit` handler dumped
// `.profraw`, deadlocking the process at teardown on the constrained CI
// runner. Synchronous persistence has zero outstanding dispatch at exit, so
// neither failure mode is possible. `pendingSave` is now effectively always
// false after any mutation (saveIdentityCache persists inline and clears
// it); it remains only as a belt-and-suspenders flag read by `forceSave`
// and `deinit`.
private var pendingSave = false private var pendingSave = false
// Encryption key // Encryption key
@@ -244,22 +233,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
deinit { deinit {
// Do NOT dispatch onto `queue` here. `deinit` can run on any thread forceSave()
// (including one draining `queue`), and the object is being
// deallocated: a `queue.sync` risks a re-entrant same-queue wait
// (deadlock) and a `queue.async` schedules work that resurrects `self`
// and may not drain before process exit.
//
// A flush here is redundant anyway: every mutating API already
// persists inline within its own barrier, so the keychain is already
// up to date. As a queue-free best-effort belt-and-suspenders, only
// flush if something is still pending. This is a direct read of
// in-hand state safe because a deallocating object has no other
// live references, so nothing can be mutating `cache` concurrently.
if pendingSave {
pendingSave = false
persist(snapshot: cache)
}
} }
// MARK: - Secure Loading/Saving // MARK: - Secure Loading/Saving
@@ -284,27 +258,21 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
} }
/// Persists the cache. Always invoked on `queue` under a barrier (its /// Persists the cache. Always invoked on `queue` under a barrier (its callers
/// callers run inside `queue.sync(flags: .barrier)`), so `cache` is read /// run inside `queue.async(.barrier)`), so it simply marks the cache dirty
/// while serialized. The encode + keychain write are done here (already on /// and persists it on the same serialized context no timer, nothing left
/// the exclusive barrier context), synchronously, so no separate hop is /// scheduled to keep the process alive.
/// scheduled and nothing is left to keep the process alive.
private func saveIdentityCache() { private func saveIdentityCache() {
pendingSave = true pendingSave = true
// On the barrier context already: snapshot is trivially consistent. performSave()
persist(snapshot: cache)
pendingSave = false
} }
/// Encodes, seals, and writes a *snapshot* of the cache to the keychain. /// Writes the cache to the keychain. Must run on `queue` with exclusive
/// /// (barrier) access.
/// Takes the cache by value so callers can capture a consistent snapshot private func performSave() {
/// under `queue` and then encode without holding it. Reading `cache` guard pendingSave else { return }
/// concurrently with a barrier writer would be a data race on the pendingSave = false
/// dictionary storage, which because `JSONEncoder` walks that storage
/// can spin forever (observed as a CI test-suite hang), so the snapshot
/// must be taken on `queue`, never off it.
private func persist(snapshot: IdentityCache) {
// Never persist under an ephemeral key it would overwrite the real // Never persist under an ephemeral key it would overwrite the real
// cache with data the next launch cannot decrypt. // cache with data the next launch cannot decrypt.
guard !encryptionKeyIsEphemeral else { guard !encryptionKeyIsEphemeral else {
@@ -313,7 +281,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
do { do {
let data = try JSONEncoder().encode(snapshot) let data = try JSONEncoder().encode(cache)
let sealedBox = try AES.GCM.seal(data, using: encryptionKey) let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey) let saved = keychain.saveIdentityKey(sealedBox.combined!, forKey: cacheKey)
if saved { if saved {
@@ -324,26 +292,14 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
} }
// Force a flush (for app-termination / lifecycle events NOT from // Force immediate save (for app termination / lifecycle events). Mutations
// `deinit`, which persists inline; see the deinit note). Every mutating // already persist synchronously via saveIdentityCache, so this is normally a
// API already persists inline inside its own barrier via // no-op (performSave early-returns when nothing is pending). Runs directly on
// `saveIdentityCache`, so by the time this is called the keychain is // the caller's thread deliberately NOT a `queue.sync(barrier)`, which is
// already up to date and this is normally a no-op; it exists as a // reachable from `deinit` and from async tests on the swift-concurrency
// belt-and-suspenders flush of any `pendingSave` left set. // cooperative pool where a blocking barrier-sync can starve/deadlock it.
//
// Runs synchronously inside a `queue.sync(flags: .barrier)`: the barrier
// makes the `cache` read race-free (a plain off-queue read races in-flight
// barrier writers JSONEncoder walking a concurrently-mutated dictionary
// can spin forever, which surfaced as a CI hang), and being synchronous it
// leaves nothing scheduled to keep the process alive at teardown. Safe
// against re-entrant deadlock because this is never invoked from `deinit`
// (the only path that can run *on* `queue`).
func forceSave() { func forceSave() {
queue.sync(flags: .barrier) { performSave()
guard pendingSave else { return }
pendingSave = false
persist(snapshot: cache)
}
} }
// MARK: - Social Identity Management // MARK: - Social Identity Management
@@ -357,33 +313,15 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// MARK: - Cryptographic Identities // MARK: - Cryptographic Identities
/// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname. /// Insert or update a cryptographic identity and optionally persist its signing key and claimed nickname.
///
/// TOFU signing-key pinning: once a signing key has been persisted for a
/// fingerprint, an update carrying a *different* signing key is refused in
/// full (including the claimed-nickname update) and security-logged. This
/// mirrors `BLEPeerRegistry.upsertVerifiedAnnounce` without it, an
/// attacker replaying a victim's noiseKey/peerID with their own signing
/// key could overwrite the victim's persisted identity while the victim is
/// offline or after an app restart. The refusal is permanent: there is
/// currently no targeted in-app way to reset the pin (`setVerified` does
/// not touch it). Recovering from a legitimate signing re-key requires the
/// peer to establish a new noise identity (new peerID) or the local user
/// to wipe all identity data (`clearAllIdentityData`, e.g. panic wipe).
/// - Parameters: /// - Parameters:
/// - fingerprint: SHA-256 hex of the Noise static public key /// - fingerprint: SHA-256 hex of the Noise static public key
/// - noisePublicKey: Noise static public key data /// - noisePublicKey: Noise static public key data
/// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages /// - signingPublicKey: Optional Ed25519 signing public key for authenticating public messages
/// - claimedNickname: Optional latest claimed nickname to persist into social identity /// - claimedNickname: Optional latest claimed nickname to persist into social identity
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) { func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String? = nil) {
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
let now = Date() let now = Date()
if var existing = self.cache.cryptographicIdentities[fingerprint] { if var existing = self.cryptographicIdentities[fingerprint] {
if let pinnedSigningKey = existing.signingPublicKey,
let announcedSigningKey = signingPublicKey,
pinnedSigningKey != announcedSigningKey {
SecureLogger.warning("🚨 Refusing to replace pinned signing key for \(fingerprint.prefix(8))… (possible impersonation attempt)", category: .security)
return
}
// Update keys if changed // Update keys if changed
if existing.publicKey != noisePublicKey { if existing.publicKey != noisePublicKey {
existing = CryptographicIdentity( existing = CryptographicIdentity(
@@ -392,11 +330,11 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
signingPublicKey: signingPublicKey ?? existing.signingPublicKey, signingPublicKey: signingPublicKey ?? existing.signingPublicKey,
firstSeen: existing.firstSeen firstSeen: existing.firstSeen
) )
self.cache.cryptographicIdentities[fingerprint] = existing self.cryptographicIdentities[fingerprint] = existing
} else { } else {
// Update signing key // Update signing key
existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey existing.signingPublicKey = signingPublicKey ?? existing.signingPublicKey
self.cache.cryptographicIdentities[fingerprint] = existing self.cryptographicIdentities[fingerprint] = existing
} }
// Persist updated state (already assigned in branches above) // Persist updated state (already assigned in branches above)
} else { } else {
@@ -407,7 +345,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
signingPublicKey: signingPublicKey, signingPublicKey: signingPublicKey,
firstSeen: now firstSeen: now
) )
self.cache.cryptographicIdentities[fingerprint] = entry self.cryptographicIdentities[fingerprint] = entry
} }
// Optionally persist claimed nickname into social identity // Optionally persist claimed nickname into social identity
@@ -439,7 +377,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
queue.sync { queue.sync {
// Defensive: ensure hex and correct length // Defensive: ensure hex and correct length
guard peerID.isShort else { return [] } guard peerID.isShort else { return [] }
return cache.cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) } return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID.id) }
} }
} }
@@ -482,9 +420,9 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
let bindingChanged = bindings[fingerprint] != signingPublicKey let bindingChanged = bindings[fingerprint] != signingPublicKey
bindings[fingerprint] = signingPublicKey bindings[fingerprint] = signingPublicKey
self.cache.authenticatedSigningKeysByFingerprint = bindings self.cache.authenticatedSigningKeysByFingerprint = bindings
if var cryptoIdentity = self.cache.cryptographicIdentities[fingerprint] { if var cryptoIdentity = self.cryptographicIdentities[fingerprint] {
cryptoIdentity.signingPublicKey = signingPublicKey cryptoIdentity.signingPublicKey = signingPublicKey
self.cache.cryptographicIdentities[fingerprint] = cryptoIdentity self.cryptographicIdentities[fingerprint] = cryptoIdentity
} }
guard bindingChanged else { return } guard bindingChanged else { return }
self.saveIdentityCache() self.saveIdentityCache()
@@ -504,7 +442,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
func updateSocialIdentity(_ identity: SocialIdentity) { func updateSocialIdentity(_ identity: SocialIdentity) {
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname let previousClaimedNickname = self.cache.socialIdentities[identity.fingerprint]?.claimedNickname
self.cache.socialIdentities[identity.fingerprint] = identity self.cache.socialIdentities[identity.fingerprint] = identity
@@ -540,7 +478,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
func setFavorite(_ fingerprint: String, isFavorite: Bool) { func setFavorite(_ fingerprint: String, isFavorite: Bool) {
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
if var identity = self.cache.socialIdentities[fingerprint] { if var identity = self.cache.socialIdentities[fingerprint] {
identity.isFavorite = isFavorite identity.isFavorite = isFavorite
self.cache.socialIdentities[fingerprint] = identity self.cache.socialIdentities[fingerprint] = identity
@@ -578,7 +516,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func setBlocked(_ fingerprint: String, isBlocked: Bool) { func setBlocked(_ fingerprint: String, isBlocked: Bool) {
SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security) SecureLogger.info("User \(isBlocked ? "blocked" : "unblocked"): \(fingerprint)", category: .security)
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
if var identity = self.cache.socialIdentities[fingerprint] { if var identity = self.cache.socialIdentities[fingerprint] {
identity.isBlocked = isBlocked identity.isBlocked = isBlocked
if isBlocked { if isBlocked {
@@ -612,7 +550,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) { func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
let key = pubkeyHexLowercased.lowercased() let key = pubkeyHexLowercased.lowercased()
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
if isBlocked { if isBlocked {
self.cache.blockedNostrPubkeys.insert(key) self.cache.blockedNostrPubkeys.insert(key)
} else { } else {
@@ -635,7 +573,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
func updateHandshakeState(peerID: PeerID, state: HandshakeState) { func updateHandshakeState(peerID: PeerID, state: HandshakeState) {
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
self.ephemeralSessions[peerID]?.handshakeState = state self.ephemeralSessions[peerID]?.handshakeState = state
// If handshake completed, update last interaction // If handshake completed, update last interaction
@@ -651,9 +589,10 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func clearAllIdentityData() { func clearAllIdentityData() {
SecureLogger.warning("Clearing all identity data", category: .security) SecureLogger.warning("Clearing all identity data", category: .security)
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
self.cache = IdentityCache() self.cache = IdentityCache()
self.ephemeralSessions.removeAll() self.ephemeralSessions.removeAll()
self.cryptographicIdentities.removeAll()
// Delete from keychain // Delete from keychain
let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey) let deleted = self.keychain.deleteIdentityKey(forKey: self.cacheKey)
@@ -662,7 +601,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
} }
func removeEphemeralSession(peerID: PeerID) { func removeEphemeralSession(peerID: PeerID) {
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
self.ephemeralSessions.removeValue(forKey: peerID) self.ephemeralSessions.removeValue(forKey: peerID)
} }
} }
@@ -672,7 +611,7 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
func setVerified(fingerprint: String, verified: Bool) { func setVerified(fingerprint: String, verified: Bool) {
SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security) SecureLogger.info("Fingerprint \(verified ? "verified" : "unverified"): \(fingerprint)", category: .security)
queue.sync(flags: .barrier) { queue.async(flags: .barrier) {
if verified { if verified {
self.cache.verifiedFingerprints.insert(fingerprint) self.cache.verifiedFingerprints.insert(fingerprint)
var verifiedAt = self.cache.verifiedAt ?? [:] 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. /// The peer's announce-bound Ed25519 signing key, if seen this session.
func signingPublicKey(forFingerprint fingerprint: String) -> Data? { 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 /// Verified fingerprints ordered most recently verified first (entries
-516
View File
@@ -1,42 +1,6 @@
{ {
"sourceLanguage" : "en", "sourceLanguage" : "en",
"strings" : { "strings" : {
"content.system.media_delete_refused" : {
"comment" : "System message shown in the affected chat when an explicit media delete or /clear was refused and bubbles/files were kept",
"extractionState" : "manual",
"localizations" : {
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "تعذّر حذف بعض الوسائط. جرّب حذف الوسائط الأقدم أولاً." } },
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "কিছু মিডিয়া মুছে ফেলা যায়নি। আগে পুরোনো মিডিয়া মুছে ফেলার চেষ্টা করুন।" } },
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "einige medien konnten nicht gelöscht werden. versuche zuerst, ältere medien zu löschen." } },
"en" : { "stringUnit" : { "state" : "translated", "value" : "some media could not be deleted. try deleting older media first." } },
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "no se pudieron eliminar algunos archivos multimedia. prueba a eliminar primero los más antiguos." } },
"fa" : { "stringUnit" : { "state" : "needs_review", "value" : "برخی رسانه‌ها حذف نشدند. ابتدا رسانه‌های قدیمی‌تر را حذف کنید." } },
"fil" : { "stringUnit" : { "state" : "needs_review", "value" : "hindi ma-delete ang ilang media. subukang i-delete muna ang mas lumang media." } },
"fr" : { "stringUnit" : { "state" : "needs_review", "value" : "impossible de supprimer certains médias. essaie d'abord de supprimer les médias plus anciens." } },
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "לא ניתן למחוק חלק מהמדיה. נסה קודם למחוק מדיה ישנה יותר." } },
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "कुछ मीडिया हटाई नहीं जा सकी। पहले पुरानी मीडिया हटाने का प्रयास करें।" } },
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "sebagian media tidak dapat dihapus. coba hapus media yang lebih lama dulu." } },
"it" : { "stringUnit" : { "state" : "needs_review", "value" : "impossibile eliminare alcuni contenuti multimediali. prova prima a eliminare quelli più vecchi." } },
"ja" : { "stringUnit" : { "state" : "needs_review", "value" : "一部のメディアを削除できませんでした。先に古いメディアを削除してみてください。" } },
"ko" : { "stringUnit" : { "state" : "needs_review", "value" : "일부 미디어를 삭제하지 못했습니다. 먼저 오래된 미디어를 삭제해 보세요." } },
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "sesetengah media tidak dapat dipadamkan. cuba padamkan media yang lebih lama dahulu." } },
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "केही मिडिया मेटाउन सकिएन। पहिले पुराना मिडिया मेटाउने प्रयास गर।" } },
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "sommige media konden niet worden verwijderd. probeer eerst oudere media te verwijderen." } },
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "nie udało się usunąć części multimediów. spróbuj najpierw usunąć starsze multimedia." } },
"pt" : { "stringUnit" : { "state" : "needs_review", "value" : "não foi possível eliminar alguns ficheiros multimédia. tenta eliminar primeiro os mais antigos." } },
"pt-BR" : { "stringUnit" : { "state" : "needs_review", "value" : "não foi possível excluir algumas mídias. tente excluir primeiro as mídias mais antigas." } },
"ru" : { "stringUnit" : { "state" : "needs_review", "value" : "не удалось удалить часть медиафайлов. попробуй сначала удалить более старые." } },
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "vissa medier kunde inte raderas. prova att radera äldre medier först." } },
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "சில ஊடகங்களை நீக்க முடியவில்லை. முதலில் பழைய ஊடகங்களை நீக்க முயற்சிக்கவும்." } },
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "ไม่สามารถลบสื่อบางรายการได้ ลองลบสื่อที่เก่ากว่าก่อน" } },
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "bazı medya silinemedi. önce daha eski medyayı silmeyi dene." } },
"uk" : { "stringUnit" : { "state" : "needs_review", "value" : "не вдалося видалити частину медіафайлів. спробуй спочатку видалити старіші." } },
"ur" : { "stringUnit" : { "state" : "needs_review", "value" : "کچھ میڈیا حذف نہیں ہو سکا۔ پہلے پرانا میڈیا حذف کرنے کی کوشش کریں۔" } },
"vi" : { "stringUnit" : { "state" : "needs_review", "value" : "không thể xóa một số phương tiện. hãy thử xóa phương tiện cũ hơn trước." } },
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "部分媒体无法删除。请先尝试删除较早的媒体。" } },
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "部分媒體無法刪除。請先嘗試刪除較舊的媒體。" } }
}
},
"notification.action.wave" : { "notification.action.wave" : {
"comment" : "Title of the notification action button that sends a friendly wave back to a nearby person", "comment" : "Title of the notification action button that sends a friendly wave back to a nearby person",
"extractionState" : "manual", "extractionState" : "manual",
@@ -30662,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" : { "content.delivery.reason.not_delivered" : {
"comment" : "Failure reason shown when the router gave up delivering a message", "comment" : "Failure reason shown when the router gave up delivering a message",
"extractionState" : "manual", "extractionState" : "manual",
@@ -73070,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" "version" : "1.1"
+6 -41
View File
@@ -31,20 +31,6 @@ enum NoiseHandshakeRecoveryPreparation {
case transferred 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 { final class NoiseSessionManager {
private var sessions: [PeerID: NoiseSession] = [:] private var sessions: [PeerID: NoiseSession] = [:]
/// Opaque identity for each exact entry in `sessions`. The generation is /// Opaque identity for each exact entry in `sessions`. The generation is
@@ -89,7 +75,7 @@ final class NoiseSessionManager {
// Callbacks // Callbacks
var onSessionEstablished: ((PeerID, Curve25519.KeyAgreement.PublicKey, UUID) -> Void)? 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 onSessionFailed: ((PeerID, Error) -> Void)?
var onHandshakeRecoveryRequired: ((NoiseHandshakeRecoveryRequest) -> Void)? var onHandshakeRecoveryRequired: ((NoiseHandshakeRecoveryRequest) -> Void)?
@@ -767,9 +753,6 @@ final class NoiseSessionManager {
recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID) recentOrdinaryInitiatorCompletions.removeValue(forKey: peerID)
session.reset() session.reset()
let isIdentityMismatch =
(error as? NoiseSessionError) == .peerIdentityMismatch
let restoredGeneration: UUID? let restoredGeneration: UUID?
if let quarantined = quarantinedTransports.removeValue(forKey: peerID) { if let quarantined = quarantinedTransports.removeValue(forKey: peerID) {
sessions[peerID] = quarantined.session sessions[peerID] = quarantined.session
@@ -780,26 +763,16 @@ final class NoiseSessionManager {
restoredGeneration = nil 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 // Schedule callback outside the synchronized block to prevent deadlock
DispatchQueue.global().async { [weak self] in DispatchQueue.global().async { [weak self] in
if let restoredGeneration { if let restoredGeneration {
self?.onSessionRestored?(peerID, restoredGeneration, restoreReason) self?.onSessionRestored?(peerID, restoredGeneration)
} }
self?.onSessionFailed?(peerID, error) self?.onSessionFailed?(peerID, error)
} }
let isIdentityMismatch =
(error as? NoiseSessionError) == .peerIdentityMismatch
if pendingHandshakeRecoveryIDs[peerID] != nil { if pendingHandshakeRecoveryIDs[peerID] != nil {
shouldSuppressImmediateHandlerRestart = true shouldSuppressImmediateHandlerRestart = true
} }
@@ -949,16 +922,8 @@ final class NoiseSessionManager {
category: .session category: .session
) )
if let restored { 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 DispatchQueue.global().async { [weak self] in
self?.onSessionRestored?( self?.onSessionRestored?(peerID, restored.generation)
peerID,
restored.generation,
.pendingConvergence
)
} }
} }
+36 -211
View File
@@ -32,23 +32,6 @@ struct GeoRelayDirectoryDependencies {
var retrySleep: (TimeInterval) async -> Void var retrySleep: (TimeInterval) async -> Void
var activeNotificationName: Notification.Name? var activeNotificationName: Notification.Name?
var autoStart: Bool var autoStart: Bool
var validationPolicy: GeoRelayDirectoryValidationPolicy
}
struct GeoRelayDirectoryValidationPolicy: Sendable {
let maximumBytes: Int
let maximumRows: Int
let maximumEntries: Int
let minimumRemoteEntries: Int
let minimumRetainedFraction: Double
static let live = GeoRelayDirectoryValidationPolicy(
maximumBytes: 512 * 1024,
maximumRows: 5_000,
maximumEntries: 5_000,
minimumRemoteEntries: 50,
minimumRetainedFraction: 0.5
)
} }
private extension GeoRelayDirectoryDependencies { private extension GeoRelayDirectoryDependencies {
@@ -61,16 +44,12 @@ private extension GeoRelayDirectoryDependencies {
#else #else
let activeNotificationName: Notification.Name? = nil let activeNotificationName: Notification.Name? = nil
#endif #endif
let validationPolicy = GeoRelayDirectoryValidationPolicy.live
return Self( return Self(
userDefaults: .standard, userDefaults: .standard,
notificationCenter: .default, notificationCenter: .default,
now: Date.init, now: Date.init,
// Runtime refreshes only from bitchat's reviewed copy. Upstream remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!,
// georelays/main is imported by a validator-backed pull request,
// so an upstream mutation cannot immediately retarget clients.
remoteURL: URL(string: "https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv")!,
fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds, fetchInterval: TransportConfig.geoRelayFetchIntervalSeconds,
refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds, refreshCheckInterval: TransportConfig.geoRelayRefreshCheckIntervalSeconds,
retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds, retryInitialSeconds: TransportConfig.geoRelayRetryInitialSeconds,
@@ -79,27 +58,7 @@ private extension GeoRelayDirectoryDependencies {
makeFetchData: { makeFetchData: {
let session = TorURLSession.shared.session let session = TorURLSession.shared.session
return { request in return { request in
let (bytes, response) = try await session.bytes(for: request) let (data, _) = try await session.data(for: request)
guard let response = response as? HTTPURLResponse,
(200...299).contains(response.statusCode),
response.url == request.url else {
throw URLError(.badServerResponse)
}
let maximumBytes = validationPolicy.maximumBytes
guard response.expectedContentLength <= Int64(maximumBytes) else {
throw URLError(.dataLengthExceedsMaximum)
}
var data = Data()
if response.expectedContentLength > 0 {
data.reserveCapacity(Int(response.expectedContentLength))
}
for try await byte in bytes {
guard data.count < maximumBytes else {
throw URLError(.dataLengthExceedsMaximum)
}
data.append(byte)
}
return data return data
} }
}, },
@@ -117,11 +76,7 @@ private extension GeoRelayDirectoryDependencies {
) )
let dir = base.appendingPathComponent("bitchat", isDirectory: true) let dir = base.appendingPathComponent("bitchat", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
// v2 ignores caches populated from the old direct-upstream return dir.appendingPathComponent("georelays_cache.csv")
// trust path and subjects every load to strict validation.
let legacyCache = dir.appendingPathComponent("georelays_cache.csv")
try? FileManager.default.removeItem(at: legacyCache)
return dir.appendingPathComponent("georelays_cache_v2.csv")
} catch { } catch {
return nil return nil
} }
@@ -139,8 +94,7 @@ private extension GeoRelayDirectoryDependencies {
try? await Task.sleep(nanoseconds: nanoseconds) try? await Task.sleep(nanoseconds: nanoseconds)
}, },
activeNotificationName: activeNotificationName, activeNotificationName: activeNotificationName,
autoStart: true, autoStart: true
validationPolicy: validationPolicy
) )
} }
} }
@@ -171,7 +125,7 @@ final class GeoRelayDirectory {
} }
private enum DetachedFetchOutcome: Sendable { private enum DetachedFetchOutcome: Sendable {
case success(entries: [Entry], csv: Data) case success(entries: [Entry], csv: String)
case torNotReady case torNotReady
case invalidData case invalidData
case network(String) case network(String)
@@ -258,8 +212,6 @@ final class GeoRelayDirectory {
) )
let awaitTorReady = dependencies.awaitTorReady let awaitTorReady = dependencies.awaitTorReady
let fetchData = dependencies.makeFetchData() let fetchData = dependencies.makeFetchData()
let validationPolicy = dependencies.validationPolicy
let baselineEntries = Set(entries)
Task { [weak self] in Task { [weak self] in
guard let self else { return } guard let self else { return }
@@ -267,9 +219,7 @@ final class GeoRelayDirectory {
let outcome = await Self.fetchRemoteOutcome( let outcome = await Self.fetchRemoteOutcome(
request: request, request: request,
awaitTorReady: awaitTorReady, awaitTorReady: awaitTorReady,
fetchData: fetchData, fetchData: fetchData
validationPolicy: validationPolicy,
baselineEntries: baselineEntries
) )
switch outcome { switch outcome {
@@ -288,9 +238,7 @@ final class GeoRelayDirectory {
nonisolated private static func fetchRemoteOutcome( nonisolated private static func fetchRemoteOutcome(
request: URLRequest, request: URLRequest,
awaitTorReady: @escaping @Sendable () async -> Bool, awaitTorReady: @escaping @Sendable () async -> Bool,
fetchData: @escaping @Sendable (URLRequest) async throws -> Data, fetchData: @escaping @Sendable (URLRequest) async throws -> Data
validationPolicy: GeoRelayDirectoryValidationPolicy,
baselineEntries: Set<Entry>
) async -> DetachedFetchOutcome { ) async -> DetachedFetchOutcome {
await Task.detached(priority: .utility) { await Task.detached(priority: .utility) {
let ready = await awaitTorReady() let ready = await awaitTorReady()
@@ -298,16 +246,16 @@ final class GeoRelayDirectory {
do { do {
let data = try await fetchData(request) let data = try await fetchData(request)
guard let parsed = Self.validatedEntries( guard let text = String(data: data, encoding: .utf8) else {
from: data,
policy: validationPolicy,
minimumEntries: validationPolicy.minimumRemoteEntries,
baselineEntries: baselineEntries
) else {
return .invalidData return .invalidData
} }
return .success(entries: parsed, csv: data) let parsed = Self.parseCSV(text)
guard !parsed.isEmpty else {
return .invalidData
}
return .success(entries: parsed, csv: text)
} catch { } catch {
return .network(error.localizedDescription) return .network(error.localizedDescription)
} }
@@ -321,7 +269,7 @@ final class GeoRelayDirectory {
} }
@MainActor @MainActor
private func handleFetchSuccess(entries parsed: [Entry], csv: Data) { private func handleFetchSuccess(entries parsed: [Entry], csv: String) {
entries = parsed entries = parsed
persistCache(csv) persistCache(csv)
dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey) dependencies.userDefaults.set(dependencies.now(), forKey: lastFetchKey)
@@ -373,8 +321,9 @@ final class GeoRelayDirectory {
cleanupState.retryTask = nil cleanupState.retryTask = nil
} }
private func persistCache(_ data: Data) { private func persistCache(_ text: String) {
guard let url = dependencies.cacheURL() else { return } guard let url = dependencies.cacheURL() else { return }
guard let data = text.data(using: .utf8) else { return }
do { do {
try dependencies.writeData(data, url) try dependencies.writeData(data, url)
} catch { } catch {
@@ -387,12 +336,9 @@ final class GeoRelayDirectory {
// Prefer cached file if present // Prefer cached file if present
if let cache = dependencies.cacheURL(), if let cache = dependencies.cacheURL(),
let data = dependencies.readData(cache), let data = dependencies.readData(cache),
let entries = Self.validatedEntries( let text = String(data: data, encoding: .utf8) {
from: data, let arr = Self.parseCSV(text)
policy: dependencies.validationPolicy, if !arr.isEmpty { return arr }
minimumEntries: 1
) {
return entries
} }
// Try bundled resource(s) // Try bundled resource(s)
@@ -400,157 +346,36 @@ final class GeoRelayDirectory {
for url in bundleCandidates { for url in bundleCandidates {
if let data = dependencies.readData(url), if let data = dependencies.readData(url),
let entries = Self.validatedEntries( let text = String(data: data, encoding: .utf8) {
from: data, let arr = Self.parseCSV(text)
policy: dependencies.validationPolicy, if !arr.isEmpty { return arr }
minimumEntries: 1
) {
return entries
} }
} }
// Try filesystem path (development/test) // Try filesystem path (development/test)
if let cwd = dependencies.currentDirectoryPath(), if let cwd = dependencies.currentDirectoryPath(),
let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")), let data = dependencies.readData(URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
let entries = Self.validatedEntries( let text = String(data: data, encoding: .utf8) {
from: data, return Self.parseCSV(text)
policy: dependencies.validationPolicy,
minimumEntries: 1
) {
return entries
} }
SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session) SecureLogger.warning("GeoRelayDirectory: no local CSV found; entries empty", category: .session)
return [] return []
} }
/// Parses the fixed three-column format as an all-or-nothing trust unit. nonisolated static func parseCSV(_ text: String) -> [Entry] {
/// One malformed or conflicting row rejects the complete dataset rather var result: Set<Entry> = []
/// than silently shrinking or partially replacing the current directory.
nonisolated static func validatedEntries(
from data: Data,
policy: GeoRelayDirectoryValidationPolicy,
minimumEntries: Int,
baselineEntries: Set<Entry>? = nil
) -> [Entry]? {
guard !data.isEmpty, data.count <= policy.maximumBytes,
let text = String(data: data, encoding: .utf8),
!text.hasPrefix("\u{feff}") else {
return nil
}
let lines = text.split(whereSeparator: { $0.isNewline }) let lines = text.split(whereSeparator: { $0.isNewline })
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } for (idx, raw) in lines.enumerated() {
.filter { !$0.isEmpty } guard let line = raw.trimmedOrNilIfEmpty else { continue }
guard let header = lines.first, if idx == 0 && line.lowercased().contains("relay url") { continue }
lines.count - 1 <= policy.maximumRows else { let parts = line.split(separator: ",").map { $0.trimmed }
return nil guard parts.count >= 3 else { continue }
guard let host = NostrRelayURL.directoryAddress(parts[0]) else { continue }
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
result.insert(Entry(host: host, lat: lat, lon: lon))
} }
return Array(result)
let headerParts = header
.split(separator: ",", omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
let supportedHeaders = [
["relay url", "latitude", "longitude"],
["relay url", "lat", "lon"]
]
guard supportedHeaders.contains(headerParts) else {
return nil
}
var entriesByHost: [String: Entry] = [:]
for line in lines.dropFirst() {
let parts = line
.split(separator: ",", omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard parts.count == 3,
let host = validatedDirectoryAddress(parts[0]),
let latitude = Double(parts[1]), latitude.isFinite,
(-90.0...90.0).contains(latitude),
let longitude = Double(parts[2]), longitude.isFinite,
(-180.0...180.0).contains(longitude) else {
return nil
}
let entry = Entry(host: host, lat: latitude, lon: longitude)
if let existing = entriesByHost[host], existing != entry {
// One endpoint cannot truthfully occupy two coordinates. Do
// not let row ordering choose which location clients trust.
return nil
}
entriesByHost[host] = entry
guard entriesByHost.count <= policy.maximumEntries else { return nil }
}
let parsedEntries = Set(entriesByHost.values)
guard parsedEntries.count >= minimumEntries else { return nil }
if let baselineEntries {
guard (0...1).contains(policy.minimumRetainedFraction) else { return nil }
let requiredOverlap = Int(
ceil(Double(baselineEntries.count) * policy.minimumRetainedFraction)
)
guard parsedEntries.intersection(baselineEntries).count >= requiredOverlap else {
return nil
}
}
return parsedEntries.sorted {
($0.host, $0.lat, $0.lon) < ($1.host, $1.lat, $1.lon)
}
}
nonisolated private static func validatedDirectoryAddress(_ rawValue: String) -> String? {
let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty,
value.unicodeScalars.allSatisfy({
$0.isASCII && !CharacterSet.controlCharacters.contains($0)
}) else {
return nil
}
let candidate = value.contains("://") ? value : "wss://\(value)"
guard let components = URLComponents(string: candidate),
let scheme = components.scheme?.lowercased(),
scheme == "wss" || scheme == "https",
components.user == nil,
components.password == nil,
components.query == nil,
components.fragment == nil,
components.path.isEmpty || components.path == "/",
let rawHost = components.host else {
return nil
}
let host = rawHost.lowercased()
guard !host.isEmpty, host.count <= 253,
host.unicodeScalars.allSatisfy({ $0.isASCII }),
!host.hasSuffix("."),
host != "localhost",
!host.hasSuffix(".localhost"),
!host.hasSuffix(".local"),
!host.hasSuffix(".internal") else {
return nil
}
let labels = host.split(separator: ".", omittingEmptySubsequences: false)
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-")
guard labels.count >= 2,
!labels.allSatisfy({ $0.allSatisfy(\.isNumber) }),
labels.allSatisfy({ label in
(1...63).contains(label.count) &&
label.first != "-" &&
label.last != "-" &&
label.unicodeScalars.allSatisfy { allowed.contains($0) }
}) else {
return nil
}
if let port = components.port {
guard (1...65_535).contains(port) else { return nil }
if port != 443 { return "\(host):\(port)" }
}
return host
} }
// MARK: - Observers & Timers // MARK: - Observers & Timers
+2 -1
View File
@@ -1,7 +1,8 @@
import Foundation import Foundation
import P256K 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 { struct NostrIdentity: Codable {
let privateKey: Data let privateKey: Data
let publicKey: Data let publicKey: Data
+427 -213
View File
@@ -1,4 +1,3 @@
import BitLogger
import Foundation import Foundation
import CryptoKit import CryptoKit
import P256K import P256K
@@ -7,16 +6,31 @@ import Security
// Note: This file depends on Data extension from BinaryEncodingUtils.swift // Note: This file depends on Data extension from BinaryEncodingUtils.swift
// Make sure BinaryEncodingUtils.swift is included in the target // Make sure BinaryEncodingUtils.swift is included in the target
/// 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 { struct NostrProtocol {
/// Nostr event kinds /// Nostr event kinds
enum EventKind: Int { enum EventKind: Int {
case metadata = 0 case metadata = 0
case textNote = 1 case textNote = 1
case dm = 14 // NIP-17 DM rumor kind // Compatibility for BitChat releases that incorrectly emitted the
case seal = 13 // NIP-17 sealed event // proprietary payload under standard NIP kinds. Kind 1059 continues
case giftWrap = 1059 // NIP-59 gift wrap // 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 ephemeralEvent = 20000
case geohashPresence = 20001 case geohashPresence = 20001
case deletion = 5 // NIP-09 event deletion request case deletion = 5 // NIP-09 event deletion request
@@ -26,144 +40,289 @@ struct NostrProtocol {
case courierDrop = 1401 case courierDrop = 1401
} }
/// Create a NIP-17 private message /// Prefix for BitChat private-envelope ciphertext. The suffix is
static func createPrivateMessage( /// 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, content: String,
recipientPubkey: String, recipientPubkey: String,
senderIdentity: NostrIdentity senderIdentity: NostrIdentity
) throws -> NostrEvent { ) 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) private static func createPrivateEnvelope(
let rumor = NostrEvent( 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, pubkey: senderIdentity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .dm, // NIP-17: DM rumor kind 14 kind: format.messageKind,
tags: [], tags: messageTags,
content: content content: content
) )
// 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S // 2. Encrypt the message to the recipient and sign the private seal
// real identity key. NIP-17 requires the seal be signed by the sender // with the sender's stable Nostr identity for sender authentication.
// so the recipient can authenticate who sent the message; signing with
// a throwaway key leaves DMs forgeable/impersonatable.
let senderKey = try senderIdentity.schnorrSigningKey() let senderKey = try senderIdentity.schnorrSigningKey()
let sealedEvent = try createSeal( let sealedEvent = try createPrivateSeal(
rumor: rumor, message: message,
recipientPubkey: recipientPubkey, recipientPubkey: recipientPubkey,
senderKey: senderKey senderKey: senderKey,
format: format
) )
// 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap // 3. Encrypt the seal under a one-time key so the public envelope does
// layer hides the sender's identity from relays; createGiftWrap mints // not reveal the stable sender identity.
// its own ephemeral key internally). return try createPrivateEnvelopeEvent(
let giftWrap = try createGiftWrap(
seal: sealedEvent, seal: sealedEvent,
recipientPubkey: recipientPubkey recipientPubkey: recipientPubkey,
format: format
) )
// Created gift wrap
return giftWrap
} }
/// Decrypt a received NIP-17 message /// Decrypt a BitChat private envelope. Legacy proprietary envelopes that
/// Returns the content, sender pubkey, and the actual message timestamp (not the randomized gift wrap timestamp) /// older BitChat releases placed under kinds 1059/13/14 are accepted only
static func decryptPrivateMessage( /// through the format-isolated receive path.
giftWrap: NostrEvent, static func decryptPrivateEnvelope(
envelope: NostrEvent,
recipientIdentity: NostrIdentity recipientIdentity: NostrIdentity
) throws -> (content: String, senderPubkey: String, timestamp: Int) { ) throws -> (content: String, senderPubkey: String, timestamp: Int) {
let layers = try decodePrivateEnvelopeLayers(
// Starting decryption envelope: envelope,
recipientIdentity: recipientIdentity
// 1. Unwrap the gift wrap )
let seal: NostrEvent return (
do { content: layers.message.content,
seal = try unwrapGiftWrap( senderPubkey: layers.seal.pubkey,
giftWrap: giftWrap, timestamp: layers.message.created_at
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)
} }
#if DEBUG #if DEBUG
static func createPrivateMessageWithInvalidSealSignatureForTesting( static func createPrivateEnvelopeWithInvalidSealSignatureForTesting(
content: String, content: String,
recipientPubkey: String, recipientPubkey: String,
senderIdentity: NostrIdentity senderIdentity: NostrIdentity
) throws -> NostrEvent { ) throws -> NostrEvent {
let rumor = NostrEvent( let format = PrivateEnvelopeWireFormat.bitchatV1
let message = NostrEvent(
pubkey: senderIdentity.publicKeyHex, pubkey: senderIdentity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .dm, kind: format.messageKind,
tags: [], tags: [],
content: content content: content
) )
var seal = try createSeal( var seal = try createPrivateSeal(
rumor: rumor, message: message,
recipientPubkey: recipientPubkey, recipientPubkey: recipientPubkey,
senderKey: senderIdentity.schnorrSigningKey() senderKey: senderIdentity.schnorrSigningKey(),
format: format
) )
seal.sig = String(repeating: "0", count: 128) 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, content: String,
recipientPubkey: String, recipientPubkey: String,
rumorIdentity: NostrIdentity, messageIdentity: NostrIdentity,
sealSignerIdentity: NostrIdentity sealSignerIdentity: NostrIdentity
) throws -> NostrEvent { ) throws -> NostrEvent {
let rumor = NostrEvent( let format = PrivateEnvelopeWireFormat.bitchatV1
pubkey: rumorIdentity.publicKeyHex, let message = NostrEvent(
pubkey: messageIdentity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .dm, kind: format.messageKind,
tags: [], tags: [],
content: content content: content
) )
let seal = try createSeal( let seal = try createPrivateSeal(
rumor: rumor, message: message,
recipientPubkey: recipientPubkey, 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 #endif
@@ -400,151 +559,207 @@ struct NostrProtocol {
// MARK: - Private Methods // MARK: - Private Methods
private static func createSeal( private static func createPrivateSeal(
rumor: NostrEvent, message: NostrEvent,
recipientPubkey: String, recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey senderKey: P256K.Schnorr.PrivateKey,
format: PrivateEnvelopeWireFormat
) throws -> NostrEvent { ) throws -> NostrEvent {
let rumorJSON = try rumor.jsonString()
let encrypted = try encrypt( let encrypted = try encrypt(
plaintext: rumorJSON, plaintext: message.jsonString(),
recipientPubkey: recipientPubkey, recipientPubkey: recipientPubkey,
senderKey: senderKey senderKey: senderKey,
format: format,
maximumPlaintextBytes: maximumPrivateEnvelopePlaintextBytes
) )
let seal = NostrEvent( let seal = NostrEvent(
pubkey: Data(senderKey.xonly.bytes).hexEncodedString(), pubkey: Data(senderKey.xonly.bytes).hexEncodedString(),
createdAt: randomizedTimestamp(), createdAt: randomizedPastTimestamp(),
kind: .seal, kind: format.sealKind,
tags: [], tags: [],
content: encrypted content: encrypted
) )
// Sign the seal with the sender's Schnorr private key
return try seal.sign(with: senderKey) return try seal.sign(with: senderKey)
} }
private static func createGiftWrap( private static func createPrivateEnvelopeEvent(
seal: NostrEvent, seal: NostrEvent,
recipientPubkey: String recipientPubkey: String,
format: PrivateEnvelopeWireFormat
) throws -> NostrEvent { ) throws -> NostrEvent {
// A fresh signing/encryption key for every public envelope keeps the
let sealJSON = try seal.jsonString() // stable sender identity inside ciphertext.
let envelopeKey = try P256K.Schnorr.PrivateKey()
// 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)
let encrypted = try encrypt( let encrypted = try encrypt(
plaintext: sealJSON, plaintext: seal.jsonString(),
recipientPubkey: recipientPubkey, recipientPubkey: recipientPubkey,
senderKey: wrapKey // Use the gift wrap ephemeral key senderKey: envelopeKey,
format: format,
maximumPlaintextBytes: maximumPrivateEnvelopeSealPlaintextBytes
) )
let giftWrap = NostrEvent( let envelope = NostrEvent(
pubkey: Data(wrapKey.xonly.bytes).hexEncodedString(), pubkey: Data(envelopeKey.xonly.bytes).hexEncodedString(),
createdAt: randomizedTimestamp(), createdAt: randomizedPastTimestamp(),
kind: .giftWrap, kind: format.envelopeKind,
tags: [["p", recipientPubkey]], // Tag recipient tags: [["p", recipientPubkey]],
content: encrypted content: encrypted
) )
return try envelope.sign(with: envelopeKey)
// Sign the gift wrap with the wrap Schnorr private key
return try giftWrap.sign(with: wrapKey)
} }
private static func unwrapGiftWrap( private static func decodePrivateEnvelopeLayers(
giftWrap: NostrEvent, envelope: NostrEvent,
recipientKey: P256K.Schnorr.PrivateKey recipientIdentity: NostrIdentity
) throws -> NostrEvent { ) throws -> (seal: NostrEvent, message: NostrEvent) {
guard envelope.content.utf8.count <= maximumPrivateEnvelopeCiphertextBytes else {
// Unwrapping gift wrap throw NostrError.invalidCiphertext
}
let decrypted = try decrypt( guard let format = PrivateEnvelopeWireFormat(outerKind: envelope.kind),
ciphertext: giftWrap.content, envelope.tags == [["p", recipientIdentity.publicKeyHex]],
senderPubkey: giftWrap.pubkey, envelope.isValidSignature() else {
recipientKey: recipientKey
)
guard let data = decrypted.data(using: .utf8),
let sealDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw NostrError.invalidEvent throw NostrError.invalidEvent
} }
let seal = try NostrEvent(from: sealDict) let recipientKey = try recipientIdentity.schnorrSigningKey()
// Unwrapped seal 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 let messageJSON = try decrypt(
}
private static func openSeal(
seal: NostrEvent,
recipientKey: P256K.Schnorr.PrivateKey
) throws -> NostrEvent {
let decrypted = try decrypt(
ciphertext: seal.content, ciphertext: seal.content,
senderPubkey: seal.pubkey, 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), // The inner message is intentionally unsigned; sender authentication
let rumorDict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { // 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 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( private static func encrypt(
plaintext: String, plaintext: String,
recipientPubkey: String, recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey senderKey: P256K.Schnorr.PrivateKey,
format: PrivateEnvelopeWireFormat,
maximumPlaintextBytes: Int
) throws -> String { ) throws -> String {
guard let recipientPubkeyData = Data(hexString: recipientPubkey) else { guard let recipientPubkeyData = Data(hexString: recipientPubkey) else {
throw NostrError.invalidPublicKey throw NostrError.invalidPublicKey
} }
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
// Derive shared secret
let sharedSecret = try deriveSharedSecret( let sharedSecret = try deriveSharedSecret(
privateKey: senderKey, privateKey: senderKey,
publicKey: recipientPubkeyData publicKey: recipientPubkeyData
) )
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info) let key = derivePrivateEnvelopeKey(from: sharedSecret, format: format)
let key = try deriveNIP44V2Key(from: sharedSecret)
// 24-byte random nonce for XChaCha20-Poly1305
var nonce24 = Data(count: 24) var nonce24 = Data(count: 24)
_ = nonce24.withUnsafeMutableBytes { ptr in let randomStatus = nonce24.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!) SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
} }
guard randomStatus == errSecSuccess else {
throw NostrError.cryptographicFailure
}
let pt = Data(plaintext.utf8) let plaintextData = Data(plaintext.utf8)
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24) 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() var combined = Data()
combined.append(nonce24) combined.append(nonce24)
combined.append(sealed.ciphertext) combined.append(sealed.ciphertext)
combined.append(sealed.tag) combined.append(sealed.tag)
return "v2:" + Base64URLCoding.encode(combined) return format.contentPrefix + Base64URLCoding.encode(combined)
} }
private static func decrypt( private static func decrypt(
ciphertext: String, ciphertext: String,
senderPubkey: String, senderPubkey: String,
recipientKey: P256K.Schnorr.PrivateKey recipientKey: P256K.Schnorr.PrivateKey,
format: PrivateEnvelopeWireFormat,
maximumPlaintextBytes: Int
) throws -> String { ) throws -> String {
// Expect NIP-44 v2 format guard ciphertext.utf8.count <= maximumPrivateEnvelopeCiphertextBytes,
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext } ciphertext.hasPrefix(format.contentPrefix) else {
let encoded = String(ciphertext.dropFirst(3)) throw NostrError.invalidCiphertext
}
let encoded = String(ciphertext.dropFirst(format.contentPrefix.count))
guard let data = Base64URLCoding.decode(encoded), guard let data = Base64URLCoding.decode(encoded),
data.count > (24 + 16), data.count > (24 + 16),
let senderPubkeyData = Data(hexString: senderPubkey) else { let senderPubkeyData = Data(hexString: senderPubkey) else {
@@ -554,33 +769,40 @@ struct NostrProtocol {
let nonce24 = data.prefix(24) let nonce24 = data.prefix(24)
let rest = data.dropFirst(24) let rest = data.dropFirst(24)
let tag = rest.suffix(16) 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 publicKeyData: Data) throws -> Data {
func attemptDecrypt(using pubKeyData: Data) throws -> Data { let sharedSecret = try deriveSharedSecret(
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData) privateKey: recipientKey,
let key = try deriveNIP44V2Key(from: ss) publicKey: publicKeyData
)
let key = derivePrivateEnvelopeKey(from: sharedSecret, format: format)
return try XChaCha20Poly1305Compat.open( return try XChaCha20Poly1305Compat.open(
ciphertext: Data(ct), ciphertext: Data(ciphertextBytes),
tag: Data(tag), tag: Data(tag),
key: key, key: key,
nonce24: Data(nonce24) nonce24: Data(nonce24)
) )
} }
// If 32 bytes (x-only) try both parities, otherwise single try let plaintext: Data
if senderPubkeyData.count == 32 { if senderPubkeyData.count == 32 {
let even = Data([0x02]) + senderPubkeyData let evenKey = Data([0x02]) + senderPubkeyData
if let pt = try? attemptDecrypt(using: even) { if let opened = try? attemptDecrypt(using: evenKey) {
return String(data: pt, encoding: .utf8) ?? "" 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 { } else {
let pt = try attemptDecrypt(using: senderPubkeyData) plaintext = try attemptDecrypt(using: senderPubkeyData)
return String(data: pt, encoding: .utf8) ?? ""
} }
guard plaintext.count <= maximumPlaintextBytes,
let decoded = String(data: plaintext, encoding: .utf8) else {
throw NostrError.invalidCiphertext
}
return decoded
} }
private static func deriveSharedSecret( private static func deriveSharedSecret(
@@ -640,29 +862,17 @@ struct NostrProtocol {
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// ECDH shared secret derived // ECDH shared secret derived
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key // Return raw ECDH shared secret; the wire-format-specific HKDF is
// applied by derivePrivateEnvelopeKey.
return sharedSecretData return sharedSecretData
} }
private static func randomizedTimestamp() -> Date { private static func randomizedPastTimestamp() -> Date {
// Add random offset to current time for privacy // Keep public timestamps in the past: future-dated events are rejected
// This prevents timing correlation attacks while the actual message timestamp // by some relays. The actual message timestamp remains encrypted.
// is preserved in the encrypted rumor Date().addingTimeInterval(
let offset = TimeInterval.random(in: -900...900) // +/- 15 minutes -TimeInterval.random(in: 0...TransportConfig.nostrPrivateEnvelopeTimestampFuzzSeconds)
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
} }
} }
@@ -794,16 +1004,20 @@ enum NostrError: Error {
case invalidPublicKey case invalidPublicKey
case invalidEvent case invalidEvent
case invalidCiphertext case invalidCiphertext
case cryptographicFailure
} }
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305) // MARK: - BitChat private-envelope key derivation
private extension NostrProtocol { 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( let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData), inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: Data(), salt: format.hkdfSalt,
info: Data("nip44-v2".utf8), info: format.hkdfInfo,
outputByteCount: 32 outputByteCount: 32
) )
return derivedKey.withUnsafeBytes { Data($0) } 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 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 let messageTTL: UInt8
/// Current time source. /// Current time source.
let now: () -> Date let now: () -> Date
/// Noise and signing public keys already recorded for the peer, if any /// Noise public key already recorded for the peer, if any (registry read).
/// (single registry read so both come from one consistent snapshot). let existingNoisePublicKey: (PeerID) -> Data?
let existingPeerKeys: (PeerID) -> (noisePublicKey: Data?, signingPublicKey: Data?)
/// Signing key from the persisted cryptographic identity for the peer, if
/// any. Registry pins do not survive app restarts or offline-peer
/// eviction; this fallback keeps the TOFU signing-key pin effective for
/// returning peers.
let persistedSigningPublicKey: (PeerID) -> Data?
/// Ed25519 key previously bound to this Noise identity by an authenticated /// Ed25519 key previously bound to this Noise identity by an authenticated
/// peer-state payload, if any (persistent identity-state read). /// peer-state payload, if any (persistent identity-state read).
let authenticatedSigningPublicKey: (_ noisePublicKey: Data) -> Data? let authenticatedSigningPublicKey: (_ noisePublicKey: Data) -> Data?
@@ -38,15 +32,13 @@ struct BLEAnnounceHandlerEnvironment {
/// Runs the registry mutation phase under the collections barrier. /// Runs the registry mutation phase under the collections barrier.
let withRegistryBarrier: (() -> Void) -> Void let withRegistryBarrier: (() -> Void) -> Void
/// Upserts the verified announce into the peer registry. /// Upserts the verified announce into the peer registry.
/// Returns `nil` when the registry refuses the announce because it carries
/// a signing key different from the one already pinned for this peer.
/// Must only be called from inside `withRegistryBarrier`. /// Must only be called from inside `withRegistryBarrier`.
let upsertVerifiedAnnounce: ( let upsertVerifiedAnnounce: (
_ peerID: PeerID, _ peerID: PeerID,
_ announcement: AnnouncementPacket, _ announcement: AnnouncementPacket,
_ isConnected: Bool, _ isConnected: Bool,
_ now: Date _ now: Date
) -> BLEPeerAnnounceUpdate? ) -> BLEPeerAnnounceUpdate
/// Debounced reconnect-log decision. /// Debounced reconnect-log decision.
/// Must only be called from inside `withRegistryBarrier`. /// Must only be called from inside `withRegistryBarrier`.
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
@@ -126,16 +118,7 @@ final class BLEAnnounceHandler {
// Suppress announce logs to reduce noise // Suppress announce logs to reduce noise
// Precompute signature verification outside barrier to reduce contention // Precompute signature verification outside barrier to reduce contention
var existingPeerKeys = env.existingPeerKeys(peerID) let existingNoisePublicKey = env.existingNoisePublicKey(peerID)
if existingPeerKeys.signingPublicKey == nil {
// The registry entry (and its signing-key pin) is dropped on app
// restart and offline-peer eviction, but the persisted
// cryptographic identity survives both. Fall back to it so a
// returning peer is not treated as first contact otherwise an
// attacker could replay the peer's noiseKey/peerID with their own
// signing key and re-pin the identity (TOFU downgrade).
existingPeerKeys.signingPublicKey = env.persistedSigningPublicKey(peerID)
}
let hasSignature = packet.signature != nil let hasSignature = packet.signature != nil
let signatureValid: Bool let signatureValid: Bool
if hasSignature { if hasSignature {
@@ -149,9 +132,8 @@ final class BLEAnnounceHandler {
let trustDecision = BLEAnnounceTrustPolicy.evaluate( let trustDecision = BLEAnnounceTrustPolicy.evaluate(
hasSignature: hasSignature, hasSignature: hasSignature,
signatureValid: signatureValid, signatureValid: signatureValid,
existingNoisePublicKey: existingPeerKeys.noisePublicKey, existingNoisePublicKey: existingNoisePublicKey,
announcedNoisePublicKey: announcement.noisePublicKey, announcedNoisePublicKey: announcement.noisePublicKey,
existingSigningPublicKey: existingPeerKeys.signingPublicKey,
authenticatedSigningPublicKey: env.authenticatedSigningPublicKey( authenticatedSigningPublicKey: env.authenticatedSigningPublicKey(
announcement.noisePublicKey announcement.noisePublicKey
), ),
@@ -160,16 +142,13 @@ final class BLEAnnounceHandler {
if case .reject(.keyMismatch) = trustDecision { if case .reject(.keyMismatch) = trustDecision {
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security) SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
} }
if case .reject(.signingKeyMismatch) = trustDecision {
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 { if case .reject(.authenticatedSigningKeyMismatch) = trustDecision {
SecureLogger.warning( SecureLogger.warning(
"⚠️ Announce signing-key replacement rejected for Noise-authenticated peer \(peerID.id.prefix(8))", "⚠️ Announce signing-key replacement rejected for Noise-authenticated peer \(peerID.id.prefix(8))",
category: .security category: .security
) )
} }
var verifiedAnnounce = trustDecision.isVerified let verifiedAnnounce = trustDecision.isVerified
var isNewPeer = false var isNewPeer = false
var isReconnectedPeer = false var isReconnectedPeer = false
@@ -206,22 +185,12 @@ final class BLEAnnounceHandler {
return return
} }
// The registry re-checks the signing-key pin inside the barrier. let update = env.upsertVerifiedAnnounce(
// The pre-barrier trust check reads the registry outside the
// barrier, so this closes the race where two announces for the
// same peer are evaluated concurrently.
guard let update = env.upsertVerifiedAnnounce(
peerID, peerID,
announcement, announcement,
hasPeripheralConnection || hasCentralSubscription || (isDirectAnnounce && !linkBoundToOtherPeer), hasPeripheralConnection || hasCentralSubscription || (isDirectAnnounce && !linkBoundToOtherPeer),
now now
) else { )
SecureLogger.warning("🚨 Registry refused announce for \(peerID.id.prefix(8))… — signing key differs from pinned key", category: .security)
verifiedAnnounce = false
isNewPeer = false
isReconnectedPeer = false
return
}
isNewPeer = update.isNewPeer isNewPeer = update.isNewPeer
isReconnectedPeer = update.wasDisconnected isReconnectedPeer = update.wasDisconnected
@@ -56,7 +56,6 @@ enum BLEAnnounceTrustRejection: Equatable {
case missingSignature case missingSignature
case invalidSignature case invalidSignature
case keyMismatch case keyMismatch
case signingKeyMismatch
case authenticatedSigningKeyMismatch case authenticatedSigningKeyMismatch
} }
@@ -75,33 +74,18 @@ enum BLEAnnounceTrustPolicy {
signatureValid: Bool, signatureValid: Bool,
existingNoisePublicKey: Data?, existingNoisePublicKey: Data?,
announcedNoisePublicKey: Data, announcedNoisePublicKey: Data,
existingSigningPublicKey: Data? = nil,
authenticatedSigningPublicKey: Data? = nil, authenticatedSigningPublicKey: Data? = nil,
announcedSigningPublicKey: Data announcedSigningPublicKey: Data? = nil
) -> BLEAnnounceTrustDecision { ) -> BLEAnnounceTrustDecision {
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey { if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
return .reject(.keyMismatch) return .reject(.keyMismatch)
} }
// Strongest binding first: an Ed25519 key bound to this Noise identity
// inside an authenticated Noise session can never be replaced by a
// merely self-signed announce.
if let authenticatedSigningPublicKey, if let authenticatedSigningPublicKey,
announcedSigningPublicKey != authenticatedSigningPublicKey { announcedSigningPublicKey != authenticatedSigningPublicKey {
return .reject(.authenticatedSigningKeyMismatch) return .reject(.authenticatedSigningKeyMismatch)
} }
// TOFU signing-key pinning. The packet signature only proves the
// announce is self-consistent it is verified against the Ed25519 key
// carried *inside the same announce*. Since peerIDs derive from the
// broadcast (public) noise key, an attacker can replay a victim's
// peerID+noiseKey with their own signing key and a valid
// self-signature. Once we have bound a signing key to this peer,
// refuse to silently replace it.
if let existingSigningPublicKey, existingSigningPublicKey != announcedSigningPublicKey {
return .reject(.signingKeyMismatch)
}
guard hasSignature else { guard hasSignature else {
return .reject(.missingSignature) return .reject(.missingSignature)
} }
+9 -18
View File
@@ -1,13 +1,6 @@
import Foundation import Foundation
/// Thread-safe announce admission state. struct BLEAnnounceThrottle {
///
/// Announce requests originate from the Bluetooth delegate queue, the
/// concurrent message queue, and the maintenance timer. Keeping the timestamp
/// behind a lock makes admission and maintenance snapshots atomic when those
/// request sources race.
final class BLEAnnounceThrottle: @unchecked Sendable {
private let lock = NSLock()
private var lastSent: Date private var lastSent: Date
private let normalMinimumInterval: TimeInterval private let normalMinimumInterval: TimeInterval
private let forcedMinimumInterval: TimeInterval private let forcedMinimumInterval: TimeInterval
@@ -23,18 +16,16 @@ final class BLEAnnounceThrottle: @unchecked Sendable {
} }
func elapsed(since now: Date) -> TimeInterval { func elapsed(since now: Date) -> TimeInterval {
lock.withLock { now.timeIntervalSince(lastSent) } now.timeIntervalSince(lastSent)
} }
func shouldSend(force: Bool, now: Date) -> Bool { mutating func shouldSend(force: Bool, now: Date) -> Bool {
lock.withLock { let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval guard elapsed(since: now) >= minimumInterval else {
guard now.timeIntervalSince(lastSent) >= minimumInterval else { return false
return false
}
lastSent = now
return true
} }
lastSent = now
return true
} }
} }
@@ -301,10 +301,8 @@ final class BLEFileTransferHandler {
) )
return true return true
case .unavailable: case .unavailable:
// Never turn an unreadable ledger into an empty ledger. A // Never turn an unreadable ledger into an empty ledger. The
// directory-level failure clears on retry; a quarantined // sender can retry after the transient storage failure clears.
// record keeps exactly this ID fail-closed while every other
// payload still flows.
SecureLogger.warning( SecureLogger.warning(
"📁 Withholding private media id=\(messageID.prefix(12))… while durable receipt state is unavailable", "📁 Withholding private media id=\(messageID.prefix(12))… while durable receipt state is unavailable",
category: .session category: .session
@@ -314,17 +314,6 @@ struct BLEIncomingFileStore: @unchecked Sendable {
} }
} }
/// 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( func privateMediaReceiptState(
messageID: String messageID: String
) -> BLEPrivateMediaReceiptState { ) -> BLEPrivateMediaReceiptState {
@@ -392,83 +381,6 @@ struct BLEIncomingFileStore: @unchecked Sendable {
) )
} }
/// Explicit deletion of a LEGACY (non-stable-ID) incoming payload.
///
/// Legacy media has no durable receipt, so the only safe unlink is one
/// that can prove no other owner may hold the basename: the path must
/// not be pending delivery, must not belong to an in-flight deletion
/// reservation, and must not be owned by a stable receipt or journal
/// entry. When any of those hold or receipt state cannot be read
/// the file stays for bounded quota cleanup (the fail-safe fallback).
/// Returns true only when the payload was verifiably unlinked.
@discardableResult
func removeLegacyIncomingFile(relativePath: String) -> Bool {
payloadCoordination.lock.lock()
defer { payloadCoordination.lock.unlock() }
guard let payload = incomingPayloadURL(
relativePath: relativePath
) else {
return false
}
let standardizedPath = payload.standardizedFileURL.path
let reservedByDeletion = payloadCoordination.deletionReservations
.values.contains { $0.contains(standardizedPath) }
guard !reservedByDeletion,
!payloadCoordination.pendingDeliveryPaths.contains(
standardizedPath
),
let receiptOwnedPaths =
privateMediaReceipts.reservedPayloadPaths(),
!receiptOwnedPaths.contains(standardizedPath) else {
return false
}
guard fileManager.fileExists(atPath: payload.path),
(try? payload.resourceValues(
forKeys: [.isRegularFileKey]
).isRegularFile) == true else {
return false
}
do {
try fileManager.removeItem(at: payload)
return !fileManager.fileExists(atPath: payload.path)
} catch {
SecureLogger.warning(
"⚠️ Failed to remove explicitly deleted legacy media: \(error)",
category: .session
)
return false
}
}
/// Resolves a `files/`-relative path iff it lands directly inside one of
/// the incoming media directories. Anything else is not a deletable
/// incoming payload.
private func incomingPayloadURL(relativePath: String) -> URL? {
guard !relativePath.isEmpty,
let base = try? filesDirectory().standardizedFileURL else {
return nil
}
let candidate = base
.appendingPathComponent(relativePath, isDirectory: false)
.standardizedFileURL
let parentPath = candidate.deletingLastPathComponent().path
let incomingDirectories = [
"voicenotes/incoming",
"images/incoming",
"files/incoming"
]
guard incomingDirectories.contains(where: { relativeDirectory in
base.appendingPathComponent(
relativeDirectory,
isDirectory: true
).standardizedFileURL.path == parentPath
}) else {
return nil
}
return candidate
}
/// Releases the short window between disk save and synchronous /// Releases the short window between disk save and synchronous
/// conversation insertion. Before this callback, a deletion transaction /// conversation insertion. Before this callback, a deletion transaction
/// may not infer ownership from a stale bubble that names the same path. /// may not infer ownership from a stale bubble that names the same path.
@@ -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 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( mutating func upsertVerifiedAnnounce(
peerID: PeerID, peerID: PeerID,
nickname: String, nickname: String,
@@ -206,15 +198,8 @@ struct BLEPeerRegistry {
now: Date, now: Date,
capabilities: PeerCapabilities? = nil, capabilities: PeerCapabilities? = nil,
bridgeGeohash: String? = nil bridgeGeohash: String? = nil
) -> BLEPeerAnnounceUpdate? { ) -> BLEPeerAnnounceUpdate {
let existing = peers[peerID] let existing = peers[peerID]
if let pinnedSigningKey = existing?.signingPublicKey,
let announcedSigningKey = signingPublicKey,
pinnedSigningKey != announcedSigningKey {
return nil
}
let update = BLEPeerAnnounceUpdate( let update = BLEPeerAnnounceUpdate(
isNewPeer: existing == nil, isNewPeer: existing == nil,
wasDisconnected: existing?.isConnected == false, wasDisconnected: existing?.isConnected == false,
@@ -226,8 +211,7 @@ struct BLEPeerRegistry {
nickname: nickname, nickname: nickname,
isConnected: isConnected, isConnected: isConnected,
noisePublicKey: noisePublicKey, noisePublicKey: noisePublicKey,
// Never drop an already-pinned signing key. signingPublicKey: signingPublicKey,
signingPublicKey: signingPublicKey ?? existing?.signingPublicKey,
isVerifiedNickname: true, isVerifiedNickname: true,
lastSeen: now, lastSeen: now,
capabilities: capabilities ?? [], capabilities: capabilities ?? [],
@@ -17,13 +17,9 @@ enum BLEPrivateMediaReceiptState: Equatable {
/// ///
/// Each ID has its own atomic record so one hot lookup never rewrites or /// Each ID has its own atomic record so one hot lookup never rewrites or
/// decodes the entire ledger. The process-lifetime index is installed only /// decodes the entire ledger. The process-lifetime index is installed only
/// after a complete directory scan. A structural failure of the directory /// after a complete, successful directory scan. An enumeration, read, decode,
/// itself (create/enumerate) or of the single batch deletion journal /// or structural-validation failure therefore remains retryable and cannot be
/// remains globally fail-closed and retryable. An individual record that /// mistaken for an empty ledger.
/// cannot be read, decoded, or validated is quarantined instead: the file is
/// moved aside with a `.corrupt` suffix, excluded from future scans, and only
/// that ID stays fail-closed the rest of the ledger keeps working, so one
/// damaged record can never make every inbound private media payload vanish.
final class BLEPrivateMediaReceiptStore: @unchecked Sendable { final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
typealias DirectoryReader = (_ directory: URL) throws -> [URL] typealias DirectoryReader = (_ directory: URL) throws -> [URL]
typealias DataReader = (_ url: URL) throws -> Data typealias DataReader = (_ url: URL) throws -> Data
@@ -34,7 +30,6 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
) throws -> Void ) throws -> Void
typealias PayloadRemover = (_ url: URL) throws -> Void typealias PayloadRemover = (_ url: URL) throws -> Void
private static let receiptDirectoryName = ".private-media-receipts" private static let receiptDirectoryName = ".private-media-receipts"
private static let quarantinePathExtension = "corrupt"
private static let deletionJournalFileName = ".deletion-journal.json" private static let deletionJournalFileName = ".deletion-journal.json"
private static let maximumDeletionPathsPerMessage = 2 private static let maximumDeletionPathsPerMessage = 2
@@ -67,10 +62,6 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
private final class Runtime: @unchecked Sendable { private final class Runtime: @unchecked Sendable {
let lock = NSLock() let lock = NSLock()
var records: [String: ReceiptRecord]? var records: [String: ReceiptRecord]?
/// IDs whose durable record was quarantined as unreadable. Installed
/// together with `records`; these IDs stay fail-closed while every
/// other record keeps serving.
var quarantined: Set<String> = []
var deletionJournal: [String: DeletionJournalEntry]? var deletionJournal: [String: DeletionJournalEntry]?
} }
@@ -118,7 +109,6 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
func resetForPanic() { func resetForPanic() {
runtime.lock.lock() runtime.lock.lock()
runtime.records = nil runtime.records = nil
runtime.quarantined.removeAll(keepingCapacity: false)
runtime.deletionJournal = nil runtime.deletionJournal = nil
runtime.lock.unlock() runtime.lock.unlock()
} }
@@ -137,18 +127,9 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
return .unavailable return .unavailable
} }
_ = recoverDeletionJournal(in: directory) _ = recoverDeletionJournal(in: directory)
// Committed deletion intent outranks quarantine: a pending journal
// entry proves the payload must stay deleted no matter what state
// the per-ID record file is in.
if runtime.deletionJournal?[messageID] != nil { if runtime.deletionJournal?[messageID] != nil {
return .tombstoned return .tombstoned
} }
// A quarantined record could have been an acceptance or a tombstone;
// only this ID fails closed, so a retry can neither resurrect deleted
// media nor double-deliver, while every other payload keeps working.
if runtime.quarantined.contains(messageID) {
return .unavailable
}
guard var records = runtime.records else { return .unavailable } guard var records = runtime.records else { return .unavailable }
guard var record = records[messageID] else { return .absent } guard var record = records[messageID] else { return .absent }
@@ -228,7 +209,6 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
} }
_ = recoverDeletionJournal(in: directory) _ = recoverDeletionJournal(in: directory)
guard runtime.deletionJournal?[messageID] == nil, guard runtime.deletionJournal?[messageID] == nil,
!runtime.quarantined.contains(messageID),
var records = runtime.records else { var records = runtime.records else {
return false return false
} }
@@ -310,10 +290,6 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
return false return false
} }
// Quarantined IDs need no special case here: their record is never
// indexed, so deleting one requires a caller-supplied payload path
// (the general pathless-deletion refusal below rejects it otherwise),
// and materialization never rewrites a quarantined ID's record.
let pendingIDs = Set(journal.keys) let pendingIDs = Set(journal.keys)
let newIDs = stableIDs.filter { messageID in let newIDs = stableIDs.filter { messageID in
if pendingIDs.contains(messageID) { return false } if pendingIDs.contains(messageID) { return false }
@@ -448,9 +424,7 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
/// Paths owned by accepted receipts, legacy pathful tombstones, or the /// Paths owned by accepted receipts, legacy pathful tombstones, or the
/// deletion journal. Incoming allocation must not reuse any of them for a /// deletion journal. Incoming allocation must not reuse any of them for a
/// different ID. Quarantined records are unreadable, so any path they may /// different ID.
/// have owned cannot be reserved; their bytes remain preserved in the
/// `.corrupt` file for offline inspection.
func reservedPayloadPaths() -> Set<String>? { func reservedPayloadPaths() -> Set<String>? {
runtime.lock.lock() runtime.lock.lock()
defer { runtime.lock.unlock() } defer { runtime.lock.unlock() }
@@ -547,22 +521,9 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
var records: [String: ReceiptRecord] = [:] var records: [String: ReceiptRecord] = [:]
var scannedRecords: [String: ReceiptRecord] = [:] var scannedRecords: [String: ReceiptRecord] = [:]
var quarantined: Set<String> = []
var expired: [String] = [] var expired: [String] = []
var tombstones: [(messageID: String, record: ReceiptRecord)] = [] var tombstones: [(messageID: String, record: ReceiptRecord)] = []
for url in urls { for url in urls {
// Records quarantined by an earlier scan stay fail-closed on
// every launch without being re-read: only their ID matters.
if url.pathExtension == Self.quarantinePathExtension {
let messageID = url
.deletingPathExtension()
.deletingPathExtension()
.lastPathComponent
if PrivateMediaMessageIdentity.isStableID(messageID) {
quarantined.insert(messageID)
}
continue
}
guard url.pathExtension == "json" else { continue } guard url.pathExtension == "json" else { continue }
let messageID = url.deletingPathExtension().lastPathComponent let messageID = url.deletingPathExtension().lastPathComponent
guard PrivateMediaMessageIdentity.isStableID(messageID) else { guard PrivateMediaMessageIdentity.isStableID(messageID) else {
@@ -574,23 +535,21 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
let data = try dataReader?(url) ?? Data(contentsOf: url) let data = try dataReader?(url) ?? Data(contentsOf: url)
record = try JSONDecoder().decode(ReceiptRecord.self, from: data) record = try JSONDecoder().decode(ReceiptRecord.self, from: data)
} catch { } catch {
// Never delete or silently skip an unreadable stable-ID // Never delete or skip an unreadable stable-ID record. Treating
// record: treating it as absent could resurrect accepted or // it as absent could resurrect accepted or deleted media.
// deleted media. But never let it poison the whole ledger SecureLogger.error(
// either quarantine the file and fail only this ID closed. "❌ Failed to read private-media receipt \(messageID.prefix(12))…: \(error)",
quarantine(url, messageID: messageID, reason: "\(error)") category: .session
quarantined.insert(messageID) )
continue return false
} }
guard isStructurallyValid(record) else { guard isStructurallyValid(record) else {
quarantine( SecureLogger.error(
url, "❌ Invalid private-media receipt \(messageID.prefix(12))",
messageID: messageID, category: .session
reason: "structurally invalid"
) )
quarantined.insert(messageID) return false
continue
} }
scannedRecords[messageID] = record scannedRecords[messageID] = record
if record.kind == .tombstone { if record.kind == .tombstone {
@@ -607,15 +566,6 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
records[messageID] = record records[messageID] = record
} }
// A readable duplicate of a quarantined ID must not override the
// fail-closed decision, not even through the failed-prune restore
// or legacy-tombstone scrub paths below.
for messageID in quarantined {
records.removeValue(forKey: messageID)
scannedRecords.removeValue(forKey: messageID)
}
tombstones.removeAll { quarantined.contains($0.messageID) }
let overflow = overflowVictims(in: records) let overflow = overflowVictims(in: records)
for messageID in overflow { for messageID in overflow {
records.removeValue(forKey: messageID) records.removeValue(forKey: messageID)
@@ -644,9 +594,6 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
candidatePayload(relativePath: $0) != nil candidatePayload(relativePath: $0) != nil
} }
}) else { }) else {
// The journal is a single batch commit file: like a
// directory-level failure it stays globally fail-closed
// and retryable, never quarantined per-ID.
SecureLogger.error( SecureLogger.error(
"❌ Invalid private-media deletion journal", "❌ Invalid private-media deletion journal",
category: .session category: .session
@@ -708,7 +655,6 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
// have been read and validated. Failed legacy cleanup remains indexed // have been read and validated. Failed legacy cleanup remains indexed
// and path-reserved instead of blocking unrelated media. // and path-reserved instead of blocking unrelated media.
runtime.records = records runtime.records = records
runtime.quarantined = quarantined
runtime.deletionJournal = journal runtime.deletionJournal = journal
return true return true
} }
@@ -735,45 +681,36 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
} }
for (messageID, journalEntry) in orderedEntries { for (messageID, journalEntry) in orderedEntries {
// Never materialize a per-ID record for a quarantined ID: the let pathlessTombstone = ReceiptRecord(
// scanner ignores readable duplicates of a quarantined record, kind: .tombstone,
// and quarantine is already permanently fail-closed relativePath: nil,
// (state == .unavailable, commitAccepted refuses it), which recordedAt: journalEntry.recordedAt
// subsumes the tombstone's no-resurrection guarantee. Only the )
// payload unlinks below still need to run before the entry can if records[messageID] != pathlessTombstone {
// retire. let victim = capacityVictim(
if !runtime.quarantined.contains(messageID) { for: .tombstone,
let pathlessTombstone = ReceiptRecord( replacing: messageID,
kind: .tombstone, in: records,
relativePath: nil, preserving: preservedMessageIDs
recordedAt: journalEntry.recordedAt
) )
if records[messageID] != pathlessTombstone { if records[messageID]?.kind != .tombstone,
let victim = capacityVictim( records.values.lazy.filter({
for: .tombstone, $0.kind == .tombstone
replacing: messageID, }).count >= capacity,
in: records, victim == nil {
preserving: preservedMessageIDs continue
) }
if records[messageID]?.kind != .tombstone, guard persist(
records.values.lazy.filter({ pathlessTombstone,
$0.kind == .tombstone messageID: messageID,
}).count >= capacity, to: directory
victim == nil { ) else {
continue continue
} }
guard persist( records[messageID] = pathlessTombstone
pathlessTombstone, if let victim, victim != messageID {
messageID: messageID, records.removeValue(forKey: victim)
to: directory removeRecord(messageID: victim, from: directory)
) else {
continue
}
records[messageID] = pathlessTombstone
if let victim, victim != messageID {
records.removeValue(forKey: victim)
removeRecord(messageID: victim, from: directory)
}
} }
} }
@@ -859,34 +796,6 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
) )
} }
/// Moves an unreadable record aside so future scans skip it while its ID
/// stays fail-closed. The bytes are preserved for offline inspection
/// quarantine never deletes receiver decisions.
private func quarantine(_ url: URL, messageID: String, reason: String) {
SecureLogger.error(
"❌ Quarantining unreadable private-media receipt \(messageID.prefix(12))…: \(reason)",
category: .session
)
let destination = url.appendingPathExtension(
Self.quarantinePathExtension
)
do {
if fileManager.fileExists(atPath: destination.path) {
// Same ID, already fail-closed; keep the earlier evidence.
try fileManager.removeItem(at: url)
} else {
try fileManager.moveItem(at: url, to: destination)
}
} catch {
// The record stays where it is and will be re-quarantined (in
// memory at minimum) by the next scan. Still fail-closed.
SecureLogger.warning(
"⚠️ Failed to move corrupt private-media receipt aside: \(error)",
category: .session
)
}
}
private func isStructurallyValid(_ record: ReceiptRecord) -> Bool { private func isStructurallyValid(_ record: ReceiptRecord) -> Bool {
switch record.kind { switch record.kind {
case .tombstone: case .tombstone:
+48 -199
View File
@@ -315,10 +315,6 @@ final class BLEService: NSObject {
/// May block announce handling after verified-link rebind work is queued. /// May block announce handling after verified-link rebind work is queued.
/// Tests use this boundary to prove rebind and reconnect are serialized. /// Tests use this boundary to prove rebind and reconnect are serialized.
var _test_afterVerifiedDirectRebindEnqueued: (() -> Void)? 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 #endif
private var selfBroadcastTracker = BLESelfBroadcastTracker() private var selfBroadcastTracker = BLESelfBroadcastTracker()
private let meshTopology = MeshTopologyTracker() private let meshTopology = MeshTopologyTracker()
@@ -362,7 +358,7 @@ final class BLEService: NSObject {
private let incomingFileStore: BLEIncomingFileStore private let incomingFileStore: BLEIncomingFileStore
// Simple announce throttling // Simple announce throttling
private let announceThrottle = BLEAnnounceThrottle() private var announceThrottle = BLEAnnounceThrottle()
// Application state tracking (thread-safe) // Application state tracking (thread-safe)
#if os(iOS) #if os(iOS)
@@ -391,13 +387,12 @@ final class BLEService: NSObject {
// MARK: - Identity // MARK: - Identity
private var noiseService: NoiseEncryptionService 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 identityManager: SecureIdentityStateManagerProtocol
private let keychain: KeychainManagerProtocol private let keychain: KeychainManagerProtocol
private let idBridge: NostrIdentityBridge 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 // MARK: - Advertising Privacy
// No Local Name by default for maximum privacy. No rotating alias. // No Local Name by default for maximum privacy. No rotating alias.
@@ -510,20 +505,14 @@ final class BLEService: NSObject {
identityManager: SecureIdentityStateManagerProtocol, identityManager: SecureIdentityStateManagerProtocol,
initializeBluetoothManagers: Bool = true, initializeBluetoothManagers: Bool = true,
incomingFileStore: BLEIncomingFileStore = BLEIncomingFileStore(), incomingFileStore: BLEIncomingFileStore = BLEIncomingFileStore(),
startSuspendedForPanicRecovery: Bool = false, startSuspendedForPanicRecovery: Bool = false
noiseResponderHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
) { ) {
self.keychain = keychain self.keychain = keychain
self.idBridge = idBridge self.idBridge = idBridge
self.incomingFileStore = incomingFileStore self.incomingFileStore = incomingFileStore
self.shouldInitializeBluetoothManagers = initializeBluetoothManagers self.shouldInitializeBluetoothManagers = initializeBluetoothManagers
self._isPanicSuspended = startSuspendedForPanicRecovery self._isPanicSuspended = startSuspendedForPanicRecovery
self.noiseResponderHandshakeTimeout = noiseResponderHandshakeTimeout noiseService = NoiseEncryptionService(keychain: keychain)
noiseService = NoiseEncryptionService(
keychain: keychain,
ordinaryResponderHandshakeTimeout: noiseResponderHandshakeTimeout
)
self.identityManager = identityManager self.identityManager = identityManager
super.init() super.init()
@@ -739,12 +728,6 @@ final class BLEService: NSObject {
/// Reopen the radio only after media deletion and recovery-marker commit. /// Reopen the radio only after media deletion and recovery-marker commit.
func completePanicReset(restartServices: Bool) { 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) setPanicSuspended(false)
guard restartServices else { return } guard restartServices else { return }
startServices() startServices()
@@ -757,14 +740,8 @@ final class BLEService: NSObject {
) { ) {
gossipSyncManager?.stop() gossipSyncManager?.stop()
gossipSyncManager = nil 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) { messageQueue.sync(flags: .barrier) {
noisePacketHandler.resetForPanic() noisePacketHandler.resetForPanic()
}
// pendingNoiseSessionQueues is owned by collectionsQueue everywhere
// else, so clear it there too rather than on messageQueue.
collectionsQueue.sync(flags: .barrier) {
pendingNoiseSessionQueues.removeAll() pendingNoiseSessionQueues.removeAll()
} }
@@ -812,19 +789,14 @@ final class BLEService: NSObject {
noiseService.clearEphemeralStateForPanic() noiseService.clearEphemeralStateForPanic()
noiseService.clearPersistentIdentity() noiseService.clearPersistentIdentity()
let newNoise = NoiseEncryptionService( let newNoise = NoiseEncryptionService(keychain: keychain)
keychain: keychain,
ordinaryResponderHandshakeTimeout: noiseResponderHandshakeTimeout
)
noiseService = newNoise noiseService = newNoise
configureNoiseServiceCallbacks(for: newNoise) configureNoiseServiceCallbacks(for: newNoise)
refreshPeerIdentity() refreshPeerIdentity()
} }
// Keep the transport silent until the application-level transaction // Keep the transport silent until the application-level transaction
// has also removed its media and committed both recovery markers. // has also removed its media and committed both recovery markers.
// Set through the identity store directly (not setNickname(_:), which myNickname = currentNickname
// would force-send an announce and break that silence).
localIdentityState.setNickname(currentNickname)
messageDeduplicator.reset() messageDeduplicator.reset()
messageQueue.async(flags: .barrier) { [weak self] in messageQueue.async(flags: .barrier) { [weak self] in
self?.selfBroadcastTracker.removeAll() self?.selfBroadcastTracker.removeAll()
@@ -903,17 +875,20 @@ final class BLEService: NSObject {
// MARK: Identity // MARK: Identity
/// Derived from the Noise identity fingerprint. Reads can originate from /// Derived from the Noise identity fingerprint; rotated only via
/// the main actor, message queue, Bluetooth queue, and maintenance timer, /// `refreshPeerIdentity()` (e.g. panic reset), which performs the swap
/// so all three local identity fields live in one lock-backed snapshot. /// inside a `messageQueue` barrier so concurrent queue work never sees a
var myPeerID: PeerID { localIdentityState.snapshot().peerID } /// half-updated identity. Externally read-only no out-of-band mutation
var myNickname: String { localIdentityState.snapshot().nickname } /// may bypass that derivation.
private var myPeerIDData: Data { localIdentityState.snapshot().peerIDData } 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 /// Sole mutator for `myNickname`: updates the stored value and force-sends
/// an announce so peers learn the new name. /// an announce so peers learn the new name.
func setNickname(_ nickname: String) { func setNickname(_ nickname: String) {
localIdentityState.setNickname(nickname) self.myNickname = nickname
// Send announce to notify peers of nickname change (force send) // Send announce to notify peers of nickname change (force send)
sendAnnounce(forceSend: true) sendAnnounce(forceSend: true)
} }
@@ -971,11 +946,10 @@ final class BLEService: NSObject {
} }
func stopServices() { func stopServices() {
let localIdentity = localIdentityState.snapshot()
// Send leave message synchronously to ensure delivery // Send leave message synchronously to ensure delivery
var leavePacket = BitchatPacket( var leavePacket = BitchatPacket(
type: MessageType.leave.rawValue, type: MessageType.leave.rawValue,
senderID: localIdentity.peerIDData, senderID: myPeerIDData,
recipientID: nil, recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: Data(), payload: Data(),
@@ -2108,26 +2082,15 @@ final class BLEService: NSObject {
// Encode once using a small per-type padding policy, then delegate by type // Encode once using a small per-type padding policy, then delegate by type
let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type) let padForBLE = BLEOutboundPacketPolicy.padsBLEFrame(for: packetToSend.type)
// The 256-fragment ceiling exists to protect *current Android* // Cross-platform private-media v1 is bounded by Android's deployed
// receivers, which only ever receive private media over the directed // 256-fragment receive cap. Run the same planner the scheduler will
// raw-file migration fallback (they do not implement the encrypted // use, after route application, for both encrypted and consented raw
// 0x20 path). Encrypted private media (`noiseEncrypted`) is sent only to // migration sends. Reject before reserving a transfer slot or writing
// peers that advertised the `.privateMedia` capability modern clients // any fragment; public media is intentionally unaffected.
// 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.
if let transferId, if let transferId,
let recipientPeerID = PeerID(hexData: packetToSend.recipientID), let recipientPeerID = PeerID(hexData: packetToSend.recipientID),
packetToSend.type == MessageType.fileTransfer.rawValue { packetToSend.type == MessageType.noiseEncrypted.rawValue
|| packetToSend.type == MessageType.fileTransfer.rawValue {
let compatibilityRequest = BLEOutboundFragmentTransferRequest( let compatibilityRequest = BLEOutboundFragmentTransferRequest(
packet: packetToSend, packet: packetToSend,
pad: padForBLE, pad: padForBLE,
@@ -2859,19 +2822,6 @@ final class BLEService: NSObject {
return true return true
} }
private func sendAnnounce(forceSend: Bool = false) { private func sendAnnounce(forceSend: Bool = false) {
guard !isPanicSuspended else { return }
// Announce construction reads the replaceable Noise service and several
// related state snapshots. Serialize the whole operation with identity
// rotation instead of letting CoreBluetooth and maintenance callbacks
// execute it directly on their own queues.
messageQueue.async(flags: .barrier) { [weak self] in
self?.sendAnnounceNow(forceSend: forceSend)
}
}
private func sendAnnounceNow(forceSend: Bool) {
// Re-check on the serialized queue: a panic suspend may have started
// after this announce was scheduled but before it runs.
guard !isPanicSuspended else { return } guard !isPanicSuspended else { return }
// Throttle announces to prevent flooding // Throttle announces to prevent flooding
if !announceThrottle.shouldSend(force: forceSend, now: Date()) { if !announceThrottle.shouldSend(force: forceSend, now: Date()) {
@@ -2892,9 +2842,8 @@ final class BLEService: NSObject {
) )
} }
let localIdentity = localIdentityState.snapshot()
let announcement = AnnouncementPacket( let announcement = AnnouncementPacket(
nickname: localIdentity.nickname, nickname: myNickname,
noisePublicKey: noisePub, noisePublicKey: noisePub,
signingPublicKey: signingPub, signingPublicKey: signingPub,
directNeighbors: connectedPeerIDs, directNeighbors: connectedPeerIDs,
@@ -2910,7 +2859,7 @@ final class BLEService: NSObject {
// Create packet with signature using the noise private key // Create packet with signature using the noise private key
let packet = BitchatPacket( let packet = BitchatPacket(
type: MessageType.announce.rawValue, type: MessageType.announce.rawValue,
senderID: localIdentity.peerIDData, senderID: myPeerIDData,
recipientID: nil, recipientID: nil,
timestamp: UInt64(Date().timeIntervalSince1970 * 1000), timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: payload, payload: payload,
@@ -2924,7 +2873,14 @@ final class BLEService: NSObject {
return 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 // Ensure our own announce is included in sync state
gossipSyncManager?.onPublicPacketSeen(signedPacket) gossipSyncManager?.onPublicPacketSeen(signedPacket)
@@ -3524,15 +3480,6 @@ extension BLEService {
capturePanicLifecycleGeneration() != nil 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 /// Models a CoreBluetooth delegate callback without requiring a physical
/// peripheral. The callback itself runs on `bleQueue`, exactly where the /// peripheral. The callback itself runs on `bleQueue`, exactly where the
/// panic radio-stop barrier must linearize it. /// panic radio-stop barrier must linearize it.
@@ -3675,20 +3622,6 @@ extension BLEService {
try noiseService.processHandshakeMessage(from: peerID, message: message) 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( func _test_enqueuePendingNoisePayload(
_ payload: Data, _ payload: Data,
transferId: String, transferId: String,
@@ -4511,14 +4444,6 @@ extension BLEService: PrivateMediaDeletionPersisting {
} }
} }
} }
@MainActor
func removeLegacyPrivateMediaPayload(relativePath: String) {
let fileStore = incomingFileStore
messageQueue.async(flags: .barrier) {
fileStore.removeLegacyIncomingFile(relativePath: relativePath)
}
}
} }
// MARK: - Private Helpers // MARK: - Private Helpers
@@ -4617,20 +4542,6 @@ extension BLEService {
} }
} }
/// 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 @MainActor
@discardableResult @discardableResult
private func deliverTransportEvent( private func deliverTransportEvent(
@@ -5116,9 +5027,6 @@ extension BLEService {
service.onHandshakeRecoveryRequired = { service.onHandshakeRecoveryRequired = {
[weak self, weak service] request in [weak self, weak service] request in
guard let self, let service else { return } guard let self, let service else { return }
#if DEBUG
self._test_beforeHandshakeRecoveryEnqueued?(request.peerID)
#endif
self.messageQueue.async(flags: .barrier) { self.messageQueue.async(flags: .barrier) {
[weak self, weak service] in [weak self, weak service] in
guard let self, guard let self,
@@ -5162,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 } guard let self, let service else { return }
// The manager makes restored keys visible atomically. Reconcile // The manager makes restored keys visible atomically. Reconcile
// transport state and queued sends as the next serialized phase. // transport state and queued sends as the next serialized phase.
@@ -5178,19 +5086,12 @@ extension BLEService {
category: .session category: .session
) )
// Re-enter the same generation-bound transition used after a // Re-enter the same generation-bound transition used after a
// successful handshake to restore authenticated protocol // successful handshake. This restores authenticated protocol
// state. Only a terminal restore may also drain the PM and // state and drains both PM and typed-payload queues.
// 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).
self.handleNoisePeerAuthenticated( self.handleNoisePeerAuthenticated(
peerID: peerID, peerID: peerID,
fingerprint: fingerprint, fingerprint: fingerprint,
sessionGeneration: generation, sessionGeneration: generation
deferOutboundUntilConvergence: reason == .pendingConvergence
) )
} }
} }
@@ -5199,8 +5100,7 @@ extension BLEService {
private func handleNoisePeerAuthenticated( private func handleNoisePeerAuthenticated(
peerID: PeerID, peerID: PeerID,
fingerprint: String, fingerprint: String,
sessionGeneration generation: UUID, sessionGeneration generation: UUID
deferOutboundUntilConvergence: Bool = false
) { ) {
let normalizedPeerID = peerID.toShort() let normalizedPeerID = peerID.toShort()
guard let transition = noiseService.withCurrentSessionGeneration( guard let transition = noiseService.withCurrentSessionGeneration(
@@ -5246,23 +5146,8 @@ extension BLEService {
// A quarantined transport restored the same cryptographic // A quarantined transport restored the same cryptographic
// generation. Its capability proof and announce state never // generation. Its capability proof and announce state never
// became stale; only work queued while outbound keys were paused // became stale; only work queued while outbound keys were paused
// needs one idempotent ready transition. Retrying the bounded // needs one idempotent ready transition.
// early-ciphertext queue is receive-side and therefore always
// safe under the restored keys.
noisePacketHandler.handleSessionAuthenticated(normalizedPeerID) 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) sendPendingMessagesAfterHandshake(for: normalizedPeerID)
sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID) sendPendingNoisePayloadsAfterHandshake(for: normalizedPeerID)
return return
@@ -5280,21 +5165,6 @@ extension BLEService {
// after this generation's transport state has been fully installed. // after this generation's transport state has been fully installed.
noisePacketHandler.handleSessionAuthenticated(normalizedPeerID) 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 // `onPeerAuthenticated` can fire while the initiator is returning XX
// message 3. This callback is queued behind the handshake handler, so // message 3. This callback is queued behind the handshake handler, so
// message 3 is broadcast first. Both peers also send one idempotent // message 3 is broadcast first. Both peers also send one idempotent
@@ -5468,9 +5338,8 @@ extension BLEService {
private func refreshPeerIdentity() { private func refreshPeerIdentity() {
let swap = { let swap = {
let fingerprint = self.noiseService.getIdentityFingerprint() let fingerprint = self.noiseService.getIdentityFingerprint()
self.localIdentityState.replacePeerIdentity( self.myPeerID = PeerID(str: fingerprint.prefix(16))
with: PeerID(str: fingerprint.prefix(16)) self.myPeerIDData = Data(hexString: self.myPeerID.id) ?? Data()
)
self.meshTopology.reset() self.meshTopology.reset()
} }
if DispatchQueue.getSpecific(key: messageQueueKey) != nil { if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
@@ -7434,21 +7303,9 @@ extension BLEService {
}, },
messageTTL: messageTTL, messageTTL: messageTTL,
now: { Date() }, now: { Date() },
existingPeerKeys: { [weak self] peerID in existingNoisePublicKey: { [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.
guard let self = self else { return nil } guard let self = self else { return nil }
return self.identityManager.getCryptoIdentitiesByPeerIDPrefix(peerID) return self.collectionsQueue.sync { self.peerRegistry.info(for: peerID)?.noisePublicKey }
.compactMap { $0.signingPublicKey }
.first
}, },
authenticatedSigningPublicKey: { [weak self] noisePublicKey in authenticatedSigningPublicKey: { [weak self] noisePublicKey in
self?.identityManager.authenticatedSigningPublicKey( self?.identityManager.authenticatedSigningPublicKey(
@@ -7485,24 +7342,16 @@ extension BLEService {
}, },
upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in upsertVerifiedAnnounce: { [weak self] peerID, announcement, isConnected, now in
// Called from inside withRegistryBarrier; access registry directly. // Called from inside withRegistryBarrier; access registry directly.
guard let self = self else { self?.peerRegistry.upsertVerifiedAnnounce(
return BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
}
return self.peerRegistry.upsertVerifiedAnnounce(
peerID: peerID, peerID: peerID,
nickname: announcement.nickname, nickname: announcement.nickname,
noisePublicKey: announcement.noisePublicKey, noisePublicKey: announcement.noisePublicKey,
signingPublicKey: announcement.signingPublicKey, signingPublicKey: announcement.signingPublicKey,
isConnected: isConnected, 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, now: now,
capabilities: announcement.capabilities, capabilities: announcement.capabilities,
bridgeGeohash: announcement.bridgeGeohash bridgeGeohash: announcement.bridgeGeohash
) ) ?? BLEPeerAnnounceUpdate(isNewPeer: false, wasDisconnected: false, previousNickname: nil)
}, },
shouldEmitReconnectLog: { [weak self] peerID, now in shouldEmitReconnectLog: { [weak self] peerID, now in
// Called from inside withRegistryBarrier; access debouncer directly. // 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 /// Envelopes eligible to park on relays as bridge courier drops. Merely
/// offering one does not start its cooldown: the caller commits that only /// 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] { func envelopesForBridgePublish(cooldown: TimeInterval) -> [CourierEnvelope] {
let date = now() let date = now()
return queue.sync { return queue.sync {
@@ -67,7 +67,7 @@ final class BridgeCourierService: ObservableObject {
var relaysConnected: (@MainActor () -> Bool)? var relaysConnected: (@MainActor () -> Bool)?
/// Publishes a signed drop event directly to connected default (DM) /// Publishes a signed drop event directly to connected default (DM)
/// relays. Completion is true only after at least one relay explicitly /// 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". /// or merely "written to a socket".
var publishEvent: (@MainActor (NostrEvent, @escaping @MainActor (Bool) -> Void) -> Void)? var publishEvent: (@MainActor (NostrEvent, @escaping @MainActor (Bool) -> Void) -> Void)?
/// (Re)opens the drop subscription for the given hex tags. /// (Re)opens the drop subscription for the given hex tags.
@@ -106,13 +106,14 @@ final class BridgeCourierService: ObservableObject {
dedupKey: String?, dedupKey: String?,
operationID: UUID? operationID: UUID?
)] = [] )] = []
/// Message IDs already published as drops (sender-side dedup) and drop /// Opaque recipient/message keys already published as drops (sender-side
/// event IDs already handled (multi-relay dedup). Both persist across /// dedup) and drop event IDs already handled (multi-relay dedup). Both
/// relaunches: relays hold drops for the full 24h NIP-40 window and the /// persist across relaunches: relays hold drops for the full 24h NIP-40
/// persisted outbox keeps re-depositing, so in-memory-only dedup meant /// window and the persisted outbox keeps re-depositing, so in-memory-only
/// every relaunch republished the same message as a fresh drop and every /// dedup meant every relaunch republished the same message as a fresh drop
/// gateway relaunch re-delivered the whole backlog (field-verified /// and every gateway relaunch re-delivered the whole backlog
/// amplification storm). Entries age out with the 24h drop window. /// (field-verified amplification storm). Entries age out with the 24h drop
/// window.
private var publishedDropKeys: ExpiringIDSet private var publishedDropKeys: ExpiringIDSet
private var seenDropEventIDs: ExpiringIDSet private var seenDropEventIDs: ExpiringIDSet
private var subscriptionOpen = false private var subscriptionOpen = false
@@ -126,10 +127,10 @@ final class BridgeCourierService: ObservableObject {
} }
/// Sender operations queued locally or awaiting relay confirmation. /// Sender operations queued locally or awaiting relay confirmation.
/// The per-attempt ID prevents a stale pre-wipe callback from completing /// The per-attempt ID prevents a stale pre-wipe callback from completing
/// a newer attempt for the same message. /// a newer attempt for the same recipient/message pair.
private var activeDropOperations: [String: ActiveDropOperation] = [:] private var activeDropOperations: [String: ActiveDropOperation] = [:]
/// Held-envelope publishes have no sender message ID, but still need an /// 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. /// must not mint duplicate relay events for the same opaque envelope.
private var heldDropOperations: [Data: UUID] = [:] private var heldDropOperations: [Data: UUID] = [:]
/// Deterministically invalid envelopes are suppressed for this process, /// Deterministically invalid envelopes are suppressed for this process,
@@ -214,11 +215,46 @@ final class BridgeCourierService: ObservableObject {
// MARK: - Sender role // MARK: - Sender role
/// Stable, opaque sender-side dedup key. Recipient scope prevents one
/// conversation's colliding message ID from suppressing another's drop,
/// while hashing keeps recipient keys out of the persisted snapshot.
private static func senderDropKey(
messageID: String,
recipientNoiseKey: Data
) -> String {
var material = Data("bitchat-bridge-drop-dedup-v2".utf8)
appendLengthPrefixed(Data(messageID.utf8), to: &material)
appendLengthPrefixed(recipientNoiseKey, to: &material)
return "v2:\(material.sha256Hex())"
}
private static func appendLengthPrefixed(_ value: Data, to output: inout Data) {
let length = UInt32(value.count)
output.append(UInt8((length >> 24) & 0xFF))
output.append(UInt8((length >> 16) & 0xFF))
output.append(UInt8((length >> 8) & 0xFF))
output.append(UInt8(length & 0xFF))
output.append(value)
}
/// Previous releases persisted raw message IDs without recipient scope.
/// They remain conservative wildcards for their original 24-hour
/// lifetime: assigning one to a recipient would be guesswork and could
/// republish the original drop. New acceptances persist only v2 keys.
private func wasPublished(
legacyMessageID: String,
dedupKey: String,
now date: Date
) -> Bool {
publishedDropKeys.contains(dedupKey, now: date)
|| publishedDropKeys.contains(legacyMessageID, now: date)
}
/// Parallel-deposit a sealed copy of an outbound private message as a /// Parallel-deposit a sealed copy of an outbound private message as a
/// relay drop. Called by the message router alongside physical courier /// relay drop. Called by the message router alongside physical courier
/// deposits; idempotent per message ID. Completion becomes true only /// deposits; idempotent per recipient/message pair. Completion becomes
/// after a real relay acceptance arrives, which is when the router may /// true only after a real relay acceptance arrives, which is when the
/// show "carried". /// router may show "carried".
func depositDrop( func depositDrop(
content: String, content: String,
messageID: String, messageID: String,
@@ -229,9 +265,18 @@ final class BridgeCourierService: ObservableObject {
completion(false) completion(false)
return return
} }
guard !publishedDropKeys.contains(messageID, now: now()), let date = now()
activeDropOperations[messageID] == nil, let dedupKey = Self.senderDropKey(
!rejectedDropKeys.contains(messageID, now: now()) else { messageID: messageID,
recipientNoiseKey: recipientNoiseKey
)
guard !wasPublished(
legacyMessageID: messageID,
dedupKey: dedupKey,
now: date
),
activeDropOperations[dedupKey] == nil,
!rejectedDropKeys.contains(dedupKey, now: date) else {
completion(false) completion(false)
return return
} }
@@ -244,13 +289,13 @@ final class BridgeCourierService: ObservableObject {
// of the sealing); suppress it in-memory so the retry sweep does not // of the sealing); suppress it in-memory so the retry sweep does not
// churn, but never persist it as a published drop. // churn, but never persist it as a published drop.
guard let encoded = envelope.encode(), encoded.count <= Limits.maxDropEnvelopeBytes else { guard let encoded = envelope.encode(), encoded.count <= Limits.maxDropEnvelopeBytes else {
rejectedDropKeys.insert(messageID, now: now()) rejectedDropKeys.insert(dedupKey, now: date)
completion(false) completion(false)
return return
} }
let operationID = UUID() let operationID = UUID()
activeDropOperations[messageID] = ActiveDropOperation(id: operationID, completion: completion) activeDropOperations[dedupKey] = ActiveDropOperation(id: operationID, completion: completion)
publishDrop(envelope, messageID: messageID, operationID: operationID) publishDrop(envelope, dedupKey: dedupKey, operationID: operationID)
} }
/// Publishes held envelopes (mail we carry for others) as drops, /// Publishes held envelopes (mail we carry for others) as drops,
@@ -272,13 +317,14 @@ final class BridgeCourierService: ObservableObject {
} }
} }
/// Publishes a drop, or queues it when relays are down. `messageID` is the /// Publishes a drop, or queues it when relays are down. `dedupKey` is the
/// sender-side dedup key (nil for held/relayed envelopes we don't track); /// opaque sender-side recipient/message key (nil for held/relayed
/// it rides the pending queue so an evicted or failed drop can release its /// envelopes we don't track); it rides the pending queue so an evicted or
/// in-flight slot. Completion reports actual NIP-20 relay acceptance. /// failed drop can release its in-flight slot. Completion reports actual
/// NIP-01 relay acceptance.
private func publishDrop( private func publishDrop(
_ envelope: CourierEnvelope, _ envelope: CourierEnvelope,
messageID: String? = nil, dedupKey: String? = nil,
operationID: UUID? = nil, operationID: UUID? = nil,
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
) { ) {
@@ -286,7 +332,7 @@ final class BridgeCourierService: ObservableObject {
encoded.count <= Limits.maxDropEnvelopeBytes, encoded.count <= Limits.maxDropEnvelopeBytes,
!envelope.isExpired else { !envelope.isExpired else {
finishPublish( finishPublish(
messageID: messageID, dedupKey: dedupKey,
operationID: operationID, operationID: operationID,
succeeded: false, succeeded: false,
untrackedCompletion: untrackedCompletion untrackedCompletion: untrackedCompletion
@@ -297,15 +343,15 @@ final class BridgeCourierService: ObservableObject {
// Held mail remains in CourierStore and has no sender operation to // Held mail remains in CourierStore and has no sender operation to
// recover after an in-memory queue loss. Leave its cooldown unset // recover after an in-memory queue loss. Leave its cooldown unset
// and let the next connected refresh offer it again. // and let the next connected refresh offer it again.
guard messageID != nil else { guard dedupKey != nil else {
untrackedCompletion?(false) untrackedCompletion?(false)
return return
} }
pendingDrops.append((envelope, messageID, operationID)) pendingDrops.append((envelope, dedupKey, operationID))
while pendingDrops.count > Limits.maxPendingDrops { while pendingDrops.count > Limits.maxPendingDrops {
let evicted = pendingDrops.removeFirst() let evicted = pendingDrops.removeFirst()
finishPublish( finishPublish(
messageID: evicted.dedupKey, dedupKey: evicted.dedupKey,
operationID: evicted.operationID, operationID: evicted.operationID,
succeeded: false succeeded: false
) )
@@ -321,7 +367,7 @@ final class BridgeCourierService: ObservableObject {
) else { ) else {
SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption) SecureLogger.error("📦🌉 Failed to compose courier drop", category: .encryption)
finishPublish( finishPublish(
messageID: messageID, dedupKey: dedupKey,
operationID: operationID, operationID: operationID,
succeeded: false, succeeded: false,
untrackedCompletion: untrackedCompletion untrackedCompletion: untrackedCompletion
@@ -331,7 +377,7 @@ final class BridgeCourierService: ObservableObject {
guard let publishEvent else { guard let publishEvent else {
SecureLogger.error("📦🌉 Courier drop publisher is not configured", category: .session) SecureLogger.error("📦🌉 Courier drop publisher is not configured", category: .session)
finishPublish( finishPublish(
messageID: messageID, dedupKey: dedupKey,
operationID: operationID, operationID: operationID,
succeeded: false, succeeded: false,
untrackedCompletion: untrackedCompletion untrackedCompletion: untrackedCompletion
@@ -341,7 +387,7 @@ final class BridgeCourierService: ObservableObject {
publishEvent(event) { [weak self] succeeded in publishEvent(event) { [weak self] succeeded in
guard let self else { return } guard let self else { return }
guard self.finishPublish( guard self.finishPublish(
messageID: messageID, dedupKey: dedupKey,
operationID: operationID, operationID: operationID,
succeeded: succeeded, succeeded: succeeded,
untrackedCompletion: untrackedCompletion untrackedCompletion: untrackedCompletion
@@ -356,23 +402,23 @@ final class BridgeCourierService: ObservableObject {
@discardableResult @discardableResult
private func finishPublish( private func finishPublish(
messageID: String?, dedupKey: String?,
operationID: UUID?, operationID: UUID?,
succeeded: Bool, succeeded: Bool,
untrackedCompletion: (@MainActor (Bool) -> Void)? = nil untrackedCompletion: (@MainActor (Bool) -> Void)? = nil
) -> Bool { ) -> Bool {
guard let messageID else { guard let dedupKey else {
untrackedCompletion?(succeeded) untrackedCompletion?(succeeded)
return true return true
} }
// Missing/mismatched means this callback was duplicated, invalidated // Missing/mismatched means this callback was duplicated, invalidated
// by panic wipe, or belongs to an older attempt for the same key. // by panic wipe, or belongs to an older attempt for the same key.
guard let operationID, guard let operationID,
let operation = activeDropOperations[messageID], let operation = activeDropOperations[dedupKey],
operation.id == operationID else { return false } operation.id == operationID else { return false }
activeDropOperations.removeValue(forKey: messageID) activeDropOperations.removeValue(forKey: dedupKey)
if succeeded { if succeeded {
publishedDropKeys.insert(messageID, now: now()) publishedDropKeys.insert(dedupKey, now: now())
persistDedup() persistDedup()
} }
operation.completion(succeeded) operation.completion(succeeded)
@@ -387,7 +433,7 @@ final class BridgeCourierService: ObservableObject {
for item in queued { for item in queued {
publishDrop( publishDrop(
item.envelope, item.envelope,
messageID: item.dedupKey, dedupKey: item.dedupKey,
operationID: item.operationID operationID: item.operationID
) )
} }
@@ -64,11 +64,11 @@ struct ExpiringIDSet {
/// fresh drop (fresh throwaway seal, undeduplicatable downstream) and every /// fresh drop (fresh throwaway seal, undeduplicatable downstream) and every
/// gateway relaunch re-delivered the whole backlog. Field-verified: ~20 /// gateway relaunch re-delivered the whole backlog. Field-verified: ~20
/// copies of one DM delivered in 40ms fed the storm behind a permanent /// copies of one DM delivered in 40ms fed the storm behind a permanent
/// device freeze. Persisting both sides caps this at one drop per message /// device freeze. Persisting both sides caps this at one drop per
/// ID per 24h regardless of relaunch count. /// recipient/message pair per 24h regardless of relaunch count.
/// ///
/// Contents are opaque IDs (message UUIDs, relay event IDs) no plaintext, /// Contents are opaque hashes and relay event IDs no plaintext or peer
/// no peer identities so until-first-unlock protection matches /// identities so until-first-unlock protection matches
/// `NostrProcessedEventStore`, and the file must load during a /// `NostrProcessedEventStore`, and the file must load during a
/// locked-background restoration relaunch. Wiped on panic with the rest of /// locked-background restoration relaunch. Wiped on panic with the rest of
/// the courier state. /// the courier state.
@@ -172,11 +172,11 @@ final class MessageDeduplicationService {
/// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format) /// Cache for Nostr ACK deduplication (messageId:ackType:senderPubkey format)
private let nostrAckCache: LRUDeduplicationCache<Bool> private let nostrAckCache: LRUDeduplicationCache<Bool>
/// Optional cross-launch persistence for the Nostr event cache. NIP-59 /// Optional cross-launch persistence for the Nostr event cache. BitChat
/// randomizes gift-wrap timestamps, so DM subscriptions look back 24h and /// randomizes private-envelope timestamps, so DM subscriptions look back
/// relays redeliver the same events on every launch; without this record /// 24h and relays redeliver the same events on every launch; without this
/// each relaunch reprocesses old PMs and acks. Nil (tests, macOS callers /// record each relaunch reprocesses old PMs and acks. Nil (tests, macOS
/// that don't opt in) keeps the cache purely in-memory. /// callers that don't opt in) keeps the cache purely in-memory.
private let nostrEventStore: NostrProcessedEventStore? private let nostrEventStore: NostrProcessedEventStore?
private let nostrEventCapacity: Int private let nostrEventCapacity: Int
private var persistScheduled = false private var persistScheduled = false
@@ -314,7 +314,7 @@ final class MessageDeduplicationService {
// MARK: - Clear // MARK: - Clear
/// Clears all caches. This is the wipe/panic path: the persisted /// 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() { func clearAll() {
contentCache.clear() contentCache.clear()
nostrEventCache.clear() nostrEventCache.clear()
@@ -325,7 +325,7 @@ final class MessageDeduplicationService {
/// Clears only the in-memory Nostr caches (events and ACKs). Runs on /// Clears only the in-memory Nostr caches (events and ACKs). Runs on
/// every geohash channel switch, so the disk record deliberately /// 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). /// each time the user changes channels (flagged by Codex on #1398).
func clearNostrCaches() { func clearNostrCaches() {
nostrEventCache.clear() nostrEventCache.clear()
+263 -26
View File
@@ -34,6 +34,20 @@ struct CourierDirectory {
final class MessageRouter { final class MessageRouter {
typealias QueuedMessage = MessageOutboxStore.QueuedMessage typealias QueuedMessage = MessageOutboxStore.QueuedMessage
private struct PeerMessageKey: Hashable {
let peerID: PeerID
let messageID: String
static func == (lhs: PeerMessageKey, rhs: PeerMessageKey) -> Bool {
lhs.peerID == rhs.peerID && lhs.messageID == rhs.messageID
}
func hash(into hasher: inout Hasher) {
hasher.combine(peerID)
hasher.combine(messageID)
}
}
private let transports: [Transport] private let transports: [Transport]
private let now: () -> Date private let now: () -> Date
private let courierDirectory: CourierDirectory private let courierDirectory: CourierDirectory
@@ -104,15 +118,25 @@ final class MessageRouter {
} }
private var bridgeSweepTask: Task<Void, Never>? private var bridgeSweepTask: Task<Void, Never>?
private var bridgeDepositsInFlight = Set<String>() private var bridgeDepositsInFlight = Set<PeerMessageKey>()
private var outbox: [PeerID: [QueuedMessage]] = [:] private var outbox: [PeerID: [QueuedMessage]] = [:]
/// Peer/message pairs whose latest router-owned transmission used an
/// already-established secure session and still await an ack. Peer scope
/// is required because message IDs are not globally unique across direct
/// conversations. This deliberately excludes messages handed to BLE while
/// a handshake is pending: BLE owns those sends and drains its queue after
/// authentication, so retrying them here would duplicate every normal
/// first-handshake DM.
private var secureTransmissions = Set<PeerMessageKey>()
// Outbox limits to prevent unbounded memory growth // Outbox limits to prevent unbounded memory growth
private static let maxMessagesPerPeer = 100 private static let maxMessagesPerPeer = 100
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
// Bound resends of messages sent on a weak reachability signal that never // Bound actual sends that never receive an ack, whether they used weak
// get a delivery ack (e.g. peer on an old client that doesn't ack). // reachability or an apparently secure session that keeps being replaced.
// Connected pre-handshake sends are transport-owned and do not burn this
// cap because BLE queues/drains them itself.
private static let maxSendAttempts = 8 private static let maxSendAttempts = 8
// Redundant couriers improve delivery odds; receivers dedup by message ID. // Redundant couriers improve delivery odds; receivers dedup by message ID.
private static let maxCouriersPerMessage = 3 private static let maxCouriersPerMessage = 3
@@ -158,6 +182,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 // MARK: - Transport Selection
private func reachableTransport(for peerID: PeerID) -> Transport? { private func reachableTransport(for peerID: PeerID) -> Transport? {
@@ -171,15 +205,28 @@ final class MessageRouter {
// MARK: - Message Sending // MARK: - Message Sending
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
let message = QueuedMessage(
content: content,
nickname: recipientNickname,
messageID: messageID,
timestamp: now(),
sendAttempts: 1
)
if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) { if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) {
// A live link that can complete an encrypted delivery is a // Even an established Noise session can be stale after the peer
// strong delivery signal; trust it outright. // restarts or replaces its app. Persist before handing the packet
// to the transport so a fast ack cannot race ahead of retention,
// then keep the copy until a delivery/read ack clears it. A
// replacement handshake will retry this same message ID, which
// receivers deduplicate.
enqueue(message, for: peerID)
secureTransmissions.insert(PeerMessageKey(peerID: peerID, messageID: messageID))
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("Routing PM via \(type(of: transport)) (connected) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
return return
} }
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: now(), sendAttempts: 1)
if let transport = connectedTransport(for: peerID) { if let transport = connectedTransport(for: peerID) {
// "Connected" without an established secure session is forgeable: // "Connected" without an established secure session is forgeable:
// link bindings heal on signature-verified "direct" announces, but // link bindings heal on signature-verified "direct" announces, but
@@ -197,8 +244,9 @@ final class MessageRouter {
// deposit is cleared on ack. Don't "optimize" the courier call // deposit is cleared on ack. Don't "optimize" the courier call
// away. // away.
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected, no secure session) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("Routing PM via \(type(of: transport)) (connected, no secure session) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
enqueue(message, for: peerID) enqueue(message, for: peerID)
secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID))
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
attemptCourierDeposit(messageID: messageID, for: peerID) attemptCourierDeposit(messageID: messageID, for: peerID)
return return
} }
@@ -209,8 +257,8 @@ final class MessageRouter {
// Send now, but retain a copy until a delivery/read ack clears it; // Send now, but retain a copy until a delivery/read ack clears it;
// receivers dedup resends by message ID. // receivers dedup resends by message ID.
SecureLogger.debug("Routing PM via \(type(of: transport)) (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("Routing PM via \(type(of: transport)) (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
enqueue(message, for: peerID) enqueue(message, for: peerID)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
// "Reachable" without prompt delivery means the send only joined // "Reachable" without prompt delivery means the send only joined
// a queue (Nostr with relays down): also hand a sealed copy to // a queue (Nostr with relays down): also hand a sealed copy to
// any connected couriers rather than waiting for internet that // any connected couriers rather than waiting for internet that
@@ -333,11 +381,12 @@ final class MessageRouter {
for peerID: PeerID, for peerID: PeerID,
recipientKey: Data recipientKey: Data
) { ) {
let inFlightKey = PeerMessageKey(peerID: peerID, messageID: message.messageID)
guard let bridgeCourierDeposit, guard let bridgeCourierDeposit,
bridgeDepositsInFlight.insert(message.messageID).inserted else { return } bridgeDepositsInFlight.insert(inFlightKey).inserted else { return }
bridgeCourierDeposit(message.content, message.messageID, recipientKey) { [weak self] succeeded in bridgeCourierDeposit(message.content, message.messageID, recipientKey) { [weak self] succeeded in
guard let self else { return } guard let self else { return }
self.bridgeDepositsInFlight.remove(message.messageID) self.bridgeDepositsInFlight.remove(inFlightKey)
// A direct delivery may have cleared the outbox while the relay // A direct delivery may have cleared the outbox while the relay
// relay confirmation was in flight; do not regress its UI state. // relay confirmation was in flight; do not regress its UI state.
guard succeeded, self.queuedMessage(message.messageID, for: peerID) != nil else { return } guard succeeded, self.queuedMessage(message.messageID, for: peerID) != nil else { return }
@@ -356,15 +405,40 @@ final class MessageRouter {
// MARK: - Outbox Management // MARK: - Outbox Management
/// A delivery or read ack confirms receipt; stop retaining the message. /// A locally trusted delivery transition confirms receipt; stop retaining
/// every copy of the message. Authenticated remote receipts must use the
/// peer-bound overload below instead.
func markDelivered(_ messageID: String) { func markDelivered(_ messageID: String) {
clearRetainedMessage(messageID, allowedPeerIDs: nil)
}
/// Stops retaining a message only for the authenticated conversation
/// aliases that produced the accepted receipt. A peer that learns another
/// conversation's message ID cannot use it to clear that conversation's
/// retry state.
func markDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {
guard !peerIDs.isEmpty else { return }
_ = markDelivered(messageID, for: Array(peerIDs))
}
private func clearRetainedMessage(
_ messageID: String,
allowedPeerIDs: Set<PeerID>?
) {
var cleared = false var cleared = false
for (peerID, queue) in outbox { for (peerID, queue) in outbox {
if let allowedPeerIDs, !allowedPeerIDs.contains(peerID) {
continue
}
let filtered = queue.filter { $0.messageID != messageID } let filtered = queue.filter { $0.messageID != messageID }
guard filtered.count != queue.count else { continue } guard filtered.count != queue.count else { continue }
outbox[peerID] = filtered.isEmpty ? nil : filtered outbox[peerID] = filtered.isEmpty ? nil : filtered
cleared = true cleared = true
} }
let matchingSecureTransmissions = secureTransmissions.filter {
$0.messageID == messageID
}
secureTransmissions.subtract(matchingSecureTransmissions)
// The durable snapshot may still be hidden by protected data. Record // The durable snapshot may still be hidden by protected data. Record
// the ack even when this cold-load view cannot find the message, then // the ack even when this cold-load view cannot find the message, then
// persist the current view so the store retains a removal tombstone. // persist the current view so the store retains a removal tombstone.
@@ -391,6 +465,9 @@ final class MessageRouter {
outbox[peerID] = filtered.isEmpty ? nil : filtered outbox[peerID] = filtered.isEmpty ? nil : filtered
cleared = true cleared = true
} }
for peerID in peerIDs {
secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID))
}
// Preserve the scoped ack even when protected data hides the durable // Preserve the scoped ack even when protected data hides the durable
// queue during a cold launch. // queue during a cold launch.
outboxStore?.recordRemoval(messageID: messageID, for: peerIDs) outboxStore?.recordRemoval(messageID: messageID, for: peerIDs)
@@ -424,7 +501,34 @@ final class MessageRouter {
persistOutbox() persistOutbox()
} }
@discardableResult
private func removeQueuedMessage(_ messageID: String, for peerID: PeerID) -> Bool {
guard let queue = outbox[peerID],
let index = queue.firstIndex(where: { $0.messageID == messageID }) else {
return false
}
var updated = queue
updated.remove(at: index)
outbox[peerID] = updated.isEmpty ? nil : updated
return true
}
@discardableResult
private func incrementSendAttemptsIfQueued(_ messageID: String, for peerID: PeerID) -> Bool {
guard var queue = outbox[peerID],
let index = queue.firstIndex(where: { $0.messageID == messageID }) else {
// A synchronous delivery/read ack may have cleared the retained
// copy while `sendPrivateMessage` was on the stack. Never
// resurrect it from the flush snapshot.
return false
}
queue[index].sendAttempts += 1
outbox[peerID] = queue
return true
}
private func dropMessage(_ messageID: String, for peerID: PeerID) { private func dropMessage(_ messageID: String, for peerID: PeerID) {
secureTransmissions.remove(PeerMessageKey(peerID: peerID, messageID: messageID))
metrics?.record(.outboxDropped) metrics?.record(.outboxDropped)
onMessageDropped?(messageID, peerID) onMessageDropped?(messageID, peerID)
} }
@@ -468,6 +572,7 @@ final class MessageRouter {
/// Panic wipe: forget queued mail on disk and in memory. /// Panic wipe: forget queued mail on disk and in memory.
func wipeOutbox() { func wipeOutbox() {
outbox.removeAll() outbox.removeAll()
secureTransmissions.removeAll()
outboxStore?.wipe() outboxStore?.wipe()
} }
@@ -495,26 +600,160 @@ final class MessageRouter {
} }
} }
/// Retries only messages that the router previously transmitted through
/// an already-established secure session and that still await an ack.
///
/// A peer restart can leave that local session looking usable until the
/// replacement handshake arrives; the first ciphertext is then
/// undecryptable remotely. Normal pre-handshake sends are intentionally
/// absent from `secureTransmissions` because BLE already queues
/// and drains them when authentication completes.
func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) {
typealias Candidate = (
peerID: PeerID,
message: QueuedMessage,
aliasOrder: Int,
queueOrder: Int
)
var visitedPeerIDs = Set<PeerID>()
var retriedMessageIDs = Set<String>()
var outboxChanged = false
let currentDate = now()
var candidates: [Candidate] = []
for (aliasOrder, peerID) in peerIDAliases.enumerated() {
guard visitedPeerIDs.insert(peerID).inserted else { continue }
guard let queued = outbox[peerID], !queued.isEmpty,
let transport = connectedTransport(for: peerID),
transport.canDeliverSecurely(to: peerID) else {
continue
}
for (queueOrder, message) in queued.enumerated() {
let key = PeerMessageKey(peerID: peerID, messageID: message.messageID)
guard secureTransmissions.contains(key) else { continue }
candidates.append((
peerID: peerID,
message: message,
aliasOrder: aliasOrder,
queueOrder: queueOrder
))
}
}
// Conversation migration can leave retained messages split across the
// ephemeral and stable outbox keys. Merge both queues into one
// chronological stream so callback alias order cannot send newer mail
// ahead of older mail.
candidates.sort { lhs, rhs in
if lhs.message.timestamp != rhs.message.timestamp {
return lhs.message.timestamp < rhs.message.timestamp
}
if lhs.aliasOrder != rhs.aliasOrder {
return lhs.aliasOrder < rhs.aliasOrder
}
if lhs.queueOrder != rhs.queueOrder {
return lhs.queueOrder < rhs.queueOrder
}
return lhs.message.messageID < rhs.message.messageID
}
for candidate in candidates {
let peerID = candidate.peerID
let message = candidate.message
let key = PeerMessageKey(peerID: peerID, messageID: message.messageID)
guard retriedMessageIDs.insert(message.messageID).inserted,
secureTransmissions.contains(key),
queuedMessage(message.messageID, for: peerID) != nil,
let transport = connectedTransport(for: peerID),
transport.canDeliverSecurely(to: peerID) else {
continue
}
if currentDate.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
if removeQueuedMessage(message.messageID, for: peerID) {
dropMessage(message.messageID, for: peerID)
outboxChanged = true
}
continue
}
guard message.sendAttempts < Self.maxSendAttempts else {
SecureLogger.warning(
"📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) secure attempts",
category: .session
)
if removeQueuedMessage(message.messageID, for: peerID) {
dropMessage(message.messageID, for: peerID)
outboxChanged = true
}
continue
}
SecureLogger.debug(
"Auth retry -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))",
category: .session
)
transport.sendPrivateMessage(
message.content,
to: peerID,
recipientNickname: message.nickname,
messageID: message.messageID
)
metrics?.record(.outboxResent)
outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged
}
if outboxChanged {
persistOutbox()
}
}
func flushOutbox(for peerID: PeerID) { func flushOutbox(for peerID: PeerID) {
guard let queued = outbox[peerID], !queued.isEmpty else { return } guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session) SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
let now = now() let now = now()
var remaining: [QueuedMessage] = [] var outboxChanged = false
for message in queued { for message in queued {
// A synchronous ack from an earlier send in this flush may have
// removed an entry from the live outbox. The snapshot is only an
// iteration order; never use it to recreate removed messages.
guard queuedMessage(message.messageID, for: peerID) != nil else { continue }
// Skip expired messages (TTL exceeded) // Skip expired messages (TTL exceeded)
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds { if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session) SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
dropMessage(message.messageID, for: peerID) if removeQueuedMessage(message.messageID, for: peerID) {
dropMessage(message.messageID, for: peerID)
outboxChanged = true
}
continue continue
} }
if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) { if let transport = connectedTransport(for: peerID), transport.canDeliverSecurely(to: peerID) {
// Live link with a secure session: send and stop retaining. // A secure session is meaningful enough to retry, but not
// proof that this particular ciphertext reached the peer: the
// remote app may have restarted while our old session still
// looked established. Retain until an ack, while bounding
// actual secure transmissions for peers that never ack.
guard message.sendAttempts < Self.maxSendAttempts else {
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
if removeQueuedMessage(message.messageID, for: peerID) {
dropMessage(message.messageID, for: peerID)
outboxChanged = true
}
continue
}
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session) SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
secureTransmissions.insert(
PeerMessageKey(peerID: peerID, messageID: message.messageID)
)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
metrics?.record(.outboxResent) metrics?.record(.outboxResent)
outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged
} else if let transport = connectedTransport(for: peerID) { } else if let transport = connectedTransport(for: peerID) {
// "Connected" without a secure session possibly a stolen // "Connected" without a secure session possibly a stolen
// binding from a replayed announce: send (a genuine link // binding from a replayed announce: send (a genuine link
@@ -527,9 +766,11 @@ final class MessageRouter {
// preserve. Retention stays bounded by the 24h outbox TTL // preserve. Retention stays bounded by the 24h outbox TTL
// and the per-peer FIFO cap. // and the per-peer FIFO cap.
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected, no secure session) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session) SecureLogger.debug("Outbox -> \(type(of: transport)) (connected, no secure session) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
secureTransmissions.remove(
PeerMessageKey(peerID: peerID, messageID: message.messageID)
)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
metrics?.record(.outboxResent) metrics?.record(.outboxResent)
remaining.append(message)
} else if let transport = reachableTransport(for: peerID) { } else if let transport = reachableTransport(for: peerID) {
// Reachability without a connection is a freshness heuristic, // Reachability without a connection is a freshness heuristic,
// so the send can silently go nowhere: send but keep retaining // so the send can silently go nowhere: send but keep retaining
@@ -537,26 +778,22 @@ final class MessageRouter {
// that never ack. // that never ack.
guard message.sendAttempts < Self.maxSendAttempts else { guard message.sendAttempts < Self.maxSendAttempts else {
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session) SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
dropMessage(message.messageID, for: peerID) if removeQueuedMessage(message.messageID, for: peerID) {
dropMessage(message.messageID, for: peerID)
outboxChanged = true
}
continue continue
} }
SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session) SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
metrics?.record(.outboxResent) metrics?.record(.outboxResent)
var retained = message outboxChanged = incrementSendAttemptsIfQueued(message.messageID, for: peerID) || outboxChanged
retained.sendAttempts += 1
remaining.append(retained)
} else {
remaining.append(message)
} }
} }
if remaining.isEmpty { if outboxChanged {
outbox.removeValue(forKey: peerID) persistOutbox()
} else {
outbox[peerID] = remaining
} }
persistOutbox()
} }
func flushAllOutbox() { func flushAllOutbox() {
@@ -193,11 +193,8 @@ final class NoiseEncryptionService {
((_ request: NoiseHandshakeRecoveryRequest) -> Void)? ((_ request: NoiseHandshakeRecoveryRequest) -> Void)?
/// An unauthenticated reconnect attempt failed or timed out and the /// An unauthenticated reconnect attempt failed or timed out and the
/// receive-only rollback session became the active transport again. /// receive-only rollback session became the active transport again.
/// Transport queues may only be drained for this exact restored /// Transport queues must be drained for this exact restored generation.
/// generation when the reason is terminal; a restore that owns a pending var onSessionRestoredWithGeneration: ((_ peerID: PeerID, _ generation: UUID) -> Void)?
/// convergence retry must keep them parked until the retry concludes.
var onSessionRestoredWithGeneration:
((_ peerID: PeerID, _ generation: UUID, _ reason: NoiseSessionRestoreReason) -> Void)?
// Add a handler for peer authentication // Add a handler for peer authentication
func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) { func addOnPeerAuthenticatedHandler(_ handler: @escaping (PeerID, String) -> Void) {
@@ -348,8 +345,8 @@ final class NoiseEncryptionService {
sessionGeneration: generation sessionGeneration: generation
) )
} }
sessionManager.onSessionRestored = { [weak self] peerID, generation, reason in sessionManager.onSessionRestored = { [weak self] peerID, generation in
self?.onSessionRestoredWithGeneration?(peerID, generation, reason) self?.onSessionRestoredWithGeneration?(peerID, generation)
} }
sessionManager.onHandshakeRecoveryRequired = { [weak self] request in sessionManager.onHandshakeRecoveryRequired = { [weak self] request in
self?.onHandshakeRecoveryRequired?(request) self?.onHandshakeRecoveryRequired?(request)
@@ -9,10 +9,10 @@
import BitLogger import BitLogger
import Foundation import Foundation
/// Disk persistence for processed gift-wrap event IDs. NIP-59 randomizes /// Disk persistence for processed private-envelope event IDs. BitChat
/// gift-wrap timestamps, so DM subscriptions must look back generously (24h) /// randomizes envelope timestamps, so DM subscriptions must look back
/// and relays redeliver the same events on every launch without a /// generously (24h) and relays redeliver the same events on every launch
/// cross-launch record, each relaunch reprocesses old PMs and acks /// without a cross-launch record, each relaunch reprocesses old PMs and acks
/// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise). /// (re-sent DELIVERED bursts, "delivered ack for unknown mid" noise).
/// ///
/// Contents are event IDs already visible to every relay, so /// 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 favoriteStatusForNoiseKey: @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?
let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship? let favoriteStatusForPeerID: @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
let currentIdentity: @MainActor () throws -> NostrIdentity? let currentIdentity: @MainActor () throws -> NostrIdentity?
let registerPendingGiftWrap: @MainActor (String) -> Void let registerPendingPrivateEnvelope: @MainActor (String) -> Void
let sendEvent: @MainActor (NostrEvent) -> Void let sendPrivateEnvelopeBatch: @MainActor (
[NostrEvent],
@escaping @MainActor () -> Void
) -> Bool
let envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue
/// Emits whether a relay that carries private messages is up /// Emits whether a relay that carries private messages is up
/// (fail-closed behind Tor). A connected geohash/custom relay alone /// (fail-closed behind Tor). A connected geohash/custom relay alone
/// doesn't count: DM sends target the default relay set and would /// 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. /// serialize behind each other; `live` passes the process-wide one.
let ackPacer: AckPacer let ackPacer: AckPacer
@MainActor
init( init(
notificationCenter: NotificationCenter, notificationCenter: NotificationCenter,
loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship], loadFavorites: @escaping @MainActor () -> [Data: FavoritesPersistenceService.FavoriteRelationship],
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?, favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoritesPersistenceService.FavoriteRelationship?,
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?, favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoritesPersistenceService.FavoriteRelationship?,
currentIdentity: @escaping @MainActor () throws -> NostrIdentity?, currentIdentity: @escaping @MainActor () throws -> NostrIdentity?,
registerPendingGiftWrap: @escaping @MainActor (String) -> Void, registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void,
sendEvent: @escaping @MainActor (NostrEvent) -> Void, sendPrivateEnvelopeBatch: @escaping @MainActor (
[NostrEvent],
@escaping @MainActor () -> Void
) -> Bool,
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void, scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void,
relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never>, relayConnectivity: @escaping @MainActor () -> AnyPublisher<Bool, Never>,
ackPacer: AckPacer? = nil ackPacer: AckPacer? = nil,
envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue? = nil
) { ) {
self.notificationCenter = notificationCenter self.notificationCenter = notificationCenter
self.loadFavorites = loadFavorites self.loadFavorites = loadFavorites
self.favoriteStatusForNoiseKey = favoriteStatusForNoiseKey self.favoriteStatusForNoiseKey = favoriteStatusForNoiseKey
self.favoriteStatusForPeerID = favoriteStatusForPeerID self.favoriteStatusForPeerID = favoriteStatusForPeerID
self.currentIdentity = currentIdentity self.currentIdentity = currentIdentity
self.registerPendingGiftWrap = registerPendingGiftWrap self.registerPendingPrivateEnvelope = registerPendingPrivateEnvelope
self.sendEvent = sendEvent self.sendPrivateEnvelopeBatch = sendPrivateEnvelopeBatch
self.envelopeRetryQueue = envelopeRetryQueue ?? NostrPrivateEnvelopeRetryQueue(
sendPrivateEnvelopeBatch: sendPrivateEnvelopeBatch,
registerPendingPrivateEnvelope: registerPendingPrivateEnvelope,
scheduleAfter: scheduleAfter
)
self.relayConnectivity = relayConnectivity self.relayConnectivity = relayConnectivity
// Default pacer drives its throttle through the same injected // Default pacer drives its throttle through the same injected
// scheduler, so tests that step scheduleAfter manually keep // scheduler, so tests that step scheduleAfter manually keep
@@ -56,13 +70,19 @@ final class NostrTransport: Transport, @unchecked Sendable {
favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) }, favoriteStatusForNoiseKey: { FavoritesPersistenceService.shared.getFavoriteStatus(for: $0) },
favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) }, favoriteStatusForPeerID: { FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: $0) },
currentIdentity: { try idBridge.getCurrentNostrIdentity() }, currentIdentity: { try idBridge.getCurrentNostrIdentity() },
registerPendingGiftWrap: { NostrRelayManager.registerPendingGiftWrap(id: $0) }, registerPendingPrivateEnvelope: { NostrRelayManager.registerPendingPrivateEnvelope(id: $0) },
sendEvent: { NostrRelayManager.shared.sendEvent($0) }, sendPrivateEnvelopeBatch: { events, terminalFailure in
NostrRelayManager.shared.sendPrivateEnvelopeBatch(
events,
terminalFailure: terminalFailure
)
},
scheduleAfter: { delay, action in scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action) DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
}, },
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() }, 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() 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 dependencies: Dependencies
private let envelopeRetryQueue: NostrPrivateEnvelopeRetryQueue
private var favoriteStatusObserver: NSObjectProtocol? private var favoriteStatusObserver: NSObjectProtocol?
// Reachability Cache (thread-safe) // Reachability Cache (thread-safe)
@@ -145,7 +195,9 @@ final class NostrTransport: Transport, @unchecked Sendable {
idBridge: NostrIdentityBridge, idBridge: NostrIdentityBridge,
dependencies: Dependencies? = nil dependencies: Dependencies? = nil
) { ) {
self.dependencies = dependencies ?? .live(idBridge: idBridge) let resolvedDependencies = dependencies ?? .live(idBridge: idBridge)
self.dependencies = resolvedDependencies
self.envelopeRetryQueue = resolvedDependencies.envelopeRetryQueue
setupObservers() 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() { private func setupObservers() {
favoriteStatusObserver = dependencies.notificationCenter.addObserver( favoriteStatusObserver = dependencies.notificationCenter.addObserver(
forName: .favoriteStatusChanged, forName: .favoriteStatusChanged,
@@ -253,15 +317,49 @@ final class NostrTransport: Transport, @unchecked Sendable {
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
Task { @MainActor in Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID), let failurePolicy = PrivateEnvelopeFailurePolicy.userMessage(
let recipientHex = npubToHex(recipientNpub), messageID: messageID
let senderIdentity = try? dependencies.currentIdentity() else { return } )
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) 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 { guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session) SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
handlePrivateEnvelopeFailure(
events: [],
registerPending: false,
policy: failurePolicy
)
return 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) SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
return 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) // MARK: Geohash DMs (per-geohash identity)
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) { /// Returns true only when the complete migration pair entered the relay
Task { @MainActor in /// delivery queue. GeoDM callers use this synchronous admission result
guard !recipientHex.isEmpty else { return } /// before showing "sent"; deterministic packet/envelope failures must not
SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))", category: .session) /// be hidden behind an unobserved MainActor task.
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else { @MainActor
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session) @discardableResult
return func sendPrivateMessageGeohash(
} content: String,
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true) 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 @MainActor
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) { @discardableResult
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else { private func sendPrivateEnvelope(
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session) content: String,
return 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 { let accepted = dependencies.sendPrivateEnvelopeBatch(events) { [self] in
dependencies.registerPendingGiftWrap(event.id) 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) SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
return 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): case .deliveredDirect(let messageID, let peerID):
guard let recipientNpub = resolveRecipientNpub(for: peerID), guard let recipientNpub = resolveRecipientNpub(for: peerID),
@@ -378,17 +617,40 @@ extension NostrTransport {
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session) SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
return 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): case .deliveredGeohash(let messageID, let recipientHex, let identity):
SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))", category: .session) SecureLogger.debug("GeoDM: send DELIVERED mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return } 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): case .readGeohash(let messageID, let recipientHex, let identity):
SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))", category: .session) SecureLogger.debug("GeoDM: send READ mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return } 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 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)
}
}
}
-8
View File
@@ -108,14 +108,6 @@ protocol PrivateMediaDeletionPersisting: AnyObject {
protectedPayloadRelativePaths: Set<String>, protectedPayloadRelativePaths: Set<String>,
completion: @escaping @MainActor (Bool) -> Void completion: @escaping @MainActor (Bool) -> Void
) )
/// Gated unlink for a LEGACY (non-stable-ID) incoming payload whose
/// bubble was explicitly removed. Implementations delete the file only
/// when its path is not pending delivery and not reserved by any receipt
/// or in-flight deletion transaction; otherwise the file stays for
/// bounded quota cleanup.
@MainActor
func removeLegacyPrivateMediaPayload(relativePath: String)
} }
protocol TransportEventDelegate: AnyObject { protocol TransportEventDelegate: AnyObject {
+28 -23
View File
@@ -100,26 +100,6 @@ enum TransportConfig {
static let nostrMaxEventTags: Int = 64 static let nostrMaxEventTags: Int = 64
static let nostrMaxEventTagValues: Int = 16 static let nostrMaxEventTagValues: Int = 16
static let nostrMaxEventTagValueBytes: Int = 1024 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) // Conversation store diagnostics (field observability)
// Sample interval for the periodic store-audit "OK" heartbeat line // Sample interval for the periodic store-audit "OK" heartbeat line
@@ -215,7 +195,22 @@ enum TransportConfig {
static let nostrGeoRelayCount: Int = 5 static let nostrGeoRelayCount: Int = 5
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300 static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
static let nostrGeohashSampleLimit: Int = 100 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 // A sampled chat message this recent means "a conversation is happening
// there" for the empty-timeline nearby-activity hint. // there" for the empty-timeline nearby-activity hint.
static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900 static let uiGeohashChatActivityWindowSeconds: TimeInterval = 900
@@ -243,11 +238,20 @@ enum TransportConfig {
// Reconnect delays get ±20% random jitter so relays that dropped together // Reconnect delays get ±20% random jitter so relays that dropped together
// (e.g. a network blip) don't thundering-herd the same reconnect instant. // (e.g. a network blip) don't thundering-herd the same reconnect instant.
static let nostrRelayBackoffJitterRatio: Double = 0.2 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 // How many consecutive Tor-readiness waits (each bounded by TorManager's
// bootstrap deadline) to attempt before unblocking pending EOSE callers. // bootstrap deadline) to attempt before unblocking pending EOSE callers.
static let nostrTorReadyMaxWaitAttempts: Int = 3 static let nostrTorReadyMaxWaitAttempts: Int = 3
static let nostrPendingSendQueueCap: Int = 200 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 // Sample interval for the send-queue overflow warning (first + every Nth
// dropped event). Drops are ephemeral presence/geo traffic log-only. // dropped event). Drops are ephemeral presence/geo traffic log-only.
static let nostrPendingSendDropLogInterval: Int = 10 static let nostrPendingSendDropLogInterval: Int = 10
@@ -259,7 +263,7 @@ enum TransportConfig {
// Fallback deadline for treating a subscription's initial fetch as complete // Fallback deadline for treating a subscription's initial fetch as complete
// when a relay never sends EOSE (generous to cover Tor circuit setup). // when a relay never sends EOSE (generous to cover Tor circuit setup).
static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0 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. // not pin the router's in-flight state indefinitely.
static let nostrConfirmedSendAckTimeoutSeconds: TimeInterval = 10.0 static let nostrConfirmedSendAckTimeoutSeconds: TimeInterval = 10.0
// After this long, a relay marked permanently failed gets another chance. // After this long, a relay marked permanently failed gets another chance.
@@ -358,6 +362,7 @@ enum TransportConfig {
// Share extension // Share extension
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0 static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60 static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
// Gossip Sync Configuration // Gossip Sync Configuration
@@ -21,6 +21,14 @@ protocol ChatDeliveryContext: AnyObject {
/// message is unknown or no copy changed. /// message is unknown or no copy changed.
@discardableResult @discardableResult
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool
/// Applies an authenticated receipt only to the direct conversations
/// represented by the supplied peer aliases.
@discardableResult
func setDeliveryStatus(
_ status: DeliveryStatus,
forMessageID messageID: String,
inDirectPeerAliases peerIDs: Set<PeerID>
) -> Bool
/// Current delivery status of the message in whichever conversation holds it. /// Current delivery status of the message in whichever conversation holds it.
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus?
/// Message IDs across all direct conversations (read-receipt pruning). /// Message IDs across all direct conversations (read-receipt pruning).
@@ -33,6 +41,12 @@ protocol ChatDeliveryContext: AnyObject {
func notifyUIChanged() func notifyUIChanged()
/// Confirms receipt so the message router stops retaining the message for resend. /// Confirms receipt so the message router stops retaining the message for resend.
func markMessageDelivered(_ messageID: String) func markMessageDelivered(_ messageID: String)
/// Peer-bound form for authenticated remote receipts. Only the supplied
/// conversation aliases may have retained state terminalized.
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>)
/// Returns true only when `messageID` is one of our outgoing messages in
/// at least one of the authenticated peer's direct-conversation aliases.
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool
} }
extension ChatViewModel: ChatDeliveryContext { extension ChatViewModel: ChatDeliveryContext {
@@ -41,6 +55,19 @@ extension ChatViewModel: ChatDeliveryContext {
conversations.setDeliveryStatus(status, forMessageID: messageID) conversations.setDeliveryStatus(status, forMessageID: messageID)
} }
@discardableResult
func setDeliveryStatus(
_ status: DeliveryStatus,
forMessageID messageID: String,
inDirectPeerAliases peerIDs: Set<PeerID>
) -> Bool {
conversations.setDeliveryStatus(
status,
forMessageID: messageID,
inDirectPeerAliases: peerIDs
)
}
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? { func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
conversations.deliveryStatus(forMessageID: messageID) conversations.deliveryStatus(forMessageID: messageID)
} }
@@ -59,6 +86,24 @@ extension ChatViewModel: ChatDeliveryContext {
messageID: messageID messageID: messageID
) )
} }
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {
messageRouter.markDelivered(messageID, from: peerIDs)
// The caller has already bound this receipt to one of our outgoing
// conversations. Release the matching media retry only after that
// peer-scoped validation and accepted delivery-status transition.
mediaTransferCoordinator.confirmPrivateMediaDelivery(
messageID: messageID
)
}
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool {
peerIDs.contains { peerID in
privateMessages(for: peerID).contains { message in
message.id == messageID && message.senderPeerID == myPeerID
}
}
}
} }
/// Thin mapper from delivery events (read receipts, transport delivery /// Thin mapper from delivery events (read receipts, transport delivery
@@ -86,9 +131,10 @@ final class ChatDeliveryCoordinator {
@MainActor @MainActor
func didReceiveReadReceipt(_ receipt: ReadReceipt) { func didReceiveReadReceipt(_ receipt: ReadReceipt) {
updateMessageDeliveryStatus( updateAcknowledgedMessageDeliveryStatus(
receipt.originalMessageID, receipt.originalMessageID,
status: .read(by: receipt.readerNickname, at: receipt.timestamp) status: .read(by: receipt.readerNickname, at: receipt.timestamp),
from: [receipt.readerID]
) )
} }
@@ -105,17 +151,47 @@ final class ChatDeliveryCoordinator {
@MainActor @MainActor
@discardableResult @discardableResult
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool { func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
guard context.setDeliveryStatus(status, forMessageID: messageID) else {
return false
}
switch status { switch status {
case .delivered, .read: case .delivered, .read:
// Confirmed receipt stop retaining the message for resend. // Terminalize only after the store accepted the transition.
context.markMessageDelivered(messageID) context.markMessageDelivered(messageID)
default: default:
break break
} }
context.notifyUIChanged()
return true
}
guard context.setDeliveryStatus(status, forMessageID: messageID) else { /// Applies an authenticated remote delivery/read receipt only when it
/// belongs to one of our outgoing messages in that peer's conversation.
/// Retry state is cleared after, never before, the status transition is
/// accepted by the store.
@MainActor
@discardableResult
func updateAcknowledgedMessageDeliveryStatus(
_ messageID: String,
status: DeliveryStatus,
from peerIDAliases: Set<PeerID>
) -> Bool {
switch status {
case .delivered, .read:
break
default:
return false return false
} }
guard !peerIDAliases.isEmpty,
context.isOutgoingPrivateMessage(messageID, toAny: peerIDAliases),
context.setDeliveryStatus(
status,
forMessageID: messageID,
inDirectPeerAliases: peerIDAliases
) else {
return false
}
context.markMessageDelivered(messageID, from: peerIDAliases)
context.notifyUIChanged() context.notifyUIChanged()
return true return true
} }
@@ -91,9 +91,6 @@ protocol ChatMediaTransferContext: AnyObject {
func removeUntombstonedMediaMessage(withID messageID: String) func removeUntombstonedMediaMessage(withID messageID: String)
func removeOutgoingMediaMessage(withID messageID: String) func removeOutgoingMediaMessage(withID messageID: String)
func addSystemMessage(_ content: String) func addSystemMessage(_ content: String)
/// Surfaces a refused explicit media deletion in the affected chat so a
/// wedged delete never looks like success.
func notifyMediaDeletionRefused(messageID: String)
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`). /// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged() func notifyUIChanged()
@@ -229,51 +226,18 @@ extension ChatViewModel: ChatMediaTransferContext {
let message = conversations.conversationsByID.values.lazy let message = conversations.conversationsByID.values.lazy
.flatMap(\.messages) .flatMap(\.messages)
.first { $0.id == messageID } .first { $0.id == messageID }
if let message, !isIncomingPrivateMessage(message) { if let message {
mediaTransferCoordinator.cleanupOutgoingLocalFile( if !isIncomingPrivateMessage(message) {
forMessage: message mediaTransferCoordinator.cleanupOutgoingLocalFile(
) forMessage: message
)
}
// Legacy/raw incoming media has no durable ID-to-file ownership.
// Its basename may already belong to a pending newer arrival, so
// leave the payload for bounded quota cleanup rather than unlink
// an ambiguous path.
} }
removeMessage(withID: messageID, cleanupFile: false) removeMessage(withID: messageID, cleanupFile: false)
if let message {
cleanupLegacyIncomingMediaPayloads(for: [message])
}
}
/// Explicitly deleted LEGACY (non-stable-ID) incoming media has no
/// durable ID-to-file ownership, so the actual unlink is delegated to
/// the transport's gated cleanup: a basename that is pending delivery or
/// reserved by a receipt/deletion transaction stays on disk for bounded
/// quota cleanup instead. Must run after the bubbles were removed; a
/// surviving reference in any conversation keeps the payload.
func cleanupLegacyIncomingMediaPayloads(for messages: [BitchatMessage]) {
guard let cleanup =
meshService as? any PrivateMediaDeletionPersisting else {
return
}
let legacyPaths = Set(messages.compactMap { message -> String? in
guard !PrivateMediaMessageIdentity.isStableID(message.id),
isIncomingPrivateMessage(message) else {
return nil
}
return incomingMediaRelativePath(for: message)
})
guard !legacyPaths.isEmpty else { return }
let survivingPaths = Set(
conversations.conversationsByID.values.lazy
.flatMap(\.messages)
.compactMap { message -> String? in
guard self.isIncomingPrivateMessage(message) else {
return nil
}
return self.incomingMediaRelativePath(for: message)
}
)
for relativePath in legacyPaths.subtracting(survivingPaths).sorted() {
cleanup.removeLegacyPrivateMediaPayload(
relativePath: relativePath
)
}
} }
func removeOutgoingMediaMessage(withID messageID: String) { func removeOutgoingMediaMessage(withID messageID: String) {
@@ -353,28 +317,6 @@ extension ChatViewModel: ChatMediaTransferContext {
} }
} }
func notifyMediaDeletionRefused(messageID: String) {
let owningPeerID = privateChats.first { _, messages in
messages.contains { $0.id == messageID }
}?.key
notifyPrivateMediaDeletionRefused(peerID: owningPeerID)
}
/// A refused deletion/clear previously surfaced only in SecureLogger, so
/// a wedged /clear looked like success. Tell the affected chat that its
/// bubbles and payloads were intentionally kept.
func notifyPrivateMediaDeletionRefused(peerID: PeerID?) {
let copy = String(
localized: "content.system.media_delete_refused",
comment: "System message when an explicit media delete or /clear was refused and bubbles/files were kept"
)
if let peerID = peerID ?? selectedPrivateChatPeer {
addLocalPrivateSystemMessage(copy, to: peerID)
} else {
addSystemMessage(copy)
}
}
private func isIncomingPrivateMessage( private func isIncomingPrivateMessage(
_ message: BitchatMessage _ message: BitchatMessage
) -> Bool { ) -> Bool {
@@ -1201,9 +1143,6 @@ final class ChatMediaTransferCoordinator {
"Refusing to delete private media without a durable tombstone id=\(messageID.prefix(12))", "Refusing to delete private media without a durable tombstone id=\(messageID.prefix(12))",
category: .session category: .session
) )
self.context.notifyMediaDeletionRefused(
messageID: messageID
)
return return
} }
self.finishMediaDeletion( self.finishMediaDeletion(
@@ -1263,16 +1202,6 @@ final class ChatMediaTransferCoordinator {
) )
} }
/// A policy resolution's completion can be dropped entirely when the
/// transport tears down mid-flight (BLEService's queue guards on a
/// deallocated self), which would leave the pending entry blocking every
/// future resolution for this peer. Disconnection invalidates the
/// resolution's premise anyway, so drop it; retained records stay and the
/// next reconnect starts a fresh resolution.
func peerDidDisconnect(_ peerID: PeerID) {
peersResolvingReconnectRetry.removeValue(forKey: peerID.toShort())
}
/// Local fragment completion is not proof that the recipient reconstructed /// Local fragment completion is not proof that the recipient reconstructed
/// the file. Only a remote delivery/read receipt releases retry ownership. /// the file. Only a remote delivery/read receipt releases retry ownership.
func confirmPrivateMediaDelivery(messageID: String) { func confirmPrivateMediaDelivery(messageID: String) {
@@ -91,7 +91,13 @@ protocol ChatPrivateConversationContext: AnyObject {
@discardableResult @discardableResult
func markMessageDelivered(_ messageID: String, for peerIDs: [PeerID]) -> Bool func markMessageDelivered(_ messageID: String, for peerIDs: [PeerID]) -> Bool
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) 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 sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
@@ -184,7 +190,13 @@ extension ChatViewModel: ChatPrivateConversationContext {
meshService.sendReadReceipt(receipt, to: peerID) 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( makeGeohashNostrTransport().sendPrivateMessageGeohash(
content: content, content: content,
toRecipientHex: recipientHex, toRecipientHex: recipientHex,
@@ -226,9 +238,16 @@ extension ChatViewModel: ChatPrivateConversationContext {
NotificationService.shared.sendPrivateMessageNotification(from: senderName, message: message, peerID: peerID) NotificationService.shared.sendPrivateMessageNotification(from: senderName, message: message, peerID: peerID)
} }
private func makeGeohashNostrTransport() -> NostrTransport { func makeGeohashNostrTransport(
let transport = NostrTransport(keychain: keychain, idBridge: idBridge) dependencies: NostrTransport.Dependencies? = nil
) -> NostrTransport {
let transport = NostrTransport(
keychain: keychain,
idBridge: idBridge,
dependencies: dependencies
)
transport.senderPeerID = meshService.myPeerID transport.senderPeerID = meshService.myPeerID
transport.eventDelegate = self
return transport return transport
} }
} }
@@ -237,7 +256,7 @@ extension ChatViewModel: ChatPrivateConversationContext {
final class ChatPrivateConversationCoordinator { final class ChatPrivateConversationCoordinator {
private unowned let context: any ChatPrivateConversationContext 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 // relay-level event-ID dedup can't catch them; track inbound GeoDM
// message IDs so each copy past the first costs one (already-deduped) // message IDs so each copy past the first costs one (already-deduped)
// ack check and nothing else. // ack check and nothing else.
@@ -463,13 +482,21 @@ final class ChatPrivateConversationCoordinator {
"GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)", "GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)",
category: .session category: .session
) )
context.sendGeohashPrivateMessage( let accepted = context.sendGeohashPrivateMessage(
content, content,
toRecipientHex: recipientHex, toRecipientHex: recipientHex,
from: identity, from: identity,
messageID: messageID 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 { } catch {
context.setPrivateDeliveryStatus( context.setPrivateDeliveryStatus(
.failed( .failed(
@@ -62,10 +62,15 @@ protocol ChatTransportEventContext: AnyObject {
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID)
// MARK: Delivery status // MARK: Delivery status
/// Applies the status to every known location of the message. /// Applies an authenticated receipt to the message only when it belongs
/// Returns `false` when no message with that ID was updated. /// to the supplied peer conversation aliases. Returns `false` for an
/// unknown ID, wrong peer, or rejected status transition.
@discardableResult @discardableResult
func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool func applyAcknowledgedMessageDeliveryStatus(
_ messageID: String,
status: DeliveryStatus,
from peerIDAliases: Set<PeerID>
) -> Bool
func deliveryStatus(for messageID: String) -> DeliveryStatus? func deliveryStatus(for messageID: String) -> DeliveryStatus?
// MARK: Verification payloads // MARK: Verification payloads
@@ -122,8 +127,16 @@ extension ChatViewModel: ChatTransportEventContext {
} }
@discardableResult @discardableResult
func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool { func applyAcknowledgedMessageDeliveryStatus(
deliveryCoordinator.updateMessageDeliveryStatus(messageID, status: status) _ messageID: String,
status: DeliveryStatus,
from peerIDAliases: Set<PeerID>
) -> Bool {
deliveryCoordinator.updateAcknowledgedMessageDeliveryStatus(
messageID,
status: status,
from: peerIDAliases
)
} }
func deliveryStatus(for messageID: String) -> DeliveryStatus? { func deliveryStatus(for messageID: String) -> DeliveryStatus? {
@@ -446,9 +459,10 @@ private extension ChatTransportEventCoordinator {
guard let messageID = String(data: payload, encoding: .utf8) else { return } guard let messageID = String(data: payload, encoding: .utf8) else { return }
let name = deliveryStatusName(for: peerID, in: context) let name = deliveryStatusName(for: peerID, in: context)
let didUpdate = context.applyMessageDeliveryStatus( let didUpdate = context.applyAcknowledgedMessageDeliveryStatus(
messageID, messageID,
status: .delivered(to: name, at: Date()) status: .delivered(to: name, at: Date()),
from: receiptPeerAliases(for: peerID, in: context)
) )
if !didUpdate { if !didUpdate {
@@ -463,9 +477,10 @@ private extension ChatTransportEventCoordinator {
guard let messageID = String(data: payload, encoding: .utf8) else { return } guard let messageID = String(data: payload, encoding: .utf8) else { return }
let name = deliveryStatusName(for: peerID, in: context) let name = deliveryStatusName(for: peerID, in: context)
let didUpdate = context.applyMessageDeliveryStatus( let didUpdate = context.applyAcknowledgedMessageDeliveryStatus(
messageID, messageID,
status: .read(by: name, at: Date()) status: .read(by: name, at: Date()),
from: receiptPeerAliases(for: peerID, in: context)
) )
if !didUpdate { if !didUpdate {
@@ -503,4 +518,21 @@ private extension ChatTransportEventCoordinator {
func deliveryStatusName(for peerID: PeerID, in context: any ChatTransportEventContext) -> String { func deliveryStatusName(for peerID: PeerID, in context: any ChatTransportEventContext) -> String {
context.unifiedPeer(for: peerID)?.nickname ?? context.resolveNickname(for: peerID) context.unifiedPeer(for: peerID)?.nickname ?? context.resolveNickname(for: peerID)
} }
@MainActor
func receiptPeerAliases(
for peerID: PeerID,
in context: any ChatTransportEventContext
) -> Set<PeerID> {
var aliases: Set<PeerID> = [peerID]
// The active authenticated Noise key is authoritative. A cached
// ephemeralstable mapping can predate an identity replacement, so
// use it only when the live session cannot provide its static key.
if let keyData = context.noiseSessionPublicKeyData(for: peerID) {
aliases.insert(PeerID(hexData: keyData))
} else if let stablePeerID = context.cachedStablePeerID(for: peerID) {
aliases.insert(stablePeerID)
}
return aliases
}
} }
@@ -59,6 +59,10 @@ protocol ChatVerificationContext: AnyObject {
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool
func triggerHandshake(with peerID: PeerID) func triggerHandshake(with peerID: PeerID)
func privateMediaPeerDidAuthenticate(_ peerID: PeerID) func privateMediaPeerDidAuthenticate(_ peerID: PeerID)
/// Retries only private messages previously transmitted through a secure
/// session and still pending an ack. Both ephemeral and stable aliases
/// are supplied because either can own the outbox entry.
func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID])
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
@@ -121,6 +125,10 @@ extension ChatViewModel: ChatVerificationContext {
mediaTransferCoordinator.peerDidAuthenticate(peerID.toShort()) mediaTransferCoordinator.peerDidAuthenticate(peerID.toShort())
} }
func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) {
messageRouter.retrySecurePrivateMessagesAfterAuthentication(for: peerIDAliases)
}
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA) meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
} }
@@ -216,16 +224,37 @@ final class ChatVerificationCoordinator {
self.context.invalidateEncryptionCache(for: peerID) self.context.invalidateEncryptionCache(for: peerID)
if self.context.cachedStablePeerID(for: peerID) == nil, var authenticatedStablePeerID: PeerID?
let keyData = self.context.noiseSessionPublicKeyData(for: peerID) { if let keyData = self.context.noiseSessionPublicKeyData(for: peerID) {
let stablePeerID = PeerID(hexData: keyData) let stablePeerID = PeerID(hexData: keyData)
self.context.cacheStablePeerID(stablePeerID, for: peerID) authenticatedStablePeerID = stablePeerID
if self.context.cachedStablePeerID(for: peerID) != stablePeerID {
// The freshly authenticated Noise key outranks a
// stale announce-derived alias.
self.context.cacheStablePeerID(stablePeerID, for: peerID)
}
SecureLogger.debug( SecureLogger.debug(
"🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))", "🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))",
category: .session category: .session
) )
} }
// A locally established session may have belonged to the
// peer's previous app process. The first ciphertext sent
// into that stale session is retained by MessageRouter;
// retry it now that this newly authenticated/replacement
// session can actually decrypt it.
var peerIDAliases = [peerID]
if let stablePeerID = authenticatedStablePeerID
?? self.context.cachedStablePeerID(for: peerID),
stablePeerID != peerID {
// Conversations can migrate from the ephemeral BLE ID
// to the authenticated Noise-key ID. Retry both aliases
// because either may own the retained outbox entry.
peerIDAliases.append(stablePeerID)
}
self.context.retrySecurePrivateMessagesAfterAuthentication(for: peerIDAliases)
if var pending = self.pendingQRVerifications[peerID], pending.sent == false { if var pending = self.pendingQRVerifications[peerID], pending.sent == false {
self.context.sendVerifyChallenge( self.context.sendVerifyChallenge(
to: peerID, to: peerID,
+7 -38
View File
@@ -774,6 +774,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
} }
let outgoingMedia = request.outgoingMedia let outgoingMedia = request.outgoingMedia
let outgoingMediaIDs = Set(outgoingMedia.map(\.id))
let capturedExclusiveIDs = let capturedExclusiveIDs =
capturedMessageIDs.subtracting(survivingMessageIDs) capturedMessageIDs.subtracting(survivingMessageIDs)
@@ -837,7 +838,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
"Refusing to clear private chat without durable media tombstones peer=\(peerID.id.prefix(8))", "Refusing to clear private chat without durable media tombstones peer=\(peerID.id.prefix(8))",
category: .session category: .session
) )
notifyPrivateMediaDeletionRefused(peerID: peerID)
completion() completion()
return return
} }
@@ -877,24 +877,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
} }
} }
// Outgoing media mirrors the incoming alias protection: an ID for message in outgoingMedia {
// whose copy survives in a conversation this clear does not
// touch (identity-alias handoff) keeps that bubble and its local
// file. Only IDs with no surviving copy are removed from every
// direct conversation and have their payload unlinked.
let outgoingPlan = currentRemovalPlan()
let removableOutgoingMedia = outgoingMedia.filter {
!hasRemainingCopy(of: $0.id, after: outgoingPlan)
}
for message in removableOutgoingMedia {
mediaTransferCoordinator.cleanupOutgoingLocalFile( mediaTransferCoordinator.cleanupOutgoingLocalFile(
forMessage: message forMessage: message
) )
} }
let removableOutgoingIDs = Set( if !outgoingMediaIDs.isEmpty {
removableOutgoingMedia.map(\.id)
)
if !removableOutgoingIDs.isEmpty {
let directConversationIDs = conversations let directConversationIDs = conversations
.conversationsByID.keys.filter { .conversationsByID.keys.filter {
if case .direct = $0 { return true } if case .direct = $0 { return true }
@@ -902,16 +890,15 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
} }
for conversationID in directConversationIDs { for conversationID in directConversationIDs {
conversations.removeMessages(from: conversationID) { conversations.removeMessages(from: conversationID) {
removableOutgoingIDs.contains($0.id) outgoingMediaIDs.contains($0.id)
} }
} }
} }
// Stable payload cleanup belongs entirely to the durable receiver // Stable payload cleanup belongs entirely to the durable receiver
// journal. Legacy/raw incoming payloads have no durable identity, // journal. Legacy/raw incoming payloads have no durable identity,
// so once their bubbles are gone the transport's gated cleanup // so their basenames may already belong to a pending new arrival;
// decides per basename: unlink when unreferenced, or leave any // leave those files for bounded quota cleanup.
// pending/reserved path for bounded quota cleanup.
let finalPlan = currentRemovalPlan() let finalPlan = currentRemovalPlan()
for (conversationID, messageIDs) in finalPlan { for (conversationID, messageIDs) in finalPlan {
@@ -919,7 +906,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
messageIDs.contains($0.id) messageIDs.contains($0.id)
} }
} }
cleanupLegacyIncomingMediaPayloads(for: capturedIncomingMedia)
completion() completion()
} }
@@ -1639,15 +1625,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
// posts are signed with our identity key and persist for days. // posts are signed with our identity key and persist for days.
BoardStore.shared.wipe() 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 // Identity manager has cleared persisted identity data above
// Clear autocomplete state // Clear autocomplete state
@@ -1677,12 +1654,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
// Drop relay subscriptions, handlers, pending sends, and replay state. // Drop relay subscriptions, handlers, pending sends, and replay state.
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain // Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
// disconnect is not enough here. // disconnect is not enough here.
NostrTransport.resetControlRetriesForPanicWipe()
NostrRelayManager.shared.resetForPanicWipe() 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 nostrRelayManager = nil
// Clear Nostr identity associations // Clear Nostr identity associations
@@ -2069,7 +2042,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
case .peerDisconnected(let peerID): case .peerDisconnected(let peerID):
transportEventCoordinator.didDisconnectFromPeerSynchronously(peerID) transportEventCoordinator.didDisconnectFromPeerSynchronously(peerID)
mediaTransferCoordinator.peerDidDisconnect(peerID)
case .peerListUpdated(let peers): case .peerListUpdated(let peers):
peerListCoordinator.didUpdatePeerListSynchronously(peers) peerListCoordinator.didUpdatePeerListSynchronously(peers)
@@ -2163,9 +2135,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
func didDisconnectFromPeer(_ peerID: PeerID) { func didDisconnectFromPeer(_ peerID: PeerID) {
transportEventCoordinator.didDisconnectFromPeer(peerID) transportEventCoordinator.didDisconnectFromPeer(peerID)
Task { @MainActor [weak self] in
self?.mediaTransferCoordinator.peerDidDisconnect(peerID)
}
} }
func didUpdatePeerList(_ peers: [PeerID]) { func didUpdatePeerList(_ peers: [PeerID]) {
@@ -41,10 +41,11 @@ struct ChatViewModelServiceBundle {
self.privateChatManager = privateChatManager self.privateChatManager = privateChatManager
self.unifiedPeerService = unifiedPeerService self.unifiedPeerService = unifiedPeerService
self.autocompleteService = AutocompleteService() self.autocompleteService = AutocompleteService()
// Persist processed gift-wrap event IDs: NIP-59 randomizes their // Persist processed private-envelope event IDs: legacy Android can
// timestamps, so the 24h-lookback DM subscriptions redeliver the same // randomize timestamps across the full 72h15m mailbox lookback, so DM
// events on every launch and only a cross-launch record stops the // subscriptions redeliver the same events on every launch and only a
// reprocessing (re-sent DELIVERED bursts, phantom-ack noise). // cross-launch record stops the reprocessing (re-sent DELIVERED
// bursts, phantom-ack noise).
self.deduplicationService = MessageDeduplicationService(nostrEventStore: NostrProcessedEventStore()) self.deduplicationService = MessageDeduplicationService(nostrEventStore: NostrProcessedEventStore())
self.publicMessagePipeline = PublicMessagePipeline() self.publicMessagePipeline = PublicMessagePipeline()
} }
@@ -176,7 +177,7 @@ private extension ChatViewModelBootstrapper {
func configureTransport() { func configureTransport() {
viewModel.meshService.delegate = viewModel viewModel.meshService.delegate = viewModel
viewModel.meshService.eventDelegate = viewModel viewModel.messageRouter.setEventDelegate(viewModel)
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak viewModel] in DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiStartupInitialDelaySeconds) { [weak viewModel] in
guard let viewModel else { return } guard let viewModel else { return }
@@ -558,7 +559,7 @@ private extension ChatViewModelBootstrapper {
// Default (DM) relays: drops need the standing global relay set, // Default (DM) relays: drops need the standing global relay set,
// not geo relays sender and recipient share no cell. // not geo relays sender and recipient share no cell.
// This confirmed path never falls back to the volatile relay // 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) NostrRelayManager.shared.sendEventImmediately(event, completion: completion)
} }
courier.openSubscription = { tagsHex in courier.openSubscription = { tagsHex in
@@ -21,8 +21,8 @@ extension ChatViewModel {
} }
@MainActor @MainActor
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { func subscribePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
nostrCoordinator.inbound.subscribeGiftWrap(giftWrap, id: id) nostrCoordinator.inbound.subscribePrivateEnvelope(envelope, id: id)
} }
@MainActor @MainActor
@@ -36,8 +36,8 @@ extension ChatViewModel {
} }
@MainActor @MainActor
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { func handlePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
nostrCoordinator.inbound.handleGiftWrap(giftWrap, id: id) nostrCoordinator.inbound.handlePrivateEnvelope(envelope, id: id)
} }
@MainActor @MainActor
+1 -2
View File
@@ -103,8 +103,7 @@ final class GeoPresenceTracker {
else { else {
return return
} }
// The signature was already verified (exactly once, off the main guard event.isValidSignature() else { return }
// actor) by NostrRelayManager before delivery.
guard shouldProcessGeoSamplingEvent(event.id) else { return } guard shouldProcessGeoSamplingEvent(event.id) else { return }
let existingCount = context.geoParticipantCount(for: gh) let existingCount = context.geoParticipantCount(for: gh)
@@ -108,7 +108,7 @@ extension ChatViewModel: GeohashSubscriptionContext {
} }
/// Owns subscription IDs and relay lifecycle for geohash channels, geohash /// 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 /// only component that talks to `NostrRelayManager`; inbound events are
/// forwarded to `NostrInboundPipeline` / `GeoPresenceTracker`. /// forwarded to `NostrInboundPipeline` / `GeoPresenceTracker`.
final class GeohashSubscriptionManager { final class GeohashSubscriptionManager {
@@ -162,13 +162,13 @@ final class GeohashSubscriptionManager {
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) { if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
let dmSub = "geo-dm-\(channel.geohash)" let dmSub = "geo-dm-\(channel.geohash)"
context.setGeoDmSubscriptionID(dmSub) context.setGeoDmSubscriptionID(dmSub)
let dmFilter = NostrFilter.giftWrapsFor( let dmFilters = NostrFilter.privateEnvelopeFiltersFor(
pubkey: identity.publicKeyHex, pubkey: identity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) 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 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 { if TorManager.shared.isReady {
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session) 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, pubkey: identity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) 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 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 category: .session
) )
let filter = NostrFilter.giftWrapsFor( let filters = NostrFilter.privateEnvelopeFiltersFor(
pubkey: currentIdentity.publicKeyHex, pubkey: currentIdentity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds) 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 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 / /// The inbound Nostr hot path: raw relay events in, chat messages / Noise
/// Noise payloads out. Pure transformation plus dedup no relay lifecycle. /// payloads out. Pure transformation plus dedup no relay lifecycle.
/// ///
/// Every event arriving here already had its Schnorr signature verified /// Ordering is deliberate and performance-critical: cheap rejects (kind,
/// exactly once, off the main actor, by `NostrRelayManager`'s serial inbound /// dedup lookup) run BEFORE Schnorr signature verification because duplicates
/// pipeline (which records events into its own dedup cache only AFTER /// dominate real relay traffic; events are recorded only AFTER verification so
/// verification, so forged copies can't suppress genuine events). This /// a forged-signature copy can never poison the dedup set; private-envelope
/// pipeline therefore never re-verifies; it keeps its own event-ID dedup /// verification for the account mailbox runs off-main with an atomic
/// (cheap main-actor lookups) and moves NIP-17 gift-wrap decryption two /// main-actor check-and-record.
/// ECDH+ChaCha layers off the main actor with an atomic main-actor
/// check-and-record.
final class NostrInboundPipeline { final class NostrInboundPipeline {
private weak var context: (any NostrInboundPipelineContext)? private weak var context: (any NostrInboundPipelineContext)?
private let presence: GeoPresenceTracker private let presence: GeoPresenceTracker
private var geoEventLogCount = 0 private var geoEventLogCount = 0
// During the coordinated wire-format migration, one logical private
/// Monotonic panic-wipe generation for this pipeline. A panic wipe clears // payload is published under both primary and compatibility formats. Outer
/// relay handlers so no NEW events flow, but a detached decrypt task // event IDs differ, so collapse the authenticated embedded payload before
/// spawned just BEFORE the wipe which strongly captures a pre-wipe Nostr // invoking message/ack side effects. Keep this bounded like the outer-ID
/// private key and ciphertext survives it. Spawn sites capture this // caches; the recipient and authenticated sender are part of the key.
/// value; the task compares it at its main-actor hops and drops its result private var recentPrivatePayloadFormats: [String: UInt8] = [:]
/// (no delivery; the captured identity and plaintext die with the task) private var recentPrivatePayloadKeyOrder: [String] = []
/// if `invalidateInFlightDecrypts()` bumped it in between. private static let privatePayloadDedupCapacity = 2_048
@MainActor private(set) var wipeGeneration: UInt64 = 0
/// Called from `ChatViewModel.panicClearAllData()` so plaintext decrypted
/// with pre-wipe keys can never land in post-wipe state.
@MainActor
func invalidateInFlightDecrypts() {
wipeGeneration &+= 1
}
init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) { init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) {
self.context = context self.context = context
@@ -118,15 +108,17 @@ final class NostrInboundPipeline {
@MainActor @MainActor
func subscribeNostrEvent(_ event: NostrEvent) { func subscribeNostrEvent(_ event: NostrEvent) {
guard let context else { return } guard let context else { return }
// Cheap rejects (kind, dedup lookup) duplicates dominate real // Cheap rejects (kind, dedup lookup) before Schnorr verification
// traffic. The signature was already verified (exactly once, off the // duplicates dominate real traffic and must not pay for crypto.
// main actor) by NostrRelayManager before delivery. // 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 guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue), || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
!context.hasProcessedNostrEvent(event.id) !context.hasProcessedNostrEvent(event.id)
else { else {
return return
} }
guard event.isValidSignature() else { return }
context.recordProcessedNostrEvent(event.id) context.recordProcessedNostrEvent(event.id)
@@ -196,14 +188,15 @@ final class NostrInboundPipeline {
@MainActor @MainActor
func handleNostrEvent(_ event: NostrEvent) { func handleNostrEvent(_ event: NostrEvent) {
guard let context else { return } guard let context else { return }
// Cheap rejects (kind, dedup lookup) the signature was already // Cheap rejects (kind, dedup lookup) before Schnorr verification
// verified (exactly once, off the main actor) by NostrRelayManager. // duplicates dominate real traffic and must not pay for crypto.
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) || event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
else { else {
return return
} }
if context.hasProcessedNostrEvent(event.id) { return } if context.hasProcessedNostrEvent(event.id) { return }
guard event.isValidSignature() else { return }
context.recordProcessedNostrEvent(event.id) context.recordProcessedNostrEvent(event.id)
let powBits = NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags) let powBits = NostrPoW.validatedDifficulty(idHex: event.id, tags: event.tags)
@@ -286,162 +279,155 @@ final class NostrInboundPipeline {
} }
@MainActor @MainActor
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { func subscribePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
guard let context else { return } guard let context else { return }
// Cheap dedup pre-check only; processGeohashGiftWrap does the // Dedup lookup before Schnorr verification; record only after it passes.
// authoritative main-actor check-and-record before the off-main guard !context.hasProcessedNostrEvent(envelope.id) else { return }
// NIP-17 unwrap. The outer signature was already verified (exactly guard envelope.content.utf8.count <= NostrProtocol.maximumPrivateEnvelopeCiphertextBytes else { return }
// once, off the main actor) by NostrRelayManager. guard envelope.isValidSignature() else { return }
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return } context.recordProcessedNostrEvent(envelope.id)
// Capture the wipe generation at spawn, alongside the per-geohash guard let (content, senderPubkey, messageTs) = try? NostrProtocol.decryptPrivateEnvelope(
// identity (private key) the detached task strongly captures. A panic envelope: envelope,
// wipe between spawn and delivery bumps the generation, and the task recipientIdentity: id
// drops its result instead of delivering plaintext post-wipe. ),
let wipeGeneration = self.wipeGeneration let packet = Self.decodeEmbeddedBitChatPacket(from: content),
Task.detached(priority: .userInitiated) { [weak self] in packet.type == MessageType.noiseEncrypted.rawValue,
await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: false, wipeGeneration: wipeGeneration) 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 @MainActor
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) { func handlePrivateEnvelope(_ envelope: NostrEvent, id: NostrIdentity) {
guard let context else { return } guard let context else { return }
// Cheap dedup pre-check only; see subscribeGiftWrap. // Dedup lookup before Schnorr verification; record only after it passes.
if context.hasProcessedNostrEvent(giftWrap.id) { if context.hasProcessedNostrEvent(envelope.id) {
return 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. guard let (content, senderPubkey, messageTs) = try? NostrProtocol.decryptPrivateEnvelope(
let wipeGeneration = self.wipeGeneration envelope: envelope,
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,
recipientIdentity: id recipientIdentity: id
) else { ) else {
if verbose { SecureLogger.warning("GeoDM: failed decrypt private envelope id=\(envelope.id.prefix(8))", category: .session)
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))", category: .session)
}
return return
} }
if verbose { SecureLogger.debug(
SecureLogger.debug( "GeoDM: decrypted private envelope id=\(envelope.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", category: .session
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 { let convKey = PeerID(nostr_: senderPubkey)
// A panic wipe during the off-main decrypt must not let the context.registerNostrKeyMapping(senderPubkey, for: convKey)
// 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) switch payload.type {
context.registerNostrKeyMapping(senderPubkey, for: convKey) case .privateMessage:
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(messageTs))
switch payload.type { context.handlePrivateMessage(
case .privateMessage: payload,
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) senderPubkey: senderPubkey,
context.handlePrivateMessage( convKey: convKey,
payload, id: id,
senderPubkey: senderPubkey, messageTimestamp: messageTimestamp
convKey: convKey, )
id: id, case .delivered:
messageTimestamp: messageTimestamp context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
) case .readReceipt:
case .delivered: context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey) // Group state travels only over mesh Noise sessions in v1; anything
case .readReceipt: // claiming to be group traffic over Nostr is ignored.
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey) // Live voice is mesh-only: latency and relay cost make it
// Group state travels only over mesh Noise sessions in v1; anything // meaningless over Nostr.
// claiming to be group traffic over Nostr is ignored. case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
// Live voice is mesh-only: latency and relay cost make it break
// meaningless over Nostr.
case .verifyChallenge, .verifyResponse, .groupInvite, .groupKeyUpdate, .vouch, .voiceFrame, .privateFile, .authenticatedPeerState:
break
}
} }
} }
@MainActor @MainActor
func handleNostrMessage(_ giftWrap: NostrEvent) { func handleAccountPrivateEnvelope(_ envelope: NostrEvent) {
guard let context else { return } guard let context else { return }
// Cheap dedup pre-check only; processNostrMessage does the // Cheap dedup pre-check only; Schnorr verification runs off-main in
// authoritative check-and-record before the off-main NIP-17 unwrap. // processAccountPrivateEnvelope, which then does the authoritative
// The outer signature was already verified (exactly once, off the // check-and-record. Recording stays after verification so a
// main actor) by NostrRelayManager, and only verified events are // forged-signature copy can never poison the dedup set and suppress
// recorded, so a forged-signature copy can never poison the dedup // the genuine event.
// set and suppress the genuine event. if context.hasProcessedNostrEvent(envelope.id) { return }
if context.hasProcessedNostrEvent(giftWrap.id) { return }
Task.detached(priority: .userInitiated) { [weak self] in 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 } guard let context else { return }
// Authoritative check-and-record, atomic on the main actor so two // Authoritative check-and-record, atomic on the main actor so two
// concurrent detached tasks can't both process the same event. // concurrent detached tasks can't both process the same event.
let alreadyProcessed: Bool = await MainActor.run { let alreadyProcessed: Bool = await MainActor.run {
if context.hasProcessedNostrEvent(giftWrap.id) { return true } if context.hasProcessedNostrEvent(envelope.id) { return true }
context.recordProcessedNostrEvent(giftWrap.id) context.recordProcessedNostrEvent(envelope.id)
return false return false
} }
if alreadyProcessed { return } if alreadyProcessed { return }
// Fetch the identity and the wipe generation in ONE main-actor hop: let currentIdentity: NostrIdentity? = await MainActor.run {
// the generation then vouches for exactly this identity. A wipe after context.currentNostrIdentity()
// 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)
} }
guard let currentIdentity else { return } guard let currentIdentity else { return }
do { do {
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage( let (content, senderPubkey, messageTimestampSeconds) = try NostrProtocol.decryptPrivateEnvelope(
giftWrap: giftWrap, envelope: envelope,
recipientIdentity: currentIdentity recipientIdentity: currentIdentity
) )
@@ -465,11 +451,14 @@ final class NostrInboundPipeline {
if packet.type == MessageType.noiseEncrypted.rawValue, if packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload) { let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp)) let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(messageTimestampSeconds))
await MainActor.run { await MainActor.run {
// Drop pre-wipe plaintext if a panic wipe landed guard self.shouldProcessPrivatePayload(
// during the off-main decrypt (see above). payload,
guard self.wipeGeneration == wipeGeneration else { return } senderPubkey: senderPubkey,
recipientPubkey: currentIdentity.publicKeyHex,
envelopeKind: envelope.kind
) else { return }
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID) context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
switch payload.type { switch payload.type {
@@ -543,6 +532,47 @@ final class NostrInboundPipeline {
} }
private extension 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 @MainActor
static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? { static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
guard content.hasPrefix("bitchat1:") else { return nil } guard content.hasPrefix("bitchat1:") else { return nil }
+4 -50
View File
@@ -10,14 +10,12 @@ struct ContentPeopleSheetModalPresentationState {
var isImagePreviewPresented = false var isImagePreviewPresented = false
var isVerificationSheetPresented = false var isVerificationSheetPresented = false
var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest? = nil var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest? = nil
var isVoiceAlertPresented = false
var isMediaPickerPresented = false var isMediaPickerPresented = false
var hasPresentation: Bool { var hasPresentation: Bool {
isImagePreviewPresented isImagePreviewPresented
|| isVerificationSheetPresented || isVerificationSheetPresented
|| legacyPrivateMediaConsentRequest != nil || legacyPrivateMediaConsentRequest != nil
|| isVoiceAlertPresented
|| isMediaPickerPresented || isMediaPickerPresented
} }
} }
@@ -53,9 +51,7 @@ struct ContentPeopleSheetView: View {
@Binding var showMacImagePicker: Bool @Binding var showMacImagePicker: Bool
#endif #endif
private func modalPresentationState( private var hasModalPresentation: Bool {
includingVoiceAlert: Bool
) -> ContentPeopleSheetModalPresentationState {
#if os(iOS) #if os(iOS)
let isMediaPickerPresented = showImagePicker let isMediaPickerPresented = showImagePicker
#else #else
@@ -67,19 +63,8 @@ struct ContentPeopleSheetView: View {
isVerificationSheetPresented: showVerifySheet, isVerificationSheetPresented: showVerifySheet,
legacyPrivateMediaConsentRequest: legacyPrivateMediaConsentRequest:
conversationUIModel.legacyPrivateMediaConsentRequest, conversationUIModel.legacyPrivateMediaConsentRequest,
isVoiceAlertPresented: includingVoiceAlert && voiceRecordingVM.showAlert,
isMediaPickerPresented: isMediaPickerPresented isMediaPickerPresented: isMediaPickerPresented
) ).hasPresentation
}
private var hasModalPresentation: Bool {
modalPresentationState(includingVoiceAlert: true).hasPresentation
}
/// The voice alert cannot defer to itself: its own binding must keep
/// reporting `true` while it is the presented modal.
private var hasModalPresentationBesidesVoiceAlert: Bool {
modalPresentationState(includingVoiceAlert: false).hasPresentation
} }
private var bluetoothAlertBinding: Binding<Bool> { private var bluetoothAlertBinding: Binding<Bool> {
@@ -100,28 +85,6 @@ struct ContentPeopleSheetView: View {
) )
} }
/// Voice recording happens inside this sheet, so its error alert must
/// present from here as well: the root copy defers whenever this sheet
/// is up, exactly like the Bluetooth alert above. Presenting from the
/// root instead would force-dismiss the sheet and end the conversation.
private var voiceAlertBinding: Binding<Bool> {
Binding(
get: {
scenePhase == .active
&& voiceRecordingVM.showAlert
&& !hasModalPresentationBesidesVoiceAlert
},
set: { isPresented in
guard !isPresented,
scenePhase == .active,
!hasModalPresentationBesidesVoiceAlert else {
return
}
voiceRecordingVM.showAlert = false
}
)
}
var body: some View { var body: some View {
let legacyConsentRequest = conversationUIModel.legacyPrivateMediaConsentRequest let legacyConsentRequest = conversationUIModel.legacyPrivateMediaConsentRequest
NavigationStack { NavigationStack {
@@ -270,16 +233,6 @@ struct ContentPeopleSheetView: View {
} }
} }
#endif #endif
.alert("Recording Error", isPresented: voiceAlertBinding, actions: {
Button("common.ok", role: .cancel) {}
if voiceRecordingVM.state == .permissionDenied {
Button("location_channels.action.open_settings") {
SystemSettings.microphone.open()
}
}
}, message: {
Text(voiceRecordingVM.state.alertMessage)
})
.alert( .alert(
"content.alert.bluetooth_required.title", "content.alert.bluetooth_required.title",
isPresented: bluetoothAlertBinding isPresented: bluetoothAlertBinding
@@ -612,7 +565,8 @@ private struct ContentPrivateChatSheetView: View {
if privateConversationModel.selectedPeerID?.isGroup == true { 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") 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 // even though they carry no Noise session status. Mesh DMs earn the
// "encrypted" claim only once the Noise handshake has secured. // "encrypted" claim only once the Noise handshake has secured.
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
+9 -88
View File
@@ -91,7 +91,6 @@ struct ContentView: View {
@EnvironmentObject private var verificationModel: VerificationModel @EnvironmentObject private var verificationModel: VerificationModel
@EnvironmentObject private var conversationUIModel: ConversationUIModel @EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel @EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var sharedContentImportModel: SharedContentImportModel
@StateObject private var voiceRecordingVM = VoiceRecordingViewModel() @StateObject private var voiceRecordingVM = VoiceRecordingViewModel()
@State private var messageText = "" @State private var messageText = ""
@@ -126,23 +125,13 @@ struct ContentView: View {
privateConversationModel.selectedPeerID privateConversationModel.selectedPeerID
} }
private var sharedContentDestination: SharedContentDestination {
SharedContentDestination.resolve(
selectedPrivatePeerID: selectedPrivatePeerID,
privateDisplayName: privateConversationModel.selectedHeaderState?.displayName,
activeChannel: locationChannelsModel.selectedChannel
)
}
private var usesGlassLayout: Bool { appTheme.usesGlassChrome } private var usesGlassLayout: Bool { appTheme.usesGlassChrome }
private var isPeopleSheetPresented: Bool { private var isPeopleSheetPresented: Bool {
showSidebar || selectedPrivatePeerID != nil showSidebar || selectedPrivatePeerID != nil
} }
private func rootModalPresentationState( private var hasRootModalPresentation: Bool {
includingVoiceAlert: Bool
) -> ContentRootModalPresentationState {
#if os(iOS) #if os(iOS)
let isMediaPickerPresented = showImagePicker let isMediaPickerPresented = showImagePicker
#else #else
@@ -154,19 +143,9 @@ struct ContentView: View {
isPeopleSheetPresented: isPeopleSheetPresented, isPeopleSheetPresented: isPeopleSheetPresented,
isImagePreviewPresented: imagePreviewURL != nil, isImagePreviewPresented: imagePreviewURL != nil,
isVerificationSheetPresented: showVerifySheet, isVerificationSheetPresented: showVerifySheet,
isVoiceAlertPresented: includingVoiceAlert && voiceRecordingVM.showAlert, isVoiceAlertPresented: voiceRecordingVM.showAlert,
isMediaPickerPresented: isMediaPickerPresented isMediaPickerPresented: isMediaPickerPresented
) ).hasPresentation
}
private var hasRootModalPresentation: Bool {
rootModalPresentationState(includingVoiceAlert: true).hasPresentation
}
/// The voice alert cannot defer to itself: its own binding must keep
/// reporting `true` while it is the presented modal.
private var hasRootModalPresentationBesidesVoiceAlert: Bool {
rootModalPresentationState(includingVoiceAlert: false).hasPresentation
} }
private var rootBluetoothAlertBinding: Binding<Bool> { private var rootBluetoothAlertBinding: Binding<Bool> {
@@ -187,29 +166,6 @@ struct ContentView: View {
) )
} }
/// Voice recording errors can surface while the people/DM sheet is up
/// (recording happens inside the sheet). Presenting the root alert then
/// would force-dismiss the sheet, so the root copy defers to any other
/// root modal; the sheet presents its own copy. Mirrors the Bluetooth
/// alert treatment above.
private var rootVoiceAlertBinding: Binding<Bool> {
Binding(
get: {
scenePhase == .active
&& voiceRecordingVM.showAlert
&& !hasRootModalPresentationBesidesVoiceAlert
},
set: { isPresented in
guard !isPresented,
scenePhase == .active,
!hasRootModalPresentationBesidesVoiceAlert else {
return
}
voiceRecordingVM.showAlert = false
}
)
}
var body: some View { var body: some View {
mainContent mainContent
.onAppear { .onAppear {
@@ -227,7 +183,6 @@ struct ContentView: View {
isTextFieldFocused = true isTextFieldFocused = true
} }
#endif #endif
sharedContentImportModel.updateDestination(sharedContentDestination)
} }
.onChange(of: colorScheme) { newValue in .onChange(of: colorScheme) { newValue in
conversationUIModel.setCurrentColorScheme(newValue) conversationUIModel.setCurrentColorScheme(newValue)
@@ -244,10 +199,6 @@ struct ContentView: View {
if newValue != nil { if newValue != nil {
showSidebar = true showSidebar = true
} }
sharedContentImportModel.updateDestination(sharedContentDestination)
}
.onChange(of: locationChannelsModel.selectedChannel) { _ in
sharedContentImportModel.updateDestination(sharedContentDestination)
} }
.sheet( .sheet(
isPresented: Binding( isPresented: Binding(
@@ -255,14 +206,12 @@ struct ContentView: View {
set: { isPresented in set: { isPresented in
if !isPresented { if !isPresented {
showSidebar = false showSidebar = false
// Scene/background and alert-presentation // Scene/background and Bluetooth-alert presentation
// reconciliation (Bluetooth-off, recording errors) // reconciliation are not user requests to leave the
// are not user requests to leave the conversation. // conversation. Keep the selected DM so the sheet
// Keep the selected DM so the sheet remains live // remains live when the app returns from Settings.
// when the app returns from Settings.
if scenePhase == .active, if scenePhase == .active,
!appChromeModel.showBluetoothAlert, !appChromeModel.showBluetoothAlert {
!voiceRecordingVM.showAlert {
privateConversationModel.endConversation() privateConversationModel.endConversation()
} }
} }
@@ -365,7 +314,7 @@ struct ContentView: View {
ImagePreviewView(url: url) ImagePreviewView(url: url)
} }
} }
.alert("Recording Error", isPresented: rootVoiceAlertBinding, actions: { .alert("Recording Error", isPresented: $voiceRecordingVM.showAlert, actions: {
Button("common.ok", role: .cancel) {} Button("common.ok", role: .cancel) {}
if voiceRecordingVM.state == .permissionDenied { if voiceRecordingVM.state == .permissionDenied {
Button("location_channels.action.open_settings") { Button("location_channels.action.open_settings") {
@@ -383,34 +332,6 @@ struct ContentView: View {
} message: { } message: {
Text(appChromeModel.bluetoothAlertMessage) 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 { .onDisappear {
autocompleteDebounceTimer?.invalidate() autocompleteDebounceTimer?.invalidate()
appChromeModel.setPanicPreparation(nil) 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" : { "share.status.no_shareable_content" : {
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
@@ -604,76 +805,406 @@
} }
} }
}, },
"share.status.failed_to_save" : { "share.status.shared_link" : {
"comment" : "Shown when content cannot be staged for the main app",
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "تعذر الحفظ في bitchat" } }, "ar" : {
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat-এ সংরক্ষণ করা যায়নি" } }, "stringUnit" : {
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "Konnte nicht in bitchat gespeichert werden" } }, "state" : "translated",
"en" : { "stringUnit" : { "state" : "translated", "value" : "Could not save to bitchat" } }, "value" : "✓ تم إرسال الرابط إلى bitchat",
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "No se pudo guardar en bitchat" } }, "comment" : "Confirmation after successfully sharing a link"
"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" } }, "bn" : {
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "לא ניתן לשמור ב-bitchat" } }, "stringUnit" : {
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat में सेव नहीं किया जा सका" } }, "state" : "needs_review",
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "Tidak dapat menyimpan ke bitchat" } }, "value" : "✓ 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에 저장할 수 없습니다" } }, "de" : {
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "Tidak dapat menyimpan ke bitchat" } }, "stringUnit" : {
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat मा सुरक्षित गर्न सकिएन" } }, "state" : "translated",
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "Kon niet opslaan in bitchat" } }, "value" : "✓ link zu bitchat geteilt",
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "Nie udało się zapisać w bitchat" } }, "comment" : "Confirmation after successfully sharing a link"
"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" } }, "en" : {
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "Kunde inte spara i bitchat" } }, "stringUnit" : {
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchat-இல் சேமிக்க முடியவில்லை" } }, "state" : "translated",
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "บันทึกไปยัง bitchat ไม่ได้" } }, "value" : "✓ shared link to bitchat",
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "bitchate kaydedilemedi" } }, "comment" : "Confirmation after successfully sharing a link"
"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" } }, "es" : {
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "无法保存到 bitchat" } }, "stringUnit" : {
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "無法儲存到 bitchat" } } "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" : { "share.status.shared_text" : {
"comment" : "Shown after content is staged for review in the main app",
"extractionState" : "manual", "extractionState" : "manual",
"localizations" : { "localizations" : {
"ar" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ حُفظ في bitchat — افتح التطبيق للمراجعة" } }, "ar" : {
"bn" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat-এ সংরক্ষিত — পর্যালোচনার জন্য অ্যাপটি খুলুন" } }, "stringUnit" : {
"de" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ In bitchat gespeichert — App zum Prüfen öffnen" } }, "state" : "translated",
"en" : { "stringUnit" : { "state" : "translated", "value" : "✓ Saved in bitchat — open the app to review" } }, "value" : "✓ تم إرسال النص إلى bitchat",
"es" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Guardado en bitchat — abre la app para revisarlo" } }, "comment" : "Confirmation after successfully sharing text"
"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" } }, "bn" : {
"he" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ נשמר ב-bitchat — יש לפתוח את האפליקציה לבדיקה" } }, "stringUnit" : {
"hi" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat में सेव किया गया — समीक्षा के लिए ऐप खोलें" } }, "state" : "needs_review",
"id" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Disimpan di bitchat — buka aplikasi untuk meninjau" } }, "value" : "✓ bitchat-এ টেক্সট শেয়ার করা হয়েছে"
"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에 저장됨 — 앱을 열어 검토하세요" } }, "de" : {
"ms" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Disimpan dalam bitchat — buka aplikasi untuk menyemak" } }, "stringUnit" : {
"ne" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat मा सुरक्षित गरियो — समीक्षा गर्न एप खोल्नुहोस्" } }, "state" : "translated",
"nl" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Opgeslagen in bitchat — open de app om te bekijken" } }, "value" : "✓ text zu bitchat geteilt",
"pl" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Zapisano w bitchat — otwórz aplikację, aby sprawdzić" } }, "comment" : "Confirmation after successfully sharing text"
"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 — откройте приложение для проверки" } }, "en" : {
"sv" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ Sparat i bitchat — öppna appen för att granska" } }, "stringUnit" : {
"ta" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchat-இல் சேமிக்கப்பட்டது — மதிப்பாய்வு செய்ய செயலியைத் திறக்கவும்" } }, "state" : "translated",
"th" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ บันทึกใน bitchat แล้ว — เปิดแอปเพื่อตรวจสอบ" } }, "value" : "✓ shared text to bitchat",
"tr" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ bitchate kaydedildi — incelemek için uygulamayı açın" } }, "comment" : "Confirmation after successfully sharing text"
"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" } }, "es" : {
"zh-Hans" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ 已保存在 bitchat 中 — 打开应用查看" } }, "stringUnit" : {
"zh-Hant" : { "stringUnit" : { "state" : "needs_review", "value" : "✓ 已儲存在 bitchat 中 — 開啟 App 查看" } } "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 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 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 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 sharedLinkConfirmation = String(localized: "share.status.shared_link", comment: "Confirmation after successfully sharing a link")
static let failedToSave = String(localized: "share.status.failed_to_save", comment: "Shown when content cannot be staged for the main app") 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 = { private let statusLabel: UILabel = {
@@ -43,7 +44,9 @@ final class ShareViewController: UIViewController {
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor), statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor) statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
]) ])
processShare() DispatchQueue.global().async {
self.processShare()
}
} }
// MARK: - Processing // MARK: - Processing
@@ -148,33 +151,30 @@ final class ShareViewController: UIViewController {
// MARK: - Save + Finish // MARK: - Save + Finish
private func saveAndFinish(url: URL, title: String?) { private func saveAndFinish(url: URL, title: String?) {
let payload = SharedContentPayload( let payload: [String: String] = [
kind: .url, "url": url.absoluteString,
content: url.absoluteString, "title": title ?? url.host ?? Strings.sharedLinkTitleFallback
title: title ?? url.host ?? Strings.sharedLinkTitleFallback ]
) if let json = try? JSONSerialization.data(withJSONObject: payload),
stageAndFinish(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) { private func saveAndFinish(text: String) {
stageAndFinish(.text(text)) saveToSharedDefaults(content: text, type: "text")
finishWithMessage(Strings.sharedTextConfirmation)
} }
private func stageAndFinish(_ payload: SharedContentPayload) { private func saveToSharedDefaults(content: String, type: String) {
guard let defaults = UserDefaults(suiteName: Self.groupID) else { guard let userDefaults = UserDefaults(suiteName: Self.groupID) else { return }
finishWithMessage(Strings.failedToSave) userDefaults.set(content, forKey: "sharedContent")
return userDefaults.set(type, forKey: "sharedContentType")
} userDefaults.set(Date(), forKey: "sharedContentDate")
let store = SharedContentStore(defaults: defaults) // No need to force synchronize; the system persists changes
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 finishWithMessage(_ msg: String) { private func finishWithMessage(_ msg: String) {
+2 -245
View File
@@ -666,10 +666,6 @@ struct BLEServiceCoreTests {
) )
await ble._test_drainNoiseMessagePipeline() await ble._test_drainNoiseMessagePipeline()
#expect(ble.canDeliverSecurely(to: alicePeerID)) #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() let outbound = OutboundPacketTap()
ble._test_onOutboundPacket = outbound.record ble._test_onOutboundPacket = outbound.record
@@ -800,202 +796,6 @@ struct BLEServiceCoreTests {
#expect(outbound.count(ofType: .announce) == 0) #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 /// A legitimate rotation announce necessarily arrives on a link still
/// bound to the OLD ID, so its registry upsert stores the new peer /// bound to the OLD ID, so its registry upsert stores the new peer
/// disconnected. The successful rebind must promote it: a healed /// disconnected. The successful rebind must promote it: a healed
@@ -1367,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 final class VerifiedDirectRebindGate: @unchecked Sendable {
private let condition = NSCondition() private let condition = NSCondition()
private var paused = false private var paused = false
@@ -1497,10 +1258,7 @@ private final class PanicIngressObserver: @unchecked Sendable {
} }
} }
private func makeService( private func makeService() -> BLEService {
noiseResponderHandshakeTimeout: TimeInterval =
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout
) -> BLEService {
let keychain = MockKeychain() let keychain = MockKeychain()
let identityManager = MockIdentityManager(keychain) let identityManager = MockIdentityManager(keychain)
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper()) let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
@@ -1508,8 +1266,7 @@ private func makeService(
keychain: keychain, keychain: keychain,
idBridge: idBridge, idBridge: idBridge,
identityManager: identityManager, identityManager: identityManager,
initializeBluetoothManagers: false, initializeBluetoothManagers: false
noiseResponderHandshakeTimeout: noiseResponderHandshakeTimeout
) )
} }
@@ -84,10 +84,6 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
} }
func addSystemMessage(_ content: String) { systemMessages.append(content) } func addSystemMessage(_ content: String) { systemMessages.append(content) }
private(set) var mediaDeletionRefusals: [String] = []
func notifyMediaDeletionRefused(messageID: String) {
mediaDeletionRefusals.append(messageID)
}
func notifyUIChanged() { notifyUIChangedCount += 1 } func notifyUIChanged() { notifyUIChangedCount += 1 }
// Delivery status & dedup // Delivery status & dedup
@@ -647,8 +643,6 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(context.removedMessages.isEmpty) #expect(context.removedMessages.isEmpty)
#expect(context.cancelledTransfers == ["failed-delete"]) #expect(context.cancelledTransfers == ["failed-delete"])
#expect(coordinator.messageIDToTransferId[messageID] == nil) #expect(coordinator.messageIDToTransferId[messageID] == nil)
// The refusal must be visible in the affected chat, not just logged.
#expect(context.mediaDeletionRefusals == [messageID])
} }
@Test @MainActor @Test @MainActor
@@ -1192,63 +1186,6 @@ struct ChatMediaTransferCoordinatorContextTests {
#expect(coordinator.messageIDToTransferId[messageID] == nil) #expect(coordinator.messageIDToTransferId[messageID] == nil)
} }
@Test @MainActor
func disconnectClearsDroppedPolicyResolutionSoRetriesRecover() async throws {
let context = MockChatMediaTransferContext()
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.supportsAuthenticatedPrivateMediaReceipts = true
let fileName = "voice_3333444455556666.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
))
#expect(coordinator.retainedReconnectRetryCount == 1)
// The transport accepts this resolution but its completion is never
// invoked (BLEService queue teardown drops the closure), so the
// pending entry parks every subsequent reconnect resolution.
context.resolvesPrivateMediaPolicyImmediately = false
coordinator.peerDidReconnect(peerID)
#expect(context.privateMediaPolicyResolutionRequests.count == 1)
coordinator.peerDidReconnect(peerID)
#expect(context.privateMediaPolicyResolutionRequests.count == 1)
// Disconnection invalidates the dropped resolution; the next
// reconnect must start fresh and complete the retry.
coordinator.peerDidDisconnect(peerID)
context.resolvesPrivateMediaPolicyImmediately = true
coordinator.peerDidReconnect(peerID)
#expect(context.privateMediaPolicyResolutionRequests.count == 2)
#expect(context.privateFileSends.count == 2)
let retry = try #require(context.privateFileSends.last)
#expect(retry.packet.encode() == initial.packet.encode())
#expect(retry.peerID == peerID)
#expect(context.privateFileReceiptRetryTransferIDs == [
retry.transferId
])
}
@Test @MainActor @Test @MainActor
func bit8OnlyEncryptedMediaNeverRetainsOrAutomaticallyRetries() async throws { func bit8OnlyEncryptedMediaNeverRetainsOrAutomaticallyRetries() async throws {
let context = MockChatMediaTransferContext() let context = MockChatMediaTransferContext()
@@ -243,7 +243,7 @@ private func drainMainQueue() async {
/// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no /// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no
/// `ChatViewModel`. Scoped to the inbound event pipeline (dedup, presence, /// `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 /// teardown, embedded ack flows, and now that favorites and notifications
/// are injected through the context the favorite-notification ingest and /// are injected through the context the favorite-notification ingest and
/// the sampled-geohash notification cooldown. Flows that hit live singletons /// the sampled-geohash notification cooldown. Flows that hit live singletons
@@ -335,7 +335,7 @@ struct ChatNostrCoordinatorContextTests {
} }
@Test @MainActor @Test @MainActor
func handleGiftWrap_routesEmbeddedPrivateMessageAndDeduplicates() async throws { func handlePrivateEnvelope_routesEmbeddedPrivateMessageAndDeduplicates() async throws {
let context = MockChatNostrContext() let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context) let coordinator = ChatNostrCoordinator(context: context)
@@ -346,20 +346,22 @@ struct ChatNostrCoordinatorContextTests {
messageID: "gm-1", messageID: "gm-1",
senderPeerID: PeerID(str: "aabbccddeeff0011") senderPeerID: PeerID(str: "aabbccddeeff0011")
)) ))
let giftWrap = try NostrProtocol.createPrivateMessage( let envelopes = try NostrProtocol.createPrivateEnvelopePublicationBatch(
content: embedded, content: embedded,
recipientPubkey: recipient.publicKeyHex, recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender 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 convKey = PeerID(nostr_: sender.publicKeyHex)
let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 }) #expect(context.recordedNostrEventIDs == envelopes.map(\.id))
#expect(routed)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex) #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?.senderPubkey == sender.publicKeyHex)
#expect(context.handledPrivateMessages.first?.convKey == convKey) #expect(context.handledPrivateMessages.first?.convKey == convKey)
@@ -370,80 +372,84 @@ struct ChatNostrCoordinatorContextTests {
#expect(pm.messageID == "gm-1") #expect(pm.messageID == "gm-1")
#expect(pm.content == "psst") #expect(pm.content == "psst")
// The same gift wrap is dropped on replay. // The same private envelope is dropped on replay.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient) coordinator.inbound.handlePrivateEnvelope(envelopes[0], id: recipient)
await drainMainQueue() #expect(context.recordedNostrEventIDs == envelopes.map(\.id))
#expect(context.recordedNostrEventIDs == [giftWrap.id])
#expect(context.handledPrivateMessages.count == 1) #expect(context.handledPrivateMessages.count == 1)
} }
@Test @MainActor @Test @MainActor
func handleGiftWrap_panicWipeAfterSpawnDropsDecryptedResult() async throws { func migrationEnvelopePairs_processEachMessageAndAckOnlyOnce() throws {
let context = MockChatNostrContext() let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context) let coordinator = ChatNostrCoordinator(context: context)
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let embedded = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient( let messageContent = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content: "pre-wipe secret", content: "migration message",
messageID: "gm-wipe-1", messageID: "migration-message-id",
senderPeerID: PeerID(str: "aabbccddeeff0011") senderPeerID: PeerID(str: "aabbccddeeff0011")
)) ))
let giftWrap = try NostrProtocol.createPrivateMessage( let deliveredContent = try #require(NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(
content: embedded, 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, recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender senderIdentity: sender
) )
coordinator.inbound.handlePrivateEnvelope(primaryRetry, id: recipient)
// Spawn the detached decrypt (it strongly captures the pre-wipe #expect(context.handledPrivateMessages.count == 2)
// identity), then panic-wipe in the SAME main-actor turn guaranteed
// to land before the task's first main-actor hop.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
coordinator.inbound.invalidateInFlightDecrypts()
// Give the detached task ample time to have delivered if the wipe
// guard were broken.
try? await Task.sleep(nanoseconds: 200_000_000)
await drainMainQueue()
#expect(context.handledPrivateMessages.isEmpty)
#expect(context.recordedNostrEventIDs.isEmpty)
// The pipeline itself stays usable: a gift wrap spawned AFTER the
// wipe (new generation) still decrypts and delivers.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
#expect(delivered)
} }
// NOTE: Inbound Schnorr signature verification (and the forged-copy
// dedup-poisoning invariant) is enforced once, off the main actor, at the
// relay boundary see NostrRelayManagerTests
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache` and
// `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`.
// The inbound pipeline only ever sees verified events.
@Test @MainActor @Test @MainActor
func processNostrMessage_duplicateDeliveryProcessesOnce() async throws { func processAccountPrivateEnvelope_invalidSignatureDoesNotPoisonDedup() async throws {
let context = MockChatNostrContext() let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context) let coordinator = ChatNostrCoordinator(context: context)
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
context.nostrIdentity = recipient let envelope = try NostrProtocol.createPrivateEnvelope(
let giftWrap = try NostrProtocol.createPrivateMessage(
content: "verify:noop", content: "verify:noop",
recipientPubkey: recipient.publicKeyHex, recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender senderIdentity: sender
) )
var invalidEnvelope = envelope
invalidEnvelope.sig = String(repeating: "0", count: 128)
// Fan-in of the same (already verified) gift wrap from several relays // A forged-signature copy is rejected WITHOUT entering the dedup set...
// records and processes exactly once. await coordinator.inbound.processAccountPrivateEnvelope(invalidEnvelope)
await coordinator.inbound.processNostrMessage(giftWrap) #expect(context.recordedNostrEventIDs.isEmpty)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
await coordinator.inbound.processNostrMessage(giftWrap) // ...so the genuine event with the same ID still processes and records.
#expect(context.recordedNostrEventIDs == [giftWrap.id]) await coordinator.inbound.processAccountPrivateEnvelope(envelope)
#expect(context.recordedNostrEventIDs == [envelope.id])
} }
@Test @MainActor @Test @MainActor
@@ -176,6 +176,7 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
private(set) var geoPrivateMessages: [(content: String, recipientHex: String, messageID: String)] = [] private(set) var geoPrivateMessages: [(content: String, recipientHex: String, messageID: String)] = []
private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = [] private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = []
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = [] private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
var geohashPrivateMessageAccepted = true
var queuedMessageIDsByPeerID: [PeerID: Set<String>] = [:] var queuedMessageIDsByPeerID: [PeerID: Set<String>] = [:]
private(set) var deliveryAckAttempts: [(messageID: String, peerIDs: [PeerID])] = [] private(set) var deliveryAckAttempts: [(messageID: String, peerIDs: [PeerID])] = []
private(set) var deliveredMessageIDs: [String] = [] private(set) var deliveredMessageIDs: [String] = []
@@ -210,8 +211,14 @@ private final class MockChatPrivateConversationContext: ChatPrivateConversationC
meshReadReceipts.append((receipt.originalMessageID, peerID)) 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)) geoPrivateMessages.append((content, recipientHex, messageID))
return geohashPrivateMessageAccepted
} }
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
@@ -298,6 +305,11 @@ private func isRead(_ status: DeliveryStatus?, by expected: String) -> Bool {
return false return false
} }
private func isFailed(_ status: DeliveryStatus?) -> Bool {
if case .failed = status { return true }
return false
}
private func makeFavoriteRelationship( private func makeFavoriteRelationship(
noiseKey: Data, noiseKey: Data,
nostrPublicKey: String? = nil, nostrPublicKey: String? = nil,
@@ -491,6 +503,25 @@ struct ChatPrivateConversationCoordinatorContextTests {
#expect(context.privateChats[convKey]?.count == 1) #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 @Test @MainActor
func accountDM_handsOpenShortIDConversationToStableWhenOffline() async { func accountDM_handsOpenShortIDConversationToStableWhenOffline() async {
let context = MockChatPrivateConversationContext() let context = MockChatPrivateConversationContext()
@@ -133,11 +133,17 @@ private final class MockChatTransportEventContext: ChatTransportEventContext {
// Delivery status // Delivery status
var applyMessageDeliveryStatusResult = true var applyMessageDeliveryStatusResult = true
var deliveryStatusesByMessageID: [String: DeliveryStatus] = [:] var deliveryStatusesByMessageID: [String: DeliveryStatus] = [:]
private(set) var appliedDeliveryStatuses: [(messageID: String, status: DeliveryStatus)] = [] private(set) var appliedDeliveryStatuses: [
(messageID: String, status: DeliveryStatus, peerIDAliases: Set<PeerID>)
] = []
@discardableResult @discardableResult
func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool { func applyAcknowledgedMessageDeliveryStatus(
appliedDeliveryStatuses.append((messageID, status)) _ messageID: String,
status: DeliveryStatus,
from peerIDAliases: Set<PeerID>
) -> Bool {
appliedDeliveryStatuses.append((messageID, status, peerIDAliases))
return applyMessageDeliveryStatusResult return applyMessageDeliveryStatusResult
} }
@@ -417,6 +423,10 @@ struct ChatTransportEventCoordinatorContextTests {
let peerID = PeerID(str: "99aabbccddeeff00") let peerID = PeerID(str: "99aabbccddeeff00")
let noiseKey = Data(repeating: 0x44, count: 32) let noiseKey = Data(repeating: 0x44, count: 32)
context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice") context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")
let stablePeerID = PeerID(hexData: noiseKey)
let staleStablePeerID = PeerID(hexData: Data(repeating: 0x55, count: 32))
context.cacheStablePeerID(staleStablePeerID, for: peerID)
context.noiseSessionKeysByPeerID[peerID] = noiseKey
// Inbound private message: decoded, handled, and delivery-acked. // Inbound private message: decoded, handled, and delivery-acked.
let packet = PrivateMessagePacket(messageID: "pm-1", content: "hi there") let packet = PrivateMessagePacket(messageID: "pm-1", content: "hi there")
@@ -438,6 +448,8 @@ struct ChatTransportEventCoordinatorContextTests {
await drainMainActorTasks() await drainMainActorTasks()
#expect(context.appliedDeliveryStatuses.count == 2) #expect(context.appliedDeliveryStatuses.count == 2)
#expect(context.appliedDeliveryStatuses[0].messageID == "m-1") #expect(context.appliedDeliveryStatuses[0].messageID == "m-1")
#expect(context.appliedDeliveryStatuses[0].peerIDAliases == [peerID, stablePeerID])
#expect(!context.appliedDeliveryStatuses[0].peerIDAliases.contains(staleStablePeerID))
if case .delivered(let to, _) = context.appliedDeliveryStatuses[0].status { if case .delivered(let to, _) = context.appliedDeliveryStatuses[0].status {
#expect(to == "alice") #expect(to == "alice")
} else { } else {
@@ -98,6 +98,7 @@ private final class MockChatVerificationContext: ChatVerificationContext {
private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)? private(set) var installedCallbacks: (onPeerAuthenticated: (PeerID, String) -> Void, onHandshakeRequired: (PeerID) -> Void)?
private(set) var triggeredHandshakes: [PeerID] = [] private(set) var triggeredHandshakes: [PeerID] = []
private(set) var privateMediaAuthenticatedPeers: [PeerID] = [] private(set) var privateMediaAuthenticatedPeers: [PeerID] = []
private(set) var securePrivateMessageRetryAliases: [[PeerID]] = []
private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var sentChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
private(set) var sentResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = [] private(set) var sentResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
@@ -118,6 +119,10 @@ private final class MockChatVerificationContext: ChatVerificationContext {
privateMediaAuthenticatedPeers.append(peerID) privateMediaAuthenticatedPeers.append(peerID)
} }
func retrySecurePrivateMessagesAfterAuthentication(for peerIDAliases: [PeerID]) {
securePrivateMessageRetryAliases.append(peerIDAliases)
}
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
sentChallenges.append((peerID, noiseKeyHex, nonceA)) sentChallenges.append((peerID, noiseKeyHex, nonceA))
} }
@@ -269,6 +274,10 @@ struct ChatVerificationCoordinatorContextTests {
let peerID = PeerID(str: "1122334455667788") let peerID = PeerID(str: "1122334455667788")
let noiseKey = Data(repeating: 0x33, count: 32) let noiseKey = Data(repeating: 0x33, count: 32)
context.noiseSessionKeysByPeerID[peerID] = noiseKey context.noiseSessionKeysByPeerID[peerID] = noiseKey
context.cacheStablePeerID(
PeerID(hexData: Data(repeating: 0x44, count: 32)),
for: peerID
)
context.verifiedFingerprints = ["fp-verified"] context.verifiedFingerprints = ["fp-verified"]
coordinator.setupNoiseCallbacks() coordinator.setupNoiseCallbacks()
@@ -279,9 +288,11 @@ struct ChatVerificationCoordinatorContextTests {
callbacks?.onPeerAuthenticated(peerID, "fp-verified") callbacks?.onPeerAuthenticated(peerID, "fp-verified")
await waitForMainQueue() await waitForMainQueue()
#expect(context.encryptionStatuses[peerID] == .noiseVerified) #expect(context.encryptionStatuses[peerID] == .noiseVerified)
#expect(context.stablePeerIDCache[peerID] == PeerID(hexData: noiseKey)) let stablePeerID = PeerID(hexData: noiseKey)
#expect(context.stablePeerIDCache[peerID] == stablePeerID)
#expect(context.invalidatedEncryptionCachePeers.contains(peerID)) #expect(context.invalidatedEncryptionCachePeers.contains(peerID))
#expect(context.privateMediaAuthenticatedPeers == [peerID]) #expect(context.privateMediaAuthenticatedPeers == [peerID])
#expect(context.securePrivateMessageRetryAliases == [[peerID, stablePeerID]])
// Handshake required -> handshaking status. // Handshake required -> handshaking status.
callbacks?.onHandshakeRequired(peerID) callbacks?.onHandshakeRequired(peerID)
@@ -298,6 +298,137 @@ struct ChatViewModelDeliveryStatusTests {
}()) }())
} }
@Test @MainActor
func authenticatedNoiseAckCannotClearAnotherPeersRetryState() async {
let (viewModel, transport) = makeTestableViewModel()
let intendedPeer = PeerID(str: "0102030405060708")
let otherPeer = PeerID(str: "1112131415161718")
let messageID = "noise-peer-bound-ack"
viewModel.seedPrivateChat(
[
BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Keep retrying",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Intended",
senderPeerID: viewModel.myPeerID,
deliveryStatus: .sent
)
],
for: intendedPeer
)
transport.reachablePeers.insert(intendedPeer)
viewModel.messageRouter.sendPrivate(
"Keep retrying",
to: intendedPeer,
recipientNickname: "Intended",
messageID: messageID
)
#expect(transport.sentPrivateMessages.count == 1)
// This models a decrypted Noise receipt: the transport-authenticated
// peer is authoritative, not the attacker-controlled message ID.
viewModel.didReceiveNoisePayload(
from: otherPeer,
type: .delivered,
payload: Data(messageID.utf8),
timestamp: Date()
)
for _ in 0..<10 { await Task.yield() }
#expect(isSent(viewModel.conversations.deliveryStatus(forMessageID: messageID)))
viewModel.messageRouter.flushOutbox(for: intendedPeer)
#expect(transport.sentPrivateMessages.count == 2)
viewModel.didReceiveNoisePayload(
from: intendedPeer,
type: .delivered,
payload: Data(messageID.utf8),
timestamp: Date()
)
for _ in 0..<10 { await Task.yield() }
#expect(isDelivered(viewModel.conversations.deliveryStatus(forMessageID: messageID)))
viewModel.messageRouter.flushOutbox(for: intendedPeer)
#expect(transport.sentPrivateMessages.count == 2)
}
@Test @MainActor
func authenticatedNoiseAckClearsOnlyIntendedPeersPrivateMediaRetry() async throws {
let (viewModel, transport) = makeTestableViewModel()
let intendedPeer = PeerID(str: "0102030405060708")
let otherPeer = PeerID(str: "1112131415161718")
let fileName = "voice_0011223344556677.m4a"
let content = Data("voice".utf8)
transport.privateMediaPolicies[intendedPeer] = .encrypted
transport.privateMediaReceiptSessionGenerations[intendedPeer] = UUID()
viewModel.selectedPrivateChatPeer = intendedPeer
let coordinator = ChatMediaTransferCoordinator(
context: viewModel,
prepareVoiceNotePacket: { _ in
BitchatFilePacket(
fileName: fileName,
fileSize: UInt64(content.count),
mimeType: "audio/mp4",
content: content
)
},
transferIDFactory: { "\($0)-receipt-ack" }
)
viewModel.mediaTransferCoordinator = coordinator
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent(
"scoped-media-ack-\(UUID().uuidString)",
isDirectory: true
)
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
let sourceURL = directory.appendingPathComponent(fileName)
try content.write(to: sourceURL)
defer { try? FileManager.default.removeItem(at: directory) }
coordinator.sendVoiceNote(at: sourceURL)
#expect(await TestHelpers.waitUntil(
{
transport.sentPrivateFiles.count == 1
&& coordinator.retainedReconnectRetryCount == 1
},
timeout: TestConstants.longTimeout
))
let messageID = try #require(
viewModel.privateChats[intendedPeer]?.first?.id
)
let transferID = try #require(
transport.sentPrivateFiles.first?.transferID
)
#expect(!viewModel.deliveryCoordinator
.updateAcknowledgedMessageDeliveryStatus(
messageID,
status: .delivered(to: "Other", at: Date()),
from: [otherPeer]
))
#expect(coordinator.retainedReconnectRetryCount == 1)
#expect(transport.cancelledTransfers.isEmpty)
#expect(viewModel.deliveryCoordinator
.updateAcknowledgedMessageDeliveryStatus(
messageID,
status: .delivered(to: "Intended", at: Date()),
from: [intendedPeer]
))
#expect(coordinator.retainedReconnectRetryCount == 0)
#expect(transport.cancelledTransfers == [transferID])
}
@Test @MainActor @Test @MainActor
func cleanupOldReadReceipts_removesReceiptIDsWithoutMessages() async { func cleanupOldReadReceipts_removesReceiptIDsWithoutMessages() async {
let (viewModel, transport) = makeTestableViewModel() let (viewModel, transport) = makeTestableViewModel()
@@ -577,12 +708,26 @@ private final class MockChatDeliveryContext: ChatDeliveryContext {
var isStartupPhase = false var isStartupPhase = false
private(set) var notifyUIChangedCount = 0 private(set) var notifyUIChangedCount = 0
private(set) var markedDeliveredMessageIDs: [String] = [] private(set) var markedDeliveredMessageIDs: [String] = []
private(set) var peerBoundDeliveredMessages: [(messageID: String, peerIDs: Set<PeerID>)] = []
@discardableResult @discardableResult
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
store.setDeliveryStatus(status, forMessageID: messageID) store.setDeliveryStatus(status, forMessageID: messageID)
} }
@discardableResult
func setDeliveryStatus(
_ status: DeliveryStatus,
forMessageID messageID: String,
inDirectPeerAliases peerIDs: Set<PeerID>
) -> Bool {
store.setDeliveryStatus(
status,
forMessageID: messageID,
inDirectPeerAliases: peerIDs
)
}
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? { func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
store.deliveryStatus(forMessageID: messageID) store.deliveryStatus(forMessageID: messageID)
} }
@@ -604,6 +749,24 @@ private final class MockChatDeliveryContext: ChatDeliveryContext {
func markMessageDelivered(_ messageID: String) { func markMessageDelivered(_ messageID: String) {
markedDeliveredMessageIDs.append(messageID) markedDeliveredMessageIDs.append(messageID)
} }
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {
peerBoundDeliveredMessages.append((messageID, peerIDs))
}
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool {
peerIDs.contains { peerID in
contextMessages(for: peerID).contains { message in
message.id == messageID && message.senderPeerID == localPeerID
}
}
}
private let localPeerID = PeerID(str: "aabbccddeeff0011")
private func contextMessages(for peerID: PeerID) -> [BitchatMessage] {
store.conversationsByID[.directPeer(peerID)]?.messages ?? []
}
} }
@MainActor @MainActor
@@ -685,7 +848,151 @@ struct ChatDeliveryCoordinatorContextTests {
#expect(isRead(coordinator.deliveryStatus(for: messageID))) #expect(isRead(coordinator.deliveryStatus(for: messageID)))
#expect(context.notifyUIChangedCount == 1) #expect(context.notifyUIChangedCount == 1)
#expect(context.markedDeliveredMessageIDs == [messageID]) #expect(context.markedDeliveredMessageIDs.isEmpty)
#expect(context.peerBoundDeliveredMessages.count == 1)
#expect(context.peerBoundDeliveredMessages[0].messageID == messageID)
#expect(context.peerBoundDeliveredMessages[0].peerIDs == [peerID])
}
@Test @MainActor
func authenticatedReceiptWithCollidingIDUpdatesOnlyAuthenticatedAliases() async {
let context = MockChatDeliveryContext()
let coordinator = ChatDeliveryCoordinator(context: context)
let ephemeralPeerID = PeerID(str: "0102030405060708")
let stablePeerID = PeerID(hexData: Data(repeating: 0x08, count: 32))
let otherPeerID = PeerID(str: "1112131415161718")
let messageID = "authenticated-receipt-collision"
let mirroredMessage = makePrivateMessage(id: messageID, status: .sent)
context.store.append(mirroredMessage, to: .directPeer(ephemeralPeerID))
context.store.append(mirroredMessage, to: .directPeer(stablePeerID))
context.store.append(
makePrivateMessage(id: messageID, status: .sent),
to: .directPeer(otherPeerID)
)
context.store.append(makePublicMessage(id: messageID, status: .sent), to: .mesh)
let aliases: Set<PeerID> = [ephemeralPeerID, stablePeerID]
let didUpdate = coordinator.updateAcknowledgedMessageDeliveryStatus(
messageID,
status: .delivered(to: "Peer", at: Date()),
from: aliases
)
#expect(didUpdate)
#expect(isDelivered(
context.store.conversation(for: .directPeer(ephemeralPeerID))
.message(withID: messageID)?.deliveryStatus
))
#expect(isDelivered(
context.store.conversation(for: .directPeer(stablePeerID))
.message(withID: messageID)?.deliveryStatus
))
#expect(isSent(
context.store.conversation(for: .directPeer(otherPeerID))
.message(withID: messageID)?.deliveryStatus
))
#expect(isSent(
context.store.conversation(for: .mesh)
.message(withID: messageID)?.deliveryStatus
))
#expect(context.peerBoundDeliveredMessages.count == 1)
#expect(context.peerBoundDeliveredMessages[0].messageID == messageID)
#expect(context.peerBoundDeliveredMessages[0].peerIDs == aliases)
#expect(context.notifyUIChangedCount == 1)
}
@Test @MainActor
func authenticatedReceiptRemainsScopedAfterPeerAliasMigration() async {
let context = MockChatDeliveryContext()
let coordinator = ChatDeliveryCoordinator(context: context)
let ephemeralPeerID = PeerID(str: "2122232425262728")
let stablePeerID = PeerID(hexData: Data(repeating: 0x28, count: 32))
let otherPeerID = PeerID(str: "3132333435363738")
let messageID = "authenticated-receipt-after-migration"
context.store.append(
makePrivateMessage(id: messageID, status: .sent),
to: .directPeer(ephemeralPeerID)
)
context.store.append(
makePrivateMessage(id: messageID, status: .sent),
to: .directPeer(otherPeerID)
)
context.store.migrateConversation(
from: .directPeer(ephemeralPeerID),
to: .directPeer(stablePeerID)
)
let didUpdate = coordinator.updateAcknowledgedMessageDeliveryStatus(
messageID,
status: .read(by: "Peer", at: Date()),
from: [ephemeralPeerID, stablePeerID]
)
#expect(didUpdate)
#expect(context.store.conversationsByID[.directPeer(ephemeralPeerID)] == nil)
#expect(isRead(
context.store.conversation(for: .directPeer(stablePeerID))
.message(withID: messageID)?.deliveryStatus
))
#expect(isSent(
context.store.conversation(for: .directPeer(otherPeerID))
.message(withID: messageID)?.deliveryStatus
))
}
@Test @MainActor
func receiptFromWrongPeerDoesNotUpdateOrTerminalizeOutgoingMessage() async {
let context = MockChatDeliveryContext()
let coordinator = ChatDeliveryCoordinator(context: context)
let intendedPeer = PeerID(str: "0102030405060708")
let otherPeer = PeerID(str: "1112131415161718")
let messageID = "wrong-peer-receipt"
context.store.append(
makePrivateMessage(id: messageID, status: .sent),
to: .directPeer(intendedPeer)
)
coordinator.didReceiveReadReceipt(
ReadReceipt(
originalMessageID: messageID,
readerID: otherPeer,
readerNickname: "Other"
)
)
#expect(isSent(coordinator.deliveryStatus(for: messageID)))
#expect(context.peerBoundDeliveredMessages.isEmpty)
#expect(context.markedDeliveredMessageIDs.isEmpty)
#expect(context.notifyUIChangedCount == 0)
}
@Test @MainActor
func rejectedStaleReceiptDoesNotTerminalizeRetryState() async {
let context = MockChatDeliveryContext()
let coordinator = ChatDeliveryCoordinator(context: context)
let peerID = PeerID(str: "0102030405060708")
let messageID = "stale-receipt"
context.store.append(
makePrivateMessage(
id: messageID,
status: .read(by: "Peer", at: Date())
),
to: .directPeer(peerID)
)
let didUpdate = coordinator.updateAcknowledgedMessageDeliveryStatus(
messageID,
status: .delivered(to: "Peer", at: Date()),
from: [peerID]
)
#expect(!didUpdate)
#expect(isRead(coordinator.deliveryStatus(for: messageID)))
#expect(context.peerBoundDeliveredMessages.isEmpty)
#expect(context.markedDeliveredMessageIDs.isEmpty)
#expect(context.notifyUIChangedCount == 0)
} }
@Test @MainActor @Test @MainActor
+117 -40
View File
@@ -366,27 +366,52 @@ struct ChatViewModelNostrExtensionTests {
#expect(!viewModel.messages.contains { $0.content == "Blocked" }) #expect(!viewModel.messages.contains { $0.content == "Blocked" })
} }
// NOTE: Tampered-signature rejection is enforced once, off the main @Test @MainActor
// actor, at the relay boundary (events only reach the inbound pipeline func handleNostrEvent_rejectsInvalidSignature() async throws {
// after verification) see NostrRelayManagerTests let (viewModel, _) = makeTestableViewModel()
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache` and let geohash = "u4pruydq"
// `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`. 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 @Test @MainActor
func subscribeGiftWrap_rejectsOversizedEmbeddedPacket() async throws { func privateEnvelope_rejectsOversizedEmbeddedPacketBeforePublication() async throws {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
let oversized = Data(repeating: 0x41, count: FileTransferLimits.maxFramedFileBytes + 1) let oversized = Data(repeating: 0x41, count: FileTransferLimits.maxFramedFileBytes + 1)
let content = "bitchat1:" + base64URLEncode(oversized) let content = "bitchat1:" + base64URLEncode(oversized)
let giftWrap = try NostrProtocol.createPrivateMessage( do {
content: content, _ = try NostrProtocol.createPrivateEnvelope(
recipientPubkey: recipient.publicKeyHex, content: content,
senderIdentity: sender recipientPubkey: recipient.publicKeyHex,
) senderIdentity: sender
)
viewModel.subscribeGiftWrap(giftWrap, id: recipient) 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) try? await Task.sleep(nanoseconds: 100_000_000)
#expect(viewModel.privateChats.isEmpty) #expect(viewModel.privateChats.isEmpty)
@@ -431,7 +456,7 @@ struct ChatViewModelNostrExtensionTests {
} }
@Test @MainActor @Test @MainActor
func subscribeGiftWrap_deliveredAckUpdatesExistingMessage() async throws { func subscribePrivateEnvelope_deliveredAckUpdatesExistingMessage() async throws {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
@@ -453,13 +478,13 @@ struct ChatViewModelNostrExtensionTests {
], for: convKey) ], for: convKey)
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef")) let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
let giftWrap = try NostrProtocol.createPrivateMessage( let envelope = try NostrProtocol.createPrivateEnvelope(
content: content, content: content,
recipientPubkey: recipient.publicKeyHex, recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender senderIdentity: sender
) )
viewModel.subscribeGiftWrap(giftWrap, id: recipient) viewModel.subscribePrivateEnvelope(envelope, id: recipient)
let didUpdate = await TestHelpers.waitUntil( let didUpdate = await TestHelpers.waitUntil(
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) }, { isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
@@ -469,7 +494,7 @@ struct ChatViewModelNostrExtensionTests {
} }
@Test @MainActor @Test @MainActor
func subscribeGiftWrap_readAckUpdatesExistingMessage() async throws { func subscribePrivateEnvelope_readAckUpdatesExistingMessage() async throws {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
@@ -491,13 +516,13 @@ struct ChatViewModelNostrExtensionTests {
], for: convKey) ], for: convKey)
let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef")) let content = try ackContent(type: .readReceipt, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
let giftWrap = try NostrProtocol.createPrivateMessage( let envelope = try NostrProtocol.createPrivateEnvelope(
content: content, content: content,
recipientPubkey: recipient.publicKeyHex, recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender senderIdentity: sender
) )
viewModel.subscribeGiftWrap(giftWrap, id: recipient) viewModel.subscribePrivateEnvelope(envelope, id: recipient)
let didUpdate = await TestHelpers.waitUntil( let didUpdate = await TestHelpers.waitUntil(
{ isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) }, { isRead(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
@@ -507,28 +532,28 @@ struct ChatViewModelNostrExtensionTests {
} }
@Test @MainActor @Test @MainActor
func handleGiftWrap_privateMessageStoresConversationAndMapping() async throws { func handlePrivateEnvelope_privateMessageStoresConversationAndMapping() async throws {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
let messageID = "gift-private" let messageID = "envelope-private"
let convKey = PeerID(nostr_: sender.publicKeyHex) let convKey = PeerID(nostr_: sender.publicKeyHex)
let content = try privateMessageContent( let content = try privateMessageContent(
text: "Hello from gift wrap", text: "Hello from private envelope",
messageID: messageID, messageID: messageID,
senderPeerID: PeerID(str: "0123456789abcdef") senderPeerID: PeerID(str: "0123456789abcdef")
) )
let giftWrap = try NostrProtocol.createPrivateMessage( let envelope = try NostrProtocol.createPrivateEnvelope(
content: content, content: content,
recipientPubkey: recipient.publicKeyHex, recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender senderIdentity: sender
) )
viewModel.handleGiftWrap(giftWrap, id: recipient) viewModel.handlePrivateEnvelope(envelope, id: recipient)
let didStore = await TestHelpers.waitUntil( 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 timeout: 5.0
) )
#expect(didStore) #expect(didStore)
@@ -537,11 +562,11 @@ struct ChatViewModelNostrExtensionTests {
} }
@Test @MainActor @Test @MainActor
func handleGiftWrap_blockedSenderSkipsMessageStorage() async throws { func handlePrivateEnvelope_blockedSenderSkipsMessageStorage() async throws {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
let messageID = "gift-blocked" let messageID = "envelope-blocked"
let convKey = PeerID(nostr_: sender.publicKeyHex) let convKey = PeerID(nostr_: sender.publicKeyHex)
viewModel.identityManager.setNostrBlocked(sender.publicKeyHex, isBlocked: true) viewModel.identityManager.setNostrBlocked(sender.publicKeyHex, isBlocked: true)
@@ -551,31 +576,26 @@ struct ChatViewModelNostrExtensionTests {
messageID: messageID, messageID: messageID,
senderPeerID: PeerID(str: "0123456789abcdef") senderPeerID: PeerID(str: "0123456789abcdef")
) )
let giftWrap = try NostrProtocol.createPrivateMessage( let envelope = try NostrProtocol.createPrivateEnvelope(
content: content, content: content,
recipientPubkey: recipient.publicKeyHex, recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender senderIdentity: sender
) )
viewModel.handleGiftWrap(giftWrap, id: recipient) viewModel.handlePrivateEnvelope(envelope, id: recipient)
// Gift-wrap decryption runs off the main actor; wait for the ack try? await Task.sleep(nanoseconds: 50_000_000)
// (sent even for blocked senders) to know processing finished.
let didAck = await TestHelpers.waitUntil(
{ viewModel.sentGeoDeliveryAcks.contains(messageID) },
timeout: 5.0
)
#expect(didAck)
#expect(viewModel.privateChats[convKey] == nil) #expect(viewModel.privateChats[convKey] == nil)
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
} }
@Test @MainActor @Test @MainActor
func handleGiftWrap_deliveredAckUpdatesExistingMessage() async throws { func handlePrivateEnvelope_deliveredAckUpdatesExistingMessage() async throws {
let (viewModel, _) = makeTestableViewModel() let (viewModel, _) = makeTestableViewModel()
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
let convKey = PeerID(nostr_: sender.publicKeyHex) let convKey = PeerID(nostr_: sender.publicKeyHex)
let messageID = "gift-delivered" let messageID = "envelope-delivered"
viewModel.seedPrivateChat([ viewModel.seedPrivateChat([
BitchatMessage( BitchatMessage(
@@ -592,13 +612,13 @@ struct ChatViewModelNostrExtensionTests {
], for: convKey) ], for: convKey)
let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef")) let content = try ackContent(type: .delivered, messageID: messageID, senderPeerID: PeerID(str: "0123456789abcdef"))
let giftWrap = try NostrProtocol.createPrivateMessage( let envelope = try NostrProtocol.createPrivateEnvelope(
content: content, content: content,
recipientPubkey: recipient.publicKeyHex, recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender senderIdentity: sender
) )
viewModel.handleGiftWrap(giftWrap, id: recipient) viewModel.handlePrivateEnvelope(envelope, id: recipient)
let didUpdate = await TestHelpers.waitUntil( let didUpdate = await TestHelpers.waitUntil(
{ isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) }, { isDelivered(status: deliveryStatus(in: viewModel, peerID: convKey, messageID: messageID)) },
@@ -766,6 +786,63 @@ struct ChatViewModelGeoDMTests {
#expect(isFailed(status: viewModel.privateChats[convKey]?.last?.deliveryStatus)) #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, /// The blocked notice belongs in the DM thread the person is typing in,
/// not on the active location-channel timeline. /// not on the active location-channel timeline.
@Test @MainActor @Test @MainActor
+48 -172
View File
@@ -1272,16 +1272,53 @@ struct ChatViewModelPrivateMediaDeletionTests {
"images/incoming/\(filename)" "images/incoming/\(filename)"
]] ]]
) )
let messages = viewModel.privateChats[peerID] ?? []
#expect(messages.prefix(2).map(\.id) == [stableID, legacyID])
// The refusal is surfaced in the affected chat, not just logged.
#expect(messages.last?.sender == "system")
#expect( #expect(
messages.last?.content viewModel.privateChats[peerID]?.map(\.id)
== String(localized: "content.system.media_delete_refused") == [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 @Test @MainActor
func clearPrivateChatTombstonesIncomingAndCancelsOutgoingBeforeClear() { func clearPrivateChatTombstonesIncomingAndCancelsOutgoingBeforeClear() {
let (viewModel, transport) = makeTestableViewModel() let (viewModel, transport) = makeTestableViewModel()
@@ -1378,17 +1415,10 @@ struct ChatViewModelPrivateMediaDeletionTests {
#expect( #expect(
transport.deletedPrivateMediaMessageIDBatches == [[incomingID]] transport.deletedPrivateMediaMessageIDBatches == [[incomingID]]
) )
let messages = viewModel.privateChats[peerID] ?? []
#expect( #expect(
messages.prefix(3).map(\.id) viewModel.privateChats[peerID]?.map(\.id)
== [incomingID, outgoingID, "ordinary-message"] == [incomingID, outgoingID, "ordinary-message"]
) )
// The refused /clear is surfaced in the affected chat.
#expect(messages.last?.sender == "system")
#expect(
messages.last?.content
== String(localized: "content.system.media_delete_refused")
)
#expect(transport.cancelledTransfers == ["failed-clear-outgoing"]) #expect(transport.cancelledTransfers == ["failed-clear-outgoing"])
#expect(viewModel.messageIDToTransferId[outgoingID] == nil) #expect(viewModel.messageIDToTransferId[outgoingID] == nil)
} }
@@ -1457,12 +1487,9 @@ struct ChatViewModelPrivateMediaDeletionTests {
transport.deletedPrivateMediaRelativePaths transport.deletedPrivateMediaRelativePaths
== [[incomingID: "images/incoming/\(filename)"]] == [[incomingID: "images/incoming/\(filename)"]]
) )
let messages = viewModel.privateChats[peerID] ?? []
#expect(messages.prefix(2).map(\.id) == [incomingID, outgoingID])
#expect(messages.last?.sender == "system")
#expect( #expect(
messages.last?.content viewModel.privateChats[peerID]?.map(\.id)
== String(localized: "content.system.media_delete_refused") == [incomingID, outgoingID]
) )
} }
@@ -1795,59 +1822,7 @@ struct ChatViewModelPrivateMediaDeletionTests {
} }
@Test @MainActor @Test @MainActor
func clearPrivateChatKeepsOutgoingMediaReferencedByAnotherConversation() func clearPrivateChatLeavesAmbiguousLegacyFileForQuotaCleanup()
throws {
let (viewModel, transport) = makeTestableViewModel()
let firstPeerID = PeerID(str: String(repeating: "4", count: 64))
let aliasPeerID = PeerID(str: String(repeating: "5", count: 64))
let outgoingDirectory = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
.appendingPathComponent("files/images/outgoing", isDirectory: true)
try FileManager.default.createDirectory(
at: outgoingDirectory,
withIntermediateDirectories: true
)
let fileURL = outgoingDirectory.appendingPathComponent(
"outgoing-mirrored-\(UUID().uuidString).jpg"
)
try Data("outgoing-image".utf8).write(to: fileURL, options: .atomic)
defer { try? FileManager.default.removeItem(at: fileURL) }
let message = privateMediaMessage(
id: UUID().uuidString,
sender: viewModel.nickname,
senderPeerID: transport.myPeerID,
recipient: "Peer",
filename: fileURL.lastPathComponent
)
viewModel.seedPrivateChat([message], for: firstPeerID)
viewModel.seedPrivateChat([message], for: aliasPeerID)
viewModel.clearPrivateChat(firstPeerID)
// The mirrored conversation keeps its bubble and the payload file:
// clearing conversation A must not reach across an identity-alias
// handoff into conversation B.
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
#expect(viewModel.privateChats[firstPeerID]?.isEmpty == true)
#expect(
viewModel.privateChats[aliasPeerID]?.map(\.id) == [message.id]
)
#expect(FileManager.default.fileExists(atPath: fileURL.path))
// Clearing the last conversation that references the outgoing
// payload removes both the bubble and the file.
viewModel.clearPrivateChat(aliasPeerID)
#expect((viewModel.privateChats[aliasPeerID] ?? []).isEmpty)
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
}
@Test @MainActor
func clearPrivateChatUnlinksLegacyFileOnlyAfterLastReference()
throws { throws {
let (viewModel, transport) = makeTestableViewModel() let (viewModel, transport) = makeTestableViewModel()
let firstPeerID = PeerID(str: String(repeating: "9", count: 64)) let firstPeerID = PeerID(str: String(repeating: "9", count: 64))
@@ -1880,113 +1855,14 @@ struct ChatViewModelPrivateMediaDeletionTests {
viewModel.clearPrivateChat(firstPeerID) viewModel.clearPrivateChat(firstPeerID)
// A surviving mirror in another conversation keeps the payload.
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty) #expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
#expect(transport.removedLegacyPrivateMediaPaths.isEmpty)
#expect(FileManager.default.fileExists(atPath: fileURL.path)) #expect(FileManager.default.fileExists(atPath: fileURL.path))
#expect(viewModel.privateChats[aliasPeerID]?.map(\.id) == [message.id]) #expect(viewModel.privateChats[aliasPeerID]?.map(\.id) == [message.id])
viewModel.clearPrivateChat(aliasPeerID) viewModel.clearPrivateChat(aliasPeerID)
// Clearing the last reference routes the legacy payload through the
// transport's gated unlink, which deletes it (nothing pending or
// reserved names this basename).
#expect((viewModel.privateChats[aliasPeerID] ?? []).isEmpty)
#expect(
transport.removedLegacyPrivateMediaPaths
== ["images/incoming/\(fileURL.lastPathComponent)"]
)
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
}
@Test @MainActor
func deleteLegacyIncomingMediaUnlinksUnreferencedPayload() throws {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: String(repeating: "b", 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-delete-\(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: peerID,
recipient: viewModel.nickname,
filename: fileURL.lastPathComponent
)
viewModel.seedPrivateChat([message], for: peerID)
viewModel.deleteMediaMessage(messageID: message.id)
// Legacy incoming media has no stable receipt, so no journal batch
// but the explicit delete must still remove the decrypted payload
// through the gated unlink.
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
#expect((viewModel.privateChats[peerID] ?? []).isEmpty)
#expect(
transport.removedLegacyPrivateMediaPaths
== ["images/incoming/\(fileURL.lastPathComponent)"]
)
#expect(!FileManager.default.fileExists(atPath: fileURL.path))
}
@Test @MainActor
func deleteLegacyIncomingMediaKeepsPayloadReferencedByAnotherBubble()
throws {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: String(repeating: "c", 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-shared-\(UUID().uuidString).jpg"
)
try Data("legacy-image".utf8).write(to: fileURL, options: .atomic)
defer { try? FileManager.default.removeItem(at: fileURL) }
let deleted = privateMediaMessage(
id: UUID().uuidString,
sender: "Old client",
senderPeerID: peerID,
recipient: viewModel.nickname,
filename: fileURL.lastPathComponent
)
// A different bubble (different ID) references the same basename.
let survivor = privateMediaMessage(
id: UUID().uuidString,
sender: "Old client",
senderPeerID: peerID,
recipient: viewModel.nickname,
filename: fileURL.lastPathComponent
)
viewModel.seedPrivateChat([deleted, survivor], for: peerID)
viewModel.deleteMediaMessage(messageID: deleted.id)
#expect(
viewModel.privateChats[peerID]?.map(\.id) == [survivor.id]
)
#expect(transport.removedLegacyPrivateMediaPaths.isEmpty)
#expect(FileManager.default.fileExists(atPath: fileURL.path)) #expect(FileManager.default.fileExists(atPath: fileURL.path))
#expect((viewModel.privateChats[aliasPeerID] ?? []).isEmpty)
} }
@Test @MainActor @Test @MainActor
+65 -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") @Suite("ConversationStore")
struct ConversationStoreTests { struct ConversationStoreTests {
@@ -288,282 +140,6 @@ struct ConversationStoreTests {
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent) #expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
} }
@Test("steady-state cap trimming keeps lookups exact across mixed mutations")
@MainActor
func steadyStateCapTrimmingKeepsLogicalIndexExact() {
let store = ConversationStore()
let conversation = store.conversation(for: .mesh)
let overflow = 64
for i in 0..<(conversation.cap + overflow) {
store.append(makeMessage(id: "m\(i)", timestamp: TimeInterval(i)), to: .mesh)
}
#expect(conversation.messages.first?.id == "m\(overflow)")
#expect(conversation.message(withID: "m\(overflow)")?.id == "m\(overflow)")
// Exercise a suffix reindex after the head offset has advanced, then
// trim the old head. The late row becomes the new first element.
let late = makeMessage(id: "late", timestamp: TimeInterval(overflow) + 0.5)
#expect(store.append(late, to: .mesh))
#expect(conversation.messages.first?.id == "late")
#expect(conversation.message(withID: "m\(overflow + 1)")?.id == "m\(overflow + 1)")
// Head and middle removals, an in-place upsert, and a status update
// must all resolve through the same logical index representation.
#expect(store.removeMessage(withID: "late", from: .mesh)?.id == "late")
let middleID = "m\(overflow + conversation.cap / 2)"
#expect(store.removeMessage(withID: middleID, from: .mesh)?.id == middleID)
let probeID = "m\(overflow + 10)"
store.upsertByID(
makeMessage(id: probeID, timestamp: TimeInterval(overflow + 10), content: "edited"),
in: .mesh
)
#expect(conversation.message(withID: probeID)?.content == "edited")
#expect(store.setDeliveryStatus(.sent, forMessageID: probeID, in: .mesh))
#expect(conversation.message(withID: probeID)?.deliveryStatus == .sent)
#expect(store.auditInvariants().isEmpty)
// Clearing resets the logical offset as well as the maps.
store.clear(.mesh)
#expect(store.append(makeMessage(id: "after-clear", timestamp: 10_000), to: .mesh))
#expect(conversation.message(withID: "after-clear")?.id == "after-clear")
#expect(store.auditInvariants().isEmpty)
}
@Test("logical index offset matches a reference model under adversarial mutations")
@MainActor
func logicalIndexOffsetDifferentialStress() async {
let store = ConversationStore()
let cap = store.conversation(for: .mesh).cap
var reference = ReferenceConversationTimeline(cap: cap)
var rng = ConversationStoreDifferentialRNG(seed: 0xC0FF_EE13_37CA_FE42)
var issuedIDs: [String] = []
var nextID = 0
var nextTailTimestamp: TimeInterval = 1_700_000_000
var trimmedCount = 0
var tailAppendCount = 0
var outOfOrderCount = 0
var duplicateOrReuseCount = 0
var headRemovalCount = 0
var middleRemovalCount = 0
var upsertCount = 0
var deliveryUpdateCount = 0
var filterCount = 0
var clearCount = 0
func issueMessage(timestamp: TimeInterval? = nil, tag: String) -> BitchatMessage {
let number = nextID
nextID += 1
let id = "diff-\(number)"
issuedIDs.append(id)
let resolvedTimestamp: TimeInterval
if let timestamp {
resolvedTimestamp = timestamp
} else {
resolvedTimestamp = nextTailTimestamp
nextTailTimestamp += 1
}
let dropMarker = number.isMultiple(of: 11) ? " [drop]" : ""
return makeMessage(
id: id,
timestamp: resolvedTimestamp,
content: "\(tag) \(number)\(dropMarker)"
)
}
@discardableResult
func appendAndCompare(_ message: BitchatMessage, checkpoint: String) -> ReferenceConversationTimeline.AppendResult {
let expected = reference.append(message)
let actual = store.append(message, to: .mesh)
#expect(actual == expected.inserted, "append result mismatch at \(checkpoint)")
trimmedCount += expected.trimmedCount
return expected
}
func refill(extra: Int, checkpoint: String) async {
let appendCount = max(0, cap - reference.messages.count) + extra
for index in 0..<appendCount {
appendAndCompare(
issueMessage(tag: "refill"),
checkpoint: "\(checkpoint)-\(index)"
)
if index.isMultiple(of: 64) {
await Task.yield()
}
}
expectStore(store, matches: reference, issuedIDs: issuedIDs, checkpoint: checkpoint)
}
// Start well into steady state so the offset is already non-zero
// before any mixed operations begin.
await refill(extra: 384, checkpoint: "initial steady-state fill")
for step in 0..<1_200 {
if step == 300 || step == 900 {
store.removeMessages(from: .mesh) { $0.content.contains("[drop]") }
reference.removeAll { $0.content.contains("[drop]") }
filterCount += 1
expectStore(
store,
matches: reference,
issuedIDs: issuedIDs,
checkpoint: "filter at step \(step)"
)
}
if step == 600 {
store.clear(.mesh)
reference.clear()
clearCount += 1
expectStore(
store,
matches: reference,
issuedIDs: issuedIDs,
checkpoint: "clear at step \(step)"
)
}
switch rng.index(upperBound: 100) {
case 0..<35:
appendAndCompare(issueMessage(tag: "tail"), checkpoint: "tail append \(step)")
tailAppendCount += 1
case 35..<55:
if reference.messages.isEmpty {
appendAndCompare(issueMessage(tag: "tail-fallback"), checkpoint: "OOO fallback \(step)")
} else {
let target = reference.messages[rng.index(upperBound: reference.messages.count)]
let jitter = [-0.25, 0.0, 0.25][rng.index(upperBound: 3)]
let timestamp = target.timestamp.timeIntervalSince1970 + jitter
appendAndCompare(
issueMessage(timestamp: timestamp, tag: "out-of-order"),
checkpoint: "out-of-order append \(step)"
)
outOfOrderCount += 1
}
case 55..<65:
if issuedIDs.isEmpty {
appendAndCompare(issueMessage(tag: "reuse-fallback"), checkpoint: "reuse fallback \(step)")
} else {
let reusedID = issuedIDs[rng.index(upperBound: issuedIDs.count)]
let message = makeMessage(
id: reusedID,
timestamp: nextTailTimestamp,
content: "duplicate-or-trimmed-reuse \(step)"
)
nextTailTimestamp += 1
appendAndCompare(message, checkpoint: "duplicate or reuse \(step)")
duplicateOrReuseCount += 1
}
case 65..<73:
if !reference.messages.isEmpty {
let expected = reference.remove(at: 0)
let actual = store.removeMessage(withID: expected.id, from: .mesh)
.map(ReferenceConversationTimeline.Message.init)
#expect(actual == expected, "head removal mismatch at step \(step)")
headRemovalCount += 1
}
case 73..<81:
if !reference.messages.isEmpty {
let middleStart = reference.messages.count / 4
let middleWidth = max(1, reference.messages.count / 2)
let index = min(
reference.messages.count - 1,
middleStart + rng.index(upperBound: middleWidth)
)
let expected = reference.remove(at: index)
let actual = store.removeMessage(withID: expected.id, from: .mesh)
.map(ReferenceConversationTimeline.Message.init)
#expect(actual == expected, "middle removal mismatch at step \(step)")
middleRemovalCount += 1
}
case 81..<90:
let message: BitchatMessage
if step.isMultiple(of: 4) || reference.messages.isEmpty {
let timestamp = reference.messages.isEmpty
? nil
: reference.messages[rng.index(upperBound: reference.messages.count)]
.timestamp.timeIntervalSince1970
message = issueMessage(timestamp: timestamp, tag: "upsert-new")
} else {
let current = reference.messages[rng.index(upperBound: reference.messages.count)]
message = makeMessage(
id: current.id,
timestamp: current.timestamp.timeIntervalSince1970,
content: "upsert-existing \(step)",
deliveryStatus: current.deliveryStatus
)
}
trimmedCount += reference.upsert(message)
store.upsertByID(message, in: .mesh)
upsertCount += 1
default:
let id: String
let repeatedStatus: DeliveryStatus?
if step.isMultiple(of: 6) || reference.messages.isEmpty {
id = "missing-\(step)"
repeatedStatus = nil
} else {
let current = reference.messages[rng.index(upperBound: reference.messages.count)]
id = current.id
repeatedStatus = current.deliveryStatus
}
let status: DeliveryStatus
if step.isMultiple(of: 4), let repeatedStatus {
status = repeatedStatus
} else {
status = .delivered(
to: "peer",
at: Date(timeIntervalSince1970: 2_000_000_000 + Double(step))
)
}
let expected = reference.applyDeliveryStatus(status, to: id)
let actual = store.setDeliveryStatus(status, forMessageID: id, in: .mesh)
#expect(actual == expected, "delivery update mismatch at step \(step)")
deliveryUpdateCount += 1
}
expectStore(
store,
matches: reference,
issuedIDs: issuedIDs,
checkpoint: "mixed operation \(step)"
)
// This intentionally expensive MainActor stress test runs beside
// async audio/UI tests in SwiftPM's parallel phase. Cooperatively
// release the actor so their bounded waits can make progress.
await Task.yield()
if (step + 1).isMultiple(of: 100) {
await refill(extra: 32, checkpoint: "periodic refill after step \(step)")
}
}
// Guarantee another long run of one-row evictions after every other
// mutation family has perturbed and rebuilt the offset/index state.
await refill(extra: 512, checkpoint: "final steady-state trim run")
#expect(trimmedCount > 1_200)
#expect(tailAppendCount > 300)
#expect(outOfOrderCount > 150)
#expect(duplicateOrReuseCount > 75)
#expect(headRemovalCount > 50)
#expect(middleRemovalCount > 50)
#expect(upsertCount > 75)
#expect(deliveryUpdateCount > 75)
#expect(filterCount == 2)
#expect(clearCount == 1)
}
// MARK: - Upsert // MARK: - Upsert
@Test("upsertByID replaces in place and appends when absent") @Test("upsertByID replaces in place and appends when absent")
@@ -1179,6 +755,71 @@ struct ConversationStoreTests {
#expect(statusChangedIDs.isEmpty) #expect(statusChangedIDs.isEmpty)
} }
@Test("peer-scoped receipt updates only authenticated direct aliases")
@MainActor
func peerScopedReceiptUpdatesOnlyAuthenticatedDirectAliases() {
let store = ConversationStore()
let ephemeralPeer = PeerID(str: "0102030405060708")
let stablePeer = PeerID(hexData: Data(repeating: 0x08, count: 32))
let otherPeer = PeerID(str: "1112131415161718")
let ephemeral = ConversationID.directPeer(ephemeralPeer)
let stable = ConversationID.directPeer(stablePeer)
let other = ConversationID.directPeer(otherPeer)
let messageID = "scoped-receipt"
let mirrored = makeMessage(
id: messageID,
timestamp: 1,
isPrivate: true,
deliveryStatus: .sent
)
store.upsertByID(mirrored, in: ephemeral)
store.upsertByID(mirrored, in: stable)
store.upsertByID(
makeMessage(
id: messageID,
timestamp: 1,
isPrivate: true,
deliveryStatus: .sent
),
in: other
)
store.upsertByID(
makeMessage(id: messageID, timestamp: 1, deliveryStatus: .sent),
in: .mesh
)
var cancellables = Set<AnyCancellable>()
var publishedIDs: [ConversationID] = []
for id in [ephemeral, stable, other, .mesh] {
store.conversation(for: id).objectWillChange
.sink { publishedIDs.append(id) }
.store(in: &cancellables)
}
var statusChangedIDs: [ConversationID] = []
store.changes
.sink { change in
if case .statusChanged(let id, messageID, _) = change,
messageID == "scoped-receipt" {
statusChangedIDs.append(id)
}
}
.store(in: &cancellables)
let delivered = DeliveryStatus.delivered(to: "bob", at: Date())
#expect(store.setDeliveryStatus(
delivered,
forMessageID: messageID,
inDirectPeerAliases: [ephemeralPeer, stablePeer]
))
#expect(Set(publishedIDs) == Set([ephemeral, stable]))
#expect(Set(statusChangedIDs) == Set([ephemeral, stable]))
#expect(store.conversation(for: ephemeral).message(withID: messageID)?.deliveryStatus == delivered)
#expect(store.conversation(for: stable).message(withID: messageID)?.deliveryStatus == delivered)
#expect(store.conversation(for: other).message(withID: messageID)?.deliveryStatus == .sent)
#expect(store.conversation(for: .mesh).message(withID: messageID)?.deliveryStatus == .sent)
}
// MARK: - Invariant audit (field observability) // MARK: - Invariant audit (field observability)
/// A store exercised through every intent family: public + geohash + /// A store exercised through every intent family: public + geohash +
@@ -957,7 +957,7 @@ struct PrivateMediaEndToEndTests {
} }
@Test @Test
func consentedLegacySendRejectsAboveAndroidFragmentCapButEncryptedDoesNot() async throws { func encryptedAndConsentedLegacySendsRejectAboveAndroidFragmentCap() async throws {
let root = FileManager.default.temporaryDirectory let root = FileManager.default.temporaryDirectory
.appendingPathComponent("private-media-fragment-cap-\(UUID().uuidString)", isDirectory: true) .appendingPathComponent("private-media-fragment-cap-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: root) } defer { try? FileManager.default.removeItem(at: root) }
@@ -1006,24 +1006,14 @@ struct PrivateMediaEndToEndTests {
allowLegacyFallback: true allowLegacyFallback: true
) )
// The directed raw-file migration fallback (Android-style peer without let bothRejected = await TestHelpers.waitUntil(
// the .privateMedia capability) still honors the 256-fragment ceiling. { rejections.contains(encryptedID) && rejections.contains(legacyID) },
let legacyRejected = await TestHelpers.waitUntil(
{ rejections.contains(legacyID) },
timeout: TestConstants.longTimeout timeout: TestConstants.longTimeout
) )
#expect(legacyRejected) #expect(bothRejected)
#expect(rejections.reason(for: encryptedID)?.contains("256") == true)
#expect(rejections.reason(for: legacyID)?.contains("256") == true) #expect(rejections.reason(for: legacyID)?.contains("256") == true)
#expect(tap.snapshot().isEmpty, "No outer packet or fragment may be exposed before size rejection")
// 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))
_ = cancellable _ = cancellable
} }
+20 -5
View File
@@ -326,11 +326,26 @@ struct ChatViewModelPresenceHandlingTests {
#expect(viewModel.geohashParticipantCount(for: activeGeohash) >= 1) #expect(viewModel.geohashParticipantCount(for: activeGeohash) >= 1)
} }
// NOTE: Tampered-signature rejection (and the forged-copy dedup-poisoning @Test func subscribeNostrEvent_samplingInvalidSignatureDoesNotPoisonDedup() async throws {
// invariant) is enforced once, off the main actor, at the relay boundary let (viewModel, _) = makeTestableViewModel()
// the sampling path only ever sees verified events. See let sampleGeohash = "u4pru"
// NostrRelayManagerTests let identity = try NostrIdentity.generate()
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache`. 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 // MARK: - Test Helper
+15 -12
View File
@@ -65,11 +65,15 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting {
var peerFingerprints: [PeerID: String] = [:] var peerFingerprints: [PeerID: String] = [:]
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:] var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:] var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:]
var privateMediaReceiptSessionGenerations: [PeerID: UUID] = [:]
var persistDeletedPrivateMediaResult = true var persistDeletedPrivateMediaResult = true
var deferDeletedPrivateMediaPersistence = false var deferDeletedPrivateMediaPersistence = false
private var pendingDeletedPrivateMediaCompletions: [ private var pendingDeletedPrivateMediaCompletions: [
@MainActor (Bool) -> Void @MainActor (Bool) -> Void
] = [] ] = []
/// Optional synchronous hook for send-ordering tests (for example, an ack
/// arriving before the router's send call returns).
var onSendPrivateMessage: (@MainActor (_ messageID: String) -> Void)?
private let mockKeychain = MockKeychain() private let mockKeychain = MockKeychain()
// MARK: - Transport Protocol Implementation // MARK: - Transport Protocol Implementation
@@ -174,6 +178,11 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting {
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) { func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
sentPrivateMessages.append((content, peerID, recipientNickname, messageID)) sentPrivateMessages.append((content, peerID, recipientNickname, messageID))
if let onSendPrivateMessage {
MainActor.assumeIsolated {
onSendPrivateMessage(messageID)
}
}
} }
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) { func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
@@ -215,6 +224,12 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting {
privateMediaPolicies[peerID] ?? .encrypted privateMediaPolicies[peerID] ?? .encrypted
} }
func authenticatedPrivateMediaReceiptSessionGeneration(
to peerID: PeerID
) -> UUID? {
privateMediaReceiptSessionGenerations[peerID]
}
func resolvePrivateMediaSendPolicy( func resolvePrivateMediaSendPolicy(
to peerID: PeerID, to peerID: PeerID,
completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void completion: @escaping @MainActor (PrivateMediaSendPolicy) -> Void
@@ -255,18 +270,6 @@ final class MockTransport: Transport, PrivateMediaDeletionPersisting {
completion(result ?? persistDeletedPrivateMediaResult) completion(result ?? persistDeletedPrivateMediaResult)
} }
/// Real store instance so view-model tests exercise the gated legacy
/// unlink end to end (same Application Support tree the tests write to).
let legacyIncomingFileStore = BLEIncomingFileStore()
private(set) var removedLegacyPrivateMediaPaths: [String] = []
func removeLegacyPrivateMediaPayload(relativePath: String) {
removedLegacyPrivateMediaPaths.append(relativePath)
legacyIncomingFileStore.removeLegacyIncomingFile(
relativePath: relativePath
)
}
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) { func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
sentVerifyChallenges.append((peerID, noiseKeyHex, nonceA)) sentVerifyChallenges.append((peerID, noiseKeyHex, nonceA))
} }
@@ -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 @MainActor
final class GeoRelayDirectoryTests: XCTestCase { final class GeoRelayDirectoryTests: XCTestCase {
private func parse(_ csv: String) -> [GeoRelayDirectory.Entry] { func test_parseCSV_normalizesRelaySchemesAndDeduplicatesEntries() {
GeoRelayDirectory.validatedEntries(
from: Data(csv.utf8),
policy: .live,
minimumEntries: 1
) ?? []
}
func test_parseCSV_normalizesSecureRelaySchemesAndDeduplicatesEntries() {
let csv = """ let csv = """
relay url,lat,lon relay url,lat,lon
wss://one.example/,10,20 wss://one.example/,10,20
https://one.example,10,20 https://one.example,10,20
wss://one.example:443/,10,20 wss://one.example:443/,10,20
two.example,11,21 http://two.example/,11,21
wss://two.example:443,11,21 wss://two.example:443,11,21
invalid row
ws://three.example,not-a-lat,22
""" """
let parsed = Set(parse(csv)) let parsed = Set(GeoRelayDirectory.parseCSV(csv))
XCTAssertEqual( XCTAssertEqual(
parsed, 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() { func test_closestRelays_sortsByDistanceForLatLonAndGeohash() {
let harness = makeHarness( let harness = makeHarness(
cacheCSV: """ cacheCSV: """
@@ -290,15 +154,11 @@ final class GeoRelayDirectoryTests: XCTestCase {
harness.userDefaults.set(harness.clock.now, forKey: "georelay.lastFetchAt") harness.userDefaults.set(harness.clock.now, forKey: "georelay.lastFetchAt")
let directory = GeoRelayDirectory(dependencies: harness.dependencies) let directory = GeoRelayDirectory(dependencies: harness.dependencies)
let baselineRequestCount = await harness.fetcher.recordedRequestCount()
directory.prefetchIfNeeded() 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) try? await Task.sleep(nanoseconds: 20_000_000)
let requestCount = await harness.fetcher.recordedRequestCount() let requestCount = await harness.fetcher.recordedRequestCount()
XCTAssertEqual(requestCount, baselineRequestCount) XCTAssertEqual(requestCount, 0)
XCTAssertFalse(directory.debugHasRetryTask) XCTAssertFalse(directory.debugHasRetryTask)
} }
@@ -323,11 +183,9 @@ final class GeoRelayDirectoryTests: XCTestCase {
XCTAssertFalse(directory.debugHasRetryTask) XCTAssertFalse(directory.debugHasRetryTask)
directory.prefetchIfNeeded(force: true) 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) try? await Task.sleep(nanoseconds: 20_000_000)
let forcedRequestCount = await harness.fetcher.recordedRequestCount() let forcedRequestCount = await harness.fetcher.recordedRequestCount()
XCTAssertEqual(forcedRequestCount, requestCount) XCTAssertEqual(forcedRequestCount, 1)
} }
func test_prefetchIfNeeded_runsRemoteFetchOffMainThread() async { func test_prefetchIfNeeded_runsRemoteFetchOffMainThread() async {
@@ -385,53 +243,6 @@ final class GeoRelayDirectoryTests: XCTestCase {
XCTAssertFalse(directory.debugHasRetryTask) XCTAssertFalse(directory.debugHasRetryTask)
} }
func test_prefetchIfNeeded_rejectsSharpValidLookingTruncationBeforeCaching() async {
let cached = """
relay url,lat,lon
old-one.example,1,1
old-two.example,2,2
old-three.example,3,3
"""
let truncated = """
relay url,lat,lon
attacker.example,9,9
"""
let recovered = """
relay url,lat,lon
old-one.example,1,1
old-two.example,2,2
new-three.example,6,6
"""
let harness = makeHarness(
cacheCSV: cached,
fetchResults: [
.success(Data(truncated.utf8)),
.success(Data(recovered.utf8))
],
validationPolicy: GeoRelayDirectoryValidationPolicy(
maximumBytes: 64 * 1024,
maximumRows: 1_000,
maximumEntries: 1_000,
minimumRemoteEntries: 1,
minimumRetainedFraction: 0.5
)
)
let directory = GeoRelayDirectory(dependencies: harness.dependencies)
directory.prefetchIfNeeded()
let refreshed = await waitUntil {
directory.entries.contains(where: { $0.host == "new-three.example" })
}
XCTAssertTrue(refreshed)
XCTAssertFalse(directory.entries.contains(where: { $0.host == "attacker.example" }))
let requestCount = await harness.fetcher.recordedRequestCount()
let retryDelays = await harness.retryRecorder.recordedDelays()
XCTAssertEqual(requestCount, 2)
XCTAssertEqual(retryDelays, [5])
XCTAssertEqual(harness.fileStore.dataByURL[harness.cacheURL], Data(recovered.utf8))
}
func test_observers_triggerPrefetchesForTorReadyAndAppActivation() async { func test_observers_triggerPrefetchesForTorReadyAndAppActivation() async {
let activeNotification = Notification.Name("GeoRelayDirectoryTests.didBecomeActive") let activeNotification = Notification.Name("GeoRelayDirectoryTests.didBecomeActive")
let harness = makeHarness( let harness = makeHarness(
@@ -442,43 +253,26 @@ final class GeoRelayDirectoryTests: XCTestCase {
autoStart: true, autoStart: true,
activeNotificationName: activeNotification 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) var directory: GeoRelayDirectory? = GeoRelayDirectory(dependencies: harness.dependencies)
let initialFetch = await waitUntil { refreshCount == 1 } let initialFetch = await waitUntil {
await harness.fetcher.recordedRequestCount() == 1
}
XCTAssertTrue(initialFetch) XCTAssertTrue(initialFetch)
var requestCount = await harness.fetcher.recordedRequestCount()
XCTAssertEqual(requestCount, 1)
XCTAssertEqual(directory?.debugObserverCount, 2) XCTAssertEqual(directory?.debugObserverCount, 2)
harness.clock.now = harness.clock.now.addingTimeInterval(6) harness.clock.now = harness.clock.now.addingTimeInterval(6)
harness.notificationCenter.post(name: .TorDidBecomeReady, object: nil) harness.notificationCenter.post(name: .TorDidBecomeReady, object: nil)
let torTriggered = await waitUntil { refreshCount == 2 } let torTriggered = await waitUntil {
await harness.fetcher.recordedRequestCount() == 2
}
XCTAssertTrue(torTriggered) XCTAssertTrue(torTriggered)
requestCount = await harness.fetcher.recordedRequestCount()
XCTAssertEqual(requestCount, 2)
harness.clock.now = harness.clock.now.addingTimeInterval(61) harness.clock.now = harness.clock.now.addingTimeInterval(61)
harness.notificationCenter.post(name: activeNotification, object: nil) harness.notificationCenter.post(name: activeNotification, object: nil)
let activeTriggered = await waitUntil { refreshCount == 3 } let activeTriggered = await waitUntil {
await harness.fetcher.recordedRequestCount() == 3
}
XCTAssertTrue(activeTriggered) XCTAssertTrue(activeTriggered)
requestCount = await harness.fetcher.recordedRequestCount()
XCTAssertEqual(requestCount, 3)
weak var weakDirectory: GeoRelayDirectory? weak var weakDirectory: GeoRelayDirectory?
weakDirectory = directory weakDirectory = directory
@@ -495,14 +289,7 @@ final class GeoRelayDirectoryTests: XCTestCase {
fetchFactoryObserver: (@MainActor @Sendable () -> Void)? = nil, fetchFactoryObserver: (@MainActor @Sendable () -> Void)? = nil,
fetchObserver: (@Sendable () async -> Void)? = nil, fetchObserver: (@Sendable () async -> Void)? = nil,
autoStart: Bool = false, autoStart: Bool = false,
activeNotificationName: Notification.Name? = nil, activeNotificationName: Notification.Name? = nil
validationPolicy: GeoRelayDirectoryValidationPolicy = GeoRelayDirectoryValidationPolicy(
maximumBytes: 64 * 1024,
maximumRows: 1_000,
maximumEntries: 1_000,
minimumRemoteEntries: 1,
minimumRetainedFraction: 0
)
) -> GeoRelayHarness { ) -> GeoRelayHarness {
let userDefaultsSuite = "GeoRelayDirectoryTests.\(UUID().uuidString)" let userDefaultsSuite = "GeoRelayDirectoryTests.\(UUID().uuidString)"
let userDefaults = UserDefaults(suiteName: userDefaultsSuite)! let userDefaults = UserDefaults(suiteName: userDefaultsSuite)!
@@ -560,8 +347,7 @@ final class GeoRelayDirectoryTests: XCTestCase {
await retryRecorder.record(delay) await retryRecorder.record(delay)
}, },
activeNotificationName: activeNotificationName, activeNotificationName: activeNotificationName,
autoStart: autoStart, autoStart: autoStart
validationPolicy: validationPolicy
) )
return GeoRelayHarness( return GeoRelayHarness(
@@ -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( private func waitUntil(
timeout: TimeInterval = 10.0, timeout: TimeInterval = 1.0,
condition: @escaping @MainActor () async -> Bool condition: @escaping @MainActor () async -> Bool
) async -> Bool { ) async -> Bool {
let deadline = Date().addingTimeInterval(timeout) let deadline = Date().addingTimeInterval(timeout)
+486 -58
View File
@@ -2,18 +2,17 @@
// NostrProtocolTests.swift // NostrProtocolTests.swift
// bitchatTests // bitchatTests
// //
// Tests for NIP-17 gift-wrapped private messages // Tests for BitChat's proprietary private-envelope transport over Nostr.
// //
import Testing import Testing
import CryptoKit
import Foundation import Foundation
import BitFoundation import BitFoundation
@testable import bitchat @testable import bitchat
struct NostrProtocolTests { struct NostrProtocolTests {
@Test func nip17MessageRoundTrip() throws { @Test func privateEnvelopeRoundTrip() throws {
// Create sender and recipient identities // Create sender and recipient identities
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
@@ -22,21 +21,19 @@ struct NostrProtocolTests {
print("Recipient pubkey: \(recipient.publicKeyHex)") print("Recipient pubkey: \(recipient.publicKeyHex)")
// Create a test message // Create a test message
let originalContent = "Hello from NIP-17 test!" let originalContent = "Hello from BitChat private-envelope test!"
// Create encrypted gift wrap let envelope = try NostrProtocol.createPrivateEnvelope(
let giftWrap = try NostrProtocol.createPrivateMessage(
content: originalContent, content: originalContent,
recipientPubkey: recipient.publicKeyHex, recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender senderIdentity: sender
) )
print("Gift wrap created with ID: \(giftWrap.id)") print("Private envelope created with ID: \(envelope.id)")
print("Gift wrap pubkey: \(giftWrap.pubkey)") print("Private envelope pubkey: \(envelope.pubkey)")
// Decrypt the gift wrap let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateEnvelope(
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage( envelope: envelope,
giftWrap: giftWrap,
recipientIdentity: recipient recipientIdentity: recipient
) )
@@ -52,37 +49,37 @@ struct NostrProtocolTests {
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)") print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
} }
@Test func giftWrapUsesUniqueEphemeralKeys() throws { @Test func privateEnvelopesUseUniqueEphemeralKeys() throws {
// Create identities // Create identities
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
// Create two messages // Create two messages
let message1 = try NostrProtocol.createPrivateMessage( let message1 = try NostrProtocol.createPrivateEnvelope(
content: "Message 1", content: "Message 1",
recipientPubkey: recipient.publicKeyHex, recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender senderIdentity: sender
) )
let message2 = try NostrProtocol.createPrivateMessage( let message2 = try NostrProtocol.createPrivateEnvelope(
content: "Message 2", content: "Message 2",
recipientPubkey: recipient.publicKeyHex, recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender senderIdentity: sender
) )
// Gift wrap pubkeys should be different (unique ephemeral keys) // Public envelope keys must be one-time use.
#expect(message1.pubkey != message2.pubkey) #expect(message1.pubkey != message2.pubkey)
print("Message 1 gift wrap pubkey: \(message1.pubkey)") print("Message 1 envelope pubkey: \(message1.pubkey)")
print("Message 2 gift wrap pubkey: \(message2.pubkey)") print("Message 2 envelope pubkey: \(message2.pubkey)")
// Both should decrypt successfully // Both should decrypt successfully
let (content1, _, _) = try NostrProtocol.decryptPrivateMessage( let (content1, _, _) = try NostrProtocol.decryptPrivateEnvelope(
giftWrap: message1, envelope: message1,
recipientIdentity: recipient recipientIdentity: recipient
) )
let (content2, _, _) = try NostrProtocol.decryptPrivateMessage( let (content2, _, _) = try NostrProtocol.decryptPrivateEnvelope(
giftWrap: message2, envelope: message2,
recipientIdentity: recipient recipientIdentity: recipient
) )
@@ -90,74 +87,431 @@ struct NostrProtocolTests {
#expect(content2 == "Message 2") #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 { @Test func decryptionFailsWithWrongRecipient() throws {
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
let wrongRecipient = try NostrIdentity.generate() let wrongRecipient = try NostrIdentity.generate()
// Create message for recipient // Create message for recipient
let giftWrap = try NostrProtocol.createPrivateMessage( let envelope = try NostrProtocol.createPrivateEnvelope(
content: "Secret message", content: "Secret message",
recipientPubkey: recipient.publicKeyHex, recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender senderIdentity: sender
) )
// Try to decrypt with wrong recipient expectInvalidEvent {
if #available(macOS 14.4, iOS 17.4, *) { _ = try NostrProtocol.decryptPrivateEnvelope(
#expect(throws: CryptoKitError.authenticationFailure) { envelope: envelope,
try NostrProtocol.decryptPrivateMessage( recipientIdentity: wrongRecipient
giftWrap: giftWrap, )
recipientIdentity: wrongRecipient
)
}
} else {
#expect(throws: (any Error).self) {
try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: wrongRecipient
)
}
} }
} }
@Test func decryptRejectsInvalidSealSignature() throws { @Test func decryptRejectsInvalidSealSignature() throws {
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
let giftWrap = try NostrProtocol.createPrivateMessageWithInvalidSealSignatureForTesting( let envelope = try NostrProtocol.createPrivateEnvelopeWithInvalidSealSignatureForTesting(
content: "forged signature", content: "forged signature",
recipientPubkey: recipient.publicKeyHex, recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender senderIdentity: sender
) )
expectInvalidEvent { expectInvalidEvent {
_ = try NostrProtocol.decryptPrivateMessage( _ = try NostrProtocol.decryptPrivateEnvelope(
giftWrap: giftWrap, envelope: envelope,
recipientIdentity: recipient recipientIdentity: recipient
) )
} }
} }
@Test func decryptRejectsSealRumorPubkeyMismatch() throws { @Test func decryptRejectsSealMessagePubkeyMismatch() throws {
let claimedSender = try NostrIdentity.generate() let claimedSender = try NostrIdentity.generate()
let sealSigner = try NostrIdentity.generate() let sealSigner = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
let giftWrap = try NostrProtocol.createPrivateMessageWithMismatchedSealRumorPubkeyForTesting( let envelope = try NostrProtocol.createPrivateEnvelopeWithMismatchedSealMessagePubkeyForTesting(
content: "spoofed sender", content: "spoofed sender",
recipientPubkey: recipient.publicKeyHex, recipientPubkey: recipient.publicKeyHex,
rumorIdentity: claimedSender, messageIdentity: claimedSender,
sealSignerIdentity: sealSigner sealSignerIdentity: sealSigner
) )
expectInvalidEvent { expectInvalidEvent {
_ = try NostrProtocol.decryptPrivateMessage( _ = try NostrProtocol.decryptPrivateEnvelope(
giftWrap: giftWrap, envelope: envelope,
recipientIdentity: recipient recipientIdentity: recipient
) )
} }
} }
@Test @Test
func testAckRoundTripNIP44V2_Delivered() throws { func deliveredAckRoundTripsInsidePrivateEnvelope() throws {
// Identities // Identities
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
@@ -171,19 +525,17 @@ struct NostrProtocolTests {
"Failed to embed delivered ack" "Failed to embed delivered ack"
) )
// Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally) let envelope = try NostrProtocol.createPrivateEnvelope(
let giftWrap = try NostrProtocol.createPrivateMessage(
content: embedded, content: embedded,
recipientPubkey: recipient.publicKeyHex, recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender senderIdentity: sender
) )
// Ensure v2 format was used for ciphertext #expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
#expect(giftWrap.content.hasPrefix("v2:"))
// Decrypt as recipient // Decrypt as recipient
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage( let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
giftWrap: giftWrap, envelope: envelope,
recipientIdentity: recipient recipientIdentity: recipient
) )
@@ -208,7 +560,7 @@ struct NostrProtocolTests {
} }
} }
@Test func ackRoundTripNIP44V2_ReadReceipt() throws { @Test func readReceiptRoundTripsInsidePrivateEnvelope() throws {
// Identities // Identities
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
@@ -220,16 +572,16 @@ struct NostrProtocolTests {
"Failed to embed read ack" "Failed to embed read ack"
) )
let giftWrap = try NostrProtocol.createPrivateMessage( let envelope = try NostrProtocol.createPrivateEnvelope(
content: embedded, content: embedded,
recipientPubkey: recipient.publicKeyHex, recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender senderIdentity: sender
) )
#expect(giftWrap.content.hasPrefix("v2:")) #expect(envelope.content.hasPrefix(NostrProtocol.privateEnvelopeContentPrefix))
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage( let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
giftWrap: giftWrap, envelope: envelope,
recipientIdentity: recipient recipientIdentity: recipient
) )
#expect(senderPubkey == sender.publicKeyHex) #expect(senderPubkey == sender.publicKeyHex)
@@ -290,7 +642,6 @@ struct NostrProtocolTests {
#expect(object["limit"] as? Int == 42) #expect(object["limit"] as? Int == 42)
} }
@Test func inboundNostrEventRejectsTooManyTags() throws { @Test func inboundNostrEventRejectsTooManyTags() throws {
var eventDict = Self.validInboundEventDict() var eventDict = Self.validInboundEventDict()
eventDict["tags"] = Array( eventDict["tags"] = Array(
@@ -336,6 +687,33 @@ struct NostrProtocolTests {
#expect(event.tags.count == 2) #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 // MARK: - Helpers
private static func validInboundEventDict() -> [String: Any] { 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? { private static func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
let rem = str.count % 4 let rem = str.count % 4
@@ -366,4 +783,15 @@ struct NostrProtocolTests {
Issue.record("Expected NostrError.invalidEvent, got \(error)") 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) // MARK: - 1a. Nostr inbound event handling (fresh events)
/// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events /// `NostrInboundPipeline.handleNostrEvent` for never-seen geo events
/// (kind 20000): dedup record, presence/nickname bookkeeping, and /// (kind 20000): signature verification, dedup record, presence/nickname
/// public-message ingest scheduling. Schnorr signature verification is /// bookkeeping, and public-message ingest scheduling.
/// NOT part of this path anymore it runs exactly once, off the main
/// actor, in `NostrRelayManager` before delivery.
func testNostrInboundEventHandling_freshEvents() throws { func testNostrInboundEventHandling_freshEvents() throws {
let events = try Self.makeSignedGeohashEvents(count: 500) let events = try Self.makeSignedGeohashEvents(count: 500)
// A fresh context per measure pass so every event takes the // 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 /// The dedup-hit path: identical events replayed. Duplicates dominate
/// real relay traffic (the same event arrives from several relays), so /// 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 /// this path runs hundreds of times a minute in busy geohashes. Note it
/// pure dedup lookup: no crypto (verification happens upstream in /// still pays full Schnorr signature verification before the dedup check.
/// `NostrRelayManager`, and only for the first-seen copy).
func testNostrInboundEventHandling_duplicateEvents() throws { func testNostrInboundEventHandling_duplicateEvents() throws {
let events = try Self.makeSignedGeohashEvents(count: 500) let events = try Self.makeSignedGeohashEvents(count: 500)
let context = PerfNostrContext() let context = PerfNostrContext()
@@ -504,62 +501,6 @@ final class PerformanceBaselineTests: XCTestCase {
reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages") reportThroughput("store.append", samples: samples, operations: messageCount, unit: "messages")
} }
// MARK: - 7b. ConversationStore append at the retention cap
/// Steady-state public timeline traffic after the 1337-message retention
/// cap has been reached. Every tail append evicts the oldest row, which is
/// the long-lived workload the cold `store.append` benchmark does not
/// exercise.
func testConversationStoreSteadyStateAppend() {
let store = ConversationStore()
let cap = TransportConfig.meshTimelineCap
let messagesPerPass = 500
let base = Date(timeIntervalSince1970: 1_700_000_000)
for i in 0..<cap {
store.append(
BitchatMessage(
id: "perf-steady-seed-\(i)",
sender: "perfsender",
content: "steady-state seed \(i)",
timestamp: base.addingTimeInterval(Double(i)),
isRelay: false
),
to: .mesh
)
}
var pass = 0
var samples: [TimeInterval] = []
measure {
let startIndex = cap + pass * messagesPerPass
let start = Date()
for offset in 0..<messagesPerPass {
let i = startIndex + offset
store.append(
BitchatMessage(
id: "perf-steady-\(i)",
sender: "perfsender",
content: "steady-state message \(i)",
timestamp: base.addingTimeInterval(Double(i)),
isRelay: false
),
to: .mesh
)
}
samples.append(Date().timeIntervalSince(start))
pass += 1
XCTAssertEqual(store.conversation(for: .mesh).messages.count, cap)
}
reportThroughput(
"store.steadyStateAppend",
samples: samples,
operations: messagesPerPass,
unit: "messages"
)
}
// MARK: - 8. ConversationStore invariant audit (field observability) // MARK: - 8. ConversationStore invariant audit (field observability)
/// `ConversationStore.auditInvariants()` over a realistic 5k-message /// `ConversationStore.auditInvariants()` over a realistic 5k-message
@@ -749,12 +690,29 @@ private final class PerfDeliveryContext: ChatDeliveryContext {
func notifyUIChanged() {} func notifyUIChanged() {}
func markMessageDelivered(_ messageID: String) {} func markMessageDelivered(_ messageID: String) {}
func markMessageDelivered(_ messageID: String, from peerIDs: Set<PeerID>) {}
func isOutgoingPrivateMessage(_ messageID: String, toAny peerIDs: Set<PeerID>) -> Bool {
true
}
@discardableResult @discardableResult
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool { func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
store.setDeliveryStatus(status, forMessageID: messageID) store.setDeliveryStatus(status, forMessageID: messageID)
} }
@discardableResult
func setDeliveryStatus(
_ status: DeliveryStatus,
forMessageID messageID: String,
inDirectPeerAliases peerIDs: Set<PeerID>
) -> Bool {
store.setDeliveryStatus(
status,
forMessageID: messageID,
inDirectPeerAliases: peerIDs
)
}
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? { func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
store.deliveryStatus(forMessageID: messageID) store.deliveryStatus(forMessageID: messageID)
} }
@@ -30,10 +30,6 @@
"store.append": 213201, "store.append": 213201,
"store.audit": 362 "store.audit": 362
}, },
"_reference_local_numbers_2026_07": {
"store.steadyStateAppend_before": 2315,
"store.steadyStateAppend": 53976
},
"floors": { "floors": {
"nostrInbound.fresh": 450, "nostrInbound.fresh": 450,
"nostrInbound.duplicate": 250000, "nostrInbound.duplicate": 250000,
@@ -45,7 +41,6 @@
"pipeline.privateIngest": 3000, "pipeline.privateIngest": 3000,
"pipeline.publicIngest": 2400, "pipeline.publicIngest": 2400,
"store.append": 48000, "store.append": 48000,
"store.steadyStateAppend": 10000,
"store.audit": 70 "store.audit": 70
}, },
"_slowest_observed_ci_numbers_2026_06": { "_slowest_observed_ci_numbers_2026_06": {
@@ -6,14 +6,11 @@ import Testing
struct BLEAnnounceHandlerTests { struct BLEAnnounceHandlerTests {
private final class Recorder { private final class Recorder {
var existingNoisePublicKey: Data? var existingNoisePublicKey: Data?
var existingSigningPublicKey: Data?
var persistedSigningPublicKey: Data?
var persistedSigningKeyQueries: [PeerID] = []
var authenticatedSigningPublicKey: Data? var authenticatedSigningPublicKey: Data?
var signatureValid = true var signatureValid = true
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false) var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
var linkBoundToOtherPeer = 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 dedupSeenIDs: Set<String> = []
var shouldEmitReconnectLogResult = true var shouldEmitReconnectLogResult = true
@@ -40,11 +37,7 @@ struct BLEAnnounceHandlerTests {
localPeerID: { localPeerID }, localPeerID: { localPeerID },
messageTTL: TransportConfig.messageTTLDefault, messageTTL: TransportConfig.messageTTLDefault,
now: { now }, now: { now },
existingPeerKeys: { _ in (recorder.existingNoisePublicKey, recorder.existingSigningPublicKey) }, existingNoisePublicKey: { _ in recorder.existingNoisePublicKey },
persistedSigningPublicKey: { peerID in
recorder.persistedSigningKeyQueries.append(peerID)
return recorder.persistedSigningPublicKey
},
authenticatedSigningPublicKey: { _ in recorder.authenticatedSigningPublicKey }, authenticatedSigningPublicKey: { _ in recorder.authenticatedSigningPublicKey },
verifySignature: { packet, signingPublicKey in verifySignature: { packet, signingPublicKey in
recorder.verifySignatureCalls.append((packet, signingPublicKey)) recorder.verifySignatureCalls.append((packet, signingPublicKey))
@@ -491,168 +484,6 @@ struct BLEAnnounceHandlerTests {
#expect(recorder.topologyUpdates.first?.neighbors == neighbors) #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 @Test
func keyMismatchWithExistingPeerKeepsAnnounceUnverified() throws { func keyMismatchWithExistingPeerKeepsAnnounceUnverified() throws {
let now = Date(timeIntervalSince1970: 1_000) let now = Date(timeIntervalSince1970: 1_000)
@@ -677,255 +508,6 @@ struct BLEAnnounceHandlerTests {
#expect(recorder.uiEventDeliveries.first?.notifyPeerConnected == false) #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) { private func expectNoSideEffects(_ recorder: Recorder) {
#expect(recorder.barrierCount == 0) #expect(recorder.barrierCount == 0)
#expect(recorder.upsertCalls.isEmpty) #expect(recorder.upsertCalls.isEmpty)
@@ -123,9 +123,7 @@ struct BLEAnnounceHandlingPolicyTests {
hasSignature: false, hasSignature: false,
signatureValid: false, signatureValid: false,
existingNoisePublicKey: nil, existingNoisePublicKey: nil,
announcedNoisePublicKey: Data(repeating: 0x11, count: 32), announcedNoisePublicKey: Data(repeating: 0x11, count: 32)
existingSigningPublicKey: nil,
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
) )
#expect(decision == .reject(.missingSignature)) #expect(decision == .reject(.missingSignature))
@@ -138,9 +136,7 @@ struct BLEAnnounceHandlingPolicyTests {
hasSignature: true, hasSignature: true,
signatureValid: false, signatureValid: false,
existingNoisePublicKey: nil, existingNoisePublicKey: nil,
announcedNoisePublicKey: Data(repeating: 0x11, count: 32), announcedNoisePublicKey: Data(repeating: 0x11, count: 32)
existingSigningPublicKey: nil,
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
) )
#expect(decision == .reject(.invalidSignature)) #expect(decision == .reject(.invalidSignature))
@@ -152,9 +148,7 @@ struct BLEAnnounceHandlingPolicyTests {
hasSignature: true, hasSignature: true,
signatureValid: true, signatureValid: true,
existingNoisePublicKey: Data(repeating: 0xAA, count: 32), existingNoisePublicKey: Data(repeating: 0xAA, count: 32),
announcedNoisePublicKey: Data(repeating: 0xBB, count: 32), announcedNoisePublicKey: Data(repeating: 0xBB, count: 32)
existingSigningPublicKey: nil,
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
) )
#expect(decision == .reject(.keyMismatch)) #expect(decision == .reject(.keyMismatch))
@@ -168,51 +162,13 @@ struct BLEAnnounceHandlingPolicyTests {
hasSignature: true, hasSignature: true,
signatureValid: true, signatureValid: true,
existingNoisePublicKey: noiseKey, existingNoisePublicKey: noiseKey,
announcedNoisePublicKey: noiseKey, announcedNoisePublicKey: noiseKey
existingSigningPublicKey: nil,
announcedSigningPublicKey: Data(repeating: 0x99, count: 32)
) )
#expect(decision == .verified) #expect(decision == .verified)
#expect(decision.isVerified) #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 @Test
func trustPolicyRejectsSigningKeyReplacementAfterNoiseBinding() { func trustPolicyRejectsSigningKeyReplacementAfterNoiseBinding() {
let noiseKey = Data(repeating: 0xCC, count: 32) let noiseKey = Data(repeating: 0xCC, count: 32)
@@ -5,7 +5,7 @@ import Testing
struct BLEAnnounceThrottleTests { struct BLEAnnounceThrottleTests {
@Test @Test
func firstAnnounceIsAllowed() { 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)) let shouldSend = throttle.shouldSend(force: false, now: Date(timeIntervalSince1970: 100))
@@ -15,7 +15,7 @@ struct BLEAnnounceThrottleTests {
@Test @Test
func regularAnnounceUsesNormalMinimumInterval() { func regularAnnounceUsesNormalMinimumInterval() {
let now = Date(timeIntervalSince1970: 100) let now = Date(timeIntervalSince1970: 100)
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2) var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
let first = throttle.shouldSend(force: false, now: now) let first = throttle.shouldSend(force: false, now: now)
let suppressed = throttle.shouldSend(force: false, now: now.addingTimeInterval(9.9)) let suppressed = throttle.shouldSend(force: false, now: now.addingTimeInterval(9.9))
@@ -29,7 +29,7 @@ struct BLEAnnounceThrottleTests {
@Test @Test
func forcedAnnounceUsesShorterMinimumInterval() { func forcedAnnounceUsesShorterMinimumInterval() {
let now = Date(timeIntervalSince1970: 100) let now = Date(timeIntervalSince1970: 100)
let throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2) var throttle = BLEAnnounceThrottle(normalMinimumInterval: 10, forcedMinimumInterval: 2)
let first = throttle.shouldSend(force: false, now: now) let first = throttle.shouldSend(force: false, now: now)
let suppressed = throttle.shouldSend(force: true, now: now.addingTimeInterval(1.9)) let suppressed = throttle.shouldSend(force: true, now: now.addingTimeInterval(1.9))
@@ -43,40 +43,10 @@ struct BLEAnnounceThrottleTests {
@Test @Test
func elapsedReportsTimeSinceAcceptedSend() { func elapsedReportsTimeSinceAcceptedSend() {
let now = Date(timeIntervalSince1970: 100) 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) _ = throttle.shouldSend(force: false, now: now)
#expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3) #expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3)
} }
@Test
func concurrentRequestsAdmitOnlyOneAnnounce() {
let now = Date(timeIntervalSince1970: 100)
let throttle = BLEAnnounceThrottle(
normalMinimumInterval: 10,
forcedMinimumInterval: 2
)
let accepted = LockedCounter()
DispatchQueue.concurrentPerform(iterations: 1_000) { _ in
if throttle.shouldSend(force: false, now: now) {
accepted.increment()
}
}
#expect(accepted.value == 1)
#expect(throttle.elapsed(since: now.addingTimeInterval(3)) == 3)
}
}
private final class LockedCounter: @unchecked Sendable {
private let lock = NSLock()
private var count = 0
var value: Int { lock.withLock { count } }
func increment() {
lock.withLock { count += 1 }
}
} }
@@ -1040,103 +1040,6 @@ struct BLEFileTransferHandlerTests {
#expect(!FileManager.default.fileExists(atPath: evictable.path)) #expect(!FileManager.default.fileExists(atPath: evictable.path))
} }
@Test
func legacyIncomingDeleteUnlinksUnreferencedPayload() throws {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent(
"legacy-unlink-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: base) }
let store = BLEIncomingFileStore(baseDirectory: base)
let incoming = try store.incomingDirectory(
subdirectory: "images/incoming"
)
let payload = incoming.appendingPathComponent("legacy.jpg")
try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload)
#expect(store.removeLegacyIncomingFile(
relativePath: "images/incoming/legacy.jpg"
))
#expect(!FileManager.default.fileExists(atPath: payload.path))
// Only paths directly inside an incoming media directory qualify.
let outgoing = base.appendingPathComponent(
"files/images/outgoing",
isDirectory: true
)
try FileManager.default.createDirectory(
at: outgoing,
withIntermediateDirectories: true
)
let victim = outgoing.appendingPathComponent("victim.jpg")
try Data([0x01]).write(to: victim)
#expect(!store.removeLegacyIncomingFile(
relativePath: "images/outgoing/victim.jpg"
))
#expect(!store.removeLegacyIncomingFile(
relativePath: "images/incoming/../outgoing/victim.jpg"
))
#expect(FileManager.default.fileExists(atPath: victim.path))
}
@Test
func legacyIncomingDeleteLeavesPendingDeliveryPayload() throws {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent(
"legacy-pending-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: base) }
let store = BLEIncomingFileStore(baseDirectory: base)
// save() marks the stored path pending until delivery finishes; a
// legacy delete naming the same basename must not unlink it.
let stored = try #require(store.save(
data: Data([0xFF, 0xD8, 0xFF, 0xD9]),
preferredName: "pending.jpg",
subdirectory: "images/incoming",
fallbackExtension: "jpg",
defaultPrefix: "img"
))
let relativePath = "images/incoming/\(stored.lastPathComponent)"
#expect(!store.removeLegacyIncomingFile(relativePath: relativePath))
#expect(FileManager.default.fileExists(atPath: stored.path))
// Once the delivery window closes the same unlink is allowed.
store.finishIncomingFileDelivery(at: stored)
#expect(store.removeLegacyIncomingFile(relativePath: relativePath))
#expect(!FileManager.default.fileExists(atPath: stored.path))
}
@Test
func legacyIncomingDeleteLeavesReceiptProtectedPayload() throws {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent(
"legacy-protected-\(UUID().uuidString)",
isDirectory: true
)
defer { try? FileManager.default.removeItem(at: base) }
let store = BLEIncomingFileStore(baseDirectory: base)
let incoming = try store.incomingDirectory(
subdirectory: "images/incoming"
)
let payload = incoming.appendingPathComponent("owned.jpg")
try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload)
// The basename belongs to a stable receipt (another record). The
// legacy delete must leave it for that owner.
#expect(store.commitPrivateMediaFile(
messageID: "media-00112233445566778899aabbccddeeff",
storedURL: payload
))
#expect(!store.removeLegacyIncomingFile(
relativePath: "images/incoming/owned.jpg"
))
#expect(FileManager.default.fileExists(atPath: payload.path))
}
@Test @Test
func panicWipeDeletesEveryManagedMediaFileAndRecreatesEmptyDirectories() throws { func panicWipeDeletesEveryManagedMediaFileAndRecreatesEmptyDirectories() throws {
let base = FileManager.default.temporaryDirectory let base = FileManager.default.temporaryDirectory
@@ -1205,40 +1108,16 @@ struct BLEFileTransferHandlerTests {
)) ))
#expect(seed.recordDeleted(messageID: messageID)) #expect(seed.recordDeleted(messageID: messageID))
// Production wiring: receipt lookups run against the service's OWN let store = BLEIncomingFileStore(baseDirectory: base)
// 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)
)
#expect( #expect(
service._test_privateMediaReceiptState(messageID: messageID) store.privateMediaReceiptState(messageID: messageID)
== .tombstoned == .tombstoned
) )
service.suspendForPanicReset() try store.panicWipe()
// 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)
#expect( #expect(
service._test_privateMediaReceiptState(messageID: messageID) store.privateMediaReceiptState(messageID: messageID)
== .absent == .absent
) )
} }
@@ -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") @Suite("BLE peer registry tests")
struct BLEPeerRegistryTests { struct BLEPeerRegistryTests {
@Test("upserted announces track new, reconnect, and rename transitions") @Test("upserted announces track new, reconnect, and rename transitions")
func upsertVerifiedAnnounceTracksTransitions() throws { func upsertVerifiedAnnounceTracksTransitions() {
var registry = BLEPeerRegistry() var registry = BLEPeerRegistry()
let peerID = PeerID(str: "1122334455667788") let peerID = PeerID(str: "1122334455667788")
let firstSeen = Date(timeIntervalSince1970: 100) let firstSeen = Date(timeIntervalSince1970: 100)
let firstResult = registry.upsertVerifiedAnnounce( let first = registry.upsertVerifiedAnnounce(
peerID: peerID, peerID: peerID,
nickname: "alice", nickname: "alice",
noisePublicKey: Data([1, 2, 3]), noisePublicKey: Data([1, 2, 3]),
@@ -19,7 +19,6 @@ struct BLEPeerRegistryTests {
isConnected: true, isConnected: true,
now: firstSeen now: firstSeen
) )
let first = try #require(firstResult)
#expect(first.isNewPeer) #expect(first.isNewPeer)
#expect(!first.wasDisconnected) #expect(!first.wasDisconnected)
@@ -28,7 +27,7 @@ struct BLEPeerRegistryTests {
#expect(registry.nickname(for: peerID, connectedOnly: true) == "alice") #expect(registry.nickname(for: peerID, connectedOnly: true) == "alice")
registry.markDisconnected(peerID) registry.markDisconnected(peerID)
let reconnectResult = registry.upsertVerifiedAnnounce( let reconnect = registry.upsertVerifiedAnnounce(
peerID: peerID, peerID: peerID,
nickname: "alice-renamed", nickname: "alice-renamed",
noisePublicKey: Data([1, 2, 3]), noisePublicKey: Data([1, 2, 3]),
@@ -36,7 +35,6 @@ struct BLEPeerRegistryTests {
isConnected: true, isConnected: true,
now: firstSeen.addingTimeInterval(1) now: firstSeen.addingTimeInterval(1)
) )
let reconnect = try #require(reconnectResult)
#expect(!reconnect.isNewPeer) #expect(!reconnect.isNewPeer)
#expect(reconnect.wasDisconnected) #expect(reconnect.wasDisconnected)
@@ -44,85 +42,6 @@ struct BLEPeerRegistryTests {
#expect(registry.info(for: peerID)?.nickname == "alice-renamed") #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") @Test("registry preserves absent versus explicit empty capabilities")
func capabilitiesPresenceIsPreserved() { func capabilitiesPresenceIsPreserved() {
var registry = BLEPeerRegistry() var registry = BLEPeerRegistry()
@@ -224,10 +224,7 @@ struct BLEPrivateMediaReceiptStoreTests {
) )
#expect(FileManager.default.fileExists(atPath: victim.path)) #expect(FileManager.default.fileExists(atPath: victim.path))
// The invalid record was quarantined aside, never executed. Clear it try FileManager.default.removeItem(at: receiptURL)
// so the second phase exercises the journal validation on its own.
#expect(!FileManager.default.fileExists(atPath: receiptURL.path))
try FileManager.default.removeItem(at: quarantinedRecord(in: root))
let journal = JournalFixture( let journal = JournalFixture(
version: 1, version: 1,
entries: [messageID: JournalEntryFixture( entries: [messageID: JournalEntryFixture(
@@ -280,7 +277,7 @@ struct BLEPrivateMediaReceiptStoreTests {
} }
@Test @Test
func recordReadFailureQuarantinesOnlyThatRecord() throws { func recordReadFailureIsUnavailableAndPreservesReceiptForRetry() throws {
let root = makeRoot("read-failure") let root = makeRoot("read-failure")
defer { try? FileManager.default.removeItem(at: root) } defer { try? FileManager.default.removeItem(at: root) }
let payload = try makePayload(in: root) let payload = try makePayload(in: root)
@@ -289,12 +286,13 @@ struct BLEPrivateMediaReceiptStoreTests {
storedURL: payload storedURL: payload
)) ))
let record = receiptRecord(in: root) let record = receiptRecord(in: root)
let durableBytes = try Data(contentsOf: record)
var shouldFail = true
let store = BLEPrivateMediaReceiptStore( let store = BLEPrivateMediaReceiptStore(
baseDirectory: root, baseDirectory: root,
dataReader: { url in dataReader: { url in
if url.lastPathComponent.hasPrefix(self.messageID) { if shouldFail {
shouldFail = false
throw TestError() throw TestError()
} }
return try Data(contentsOf: url) return try Data(contentsOf: url)
@@ -302,17 +300,12 @@ struct BLEPrivateMediaReceiptStoreTests {
) )
#expect(store.state(for: messageID) == .unavailable) #expect(store.state(for: messageID) == .unavailable)
// The record was moved aside, bytes intact, not deleted. #expect(FileManager.default.fileExists(atPath: record.path))
#expect(!FileManager.default.fileExists(atPath: record.path)) #expect(store.state(for: messageID) == .accepted(payload))
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)
} }
@Test @Test
func decodeFailureQuarantinesRecordWithoutRetryOrDeletion() throws { func decodeFailureIsUnavailableAndDoesNotDeleteOrCachePastRepair() throws {
let root = makeRoot("decode-failure") let root = makeRoot("decode-failure")
defer { try? FileManager.default.removeItem(at: root) } defer { try? FileManager.default.removeItem(at: root) }
let payload = try makePayload(in: root) let payload = try makePayload(in: root)
@@ -321,74 +314,18 @@ struct BLEPrivateMediaReceiptStoreTests {
storedURL: payload storedURL: payload
)) ))
let record = receiptRecord(in: root) let record = receiptRecord(in: root)
let durableBytes = try Data(contentsOf: record)
let corruptBytes = Data("{not-json".utf8) let corruptBytes = Data("{not-json".utf8)
try corruptBytes.write(to: record, options: .atomic) try corruptBytes.write(to: record, options: .atomic)
let store = BLEPrivateMediaReceiptStore(baseDirectory: root) 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.state(for: messageID) == .unavailable)
#expect(!store.commitAccepted(messageID: messageID, storedURL: payload)) #expect(!store.commitAccepted(messageID: messageID, storedURL: payload))
#expect(!store.recordDeleted(messageID: messageID)) #expect(FileManager.default.fileExists(atPath: record.path))
#expect(store.state(for: messageID) == .unavailable) #expect(try Data(contentsOf: record) == corruptBytes)
// The unreadable bytes were preserved at the quarantine name. try durableBytes.write(to: record, options: .atomic)
let quarantined = quarantinedRecord(in: root) #expect(store.state(for: messageID) == .accepted(payload))
#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))
} }
@Test @Test
@@ -402,26 +339,15 @@ struct BLEPrivateMediaReceiptStoreTests {
#expect(!FileManager.default.fileExists(atPath: payload.path)) #expect(!FileManager.default.fileExists(atPath: payload.path))
let record = receiptRecord(in: root) let record = receiptRecord(in: root)
let corruptBytes = Data([0xFF, 0x00, 0x7B]) let durableBytes = try Data(contentsOf: record)
try corruptBytes.write(to: record, options: .atomic) try Data([0xFF, 0x00, 0x7B]).write(to: record, options: .atomic)
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root) let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(relaunched.state(for: messageID) == .unavailable) #expect(relaunched.state(for: messageID) == .unavailable)
// The quarantined tombstone can never flip to absent a sender retry #expect(FileManager.default.fileExists(atPath: record.path))
// must not resurrect explicitly deleted media even when the payload
// bytes arrive again. try durableBytes.write(to: record, options: .atomic)
try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload) #expect(relaunched.state(for: messageID) == .tombstoned)
#expect(!relaunched.commitAccepted(
messageID: messageID,
storedURL: payload
))
let quarantined = quarantinedRecord(in: root)
#expect(FileManager.default.fileExists(atPath: quarantined.path))
#expect(try Data(contentsOf: quarantined) == corruptBytes)
#expect(
BLEPrivateMediaReceiptStore(baseDirectory: root)
.state(for: messageID) == .unavailable
)
} }
@Test @Test
@@ -929,10 +855,6 @@ struct BLEPrivateMediaReceiptStoreTests {
.appendingPathExtension("json") .appendingPathExtension("json")
} }
private func quarantinedRecord(in root: URL) -> URL {
receiptRecord(in: root).appendingPathExtension("corrupt")
}
private func deletionJournal(in root: URL) -> URL { private func deletionJournal(in root: URL) -> URL {
root root
.appendingPathComponent( .appendingPathComponent(
@@ -942,10 +864,3 @@ struct BLEPrivateMediaReceiptStoreTests {
.appendingPathComponent(".deletion-journal.json") .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) }
}
@@ -203,6 +203,134 @@ struct BridgeCourierServiceTests {
#expect(confirmed.sealRequests.isEmpty) #expect(confirmed.sealRequests.isEmpty)
} }
@Test func sameMessageIDIsScopedByRecipientAcrossRejectedActiveAndPersistedState() {
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
defer { try? FileManager.default.removeItem(at: fileURL) }
let rejectedKey = Fixture.randomKey()
let firstKey = Fixture.randomKey()
let secondKey = Fixture.randomKey()
let thirdKey = Fixture.randomKey()
let messageID = "recipient-scoped-collision"
let fixture = Fixture(dedupStore: BridgeDropDedupStore(fileURL: fileURL))
fixture.sealResult = makeEnvelope(
recipientKey: rejectedKey,
ciphertext: Data(
repeating: 7,
count: BridgeCourierService.Limits.maxDropEnvelopeBytes + 1
)
)
var rejectedResults: [Bool] = []
fixture.service.depositDrop(
content: "rejected",
messageID: messageID,
recipientNoiseKey: rejectedKey
) { rejectedResults.append($0) }
#expect(rejectedResults == [false])
fixture.sealResult = makeEnvelope(recipientKey: firstKey)
fixture.automaticPublishResult = nil
var firstResults: [Bool] = []
var secondResults: [Bool] = []
var duplicateFirstResults: [Bool] = []
fixture.service.depositDrop(
content: "first",
messageID: messageID,
recipientNoiseKey: firstKey
) { firstResults.append($0) }
fixture.service.depositDrop(
content: "second",
messageID: messageID,
recipientNoiseKey: secondKey
) { secondResults.append($0) }
fixture.service.depositDrop(
content: "first duplicate",
messageID: messageID,
recipientNoiseKey: firstKey
) { duplicateFirstResults.append($0) }
#expect(fixture.publishedEvents.count == 2)
#expect(fixture.pendingPublishCompletions.count == 2)
#expect(duplicateFirstResults == [false])
#expect(firstResults.isEmpty)
#expect(secondResults.isEmpty)
fixture.resolveNextPublish(true)
fixture.resolveNextPublish(true)
#expect(firstResults == [true])
#expect(secondResults == [true])
fixture.service.flushDedupSnapshot()
let relaunched = Fixture(
dedupStore: BridgeDropDedupStore(fileURL: fileURL)
)
relaunched.sealResult = makeEnvelope(recipientKey: thirdKey)
var relaunchResults: [Bool] = []
relaunched.service.depositDrop(
content: "first",
messageID: messageID,
recipientNoiseKey: firstKey
) { relaunchResults.append($0) }
relaunched.service.depositDrop(
content: "second",
messageID: messageID,
recipientNoiseKey: secondKey
) { relaunchResults.append($0) }
relaunched.service.depositDrop(
content: "third",
messageID: messageID,
recipientNoiseKey: thirdKey
) { relaunchResults.append($0) }
#expect(relaunchResults == [false, false, true])
#expect(relaunched.sealRequests.count == 1)
#expect(relaunched.sealRequests.first?.key == thirdKey)
#expect(relaunched.publishedEvents.count == 1)
}
@Test func legacyPublishedMessageIDIsWildcardUntilItsOriginalExpiry() {
let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
defer { try? FileManager.default.removeItem(at: fileURL) }
var date = Date()
let messageID = "legacy-wildcard"
let recipientKey = Fixture.randomKey()
let store = BridgeDropDedupStore(fileURL: fileURL)
store.save(BridgeDropDedupStore.Snapshot(
publishedDropKeys: [messageID: date],
seenDropEventIDs: [:]
))
let fixture = Fixture(
now: { date },
dedupStore: BridgeDropDedupStore(fileURL: fileURL)
)
fixture.sealResult = makeEnvelope(recipientKey: recipientKey)
var results: [Bool] = []
fixture.service.depositDrop(
content: "legacy",
messageID: messageID,
recipientNoiseKey: recipientKey
) { results.append($0) }
#expect(results == [false])
#expect(fixture.sealRequests.isEmpty)
date = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + 1)
fixture.service.depositDrop(
content: "after expiry",
messageID: messageID,
recipientNoiseKey: recipientKey
) { results.append($0) }
#expect(results == [false, true])
#expect(fixture.publishedEvents.count == 1)
fixture.service.flushDedupSnapshot()
let snapshot = BridgeDropDedupStore(fileURL: fileURL).load()
#expect(snapshot.publishedDropKeys[messageID] == nil)
#expect(snapshot.publishedDropKeys.count == 1)
}
@Test func panicWipeInvalidatesInFlightPublishCompletion() throws { @Test func panicWipeInvalidatesInFlightPublishCompletion() throws {
let fileURL = FileManager.default.temporaryDirectory let fileURL = FileManager.default.temporaryDirectory
.appendingPathComponent("bridge-dedup-\(UUID().uuidString).json") .appendingPathComponent("bridge-dedup-\(UUID().uuidString).json")
@@ -297,8 +425,10 @@ struct BridgeCourierServiceTests {
#expect(firstResults == [false]) #expect(firstResults == [false])
// The evicted first drop is deposit-able again (slot released). // The evicted first drop is deposit-able again (slot released).
let sealCountBeforeRetry = fixture.sealRequests.count
fixture.service.depositDrop(content: "0-retry", messageID: firstID, recipientNoiseKey: key) fixture.service.depositDrop(content: "0-retry", messageID: firstID, recipientNoiseKey: key)
#expect(fixture.service.pendingDrops.last?.dedupKey == firstID) #expect(fixture.sealRequests.count == sealCountBeforeRetry + 1)
#expect(fixture.service.pendingDrops.count == BridgeCourierService.Limits.maxPendingDrops)
} }
@Test func oversizeDropConsumesSlotInsteadOfChurning() { @Test func oversizeDropConsumesSlotInsteadOfChurning() {
+301 -11
View File
@@ -79,18 +79,231 @@ struct MessageRouterTests {
} }
@Test @MainActor @Test @MainActor
func sendPrivate_connectedSendIsNotRetained() async { func peerBoundDeliveryAckCannotClearAnotherPeersRetainedMessage() async {
let intendedPeer = PeerID(str: "0000000000000023")
let otherPeer = PeerID(str: "0000000000000024")
let transport = MockTransport()
transport.reachablePeers = [intendedPeer, otherPeer]
let router = MessageRouter(transports: [transport])
router.sendPrivate(
"Secret",
to: intendedPeer,
recipientNickname: "Intended",
messageID: "peer-bound-ack"
)
#expect(transport.sentPrivateMessages.count == 1)
// Even a receipt arriving over another authenticated conversation
// must not terminalize the intended peer's retained retry.
router.markDelivered("peer-bound-ack", from: [otherPeer])
router.flushOutbox(for: intendedPeer)
#expect(transport.sentPrivateMessages.count == 2)
router.markDelivered("peer-bound-ack", from: [intendedPeer])
router.flushOutbox(for: intendedPeer)
#expect(transport.sentPrivateMessages.count == 2)
}
@Test @MainActor
func sendPrivate_connectedSecureSendRetainsUntilDeliveryAck() async {
let peerID = PeerID(str: "0000000000000007") let peerID = PeerID(str: "0000000000000007")
let transport = MockTransport() let transport = MockTransport()
transport.connectedPeers.insert(peerID) transport.connectedPeers.insert(peerID)
transport.reachablePeers.insert(peerID) transport.reachablePeers.insert(peerID)
transport.securePeers = [peerID]
let router = MessageRouter(transports: [transport]) let router = MessageRouter(transports: [transport])
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m7") router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m7")
#expect(transport.sentPrivateMessages.count == 1) #expect(transport.sentPrivateMessages.count == 1)
router.flushOutbox(for: peerID) // A newly authenticated/replacement session retries the retained
// message instead of losing the first ciphertext to a stale session.
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
#expect(transport.sentPrivateMessages.count == 2)
router.markDelivered("m7")
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
#expect(transport.sentPrivateMessages.count == 2)
}
@Test @MainActor
func authenticationRetry_matchesStableOutboxAliasWithoutDoubleSending() async {
let shortPeerID = PeerID(str: "0000000000000019")
let stablePeerID = PeerID(hexData: Data(repeating: 0x19, count: 32))
let transport = MockTransport()
transport.connectedPeers.insert(stablePeerID)
transport.securePeers = [stablePeerID]
let router = MessageRouter(transports: [transport])
router.sendPrivate("Hello", to: stablePeerID, recipientNickname: "Peer", messageID: "alias-retry")
router.retrySecurePrivateMessagesAfterAuthentication(for: [shortPeerID, stablePeerID, stablePeerID])
#expect(transport.sentPrivateMessages.map(\.messageID) == ["alias-retry", "alias-retry"])
#expect(transport.sentPrivateMessages.allSatisfy { $0.peerID == stablePeerID })
}
@Test @MainActor
func authenticationRetry_preservesFIFOAcrossSplitAliases() async {
let shortPeerID = PeerID(str: "0000000000000022")
let stablePeerID = PeerID(hexData: Data(repeating: 0x22, count: 32))
let transport = MockTransport()
transport.connectedPeers = [shortPeerID, stablePeerID]
transport.securePeers = [shortPeerID, stablePeerID]
let clock = MutableTestClock()
let router = MessageRouter(transports: [transport], now: { clock.now })
// The older message lives under the stable key, even though the auth
// callback supplies the ephemeral alias first.
router.sendPrivate("Older", to: stablePeerID, recipientNickname: "Peer", messageID: "fifo-old")
clock.now = clock.now.addingTimeInterval(1)
router.sendPrivate("Newer", to: shortPeerID, recipientNickname: "Peer", messageID: "fifo-new")
transport.resetRecordings()
router.retrySecurePrivateMessagesAfterAuthentication(for: [shortPeerID, stablePeerID])
#expect(transport.sentPrivateMessages.map(\.messageID) == ["fifo-old", "fifo-new"])
#expect(transport.sentPrivateMessages.map(\.peerID) == [stablePeerID, shortPeerID])
}
@Test @MainActor
func authenticationRetry_doesNotDuplicateNormalPendingHandshakeSend() async {
let peerID = PeerID(str: "0000000000000020")
let transport = MockTransport()
transport.connectedPeers.insert(peerID)
transport.securePeers = []
let router = MessageRouter(transports: [transport])
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "normal-handshake")
#expect(transport.sentPrivateMessages.count == 1) #expect(transport.sentPrivateMessages.count == 1)
// BLE owns this pending send and drains it after authentication. Once
// the session becomes secure, the router's targeted auth retry must
// stay silent instead of producing a second copy.
transport.securePeers = [peerID]
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
#expect(transport.sentPrivateMessages.count == 1)
router.markDelivered("normal-handshake")
}
@Test @MainActor
func authenticationRetry_scopesCollidingMessageIDsByPeer() async {
let securePeer = PeerID(str: "0000000000000025")
let pendingPeer = PeerID(str: "0000000000000026")
let transport = MockTransport()
transport.connectedPeers = [securePeer, pendingPeer]
transport.securePeers = [securePeer]
let router = MessageRouter(transports: [transport])
let promotedID = "collision-promoted"
let clearedID = "collision-cleared"
// Pending B then secure A: an ID-global marker falsely promotes B.
router.sendPrivate(
"pending promoted",
to: pendingPeer,
recipientNickname: "Pending",
messageID: promotedID
)
router.sendPrivate(
"secure promoted",
to: securePeer,
recipientNickname: "Secure",
messageID: promotedID
)
// Secure A then pending B: an ID-global removal falsely clears A.
router.sendPrivate(
"secure cleared",
to: securePeer,
recipientNickname: "Secure",
messageID: clearedID
)
router.sendPrivate(
"pending cleared",
to: pendingPeer,
recipientNickname: "Pending",
messageID: clearedID
)
transport.resetRecordings()
transport.securePeers = [securePeer, pendingPeer]
router.retrySecurePrivateMessagesAfterAuthentication(for: [pendingPeer])
#expect(transport.sentPrivateMessages.isEmpty)
router.retrySecurePrivateMessagesAfterAuthentication(for: [securePeer])
#expect(transport.sentPrivateMessages.count == 2)
#expect(Set(transport.sentPrivateMessages.map(\.messageID)) == [promotedID, clearedID])
#expect(transport.sentPrivateMessages.allSatisfy { $0.peerID == securePeer })
}
@Test @MainActor
func authenticationRetry_doesNotDuplicateMessageRequeuedByBLEForHandshake() async {
let peerID = PeerID(str: "0000000000000021")
let transport = MockTransport()
transport.connectedPeers.insert(peerID)
transport.securePeers = [peerID]
let router = MessageRouter(transports: [transport])
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "session-lost")
#expect(transport.sentPrivateMessages.count == 1)
// The session disappears before a normal outbox flush. That send is
// now owned by BLE's pending-handshake queue, so it clears the
// router's secure-auth retry marker.
transport.securePeers = []
router.flushOutbox(for: peerID)
#expect(transport.sentPrivateMessages.count == 2)
transport.securePeers = [peerID]
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
#expect(transport.sentPrivateMessages.count == 2)
router.markDelivered("session-lost")
}
@Test @MainActor
func sendPrivate_fastDeliveryAckCannotRaceAheadOfRetention() async {
let peerID = PeerID(str: "0000000000000017")
let transport = MockTransport()
transport.connectedPeers.insert(peerID)
transport.securePeers = [peerID]
let router = MessageRouter(transports: [transport])
transport.onSendPrivateMessage = { messageID in
router.markDelivered(messageID)
}
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "fast-ack")
#expect(transport.sentPrivateMessages.map(\.messageID) == ["fast-ack"])
transport.onSendPrivateMessage = nil
router.flushOutbox(for: peerID)
#expect(transport.sentPrivateMessages.map(\.messageID) == ["fast-ack"])
}
@Test @MainActor
func flushOutbox_synchronousAckDoesNotResurrectSnapshotEntry() async {
let peerID = PeerID(str: "0000000000000018")
let transport = MockTransport()
transport.connectedPeers.insert(peerID)
transport.securePeers = [peerID]
let router = MessageRouter(transports: [transport])
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "flush-fast-ack")
transport.onSendPrivateMessage = { messageID in
router.markDelivered(messageID)
}
router.flushOutbox(for: peerID)
#expect(transport.sentPrivateMessages.count == 2)
transport.onSendPrivateMessage = nil
router.flushOutbox(for: peerID)
#expect(transport.sentPrivateMessages.count == 2)
} }
@Test @MainActor @Test @MainActor
@@ -392,10 +605,31 @@ struct MessageRouterTests {
#expect(transport.sentPrivateMessages.count == 11) #expect(transport.sentPrivateMessages.count == 11)
} }
/// With an established secure session the connected fast-path stays
/// exactly as before: trusted outright, no retained copy, no courier.
@Test @MainActor @Test @MainActor
func sendPrivate_connectedWithSecureSessionIsTrustedOutright() async { func authenticationRetry_capsActualSecureTransmissions() async {
let peerID = PeerID(str: "00000000000000ad")
let transport = MockTransport()
transport.connectedPeers.insert(peerID)
transport.securePeers = [peerID]
let router = MessageRouter(transports: [transport])
var dropped: [String] = []
router.onMessageDropped = { messageID, _ in dropped.append(messageID) }
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "secure-retry")
for _ in 0..<10 {
router.retrySecurePrivateMessagesAfterAuthentication(for: [peerID])
}
#expect(dropped == ["secure-retry"])
#expect(transport.sentPrivateMessages.count == 8)
}
/// With an established secure session the connected fast-path sends
/// immediately and never leaks to couriers, but retains a local encrypted
/// outbox copy until the peer confirms receipt.
@Test @MainActor
func sendPrivate_connectedWithSecureSessionRetainsLocallyWithoutCourier() async {
let peerID = PeerID(str: "00000000000000ab") let peerID = PeerID(str: "00000000000000ab")
let peerKey = Data(repeating: 0xAB, count: 32) let peerKey = Data(repeating: 0xAB, count: 32)
let courier = PeerID(str: "00000000000000cc") let courier = PeerID(str: "00000000000000cc")
@@ -415,7 +649,11 @@ struct MessageRouterTests {
#expect(transport.sentPrivateMessages.map(\.messageID) == ["cs2"]) #expect(transport.sentPrivateMessages.map(\.messageID) == ["cs2"])
#expect(transport.sentCourierMessages.isEmpty) #expect(transport.sentCourierMessages.isEmpty)
router.flushOutbox(for: peerID) router.flushOutbox(for: peerID)
#expect(transport.sentPrivateMessages.count == 1) #expect(transport.sentPrivateMessages.count == 2)
#expect(transport.sentCourierMessages.isEmpty)
router.markDelivered("cs2")
router.flushOutbox(for: peerID)
#expect(transport.sentPrivateMessages.count == 2)
} }
@Test @MainActor @Test @MainActor
@@ -527,6 +765,52 @@ struct MessageRouterTests {
#expect(carried == ["bridge-ack"]) #expect(carried == ["bridge-ack"])
} }
@Test @MainActor
func bridgeDepositsScopeCollidingMessageIDsByRecipient() async {
let firstRecipient = PeerID(str: "00000000000000b1")
let secondRecipient = PeerID(str: "00000000000000b2")
let firstKey = Data(repeating: 0xB1, count: 32)
let secondKey = Data(repeating: 0xB2, count: 32)
let recipientKeys = [
firstRecipient: firstKey,
secondRecipient: secondKey
]
let router = MessageRouter(
transports: [MockTransport()],
courierDirectory: CourierDirectory(
noiseKey: { recipientKeys[$0] },
isTrustedCourier: { _ in false }
)
)
var requestedKeys: [Data] = []
var completions: [@MainActor (Bool) -> Void] = []
router.bridgeCourierDeposit = { _, _, recipientKey, completion in
requestedKeys.append(recipientKey)
completions.append(completion)
}
var carriedPeers: [PeerID] = []
router.onMessageCarried = { _, peerID in carriedPeers.append(peerID) }
router.sendPrivate(
"First",
to: firstRecipient,
recipientNickname: "First",
messageID: "bridge-collision"
)
router.sendPrivate(
"Second",
to: secondRecipient,
recipientNickname: "Second",
messageID: "bridge-collision"
)
#expect(completions.count == 2)
#expect(Set(requestedKeys) == [firstKey, secondKey])
completions.forEach { $0(true) }
#expect(Set(carriedPeers) == [firstRecipient, secondRecipient])
}
// MARK: - Outbox persistence // MARK: - Outbox persistence
@Test @MainActor @Test @MainActor
@@ -643,7 +927,10 @@ struct MessageRouterTests {
transport.reachablePeers.formUnion([acknowledgedPeer, otherPeer]) transport.reachablePeers.formUnion([acknowledgedPeer, otherPeer])
let router = MessageRouter(transports: [transport], outboxStore: restoredStore) let router = MessageRouter(transports: [transport], outboxStore: restoredStore)
#expect(!router.markDelivered("shared-locked-id", for: [acknowledgedPeer])) router.markDelivered(
"shared-locked-id",
from: [acknowledgedPeer]
)
protectedDataUnavailable = false protectedDataUnavailable = false
restoredStore.retryDeferredLoad() restoredStore.retryDeferredLoad()
await Task.yield() await Task.yield()
@@ -853,12 +1140,14 @@ struct MessageRouterTests {
protectedDataUnavailable = false protectedDataUnavailable = false
restoredStore.retryDeferredLoad() // captures unseen durable + known wake restoredStore.retryDeferredLoad() // captures unseen durable + known wake
// Secure direct flush removes the wake message before recovery's // A secure direct retry followed by its delivery ack removes the wake
// MainActor merge. It must remain removed, while the unseen durable // message before recovery's MainActor merge. It must remain removed,
// message still arrives through the pending recovery claim. // while the unseen durable message still arrives through the pending
// recovery claim.
transport.connectedPeers.insert(peerID) transport.connectedPeers.insert(peerID)
transport.securePeers = [peerID] transport.securePeers = [peerID]
router.flushOutbox(for: peerID) router.flushOutbox(for: peerID)
router.markDelivered("recovery-gap-known")
await Task.yield() await Task.yield()
await Task.yield() await Task.yield()
@@ -929,7 +1218,8 @@ struct MessageRouterTests {
restoredStore.retryDeferredLoad() // persists D+W and queues recovery restoredStore.retryDeferredLoad() // persists D+W and queues recovery
transport.connectedPeers.insert(peerID) transport.connectedPeers.insert(peerID)
transport.securePeers = [peerID] transport.securePeers = [peerID]
router.flushOutbox(for: peerID) // removes W before queued callback router.flushOutbox(for: peerID)
router.markDelivered("recovery-write-failure-known") // removes W before queued callback
// The gap save may remove W, but it must leave unseen D durable until // The gap save may remove W, but it must leave unseen D durable until
// MessageRouter receives the pending recovery callback. // MessageRouter receives the pending recovery callback.
+678 -188
View File
@@ -267,6 +267,588 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertEqual(context.manager.debugPendingMessageQueueCount, TransportConfig.nostrPendingSendQueueCap) 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 { func test_sendEvent_waitsForTorReadinessBeforeSending() async throws {
let relayURL = "wss://tor-ready.example" let relayURL = "wss://tor-ready.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) 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) 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 { func test_subscribe_coalescesDuplicateRequestsBeforeTorReadyAndDefersEOSE() async throws {
let relayURL = "wss://tor-subscribe-coalesce.example" let relayURL = "wss://tor-subscribe-coalesce.example"
let context = makeContext(permission: .denied, userTorEnabled: true, torEnforced: true, torIsReady: false) 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: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
try context.sessionFactory.latestConnection(for: secondRelayURL)?.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 let countedOnBothRelays = await waitUntil {
// drop both land on the second main hop, after off-main verification.
let settled = await waitUntil {
context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 && context.manager.relays.first(where: { $0.url == firstRelayURL })?.messagesReceived == 1 &&
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 && context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
receivedIDs == [event.id] &&
context.manager.debugDuplicateInboundEventDropCount == 1
} }
XCTAssertTrue(settled) XCTAssertTrue(countedOnBothRelays)
XCTAssertEqual(receivedIDs, [event.id]) XCTAssertEqual(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 1) XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 1)
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 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 let countedOnEveryRelay = await waitUntil {
// the losers' duplicate drops both land on the second main hop, after
// off-main verification messagesReceived (first hop) settles sooner.
let settled = await waitUntil {
relayURLs.allSatisfy { relayURL in relayURLs.allSatisfy { relayURL in
context.manager.relays.first(where: { $0.url == relayURL })?.messagesReceived == 1 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(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, relayURLs.count - 1) XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, relayURLs.count - 1)
XCTAssertEqual( 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: firstRelayURL)?.emitEventMessage(subscriptionID: "events", event: invalidEvent)
try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event) try context.sessionFactory.latestConnection(for: secondRelayURL)?.emitEventMessage(subscriptionID: "events", event: event)
// Wait on the DELIVERY-side state (second main hop, after off-main let countedOnBothRelays = await waitUntil {
// 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 == firstRelayURL })?.messagesReceived == 1 &&
context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1 && context.manager.relays.first(where: { $0.url == secondRelayURL })?.messagesReceived == 1
receivedIDs == [event.id]
} }
XCTAssertTrue(genuineDelivered) XCTAssertTrue(countedOnBothRelays)
XCTAssertEqual(receivedIDs, [event.id]) XCTAssertEqual(receivedIDs, [event.id])
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0) XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount, 0)
XCTAssertEqual(context.manager.debugDuplicateInboundEventDropCount(forSubscriptionID: "events"), 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 { func test_receiveEvent_withoutHandlerStillTracksReceivedCount() async throws {
let relayURL = "wss://missing-handler.example" let relayURL = "wss://missing-handler.example"
let context = makeContext(permission: .denied) let context = makeContext(permission: .denied)
@@ -1039,11 +1521,11 @@ final class NostrRelayManagerTests: XCTestCase {
XCTAssertTrue(secondDelivered) XCTAssertTrue(secondDelivered)
} }
func test_okMessages_clearPendingGiftWrapIDs() async throws { func test_okMessages_clearPendingPrivateEnvelopeIDs() async throws {
let relayURL = "wss://ok.example" let relayURL = "wss://ok.example"
let context = makeContext(permission: .denied) let context = makeContext(permission: .denied)
let successID = "gift-wrap-success" let successID = "private-envelope-success"
let failureID = "gift-wrap-failure" let failureID = "private-envelope-failure"
context.manager.ensureConnections(to: [relayURL]) context.manager.ensureConnections(to: [relayURL])
let connected = await waitUntil { let connected = await waitUntil {
@@ -1052,17 +1534,17 @@ final class NostrRelayManagerTests: XCTestCase {
} }
XCTAssertTrue(connected) XCTAssertTrue(connected)
NostrRelayManager.registerPendingGiftWrap(id: successID) NostrRelayManager.registerPendingPrivateEnvelope(id: successID)
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: successID, success: true, reason: "ok") try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: successID, success: true, reason: "ok")
let successCleared = await waitUntil { let successCleared = await waitUntil {
!NostrRelayManager.pendingGiftWrapIDs.contains(successID) !NostrRelayManager.pendingPrivateEnvelopeIDs.contains(successID)
} }
XCTAssertTrue(successCleared) XCTAssertTrue(successCleared)
NostrRelayManager.registerPendingGiftWrap(id: failureID) NostrRelayManager.registerPendingPrivateEnvelope(id: failureID)
try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: failureID, success: false, reason: "rejected") try context.sessionFactory.latestConnection(for: relayURL)?.emitOK(eventID: failureID, success: false, reason: "rejected")
let failureCleared = await waitUntil { let failureCleared = await waitUntil {
!NostrRelayManager.pendingGiftWrapIDs.contains(failureID) !NostrRelayManager.pendingPrivateEnvelopeIDs.contains(failureID)
} }
XCTAssertTrue(failureCleared) XCTAssertTrue(failureCleared)
} }
@@ -1792,12 +2274,31 @@ final class NostrRelayManagerTests: XCTestCase {
return filter 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 identity = try NostrIdentity.generate()
let event = NostrEvent( let event = NostrEvent(
pubkey: identity.publicKeyHex, pubkey: identity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .textNote, kind: kind,
tags: [], tags: [],
content: content content: content
) )
@@ -1937,6 +2438,7 @@ private final class MockRelaySessionFactory: NostrRelaySessionProtocol {
private final class MockRelayConnection: NostrRelayConnectionProtocol { private final class MockRelayConnection: NostrRelayConnectionProtocol {
private let pingError: Error? private let pingError: Error?
var sendError: Error? var sendError: Error?
var sendErrorSequence: [Error?] = []
private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)? private var receiveHandler: ((Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
private(set) var resumeCallCount = 0 private(set) var resumeCallCount = 0
private(set) var cancelCallCount = 0 private(set) var cancelCallCount = 0
@@ -1966,14 +2468,15 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
} }
var deferSendCompletions = false 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) { func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping (Error?) -> Void) {
sentMessages.append(message) sentMessages.append(message)
let error = sendErrorSequence.isEmpty ? sendError : sendErrorSequence.removeFirst()
if deferSendCompletions { if deferSendCompletions {
deferredSendCompletions.append(completionHandler) deferredSendCompletions.append((error, completionHandler))
} else { } else {
completionHandler(sendError) completionHandler(error)
} }
} }
@@ -1981,16 +2484,12 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
let pending = deferredSendCompletions let pending = deferredSendCompletions
deferredSendCompletions = [] deferredSendCompletions = []
pending.forEach { pending.forEach {
$0(sendError) $0.completion($0.error)
} }
} }
func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) { func receive(completionHandler: @escaping (Result<URLSessionWebSocketTask.Message, Error>) -> Void) {
if !pendingResults.isEmpty { receiveHandler = completionHandler
completionHandler(pendingResults.removeFirst())
} else {
receiveHandler = completionHandler
}
} }
func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) { func sendPing(pongReceiveHandler: @escaping (Error?) -> Void) {
@@ -2023,24 +2522,15 @@ private final class MockRelayConnection: NostrRelayConnectionProtocol {
} }
func emitRawString(_ string: String) throws { func emitRawString(_ string: String) throws {
deliver(.success(.string(string))) let handler = receiveHandler
receiveHandler = nil
handler?(.success(.string(string)))
} }
private func emit(jsonObject: Any) throws { private func emit(jsonObject: Any) throws {
let data = try JSONSerialization.data(withJSONObject: jsonObject) let data = try JSONSerialization.data(withJSONObject: jsonObject)
deliver(.success(.data(data))) let handler = receiveHandler
} receiveHandler = nil
handler?(.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)
}
} }
} }
+637 -40
View File
@@ -131,7 +131,7 @@ struct NostrTransportTests {
#expect(undeliverable) #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 @MainActor
func sendPrivateMessageResolvesShortPeerID() async throws { func sendPrivateMessageResolvesShortPeerID() async throws {
let keychain = MockKeychain() let keychain = MockKeychain()
@@ -153,8 +153,8 @@ struct NostrTransportTests {
favoriteStatusForNoiseKey: { _ in nil }, favoriteStatusForNoiseKey: { _ in nil },
favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil }, favoriteStatusForPeerID: { $0 == shortPeerID ? relationship : nil },
currentIdentity: { sender }, currentIdentity: { sender },
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:), registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
sendEvent: probe.record(event:), sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
scheduleAfter: { delay, action in scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action) probe.enqueueScheduledAction(delay: delay, action: action)
} }
@@ -164,8 +164,12 @@ struct NostrTransportTests {
transport.sendPrivateMessage("hello over nostr", to: shortPeerID, recipientNickname: "Carol", messageID: "pm-1") 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(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 result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
let privateMessage = try decodePrivateMessage(from: result.payload) let privateMessage = try decodePrivateMessage(from: result.payload)
@@ -173,7 +177,583 @@ struct NostrTransportTests {
#expect(privateMessage.messageID == "pm-1") #expect(privateMessage.messageID == "pm-1")
#expect(privateMessage.content == "hello over nostr") #expect(privateMessage.content == "hello over nostr")
#expect(result.packet.recipientID == shortPeerID.routingData) #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") @Test("Favorite notification embeds current npub")
@@ -198,8 +778,8 @@ struct NostrTransportTests {
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil }, favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
favoriteStatusForPeerID: { _ in nil }, favoriteStatusForPeerID: { _ in nil },
currentIdentity: { sender }, currentIdentity: { sender },
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:), registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
sendEvent: probe.record(event:), sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
scheduleAfter: { delay, action in scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action) probe.enqueueScheduledAction(delay: delay, action: action)
} }
@@ -209,7 +789,7 @@ struct NostrTransportTests {
transport.sendFavoriteNotification(to: fullPeerID, isFavorite: true) 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) #expect(didSend)
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient) let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
let privateMessage = try decodePrivateMessage(from: result.payload) let privateMessage = try decodePrivateMessage(from: result.payload)
@@ -239,8 +819,8 @@ struct NostrTransportTests {
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil }, favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
favoriteStatusForPeerID: { _ in nil }, favoriteStatusForPeerID: { _ in nil },
currentIdentity: { sender }, currentIdentity: { sender },
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:), registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
sendEvent: probe.record(event:), sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
scheduleAfter: { delay, action in scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action) probe.enqueueScheduledAction(delay: delay, action: action)
} }
@@ -250,7 +830,7 @@ struct NostrTransportTests {
transport.sendDeliveryAck(for: "ack-1", to: fullPeerID) 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) #expect(didSend)
let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient) let result = try decodeEmbeddedPayload(from: probe.sentEvents[0], recipient: recipient)
@@ -259,9 +839,9 @@ struct NostrTransportTests {
#expect(result.packet.recipientID == fullPeerID.toShort().routingData) #expect(result.packet.recipientID == fullPeerID.toShort().routingData)
} }
@Test("Geohash private message registers pending gift wrap") @Test("Geohash private message registers pending private envelope")
@MainActor @MainActor
func sendPrivateMessageGeohashRegistersPendingGiftWrap() async throws { func sendPrivateMessageGeohashRegistersPendingPrivateEnvelope() async throws {
let keychain = MockKeychain() let keychain = MockKeychain()
let idBridge = NostrIdentityBridge(keychain: keychain) let idBridge = NostrIdentityBridge(keychain: keychain)
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
@@ -272,8 +852,8 @@ struct NostrTransportTests {
idBridge: idBridge, idBridge: idBridge,
dependencies: makeDependencies( dependencies: makeDependencies(
currentIdentity: { sender }, currentIdentity: { sender },
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:), registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
sendEvent: probe.record(event:), sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
scheduleAfter: { delay, action in scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action) probe.enqueueScheduledAction(delay: delay, action: action)
} }
@@ -288,7 +868,7 @@ struct NostrTransportTests {
messageID: "geo-1" 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) #expect(didSend)
let event = probe.sentEvents[0] let event = probe.sentEvents[0]
let result = try decodeEmbeddedPayload(from: event, recipient: recipient) let result = try decodeEmbeddedPayload(from: event, recipient: recipient)
@@ -297,7 +877,7 @@ struct NostrTransportTests {
#expect(privateMessage.messageID == "geo-1") #expect(privateMessage.messageID == "geo-1")
#expect(privateMessage.content == "geo hello") #expect(privateMessage.content == "geo hello")
#expect(result.packet.recipientID == nil) #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") @Test("Read receipt queue sends in order and waits for scheduler")
@@ -322,8 +902,8 @@ struct NostrTransportTests {
favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil }, favoriteStatusForNoiseKey: { $0 == noiseKey ? relationship : nil },
favoriteStatusForPeerID: { _ in nil }, favoriteStatusForPeerID: { _ in nil },
currentIdentity: { sender }, currentIdentity: { sender },
registerPendingGiftWrap: probe.recordPendingGiftWrap(id:), registerPendingPrivateEnvelope: probe.recordPendingPrivateEnvelope(id:),
sendEvent: probe.record(event:), sendPrivateEnvelopeBatch: { events, _ in probe.record(batch: events) },
scheduleAfter: { delay, action in scheduleAfter: { delay, action in
probe.enqueueScheduledAction(delay: delay, action: action) probe.enqueueScheduledAction(delay: delay, action: action)
} }
@@ -338,20 +918,20 @@ struct NostrTransportTests {
transport.sendReadReceipt(second, to: fullPeerID) transport.sendReadReceipt(second, to: fullPeerID)
let readReceiptTimeout: TimeInterval = 5.0 let readReceiptTimeout: TimeInterval = 5.0
let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count >= 1 }, timeout: readReceiptTimeout) let sentFirst = await TestHelpers.waitUntil({ probe.sentEvents.count == 2 }, timeout: readReceiptTimeout)
try #require(sentFirst, "Expected first queued read receipt event") try #require(sentFirst, "Expected first queued read receipt pair")
let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: readReceiptTimeout) let scheduledThrottle = await TestHelpers.waitUntil({ probe.scheduledActionCount == 1 }, timeout: readReceiptTimeout)
try #require(scheduledThrottle, "Expected queued throttle action after first read receipt") 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 let firstPayload = try decodeEmbeddedPayload(from: firstEvent, recipient: recipient).payload
#expect(firstPayload.type == .readReceipt) #expect(firstPayload.type == .readReceipt)
#expect(String(data: firstPayload.data, encoding: .utf8) == "read-1") #expect(String(data: firstPayload.data, encoding: .utf8) == "read-1")
try #require(probe.runNextScheduledAction(), "Expected queued throttle action after first read receipt") try #require(probe.runNextScheduledAction(), "Expected queued throttle action after first read receipt")
let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count >= 2 }, timeout: readReceiptTimeout) let sentSecond = await TestHelpers.waitUntil({ probe.sentEvents.count == 4 }, timeout: readReceiptTimeout)
try #require(sentSecond, "Expected second read receipt after running throttle action") try #require(sentSecond, "Expected second read receipt pair after running throttle action")
let secondEvent = try #require(probe.sentEvents.last, "Expected second queued read receipt event") let secondEvent = probe.sentEvents[2]
let secondPayload = try decodeEmbeddedPayload(from: secondEvent, recipient: recipient).payload let secondPayload = try decodeEmbeddedPayload(from: secondEvent, recipient: recipient).payload
#expect(secondPayload.type == .readReceipt) #expect(secondPayload.type == .readReceipt)
#expect(String(data: secondPayload.data, encoding: .utf8) == "read-2") #expect(String(data: secondPayload.data, encoding: .utf8) == "read-2")
@@ -419,10 +999,14 @@ struct NostrTransportTests {
favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoriteRelationship? = { _ in nil }, favoriteStatusForNoiseKey: @escaping @MainActor (Data) -> FavoriteRelationship? = { _ in nil },
favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil }, favoriteStatusForPeerID: @escaping @MainActor (PeerID) -> FavoriteRelationship? = { _ in nil },
currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil }, currentIdentity: @escaping @MainActor () throws -> NostrIdentity? = { nil },
registerPendingGiftWrap: @escaping @MainActor (String) -> Void = { _ in }, registerPendingPrivateEnvelope: @escaping @MainActor (String) -> Void = { _ in },
sendEvent: @escaping @MainActor (NostrEvent) -> Void = { _ in }, sendPrivateEnvelopeBatch: @escaping @MainActor (
[NostrEvent],
@escaping @MainActor () -> Void
) -> Bool = { _, _ in true },
scheduleAfter: @escaping @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void = { _, _ in }, 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 {
NostrTransport.Dependencies( NostrTransport.Dependencies(
notificationCenter: notificationCenter, notificationCenter: notificationCenter,
@@ -430,10 +1014,11 @@ struct NostrTransportTests {
favoriteStatusForNoiseKey: favoriteStatusForNoiseKey, favoriteStatusForNoiseKey: favoriteStatusForNoiseKey,
favoriteStatusForPeerID: favoriteStatusForPeerID, favoriteStatusForPeerID: favoriteStatusForPeerID,
currentIdentity: currentIdentity, currentIdentity: currentIdentity,
registerPendingGiftWrap: registerPendingGiftWrap, registerPendingPrivateEnvelope: registerPendingPrivateEnvelope,
sendEvent: sendEvent, sendPrivateEnvelopeBatch: sendPrivateEnvelopeBatch,
scheduleAfter: scheduleAfter, scheduleAfter: scheduleAfter,
relayConnectivity: relayConnectivity relayConnectivity: relayConnectivity,
envelopeRetryQueue: envelopeRetryQueue
) )
} }
@@ -457,8 +1042,8 @@ struct NostrTransportTests {
from event: NostrEvent, from event: NostrEvent,
recipient: NostrIdentity recipient: NostrIdentity
) throws -> (packet: BitchatPacket, payload: NoisePayload, senderPubkey: String) { ) throws -> (packet: BitchatPacket, payload: NoisePayload, senderPubkey: String) {
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage( let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateEnvelope(
giftWrap: event, envelope: event,
recipientIdentity: recipient recipientIdentity: recipient
) )
guard content.hasPrefix("bitchat1:") else { guard content.hasPrefix("bitchat1:") else {
@@ -488,6 +1073,17 @@ private enum NostrTransportTestError: Error {
case invalidPrivateMessage 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? { private func base64URLDecode(_ string: String) -> Data? {
var candidate = string var candidate = string
let padding = (4 - (candidate.count % 4)) % 4 let padding = (4 - (candidate.count % 4)) % 4
@@ -503,7 +1099,7 @@ private func base64URLDecode(_ string: String) -> Data? {
private final class NostrTransportProbe: @unchecked Sendable { private final class NostrTransportProbe: @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private var sentEventsStorage: [NostrEvent] = [] private var sentEventsStorage: [NostrEvent] = []
private var pendingGiftWrapIDsStorage: [String] = [] private var pendingPrivateEnvelopeIDsStorage: [String] = []
private var scheduledActionsStorage: [(@Sendable () -> Void)] = [] private var scheduledActionsStorage: [(@Sendable () -> Void)] = []
var sentEvents: [NostrEvent] { var sentEvents: [NostrEvent] {
@@ -512,10 +1108,10 @@ private final class NostrTransportProbe: @unchecked Sendable {
return sentEventsStorage return sentEventsStorage
} }
var pendingGiftWrapIDs: [String] { var pendingPrivateEnvelopeIDs: [String] {
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
return pendingGiftWrapIDsStorage return pendingPrivateEnvelopeIDsStorage
} }
var scheduledActionCount: Int { var scheduledActionCount: Int {
@@ -524,15 +1120,16 @@ private final class NostrTransportProbe: @unchecked Sendable {
return scheduledActionsStorage.count return scheduledActionsStorage.count
} }
func record(event: NostrEvent) { func record(batch: [NostrEvent]) -> Bool {
lock.lock() lock.lock()
sentEventsStorage.append(event) sentEventsStorage.append(contentsOf: batch)
lock.unlock() lock.unlock()
return true
} }
func recordPendingGiftWrap(id: String) { func recordPendingPrivateEnvelope(id: String) {
lock.lock() lock.lock()
pendingGiftWrapIDsStorage.append(id) pendingPrivateEnvelopeIDsStorage.append(id)
lock.unlock() lock.unlock()
} }
@@ -79,100 +79,6 @@ final class SecureIdentityStateManagerTests: XCTestCase {
XCTAssertEqual(matches.first?.signingPublicKey, signingPublicKey) 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 { func test_setBlocked_clearsFavoriteState() async {
let manager = SecureIdentityStateManager(MockKeychain()) let manager = SecureIdentityStateManager(MockKeychain())
let fingerprint = String(repeating: "ab", count: 32) 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 -18
View File
@@ -41,7 +41,6 @@ private struct SmokeFeatureModels {
let conversationUIModel: ConversationUIModel let conversationUIModel: ConversationUIModel
let peerListModel: PeerListModel let peerListModel: PeerListModel
let boardAlertsModel: BoardAlertsModel let boardAlertsModel: BoardAlertsModel
let sharedContentImportModel: SharedContentImportModel
} }
@MainActor @MainActor
@@ -100,8 +99,7 @@ private func makeSmokeFeatureModels(for viewModel: ChatViewModel) -> SmokeFeatur
verificationModel: verificationModel, verificationModel: verificationModel,
conversationUIModel: conversationUIModel, conversationUIModel: conversationUIModel,
peerListModel: peerListModel, peerListModel: peerListModel,
boardAlertsModel: boardAlertsModel, boardAlertsModel: boardAlertsModel
sharedContentImportModel: SharedContentImportModel(store: nil)
) )
} }
@@ -120,7 +118,6 @@ private func installSmokeEnvironment<V: View>(
.environmentObject(featureModels.conversationUIModel) .environmentObject(featureModels.conversationUIModel)
.environmentObject(featureModels.peerListModel) .environmentObject(featureModels.peerListModel)
.environmentObject(featureModels.boardAlertsModel) .environmentObject(featureModels.boardAlertsModel)
.environmentObject(featureModels.sharedContentImportModel)
} }
@MainActor @MainActor
@@ -579,20 +576,6 @@ struct ViewSmokeTests {
) )
} }
@Test("Bluetooth alerts wait for the voice recording error alert")
func bluetoothAlertGuards_includeVoiceAlert() {
#expect(
ContentRootModalPresentationState(
isVoiceAlertPresented: true
).hasPresentation
)
#expect(
ContentPeopleSheetModalPresentationState(
isVoiceAlertPresented: true
).hasPresentation
)
}
@Test("Root Bluetooth alert waits for screenshot privacy alert") @Test("Root Bluetooth alert waits for screenshot privacy alert")
@MainActor @MainActor
func rootBluetoothAlertGuard_tracksScreenshotPrivacyState() { func rootBluetoothAlertGuard_tracksScreenshotPrivacyState() {
+34 -21
View File
@@ -13,17 +13,16 @@ import Testing
private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendable { private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private let activationGate = DispatchSemaphore(value: 0) private let activationGate = DispatchSemaphore(value: 0)
private let activationBeganGate = DispatchSemaphore(value: 0)
private let shouldGateFirstActivation: Bool private let shouldGateFirstActivation: Bool
private var gatedFirstActivation = false private var gatedFirstActivation = false
private var _activationCalls: [Bool] = [] private var _activationCalls: [Bool] = []
private var _activationBegan = false
init(gateFirstActivation: Bool = false) { init(gateFirstActivation: Bool = false) {
self.shouldGateFirstActivation = gateFirstActivation self.shouldGateFirstActivation = gateFirstActivation
} }
var activationCalls: [Bool] { lock.withLock { _activationCalls } } var activationCalls: [Bool] { lock.withLock { _activationCalls } }
var activationBegan: Bool { lock.withLock { _activationBegan } }
func setCategory(_ category: AudioSessionCoordinator.Category) throws {} func setCategory(_ category: AudioSessionCoordinator.Category) throws {}
@@ -32,14 +31,28 @@ private final class VoiceRecorderTestSession: SessionApplying, @unchecked Sendab
_activationCalls.append(active) _activationCalls.append(active)
guard active, shouldGateFirstActivation, !gatedFirstActivation else { return false } guard active, shouldGateFirstActivation, !gatedFirstActivation else { return false }
gatedFirstActivation = true gatedFirstActivation = true
_activationBegan = true
return true return true
} }
if shouldWait { if shouldWait {
activationBeganGate.signal()
activationGate.wait() activationGate.wait()
} }
} }
func waitUntilActivationBegan(
timeout: DispatchTimeInterval = .seconds(5)
) async -> Bool {
await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
continuation.resume(
returning: self.activationBeganGate.wait(
timeout: DispatchTime.now() + timeout
) == .success
)
}
}
}
func resumeActivation() { func resumeActivation() {
activationGate.signal() activationGate.signal()
} }
@@ -142,26 +155,38 @@ private final class TestVoiceAudioRecorderFactory: VoiceAudioRecorderCreating {
/// this remains deterministic when the full test suite saturates the executor. /// this remains deterministic when the full test suite saturates the executor.
private final class VoiceRecorderPaddingGate: @unchecked Sendable { private final class VoiceRecorderPaddingGate: @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private var _entered = false private let enteredGate = DispatchSemaphore(value: 0)
private var isOpen = false private var isOpen = false
private var openWaiters: [CheckedContinuation<Void, Never>] = [] private var openWaiters: [CheckedContinuation<Void, Never>] = []
var entered: Bool { lock.withLock { _entered } }
func wait() async { func wait() async {
await withCheckedContinuation { continuation in await withCheckedContinuation { continuation in
let resumeImmediately = lock.withLock { () -> Bool in let resumeImmediately = lock.withLock { () -> Bool in
_entered = true
guard !isOpen else { return true } guard !isOpen else { return true }
openWaiters.append(continuation) openWaiters.append(continuation)
return false return false
} }
enteredGate.signal()
if resumeImmediately { if resumeImmediately {
continuation.resume() continuation.resume()
} }
} }
} }
func waitUntilEntered(
timeout: DispatchTimeInterval = .seconds(5)
) async -> Bool {
await withCheckedContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
continuation.resume(
returning: self.enteredGate.wait(
timeout: DispatchTime.now() + timeout
) == .success
)
}
}
}
func open() { func open() {
let waiters = lock.withLock { () -> [CheckedContinuation<Void, Never>] in let waiters = lock.withLock { () -> [CheckedContinuation<Void, Never>] in
isOpen = true isOpen = true
@@ -181,18 +206,6 @@ struct VoiceRecorderTests {
return url return url
} }
private func waitUntil(
_ condition: () -> Bool,
sourceLocation: SourceLocation = #_sourceLocation
) async {
let deadline = ContinuousClock.now.advanced(by: .seconds(5))
while !condition(), ContinuousClock.now < deadline {
await Task.yield()
try? await Task.sleep(nanoseconds: 1_000_000)
}
#expect(condition(), sourceLocation: sourceLocation)
}
@Test func cancelWhileSessionAcquireIsInFlightNeverCreatesARecorder() async throws { @Test func cancelWhileSessionAcquireIsInFlightNeverCreatesARecorder() async throws {
let directory = try makeTemporaryDirectory() let directory = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) } defer { try? FileManager.default.removeItem(at: directory) }
@@ -210,7 +223,7 @@ struct VoiceRecorderTests {
let owner = VoiceRecorder.RecordingOwner() let owner = VoiceRecorder.RecordingOwner()
let startTask = Task { try await voiceRecorder.startRecording(owner: owner) } let startTask = Task { try await voiceRecorder.startRecording(owner: owner) }
await waitUntil { session.activationBegan } #expect(await session.waitUntilActivationBegan())
await voiceRecorder.cancelRecording(owner: owner) await voiceRecorder.cancelRecording(owner: owner)
session.resumeActivation() session.resumeActivation()
@@ -308,7 +321,7 @@ struct VoiceRecorderTests {
try await finishingHold.start() try await finishingHold.start()
let firstURL = try #require(factory.urls.first) let firstURL = try #require(factory.urls.first)
let finishTask = Task { await finishingHold.finish() } let finishTask = Task { await finishingHold.finish() }
await waitUntil { paddingGate.entered } #expect(await paddingGate.waitUntilEntered())
await #expect(throws: VoiceRecorder.RecorderError.recordingInProgress) { await #expect(throws: VoiceRecorder.RecorderError.recordingInProgress) {
try await rejectedHold.start() try await rejectedHold.start()
+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 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, 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 well below iOS's absolute inbound ceiling. Private-media v1 therefore runs the
receivers, which take private media exclusively over the directed raw-file actual route-aware BLE fragment planner before both encrypted and consented
migration fallback (they do not implement the encrypted `0x20` path). legacy sends and rejects any plan above 256 fragments with a visible failure.
Private-media v1 therefore runs the actual route-aware BLE fragment planner This fragment-count contract, rather than a guessed byte threshold, stays
before a consented legacy send and rejects any plan above 256 fragments with a correct as route overhead changes.
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).
+2 -2
View File
@@ -54,9 +54,9 @@ Public archives contain content already intended for public mesh/board distribut
## Nostr and Mesh Bridge ## 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. - 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. - 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. - 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.

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